Commit 07f3eebc by James Yuan

feat: 设施管理与分类打点按设备类型显示

设施含压差/照明/冰箱/通风柜/摄像头/门禁;压差仅文字标签;三维打点预览与大屏效果一致;设备管理改为数据采集。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent eff3fd97
...@@ -59,8 +59,8 @@ VITE_API_BASE= ...@@ -59,8 +59,8 @@ VITE_API_BASE=
VITE_WS_URL= VITE_WS_URL=
# 三维视口:glb | ue-stream | rtengine(RTEngineHD 云渲染 iframe) # 三维视口:glb | ue-stream | rtengine(RTEngineHD 云渲染 iframe)
VITE_VIEWPORT_MODE=rtengine VITE_VIEWPORT_MODE=rtengine
# 推荐同域反代(postMessage 同域);开发/Nginx 已代理 /rtengine + /peer-stream + /designer # 推荐同域反代(postMessage 同域);开发/Nginx 已代理 /gis + /peer-stream + /designer + /api/icons
VITE_RTENGINE_URL=/rtengine/gis.html?scene=mvlpj2yt6379aa29 VITE_RTENGINE_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
VITE_UE_STREAM_URL=/rtengine/gis.html?scene=mvlpj2yt6379aa29 VITE_UE_STREAM_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
# 反代上游引擎地址(110 本机推流用 127.0.0.1) # 反代上游引擎地址(110 本机推流用 127.0.0.1)
VITE_RTENGINE_PROXY_TARGET=http://127.0.0.1:6088 VITE_RTENGINE_PROXY_TARGET=http://127.0.0.1:6088
...@@ -75,8 +75,8 @@ npm run dev ...@@ -75,8 +75,8 @@ npm run dev
- `DATABASE_URL` PostgreSQL 连接串 - `DATABASE_URL` PostgreSQL 连接串
- `DATA_MODE=mock|modbus` - `DATA_MODE=mock|modbus`
- `VITE_VIEWPORT_MODE=glb|ue-stream|rtengine` - `VITE_VIEWPORT_MODE=glb|ue-stream|rtengine`
- `VITE_RTENGINE_URL=` RTEngineHD 云渲染页(推荐同域 `/rtengine/gis.html?scene=mvg8x4b453b45f38`) - `VITE_RTENGINE_URL=` RTEngineHD 云渲染页(推荐同域 `/gis/gis.html?scene=mvlpj2yt6379aa29`)
- `VITE_RTENGINE_PROXY_TARGET=` 反代上游(如 `http://192.168.32.121:6088`);开发代理 `/rtengine` + `/peer-stream` + `/designer` - `VITE_RTENGINE_PROXY_TARGET=` 反代上游(如 `http://192.168.32.110:6088`);开发代理 `/gis` + `/peer-stream` + `/designer` + `/api/icons`
- `VITE_UE_STREAM_URL=` UE/云渲染 iframe 备用地址 - `VITE_UE_STREAM_URL=` UE/云渲染 iframe 备用地址
- `ACCESS_BRIDGE_URL=` 门禁桥接 - `ACCESS_BRIDGE_URL=` 门禁桥接
......
...@@ -43,7 +43,7 @@ for u in [ ...@@ -43,7 +43,7 @@ for u in [
"http://192.168.32.110/", "http://192.168.32.110/",
"http://192.168.32.110/dashboard/overview", "http://192.168.32.110/dashboard/overview",
"http://192.168.32.110/admin/metering", "http://192.168.32.110/admin/metering",
"http://192.168.32.110/rtengine/designer/data/scenes/mvlpj2yt6379aa29.json", "http://192.168.32.110/gis/designer/data/scenes/mvlpj2yt6379aa29.json",
]: ]:
try: try:
r = urllib.request.urlopen(u, timeout=12) r = urllib.request.urlopen(u, timeout=12)
......
...@@ -6,7 +6,7 @@ for u in [ ...@@ -6,7 +6,7 @@ for u in [
"http://192.168.32.110/", "http://192.168.32.110/",
"http://192.168.32.110/dashboard/overview", "http://192.168.32.110/dashboard/overview",
"http://192.168.32.110/admin/metering", "http://192.168.32.110/admin/metering",
"http://192.168.32.110/rtengine/designer/data/scenes/mvlpj2yt6379aa29.json", "http://192.168.32.110/gis/designer/data/scenes/mvlpj2yt6379aa29.json",
]: ]:
try: try:
r = urllib.request.urlopen(u, timeout=12) r = urllib.request.urlopen(u, timeout=12)
......
...@@ -41,7 +41,7 @@ c.close() ...@@ -41,7 +41,7 @@ c.close()
for u in [ for u in [
"http://192.168.32.110/", "http://192.168.32.110/",
"http://192.168.32.110/dashboard/overview", "http://192.168.32.110/dashboard/overview",
"http://192.168.32.110/rtengine/designer/data/scenes/mvlpj2yt6379aa29.json", "http://192.168.32.110/gis/designer/data/scenes/mvlpj2yt6379aa29.json",
]: ]:
r = urllib.request.urlopen(u, timeout=12) r = urllib.request.urlopen(u, timeout=12)
body = r.read() body = r.read()
......
...@@ -12,8 +12,8 @@ WORKDIR /app/web ...@@ -12,8 +12,8 @@ WORKDIR /app/web
ARG VITE_API_BASE= ARG VITE_API_BASE=
ARG VITE_VIEWPORT_MODE=rtengine ARG VITE_VIEWPORT_MODE=rtengine
ARG VITE_RTENGINE_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38 ARG VITE_RTENGINE_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ARG VITE_UE_STREAM_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38 ARG VITE_UE_STREAM_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ENV VITE_API_BASE=$VITE_API_BASE \ ENV VITE_API_BASE=$VITE_API_BASE \
VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \ VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \
VITE_RTENGINE_URL=$VITE_RTENGINE_URL \ VITE_RTENGINE_URL=$VITE_RTENGINE_URL \
......
...@@ -11,6 +11,11 @@ http { ...@@ -11,6 +11,11 @@ http {
root D:/apps/jh-lab/platform/web; root D:/apps/jh-lab/platform/web;
index index.html; index index.html;
location /api/icons {
proxy_pass http://127.0.0.1:6088/api/icons;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
}
location /api/ { location /api/ {
proxy_pass http://127.0.0.1:3001/api/; proxy_pass http://127.0.0.1:3001/api/;
proxy_http_version 1.1; proxy_http_version 1.1;
...@@ -26,6 +31,15 @@ http { ...@@ -26,6 +31,15 @@ http {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_read_timeout 86400; proxy_read_timeout 86400;
} }
location /gis/ {
proxy_pass http://127.0.0.1:6088/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_buffering off;
}
location /rtengine/ { location /rtengine/ {
proxy_pass http://127.0.0.1:6088/; proxy_pass http://127.0.0.1:6088/;
proxy_http_version 1.1; proxy_http_version 1.1;
......
...@@ -11,6 +11,11 @@ http { ...@@ -11,6 +11,11 @@ http {
root D:/apps/jh-lab/platform/web; root D:/apps/jh-lab/platform/web;
index index.html; index index.html;
location /api/icons {
proxy_pass http://127.0.0.1:6088/api/icons;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
}
location /api/ { location /api/ {
proxy_pass http://127.0.0.1:3001/api/; proxy_pass http://127.0.0.1:3001/api/;
proxy_http_version 1.1; proxy_http_version 1.1;
...@@ -26,6 +31,16 @@ http { ...@@ -26,6 +31,16 @@ http {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_read_timeout 86400; proxy_read_timeout 86400;
} }
# 同域 /gis → 引擎根(相对脚本 gis.js / scene-embed.js 才完整)
location /gis/ {
proxy_pass http://127.0.0.1:6088/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_buffering off;
}
location /rtengine/ { location /rtengine/ {
proxy_pass http://127.0.0.1:6088/; proxy_pass http://127.0.0.1:6088/;
proxy_http_version 1.1; proxy_http_version 1.1;
......
...@@ -75,15 +75,15 @@ services: ...@@ -75,15 +75,15 @@ services:
args: args:
VITE_API_BASE: "" VITE_API_BASE: ""
VITE_VIEWPORT_MODE: ${VITE_VIEWPORT_MODE:-rtengine} VITE_VIEWPORT_MODE: ${VITE_VIEWPORT_MODE:-rtengine}
VITE_RTENGINE_URL: ${VITE_RTENGINE_URL:-/rtengine/gis.html?scene=mvg8x4b453b45f38} VITE_RTENGINE_URL: ${VITE_RTENGINE_URL:-/gis/gis.html?scene=mvlpj2yt6379aa29}
VITE_UE_STREAM_URL: ${VITE_UE_STREAM_URL:-/rtengine/gis.html?scene=mvg8x4b453b45f38} VITE_UE_STREAM_URL: ${VITE_UE_STREAM_URL:-/gis/gis.html?scene=mvlpj2yt6379aa29}
container_name: jh-lab-web container_name: jh-lab-web
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- server - server
ports: ports:
- "${WEB_PORT:-18080}:80" - "${WEB_PORT:-18080}:80"
# 热更新 nginx 反代(/rtengine → 121:6088),避免旧镜像缺云渲染路由 # 热更新 nginx 反代(/gis → 引擎),避免旧镜像缺云渲染路由
volumes: volumes:
- ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
......
...@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS write_audit_log ( ...@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS write_audit_log (
CREATE TABLE IF NOT EXISTS system_settings ( CREATE TABLE IF NOT EXISTS system_settings (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
collect_poll_ms INT NOT NULL DEFAULT 2000 CHECK (collect_poll_ms BETWEEN 500 AND 600000), collect_poll_ms INT NOT NULL DEFAULT 2000 CHECK (collect_poll_ms BETWEEN 500 AND 600000),
screen_title TEXT NOT NULL DEFAULT '分子实验室三维可视化管理平台',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system' updated_by TEXT NOT NULL DEFAULT 'system'
); );
...@@ -172,11 +173,36 @@ CREATE INDEX IF NOT EXISTS idx_alarms_source ON alarms (source, raised_at DESC); ...@@ -172,11 +173,36 @@ CREATE INDEX IF NOT EXISTS idx_alarms_source ON alarms (source, raised_at DESC);
CREATE INDEX IF NOT EXISTS idx_alarms_device ON alarms (device_id, raised_at DESC); CREATE INDEX IF NOT EXISTS idx_alarms_device ON alarms (device_id, raised_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_alarms_open_source_key CREATE UNIQUE INDEX IF NOT EXISTS idx_alarms_open_source_key
ON alarms (source, source_key) WHERE status IN ('active', 'acked'); ON alarms (source, source_key) WHERE status IN ('active', 'acked');
CREATE TABLE IF NOT EXISTS sensors (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
category TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
scene_x DOUBLE PRECISION NOT NULL DEFAULT 0,
scene_y DOUBLE PRECISION NOT NULL DEFAULT 0,
scene_z DOUBLE PRECISION NOT NULL DEFAULT 51,
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sensors_category ON sensors (category);
CREATE TABLE IF NOT EXISTS sensor_points (
sensor_id TEXT NOT NULL REFERENCES sensors(id) ON DELETE CASCADE,
point_id UUID NOT NULL REFERENCES device_points(id) ON DELETE CASCADE,
role TEXT NOT NULL,
sort INT NOT NULL DEFAULT 0,
PRIMARY KEY (sensor_id, role)
);
CREATE INDEX IF NOT EXISTS idx_sensor_points_point ON sensor_points (point_id);
` `
const ALTER = ` const ALTER = `
ALTER TABLE device_points ADD COLUMN IF NOT EXISTS alarm_low DOUBLE PRECISION; ALTER TABLE device_points ADD COLUMN IF NOT EXISTS alarm_low DOUBLE PRECISION;
ALTER TABLE device_points ADD COLUMN IF NOT EXISTS alarm_high DOUBLE PRECISION; ALTER TABLE device_points ADD COLUMN IF NOT EXISTS alarm_high DOUBLE PRECISION;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS screen_title TEXT NOT NULL DEFAULT '分子实验室三维可视化管理平台';
` `
/** 纠正房间/冰箱传感器被 CSV 误标为写点的问题 */ /** 纠正房间/冰箱传感器被 CSV 误标为写点的问题 */
......
...@@ -12,6 +12,14 @@ import { ensureSettingsSeed } from './settings/store.js' ...@@ -12,6 +12,14 @@ import { ensureSettingsSeed } from './settings/store.js'
import { ensureVideoSeed } from './video/store.js' import { ensureVideoSeed } from './video/store.js'
import { ensureLightSeed } from './light/store.js' import { ensureLightSeed } from './light/store.js'
import { ensureDevicesSeed } from './devices/store.js' import { ensureDevicesSeed } from './devices/store.js'
import {
ensureAccessSensorSeed,
ensureCameraSensorSeed,
ensureFridgeSensorSeed,
ensureNeLightSensorSeed,
ensurePressureSensorSeed,
ensureVavSensorSeed,
} from './sensors/store.js'
import { authRouter } from './routes/auth.js' import { authRouter } from './routes/auth.js'
import { usersRouter } from './routes/users.js' import { usersRouter } from './routes/users.js'
import { realtimeRouter } from './routes/realtime.js' import { realtimeRouter } from './routes/realtime.js'
...@@ -20,6 +28,7 @@ import { settingsRouter } from './routes/settings.js' ...@@ -20,6 +28,7 @@ import { settingsRouter } from './routes/settings.js'
import { videoRouter } from './routes/video.js' import { videoRouter } from './routes/video.js'
import { lightRouter } from './routes/light.js' import { lightRouter } from './routes/light.js'
import { devicesRouter } from './routes/devices.js' import { devicesRouter } from './routes/devices.js'
import { sensorsRouter } from './routes/sensors.js'
import { gatewayRouter } from './routes/gateway.js' import { gatewayRouter } from './routes/gateway.js'
import { alarmsRouter } from './routes/alarms.js' import { alarmsRouter } from './routes/alarms.js'
import { weatherRouter } from './routes/weather.js' import { weatherRouter } from './routes/weather.js'
...@@ -46,6 +55,42 @@ async function bootstrap() { ...@@ -46,6 +55,42 @@ async function bootstrap() {
ensureVideoSeed() ensureVideoSeed()
ensureLightSeed() ensureLightSeed()
await ensureDevicesSeed() await ensureDevicesSeed()
try {
const seed = await ensurePressureSensorSeed()
console.log(`[sensors] pressure seed created=${seed.created} skipped=${seed.skipped}`)
} catch (e) {
console.warn('[sensors] pressure seed failed:', e instanceof Error ? e.message : e)
}
try {
const lightSeed = await ensureNeLightSensorSeed()
console.log(`[sensors] ne-light seed created=${lightSeed.created} skipped=${lightSeed.skipped}`)
} catch (e) {
console.warn('[sensors] ne-light seed failed:', e instanceof Error ? e.message : e)
}
try {
const fridgeSeed = await ensureFridgeSensorSeed()
console.log(`[sensors] fridge seed created=${fridgeSeed.created} skipped=${fridgeSeed.skipped}`)
} catch (e) {
console.warn('[sensors] fridge seed failed:', e instanceof Error ? e.message : e)
}
try {
const vavSeed = await ensureVavSensorSeed()
console.log(`[sensors] vav seed created=${vavSeed.created} skipped=${vavSeed.skipped}`)
} catch (e) {
console.warn('[sensors] vav seed failed:', e instanceof Error ? e.message : e)
}
try {
const camSeed = await ensureCameraSensorSeed()
console.log(`[sensors] camera seed created=${camSeed.created} skipped=${camSeed.skipped}`)
} catch (e) {
console.warn('[sensors] camera seed failed:', e instanceof Error ? e.message : e)
}
try {
const accessSeed = await ensureAccessSensorSeed()
console.log(`[sensors] access seed created=${accessSeed.created} skipped=${accessSeed.skipped}`)
} catch (e) {
console.warn('[sensors] access seed failed:', e instanceof Error ? e.message : e)
}
const app = express() const app = express()
app.use(cors()) app.use(cors())
...@@ -69,6 +114,7 @@ async function bootstrap() { ...@@ -69,6 +114,7 @@ async function bootstrap() {
app.use('/api/video', videoRouter) app.use('/api/video', videoRouter)
app.use('/api/light', lightRouter) app.use('/api/light', lightRouter)
app.use('/api/devices', devicesRouter) app.use('/api/devices', devicesRouter)
app.use('/api/sensors', sensorsRouter)
app.use('/api/alarms', alarmsRouter) app.use('/api/alarms', alarmsRouter)
app.use('/api/weather', weatherRouter) app.use('/api/weather', weatherRouter)
/** Windows 网关上行:拉点表 / 推测点落库 */ /** Windows 网关上行:拉点表 / 推测点落库 */
......
...@@ -127,6 +127,15 @@ export function generateSnapshot(): RealtimeSnapshot { ...@@ -127,6 +127,15 @@ export function generateSnapshot(): RealtimeSnapshot {
temp: i === 2 ? jitter(-18.6, 0.3) : jitter(-20 + (i % 3), 0.4), temp: i === 2 ? jitter(-18.6, 0.3) : jitter(-20 + (i % 3), 0.4),
humidity: jitter(35, 4, 0), humidity: jitter(35, 4, 0),
})), })),
sensors: rooms.map((r) => ({
id: `mock-${r.id}`,
code: `p-${r.id}`,
name: `${r.name}压差`,
category: 'pressure',
values: { temp: r.temp, humidity: r.humidity, pressure: r.pressure },
alarms: r.alarms,
scene: { x: 133.543, y: 22.1972, z: 51 },
})),
metering: { metering: {
powerKwh: jitter(486.2, 2), powerKwh: jitter(486.2, 2),
waterM3: jitter(12.8, 0.2), waterM3: jitter(12.8, 0.2),
......
/**
* 摄像头 / 门禁设施定义(来源:澳门镜湖医院分子实验室项目技术交底表)。
* 不含硬盘录像机、交换机等基础设施。
*/
const PLAN_BL = { lon: 133.54299724844427, lat: 22.19728530602825, h: 51 }
const PLAN_TR = { lon: 133.54308017687475, lat: 22.19712618090024, h: 51 }
function uv(u: number, v: number): { x: number; y: number; z: number } {
return {
x: PLAN_BL.lon + u * (PLAN_TR.lon - PLAN_BL.lon),
y: PLAN_BL.lat + v * (PLAN_TR.lat - PLAN_BL.lat),
z: PLAN_BL.h,
}
}
export type CameraFacilityDef = {
id: string
name: string
ip: string
port: number
channel: number
scene: { x: number; y: number; z: number }
}
export type AccessFacilityDef = {
id: string
name: string
ip: string
port: number
scene: { x: number; y: number; z: number }
}
/** 交底表 IPC(海康威视 · 8000 · admin) */
export const CAMERA_FACILITY_DEFS: CameraFacilityDef[] = [
{ id: 'cam-recv', name: '收样咨询室', ip: '192.168.10.150', port: 8000, channel: 1, scene: uv(0.88, 0.88) },
{ id: 'cam-sample2-front', name: '样本制备区2房前面', ip: '192.168.10.144', port: 8000, channel: 2, scene: uv(0.55, 0.42) },
{ id: 'cam-lib2-back', name: '文库制备2房后面', ip: '192.168.10.146', port: 8000, channel: 3, scene: uv(0.25, 0.72) },
{ id: 'cam-sample2-back', name: '样本制备区2房后面', ip: '192.168.10.152', port: 8000, channel: 4, scene: uv(0.55, 0.72) },
{ id: 'cam-lib2-front', name: '文库制备2房前面', ip: '192.168.10.147', port: 8000, channel: 5, scene: uv(0.25, 0.42) },
{ id: 'cam-front-corridor', name: '前走廊', ip: '192.168.10.153', port: 8000, channel: 6, scene: uv(0.62, 0.12) },
{ id: 'cam-seq-back', name: '测序区后', ip: '192.168.10.148', port: 8000, channel: 7, scene: uv(0.1, 0.72) },
{ id: 'cam-report', name: '生信分析/报告室', ip: '192.168.10.151', port: 8000, channel: 8, scene: uv(0.88, 0.38) },
{ id: 'cam-sample1-back', name: '样本制备区1房后面', ip: '192.168.10.145', port: 8000, channel: 9, scene: uv(0.7, 0.72) },
{ id: 'cam-reagent', name: '试剂准备间', ip: '192.168.10.142', port: 8000, channel: 10, scene: uv(0.85, 0.55) },
{ id: 'cam-lib1-front', name: '文库制备区1房前面', ip: '192.168.10.156', port: 8000, channel: 11, scene: uv(0.4, 0.42) },
{ id: 'cam-sample1-front', name: '样本制备区1房前面', ip: '192.168.10.154', port: 8000, channel: 12, scene: uv(0.7, 0.42) },
{ id: 'cam-seq-front', name: '测序区前', ip: '192.168.10.155', port: 8000, channel: 13, scene: uv(0.1, 0.42) },
{ id: 'cam-lib1-back', name: '文库制备区1房后面', ip: '192.168.10.149', port: 8000, channel: 14, scene: uv(0.4, 0.72) },
{ id: 'cam-back-corridor', name: '后走廊', ip: '192.168.10.143', port: 8000, channel: 15, scene: uv(0.35, 0.12) },
]
/** 交底表门禁控制器 */
export const ACCESS_FACILITY_DEFS: AccessFacilityDef[] = [
{ id: 'door-recv', name: '咨询室门禁', ip: '192.168.10.120', port: 8000, scene: uv(0.9, 0.92) },
{ id: 'door-back-corridor', name: '后走廊门禁', ip: '192.168.10.121', port: 8000, scene: uv(0.28, 0.1) },
{ id: 'door-report', name: '报告室门禁', ip: '192.168.10.122', port: 8000, scene: uv(0.9, 0.32) },
{ id: 'door-front-corridor', name: '前走廊门禁', ip: '192.168.10.123', port: 8000, scene: uv(0.7, 0.1) },
]
/** NVR / 交换机(写入视频通信配置,不作为大屏设施点) */
export const BRIEFING_INFRA = {
nvr: { name: '硬盘录像机', ip: '192.168.10.140', port: 8000 },
switch: { name: '交换机', ip: '192.168.10.141' },
username: 'admin',
password: 'Hk223366',
protocol: '海康威视',
}
/**
* 冰箱 / 通风柜设施定义(对齐大屏 MODE_POIS 与设备台账 id)。
*/
/** 与大屏平面图对角标定一致 */
const PLAN_BL = { lon: 133.54299724844427, lat: 22.19728530602825, h: 51 }
const PLAN_TR = { lon: 133.54308017687475, lat: 22.19712618090024, h: 51 }
function uv(u: number, v: number): { x: number; y: number; z: number } {
return {
x: PLAN_BL.lon + u * (PLAN_TR.lon - PLAN_BL.lon),
y: PLAN_BL.lat + v * (PLAN_TR.lat - PLAN_BL.lat),
z: PLAN_BL.h,
}
}
export type FridgeFacilityDef = {
id: string
name: string
deviceId: string
scene: { x: number; y: number; z: number }
}
export type VavFacilityDef = {
id: string
name: string
deviceId: string
scene: { x: number; y: number; z: number }
}
export const FRIDGE_FACILITY_DEFS: FridgeFacilityDef[] = [
{
id: 'f-recv-1',
name: '收样咨询室冰箱1',
deviceId: 'mb-104-rtu-3-收样咨询室冰箱1',
scene: uv(0.86, 0.82),
},
{
id: 'f-recv-2',
name: '收样咨询室冰箱2',
deviceId: 'mb-104-rtu-4-收样咨询室冰箱2',
scene: uv(0.9, 0.82),
},
{
id: 'f-lib-1',
name: '文库制备区1冰箱',
deviceId: 'mb-104-rtu-6-文库制备区1冰箱',
scene: uv(0.42, 0.4),
},
{
id: 'f-lib-2',
name: '文库制备区2冰箱',
deviceId: 'mb-104-rtu-7-文库制备区2冰箱',
scene: uv(0.27, 0.4),
},
{
id: 'f-sample-1',
name: '样本制备区1冰箱',
deviceId: 'mb-104-rtu-5-样本制备区1冰箱',
scene: uv(0.7, 0.42),
},
{
id: 'f-seq',
name: '测序区冰箱1',
deviceId: 'mb-104-rtu-8-测序区冰箱1',
scene: uv(0.12, 0.4),
},
{
id: 'f-reagent-1',
name: '试剂准备间冰箱1',
deviceId: 'mb-104-rtu-1-试剂准备间冰箱1',
scene: uv(0.83, 0.55),
},
{
id: 'f-reagent-2',
name: '试剂准备间冰箱2',
deviceId: 'mb-104-rtu-2-试剂准备间冰箱2',
scene: uv(0.87, 0.55),
},
]
export const VAV_FACILITY_DEFS: VavFacilityDef[] = [
{
id: 'v-lib-1',
name: '文库制备1通风柜',
deviceId: 'mb-103-文库制备1通风柜',
scene: uv(0.4, 0.78),
},
{
id: 'v-lib-2',
name: '文库制备2通风柜',
deviceId: 'mb-103-文库制备2通风柜',
scene: uv(0.25, 0.78),
},
]
import { evaluatePointAlarms } from '../devices/alarms.js' import { evaluatePointAlarms } from '../devices/alarms.js'
import { listDevices, listGateways, listPoints } from '../devices/store.js' import { listDevices, listGateways, listPoints } from '../devices/store.js'
import type { Device, DevicePoint } from '../devices/types.js' import type { Device, DevicePoint } from '../devices/types.js'
import { listSensors } from '../sensors/store.js'
import type { import type {
FridgeSnapshot, FridgeSnapshot,
NeLightSnapshot, NeLightSnapshot,
RealtimeSnapshot, RealtimeSnapshot,
RoomSnapshot, RoomSnapshot,
SensorSnapshot,
VavSnapshot, VavSnapshot,
} from '../types.js' } from '../types.js'
import { NE_LIGHT_DEFS } from './neLights.js' import { NE_LIGHT_DEFS } from './neLights.js'
...@@ -128,23 +130,92 @@ function sumEng(devices: Device[], byDevice: Map<string, DevicePoint[]>, nameRe: ...@@ -128,23 +130,92 @@ function sumEng(devices: Device[], byDevice: Map<string, DevicePoint[]>, nameRe:
/** /**
* 从设备台账 + 点位 lastEng 组装大屏业务快照(无假数;无遥测则为 0 / 空告警)。 * 从设备台账 + 点位 lastEng 组装大屏业务快照(无假数;无遥测则为 0 / 空告警)。
*/ */
function sensorFromBinds(
sensor: Awaited<ReturnType<typeof listSensors>>[number],
pointById: Map<string, DevicePoint>,
): SensorSnapshot {
const rolePoint = (role: string) => {
const bind = sensor.points.find((p) => p.role === role)
return bind ? pointById.get(bind.pointId) : undefined
}
const tempP = rolePoint('temp')
const humP = rolePoint('humidity')
const pressP = rolePoint('pressure')
const statusP = rolePoint('status') || rolePoint('value')
const faceP = rolePoint('faceVelocity')
const sashP = rolePoint('sashHeight')
const airflowP = rolePoint('airflow')
const alarmP = rolePoint('alarmCode')
const alarms: string[] = []
for (const p of [tempP, humP, pressP]) {
if (!p || !pointFresh(p) || p.lastEng === null || p.lastEng === undefined) continue
const v = Number(p.lastEng)
if (p.alarmLow !== null && v < p.alarmLow) alarms.push(`${p.name}偏低`)
if (p.alarmHigh !== null && v > p.alarmHigh) alarms.push(`${p.name}偏高`)
}
const icon =
typeof sensor.meta?.icon === 'string' && sensor.meta.icon.trim()
? String(sensor.meta.icon).trim()
: null
const pointVal = (p: DevicePoint | undefined) =>
p ? (pointFresh(p) ? num(p.lastEng) ?? num(p.lastRaw) : num(p.lastEng) ?? num(p.lastRaw)) : null
const statusVal = pointVal(statusP)
const isLight = sensor.category === 'lightN' || sensor.category === 'lightE'
const isVav = sensor.category === 'vav'
const isFridge = sensor.category === 'fridge'
return {
id: sensor.id,
code: sensor.code,
name: sensor.name,
category: sensor.category,
values: {
temp: pointFresh(tempP) ? num(tempP?.lastEng) : isFridge ? pointVal(tempP) : null,
humidity: pointFresh(humP) ? num(humP?.lastEng) : isFridge ? pointVal(humP) : null,
pressure: pointFresh(pressP) ? num(pressP?.lastEng) : null,
},
on:
isLight || isVav ? (statusVal !== null ? statusVal !== 0 : null) : undefined,
controlPointId: isLight && statusP ? statusP.id : undefined,
vav: isVav
? {
faceVelocity: pointVal(faceP),
sashHeight: pointVal(sashP),
airflow: pointVal(airflowP),
alarmCode: pointVal(alarmP),
}
: undefined,
alarms,
scene: { x: sensor.sceneX, y: sensor.sceneY, z: sensor.sceneZ },
icon,
videoDeviceId:
typeof sensor.meta?.videoDeviceId === 'string' ? sensor.meta.videoDeviceId : null,
doorDeviceId:
typeof sensor.meta?.doorDeviceId === 'string' ? sensor.meta.doorDeviceId : null,
}
}
export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> { export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> {
const [devices, points, gateways, pointAlarms] = await Promise.all([ const [devices, points, gateways, pointAlarms, sensors] = await Promise.all([
listDevices(), listDevices(),
listPoints(), listPoints(),
listGateways(), listGateways(),
evaluatePointAlarms().catch(() => []), evaluatePointAlarms().catch(() => []),
listSensors({ enabled: true }).catch(() => []),
]) ])
const enabled = devices.filter((d) => d.enabled) const enabled = devices.filter((d) => d.enabled)
const byDevice = new Map<string, DevicePoint[]>() const byDevice = new Map<string, DevicePoint[]>()
const pointById = new Map<string, DevicePoint>()
for (const p of points) { for (const p of points) {
pointById.set(p.id, p)
if (!p.deviceId || !p.enabled) continue if (!p.deviceId || !p.enabled) continue
const list = byDevice.get(p.deviceId) || [] const list = byDevice.get(p.deviceId) || []
list.push(p) list.push(p)
byDevice.set(p.deviceId, list) byDevice.set(p.deviceId, list)
} }
const sensorSnapshots: SensorSnapshot[] = sensors.map((s) => sensorFromBinds(s, pointById))
const rooms = enabled const rooms = enabled
.filter((d) => d.category === 'room') .filter((d) => d.category === 'room')
.map((d) => roomFromDevice(d, byDevice.get(d.id) || [])) .map((d) => roomFromDevice(d, byDevice.get(d.id) || []))
...@@ -220,6 +291,7 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> { ...@@ -220,6 +291,7 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> {
vav, vav,
fridges, fridges,
neLights, neLights,
sensors: sensorSnapshots,
metering: { metering: {
powerKwh: sumEng(powerMeters, byDevice, /用电量|有功电能|总有功/), powerKwh: sumEng(powerMeters, byDevice, /用电量|有功电能|总有功/),
waterM3: sumEng(waterMeters, byDevice, /用水量|累计流量|流量/), waterM3: sumEng(waterMeters, byDevice, /用水量|累计流量|流量/),
......
...@@ -4,6 +4,18 @@ ...@@ -4,6 +4,18 @@
*/ */
export type NeLightKind = 'N' | 'E' export type NeLightKind = 'N' | 'E'
/** 与大屏平面图对角标定一致 */
const PLAN_BL = { lon: 133.54299724844427, lat: 22.19728530602825, h: 51 }
const PLAN_TR = { lon: 133.54308017687475, lat: 22.19712618090024, h: 51 }
function uv(u: number, v: number): { x: number; y: number; z: number } {
return {
x: PLAN_BL.lon + u * (PLAN_TR.lon - PLAN_BL.lon),
y: PLAN_BL.lat + v * (PLAN_TR.lat - PLAN_BL.lat),
z: PLAN_BL.h,
}
}
export type NeLightDef = { export type NeLightDef = {
id: string id: string
kind: NeLightKind kind: NeLightKind
...@@ -11,29 +23,30 @@ export type NeLightDef = { ...@@ -11,29 +23,30 @@ export type NeLightDef = {
deviceId: string deviceId: string
/** 点位 name,如 KA1 */ /** 点位 name,如 KA1 */
pointName: string pointName: string
scene: { x: number; y: number; z: number }
} }
export const NE_LIGHT_DEFS: NeLightDef[] = [ export const NE_LIGHT_DEFS: NeLightDef[] = [
// N电 // N电(普通照明)
{ id: 'n-seq', kind: 'N', zone: '测序区', deviceId: 'mb-104-qtn1', pointName: 'KA1' }, { id: 'n-seq', kind: 'N', zone: '测序区', deviceId: 'mb-104-qtn1', pointName: 'KA1', scene: uv(0.1, 0.52) },
{ id: 'n-lib-2', kind: 'N', zone: '文库制备区2', deviceId: 'mb-104-qtn2', pointName: 'KA1' }, { id: 'n-lib-2', kind: 'N', zone: '文库制备区2', deviceId: 'mb-104-qtn2', pointName: 'KA1', scene: uv(0.27, 0.42) },
{ id: 'n-lib-1', kind: 'N', zone: '文库制备区1', deviceId: 'mb-104-qtn3', pointName: 'KA1' }, { id: 'n-lib-1', kind: 'N', zone: '文库制备区1', deviceId: 'mb-104-qtn3', pointName: 'KA1', scene: uv(0.38, 0.52) },
{ id: 'n-sample-2', kind: 'N', zone: '样本制备区2', deviceId: 'mb-104-qtn4', pointName: 'KA1' }, { id: 'n-sample-2', kind: 'N', zone: '样本制备区2', deviceId: 'mb-104-qtn4', pointName: 'KA1', scene: uv(0.55, 0.52) },
{ id: 'n-sample-1', kind: 'N', zone: '样本制备区1', deviceId: 'mb-104-qtn4', pointName: 'KA3' }, { id: 'n-sample-1', kind: 'N', zone: '样本制备区1', deviceId: 'mb-104-qtn4', pointName: 'KA3', scene: uv(0.7, 0.52) },
{ id: 'n-report', kind: 'N', zone: '报告室', deviceId: 'mb-104-qtn5', pointName: 'KA1' }, { id: 'n-report', kind: 'N', zone: '报告室', deviceId: 'mb-104-qtn5', pointName: 'KA1', scene: uv(0.88, 0.4) },
{ id: 'n-corridor', kind: 'N', zone: 'NGS走廊', deviceId: 'mb-104-qtn6', pointName: 'KA1' }, { id: 'n-corridor', kind: 'N', zone: 'NGS走廊', deviceId: 'mb-104-qtn6', pointName: 'KA1', scene: uv(0.5, 0.12) },
// E电 // E电(应急照明)
{ id: 'e-seq-buf', kind: 'E', zone: '测序区缓冲', deviceId: 'mb-104-qte1', pointName: 'KA1' }, { id: 'e-seq-buf', kind: 'E', zone: '测序区缓冲', deviceId: 'mb-104-qte1', pointName: 'KA1', scene: uv(0.1, 0.3) },
{ id: 'e-seq', kind: 'E', zone: '测序区', deviceId: 'mb-104-qte1', pointName: 'KA2' }, { id: 'e-seq', kind: 'E', zone: '测序区', deviceId: 'mb-104-qte1', pointName: 'KA2', scene: uv(0.1, 0.55) },
{ id: 'e-lib-2-buf', kind: 'E', zone: '文库制备区2缓冲', deviceId: 'mb-104-qte2', pointName: 'KA1' }, { id: 'e-lib-2-buf', kind: 'E', zone: '文库制备区2缓冲', deviceId: 'mb-104-qte2', pointName: 'KA1', scene: uv(0.25, 0.3) },
{ id: 'e-lib-2', kind: 'E', zone: '文库制备区2', deviceId: 'mb-104-qte2', pointName: 'KA2' }, { id: 'e-lib-2', kind: 'E', zone: '文库制备区2', deviceId: 'mb-104-qte2', pointName: 'KA2', scene: uv(0.25, 0.55) },
{ id: 'e-lib-1-buf', kind: 'E', zone: '文库制备区1缓冲', deviceId: 'mb-104-qte3', pointName: 'KA1' }, { id: 'e-lib-1-buf', kind: 'E', zone: '文库制备区1缓冲', deviceId: 'mb-104-qte3', pointName: 'KA1', scene: uv(0.4, 0.3) },
{ id: 'e-lib-1', kind: 'E', zone: '文库制备区1', deviceId: 'mb-104-qte3', pointName: 'KA2' }, { id: 'e-lib-1', kind: 'E', zone: '文库制备区1', deviceId: 'mb-104-qte3', pointName: 'KA2', scene: uv(0.4, 0.55) },
{ id: 'e-sample-1-buf', kind: 'E', zone: '样本制备区1缓冲', deviceId: 'mb-104-qte4', pointName: 'KA1' }, { id: 'e-sample-1-buf', kind: 'E', zone: '样本制备区1缓冲', deviceId: 'mb-104-qte4', pointName: 'KA1', scene: uv(0.66, 0.3) },
{ id: 'e-sample-2', kind: 'E', zone: '样本制备区2', deviceId: 'mb-104-qte4', pointName: 'KA2' }, { id: 'e-sample-2', kind: 'E', zone: '样本制备区2', deviceId: 'mb-104-qte4', pointName: 'KA2', scene: uv(0.55, 0.55) },
{ id: 'e-sample-1', kind: 'E', zone: '样本制备区1', deviceId: 'mb-104-qte4', pointName: 'KA3' }, { id: 'e-sample-1', kind: 'E', zone: '样本制备区1', deviceId: 'mb-104-qte4', pointName: 'KA3', scene: uv(0.68, 0.55) },
{ id: 'e-report', kind: 'E', zone: '报告室', deviceId: 'mb-104-qte5', pointName: 'KA1' }, { id: 'e-report', kind: 'E', zone: '报告室', deviceId: 'mb-104-qte5', pointName: 'KA1', scene: uv(0.88, 0.38) },
{ id: 'e-prep', kind: 'E', zone: '准备间', deviceId: 'mb-104-qte5', pointName: 'KA2' }, { id: 'e-prep', kind: 'E', zone: '准备间', deviceId: 'mb-104-qte5', pointName: 'KA2', scene: uv(0.85, 0.58) },
{ id: 'e-recv', kind: 'E', zone: '咨询室', deviceId: 'mb-104-qte5', pointName: 'KA3' }, { id: 'e-recv', kind: 'E', zone: '咨询室', deviceId: 'mb-104-qte5', pointName: 'KA3', scene: uv(0.88, 0.88) },
{ id: 'e-corridor', kind: 'E', zone: '走廊', deviceId: 'mb-104-qte6', pointName: 'KA1' }, { id: 'e-corridor', kind: 'E', zone: '走廊', deviceId: 'mb-104-qte6', pointName: 'KA1', scene: uv(0.35, 0.12) },
] ]
...@@ -31,6 +31,7 @@ let snapshot: RealtimeSnapshot = { ...@@ -31,6 +31,7 @@ let snapshot: RealtimeSnapshot = {
vav: [], vav: [],
fridges: [], fridges: [],
neLights: [], neLights: [],
sensors: [],
metering: { metering: {
powerKwh: 0, powerKwh: 0,
waterM3: 0, waterM3: 0,
......
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { applyPointTelemetry, listPoints } from '../devices/store.js'
import { inferPointRw } from '../devices/pointRw.js'
import { gatewayModbusWrite } from '../gateway/client.js'
import { applyWriteLog } from '../realtime/store.js'
import {
createSensor,
deleteSensor,
getSensor,
getSensorByCode,
listSensors,
setSensorPoints,
updateSensor,
} from '../sensors/store.js'
import type { SensorCategory, SensorPointRole } from '../sensors/types.js'
export const sensorsRouter = Router()
sensorsRouter.get('/', async (req, res) => {
const category = req.query.category ? String(req.query.category) : undefined
const enabled =
req.query.enabled === undefined
? undefined
: String(req.query.enabled) === '1' || String(req.query.enabled) === 'true'
res.json({ sensors: await listSensors({ category, enabled }) })
})
sensorsRouter.get('/:id', async (req, res) => {
const s = await getSensor(req.params.id)
if (!s) {
res.status(404).json({ message: '设施不存在' })
return
}
res.json(s)
})
sensorsRouter.post('/', authRequired, requireRole('admin'), async (req, res) => {
try {
const body = req.body || {}
const sensor = await createSensor({
code: String(body.code || ''),
name: String(body.name || ''),
category: body.category as SensorCategory,
enabled: body.enabled,
sceneX: body.sceneX !== undefined ? Number(body.sceneX) : undefined,
sceneY: body.sceneY !== undefined ? Number(body.sceneY) : undefined,
sceneZ: body.sceneZ !== undefined ? Number(body.sceneZ) : undefined,
meta: body.meta,
points: Array.isArray(body.points) ? body.points : undefined,
})
res.status(201).json(sensor)
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' })
}
})
sensorsRouter.put('/:id', authRequired, requireRole('admin'), async (req, res) => {
try {
const body = req.body || {}
const sensor = await updateSensor(req.params.id, {
code: body.code,
name: body.name,
category: body.category,
enabled: body.enabled,
sceneX: body.sceneX !== undefined ? Number(body.sceneX) : undefined,
sceneY: body.sceneY !== undefined ? Number(body.sceneY) : undefined,
sceneZ: body.sceneZ !== undefined ? Number(body.sceneZ) : undefined,
meta: body.meta,
points: Array.isArray(body.points) ? body.points : undefined,
})
if (!sensor) {
res.status(404).json({ message: '设施不存在' })
return
}
res.json(sensor)
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' })
}
})
sensorsRouter.put('/:id/points', authRequired, requireRole('admin'), async (req, res) => {
try {
const raw = Array.isArray(req.body?.points) ? req.body.points : req.body
if (!Array.isArray(raw)) {
res.status(400).json({ message: 'points 需为数组' })
return
}
const points = raw.map((p: { role?: string; pointId?: string; sort?: number }, i: number) => ({
role: String(p.role || '') as SensorPointRole,
pointId: String(p.pointId || ''),
sort: p.sort ?? i,
}))
const sensor = await setSensorPoints(req.params.id, points)
if (!sensor) {
res.status(404).json({ message: '设施不存在' })
return
}
res.json(sensor)
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '绑定失败' })
}
})
sensorsRouter.delete('/:id', authRequired, requireRole('admin'), async (req, res) => {
const ok = await deleteSensor(req.params.id)
if (!ok) {
res.status(404).json({ message: '设施不存在' })
return
}
res.json({ ok: true })
})
/** 照明等开关控制:写 status/value 绑定点位 */
sensorsRouter.post('/:id/control', authRequired, requireRole('admin'), async (req, res) => {
try {
const on = req.body?.on === true || req.body?.on === 1 || req.body?.on === '1'
let sensor = await getSensor(req.params.id)
if (!sensor) sensor = await getSensorByCode(req.params.id)
if (!sensor) {
res.status(404).json({ message: '设施不存在' })
return
}
const bind =
sensor.points.find((p) => p.role === 'status') ||
sensor.points.find((p) => p.role === 'value') ||
sensor.points[0]
if (!bind?.pointId) {
res.status(400).json({ message: '未绑定可写点位' })
return
}
const points = await listPoints()
const point = points.find((p) => p.id === bind.pointId)
if (!point) {
res.status(404).json({ message: '绑定点位不存在' })
return
}
const rw = inferPointRw(point.regType, point.rw || 'r', {
name: point.name,
itemId: point.itemId,
})
if (rw !== 'w' && rw !== 'rw') {
res.status(400).json({ message: '该点位为只读,不可控制' })
return
}
const value = on ? 1 : 0
const result = await gatewayModbusWrite({
pointId: point.id,
itemId: point.itemId,
value,
})
applyWriteLog('facility_switch', {
sensorId: sensor.id,
code: sensor.code,
pointId: point.id,
itemId: point.itemId,
value,
on,
by: req.user!.username,
gateway: result,
})
if (!result.ok) {
res.status(502).json({ ok: false, message: result.message })
return
}
await applyPointTelemetry([
{ id: point.id, itemId: point.itemId, eng: value, raw: value, ts: new Date().toISOString() },
])
res.json({ ok: true, message: result.message, on, pointId: point.id, value })
} catch (e) {
res.status(500).json({ message: e instanceof Error ? e.message : '控制失败' })
}
})
...@@ -3,8 +3,10 @@ import { authRequired, requireRole } from '../auth/middleware.js' ...@@ -3,8 +3,10 @@ import { authRequired, requireRole } from '../auth/middleware.js'
import { import {
getSettings, getSettings,
updateCollectPollMs, updateCollectPollMs,
updateScreenTitle,
MIN_POLL_MS, MIN_POLL_MS,
MAX_POLL_MS, MAX_POLL_MS,
MAX_SCREEN_TITLE_LEN,
} from '../settings/store.js' } from '../settings/store.js'
import { setCollectPollMs } from '../realtime/store.js' import { setCollectPollMs } from '../realtime/store.js'
...@@ -30,3 +32,22 @@ settingsRouter.put('/collect-poll', authRequired, requireRole('admin'), async (r ...@@ -30,3 +32,22 @@ settingsRouter.put('/collect-poll', authRequired, requireRole('admin'), async (r
await setCollectPollMs(settings.collectPollMs) await setCollectPollMs(settings.collectPollMs)
res.json(settings) res.json(settings)
}) })
settingsRouter.put('/screen-title', authRequired, requireRole('admin'), async (req, res) => {
const raw = req.body?.screenTitle
if (raw == null || typeof raw !== 'string') {
res.status(400).json({ message: '请输入大屏标题' })
return
}
const trimmed = raw.trim()
if (!trimmed) {
res.status(400).json({ message: '大屏标题不能为空' })
return
}
if (trimmed.length > MAX_SCREEN_TITLE_LEN) {
res.status(400).json({ message: `大屏标题最多 ${MAX_SCREEN_TITLE_LEN} 个字符` })
return
}
const settings = await updateScreenTitle(trimmed, req.user!.username)
res.json(settings)
})
import { randomUUID } from 'node:crypto'
import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js'
import { createDevice, listDevices, listPoints } from '../devices/store.js'
import type { Device, DevicePoint } from '../devices/types.js'
import {
ACCESS_FACILITY_DEFS,
BRIEFING_INFRA,
CAMERA_FACILITY_DEFS,
} from '../realtime/cameraAccess.js'
import { FRIDGE_FACILITY_DEFS, VAV_FACILITY_DEFS } from '../realtime/fridgeVav.js'
import { NE_LIGHT_DEFS } from '../realtime/neLights.js'
import { listDevices as listVideoDevices } from '../video/store.js'
import type { Sensor, SensorCategory, SensorInput, SensorPointBind, SensorPointRole } from './types.js'
type SensorRow = {
id: string
code: string
name: string
category: string
enabled: boolean
scene_x: number
scene_y: number
scene_z: number
meta: Record<string, unknown> | null
created_at: Date
updated_at: Date
}
type BindRow = {
sensor_id: string
point_id: string
role: string
sort: number
point_name: string | null
item_id: string | null
device_id: string | null
last_eng: number | null
}
const memSensors = new Map<string, Sensor>()
function mapSensor(row: SensorRow, points: SensorPointBind[] = []): Sensor {
return {
id: row.id,
code: row.code,
name: row.name,
category: row.category as SensorCategory,
enabled: !!row.enabled,
sceneX: Number(row.scene_x) || 0,
sceneY: Number(row.scene_y) || 0,
sceneZ: Number(row.scene_z) || 0,
meta: (row.meta && typeof row.meta === 'object' ? row.meta : {}) as Record<string, unknown>,
points,
createdAt: row.created_at?.toISOString?.() || new Date().toISOString(),
updatedAt: row.updated_at?.toISOString?.() || new Date().toISOString(),
}
}
async function loadBinds(sensorIds: string[]): Promise<Map<string, SensorPointBind[]>> {
const map = new Map<string, SensorPointBind[]>()
if (!sensorIds.length) return map
const { rows } = await query<BindRow>(
`SELECT sp.sensor_id, sp.point_id, sp.role, sp.sort,
dp.name AS point_name, dp.item_id, dp.device_id, dp.last_eng
FROM sensor_points sp
LEFT JOIN device_points dp ON dp.id = sp.point_id
WHERE sp.sensor_id = ANY($1::text[])
ORDER BY sp.sort ASC, sp.role ASC`,
[sensorIds],
)
for (const r of rows) {
const list = map.get(r.sensor_id) || []
list.push({
pointId: r.point_id,
role: r.role,
sort: Number(r.sort) || 0,
pointName: r.point_name || undefined,
itemId: r.item_id || undefined,
deviceId: r.device_id,
lastEng: r.last_eng === null || r.last_eng === undefined ? null : Number(r.last_eng),
})
map.set(r.sensor_id, list)
}
return map
}
export async function listSensors(opts?: {
category?: string
enabled?: boolean
}): Promise<Sensor[]> {
if (!isPgEnabled()) {
let list = [...memSensors.values()]
if (opts?.category) list = list.filter((s) => s.category === opts.category)
if (opts?.enabled !== undefined) list = list.filter((s) => s.enabled === opts.enabled)
return list.sort((a, b) => a.code.localeCompare(b.code))
}
const where: string[] = []
const params: unknown[] = []
if (opts?.category) {
params.push(opts.category)
where.push(`category = $${params.length}`)
}
if (opts?.enabled !== undefined) {
params.push(opts.enabled)
where.push(`enabled = $${params.length}`)
}
const sql = `SELECT * FROM sensors${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY category, code`
const { rows } = await query<SensorRow>(sql, params)
const binds = await loadBinds(rows.map((r) => r.id))
return rows.map((r) => mapSensor(r, binds.get(r.id) || []))
}
export async function getSensor(id: string): Promise<Sensor | null> {
if (!isPgEnabled()) return memSensors.get(id) || null
const { rows } = await query<SensorRow>(`SELECT * FROM sensors WHERE id = $1`, [id])
const r = rows[0]
if (!r) return null
const binds = await loadBinds([r.id])
return mapSensor(r, binds.get(r.id) || [])
}
export async function getSensorByCode(code: string): Promise<Sensor | null> {
if (!isPgEnabled()) {
return [...memSensors.values()].find((s) => s.code === code) || null
}
const { rows } = await query<SensorRow>(`SELECT * FROM sensors WHERE code = $1`, [code])
const r = rows[0]
if (!r) return null
const binds = await loadBinds([r.id])
return mapSensor(r, binds.get(r.id) || [])
}
async function replacePoints(
sensorId: string,
points: { role: SensorPointRole; pointId: string; sort?: number }[],
) {
await query(`DELETE FROM sensor_points WHERE sensor_id = $1`, [sensorId])
let i = 0
for (const p of points) {
if (!p.pointId || !p.role) continue
await query(
`INSERT INTO sensor_points (sensor_id, point_id, role, sort)
VALUES ($1, $2, $3, $4)
ON CONFLICT (sensor_id, role) DO UPDATE
SET point_id = EXCLUDED.point_id, sort = EXCLUDED.sort`,
[sensorId, p.pointId, String(p.role), p.sort ?? i++],
)
}
}
export async function createSensor(input: SensorInput): Promise<Sensor> {
const code = String(input.code || '').trim()
const name = String(input.name || '').trim()
const category = String(input.category || '').trim() as SensorCategory
if (!code || !name || !category) throw new Error('code / name / category 必填')
const id = randomUUID()
const now = new Date().toISOString()
const sensor: Sensor = {
id,
code,
name,
category,
enabled: input.enabled !== false,
sceneX: Number(input.sceneX) || 0,
sceneY: Number(input.sceneY) || 0,
sceneZ: Number.isFinite(Number(input.sceneZ)) ? Number(input.sceneZ) : 51,
meta: input.meta || {},
points: [],
createdAt: now,
updatedAt: now,
}
if (!isPgEnabled()) {
if ([...memSensors.values()].some((s) => s.code === code)) throw new Error('传感器编码已存在')
memSensors.set(id, sensor)
if (input.points?.length) {
sensor.points = input.points.map((p, idx) => ({
pointId: p.pointId,
role: p.role,
sort: p.sort ?? idx,
}))
}
return sensor
}
await query(
`INSERT INTO sensors (id, code, name, category, enabled, scene_x, scene_y, scene_z, meta)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb)`,
[
id,
code,
name,
category,
sensor.enabled,
sensor.sceneX,
sensor.sceneY,
sensor.sceneZ,
JSON.stringify(sensor.meta),
],
)
if (input.points?.length) await replacePoints(id, input.points)
const created = await getSensor(id)
if (!created) throw new Error('创建失败')
return created
}
export async function updateSensor(
id: string,
patch: Partial<SensorInput> & { enabled?: boolean },
): Promise<Sensor | null> {
const cur = await getSensor(id)
if (!cur) return null
const next: Sensor = {
...cur,
code: patch.code !== undefined ? String(patch.code).trim() : cur.code,
name: patch.name !== undefined ? String(patch.name).trim() : cur.name,
category: (patch.category !== undefined ? String(patch.category).trim() : cur.category) as SensorCategory,
enabled: patch.enabled !== undefined ? !!patch.enabled : cur.enabled,
sceneX: patch.sceneX !== undefined ? Number(patch.sceneX) || 0 : cur.sceneX,
sceneY: patch.sceneY !== undefined ? Number(patch.sceneY) || 0 : cur.sceneY,
sceneZ: patch.sceneZ !== undefined ? Number(patch.sceneZ) : cur.sceneZ,
meta: patch.meta !== undefined ? patch.meta || {} : cur.meta,
updatedAt: new Date().toISOString(),
}
if (!next.code || !next.name || !next.category) throw new Error('code / name / category 必填')
if (!isPgEnabled()) {
memSensors.set(id, next)
if (patch.points) {
next.points = patch.points.map((p, idx) => ({
pointId: p.pointId,
role: p.role,
sort: p.sort ?? idx,
}))
}
return next
}
await query(
`UPDATE sensors
SET code=$2, name=$3, category=$4, enabled=$5,
scene_x=$6, scene_y=$7, scene_z=$8, meta=$9::jsonb, updated_at=NOW()
WHERE id=$1`,
[
id,
next.code,
next.name,
next.category,
next.enabled,
next.sceneX,
next.sceneY,
next.sceneZ,
JSON.stringify(next.meta),
],
)
if (patch.points) await replacePoints(id, patch.points)
return getSensor(id)
}
export async function setSensorPoints(
id: string,
points: { role: SensorPointRole; pointId: string; sort?: number }[],
): Promise<Sensor | null> {
const cur = await getSensor(id)
if (!cur) return null
if (!isPgEnabled()) {
cur.points = points.map((p, idx) => ({
pointId: p.pointId,
role: p.role,
sort: p.sort ?? idx,
}))
cur.updatedAt = new Date().toISOString()
memSensors.set(id, cur)
return cur
}
await replacePoints(id, points)
await query(`UPDATE sensors SET updated_at=NOW() WHERE id=$1`, [id])
return getSensor(id)
}
export async function deleteSensor(id: string): Promise<boolean> {
if (!isPgEnabled()) return memSensors.delete(id)
const { rowCount } = await query(`DELETE FROM sensors WHERE id = $1`, [id])
return (rowCount || 0) > 0
}
function pickPoint(points: DevicePoint[], ...patterns: RegExp[]): DevicePoint | undefined {
for (const re of patterns) {
const hit = points.find((p) => re.test(p.name) || re.test(p.itemId))
if (hit) return hit
}
return undefined
}
function matchRoomDevice(devices: Device[], name: string): Device | undefined {
const exact = devices.find((d) => d.category === 'room' && d.name === name)
if (exact) return exact
return devices.find(
(d) => d.category === 'room' && (d.name.includes(name) || name.includes(d.name)),
)
}
/** 13 组压差传感器种子(对齐 McgsPro + 现有 UV 标定) */
const PRESSURE_SEEDS: {
code: string
name: string
deviceName: string
x: number
y: number
z: number
}[] = [
{ code: 'p-seq', name: '测序区压差', deviceName: '测序区', x: 133.5430055413, y: 22.1971930135, z: 51 },
{ code: 'p-seq-buf', name: '测序区缓冲压差', deviceName: '测序区缓冲', x: 133.5430055413, y: 22.1972375685, z: 51 },
{ code: 'p-lib-2', name: '文库制备区2压差', deviceName: '文库制备区2', x: 133.5430179806, y: 22.1971930135, z: 51 },
{ code: 'p-lib-2-buf', name: '文库制备区2缓冲压差', deviceName: '文库制备区2缓冲', x: 133.5430179806, y: 22.1972375685, z: 51 },
{ code: 'p-lib-1', name: '文库制备区1压差', deviceName: '文库制备区1', x: 133.5430304198, y: 22.1971930135, z: 51 },
{ code: 'p-lib-1-buf', name: '文库制备区1缓冲压差', deviceName: '文库制备区1缓冲', x: 133.5430304198, y: 22.1972375685, z: 51 },
{ code: 'p-sample-2', name: '样本制备区2压差', deviceName: '样本制备区2', x: 133.5430428591, y: 22.1971930135, z: 51 },
{ code: 'p-sample-1', name: '样本制备区1压差', deviceName: '样本制备区1', x: 133.5430536398, y: 22.1971930135, z: 51 },
{ code: 'p-sample-buf', name: '样本制备区缓冲压差', deviceName: '样本制备区缓冲', x: 133.5430519812, y: 22.1972375685, z: 51 },
{ code: 'p-recv', name: '咨询室压差', deviceName: '收样咨询室', x: 133.5430702255, y: 22.1971452759, z: 51 },
{ code: 'p-reagent', name: '准备区压差', deviceName: '试剂准备间', x: 133.5430677376, y: 22.1971930135, z: 51 },
{ code: 'p-report', name: '报告室压差', deviceName: '生信分析报告室', x: 133.5430702255, y: 22.1972248385, z: 51 },
{ code: 'p-pcr', name: 'PCR走廊压差', deviceName: 'PCR走廊', x: 133.5430320784, y: 22.1972662110, z: 51 },
]
/**
* 自动注册压差传感器:已存在同 code 则跳过。
* 从 room 设备点位按正则绑定 temp/humidity/pressure。
*/
export async function ensurePressureSensorSeed(): Promise<{ created: number; skipped: number }> {
let created = 0
let skipped = 0
const [devices, allPoints] = await Promise.all([listDevices(), listPoints()])
const byDevice = new Map<string, DevicePoint[]>()
for (const p of allPoints) {
if (!p.deviceId || !p.enabled) continue
const list = byDevice.get(p.deviceId) || []
list.push(p)
byDevice.set(p.deviceId, list)
}
for (const seed of PRESSURE_SEEDS) {
const exists = await getSensorByCode(seed.code)
if (exists) {
skipped += 1
continue
}
const device = matchRoomDevice(devices, seed.deviceName)
const pts = device ? byDevice.get(device.id) || [] : []
const tempP = pickPoint(pts, /实时温度|^温度$|Temp/i)
const humP = pickPoint(pts, /实时湿度|^湿度$|Humid/i)
const pressP = pickPoint(pts, /实时压差|压差(?!设定|上限|下限)/)
const binds: { role: SensorPointRole; pointId: string; sort: number }[] = []
if (tempP) binds.push({ role: 'temp', pointId: tempP.id, sort: 0 })
if (humP) binds.push({ role: 'humidity', pointId: humP.id, sort: 1 })
if (pressP) binds.push({ role: 'pressure', pointId: pressP.id, sort: 2 })
await createSensor({
code: seed.code,
name: seed.name,
category: 'pressure',
enabled: true,
sceneX: seed.x,
sceneY: seed.y,
sceneZ: seed.z,
meta: { deviceName: seed.deviceName, deviceId: device?.id || null },
points: binds,
})
created += 1
}
return { created, skipped }
}
/**
* 注册 N电 / E电 照明设施:已存在同 code 则跳过。
* 绑定 QTN / QTE 设备上的 KA 点位为 status。
*/
export async function ensureNeLightSensorSeed(): Promise<{ created: number; skipped: number }> {
let created = 0
let skipped = 0
const allPoints = await listPoints()
const byDevice = new Map<string, DevicePoint[]>()
for (const p of allPoints) {
if (!p.deviceId || !p.enabled) continue
const list = byDevice.get(p.deviceId) || []
list.push(p)
byDevice.set(p.deviceId, list)
}
for (const def of NE_LIGHT_DEFS) {
const exists = await getSensorByCode(def.id)
if (exists) {
skipped += 1
continue
}
const pts = byDevice.get(def.deviceId) || []
const statusP = pts.find(
(p) => p.name === def.pointName || p.itemId.endsWith(`.${def.pointName}`),
)
const category: SensorCategory = def.kind === 'N' ? 'lightN' : 'lightE'
const prefix = def.kind === 'N' ? '普通照明' : '应急照明'
await createSensor({
code: def.id,
name: `${prefix}-${def.zone}`,
category,
enabled: true,
sceneX: def.scene.x,
sceneY: def.scene.y,
sceneZ: def.scene.z,
meta: {
lightId: def.id,
kind: def.kind,
zone: def.zone,
deviceId: def.deviceId,
pointName: def.pointName,
icon: def.kind === 'N' ? 'electricity-meter-blue.png' : 'electricity-meter-orange.png',
},
points: statusP ? [{ role: 'status', pointId: statusP.id, sort: 0 }] : [],
})
created += 1
}
return { created, skipped }
}
function pickDevicePoint(points: DevicePoint[], ...patterns: RegExp[]): DevicePoint | undefined {
for (const re of patterns) {
const hit = points.find((p) => re.test(p.name) || re.test(p.itemId))
if (hit) return hit
}
return undefined
}
/**
* 注册冰箱设施:绑定温度 / 湿度点位。
*/
export async function ensureFridgeSensorSeed(): Promise<{ created: number; skipped: number }> {
let created = 0
let skipped = 0
const allPoints = await listPoints()
const byDevice = new Map<string, DevicePoint[]>()
for (const p of allPoints) {
if (!p.deviceId || !p.enabled) continue
const list = byDevice.get(p.deviceId) || []
list.push(p)
byDevice.set(p.deviceId, list)
}
for (const def of FRIDGE_FACILITY_DEFS) {
const exists = await getSensorByCode(def.id)
if (exists) {
skipped += 1
continue
}
const pts = byDevice.get(def.deviceId) || []
const tempP = pickDevicePoint(pts, /冰箱温度|温度/)
const humP = pickDevicePoint(pts, /冰箱湿度|湿度/)
const binds: { role: SensorPointRole; pointId: string; sort: number }[] = []
if (tempP) binds.push({ role: 'temp', pointId: tempP.id, sort: 0 })
if (humP) binds.push({ role: 'humidity', pointId: humP.id, sort: 1 })
await createSensor({
code: def.id,
name: def.name,
category: 'fridge',
enabled: true,
sceneX: def.scene.x,
sceneY: def.scene.y,
sceneZ: def.scene.z,
meta: {
deviceId: def.deviceId,
icon: 'temperature-sensor-orange.png',
},
points: binds,
})
created += 1
}
return { created, skipped }
}
/**
* 注册通风柜设施:绑定开停 / 面风速 / 门高 / 风量 / 报警码。
*/
export async function ensureVavSensorSeed(): Promise<{ created: number; skipped: number }> {
let created = 0
let skipped = 0
const allPoints = await listPoints()
const byDevice = new Map<string, DevicePoint[]>()
for (const p of allPoints) {
if (!p.deviceId || !p.enabled) continue
const list = byDevice.get(p.deviceId) || []
list.push(p)
byDevice.set(p.deviceId, list)
}
for (const def of VAV_FACILITY_DEFS) {
const exists = await getSensorByCode(def.id)
if (exists) {
skipped += 1
continue
}
const pts = byDevice.get(def.deviceId) || []
const statusP = pickDevicePoint(pts, /开停状态|^开机$/)
const faceP = pickDevicePoint(pts, /实际面风速|面风速/)
const sashP = pickDevicePoint(pts, /实际门高|门高/)
const airflowP = pickDevicePoint(pts, /实际风量|风量/)
const alarmP = pickDevicePoint(pts, /报警代码/)
const binds: { role: SensorPointRole; pointId: string; sort: number }[] = []
if (statusP) binds.push({ role: 'status', pointId: statusP.id, sort: 0 })
if (faceP) binds.push({ role: 'faceVelocity', pointId: faceP.id, sort: 1 })
if (sashP) binds.push({ role: 'sashHeight', pointId: sashP.id, sort: 2 })
if (airflowP) binds.push({ role: 'airflow', pointId: airflowP.id, sort: 3 })
if (alarmP) binds.push({ role: 'alarmCode', pointId: alarmP.id, sort: 4 })
await createSensor({
code: def.id,
name: def.name,
category: 'vav',
enabled: true,
sceneX: def.scene.x,
sceneY: def.scene.y,
sceneZ: def.scene.z,
meta: {
deviceId: def.deviceId,
icon: 'motor-green.png',
},
points: binds,
})
created += 1
}
return { created, skipped }
}
/**
* 注册摄像头设施(交底表 IPC);绑定视频台账 id(若已同步)。
*/
export async function ensureCameraSensorSeed(): Promise<{ created: number; skipped: number }> {
let created = 0
let skipped = 0
const videos = listVideoDevices()
for (const def of CAMERA_FACILITY_DEFS) {
const exists = await getSensorByCode(def.id)
if (exists) {
skipped += 1
continue
}
const video = videos.find((v) => v.ip === def.ip || v.name === def.name)
await createSensor({
code: def.id,
name: def.name,
category: 'camera',
enabled: true,
sceneX: def.scene.x,
sceneY: def.scene.y,
sceneZ: def.scene.z,
meta: {
ip: def.ip,
port: def.port,
channel: def.channel,
protocol: BRIEFING_INFRA.protocol,
username: BRIEFING_INFRA.username,
videoDeviceId: video?.id || null,
icon: 'cctv-blue.png',
},
points: [],
})
created += 1
}
return { created, skipped }
}
/**
* 注册门禁设施(交底表),并补齐 devices 台账 door 分类供大屏快照。
*/
export async function ensureAccessSensorSeed(): Promise<{ created: number; skipped: number }> {
let created = 0
let skipped = 0
const devices = await listDevices()
for (const def of ACCESS_FACILITY_DEFS) {
let door = devices.find((d) => d.code === def.id || d.name === def.name)
if (!door) {
door = await createDevice({
category: 'door',
name: def.name,
code: def.id,
bindUi: 'access',
meta: {
ip: def.ip,
port: def.port,
protocol: BRIEFING_INFRA.protocol,
username: BRIEFING_INFRA.username,
},
})
devices.push(door)
}
const exists = await getSensorByCode(def.id)
if (exists) {
skipped += 1
continue
}
await createSensor({
code: def.id,
name: def.name,
category: 'access',
enabled: true,
sceneX: def.scene.x,
sceneY: def.scene.y,
sceneZ: def.scene.z,
meta: {
ip: def.ip,
port: def.port,
protocol: BRIEFING_INFRA.protocol,
username: BRIEFING_INFRA.username,
doorDeviceId: door.id,
icon: 'access-control-blue.png',
},
points: [],
})
created += 1
}
return { created, skipped }
}
export type SensorCategory =
| 'pressure'
| 'lightN'
| 'lightE'
| 'fridge'
| 'vav'
| 'camera'
| 'access'
| 'other'
/** 压差传感器角色;其它分类可扩展 */
export type SensorPointRole = 'temp' | 'humidity' | 'pressure' | 'value' | 'status' | string
export interface SensorPointBind {
pointId: string
role: SensorPointRole
sort: number
/** 列表展示用 */
pointName?: string
itemId?: string
deviceId?: string | null
lastEng?: number | null
}
export interface Sensor {
id: string
code: string
name: string
category: SensorCategory
enabled: boolean
sceneX: number
sceneY: number
sceneZ: number
meta: Record<string, unknown>
points: SensorPointBind[]
createdAt: string
updatedAt: string
}
export interface SensorInput {
code: string
name: string
category: SensorCategory
enabled?: boolean
sceneX?: number
sceneY?: number
sceneZ?: number
meta?: Record<string, unknown>
points?: { role: SensorPointRole; pointId: string; sort?: number }[]
}
...@@ -2,8 +2,12 @@ import { config } from '../config.js' ...@@ -2,8 +2,12 @@ import { config } from '../config.js'
import { query } from '../db/pool.js' import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js' import { isPgEnabled } from '../db/state.js'
export const DEFAULT_SCREEN_TITLE = '分子实验室三维可视化管理平台'
export const MAX_SCREEN_TITLE_LEN = 64
export interface SystemSettings { export interface SystemSettings {
collectPollMs: number collectPollMs: number
screenTitle: string
dataMode: string dataMode: string
collectRetentionDays: number collectRetentionDays: number
updatedAt: string updatedAt: string
...@@ -19,13 +23,23 @@ export function clampPollMs(ms: number) { ...@@ -19,13 +23,23 @@ export function clampPollMs(ms: number) {
return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, Math.round(ms))) return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, Math.round(ms)))
} }
export function normalizeScreenTitle(raw: unknown): string {
const s = String(raw ?? '')
.replace(/\s+/g, ' ')
.trim()
if (!s) return DEFAULT_SCREEN_TITLE
return s.slice(0, MAX_SCREEN_TITLE_LEN)
}
let memPollMs = clampPollMs(config.modbus.pollMs) let memPollMs = clampPollMs(config.modbus.pollMs)
let memScreenTitle = DEFAULT_SCREEN_TITLE
let memUpdatedAt = new Date().toISOString() let memUpdatedAt = new Date().toISOString()
let memUpdatedBy = 'system' let memUpdatedBy = 'system'
function base(extra: Partial<SystemSettings> = {}): SystemSettings { function base(extra: Partial<SystemSettings> = {}): SystemSettings {
return { return {
collectPollMs: memPollMs, collectPollMs: memPollMs,
screenTitle: memScreenTitle,
dataMode: config.dataMode, dataMode: config.dataMode,
collectRetentionDays: config.collectRetentionDays, collectRetentionDays: config.collectRetentionDays,
updatedAt: memUpdatedAt, updatedAt: memUpdatedAt,
...@@ -40,9 +54,9 @@ export async function ensureSettingsSeed() { ...@@ -40,9 +54,9 @@ export async function ensureSettingsSeed() {
const { rows } = await query(`SELECT id FROM system_settings WHERE id = 1`) const { rows } = await query(`SELECT id FROM system_settings WHERE id = 1`)
if (rows.length) return if (rows.length) return
await query( await query(
`INSERT INTO system_settings (id, collect_poll_ms, updated_by) `INSERT INTO system_settings (id, collect_poll_ms, screen_title, updated_by)
VALUES (1, $1, 'system')`, VALUES (1, $1, $2, 'system')`,
[clampPollMs(config.modbus.pollMs)], [clampPollMs(config.modbus.pollMs), DEFAULT_SCREEN_TITLE],
) )
} }
...@@ -50,12 +64,17 @@ export async function getSettings(): Promise<SystemSettings> { ...@@ -50,12 +64,17 @@ export async function getSettings(): Promise<SystemSettings> {
if (!isPgEnabled()) return base() if (!isPgEnabled()) return base()
const { rows } = await query<{ const { rows } = await query<{
collect_poll_ms: number collect_poll_ms: number
screen_title: string | null
updated_at: Date updated_at: Date
updated_by: string updated_by: string
}>(`SELECT collect_poll_ms, updated_at, updated_by FROM system_settings WHERE id = 1`) }>(
`SELECT collect_poll_ms, screen_title, updated_at, updated_by
FROM system_settings WHERE id = 1`,
)
const r = rows[0] const r = rows[0]
return { return {
collectPollMs: r ? Number(r.collect_poll_ms) : clampPollMs(config.modbus.pollMs), collectPollMs: r ? Number(r.collect_poll_ms) : clampPollMs(config.modbus.pollMs),
screenTitle: normalizeScreenTitle(r?.screen_title || DEFAULT_SCREEN_TITLE),
dataMode: config.dataMode, dataMode: config.dataMode,
collectRetentionDays: config.collectRetentionDays, collectRetentionDays: config.collectRetentionDays,
updatedAt: r?.updated_at?.toISOString() || new Date().toISOString(), updatedAt: r?.updated_at?.toISOString() || new Date().toISOString(),
...@@ -86,4 +105,21 @@ export async function updateCollectPollMs(ms: number, by: string): Promise<Syste ...@@ -86,4 +105,21 @@ export async function updateCollectPollMs(ms: number, by: string): Promise<Syste
return getSettings() return getSettings()
} }
export async function updateScreenTitle(title: unknown, by: string): Promise<SystemSettings> {
const next = normalizeScreenTitle(title)
if (!isPgEnabled()) {
memScreenTitle = next
memUpdatedAt = new Date().toISOString()
memUpdatedBy = by
return base()
}
await query(
`UPDATE system_settings
SET screen_title = $1, updated_at = NOW(), updated_by = $2
WHERE id = 1`,
[next, by],
)
return getSettings()
}
export { MIN_POLL_MS, MAX_POLL_MS } export { MIN_POLL_MS, MAX_POLL_MS }
...@@ -88,6 +88,38 @@ export interface PointAlarmSnapshot { ...@@ -88,6 +88,38 @@ export interface PointAlarmSnapshot {
ts: string ts: string
} }
/** 大屏传感器/设施聚合(压差、照明、冰箱、通风柜等:多点位 → 单打点) */
export interface SensorSnapshot {
id: string
code: string
name: string
category: string
values: {
temp: number | null
humidity: number | null
pressure: number | null
}
/** 照明等开关态(status 角色点位);通风柜=开停 */
on?: boolean | null
/** 可写控制点位 id(大屏开/关) */
controlPointId?: string | null
/** 通风柜扩展读数 */
vav?: {
faceVelocity: number | null
sashHeight: number | null
airflow: number | null
alarmCode: number | null
}
alarms: string[]
scene: { x: number; y: number; z: number }
/** 引擎图标文件名,来自 meta.icon */
icon?: string | null
/** 摄像头设施关联的视频台账 id */
videoDeviceId?: string | null
/** 门禁设施关联的 door 设备 id */
doorDeviceId?: string | null
}
export interface RealtimeSnapshot { export interface RealtimeSnapshot {
ts: number ts: number
rooms: RoomSnapshot[] rooms: RoomSnapshot[]
...@@ -107,6 +139,8 @@ export interface RealtimeSnapshot { ...@@ -107,6 +139,8 @@ export interface RealtimeSnapshot {
fridges: FridgeSnapshot[] fridges: FridgeSnapshot[]
/** N电/E电照明回路(大屏普通/应急照明态) */ /** N电/E电照明回路(大屏普通/应急照明态) */
neLights?: NeLightSnapshot[] neLights?: NeLightSnapshot[]
/** 传感器聚合(压差等:多点位 → 单打点) */
sensors?: SensorSnapshot[]
metering: { metering: {
powerKwh: number powerKwh: number
waterM3: number waterM3: number
......
...@@ -2,6 +2,7 @@ import fs from 'node:fs' ...@@ -2,6 +2,7 @@ import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { config } from '../config.js' import { config } from '../config.js'
import { BRIEFING_INFRA, CAMERA_FACILITY_DEFS } from '../realtime/cameraAccess.js'
export type DeviceStatus = 'online' | 'offline' | 'alarm' export type DeviceStatus = 'online' | 'offline' | 'alarm'
export type DeviceType = 'ipc' | 'nvr-ch' export type DeviceType = 'ipc' | 'nvr-ch'
...@@ -180,8 +181,109 @@ function save(data: VideoData) { ...@@ -180,8 +181,109 @@ function save(data: VideoData) {
fs.writeFileSync(filePath(), JSON.stringify(data, null, 2), 'utf-8') fs.writeFileSync(filePath(), JSON.stringify(data, null, 2), 'utf-8')
} }
const PLACEHOLDER_CAM_NAMES = new Set(['走廊东', 'PCR区', '样本库', '机房', '门禁口', '冷链库'])
/**
* 按技术交底表同步摄像头台账与 NVR 通信配置。
* 若当前仍是占位通道,则整表替换;否则按 IP/名称补缺。
*/
export function ensureBriefingVideoSeed(): { created: number; updated: number; replaced: boolean } {
const data = load()
const now = new Date().toISOString()
data.config.host = BRIEFING_INFRA.nvr.ip
data.config.port = BRIEFING_INFRA.nvr.port
data.config.username = BRIEFING_INFRA.username
data.config.password = BRIEFING_INFRA.password
data.config.vendor = 'hikvision'
data.config.updatedAt = now
data.config.updatedBy = 'briefing-seed'
const allPlaceholder =
data.devices.length > 0 &&
data.devices.every((d) => PLACEHOLDER_CAM_NAMES.has(d.name) || (!d.ip && d.deviceType === 'nvr-ch'))
const briefingIpHit = CAMERA_FACILITY_DEFS.some((c) =>
data.devices.some((d) => d.ip === c.ip || d.name === c.name),
)
if (allPlaceholder || !briefingIpHit) {
data.devices = CAMERA_FACILITY_DEFS.map((c) => ({
id: randomUUID(),
name: c.name,
channel: c.channel,
location: c.name,
deviceType: 'ipc' as const,
status: 'online' as const,
enabled: true,
ip: c.ip,
remark: `交底表 · ${BRIEFING_INFRA.protocol} · ${c.ip}:${c.port}`,
scenePoint: {
x: c.scene.x,
y: c.scene.y,
z: c.scene.z,
icon: 'cctv-blue.png',
},
createdAt: now,
updatedAt: now,
}))
save(data)
return { created: data.devices.length, updated: 0, replaced: true }
}
let created = 0
let updated = 0
for (const c of CAMERA_FACILITY_DEFS) {
const hit = data.devices.find((d) => d.ip === c.ip || d.name === c.name)
if (!hit) {
data.devices.push({
id: randomUUID(),
name: c.name,
channel: c.channel,
location: c.name,
deviceType: 'ipc',
status: 'online',
enabled: true,
ip: c.ip,
remark: `交底表 · ${BRIEFING_INFRA.protocol} · ${c.ip}:${c.port}`,
scenePoint: {
x: c.scene.x,
y: c.scene.y,
z: c.scene.z,
icon: 'cctv-blue.png',
},
createdAt: now,
updatedAt: now,
})
created += 1
continue
}
let dirty = false
if (!hit.ip) {
hit.ip = c.ip
dirty = true
}
if (!hit.scenePoint) {
hit.scenePoint = { x: c.scene.x, y: c.scene.y, z: c.scene.z, icon: 'cctv-blue.png' }
dirty = true
}
if (dirty) {
hit.updatedAt = now
updated += 1
}
}
save(data)
return { created, updated, replaced: false }
}
export function ensureVideoSeed() { export function ensureVideoSeed() {
load() load()
try {
const r = ensureBriefingVideoSeed()
console.log(
`[video] briefing seed created=${r.created} updated=${r.updated} replaced=${r.replaced}`,
)
} catch (e) {
console.warn('[video] briefing seed failed:', e instanceof Error ? e.message : e)
}
} }
function toPublicConfig(c: VideoCommConfig): PublicCommConfig { function toPublicConfig(c: VideoCommConfig): PublicCommConfig {
......
...@@ -12,8 +12,8 @@ WORKDIR /app/web ...@@ -12,8 +12,8 @@ WORKDIR /app/web
ARG VITE_API_BASE= ARG VITE_API_BASE=
ARG VITE_VIEWPORT_MODE=rtengine ARG VITE_VIEWPORT_MODE=rtengine
ARG VITE_RTENGINE_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38 ARG VITE_RTENGINE_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ARG VITE_UE_STREAM_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38 ARG VITE_UE_STREAM_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ENV VITE_API_BASE=$VITE_API_BASE \ ENV VITE_API_BASE=$VITE_API_BASE \
VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \ VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \
VITE_RTENGINE_URL=$VITE_RTENGINE_URL \ VITE_RTENGINE_URL=$VITE_RTENGINE_URL \
......
...@@ -6,6 +6,16 @@ server { ...@@ -6,6 +6,16 @@ server {
client_max_body_size 20m; client_max_body_size 20m;
# 引擎图标:必须先于业务 /api,iframe 内绝对路径 /api/icons 同域可达
location /api/icons {
proxy_pass http://127.0.0.1:6088/api/icons;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ { location /api/ {
proxy_pass http://server:3001/api/; proxy_pass http://server:3001/api/;
proxy_http_version 1.1; proxy_http_version 1.1;
...@@ -24,9 +34,9 @@ server { ...@@ -24,9 +34,9 @@ server {
proxy_read_timeout 86400; proxy_read_timeout 86400;
} }
# RTEngineHD:同域 iframe,便于 postMessage;媒体仍由 ICE 直连引擎机 # RTEngineHD:同域 /gis → 引擎根(勿转到引擎空目录 /gis/)
# 原生 nginx:127.0.0.1:6088;Docker 容器内需改为 host.docker.internal:6088 # 原生 nginx:127.0.0.1:6088;Docker 容器内需改为 host.docker.internal:6088
location /rtengine/ { location /gis/ {
proxy_pass http://127.0.0.1:6088/; proxy_pass http://127.0.0.1:6088/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $proxy_host; proxy_set_header Host $proxy_host;
...@@ -40,8 +50,19 @@ server { ...@@ -40,8 +50,19 @@ server {
proxy_buffering off; proxy_buffering off;
} }
location = /rtengine { location = /gis {
return 302 /rtengine/; return 302 /gis/;
}
# 兼容旧 /rtengine 前缀
location /rtengine/ {
proxy_pass http://127.0.0.1:6088/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_buffering off;
} }
# 引擎 WebSocket 信令(dte.js 使用绝对路径 /peer-stream) # 引擎 WebSocket 信令(dte.js 使用绝对路径 /peer-stream)
......
...@@ -9,22 +9,33 @@ const props = withDefaults( ...@@ -9,22 +9,33 @@ const props = withDefaults(
left: number left: number
top: number top: number
src?: string src?: string
/** video=监控预览;values=其它设备的接口读数 */ /** video=监控预览;values=读数;switch=照明开/关 */
kind?: 'video' | 'values' kind?: 'video' | 'values' | 'switch'
lines?: { label: string; value: string }[] lines?: { label: string; value: string }[]
/** 当前开关态(switch) */
switchOn?: boolean | null
switching?: boolean
switchError?: string
}>(), }>(),
{ {
src: '/cams/lab-seq.mp4', src: '/cams/lab-seq.mp4',
kind: 'video', kind: 'video',
lines: () => [], lines: () => [],
switchOn: null,
switching: false,
switchError: '',
}, },
) )
const emit = defineEmits<{ close: [] }>() const emit = defineEmits<{
close: []
control: [on: boolean]
}>()
const BOX_W = 400 const BOX_W = 400
const TITLE_H = 36 const TITLE_H = 36
const boxH = computed(() => { const boxH = computed(() => {
if (props.kind === 'switch') return TITLE_H + 120
if (props.kind === 'values') { if (props.kind === 'values') {
const rows = Math.max(props.lines.length, 1) const rows = Math.max(props.lines.length, 1)
return Math.min(420, TITLE_H + rows * 36 + 16) return Math.min(420, TITLE_H + rows * 36 + 16)
...@@ -157,9 +168,16 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) ...@@ -157,9 +168,16 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
× ×
</button> </button>
</header> </header>
<div class="near-body" :class="{ 'near-body--values': kind === 'values' }" :style="{ height: `${videoH}px` }"> <div
class="near-body"
:class="{
'near-body--values': kind === 'values',
'near-body--switch': kind === 'switch',
}"
:style="{ height: `${videoH}px` }"
>
<video <video
v-if="kind !== 'values'" v-if="kind === 'video'"
class="near-video" class="near-video"
:src="src" :src="src"
autoplay autoplay
...@@ -168,12 +186,39 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) ...@@ -168,12 +186,39 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
playsinline playsinline
controls controls
/> />
<ul v-else class="near-values"> <ul v-else-if="kind === 'values'" class="near-values">
<li v-for="row in lines" :key="row.label"> <li v-for="row in lines" :key="row.label">
<span>{{ row.label }}</span> <span>{{ row.label }}</span>
<b>{{ row.value }}</b> <b>{{ row.value }}</b>
</li> </li>
</ul> </ul>
<div v-else class="near-switch">
<p class="near-switch-status">
当前:
<b :class="{ on: switchOn === true, off: switchOn === false }">
{{ switchOn == null ? '未知' : switchOn ? '开' : '关' }}
</b>
</p>
<div class="near-switch-actions">
<button
type="button"
class="sw-btn on"
:disabled="switching"
@click="emit('control', true)"
>
开
</button>
<button
type="button"
class="sw-btn off"
:disabled="switching"
@click="emit('control', false)"
>
关
</button>
</div>
<p v-if="switchError" class="near-switch-err">{{ switchError }}</p>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -257,6 +302,73 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) ...@@ -257,6 +302,73 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
background: #071426; background: #071426;
overflow: auto; overflow: auto;
} }
.near-body--switch {
background: #071426;
display: flex;
align-items: center;
justify-content: center;
}
.near-switch {
width: 100%;
padding: 12px 18px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.near-switch-status {
margin: 0;
font-size: 13px;
color: #8fb4c8;
}
.near-switch-status b {
font-size: 15px;
font-weight: 700;
margin-left: 4px;
}
.near-switch-status b.on {
color: #4dffb5;
}
.near-switch-status b.off {
color: #9ec8dc;
}
.near-switch-actions {
display: flex;
gap: 16px;
}
.sw-btn {
min-width: 96px;
height: 40px;
border-radius: 4px;
border: 1px solid rgba(0, 247, 255, 0.35);
background: rgba(8, 36, 64, 0.9);
color: #e8fbff;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.2em;
cursor: pointer;
}
.sw-btn.on {
border-color: rgba(77, 255, 181, 0.55);
color: #4dffb5;
}
.sw-btn.off {
border-color: rgba(158, 200, 220, 0.45);
color: #c5e4f4;
}
.sw-btn:hover:not(:disabled) {
background: rgba(0, 100, 160, 0.4);
}
.sw-btn:disabled {
opacity: 0.55;
cursor: wait;
}
.near-switch-err {
margin: 0;
font-size: 12px;
color: #ffb0b0;
text-align: center;
}
.near-values { .near-values {
list-style: none; list-style: none;
margin: 0; margin: 0;
......
...@@ -14,12 +14,15 @@ import AppIcon from '@/components/icons/AppIcon.vue' ...@@ -14,12 +14,15 @@ import AppIcon from '@/components/icons/AppIcon.vue'
import MarkerNearDialog from '@/components/scene/MarkerNearDialog.vue' import MarkerNearDialog from '@/components/scene/MarkerNearDialog.vue'
import { api } from '@/api/http' import { api } from '@/api/http'
import { import {
MARKER_LEADER_HEIGHT_M,
preloadRtengineIcons, preloadRtengineIcons,
useRtengineBridge, useRtengineBridge,
type SceneMarkerHit, type SceneMarkerHit,
type ScenePoint, type ScenePoint,
} from '@/composables/useRtengineBridge' } from '@/composables/useRtengineBridge'
import { useScreenModePois } from '@/composables/useScreenModePois' import { useScreenModePois } from '@/composables/useScreenModePois'
import { useAuthStore } from '@/stores/auth'
import type { SensorSnapshot } from '@/types'
import type { VideoDevice } from '@/types/video' import type { VideoDevice } from '@/types/video'
const props = withDefaults( const props = withDefaults(
...@@ -33,19 +36,20 @@ const props = withDefaults( ...@@ -33,19 +36,20 @@ const props = withDefaults(
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
const screenMode = useScreenModeStore() const screenMode = useScreenModeStore()
const auth = useAuthStore()
const { mode: activeMode } = storeToRefs(screenMode) const { mode: activeMode } = storeToRefs(screenMode)
type ViewportMode = 'glb' | 'ue-stream' | 'rtengine' type ViewportMode = 'glb' | 'ue-stream' | 'rtengine'
const mode = (import.meta.env.VITE_VIEWPORT_MODE || 'rtengine') as ViewportMode const mode = (import.meta.env.VITE_VIEWPORT_MODE || 'rtengine') as ViewportMode
/** /**
* RTEngineHD 云渲染地址(默认同域 /rtengine 反代,便于 postMessage)。 * RTEngineHD 云渲染地址(默认同域 /gis 反代,便于 postMessage)。
* 信令走 /peer-stream、书签走 /designer(见 vite/nginx 代理); * 信令走 /peer-stream、书签走 /designer(见 vite/nginx 代理);
* WebRTC 媒体仍由 ICE 直连引擎机,一般不黑屏。 * WebRTC 媒体仍由 ICE 直连引擎机,一般不黑屏。
*/ */
const cloudUrl = const cloudUrl =
(import.meta.env.VITE_RTENGINE_URL || '').trim() || (import.meta.env.VITE_RTENGINE_URL || '').trim() ||
(import.meta.env.VITE_UE_STREAM_URL || '').trim() || (import.meta.env.VITE_UE_STREAM_URL || '').trim() ||
'/rtengine/gis.html?scene=mvlpj2yt6379aa29' '/gis/gis.html?scene=mvlpj2yt6379aa29'
const useCloudFrame = computed( const useCloudFrame = computed(
() => () =>
...@@ -62,9 +66,13 @@ const cameras = ref<VideoDevice[]>([]) ...@@ -62,9 +66,13 @@ const cameras = ref<VideoDevice[]>([])
/** Marker 附近弹窗(400×240) */ /** Marker 附近弹窗(400×240) */
const nearOpen = ref(false) const nearOpen = ref(false)
const nearTitle = ref('监控预览') const nearTitle = ref('监控预览')
const nearKind = ref<'video' | 'values'>('video') const nearKind = ref<'video' | 'values' | 'switch'>('video')
const nearLines = ref<{ label: string; value: string }[]>([]) const nearLines = ref<{ label: string; value: string }[]>([])
const nearHit = ref<SceneMarkerHit | null>(null) const nearHit = ref<SceneMarkerHit | null>(null)
const nearSensorId = ref<string | null>(null)
const nearSwitchOn = ref<boolean | null>(null)
const nearSwitching = ref(false)
const nearSwitchError = ref('')
const nearLeft = ref(120) const nearLeft = ref(120)
const nearTop = ref(120) const nearTop = ref(120)
/** 关闭后短时忽略 Marker 点击,防止卸层点击穿透再次打开 */ /** 关闭后短时忽略 Marker 点击,防止卸层点击穿透再次打开 */
...@@ -289,10 +297,71 @@ function openNearDialog(title: string, markerId: string | null, raw: unknown) { ...@@ -289,10 +297,71 @@ function openNearDialog(title: string, markerId: string | null, raw: unknown) {
function closeNearDialog() { function closeNearDialog() {
nearOpen.value = false nearOpen.value = false
nearSensorId.value = null
nearSwitchError.value = ''
nearSwitching.value = false
nearCloseGuardUntil = Date.now() + 400 nearCloseGuardUntil = Date.now() + 400
lastHitPagePos.value = null lastHitPagePos.value = null
} }
function findLightSensor(marker: SceneMarkerHit, markerId: string | null): SensorSnapshot | null {
const sensors = realtime.snapshot.sensors || []
const keys = [marker.tag1, markerId, marker.id]
.map((k) => String(k || '').replace(/^sm:sensor:/i, '').trim())
.filter(Boolean)
for (const key of keys) {
const hit = sensors.find(
(s) =>
(s.category === 'lightN' || s.category === 'lightE') &&
(s.id === key || s.code === key),
)
if (hit) return hit
}
const name = (marker.name || '').trim()
if (name) {
const byName = sensors.find(
(s) =>
(s.category === 'lightN' || s.category === 'lightE') &&
(s.name === name || s.name.includes(name) || name.includes(s.name)),
)
if (byName) return byName
}
// 兼容旧 MODE_POIS:tag1 = ln-seq / lightId = n-seq
const tag = String(marker.tag1 || '').trim()
if (tag) {
const lightId = tag.replace(/^l[ne]-/, (m) => (m === 'ln-' ? 'n-' : 'e-'))
const byCode = sensors.find(
(s) =>
(s.category === 'lightN' || s.category === 'lightE') &&
(s.code === tag || s.code === lightId),
)
if (byCode) return byCode
}
return null
}
async function onLightControl(on: boolean) {
if (!nearSensorId.value) return
if (!auth.token) {
nearSwitchError.value = '请先登录管理员账号后再控制'
return
}
nearSwitching.value = true
nearSwitchError.value = ''
try {
await api(`/api/sensors/${nearSensorId.value}/control`, {
method: 'POST',
token: auth.token,
body: JSON.stringify({ on }),
})
nearSwitchOn.value = on
} catch (e) {
nearSwitchError.value = e instanceof Error ? e.message : '控制失败'
} finally {
nearSwitching.value = false
}
}
function cameraMarkerList() { function cameraMarkerList() {
return cameras.value return cameras.value
.filter((d) => d.enabled && d.scenePoint) .filter((d) => d.enabled && d.scenePoint)
...@@ -306,6 +375,10 @@ function cameraMarkerList() { ...@@ -306,6 +375,10 @@ function cameraMarkerList() {
icon: d.scenePoint!.icon || 'cctv-blue.png', icon: d.scenePoint!.icon || 'cctv-blue.png',
tag0: 'camera', tag0: 'camera',
tag1: d.id, tag1: d.id,
leader: true,
leaderHeight: MARKER_LEADER_HEIGHT_M,
leaderThickness: 2,
leaderColor: '#00f7ff',
})) }))
} }
...@@ -438,13 +511,25 @@ function onMarkerClick(payload: { ...@@ -438,13 +511,25 @@ function onMarkerClick(payload: {
if (category === 'camera' || cam) { if (category === 'camera' || cam) {
nearKind.value = 'video' nearKind.value = 'video'
nearLines.value = [] nearLines.value = []
nearSensorId.value = null
openNearDialog(resolveNearTitle(cam, marker, markerId), markerId || marker.id, raw) openNearDialog(resolveNearTitle(cam, marker, markerId), markerId || marker.id, raw)
return return
} }
const light = findLightSensor(marker, markerId)
if (light || category === 'lightN' || category === 'lightE') {
nearKind.value = 'switch'
nearLines.value = []
nearSensorId.value = light?.id || light?.code || null
nearSwitchOn.value = light?.on ?? null
nearSwitchError.value = light ? '' : '未找到对应照明设施,请检查设施管理绑定'
openNearDialog(light?.name || marker.name || '照明控制', markerId || marker.id, raw)
return
}
const readout = markerReadout(marker, realtime.snapshot) const readout = markerReadout(marker, realtime.snapshot)
if (!readout) return if (!readout) return
nearKind.value = 'values' nearKind.value = 'values'
nearLines.value = readout.lines nearLines.value = readout.lines
nearSensorId.value = null
openNearDialog(readout.title, markerId || marker.id, raw) openNearDialog(readout.title, markerId || marker.id, raw)
} }
...@@ -602,7 +687,11 @@ function onSelectOverview() { ...@@ -602,7 +687,11 @@ function onSelectOverview() {
} }
function onSelectMode(next: ScreenMode) { function onSelectMode(next: ScreenMode) {
// 右侧设备分类:只切换场景图标类型(压差/照明/冰箱…)
screenMode.setMode(next) screenMode.setMode(next)
applyCameraVisibility()
// 分类切换后补一次,避免引擎异步过滤把新点藏掉
window.setTimeout(() => applyCameraVisibility(), 300)
} }
watch(activeMode, () => applyCameraVisibility()) watch(activeMode, () => applyCameraVisibility())
...@@ -610,7 +699,15 @@ watch(activeMode, () => applyCameraVisibility()) ...@@ -610,7 +699,15 @@ watch(activeMode, () => applyCameraVisibility())
watch( watch(
() => realtime.snapshot.ts, () => realtime.snapshot.ts,
() => { () => {
if (!nearOpen.value || nearKind.value !== 'values' || !nearHit.value) return if (!nearOpen.value || !nearHit.value) return
if (nearKind.value === 'switch' && nearSensorId.value) {
const s = (realtime.snapshot.sensors || []).find(
(x) => x.id === nearSensorId.value || x.code === nearSensorId.value,
)
if (s && !nearSwitching.value) nearSwitchOn.value = s.on ?? null
return
}
if (nearKind.value !== 'values') return
const readout = markerReadout(nearHit.value, realtime.snapshot) const readout = markerReadout(nearHit.value, realtime.snapshot)
if (readout) nearLines.value = readout.lines if (readout) nearLines.value = readout.lines
}, },
...@@ -777,10 +874,14 @@ onBeforeUnmount(() => { ...@@ -777,10 +874,14 @@ onBeforeUnmount(() => {
:title="nearTitle" :title="nearTitle"
:kind="nearKind" :kind="nearKind"
:lines="nearLines" :lines="nearLines"
:switch-on="nearSwitchOn"
:switching="nearSwitching"
:switch-error="nearSwitchError"
:left="nearLeft" :left="nearLeft"
:top="nearTop" :top="nearTop"
src="/cams/lab-seq.mp4" src="/cams/lab-seq.mp4"
@close="closeNearDialog" @close="closeNearDialog"
@control="onLightControl"
/> />
</div> </div>
</template> </template>
......
...@@ -3,17 +3,63 @@ import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue' ...@@ -3,17 +3,63 @@ import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { import {
defaultRtengineUrl, defaultRtengineUrl,
fetchRtengineIcons, fetchRtengineIcons,
MARKER_LEADER_HEIGHT_M,
preloadRtengineIcons, preloadRtengineIcons,
rtengineIconUrl, rtengineIconUrl,
useRtengineBridge, useRtengineBridge,
type RtengineMarker,
type ScenePoint, type ScenePoint,
} from '@/composables/useRtengineBridge' } from '@/composables/useRtengineBridge'
const props = defineProps<{ const PREVIEW_ID = '__pick_preview__'
const props = withDefaults(
defineProps<{
open: boolean open: boolean
title?: string title?: string
initial?: ScenePoint | null initial?: ScenePoint | null
}>() /** 默认拾取提示 */
pickHint?: string
/** 默认图标文件名 */
defaultIcon?: string
/** 图标排序优先列表 */
preferredIcons?: string[]
/**
* 预览样式(与大屏一致):
* - marker:图标 + 可选文字
* - label:仅文字(压差等)
*/
markerType?: 'marker' | 'label'
/** 预览文案;空则用 title / 设施名 */
previewText?: string
/** 是否显示文字;摄像头/门禁通常为 false */
showText?: boolean
/** 仅文字:隐藏图标选择,落点不带图标 */
textOnly?: boolean
/** 预览标记名称 */
previewName?: string
}>(),
{
pickHint: '场景加载后,点击地面拾取位置',
defaultIcon: 'cctv-blue.png',
preferredIcons: () => [
'cctv-blue.png',
'cctv-green.png',
'cctv-orange.png',
'cctv-red.png',
'cctv.png',
'ai-camera-blue.png',
'ai-camera-green.png',
'ai-camera-orange.png',
'ai-camera-red.png',
],
markerType: 'marker',
previewText: '',
showText: true,
textOnly: false,
previewName: '打点预览',
},
)
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
...@@ -23,25 +69,16 @@ const emit = defineEmits<{ ...@@ -23,25 +69,16 @@ const emit = defineEmits<{
const cloudUrl = defaultRtengineUrl() const cloudUrl = defaultRtengineUrl()
const iframeRef = ref<HTMLIFrameElement | null>(null) const iframeRef = ref<HTMLIFrameElement | null>(null)
const picked = ref<ScenePoint | null>(props.initial ? { ...props.initial } : null) const picked = ref<ScenePoint | null>(props.initial ? { ...props.initial } : null)
const selectedIcon = ref(props.initial?.icon || 'cctv-blue.png') const selectedIcon = ref(props.textOnly ? '' : props.initial?.icon || props.defaultIcon)
const icons = ref<string[]>([]) const icons = ref<string[]>([])
const iconError = ref('') const iconError = ref('')
const iconFilter = ref('') const iconFilter = ref('')
const hint = ref('场景加载后,点击地面拾取摄像头位置') const hint = ref(props.pickHint)
const preferred = [ const isTextOnly = computed(() => props.textOnly || props.markerType === 'label')
'cctv-blue.png',
'cctv-green.png',
'cctv-orange.png',
'cctv-red.png',
'cctv.png',
'ai-camera-blue.png',
'ai-camera-green.png',
'ai-camera-orange.png',
'ai-camera-red.png',
]
const filteredIcons = computed(() => { const filteredIcons = computed(() => {
const preferred = props.preferredIcons || []
const q = iconFilter.value.trim().toLowerCase() const q = iconFilter.value.trim().toLowerCase()
let list = icons.value.slice() let list = icons.value.slice()
list.sort((a, b) => { list.sort((a, b) => {
...@@ -59,31 +96,86 @@ const bridge = useRtengineBridge({ ...@@ -59,31 +96,86 @@ const bridge = useRtengineBridge({
cloudUrl, cloudUrl,
onMouseClick: ({ point, markerId }) => { onMouseClick: ({ point, markerId }) => {
if (point) { if (point) {
picked.value = { ...point, icon: selectedIcon.value } const icon = isTextOnly.value ? '' : selectedIcon.value
picked.value = { ...point, icon }
hint.value = markerId hint.value = markerId
? `已拾取(对象 ${markerId})` ? `已拾取(对象 ${markerId})· 效果已按设备类型预览`
: `已拾取 X=${point.x.toFixed(6)} Y=${point.y.toFixed(6)} Z=${point.z.toFixed(2)}` : `已拾取 X=${point.x.toFixed(6)} Y=${point.y.toFixed(6)} Z=${point.z.toFixed(2)}`
syncPreview()
} }
}, },
onReady: () => { onReady: () => {
hint.value = '引擎已就绪 · 正在加载预设图标…' hint.value = isTextOnly.value
? '引擎已就绪 · 点击地面放置文字标签'
: '引擎已就绪 · 正在加载预设图标…'
bridge.setPickMode(true) bridge.setPickMode(true)
bridge.showScenePresets() bridge.showScenePresets()
void loadIcons(true) void loadIcons(true)
if (picked.value) syncPreview()
}, },
}) })
function previewMarker(): RtengineMarker | null {
const p = picked.value
if (!p) return null
const textOnly = isTextOnly.value
const text = (props.previewText || props.previewName || props.title || '预览').trim()
return {
id: PREVIEW_ID,
name: props.previewName || props.title || '打点预览',
text,
showText: textOnly ? true : props.showText,
type: textOnly ? 'label' : props.markerType || 'marker',
x: p.x,
y: p.y,
z: p.z,
tag0: 'pick-preview',
tag1: PREVIEW_ID,
scale: 1,
icon: textOnly ? '' : (selectedIcon.value || '').trim(),
leader: true,
leaderHeight: MARKER_LEADER_HEIGHT_M,
leaderThickness: 2,
leaderColor: '#00f7ff',
}
}
function syncPreview() {
const m = previewMarker()
if (!m || !bridge.ready.value) return
bridge.updateMarker(m)
}
function clearPreview() {
try {
bridge.removeMarker(PREVIEW_ID)
} catch {
/* ignore */
}
}
async function loadIcons(fromEngine = false) { async function loadIcons(fromEngine = false) {
if (isTextOnly.value) {
icons.value = []
selectedIcon.value = ''
if (fromEngine) hint.value = '已就绪 · 点击地面放置文字标签(不显示图标)'
return
}
iconError.value = '' iconError.value = ''
try { try {
icons.value = fromEngine icons.value = fromEngine
? await preloadRtengineIcons(undefined, cloudUrl) ? await preloadRtengineIcons(undefined, cloudUrl)
: await fetchRtengineIcons(cloudUrl) : await fetchRtengineIcons(cloudUrl)
if (!icons.value.includes(selectedIcon.value) && icons.value.length) { if (!icons.value.includes(selectedIcon.value) && icons.value.length) {
const cam = icons.value.find((f) => /cctv|camera/i.test(f)) const preferred = props.preferredIcons || []
selectedIcon.value = cam || icons.value[0] const hit = preferred.find((f) => icons.value.includes(f))
const cam = icons.value.find((f) =>
/cctv|camera|speedometer|temperature|motor|electricity|access/i.test(f),
)
selectedIcon.value = hit || cam || icons.value[0]
} }
if (fromEngine) hint.value = `已加载 ${icons.value.length} 个场景预设图标 · 点击地面拾取` if (fromEngine) hint.value = `已加载 ${icons.value.length} 个场景预设图标 · 点击地面拾取`
syncPreview()
} catch (e) { } catch (e) {
iconError.value = e instanceof Error ? e.message : '图标列表加载失败' iconError.value = e instanceof Error ? e.message : '图标列表加载失败'
} }
...@@ -94,38 +186,61 @@ watch( ...@@ -94,38 +186,61 @@ watch(
(v) => { (v) => {
if (v) { if (v) {
picked.value = props.initial ? { ...props.initial } : null picked.value = props.initial ? { ...props.initial } : null
selectedIcon.value = props.initial?.icon || 'cctv-blue.png' selectedIcon.value = isTextOnly.value
hint.value = '场景加载后,点击地面拾取摄像头位置' ? ''
: props.initial?.icon || props.defaultIcon
hint.value = props.pickHint
void loadIcons() void loadIcons()
window.setTimeout(() => bridge.setPickMode(true), 1500) window.setTimeout(() => {
bridge.setPickMode(true)
syncPreview()
}, 1500)
} else { } else {
clearPreview()
bridge.setPickMode(false) bridge.setPickMode(false)
} }
}, },
) )
watch(selectedIcon, (icon) => { watch(selectedIcon, (icon) => {
if (picked.value) picked.value = { ...picked.value, icon } if (isTextOnly.value) return
if (picked.value) {
picked.value = { ...picked.value, icon }
syncPreview()
}
}) })
watch(
() => [props.previewText, props.markerType, props.showText, props.textOnly] as const,
() => syncPreview(),
)
onMounted(() => { onMounted(() => {
if (props.open) { if (props.open) {
void loadIcons() void loadIcons()
window.setTimeout(() => bridge.setPickMode(true), 1500) window.setTimeout(() => {
bridge.setPickMode(true)
syncPreview()
}, 1500)
} }
}) })
onBeforeUnmount(() => bridge.setPickMode(false)) onBeforeUnmount(() => {
clearPreview()
bridge.setPickMode(false)
})
function confirm() { function confirm() {
if (!picked.value) { if (!picked.value) {
hint.value = '请先在场景中点击拾取位置' hint.value = '请先在场景中点击拾取位置'
return return
} }
emit('save', { ...picked.value, icon: selectedIcon.value }) const icon = isTextOnly.value ? '' : selectedIcon.value
emit('save', { ...picked.value, icon })
} }
function clearPoint() { function clearPoint() {
clearPreview()
picked.value = null picked.value = null
hint.value = '已清除,请重新点击拾取' hint.value = '已清除,请重新点击拾取'
} }
...@@ -137,14 +252,14 @@ function iconSrc(file: string) { ...@@ -137,14 +252,14 @@ function iconSrc(file: string) {
<template> <template>
<div v-if="open" class="mask" @click.self="emit('close')"> <div v-if="open" class="mask" @click.self="emit('close')">
<div class="dialog"> <div class="dialog" :class="{ 'no-icons': isTextOnly }">
<div class="head"> <div class="head">
<h3>{{ title || '三维场景打点' }}</h3> <h3>{{ title || '三维场景打点' }}</h3>
<button class="btn ghost" type="button" @click="emit('close')">关闭</button> <button class="btn ghost" type="button" @click="emit('close')">关闭</button>
</div> </div>
<p class="hint">{{ hint }}</p> <p class="hint">{{ hint }}</p>
<div class="body"> <div class="body">
<aside class="icon-pane"> <aside v-if="!isTextOnly" class="icon-pane">
<div class="icon-head"> <div class="icon-head">
<span>引擎图标</span> <span>引擎图标</span>
<input v-model="iconFilter" class="icon-search" placeholder="筛选…" /> <input v-model="iconFilter" class="icon-search" placeholder="筛选…" />
...@@ -170,6 +285,13 @@ function iconSrc(file: string) { ...@@ -170,6 +285,13 @@ function iconSrc(file: string) {
<b>{{ selectedIcon || '—' }}</b> <b>{{ selectedIcon || '—' }}</b>
</div> </div>
</aside> </aside>
<aside v-else class="icon-pane text-only-pane">
<div class="icon-head"><span>显示效果</span></div>
<p class="text-only-tip">
本类型为大屏文字标签:场景中只显示读数文字,不挂图标。点击地面即可预览落点效果。
</p>
<pre class="text-only-sample">{{ previewText || '—℃\n—%\n—Pa' }}</pre>
</aside>
<div class="frame-wrap"> <div class="frame-wrap">
<iframe <iframe
ref="iframeRef" ref="iframeRef"
...@@ -340,6 +462,27 @@ function iconSrc(file: string) { ...@@ -340,6 +462,27 @@ function iconSrc(file: string) {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.text-only-pane {
padding-bottom: 12px;
}
.text-only-tip {
margin: 12px;
font-size: 12px;
line-height: 1.55;
color: #7eb6d6;
}
.text-only-sample {
margin: 0 12px;
padding: 12px;
border-radius: 6px;
border: 1px solid rgba(0, 247, 255, 0.28);
background: rgba(0, 40, 72, 0.45);
color: #00f7ff;
font-family: var(--digital-font, ui-monospace, monospace);
font-size: 14px;
line-height: 1.45;
white-space: pre-wrap;
}
.frame-wrap { .frame-wrap {
min-height: 0; min-height: 0;
background: #000; background: #000;
......
...@@ -2,6 +2,9 @@ import { onBeforeUnmount, ref, type Ref } from 'vue' ...@@ -2,6 +2,9 @@ import { onBeforeUnmount, ref, type Ref } from 'vue'
export type ScenePoint = { x: number; y: number; z: number; icon?: string } export type ScenePoint = { x: number; y: number; z: number; icon?: string }
/** 默认引线高度(米):图标相对地面锚点抬升 */
export const MARKER_LEADER_HEIGHT_M = 2
export type RtengineMarker = { export type RtengineMarker = {
id: string id: string
name: string name: string
...@@ -16,11 +19,17 @@ export type RtengineMarker = { ...@@ -16,11 +19,17 @@ export type RtengineMarker = {
scale?: number scale?: number
/** 引擎图标文件名,如 cctv-blue.png */ /** 引擎图标文件名,如 cctv-blue.png */
icon?: string icon?: string
/** 是否显示地面引线;默认 true */
leader?: boolean
/** 引线高度(米) */
leaderHeight?: number
leaderThickness?: number
leaderColor?: string
} }
/** 默认走同域 /rtengine 反代,避免 iframe postMessage 跨域 */ /** 默认走同域 /gis 反代,避免 iframe postMessage 跨域 */
export const RTENGINE_SCENE_QUERY = 'scene=mvlpj2yt6379aa29' export const RTENGINE_SCENE_QUERY = 'scene=mvlpj2yt6379aa29'
export const RTENGINE_PROXY_PATH = '/rtengine' export const RTENGINE_PROXY_PATH = '/gis'
export function defaultRtengineUrl() { export function defaultRtengineUrl() {
return ( return (
...@@ -39,8 +48,8 @@ export function rtengineOrigin(cloudUrl = defaultRtengineUrl()) { ...@@ -39,8 +48,8 @@ export function rtengineOrigin(cloudUrl = defaultRtengineUrl()) {
} }
/** /**
* 引擎资源前缀:同域反代时为 /rtengine,直连 6088 时为空。 * 引擎资源前缀:同域反代时为 /gis,直连 6088 时为空。
* 注意:不能把图标打到本站 /api(会撞业务 API)。 * 注意:图标请走 /gis/api/icons 或专用 /api/icons 反代,勿撞业务 /api。
*/ */
export function rtengineAssetPrefix(cloudUrl = defaultRtengineUrl()) { export function rtengineAssetPrefix(cloudUrl = defaultRtengineUrl()) {
try { try {
...@@ -48,6 +57,10 @@ export function rtengineAssetPrefix(cloudUrl = defaultRtengineUrl()) { ...@@ -48,6 +57,10 @@ export function rtengineAssetPrefix(cloudUrl = defaultRtengineUrl()) {
if (u.pathname.startsWith(`${RTENGINE_PROXY_PATH}/`) || u.pathname === RTENGINE_PROXY_PATH) { if (u.pathname.startsWith(`${RTENGINE_PROXY_PATH}/`) || u.pathname === RTENGINE_PROXY_PATH) {
return RTENGINE_PROXY_PATH return RTENGINE_PROXY_PATH
} }
// 兼容旧 /rtengine 前缀
if (u.pathname.startsWith('/rtengine/') || u.pathname === '/rtengine') {
return '/rtengine'
}
// 直连引擎机:资源挂在 origin 根路径 // 直连引擎机:资源挂在 origin 根路径
return '' return ''
} catch { } catch {
...@@ -317,7 +330,7 @@ export function useRtengineBridge(opts: { ...@@ -317,7 +330,7 @@ export function useRtengineBridge(opts: {
} }
if (!sceneId) return if (!sceneId) return
const urls = [ const urls = [
`/rtengine/designer/data/scenes/${encodeURIComponent(sceneId)}.json`, `/gis/designer/data/scenes/${encodeURIComponent(sceneId)}.json`,
`/designer/data/scenes/${encodeURIComponent(sceneId)}.json`, `/designer/data/scenes/${encodeURIComponent(sceneId)}.json`,
`/designer/api/embed/scenes/${encodeURIComponent(sceneId)}`, `/designer/api/embed/scenes/${encodeURIComponent(sceneId)}`,
] ]
...@@ -378,22 +391,40 @@ export function useRtengineBridge(opts: { ...@@ -378,22 +391,40 @@ export function useRtengineBridge(opts: {
function markerPayload(m: RtengineMarker) { function markerPayload(m: RtengineMarker) {
const icon = (m.icon || '').trim() const icon = (m.icon || '').trim()
const scale = m.scale ?? 1
const leader = m.leader !== false
const leaderHeight =
m.leaderHeight != null && Number(m.leaderHeight) > 0
? Number(m.leaderHeight)
: MARKER_LEADER_HEIGHT_M
return { return {
id: m.id, id: m.id,
name: m.name, name: m.name,
text: m.text ?? m.name, text: m.text ?? m.name,
showText: m.showText !== false, showText: m.showText !== false,
type: m.type || 'marker', type: m.type || 'marker',
// 引擎同时认 x/y/z 与 lng/lat/height
x: m.x, x: m.x,
y: m.y, y: m.y,
z: m.z, z: m.z,
scale: m.scale ?? 1, lng: m.x,
scalex: m.scale ?? 1, lat: m.y,
scaley: m.scale ?? 1, height: m.z,
scalez: m.scale ?? 1, longitude: m.x,
latitude: m.y,
scale,
scalex: scale,
scaley: scale,
scalez: scale,
tag0: m.tag0 || 'camera', tag0: m.tag0 || 'camera',
tag1: m.tag1 || m.id, tag1: m.tag1 || m.id,
...(icon ? { icon, url: icon } : {}), // 地面锚点 → 图标引线(引擎 leader / leaderHeight)
leader,
leaderHeight,
leaderThickness: m.leaderThickness ?? 2,
leaderColor: m.leaderColor || '#00f7ff',
// label / 空 icon:不传 url,避免引擎回落默认图标
...(icon ? { icon, url: icon } : { icon: '' }),
} }
} }
...@@ -401,11 +432,9 @@ export function useRtengineBridge(opts: { ...@@ -401,11 +432,9 @@ export function useRtengineBridge(opts: {
send('addMarker', markerPayload(m)) send('addMarker', markerPayload(m))
} }
/** 引擎仅有 addMarker,无 addMarkers;批量时逐条写入 */
function addMarkers(list: RtengineMarker[]) { function addMarkers(list: RtengineMarker[]) {
send( for (const m of list) addMarker(m)
'addMarkers',
list.map((m) => markerPayload(m)),
)
} }
function removeMarker(id: string) { function removeMarker(id: string) {
......
import { watch, type Ref } from 'vue' import { watch, type Ref } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useRealtimeStore } from '@/stores/realtime'
import { useScreenModeStore } from '@/stores/screenMode' import { useScreenModeStore } from '@/stores/screenMode'
import { DEVICE_TAG, MODE_POIS, categoryOfMarker, type ScreenMode } from '@/data/screenModePois' import {
DEVICE_TAG,
MODE_MARKER_ICON,
MODE_MARKER_SCALE,
MODE_POIS,
categoryOfMarker,
fmtNum,
poisForMode,
type ModePoiDef,
type ScreenMode,
} from '@/data/screenModePois'
import {
MARKER_LEADER_HEIGHT_M,
type RtengineMarker,
} from '@/composables/useRtengineBridge'
import type { RealtimeSnapshot, SensorSnapshot } from '@/types'
const MARKER_LEADER = {
leader: true as const,
leaderHeight: MARKER_LEADER_HEIGHT_M,
leaderThickness: 2,
leaderColor: '#00f7ff',
}
type BridgeLike = { type BridgeLike = {
ready: Ref<boolean> ready: Ref<boolean>
addMarker: (m: RtengineMarker) => void
addMarkers: (list: RtengineMarker[]) => void
removeMarker: (id: string) => void
updateMarker: (m: RtengineMarker) => void
setMarkerVisible: (id: string, visible: boolean) => void setMarkerVisible: (id: string, visible: boolean) => void
showPois: (tag0?: string) => void showPois: (tag0?: string) => void
hidePois: (tag0?: string) => void hidePois: (tag0?: string) => void
removeMarker: (id: string) => void listMarkers?: () => { id: string; name: string; tag0: string; icon: string }[]
listMarkers?: () => SceneMarkerRef[]
} }
type SceneMarkerRef = { const ALL_DEVICE_TAGS = [...new Set(Object.values(DEVICE_TAG))]
id: string
name: string
icon: string
tag0: string
}
const PREFIX = 'sm:' const PREFIX = 'sm:'
const SENSOR_PREFIX = 'sm:sensor:'
function sceneIdFromUrl(cloudUrl: string): string { function poiMarkerId(def: ModePoiDef) {
try { return `${PREFIX}${def.id}`
return new URL(cloudUrl, window.location.href).searchParams.get('scene') || '' }
} catch {
return '' function sensorMarkerId(s: SensorSnapshot) {
return `${SENSOR_PREFIX}${s.id}`
}
function buildText(def: ModePoiDef, snap: RealtimeSnapshot): string {
const bind = def.bind
if (bind.kind === 'room') {
const r = snap.rooms.find((x) => x.id === bind.roomId)
return `${fmtNum(r?.temp)}℃\n${fmtNum(r?.humidity, 0)}%\n${fmtNum(r?.pressure)}Pa`
} }
if (bind.kind === 'fridge') {
const f = snap.fridges.find((x) => x.id === bind.deviceId)
return `${fmtNum(f?.temp)}℃\n${fmtNum(f?.humidity, 0)}%`
}
if (bind.kind === 'vav') {
const v = snap.vav.find((x) => x.id === bind.deviceId)
const run = v?.running ? '运行' : '停止'
return `状态:${run}\n面风速:${fmtNum(v?.faceVelocity)}m/s\n风量:${fmtNum(v?.airflow, 0)}`
}
if (bind.kind === 'neLight') {
const lit = (snap.neLights || []).find((x) => x.id === bind.lightId)
return `状态:${lit?.on ? '开' : '关'}`
}
return def.label
} }
/** 读取场景里已打好的 Marker,按名称分类后显隐,避免再叠一套运行时点 */ function sensorText(s: SensorSnapshot): string {
export async function fetchSceneMarkers(cloudUrl: string): Promise<SceneMarkerRef[]> { if (s.category === 'lightN' || s.category === 'lightE') {
const sceneId = sceneIdFromUrl(cloudUrl) return s.on == null ? '—' : s.on ? '开' : '关'
if (!sceneId) return []
const urls = [
`/rtengine/designer/data/scenes/${encodeURIComponent(sceneId)}.json`,
`/designer/data/scenes/${encodeURIComponent(sceneId)}.json`,
`/designer/api/embed/scenes/${encodeURIComponent(sceneId)}`,
`/rtengine/api/scenes/${encodeURIComponent(sceneId)}`,
]
for (const url of urls) {
try {
const res = await fetch(url)
if (!res.ok) continue
const json = (await res.json()) as { components?: unknown[] }
const list = Array.isArray(json.components) ? json.components : []
const markers: SceneMarkerRef[] = []
for (const item of list) {
if (!item || typeof item !== 'object') continue
const c = item as { id?: unknown; type?: unknown; name?: unknown; params?: Record<string, unknown> }
if (c.type !== 'marker' || c.id == null) continue
const params = c.params || {}
markers.push({
id: String(c.id),
name: String(c.name || params.text || params.name || ''),
icon: String(params.icon || params.url || ''),
tag0: String(params.tag0 || ''),
})
}
if (markers.length) return markers
} catch {
/* try next */
} }
if (s.category === 'fridge') {
const v = s.values || { temp: null, humidity: null, pressure: null }
return `${fmtNum(v.temp)}℃\n${fmtNum(v.humidity, 0)}%`
} }
if (s.category === 'vav') {
const run = s.on == null ? '—' : s.on ? '运行' : '停止'
return `状态:${run}\n面风速:${fmtNum(s.vav?.faceVelocity)}m/s\n风量:${fmtNum(s.vav?.airflow, 0)}`
}
if (s.category === 'camera' || s.category === 'access') {
return s.name
}
const v = s.values || { temp: null, humidity: null, pressure: null }
return `${fmtNum(v.temp)}℃\n${fmtNum(v.humidity, 0)}%\n${fmtNum(v.pressure)}Pa`
}
function toPoiMarker(def: ModePoiDef, snap: RealtimeSnapshot): RtengineMarker {
const textOnly = def.mode === 'pressure'
return {
id: poiMarkerId(def),
name: def.label,
text: buildText(def, snap),
showText: true,
type: textOnly ? 'label' : 'marker',
x: def.x,
y: def.y,
z: def.z,
tag0: DEVICE_TAG[def.mode],
tag1: def.id,
scale: MODE_MARKER_SCALE,
...(textOnly ? {} : { icon: MODE_MARKER_ICON[def.mode] }),
...MARKER_LEADER,
}
}
const FACILITY_ICON_FALLBACK: Record<string, string> = {
...MODE_MARKER_ICON,
camera: 'cctv-blue.png',
access: 'access-control-blue.png',
}
function toSensorMarker(s: SensorSnapshot): RtengineMarker {
const fallback = FACILITY_ICON_FALLBACK[s.category] || MODE_MARKER_ICON.pressure
const tag0 =
s.category in DEVICE_TAG
? DEVICE_TAG[s.category as keyof typeof DEVICE_TAG]
: s.category || 'pressure'
const isCamOrDoor = s.category === 'camera' || s.category === 'access'
const textOnly = s.category === 'pressure'
return {
id: sensorMarkerId(s),
name: s.name,
text: sensorText(s),
showText: !isCamOrDoor,
type: textOnly ? 'label' : 'marker',
x: s.scene.x,
y: s.scene.y,
z: s.scene.z,
tag0,
tag1: s.id,
scale: MODE_MARKER_SCALE,
// 压差:只显示温湿度压差文字,不挂图标
...(textOnly ? { icon: '' } : { icon: (s.icon || '').trim() || fallback }),
...MARKER_LEADER,
}
}
function sensorsOf(
snap: RealtimeSnapshot,
...cats: SensorSnapshot['category'][]
): SensorSnapshot[] {
return (snap.sensors || []).filter((s) => cats.includes(s.category))
}
const SENSOR_MODES = new Set([
'pressure',
'lightN',
'lightE',
'fridge',
'vav',
'camera',
'access',
])
/** 主要分类走设施快照;监控仍可由 SceneViewport 叠加视频台账点 */
function markersForMode(mode: ScreenMode, snap: RealtimeSnapshot): RtengineMarker[] {
if (mode === 'pressure') {
return sensorsOf(snap, 'pressure').map(toSensorMarker)
}
if (mode === 'lightN') {
return sensorsOf(snap, 'lightN').map(toSensorMarker)
}
if (mode === 'lightE') {
return sensorsOf(snap, 'lightE').map(toSensorMarker)
}
if (mode === 'fridge') {
return sensorsOf(snap, 'fridge').map(toSensorMarker)
}
if (mode === 'vav') {
return sensorsOf(snap, 'vav').map(toSensorMarker)
}
// 监控点由 SceneViewport 视频台账打点,避免与设施重复
if (mode === 'camera') {
return [] return []
}
if (mode === 'access') {
return sensorsOf(snap, 'access').map(toSensorMarker)
}
if (mode === 'home') {
return sensorsOf(snap, 'pressure', 'lightN', 'lightE', 'fridge', 'vav', 'access').map(
toSensorMarker,
)
}
return poisForMode(mode).map((d) => toPoiMarker(d, snap))
} }
/** /**
* 全景总览显示全部设备图标;点某一分类只留该类,其余隐藏。 * 大屏分类 POI:右侧点选分类 → 场景只保留对应设施图标。
* 点位使用场景内已保存的 Marker,不再按模式重复 addMarker。 * 先 hide 其它 tag,再 add 当前类,最后 show 当前 tag 并强制 setMarkerVisible。
*/ */
export function useScreenModePois( export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cloudUrl: string) {
bridge: BridgeLike,
enabled: Ref<boolean>,
cloudUrl: string,
) {
const modeStore = useScreenModeStore() const modeStore = useScreenModeStore()
const realtime = useRealtimeStore()
const { mode } = storeToRefs(modeStore) const { mode } = storeToRefs(modeStore)
let sceneMarkers: SceneMarkerRef[] = [] let activeIds: string[] = []
let loaded = false let lastSig = ''
let loading = false let lastMode: ScreenMode | '' = ''
let timer: number | undefined let timer: number | undefined
let poll: number | undefined let poll: number | undefined
let pollsLeft = 0 let pollsLeft = 0
/** 引擎晚于页面把图标刷出来,加载后持续按当前分类收一轮 */
function armPoll() { function armPoll() {
pollsLeft = 24 pollsLeft = 24
if (poll) return if (poll) return
poll = window.setInterval(() => { poll = window.setInterval(() => {
pollsLeft -= 1 pollsLeft -= 1
if (enabled.value && bridge.ready.value) apply(mode.value) if (enabled.value && bridge.ready.value) {
applyCadVisibility(mode.value)
revealActiveMarkers()
}
if (pollsLeft <= 0 && poll) { if (pollsLeft <= 0 && poll) {
window.clearInterval(poll) window.clearInterval(poll)
poll = undefined poll = undefined
} }
}, 700) }, 500)
} }
async function ensureSceneMarkers() { /** 当前分类应显示的 tag0 集合 */
if (loaded || loading) return function tagsForMode(current: ScreenMode): Set<string> {
loading = true if (current === 'home') {
try { return new Set(ALL_DEVICE_TAGS.filter((t) => t !== 'camera'))
sceneMarkers = await fetchSceneMarkers(cloudUrl) }
loaded = sceneMarkers.length > 0 if (current === 'e066') return new Set()
} finally { if (current in DEVICE_TAG) {
loading = false return new Set([DEVICE_TAG[current as keyof typeof DEVICE_TAG]])
}
return new Set()
}
/** 引擎预设 / CAD 点:按分类显隐(摄像头留给 SceneViewport) */
function applyCadVisibility(current: ScreenMode) {
const showTags = tagsForMode(current)
for (const tag of ALL_DEVICE_TAGS) {
if (tag === 'camera') continue
if (showTags.has(tag)) bridge.showPois(tag)
else bridge.hidePois(tag)
}
const live = bridge.listMarkers?.() || []
for (const marker of live) {
if (marker.id.startsWith(PREFIX)) continue
if (marker.id.startsWith('cam:') || marker.tag0 === 'camera') continue
const cat = categoryOfMarker(marker)
if (!cat || cat === 'camera') continue
const show = current === 'home' || showTags.has(cat) || showTags.has(marker.tag0)
bridge.setMarkerVisible(marker.id, show)
}
}
function revealActiveMarkers() {
for (const id of activeIds) bridge.setMarkerVisible(id, true)
const showTags = tagsForMode(mode.value)
for (const tag of showTags) {
if (tag === 'camera') continue
bridge.showPois(tag)
} }
} }
function clearModeMarkers() { function clearModeMarkers() {
for (const id of activeIds) {
try {
bridge.setMarkerVisible(id, false)
bridge.removeMarker(id)
} catch {
/* ignore */
}
}
for (const def of MODE_POIS) { for (const def of MODE_POIS) {
try { try {
bridge.removeMarker(`${PREFIX}${def.id}`) bridge.removeMarker(poiMarkerId(def))
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
activeIds = []
lastSig = ''
} }
function apply(current: ScreenMode) { function sync(force = false) {
if (!enabled.value || !bridge.ready.value) return if (!enabled.value || !bridge.ready.value) return
clearModeMarkers()
bridge.hidePois('screen-mode')
const live = bridge.listMarkers?.() || [] const current = mode.value
const markers = live.length ? live : sceneMarkers const modeChanged = current !== lastMode
const snap = realtime.snapshot
const markers = markersForMode(current, snap)
const sig = markers
.map((m) => `${m.id}|${m.text}|${m.icon || ''}|${m.x},${m.y},${m.z}`)
.join(';')
// 不用 showPOIs:引擎会把未匹配标签的图标一并打开。只按点逐个显隐。 if (!force && !modeChanged && sig === lastSig) {
for (const tag of Object.values(DEVICE_TAG)) { applyCadVisibility(current)
if (current !== 'home' && current !== tag) bridge.hidePois(tag) revealActiveMarkers()
return
} }
if (current !== 'home' && current !== 'camera') bridge.hidePois('Camera')
if (current !== 'home' && current !== 'access') bridge.hidePois('Access')
if (current !== 'home') bridge.hidePois('Devices')
for (const marker of markers) { // 分类切换:先卸旧点、滤掉其它 tag,再写入当前类
const cat = categoryOfMarker(marker) if (force || modeChanged) {
const visible = clearModeMarkers()
current === 'home' || (current !== 'e066' && cat != null && current === cat) for (const tag of ALL_DEVICE_TAGS) {
bridge.setMarkerVisible(marker.id, visible) if (tag === 'camera') continue
bridge.hidePois(tag)
}
} else {
const nextIds = new Set(markers.map((m) => m.id))
for (const id of activeIds) {
if (!nextIds.has(id)) {
bridge.setMarkerVisible(id, false)
bridge.removeMarker(id)
}
} }
} }
async function syncModePois() { if (markers.length) {
await ensureSceneMarkers() if (force || modeChanged || !activeIds.length || markers.length !== activeIds.length) {
apply(mode.value) bridge.addMarkers(markers)
if (!loaded) { } else {
window.setTimeout(() => { for (const m of markers) bridge.updateMarker(m)
void ensureSceneMarkers().then(() => apply(mode.value)) }
}, 2000) }
activeIds = markers.map((m) => m.id)
lastSig = sig
lastMode = current
applyCadVisibility(current)
// 引擎异步过滤:稍后强制再亮一次当前运行时点
revealActiveMarkers()
window.setTimeout(() => revealActiveMarkers(), 200)
window.setTimeout(() => revealActiveMarkers(), 800)
} }
function scheduleSync() {
window.clearTimeout(timer)
timer = window.setTimeout(() => sync(false), 300)
}
async function syncModePois() {
sync(true)
armPoll()
} }
watch( watch(
...@@ -159,13 +353,17 @@ export function useScreenModePois( ...@@ -159,13 +353,17 @@ export function useScreenModePois(
() => { () => {
window.clearTimeout(timer) window.clearTimeout(timer)
timer = window.setTimeout(() => { timer = window.setTimeout(() => {
armPoll()
void syncModePois() void syncModePois()
}, 80) }, 50)
}, },
{ flush: 'post' }, { flush: 'post' },
) )
watch(
() => realtime.snapshot.ts,
() => scheduleSync(),
)
return { return {
clearModeMarkers, clearModeMarkers,
syncModePois, syncModePois,
......
import { onMounted, onUnmounted, ref } from 'vue'
import { api } from '@/api/http'
export const DEFAULT_SCREEN_TITLE = '分子实验室三维可视化管理平台'
export const SCREEN_TITLE_CHANNEL = 'jh-lab-screen-title'
const title = ref(DEFAULT_SCREEN_TITLE)
let pollTimer: number | undefined
let channel: BroadcastChannel | null = null
let subscribers = 0
async function fetchTitle() {
try {
const data = await api<{ screenTitle?: string }>('/api/settings')
const next = String(data.screenTitle || '').trim()
if (next) title.value = next
} catch {
/* 大屏允许匿名;失败保留当前值 */
}
}
function ensureChannel() {
if (channel || typeof BroadcastChannel === 'undefined') return
channel = new BroadcastChannel(SCREEN_TITLE_CHANNEL)
channel.onmessage = (ev) => {
const next = String(ev.data?.screenTitle || '').trim()
if (next) title.value = next
}
}
/** 设置页保存后广播,同浏览器已打开的大屏立即刷新 */
export function publishScreenTitle(screenTitle: string) {
const next = String(screenTitle || '').trim() || DEFAULT_SCREEN_TITLE
title.value = next
try {
ensureChannel()
channel?.postMessage({ screenTitle: next })
} catch {
/* ignore */
}
}
/**
* 大屏标题:挂载时拉取,定时轮询;同页/跨页保存经 BroadcastChannel 即时更新。
*/
export function useScreenTitle(opts?: { pollMs?: number }) {
const pollMs = opts?.pollMs ?? 5000
onMounted(() => {
subscribers += 1
ensureChannel()
void fetchTitle()
if (!pollTimer) {
pollTimer = window.setInterval(() => {
void fetchTitle()
}, pollMs)
}
})
onUnmounted(() => {
subscribers = Math.max(0, subscribers - 1)
if (subscribers === 0 && pollTimer) {
window.clearInterval(pollTimer)
pollTimer = undefined
}
})
return { title, refresh: fetchTitle }
}
/** /**
* 大屏设备分类 ↔ 场景 POI。 * 大屏设备分类 ↔ 场景 POI。
* 坐标 (x=lng, y=lat, z=height) 来自镜湖医院场景已落点的房间位置。 * 坐标由平面图对角标定线性插值得到(RTEngine:x=lng, y=lat, z=height)。
* 照明:普通=N电(QTN*),应急=E电(QTE*),注释来自协议 CSV。 * 照明:普通=N电(QTN*),应急=E电(QTE*);home = 全景总览。
* home = 全景总览(显示全部图标);其余分类只显示对应图标。
*/ */
import type { RealtimeSnapshot } from '@/types' import type { RealtimeSnapshot } from '@/types'
...@@ -31,7 +30,7 @@ export const DEVICE_TAG: Record<DeviceCategory, string> = { ...@@ -31,7 +30,7 @@ export const DEVICE_TAG: Record<DeviceCategory, string> = {
access: 'access', access: 'access',
} }
export const MODE_MARKER_SCALE = 0.5 export const MODE_MARKER_SCALE = 1
export const SCREEN_MODES: { id: ScreenMode; label: string }[] = [ export const SCREEN_MODES: { id: ScreenMode; label: string }[] = [
{ id: 'home', label: '全景总览' }, { id: 'home', label: '全景总览' },
...@@ -65,65 +64,90 @@ export type ModePoiDef = { ...@@ -65,65 +64,90 @@ export type ModePoiDef = {
z: number z: number
} }
/** 压差:一区一 marker(缓冲单独) */ /** 平面图左下角 / 右上角(用户标定) */
const PLAN_BL = {
lon: 133.54299724844427,
lat: 22.19728530602825,
h: 51,
}
const PLAN_TR = {
lon: 133.54308017687475,
lat: 22.19712618090024,
h: 51,
}
/** u: 左→右 0..1;v: 下→上 0..1(相对对角包围盒) */
function uv(u: number, v: number): { x: number; y: number; z: number } {
return {
x: PLAN_BL.lon + u * (PLAN_TR.lon - PLAN_BL.lon),
y: PLAN_BL.lat + v * (PLAN_TR.lat - PLAN_BL.lat),
z: PLAN_BL.h + v * (PLAN_TR.h - PLAN_BL.h),
}
}
function poi(partial: Omit<ModePoiDef, 'x' | 'y' | 'z'>, u: number, v: number): ModePoiDef {
return { ...partial, ...uv(u, v) }
}
/** 压差:房间中心 / 缓冲靠门 */
const PRESSURE_POIS: ModePoiDef[] = [ const PRESSURE_POIS: ModePoiDef[] = [
{ id: 'p-seq', mode: 'pressure', label: '测序区', roomHint: '测序区', bind: { kind: 'room', roomId: 'seq' }, x: 133.54306829, y: 22.19722037, z: 53.009 }, poi({ id: 'p-seq', mode: 'pressure', label: '测序区', roomHint: '测序区', bind: { kind: 'room', roomId: 'seq' } }, 0.1, 0.58),
{ id: 'p-seq-buf', mode: 'pressure', label: '测序区缓冲', roomHint: '测序区缓冲', bind: { kind: 'room', roomId: 'seq-buf' }, x: 133.54303821, y: 22.19722473, z: 51.299 }, poi({ id: 'p-seq-buf', mode: 'pressure', label: '测序区缓冲', roomHint: '测序区缓冲', bind: { kind: 'room', roomId: 'seq-buf' } }, 0.1, 0.3),
{ id: 'p-lib-2', mode: 'pressure', label: '文库制备区2', roomHint: '文库制备区2', bind: { kind: 'room', roomId: 'lib-2' }, x: 133.54306137, y: 22.19719660, z: 53.300 }, poi({ id: 'p-lib-2', mode: 'pressure', label: '文库制备区2', roomHint: '文库制备区2', bind: { kind: 'room', roomId: 'lib-2' } }, 0.25, 0.58),
{ id: 'p-lib-2-buf', mode: 'pressure', label: '文库制备区2缓冲', roomHint: '文库制备区2缓冲', bind: { kind: 'room', roomId: 'lib-2-buf' }, x: 133.54302644, y: 22.19721349, z: 53.858 }, poi({ id: 'p-lib-2-buf', mode: 'pressure', label: '文库制备区2缓冲', roomHint: '文库制备区2缓冲', bind: { kind: 'room', roomId: 'lib-2-buf' } }, 0.25, 0.3),
{ id: 'p-lib-1', mode: 'pressure', label: '文库制备区1', roomHint: '文库制备区1', bind: { kind: 'room', roomId: 'lib-1' }, x: 133.54306985, y: 22.19716263, z: 51.299 }, poi({ id: 'p-lib-1', mode: 'pressure', label: '文库制备区1', roomHint: '文库制备区1', bind: { kind: 'room', roomId: 'lib-1' } }, 0.4, 0.58),
{ id: 'p-lib-1-buf', mode: 'pressure', label: '文库制备区1缓冲', roomHint: '文库制备区1缓冲', bind: { kind: 'room', roomId: 'lib-1-buf' }, x: 133.54303366, y: 22.19717608, z: 51.499 }, poi({ id: 'p-lib-1-buf', mode: 'pressure', label: '文库制备区1缓冲', roomHint: '文库制备区1缓冲', bind: { kind: 'room', roomId: 'lib-1-buf' } }, 0.4, 0.3),
{ id: 'p-sample-2', mode: 'pressure', label: '样本制备区2', roomHint: '样本制备区2', bind: { kind: 'room', roomId: 'sample-2' }, x: 133.54305343, y: 22.19715687, z: 52.670 }, poi({ id: 'p-sample-2', mode: 'pressure', label: '样本制备区2', roomHint: '样本制备区2', bind: { kind: 'room', roomId: 'sample-2' } }, 0.55, 0.58),
{ id: 'p-sample-1', mode: 'pressure', label: '样本制备区1', roomHint: '样本制备区1', bind: { kind: 'room', roomId: 'sample-1' }, x: 133.54303277, y: 22.19715687, z: 53.924 }, poi({ id: 'p-sample-1', mode: 'pressure', label: '样本制备区1', roomHint: '样本制备区1', bind: { kind: 'room', roomId: 'sample-1' } }, 0.68, 0.58),
{ id: 'p-sample-buf', mode: 'pressure', label: '样本制备区缓冲', roomHint: '样本制备区缓冲', bind: { kind: 'room', roomId: 'sample-buf' }, x: 133.54300622, y: 22.19717578, z: 54.124 }, poi({ id: 'p-sample-buf', mode: 'pressure', label: '样本制备区缓冲', roomHint: '样本制备区缓冲', bind: { kind: 'room', roomId: 'sample-buf' } }, 0.66, 0.3),
{ id: 'p-recv', mode: 'pressure', label: '收样咨询室', roomHint: '咨询室', bind: { kind: 'room', roomId: 'recv' }, x: 133.54308054, y: 22.19712738, z: 52.734 }, poi({ id: 'p-recv', mode: 'pressure', label: '收样咨询室', roomHint: '咨询室', bind: { kind: 'room', roomId: 'recv' } }, 0.88, 0.88),
{ id: 'p-reagent', mode: 'pressure', label: '试剂准备间', roomHint: '准备区', bind: { kind: 'room', roomId: 'reagent' }, x: 133.54302092, y: 22.19715687, z: 53.729 }, poi({ id: 'p-reagent', mode: 'pressure', label: '试剂准备间', roomHint: '准备区', bind: { kind: 'room', roomId: 'reagent' } }, 0.85, 0.58),
{ id: 'p-report', mode: 'pressure', label: '生信分析报告室', roomHint: '报告室', bind: { kind: 'room', roomId: 'report' }, x: 133.54300854, y: 22.19715864, z: 54.059 }, poi({ id: 'p-report', mode: 'pressure', label: '生信分析报告室', roomHint: '报告室', bind: { kind: 'room', roomId: 'report' } }, 0.88, 0.38),
{ id: 'p-pcr', mode: 'pressure', label: 'PCR走廊', roomHint: 'PCR走廊', bind: { kind: 'room', roomId: 'pcr-corridor' }, x: 133.54298833, y: 22.19716597, z: 53.436 }, poi({ id: 'p-pcr', mode: 'pressure', label: 'PCR走廊', roomHint: 'PCR走廊', bind: { kind: 'room', roomId: 'pcr-corridor' } }, 0.42, 0.12),
] ]
/** 普通照明 = N电 */ /** 普通照明 = N电 */
const LIGHT_N_POIS: ModePoiDef[] = [ const LIGHT_N_POIS: ModePoiDef[] = [
{ id: 'ln-seq', mode: 'lightN', label: '测序区', roomHint: '测序区', bind: { kind: 'neLight', lightId: 'n-seq' }, x: 133.54306571, y: 22.19722162, z: 53.31 }, poi({ id: 'ln-seq', mode: 'lightN', label: '测序区', roomHint: '测序区', bind: { kind: 'neLight', lightId: 'n-seq' } }, 0.1, 0.52),
{ id: 'ln-lib-2', mode: 'lightN', label: '文库制备区2', roomHint: '文库制备区2', bind: { kind: 'neLight', lightId: 'n-lib-2' }, x: 133.54305879, y: 22.19719785, z: 53.60 }, poi({ id: 'ln-lib-2', mode: 'lightN', label: '文库制备区2', roomHint: '文库制备区2', bind: { kind: 'neLight', lightId: 'n-lib-2' } }, 0.27, 0.42),
{ id: 'ln-lib-1', mode: 'lightN', label: '文库制备区1', roomHint: '文库制备区1', bind: { kind: 'neLight', lightId: 'n-lib-1' }, x: 133.54306727, y: 22.19716387, z: 51.60 }, poi({ id: 'ln-lib-1', mode: 'lightN', label: '文库制备区1', roomHint: '文库制备区1', bind: { kind: 'neLight', lightId: 'n-lib-1' } }, 0.38, 0.52),
{ id: 'ln-sample-2', mode: 'lightN', label: '样本制备区2', roomHint: '样本制备区2', bind: { kind: 'neLight', lightId: 'n-sample-2' }, x: 133.54305085, y: 22.19715811, z: 52.97 }, poi({ id: 'ln-sample-2', mode: 'lightN', label: '样本制备区2', roomHint: '样本制备区2', bind: { kind: 'neLight', lightId: 'n-sample-2' } }, 0.55, 0.52),
{ id: 'ln-sample-1', mode: 'lightN', label: '样本制备区1', roomHint: '样本制备区1', bind: { kind: 'neLight', lightId: 'n-sample-1' }, x: 133.54303019, y: 22.19715811, z: 54.22 }, poi({ id: 'ln-sample-1', mode: 'lightN', label: '样本制备区1', roomHint: '样本制备区1', bind: { kind: 'neLight', lightId: 'n-sample-1' } }, 0.7, 0.52),
{ id: 'ln-report', mode: 'lightN', label: '报告室', roomHint: '报告室', bind: { kind: 'neLight', lightId: 'n-report' }, x: 133.54300596, y: 22.19715989, z: 54.36 }, poi({ id: 'ln-report', mode: 'lightN', label: '报告室', roomHint: '报告室', bind: { kind: 'neLight', lightId: 'n-report' } }, 0.88, 0.4),
{ id: 'ln-corridor', mode: 'lightN', label: 'NGS走廊', roomHint: 'PCR走廊', bind: { kind: 'neLight', lightId: 'n-corridor' }, x: 133.54298833, y: 22.19716597, z: 53.74 }, poi({ id: 'ln-corridor', mode: 'lightN', label: 'NGS走廊', roomHint: 'PCR走廊', bind: { kind: 'neLight', lightId: 'n-corridor' } }, 0.5, 0.12),
] ]
/** 应急照明 = E电 */ /** 应急照明 = E电 */
const LIGHT_E_POIS: ModePoiDef[] = [ const LIGHT_E_POIS: ModePoiDef[] = [
{ id: 'le-seq-buf', mode: 'lightE', label: '测序区缓冲', roomHint: '测序区缓冲', bind: { kind: 'neLight', lightId: 'e-seq-buf' }, x: 133.54303649, y: 22.19722556, z: 51.65 }, poi({ id: 'le-seq-buf', mode: 'lightE', label: '测序区缓冲', roomHint: '测序区缓冲', bind: { kind: 'neLight', lightId: 'e-seq-buf' } }, 0.1, 0.3),
{ id: 'le-seq', mode: 'lightE', label: '测序区', roomHint: '测序区', bind: { kind: 'neLight', lightId: 'e-seq' }, x: 133.54307173, y: 22.19721871, z: 53.36 }, poi({ id: 'le-seq', mode: 'lightE', label: '测序区', roomHint: '测序区', bind: { kind: 'neLight', lightId: 'e-seq' } }, 0.1, 0.55),
{ id: 'le-lib-2-buf', mode: 'lightE', label: '文库制备区2缓冲', roomHint: '文库制备区2缓冲', bind: { kind: 'neLight', lightId: 'e-lib-2-buf' }, x: 133.54302472, y: 22.19721432, z: 54.21 }, poi({ id: 'le-lib-2-buf', mode: 'lightE', label: '文库制备区2缓冲', roomHint: '文库制备区2缓冲', bind: { kind: 'neLight', lightId: 'e-lib-2-buf' } }, 0.25, 0.3),
{ id: 'le-lib-2', mode: 'lightE', label: '文库制备区2', roomHint: '文库制备区2', bind: { kind: 'neLight', lightId: 'e-lib-2' }, x: 133.54306481, y: 22.19719494, z: 53.65 }, poi({ id: 'le-lib-2', mode: 'lightE', label: '文库制备区2', roomHint: '文库制备区2', bind: { kind: 'neLight', lightId: 'e-lib-2' } }, 0.25, 0.55),
{ id: 'le-lib-1-buf', mode: 'lightE', label: '文库制备区1缓冲', roomHint: '文库制备区1缓冲', bind: { kind: 'neLight', lightId: 'e-lib-1-buf' }, x: 133.54303194, y: 22.19717691, z: 51.85 }, poi({ id: 'le-lib-1-buf', mode: 'lightE', label: '文库制备区1缓冲', roomHint: '文库制备区1缓冲', bind: { kind: 'neLight', lightId: 'e-lib-1-buf' } }, 0.4, 0.3),
{ id: 'le-lib-1', mode: 'lightE', label: '文库制备区1', roomHint: '文库制备区1', bind: { kind: 'neLight', lightId: 'e-lib-1' }, x: 133.54307329, y: 22.19716096, z: 51.65 }, poi({ id: 'le-lib-1', mode: 'lightE', label: '文库制备区1', roomHint: '文库制备区1', bind: { kind: 'neLight', lightId: 'e-lib-1' } }, 0.4, 0.55),
{ id: 'le-sample-1-buf', mode: 'lightE', label: '样本制备区1缓冲', roomHint: '样本制备区缓冲', bind: { kind: 'neLight', lightId: 'e-sample-1-buf' }, x: 133.54300450, y: 22.19717661, z: 54.47 }, poi({ id: 'le-sample-1-buf', mode: 'lightE', label: '样本制备区1缓冲', roomHint: '样本制备区缓冲', bind: { kind: 'neLight', lightId: 'e-sample-1-buf' } }, 0.66, 0.3),
{ id: 'le-sample-2', mode: 'lightE', label: '样本制备区2', roomHint: '样本制备区2', bind: { kind: 'neLight', lightId: 'e-sample-2' }, x: 133.54305687, y: 22.19715520, z: 53.02 }, poi({ id: 'le-sample-2', mode: 'lightE', label: '样本制备区2', roomHint: '样本制备区2', bind: { kind: 'neLight', lightId: 'e-sample-2' } }, 0.55, 0.55),
{ id: 'le-sample-1', mode: 'lightE', label: '样本制备区1', roomHint: '样本制备区1', bind: { kind: 'neLight', lightId: 'e-sample-1' }, x: 133.54303621, y: 22.19715520, z: 54.27 }, poi({ id: 'le-sample-1', mode: 'lightE', label: '样本制备区1', roomHint: '样本制备区1', bind: { kind: 'neLight', lightId: 'e-sample-1' } }, 0.68, 0.55),
{ id: 'le-report', mode: 'lightE', label: '报告室', roomHint: '报告室', bind: { kind: 'neLight', lightId: 'e-report' }, x: 133.54301112, y: 22.19715739, z: 54.41 }, poi({ id: 'le-report', mode: 'lightE', label: '报告室', roomHint: '报告室', bind: { kind: 'neLight', lightId: 'e-report' } }, 0.88, 0.38),
{ id: 'le-prep', mode: 'lightE', label: '准备间', roomHint: '准备区', bind: { kind: 'neLight', lightId: 'e-prep' }, x: 133.54302350, y: 22.19715562, z: 54.08 }, poi({ id: 'le-prep', mode: 'lightE', label: '准备间', roomHint: '准备区', bind: { kind: 'neLight', lightId: 'e-prep' } }, 0.85, 0.58),
{ id: 'le-recv', mode: 'lightE', label: '咨询室', roomHint: '咨询室', bind: { kind: 'neLight', lightId: 'e-recv' }, x: 133.54308312, y: 22.19712613, z: 53.08 }, poi({ id: 'le-recv', mode: 'lightE', label: '咨询室', roomHint: '咨询室', bind: { kind: 'neLight', lightId: 'e-recv' } }, 0.88, 0.88),
{ id: 'le-corridor', mode: 'lightE', label: '走廊', roomHint: 'PCR走廊', bind: { kind: 'neLight', lightId: 'e-corridor' }, x: 133.54297987, y: 22.19715486, z: 53.79 }, poi({ id: 'le-corridor', mode: 'lightE', label: '走廊', roomHint: 'PCR走廊', bind: { kind: 'neLight', lightId: 'e-corridor' } }, 0.35, 0.12),
] ]
const FRIDGE_POIS: ModePoiDef[] = [ const FRIDGE_POIS: ModePoiDef[] = [
{ id: 'f-recv-1', mode: 'fridge', label: '收样咨询室冰箱1', roomHint: '咨询室', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-3-收样咨询室冰箱1' }, x: 133.54307894, y: 22.19713424, z: 52.98 }, poi({ id: 'f-recv-1', mode: 'fridge', label: '收样咨询室冰箱1', roomHint: '咨询室', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-3-收样咨询室冰箱1' } }, 0.86, 0.82),
{ id: 'f-recv-2', mode: 'fridge', label: '收样咨询室冰箱2', roomHint: '咨询室', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-4-收样咨询室冰箱2' }, x: 133.54307355, y: 22.19712468, z: 52.98 }, poi({ id: 'f-recv-2', mode: 'fridge', label: '收样咨询室冰箱2', roomHint: '咨询室', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-4-收样咨询室冰箱2' } }, 0.9, 0.82),
{ id: 'f-lib-1', mode: 'fridge', label: '文库制备区1冰箱', roomHint: '文库制备区1', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-6-文库制备区1冰箱' }, x: 133.54306507, y: 22.19716190, z: 51.55 }, poi({ id: 'f-lib-1', mode: 'fridge', label: '文库制备区1冰箱', roomHint: '文库制备区1', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-6-文库制备区1冰箱' } }, 0.42, 0.4),
{ id: 'f-lib-2', mode: 'fridge', label: '文库制备区2冰箱', roomHint: '文库制备区2', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-7-文库制备区2冰箱' }, x: 133.54305179, y: 22.19719515, z: 53.55 }, poi({ id: 'f-lib-2', mode: 'fridge', label: '文库制备区2冰箱', roomHint: '文库制备区2', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-7-文库制备区2冰箱' } }, 0.27, 0.4),
{ id: 'f-sample-1', mode: 'fridge', label: '样本制备区1冰箱', roomHint: '样本制备区1', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-5-样本制备区1冰箱' }, x: 133.54303441, y: 22.19715202, z: 54.17 }, poi({ id: 'f-sample-1', mode: 'fridge', label: '样本制备区1冰箱', roomHint: '样本制备区1', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-5-样本制备区1冰箱' } }, 0.7, 0.42),
{ id: 'f-seq', mode: 'fridge', label: '测序区冰箱1', roomHint: '测序区', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-8-测序区冰箱1' }, x: 133.54307704, y: 22.19722424, z: 53.26 }, poi({ id: 'f-seq', mode: 'fridge', label: '测序区冰箱1', roomHint: '测序区', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-8-测序区冰箱1' } }, 0.12, 0.4),
{ id: 'f-reagent-1', mode: 'fridge', label: '试剂准备间冰箱1', roomHint: '准备区', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-1-试剂准备间冰箱1' }, x: 133.54301972, y: 22.19716251, z: 53.98 }, poi({ id: 'f-reagent-1', mode: 'fridge', label: '试剂准备间冰箱1', roomHint: '准备区', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-1-试剂准备间冰箱1' } }, 0.83, 0.55),
{ id: 'f-reagent-2', mode: 'fridge', label: '试剂准备间冰箱2', roomHint: '准备区', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-2-试剂准备间冰箱2' }, x: 133.54301523, y: 22.19715455, z: 53.98 }, poi({ id: 'f-reagent-2', mode: 'fridge', label: '试剂准备间冰箱2', roomHint: '准备区', bind: { kind: 'fridge', deviceId: 'mb-104-rtu-2-试剂准备间冰箱2' } }, 0.87, 0.55),
] ]
const VAV_POIS: ModePoiDef[] = [ const VAV_POIS: ModePoiDef[] = [
{ id: 'v-lib-1', mode: 'vav', label: '文库制备1通风柜', roomHint: '文库制备区1', bind: { kind: 'vav', deviceId: 'mb-103-文库制备1通风柜' }, x: 133.54308365, y: 22.19715798, z: 51.50 }, poi({ id: 'v-lib-1', mode: 'vav', label: '文库制备1通风柜', roomHint: '文库制备区1', bind: { kind: 'vav', deviceId: 'mb-103-文库制备1通风柜' } }, 0.4, 0.78),
{ id: 'v-lib-2', mode: 'vav', label: '文库制备2通风柜', roomHint: '文库制备区2', bind: { kind: 'vav', deviceId: 'mb-103-文库制备2通风柜' }, x: 133.54307292, y: 22.19718797, z: 53.50 }, poi({ id: 'v-lib-2', mode: 'vav', label: '文库制备2通风柜', roomHint: '文库制备区2', bind: { kind: 'vav', deviceId: 'mb-103-文库制备2通风柜' } }, 0.25, 0.78),
] ]
export const MODE_POIS: ModePoiDef[] = [ export const MODE_POIS: ModePoiDef[] = [
...@@ -135,7 +159,8 @@ export const MODE_POIS: ModePoiDef[] = [ ...@@ -135,7 +159,8 @@ export const MODE_POIS: ModePoiDef[] = [
] ]
export function poisForMode(mode: ScreenMode): ModePoiDef[] { export function poisForMode(mode: ScreenMode): ModePoiDef[] {
if (mode === 'home' || mode === 'e066' || mode === 'camera' || mode === 'access') return [] if (mode === 'e066' || mode === 'camera' || mode === 'access') return []
if (mode === 'home') return MODE_POIS
return MODE_POIS.filter((p) => p.mode === mode) return MODE_POIS.filter((p) => p.mode === mode)
} }
...@@ -264,6 +289,78 @@ export type MarkerReadout = { title: string; lines: ReadoutLine[] } ...@@ -264,6 +289,78 @@ export type MarkerReadout = { title: string; lines: ReadoutLine[] }
/** 非监控设备:用场景名称对齐实时快照,弹出接口当前值 */ /** 非监控设备:用场景名称对齐实时快照,弹出接口当前值 */
export function markerReadout(hit: MarkerIdentity, snap: RealtimeSnapshot): MarkerReadout | null { export function markerReadout(hit: MarkerIdentity, snap: RealtimeSnapshot): MarkerReadout | null {
const sensorId = (hit.tag1 || '').trim()
const sensor =
sensorId && snap.sensors?.length
? snap.sensors.find((s) => s.id === sensorId || s.code === sensorId)
: undefined
if (sensor) {
if (sensor.category === 'lightN' || sensor.category === 'lightE') {
return {
title: sensor.name,
lines: [
{
label: '类型',
value: sensor.category === 'lightN' ? '普通照明(N电)' : '应急照明(E电)',
},
{ label: '状态', value: sensor.on == null ? '--' : sensor.on ? '开' : '关' },
],
}
}
if (sensor.category === 'fridge') {
const lines: ReadoutLine[] = [
{ label: '温度', value: `${fmtNum(sensor.values.temp)} ℃` },
{ label: '湿度', value: `${fmtNum(sensor.values.humidity, 0)} %` },
]
if (sensor.alarms?.length) lines.push({ label: '报警', value: sensor.alarms.join('、') })
return { title: sensor.name, lines }
}
if (sensor.category === 'access') {
const door = snap.access.doors.find(
(d) => d.id === sensor.doorDeviceId || d.name === sensor.name || d.id === sensor.code,
)
return {
title: sensor.name,
lines: [
{ label: '门点', value: door?.name || sensor.name },
{ label: '状态', value: door ? DOOR_STATUS[door.status] || door.status : '--' },
{ label: '今日通行', value: String(snap.access.todayPassCount ?? '--') },
],
}
}
if (sensor.category === 'camera') {
return {
title: sensor.name,
lines: [{ label: '类型', value: '摄像头' }],
}
}
if (sensor.category === 'vav') {
return {
title: sensor.name,
lines: [
{ label: '状态', value: sensor.on == null ? '--' : sensor.on ? '运行' : '停止' },
{ label: '面风速', value: `${fmtNum(sensor.vav?.faceVelocity)} m/s` },
{ label: '视窗高度', value: `${fmtNum(sensor.vav?.sashHeight)} mm` },
{ label: '风量', value: `${fmtNum(sensor.vav?.airflow, 0)} m³/h` },
{
label: '报警码',
value:
sensor.vav?.alarmCode == null || sensor.vav.alarmCode === 0
? '无'
: String(sensor.vav.alarmCode),
},
],
}
}
const lines: ReadoutLine[] = [
{ label: '温度', value: `${fmtNum(sensor.values.temp)} ℃` },
{ label: '湿度', value: `${fmtNum(sensor.values.humidity, 0)} %` },
{ label: '压差', value: `${fmtNum(sensor.values.pressure)} Pa` },
]
if (sensor.alarms?.length) lines.push({ label: '报警', value: sensor.alarms.join('、') })
return { title: sensor.name, lines }
}
const byId = hit.tag1 ? MODE_POIS.find((p) => p.id === hit.tag1) : undefined const byId = hit.tag1 ? MODE_POIS.find((p) => p.id === hit.tag1) : undefined
const cat = byId?.mode || categoryOfMarker(hit) const cat = byId?.mode || categoryOfMarker(hit)
if (!cat || cat === 'camera') return null if (!cat || cat === 'camera') return null
...@@ -284,6 +381,20 @@ export function markerReadout(hit: MarkerIdentity, snap: RealtimeSnapshot): Mark ...@@ -284,6 +381,20 @@ export function markerReadout(hit: MarkerIdentity, snap: RealtimeSnapshot): Mark
], ],
} }
} }
if (cat === 'pressure') {
const byName = snap.sensors?.find(
(s) => s.category === 'pressure' && (s.name === name || s.name.includes(name) || name.includes(s.name)),
)
if (byName) {
const lines: ReadoutLine[] = [
{ label: '温度', value: `${fmtNum(byName.values.temp)} ℃` },
{ label: '湿度', value: `${fmtNum(byName.values.humidity, 0)} %` },
{ label: '压差', value: `${fmtNum(byName.values.pressure)} Pa` },
]
if (byName.alarms?.length) lines.push({ label: '报警', value: byName.alarms.join('、') })
return { title: byName.name, lines }
}
}
const def = byId || findPoi(name, cat) const def = byId || findPoi(name, cat)
if (!def) return { title, lines: [{ label: '数据', value: '--' }] } if (!def) return { title, lines: [{ label: '数据', value: '--' }] }
return { title: name || def.label, lines: linesFor(def, snap) } return { title: name || def.label, lines: linesFor(def, snap) }
......
...@@ -37,17 +37,17 @@ const navItems = computed(() => ...@@ -37,17 +37,17 @@ const navItems = computed(() =>
[ [
{ {
to: '/admin/devices', to: '/admin/devices',
label: '设备管理', label: '数据采集',
hint: '点位 / 网关', hint: '设备 / 点位 / 网关',
tone: 'accent' as const, tone: 'accent' as const,
icon: 'devices', icon: 'devices',
}, },
{ {
to: '/admin/cameras', to: '/admin/sensors',
label: '摄像头管理', label: '设施管理',
hint: '列表 / 三维打点', hint: '压差 / 照明 / 监控 / 门禁',
tone: 'accent' as const, tone: 'accent' as const,
icon: 'cameras', icon: 'devices',
}, },
{ {
to: '/admin/metering', to: '/admin/metering',
......
...@@ -9,6 +9,7 @@ import { useStageScale } from '@/composables/useStageScale' ...@@ -9,6 +9,7 @@ import { useStageScale } from '@/composables/useStageScale'
import ScreenHeader from '@/components/screen/ScreenHeader.vue' import ScreenHeader from '@/components/screen/ScreenHeader.vue'
import SceneViewport from '@/components/scene/SceneViewport.vue' import SceneViewport from '@/components/scene/SceneViewport.vue'
import AppIcon from '@/components/icons/AppIcon.vue' import AppIcon from '@/components/icons/AppIcon.vue'
import { useScreenTitle } from '@/composables/useScreenTitle'
const auth = useAuthStore() const auth = useAuthStore()
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
...@@ -16,6 +17,7 @@ const router = useRouter() ...@@ -16,6 +17,7 @@ const router = useRouter()
const { time, date } = useClock() const { time, date } = useClock()
const { place, temp, text: weatherText, humidity } = useMacauWeather() const { place, temp, text: weatherText, humidity } = useMacauWeather()
const { style } = useStageScale() const { style } = useStageScale()
const { title: screenTitle } = useScreenTitle()
const alarmOpen = ref(false) const alarmOpen = ref(false)
const alarmBtnRef = ref<HTMLButtonElement | null>(null) const alarmBtnRef = ref<HTMLButtonElement | null>(null)
const alarmPanelStyle = ref<Record<string, string>>({}) const alarmPanelStyle = ref<Record<string, string>>({})
...@@ -93,7 +95,7 @@ function goAdmin() { ...@@ -93,7 +95,7 @@ function goAdmin() {
<!-- 上层:标题 / 导航 / 业务面板悬浮 --> <!-- 上层:标题 / 导航 / 业务面板悬浮 -->
<div class="ui-layer"> <div class="ui-layer">
<ScreenHeader> <ScreenHeader :title="screenTitle">
<template #left> <template #left>
<div class="weather-wrap"> <div class="weather-wrap">
<span class="place">{{ place }}</span> <span class="place">{{ place }}</span>
......
...@@ -108,7 +108,13 @@ const router = createRouter({ ...@@ -108,7 +108,13 @@ const router = createRouter({
path: 'devices', path: 'devices',
name: 'devices', name: 'devices',
component: () => import('@/views/devices/DevicesView.vue'), component: () => import('@/views/devices/DevicesView.vue'),
meta: { title: '设备管理', requiresAuth: true }, meta: { title: '数据采集', requiresAuth: true },
},
{
path: 'sensors',
name: 'sensors',
component: () => import('@/views/sensors/SensorsView.vue'),
meta: { title: '设施管理', requiresAuth: true },
}, },
{ {
path: 'history', path: 'history',
...@@ -116,12 +122,7 @@ const router = createRouter({ ...@@ -116,12 +122,7 @@ const router = createRouter({
component: () => import('@/views/history/HistoryView.vue'), component: () => import('@/views/history/HistoryView.vue'),
meta: { title: '历史曲线', requiresAuth: true }, meta: { title: '历史曲线', requiresAuth: true },
}, },
{ { path: 'cameras', redirect: '/admin/sensors' },
path: 'cameras',
name: 'cameras',
component: () => import('@/views/cameras/CamerasView.vue'),
meta: { title: '摄像头管理', requiresAuth: true },
},
{ {
path: 'metering', path: 'metering',
name: 'metering', name: 'metering',
......
...@@ -39,7 +39,7 @@ export interface FridgeSnapshot { ...@@ -39,7 +39,7 @@ export interface FridgeSnapshot {
humidity: number humidity: number
} }
/** 普通照明(N电) / 应急照明(E电) 回路状态 */ /** 普通照明(N电) / 应急照明(E电) 回路状态 */
export interface NeLightSnapshot { export interface NeLightSnapshot {
id: string id: string
kind: 'N' | 'E' kind: 'N' | 'E'
...@@ -47,6 +47,32 @@ export interface NeLightSnapshot { ...@@ -47,6 +47,32 @@ export interface NeLightSnapshot {
on: boolean on: boolean
} }
/** 大屏传感器/设施聚合(压差、照明、冰箱、通风柜等) */
export interface SensorSnapshot {
id: string
code: string
name: string
category: string
values: {
temp: number | null
humidity: number | null
pressure: number | null
}
on?: boolean | null
controlPointId?: string | null
vav?: {
faceVelocity: number | null
sashHeight: number | null
airflow: number | null
alarmCode: number | null
}
alarms: string[]
scene: { x: number; y: number; z: number }
icon?: string | null
videoDeviceId?: string | null
doorDeviceId?: string | null
}
export interface DoorSnapshot { export interface DoorSnapshot {
id: string id: string
name: string name: string
...@@ -94,6 +120,7 @@ export interface RealtimeSnapshot { ...@@ -94,6 +120,7 @@ export interface RealtimeSnapshot {
vav: VavSnapshot[] vav: VavSnapshot[]
fridges: FridgeSnapshot[] fridges: FridgeSnapshot[]
neLights?: NeLightSnapshot[] neLights?: NeLightSnapshot[]
sensors?: SensorSnapshot[]
metering: { metering: {
powerKwh: number powerKwh: number
waterM3: number waterM3: number
......
...@@ -241,6 +241,10 @@ onMounted(load) ...@@ -241,6 +241,10 @@ onMounted(load)
:open="pickOpen" :open="pickOpen"
:title="pickDevice ? `打点 · ${pickDevice.name}` : '三维打点'" :title="pickDevice ? `打点 · ${pickDevice.name}` : '三维打点'"
:initial="pickDevice?.scenePoint || null" :initial="pickDevice?.scenePoint || null"
:preview-name="pickDevice?.name || '摄像头'"
:preview-text="pickDevice?.name || ''"
:show-text="false"
marker-type="marker"
@close="pickOpen = false" @close="pickOpen = false"
@save="onPickSave" @save="onPickSave"
/> />
......
...@@ -67,7 +67,7 @@ function isBoolWrite(p: DevicePoint) { ...@@ -67,7 +67,7 @@ function isBoolWrite(p: DevicePoint) {
return false return false
} }
type Tab = 'devices' | 'points' | 'gateways' type Tab = 'devices' | 'points'
type Dialog = 'import' | 'createPoint' | 'createDevice' | 'editPoint' | 'viewDevice' | null type Dialog = 'import' | 'createPoint' | 'createDevice' | 'editPoint' | 'viewDevice' | null
const auth = useAuthStore() const auth = useAuthStore()
...@@ -552,8 +552,11 @@ onUnmounted(stopViewRefresh) ...@@ -552,8 +552,11 @@ onUnmounted(stopViewRefresh)
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"> <div class="panel-title">
<div class="vbar" /> <div class="vbar" />
<h3>设备与点位</h3> <h3>数据采集</h3>
<div class="badge">点表 v{{ pointsVersion }}</div> <div class="badge">点表 v{{ pointsVersion }}</div>
<span class="badge" :class="gatewayHealth?.ok ? '' : 'amber'">
Windows 网关 {{ gatewayHealth?.ok ? '在线' : gatewayHealth?.message || '未连接' }}
</span>
</div> </div>
<div class="head-actions"> <div class="head-actions">
<button class="btn ghost" type="button" :disabled="loading" @click="loadAll">刷新</button> <button class="btn ghost" type="button" :disabled="loading" @click="loadAll">刷新</button>
...@@ -585,6 +588,21 @@ onUnmounted(stopViewRefresh) ...@@ -585,6 +588,21 @@ onUnmounted(stopViewRefresh)
</div> </div>
</div> </div>
<div class="gw-strip">
<div class="gw-strip-title">采集网关({{ gateways.length }})</div>
<div class="gw-chips">
<span v-for="g in gateways" :key="g.id" class="gw-chip" :title="`${g.id} · ${g.host}:${g.port}`">
<b>{{ g.name }}</b>
<span class="mono">{{ g.host }}:{{ g.port }}</span>
<i :class="g.enabled ? 'on' : 'off'">{{ g.enabled ? '启用' : '停用' }}</i>
</span>
<span v-if="!gateways.length" class="muted">暂无网关主机</span>
</div>
<p class="hint gw-hint">
由 Windows 网关连接上述主机采集;本页维护设备、点位并下发。
</p>
</div>
<div class="tab-row"> <div class="tab-row">
<button <button
class="btn" class="btn"
...@@ -602,14 +620,6 @@ onUnmounted(stopViewRefresh) ...@@ -602,14 +620,6 @@ onUnmounted(stopViewRefresh)
> >
点位({{ points.length }}) 点位({{ points.length }})
</button> </button>
<button
class="btn"
:class="{ ghost: tab !== 'gateways' }"
type="button"
@click="tab = 'gateways'"
>
网关({{ gateways.length }})
</button>
</div> </div>
<div v-if="tab === 'points'" class="filters"> <div v-if="tab === 'points'" class="filters">
...@@ -665,7 +675,7 @@ onUnmounted(stopViewRefresh) ...@@ -665,7 +675,7 @@ onUnmounted(stopViewRefresh)
</tbody> </tbody>
</table> </table>
<table v-else-if="tab === 'points'" class="table"> <table v-else class="table">
<thead> <thead>
<tr> <tr>
<th>名称</th> <th>名称</th>
...@@ -746,29 +756,6 @@ onUnmounted(stopViewRefresh) ...@@ -746,29 +756,6 @@ onUnmounted(stopViewRefresh)
</tr> </tr>
</tbody> </tbody>
</table> </table>
<template v-else>
<div class="gw-status">
<span class="badge" :class="gatewayHealth?.ok ? '' : 'amber'">
Windows 网关 {{ gatewayHealth?.ok ? '在线' : gatewayHealth?.message || '未连接' }}
</span>
</div>
<table class="table">
<thead>
<tr><th>ID</th><th>名称</th><th>主机</th><th>端口</th><th>启用</th></tr>
</thead>
<tbody>
<tr v-for="g in gateways" :key="g.id">
<td>{{ g.id }}</td>
<td>{{ g.name }}</td>
<td>{{ g.host }}</td>
<td>{{ g.port }}</td>
<td>{{ g.enabled ? '是' : '否' }}</td>
</tr>
</tbody>
</table>
<p class="hint">实际协议由单一 Windows 网关连接上述主机;平台仅维护主数据与点位。</p>
</template>
</div> </div>
</section> </section>
...@@ -1219,7 +1206,47 @@ onUnmounted(stopViewRefresh) ...@@ -1219,7 +1206,47 @@ onUnmounted(stopViewRefresh)
color: var(--panel-muted); color: var(--panel-muted);
padding: 28px 10px !important; padding: 28px 10px !important;
} }
.gw-status { margin-bottom: 8px; } .gw-strip {
margin: 0 0 10px;
padding: 10px 12px;
border: 1px solid var(--panel-border);
border-radius: 4px;
background: rgba(6, 16, 28, 0.45);
}
.gw-strip-title {
font-size: 12px;
color: var(--panel-muted);
margin-bottom: 8px;
}
.gw-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.gw-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 4px;
border: 1px solid rgba(0, 247, 255, 0.2);
background: rgba(0, 40, 72, 0.35);
font-size: 12px;
color: #cfe8f6;
}
.gw-chip b {
font-weight: 600;
color: #eaf4ff;
}
.gw-chip i {
font-style: normal;
font-size: 11px;
}
.gw-chip i.on { color: var(--ok, #4dffb5); }
.gw-chip i.off { color: #ffb0b0; }
.gw-hint {
margin: 8px 0 0;
}
.modal-mask { .modal-mask {
position: fixed; position: fixed;
inset: 0; inset: 0;
......
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { api } from '@/api/http'
import ScenePickDialog from '@/components/video/ScenePickDialog.vue'
import type { ScenePoint } from '@/composables/useRtengineBridge'
import { MODE_MARKER_ICON } from '@/data/screenModePois'
import { useAuthStore } from '@/stores/auth'
interface SensorPointBind {
pointId: string
role: string
sort: number
pointName?: string
itemId?: string
deviceId?: string | null
lastEng?: number | null
}
interface Sensor {
id: string
code: string
name: string
category: string
enabled: boolean
sceneX: number
sceneY: number
sceneZ: number
meta?: Record<string, unknown>
points: SensorPointBind[]
updatedAt: string
}
interface DevicePoint {
id: string
deviceId: string | null
name: string
itemId: string
lastEng: number | null
enabled: boolean
}
const CATEGORY_LABEL: Record<string, string> = {
pressure: '压差',
lightN: '普通照明(N电)',
lightE: '应急照明(E电)',
fridge: '冰箱',
vav: '通风柜',
camera: '摄像头',
access: '门禁',
other: '其它',
}
const ROLE_LABEL: Record<string, string> = {
temp: '温度',
humidity: '湿度',
pressure: '压差',
value: '数值',
status: '开关/开停',
faceVelocity: '面风速',
sashHeight: '视窗高度',
airflow: '风量',
alarmCode: '报警码',
}
const PRESSURE_ROLES = ['temp', 'humidity', 'pressure'] as const
const LIGHT_ROLES = ['status'] as const
const FRIDGE_ROLES = ['temp', 'humidity'] as const
const VAV_ROLES = ['status', 'faceVelocity', 'sashHeight', 'airflow', 'alarmCode'] as const
function rolesForCategory(category: string): readonly string[] {
if (category === 'pressure') return PRESSURE_ROLES
if (category === 'lightN' || category === 'lightE') return LIGHT_ROLES
if (category === 'fridge') return FRIDGE_ROLES
if (category === 'vav') return VAV_ROLES
if (category === 'camera' || category === 'access') return []
return PRESSURE_ROLES
}
const SENSOR_PREFERRED_ICONS: Record<string, string[]> = {
pressure: [],
lightN: ['electricity-meter-blue.png', 'electricity-meter-orange.png'],
lightE: ['electricity-meter-orange.png', 'electricity-meter-blue.png'],
fridge: ['temperature-sensor-orange.png', 'temperature-sensor-blue.png'],
vav: ['motor-green.png', 'motor-blue.png'],
camera: ['cctv-blue.png', 'cctv-green.png', 'cctv-orange.png', 'ai-camera-blue.png'],
access: ['access-control-blue.png', 'access-control-green.png', 'access-control-orange.png'],
}
const FACILITY_DEFAULT_ICON: Record<string, string> = {
...MODE_MARKER_ICON,
camera: 'cctv-blue.png',
access: 'access-control-blue.png',
}
const auth = useAuthStore()
const loading = ref(false)
const saving = ref(false)
const message = ref('')
const error = ref('')
const sensors = ref<Sensor[]>([])
const points = ref<DevicePoint[]>([])
const filterCategory = ref('')
const showEdit = ref(false)
const editingId = ref<string | null>(null)
const pickOpen = ref(false)
const pickSensor = ref<Sensor | null>(null)
function defaultIconFor(category: string) {
if (category === 'pressure') return ''
return FACILITY_DEFAULT_ICON[category] || 'cctv-blue.png'
}
function sensorIcon(s: Sensor) {
if (s.category === 'pressure') return ''
const raw = s.meta?.icon
return typeof raw === 'string' && raw.trim() ? raw.trim() : defaultIconFor(s.category)
}
function pickInitial(s: Sensor | null): ScenePoint | null {
if (!s) return null
return { x: s.sceneX, y: s.sceneY, z: s.sceneZ, icon: sensorIcon(s) }
}
/** 打点预览文案:与大屏读数格式一致 */
function pickPreviewText(s: Sensor | null): string {
if (!s) return ''
if (s.category === 'pressure') return '—℃\n—%\n—Pa'
if (s.category === 'lightN' || s.category === 'lightE') return '状态:—'
if (s.category === 'fridge') return '—℃\n—%'
if (s.category === 'vav') return '状态:—\n面风速:—m/s\n风量:—'
return s.name
}
function pickIsTextOnly(s: Sensor | null) {
return s?.category === 'pressure'
}
function pickShowText(s: Sensor | null) {
if (!s) return true
return s.category !== 'camera' && s.category !== 'access'
}
function pickPreferredIcons(s: Sensor | null) {
if (!s) return SENSOR_PREFERRED_ICONS.pressure
return SENSOR_PREFERRED_ICONS[s.category] || Object.values(FACILITY_DEFAULT_ICON)
}
const form = reactive({
code: '',
name: '',
category: 'pressure',
enabled: true,
sceneX: 0,
sceneY: 0,
sceneZ: 51,
roles: {
temp: '',
humidity: '',
pressure: '',
status: '',
faceVelocity: '',
sashHeight: '',
airflow: '',
alarmCode: '',
} as Record<string, string>,
})
const filtered = computed(() => {
if (!filterCategory.value) return sensors.value
return sensors.value.filter((s) => s.category === filterCategory.value)
})
const pointOptions = computed(() =>
points.value
.filter((p) => p.enabled)
.slice()
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')),
)
function pointLabel(p: DevicePoint) {
const eng = p.lastEng == null ? '—' : String(p.lastEng)
return `${p.name}(${p.itemId} · ${eng})`
}
function bindsSummary(s: Sensor) {
if (!s.points?.length) return '未绑定'
return s.points
.map((b) => `${ROLE_LABEL[b.role] || b.role}:${b.pointName || b.itemId || b.pointId.slice(0, 8)}`)
.join(' · ')
}
async function load() {
loading.value = true
error.value = ''
try {
const [sRes, pRes] = await Promise.all([
api<{ sensors: Sensor[] }>('/api/sensors', { token: auth.token }),
api<{ points: DevicePoint[] }>('/api/devices/points', { token: auth.token }),
])
sensors.value = sRes.sensors || []
points.value = pRes.points || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function openCreate() {
if (!auth.isAdmin) return
editingId.value = null
form.code = ''
form.name = ''
form.category = filterCategory.value || 'pressure'
form.enabled = true
form.sceneX = 133.543
form.sceneY = 22.1972
form.sceneZ = 51
form.roles = {
temp: '',
humidity: '',
pressure: '',
status: '',
faceVelocity: '',
sashHeight: '',
airflow: '',
alarmCode: '',
}
error.value = ''
showEdit.value = true
}
function openEdit(s: Sensor) {
if (!auth.isAdmin) return
editingId.value = s.id
form.code = s.code
form.name = s.name
form.category = s.category
form.enabled = s.enabled
form.sceneX = s.sceneX
form.sceneY = s.sceneY
form.sceneZ = s.sceneZ
form.roles = {
temp: '',
humidity: '',
pressure: '',
status: '',
faceVelocity: '',
sashHeight: '',
airflow: '',
alarmCode: '',
}
for (const b of s.points || []) {
form.roles[b.role] = b.pointId
}
error.value = ''
showEdit.value = true
}
function closeEdit() {
showEdit.value = false
error.value = ''
}
function buildPointsPayload() {
const roles = [...rolesForCategory(form.category)]
return roles
.filter((role) => form.roles[role])
.map((role, i) => ({ role, pointId: form.roles[role], sort: i }))
}
async function save() {
if (!auth.isAdmin) return
const code = form.code.trim()
const name = form.name.trim()
if (!code || !name) {
error.value = '编码与名称必填'
return
}
saving.value = true
error.value = ''
message.value = ''
const body = {
code,
name,
category: form.category,
enabled: form.enabled,
sceneX: Number(form.sceneX),
sceneY: Number(form.sceneY),
sceneZ: Number(form.sceneZ),
points: buildPointsPayload(),
}
try {
if (editingId.value) {
await api(`/api/sensors/${editingId.value}`, {
method: 'PUT',
token: auth.token,
body: JSON.stringify(body),
})
message.value = '设施已更新'
} else {
await api('/api/sensors', {
method: 'POST',
token: auth.token,
body: JSON.stringify(body),
})
message.value = '设施已创建'
}
closeEdit()
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function toggleEnabled(s: Sensor) {
if (!auth.isAdmin) return
try {
await api(`/api/sensors/${s.id}`, {
method: 'PUT',
token: auth.token,
body: JSON.stringify({ enabled: !s.enabled }),
})
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '更新失败'
}
}
async function removeSensor(s: Sensor) {
if (!auth.isAdmin) return
if (!window.confirm(`确定删除设施「${s.name}」?`)) return
try {
await api(`/api/sensors/${s.id}`, { method: 'DELETE', token: auth.token })
message.value = '已删除'
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
}
}
function openPick(s: Sensor) {
if (!auth.isAdmin) return
pickSensor.value = s
pickOpen.value = true
}
async function onPickSave(point: ScenePoint) {
if (!pickSensor.value) return
const cur = pickSensor.value
const icon =
cur.category === 'pressure' ? '' : (point.icon || '').trim() || defaultIconFor(cur.category)
const meta = { ...(cur.meta || {}), icon }
try {
await api(`/api/sensors/${cur.id}`, {
method: 'PUT',
token: auth.token,
body: JSON.stringify({
sceneX: point.x,
sceneY: point.y,
sceneZ: point.z,
meta,
}),
})
message.value = `已保存「${cur.name}」三维点位`
pickOpen.value = false
pickSensor.value = null
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '保存点位失败'
}
}
onMounted(load)
</script>
<template>
<div class="page-fill admin-list-page">
<section class="panel list-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>设施管理</h3></div>
<div class="head-actions">
<select v-model="filterCategory" class="filter">
<option value="">全部分类</option>
<option v-for="(label, key) in CATEGORY_LABEL" :key="key" :value="key">{{ label }}</option>
</select>
<button class="btn ghost" type="button" :disabled="loading" @click="load">刷新</button>
<button v-if="auth.isAdmin" class="btn" type="button" @click="openCreate">新建设施</button>
</div>
</div>
<p class="desc">
设施含压差、照明 N/E 电、冰箱、通风柜、摄像头、门禁(后四类与交底表对齐)。摄像头/门禁无采集点绑定,可通过「三维打点」调整坐标与图标。
</p>
<p v-if="message" class="msg ok">{{ message }}</p>
<p v-if="error && !showEdit" class="msg err">{{ error }}</p>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>编码</th>
<th>分类</th>
<th>启用</th>
<th>坐标</th>
<th>绑定点位</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="s in filtered" :key="s.id">
<td>{{ s.name }}</td>
<td class="mono">{{ s.code }}</td>
<td>{{ CATEGORY_LABEL[s.category] || s.category }}</td>
<td>
<button
class="btn ghost"
type="button"
:disabled="!auth.isAdmin"
@click="toggleEnabled(s)"
>
{{ s.enabled ? '是' : '否' }}
</button>
</td>
<td class="mono">
{{ s.sceneX.toFixed(6) }}, {{ s.sceneY.toFixed(6) }}, {{ s.sceneZ }}
</td>
<td class="binds">{{ bindsSummary(s) }}</td>
<td class="ops">
<button
v-if="auth.isAdmin"
class="btn ghost"
type="button"
@click="openPick(s)"
>
三维打点
</button>
<button
v-if="auth.isAdmin"
class="btn ghost"
type="button"
@click="openEdit(s)"
>
编辑
</button>
<button
v-if="auth.isAdmin"
class="btn ghost danger"
type="button"
@click="removeSensor(s)"
>
删除
</button>
<span v-if="!auth.isAdmin" class="muted">—</span>
</td>
</tr>
<tr v-if="!filtered.length">
<td colspan="7" class="muted">暂无设施</td>
</tr>
</tbody>
</table>
</div>
</section>
<div v-if="showEdit" class="modal-mask" @click.self="closeEdit">
<div class="modal panel">
<div class="panel-head">
<div class="panel-title">
<div class="vbar" />
<h3>{{ editingId ? '编辑设施' : '新建设施' }}</h3>
</div>
<button class="btn ghost" type="button" @click="closeEdit">关闭</button>
</div>
<div class="form-grid">
<div class="field">
<label>编码</label>
<input v-model="form.code" type="text" placeholder="p-seq" />
</div>
<div class="field">
<label>名称</label>
<input v-model="form.name" type="text" placeholder="测序区压差" />
</div>
<div class="field">
<label>分类</label>
<select v-model="form.category">
<option v-for="(label, key) in CATEGORY_LABEL" :key="key" :value="key">{{ label }}</option>
</select>
</div>
<div class="field check">
<label>
<input v-model="form.enabled" type="checkbox" />
启用
</label>
</div>
<div class="field">
<label>场景 X (lng)</label>
<input v-model.number="form.sceneX" type="number" step="0.0000001" />
</div>
<div class="field">
<label>场景 Y (lat)</label>
<input v-model.number="form.sceneY" type="number" step="0.0000001" />
</div>
<div class="field">
<label>场景 Z (height)</label>
<input v-model.number="form.sceneZ" type="number" step="0.1" />
</div>
</div>
<h4 class="sub">点位绑定</h4>
<div class="form-grid roles">
<div v-for="role in rolesForCategory(form.category)" :key="role" class="field">
<label>{{ ROLE_LABEL[role] || role }}</label>
<select v-model="form.roles[role]">
<option value="">— 未选择 —</option>
<option v-for="p in pointOptions" :key="p.id" :value="p.id">
{{ pointLabel(p) }}
</option>
</select>
</div>
</div>
<p v-if="error" class="msg err">{{ error }}</p>
<div class="modal-actions">
<button class="btn" type="button" :disabled="saving" @click="save">
{{ saving ? '保存中…' : '保存' }}
</button>
<button class="btn ghost" type="button" @click="closeEdit">取消</button>
</div>
</div>
</div>
<ScenePickDialog
:open="pickOpen"
:title="pickSensor ? `打点 · ${pickSensor.name}` : '设施三维打点'"
:initial="pickInitial(pickSensor)"
:pick-hint="
pickIsTextOnly(pickSensor)
? '场景加载后,点击地面放置压差文字标签(不显示图标)'
: '场景加载后,点击地面拾取设施位置;左侧可选该类默认图标'
"
:default-icon="pickSensor ? defaultIconFor(pickSensor.category) : 'cctv-blue.png'"
:preferred-icons="pickPreferredIcons(pickSensor)"
:marker-type="pickIsTextOnly(pickSensor) ? 'label' : 'marker'"
:text-only="pickIsTextOnly(pickSensor)"
:preview-text="pickPreviewText(pickSensor)"
:preview-name="pickSensor?.name || '设施'"
:show-text="pickShowText(pickSensor)"
@close="pickOpen = false"
@save="onPickSave"
/>
</div>
</template>
<style scoped>
.admin-list-page { min-height: 0; }
.list-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.head-actions {
display: flex;
align-items: center;
gap: 10px;
}
.filter {
min-width: 120px;
border-radius: 2px;
border: 1px solid var(--panel-border);
background: rgba(6, 12, 20, 0.55);
color: #eaf4ff;
padding: 6px 10px;
}
.desc {
margin: 0 0 10px;
font-size: 12px;
color: var(--panel-muted);
line-height: 1.5;
}
.table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.binds {
max-width: 360px;
font-size: 12px;
color: var(--panel-muted);
}
.ops {
display: flex;
gap: 6px;
white-space: nowrap;
}
.muted { color: var(--panel-muted); }
.msg.ok { color: var(--ok); font-size: 13px; margin-bottom: 8px; }
.msg.err { color: #ffb0b0; font-size: 13px; margin: 8px 0; }
.btn.danger { color: #ffb0b0; }
.modal-mask {
position: fixed;
inset: 0;
background: rgba(4, 8, 14, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal {
width: min(720px, 94vw);
max-height: 90vh;
overflow: auto;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-grid.roles {
grid-template-columns: 1fr;
}
.field label {
display: block;
font-size: 12px;
color: var(--panel-muted);
margin-bottom: 4px;
}
.field input,
.field select {
width: 100%;
border-radius: 2px;
border: 1px solid var(--panel-border);
background: rgba(6, 12, 20, 0.55);
color: #eaf4ff;
padding: 8px 10px;
}
.field.check {
display: flex;
align-items: end;
}
.field.check label {
display: flex;
align-items: center;
gap: 8px;
color: #eaf4ff;
}
.sub {
margin: 16px 0 8px;
font-size: 13px;
color: #eaf4ff;
}
.modal-actions {
margin-top: 14px;
display: flex;
gap: 10px;
}
</style>
...@@ -2,9 +2,11 @@ ...@@ -2,9 +2,11 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { api } from '@/api/http' import { api } from '@/api/http'
import { DEFAULT_SCREEN_TITLE, publishScreenTitle } from '@/composables/useScreenTitle'
interface SystemSettings { interface SystemSettings {
collectPollMs: number collectPollMs: number
screenTitle: string
dataMode: string dataMode: string
collectRetentionDays: number collectRetentionDays: number
updatedAt: string updatedAt: string
...@@ -21,10 +23,12 @@ const ueUrl = ...@@ -21,10 +23,12 @@ const ueUrl =
const settings = ref<SystemSettings | null>(null) const settings = ref<SystemSettings | null>(null)
const pollMs = ref(2000) const pollMs = ref(2000)
const pollSec = ref(2) const pollSec = ref(2)
const screenTitleDraft = ref(DEFAULT_SCREEN_TITLE)
const saving = ref(false) const saving = ref(false)
const message = ref('') const message = ref('')
const error = ref('') const error = ref('')
const showPollEdit = ref(false) const showPollEdit = ref(false)
const showTitleEdit = ref(false)
const presets = [ const presets = [
{ label: '1 秒', ms: 1000 }, { label: '1 秒', ms: 1000 },
...@@ -37,12 +41,24 @@ const presets = [ ...@@ -37,12 +41,24 @@ const presets = [
const rows = computed(() => [ const rows = computed(() => [
{ key: 'user', label: '当前用户', value: `${auth.user?.displayName || '—'} (${auth.user?.username || '—'})` }, { key: 'user', label: '当前用户', value: `${auth.user?.displayName || '—'} (${auth.user?.username || '—'})` },
{
key: 'screenTitle',
label: '大屏标题',
value: settings.value?.screenTitle || DEFAULT_SCREEN_TITLE,
editable: true,
edit: 'title',
},
{ key: 'viewport', label: '三维视口模式', value: viewportMode }, { key: 'viewport', label: '三维视口模式', value: viewportMode },
{ key: 'ue', label: '云渲染 / UE 地址', value: ueUrl }, { key: 'ue', label: '云渲染 / UE 地址', value: ueUrl },
{ {
key: 'dataMode', key: 'dataMode',
label: '数据采集模式', label: '数据采集模式',
value: settings.value?.dataMode === 'modbus' ? 'Modbus 实采' : settings.value?.dataMode === 'gateway' ? '网关实采' : 'Mock 模拟', value:
settings.value?.dataMode === 'modbus'
? 'Modbus 实采'
: settings.value?.dataMode === 'gateway'
? '网关实采'
: 'Mock 模拟',
}, },
{ {
key: 'retention', key: 'retention',
...@@ -54,6 +70,7 @@ const rows = computed(() => [ ...@@ -54,6 +70,7 @@ const rows = computed(() => [
label: '数据采集频率', label: '数据采集频率',
value: settings.value ? `${settings.value.collectPollMs / 1000} 秒` : '—', value: settings.value ? `${settings.value.collectPollMs / 1000} 秒` : '—',
editable: true, editable: true,
edit: 'poll',
}, },
{ key: 'access', label: '门禁桥接', value: 'ACCESS_BRIDGE_URL 未配置时使用 Mock' }, { key: 'access', label: '门禁桥接', value: 'ACCESS_BRIDGE_URL 未配置时使用 Mock' },
]) ])
...@@ -81,12 +98,30 @@ function closePollEdit() { ...@@ -81,12 +98,30 @@ function closePollEdit() {
error.value = '' error.value = ''
} }
function openTitleEdit() {
if (!auth.isAdmin) return
screenTitleDraft.value = settings.value?.screenTitle || DEFAULT_SCREEN_TITLE
error.value = ''
showTitleEdit.value = true
}
function closeTitleEdit() {
showTitleEdit.value = false
error.value = ''
}
function openEdit(kind?: string) {
if (kind === 'title') openTitleEdit()
else openPollEdit()
}
async function load() { async function load() {
message.value = '' message.value = ''
try { try {
const data = await api<SystemSettings>('/api/settings', { token: auth.token }) const data = await api<SystemSettings>('/api/settings', { token: auth.token })
settings.value = data settings.value = data
syncFromMs(data.collectPollMs) syncFromMs(data.collectPollMs)
screenTitleDraft.value = data.screenTitle || DEFAULT_SCREEN_TITLE
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '加载失败' error.value = e instanceof Error ? e.message : '加载失败'
} }
...@@ -113,6 +148,37 @@ async function savePoll() { ...@@ -113,6 +148,37 @@ async function savePoll() {
} }
} }
async function saveTitle() {
if (!auth.isAdmin) return
const next = screenTitleDraft.value.trim()
if (!next) {
error.value = '大屏标题不能为空'
return
}
if (next.length > 64) {
error.value = '大屏标题最多 64 个字符'
return
}
saving.value = true
error.value = ''
try {
const data = await api<SystemSettings>('/api/settings/screen-title', {
method: 'PUT',
token: auth.token,
body: JSON.stringify({ screenTitle: next }),
})
settings.value = data
screenTitleDraft.value = data.screenTitle
publishScreenTitle(data.screenTitle)
message.value = '大屏标题已更新'
closeTitleEdit()
} catch (e) {
error.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
onMounted(load) onMounted(load)
</script> </script>
...@@ -125,6 +191,9 @@ onMounted(load) ...@@ -125,6 +191,9 @@ onMounted(load)
<div class="badge" :class="auth.isAdmin ? '' : 'amber'"> <div class="badge" :class="auth.isAdmin ? '' : 'amber'">
{{ auth.isAdmin ? '管理员' : '只读' }} {{ auth.isAdmin ? '管理员' : '只读' }}
</div> </div>
<button v-if="auth.isAdmin" class="btn ghost" type="button" @click="openTitleEdit">
编辑大屏标题
</button>
<button v-if="auth.isAdmin" class="btn" type="button" @click="openPollEdit"> <button v-if="auth.isAdmin" class="btn" type="button" @click="openPollEdit">
编辑采集频率 编辑采集频率
</button> </button>
...@@ -132,7 +201,7 @@ onMounted(load) ...@@ -132,7 +201,7 @@ onMounted(load)
</div> </div>
<p v-if="message" class="msg ok">{{ message }}</p> <p v-if="message" class="msg ok">{{ message }}</p>
<p v-if="error && !showPollEdit" class="msg err">{{ error }}</p> <p v-if="error && !showPollEdit && !showTitleEdit" class="msg err">{{ error }}</p>
<div class="table-wrap"> <div class="table-wrap">
<table class="table"> <table class="table">
...@@ -148,7 +217,7 @@ onMounted(load) ...@@ -148,7 +217,7 @@ onMounted(load)
v-if="r.editable && auth.isAdmin" v-if="r.editable && auth.isAdmin"
class="btn ghost" class="btn ghost"
type="button" type="button"
@click="openPollEdit" @click="openEdit(r.edit)"
> >
编辑 编辑
</button> </button>
...@@ -165,6 +234,38 @@ onMounted(load) ...@@ -165,6 +234,38 @@ onMounted(load)
</p> </p>
</section> </section>
<div v-if="showTitleEdit" class="modal-mask" @click.self="closeTitleEdit">
<div class="modal panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>编辑大屏标题</h3></div>
<button class="btn ghost" type="button" @click="closeTitleEdit">关闭</button>
</div>
<p class="desc">
保存后大屏顶栏标题立即更新(其他终端约数秒内同步)。最多 64 个字符。
</p>
<div class="form-grid title-form">
<div class="field">
<label>标题文案</label>
<input
v-model="screenTitleDraft"
type="text"
maxlength="64"
placeholder="分子实验室三维可视化管理平台"
@keydown.enter.prevent="saveTitle"
/>
</div>
</div>
<p class="hint">{{ screenTitleDraft.trim().length }} / 64</p>
<p v-if="error" class="msg err">{{ error }}</p>
<div class="modal-actions">
<button class="btn" type="button" :disabled="saving" @click="saveTitle">
{{ saving ? '保存中…' : '保存并生效' }}
</button>
<button class="btn ghost" type="button" @click="closeTitleEdit">取消</button>
</div>
</div>
</div>
<div v-if="showPollEdit" class="modal-mask" @click.self="closePollEdit"> <div v-if="showPollEdit" class="modal-mask" @click.self="closePollEdit">
<div class="modal panel"> <div class="modal panel">
<div class="panel-head"> <div class="panel-head">
...@@ -279,6 +380,9 @@ onMounted(load) ...@@ -279,6 +380,9 @@ onMounted(load)
.poll-form { .poll-form {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.title-form {
grid-template-columns: 1fr;
}
.modal-mask { .modal-mask {
position: fixed; position: fixed;
inset: 0; inset: 0;
......
...@@ -5,9 +5,9 @@ interface ImportMetaEnv { ...@@ -5,9 +5,9 @@ interface ImportMetaEnv {
readonly VITE_WS_URL: string readonly VITE_WS_URL: string
readonly VITE_VIEWPORT_MODE: 'glb' | 'ue-stream' | 'rtengine' readonly VITE_VIEWPORT_MODE: 'glb' | 'ue-stream' | 'rtengine'
readonly VITE_UE_STREAM_URL: string readonly VITE_UE_STREAM_URL: string
/** RTEngineHD 云渲染页(推荐同域 /rtengine/gis.html?scene=…) */ /** RTEngineHD 云渲染页(推荐同域 /gis/gis.html?scene=…) */
readonly VITE_RTENGINE_URL: string readonly VITE_RTENGINE_URL: string
/** /rtengine、/peer-stream、/designer 反代上游,如 http://192.168.32.121:6088 */ /** /gis、/peer-stream、/designer、/api/icons 反代上游,如 http://192.168.32.110:6088 */
readonly VITE_RTENGINE_PROXY_TARGET: string readonly VITE_RTENGINE_PROXY_TARGET: string
} }
......
...@@ -10,7 +10,7 @@ export default defineConfig(({ mode }) => { ...@@ -10,7 +10,7 @@ export default defineConfig(({ mode }) => {
process.env.VITE_RTENGINE_PROXY_TARGET || process.env.VITE_RTENGINE_PROXY_TARGET ||
'http://127.0.0.1:6088' 'http://127.0.0.1:6088'
/** 同域反代 RTEngine:页面走 /rtengine,信令/场景 API 走根路径(引擎写死绝对路径) */ /** 同域反代 RTEngine:页面走 /gis,信令/场景 API 走根路径(引擎写死绝对路径) */
const rtengineProxy = (extra: ProxyOptions = {}): ProxyOptions => ({ const rtengineProxy = (extra: ProxyOptions = {}): ProxyOptions => ({
target: rtengineTarget, target: rtengineTarget,
changeOrigin: true, changeOrigin: true,
...@@ -37,12 +37,18 @@ export default defineConfig(({ mode }) => { ...@@ -37,12 +37,18 @@ export default defineConfig(({ mode }) => {
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
// 引擎图标:必须先于业务 /api,否则 iframe 绝对路径 /api/icons 会打到 Node
'/api/icons': rtengineProxy(),
'/api': 'http://localhost:3001', '/api': 'http://localhost:3001',
'/ws': { '/ws': {
target: 'ws://localhost:3001', target: 'ws://localhost:3001',
ws: true, ws: true,
}, },
// iframe 页与相对资源:/rtengine/gis.html → 6088/gis.html // iframe 页与相对资源:/gis/gis.html → 6088/gis.html(勿转发到引擎空目录 /gis/)
'/gis': rtengineProxy({
rewrite: (p) => p.replace(/^\/gis/, '') || '/',
}),
// 兼容旧路径
'/rtengine': rtengineProxy({ '/rtengine': rtengineProxy({
rewrite: (p) => p.replace(/^\/rtengine/, '') || '/', rewrite: (p) => p.replace(/^\/rtengine/, '') || '/',
}), }),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment