Commit 07f3eebc by James Yuan

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
parent eff3fd97
......@@ -59,8 +59,8 @@ VITE_API_BASE=
VITE_WS_URL=
# 三维视口:glb | ue-stream | rtengine(RTEngineHD 云渲染 iframe)
VITE_VIEWPORT_MODE=rtengine
# 推荐同域反代(postMessage 同域);开发/Nginx 已代理 /rtengine + /peer-stream + /designer
VITE_RTENGINE_URL=/rtengine/gis.html?scene=mvlpj2yt6379aa29
VITE_UE_STREAM_URL=/rtengine/gis.html?scene=mvlpj2yt6379aa29
# 推荐同域反代(postMessage 同域);开发/Nginx 已代理 /gis + /peer-stream + /designer + /api/icons
VITE_RTENGINE_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
VITE_UE_STREAM_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
# 反代上游引擎地址(110 本机推流用 127.0.0.1)
VITE_RTENGINE_PROXY_TARGET=http://127.0.0.1:6088
......@@ -75,8 +75,8 @@ npm run dev
- `DATABASE_URL` PostgreSQL 连接串
- `DATA_MODE=mock|modbus`
- `VITE_VIEWPORT_MODE=glb|ue-stream|rtengine`
- `VITE_RTENGINE_URL=` RTEngineHD 云渲染页(推荐同域 `/rtengine/gis.html?scene=mvg8x4b453b45f38`)
- `VITE_RTENGINE_PROXY_TARGET=` 反代上游(如 `http://192.168.32.121:6088`);开发代理 `/rtengine` + `/peer-stream` + `/designer`
- `VITE_RTENGINE_URL=` RTEngineHD 云渲染页(推荐同域 `/gis/gis.html?scene=mvlpj2yt6379aa29`)
- `VITE_RTENGINE_PROXY_TARGET=` 反代上游(如 `http://192.168.32.110:6088`);开发代理 `/gis` + `/peer-stream` + `/designer` + `/api/icons`
- `VITE_UE_STREAM_URL=` UE/云渲染 iframe 备用地址
- `ACCESS_BRIDGE_URL=` 门禁桥接
......
......@@ -43,7 +43,7 @@ for u in [
"http://192.168.32.110/",
"http://192.168.32.110/dashboard/overview",
"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:
r = urllib.request.urlopen(u, timeout=12)
......
......@@ -6,7 +6,7 @@ for u in [
"http://192.168.32.110/",
"http://192.168.32.110/dashboard/overview",
"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:
r = urllib.request.urlopen(u, timeout=12)
......
......@@ -41,7 +41,7 @@ c.close()
for u in [
"http://192.168.32.110/",
"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)
body = r.read()
......
......@@ -12,8 +12,8 @@ WORKDIR /app/web
ARG VITE_API_BASE=
ARG VITE_VIEWPORT_MODE=rtengine
ARG VITE_RTENGINE_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38
ARG VITE_UE_STREAM_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38
ARG VITE_RTENGINE_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ARG VITE_UE_STREAM_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ENV VITE_API_BASE=$VITE_API_BASE \
VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \
VITE_RTENGINE_URL=$VITE_RTENGINE_URL \
......
......@@ -11,6 +11,11 @@ http {
root D:/apps/jh-lab/platform/web;
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/ {
proxy_pass http://127.0.0.1:3001/api/;
proxy_http_version 1.1;
......@@ -26,6 +31,15 @@ http {
proxy_set_header Host $host;
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/ {
proxy_pass http://127.0.0.1:6088/;
proxy_http_version 1.1;
......
......@@ -11,6 +11,11 @@ http {
root D:/apps/jh-lab/platform/web;
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/ {
proxy_pass http://127.0.0.1:3001/api/;
proxy_http_version 1.1;
......@@ -26,6 +31,16 @@ http {
proxy_set_header Host $host;
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/ {
proxy_pass http://127.0.0.1:6088/;
proxy_http_version 1.1;
......
......@@ -75,15 +75,15 @@ services:
args:
VITE_API_BASE: ""
VITE_VIEWPORT_MODE: ${VITE_VIEWPORT_MODE:-rtengine}
VITE_RTENGINE_URL: ${VITE_RTENGINE_URL:-/rtengine/gis.html?scene=mvg8x4b453b45f38}
VITE_UE_STREAM_URL: ${VITE_UE_STREAM_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:-/gis/gis.html?scene=mvlpj2yt6379aa29}
container_name: jh-lab-web
restart: unless-stopped
depends_on:
- server
ports:
- "${WEB_PORT:-18080}:80"
# 热更新 nginx 反代(/rtengine → 121:6088),避免旧镜像缺云渲染路由
# 热更新 nginx 反代(/gis → 引擎),避免旧镜像缺云渲染路由
volumes:
- ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
......
......@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS write_audit_log (
CREATE TABLE IF NOT EXISTS system_settings (
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),
screen_title TEXT NOT NULL DEFAULT '分子实验室三维可视化管理平台',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
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);
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
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 = `
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 system_settings ADD COLUMN IF NOT EXISTS screen_title TEXT NOT NULL DEFAULT '分子实验室三维可视化管理平台';
`
/** 纠正房间/冰箱传感器被 CSV 误标为写点的问题 */
......
......@@ -12,6 +12,14 @@ import { ensureSettingsSeed } from './settings/store.js'
import { ensureVideoSeed } from './video/store.js'
import { ensureLightSeed } from './light/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 { usersRouter } from './routes/users.js'
import { realtimeRouter } from './routes/realtime.js'
......@@ -20,6 +28,7 @@ import { settingsRouter } from './routes/settings.js'
import { videoRouter } from './routes/video.js'
import { lightRouter } from './routes/light.js'
import { devicesRouter } from './routes/devices.js'
import { sensorsRouter } from './routes/sensors.js'
import { gatewayRouter } from './routes/gateway.js'
import { alarmsRouter } from './routes/alarms.js'
import { weatherRouter } from './routes/weather.js'
......@@ -46,6 +55,42 @@ async function bootstrap() {
ensureVideoSeed()
ensureLightSeed()
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()
app.use(cors())
......@@ -69,6 +114,7 @@ async function bootstrap() {
app.use('/api/video', videoRouter)
app.use('/api/light', lightRouter)
app.use('/api/devices', devicesRouter)
app.use('/api/sensors', sensorsRouter)
app.use('/api/alarms', alarmsRouter)
app.use('/api/weather', weatherRouter)
/** Windows 网关上行:拉点表 / 推测点落库 */
......
......@@ -127,6 +127,15 @@ export function generateSnapshot(): RealtimeSnapshot {
temp: i === 2 ? jitter(-18.6, 0.3) : jitter(-20 + (i % 3), 0.4),
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: {
powerKwh: jitter(486.2, 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 { listDevices, listGateways, listPoints } from '../devices/store.js'
import type { Device, DevicePoint } from '../devices/types.js'
import { listSensors } from '../sensors/store.js'
import type {
FridgeSnapshot,
NeLightSnapshot,
RealtimeSnapshot,
RoomSnapshot,
SensorSnapshot,
VavSnapshot,
} from '../types.js'
import { NE_LIGHT_DEFS } from './neLights.js'
......@@ -128,23 +130,92 @@ function sumEng(devices: Device[], byDevice: Map<string, DevicePoint[]>, nameRe:
/**
* 从设备台账 + 点位 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> {
const [devices, points, gateways, pointAlarms] = await Promise.all([
const [devices, points, gateways, pointAlarms, sensors] = await Promise.all([
listDevices(),
listPoints(),
listGateways(),
evaluatePointAlarms().catch(() => []),
listSensors({ enabled: true }).catch(() => []),
])
const enabled = devices.filter((d) => d.enabled)
const byDevice = new Map<string, DevicePoint[]>()
const pointById = new Map<string, DevicePoint>()
for (const p of points) {
pointById.set(p.id, p)
if (!p.deviceId || !p.enabled) continue
const list = byDevice.get(p.deviceId) || []
list.push(p)
byDevice.set(p.deviceId, list)
}
const sensorSnapshots: SensorSnapshot[] = sensors.map((s) => sensorFromBinds(s, pointById))
const rooms = enabled
.filter((d) => d.category === 'room')
.map((d) => roomFromDevice(d, byDevice.get(d.id) || []))
......@@ -220,6 +291,7 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> {
vav,
fridges,
neLights,
sensors: sensorSnapshots,
metering: {
powerKwh: sumEng(powerMeters, byDevice, /用电量|有功电能|总有功/),
waterM3: sumEng(waterMeters, byDevice, /用水量|累计流量|流量/),
......
......@@ -4,6 +4,18 @@
*/
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 = {
id: string
kind: NeLightKind
......@@ -11,29 +23,30 @@ export type NeLightDef = {
deviceId: string
/** 点位 name,如 KA1 */
pointName: string
scene: { x: number; y: number; z: number }
}
export const NE_LIGHT_DEFS: NeLightDef[] = [
// N电
{ id: 'n-seq', kind: 'N', zone: '测序区', deviceId: 'mb-104-qtn1', pointName: 'KA1' },
{ id: 'n-lib-2', kind: 'N', zone: '文库制备区2', deviceId: 'mb-104-qtn2', pointName: 'KA1' },
{ id: 'n-lib-1', kind: 'N', zone: '文库制备区1', deviceId: 'mb-104-qtn3', pointName: 'KA1' },
{ id: 'n-sample-2', kind: 'N', zone: '样本制备区2', deviceId: 'mb-104-qtn4', pointName: 'KA1' },
{ id: 'n-sample-1', kind: 'N', zone: '样本制备区1', deviceId: 'mb-104-qtn4', pointName: 'KA3' },
{ id: 'n-report', kind: 'N', zone: '报告室', deviceId: 'mb-104-qtn5', pointName: 'KA1' },
{ id: 'n-corridor', kind: 'N', zone: 'NGS走廊', deviceId: 'mb-104-qtn6', pointName: 'KA1' },
// E电
{ id: 'e-seq-buf', kind: 'E', zone: '测序区缓冲', deviceId: 'mb-104-qte1', pointName: 'KA1' },
{ id: 'e-seq', kind: 'E', zone: '测序区', deviceId: 'mb-104-qte1', pointName: 'KA2' },
{ id: 'e-lib-2-buf', kind: 'E', zone: '文库制备区2缓冲', deviceId: 'mb-104-qte2', pointName: 'KA1' },
{ id: 'e-lib-2', kind: 'E', zone: '文库制备区2', deviceId: 'mb-104-qte2', pointName: 'KA2' },
{ id: 'e-lib-1-buf', kind: 'E', zone: '文库制备区1缓冲', deviceId: 'mb-104-qte3', pointName: 'KA1' },
{ id: 'e-lib-1', kind: 'E', zone: '文库制备区1', deviceId: 'mb-104-qte3', pointName: 'KA2' },
{ id: 'e-sample-1-buf', kind: 'E', zone: '样本制备区1缓冲', deviceId: 'mb-104-qte4', pointName: 'KA1' },
{ id: 'e-sample-2', kind: 'E', zone: '样本制备区2', deviceId: 'mb-104-qte4', pointName: 'KA2' },
{ id: 'e-sample-1', kind: 'E', zone: '样本制备区1', deviceId: 'mb-104-qte4', pointName: 'KA3' },
{ id: 'e-report', kind: 'E', zone: '报告室', deviceId: 'mb-104-qte5', pointName: 'KA1' },
{ id: 'e-prep', kind: 'E', zone: '准备间', deviceId: 'mb-104-qte5', pointName: 'KA2' },
{ id: 'e-recv', kind: 'E', zone: '咨询室', deviceId: 'mb-104-qte5', pointName: 'KA3' },
{ id: 'e-corridor', kind: 'E', zone: '走廊', deviceId: 'mb-104-qte6', pointName: 'KA1' },
// N电(普通照明)
{ 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', scene: uv(0.27, 0.42) },
{ 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', scene: uv(0.55, 0.52) },
{ 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', scene: uv(0.88, 0.4) },
{ id: 'n-corridor', kind: 'N', zone: 'NGS走廊', deviceId: 'mb-104-qtn6', pointName: 'KA1', scene: uv(0.5, 0.12) },
// E电(应急照明)
{ 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', scene: uv(0.1, 0.55) },
{ 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', scene: uv(0.25, 0.55) },
{ 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', scene: uv(0.4, 0.55) },
{ 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', scene: uv(0.55, 0.55) },
{ 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', scene: uv(0.88, 0.38) },
{ 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', scene: uv(0.88, 0.88) },
{ 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 = {
vav: [],
fridges: [],
neLights: [],
sensors: [],
metering: {
powerKwh: 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'
import {
getSettings,
updateCollectPollMs,
updateScreenTitle,
MIN_POLL_MS,
MAX_POLL_MS,
MAX_SCREEN_TITLE_LEN,
} from '../settings/store.js'
import { setCollectPollMs } from '../realtime/store.js'
......@@ -30,3 +32,22 @@ settingsRouter.put('/collect-poll', authRequired, requireRole('admin'), async (r
await setCollectPollMs(settings.collectPollMs)
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)
})
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'
import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js'
export const DEFAULT_SCREEN_TITLE = '分子实验室三维可视化管理平台'
export const MAX_SCREEN_TITLE_LEN = 64
export interface SystemSettings {
collectPollMs: number
screenTitle: string
dataMode: string
collectRetentionDays: number
updatedAt: string
......@@ -19,13 +23,23 @@ export function clampPollMs(ms: number) {
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 memScreenTitle = DEFAULT_SCREEN_TITLE
let memUpdatedAt = new Date().toISOString()
let memUpdatedBy = 'system'
function base(extra: Partial<SystemSettings> = {}): SystemSettings {
return {
collectPollMs: memPollMs,
screenTitle: memScreenTitle,
dataMode: config.dataMode,
collectRetentionDays: config.collectRetentionDays,
updatedAt: memUpdatedAt,
......@@ -40,9 +54,9 @@ export async function ensureSettingsSeed() {
const { rows } = await query(`SELECT id FROM system_settings WHERE id = 1`)
if (rows.length) return
await query(
`INSERT INTO system_settings (id, collect_poll_ms, updated_by)
VALUES (1, $1, 'system')`,
[clampPollMs(config.modbus.pollMs)],
`INSERT INTO system_settings (id, collect_poll_ms, screen_title, updated_by)
VALUES (1, $1, $2, 'system')`,
[clampPollMs(config.modbus.pollMs), DEFAULT_SCREEN_TITLE],
)
}
......@@ -50,12 +64,17 @@ export async function getSettings(): Promise<SystemSettings> {
if (!isPgEnabled()) return base()
const { rows } = await query<{
collect_poll_ms: number
screen_title: string | null
updated_at: Date
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]
return {
collectPollMs: r ? Number(r.collect_poll_ms) : clampPollMs(config.modbus.pollMs),
screenTitle: normalizeScreenTitle(r?.screen_title || DEFAULT_SCREEN_TITLE),
dataMode: config.dataMode,
collectRetentionDays: config.collectRetentionDays,
updatedAt: r?.updated_at?.toISOString() || new Date().toISOString(),
......@@ -86,4 +105,21 @@ export async function updateCollectPollMs(ms: number, by: string): Promise<Syste
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 }
......@@ -88,6 +88,38 @@ export interface PointAlarmSnapshot {
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 {
ts: number
rooms: RoomSnapshot[]
......@@ -107,6 +139,8 @@ export interface RealtimeSnapshot {
fridges: FridgeSnapshot[]
/** N电/E电照明回路(大屏普通/应急照明态) */
neLights?: NeLightSnapshot[]
/** 传感器聚合(压差等:多点位 → 单打点) */
sensors?: SensorSnapshot[]
metering: {
powerKwh: number
waterM3: number
......
......@@ -2,6 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
import { BRIEFING_INFRA, CAMERA_FACILITY_DEFS } from '../realtime/cameraAccess.js'
export type DeviceStatus = 'online' | 'offline' | 'alarm'
export type DeviceType = 'ipc' | 'nvr-ch'
......@@ -180,8 +181,109 @@ function save(data: VideoData) {
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() {
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 {
......
......@@ -12,8 +12,8 @@ WORKDIR /app/web
ARG VITE_API_BASE=
ARG VITE_VIEWPORT_MODE=rtengine
ARG VITE_RTENGINE_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38
ARG VITE_UE_STREAM_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38
ARG VITE_RTENGINE_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ARG VITE_UE_STREAM_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ENV VITE_API_BASE=$VITE_API_BASE \
VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \
VITE_RTENGINE_URL=$VITE_RTENGINE_URL \
......
......@@ -6,6 +6,16 @@ server {
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/ {
proxy_pass http://server:3001/api/;
proxy_http_version 1.1;
......@@ -24,9 +34,9 @@ server {
proxy_read_timeout 86400;
}
# RTEngineHD:同域 iframe,便于 postMessage;媒体仍由 ICE 直连引擎机
# RTEngineHD:同域 /gis → 引擎根(勿转到引擎空目录 /gis/)
# 原生 nginx:127.0.0.1:6088;Docker 容器内需改为 host.docker.internal:6088
location /rtengine/ {
location /gis/ {
proxy_pass http://127.0.0.1:6088/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
......@@ -40,8 +50,19 @@ server {
proxy_buffering off;
}
location = /rtengine {
return 302 /rtengine/;
location = /gis {
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)
......
......@@ -9,22 +9,33 @@ const props = withDefaults(
left: number
top: number
src?: string
/** video=监控预览;values=其它设备的接口读数 */
kind?: 'video' | 'values'
/** video=监控预览;values=读数;switch=照明开/关 */
kind?: 'video' | 'values' | 'switch'
lines?: { label: string; value: string }[]
/** 当前开关态(switch) */
switchOn?: boolean | null
switching?: boolean
switchError?: string
}>(),
{
src: '/cams/lab-seq.mp4',
kind: 'video',
lines: () => [],
switchOn: null,
switching: false,
switchError: '',
},
)
const emit = defineEmits<{ close: [] }>()
const emit = defineEmits<{
close: []
control: [on: boolean]
}>()
const BOX_W = 400
const TITLE_H = 36
const boxH = computed(() => {
if (props.kind === 'switch') return TITLE_H + 120
if (props.kind === 'values') {
const rows = Math.max(props.lines.length, 1)
return Math.min(420, TITLE_H + rows * 36 + 16)
......@@ -157,9 +168,16 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
×
</button>
</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
v-if="kind !== 'values'"
v-if="kind === 'video'"
class="near-video"
:src="src"
autoplay
......@@ -168,12 +186,39 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
playsinline
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">
<span>{{ row.label }}</span>
<b>{{ row.value }}</b>
</li>
</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>
......@@ -257,6 +302,73 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
background: #071426;
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 {
list-style: none;
margin: 0;
......
......@@ -14,12 +14,15 @@ import AppIcon from '@/components/icons/AppIcon.vue'
import MarkerNearDialog from '@/components/scene/MarkerNearDialog.vue'
import { api } from '@/api/http'
import {
MARKER_LEADER_HEIGHT_M,
preloadRtengineIcons,
useRtengineBridge,
type SceneMarkerHit,
type ScenePoint,
} from '@/composables/useRtengineBridge'
import { useScreenModePois } from '@/composables/useScreenModePois'
import { useAuthStore } from '@/stores/auth'
import type { SensorSnapshot } from '@/types'
import type { VideoDevice } from '@/types/video'
const props = withDefaults(
......@@ -33,19 +36,20 @@ const props = withDefaults(
const realtime = useRealtimeStore()
const screenMode = useScreenModeStore()
const auth = useAuthStore()
const { mode: activeMode } = storeToRefs(screenMode)
type ViewportMode = 'glb' | 'ue-stream' | 'rtengine'
const mode = (import.meta.env.VITE_VIEWPORT_MODE || 'rtengine') as ViewportMode
/**
* RTEngineHD 云渲染地址(默认同域 /rtengine 反代,便于 postMessage)。
* RTEngineHD 云渲染地址(默认同域 /gis 反代,便于 postMessage)。
* 信令走 /peer-stream、书签走 /designer(见 vite/nginx 代理);
* WebRTC 媒体仍由 ICE 直连引擎机,一般不黑屏。
*/
const cloudUrl =
(import.meta.env.VITE_RTENGINE_URL || '').trim() ||
(import.meta.env.VITE_UE_STREAM_URL || '').trim() ||
'/rtengine/gis.html?scene=mvlpj2yt6379aa29'
'/gis/gis.html?scene=mvlpj2yt6379aa29'
const useCloudFrame = computed(
() =>
......@@ -62,9 +66,13 @@ const cameras = ref<VideoDevice[]>([])
/** Marker 附近弹窗(400×240) */
const nearOpen = ref(false)
const nearTitle = ref('监控预览')
const nearKind = ref<'video' | 'values'>('video')
const nearKind = ref<'video' | 'values' | 'switch'>('video')
const nearLines = ref<{ label: string; value: string }[]>([])
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 nearTop = ref(120)
/** 关闭后短时忽略 Marker 点击,防止卸层点击穿透再次打开 */
......@@ -289,10 +297,71 @@ function openNearDialog(title: string, markerId: string | null, raw: unknown) {
function closeNearDialog() {
nearOpen.value = false
nearSensorId.value = null
nearSwitchError.value = ''
nearSwitching.value = false
nearCloseGuardUntil = Date.now() + 400
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() {
return cameras.value
.filter((d) => d.enabled && d.scenePoint)
......@@ -306,6 +375,10 @@ function cameraMarkerList() {
icon: d.scenePoint!.icon || 'cctv-blue.png',
tag0: 'camera',
tag1: d.id,
leader: true,
leaderHeight: MARKER_LEADER_HEIGHT_M,
leaderThickness: 2,
leaderColor: '#00f7ff',
}))
}
......@@ -438,13 +511,25 @@ function onMarkerClick(payload: {
if (category === 'camera' || cam) {
nearKind.value = 'video'
nearLines.value = []
nearSensorId.value = null
openNearDialog(resolveNearTitle(cam, marker, markerId), markerId || marker.id, raw)
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)
if (!readout) return
nearKind.value = 'values'
nearLines.value = readout.lines
nearSensorId.value = null
openNearDialog(readout.title, markerId || marker.id, raw)
}
......@@ -602,7 +687,11 @@ function onSelectOverview() {
}
function onSelectMode(next: ScreenMode) {
// 右侧设备分类:只切换场景图标类型(压差/照明/冰箱…)
screenMode.setMode(next)
applyCameraVisibility()
// 分类切换后补一次,避免引擎异步过滤把新点藏掉
window.setTimeout(() => applyCameraVisibility(), 300)
}
watch(activeMode, () => applyCameraVisibility())
......@@ -610,7 +699,15 @@ watch(activeMode, () => applyCameraVisibility())
watch(
() => 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)
if (readout) nearLines.value = readout.lines
},
......@@ -777,10 +874,14 @@ onBeforeUnmount(() => {
:title="nearTitle"
:kind="nearKind"
:lines="nearLines"
:switch-on="nearSwitchOn"
:switching="nearSwitching"
:switch-error="nearSwitchError"
:left="nearLeft"
:top="nearTop"
src="/cams/lab-seq.mp4"
@close="closeNearDialog"
@control="onLightControl"
/>
</div>
</template>
......
......@@ -3,17 +3,63 @@ import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import {
defaultRtengineUrl,
fetchRtengineIcons,
MARKER_LEADER_HEIGHT_M,
preloadRtengineIcons,
rtengineIconUrl,
useRtengineBridge,
type RtengineMarker,
type ScenePoint,
} from '@/composables/useRtengineBridge'
const props = defineProps<{
open: boolean
title?: string
initial?: ScenePoint | null
}>()
const PREVIEW_ID = '__pick_preview__'
const props = withDefaults(
defineProps<{
open: boolean
title?: string
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<{
close: []
......@@ -23,25 +69,16 @@ const emit = defineEmits<{
const cloudUrl = defaultRtengineUrl()
const iframeRef = ref<HTMLIFrameElement | null>(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 iconError = ref('')
const iconFilter = ref('')
const hint = ref('场景加载后,点击地面拾取摄像头位置')
const hint = ref(props.pickHint)
const preferred = [
'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 isTextOnly = computed(() => props.textOnly || props.markerType === 'label')
const filteredIcons = computed(() => {
const preferred = props.preferredIcons || []
const q = iconFilter.value.trim().toLowerCase()
let list = icons.value.slice()
list.sort((a, b) => {
......@@ -59,31 +96,86 @@ const bridge = useRtengineBridge({
cloudUrl,
onMouseClick: ({ point, markerId }) => {
if (point) {
picked.value = { ...point, icon: selectedIcon.value }
const icon = isTextOnly.value ? '' : selectedIcon.value
picked.value = { ...point, icon }
hint.value = markerId
? `已拾取(对象 ${markerId})`
? `已拾取(对象 ${markerId})· 效果已按设备类型预览`
: `已拾取 X=${point.x.toFixed(6)} Y=${point.y.toFixed(6)} Z=${point.z.toFixed(2)}`
syncPreview()
}
},
onReady: () => {
hint.value = '引擎已就绪 · 正在加载预设图标…'
hint.value = isTextOnly.value
? '引擎已就绪 · 点击地面放置文字标签'
: '引擎已就绪 · 正在加载预设图标…'
bridge.setPickMode(true)
bridge.showScenePresets()
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) {
if (isTextOnly.value) {
icons.value = []
selectedIcon.value = ''
if (fromEngine) hint.value = '已就绪 · 点击地面放置文字标签(不显示图标)'
return
}
iconError.value = ''
try {
icons.value = fromEngine
? await preloadRtengineIcons(undefined, cloudUrl)
: await fetchRtengineIcons(cloudUrl)
if (!icons.value.includes(selectedIcon.value) && icons.value.length) {
const cam = icons.value.find((f) => /cctv|camera/i.test(f))
selectedIcon.value = cam || icons.value[0]
const preferred = props.preferredIcons || []
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} 个场景预设图标 · 点击地面拾取`
syncPreview()
} catch (e) {
iconError.value = e instanceof Error ? e.message : '图标列表加载失败'
}
......@@ -94,38 +186,61 @@ watch(
(v) => {
if (v) {
picked.value = props.initial ? { ...props.initial } : null
selectedIcon.value = props.initial?.icon || 'cctv-blue.png'
hint.value = '场景加载后,点击地面拾取摄像头位置'
selectedIcon.value = isTextOnly.value
? ''
: props.initial?.icon || props.defaultIcon
hint.value = props.pickHint
void loadIcons()
window.setTimeout(() => bridge.setPickMode(true), 1500)
window.setTimeout(() => {
bridge.setPickMode(true)
syncPreview()
}, 1500)
} else {
clearPreview()
bridge.setPickMode(false)
}
},
)
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(() => {
if (props.open) {
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() {
if (!picked.value) {
hint.value = '请先在场景中点击拾取位置'
return
}
emit('save', { ...picked.value, icon: selectedIcon.value })
const icon = isTextOnly.value ? '' : selectedIcon.value
emit('save', { ...picked.value, icon })
}
function clearPoint() {
clearPreview()
picked.value = null
hint.value = '已清除,请重新点击拾取'
}
......@@ -137,14 +252,14 @@ function iconSrc(file: string) {
<template>
<div v-if="open" class="mask" @click.self="emit('close')">
<div class="dialog">
<div class="dialog" :class="{ 'no-icons': isTextOnly }">
<div class="head">
<h3>{{ title || '三维场景打点' }}</h3>
<button class="btn ghost" type="button" @click="emit('close')">关闭</button>
</div>
<p class="hint">{{ hint }}</p>
<div class="body">
<aside class="icon-pane">
<aside v-if="!isTextOnly" class="icon-pane">
<div class="icon-head">
<span>引擎图标</span>
<input v-model="iconFilter" class="icon-search" placeholder="筛选…" />
......@@ -170,6 +285,13 @@ function iconSrc(file: string) {
<b>{{ selectedIcon || '—' }}</b>
</div>
</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">
<iframe
ref="iframeRef"
......@@ -340,6 +462,27 @@ function iconSrc(file: string) {
text-overflow: ellipsis;
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 {
min-height: 0;
background: #000;
......
......@@ -2,6 +2,9 @@ import { onBeforeUnmount, ref, type Ref } from 'vue'
export type ScenePoint = { x: number; y: number; z: number; icon?: string }
/** 默认引线高度(米):图标相对地面锚点抬升 */
export const MARKER_LEADER_HEIGHT_M = 2
export type RtengineMarker = {
id: string
name: string
......@@ -16,11 +19,17 @@ export type RtengineMarker = {
scale?: number
/** 引擎图标文件名,如 cctv-blue.png */
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_PROXY_PATH = '/rtengine'
export const RTENGINE_PROXY_PATH = '/gis'
export function defaultRtengineUrl() {
return (
......@@ -39,8 +48,8 @@ export function rtengineOrigin(cloudUrl = defaultRtengineUrl()) {
}
/**
* 引擎资源前缀:同域反代时为 /rtengine,直连 6088 时为空。
* 注意:不能把图标打到本站 /api(会撞业务 API)。
* 引擎资源前缀:同域反代时为 /gis,直连 6088 时为空。
* 注意:图标请走 /gis/api/icons 或专用 /api/icons 反代,勿撞业务 /api。
*/
export function rtengineAssetPrefix(cloudUrl = defaultRtengineUrl()) {
try {
......@@ -48,6 +57,10 @@ export function rtengineAssetPrefix(cloudUrl = defaultRtengineUrl()) {
if (u.pathname.startsWith(`${RTENGINE_PROXY_PATH}/`) || u.pathname === RTENGINE_PROXY_PATH) {
return RTENGINE_PROXY_PATH
}
// 兼容旧 /rtengine 前缀
if (u.pathname.startsWith('/rtengine/') || u.pathname === '/rtengine') {
return '/rtengine'
}
// 直连引擎机:资源挂在 origin 根路径
return ''
} catch {
......@@ -317,7 +330,7 @@ export function useRtengineBridge(opts: {
}
if (!sceneId) return
const urls = [
`/rtengine/designer/data/scenes/${encodeURIComponent(sceneId)}.json`,
`/gis/designer/data/scenes/${encodeURIComponent(sceneId)}.json`,
`/designer/data/scenes/${encodeURIComponent(sceneId)}.json`,
`/designer/api/embed/scenes/${encodeURIComponent(sceneId)}`,
]
......@@ -378,22 +391,40 @@ export function useRtengineBridge(opts: {
function markerPayload(m: RtengineMarker) {
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 {
id: m.id,
name: m.name,
text: m.text ?? m.name,
showText: m.showText !== false,
type: m.type || 'marker',
// 引擎同时认 x/y/z 与 lng/lat/height
x: m.x,
y: m.y,
z: m.z,
scale: m.scale ?? 1,
scalex: m.scale ?? 1,
scaley: m.scale ?? 1,
scalez: m.scale ?? 1,
lng: m.x,
lat: m.y,
height: m.z,
longitude: m.x,
latitude: m.y,
scale,
scalex: scale,
scaley: scale,
scalez: scale,
tag0: m.tag0 || 'camera',
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: {
send('addMarker', markerPayload(m))
}
/** 引擎仅有 addMarker,无 addMarkers;批量时逐条写入 */
function addMarkers(list: RtengineMarker[]) {
send(
'addMarkers',
list.map((m) => markerPayload(m)),
)
for (const m of list) addMarker(m)
}
function removeMarker(id: string) {
......
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 }
}
......@@ -37,17 +37,17 @@ const navItems = computed(() =>
[
{
to: '/admin/devices',
label: '设备管理',
hint: '点位 / 网关',
label: '数据采集',
hint: '设备 / 点位 / 网关',
tone: 'accent' as const,
icon: 'devices',
},
{
to: '/admin/cameras',
label: '摄像头管理',
hint: '列表 / 三维打点',
to: '/admin/sensors',
label: '设施管理',
hint: '压差 / 照明 / 监控 / 门禁',
tone: 'accent' as const,
icon: 'cameras',
icon: 'devices',
},
{
to: '/admin/metering',
......
......@@ -9,6 +9,7 @@ import { useStageScale } from '@/composables/useStageScale'
import ScreenHeader from '@/components/screen/ScreenHeader.vue'
import SceneViewport from '@/components/scene/SceneViewport.vue'
import AppIcon from '@/components/icons/AppIcon.vue'
import { useScreenTitle } from '@/composables/useScreenTitle'
const auth = useAuthStore()
const realtime = useRealtimeStore()
......@@ -16,6 +17,7 @@ const router = useRouter()
const { time, date } = useClock()
const { place, temp, text: weatherText, humidity } = useMacauWeather()
const { style } = useStageScale()
const { title: screenTitle } = useScreenTitle()
const alarmOpen = ref(false)
const alarmBtnRef = ref<HTMLButtonElement | null>(null)
const alarmPanelStyle = ref<Record<string, string>>({})
......@@ -93,7 +95,7 @@ function goAdmin() {
<!-- 上层:标题 / 导航 / 业务面板悬浮 -->
<div class="ui-layer">
<ScreenHeader>
<ScreenHeader :title="screenTitle">
<template #left>
<div class="weather-wrap">
<span class="place">{{ place }}</span>
......
......@@ -108,7 +108,13 @@ const router = createRouter({
path: 'devices',
name: 'devices',
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',
......@@ -116,12 +122,7 @@ const router = createRouter({
component: () => import('@/views/history/HistoryView.vue'),
meta: { title: '历史曲线', requiresAuth: true },
},
{
path: 'cameras',
name: 'cameras',
component: () => import('@/views/cameras/CamerasView.vue'),
meta: { title: '摄像头管理', requiresAuth: true },
},
{ path: 'cameras', redirect: '/admin/sensors' },
{
path: 'metering',
name: 'metering',
......
......@@ -39,7 +39,7 @@ export interface FridgeSnapshot {
humidity: number
}
/** 普通照明(N电) / 应急照明(E电) 回路状态 */
/** 普通照明(N电) / 应急照明(E电) 回路状态 */
export interface NeLightSnapshot {
id: string
kind: 'N' | 'E'
......@@ -47,6 +47,32 @@ export interface NeLightSnapshot {
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 {
id: string
name: string
......@@ -94,6 +120,7 @@ export interface RealtimeSnapshot {
vav: VavSnapshot[]
fridges: FridgeSnapshot[]
neLights?: NeLightSnapshot[]
sensors?: SensorSnapshot[]
metering: {
powerKwh: number
waterM3: number
......
......@@ -241,6 +241,10 @@ onMounted(load)
:open="pickOpen"
:title="pickDevice ? `打点 · ${pickDevice.name}` : '三维打点'"
:initial="pickDevice?.scenePoint || null"
:preview-name="pickDevice?.name || '摄像头'"
:preview-text="pickDevice?.name || ''"
:show-text="false"
marker-type="marker"
@close="pickOpen = false"
@save="onPickSave"
/>
......
......@@ -67,7 +67,7 @@ function isBoolWrite(p: DevicePoint) {
return false
}
type Tab = 'devices' | 'points' | 'gateways'
type Tab = 'devices' | 'points'
type Dialog = 'import' | 'createPoint' | 'createDevice' | 'editPoint' | 'viewDevice' | null
const auth = useAuthStore()
......@@ -552,8 +552,11 @@ onUnmounted(stopViewRefresh)
<div class="panel-head">
<div class="panel-title">
<div class="vbar" />
<h3>设备与点位</h3>
<h3>数据采集</h3>
<div class="badge">点表 v{{ pointsVersion }}</div>
<span class="badge" :class="gatewayHealth?.ok ? '' : 'amber'">
Windows 网关 {{ gatewayHealth?.ok ? '在线' : gatewayHealth?.message || '未连接' }}
</span>
</div>
<div class="head-actions">
<button class="btn ghost" type="button" :disabled="loading" @click="loadAll">刷新</button>
......@@ -585,6 +588,21 @@ onUnmounted(stopViewRefresh)
</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">
<button
class="btn"
......@@ -602,14 +620,6 @@ onUnmounted(stopViewRefresh)
>
点位({{ points.length }})
</button>
<button
class="btn"
:class="{ ghost: tab !== 'gateways' }"
type="button"
@click="tab = 'gateways'"
>
网关({{ gateways.length }})
</button>
</div>
<div v-if="tab === 'points'" class="filters">
......@@ -665,7 +675,7 @@ onUnmounted(stopViewRefresh)
</tbody>
</table>
<table v-else-if="tab === 'points'" class="table">
<table v-else class="table">
<thead>
<tr>
<th>名称</th>
......@@ -746,29 +756,6 @@ onUnmounted(stopViewRefresh)
</tr>
</tbody>
</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>
</section>
......@@ -1219,7 +1206,47 @@ onUnmounted(stopViewRefresh)
color: var(--panel-muted);
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 {
position: fixed;
inset: 0;
......
......@@ -2,9 +2,11 @@
import { computed, onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { api } from '@/api/http'
import { DEFAULT_SCREEN_TITLE, publishScreenTitle } from '@/composables/useScreenTitle'
interface SystemSettings {
collectPollMs: number
screenTitle: string
dataMode: string
collectRetentionDays: number
updatedAt: string
......@@ -21,10 +23,12 @@ const ueUrl =
const settings = ref<SystemSettings | null>(null)
const pollMs = ref(2000)
const pollSec = ref(2)
const screenTitleDraft = ref(DEFAULT_SCREEN_TITLE)
const saving = ref(false)
const message = ref('')
const error = ref('')
const showPollEdit = ref(false)
const showTitleEdit = ref(false)
const presets = [
{ label: '1 秒', ms: 1000 },
......@@ -37,12 +41,24 @@ const presets = [
const rows = computed(() => [
{ 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: 'ue', label: '云渲染 / UE 地址', value: ueUrl },
{
key: 'dataMode',
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',
......@@ -54,6 +70,7 @@ const rows = computed(() => [
label: '数据采集频率',
value: settings.value ? `${settings.value.collectPollMs / 1000} 秒` : '—',
editable: true,
edit: 'poll',
},
{ key: 'access', label: '门禁桥接', value: 'ACCESS_BRIDGE_URL 未配置时使用 Mock' },
])
......@@ -81,12 +98,30 @@ function closePollEdit() {
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() {
message.value = ''
try {
const data = await api<SystemSettings>('/api/settings', { token: auth.token })
settings.value = data
syncFromMs(data.collectPollMs)
screenTitleDraft.value = data.screenTitle || DEFAULT_SCREEN_TITLE
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
}
......@@ -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)
</script>
......@@ -125,6 +191,9 @@ onMounted(load)
<div class="badge" :class="auth.isAdmin ? '' : 'amber'">
{{ auth.isAdmin ? '管理员' : '只读' }}
</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>
......@@ -132,7 +201,7 @@ onMounted(load)
</div>
<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">
<table class="table">
......@@ -148,7 +217,7 @@ onMounted(load)
v-if="r.editable && auth.isAdmin"
class="btn ghost"
type="button"
@click="openPollEdit"
@click="openEdit(r.edit)"
>
编辑
</button>
......@@ -165,6 +234,38 @@ onMounted(load)
</p>
</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 class="modal panel">
<div class="panel-head">
......@@ -279,6 +380,9 @@ onMounted(load)
.poll-form {
grid-template-columns: 1fr 1fr;
}
.title-form {
grid-template-columns: 1fr;
}
.modal-mask {
position: fixed;
inset: 0;
......
......@@ -5,9 +5,9 @@ interface ImportMetaEnv {
readonly VITE_WS_URL: string
readonly VITE_VIEWPORT_MODE: 'glb' | 'ue-stream' | 'rtengine'
readonly VITE_UE_STREAM_URL: string
/** RTEngineHD 云渲染页(推荐同域 /rtengine/gis.html?scene=…) */
/** RTEngineHD 云渲染页(推荐同域 /gis/gis.html?scene=…) */
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
}
......
......@@ -10,7 +10,7 @@ export default defineConfig(({ mode }) => {
process.env.VITE_RTENGINE_PROXY_TARGET ||
'http://127.0.0.1:6088'
/** 同域反代 RTEngine:页面走 /rtengine,信令/场景 API 走根路径(引擎写死绝对路径) */
/** 同域反代 RTEngine:页面走 /gis,信令/场景 API 走根路径(引擎写死绝对路径) */
const rtengineProxy = (extra: ProxyOptions = {}): ProxyOptions => ({
target: rtengineTarget,
changeOrigin: true,
......@@ -37,12 +37,18 @@ export default defineConfig(({ mode }) => {
server: {
port: 5173,
proxy: {
// 引擎图标:必须先于业务 /api,否则 iframe 绝对路径 /api/icons 会打到 Node
'/api/icons': rtengineProxy(),
'/api': 'http://localhost:3001',
'/ws': {
target: 'ws://localhost:3001',
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({
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