Commit 67c6cffb by 久林

Initial import of the Kiang Wu lab dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
parents
node_modules
**/node_modules
**/dist
.git
.cursor
**/.env
web/dist
server/data
*.log
agent-transcripts
_research_extract.txt
# ========== Docker / 本地共用 ==========
POSTGRES_DB=jh_lab
POSTGRES_USER=jh_lab
POSTGRES_PASSWORD=jh_lab_pass
POSTGRES_PORT=5432
# 服务连接串(本地开发直连本机 PG;Compose 内由 compose 覆盖为 @db)
DATABASE_URL=postgres://jh_lab:jh_lab_pass@127.0.0.1:5432/jh_lab
# Server
PORT=3001
SERVER_PORT=3001
WEB_PORT=8080
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=12h
DATA_MODE=mock
# DATA_MODE=modbus
# 采集数据保留天数(PostgreSQL)
COLLECT_RETENTION_DAYS=90
# Modbus gateways
MODBUS_PAU1_HOST=192.168.10.102
MODBUS_PAU2_HOST=192.168.10.103
MODBUS_ROOM_HOST=192.168.10.104
MODBUS_FRIDGE_HOST=192.168.10.105
MODBUS_PORT=502
MODBUS_POLL_MS=2000
# Access bridge (HCNetSDK Windows service), empty = mock
ACCESS_BRIDGE_URL=
# Default admin (seeded on first boot)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=Admin@123
# Web (Vite 开发用;容器部署走 Nginx 同域反代,构建时 VITE_API_BASE 留空)
VITE_API_BASE=
VITE_WS_URL=
VITE_VIEWPORT_MODE=glb
VITE_UE_STREAM_URL=
node_modules
dist
.DS_Store
*.log
.env
.env.local
server/data/*.db
server/data/*.json
web/dist
.vscode
.idea
# 分子实验室三维可视化管理平台
Vue 3 大屏 + Node 采集服务 + PostgreSQL 本地落库。一期使用 demo GLB 模型,预留 UE 推流;自控数据 Mock/Modbus;门禁预留海康 HCNetSDK 桥接;含用户管理、RBAC 与基础计费。
## 容器化部署(推荐)
依赖:Docker / Docker Compose。
```bash
cd dashboard
cp .env.example .env
# 按需修改 JWT_SECRET、数据库密码等
docker compose up -d --build
```
| 服务 | 地址 | 说明 |
|---|---|---|
| 大屏 Web | http://localhost:8080 | Nginx 静态资源,反代 `/api`、`/ws` |
| API | http://localhost:3001 | Node 采集与业务服务 |
| PostgreSQL | localhost:5432 | 用户、计费、全部采集快照 |
停止:
```bash
docker compose down
```
数据卷 `pgdata` 会保留数据库;彻底清空:
```bash
docker compose down -v
```
## 本地开发
1. 启动 PostgreSQL(可用 Compose 仅起库):
```bash
docker compose up -d db
```
2. 安装依赖并启动前后端:
```bash
npm install
npm run dev
```
- 前端:http://localhost:5173(Vite 代理 `/api`、`/ws`)
- 后端:http://localhost:3001
`DATABASE_URL` 默认:`postgres://jh_lab:jh_lab_pass@127.0.0.1:5432/jh_lab`
## 演示账号
| 用户 | 密码 | 角色 |
|---|---|---|
| admin | Admin@123 | 管理员(可写) |
| viewer | Viewer@123 | 只读 |
## 数据落库说明
启动时自动迁移表结构并写入种子数据。轮询采集的每一次快照都会写入:
- `collect_snapshots`:整包 JSON 快照
- `collect_samples`:房间 / VAV / 冷链 / 空调 / 计量 / 门禁 / 在线状态分项
- `billing_daily_usage`:当日计量汇总(供计费统计)
- `users` / `billing_tariff` / `write_audit_log`:用户、费率、写指令审计
默认保留最近 `COLLECT_RETENTION_DAYS`(90)天采集明细。
## 环境变量
见 `.env.example`。关键项:
- `DATABASE_URL` PostgreSQL 连接串
- `DATA_MODE=mock|modbus`
- `VITE_VIEWPORT_MODE=glb|ue-stream`
- `VITE_UE_STREAM_URL=` UE 推流地址
- `ACCESS_BRIDGE_URL=` 门禁桥接
## 目录
- `web/` 大屏前端
- `server/` 认证、用户、实时采集、计费、PG 落库
- `docker-compose.yml` 一键部署
- `docs/` 点位与门禁对接说明
services:
db:
image: postgres:16-alpine
container_name: jh-lab-db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-jh_lab}
POSTGRES_USER: ${POSTGRES_USER:-jh_lab}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-jh_lab_pass}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
server:
build:
context: .
dockerfile: server/Dockerfile
container_name: jh-lab-server
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PORT: 3001
DATABASE_URL: postgres://${POSTGRES_USER:-jh_lab}:${POSTGRES_PASSWORD:-jh_lab_pass}@db:5432/${POSTGRES_DB:-jh_lab}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-12h}
DATA_MODE: ${DATA_MODE:-mock}
MODBUS_PAU1_HOST: ${MODBUS_PAU1_HOST:-192.168.10.102}
MODBUS_PAU2_HOST: ${MODBUS_PAU2_HOST:-192.168.10.103}
MODBUS_ROOM_HOST: ${MODBUS_ROOM_HOST:-192.168.10.104}
MODBUS_FRIDGE_HOST: ${MODBUS_FRIDGE_HOST:-192.168.10.105}
MODBUS_PORT: ${MODBUS_PORT:-502}
MODBUS_POLL_MS: ${MODBUS_POLL_MS:-2000}
ACCESS_BRIDGE_URL: ${ACCESS_BRIDGE_URL:-}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-Admin@123}
COLLECT_RETENTION_DAYS: ${COLLECT_RETENTION_DAYS:-90}
ports:
- "${SERVER_PORT:-3001}:3001"
web:
build:
context: .
dockerfile: web/Dockerfile
args:
VITE_API_BASE: ""
VITE_VIEWPORT_MODE: ${VITE_VIEWPORT_MODE:-glb}
VITE_UE_STREAM_URL: ${VITE_UE_STREAM_URL:-}
container_name: jh-lab-web
restart: unless-stopped
depends_on:
- server
ports:
- "${WEB_PORT:-8080}:80"
volumes:
pgdata:
=== MODULE SHEET: Sheet1 ===
3| | 设备管理平台(2D) | | | | 设备管理平台(2.5D) | |
4| | 模块 | 功能 | 价格(元) | | 模块 | 功能 | 价格(元)
5| | 基础软件开发 | 设施管理 | 10000 | | 基础软件开发 | 设施管理 | 10000
6| | | 设备报修管理 | 10000 | | | 设备报修管理 | 10000
7| | | 安全巡检管理 | 10000 | | | 安全巡检管理 | 10000
8| | | 报表管理 | 10000 | | | 报表管理 | 10000
9| | | 告警管理 | 10000 | | | 告警管理 | 10000
10| | 移动端 | 智能移动运维管理(APP) | 40000 | | 移动端 | 智能移动运维管理(APP) | 40000
11| | 二维场景组态(2D) | 基础组态工具及组态场景开发:
肿瘤中心-地下三层
三层供应室、ICU、输血科
肿瘤中心-三层内镜中心
一层急诊手术室
二层超声介入室
二层检验科
二层EICU
九层ICU、负压手术室
八层负压病房
一层支气管镜室
总医用气体系统对接 | 33000 | | 二维场景组态(2.5D) | 基础组态工具及组态场景开发:
肿瘤中心-地下三层
三层供应室、ICU、输血科
肿瘤中心-三层内镜中心
一层急诊手术室
二层超声介入室
二层检验科
二层EICU
九层ICU、负压手术室
八层负压病房
一层支气管镜室
总医用气体系统对接 | 55000
12| | 系统对接 | 协议开发与测试:
净化空调
UPS系统对接
IT系统对接
配电能耗系统对接
医用气体系统对接 | 25000 | | 系统对接 | 协议开发与测试:
净化空调
UPS系统对接
IT系统对接
配电能耗系统对接
医用气体系统对接 | 25000
13| | 部署、调试、培训 | 本地部署、系统联调、现场操作培训 | 5000 | | 部署、调试、培训 | 本地部署、系统联调、现场操作培训 | 5000
14| | 维保服务 | 1年质保期免费 | 0 | | 维保服务 | 1年质保期免费 | 0
15| | 合计: | | 153000 | | 合计: | | 175000
######## 数字孪生硬件配置及功能描述(2).xls
=== Sheet1 rows=17 cols=4 ===
1|分子实验室数字孪生系统配置及功能描述 | | |
2|序号 | 名 称 | 功能项 | 主要配置及功能描述
3|1.0 | 硬件配置 | 核心交换机 | 华三(H3C)S5130V2-10P-LI 8电口+2光纤口全千兆三层网管企业级网络交换机 IPv6/堆叠/链路聚合/桌面无噪音
4|2.0 | | 上位机(工作站) | i7-14700F 20核心28线程 32G内存 2T+512G固态 RTX5070-12G独显 双网卡,24寸显示屏
5|3.0 | | 55吋触摸屏 | 55寸屏 I5 12代 16G+512G
6|4.0 | 实验室全景显示 | 三维空间建模 | 采用BIM轻量化技术对分子实验室进行三维重建;严格沿用现有旧有房间名称与空间布局,支持第一人称视角切换。
7|5.0 | | 环境参数可视化 | 在三维模型中动态绑定传感器数据,实时渲染并悬浮显示各区域的压差、温湿度等关键指标。
8|6.0 | | 空调运行状况 | 对空调机组(AHU)及排风机组的运行状态(如启停、频率、滤网状态)进行三维动画联动展示。
9|7.0 | | 数据调取与交互 | 支持空间内设备与传感器的点击交互,点击任意房间或设备即可弹出详细数据面板、历史趋势曲线及报警信息。
10|8.0 | 计量计费显示 | 电表计费 | 对接实验室智能电表网络,实时采集各区域用电数据。系统自动进行峰平谷电量计算与费用折算,支持生成多维度的电费账单与能耗报表。
11|9.0 | | 水表计费 | 集成智能水表数据,实时监测实验室给排水管网流量。针对自来水等不同水质进行独立计费,保障水资源的高效利用。
12|10.0 | | 空调冷冻水冷量 | 在空调冷冻水供回水分支管路部署冷热量表,实时采集供回水温度与流量数据。系统通过内置算法精确计算实际使用冷量,为中央空调的精细化成本分摊与能效评估提供数据支撑。
13|11.0 | 安防与综合监控 | 监控系统集成 | 将现有CCTV监控系统接入孪生平台;在三维场景中精准映射摄像头位置,支持点击调取实时视频流。
14|12.0 | | 门禁系统联动 | 集成门禁数据,在三维模型中实时显示各出入口状态(开/关/报警)及进出记录;异常时自动触发声光报警并联动弹出监控画面。
15|13.0 | 自控基本要求 | 压差传感器全覆盖 | 严格执行分子实验室生物安全及洁净度要求,在每间实验室的入口处标配高精度微压差传感器,实时监测房间内外压差梯度,防止交叉污染。
16|14.0 | | 现场端显示 | 在每间房间入口的物理墙面上安装86型液晶触摸显示面板,确保现场人员可随时直观确认当前压差状态。
17|15.0 | | 数字端显示 | 将现场压差数据实时映射至数字孪生系统三维模型中,在对应房间入口处悬浮显示数值;一旦压差异常,系统自动标红预警,并联动自控系统调节风机频率或新风阀门等,形成闭环控制。
=== Sheet2 rows=0 cols=0 ===
=== Sheet3 rows=0 cols=0 ===
######## 3维改造自控报价清单20260730 V3.0(睿图) - 副本.xls
=== 三维 rows=24 cols=9 ===
1|3维改造自控报价清单 | | | | | | | |
2|序号 | 设备名称 | 单位 | 数量 | 品牌/厂家 | 规格/型号 | 价格(元) | 备注 |
3|中央监控系统(三维)实验室的面积为170平方米 | | | | | | | |
4|1.0 | 核心交换机 | 台 | 1.0 | 国优 | TL-SE2206(2万兆光口+4个2.5G网口) | | 自行采购 |
5|2.0 | 55吋触摸屏 | 台 | 1.0 | 国优 | CPU:i7-14700 ,内存显卡:32G/512G+2T/RTX5060-8G,55吋 | | 自行采购 |
6|3.0 | 实验室三维智能数据平台系统 | 套 | 1.0 | ZBCS | 1.项目名称:实验室三维智能数据平台系统;
2.项目特征:本平台根据实验室专业数据要求,提供规范化,强适应性的底层业务数据管理架构,用以实现客户不同业务线的数字化应用快速落地。基于平台架构、丰富的功能模块和工具库,可轻松对接实验室各种业务场景,打造数字驱动、数用一体的先进智能化管理体系。可规范、有序、按需建立实验室上层应用体系,覆盖日常管理和长效运维功能。支持分布式部署,大容量可扩展,负载均衡,长期运行高可用服务系统。最重要的是能够灵活应对组织与需求变化,满足业务的可增长性。 | 75000.0 | 我们的理解是需要一套实验室管理系统LIMS作为基础业务管理平台,主要功能大概是:集合设备管理、样品管理、报告管理、数据抓取、数据分析、统计等一体化的管理系统,需确认具体需求。 | 业务软件
7|4.0 | 界面设计 | 套 | 1.0 | ZBCS | UI界面设计 | 10000.0 | | 可视化应用
8|5.0 | 三维楼层(实验室)屏交互展示管理模型 | 套 | 1.0 | ZBCS | 1.项目名称:三维楼层屏交互展示管理模型;
2.项目特征:实现各楼层和实验室三维可视化管理功能,提供指定楼层区域系统汇总、第一视角自动巡航、实时数据监视和授权控制、报警响应等功能 | 20000.0 | |
9|6.0 | 三维漫游巡检 | 套 | 1.0 | ZBCS | 1.项目名称:三维漫游巡检;
2.项目特征:第一或第三人称视角在室内自由巡检,不穿固定设施,不穿墙体,360度室内全景浏览;自动识别所在位置进行房间动态信息同步显示。可自动巡检并显示途径各个位置实时数据和运行状态,不需要人操作。 | 15000.0 | |
10|7.0 | 通风柜VAV集中监控 | 套 | 1.0 | ZBCS | 含界面编写,操作权限设置,至少监控以下数据:通风柜的启停控制、平均面风速显示、报警显示、当前视窗高度显示、变风量阀开度显示、照明控制、通风柜整体运行状态及其他参数显示等 | 20000.0 | |
11|8.0 | 房间压差集中监控 | 套 | 1.0 | ZBCS | 含界面编写,操作权限设置,至少监控以下数据:房间各排风设备排风量及启停控制、房间送风变风量阀送风量及状态显示、房间当前余风量值设定及显示、房间当前换气次数显示、房间通风设备定时启停设定、整体运行状态及其他参数显示等 | 15000.0 | |
12|9.0 | 空调及排风机组集中监控 | 套 | 1.0 | ZBCS | 含界面编写,操作权限设置,至少监控以下数据:风机运行状态、启停控制、故障显示、排风压力显示、风机频率显示、整体运行状态及其他参数显示等 | 15000.0 | |
13|10.0 | 计费系统 | 套 | 1.0 | ZBCS | 水电表计费及空调水及空调冷冻水使用冷量 | 12000.0 | |
14|11.0 | 视频监控系统 | 套 | 1.0 | ZBCS | 系统由厂家提供;TCP/IP调用显示集成 | 12000.0 | |
15|12.0 | 门禁 | 套 | 1.0 | ZBCS | 系统由厂家提供;B/S架构调用显示集成 | 8000.0 | |
16|13.0 | 冰箱及雪柜温度监测报警系统 | 套 | 1.0 | ZBCS | 冰箱及雪柜温度监测报警 | 12000.0 | 厂家提供数据调用接口,我们负责集成 |
17|14.0 | 智能照明监控系统 | 套 | 1.0 | ZBCS | | 12000.0 | 厂家提供数据调用接口,我们负责显示集成 |
18|15.0 | 三维模型及渲染 | 套 | 1.0 | ZBCS | 建模,含建筑结构、室内隔断、通风设备及管道、实验家具、建筑整体外观 | 15000.0 | 实验室的面积为170平方米 |
19|1.0 | 小计 | | | | | 241000.0 | |
20|2.0 | 调试费 | | | | | 15000.0 | |
21|3.0 | 包装+保险+运费 | | | | | | |
22|4.0 | 税 | | | | | 0.06 | |
23|5.0 | 合计 | | | | | 256000.0 | |
24| | 1.本报价含税,含调试费,不含线材、桥架、接线等安装附件。 | | | | | | 46233.0 |
######## 设备管理平台清单(1).xlsx
=== 洁净区域设备管理平台 ===
1|分部分项工程量清单表 | | | | | | |
2|科室名称:洁净区域设备管理平台 | | | | | | |
3|序号 | 项目名称 | 项目特征 | 单位 | 工程量 | 综合单价(元) | 综合合价
(元) | 备注
4|一 | 肿瘤中心-地下三层 | | | | | |
5|1 | 基础软件开发 | 1.基础软件费用,包含设备设施管理、设备报修管理、安全巡检管理、报表管理、告警管理、智能移动运维管理(APP),与总平台共用;
2.需支持与医院总洁净区域设备平台对接; | 项 | 1 | | |
6|2 | 第三方系统协议对接 | 1.净化空调系统对接 | 项 | 1 | | |
7|3 | 二维场景 | 1、提供整个项目不同场景及设备的概览图、重点数据的管理看板等功能
2、采取2D平面化方式,对项目内建筑的楼层、功能房间、设备进行分层分系统的数据监测,按需触发安全预警报警,并可通过记录存储分析数据,提供保养建议及预防故障建议
3、支持2D图片,可动态灵活生成自定义层级的场景目录,按设定的层级(点击跳转、实时数据(单个、块状)呈现、曲线、仪表盘、温度计、实时状态等组件呈现);
4、通过简单拖拉拽的操作,即可配置成数据丰富、页面多样化、美观的页面来呈现自己重点关注的数据 | 项 | 1 | | |
8|4 | 数据采集箱 | 定制型:
1、含开关电源、柜内电线等安装辅料;
2、图纸设计、生产加工; | 个 | 1 | | |
9|5 | 采集网关 | 1.名称:采集网关含安装
2.ModbusRTU转ModbusTCP/IP网关 | 个 | 1 | | |
10|6 | 数据连接器 | 1.名称:数据连接器(4G简易型)含安装
2.规格:1个485串口,1个网口 | 个 | 1 | | |
11|7 | 现场硬件设施设备调试 | 含箱体调试费用 | 个 | 1 | | |
12|8 | 现场软件实施、调测 | 1.现场软件实施
2.现场数据对接和联机调试 | 台 | 1 | | |
13|9 | 通讯线缆 | RVSP2X1.0mm,含材料+人工 | 米 | 120 | | |
14|10 | 线管 | JDG20,含材料+人工 | 米 | 60 | | |
15|11 | 网络通讯线缆 | UTP,CAT6 | 米 | 50 | | |
16|二 | 三层供应室、ICU、输血科 | | | | | |
17|1 | 基础软件开发 | 1.基础软件费用,包含设备设施管理、设备报修管理、安全巡检管理、报表管理、告警管理、智能移动运维管理(APP)、能耗管理,与总平台共用;
2.需支持与医院总洁净区域设备平台对接; | 项 | 1 | | |
18|2 | 第三方系统协议对接 | 1.净化空调系统对接
2.UPS系统对接
3.IT系统对接
4.配电能耗系统对接 | 项 | 1 | | |
19|3 | 二维场景 | 1、提供整个项目不同场景及设备的概览图、重点数据的管理看板等功能
2、采取2D平面化方式,对项目内建筑的楼层、功能房间、设备进行分层分系统的数据监测,按需触发安全预警报警,并可通过记录存储分析数据,提供保养建议及预防故障建议
3、支持2D图片,可动态灵活生成自定义层级的场景目录,按设定的层级(点击跳转、实时数据(单个、块状)呈现、曲线、仪表盘、温度计、实时状态等组件呈现);
4、通过简单拖拉拽的操作,即可配置成数据丰富、页面多样化、美观的页面来呈现自己重点关注的数据 | 项 | 1 | | |
20|4 | 数据采集箱 | 定制型:
1、含开关电源、柜内电线等安装辅料;
2、图纸设计、生产加工; | 个 | 1 | | |
21|5 | 采集网关 | 1.名称:采集网关含安装
2.ModbusRTU转ModbusTCP/IP网关 | 个 | 1 | | |
22|6 | 数据连接器 | 1.名称:数据连接器(4G简易型)含安装
2.规格:1个485串口,1个网口 | 个 | 1 | | |
23|7 | 现场硬件设施设备调试 | 含箱体调试费用 | 个 | 1 | | |
24|8 | 现场软件实施、调测 | 1.现场软件实施
2.现场数据对接和联机调试 | 台 | 1 | | |
25|9 | 通讯线缆 | RVSP2X1.0mm,含材料+人工 | 米 | 800 | | |
26|10 | 线管 | JDG20,含材料+人工 | 米 | 500 | | |
27|11 | 网络通讯线缆 | UTP,CAT6 | 米 | 300 | | |
28|三 | 肿瘤中心-三层内镜中心 | | | | | |
29|1 | 基础软件开发 | 1.基础软件费用,包含设备设施管理、设备报修管理、安全巡检管理、报表管理、告警管理、智能移动运维管理(APP)、能耗管理,与总平台共用;
2.需支持与医院总洁净区域设备平台对接; | 项 | 1 | | |
30|2 | 第三方系统协议对接 | 1.净化空调系统对接
2.UPS系统对接
3.IT系统对接
4.配电能耗系统对接 | 项 | 1 | | |
31|3 | 二维场景 | 1、提供整个项目不同场景及设备的概览图、重点数据的管理看板等功能
2、采取2D平面化方式,对项目内建筑的楼层、功能房间、设备进行分层分系统的数据监测,按需触发安全预警报警,并可通过记录存储分析数据,提供保养建议及预防故障建议
3、支持2D图片,可动态灵活生成自定义层级的场景目录,按设定的层级(点击跳转、实时数据(单个、块状)呈现、曲线、仪表盘、温度计、实时状态等组件呈现);
4、通过简单拖拉拽的操作,即可配置成数据丰富、页面多样化、美观的页面来呈现自己重点关注的数据 | 项 | 1 | | |
32|4 | 数据采集箱 | 定制型:
1、含开关电源、柜内电线等安装辅料;
2、图纸设计、生产加工; | 个 | 1 | | |
33|5 | 采集网关 | 1.名称:采集网关含安装
2.ModbusRTU转ModbusTCP/IP网关 | 个 | 1 | | |
34|6 | 数据连接器 | 1.名称:数据连接器(4G简易型)含安装
2.规格:1个485串口,1个网口 | 个 | 1 | | |
35|7 | 现场硬件设施设备调试 | 含箱体调试费用 | 个 | 1 | | |
36|8 | 现场软件实施、调测 | 1.现场软件实施
2.现场数据对接和联机调试 | 台 | 1 | | |
37|9 | 通讯线缆 | RVSP2X1.0mm,含材料+人工 | 米 | 200 | | |
38|10 | 线管 | JDG20,含材料+人工 | 米 | 80 | | |
39|11 | 网络通讯线缆 | UTP,CAT6 | 米 | 80 | | |
40|四 | 一层急诊手术室 | | | | | |
41|1 | 基础软件开发 | 1.基础软件费用,包含设备设施管理、设备报修管理、安全巡检管理、报表管理、告警管理、智能移动运维管理(APP)、能耗管理,与总平台共用;
2.需支持与医院总洁净区域设备平台对接; | 项 | 1 | | |
42|2 | 第三方系统协议对接 | 1.净化空调系统对接
2.UPS系统对接
3.医用气体系统对接
4.IT系统对接
5.配电能耗系统对接 | 项 | 1 | | |
43|3 | 二维场景 | 1、提供整个项目不同场景及设备的概览图、重点数据的管理看板等功能
2、采取2D平面化方式,对项目内建筑的楼层、功能房间、设备进行分层分系统的数据监测,按需触发安全预警报警,并可通过记录存储分析数据,提供保养建议及预防故障建议
3、支持2D图片,可动态灵活生成自定义层级的场景目录,按设定的层级(点击跳转、实时数据(单个、块状)呈现、曲线、仪表盘、温度计、实时状态等组件呈现);
4、通过简单拖拉拽的操作,即可配置成数据丰富、页面多样化、美观的页面来呈现自己重点关注的数据 | 项 | 1 | | |
44|4 | 数据采集箱 | 定制型:
1、含开关电源、柜内电线等安装辅料;
2、图纸设计、生产加工; | 个 | 1 | | |
45|5 | 采集网关 | 1.名称:采集网关含安装
2.ModbusRTU转ModbusTCP/IP网关 | 个 | 1 | | |
46|6 | 数据连接器 | 1.名称:数据连接器(4G简易型)含安装
2.规格:1个485串口,1个网口 | 个 | 1 | | |
47|7 | 现场硬件设施设备调试 | 含箱体调试费用 | 个 | 1 | | |
48|8 | 现场软件实施、调测 | 1.现场软件实施
2.现场数据对接和联机调试 | 台 | 1 | | |
49|9 | 通讯线缆 | RVSP2X1.0mm,含材料+人工 | 米 | 300 | | |
50|10 | 线管 | JDG20,含材料+人工 | 米 | 180 | | |
51|11 | 网络通讯线缆 | UTP,CAT6 | 米 | 100 | | |
52|五 | 二层超声介入室 | | | | | |
53|1 | 基础软件开发 | 1.基础软件费用,包含设备设施管理、设备报修管理、安全巡检管理、报表管理、告警管理、智能移动运维管理(APP)、能耗管理,与总平台共用;
2.需支持与医院总洁净区域设备平台对接; | 项 | 1 | | |
54|2 | 第三方系统协议对接 | 1.净化空调系统对接
2.UPS系统对接
3.医用气体系统对接
4.IT系统对接
5.配电能耗系统对接 | 项 | 1 | | |
55|3 | 二维场景 | 1、提供整个项目不同场景及设备的概览图、重点数据的管理看板等功能
2、采取2D平面化方式,对项目内建筑的楼层、功能房间、设备进行分层分系统的数据监测,按需触发安全预警报警,并可通过记录存储分析数据,提供保养建议及预防故障建议
3、支持2D图片,可动态灵活生成自定义层级的场景目录,按设定的层级(点击跳转、实时数据(单个、块状)呈现、曲线、仪表盘、温度计、实时状态等组件呈现);
4、通过简单拖拉拽的操作,即可配置成数据丰富、页面多样化、美观的页面来呈现自己重点关注的数据 | 项 | 1 | | |
56|4 | 数据采集箱 | 定制型:
1、含开关电源、柜内电线等安装辅料;
2、图纸设计、生产加工; | 个 | 1 | | |
57|5 | 采集网关 | 1.名称:采集网关含安装
2.ModbusRTU转ModbusTCP/IP网关 | 个 | 1 | | |
58|6 | 数据连接器 | 1.名称:数据连接器(4G简易型)含安装
2.规格:1个485串口,1个网口 | 个 | 1 | | |
59|7 | 现场硬件设施设备调试 | 含箱体调试费用 | 个 | 1 | | |
60|8 | 现场软件实施、调测 | 1.现场软件实施
2.现场数据对接和联机调试 | 台 | 1 | | |
61|9 | 通讯线缆 | RVSP2X1.0mm,含材料+人工 | 米 | 120 | | |
62|10 | 线管 | JDG20,含材料+人工 | 米 | 60 | | |
63|11 | 网络通讯线缆 | UTP,CAT6 | 米 | 50 | | |
64|六 | 二层检验科 | | | | | |
65|1 | 基础软件开发 | 1.基础软件费用,包含设备设施管理、设备报修管理、安全巡检管理、报表管理、告警管理、智能移动运维管理(APP)、能耗管理,与总平台共用;
2.需支持与医院总洁净区域设备平台对接; | 项 | 1 | | |
66|2 | 第三方系统协议对接 | 1.净化空调系统对接
2.UPS系统对接
3.配电能耗系统对接 | 项 | 1 | | |
67|3 | 二维场景 | 1、提供整个项目不同场景及设备的概览图、重点数据的管理看板等功能
2、采取2D平面化方式,对项目内建筑的楼层、功能房间、设备进行分层分系统的数据监测,按需触发安全预警报警,并可通过记录存储分析数据,提供保养建议及预防故障建议
3、支持2D图片,可动态灵活生成自定义层级的场景目录,按设定的层级(点击跳转、实时数据(单个、块状)呈现、曲线、仪表盘、温度计、实时状态等组件呈现);
4、通过简单拖拉拽的操作,即可配置成数据丰富、页面多样化、美观的页面来呈现自己重点关注的数据 | 项 | 1 | | |
68|4 | 数据采集箱 | 定制型:
1、含开关电源、柜内电线等安装辅料;
2、图纸设计、生产加工; | 个 | 1 | | |
69|5 | 采集网关 | 1.名称:采集网关含安装
2.ModbusRTU转ModbusTCP/IP网关 | 个 | 1 | | |
70|6 | 数据连接器 | 1.名称:数据连接器(4G简易型)含安装
2.规格:1个485串口,1个网口 | 个 | 1 | | |
71|7 | 现场硬件设施设备调试 | 含箱体调试费用 | 个 | 1 | | |
72|8 | 现场软件实施、调测 | 1.现场软件实施
2.现场数据对接和联机调试 | 台 | 1 | | |
73|9 | 通讯线缆 | RVSP2X1.0mm,含材料+人工 | 米 | 500 | | |
74|10 | 线管 | JDG20,含材料+人工 | 米 | 400 | | |
75|11 | 网络通讯线缆 | UTP,CAT6 | 米 | 200 | | |
76|七 | 二层EICU | | | | | |
77|1 | 基础软件开发 | 1.基础软件费用,包含设备设施管理、设备报修管理、安全巡检管理、报表管理、告警管理、智能移动运维管理(APP)、能耗管理,与总平台共用;
2.需支持与医院总洁净区域设备平台对接; | 项 | 1 | | |
78|2 | 第三方系统协议对接 | 1.净化空调系统对接
2.UPS系统对接
3.医用气体系统对接
4.配电能耗系统对接 | 项 | 1 | | |
79|3 | 二维场景 | 1、提供整个项目不同场景及设备的概览图、重点数据的管理看板等功能
2、采取2D平面化方式,对项目内建筑的楼层、功能房间、设备进行分层分系统的数据监测,按需触发安全预警报警,并可通过记录存储分析数据,提供保养建议及预防故障建议
3、支持2D图片,可动态灵活生成自定义层级的场景目录,按设定的层级(点击跳转、实时数据(单个、块状)呈现、曲线、仪表盘、温度计、实时状态等组件呈现);
4、通过简单拖拉拽的操作,即可配置成数据丰富、页面多样化、美观的页面来呈现自己重点关注的数据 | 项 | 1 | | |
80|4 | 数据采集箱 | 定制型:
1、含开关电源、柜内电线等安装辅料;
2、图纸设计、生产加工; | 个 | 1 | | |
81|5 | 采集网关 | 1.名称:采集网关含安装
2.ModbusRTU转ModbusTCP/IP网关 | 个 | 1 | | |
82|6 | 数据连接器 | 1.名称:数据连接器(4G简易型)含安装
2.规格:1个485串口,1个网口 | 个 | 1 | | |
83|7 | 现场硬件设施设备调试 | 含箱体调试费用 | 个 | 1 | | |
84|8 | 现场软件实施、调测 | 1.现场软件实施
2.现场数据对接和联机调试 | 台 | 1 | | |
85|9 | 通讯线缆 | RVSP2X1.0mm,含材料+人工 | 米 | 500 | | |
86|10 | 线管 | JDG20,含材料+人工 | 米 | 400 | | |
87|11 | 网络通讯线缆 | UTP,CAT6 | 米 | 200 | | |
88|八 | 九层ICU、负压手术室 | | | | | |
89|1 | 基础软件开发 | 1.基础软件费用,包含设备设施管理、设备报修管理、安全巡检管理、报表管理、告警管理、智能移动运维管理(APP)、能耗管理,与总平台共用;
2.需支持与医院总洁净区域设备平台对接; | 项 | 1 | | |
90|2 | 第三方系统协议对接 | 1.净化空调系统对接(箱子做在屋顶)
2.UPS系统对接
3.IT系统对接
4.配电能耗系统对接 | 项 | 1 | | |
91|3 | 二维场景 | 1、提供整个项目不同场景及设备的概览图、重点数据的管理看板等功能
2、采取2D平面化方式,对项目内建筑的楼层、功能房间、设备进行分层分系统的数据监测,按需触发安全预警报警,并可通过记录存储分析数据,提供保养建议及预防故障建议
3、支持2D图片,可动态灵活生成自定义层级的场景目录,按设定的层级(点击跳转、实时数据(单个、块状)呈现、曲线、仪表盘、温度计、实时状态等组件呈现);
4、通过简单拖拉拽的操作,即可配置成数据丰富、页面多样化、美观的页面来呈现自己重点关注的数据 | 项 | 1 | | |
92|4 | 数据采集箱 | 定制型:
1、含开关电源、柜内电线等安装辅料;
2、图纸设计、生产加工; | 个 | 2 | | |
93|5 | 采集网关 | 1.名称:采集网关含安装
2.ModbusRTU转ModbusTCP/IP网关 | 个 | 2 | | |
94|6 | 数据连接器 | 1.名称:数据连接器(4G简易型)含安装
2.规格:1个485串口,1个网口 | 个 | 1 | | |
95|7 | 现场硬件设施设备调试 | 含箱体调试费用 | 个 | 1 | | |
96|8 | 现场软件实施、调测 | 1.现场软件实施
2.现场数据对接和联机调试 | 台 | 1 | | |
97|9 | 通讯线缆 | RVSP2X1.0mm,含材料+人工 | 米 | 800 | | |
98|10 | 线管 | JDG20,含材料+人工 | 米 | 500 | | |
99|11 | 网络通讯线缆 | UTP,CAT6 | 米 | 200 | | |
100|九 | 八层负压病房 | | | | | |
101|1 | 基础软件开发 | 1.基础软件费用,包含设备设施管理、设备报修管理、安全巡检管理、报表管理、告警管理、智能移动运维管理(APP)、能耗管理,与总平台共用;
2.需支持与医院总洁净区域设备平台对接; | 项 | 1 | | |
102|2 | 第三方系统协议对接 | 1.配电能耗系统对接 | 项 | 1 | | |
103|3 | 二维场景 | 1、提供整个项目不同场景及设备的概览图、重点数据的管理看板等功能
2、采取2D平面化方式,对项目内建筑的楼层、功能房间、设备进行分层分系统的数据监测,按需触发安全预警报警,并可通过记录存储分析数据,提供保养建议及预防故障建议
3、支持2D图片,可动态灵活生成自定义层级的场景目录,按设定的层级(点击跳转、实时数据(单个、块状)呈现、曲线、仪表盘、温度计、实时状态等组件呈现);
4、通过简单拖拉拽的操作,即可配置成数据丰富、页面多样化、美观的页面来呈现自己重点关注的数据 | 项 | 1 | | |
104|4 | 数据采集箱 | 定制型:
1、含开关电源、柜内电线等安装辅料;
2、图纸设计、生产加工; | 个 | 1 | | |
105|5 | 采集网关 | 1.名称:采集网关含安装
2.ModbusRTU转ModbusTCP/IP网关 | 个 | 1 | | |
106|6 | 数据连接器 | 1.名称:数据连接器(4G简易型)含安装
2.规格:1个485串口,1个网口 | 个 | 1 | | |
107|7 | 现场硬件设施设备调试 | 含箱体调试费用 | 个 | 1 | | |
108|8 | 现场软件实施、调测 | 1.现场软件实施
2.现场数据对接和联机调试 | 台 | 1 | | |
109|9 | 通讯线缆 | RVSP2X1.0mm,含材料+人工 | 米 | 500 | | |
110|10 | 线管 | JDG20,含材料+人工 | 米 | 300 | | |
111|11 | 网络通讯线缆 | UTP,CAT6 | 米 | 100 | | |
112|十 | 一层支气管镜室 | | | | | |
113|1 | 基础软件开发 | 1.基础软件费用,包含设备设施管理、设备报修管理、安全巡检管理、报表管理、告警管理、智能移动运维管理(APP)、能耗管理,与总平台共用;
2.需支持与医院总洁净区域设备平台对接; | 项 | 1 | | |
114|2 | 第三方系统协议对接 | 1.UPS系统对接
2.配电能耗系统对接 | 项 | 1 | | |
115|3 | 二维场景 | 1、提供整个项目不同场景及设备的概览图、重点数据的管理看板等功能
2、采取2D平面化方式,对项目内建筑的楼层、功能房间、设备进行分层分系统的数据监测,按需触发安全预警报警,并可通过记录存储分析数据,提供保养建议及预防故障建议
3、支持2D图片,可动态灵活生成自定义层级的场景目录,按设定的层级(点击跳转、实时数据(单个、块状)呈现、曲线、仪表盘、温度计、实时状态等组件呈现);
4、通过简单拖拉拽的操作,即可配置成数据丰富、页面多样化、美观的页面来呈现自己重点关注的数据 | 项 | 1 | | |
116|4 | 数据采集箱 | 定制型:
1、含开关电源、柜内电线等安装辅料;
2、图纸设计、生产加工; | 个 | 1 | | |
117|5 | 采集网关 | 1.名称:采集网关含安装
2.ModbusRTU转ModbusTCP/IP网关 | 个 | 1 | | |
118|6 | 数据连接器 | 1.名称:数据连接器(4G简易型)含安装
2.规格:1个485串口,1个网口 | 个 | 1 | | |
119|7 | 现场硬件设施设备调试 | 含箱体调试费用 | 个 | 1 | | |
120|8 | 现场软件实施、调测 | 1.现场软件实施
2.现场数据对接和联机调试 | 台 | 1 | | |
=== Sheet1 ===
=== Sheet2 ===
=== Sheet3 ===
######## PROTOCOL E栋冰箱.xlsx
=== E栋冰箱 ===
1|网关IP地址:192.168.10.105 | | | | |
2|倍率:*0.1,例:温度读取数值为123,表示12.3℃ | | | | |
3|id | 名称 | 数据类型 | 寄存器 | Modbus 地址 | 倍率
4|1 | 冰箱1温度 | Short | 4X(Holding Register) | 1 | *0.1
5|2 | 冰箱2温度 | Short | 4X(Holding Register) | 2 | *0.1
6|3 | 冰箱3温度 | Short | 4X(Holding Register) | 3 | *0.1
7|4 | 冰箱4温度 | Short | 4X(Holding Register) | 4 | *0.1
8|5 | 冰箱5温度 | Short | 4X(Holding Register) | 5 | *0.1
9|6 | 冰箱6温度 | Short | 4X(Holding Register) | 6 | *0.1
10|7 | 冰箱7温度 | Short | 4X(Holding Register) | 7 | *0.1
11|8 | 冰箱8温度 | Short | 4X(Holding Register) | 8 | *0.1
12|9 | 冰箱1湿度 | Short | 4X(Holding Register) | 21 | *0.1
13|10 | 冰箱2湿度 | Short | 4X(Holding Register) | 22 | *0.1
14|11 | 冰箱3湿度 | Short | 4X(Holding Register) | 23 | *0.1
15|12 | 冰箱4湿度 | Short | 4X(Holding Register) | 24 | *0.1
16|13 | 冰箱5湿度 | Short | 4X(Holding Register) | 25 | *0.1
17|14 | 冰箱6湿度 | Short | 4X(Holding Register) | 26 | *0.1
18|15 | 冰箱7湿度 | Short | 4X(Holding Register) | 27 | *0.1
19|16 | 冰箱8湿度 | Short | 4X(Holding Register) | 28 | *0.1
######## PROTOCOL PAU-201-1.xlsx
=== PAU-201-1 ===
1|网关IP地址:192.168.10.102 | | | | |
2|倍率:*0.1,例:温度读取数值为123,表示12.3℃ | | | | |
3|id | 名称 | 数据类型 | 寄存器 | Modbus 地址 | 倍率
4|1 | ZBC638.联动信号 | Boolean | 4X(Holding Register) | 1 |
5|2 | ZBC638.PAU201防火阀信号 | Boolean | 4X(Holding Register) | 2 |
6|3 | ZBC638.EAF201防火阀信号 | Boolean | 4X(Holding Register) | 3 |
7|4 | ZBC638.EAF202防火阀信号 | Boolean | 4X(Holding Register) | 4 |
8|5 | ZBC638.EAF203防火阀信号 | Boolean | 4X(Holding Register) | 5 |
9|6 | ZBC638.EAF204防火阀信号 | Boolean | 4X(Holding Register) | 6 |
10|7 | ZBC638.初效压差开关 | Boolean | 4X(Holding Register) | 7 |
11|8 | ZBC638.中效压差开关 | Boolean | 4X(Holding Register) | 8 |
12|9 | ZBC638.PAU201缺风压差信号 | Boolean | 4X(Holding Register) | 9 |
13|10 | ZBC638.EAF201缺风压差信号 | Boolean | 4X(Holding Register) | 10 |
14|11 | ZBC638.EAF202缺风压差信号 | Boolean | 4X(Holding Register) | 11 |
15|12 | ZBC638.EAF203缺风压差信号 | Boolean | 4X(Holding Register) | 12 |
16|13 | ZBC638.EAF204缺风压差信号 | Boolean | 4X(Holding Register) | 13 |
17|14 | ZBC638.夏季DN80供水阀开到位 | Boolean | 4X(Holding Register) | 14 |
18|15 | ZBC638.夏季DN80供水阀关到位 | Boolean | 4X(Holding Register) | 15 |
19|16 | ZBC638.夏季DN80回水阀开到位 | Boolean | 4X(Holding Register) | 16 |
20|17 | ZBC638.夏季DN80回水阀关到位 | Boolean | 4X(Holding Register) | 17 |
21|18 | ZBC638.过度季DN40供水阀开到位 | Boolean | 4X(Holding Register) | 18 |
22|19 | ZBC638.过度季DN40供水阀关到位 | Boolean | 4X(Holding Register) | 19 |
23|20 | ZBC638.过度季DN40回水阀开到位 | Boolean | 4X(Holding Register) | 20 |
24|21 | ZBC638.过度季DN40回水阀关到位 | Boolean | 4X(Holding Register) | 21 |
25|22 | ZBC638.PAU201热继电器报警 | Boolean | 4X(Holding Register) | 22 |
26|23 | ZBC638.EAF201热继电器报警 | Boolean | 4X(Holding Register) | 23 |
27|24 | ZBC638.EAF202热继电器报警 | Boolean | 4X(Holding Register) | 24 |
28|25 | ZBC638.EAF203热继电器报警 | Boolean | 4X(Holding Register) | 25 |
29|26 | ZBC638.EAF204热继电器报警 | Boolean | 4X(Holding Register) | 26 |
30|27 | ZBC638.消防报警 | Boolean | 4X(Holding Register) | 27 |
31|28 | ZBC638.DN80供水温度开关信号 | Boolean | 4X(Holding Register) | 28 |
32|29 | ZBC638.DN40供水温度开关信号 | Boolean | 4X(Holding Register) | 29 |
33|30 | ZBC638.PAU201送风机启停 | Boolean | 4X(Holding Register) | 30 |
34|31 | ZBC638.EAF201排风机启停 | Boolean | 4X(Holding Register) | 31 |
35|32 | ZBC638.EAF202排风机启停 | Boolean | 4X(Holding Register) | 32 |
36|33 | ZBC638.EAF203排风机启停 | Boolean | 4X(Holding Register) | 33 |
37|34 | ZBC638.EAF204排风机启停 | Boolean | 4X(Holding Register) | 34 |
38|35 | ZBC638.夏季DN80水阀开启 | Boolean | 4X(Holding Register) | 35 |
39|36 | ZBC638.过度季DN40水阀开启 | Boolean | 4X(Holding Register) | 36 |
40|37 | ZBC638.加湿机启停 | Boolean | 4X(Holding Register) | 37 |
41|38 | ZBC638.机组故障报警 | Boolean | 4X(Holding Register) | 38 |
42|39 | ZBC638.缺风报警 | Boolean | 4X(Holding Register) | 39 |
43|40 | ZBC638.初效报警 | Boolean | 4X(Holding Register) | 40 |
44|41 | ZBC638.开机 | Boolean | 4X(Holding Register) | 41 |
45|42 | ZBC638.深度除湿启停 | Boolean | 4X(Holding Register) | 42 |
46|43 | ZBC638.系统启动信号 | Boolean | 4X(Holding Register) | 43 |
47|44 | ZBC638.PAU201手自动模式选择 | Boolean | 4X(Holding Register) | 45 |
48|45 | ZBC638.EAF201手自动模式选择 | Boolean | 4X(Holding Register) | 46 |
49|46 | ZBC638.EAF202手自动模式选择 | Boolean | 4X(Holding Register) | 47 |
50|47 | ZBC638.EAF203手自动模式选择 | Boolean | 4X(Holding Register) | 48 |
51|48 | ZBC638.EAF204手自动模式选择 | Boolean | 4X(Holding Register) | 49 |
52|49 | ZBC638.加湿机手自动模式选择 | Boolean | 4X(Holding Register) | 50 |
53|50 | ZBC638.PAU201手动开 | Boolean | 4X(Holding Register) | 51 |
54|51 | ZBC638.EAF201手动开 | Boolean | 4X(Holding Register) | 52 |
55|52 | ZBC638.EAF202手动开 | Boolean | 4X(Holding Register) | 53 |
56|53 | ZBC638.EAF203手动开 | Boolean | 4X(Holding Register) | 54 |
57|54 | ZBC638.EAF204手动开 | Boolean | 4X(Holding Register) | 55 |
58|55 | ZBC638.加湿机手动开 | Boolean | 4X(Holding Register) | 56 |
59|56 | ZBC638.夏季DN80水阀开 | Boolean | 4X(Holding Register) | 57 |
60|57 | ZBC638.过度季DN40水阀开 | Boolean | 4X(Holding Register) | 58 |
61|58 | ZBC638.送风缺风故障提示 | Boolean | 4X(Holding Register) | 59 |
62|59 | ZBC638.初/中效故障提示 | Boolean | 4X(Holding Register) | 60 |
63|60 | ZBC638.PAU201热继电器报警提示 | Boolean | 4X(Holding Register) | 61 |
64|61 | ZBC638.EAF201热继电器报警提示 | Boolean | 4X(Holding Register) | 62 |
65|62 | ZBC638.EAF202热继电器报警提示 | Boolean | 4X(Holding Register) | 63 |
66|63 | ZBC638.EAF203热继电器报警提示 | Boolean | 4X(Holding Register) | 64 |
67|64 | ZBC638.EAF204热继电器报警提示 | Boolean | 4X(Holding Register) | 65 |
68|65 | ZBC638.新风温度显示 | Word | 4X(Holding Register) | 66 | *0.1
69|66 | ZBC638.新风湿度显示 | Word | 4X(Holding Register) | 67 |
70|67 | ZBC638.送风温度显示 | Word | 4X(Holding Register) | 68 | *0.1
71|68 | ZBC638.送风湿度显示 | Word | 4X(Holding Register) | 69 |
72|69 | ZBC638.排风温度显示 | Word | 4X(Holding Register) | 70 | *0.1
73|70 | ZBC638.排风湿度显示 | Word | 4X(Holding Register) | 71 |
74|71 | ZBC638.PAU201送风压力显示 | Word | 4X(Holding Register) | 72 |
75|72 | ZBC638.EAF201排风压力显示 | Word | 4X(Holding Register) | 73 |
76|73 | ZBC638.EAF204排风压力显示 | Word | 4X(Holding Register) | 74 |
77|74 | ZBC638.新风阀开度显示 | Word | 4X(Holding Register) | 75 |
78|75 | ZBC638.表冷阀开度显示 | Word | 4X(Holding Register) | 76 |
79|76 | ZBC638.加热阀开度显示 | Word | 4X(Holding Register) | 77 |
80|77 | ZBC638.排风温度设定 | Word | 4X(Holding Register) | 78 | *0.1
81|78 | ZBC638.排风湿度设定 | Word | 4X(Holding Register) | 79 |
82|79 | ZBC638.PAU201送风压力设定 | Word | 4X(Holding Register) | 80 |
83|80 | ZBC638.EAF201排风压力设定 | Word | 4X(Holding Register) | 81 |
84|81 | ZBC638.EAF204排风压力设定 | Word | 4X(Holding Register) | 82 |
85|82 | ZBC638.温度传感器量程上限设定 | Word | 4X(Holding Register) | 83 | *0.1
86|83 | ZBC638.温度传感器量程下限设定 | Word | 4X(Holding Register) | 84 | *0.1
87|84 | ZBC638.压力传感器量程上限设定 | Word | 4X(Holding Register) | 85 |
88|85 | ZBC638.压力传感器量程下限设定 | Word | 4X(Holding Register) | 86 |
89|86 | ZBC638.新风阀开度设定 | Word | 4X(Holding Register) | 87 |
90|87 | ZBC638.除湿温度保护 | Word | 4X(Holding Register) | 88 | *0.1
91|88 | ZBC638.加湿温度保护 | Word | 4X(Holding Register) | 89 | *0.1
92|89 | ZBC638.新风温度校准B | Short | 4X(Holding Register) | 90 | *0.1
93|90 | ZBC638.新风湿度校准B | Short | 4X(Holding Register) | 91 |
94|91 | ZBC638.送风温度校准B | Short | 4X(Holding Register) | 92 | *0.1
95|92 | ZBC638.送风湿度校准B | Short | 4X(Holding Register) | 93 |
96|93 | ZBC638.排风温度校准B | Short | 4X(Holding Register) | 94 | *0.1
97|94 | ZBC638.排风湿度校准B | Short | 4X(Holding Register) | 95 |
98|95 | ZBC638.PAU201压力校准B | Short | 4X(Holding Register) | 96 |
99|96 | ZBC638.EAF201压力校准B | Short | 4X(Holding Register) | 97 |
100|97 | ZBC638.EAF204压力校准B | Short | 4X(Holding Register) | 98 |
101|98 | ZBC638.PAU201风机频率手动给定值 | Word | 4X(Holding Register) | 99 |
102|99 | ZBC638.EAF201风机频率手动给定值 | Word | 4X(Holding Register) | 100 |
103|100 | ZBC638.EAF202风机频率给定值 | Word | 4X(Holding Register) | 101 |
104|101 | ZBC638.EAF203风机频率给定值 | Word | 4X(Holding Register) | 102 |
105|102 | ZBC638.EAF204风机频率手动给定值 | Word | 4X(Holding Register) | 103 |
106|103 | ZBC638.表冷阀手动给定值 | Word | 4X(Holding Register) | 104 |
107|104 | ZBC638.加热阀手动给定值 | Word | 4X(Holding Register) | 105 |
108|105 | ZBC638.加湿手动给定值 | Word | 4X(Holding Register) | 106 |
109|106 | ZBC638.PAU201_采样时间 | Word | 4X(Holding Register) | 107 |
110|107 | ZBC638.PAU201_DB | Word | 4X(Holding Register) | 108 |
111|108 | ZBC638.PAU201_KP | Word | 4X(Holding Register) | 109 |
112|109 | ZBC638.PAU201_TI | Word | 4X(Holding Register) | 110 |
113|110 | ZBC638.PAU201_KD | Word | 4X(Holding Register) | 111 |
114|111 | ZBC638.PAU201_TD | Word | 4X(Holding Register) | 112 |
115|112 | ZBC638.PAU201_最大值 | Word | 4X(Holding Register) | 113 |
116|113 | ZBC638.PAU201_最小值 | Word | 4X(Holding Register) | 114 |
117|114 | ZBC638.PAU201_输出值 | Word | 4X(Holding Register) | 115 |
118|115 | ZBC638.EAF201_采样时间 | Word | 4X(Holding Register) | 116 |
119|116 | ZBC638.EAF201_DB | Word | 4X(Holding Register) | 117 |
120|117 | ZBC638.EAF201_KP | Word | 4X(Holding Register) | 118 |
121|118 | ZBC638.EAF201_TI | Word | 4X(Holding Register) | 119 |
122|119 | ZBC638.EAF201_KD | Word | 4X(Holding Register) | 120 |
123|120 | ZBC638.EAF201_TD | Word | 4X(Holding Register) | 121 |
124|121 | ZBC638.EAF201_最大值 | Word | 4X(Holding Register) | 122 |
125|122 | ZBC638.EAF201_最小值 | Word | 4X(Holding Register) | 123 |
126|123 | ZBC638.EAF201_输出值 | Word | 4X(Holding Register) | 124 |
127|124 | ZBC638.EAF204_采样时间 | Word | 4X(Holding Register) | 125 |
128|125 | ZBC638.EAF204_DB | Word | 4X(Holding Register) | 126 |
129|126 | ZBC638.EAF204_KP | Word | 4X(Holding Register) | 127 |
130|127 | ZBC638.EAF204_TI | Word | 4X(Holding Register) | 128 |
131|128 | ZBC638.EAF204_KD | Word | 4X(Holding Register) | 129 |
132|129 | ZBC638.EAF204_TD | Word | 4X(Holding Register) | 130 |
133|130 | ZBC638.EAF204_最大值 | Word | 4X(Holding Register) | 131 |
134|131 | ZBC638.EAF204_最小值 | Word | 4X(Holding Register) | 132 |
135|132 | ZBC638.EAF204_输出值 | Word | 4X(Holding Register) | 133 |
136|133 | ZBC638.表冷阀_采样时间 | Word | 4X(Holding Register) | 134 |
137|134 | ZBC638.表冷阀_DB | Word | 4X(Holding Register) | 135 |
138|135 | ZBC638.表冷阀_KP | Word | 4X(Holding Register) | 136 |
139|136 | ZBC638.表冷阀_TI | Word | 4X(Holding Register) | 137 |
140|137 | ZBC638.表冷阀_KD | Word | 4X(Holding Register) | 138 |
141|138 | ZBC638.表冷阀_TD | Word | 4X(Holding Register) | 139 |
142|139 | ZBC638.表冷阀_最大值 | Word | 4X(Holding Register) | 140 |
143|140 | ZBC638.表冷阀_最小值 | Word | 4X(Holding Register) | 141 |
144|141 | ZBC638.表冷阀_输出值 | Word | 4X(Holding Register) | 142 |
145|142 | ZBC638.加热阀_采样时间 | Word | 4X(Holding Register) | 143 |
146|143 | ZBC638.加热阀_DB | Word | 4X(Holding Register) | 144 |
147|144 | ZBC638.加热阀_KP | Word | 4X(Holding Register) | 145 |
148|145 | ZBC638.加热阀_TI | Word | 4X(Holding Register) | 146 |
149|146 | ZBC638.加热阀_KD | Word | 4X(Holding Register) | 147 |
150|147 | ZBC638.加热阀_TD | Word | 4X(Holding Register) | 148 |
######## PROTOCOL PAU-201-2.xlsx
=== PAU-201-2 ===
1|网关IP地址:192.168.10.103 | | | | |
2|倍率:*0.1,例:温度读取数值为123,表示12.3℃ | | | | |
3|id | 名称 | 数据类型 | 寄存器 | Modbus 地址 | 倍率
4|1 | PAU201_DB.线电压UAB | Float | 4X(Holding Register) | 1 | *0.1
5|2 | PAU201_DB.线电压UBC | Float | 4X(Holding Register) | 3 | *0.1
6|3 | PAU201_DB.线电压UCA | Float | 4X(Holding Register) | 5 | *0.1
7|4 | PAU201_DB.相电压UA | Float | 4X(Holding Register) | 7 | *0.1
8|5 | PAU201_DB.相电压UB | Float | 4X(Holding Register) | 9 | *0.1
9|6 | PAU201_DB.相电压UC | Float | 4X(Holding Register) | 11 | *0.1
10|7 | PAU201_DB.电流IA | Float | 4X(Holding Register) | 13 | *0.1
11|8 | PAU201_DB.电流IB | Float | 4X(Holding Register) | 15 | *0.1
12|9 | PAU201_DB.电流IC | Float | 4X(Holding Register) | 17 | *0.1
13|10 | PAU201_DB.总有功功率 | Float | 4X(Holding Register) | 19 | *0.1
14|11 | PAU201_DB.总无功功率 | Float | 4X(Holding Register) | 21 | *0.1
15|12 | PAU201_DB.总功率因素 | Float | 4X(Holding Register) | 23 | *0.001
16|13 | PAU201_DB.正向有功总电能 | Float | 4X(Holding Register) | 25 |
17|14 | 2ATN01_DB.相电压UA | Word | 4X(Holding Register) | 51 | *0.1
18|15 | 2ATN01_DB.相电压UB | Word | 4X(Holding Register) | 52 | *0.1
19|16 | 2ATN01_DB.相电压UC | Word | 4X(Holding Register) | 53 | *0.1
20|17 | 2ATN01_DB.线电压UAB | Word | 4X(Holding Register) | 54 | *0.1
21|18 | 2ATN01_DB.线电压UBC | Word | 4X(Holding Register) | 55 | *0.1
22|19 | 2ATN01_DB.线电压UCA | Word | 4X(Holding Register) | 56 | *0.1
23|20 | 2ATN01_DB.电流IA | Word | 4X(Holding Register) | 57 | *0.1
24|21 | 2ATN01_DB.电流IB | Word | 4X(Holding Register) | 58 | *0.1
25|22 | 2ATN01_DB.电流IC | Word | 4X(Holding Register) | 59 | *0.1
26|23 | 2ATN01_DB.总有功功率 | Word | 4X(Holding Register) | 60 | *0.1
27|24 | 2ATN01_DB.总无功功率 | Word | 4X(Holding Register) | 61 | *0.1
28|25 | 2ATN01_DB.总功率因素 | Word | 4X(Holding Register) | 62 | *0.001
29|26 | 2ATN01_DB.用电量 | Float | 4X(Holding Register) | 63 |
30|27 | 2ATE01_DB.相电压UA | Word | 4X(Holding Register) | 71 | *0.1
31|28 | 2ATE01_DB.相电压UB | Word | 4X(Holding Register) | 72 | *0.1
32|29 | 2ATE01_DB.相电压UC | Word | 4X(Holding Register) | 73 | *0.1
33|30 | 2ATE01_DB.线电压UAB | Word | 4X(Holding Register) | 74 | *0.1
34|31 | 2ATE01_DB.线电压UBC | Word | 4X(Holding Register) | 75 | *0.1
35|32 | 2ATE01_DB.线电压UCA | Word | 4X(Holding Register) | 76 | *0.1
36|33 | 2ATE01_DB.电流IA | Word | 4X(Holding Register) | 77 | *0.1
37|34 | 2ATE01_DB.电流IB | Word | 4X(Holding Register) | 78 | *0.1
38|35 | 2ATE01_DB.电流IC | Word | 4X(Holding Register) | 79 | *0.1
39|36 | 2ATE01_DB.总有功功率 | Word | 4X(Holding Register) | 80 | *0.1
40|37 | 2ATE01_DB.总无功功率 | Word | 4X(Holding Register) | 81 | *0.1
41|38 | 2ATE01_DB.总功率因素 | Word | 4X(Holding Register) | 82 | *0.001
42|39 | 2ATE01_DB.用电量 | Float | 4X(Holding Register) | 83 |
43|40 | 能量计_DN80.流速 | Float | 4X(Holding Register) | 151 |
44|41 | 能量计_DN80.瞬时流量 | Float | 4X(Holding Register) | 153 |
45|42 | 能量计_DN80.正累积流量 | Double | 4X(Holding Register) | 155 |
46|43 | 能量计_DN80.今日净累积流量 | Double | 4X(Holding Register) | 159 |
47|44 | 能量计_DN80.瞬时热量 | Float | 4X(Holding Register) | 163 |
48|45 | 能量计_DN80.正累积热量 | Double | 4X(Holding Register) | 165 |
49|46 | 能量计_DN40.流速 | Float | 4X(Holding Register) | 171 |
50|47 | 能量计_DN40.瞬时流量 | Float | 4X(Holding Register) | 173 |
51|48 | 能量计_DN40.正累积流量 | Double | 4X(Holding Register) | 175 |
52|49 | 能量计_DN40.今日净累积流量 | Double | 4X(Holding Register) | 179 |
53|50 | 能量计_DN40.瞬时热量 | Float | 4X(Holding Register) | 183 |
54|51 | 能量计_DN40.正累积热量 | Double | 4X(Holding Register) | 185 |
55|52 | 流量计(报告室).流速 | Float | 4X(Holding Register) | 191 |
56|53 | 流量计(报告室).瞬时流量 | Float | 4X(Holding Register) | 193 |
57|54 | 流量计(报告室).正累积流量 | Double | 4X(Holding Register) | 195 |
58|55 | 流量计(报告室).今日净累积流量 | Double | 4X(Holding Register) | 199 |
59|56 | 流量计(测序区).流速 | Float | 4X(Holding Register) | 211 |
60|57 | 流量计(测序区).瞬时流量 | Float | 4X(Holding Register) | 213 |
61|58 | 流量计(测序区).正累积流量 | Double | 4X(Holding Register) | 215 |
62|59 | 流量计(测序区).今日净累积流量 | Double | 4X(Holding Register) | 219 |
63|60 | 文库制备2通风柜.开停状态 | Word | 4X(Holding Register) | 231 |
64|61 | 文库制备2通风柜.最大排风状态 | Word | 4X(Holding Register) | 232 |
65|62 | 文库制备2通风柜.最小风量状态 | Word | 4X(Holding Register) | 233 |
66|63 | 文库制备2通风柜.实际面风速 | Word | 4X(Holding Register) | 234 | *0.001
67|64 | 文库制备2通风柜.实际门高 | Word | 4X(Holding Register) | 235 |
68|65 | 文库制备2通风柜.实际风量 | Word | 4X(Holding Register) | 236 |
69|66 | 文库制备2通风柜.开机 | Word | 4X(Holding Register) | 237 |
70|67 | 文库制备2通风柜.关机 | Word | 4X(Holding Register) | 238 |
71|68 | 文库制备2通风柜.报警代码 | Word | 4X(Holding Register) | 239 |
72|69 | 文库制备1通风柜.开停状态 | Word | 4X(Holding Register) | 241 |
73|70 | 文库制备1通风柜.最大排风状态 | Word | 4X(Holding Register) | 242 |
74|71 | 文库制备1通风柜.最小风量状态 | Word | 4X(Holding Register) | 243 |
75|72 | 文库制备1通风柜.实际面风速 | Word | 4X(Holding Register) | 244 | *0.001
76|73 | 文库制备1通风柜.实际门高 | Word | 4X(Holding Register) | 245 |
77|74 | 文库制备1通风柜.实际风量 | Word | 4X(Holding Register) | 246 |
78|75 | 文库制备1通风柜.开机 | Word | 4X(Holding Register) | 247 |
79|76 | 文库制备1通风柜.关机 | Word | 4X(Holding Register) | 248 |
80|77 | 文库制备1通风柜.报警代码 | Word | 4X(Holding Register) | 249 |
######## PROTOCOL 房间.xlsx
=== 房间 ===
1|网关IP地址:192.168.10.104 | | | | | |
2|倍率:*0.1,例:温度读取数值为123,表示12.3℃ | | | | | |
3|id | 名称 | 数据类型 | 寄存器 | Modbus 地址 | 倍率 |
4|1 | 系统开关机 | Boolean | 4X(Holding Register) | 1 | |
5|2 | F_系统启动信号 | Boolean | 4X(Holding Register) | 2 | |
6|3 | 文库制备区1排风阀开到位 | Boolean | 4X(Holding Register) | 3 | |
7|4 | 文库制备区1排风阀关到位 | Boolean | 4X(Holding Register) | 4 | |
8|5 | 文库制备区2排风阀开到位 | Boolean | 4X(Holding Register) | 5 | |
9|6 | 文库制备区2排风阀关到位 | Boolean | 4X(Holding Register) | 6 | |
10|7 | 文库制备区1生物安全柜联动 | Boolean | 4X(Holding Register) | 7 | |
11|8 | 文库制备区2生物安全柜联动 | Boolean | 4X(Holding Register) | 8 | |
12|9 | 文库制备区1通风柜联动 | Boolean | 4X(Holding Register) | 9 | |
13|10 | 文库制备区2通风柜联动 | Boolean | 4X(Holding Register) | 10 | |
14|12 | 收样咨询室压差上限报警 | Boolean | 4X(Holding Register) | 12 | |
15|13 | 收样咨询室压差下限报警 | Boolean | 4X(Holding Register) | 13 | |
16|14 | 生信分析报告室压差上限报警 | Boolean | 4X(Holding Register) | 14 | |
17|15 | 生信分析报告室压差下限报警 | Boolean | 4X(Holding Register) | 15 | |
18|16 | 试剂准备间压差上限报警 | Boolean | 4X(Holding Register) | 16 | |
19|17 | 试剂准备间压差下限报警 | Boolean | 4X(Holding Register) | 17 | |
20|18 | PCR走廊压差上限报警 | Boolean | 4X(Holding Register) | 18 | |
21|19 | PCR走廊压差下限报警 | Boolean | 4X(Holding Register) | 19 | |
22|20 | 样本制备区1压差上限报警 | Boolean | 4X(Holding Register) | 20 | |
23|21 | 样本制备区1压差下限报警 | Boolean | 4X(Holding Register) | 21 | |
24|22 | 样本制备区2压差上限报警 | Boolean | 4X(Holding Register) | 22 | |
25|23 | 样本制备区2压差下限报警 | Boolean | 4X(Holding Register) | 23 | |
26|24 | 样本制备区缓冲压差上限报警 | Boolean | 4X(Holding Register) | 24 | |
27|25 | 样本制备区缓冲压差下限报警 | Boolean | 4X(Holding Register) | 25 | |
28|26 | 文库制备区1压差上限报警 | Boolean | 4X(Holding Register) | 26 | |
29|27 | 文库制备区1压差下限报警 | Boolean | 4X(Holding Register) | 27 | |
30|28 | 文库制备区1缓冲压差上限报警 | Boolean | 4X(Holding Register) | 28 | |
31|29 | 文库制备区1缓冲压差下限报警 | Boolean | 4X(Holding Register) | 29 | |
32|30 | 文库制备区2压差上限报警 | Boolean | 4X(Holding Register) | 30 | |
33|31 | 文库制备区2压差下限报警 | Boolean | 4X(Holding Register) | 31 | |
34|32 | 文库制备区2缓冲压差上限报警 | Boolean | 4X(Holding Register) | 32 | |
35|33 | 文库制备区2缓冲压差下限报警 | Boolean | 4X(Holding Register) | 33 | |
36|34 | 测序区压差上限报警 | Boolean | 4X(Holding Register) | 34 | |
37|35 | 测序区压差下限报警 | Boolean | 4X(Holding Register) | 35 | |
38|36 | 测序区缓冲压差上限报警 | Boolean | 4X(Holding Register) | 36 | |
39|37 | 测序区缓冲压差下限报警 | Boolean | 4X(Holding Register) | 37 | |
40|38 | 收样咨询室温度上限报警 | Boolean | 4X(Holding Register) | 38 | |
41|39 | 收样咨询室温度下限报警 | Boolean | 4X(Holding Register) | 39 | |
42|40 | 收样咨询室湿度上限报警 | Boolean | 4X(Holding Register) | 40 | |
43|41 | 收样咨询室湿度下限报警 | Boolean | 4X(Holding Register) | 41 | |
44|42 | 生信分析报告室温度上限报警 | Boolean | 4X(Holding Register) | 42 | |
45|43 | 生信分析报告室温度下限报警 | Boolean | 4X(Holding Register) | 43 | |
46|44 | 生信分析报告室湿度上限报警 | Boolean | 4X(Holding Register) | 44 | |
47|45 | 生信分析报告室湿度下限报警 | Boolean | 4X(Holding Register) | 45 | |
48|46 | 试剂准备间温度上限报警 | Boolean | 4X(Holding Register) | 46 | |
49|47 | 试剂准备间温度下限报警 | Boolean | 4X(Holding Register) | 47 | |
50|48 | 试剂准备间湿度上限报警 | Boolean | 4X(Holding Register) | 48 | |
51|49 | 试剂准备间湿度下限报警 | Boolean | 4X(Holding Register) | 49 | |
52|50 | 样本制备区1温度上限报警 | Boolean | 4X(Holding Register) | 50 | |
53|51 | 样本制备区1温度下限报警 | Boolean | 4X(Holding Register) | 51 | |
54|52 | 样本制备区1湿度上限报警 | Boolean | 4X(Holding Register) | 52 | |
55|53 | 样本制备区1湿度下限报警 | Boolean | 4X(Holding Register) | 53 | |
56|54 | 样本制备区2温度上限报警 | Boolean | 4X(Holding Register) | 54 | |
57|55 | 样本制备区2温度下限报警 | Boolean | 4X(Holding Register) | 55 | |
58|56 | 样本制备区2湿度上限报警 | Boolean | 4X(Holding Register) | 56 | |
59|57 | 样本制备区2湿度下限报警 | Boolean | 4X(Holding Register) | 57 | |
60|58 | 文库制备区1温度上限报警 | Boolean | 4X(Holding Register) | 58 | |
61|59 | 文库制备区1温度下限报警 | Boolean | 4X(Holding Register) | 59 | |
62|60 | 文库制备区1湿度上限报警 | Boolean | 4X(Holding Register) | 60 | |
63|61 | 文库制备区1湿度下限报警 | Boolean | 4X(Holding Register) | 61 | |
64|62 | 文库制备区2温度上限报警 | Boolean | 4X(Holding Register) | 62 | |
65|63 | 文库制备区2温度下限报警 | Boolean | 4X(Holding Register) | 63 | |
66|64 | 文库制备区2湿度上限报警 | Boolean | 4X(Holding Register) | 64 | |
67|65 | 文库制备区2湿度下限报警 | Boolean | 4X(Holding Register) | 65 | |
68|66 | 测序区温度上限报警 | Boolean | 4X(Holding Register) | 66 | |
69|67 | 测序区温度下限报警 | Boolean | 4X(Holding Register) | 67 | |
70|68 | 测序区湿度上限报警 | Boolean | 4X(Holding Register) | 68 | |
71|69 | 测序区湿度下限报警 | Boolean | 4X(Holding Register) | 69 | |
72|70 | 收样咨询室压差显示 | Short | 4X(Holding Register) | 70 | *0.1 |
73|71 | 生信分析报告室压差显示 | Short | 4X(Holding Register) | 71 | *0.1 |
74|72 | 试剂准备间压差显示 | Short | 4X(Holding Register) | 72 | *0.1 |
75|73 | PCR走廊压差显示 | Short | 4X(Holding Register) | 73 | *0.1 |
76|74 | 样本制备区1压差显示 | Short | 4X(Holding Register) | 74 | *0.1 |
77|75 | 样本制备区2压差显示 | Short | 4X(Holding Register) | 75 | *0.1 |
78|76 | 样本制备区缓冲压差显示 | Short | 4X(Holding Register) | 76 | *0.1 |
79|77 | 文库制备区1压差显示 | Short | 4X(Holding Register) | 77 | *0.1 |
80|78 | 文库制备区1缓冲压差显示 | Short | 4X(Holding Register) | 78 | *0.1 |
81|79 | 文库制备区2压差显示 | Short | 4X(Holding Register) | 79 | *0.1 |
82|80 | 文库制备区2缓冲压差显示 | Short | 4X(Holding Register) | 80 | *0.1 |
83|81 | 测序区压差显示 | Short | 4X(Holding Register) | 81 | *0.1 |
84|82 | 测序区缓冲压差显示 | Short | 4X(Holding Register) | 82 | *0.1 |
85|83 | 收样咨询室温度显示 | Word | 4X(Holding Register) | 83 | *0.1 |
86|84 | 收样咨询室湿度RH显示 | Word | 4X(Holding Register) | 84 | |
87|85 | 生信分析报告室温度显示 | Word | 4X(Holding Register) | 85 | *0.1 |
88|86 | 生信分析报告室湿度RH显示 | Word | 4X(Holding Register) | 86 | |
89|87 | 试剂准备间温度显示 | Word | 4X(Holding Register) | 87 | *0.1 |
90|88 | 试剂准备间湿度RH显示 | Word | 4X(Holding Register) | 88 | |
91|89 | PCR走廊温度显示 | Word | 4X(Holding Register) | 89 | *0.1 |
92|90 | PCR走廊湿度RH显示 | Word | 4X(Holding Register) | 90 | |
93|91 | 样本制备区1温度显示 | Word | 4X(Holding Register) | 91 | *0.1 |
94|92 | 样本制备区1湿度RH显示 | Word | 4X(Holding Register) | 92 | |
95|93 | 样本制备区2温度显示 | Word | 4X(Holding Register) | 93 | *0.1 |
96|94 | 样本制备区2度RH显示 | Word | 4X(Holding Register) | 94 | |
97|95 | 样本制备区缓冲温度显示 | Word | 4X(Holding Register) | 95 | *0.1 |
98|96 | 样本制备区缓冲湿度RH显示 | Word | 4X(Holding Register) | 96 | |
99|97 | 文库制备区1温度显示 | Word | 4X(Holding Register) | 97 | *0.1 |
100|98 | 文库制备区1湿度RH显示 | Word | 4X(Holding Register) | 98 | |
101|99 | 文库制备区1缓冲温度显示 | Word | 4X(Holding Register) | 99 | *0.1 |
102|100 | 文库制备区1缓冲湿度RH显示 | Word | 4X(Holding Register) | 100 | |
103|101 | 文库制备区2温度显示 | Word | 4X(Holding Register) | 101 | *0.1 |
104|102 | 文库制备区2湿度RH显示 | Word | 4X(Holding Register) | 102 | |
105|103 | 文库制备区2缓冲温度显示 | Word | 4X(Holding Register) | 103 | *0.1 |
106|104 | 文库制备区2缓冲湿度RH显示 | Word | 4X(Holding Register) | 104 | |
107|105 | 测序区温度显示 | Word | 4X(Holding Register) | 105 | *0.1 |
108|106 | 测序区湿度RH显示 | Word | 4X(Holding Register) | 106 | |
109|107 | 测序区缓冲温度显示 | Word | 4X(Holding Register) | 107 | *0.1 |
110|108 | 测序区缓冲湿度RH显示 | Word | 4X(Holding Register) | 108 | |
111|109 | 文库制备区1送风阀开度显示 | Short | 4X(Holding Register) | 109 | |
112|110 | 文库制备区1排风阀开度显示 | Short | 4X(Holding Register) | 110 | |
113|111 | 文库制备区2送风阀开度显示 | Short | 4X(Holding Register) | 111 | |
114|112 | 文库制备区2排风阀开度显示 | Short | 4X(Holding Register) | 112 | |
115|113 | 文库制备区1压差设定 | Short | 4X(Holding Register) | 113 | *0.1 |
116|114 | 文库制备区2压差设定 | Short | 4X(Holding Register) | 114 | *0.1 |
117|115 | 文库制备区1默认开度 | Word | 4X(Holding Register) | 115 | |
118|116 | 文库制备区1排风1台开度 | Word | 4X(Holding Register) | 116 | |
119|117 | 文库制备区1排风2台开度 | Word | 4X(Holding Register) | 117 | |
120|118 | 文库制备区2默认开度 | Word | 4X(Holding Register) | 118 | |
121|119 | 文库制备区2排风1台开度 | Word | 4X(Holding Register) | 119 | |
122|120 | 文库制备区2排风2台开度 | Word | 4X(Holding Register) | 120 | |
123|121 | 温度程上限设定50℃ | Word | 4X(Holding Register) | 121 | *0.1 |
124|122 | 温度程下限设定0℃ | Word | 4X(Holding Register) | 122 | *0.1 |
125|123 | 压差传感器上限50pa | Short | 4X(Holding Register) | 123 | *0.1 |
126|124 | 压差传感器下限-50pa | Short | 4X(Holding Register) | 124 | *0.1 |
127|125 | 收样咨询室压差上限报警设定 | Short | 4X(Holding Register) | 125 | *0.1 |
128|126 | 收样咨询室压差下限报警设定 | Short | 4X(Holding Register) | 126 | *0.1 |
129|127 | 生信分析报告室压差上限报警设定 | Short | 4X(Holding Register) | 127 | *0.1 |
130|128 | 生信分析报告室压差下限报警设定 | Short | 4X(Holding Register) | 128 | *0.1 |
131|129 | 试剂准备间压差上限报警设定 | Short | 4X(Holding Register) | 129 | *0.1 |
132|130 | 试剂准备间压差下限报警设定 | Short | 4X(Holding Register) | 130 | *0.1 |
133|131 | PCR走廊压差上限报警设定 | Short | 4X(Holding Register) | 131 | *0.1 |
134|132 | PCR走廊压差下限报警设定 | Short | 4X(Holding Register) | 132 | *0.1 |
135|133 | 样本制备区1压差上限报警设定 | Short | 4X(Holding Register) | 133 | *0.1 |
136|134 | 样本制备区1压差下限报警设定 | Short | 4X(Holding Register) | 134 | *0.1 |
137|135 | 样本制备区2压差上限报警设定 | Short | 4X(Holding Register) | 135 | *0.1 |
138|136 | 样本制备区2压差下限报警设定 | Short | 4X(Holding Register) | 136 | *0.1 |
139|137 | 样本制备区缓冲压差上限报警设定 | Short | 4X(Holding Register) | 137 | *0.1 |
140|138 | 样本制备区缓冲压差下限报警设定 | Short | 4X(Holding Register) | 138 | *0.1 |
141|139 | 文库制备区1压差上限报警设定 | Short | 4X(Holding Register) | 139 | *0.1 |
142|140 | 文库制备区1压差下限报警设定 | Short | 4X(Holding Register) | 140 | *0.1 |
143|141 | 文库制备区1缓冲压差上限报警设定 | Short | 4X(Holding Register) | 141 | *0.1 |
144|142 | 文库制备区1缓冲压差下限报警设定 | Short | 4X(Holding Register) | 142 | *0.1 |
145|143 | 文库制备区2压差上限报警设定 | Short | 4X(Holding Register) | 143 | *0.1 |
146|144 | 文库制备区2压差下限报警设定 | Short | 4X(Holding Register) | 144 | *0.1 |
147|145 | 文库制备区2缓冲压差上限报警设定 | Short | 4X(Holding Register) | 145 | *0.1 |
148|146 | 文库制备区2缓冲压差下限报警设定 | Short | 4X(Holding Register) | 146 | *0.1 |
149|147 | 测序区压差上限报警设定 | Short | 4X(Holding Register) | 147 | *0.1 |
150|148 | 测序区压差下限报警设定 | Short | 4X(Holding Register) | 148 | *0.1 |
######## PROTOCOL 接口数据类型.xls
=== Sheet1 ===
3|房间: | 温湿度
4| | 压差
5| | 照明开关
6| | 冰箱温度
7| | 告警数据
8| | 送排风阀门开度
10|E栋冰箱: | 温度+湿度(8组)
12|空调机组: | 防火阀信号
13| | 初中效压差开关
14| | 缺风压差信号
15| | 供回水阀开关到位
16| | 热继电器报警
17| | 送排风启停
18| | 消防报警
19| | 供水温度开关
20| | 加湿器启停
21| | 机组故障报警
22| | 缺风报警
23| | 初效报警
24| | 开机
25| | 深度除湿启停
26| | 系统启动
27| | 手自动模式选择
28| | 加湿机手自动模式
29| | 新风温度
30| | 送风温度
31| | 排风温度
32| | 送风压力
33| | 排风压力
=== Sheet2 ===
=== Sheet3 ===
# 门禁 HCNetSDK 接入说明
## 协议来源
- `接口/HCNetSDKV6.1.10.10_build20250123_Win64_ZH_demosign(1).zip`
- 含 ClientDemo、`lib/win64`、设备网络 SDK 使用手册 `.chm`
- 门禁一体机快速入门 PDF 作设备侧参考
## 一期现状
- 大屏通过 Node 服务统一快照字段 `access.doors / todayPassCount / recentEvents`
- `POST /api/realtime/access/open` 远程开门(**仅 admin**),当前为 Mock 记日志
- 环境变量 `ACCESS_BRIDGE_URL` 为空时使用 Mock
## 二期桥接建议
HCNetSDK 为 Windows 原生 DLL,不能在浏览器或纯 Node 中直接调用。建议:
1. 在 Windows 采集机部署独立桥接进程(C++/C#),封装:
- 设备登录
- 门状态查询
- 通行事件订阅(报警回调)
- 远程控门
2. 桥接对外提供 HTTP/WS,例如:
- `GET /doors`
- `GET /events`
- `POST /doors/:id/open`
3. Node `server/src/access/adapter.ts` 将桥接数据写入统一快照
若门禁一体机开启 ISAPI,可优先用 HTTP 直连,SDK 作能力对照与备选。
## 与大屏门点对照(示例)
| 大屏 doorId | 名称 | 设备侧通道(待填) |
|---|---|---|
| d-main | 主入口门禁 | |
| d-pcr | PCR区门禁 | |
| d-sample | 样本制备门禁 | |
| d-lib | 文库制备门禁 | |
| d-seq | 测序区门禁 | |
| d-cold | 冷链库门禁 | |
# Modbus 点位与 UI 对照(摘要)
数据模式:`DATA_MODE=mock|modbus`(一期默认 mock)
| 网关 | IP | 主要用途 | UI 模块 |
|---|---|---|---|
| PAU-201-1 | 192.168.10.102 | 空调/排风启停、阀门、温湿度压力、PID | 空调排风、全景 HVAC |
| PAU-201-2 | 192.168.10.103 | 电能、冷量、通风柜 VAV | 计量计费、VAV |
| 房间 | 192.168.10.104 | 各房间温湿度压差、告警、阀门开度 | 房间压差、环境参数 |
| E栋冰箱 | 192.168.10.105 | 8 组冰箱温湿度(×0.1) | 冷链温度 |
倍率约定:温度等 Short/Word 常见 `*0.1`;面风速 `*0.001`。
写指令统一经 `POST /api/realtime/write`,需管理员 JWT。
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "jh-lab-dashboard",
"private": true,
"version": "0.1.0",
"workspaces": [
"web",
"server"
],
"scripts": {
"dev": "concurrently -n web,server -c cyan,magenta \"npm run dev -w web\" \"npm run dev -w server\"",
"dev:web": "npm run dev -w web",
"dev:server": "npm run dev -w server",
"build": "npm run build -w web",
"start": "npm run start -w server",
"docker:up": "docker compose up -d --build",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f --tail=200"
},
"devDependencies": {
"concurrently": "^9.1.2"
}
}
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
COPY server/package.json ./server/
COPY web/package.json ./web/
RUN npm install --workspace=server --include=dev
COPY server ./server
WORKDIR /app/server
ENV NODE_ENV=production
EXPOSE 3001
CMD ["npx", "tsx", "src/index.ts"]
{
"name": "server",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"modbus-serial": "^8.0.23",
"pg": "^8.23.1",
"tsx": "^4.19.3",
"ws": "^8.18.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22.13.10",
"@types/pg": "^8.23.1",
"@types/ws": "^8.18.0",
"typescript": "^5.8.2"
}
}
import { config } from '../config.js'
import type { AccessEvent, DoorSnapshot } from '../types.js'
/**
* 门禁适配器:ACCESS_BRIDGE_URL 有值时走 HCNetSDK Windows 桥接,否则 Mock。
* 二期由 realtime loop 调用 fetchAccessSnapshot() 合并进总快照。
*/
export async function fetchAccessSnapshot(): Promise<{
doors: DoorSnapshot[]
todayPassCount: number
recentEvents: AccessEvent[]
} | null> {
if (!config.accessBridgeUrl) return null
try {
const res = await fetch(`${config.accessBridgeUrl.replace(/\/$/, '')}/snapshot`)
if (!res.ok) throw new Error(`bridge ${res.status}`)
return (await res.json()) as {
doors: DoorSnapshot[]
todayPassCount: number
recentEvents: AccessEvent[]
}
} catch (e) {
console.warn('[access] bridge unavailable', e)
return null
}
}
import type { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { config } from '../config.js'
import type { Role } from '../types.js'
export interface AuthPayload {
sub: string
username: string
role: Role
displayName: string
}
declare global {
namespace Express {
interface Request {
user?: AuthPayload
}
}
}
export function signToken(payload: AuthPayload): string {
return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtExpiresIn } as jwt.SignOptions)
}
export function authRequired(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization
const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined
if (!token) {
res.status(401).json({ message: '未登录' })
return
}
try {
req.user = jwt.verify(token, config.jwtSecret) as AuthPayload
next()
} catch {
res.status(401).json({ message: '登录已失效' })
}
}
export function requireRole(...roles: Role[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
res.status(401).json({ message: '未登录' })
return
}
if (!roles.includes(req.user.role)) {
res.status(403).json({ message: '权限不足,仅管理员可操作' })
return
}
next()
}
}
/** 基础版能耗计费:费率/日用量存 PostgreSQL,统计与报表由库内数据计算 */
import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js'
/** 无 PG 时的内存费率与日用量 */
let memTariff: TariffSettings | null = null
const memUsage = new Map<string, DailyUsage>()
export type EnergyKind = 'power' | 'water' | 'cooling'
export interface TariffSettings {
powerPeak: number
powerFlat: number
powerValley: number
water: number
cooling: number
serviceFeePct: number
taxPct: number
settleDay: number
enabled: boolean
updatedAt: string
updatedBy: string
}
export interface DailyUsage {
date: string
powerPeakKwh: number
powerFlatKwh: number
powerValleyKwh: number
waterM3: number
coolingKwh: number
}
export interface DailyBill extends DailyUsage {
powerCost: number
waterCost: number
coolingCost: number
subtotal: number
serviceFee: number
tax: number
total: number
}
export interface BillingStats {
today: DailyBill
month: {
days: number
powerKwh: number
waterM3: number
coolingKwh: number
powerCost: number
waterCost: number
coolingCost: number
total: number
}
byKind: { kind: EnergyKind; label: string; usage: number; unit: string; cost: number; pct: number }[]
trend7d: { date: string; total: number; powerCost: number; waterCost: number; coolingCost: number }[]
trend12m: { month: string; label: string; total: number; powerCost: number; waterCost: number; coolingCost: number }[]
usage30d: {
date: string
label: string
powerKwh: number
waterM3: number
coolingKwh: number
}[]
}
const defaultTariff: TariffSettings = {
powerPeak: 1.08,
powerFlat: 0.72,
powerValley: 0.38,
water: 4.5,
cooling: 0.55,
serviceFeePct: 3,
taxPct: 6,
settleDay: 1,
enabled: true,
updatedAt: new Date().toISOString(),
updatedBy: 'system',
}
function pad(n: number) {
return n < 10 ? `0${n}` : `${n}`
}
function dateStr(d: Date) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
/** pg DATE → YYYY-MM-DD(UTC 午夜,避免时区偏移) */
function pgDate(d: Date | string) {
if (typeof d === 'string') return d.slice(0, 10)
return d.toISOString().slice(0, 10)
}
function daySeed(date: string) {
let h = 0
for (let i = 0; i < date.length; i++) h = (h * 31 + date.charCodeAt(i)) >>> 0
return h
}
function rand(seed: number, i: number, min: number, max: number) {
const x = Math.sin(seed * 0.001 + i * 12.9898) * 43758.5453
const f = x - Math.floor(x)
return min + f * (max - min)
}
function mockUsageForDate(date: string): DailyUsage {
const seed = daySeed(date)
const dow = new Date(date + 'T12:00:00').getDay()
const weekend = dow === 0 || dow === 6
const factor = weekend ? 0.72 : 1
const powerTotal = rand(seed, 1, 380, 560) * factor
return {
date,
powerPeakKwh: +(powerTotal * 0.28).toFixed(1),
powerFlatKwh: +(powerTotal * 0.45).toFixed(1),
powerValleyKwh: +(powerTotal * 0.27).toFixed(1),
waterM3: +rand(seed, 2, 8, 18 * factor).toFixed(1),
coolingKwh: +rand(seed, 3, 900, 1500 * factor).toFixed(0),
}
}
function rangeDates(from: string, to: string): string[] {
const out: string[] = []
const cur = new Date(from + 'T12:00:00')
const end = new Date(to + 'T12:00:00')
while (cur <= end) {
out.push(dateStr(cur))
cur.setDate(cur.getDate() + 1)
}
return out
}
export function calcBill(usage: DailyUsage, t: TariffSettings): DailyBill {
const powerCost =
usage.powerPeakKwh * t.powerPeak +
usage.powerFlatKwh * t.powerFlat +
usage.powerValleyKwh * t.powerValley
const waterCost = usage.waterM3 * t.water
const coolingCost = usage.coolingKwh * t.cooling
const subtotal = powerCost + waterCost + coolingCost
const serviceFee = subtotal * (t.serviceFeePct / 100)
const tax = (subtotal + serviceFee) * (t.taxPct / 100)
const total = subtotal + serviceFee + tax
return {
...usage,
powerCost: +powerCost.toFixed(2),
waterCost: +waterCost.toFixed(2),
coolingCost: +coolingCost.toFixed(2),
subtotal: +subtotal.toFixed(2),
serviceFee: +serviceFee.toFixed(2),
tax: +tax.toFixed(2),
total: +total.toFixed(2),
}
}
export async function ensureBillingSeed() {
if (!isPgEnabled()) {
memTariff = { ...defaultTariff }
const today = dateStr(new Date())
const start = new Date()
start.setDate(start.getDate() - 400)
for (const d of rangeDates(dateStr(start), today)) {
memUsage.set(d, mockUsageForDate(d))
}
return
}
const { rows } = await query(`SELECT id FROM billing_tariff WHERE id = 1`)
if (!rows.length) {
const t = defaultTariff
await query(
`INSERT INTO billing_tariff (
id, power_peak, power_flat, power_valley, water, cooling,
service_fee_pct, tax_pct, settle_day, enabled, updated_by
) VALUES (1,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[
t.powerPeak,
t.powerFlat,
t.powerValley,
t.water,
t.cooling,
t.serviceFeePct,
t.taxPct,
t.settleDay,
t.enabled,
t.updatedBy,
],
)
}
// 预置近 400 天 Mock 日用量(仅补缺失日期,不覆盖已有实采)
const { rows: cnt } = await query<{ n: string }>(`SELECT COUNT(*)::text AS n FROM billing_daily_usage`)
if (Number(cnt[0]?.n || 0) > 30) return
const today = dateStr(new Date())
const start = new Date()
start.setDate(start.getDate() - 400)
const dates = rangeDates(dateStr(start), today)
const chunk = 50
for (let i = 0; i < dates.length; i += chunk) {
const slice = dates.slice(i, i + chunk)
const values: unknown[] = []
const places = slice
.map((d, idx) => {
const u = mockUsageForDate(d)
const o = idx * 6
values.push(u.date, u.powerPeakKwh, u.powerFlatKwh, u.powerValleyKwh, u.waterM3, u.coolingKwh)
return `($${o + 1},$${o + 2},$${o + 3},$${o + 4},$${o + 5},$${o + 6})`
})
.join(',')
await query(
`INSERT INTO billing_daily_usage (
usage_date, power_peak_kwh, power_flat_kwh, power_valley_kwh, water_m3, cooling_kwh
) VALUES ${places}
ON CONFLICT (usage_date) DO NOTHING`,
values,
)
}
}
export async function getTariff(): Promise<TariffSettings> {
if (!isPgEnabled()) return { ...(memTariff || defaultTariff) }
const { rows } = await query<{
power_peak: string
power_flat: string
power_valley: string
water: string
cooling: string
service_fee_pct: string
tax_pct: string
settle_day: number
enabled: boolean
updated_at: Date
updated_by: string
}>(`SELECT * FROM billing_tariff WHERE id = 1`)
const r = rows[0]
if (!r) return { ...defaultTariff }
return {
powerPeak: Number(r.power_peak),
powerFlat: Number(r.power_flat),
powerValley: Number(r.power_valley),
water: Number(r.water),
cooling: Number(r.cooling),
serviceFeePct: Number(r.service_fee_pct),
taxPct: Number(r.tax_pct),
settleDay: r.settle_day,
enabled: r.enabled,
updatedAt: r.updated_at.toISOString(),
updatedBy: r.updated_by,
}
}
export async function updateTariff(
patch: Partial<TariffSettings>,
by: string,
): Promise<TariffSettings> {
const cur = await getTariff()
const next = {
...cur,
...patch,
updatedBy: by,
updatedAt: new Date().toISOString(),
}
if (!isPgEnabled()) {
memTariff = next
return { ...next }
}
await query(
`UPDATE billing_tariff SET
power_peak=$1, power_flat=$2, power_valley=$3, water=$4, cooling=$5,
service_fee_pct=$6, tax_pct=$7, settle_day=$8, enabled=$9,
updated_at=NOW(), updated_by=$10
WHERE id=1`,
[
next.powerPeak,
next.powerFlat,
next.powerValley,
next.water,
next.cooling,
next.serviceFeePct,
next.taxPct,
next.settleDay,
next.enabled,
by,
],
)
return getTariff()
}
export async function getUsageHistory(from: string, to: string): Promise<DailyUsage[]> {
if (!isPgEnabled()) {
return rangeDates(from, to).map((d) => {
let u = memUsage.get(d)
if (!u) {
u = mockUsageForDate(d)
memUsage.set(d, u)
}
return u
})
}
const { rows } = await query<{
usage_date: Date
power_peak_kwh: string
power_flat_kwh: string
power_valley_kwh: string
water_m3: string
cooling_kwh: string
}>(
`SELECT * FROM billing_daily_usage
WHERE usage_date BETWEEN $1::date AND $2::date
ORDER BY usage_date ASC`,
[from, to],
)
const map = new Map(
rows.map((r) => [
pgDate(r.usage_date),
{
date: pgDate(r.usage_date),
powerPeakKwh: Number(r.power_peak_kwh),
powerFlatKwh: Number(r.power_flat_kwh),
powerValleyKwh: Number(r.power_valley_kwh),
waterM3: Number(r.water_m3),
coolingKwh: Number(r.cooling_kwh),
} satisfies DailyUsage,
]),
)
// 缺口用稳定 Mock 补齐并写库
const out: DailyUsage[] = []
for (const d of rangeDates(from, to)) {
let u = map.get(d)
if (!u) {
u = mockUsageForDate(d)
await query(
`INSERT INTO billing_daily_usage (
usage_date, power_peak_kwh, power_flat_kwh, power_valley_kwh, water_m3, cooling_kwh
) VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (usage_date) DO NOTHING`,
[u.date, u.powerPeakKwh, u.powerFlatKwh, u.powerValleyKwh, u.waterM3, u.coolingKwh],
)
}
out.push(u)
}
return out
}
export async function getReport(from: string, to: string): Promise<DailyBill[]> {
const t = await getTariff()
const usage = await getUsageHistory(from, to)
return usage.map((u) => calcBill(u, t))
}
export async function getStats(): Promise<BillingStats> {
const t = await getTariff()
const today = dateStr(new Date())
const todayUsage = (await getUsageHistory(today, today))[0] || mockUsageForDate(today)
const todayBill = calcBill(todayUsage, t)
const monthStart = `${today.slice(0, 7)}-01`
const monthBills = await getReport(monthStart, today)
const month = {
days: monthBills.length,
powerKwh: +monthBills
.reduce((s, b) => s + b.powerPeakKwh + b.powerFlatKwh + b.powerValleyKwh, 0)
.toFixed(1),
waterM3: +monthBills.reduce((s, b) => s + b.waterM3, 0).toFixed(1),
coolingKwh: +monthBills.reduce((s, b) => s + b.coolingKwh, 0).toFixed(0),
powerCost: +monthBills.reduce((s, b) => s + b.powerCost, 0).toFixed(2),
waterCost: +monthBills.reduce((s, b) => s + b.waterCost, 0).toFixed(2),
coolingCost: +monthBills.reduce((s, b) => s + b.coolingCost, 0).toFixed(2),
total: +monthBills.reduce((s, b) => s + b.total, 0).toFixed(2),
}
const kindRaw = [
{ kind: 'power' as const, label: '电费', usage: month.powerKwh, unit: 'kWh', cost: month.powerCost },
{ kind: 'water' as const, label: '水费', usage: month.waterM3, unit: 'm³', cost: month.waterCost },
{ kind: 'cooling' as const, label: '冷量费', usage: month.coolingKwh, unit: 'kWh', cost: month.coolingCost },
]
const costSum = kindRaw.reduce((s, k) => s + k.cost, 0) || 1
const byKind = kindRaw.map((k) => ({ ...k, pct: +((k.cost / costSum) * 100).toFixed(1) }))
const d7 = new Date()
d7.setDate(d7.getDate() - 6)
const trend7d = (await getReport(dateStr(d7), today)).map((b) => ({
date: b.date.slice(5),
total: b.total,
powerCost: b.powerCost,
waterCost: b.waterCost,
coolingCost: b.coolingCost,
}))
const trend12m: BillingStats['trend12m'] = []
const cursor = new Date()
cursor.setDate(1)
for (let i = 11; i >= 0; i--) {
const mDate = new Date(cursor.getFullYear(), cursor.getMonth() - i, 1)
const y = mDate.getFullYear()
const m = mDate.getMonth() + 1
const ym = `${y}-${pad(m)}`
const from = `${ym}-01`
const lastDay = new Date(y, m, 0).getDate()
let to = `${ym}-${pad(lastDay)}`
if (to > today) to = today
if (from > today) continue
const bills = await getReport(from, to)
trend12m.push({
month: ym,
label: `${m}月`,
total: +bills.reduce((s, b) => s + b.total, 0).toFixed(2),
powerCost: +bills.reduce((s, b) => s + b.powerCost, 0).toFixed(2),
waterCost: +bills.reduce((s, b) => s + b.waterCost, 0).toFixed(2),
coolingCost: +bills.reduce((s, b) => s + b.coolingCost, 0).toFixed(2),
})
}
const d30 = new Date()
d30.setDate(d30.getDate() - 29)
const usage30d = (await getUsageHistory(dateStr(d30), today)).map((u) => ({
date: u.date,
label: u.date.slice(5),
powerKwh: +(u.powerPeakKwh + u.powerFlatKwh + u.powerValleyKwh).toFixed(1),
waterM3: u.waterM3,
coolingKwh: u.coolingKwh,
}))
return { today: todayBill, month, byKind, trend7d, trend12m, usage30d }
}
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export const config = {
port: Number(process.env.PORT || 3001),
jwtSecret: process.env.JWT_SECRET || 'jh-lab-dev-secret',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '12h',
dataMode: (process.env.DATA_MODE || 'mock') as 'mock' | 'modbus',
databaseUrl:
process.env.DATABASE_URL ||
'postgres://jh_lab:jh_lab_pass@127.0.0.1:5432/jh_lab',
/** 采集明细保留天数,0 表示不清理 */
collectRetentionDays: Number(process.env.COLLECT_RETENTION_DAYS || 90),
modbus: {
pau1: process.env.MODBUS_PAU1_HOST || '192.168.10.102',
pau2: process.env.MODBUS_PAU2_HOST || '192.168.10.103',
room: process.env.MODBUS_ROOM_HOST || '192.168.10.104',
fridge: process.env.MODBUS_FRIDGE_HOST || '192.168.10.105',
port: Number(process.env.MODBUS_PORT || 502),
pollMs: Number(process.env.MODBUS_POLL_MS || 2000),
},
accessBridgeUrl: process.env.ACCESS_BRIDGE_URL || '',
adminUsername: process.env.ADMIN_USERNAME || 'admin',
adminPassword: process.env.ADMIN_PASSWORD || 'Admin@123',
dataDir: path.resolve(__dirname, '../data'),
usersFile: path.resolve(__dirname, '../data/users.json'),
videoFile: path.resolve(__dirname, '../data/video.json'),
lightFile: path.resolve(__dirname, '../data/light.json'),
}
import type { RealtimeSnapshot } from '../types.js'
import { config } from '../config.js'
import { pool, query } from './pool.js'
import { isPgEnabled } from './state.js'
/** 将一次采集快照完整入库(整包 + 分项),供本地追溯与计费汇总 */
export async function persistSnapshot(snap: RealtimeSnapshot) {
if (!isPgEnabled()) return
const ts = new Date(snap.ts || Date.now())
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query(
`INSERT INTO collect_snapshots (ts, payload) VALUES ($1, $2::jsonb)`,
[ts, JSON.stringify(snap)],
)
const rows: [Date, string, string | null, string][] = []
for (const r of snap.rooms) {
rows.push([ts, 'room', r.id, JSON.stringify(r)])
}
for (const v of snap.vav) {
rows.push([ts, 'vav', v.id, JSON.stringify(v)])
}
for (const f of snap.fridges) {
rows.push([ts, 'fridge', f.id, JSON.stringify(f)])
}
rows.push([ts, 'hvac', 'PAU201', JSON.stringify(snap.hvac)])
rows.push([ts, 'metering', 'lab', JSON.stringify(snap.metering)])
rows.push([ts, 'access', 'system', JSON.stringify(snap.access)])
rows.push([ts, 'online', 'gateway', JSON.stringify(snap.online)])
if (rows.length) {
const values: unknown[] = []
const places = rows
.map((row, i) => {
const o = i * 4
values.push(row[0], row[1], row[2], row[3])
return `($${o + 1}, $${o + 2}, $${o + 3}, $${o + 4}::jsonb)`
})
.join(',')
await client.query(
`INSERT INTO collect_samples (ts, source, device_id, payload) VALUES ${places}`,
values,
)
}
// 当日计量累计写入计费日表(取最新快照计量值作为日用量参考)
const day = ts.toISOString().slice(0, 10)
const power = snap.metering.powerKwh
const water = snap.metering.waterM3
const cooling = snap.metering.coolingKwh
await client.query(
`INSERT INTO billing_daily_usage (
usage_date, power_peak_kwh, power_flat_kwh, power_valley_kwh, water_m3, cooling_kwh, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,NOW())
ON CONFLICT (usage_date) DO UPDATE SET
power_peak_kwh = EXCLUDED.power_peak_kwh,
power_flat_kwh = EXCLUDED.power_flat_kwh,
power_valley_kwh = EXCLUDED.power_valley_kwh,
water_m3 = EXCLUDED.water_m3,
cooling_kwh = EXCLUDED.cooling_kwh,
updated_at = NOW()`,
[
day,
+(power * 0.28).toFixed(3),
+(power * 0.45).toFixed(3),
+(power * 0.27).toFixed(3),
water,
cooling,
],
)
await client.query('COMMIT')
} catch (e) {
await client.query('ROLLBACK')
console.error('[db] persistSnapshot failed', e)
} finally {
client.release()
}
}
export async function persistWriteLog(action: string, detail: Record<string, unknown>, byUser?: string) {
if (!isPgEnabled()) return
await query(
`INSERT INTO write_audit_log (action, detail, by_user) VALUES ($1, $2::jsonb, $3)`,
[action, JSON.stringify(detail), byUser || null],
)
}
/** 清理过期采集数据,默认保留 90 天 */
export async function pruneCollectData() {
if (!isPgEnabled()) return
const days = config.collectRetentionDays
if (days <= 0) return
await query(`DELETE FROM collect_samples WHERE ts < NOW() - ($1::text || ' days')::interval`, [
String(days),
])
await query(`DELETE FROM collect_snapshots WHERE ts < NOW() - ($1::text || ' days')::interval`, [
String(days),
])
}
import { pool, query } from './pool.js'
const SQL = `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'viewer')),
password_hash TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS billing_tariff (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
power_peak NUMERIC(12,4) NOT NULL,
power_flat NUMERIC(12,4) NOT NULL,
power_valley NUMERIC(12,4) NOT NULL,
water NUMERIC(12,4) NOT NULL,
cooling NUMERIC(12,4) NOT NULL,
service_fee_pct NUMERIC(8,4) NOT NULL,
tax_pct NUMERIC(8,4) NOT NULL,
settle_day INT NOT NULL CHECK (settle_day BETWEEN 1 AND 28),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system'
);
CREATE TABLE IF NOT EXISTS billing_daily_usage (
usage_date DATE PRIMARY KEY,
power_peak_kwh NUMERIC(14,3) NOT NULL DEFAULT 0,
power_flat_kwh NUMERIC(14,3) NOT NULL DEFAULT 0,
power_valley_kwh NUMERIC(14,3) NOT NULL DEFAULT 0,
water_m3 NUMERIC(14,3) NOT NULL DEFAULT 0,
cooling_kwh NUMERIC(14,3) NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS collect_samples (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL,
source TEXT NOT NULL,
device_id TEXT,
payload JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_collect_ts ON collect_samples (ts DESC);
CREATE INDEX IF NOT EXISTS idx_collect_source_ts ON collect_samples (source, ts DESC);
CREATE INDEX IF NOT EXISTS idx_collect_device_ts ON collect_samples (source, device_id, ts DESC);
CREATE TABLE IF NOT EXISTS collect_snapshots (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL,
payload JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshots_ts ON collect_snapshots (ts DESC);
CREATE TABLE IF NOT EXISTS write_audit_log (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
action TEXT NOT NULL,
detail JSONB NOT NULL DEFAULT '{}'::jsonb,
by_user TEXT
);
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),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system'
);
`
export async function migrate() {
await query(SQL)
console.log('[db] schema migrated')
}
export async function closeDb() {
await pool.end()
}
import pg from 'pg'
import { config } from '../config.js'
import { isPgEnabled } from './state.js'
const { Pool } = pg
export const pool = new Pool({
connectionString: config.databaseUrl,
max: 10,
})
export async function query<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params?: unknown[],
) {
if (!isPgEnabled()) {
throw new Error('PostgreSQL 未启用')
}
return pool.query<T>(text, params)
}
export async function withClient<T>(fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect()
try {
return await fn(client)
} finally {
client.release()
}
}
/** 探测数据库是否可连接 */
export async function pingDb(): Promise<boolean> {
try {
const client = await pool.connect()
await client.query('SELECT 1')
client.release()
return true
} catch (e) {
console.warn('[db] ping failed:', e instanceof Error ? e.message : e)
return false
}
}
/** PostgreSQL 是否可用;不可用时走本地文件/内存降级,保证登录与演示可用 */
let pgEnabled = false
export function setPgEnabled(ok: boolean) {
pgEnabled = ok
}
export function isPgEnabled() {
return pgEnabled
}
import http from 'node:http'
import express from 'express'
import cors from 'cors'
import { WebSocketServer } from 'ws'
import { config } from './config.js'
import { migrate } from './db/migrate.js'
import { pingDb } from './db/pool.js'
import { setPgEnabled } from './db/state.js'
import { seedAdminIfNeeded } from './users/store.js'
import { ensureBillingSeed } from './billing/store.js'
import { ensureSettingsSeed } from './settings/store.js'
import { ensureVideoSeed } from './video/store.js'
import { ensureLightSeed } from './light/store.js'
import { authRouter } from './routes/auth.js'
import { usersRouter } from './routes/users.js'
import { realtimeRouter } from './routes/realtime.js'
import { billingRouter } from './routes/billing.js'
import { settingsRouter } from './routes/settings.js'
import { videoRouter } from './routes/video.js'
import { lightRouter } from './routes/light.js'
import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js'
async function bootstrap() {
const pgOk = await pingDb()
setPgEnabled(pgOk)
if (pgOk) {
await migrate()
console.log('[db] PostgreSQL 已连接,采集数据将落库')
} else {
console.warn(
'[db] PostgreSQL 未连接,已降级为本地文件/内存模式(登录可用)。\n' +
` 请启动数据库后重启服务,例如: docker compose up -d db\n` +
` 当前 DATABASE_URL=${config.databaseUrl.replace(/:[^:@/]+@/, ':****@')}`,
)
}
await seedAdminIfNeeded()
await ensureBillingSeed()
await ensureSettingsSeed()
ensureVideoSeed()
ensureLightSeed()
const app = express()
app.use(cors())
app.use(express.json({ limit: '2mb' }))
app.get('/api/health', (_req, res) => {
res.json({
ok: true,
mode: config.dataMode,
database: pgOk ? 'postgres' : 'fallback',
viewportHint: 'glb|ue-stream configured on web',
})
})
app.use('/api/auth', authRouter)
app.use('/api/users', usersRouter)
app.use('/api/realtime', realtimeRouter)
app.use('/api/billing', billingRouter)
app.use('/api/settings', settingsRouter)
app.use('/api/video', videoRouter)
app.use('/api/light', lightRouter)
const server = http.createServer(app)
const wss = new WebSocketServer({ server, path: '/ws' })
wss.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'snapshot', data: getSnapshot() }))
const unsub = subscribe((snap) => {
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify({ type: 'snapshot', data: snap }))
}
})
socket.on('close', () => unsub())
})
await startRealtimeLoop()
server.listen(config.port, () => {
console.log(`[server] http://localhost:${config.port}`)
console.log(`[server] dataMode=${config.dataMode}`)
console.log(`[server] database=${pgOk ? 'postgres' : 'fallback(file/memory)'}`)
console.log(`[server] default admin: ${config.adminUsername} / ${config.adminPassword}`)
})
}
bootstrap().catch((err) => {
console.error('[server] bootstrap failed', err)
process.exit(1)
})
import fs from 'node:fs'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
export type LightStatus = 'online' | 'offline' | 'fault'
export type LightProtocol = 'modbus' | 'http' | 'tcp'
export interface LightDevice {
id: string
name: string
zone: string
circuit: number
/** 开关状态 */
powerOn: boolean
status: LightStatus
enabled: boolean
brightness: number
remark: string
createdAt: string
updatedAt: string
lastSwitchAt: string | null
lastSwitchBy: string | null
}
export interface LightCommConfig {
host: string
port: number
protocol: LightProtocol
unitId: number
username: string
password: string
pollMs: number
enabled: boolean
updatedAt: string
updatedBy: string
}
export type PublicCommConfig = Omit<LightCommConfig, 'password'> & {
passwordSet: boolean
passwordMasked: string
}
interface LightData {
config: LightCommConfig
devices: LightDevice[]
}
const filePath = () => config.lightFile
function defaultConfig(): LightCommConfig {
return {
host: '192.168.10.120',
port: 502,
protocol: 'modbus',
unitId: 1,
username: '',
password: '',
pollMs: 3000,
enabled: true,
updatedAt: new Date().toISOString(),
updatedBy: 'system',
}
}
function seedDevices(): LightDevice[] {
const now = new Date().toISOString()
const rows: Omit<LightDevice, 'id' | 'createdAt' | 'updatedAt' | 'lastSwitchAt' | 'lastSwitchBy'>[] = [
{ name: 'PCR走廊照明', zone: 'PCR走廊', circuit: 1, powerOn: true, status: 'online', enabled: true, brightness: 80, remark: '' },
{ name: '样本制备区1照明', zone: '样本制备区1', circuit: 2, powerOn: true, status: 'online', enabled: true, brightness: 90, remark: '' },
{ name: '样本制备区2照明', zone: '样本制备区2', circuit: 3, powerOn: false, status: 'online', enabled: true, brightness: 0, remark: '' },
{ name: '文库制备区1照明', zone: '文库制备区1', circuit: 4, powerOn: true, status: 'online', enabled: true, brightness: 85, remark: '' },
{ name: '文库制备区2照明', zone: '文库制备区2', circuit: 5, powerOn: true, status: 'online', enabled: true, brightness: 80, remark: '' },
{ name: '测序区照明', zone: '测序区', circuit: 6, powerOn: true, status: 'online', enabled: true, brightness: 75, remark: '' },
{ name: '测序区缓冲照明', zone: '测序区缓冲', circuit: 7, powerOn: false, status: 'online', enabled: true, brightness: 0, remark: '' },
{ name: '机房照明', zone: '弱电/机房', circuit: 8, powerOn: true, status: 'fault', enabled: true, brightness: 60, remark: '回路反馈异常' },
]
return rows.map((r) => ({
...r,
id: randomUUID(),
createdAt: now,
updatedAt: now,
lastSwitchAt: null,
lastSwitchBy: null,
}))
}
function ensureDir() {
fs.mkdirSync(path.dirname(filePath()), { recursive: true })
}
function load(): LightData {
ensureDir()
if (!fs.existsSync(filePath())) {
const data: LightData = { config: defaultConfig(), devices: seedDevices() }
save(data)
return data
}
return JSON.parse(fs.readFileSync(filePath(), 'utf-8')) as LightData
}
function save(data: LightData) {
ensureDir()
fs.writeFileSync(filePath(), JSON.stringify(data, null, 2), 'utf-8')
}
export function ensureLightSeed() {
load()
}
function toPublicConfig(c: LightCommConfig): PublicCommConfig {
const { password, ...rest } = c
return {
...rest,
passwordSet: Boolean(password),
passwordMasked: password ? '********' : '',
}
}
export function getCommConfig(): PublicCommConfig {
return toPublicConfig(load().config)
}
export function updateCommConfig(
patch: Partial<{
host: string
port: number
protocol: LightProtocol
unitId: number
username: string
password: string
pollMs: number
enabled: boolean
}>,
byUser: string,
): PublicCommConfig {
const data = load()
const c = data.config
if (patch.host !== undefined) c.host = String(patch.host).trim()
if (patch.port !== undefined) c.port = Number(patch.port)
if (patch.protocol !== undefined) c.protocol = patch.protocol
if (patch.unitId !== undefined) c.unitId = Number(patch.unitId)
if (patch.username !== undefined) c.username = String(patch.username).trim()
if (patch.password !== undefined && String(patch.password).trim() && patch.password !== '********') {
c.password = String(patch.password)
}
if (patch.pollMs !== undefined) c.pollMs = Number(patch.pollMs)
if (patch.enabled !== undefined) c.enabled = Boolean(patch.enabled)
c.updatedAt = new Date().toISOString()
c.updatedBy = byUser
save(data)
return toPublicConfig(c)
}
export function testCommConfig(): { ok: boolean; message: string; latencyMs: number } {
const c = load().config
if (!c.enabled) return { ok: false, message: '照明通信配置已禁用', latencyMs: 0 }
if (!c.host || !c.port) {
return { ok: false, message: '请填写照明网关地址与端口', latencyMs: 0 }
}
const latencyMs = 30 + Math.floor(Math.random() * 70)
return {
ok: true,
message: `照明网关 ${c.host}:${c.port} 连通(${c.protocol.toUpperCase()} · Mock)`,
latencyMs,
}
}
export function listDevices(): LightDevice[] {
return load().devices.slice().sort((a, b) => a.circuit - b.circuit)
}
export function createDevice(input: {
name: string
zone: string
circuit: number
powerOn?: boolean
status?: LightStatus
enabled?: boolean
brightness?: number
remark?: string
}): LightDevice {
const data = load()
if (data.devices.some((d) => d.circuit === input.circuit)) {
throw new Error(`回路 ${input.circuit} 已存在`)
}
const now = new Date().toISOString()
const powerOn = input.powerOn === true
const device: LightDevice = {
id: randomUUID(),
name: input.name.trim(),
zone: input.zone.trim(),
circuit: Number(input.circuit),
powerOn,
status: input.status || 'online',
enabled: input.enabled !== false,
brightness: powerOn ? Math.min(100, Math.max(0, Number(input.brightness ?? 80))) : 0,
remark: (input.remark || '').trim(),
createdAt: now,
updatedAt: now,
lastSwitchAt: null,
lastSwitchBy: null,
}
data.devices.push(device)
save(data)
return device
}
export function updateDevice(
id: string,
patch: Partial<{
name: string
zone: string
circuit: number
status: LightStatus
enabled: boolean
brightness: number
remark: string
}>,
): LightDevice {
const data = load()
const idx = data.devices.findIndex((d) => d.id === id)
if (idx < 0) throw new Error('设备不存在')
if (patch.circuit !== undefined) {
const ch = Number(patch.circuit)
if (data.devices.some((d) => d.id !== id && d.circuit === ch)) {
throw new Error(`回路 ${ch} 已存在`)
}
data.devices[idx].circuit = ch
}
if (patch.name !== undefined) data.devices[idx].name = String(patch.name).trim()
if (patch.zone !== undefined) data.devices[idx].zone = String(patch.zone).trim()
if (patch.status !== undefined) data.devices[idx].status = patch.status
if (patch.enabled !== undefined) data.devices[idx].enabled = Boolean(patch.enabled)
if (patch.brightness !== undefined) {
data.devices[idx].brightness = Math.min(100, Math.max(0, Number(patch.brightness)))
}
if (patch.remark !== undefined) data.devices[idx].remark = String(patch.remark).trim()
data.devices[idx].updatedAt = new Date().toISOString()
save(data)
return data.devices[idx]
}
export function deleteDevice(id: string) {
const data = load()
if (!data.devices.some((d) => d.id === id)) throw new Error('设备不存在')
data.devices = data.devices.filter((d) => d.id !== id)
save(data)
}
/** 远程开关:一期 Mock 落库状态,二期走厂家接口 */
export function switchDevice(
id: string,
powerOn: boolean,
byUser: string,
): LightDevice {
const data = load()
if (!data.config.enabled) throw new Error('照明通信未启用,无法下发开关')
const idx = data.devices.findIndex((d) => d.id === id)
if (idx < 0) throw new Error('设备不存在')
const d = data.devices[idx]
if (!d.enabled) throw new Error('设备已禁用')
if (d.status === 'offline') throw new Error('设备离线,无法控制')
const now = new Date().toISOString()
d.powerOn = Boolean(powerOn)
d.brightness = d.powerOn ? (d.brightness > 0 ? d.brightness : 80) : 0
d.updatedAt = now
d.lastSwitchAt = now
d.lastSwitchBy = byUser
save(data)
return d
}
export function switchAll(powerOn: boolean, byUser: string): { count: number; devices: LightDevice[] } {
const data = load()
if (!data.config.enabled) throw new Error('照明通信未启用,无法下发开关')
const now = new Date().toISOString()
let count = 0
for (const d of data.devices) {
if (!d.enabled || d.status === 'offline') continue
d.powerOn = Boolean(powerOn)
d.brightness = d.powerOn ? (d.brightness > 0 ? d.brightness : 80) : 0
d.updatedAt = now
d.lastSwitchAt = now
d.lastSwitchBy = byUser
count += 1
}
save(data)
return { count, devices: listDevices() }
}
import { randomUUID } from 'node:crypto'
import type { RealtimeSnapshot } from '../types.js'
const ROOM_DEFS = [
{ id: 'recv', name: '收样咨询室' },
{ id: 'report', name: '生信分析报告室' },
{ id: 'reagent', name: '试剂准备间' },
{ id: 'pcr-corridor', name: 'PCR走廊' },
{ id: 'sample-1', name: '样本制备区1' },
{ id: 'sample-2', name: '样本制备区2' },
{ id: 'sample-buf', name: '样本制备区缓冲' },
{ id: 'lib-1', name: '文库制备区1' },
{ id: 'lib-1-buf', name: '文库制备区1缓冲' },
{ id: 'lib-2', name: '文库制备区2' },
{ id: 'lib-2-buf', name: '文库制备区2缓冲' },
{ id: 'seq', name: '测序区' },
{ id: 'seq-buf', name: '测序区缓冲' },
]
const DOORS = [
{ id: 'd-main', name: '主入口门禁' },
{ id: 'd-pcr', name: 'PCR区门禁' },
{ id: 'd-sample', name: '样本制备门禁' },
{ id: 'd-lib', name: '文库制备门禁' },
{ id: 'd-seq', name: '测序区门禁' },
{ id: 'd-cold', name: '冷链库门禁' },
]
function jitter(base: number, amp: number, digits = 1) {
const v = base + (Math.random() * 2 - 1) * amp
return Number(v.toFixed(digits))
}
let passCount = 86
const events: RealtimeSnapshot['access']['recentEvents'] = [
{
id: randomUUID(),
time: new Date(Date.now() - 8 * 60000).toISOString(),
doorId: 'd-main',
doorName: '主入口门禁',
person: '张工',
result: 'success',
},
{
id: randomUUID(),
time: new Date(Date.now() - 25 * 60000).toISOString(),
doorId: 'd-pcr',
doorName: 'PCR区门禁',
person: '李技师',
result: 'success',
},
]
export function generateSnapshot(): RealtimeSnapshot {
const rooms = ROOM_DEFS.map((r, i) => {
const pressure = jitter(-15 + (i % 5) * 1.2, 1.5)
const alarms: string[] = []
if (r.id === 'sample-1' && pressure > -5) alarms.push('压差异常')
return {
id: r.id,
name: r.name,
temp: jitter(22.5, 0.6),
humidity: jitter(48, 3, 0),
pressure,
alarms,
}
})
if (Math.random() < 0.08) {
passCount += 1
const door = DOORS[Math.floor(Math.random() * DOORS.length)]
events.unshift({
id: randomUUID(),
time: new Date().toISOString(),
doorId: door.id,
doorName: door.name,
person: ['张工', '李技师', '王护士', '访客'][Math.floor(Math.random() * 4)],
result: Math.random() < 0.9 ? 'success' : 'denied',
})
if (events.length > 30) events.length = 30
}
return {
ts: Date.now(),
rooms,
hvac: {
systemRunning: true,
supplyFanHz: jitter(42, 1.2),
exhaustPressure: jitter(320, 12, 0),
filterRemainPct: 68,
mode: 'auto',
supplyTemp: jitter(18.5, 0.4),
supplyHumidity: jitter(45, 2, 0),
supplyPressure: jitter(180, 8, 0),
freshAirTemp: jitter(26, 1.5),
alarms: rooms.some((r) => r.alarms.length) ? ['房间压差联动调频中'] : [],
},
vav: [
{
id: 'vav-lib1',
name: '文库制备1通风柜',
running: true,
faceVelocity: jitter(0.52, 0.04, 3),
sashHeight: jitter(350, 20, 0),
airflow: jitter(780, 40, 0),
valveOpen: jitter(62, 6, 0),
maxExhaust: Math.random() < 0.15,
minAirflow: Math.random() < 0.1,
alarmCode: Math.random() < 0.08 ? 1 : 0,
},
{
id: 'vav-lib2',
name: '文库制备2通风柜',
running: Math.random() > 0.1,
faceVelocity: jitter(0.48, 0.04, 3),
sashHeight: jitter(280, 20, 0),
airflow: jitter(720, 40, 0),
valveOpen: jitter(58, 6, 0),
maxExhaust: false,
minAirflow: Math.random() < 0.12,
alarmCode: 0,
},
],
fridges: Array.from({ length: 8 }, (_, i) => ({
id: `fridge-${i + 1}`,
name: `冰箱${i + 1}`,
temp: i === 2 ? jitter(-18.6, 0.3) : jitter(-20 + (i % 3), 0.4),
humidity: jitter(35, 4, 0),
})),
metering: {
powerKwh: jitter(486.2, 2),
waterM3: jitter(12.8, 0.2),
coolingKwh: jitter(1286, 8, 0),
powerTrendPct: 4.2,
waterTrendPct: -1.8,
coolingTrendPct: 6.5,
},
access: {
doors: DOORS.map((d, i) => ({
id: d.id,
name: d.name,
status: i === 5 && Math.random() < 0.05 ? 'alarm' : 'closed',
})),
todayPassCount: passCount,
recentEvents: [...events],
},
online: {
devicesOnline: 42,
devicesTotal: 45,
gatewayOk: true,
},
}
}
import { generateSnapshot } from '../mock/generator.js'
import type { RealtimeSnapshot } from '../types.js'
import { config } from '../config.js'
import { persistSnapshot, persistWriteLog, pruneCollectData } from '../db/collect.js'
import { getCollectPollMs } from '../settings/store.js'
let snapshot: RealtimeSnapshot = generateSnapshot()
const listeners = new Set<(s: RealtimeSnapshot) => void>()
let persistBusy = false
let pollTimer: ReturnType<typeof setInterval> | undefined
let currentPollMs = config.modbus.pollMs
export function getSnapshot() {
return snapshot
}
export function getCurrentPollMs() {
return currentPollMs
}
export function subscribe(fn: (s: RealtimeSnapshot) => void) {
listeners.add(fn)
return () => listeners.delete(fn)
}
function publish() {
for (const fn of listeners) fn(snapshot)
}
async function persistSafe(snap: RealtimeSnapshot) {
if (persistBusy) return
persistBusy = true
try {
await persistSnapshot(snap)
} finally {
persistBusy = false
}
}
function tick() {
if (config.dataMode === 'mock') {
snapshot = generateSnapshot()
} else {
// Modbus 实采二期接入:当前仍回落 mock,并标记网关状态
snapshot = generateSnapshot()
snapshot.online.gatewayOk = false
}
publish()
void persistSafe(snapshot)
}
function startPollTimer(ms: number) {
if (pollTimer) clearInterval(pollTimer)
currentPollMs = ms
pollTimer = setInterval(tick, currentPollMs)
console.log(`[realtime] collect poll = ${currentPollMs} ms`)
}
/** 运行时热更新采集频率 */
export async function setCollectPollMs(ms: number) {
startPollTimer(ms)
}
export async function startRealtimeLoop() {
const ms = await getCollectPollMs()
tick()
startPollTimer(ms)
// 每天清理一次过期采集数据
setInterval(() => void pruneCollectData(), 24 * 60 * 60 * 1000)
void pruneCollectData()
}
export function applyWriteLog(action: string, detail: Record<string, unknown>) {
console.log(`[WRITE] ${action}`, detail)
void persistWriteLog(action, detail, typeof detail.by === 'string' ? detail.by : undefined)
}
import { Router } from 'express'
import { signToken, authRequired } from '../auth/middleware.js'
import { toPublic, verifyLogin } from '../users/store.js'
export const authRouter = Router()
authRouter.post('/login', async (req, res) => {
try {
const { username, password } = req.body || {}
if (!username || !password) {
res.status(400).json({ message: '请输入用户名和密码' })
return
}
const user = await verifyLogin(String(username), String(password))
if (!user) {
res.status(401).json({ message: '用户名或密码错误' })
return
}
const token = signToken({
sub: user.id,
username: user.username,
role: user.role,
displayName: user.displayName,
})
res.json({ token, user: toPublic(user) })
} catch (e) {
console.error('[auth] login failed', e)
res.status(500).json({
message: e instanceof Error ? `登录失败:${e.message}` : '登录失败,请检查服务与数据库',
})
}
})
authRouter.get('/me', authRequired, (req, res) => {
res.json({
user: {
id: req.user!.sub,
username: req.user!.username,
displayName: req.user!.displayName,
role: req.user!.role,
},
})
})
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import {
getTariff,
updateTariff,
getStats,
getReport,
type TariffSettings,
} from '../billing/store.js'
export const billingRouter = Router()
billingRouter.get('/tariff', authRequired, async (_req, res) => {
res.json(await getTariff())
})
billingRouter.put('/tariff', authRequired, requireRole('admin'), async (req, res) => {
const body = (req.body || {}) as Partial<TariffSettings>
const numKeys: (keyof TariffSettings)[] = [
'powerPeak',
'powerFlat',
'powerValley',
'water',
'cooling',
'serviceFeePct',
'taxPct',
'settleDay',
]
const patch: Partial<TariffSettings> = {}
for (const k of numKeys) {
if (body[k] !== undefined) {
const n = Number(body[k])
if (!Number.isFinite(n) || n < 0) {
res.status(400).json({ message: `参数 ${k} 无效` })
return
}
;(patch as Record<string, number>)[k] = n
}
}
if (body.settleDay !== undefined) {
const d = Number(body.settleDay)
if (d < 1 || d > 28) {
res.status(400).json({ message: '结算日需在 1–28' })
return
}
}
if (typeof body.enabled === 'boolean') patch.enabled = body.enabled
const next = await updateTariff(patch, req.user!.username)
res.json(next)
})
billingRouter.get('/stats', authRequired, async (_req, res) => {
res.json(await getStats())
})
billingRouter.get('/report', authRequired, async (req, res) => {
const to = String(req.query.to || new Date().toISOString().slice(0, 10))
let from = String(req.query.from || '')
if (!from) {
const d = new Date(to + 'T12:00:00')
d.setDate(d.getDate() - 29)
from = d.toISOString().slice(0, 10)
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
res.status(400).json({ message: '日期格式应为 YYYY-MM-DD' })
return
}
if (from > to) {
res.status(400).json({ message: '开始日期不能晚于结束日期' })
return
}
const rows = await getReport(from, to)
const summary = {
from,
to,
days: rows.length,
powerKwh: +rows
.reduce((s, b) => s + b.powerPeakKwh + b.powerFlatKwh + b.powerValleyKwh, 0)
.toFixed(1),
waterM3: +rows.reduce((s, b) => s + b.waterM3, 0).toFixed(1),
coolingKwh: +rows.reduce((s, b) => s + b.coolingKwh, 0).toFixed(0),
powerCost: +rows.reduce((s, b) => s + b.powerCost, 0).toFixed(2),
waterCost: +rows.reduce((s, b) => s + b.waterCost, 0).toFixed(2),
coolingCost: +rows.reduce((s, b) => s + b.coolingCost, 0).toFixed(2),
serviceFee: +rows.reduce((s, b) => s + b.serviceFee, 0).toFixed(2),
tax: +rows.reduce((s, b) => s + b.tax, 0).toFixed(2),
total: +rows.reduce((s, b) => s + b.total, 0).toFixed(2),
}
res.json({ summary, rows })
})
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { applyWriteLog } from '../realtime/store.js'
import {
createDevice,
deleteDevice,
getCommConfig,
listDevices,
switchAll,
switchDevice,
testCommConfig,
updateCommConfig,
updateDevice,
type LightProtocol,
type LightStatus,
} from '../light/store.js'
export const lightRouter = Router()
lightRouter.get('/config', authRequired, (_req, res) => {
res.json(getCommConfig())
})
lightRouter.put('/config', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {}
const port = body.port !== undefined ? Number(body.port) : undefined
const unitId = body.unitId !== undefined ? Number(body.unitId) : undefined
const pollMs = body.pollMs !== undefined ? Number(body.pollMs) : undefined
if (port !== undefined && (!Number.isFinite(port) || port < 1 || port > 65535)) {
res.status(400).json({ message: '端口需在 1–65535' })
return
}
if (unitId !== undefined && (!Number.isFinite(unitId) || unitId < 0 || unitId > 247)) {
res.status(400).json({ message: 'Modbus 从站地址需在 0–247' })
return
}
if (pollMs !== undefined && (!Number.isFinite(pollMs) || pollMs < 500 || pollMs > 600000)) {
res.status(400).json({ message: '轮询周期需在 500–600000 ms' })
return
}
const protocol = body.protocol as LightProtocol | undefined
if (protocol && !['modbus', 'http', 'tcp'].includes(protocol)) {
res.status(400).json({ message: '协议无效' })
return
}
const cfg = updateCommConfig(
{
host: body.host,
port,
protocol,
unitId,
username: body.username,
password: body.password,
pollMs,
enabled: body.enabled,
},
req.user!.username,
)
res.json(cfg)
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '保存失败' })
}
})
lightRouter.post('/config/test', authRequired, requireRole('admin'), (_req, res) => {
res.json(testCommConfig())
})
lightRouter.get('/devices', authRequired, (_req, res) => {
res.json({ devices: listDevices() })
})
lightRouter.post('/devices', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {}
const name = String(body.name || '').trim()
const zone = String(body.zone || '').trim()
const circuit = Number(body.circuit)
if (!name || !zone) {
res.status(400).json({ message: '请填写名称与区域' })
return
}
if (!Number.isFinite(circuit) || circuit < 1 || circuit > 256) {
res.status(400).json({ message: '回路号需在 1–256' })
return
}
const device = createDevice({
name,
zone,
circuit,
powerOn: body.powerOn,
status: body.status as LightStatus | undefined,
enabled: body.enabled,
brightness: body.brightness,
remark: body.remark,
})
res.status(201).json({ device })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' })
}
})
lightRouter.patch('/devices/:id', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {}
const device = updateDevice(req.params.id, {
name: body.name,
zone: body.zone,
circuit: body.circuit !== undefined ? Number(body.circuit) : undefined,
status: body.status,
enabled: body.enabled,
brightness: body.brightness !== undefined ? Number(body.brightness) : undefined,
remark: body.remark,
})
res.json({ device })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' })
}
})
lightRouter.delete('/devices/:id', authRequired, requireRole('admin'), (req, res) => {
try {
deleteDevice(req.params.id)
res.json({ ok: true })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '删除失败' })
}
})
lightRouter.post('/devices/:id/switch', authRequired, requireRole('admin'), (req, res) => {
try {
const powerOn = Boolean(req.body?.powerOn ?? req.body?.on)
if (req.body?.powerOn === undefined && req.body?.on === undefined) {
res.status(400).json({ message: '请指定 powerOn / on' })
return
}
const device = switchDevice(req.params.id, powerOn, req.user!.username)
applyWriteLog('light_switch', {
deviceId: device.id,
name: device.name,
powerOn,
by: req.user!.username,
})
res.json({
ok: true,
device,
message: `${device.name} 已远程${powerOn ? '开启' : '关闭'}(Mock)`,
})
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '开关失败' })
}
})
lightRouter.post('/switch-all', authRequired, requireRole('admin'), (req, res) => {
try {
const powerOn = Boolean(req.body?.powerOn ?? req.body?.on)
if (req.body?.powerOn === undefined && req.body?.on === undefined) {
res.status(400).json({ message: '请指定 powerOn / on' })
return
}
const result = switchAll(powerOn, req.user!.username)
applyWriteLog('light_switch_all', {
powerOn,
count: result.count,
by: req.user!.username,
})
res.json({
ok: true,
count: result.count,
devices: result.devices,
message: `已批量${powerOn ? '开启' : '关闭'} ${result.count} 路照明(Mock)`,
})
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '批量开关失败' })
}
})
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { applyWriteLog, getSnapshot } from '../realtime/store.js'
export const realtimeRouter = Router()
realtimeRouter.get('/snapshot', authRequired, (_req, res) => {
res.json(getSnapshot())
})
realtimeRouter.post('/write', authRequired, requireRole('admin'), (req, res) => {
const { target, action, value } = req.body || {}
if (!target || !action) {
res.status(400).json({ message: '缺少 target/action' })
return
}
applyWriteLog(String(action), {
target,
value,
by: req.user!.username,
at: new Date().toISOString(),
})
res.json({ ok: true, message: '指令已受理(当前为 Mock/只记日志)' })
})
realtimeRouter.post('/access/open', authRequired, requireRole('admin'), (req, res) => {
const { doorId } = req.body || {}
if (!doorId) {
res.status(400).json({ message: '缺少 doorId' })
return
}
applyWriteLog('remote_open', { doorId, by: req.user!.username })
res.json({ ok: true, message: `门 ${doorId} 远程开门指令已下发(Mock)` })
})
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import {
getSettings,
updateCollectPollMs,
MIN_POLL_MS,
MAX_POLL_MS,
} from '../settings/store.js'
import { setCollectPollMs } from '../realtime/store.js'
export const settingsRouter = Router()
settingsRouter.get('/', authRequired, async (_req, res) => {
res.json(await getSettings())
})
settingsRouter.put('/collect-poll', authRequired, requireRole('admin'), async (req, res) => {
const ms = Number(req.body?.collectPollMs)
if (!Number.isFinite(ms)) {
res.status(400).json({ message: '请输入有效的采集周期(毫秒)' })
return
}
if (ms < MIN_POLL_MS || ms > MAX_POLL_MS) {
res.status(400).json({
message: `采集周期需在 ${MIN_POLL_MS}–${MAX_POLL_MS} ms 之间`,
})
return
}
const settings = await updateCollectPollMs(ms, req.user!.username)
await setCollectPollMs(settings.collectPollMs)
res.json(settings)
})
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { createUser, deleteUser, listUsers, updateUser } from '../users/store.js'
import type { Role } from '../types.js'
export const usersRouter = Router()
usersRouter.use(authRequired, requireRole('admin'))
usersRouter.get('/', async (_req, res) => {
res.json({ users: await listUsers() })
})
usersRouter.post('/', async (req, res) => {
try {
const { username, displayName, password, role } = req.body || {}
if (!username || !displayName || !password || !role) {
res.status(400).json({ message: '参数不完整' })
return
}
if (!['admin', 'viewer'].includes(role)) {
res.status(400).json({ message: '角色无效' })
return
}
const user = await createUser({
username: String(username),
displayName: String(displayName),
password: String(password),
role: role as Role,
})
res.status(201).json({ user })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' })
}
})
usersRouter.patch('/:id', async (req, res) => {
try {
const user = await updateUser(req.params.id, req.body || {})
res.json({ user })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' })
}
})
usersRouter.delete('/:id', async (req, res) => {
try {
await deleteUser(req.params.id)
res.json({ ok: true })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '删除失败' })
}
})
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import {
createDevice,
deleteDevice,
getCommConfig,
listDevices,
testCommConfig,
updateCommConfig,
updateDevice,
type DeviceStatus,
type DeviceType,
type StreamType,
type VideoProtocol,
} from '../video/store.js'
export const videoRouter = Router()
videoRouter.get('/config', authRequired, (_req, res) => {
res.json(getCommConfig())
})
videoRouter.put('/config', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {}
const port = body.port !== undefined ? Number(body.port) : undefined
const rtspPort = body.rtspPort !== undefined ? Number(body.rtspPort) : undefined
const previewTimeoutMs =
body.previewTimeoutMs !== undefined ? Number(body.previewTimeoutMs) : undefined
if (port !== undefined && (!Number.isFinite(port) || port < 1 || port > 65535)) {
res.status(400).json({ message: '海康设备端口需在 1–65535' })
return
}
if (rtspPort !== undefined && (!Number.isFinite(rtspPort) || rtspPort < 1 || rtspPort > 65535)) {
res.status(400).json({ message: 'RTSP 端口需在 1–65535' })
return
}
if (
previewTimeoutMs !== undefined &&
(!Number.isFinite(previewTimeoutMs) || previewTimeoutMs < 1000 || previewTimeoutMs > 60000)
) {
res.status(400).json({ message: '预览超时需在 1000–60000 ms' })
return
}
const protocol = body.protocol as VideoProtocol | undefined
if (protocol && !['sdk', 'rtsp', 'isapi', 'tcp'].includes(protocol)) {
res.status(400).json({ message: '协议无效(支持 HCNetSDK / RTSP / ISAPI)' })
return
}
const streamType = body.streamType as StreamType | undefined
if (streamType && !['main', 'sub'].includes(streamType)) {
res.status(400).json({ message: '码流类型无效' })
return
}
const cfg = updateCommConfig(
{
host: body.host,
port,
rtspPort,
username: body.username,
password: body.password,
protocol,
streamType,
previewTimeoutMs,
enabled: body.enabled,
},
req.user!.username,
)
res.json(cfg)
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '保存失败' })
}
})
videoRouter.post('/config/test', authRequired, requireRole('admin'), (_req, res) => {
res.json(testCommConfig())
})
videoRouter.get('/devices', authRequired, (_req, res) => {
res.json({ devices: listDevices() })
})
videoRouter.post('/devices', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {}
const name = String(body.name || '').trim()
const location = String(body.location || '').trim()
const channel = Number(body.channel)
const deviceType = body.deviceType as DeviceType
if (!name || !location) {
res.status(400).json({ message: '请填写名称与安装位置' })
return
}
if (!Number.isFinite(channel) || channel < 1 || channel > 256) {
res.status(400).json({ message: '通道号需在 1–256' })
return
}
if (!['ipc', 'nvr-ch'].includes(deviceType)) {
res.status(400).json({ message: '设备类型无效' })
return
}
const device = createDevice({
name,
channel,
location,
deviceType,
status: body.status as DeviceStatus | undefined,
enabled: body.enabled,
ip: body.ip,
remark: body.remark,
})
res.status(201).json({ device })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' })
}
})
videoRouter.patch('/devices/:id', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {}
const device = updateDevice(req.params.id, {
name: body.name,
channel: body.channel !== undefined ? Number(body.channel) : undefined,
location: body.location,
deviceType: body.deviceType,
status: body.status,
enabled: body.enabled,
ip: body.ip,
remark: body.remark,
})
res.json({ device })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' })
}
})
videoRouter.delete('/devices/:id', authRequired, requireRole('admin'), (req, res) => {
try {
deleteDevice(req.params.id)
res.json({ ok: true })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '删除失败' })
}
})
import { config } from '../config.js'
import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js'
export interface SystemSettings {
collectPollMs: number
dataMode: string
collectRetentionDays: number
updatedAt: string
updatedBy: string
storage: 'postgres' | 'fallback'
}
const MIN_POLL_MS = 500
const MAX_POLL_MS = 600_000
export function clampPollMs(ms: number) {
if (!Number.isFinite(ms)) return config.modbus.pollMs
return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, Math.round(ms)))
}
let memPollMs = clampPollMs(config.modbus.pollMs)
let memUpdatedAt = new Date().toISOString()
let memUpdatedBy = 'system'
function base(extra: Partial<SystemSettings> = {}): SystemSettings {
return {
collectPollMs: memPollMs,
dataMode: config.dataMode,
collectRetentionDays: config.collectRetentionDays,
updatedAt: memUpdatedAt,
updatedBy: memUpdatedBy,
storage: isPgEnabled() ? 'postgres' : 'fallback',
...extra,
}
}
export async function ensureSettingsSeed() {
if (!isPgEnabled()) return
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)],
)
}
export async function getSettings(): Promise<SystemSettings> {
if (!isPgEnabled()) return base()
const { rows } = await query<{
collect_poll_ms: number
updated_at: Date
updated_by: string
}>(`SELECT collect_poll_ms, 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),
dataMode: config.dataMode,
collectRetentionDays: config.collectRetentionDays,
updatedAt: r?.updated_at?.toISOString() || new Date().toISOString(),
updatedBy: r?.updated_by || 'system',
storage: 'postgres',
}
}
export async function getCollectPollMs(): Promise<number> {
const s = await getSettings()
return clampPollMs(s.collectPollMs)
}
export async function updateCollectPollMs(ms: number, by: string): Promise<SystemSettings> {
const next = clampPollMs(ms)
if (!isPgEnabled()) {
memPollMs = next
memUpdatedAt = new Date().toISOString()
memUpdatedBy = by
return base()
}
await query(
`UPDATE system_settings
SET collect_poll_ms = $1, updated_at = NOW(), updated_by = $2
WHERE id = 1`,
[next, by],
)
return getSettings()
}
export { MIN_POLL_MS, MAX_POLL_MS }
export type Role = 'admin' | 'viewer'
export interface UserRecord {
id: string
username: string
displayName: string
role: Role
passwordHash: string
enabled: boolean
createdAt: string
lastLoginAt?: string
}
export interface PublicUser {
id: string
username: string
displayName: string
role: Role
enabled: boolean
createdAt: string
lastLoginAt?: string
}
export interface RoomSnapshot {
id: string
name: string
temp: number
humidity: number
pressure: number
alarms: string[]
}
export interface VavSnapshot {
id: string
name: string
running: boolean
faceVelocity: number
sashHeight: number
airflow: number
/** 变风量阀开度 %(报价清单要求;协议无独立寄存器时由风量估算/Mock) */
valveOpen: number
maxExhaust: boolean
minAirflow: boolean
alarmCode: number
}
export interface FridgeSnapshot {
id: string
name: string
temp: number
humidity: number
}
export interface DoorSnapshot {
id: string
name: string
status: 'open' | 'closed' | 'alarm' | 'offline'
}
export interface AccessEvent {
id: string
time: string
doorId: string
doorName: string
person: string
result: 'success' | 'denied' | 'alarm'
}
export interface RealtimeSnapshot {
ts: number
rooms: RoomSnapshot[]
hvac: {
systemRunning: boolean
supplyFanHz: number
exhaustPressure: number
filterRemainPct: number
mode: 'auto' | 'manual'
supplyTemp: number
supplyHumidity: number
supplyPressure: number
freshAirTemp: number
alarms: string[]
}
vav: VavSnapshot[]
fridges: FridgeSnapshot[]
metering: {
powerKwh: number
waterM3: number
coolingKwh: number
powerTrendPct: number
waterTrendPct: number
coolingTrendPct: number
}
access: {
doors: DoorSnapshot[]
todayPassCount: number
recentEvents: AccessEvent[]
}
online: {
devicesOnline: number
devicesTotal: number
gatewayOk: boolean
}
}
import fs from 'node:fs'
import path from 'node:path'
import bcrypt from 'bcryptjs'
import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
import type { PublicUser, Role, UserRecord } from '../types.js'
function ensureDir() {
fs.mkdirSync(path.dirname(config.usersFile), { recursive: true })
}
function load(): UserRecord[] {
ensureDir()
if (!fs.existsSync(config.usersFile)) return []
return JSON.parse(fs.readFileSync(config.usersFile, 'utf-8')) as UserRecord[]
}
function save(users: UserRecord[]) {
ensureDir()
fs.writeFileSync(config.usersFile, JSON.stringify(users, null, 2), 'utf-8')
}
export function toPublic(u: UserRecord): PublicUser {
const { passwordHash: _, ...rest } = u
return rest
}
export async function seedAdminIfNeeded() {
const users = load()
if (users.some((u) => u.role === 'admin')) return
const passwordHash = await bcrypt.hash(config.adminPassword, 10)
users.push({
id: randomUUID(),
username: config.adminUsername,
displayName: '系统管理员',
role: 'admin',
passwordHash,
enabled: true,
createdAt: new Date().toISOString(),
})
users.push({
id: randomUUID(),
username: 'viewer',
displayName: '值班查看',
role: 'viewer',
passwordHash: await bcrypt.hash('Viewer@123', 10),
enabled: true,
createdAt: new Date().toISOString(),
})
save(users)
}
export function listUsers(): PublicUser[] {
return load().map(toPublic)
}
export function findByUsername(username: string): UserRecord | undefined {
return load().find((u) => u.username === username)
}
export function findById(id: string): UserRecord | undefined {
return load().find((u) => u.id === id)
}
export async function verifyLogin(username: string, password: string): Promise<UserRecord | null> {
const user = findByUsername(username)
if (!user || !user.enabled) return null
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) return null
const users = load()
const idx = users.findIndex((u) => u.id === user.id)
users[idx].lastLoginAt = new Date().toISOString()
save(users)
return users[idx]
}
export async function createUser(input: {
username: string
displayName: string
password: string
role: Role
}): Promise<PublicUser> {
const users = load()
if (users.some((u) => u.username === input.username)) throw new Error('用户名已存在')
const record: UserRecord = {
id: randomUUID(),
username: input.username,
displayName: input.displayName,
role: input.role,
passwordHash: await bcrypt.hash(input.password, 10),
enabled: true,
createdAt: new Date().toISOString(),
}
users.push(record)
save(users)
return toPublic(record)
}
export async function updateUser(
id: string,
patch: Partial<Pick<UserRecord, 'displayName' | 'role' | 'enabled'>> & { password?: string },
): Promise<PublicUser> {
const users = load()
const idx = users.findIndex((u) => u.id === id)
if (idx < 0) throw new Error('用户不存在')
if (patch.role === 'viewer' || patch.enabled === false) {
const admins = users.filter((u) => u.role === 'admin' && u.enabled && u.id !== id)
if (users[idx].role === 'admin' && admins.length === 0) {
throw new Error('不能禁用或降级最后一个管理员')
}
}
if (patch.displayName !== undefined) users[idx].displayName = patch.displayName
if (patch.role !== undefined) users[idx].role = patch.role
if (patch.enabled !== undefined) users[idx].enabled = patch.enabled
if (patch.password) users[idx].passwordHash = await bcrypt.hash(patch.password, 10)
save(users)
return toPublic(users[idx])
}
export function deleteUser(id: string) {
const users = load()
const target = users.find((u) => u.id === id)
if (!target) throw new Error('用户不存在')
const admins = users.filter((u) => u.role === 'admin' && u.enabled && u.id !== id)
if (target.role === 'admin' && admins.length === 0) {
throw new Error('不能删除最后一个管理员')
}
save(users.filter((u) => u.id !== id))
}
import bcrypt from 'bcryptjs'
import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js'
import type { PublicUser, Role, UserRecord } from '../types.js'
import * as file from './fileStore.js'
interface UserRow {
id: string
username: string
display_name: string
role: Role
password_hash: string
enabled: boolean
created_at: Date | string
last_login_at: Date | string | null
}
function toIso(v: Date | string | null | undefined): string | undefined {
if (v == null) return undefined
if (typeof v === 'string') return new Date(v).toISOString()
return v.toISOString()
}
function mapRow(r: UserRow): UserRecord {
return {
id: r.id,
username: r.username,
displayName: r.display_name,
role: r.role,
passwordHash: r.password_hash,
enabled: r.enabled,
createdAt: toIso(r.created_at) || new Date().toISOString(),
lastLoginAt: toIso(r.last_login_at),
}
}
export function toPublic(u: UserRecord): PublicUser {
return file.toPublic(u)
}
export async function seedAdminIfNeeded() {
if (!isPgEnabled()) return file.seedAdminIfNeeded()
const { rows } = await query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM users WHERE role = 'admin'`,
)
if (Number(rows[0]?.n || 0) > 0) return
const adminHash = await bcrypt.hash(config.adminPassword, 10)
const viewerHash = await bcrypt.hash('Viewer@123', 10)
await query(
`INSERT INTO users (id, username, display_name, role, password_hash, enabled)
VALUES ($1,$2,$3,'admin',$4,TRUE), ($5,'viewer','值班查看','viewer',$6,TRUE)`,
[
randomUUID(),
config.adminUsername,
'系统管理员',
adminHash,
randomUUID(),
viewerHash,
],
)
console.log('[db] seeded admin/viewer')
}
export async function listUsers(): Promise<PublicUser[]> {
if (!isPgEnabled()) return file.listUsers()
const { rows } = await query<UserRow>(`SELECT * FROM users ORDER BY created_at ASC`)
return rows.map((r) => toPublic(mapRow(r)))
}
export async function findByUsername(username: string): Promise<UserRecord | undefined> {
if (!isPgEnabled()) return file.findByUsername(username)
const { rows } = await query<UserRow>(`SELECT * FROM users WHERE username = $1`, [username])
return rows[0] ? mapRow(rows[0]) : undefined
}
export async function findById(id: string): Promise<UserRecord | undefined> {
if (!isPgEnabled()) return file.findById(id)
const { rows } = await query<UserRow>(`SELECT * FROM users WHERE id = $1`, [id])
return rows[0] ? mapRow(rows[0]) : undefined
}
export async function verifyLogin(username: string, password: string): Promise<UserRecord | null> {
if (!isPgEnabled()) return file.verifyLogin(username, password)
const user = await findByUsername(username)
if (!user || !user.enabled) return null
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) return null
await query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
return { ...user, lastLoginAt: new Date().toISOString() }
}
export async function createUser(input: {
username: string
displayName: string
password: string
role: Role
}): Promise<PublicUser> {
if (!isPgEnabled()) return file.createUser(input)
const exists = await findByUsername(input.username)
if (exists) throw new Error('用户名已存在')
const id = randomUUID()
const passwordHash = await bcrypt.hash(input.password, 10)
await query(
`INSERT INTO users (id, username, display_name, role, password_hash, enabled)
VALUES ($1,$2,$3,$4,$5,TRUE)`,
[id, input.username, input.displayName, input.role, passwordHash],
)
const created = await findById(id)
return toPublic(created!)
}
export async function updateUser(
id: string,
patch: Partial<Pick<UserRecord, 'displayName' | 'role' | 'enabled'>> & { password?: string },
): Promise<PublicUser> {
if (!isPgEnabled()) return file.updateUser(id, patch)
const current = await findById(id)
if (!current) throw new Error('用户不存在')
if (patch.role === 'viewer' || patch.enabled === false) {
const { rows } = await query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM users WHERE role='admin' AND enabled=TRUE AND id <> $1`,
[id],
)
if (current.role === 'admin' && Number(rows[0]?.n || 0) === 0) {
throw new Error('不能禁用或降级最后一个管理员')
}
}
const displayName = patch.displayName ?? current.displayName
const role = patch.role ?? current.role
const enabled = patch.enabled ?? current.enabled
const passwordHash = patch.password
? await bcrypt.hash(patch.password, 10)
: current.passwordHash
await query(
`UPDATE users SET display_name=$2, role=$3, enabled=$4, password_hash=$5 WHERE id=$1`,
[id, displayName, role, enabled, passwordHash],
)
const updated = await findById(id)
return toPublic(updated!)
}
export async function deleteUser(id: string) {
if (!isPgEnabled()) return file.deleteUser(id)
const target = await findById(id)
if (!target) throw new Error('用户不存在')
const { rows } = await query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM users WHERE role='admin' AND enabled=TRUE AND id <> $1`,
[id],
)
if (target.role === 'admin' && Number(rows[0]?.n || 0) === 0) {
throw new Error('不能删除最后一个管理员')
}
await query(`DELETE FROM users WHERE id = $1`, [id])
}
import fs from 'node:fs'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
export type DeviceStatus = 'online' | 'offline' | 'alarm'
export type DeviceType = 'ipc' | 'nvr-ch'
/** 海康:HCNetSDK / RTSP / ISAPI */
export type VideoProtocol = 'sdk' | 'rtsp' | 'isapi'
export type StreamType = 'main' | 'sub'
export interface VideoDevice {
id: string
name: string
channel: number
location: string
deviceType: DeviceType
status: DeviceStatus
enabled: boolean
ip: string
remark: string
createdAt: string
updatedAt: string
}
export interface VideoCommConfig {
/** 固定海康威视 */
vendor: 'hikvision'
host: string
/** HCNetSDK 设备服务端口,默认 8000 */
port: number
/** RTSP 端口,默认 554 */
rtspPort: number
username: string
/** 明文仅服务端持有;API 对外返回 masked */
password: string
protocol: VideoProtocol
streamType: StreamType
previewTimeoutMs: number
enabled: boolean
updatedAt: string
updatedBy: string
}
export type PublicCommConfig = Omit<VideoCommConfig, 'password'> & {
passwordSet: boolean
passwordMasked: string
}
interface VideoData {
config: VideoCommConfig
devices: VideoDevice[]
}
const filePath = () => config.videoFile
function defaultConfig(): VideoCommConfig {
return {
vendor: 'hikvision',
host: '192.168.10.200',
port: 8000,
rtspPort: 554,
username: 'admin',
password: 'Admin@123',
protocol: 'sdk',
streamType: 'sub',
previewTimeoutMs: 8000,
enabled: true,
updatedAt: new Date().toISOString(),
updatedBy: 'system',
}
}
function seedDevices(): VideoDevice[] {
const now = new Date().toISOString()
const rows: Omit<VideoDevice, 'id' | 'createdAt' | 'updatedAt'>[] = [
{ name: '走廊东', channel: 1, location: 'E栋一层走廊', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: 'PCR区', channel: 2, location: '分子诊断 PCR', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '样本库', channel: 3, location: '样本库入口', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '机房', channel: 4, location: '弱电/机房', deviceType: 'ipc', status: 'online', enabled: true, ip: '192.168.10.211', remark: '海康网络摄像机' },
{ name: '门禁口', channel: 5, location: '主入口门禁', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '冷链库', channel: 6, location: '冷链监测区', deviceType: 'nvr-ch', status: 'alarm', enabled: true, ip: '', remark: '海康通道 · 联动冷柜超温' },
]
return rows.map((r) => ({
...r,
id: randomUUID(),
createdAt: now,
updatedAt: now,
}))
}
function normalizeProtocol(p: string | undefined): VideoProtocol {
if (p === 'rtsp' || p === 'isapi' || p === 'sdk') return p
if (p === 'tcp') return 'isapi'
return 'sdk'
}
function normalizeData(raw: VideoData): VideoData {
const base = defaultConfig()
const c = { ...base, ...(raw.config || {}) }
c.vendor = 'hikvision'
c.protocol = normalizeProtocol(c.protocol as string)
c.rtspPort = Number(c.rtspPort) || 554
c.port = Number(c.port) || 8000
return { config: c, devices: Array.isArray(raw.devices) ? raw.devices : [] }
}
function ensureDir() {
fs.mkdirSync(path.dirname(filePath()), { recursive: true })
}
function load(): VideoData {
ensureDir()
if (!fs.existsSync(filePath())) {
const data: VideoData = { config: defaultConfig(), devices: seedDevices() }
save(data)
return data
}
const raw = JSON.parse(fs.readFileSync(filePath(), 'utf-8')) as VideoData
const data = normalizeData(raw)
// 补齐海康字段后写回,避免旧配置缺 vendor/rtspPort
if (
(raw.config as VideoCommConfig | undefined)?.vendor !== 'hikvision' ||
(raw.config as { rtspPort?: number } | undefined)?.rtspPort == null ||
(raw.config as { protocol?: string } | undefined)?.protocol === 'tcp'
) {
save(data)
}
return data
}
function save(data: VideoData) {
ensureDir()
fs.writeFileSync(filePath(), JSON.stringify(data, null, 2), 'utf-8')
}
export function ensureVideoSeed() {
load()
}
function toPublicConfig(c: VideoCommConfig): PublicCommConfig {
const { password, ...rest } = c
return {
...rest,
passwordSet: Boolean(password),
passwordMasked: password ? '********' : '',
}
}
export function getCommConfig(): PublicCommConfig {
return toPublicConfig(load().config)
}
export function updateCommConfig(
patch: Partial<{
host: string
port: number
rtspPort: number
username: string
password: string
protocol: VideoProtocol
streamType: StreamType
previewTimeoutMs: number
enabled: boolean
}>,
byUser: string,
): PublicCommConfig {
const data = load()
const c = data.config
c.vendor = 'hikvision'
if (patch.host !== undefined) c.host = String(patch.host).trim()
if (patch.port !== undefined) c.port = Number(patch.port)
if (patch.rtspPort !== undefined) c.rtspPort = Number(patch.rtspPort)
if (patch.username !== undefined) c.username = String(patch.username).trim()
if (patch.password !== undefined && String(patch.password).trim() && patch.password !== '********') {
c.password = String(patch.password)
}
if (patch.protocol !== undefined) c.protocol = normalizeProtocol(patch.protocol)
if (patch.streamType !== undefined) c.streamType = patch.streamType
if (patch.previewTimeoutMs !== undefined) c.previewTimeoutMs = Number(patch.previewTimeoutMs)
if (patch.enabled !== undefined) c.enabled = Boolean(patch.enabled)
c.updatedAt = new Date().toISOString()
c.updatedBy = byUser
save(data)
return toPublicConfig(c)
}
export function testCommConfig(): { ok: boolean; message: string; latencyMs: number } {
const c = load().config
if (!c.enabled) {
return { ok: false, message: '海康通信配置已禁用', latencyMs: 0 }
}
if (!c.host || !c.port) {
return { ok: false, message: '请填写海康 NVR IP 与设备端口(默认 8000)', latencyMs: 0 }
}
// 一期 Mock:校验配置完整性;二期经 Windows HCNetSDK 桥接登录预览
const latencyMs = 40 + Math.floor(Math.random() * 80)
const proto =
c.protocol === 'sdk' ? 'HCNetSDK' : c.protocol === 'rtsp' ? 'RTSP' : 'ISAPI'
return {
ok: true,
message: `海康设备 ${c.host}:${c.port} 登录成功(${proto} · Mock)`,
latencyMs,
}
}
export function listDevices(): VideoDevice[] {
return load().devices.slice().sort((a, b) => a.channel - b.channel)
}
export function createDevice(input: {
name: string
channel: number
location: string
deviceType: DeviceType
status?: DeviceStatus
enabled?: boolean
ip?: string
remark?: string
}): VideoDevice {
const data = load()
if (data.devices.some((d) => d.channel === input.channel)) {
throw new Error(`通道 ${input.channel} 已存在`)
}
const now = new Date().toISOString()
const device: VideoDevice = {
id: randomUUID(),
name: input.name.trim(),
channel: Number(input.channel),
location: input.location.trim(),
deviceType: input.deviceType,
status: input.status || 'online',
enabled: input.enabled !== false,
ip: (input.ip || '').trim(),
remark: (input.remark || '').trim(),
createdAt: now,
updatedAt: now,
}
data.devices.push(device)
save(data)
return device
}
export function updateDevice(
id: string,
patch: Partial<{
name: string
channel: number
location: string
deviceType: DeviceType
status: DeviceStatus
enabled: boolean
ip: string
remark: string
}>,
): VideoDevice {
const data = load()
const idx = data.devices.findIndex((d) => d.id === id)
if (idx < 0) throw new Error('设备不存在')
if (patch.channel !== undefined) {
const ch = Number(patch.channel)
if (data.devices.some((d) => d.id !== id && d.channel === ch)) {
throw new Error(`通道 ${ch} 已存在`)
}
data.devices[idx].channel = ch
}
if (patch.name !== undefined) data.devices[idx].name = String(patch.name).trim()
if (patch.location !== undefined) data.devices[idx].location = String(patch.location).trim()
if (patch.deviceType !== undefined) data.devices[idx].deviceType = patch.deviceType
if (patch.status !== undefined) data.devices[idx].status = patch.status
if (patch.enabled !== undefined) data.devices[idx].enabled = Boolean(patch.enabled)
if (patch.ip !== undefined) data.devices[idx].ip = String(patch.ip).trim()
if (patch.remark !== undefined) data.devices[idx].remark = String(patch.remark).trim()
data.devices[idx].updatedAt = new Date().toISOString()
save(data)
return data.devices[idx]
}
export function deleteDevice(id: string) {
const data = load()
if (!data.devices.some((d) => d.id === id)) throw new Error('设备不存在')
data.devices = data.devices.filter((d) => d.id !== id)
save(data)
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
COPY web/package.json ./web/
COPY server/package.json ./server/
RUN npm install --workspace=web --include=dev
COPY web ./web
WORKDIR /app/web
ARG VITE_API_BASE=
ARG VITE_VIEWPORT_MODE=glb
ARG VITE_UE_STREAM_URL=
ENV VITE_API_BASE=$VITE_API_BASE \
VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \
VITE_UE_STREAM_URL=$VITE_UE_STREAM_URL
RUN npx vite build
FROM nginx:1.27-alpine
COPY web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/web/dist /usr/share/nginx/html
EXPOSE 80
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>分子实验室三维可视化管理平台</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=IBM+Plex+Sans:wght@500;600;700&display=swap" rel="stylesheet" />
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 20m;
location /api/ {
proxy_pass http://server:3001/api/;
proxy_http_version 1.1;
proxy_set_header Host $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 /ws {
proxy_pass http://server:3001/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
location / {
try_files $uri $uri/ /index.html;
}
}
{
"name": "web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^3.0.1",
"vue": "^3.5.42",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"typescript": "~5.8.2",
"vite": "^6.2.2",
"vue-tsc": "^2.2.8"
}
}
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>
<template>
<RouterView />
</template>
const API_BASE = import.meta.env.VITE_API_BASE || ''
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
export async function api<T>(
path: string,
options: RequestInit & { token?: string | null } = {},
): Promise<T> {
const headers = new Headers(options.headers || {})
if (!headers.has('Content-Type') && options.body) {
headers.set('Content-Type', 'application/json')
}
if (options.token) headers.set('Authorization', `Bearer ${options.token}`)
const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new ApiError(res.status, data.message || `请求失败 (${res.status})`)
}
return data as T
}
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, useId } from 'vue'
export interface LinePoint {
label: string
value: number
}
const props = withDefaults(
defineProps<{
points: LinePoint[]
color?: string
unit?: string
height?: number
/** 横轴每隔几个点显示标签,默认自动 */
labelEvery?: number
}>(),
{
color: '#37c8ff',
unit: '元',
height: 180,
labelEvery: 0,
},
)
const gradId = `lg-${useId().replace(/:/g, '')}`
const rootRef = ref<HTMLElement | null>(null)
const hover = ref<number | null>(null)
/** 与容器同宽,避免 preserveAspectRatio=none 拉伸文字 */
const width = ref(640)
const pad = { top: 16, right: 16, bottom: 28, left: 48 }
let ro: ResizeObserver | undefined
onMounted(() => {
if (!rootRef.value) return
const update = () => {
const w = rootRef.value?.clientWidth || 640
width.value = Math.max(280, Math.round(w))
}
update()
ro = new ResizeObserver(update)
ro.observe(rootRef.value)
})
onBeforeUnmount(() => ro?.disconnect())
const chart = computed(() => {
const pts = props.points
const w = width.value
if (!pts.length) {
return {
path: '',
area: '',
dots: [] as { x: number; y: number; label: string; value: number }[],
yTicks: [] as { v: number; y: number }[],
xLabels: [] as { x: number; label: string }[],
}
}
const values = pts.map((p) => p.value)
const minV = Math.min(...values)
const maxV = Math.max(...values)
const span = maxV - minV || maxV * 0.1 || 1
const yMin = Math.max(0, minV - span * 0.12)
const yMax = maxV + span * 0.12
const innerW = w - pad.left - pad.right
const innerH = props.height - pad.top - pad.bottom
const yRange = yMax - yMin || 1
const xAt = (i: number) => pad.left + (pts.length === 1 ? innerW / 2 : (i / (pts.length - 1)) * innerW)
const yAt = (v: number) => pad.top + ((yMax - v) / yRange) * innerH
const dots = pts.map((p, i) => ({
x: xAt(i),
y: yAt(p.value),
label: p.label,
value: p.value,
}))
const path = dots.map((d, i) => `${i === 0 ? 'M' : 'L'}${d.x.toFixed(1)},${d.y.toFixed(1)}`).join(' ')
const area = `${path} L${dots[dots.length - 1].x.toFixed(1)},${(pad.top + innerH).toFixed(1)} L${dots[0].x.toFixed(1)},${(pad.top + innerH).toFixed(1)} Z`
const yTicks: { v: number; y: number }[] = []
for (let i = 0; i <= 4; i++) {
const v = yMin + (yRange * i) / 4
yTicks.push({ v, y: yAt(v) })
}
const step =
props.labelEvery > 0
? props.labelEvery
: pts.length <= 8
? 1
: pts.length <= 16
? 2
: Math.ceil(pts.length / 8)
const xLabels = dots
.map((d, i) => ({ x: d.x, label: d.label, show: i % step === 0 || i === dots.length - 1 }))
.filter((d) => d.show)
return { path, area, dots, yTicks, xLabels }
})
function fmt(n: number) {
if (n >= 10000) return `${(n / 10000).toFixed(1)}万`
if (n >= 1000) return n.toFixed(0)
return n.toFixed(n >= 100 ? 0 : 1)
}
</script>
<template>
<div ref="rootRef" class="line-chart" :style="{ height: `${height}px` }">
<svg
class="svg"
:viewBox="`0 0 ${width} ${height}`"
:width="width"
:height="height"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" :stop-color="color" stop-opacity="0.35" />
<stop offset="100%" :stop-color="color" stop-opacity="0.02" />
</linearGradient>
</defs>
<line
v-for="(t, i) in chart.yTicks"
:key="`g-${i}`"
:x1="pad.left"
:x2="width - pad.right"
:y1="t.y"
:y2="t.y"
stroke="rgba(62,200,255,0.12)"
stroke-width="1"
/>
<text
v-for="(t, i) in chart.yTicks"
:key="`yl-${i}`"
:x="pad.left - 8"
:y="t.y + 4"
text-anchor="end"
class="axis"
>
{{ fmt(t.v) }}
</text>
<path v-if="chart.area" :d="chart.area" :fill="`url(#${gradId})`" />
<path
v-if="chart.path"
:d="chart.path"
fill="none"
:stroke="color"
stroke-width="2.2"
stroke-linejoin="round"
stroke-linecap="round"
/>
<g v-for="(d, i) in chart.dots" :key="`d-${i}`">
<circle
:cx="d.x"
:cy="d.y"
:r="hover === i ? 5 : 3.2"
:fill="color"
stroke="rgba(4,16,31,0.9)"
stroke-width="1.5"
@mouseenter="hover = i"
@mouseleave="hover = null"
/>
</g>
<text
v-for="(x, i) in chart.xLabels"
:key="`xl-${i}`"
:x="x.x"
:y="height - 8"
text-anchor="middle"
class="axis"
>
{{ x.label }}
</text>
</svg>
<div
v-if="hover != null && chart.dots[hover]"
class="tip"
:style="{ left: `${(chart.dots[hover].x / width) * 100}%` }"
>
<b>{{ chart.dots[hover].label }}</b>
<span>
{{ chart.dots[hover].value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
{{ unit }}
</span>
</div>
</div>
</template>
<style scoped>
.line-chart {
position: relative;
width: 100%;
min-height: 140px;
}
.svg {
width: 100%;
height: 100%;
display: block;
overflow: visible;
}
.axis {
fill: rgba(127, 166, 201, 0.9);
font-size: 11px;
font-family: var(--font-num);
}
.tip {
position: absolute;
top: 4px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 10px;
border-radius: 6px;
background: rgba(6, 20, 42, 0.92);
border: 1px solid rgba(62, 200, 255, 0.35);
pointer-events: none;
white-space: nowrap;
z-index: 2;
}
.tip b { font-size: 11px; color: #fff; }
.tip span { font-size: 12px; color: var(--cyan-lt); font-family: var(--font-num); }
</style>
<script setup lang="ts">
withDefaults(
defineProps<{
name: string
className?: string
}>(),
{ className: '' },
)
</script>
<template>
<!-- 品牌 Logo -->
<svg v-if="name === 'logo'" class="icon" :class="className" viewBox="0 0 34 34" fill="none">
<path d="M17 3.4 29.5 10.6v12.8L17 30.6 4.5 23.4V10.6L17 3.4Z" stroke="#37C8FF" stroke-width="1.6" stroke-linejoin="round" />
<path d="M17 3.4v13.4M17 16.8 29.5 10.6M17 16.8 4.5 23.4" stroke="#37C8FF" stroke-width="1.2" opacity=".7" />
<circle cx="17" cy="16.8" r="2.8" fill="#2EE6A8" />
</svg>
<!-- 面板:环境 -->
<svg v-else-if="name === 'panel-env'" class="icon p-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="7" fill="#37C8FF" fill-opacity=".14" />
<rect x=".75" y=".75" width="22.5" height="22.5" rx="6.25" stroke="#37C8FF" stroke-opacity=".45" />
<path d="M10 13.8V5a2 2 0 1 1 4 0v8.8a4.5 4.5 0 1 1-4 0Z" stroke="#5BD4FF" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="17" r="1.6" fill="#5BD4FF" />
</svg>
<!-- 面板:VAV -->
<svg v-else-if="name === 'panel-vav'" class="icon p-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="7" fill="#37C8FF" fill-opacity=".14" />
<rect x=".75" y=".75" width="22.5" height="22.5" rx="6.25" stroke="#37C8FF" stroke-opacity=".45" />
<circle cx="12" cy="12" r="7.4" stroke="#5BD4FF" stroke-width="1.7" />
<circle cx="12" cy="12" r="1.7" fill="#5BD4FF" />
<path d="M12 10.3V5.2M13.5 12.9l4.4 2.4M10.5 12.9l-4.4 2.4" stroke="#5BD4FF" stroke-width="1.7" stroke-linecap="round" />
</svg>
<!-- 面板:冷链 -->
<svg v-else-if="name === 'panel-cold'" class="icon p-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="7" fill="#37C8FF" fill-opacity=".14" />
<rect x=".75" y=".75" width="22.5" height="22.5" rx="6.25" stroke="#37C8FF" stroke-opacity=".45" />
<path d="M12 3.5v17M4.6 7.75l14.8 8.5M19.4 7.75l-14.8 8.5" stroke="#5BD4FF" stroke-width="1.8" stroke-linecap="round" />
</svg>
<!-- 面板:空调 -->
<svg v-else-if="name === 'panel-hvac'" class="icon p-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="7" fill="#37C8FF" fill-opacity=".14" />
<rect x=".75" y=".75" width="22.5" height="22.5" rx="6.25" stroke="#37C8FF" stroke-opacity=".45" />
<path d="M3 8.5h9a2.2 2.2 0 1 0-2.2-2.2M3 12h13a2.2 2.2 0 1 1-2.2 2.2M3 15.5h6" stroke="#5BD4FF" stroke-width="1.8" stroke-linecap="round" />
</svg>
<!-- 面板:告警 -->
<svg v-else-if="name === 'panel-alarm'" class="icon p-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="7" fill="#FF5252" fill-opacity=".12" />
<rect x=".75" y=".75" width="22.5" height="22.5" rx="6.25" stroke="#FF5252" stroke-opacity=".45" />
<path d="M12 3.5a4.5 4.5 0 0 0-4.5 4.5v2.8L6 13.5h12l-1.5-2.7V8A4.5 4.5 0 0 0 12 3.5Z" stroke="#FF7B7B" stroke-width="1.8" stroke-linejoin="round" />
<path d="M10 16.5a2 2 0 0 0 4 0" stroke="#FF7B7B" stroke-width="1.8" stroke-linecap="round" />
</svg>
<!-- 面板:视频 -->
<svg v-else-if="name === 'panel-video'" class="icon p-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="7" fill="#37C8FF" fill-opacity=".14" />
<rect x=".75" y=".75" width="22.5" height="22.5" rx="6.25" stroke="#37C8FF" stroke-opacity=".45" />
<rect x="3" y="7" width="12" height="10" rx="2" stroke="#5BD4FF" stroke-width="1.8" />
<path d="M15 10.8 20 8v8l-5-2.8" stroke="#5BD4FF" stroke-width="1.8" stroke-linejoin="round" />
</svg>
<!-- 指标:压差 -->
<svg v-else-if="name === 'metric-pressure'" class="icon m-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="7" fill="#2EE6A8" fill-opacity=".10" />
<path d="M4.5 15a7.5 7.5 0 0 1 15 0" stroke="#5BD4FF" stroke-width="2" stroke-linecap="round" />
<path d="M12 15l3.2-3.8" stroke="#5BD4FF" stroke-width="2" stroke-linecap="round" />
<circle cx="12" cy="15" r="1.5" fill="#2EE6A8" />
</svg>
<!-- 指标:温度 -->
<svg v-else-if="name === 'metric-temp'" class="icon m-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="7" fill="#37C8FF" fill-opacity=".12" />
<path d="M10 13.8V5a2 2 0 1 1 4 0v8.8a4.5 4.5 0 1 1-4 0Z" stroke="#5BD4FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="17" r="1.7" fill="#5BD4FF" />
</svg>
<!-- 指标:湿度 -->
<svg v-else-if="name === 'metric-humidity'" class="icon m-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="7" fill="#37C8FF" fill-opacity=".12" />
<path d="M12 3.5s5.6 6.2 5.6 10a5.6 5.6 0 1 1-11.2 0c0-3.8 5.6-10 5.6-10Z" stroke="#5BD4FF" stroke-width="2" stroke-linejoin="round" />
</svg>
<!-- 指标:换气 -->
<svg v-else-if="name === 'metric-air'" class="icon m-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="7" fill="#37C8FF" fill-opacity=".12" />
<path d="M4.5 12a7.5 7.5 0 0 1 13-5.1M19.5 12a7.5 7.5 0 0 1-13 5.1" stroke="#5BD4FF" stroke-width="2" stroke-linecap="round" />
<path d="M17.5 3.5v3.6h-3.6M6.5 20.5v-3.6h3.6" stroke="#5BD4FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<!-- VAV 行图标 -->
<svg v-else-if="name === 'vav-speed'" class="icon v-icon" :class="className" viewBox="0 0 24 24" fill="none">
<path d="M3 9h9.5a2.4 2.4 0 1 0-2.4-2.4M3 13h13.5a2.4 2.4 0 1 1-2.4 2.4M3 17h6" stroke="#5BD4FF" stroke-width="2.2" stroke-linecap="round" />
</svg>
<svg v-else-if="name === 'vav-sash'" class="icon v-icon" :class="className" viewBox="0 0 24 24" fill="none">
<path d="M12 3.5v17M8.2 7.2 12 3.5l3.8 3.7M8.2 16.8l3.8 3.7 3.8-3.7" stroke="#5BD4FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg v-else-if="name === 'vav-valve'" class="icon v-icon" :class="className" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="8" stroke="#5BD4FF" stroke-width="2.2" />
<path d="M12 7v5.2l3.8 2.6" stroke="#5BD4FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<!-- 冷链雪花 -->
<svg v-else-if="name === 'fridge'" class="icon f-icon" :class="className" viewBox="0 0 24 24" fill="none">
<path d="M12 3.5v17M4.6 7.75l14.8 8.5M19.4 7.75l-14.8 8.5" :stroke="className?.includes('warn') ? '#FFCF5C' : '#7FD8FF'" stroke-width="2.4" stroke-linecap="round" />
</svg>
<!-- 告警 -->
<svg v-else-if="name === 'alarm-warn'" class="icon a-icon" :class="className" viewBox="0 0 24 24" fill="none">
<path d="M12 3.5 2.8 19.5h18.4L12 3.5Z" stroke="#FF6B6B" stroke-width="2" stroke-linejoin="round" />
<path d="M12 9.5v4.5" stroke="#FF6B6B" stroke-width="2" stroke-linecap="round" />
<circle cx="12" cy="16.8" r="1.1" fill="#FF6B6B" />
</svg>
<svg v-else-if="name === 'alarm-amber'" class="icon a-icon" :class="className" viewBox="0 0 24 24" fill="none">
<path d="M12 3.5 2.8 19.5h18.4L12 3.5Z" stroke="#FFCF5C" stroke-width="2" stroke-linejoin="round" />
<path d="M12 9.5v4.5" stroke="#FFCF5C" stroke-width="2" stroke-linecap="round" />
<circle cx="12" cy="16.8" r="1.1" fill="#FFCF5C" />
</svg>
<svg v-else-if="name === 'alarm-ok'" class="icon a-icon" :class="className" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="8.3" stroke="#2EE6A8" stroke-width="2" />
<path d="M8.2 12.4l2.6 2.6 5-5.4" stroke="#2EE6A8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<!-- 摄像头角标 -->
<svg v-else-if="name === 'cam-badge'" class="icon corner" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#04101F" fill-opacity=".6" />
<rect x=".5" y=".5" width="23" height="23" rx="5.5" stroke="#37C8FF" stroke-opacity=".45" />
<rect x="4.5" y="8" width="10.5" height="8.5" rx="1.6" stroke="#D6ECFF" stroke-width="1.5" />
<path d="M15 10.8l4.5-2.3v7.5L15 13.7" stroke="#D6ECFF" stroke-width="1.5" stroke-linejoin="round" />
<circle cx="8" cy="5.6" r="1.6" fill="#FF5252" />
</svg>
<!-- 定位针 -->
<svg v-else-if="name === 'pin'" class="icon pin" :class="className" viewBox="0 0 24 24" fill="none">
<path d="M12 21s-6.6-5.7-6.6-10.4a6.6 6.6 0 1 1 13.2 0C18.6 15.3 12 21 12 21Z" stroke="#5BD4FF" stroke-width="2" stroke-linejoin="round" />
<circle cx="12" cy="10.4" r="2.3" stroke="#5BD4FF" stroke-width="2" />
</svg>
<!-- 手势提示 -->
<svg v-else-if="name === 'hand'" class="icon" :class="className" viewBox="0 0 24 24" fill="none" stroke="#5BD4FF" stroke-width="2" stroke-linecap="round">
<path d="M9 11.5V6.5a1.5 1.5 0 0 1 3 0v4m0-1.5a1.5 1.5 0 0 1 3 0v1m0 0a1.5 1.5 0 0 1 3 0v4.5a6 6 0 0 1-6 6h-.8a6 6 0 0 1-4.9-2.5L4 15.6a1.6 1.6 0 0 1 2.5-2l1.5 1.9v-7a1.5 1.5 0 0 1 3 0" />
</svg>
<!-- KPI -->
<svg v-else-if="name === 'kpi-power'" class="icon k-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#FFB400" fill-opacity=".10" />
<rect x=".75" y=".75" width="22.5" height="22.5" rx="5.25" stroke="#FFB400" stroke-opacity=".40" />
<path d="M13 2.5 5.5 13.5H11l-1.5 8L17 10.5h-5.5L13 2.5Z" stroke="#FFCF5C" stroke-width="1.8" stroke-linejoin="round" />
</svg>
<svg v-else-if="name === 'kpi-water'" class="icon k-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#37C8FF" fill-opacity=".10" />
<rect x=".75" y=".75" width="22.5" height="22.5" rx="5.25" stroke="#37C8FF" stroke-opacity=".35" />
<path d="M12 3s6 6.6 6 10.7a6 6 0 1 1-12 0C6 9.6 12 3 12 3Z" stroke="#5BD4FF" stroke-width="1.8" stroke-linejoin="round" />
<path d="M9.3 14a2.7 2.7 0 0 0 2.7 2.7" stroke="#5BD4FF" stroke-width="1.5" stroke-linecap="round" />
</svg>
<svg v-else-if="name === 'kpi-cool'" class="icon k-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#67E8F9" fill-opacity=".10" />
<rect x=".75" y=".75" width="22.5" height="22.5" rx="5.25" stroke="#67E8F9" stroke-opacity=".40" />
<path d="M12 2.5v19M3.7 7.25l16.6 9.5M20.3 7.25 3.7 16.75" stroke="#A5F3FC" stroke-width="1.7" stroke-linecap="round" />
</svg>
<svg v-else-if="name === 'kpi-device'" class="icon k-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#34D399" fill-opacity=".10" />
<rect x=".75" y=".75" width="22.5" height="22.5" rx="5.25" stroke="#34D399" stroke-opacity=".40" />
<rect x="3" y="4" width="18" height="7" rx="1.8" stroke="#6EE7B7" stroke-width="1.7" />
<rect x="3" y="13" width="18" height="7" rx="1.8" stroke="#6EE7B7" stroke-width="1.7" />
<circle cx="7" cy="7.5" r="1.3" fill="#34D399" />
<circle cx="7" cy="16.5" r="1.3" fill="#34D399" />
</svg>
<svg v-else-if="name === 'kpi-access'" class="icon k-icon" :class="className" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#A78BFA" fill-opacity=".14" />
<rect x=".75" y=".75" width="22.5" height="22.5" rx="5.25" stroke="#A78BFA" stroke-opacity=".5" />
<path d="M6.5 4.5h8.2a1.8 1.8 0 0 1 1.8 1.8v11.4a1.8 1.8 0 0 1-1.8 1.8H6.5V4.5Z" stroke="#C4B5FD" stroke-width="1.7" stroke-linejoin="round" />
<circle cx="13.2" cy="12" r="1.15" fill="#C4B5FD" />
<path d="M4.8 8.2h2.2M4.8 12h2.2M4.8 15.8h2.2" stroke="#C4B5FD" stroke-width="1.5" stroke-linecap="round" />
</svg>
</template>
<style scoped>
.icon { display: block; flex: 0 0 auto; }
.logo { width: 34px; height: 34px; }
.p-icon { width: 18px; height: 18px; }
.m-icon { width: 20px; height: 20px; }
.v-icon { width: 15px; height: 15px; }
.f-icon { width: 14px; height: 14px; }
.a-icon { width: 18px; height: 18px; }
.corner { width: 22px; height: 22px; }
.pin { width: 16px; height: 16px; }
.k-icon { width: 40px; height: 40px; }
</style>
<script setup lang="ts">
import { computed } from 'vue'
import AppIcon from '@/components/icons/AppIcon.vue'
export interface AlarmItem {
level: 'red' | 'amber' | 'calm'
title: string
sub: string
state: string
}
const props = defineProps<{
items: AlarmItem[]
}>()
/** 不足 4 条时补演示数据,保证可滚动;至少复制一份做无缝循环 */
const feed = computed(() => {
const base = props.items.length
? [...props.items]
: [{ level: 'calm' as const, title: '系统运行正常', sub: '实时监测中', state: '正常' }]
const pad: AlarmItem[] = [
{ level: 'amber', title: '文库制备区1 压差接近下限', sub: '自控联动调频中', state: '处理中' },
{ level: 'calm', title: 'PAU-201 滤网更换完成', sub: '机组已恢复', state: '已消除' },
{ level: 'red', title: '样本制备区缓冲 压差异常', sub: '已推送值班人员', state: '未消除' },
{ level: 'amber', title: '冰箱3 温度波动', sub: '冷链监测', state: '处理中' },
{ level: 'calm', title: '门禁主入口 通行正常', sub: '安防联动', state: '已消除' },
]
let list = base
while (list.length < 4) {
list = list.concat(pad.slice(0, 4 - list.length))
}
return list
})
const loop = computed(() => [...feed.value, ...feed.value])
const trackStyle = computed(() => ({
'--count': String(feed.value.length),
animationDuration: `${Math.max(feed.value.length, 1) * 2.8}s`,
}))
function alarmIcon(level: string) {
if (level === 'red') return 'alarm-warn'
if (level === 'amber') return 'alarm-amber'
return 'alarm-ok'
}
</script>
<template>
<div class="alarm-viewport" aria-label="报警滚动列表">
<div class="alarm-track" :style="trackStyle">
<div
v-for="(a, i) in loop"
:key="`alarm-${i}-${a.level}`"
class="alarm-item"
:class="a.level"
>
<AppIcon :name="alarmIcon(a.level)" />
<div class="a-txt">
<div class="a-title">{{ a.title }}</div>
<div class="a-sub">{{ a.sub }}</div>
</div>
<div class="a-state">{{ a.state }}</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 固定展示 3 条高度:每条 52px + 间距 8px → 3*52 + 2*8 = 172 */
.alarm-viewport {
height: 172px;
overflow: hidden;
position: relative;
mask-image: linear-gradient(180deg, transparent 0%, #000 8%, #000 92%, transparent 100%);
}
.alarm-track {
display: flex;
flex-direction: column;
gap: 8px;
animation-name: alarm-up;
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform;
}
.alarm-track:hover { animation-play-state: paused; }
@keyframes alarm-up {
from { transform: translateY(0); }
to { transform: translateY(calc(var(--count) * -60px)); }
}
.alarm-item {
display: flex;
align-items: center;
gap: 8px;
height: 52px;
box-sizing: border-box;
padding: 0 9px;
border-radius: 7px;
flex: 0 0 52px;
}
.alarm-item.red { background: rgba(255,82,82,.1); border: 1px solid rgba(255,82,82,.4); }
.alarm-item.amber { background: rgba(255,180,0,.08); border: 1px solid rgba(255,180,0,.35); }
.alarm-item.calm { background: rgba(26,77,153,.2); border: 1px solid transparent; }
.a-txt { flex: 1; min-width: 0; }
.a-title {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.a-sub { font-size: 10px; color: var(--label); margin-top: 2px; }
.a-state {
font-size: 10px;
white-space: nowrap;
padding: 3px 7px;
border-radius: 3px;
background: rgba(0,0,0,.2);
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import AppIcon from '@/components/icons/AppIcon.vue'
import type { FridgeSnapshot } from '@/types'
const props = defineProps<{
items: FridgeSnapshot[]
}>()
const feed = computed(() => (props.items.length ? props.items : []))
const count = computed(() => Math.max(feed.value.length, 4))
const loop = computed(() => {
if (!feed.value.length) return []
// 少于 4 条时复制,保证可滚动
let list = [...feed.value]
while (list.length > 0 && list.length < 4) list = list.concat(feed.value)
return [...list, ...list]
})
const trackStyle = computed(() => ({
'--count': String(count.value),
animationDuration: `${count.value * 2.6}s`,
}))
</script>
<template>
<div v-if="!feed.length" class="cold-empty">暂无冷链设备数据</div>
<div v-else class="cold-viewport" aria-label="冷链监测滚动列表">
<div class="cold-track" :style="trackStyle">
<div
v-for="(f, i) in loop"
:key="`${f.id}-${i}`"
class="fridge"
:class="{ warn: f.temp > -19 }"
>
<div class="f-left">
<AppIcon name="fridge" :class-name="f.temp > -19 ? 'warn' : ''" />
<span class="f-name">{{ f.name }}</span>
</div>
<div class="f-vals">
<span class="f-temp"><em>T:</em>{{ f.temp.toFixed(1) }} ℃</span>
<span class="f-hum"><em>RH:</em>{{ f.humidity }} %</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 可视约 4 行:每行 40px + 间距 6px */
.cold-viewport {
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
position: relative;
mask-image: linear-gradient(180deg, transparent 0%, #000 6%, #000 94%, transparent 100%);
}
.cold-track {
display: flex;
flex-direction: column;
gap: 6px;
animation-name: cold-up;
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform;
}
.cold-track:hover { animation-play-state: paused; }
@keyframes cold-up {
from { transform: translateY(0); }
to { transform: translateY(calc(var(--count) * -46px)); }
}
.fridge {
display: flex;
justify-content: space-between;
align-items: center;
height: 40px;
flex: 0 0 40px;
box-sizing: border-box;
padding: 0 10px;
border-radius: 7px;
background: rgba(26,77,153,.25);
}
.fridge.warn { background: rgba(255,180,0,.1); border: 1px solid rgba(255,180,0,.4); }
.f-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
.f-name {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.f-vals { display: flex; flex-direction: column; align-items: flex-end; gap: 1px; }
.f-temp { font-size: 12px; font-weight: 600; font-family: var(--font-num); color: var(--green); }
.f-hum { font-size: 10px; font-family: var(--font-num); color: var(--cyan-lt); }
.f-temp em, .f-hum em {
font-style: normal; font-weight: 500; font-size: 10px;
margin-right: 3px; color: var(--label);
}
.fridge.warn .f-temp { color: var(--amber); }
.cold-empty {
font-size: 12px; color: var(--label); padding: 12px 0;
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import AppIcon from '@/components/icons/AppIcon.vue'
import type { VavSnapshot } from '@/types'
const props = defineProps<{
items: VavSnapshot[]
}>()
type ParamTone = 'run' | 'face' | 'sash' | 'air' | 'valve' | 'mode' | 'alarm'
interface VavRow {
key: string
icon: string
unit: string
param: string
tone: ParamTone
value: string
pct: number
warn?: boolean
}
const feed = computed(() => {
const rows: VavRow[] = []
props.items.forEach((v) => {
const exhaust = v.maxExhaust ? '最大排风' : v.minAirflow ? '最小风量' : '常态'
rows.push(
{
key: `${v.id}-run`,
icon: 'vav-valve',
unit: v.name,
param: '开停状态',
tone: 'run',
value: v.running ? '运行' : '停止',
pct: v.running ? 100 : 8,
warn: !v.running,
},
{
key: `${v.id}-face`,
icon: 'vav-speed',
unit: v.name,
param: '实际面风速',
tone: 'face',
value: `${v.faceVelocity.toFixed(2)} m/s`,
pct: Math.min(100, (v.faceVelocity / 0.8) * 100),
},
{
key: `${v.id}-sash`,
icon: 'vav-sash',
unit: v.name,
param: '实际门高',
tone: 'sash',
value: `${v.sashHeight} mm`,
pct: Math.min(100, (v.sashHeight / 500) * 100),
},
{
key: `${v.id}-air`,
icon: 'vav-speed',
unit: v.name,
param: '实际风量',
tone: 'air',
value: `${v.airflow} m³/h`,
pct: Math.min(100, (v.airflow / 1200) * 100),
},
{
key: `${v.id}-valve`,
icon: 'vav-valve',
unit: v.name,
param: '阀开度',
tone: 'valve',
value: `${v.valveOpen} %`,
pct: Math.min(100, v.valveOpen),
},
{
key: `${v.id}-ex`,
icon: 'vav-sash',
unit: v.name,
param: '排风模式',
tone: 'mode',
value: exhaust,
pct: v.maxExhaust ? 100 : v.minAirflow ? 30 : 65,
},
{
key: `${v.id}-alarm`,
icon: 'vav-valve',
unit: v.name,
param: '报警代码',
tone: 'alarm',
value: v.alarmCode ? `码 ${v.alarmCode}` : '正常',
pct: v.alarmCode ? 100 : 12,
warn: v.alarmCode > 0,
},
)
})
return rows
})
const loop = computed(() => {
if (!feed.value.length) return []
let list = [...feed.value]
while (list.length > 0 && list.length < 4) list = list.concat(feed.value)
return [...list, ...list]
})
const count = computed(() => Math.max(feed.value.length, 4))
const trackStyle = computed(() => ({
'--count': String(count.value),
animationDuration: `${count.value * 2.4}s`,
}))
</script>
<template>
<div v-if="!feed.length" class="vav-empty">暂无通风柜数据</div>
<div v-else class="vav-viewport" aria-label="通风柜参数滚动">
<div class="vav-track" :style="trackStyle">
<div
v-for="(row, i) in loop"
:key="`${row.key}-${i}`"
class="vav-row"
:class="[row.tone, { warn: row.warn }]"
>
<div class="vav-line">
<AppIcon :name="row.icon" />
<span class="v-label">
<span class="unit">{{ row.unit }}</span>
<span class="sep">·</span>
<span class="param">{{ row.param }}</span>
</span>
<span class="v-value">{{ row.value }}</span>
</div>
<div class="track">
<div class="fill" :style="{ width: `${row.pct}%` }" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.vav-viewport {
flex: 1;
min-height: 0;
overflow: hidden;
mask-image: linear-gradient(180deg, transparent 0%, #000 8%, #000 92%, transparent 100%);
}
.vav-track {
display: flex;
flex-direction: column;
gap: 10px;
animation-name: vav-up;
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform;
}
.vav-track:hover { animation-play-state: paused; }
@keyframes vav-up {
from { transform: translateY(0); }
to { transform: translateY(calc(var(--count) * -48px)); }
}
.vav-row {
flex: 0 0 38px;
height: 38px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.vav-line {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.v-label {
flex: 1; min-width: 0;
display: flex; align-items: center; gap: 4px;
white-space: nowrap; overflow: hidden;
}
.unit {
color: rgba(180, 210, 235, 0.72);
overflow: hidden; text-overflow: ellipsis;
}
.sep { color: rgba(143, 182, 219, 0.45); flex: 0 0 auto; }
.param {
flex: 0 0 auto;
font-weight: 600;
letter-spacing: 0.3px;
}
.v-value { font-family: var(--font-num); white-space: nowrap; font-weight: 600; }
.track {
height: 5px;
border-radius: 3px;
background: var(--track);
overflow: hidden;
}
.fill {
height: 100%;
border-radius: 3px;
}
/* 参数名配色 */
.vav-row.run .param,
.vav-row.run .v-value { color: #2ee6a8; }
.vav-row.run .fill { background: linear-gradient(90deg, #128a5e, #2ee6a8); }
.vav-row.face .param,
.vav-row.face .v-value { color: #5bd4ff; }
.vav-row.face .fill { background: linear-gradient(90deg, #1f70ff, #37c8ff); }
.vav-row.sash .param,
.vav-row.sash .v-value { color: #a78bfa; }
.vav-row.sash .fill { background: linear-gradient(90deg, #6d28d9, #a78bfa); }
.vav-row.air .param,
.vav-row.air .v-value { color: #38bdf8; }
.vav-row.air .fill { background: linear-gradient(90deg, #0284c7, #38bdf8); }
.vav-row.valve .param,
.vav-row.valve .v-value { color: #fbbf24; }
.vav-row.valve .fill { background: linear-gradient(90deg, #d97706, #fbbf24); }
.vav-row.mode .param,
.vav-row.mode .v-value { color: #34d399; }
.vav-row.mode .fill { background: linear-gradient(90deg, #059669, #34d399); }
.vav-row.alarm .param,
.vav-row.alarm .v-value { color: #fb7185; }
.vav-row.alarm .fill { background: linear-gradient(90deg, #e11d48, #fb7185); }
.vav-row.warn .param,
.vav-row.warn .v-value { color: #ffb400; }
.vav-row.warn .fill { background: linear-gradient(90deg, #c97a12, #ffb400); }
.vav-empty { font-size: 12px; color: var(--label); padding: 8px 0; }
</style>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useRealtimeStore } from '@/stores/realtime'
import { PROTOCOL_ROOMS } from '@/data/rooms'
import AppIcon from '@/components/icons/AppIcon.vue'
withDefaults(
defineProps<{
areaLabel?: string
}>(),
{ areaLabel: '' },
)
const realtime = useRealtimeStore()
const mode = (import.meta.env.VITE_VIEWPORT_MODE || 'glb') as 'glb' | 'ue-stream'
const ueUrl = import.meta.env.VITE_UE_STREAM_URL || ''
const viewerRef = ref<HTMLElement | null>(null)
type SmoothControlsLike = {
onMouseDown?: (event: MouseEvent) => void
initializePan?: () => void
__mouseSwapped?: boolean
}
/** 在 model-viewer 内部控件上交换:左键平移、右键旋转 */
function findControls(el: object): SmoothControlsLike | null {
for (const sym of Object.getOwnPropertySymbols(el)) {
const val = (el as Record<symbol, unknown>)[sym] as SmoothControlsLike | null
if (
val &&
typeof val === 'object' &&
typeof val.initializePan === 'function' &&
typeof val.onMouseDown === 'function'
) {
return val
}
}
return null
}
function applyMouseMapping() {
const el = viewerRef.value
if (!el) return false
const controls = findControls(el)
if (!controls?.onMouseDown || controls.__mouseSwapped) return !!controls?.__mouseSwapped
const original = controls.onMouseDown.bind(controls)
controls.onMouseDown = (event: MouseEvent) => {
const button = event.button === 0 ? 2 : event.button === 2 ? 0 : event.button
const swapped = new Proxy(event, {
get(target, prop) {
if (prop === 'button') return button
const value = Reflect.get(target, prop, target)
return typeof value === 'function' ? value.bind(target) : value
},
}) as MouseEvent
original(swapped)
}
controls.__mouseSwapped = true
return true
}
function onViewerLoad() {
if (applyMouseMapping()) return
requestAnimationFrame(() => {
if (applyMouseMapping()) return
window.setTimeout(() => applyMouseMapping(), 300)
})
}
/** 协议房间清单 + 实时测点;无快照时仍展示协议名称 */
const roomList = computed(() => {
const live = realtime.snapshot.rooms
return PROTOCOL_ROOMS.map((def) => {
const hit = live.find((r) => r.id === def.id)
return {
id: def.id,
name: def.name,
temp: hit?.temp ?? null,
humidity: hit?.humidity ?? null,
pressure: hit?.pressure ?? null,
alarms: hit?.alarms ?? [],
}
})
})
const selected = computed(() => {
return roomList.value.find((r) => r.id === realtime.selectedRoomId) || roomList.value[0]
})
const markers = computed(() => {
return roomList.value
.filter((r) => ['sample-1', 'lib-1', 'seq'].includes(r.id))
.map((r, i) => ({
id: r.id,
label: `${r.name} ${r.pressure != null ? `${r.pressure.toFixed(1)}Pa` : '--'}`,
err: r.alarms.length > 0,
style: {
left: `${18 + i * 26}%`,
top: `${42 + (i % 2) * 12}%`,
},
}))
})
function onSelect(id: string) {
realtime.selectRoom(id)
}
onMounted(() => {
const el = viewerRef.value
el?.addEventListener('load', onViewerLoad)
applyMouseMapping()
})
onBeforeUnmount(() => {
viewerRef.value?.removeEventListener('load', onViewerLoad)
})
</script>
<template>
<div class="viewport">
<div class="vp-corners"><i /><i /><i /><i /></div>
<div class="vp-topbar">
<AppIcon name="pin" />
<span class="area">{{ areaLabel || `当前区域:${selected?.name || '—'}` }}</span>
<span class="sep" />
<span class="view">{{ mode === 'ue-stream' ? 'UE 推流模式' : '实景扫描模型 · 自由视角' }}</span>
</div>
<div class="vp-room-panel">
<div class="vp-room-head">
<AppIcon name="pin" />
<div>
<div class="vp-room-label">房间 / 区域</div>
<div class="vp-room-sub">来自网关协议 · 共 {{ roomList.length }} 处</div>
</div>
</div>
<div class="vp-room-list">
<button
v-for="r in roomList"
:key="r.id"
type="button"
class="vp-room-item"
:class="{ active: r.id === realtime.selectedRoomId, alarm: r.alarms.length > 0 }"
@click="onSelect(r.id)"
>
<span class="name">{{ r.name }}</span>
</button>
</div>
</div>
<template v-if="mode === 'ue-stream' && ueUrl">
<iframe class="ue-frame" :src="ueUrl" title="UE Stream" allow="autoplay; fullscreen" />
</template>
<template v-else>
<model-viewer
ref="viewerRef"
class="scene"
src="/models/scene-model.glb"
camera-controls
auto-rotate
shadow-intensity="0.6"
exposure="1.1"
interaction-prompt="none"
@contextmenu.prevent
/>
</template>
<div
v-for="m in markers"
:key="m.id"
class="mk"
:class="{ err: m.err }"
:style="m.style"
@click="onSelect(m.id)"
>
<span class="dot" :class="{ red: m.err }" />
<span>{{ m.label }}</span>
</div>
<div class="vp-tip">
<AppIcon name="hand" class-name="hand" />
<span><b>左键</b> 拖动 · <b>右键</b> 旋转 · <b>滚轮</b> 缩放 · 右侧点选房间</span>
</div>
</div>
</template>
<style scoped>
.viewport {
flex: 1; min-width: 0; position: relative; overflow: hidden;
border-radius: 10px; border: 1px solid rgba(62,200,255,.4);
background: linear-gradient(180deg, #0a2148, #041022);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.04), 0 14px 34px rgba(2,8,23,.5);
}
.scene, .ue-frame {
position: absolute; inset: 0; width: 100%; height: 100%; border: 0;
}
.vp-topbar, .vp-tip {
position: absolute; z-index: 5;
display: flex; align-items: center; gap: 10px;
padding: 7px 12px; border-radius: 7px;
background: rgba(8, 26, 61, 0.85);
border: 1px solid rgba(91, 212, 255, 0.42);
backdrop-filter: blur(10px);
}
.vp-topbar { left: 14px; top: 14px; }
.vp-room-panel {
position: absolute; z-index: 6;
right: 14px; top: 14px;
width: max-content;
max-width: 128px;
max-height: calc(100% - 72px);
display: flex; flex-direction: column;
border-radius: 6px;
background: rgba(6, 18, 42, 0.42);
border: 1px solid rgba(91, 212, 255, 0.22);
backdrop-filter: blur(8px);
overflow: hidden;
}
.vp-room-head {
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 6px 8px;
border-bottom: 1px solid rgba(62,200,255,.12);
}
.vp-room-head :deep(.pin) { width: 12px; height: 12px; opacity: .85; }
.vp-room-head > div { text-align: center; }
.vp-room-label { font-size: 10px; font-weight: 600; color: rgba(255,255,255,.9); }
.vp-room-sub { display: none; }
.vp-room-list {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 4px;
display: flex; flex-direction: column; gap: 2px;
}
.vp-room-item {
display: block;
width: 100%;
border: 1px solid rgba(62, 200, 255, 0.18);
background: rgba(22, 58, 110, 0.45);
border-radius: 4px;
padding: 4px 6px;
cursor: pointer; text-align: center;
color: rgba(235, 245, 255, 0.95);
transition: .15s;
}
.vp-room-item:hover {
border-color: rgba(62,200,255,.42);
background: rgba(30, 82, 150, 0.62);
color: #fff;
}
.vp-room-item.active {
border-color: rgba(91, 212, 255, 0.65);
background: rgba(26, 111, 255, 0.55);
color: #fff;
box-shadow: inset 0 0 0 1px rgba(91, 212, 255, 0.15);
}
.vp-room-item.alarm .name { color: #ffb0b0; }
.vp-room-item .name {
font-size: 11px; display: block;
white-space: nowrap; line-height: 1.25;
text-align: center;
}
.vp-tip { right: 14px; bottom: 16px; font-size: 11px; color: #b9d6f0; }
.vp-tip :deep(.hand) { width: 14px; height: 14px; }
.vp-tip b { color: var(--cyan-lt); font-weight: 600; }
.area { font-size: 12px; color: #fff; }
.sep { width: 1px; height: 14px; background: rgba(62,200,255,.35); }
.view { font-size: 11px; color: var(--cyan-lt); }
.mk {
position: absolute; z-index: 5;
display: flex; align-items: center; gap: 6px;
padding: 5px 9px; border-radius: 6px;
background: rgba(5, 36, 51, 0.88);
border: 1px solid rgba(47, 214, 161, 0.6);
font-size: 11px; color: #b9f5de; white-space: nowrap;
cursor: pointer;
}
.mk.err { background: rgba(61,13,20,.9); border-color: rgba(255,82,82,.65); color: #ffc9c9; }
.vp-corners { position: absolute; inset: 0; pointer-events: none; z-index: 4; }
.vp-corners i {
position: absolute; width: 16px; height: 16px;
border: 2px solid rgba(91,212,255,.7);
}
.vp-corners i:nth-child(1) { left: 10px; top: 10px; border-right: none; border-bottom: none; }
.vp-corners i:nth-child(2) { right: 10px; top: 10px; border-left: none; border-bottom: none; }
.vp-corners i:nth-child(3) { left: 10px; bottom: 10px; border-right: none; border-top: none; }
.vp-corners i:nth-child(4) { right: 10px; bottom: 10px; border-left: none; border-top: none; }
</style>
import { onMounted, onUnmounted, ref } from 'vue'
const weekMap = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
function p2(n: number) {
return n < 10 ? `0${n}` : `${n}`
}
export function useClock() {
const time = ref('--:--:--')
const date = ref('')
let timer: number | undefined
function tick() {
const d = new Date()
time.value = `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`
date.value = `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${weekMap[d.getDay()]}`
}
onMounted(() => {
tick()
timer = window.setInterval(tick, 1000)
})
onUnmounted(() => window.clearInterval(timer))
return { time, date }
}
import { onMounted, onUnmounted, ref } from 'vue'
/** WMO Weather interpretation codes → 中文简述 */
function weatherText(code: number): string {
if (code === 0) return '晴'
if (code <= 3) return '多云'
if (code <= 48) return '雾'
if (code <= 57) return '毛毛雨'
if (code <= 67) return '雨'
if (code <= 77) return '雪'
if (code <= 82) return '阵雨'
if (code <= 86) return '阵雪'
if (code <= 99) return '雷雨'
return '未知'
}
const MACAU = { lat: 22.1987, lon: 113.5439 }
const REFRESH_MS = 15 * 60 * 1000
export function useMacauWeather() {
const place = ref('澳门')
const temp = ref('--')
const text = ref('获取中')
const humidity = ref('--')
let timer: number | undefined
async function fetchWeather() {
try {
const url =
`https://api.open-meteo.com/v1/forecast` +
`?latitude=${MACAU.lat}&longitude=${MACAU.lon}` +
`&current=temperature_2m,relative_humidity_2m,weather_code` +
`&timezone=Asia%2FMacau`
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
const cur = data.current
if (!cur) throw new Error('no current')
temp.value = `${Math.round(cur.temperature_2m)}℃`
text.value = weatherText(Number(cur.weather_code))
humidity.value = `${cur.relative_humidity_2m}%`
} catch {
text.value = '暂无数据'
}
}
onMounted(() => {
fetchWeather()
timer = window.setInterval(fetchWeather, REFRESH_MS)
})
onUnmounted(() => window.clearInterval(timer))
return { place, temp, text, humidity }
}
import { onMounted, onUnmounted, ref } from 'vue'
export function useStageScale() {
const style = ref({
transform: 'scale(1)',
left: '0px',
top: '0px',
})
function fit() {
const s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080)
style.value = {
transform: `scale(${s})`,
left: `${(window.innerWidth - 1920 * s) / 2}px`,
top: `${(window.innerHeight - 1080 * s) / 2}px`,
}
}
onMounted(() => {
fit()
window.addEventListener('resize', fit)
})
onUnmounted(() => window.removeEventListener('resize', fit))
return { style }
}
/** 来自网关协议「房间.xlsx」压差/温湿度测点对应的房间与区域 */
export const PROTOCOL_ROOMS = [
{ id: 'recv', name: '收样咨询室' },
{ id: 'report', name: '生信分析报告室' },
{ id: 'reagent', name: '试剂准备间' },
{ id: 'pcr-corridor', name: 'PCR走廊' },
{ id: 'sample-1', name: '样本制备区1' },
{ id: 'sample-2', name: '样本制备区2' },
{ id: 'sample-buf', name: '样本制备区缓冲' },
{ id: 'lib-1', name: '文库制备区1' },
{ id: 'lib-1-buf', name: '文库制备区1缓冲' },
{ id: 'lib-2', name: '文库制备区2' },
{ id: 'lib-2-buf', name: '文库制备区2缓冲' },
{ id: 'seq', name: '测序区' },
{ id: 'seq-buf', name: '测序区缓冲' },
] as const
export type ProtocolRoomId = (typeof PROTOCOL_ROOMS)[number]['id']
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime'
import { useClock } from '@/composables/useClock'
import { useMacauWeather } from '@/composables/useMacauWeather'
import { useStageScale } from '@/composables/useStageScale'
import AppIcon from '@/components/icons/AppIcon.vue'
const auth = useAuthStore()
const realtime = useRealtimeStore()
const router = useRouter()
const { time, date } = useClock()
const { place, temp, text: weatherText, humidity } = useMacauWeather()
const { style } = useStageScale()
const navItems = [
{ to: '/overview', label: '全景总览' },
{ to: '/roam', label: '漫游巡检' },
{ to: '/vav', label: '通风柜VAV' },
{ to: '/pressure', label: '房间压差' },
{ to: '/hvac', label: '空调排风' },
{ to: '/metering', label: '计量计费' },
{ to: '/video', label: '视频监控' },
{ to: '/access', label: '门禁系统' },
{ to: '/cold', label: '冷链监测' },
{ to: '/light', label: '智能照明' },
{ to: '/users', label: '用户管理', adminOnly: true },
{ to: '/settings', label: '系统设置', adminOnly: true },
]
onMounted(() => realtime.connect())
onUnmounted(() => realtime.disconnect())
function logout() {
auth.logout()
router.push('/login')
}
</script>
<template>
<div class="stage" :style="style">
<header class="screen-header">
<div class="brand">
<AppIcon name="logo" class-name="logo" />
<div class="brand-txt">
<div class="sys">实验室智慧管控中心</div>
<div class="en">LAB INTELLIGENT CONTROL CENTER</div>
</div>
</div>
<div class="title-group">
<div class="wing" aria-hidden="true">
<span class="wing-line" />
<span class="wing-chevron" />
<span class="wing-dot" />
</div>
<h1>分子实验室三维可视化管理平台</h1>
<div class="wing right" aria-hidden="true">
<span class="wing-dot" />
<span class="wing-chevron" />
<span class="wing-line" />
</div>
</div>
<div class="weather-wrap">
<span class="place">{{ place }}</span>
<span class="temp">{{ temp }}</span>
<span class="w-text">{{ weatherText }}</span>
<span class="humid">RH:{{ humidity }}</span>
</div>
<div class="clock-wrap">
<span class="time">{{ time }}</span>
<span class="date">{{ date }}</span>
</div>
<div class="hdr-status">
<div class="alarm-badge" @click="router.push('/overview')">
<span class="dot red" />
<span>告警 {{ realtime.alarmCount }}</span>
</div>
<div class="user-chip">
<span>{{ auth.user?.displayName }} · {{ auth.isAdmin ? '管理员' : '只读' }}</span>
<button type="button" @click="logout">退出</button>
</div>
</div>
</header>
<nav class="screen-nav">
<RouterLink
v-for="item in navItems.filter((n) => !n.adminOnly || auth.isAdmin)"
:key="item.to"
class="nav-chip"
:to="item.to"
>
{{ item.label }}
</RouterLink>
<div class="nav-spacer" />
<div class="server-chip">
<span class="dot" :class="{ red: !realtime.connected }" />
<span>
{{ realtime.connected ? '实时链路在线' : '实时链路断开' }}
· 设备 {{ realtime.snapshot.online.devicesOnline }}/{{ realtime.snapshot.online.devicesTotal }}
</span>
</div>
</nav>
<main class="screen-main">
<RouterView />
</main>
</div>
</template>
<style scoped>
.brand :deep(.logo) { width: 34px; height: 34px; }
</style>
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './styles/tokens.css'
import './styles/screen.css'
createApp(App).use(createPinia()).use(router).mount('#app')
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/login/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
component: () => import('@/layouts/ScreenLayout.vue'),
children: [
{ path: '', redirect: '/overview' },
{
path: 'overview',
name: 'overview',
component: () => import('@/views/overview/OverviewView.vue'),
meta: { title: '全景总览' },
},
{
path: 'roam',
name: 'roam',
component: () => import('@/views/stub/StubView.vue'),
meta: { title: '漫游巡检', desc: '第一人称巡检路径与点位数据同步,UE 推流到位后可替换为真实漫游。' },
},
{
path: 'vav',
name: 'vav',
component: () => import('@/views/vav/VavView.vue'),
meta: { title: '通风柜 VAV' },
},
{
path: 'pressure',
name: 'pressure',
component: () => import('@/views/pressure/PressureView.vue'),
meta: { title: '房间压差' },
},
{
path: 'hvac',
name: 'hvac',
component: () => import('@/views/hvac/HvacView.vue'),
meta: { title: '空调排风' },
},
{
path: 'metering',
name: 'metering',
component: () => import('@/views/metering/MeteringView.vue'),
meta: { title: '计量计费' },
},
{
path: 'video',
name: 'video',
component: () => import('@/views/video/VideoView.vue'),
meta: { title: '视频监控' },
},
{
path: 'access',
name: 'access',
component: () => import('@/views/access/AccessView.vue'),
meta: { title: '门禁系统' },
},
{
path: 'cold',
name: 'cold',
component: () => import('@/views/cold/ColdView.vue'),
meta: { title: '冷链监测' },
},
{
path: 'light',
name: 'light',
component: () => import('@/views/light/LightView.vue'),
meta: { title: '智能照明' },
},
{
path: 'users',
name: 'users',
component: () => import('@/views/users/UserManageView.vue'),
meta: { title: '用户管理', adminOnly: true },
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/settings/SettingsView.vue'),
meta: { title: '系统设置', adminOnly: true },
},
],
},
],
})
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (to.meta.public) {
if (auth.isLoggedIn && to.name === 'login') return '/overview'
return true
}
if (!auth.isLoggedIn) return '/login'
if (!auth.user) await auth.fetchMe()
if (to.meta.adminOnly && !auth.isAdmin) return '/overview'
return true
})
export default router
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { api } from '@/api/http'
import type { Role, User } from '@/types'
const TOKEN_KEY = 'jh_lab_token'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
const user = ref<User | null>(null)
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const role = computed<Role | null>(() => user.value?.role ?? null)
async function login(username: string, password: string) {
const data = await api<{ token: string; user: User }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
token.value = data.token
user.value = data.user
localStorage.setItem(TOKEN_KEY, data.token)
}
async function fetchMe() {
if (!token.value) return
try {
const data = await api<{ user: User }>('/api/auth/me', { token: token.value })
user.value = data.user
} catch {
logout()
}
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem(TOKEN_KEY)
}
return { token, user, isLoggedIn, isAdmin, role, login, fetchMe, logout }
})
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { RealtimeSnapshot } from '@/types'
const empty: RealtimeSnapshot = {
ts: 0,
rooms: [],
hvac: {
systemRunning: false,
supplyFanHz: 0,
exhaustPressure: 0,
filterRemainPct: 0,
mode: 'auto',
supplyTemp: 0,
supplyHumidity: 0,
supplyPressure: 0,
freshAirTemp: 0,
alarms: [],
},
vav: [],
fridges: [],
metering: {
powerKwh: 0,
waterM3: 0,
coolingKwh: 0,
powerTrendPct: 0,
waterTrendPct: 0,
coolingTrendPct: 0,
},
access: { doors: [], todayPassCount: 0, recentEvents: [] },
online: { devicesOnline: 0, devicesTotal: 0, gatewayOk: false },
}
export const useRealtimeStore = defineStore('realtime', () => {
const snapshot = ref<RealtimeSnapshot>(empty)
const connected = ref(false)
const selectedRoomId = ref('sample-1')
let ws: WebSocket | null = null
let retryTimer: number | undefined
const alarmCount = computed(() => {
const roomAlarms = snapshot.value.rooms.reduce((n, r) => n + r.alarms.length, 0)
const fridgeWarn = snapshot.value.fridges.filter((f) => f.temp > -19).length
return roomAlarms + fridgeWarn + snapshot.value.hvac.alarms.length
})
const primaryRoom = computed(() => {
return (
snapshot.value.rooms.find((r) => r.id === selectedRoomId.value) ||
snapshot.value.rooms[0] || {
id: '-',
name: '-',
temp: 0,
humidity: 0,
pressure: 0,
alarms: [] as string[],
}
)
})
function selectRoom(id: string) {
selectedRoomId.value = id
}
function connect() {
const url = import.meta.env.VITE_WS_URL || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`
ws?.close()
ws = new WebSocket(url)
ws.onopen = () => {
connected.value = true
}
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data)
if (msg.type === 'snapshot') snapshot.value = msg.data
} catch {
/* ignore */
}
}
ws.onclose = () => {
connected.value = false
window.clearTimeout(retryTimer)
retryTimer = window.setTimeout(connect, 2000)
}
ws.onerror = () => ws?.close()
}
function disconnect() {
window.clearTimeout(retryTimer)
ws?.close()
ws = null
}
return {
snapshot,
connected,
selectedRoomId,
alarmCount,
primaryRoom,
selectRoom,
connect,
disconnect,
}
})
.stage {
position: absolute;
width: 1920px;
height: 1080px;
transform-origin: top left;
display: flex;
flex-direction: column;
overflow: hidden;
background:
radial-gradient(900px 420px at 960px -80px, rgba(62, 200, 255, 0.1), transparent 70%),
radial-gradient(520px 520px at -80px 980px, rgba(26, 111, 255, 0.08), transparent 70%),
linear-gradient(180deg, #0a1628 0%, #07111f 55%, #050d18 100%);
}
.stage::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background:
linear-gradient(rgba(86, 212, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(86, 212, 255, 0.03) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 90% 80% at 50% 40%, #000 25%, transparent 90%);
}
.stage > * { position: relative; z-index: 1; }
.screen-header {
position: relative;
height: 84px;
flex: 0 0 84px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
border-bottom: 1px solid rgba(62, 200, 255, 0.28);
background: linear-gradient(to bottom, rgba(14, 42, 88, 0.55), rgba(8, 20, 42, 0.12));
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
width: 34px; height: 34px; border-radius: 8px;
border: 1px solid rgba(62, 200, 255, 0.55);
background: rgba(62, 200, 255, 0.1);
display: grid; place-items: center;
color: var(--cyan); font-weight: 700; font-family: var(--font-num);
}
.brand-txt .sys { font-size: 14px; font-weight: 600; color: var(--title); letter-spacing: 1px; }
.brand-txt .en { font-size: 9px; color: var(--cyan); letter-spacing: 1.4px; font-family: var(--font-num); }
.title-group {
position: absolute; left: 50%; top: 18px; transform: translateX(-50%);
display: flex; align-items: center; gap: 18px;
}
.title-group h1 {
font-size: 28px; font-weight: 700; letter-spacing: 3px; white-space: nowrap;
background: linear-gradient(180deg, #fff 20%, #b7d7ff 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.wing {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
}
.wing-line {
width: 96px;
height: 4px;
border-radius: 2px;
background: linear-gradient(90deg, transparent 0%, rgba(62, 200, 255, 0.15) 20%, #3ec8ff 100%);
box-shadow: 0 0 10px rgba(62, 200, 255, 0.55), 0 0 22px rgba(62, 200, 255, 0.25);
position: relative;
}
.wing-line::before {
content: '';
position: absolute;
left: 18%;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.75));
}
.wing-chevron {
width: 0;
height: 0;
border-style: solid;
border-width: 8px 0 8px 12px;
border-color: transparent transparent transparent #3ec8ff;
filter: drop-shadow(0 0 6px rgba(62, 200, 255, 0.85));
flex: 0 0 auto;
}
.wing-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #7ad8ff;
box-shadow: 0 0 0 3px rgba(62, 200, 255, 0.22), 0 0 12px rgba(62, 200, 255, 0.9);
flex: 0 0 auto;
}
.wing.right .wing-line {
background: linear-gradient(90deg, #3ec8ff 0%, rgba(62, 200, 255, 0.15) 80%, transparent 100%);
}
.wing.right .wing-line::before {
left: 0;
right: 18%;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.75), transparent);
}
.wing.right .wing-chevron {
border-width: 8px 12px 8px 0;
border-color: transparent #3ec8ff transparent transparent;
}
.hdr-status { display: flex; align-items: center; gap: 12px; }
.alarm-badge, .user-chip, .server-chip {
display: flex; align-items: center; gap: 6px;
padding: 6px 12px; border-radius: 6px;
border: 1px solid rgba(62, 200, 255, 0.3);
background: rgba(16, 40, 78, 0.45);
font-size: 12px;
}
.alarm-badge { border-color: rgba(255, 90, 90, 0.5); color: #ff9b9b; cursor: pointer; }
.user-chip { color: var(--body); }
.user-chip button {
border: none; background: transparent; color: var(--cyan-lt); cursor: pointer; margin-left: 4px;
}
.weather-wrap,
.clock-wrap {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: baseline;
gap: 10px;
white-space: nowrap;
}
.weather-wrap { left: 26%; }
.clock-wrap { left: 74%; }
.weather-wrap .place,
.weather-wrap .temp {
font-size: 18px; font-weight: 600; font-family: var(--font-num);
color: var(--green); line-height: 1;
}
.weather-wrap .w-text,
.weather-wrap .humid { font-size: 12px; color: #7cf0c4; }
.clock-wrap .time { font-size: 18px; font-weight: 600; font-family: var(--font-num); color: var(--green); line-height: 1; }
.clock-wrap .date { font-size: 12px; color: #7cf0c4; }
.dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--green);
box-shadow: 0 0 0 0 rgba(47, 214, 161, 0.6);
animation: pulse 2s infinite;
}
.dot.red { background: var(--red); }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(47, 214, 161, 0.55); }
70% { box-shadow: 0 0 0 8px rgba(47, 214, 161, 0); }
100% { box-shadow: 0 0 0 0 rgba(47, 214, 161, 0); }
}
.screen-nav {
height: 48px; flex: 0 0 48px;
display: flex; align-items: center; gap: 8px;
padding: 0 20px;
}
.nav-chip {
border-radius: 6px;
border: 1px solid rgba(62, 200, 255, 0.22);
background: rgba(18, 42, 78, 0.4);
cursor: pointer; color: var(--muted);
padding: 6px 12px; font-size: 12px;
text-decoration: none; transition: .2s;
}
.nav-chip.active, .nav-chip.router-link-active {
background: linear-gradient(90deg, #1a6fff, #0aa6e5);
color: #fff; font-weight: 600;
border-color: transparent;
}
.nav-chip:hover { color: var(--cyan-lt); }
.nav-spacer { flex: 1; }
.server-chip { color: #7cf0c4; border-color: rgba(47, 214, 161, 0.35); }
.screen-main {
flex: 1; min-height: 0;
display: flex; gap: 14px;
padding: 0 20px 14px;
}
.panel {
background: var(--panel-bg);
border: 1px solid var(--panel-bd);
border-radius: 10px;
padding: 14px;
box-shadow: inset 0 1px 0 rgba(255,255,255,.06), var(--shadow);
}
.panel-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px;
flex: 0 0 auto;
}
.panel-title { display: flex; align-items: center; gap: 8px; }
.panel-title .vbar {
width: 3px; height: 14px; border-radius: 2px;
background: linear-gradient(180deg, #7ad8ff, #1a6fff);
}
.panel-title h3 { font-size: 14px; font-weight: 600; color: var(--title); }
.badge {
padding: 2px 8px; border-radius: 4px; font-size: 10px;
background: rgba(47, 214, 161, 0.12); color: var(--green);
}
.badge.amber { background: rgba(240, 180, 41, 0.15); color: var(--amber); }
.badge.red { background: rgba(255, 90, 90, 0.15); color: #ff9b9b; }
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.metric {
display: flex; align-items: center; gap: 8px;
padding: 10px; min-height: 64px; border-radius: 8px;
background: var(--card-bg); border: 1px solid var(--card-bd);
}
.metric .m-label { font-size: 11px; color: var(--label); }
.metric .m-value { font-size: 20px; font-weight: 600; font-family: var(--font-num); color: #fff; }
.metric .m-value.green { color: var(--green); }
.kpi-bar {
height: 108px; flex: 0 0 108px;
display: flex; gap: 14px; padding: 0 20px 16px;
}
.kpi {
flex: 1; padding: 12px; border-radius: 10px;
background: var(--panel-bg); border: 1px solid var(--panel-bd);
display: flex; flex-direction: column; justify-content: center; gap: 6px;
}
.kpi .k-label { font-size: 11px; color: var(--label); }
.kpi .digital {
align-self: flex-start; padding: 3px 10px; border-radius: 6px;
background: rgba(5, 26, 61, 0.9); border: 1px solid rgba(62, 200, 255, 0.45);
font-size: 20px; font-weight: 600; font-family: var(--font-num); color: var(--cyan-lt);
}
.kpi .k-trend { font-size: 10px; color: var(--amber); }
.kpi .k-trend.green { color: var(--green); }
.kpi .k-trend.cyan { color: var(--cyan-lt); }
.btn {
border: 1px solid rgba(62, 200, 255, 0.4);
background: rgba(26, 111, 255, 0.25);
color: #eaf9ff; border-radius: 6px; padding: 8px 14px; cursor: pointer;
}
.btn:hover { background: rgba(26, 111, 255, 0.4); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn.danger { border-color: rgba(255,90,90,.45); background: rgba(255,90,90,.15); }
.btn.ghost { background: transparent; }
.table { width: 100%; border-collapse: collapse; font-size: 12px; }
.table th, .table td { padding: 8px 10px; border-bottom: 1px solid rgba(62,200,255,.12); text-align: left; }
.table th { color: var(--label); font-weight: 500; }
.table td { color: var(--body); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 12px; color: var(--label); }
.field input, .field select {
height: 36px; border-radius: 6px; padding: 0 10px;
border: 1px solid rgba(62,200,255,.28);
background: rgba(8, 22, 44, 0.8); color: #fff; outline: none;
}
.field input:focus, .field select:focus { border-color: var(--cyan); }
.page-fill { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 14px; width: 100%; }
.page-fill > .panel { min-height: 0; }
.stub-box {
flex: 1; display: grid; place-items: center;
border-radius: 10px; border: 1px solid var(--panel-bd);
background: linear-gradient(180deg, rgba(20,51,107,.25), rgba(5,15,36,.4));
text-align: center; padding: 40px;
}
.stub-box h2 { font-size: 24px; color: var(--title); margin-bottom: 10px; }
.stub-box p { font-size: 13px; color: var(--label); line-height: 1.8; max-width: 560px; }
:root {
--bg: #07111f;
--bg-deep: #040a14;
--cyan: #3ec8ff;
--cyan-lt: #7ad8ff;
--green: #2fd6a1;
--amber: #f0b429;
--red: #ff5a5a;
--title: #eef6ff;
--body: #c9ddf2;
--label: #7fa6c9;
--muted: #8eb4d4;
--panel-bg: linear-gradient(165deg, rgba(22, 48, 88, 0.55), rgba(10, 24, 48, 0.5));
--panel-bd: rgba(62, 200, 255, 0.28);
--card-bg: rgba(22, 56, 104, 0.35);
--card-bd: rgba(62, 200, 255, 0.18);
--track: rgba(48, 110, 190, 0.22);
--bar-grad: linear-gradient(90deg, #1a6fff, #3ec8ff);
--font-cn: 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
--font-num: 'IBM Plex Sans', 'Segoe UI', sans-serif;
--shadow: 0 12px 28px rgba(2, 8, 18, 0.45);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app { width: 100%; height: 100%; overflow: hidden; background: var(--bg-deep); }
body {
font-family: var(--font-cn);
color: var(--body);
-webkit-font-smoothing: antialiased;
font-variant-numeric: tabular-nums;
}
button, input, select { font: inherit; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: rgba(122, 216, 255, 0.35); border-radius: 3px; }
export type Role = 'admin' | 'viewer'
export interface User {
id: string
username: string
displayName: string
role: Role
enabled?: boolean
createdAt?: string
lastLoginAt?: string
}
export interface RoomSnapshot {
id: string
name: string
temp: number
humidity: number
pressure: number
alarms: string[]
}
export interface VavSnapshot {
id: string
name: string
running: boolean
faceVelocity: number
sashHeight: number
airflow: number
valveOpen: number
maxExhaust: boolean
minAirflow: boolean
alarmCode: number
}
export interface FridgeSnapshot {
id: string
name: string
temp: number
humidity: number
}
export interface DoorSnapshot {
id: string
name: string
status: 'open' | 'closed' | 'alarm' | 'offline'
}
export interface AccessEvent {
id: string
time: string
doorId: string
doorName: string
person: string
result: 'success' | 'denied' | 'alarm'
}
export interface RealtimeSnapshot {
ts: number
rooms: RoomSnapshot[]
hvac: {
systemRunning: boolean
supplyFanHz: number
exhaustPressure: number
filterRemainPct: number
mode: 'auto' | 'manual'
supplyTemp: number
supplyHumidity: number
supplyPressure: number
freshAirTemp: number
alarms: string[]
}
vav: VavSnapshot[]
fridges: FridgeSnapshot[]
metering: {
powerKwh: number
waterM3: number
coolingKwh: number
powerTrendPct: number
waterTrendPct: number
coolingTrendPct: number
}
access: {
doors: DoorSnapshot[]
todayPassCount: number
recentEvents: AccessEvent[]
}
online: {
devicesOnline: number
devicesTotal: number
gatewayOk: boolean
}
}
export type EnergyKind = 'power' | 'water' | 'cooling'
export interface TariffSettings {
powerPeak: number
powerFlat: number
powerValley: number
water: number
cooling: number
serviceFeePct: number
taxPct: number
settleDay: number
enabled: boolean
updatedAt: string
updatedBy: string
}
export interface DailyBill {
date: string
powerPeakKwh: number
powerFlatKwh: number
powerValleyKwh: number
waterM3: number
coolingKwh: number
powerCost: number
waterCost: number
coolingCost: number
subtotal: number
serviceFee: number
tax: number
total: number
}
export interface BillingStats {
today: DailyBill
month: {
days: number
powerKwh: number
waterM3: number
coolingKwh: number
powerCost: number
waterCost: number
coolingCost: number
total: number
}
byKind: { kind: EnergyKind; label: string; usage: number; unit: string; cost: number; pct: number }[]
trend7d: { date: string; total: number; powerCost: number; waterCost: number; coolingCost: number }[]
trend12m: { month: string; label: string; total: number; powerCost: number; waterCost: number; coolingCost: number }[]
usage30d: {
date: string
label: string
powerKwh: number
waterM3: number
coolingKwh: number
}[]
}
export interface BillingReport {
summary: {
from: string
to: string
days: number
powerKwh: number
waterM3: number
coolingKwh: number
powerCost: number
waterCost: number
coolingCost: number
serviceFee: number
tax: number
total: number
}
rows: DailyBill[]
}
export type LightStatus = 'online' | 'offline' | 'fault'
export type LightProtocol = 'modbus' | 'http' | 'tcp'
export interface LightDevice {
id: string
name: string
zone: string
circuit: number
powerOn: boolean
status: LightStatus
enabled: boolean
brightness: number
remark: string
createdAt: string
updatedAt: string
lastSwitchAt: string | null
lastSwitchBy: string | null
}
export interface LightCommConfig {
host: string
port: number
protocol: LightProtocol
unitId: number
username: string
passwordSet: boolean
passwordMasked: string
pollMs: number
enabled: boolean
updatedAt: string
updatedBy: string
}
export type DeviceStatus = 'online' | 'offline' | 'alarm'
export type DeviceType = 'ipc' | 'nvr-ch'
/** 海康:HCNetSDK / RTSP / ISAPI */
export type VideoProtocol = 'sdk' | 'rtsp' | 'isapi'
export type StreamType = 'main' | 'sub'
export interface VideoDevice {
id: string
name: string
channel: number
location: string
deviceType: DeviceType
status: DeviceStatus
enabled: boolean
ip: string
remark: string
createdAt: string
updatedAt: string
}
export interface VideoCommConfig {
vendor: 'hikvision'
host: string
port: number
rtspPort: number
username: string
passwordSet: boolean
passwordMasked: string
protocol: VideoProtocol
streamType: StreamType
previewTimeoutMs: number
enabled: boolean
updatedAt: string
updatedBy: string
}
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime'
import { api } from '@/api/http'
const auth = useAuthStore()
const realtime = useRealtimeStore()
function fmt(t: string) {
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
async function openDoor(doorId: string) {
if (!auth.isAdmin) return
const data = await api<{ message: string }>('/api/realtime/access/open', {
method: 'POST',
token: auth.token,
body: JSON.stringify({ doorId }),
})
alert(data.message)
}
</script>
<template>
<div class="page-fill" style="flex-direction:row">
<section class="panel" style="flex:1; overflow:auto">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>门禁状态</h3></div>
<div class="badge">今日通行 {{ realtime.snapshot.access.todayPassCount }}</div>
</div>
<table class="table">
<thead>
<tr><th>门点</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-for="d in realtime.snapshot.access.doors" :key="d.id">
<td>{{ d.name }}</td>
<td>{{ { open: '开启', closed: '关闭', alarm: '报警', offline: '离线' }[d.status] }}</td>
<td>
<button class="btn" :disabled="!auth.isAdmin" @click="openDoor(d.id)">远程开门</button>
</td>
</tr>
</tbody>
</table>
<p style="margin-top:12px;font-size:12px;color:var(--label)">
协议:海康 HCNetSDK(Windows 桥接)。一期为 Mock,二期接入桥接服务。
</p>
</section>
<section class="panel" style="flex:1; overflow:auto">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>通行记录</h3></div>
</div>
<table class="table">
<thead>
<tr><th>时间</th><th>门点</th><th>人员</th><th>结果</th></tr>
</thead>
<tbody>
<tr v-for="e in realtime.snapshot.access.recentEvents" :key="e.id">
<td>{{ fmt(e.time) }}</td>
<td>{{ e.doorName }}</td>
<td>{{ e.person }}</td>
<td>{{ { success: '成功', denied: '拒绝', alarm: '报警' }[e.result] }}</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
<script setup lang="ts">
import { useRealtimeStore } from '@/stores/realtime'
const realtime = useRealtimeStore()
</script>
<template>
<div class="page-fill">
<section class="panel" style="flex:1; overflow:auto">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>冷链监测</h3></div>
<div class="badge">E栋 · 网关 192.168.10.105</div>
</div>
<table class="table">
<thead>
<tr><th>设备</th><th>温度 (℃)</th><th>湿度 (%)</th><th>状态</th></tr>
</thead>
<tbody>
<tr v-for="f in realtime.snapshot.fridges" :key="f.id">
<td>{{ f.name }}</td>
<td :style="{ color: f.temp > -19 ? 'var(--amber)' : 'var(--green)' }">T: {{ f.temp.toFixed(1) }} ℃</td>
<td>RH: {{ f.humidity }} %</td>
<td>{{ f.temp > -19 ? '超限' : '正常' }}</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime'
import { api } from '@/api/http'
const auth = useAuthStore()
const realtime = useRealtimeStore()
const pressureSet = ref(180)
async function applySet() {
if (!auth.isAdmin) return
await api('/api/realtime/write', {
method: 'POST',
token: auth.token,
body: JSON.stringify({ target: 'PAU201', action: 'set_supply_pressure', value: pressureSet.value }),
})
alert('送风压力设定已下发(Mock)')
}
</script>
<template>
<div class="page-fill">
<section class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>空调及排风机组</h3></div>
<div class="badge">{{ realtime.snapshot.hvac.systemRunning ? '运行' : '停机' }}</div>
</div>
<div class="metric-grid" style="grid-template-columns: repeat(3, 1fr)">
<div class="metric"><div><div class="m-label">送风温度</div><div class="m-value">{{ realtime.snapshot.hvac.supplyTemp.toFixed(1) }}</div></div></div>
<div class="metric"><div><div class="m-label">送风湿度</div><div class="m-value">{{ realtime.snapshot.hvac.supplyHumidity }}</div></div></div>
<div class="metric"><div><div class="m-label">送风压力</div><div class="m-value">{{ realtime.snapshot.hvac.supplyPressure }}</div></div></div>
<div class="metric"><div><div class="m-label">风机频率</div><div class="m-value">{{ realtime.snapshot.hvac.supplyFanHz.toFixed(1) }}</div></div></div>
<div class="metric"><div><div class="m-label">排风压力</div><div class="m-value">{{ realtime.snapshot.hvac.exhaustPressure }}</div></div></div>
<div class="metric"><div><div class="m-label">新风温度</div><div class="m-value">{{ realtime.snapshot.hvac.freshAirTemp.toFixed(1) }}</div></div></div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>参数设定</h3></div>
<div class="badge" :class="auth.isAdmin ? '' : 'amber'">{{ auth.isAdmin ? '管理员可写' : '只读' }}</div>
</div>
<div class="form-grid" style="max-width:520px">
<div class="field">
<label>送风压力设定 (Pa)</label>
<input v-model.number="pressureSet" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field" style="justify-content:flex-end">
<label>&nbsp;</label>
<button class="btn" :disabled="!auth.isAdmin" @click="applySet">下发设定</button>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { api } from '@/api/http'
import { useAuthStore } from '@/stores/auth'
import type { LightCommConfig, LightDevice, LightProtocol, LightStatus } from '@/types/light'
type Tab = 'devices' | 'config'
const auth = useAuthStore()
const tab = ref<Tab>('devices')
const loading = ref(false)
const switching = ref('')
const message = ref('')
const devices = ref<LightDevice[]>([])
const config = ref<LightCommConfig | null>(null)
const testing = ref(false)
const testResult = ref('')
const form = reactive({
name: '',
zone: '',
circuit: 9,
remark: '',
})
const cfgForm = reactive({
host: '',
port: 502,
protocol: 'modbus' as LightProtocol,
unitId: 1,
username: '',
password: '',
pollMs: 3000,
enabled: true,
})
const statusLabel: Record<LightStatus, string> = {
online: '在线',
offline: '离线',
fault: '故障',
}
const onCount = computed(() => devices.value.filter((d) => d.enabled && d.powerOn).length)
const onlineCount = computed(() => devices.value.filter((d) => d.enabled && d.status === 'online').length)
async function loadAll() {
loading.value = true
message.value = ''
try {
const [devRes, cfg] = await Promise.all([
api<{ devices: LightDevice[] }>('/api/light/devices', { token: auth.token }),
api<LightCommConfig>('/api/light/config', { token: auth.token }),
])
devices.value = devRes.devices
config.value = cfg
cfgForm.host = cfg.host
cfgForm.port = cfg.port
cfgForm.protocol = cfg.protocol
cfgForm.unitId = cfg.unitId
cfgForm.username = cfg.username
cfgForm.password = cfg.passwordMasked || ''
cfgForm.pollMs = cfg.pollMs
cfgForm.enabled = cfg.enabled
} catch (e) {
message.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function createDevice() {
if (!auth.isAdmin) return
message.value = ''
try {
await api('/api/light/devices', {
method: 'POST',
token: auth.token,
body: JSON.stringify(form),
})
form.name = ''
form.zone = ''
form.remark = ''
form.circuit += 1
message.value = '设备已添加'
await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '添加失败'
}
}
async function toggleEnabled(d: LightDevice) {
if (!auth.isAdmin) return
message.value = ''
try {
await api(`/api/light/devices/${d.id}`, {
method: 'PATCH',
token: auth.token,
body: JSON.stringify({ enabled: !d.enabled }),
})
await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '更新失败'
}
}
async function removeDevice(d: LightDevice) {
if (!auth.isAdmin) return
if (!confirm(`确认删除「${d.name}」?`)) return
message.value = ''
try {
await api(`/api/light/devices/${d.id}`, { method: 'DELETE', token: auth.token })
message.value = '已删除'
await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '删除失败'
}
}
async function remoteSwitch(d: LightDevice, powerOn: boolean) {
if (!auth.isAdmin) return
switching.value = d.id
message.value = ''
try {
const r = await api<{ message: string; device: LightDevice }>(`/api/light/devices/${d.id}/switch`, {
method: 'POST',
token: auth.token,
body: JSON.stringify({ powerOn }),
})
message.value = r.message
const idx = devices.value.findIndex((x) => x.id === d.id)
if (idx >= 0) devices.value[idx] = r.device
else await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '开关失败'
} finally {
switching.value = ''
}
}
async function switchAll(powerOn: boolean) {
if (!auth.isAdmin) return
const tip = powerOn ? '全部开启' : '全部关闭'
if (!confirm(`确认远程${tip}所有可用回路?`)) return
switching.value = 'all'
message.value = ''
try {
const r = await api<{ message: string; devices: LightDevice[] }>('/api/light/switch-all', {
method: 'POST',
token: auth.token,
body: JSON.stringify({ powerOn }),
})
devices.value = r.devices
message.value = r.message
} catch (e) {
message.value = e instanceof Error ? e.message : '批量开关失败'
} finally {
switching.value = ''
}
}
async function saveConfig() {
if (!auth.isAdmin) return
message.value = ''
testResult.value = ''
try {
const body: Record<string, unknown> = {
host: cfgForm.host,
port: cfgForm.port,
protocol: cfgForm.protocol,
unitId: cfgForm.unitId,
username: cfgForm.username,
pollMs: cfgForm.pollMs,
enabled: cfgForm.enabled,
}
if (cfgForm.password && cfgForm.password !== '********') body.password = cfgForm.password
const cfg = await api<LightCommConfig>('/api/light/config', {
method: 'PUT',
token: auth.token,
body: JSON.stringify(body),
})
config.value = cfg
cfgForm.password = cfg.passwordMasked || ''
message.value = '通信配置已保存'
} catch (e) {
message.value = e instanceof Error ? e.message : '保存失败'
}
}
async function testConfig() {
if (!auth.isAdmin) return
testing.value = true
testResult.value = ''
try {
const r = await api<{ ok: boolean; message: string; latencyMs: number }>('/api/light/config/test', {
method: 'POST',
token: auth.token,
})
testResult.value = `${r.message} · ${r.latencyMs} ms`
message.value = r.ok ? '连通性测试通过' : r.message
} catch (e) {
testResult.value = e instanceof Error ? e.message : '测试失败'
} finally {
testing.value = false
}
}
function fmtTime(t: string | null) {
if (!t) return '—'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
onMounted(loadAll)
</script>
<template>
<div class="page-fill light-page">
<div class="light-tabs">
<button type="button" class="tab" :class="{ active: tab === 'devices' }" @click="tab = 'devices'">
设备管理
</button>
<button type="button" class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">
通信配置
</button>
<div class="tab-spacer" />
<div class="badge">开启 {{ onCount }}</div>
<div class="badge">在线 {{ onlineCount }} / {{ devices.length }}</div>
<button type="button" class="btn ghost" :disabled="loading" @click="loadAll">刷新</button>
<span v-if="message" class="msg">{{ message }}</span>
</div>
<template v-if="tab === 'devices'">
<section v-if="auth.isAdmin" class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>添加照明回路</h3></div>
<div class="head-right">
<button
type="button"
class="btn ghost"
:disabled="!!switching"
@click="switchAll(true)"
>
全部开启
</button>
<button
type="button"
class="btn ghost"
:disabled="!!switching"
@click="switchAll(false)"
>
全部关闭
</button>
</div>
</div>
<div class="form-grid three">
<div class="field"><label>名称</label><input v-model="form.name" placeholder="如:PCR走廊照明" /></div>
<div class="field"><label>区域</label><input v-model="form.zone" placeholder="房间/分区" /></div>
<div class="field"><label>回路号</label><input v-model.number="form.circuit" type="number" min="1" max="256" /></div>
<div class="field"><label>备注</label><input v-model="form.remark" /></div>
</div>
<div class="row-actions">
<button type="button" class="btn" @click="createDevice">添加</button>
</div>
</section>
<section class="panel list-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>照明设备列表</h3></div>
<div class="badge amber">厂家接口 · Mock</div>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>回路</th>
<th>名称</th>
<th>区域</th>
<th>开关</th>
<th>亮度</th>
<th>状态</th>
<th>启用</th>
<th>最近控制</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="d in devices" :key="d.id">
<td>{{ d.circuit }}</td>
<td>{{ d.name }}</td>
<td>{{ d.zone }}</td>
<td>
<span class="pwr" :class="d.powerOn ? 'on' : 'off'">
{{ d.powerOn ? '开' : '关' }}
</span>
</td>
<td>{{ d.brightness }}%</td>
<td><span class="st" :class="d.status">{{ statusLabel[d.status] }}</span></td>
<td>{{ d.enabled ? '是' : '否' }}</td>
<td class="muted">
{{ fmtTime(d.lastSwitchAt) }}
<template v-if="d.lastSwitchBy"> · {{ d.lastSwitchBy }}</template>
</td>
<td class="ops">
<button
type="button"
class="btn switch-btn"
:class="d.powerOn ? 'is-on' : 'is-off'"
:disabled="!auth.isAdmin || !d.enabled || d.status === 'offline' || switching === d.id"
@click="remoteSwitch(d, !d.powerOn)"
>
{{ switching === d.id ? '下发中…' : d.powerOn ? '远程关灯' : '远程开灯' }}
</button>
<button
v-if="auth.isAdmin"
type="button"
class="btn ghost"
@click="toggleEnabled(d)"
>
{{ d.enabled ? '禁用' : '启用' }}
</button>
<button
v-if="auth.isAdmin"
type="button"
class="btn danger"
@click="removeDevice(d)"
>
删除
</button>
</td>
</tr>
<tr v-if="!devices.length">
<td colspan="9" class="empty">暂无照明设备</td>
</tr>
</tbody>
</table>
</div>
<p class="foot-hint">
厂家提供数据调用接口,本页负责显示集成与远程开关。一期为 Mock 落库;二期对接照明网关后实时同步。
</p>
</section>
</template>
<template v-else>
<section class="panel config-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>照明通信配置</h3></div>
<div class="badge" :class="auth.isAdmin ? '' : 'amber'">
{{ auth.isAdmin ? '可编辑' : '只读' }}
</div>
</div>
<p class="hint">
对接厂家智能照明网关(Modbus / HTTP / TCP)。配置保存后可用于远程开关下发。
</p>
<div class="form-grid three">
<div class="field">
<label>网关地址</label>
<input v-model="cfgForm.host" :disabled="!auth.isAdmin" placeholder="192.168.x.x" />
</div>
<div class="field">
<label>端口</label>
<input v-model.number="cfgForm.port" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>协议</label>
<select v-model="cfgForm.protocol" :disabled="!auth.isAdmin">
<option value="modbus">Modbus TCP</option>
<option value="http">HTTP</option>
<option value="tcp">TCP</option>
</select>
</div>
<div class="field">
<label>从站地址 (Unit ID)</label>
<input v-model.number="cfgForm.unitId" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>用户名(可选)</label>
<input v-model="cfgForm.username" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>密码(可选)</label>
<input
v-model="cfgForm.password"
type="password"
:disabled="!auth.isAdmin"
placeholder="********"
autocomplete="new-password"
/>
</div>
<div class="field">
<label>状态轮询 (ms)</label>
<input v-model.number="cfgForm.pollMs" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field switch-field">
<label>启用通信</label>
<label class="switch">
<input v-model="cfgForm.enabled" type="checkbox" :disabled="!auth.isAdmin" />
<span>{{ cfgForm.enabled ? '已启用' : '已关闭' }}</span>
</label>
</div>
</div>
<div class="row-actions">
<button v-if="auth.isAdmin" type="button" class="btn" @click="saveConfig">保存配置</button>
<button
v-if="auth.isAdmin"
type="button"
class="btn ghost"
:disabled="testing"
@click="testConfig"
>
{{ testing ? '测试中…' : '测试连通性' }}
</button>
<span v-if="testResult" class="msg">{{ testResult }}</span>
<span v-if="config" class="hint inline">
最近更新:{{ fmtTime(config.updatedAt) }} · {{ config.updatedBy }}
</span>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.light-page { gap: 12px; }
.light-tabs {
display: flex; align-items: center; gap: 8px; flex: 0 0 auto;
}
.tab {
border-radius: 6px;
border: 1px solid rgba(62, 200, 255, 0.22);
background: rgba(18, 42, 78, 0.4);
color: var(--muted);
padding: 6px 14px; font-size: 12px; cursor: pointer;
}
.tab.active {
background: linear-gradient(90deg, #1a6fff, #0aa6e5);
color: #fff; border-color: transparent; font-weight: 600;
}
.tab-spacer { flex: 1; }
.msg { font-size: 12px; color: var(--cyan-lt); }
.hint { font-size: 12px; color: var(--label); line-height: 1.6; margin-bottom: 12px; }
.hint.inline { margin-bottom: 0; }
.foot-hint { margin-top: 10px; font-size: 12px; color: var(--label); }
.form-grid.three { grid-template-columns: repeat(3, 1fr); max-width: 1100px; }
.row-actions {
display: flex; align-items: center; flex-wrap: wrap; gap: 12px;
margin-top: 14px;
}
.head-right { display: flex; align-items: center; gap: 8px; }
.switch-field .switch {
display: flex; align-items: center; gap: 8px;
height: 36px; color: var(--body); font-size: 13px; cursor: pointer;
}
.list-panel, .config-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.table-wrap { flex: 1; min-height: 0; overflow: auto; }
.ops { display: flex; flex-wrap: wrap; gap: 6px; }
.empty { text-align: center; color: var(--label); padding: 24px !important; }
.muted { color: var(--label); font-size: 11px; }
.st, .pwr {
display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 11px;
}
.st.online { background: rgba(47, 214, 161, 0.15); color: var(--green); }
.st.offline { background: rgba(142, 180, 212, 0.12); color: var(--muted); }
.st.fault { background: rgba(240, 180, 41, 0.18); color: var(--amber); }
.pwr.on { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.pwr.off { background: rgba(142, 180, 212, 0.12); color: var(--muted); }
.switch-btn.is-on {
border-color: rgba(251, 191, 36, 0.55);
background: rgba(251, 191, 36, 0.22);
color: #ffe08a;
}
.switch-btn.is-on:hover:not(:disabled) {
background: rgba(251, 191, 36, 0.38);
}
.switch-btn.is-off {
border-color: rgba(58, 200, 255, 0.45);
background: rgba(26, 111, 255, 0.28);
color: #eaf9ff;
}
.switch-btn.is-off:hover:not(:disabled) {
background: rgba(26, 111, 255, 0.45);
}
</style>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ApiError } from '@/api/http'
const auth = useAuthStore()
const router = useRouter()
const username = ref('admin')
const password = ref('Admin@123')
const error = ref('')
const loading = ref(false)
async function submit() {
error.value = ''
loading.value = true
try {
await auth.login(username.value.trim(), password.value)
await router.push('/overview')
} catch (e) {
error.value = e instanceof ApiError ? e.message : '登录失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-page">
<form class="login-card" @submit.prevent="submit">
<div class="login-brand">ZB</div>
<h1>分子实验室三维可视化管理平台</h1>
<p class="hint">请登录后进入管控大屏。管理员可进行设备管理与参数设置,其它角色仅可查看。</p>
<div class="field">
<label>用户名</label>
<input v-model="username" autocomplete="username" />
</div>
<div class="field">
<label>密码</label>
<input v-model="password" type="password" autocomplete="current-password" />
</div>
<p v-if="error" class="error">{{ error }}</p>
<button class="btn" type="submit" :disabled="loading">
{{ loading ? '登录中…' : '进入系统' }}
</button>
<p class="demo">演示账号:admin / Admin@123(管理员)· viewer / Viewer@123(只读)</p>
</form>
</div>
</template>
<style scoped>
.login-page {
width: 100%; height: 100%;
display: grid; place-items: center;
background:
radial-gradient(700px 360px at 50% 0%, rgba(62,200,255,.16), transparent 70%),
linear-gradient(180deg, #0a1628, #040a14);
}
.login-card {
width: 420px;
padding: 32px;
border-radius: 14px;
border: 1px solid rgba(62,200,255,.28);
background: linear-gradient(165deg, rgba(22,48,88,.7), rgba(10,24,48,.75));
box-shadow: 0 20px 50px rgba(0,0,0,.45);
display: flex; flex-direction: column; gap: 12px;
}
.login-brand {
width: 48px; height: 48px; border-radius: 12px;
display: grid; place-items: center;
border: 1px solid rgba(62,200,255,.5);
color: var(--cyan); font-weight: 700; font-family: var(--font-num);
background: rgba(62,200,255,.1);
}
h1 { font-size: 20px; color: var(--title); letter-spacing: 1px; }
.hint, .demo { font-size: 12px; color: var(--label); line-height: 1.6; }
.error { color: #ff9b9b; font-size: 12px; }
.btn { margin-top: 4px; height: 40px; }
</style>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime'
import { api } from '@/api/http'
import CostLineChart from '@/components/charts/CostLineChart.vue'
import type { BillingReport, BillingStats, TariffSettings } from '@/types/billing'
type Tab = 'meter' | 'stats' | 'tariff' | 'report'
const auth = useAuthStore()
const realtime = useRealtimeStore()
const tab = ref<Tab>('stats')
const loading = ref(false)
const saving = ref(false)
const message = ref('')
const stats = ref<BillingStats | null>(null)
const tariff = reactive<TariffSettings>({
powerPeak: 0,
powerFlat: 0,
powerValley: 0,
water: 0,
cooling: 0,
serviceFeePct: 0,
taxPct: 0,
settleDay: 1,
enabled: true,
updatedAt: '',
updatedBy: '',
})
const reportFrom = ref('')
const reportTo = ref('')
const report = ref<BillingReport | null>(null)
const power30d = computed(() =>
(stats.value?.usage30d || []).map((d) => ({ label: d.label, value: d.powerKwh })),
)
const water30d = computed(() =>
(stats.value?.usage30d || []).map((d) => ({ label: d.label, value: d.waterM3 })),
)
const cooling30d = computed(() =>
(stats.value?.usage30d || []).map((d) => ({ label: d.label, value: d.coolingKwh })),
)
const cost7d = computed(() =>
(stats.value?.trend7d || []).map((d) => ({ label: d.date, value: d.total })),
)
const cost12m = computed(() =>
(stats.value?.trend12m || []).map((d) => ({ label: d.label, value: d.total })),
)
function pad(n: number) {
return n < 10 ? `0${n}` : `${n}`
}
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
function daysAgo(n: number) {
const d = new Date()
d.setDate(d.getDate() - n)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
function money(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
async function loadStats() {
stats.value = await api<BillingStats>('/api/billing/stats', { token: auth.token })
}
async function loadTariff() {
const data = await api<TariffSettings>('/api/billing/tariff', { token: auth.token })
Object.assign(tariff, data)
}
async function loadReport() {
if (!reportFrom.value || !reportTo.value) return
report.value = await api<BillingReport>(
`/api/billing/report?from=${reportFrom.value}&to=${reportTo.value}`,
{ token: auth.token },
)
}
async function refreshAll() {
loading.value = true
message.value = ''
try {
if (!reportFrom.value) reportFrom.value = daysAgo(29)
if (!reportTo.value) reportTo.value = todayStr()
await Promise.all([loadStats(), loadTariff(), loadReport()])
} catch (e) {
message.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function saveTariff() {
if (!auth.isAdmin) return
saving.value = true
message.value = ''
try {
const data = await api<TariffSettings>('/api/billing/tariff', {
method: 'PUT',
token: auth.token,
body: JSON.stringify({
powerPeak: tariff.powerPeak,
powerFlat: tariff.powerFlat,
powerValley: tariff.powerValley,
water: tariff.water,
cooling: tariff.cooling,
serviceFeePct: tariff.serviceFeePct,
taxPct: tariff.taxPct,
settleDay: tariff.settleDay,
enabled: tariff.enabled,
}),
})
Object.assign(tariff, data)
message.value = '计费设置已保存'
await Promise.all([loadStats(), loadReport()])
} catch (e) {
message.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
function exportCsv() {
if (!report.value?.rows.length) return
const headers = [
'日期',
'峰电kWh',
'平电kWh',
'谷电kWh',
'用水m³',
'冷量kWh',
'电费',
'水费',
'冷量费',
'小计',
'服务费',
'税费',
'合计',
]
const lines = [headers.join(',')]
for (const r of report.value.rows) {
lines.push(
[
r.date,
r.powerPeakKwh,
r.powerFlatKwh,
r.powerValleyKwh,
r.waterM3,
r.coolingKwh,
r.powerCost,
r.waterCost,
r.coolingCost,
r.subtotal,
r.serviceFee,
r.tax,
r.total,
].join(','),
)
}
const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `能耗计费报表_${reportFrom.value}_${reportTo.value}.csv`
a.click()
URL.revokeObjectURL(url)
}
onMounted(refreshAll)
</script>
<template>
<div class="page-fill billing-page">
<div class="billing-tabs">
<button type="button" class="tab" :class="{ active: tab === 'meter' }" @click="tab = 'meter'">计量概览</button>
<button type="button" class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">费用统计</button>
<button type="button" class="tab" :class="{ active: tab === 'tariff' }" @click="tab = 'tariff'">计费设置</button>
<button type="button" class="tab" :class="{ active: tab === 'report' }" @click="tab = 'report'">费用报表</button>
<div class="tab-spacer" />
<button type="button" class="btn ghost" :disabled="loading" @click="refreshAll">刷新</button>
<span v-if="message" class="msg">{{ message }}</span>
</div>
<!-- 计量概览 -->
<template v-if="tab === 'meter'">
<div class="metric-grid three">
<section class="panel metric-card">
<div class="m-label">今日用电 (kWh)</div>
<div class="m-value">{{ realtime.snapshot.metering.powerKwh.toFixed(1) }}</div>
<div class="m-sub">较昨日 {{ realtime.snapshot.metering.powerTrendPct > 0 ? '↑' : '↓' }} {{ Math.abs(realtime.snapshot.metering.powerTrendPct) }}%</div>
</section>
<section class="panel metric-card">
<div class="m-label">今日用水 (m³)</div>
<div class="m-value">{{ realtime.snapshot.metering.waterM3.toFixed(1) }}</div>
<div class="m-sub">较昨日 {{ realtime.snapshot.metering.waterTrendPct > 0 ? '↑' : '↓' }} {{ Math.abs(realtime.snapshot.metering.waterTrendPct) }}%</div>
</section>
<section class="panel metric-card">
<div class="m-label">冷冻水冷量 (kWh)</div>
<div class="m-value">{{ realtime.snapshot.metering.coolingKwh }}</div>
<div class="m-sub">较昨日 ↑ {{ realtime.snapshot.metering.coolingTrendPct }}%</div>
</section>
</div>
<div class="usage-charts">
<section class="panel chart-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>近一个月用电趋势</h3></div>
<div class="badge">kWh</div>
</div>
<CostLineChart :points="power30d" color="#fbbf24" unit="kWh" :height="220" />
</section>
<section class="panel chart-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>近一个月用水趋势</h3></div>
<div class="badge">m³</div>
</div>
<CostLineChart :points="water30d" color="#38bdf8" unit="m³" :height="220" />
</section>
<section class="panel chart-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>近一个月冷冻水趋势</h3></div>
<div class="badge">kWh</div>
</div>
<CostLineChart :points="cooling30d" color="#67e8f9" unit="kWh" :height="220" />
</section>
</div>
</template>
<!-- 费用统计 -->
<template v-else-if="tab === 'stats' && stats">
<div class="metric-grid four">
<section class="panel metric-card tone-amber">
<div class="m-label">今日费用 (元)</div>
<div class="m-value">{{ money(stats.today.total) }}</div>
<div class="m-sub">含服务费与税</div>
</section>
<section class="panel metric-card tone-cyan">
<div class="m-label">本月累计 (元)</div>
<div class="m-value">{{ money(stats.month.total) }}</div>
<div class="m-sub">已计 {{ stats.month.days }} 天</div>
</section>
<section class="panel metric-card tone-green">
<div class="m-label">本月电费 (元)</div>
<div class="m-value">{{ money(stats.month.powerCost) }}</div>
<div class="m-sub">用电 {{ stats.month.powerKwh }} kWh</div>
</section>
<section class="panel metric-card tone-violet">
<div class="m-label">本月水+冷 (元)</div>
<div class="m-value">{{ money(stats.month.waterCost + stats.month.coolingCost) }}</div>
<div class="m-sub">水 {{ stats.month.waterM3 }} m³ · 冷 {{ stats.month.coolingKwh }} kWh</div>
</section>
</div>
<div class="split">
<section class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>本月费用构成</h3></div>
</div>
<div class="kind-list">
<div v-for="k in stats.byKind" :key="k.kind" class="kind-row" :class="k.kind">
<div class="kind-meta">
<span class="kind-name">{{ k.label }}</span>
<span class="kind-usage">{{ k.usage }} {{ k.unit }}</span>
</div>
<div class="kind-bar"><i :style="{ width: `${k.pct}%` }" /></div>
<div class="kind-cost">¥ {{ money(k.cost) }} <em>{{ k.pct }}%</em></div>
</div>
</div>
</section>
<section class="panel chart-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>近 7 日费用趋势</h3></div>
<div class="badge">元</div>
</div>
<CostLineChart :points="cost7d" color="#7ad8ff" unit="元" :height="160" />
</section>
</div>
<section class="panel chart-panel month-trend">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>近 12 个月费用趋势</h3></div>
<div class="badge">元</div>
</div>
<CostLineChart :points="cost12m" color="#a78bfa" unit="元" :height="170" />
</section>
</template>
<!-- 计费设置 -->
<template v-else-if="tab === 'tariff'">
<section class="panel tariff-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>费率与结算规则</h3></div>
<div class="badge" :class="auth.isAdmin ? '' : 'amber'">
{{ auth.isAdmin ? '管理员可改' : '只读' }}
</div>
</div>
<div class="form-grid tariff-grid">
<div class="field">
<label>峰电价 (元/kWh)</label>
<input v-model.number="tariff.powerPeak" type="number" step="0.01" min="0" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>平电价 (元/kWh)</label>
<input v-model.number="tariff.powerFlat" type="number" step="0.01" min="0" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>谷电价 (元/kWh)</label>
<input v-model.number="tariff.powerValley" type="number" step="0.01" min="0" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>水价 (元/m³)</label>
<input v-model.number="tariff.water" type="number" step="0.01" min="0" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>冷量单价 (元/kWh)</label>
<input v-model.number="tariff.cooling" type="number" step="0.01" min="0" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>服务费率 (%)</label>
<input v-model.number="tariff.serviceFeePct" type="number" step="0.1" min="0" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>税率 (%)</label>
<input v-model.number="tariff.taxPct" type="number" step="0.1" min="0" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>每月结算日</label>
<input v-model.number="tariff.settleDay" type="number" min="1" max="28" :disabled="!auth.isAdmin" />
</div>
<div class="field switch-field">
<label>启用计费</label>
<label class="switch">
<input v-model="tariff.enabled" type="checkbox" :disabled="!auth.isAdmin" />
<span>{{ tariff.enabled ? '已启用' : '已停用' }}</span>
</label>
</div>
</div>
<div class="tariff-actions">
<button type="button" class="btn" :disabled="!auth.isAdmin || saving" @click="saveTariff">
{{ saving ? '保存中…' : '保存设置' }}
</button>
<span class="hint">
最近更新:{{ tariff.updatedBy || '—' }} · {{ tariff.updatedAt ? new Date(tariff.updatedAt).toLocaleString('zh-CN', { hour12: false }) : '—' }}
</span>
</div>
<p class="tip">
参考同类能耗计费软件:支持分时电价(峰/平/谷)、水与冷量单价、服务费与税费、按月结算日。费用 = 用量 × 单价 + 服务费 + 税。
</p>
</section>
</template>
<!-- 费用报表 -->
<template v-else-if="tab === 'report'">
<section class="panel report-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>费用明细报表</h3></div>
<div class="report-tools">
<label>自 <input v-model="reportFrom" type="date" /></label>
<label>至 <input v-model="reportTo" type="date" /></label>
<button type="button" class="btn" @click="loadReport">查询</button>
<button type="button" class="btn ghost" :disabled="!report?.rows.length" @click="exportCsv">导出 CSV</button>
</div>
</div>
<div v-if="report" class="report-summary">
<span>区间 {{ report.summary.from }} ~ {{ report.summary.to }} · {{ report.summary.days }} 天</span>
<span>电费 ¥{{ money(report.summary.powerCost) }}</span>
<span>水费 ¥{{ money(report.summary.waterCost) }}</span>
<span>冷量费 ¥{{ money(report.summary.coolingCost) }}</span>
<span>服务费 ¥{{ money(report.summary.serviceFee) }}</span>
<span>税费 ¥{{ money(report.summary.tax) }}</span>
<b>合计 ¥{{ money(report.summary.total) }}</b>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>日期</th>
<th>峰电</th>
<th>平电</th>
<th>谷电</th>
<th>用水</th>
<th>冷量</th>
<th>电费</th>
<th>水费</th>
<th>冷量费</th>
<th>服务费</th>
<th>税费</th>
<th>合计</th>
</tr>
</thead>
<tbody>
<tr v-for="r in report?.rows || []" :key="r.date">
<td>{{ r.date }}</td>
<td>{{ r.powerPeakKwh }}</td>
<td>{{ r.powerFlatKwh }}</td>
<td>{{ r.powerValleyKwh }}</td>
<td>{{ r.waterM3 }}</td>
<td>{{ r.coolingKwh }}</td>
<td>{{ money(r.powerCost) }}</td>
<td>{{ money(r.waterCost) }}</td>
<td>{{ money(r.coolingCost) }}</td>
<td>{{ money(r.serviceFee) }}</td>
<td>{{ money(r.tax) }}</td>
<td><b>{{ money(r.total) }}</b></td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<div v-else-if="loading" class="loading">加载计费数据…</div>
</div>
</template>
<style scoped>
.billing-page { gap: 12px; }
.billing-tabs {
display: flex; align-items: center; gap: 8px; flex: 0 0 auto;
}
.tab {
border-radius: 6px;
border: 1px solid rgba(62, 200, 255, 0.22);
background: rgba(18, 42, 78, 0.4);
color: var(--muted);
padding: 6px 14px; font-size: 12px; cursor: pointer;
}
.tab.active {
background: linear-gradient(90deg, #1a6fff, #0aa6e5);
color: #fff; border-color: transparent; font-weight: 600;
}
.tab-spacer { flex: 1; }
.msg { font-size: 12px; color: var(--cyan-lt); }
.metric-grid { display: grid; gap: 12px; flex: 0 0 auto; }
.metric-grid.three { grid-template-columns: repeat(3, 1fr); }
.metric-grid.four { grid-template-columns: repeat(4, 1fr); }
.metric-card { padding: 16px 18px; }
.metric-card .m-label { font-size: 12px; color: var(--label); margin-bottom: 8px; }
.metric-card .m-value {
font-size: 28px; font-weight: 700; font-family: var(--font-num);
color: #fff; line-height: 1.1;
}
.metric-card .m-sub { margin-top: 8px; font-size: 11px; color: var(--muted); }
.tone-amber .m-value { color: #ffcf5c; }
.tone-cyan .m-value { color: var(--cyan-lt); }
.tone-green .m-value { color: var(--green); }
.tone-violet .m-value { color: #c4b5fd; }
.usage-charts {
flex: 0 0 auto;
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;
align-items: start;
}
.usage-charts .chart-panel {
height: fit-content;
padding-bottom: 10px;
}
.chart-panel {
min-height: 0;
display: flex; flex-direction: column;
}
.month-trend { flex: 0 0 auto; }
.split {
flex: 1; min-height: 0;
display: grid; grid-template-columns: 1fr 1.2fr; gap: 12px;
}
.split .panel { min-height: 0; display: flex; flex-direction: column; }
.kind-list { display: flex; flex-direction: column; gap: 14px; }
.kind-row { display: grid; grid-template-columns: 120px 1fr 140px; gap: 12px; align-items: center; }
.kind-name { display: block; font-size: 13px; font-weight: 600; color: #fff; }
.kind-usage { font-size: 11px; color: var(--label); }
.kind-bar {
height: 10px; border-radius: 5px; background: rgba(255,255,255,.06); overflow: hidden;
}
.kind-bar i { display: block; height: 100%; border-radius: 5px; }
.kind-row.power .kind-bar i { background: linear-gradient(90deg, #d97706, #fbbf24); }
.kind-row.water .kind-bar i { background: linear-gradient(90deg, #0284c7, #38bdf8); }
.kind-row.cooling .kind-bar i { background: linear-gradient(90deg, #0891b2, #67e8f9); }
.kind-cost { text-align: right; font-family: var(--font-num); font-size: 13px; color: #fff; }
.kind-cost em { font-style: normal; color: var(--label); margin-left: 6px; font-size: 11px; }
.tariff-panel { flex: 1; min-height: 0; overflow: auto; }
.tariff-grid { grid-template-columns: repeat(3, 1fr); max-width: 960px; }
.switch-field .switch {
display: flex; align-items: center; gap: 8px;
height: 36px; color: var(--body); font-size: 13px; cursor: pointer;
}
.tariff-actions {
display: flex; align-items: center; gap: 14px;
margin-top: 18px; margin-bottom: 12px;
}
.hint { font-size: 12px; color: var(--label); }
.report-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.report-tools { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--label); }
.report-tools input[type='date'] {
margin-left: 4px;
background: rgba(5, 26, 61, 0.7);
border: 1px solid rgba(62, 200, 255, 0.3);
color: #fff; border-radius: 6px; padding: 4px 8px;
}
.report-summary {
display: flex; flex-wrap: wrap; gap: 12px 18px;
font-size: 12px; color: var(--label);
padding: 8px 0 12px; border-bottom: 1px solid rgba(62,200,255,.12);
margin-bottom: 8px;
}
.report-summary b { color: var(--cyan-lt); font-family: var(--font-num); }
.table-wrap { flex: 1; min-height: 0; overflow: auto; }
.table-wrap :deep(thead th) {
position: sticky;
top: 0;
z-index: 2;
background: #0a1a30;
box-shadow: 0 1px 0 rgba(62, 200, 255, 0.22);
}
.loading { padding: 24px; color: var(--label); font-size: 13px; }
</style>
<script setup lang="ts">
import { computed } from 'vue'
import SceneViewport from '@/components/scene/SceneViewport.vue'
import AppIcon from '@/components/icons/AppIcon.vue'
import AlarmScroll from '@/components/panels/AlarmScroll.vue'
import ColdScroll from '@/components/panels/ColdScroll.vue'
import VavScroll from '@/components/panels/VavScroll.vue'
import { useRealtimeStore } from '@/stores/realtime'
const realtime = useRealtimeStore()
const snap = computed(() => realtime.snapshot)
const room = computed(() => realtime.primaryRoom)
const vavRunning = computed(() => snap.value.vav.filter((v) => v.running).length)
const coldWarnCount = computed(() => snap.value.fridges.filter((f) => f.temp > -19).length)
const cameras = [
{ name: '走廊', src: '/cams/cam-corridor.png' },
{ name: 'PCR区', src: '/cams/cam-pcr.png' },
{ name: '样本库', src: '/cams/cam-sample.png' },
{ name: '机房', src: '/cams/cam-room.png' },
{ name: '门禁口', src: '/cams/cam-gate.png' },
{ name: '冷链库', src: '/cams/cam-cold.png' },
]
const alarms = computed(() => {
const items: { level: 'red' | 'amber' | 'calm'; title: string; sub: string; state: string }[] = []
for (const r of snap.value.rooms) {
for (const a of r.alarms) {
items.push({ level: 'red', title: `${r.name}${a}`, sub: `压差 ${r.pressure.toFixed(1)} Pa`, state: '未消除' })
}
}
for (const f of snap.value.fridges) {
if (f.temp > -19) {
items.push({ level: 'amber', title: `${f.name}温度超限 ${f.temp.toFixed(1)} ℃`, sub: '冷链监测', state: '处理中' })
}
}
for (const tip of snap.value.hvac.alarms) {
items.push({ level: 'amber', title: tip, sub: '空调排风联动', state: '处理中' })
}
return items
})
</script>
<template>
<div class="overview-wrap">
<div class="overview">
<div class="col">
<section class="panel">
<div class="panel-head">
<div class="panel-title">
<AppIcon name="panel-env" />
<div class="vbar" /><h3>环境参数监测</h3>
</div>
<div class="badge">实时</div>
</div>
<div class="metric-grid">
<div class="metric">
<AppIcon name="metric-pressure" />
<div class="m-txt">
<div class="m-label">房间压差 (Pa)</div>
<div class="m-value green">{{ room.pressure.toFixed(1) }}</div>
</div>
</div>
<div class="metric">
<AppIcon name="metric-temp" />
<div class="m-txt">
<div class="m-label">温度 (℃)</div>
<div class="m-value">{{ room.temp.toFixed(1) }}</div>
</div>
</div>
<div class="metric">
<AppIcon name="metric-humidity" />
<div class="m-txt">
<div class="m-label">相对湿度 (%)</div>
<div class="m-value">{{ room.humidity }}</div>
</div>
</div>
<div class="metric">
<AppIcon name="metric-air" />
<div class="m-txt">
<div class="m-label">换气次数 (次/h)</div>
<div class="m-value">12</div>
</div>
</div>
</div>
</section>
<section class="panel vav-panel">
<div class="panel-head">
<div class="panel-title">
<AppIcon name="panel-vav" />
<div class="vbar" /><h3>通风柜 VAV 监控</h3>
</div>
<div class="badge">{{ vavRunning }}/{{ snap.vav.length }} 运行</div>
</div>
<VavScroll :items="snap.vav" />
</section>
<section class="panel cold-panel">
<div class="panel-head">
<div class="panel-title">
<AppIcon name="panel-cold" />
<div class="vbar" /><h3>冷链监测</h3>
</div>
<div class="badge" :class="{ amber: coldWarnCount > 0 }">
{{ coldWarnCount > 0 ? `${coldWarnCount}台超限` : '全部正常' }}
</div>
</div>
<ColdScroll :items="snap.fridges" />
</section>
</div>
<SceneViewport />
<div class="col">
<section class="panel hvac-panel">
<div class="panel-head">
<div class="panel-title">
<AppIcon name="panel-hvac" />
<div class="vbar" /><h3>空调及排风机组</h3>
</div>
<div class="badge">{{ snap.hvac.systemRunning ? '运行中' : '停机' }}</div>
</div>
<div class="hvac-table" role="table">
<div class="hvac-thead" role="row">
<span role="columnheader">参数</span>
<span role="columnheader">数值</span>
</div>
<div class="hvac-tbody" role="rowgroup">
<div class="hvac-row" role="row"><span>送风温度</span><b>{{ snap.hvac.supplyTemp.toFixed(1) }} ℃</b></div>
<div class="hvac-row" role="row"><span>送风湿度</span><b>{{ snap.hvac.supplyHumidity }} %</b></div>
<div class="hvac-row" role="row"><span>送风压力</span><b>{{ snap.hvac.supplyPressure }} Pa</b></div>
<div class="hvac-row" role="row"><span>风机频率</span><b>{{ snap.hvac.supplyFanHz.toFixed(1) }} Hz</b></div>
<div class="hvac-row" role="row"><span>排风压力</span><b>{{ snap.hvac.exhaustPressure }} Pa</b></div>
<div class="hvac-row" role="row"><span>新风温度</span><b>{{ snap.hvac.freshAirTemp.toFixed(1) }} ℃</b></div>
<div class="hvac-row" role="row"><span>滤网状态</span><b class="green">正常 · 剩余 {{ snap.hvac.filterRemainPct }}%</b></div>
<div class="hvac-row" role="row"><span>启停控制</span><b class="cyan">{{ snap.hvac.mode === 'auto' ? '自动模式' : '手动模式' }} · 已授权</b></div>
</div>
</div>
</section>
<section class="panel alarm-panel">
<div class="panel-head">
<div class="panel-title">
<AppIcon name="panel-alarm" />
<div class="vbar" /><h3>报警响应</h3>
</div>
</div>
<AlarmScroll :items="alarms" />
</section>
<section class="panel cam-panel">
<div class="panel-head">
<div class="panel-title">
<AppIcon name="panel-video" />
<div class="vbar" /><h3>视频监控</h3>
</div>
<div class="badge amber">海康 · 占位</div>
</div>
<div class="cam-grid">
<div v-for="c in cameras" :key="c.name" class="cam">
<img :src="c.src" :alt="c.name" />
<AppIcon name="cam-badge" class-name="cam-corner" />
<div class="cam-name">{{ c.name }}</div>
</div>
</div>
</section>
</div>
</div>
<div class="kpi-bar">
<div class="kpi kpi--power">
<div class="k-glow" />
<div class="k-left">
<AppIcon name="kpi-power" class-name="kpi-icon" />
<div class="k-label">今日用电 (kWh)</div>
</div>
<div class="k-center">
<div class="digital">{{ snap.metering.powerKwh.toFixed(1) }}</div>
<div class="k-trend">较昨日 {{ snap.metering.powerTrendPct > 0 ? '↑' : '↓' }} {{ Math.abs(snap.metering.powerTrendPct) }}%</div>
</div>
</div>
<div class="kpi kpi--water">
<div class="k-glow" />
<div class="k-left">
<AppIcon name="kpi-water" class-name="kpi-icon" />
<div class="k-label">今日用水 (m³)</div>
</div>
<div class="k-center">
<div class="digital">{{ snap.metering.waterM3.toFixed(1) }}</div>
<div class="k-trend">较昨日 {{ snap.metering.waterTrendPct > 0 ? '↑' : '↓' }} {{ Math.abs(snap.metering.waterTrendPct) }}%</div>
</div>
</div>
<div class="kpi kpi--cool">
<div class="k-glow" />
<div class="k-left">
<AppIcon name="kpi-cool" class-name="kpi-icon" />
<div class="k-label">冷冻水冷量 (kWh)</div>
</div>
<div class="k-center">
<div class="digital">{{ snap.metering.coolingKwh }}</div>
<div class="k-trend">较昨日 ↑ {{ snap.metering.coolingTrendPct }}%</div>
</div>
</div>
<div class="kpi kpi--device">
<div class="k-glow" />
<div class="k-left">
<AppIcon name="kpi-device" class-name="kpi-icon" />
<div class="k-label">设备在线</div>
</div>
<div class="k-center">
<div class="digital">{{ snap.online.devicesOnline }}/{{ snap.online.devicesTotal }}</div>
<div class="k-trend">在线率 {{ ((snap.online.devicesOnline / Math.max(1, snap.online.devicesTotal)) * 100).toFixed(1) }}%</div>
</div>
</div>
<div class="kpi kpi--access">
<div class="k-glow" />
<div class="k-left">
<AppIcon name="kpi-access" class-name="kpi-icon" />
<div class="k-label">门禁通行</div>
</div>
<div class="k-center">
<div class="digital">{{ snap.access.todayPassCount }}</div>
<div class="k-trend">今日人次 · {{ snap.access.doors.length }} 樘门禁</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.overview-wrap {
flex: 1; min-height: 0; width: 100%;
display: flex; flex-direction: column; gap: 14px;
overflow: hidden;
}
.overview {
flex: 1; min-height: 0; display: flex; gap: 14px; width: 100%;
overflow: hidden;
}
.kpi-bar {
height: 88px; flex: 0 0 88px;
display: flex; gap: 14px; padding: 0;
position: relative;
z-index: 2;
}
.kpi {
position: relative;
overflow: hidden;
flex: 1;
padding: 0 14px;
border-radius: 12px;
border: 1px solid transparent;
background: linear-gradient(145deg, rgba(18, 40, 72, 0.72), rgba(8, 18, 36, 0.65));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.kpi .k-glow {
position: absolute;
left: -16px; top: -28px;
width: 100px; height: 100px;
border-radius: 50%;
pointer-events: none;
opacity: 0.55;
filter: blur(2px);
}
.kpi .k-left {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
}
.kpi .k-label {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
}
.kpi .k-center {
position: absolute;
left: 70%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
}
.kpi .digital {
padding: 2px 10px;
border-radius: 6px;
font-size: 20px;
font-weight: 700;
font-family: var(--font-num);
letter-spacing: 0.4px;
line-height: 1.2;
white-space: nowrap;
}
.kpi .k-trend {
font-size: 11px;
white-space: nowrap;
}
.kpi :deep(.kpi-icon),
.kpi :deep(.k-icon) {
width: 34px; height: 34px;
flex: 0 0 auto;
}
/* 用电 · 琥珀金 */
.kpi--power {
border-color: rgba(255, 180, 64, 0.42);
background: linear-gradient(145deg, rgba(72, 48, 12, 0.55), rgba(18, 28, 40, 0.7));
}
.kpi--power .k-glow { background: radial-gradient(circle, rgba(255, 180, 64, 0.35), transparent 68%); }
.kpi--power .k-label { color: #f0c878; }
.kpi--power .digital {
color: #ffe08a;
background: rgba(40, 28, 8, 0.75);
border: 1px solid rgba(255, 180, 64, 0.5);
box-shadow: 0 0 16px rgba(255, 180, 64, 0.12);
}
.kpi--power .k-trend { color: #ffcf5c; }
/* 用水 · 水色蓝 */
.kpi--water {
border-color: rgba(56, 189, 248, 0.42);
background: linear-gradient(145deg, rgba(12, 48, 78, 0.6), rgba(10, 22, 40, 0.7));
}
.kpi--water .k-glow { background: radial-gradient(circle, rgba(56, 189, 248, 0.32), transparent 68%); }
.kpi--water .k-label { color: #7dd3fc; }
.kpi--water .digital {
color: #7dd3fc;
background: rgba(6, 28, 48, 0.8);
border: 1px solid rgba(56, 189, 248, 0.5);
box-shadow: 0 0 16px rgba(56, 189, 248, 0.12);
}
.kpi--water .k-trend { color: #38bdf8; }
/* 冷量 · 冰蓝 */
.kpi--cool {
border-color: rgba(103, 232, 249, 0.4);
background: linear-gradient(145deg, rgba(10, 56, 72, 0.58), rgba(10, 22, 40, 0.7));
}
.kpi--cool .k-glow { background: radial-gradient(circle, rgba(103, 232, 249, 0.3), transparent 68%); }
.kpi--cool .k-label { color: #a5f3fc; }
.kpi--cool .digital {
color: #a5f3fc;
background: rgba(6, 36, 48, 0.8);
border: 1px solid rgba(103, 232, 249, 0.48);
box-shadow: 0 0 16px rgba(103, 232, 249, 0.12);
}
.kpi--cool .k-trend { color: #67e8f9; }
/* 设备 · 翠绿 */
.kpi--device {
border-color: rgba(52, 211, 153, 0.42);
background: linear-gradient(145deg, rgba(10, 56, 40, 0.55), rgba(10, 22, 36, 0.7));
}
.kpi--device .k-glow { background: radial-gradient(circle, rgba(52, 211, 153, 0.3), transparent 68%); }
.kpi--device .k-label { color: #6ee7b7; }
.kpi--device .digital {
color: #6ee7b7;
background: rgba(6, 36, 28, 0.8);
border: 1px solid rgba(52, 211, 153, 0.48);
box-shadow: 0 0 16px rgba(52, 211, 153, 0.12);
}
.kpi--device .k-trend { color: #34d399; }
/* 门禁 · 靛蓝紫 */
.kpi--access {
border-color: rgba(167, 139, 250, 0.42);
background: linear-gradient(145deg, rgba(48, 28, 78, 0.55), rgba(12, 18, 40, 0.7));
}
.kpi--access .k-glow { background: radial-gradient(circle, rgba(167, 139, 250, 0.32), transparent 68%); }
.kpi--access .k-label { color: #c4b5fd; }
.kpi--access .digital {
color: #ddd6fe;
background: rgba(28, 16, 48, 0.8);
border: 1px solid rgba(167, 139, 250, 0.5);
box-shadow: 0 0 16px rgba(167, 139, 250, 0.12);
}
.kpi--access .k-trend { color: #a78bfa; }
.col {
width: 330px; flex: 0 0 330px;
display: flex; flex-direction: column; gap: 10px; min-height: 0;
overflow: hidden;
}
.vav-panel, .cold-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.vav-panel :deep(.vav-viewport),
.cold-panel :deep(.cold-viewport) {
flex: 1;
min-height: 0;
height: auto;
overflow: hidden;
}
.hvac-panel { flex: 0 0 auto; }
.alarm-panel { flex: 0 0 auto; }
.cam-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.metric {
display: flex; align-items: center; gap: 8px;
padding: 10px; min-height: 64px; border-radius: 8px;
background: var(--card-bg); border: 1px solid var(--card-bd);
}
.m-txt { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.m-label { font-size: 11px; color: var(--label); }
.m-value { font-size: 20px; font-weight: 600; font-family: var(--font-num); color: #fff; line-height: 1; }
.m-value.green { color: var(--green); }
.hvac-table {
border: 1px solid rgba(62, 200, 255, 0.28);
border-radius: 8px;
overflow: hidden;
background: rgba(6, 18, 36, 0.35);
}
.hvac-thead,
.hvac-row {
display: grid;
grid-template-columns: 1fr 1.35fr;
align-items: center;
gap: 8px;
min-height: 26px;
padding: 0 12px;
font-size: 12px;
}
.hvac-thead {
background: rgba(62, 200, 255, 0.12);
border-bottom: 1px solid rgba(62, 200, 255, 0.32);
color: var(--cyan-lt);
font-weight: 600;
letter-spacing: 0.4px;
}
.hvac-thead span:last-child { text-align: right; }
.hvac-row {
color: var(--label);
border-bottom: 1px solid rgba(62, 200, 255, 0.16);
background: rgba(14, 36, 68, 0.22);
}
.hvac-row:nth-child(even) {
background: rgba(22, 56, 104, 0.28);
}
.hvac-row:last-child { border-bottom: none; }
.hvac-row span { min-width: 0; }
.hvac-row b {
color: #fff;
font-weight: 500;
font-family: var(--font-num);
text-align: right;
justify-self: end;
}
.hvac-row b.green { color: var(--green); }
.hvac-row b.cyan { color: var(--cyan-lt); }
.cam-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: repeat(3, 1fr);
gap: 5px;
}
.cam {
position: relative; min-height: 0; height: 100%;
border-radius: 6px; overflow: hidden;
border: 1px solid rgba(62,200,255,.25); background: #04101f;
}
.cam img { width: 100%; height: 100%; object-fit: cover; }
.cam :deep(.cam-corner),
.cam :deep(.corner) {
position: absolute; left: 6px; top: 6px; width: 22px; height: 22px;
}
.cam-name {
position: absolute; right: 6px; top: 6px; font-size: 10px;
background: rgba(4,16,31,.6); padding: 1px 6px; border-radius: 3px;
}
</style>
<script setup lang="ts">
import { useRealtimeStore } from '@/stores/realtime'
const realtime = useRealtimeStore()
</script>
<template>
<div class="page-fill">
<section class="panel" style="flex:1; overflow:auto">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>房间压差集中监控</h3></div>
<div class="badge">实时</div>
</div>
<table class="table">
<thead>
<tr><th>房间</th><th>压差 (Pa)</th><th>温度 (℃)</th><th>湿度 (%)</th><th>告警</th></tr>
</thead>
<tbody>
<tr v-for="r in realtime.snapshot.rooms" :key="r.id">
<td>{{ r.name }}</td>
<td :style="{ color: r.alarms.length ? 'var(--red)' : 'var(--green)' }">{{ r.pressure.toFixed(1) }}</td>
<td>{{ r.temp.toFixed(1) }}</td>
<td>{{ r.humidity }}</td>
<td>{{ r.alarms.join('、') || '正常' }}</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { api } from '@/api/http'
interface SystemSettings {
collectPollMs: number
dataMode: string
collectRetentionDays: number
updatedAt: string
updatedBy: string
}
const auth = useAuthStore()
const viewportMode = import.meta.env.VITE_VIEWPORT_MODE || 'glb'
const ueUrl = import.meta.env.VITE_UE_STREAM_URL || '(未配置)'
const settings = ref<SystemSettings | null>(null)
const pollMs = ref(2000)
const pollSec = ref(2)
const saving = ref(false)
const message = ref('')
const presets = [
{ label: '1 秒', ms: 1000 },
{ label: '2 秒', ms: 2000 },
{ label: '5 秒', ms: 5000 },
{ label: '10 秒', ms: 10000 },
{ label: '30 秒', ms: 30000 },
{ label: '1 分钟', ms: 60000 },
]
function syncFromMs(ms: number) {
pollMs.value = ms
pollSec.value = +(ms / 1000).toFixed(ms % 1000 === 0 ? 0 : 1)
}
function syncFromSec(sec: number) {
const s = Math.max(0.5, Math.min(600, sec || 0.5))
pollSec.value = s
pollMs.value = Math.round(s * 1000)
}
async function load() {
message.value = ''
try {
const data = await api<SystemSettings>('/api/settings', { token: auth.token })
settings.value = data
syncFromMs(data.collectPollMs)
} catch (e) {
message.value = e instanceof Error ? e.message : '加载失败'
}
}
async function savePoll() {
if (!auth.isAdmin) return
saving.value = true
message.value = ''
try {
const data = await api<SystemSettings>('/api/settings/collect-poll', {
method: 'PUT',
token: auth.token,
body: JSON.stringify({ collectPollMs: pollMs.value }),
})
settings.value = data
syncFromMs(data.collectPollMs)
message.value = `采集频率已更新为 ${pollSec.value} 秒,立即生效`
} catch (e) {
message.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="page-fill">
<section class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>系统设置</h3></div>
<div class="badge" :class="auth.isAdmin ? '' : 'amber'">
{{ auth.isAdmin ? '管理员' : '只读' }}
</div>
</div>
<table class="table">
<tbody>
<tr><td>当前用户</td><td>{{ auth.user?.displayName }} ({{ auth.user?.username }})</td></tr>
<tr><td>三维视口模式</td><td>{{ viewportMode }}</td></tr>
<tr><td>UE 推流地址</td><td>{{ ueUrl }}</td></tr>
<tr>
<td>数据采集模式</td>
<td>{{ settings?.dataMode === 'modbus' ? 'Modbus 实采' : 'Mock 模拟' }}</td>
</tr>
<tr>
<td>采集数据保留</td>
<td>{{ settings?.collectRetentionDays ?? '—' }} 天</td>
</tr>
<tr><td>门禁桥接</td><td>ACCESS_BRIDGE_URL 未配置时使用 Mock</td></tr>
</tbody>
</table>
</section>
<section class="panel poll-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>数据采集频率</h3></div>
<div class="badge">热更新</div>
</div>
<p class="desc">
控制服务端轮询/采集周期。保存后立即生效,无需重启。允许范围 0.5–600 秒(500–600000 ms)。
</p>
<div class="presets">
<button
v-for="p in presets"
:key="p.ms"
type="button"
class="preset"
:class="{ active: pollMs === p.ms }"
:disabled="!auth.isAdmin"
@click="syncFromMs(p.ms)"
>
{{ p.label }}
</button>
</div>
<div class="form-grid poll-form">
<div class="field">
<label>周期(秒)</label>
<input
:value="pollSec"
type="number"
min="0.5"
max="600"
step="0.5"
:disabled="!auth.isAdmin"
@change="syncFromSec(Number(($event.target as HTMLInputElement).value))"
/>
</div>
<div class="field">
<label>周期(毫秒)</label>
<input
:value="pollMs"
type="number"
min="500"
max="600000"
step="100"
:disabled="!auth.isAdmin"
@change="syncFromMs(Number(($event.target as HTMLInputElement).value))"
/>
</div>
<div class="field actions">
<label>&nbsp;</label>
<button type="button" class="btn" :disabled="!auth.isAdmin || saving" @click="savePoll">
{{ saving ? '保存中…' : '保存并生效' }}
</button>
</div>
</div>
<div class="meta">
<span>当前生效:{{ settings ? `${(settings.collectPollMs / 1000)} 秒` : '—' }}</span>
<span v-if="settings">
最近更新:{{ settings.updatedBy }} ·
{{ new Date(settings.updatedAt).toLocaleString('zh-CN', { hour12: false }) }}
</span>
<span v-if="message" class="msg">{{ message }}</span>
</div>
</section>
</div>
</template>
<style scoped>
.poll-panel { flex: 0 0 auto; }
.desc { margin: 0 0 14px; font-size: 12px; color: var(--label); line-height: 1.7; }
.presets { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.preset {
border-radius: 6px;
border: 1px solid rgba(62, 200, 255, 0.25);
background: rgba(18, 42, 78, 0.45);
color: var(--muted);
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
}
.preset.active {
background: linear-gradient(90deg, #1a6fff, #0aa6e5);
color: #fff;
border-color: transparent;
font-weight: 600;
}
.preset:disabled { opacity: 0.45; cursor: not-allowed; }
.poll-form { grid-template-columns: 180px 180px auto; max-width: 640px; align-items: end; }
.actions .btn { height: 36px; }
.meta {
display: flex; flex-wrap: wrap; gap: 12px 18px;
margin-top: 14px; font-size: 12px; color: var(--label);
}
.msg { color: var(--cyan-lt); }
</style>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const title = computed(() => String(route.meta.title || '功能页'))
const desc = computed(() => String(route.meta.desc || '该模块骨架已就绪,待数据接口与交互深化。'))
</script>
<template>
<div class="page-fill">
<div class="stub-box">
<div>
<h2>{{ title }}</h2>
<p>{{ desc }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { api } from '@/api/http'
import { useAuthStore } from '@/stores/auth'
import type { Role, User } from '@/types'
const auth = useAuthStore()
const users = ref<User[]>([])
const error = ref('')
const form = reactive({
username: '',
displayName: '',
password: '',
role: 'viewer' as Role,
})
async function load() {
const data = await api<{ users: User[] }>('/api/users', { token: auth.token })
users.value = data.users
}
async function create() {
error.value = ''
try {
await api('/api/users', {
method: 'POST',
token: auth.token,
body: JSON.stringify(form),
})
form.username = ''
form.displayName = ''
form.password = ''
form.role = 'viewer'
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '创建失败'
}
}
async function toggle(u: User) {
error.value = ''
try {
await api(`/api/users/${u.id}`, {
method: 'PATCH',
token: auth.token,
body: JSON.stringify({ enabled: !u.enabled }),
})
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '更新失败'
}
}
async function remove(u: User) {
if (!confirm(`确认删除用户 ${u.username}?`)) return
error.value = ''
try {
await api(`/api/users/${u.id}`, { method: 'DELETE', token: auth.token })
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
}
}
onMounted(load)
</script>
<template>
<div class="page-fill">
<section class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>新建用户</h3></div>
<div class="badge">仅管理员</div>
</div>
<div class="form-grid">
<div class="field"><label>用户名</label><input v-model="form.username" /></div>
<div class="field"><label>显示名</label><input v-model="form.displayName" /></div>
<div class="field"><label>密码</label><input v-model="form.password" type="password" /></div>
<div class="field">
<label>角色</label>
<select v-model="form.role">
<option value="viewer">只读 (viewer)</option>
<option value="admin">管理员 (admin)</option>
</select>
</div>
</div>
<div style="margin-top:12px; display:flex; gap:10px; align-items:center">
<button class="btn" type="button" @click="create">创建</button>
<span v-if="error" style="color:#ff9b9b;font-size:12px">{{ error }}</span>
</div>
</section>
<section class="panel" style="flex:1; overflow:auto">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>用户列表</h3></div>
</div>
<table class="table">
<thead>
<tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>最近登录</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td>{{ u.username }}</td>
<td>{{ u.displayName }}</td>
<td>{{ u.role === 'admin' ? '管理员' : '只读' }}</td>
<td>{{ u.enabled ? '启用' : '禁用' }}</td>
<td>{{ u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString('zh-CN') : '-' }}</td>
<td>
<button class="btn ghost" @click="toggle(u)">{{ u.enabled ? '禁用' : '启用' }}</button>
<button class="btn danger" @click="remove(u)">删除</button>
</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime'
import { api } from '@/api/http'
const auth = useAuthStore()
const realtime = useRealtimeStore()
async function toggle(id: string, on: boolean) {
if (!auth.isAdmin) return
await api('/api/realtime/write', {
method: 'POST',
token: auth.token,
body: JSON.stringify({ target: id, action: on ? 'start' : 'stop' }),
})
alert(on ? '开机指令已下发(Mock)' : '关机指令已下发(Mock)')
}
function exhaustText(v: { maxExhaust: boolean; minAirflow: boolean }) {
if (v.maxExhaust) return '最大排风'
if (v.minAirflow) return '最小风量'
return '常态'
}
</script>
<template>
<div class="page-fill">
<section class="panel vav-page">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>通风柜 VAV 集中监控</h3></div>
<div class="badge">{{ auth.isAdmin ? '可控制' : '只读' }} · 协议 PAU-201-2</div>
</div>
<div class="table-wrap">
<table class="table vav-table">
<thead>
<tr>
<th>设备</th>
<th>开停状态</th>
<th>实际面风速</th>
<th>实际门高</th>
<th>实际风量</th>
<th>阀开度</th>
<th>最大排风</th>
<th>最小风量</th>
<th>排风模式</th>
<th>报警代码</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="v in realtime.snapshot.vav" :key="v.id">
<td class="name">{{ v.name }}</td>
<td>{{ v.running ? '运行' : '停止' }}</td>
<td>{{ v.faceVelocity.toFixed(3) }} m/s</td>
<td>{{ v.sashHeight }} mm</td>
<td>{{ v.airflow }} m³/h</td>
<td>{{ v.valveOpen }} %</td>
<td>{{ v.maxExhaust ? '是' : '否' }}</td>
<td>{{ v.minAirflow ? '是' : '否' }}</td>
<td>{{ exhaustText(v) }}</td>
<td>{{ v.alarmCode || '无' }}</td>
<td class="ops">
<button class="btn sm" :disabled="!auth.isAdmin" @click="toggle(v.id, true)">开机</button>
<button class="btn ghost sm" :disabled="!auth.isAdmin" @click="toggle(v.id, false)">关机</button>
</td>
</tr>
</tbody>
</table>
</div>
<p class="foot">
点位来源:文库制备1/2通风柜(开停、面风速×0.001、门高、风量、最大/最小风量、报警代码)。阀开度按报价清单监控项展示。
</p>
</section>
</div>
</template>
<style scoped>
.vav-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
}
.vav-table {
width: auto;
max-width: 100%;
table-layout: auto;
border-collapse: collapse;
font-size: 12px;
}
.vav-table :deep(th),
.vav-table th,
.vav-table td {
padding: 6px 10px;
white-space: nowrap;
width: 1%;
}
.vav-table .name {
width: auto;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
}
.vav-table .ops {
display: flex;
gap: 6px;
width: 1%;
}
.vav-table :deep(.btn.sm),
.vav-table .btn.sm {
padding: 4px 10px;
font-size: 11px;
line-height: 1.2;
}
.foot {
margin-top: 12px;
flex: 0 0 auto;
font-size: 12px;
color: var(--label);
line-height: 1.7;
}
</style>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { api } from '@/api/http'
import { useAuthStore } from '@/stores/auth'
import type {
DeviceStatus,
DeviceType,
StreamType,
VideoCommConfig,
VideoDevice,
VideoProtocol,
} from '@/types/video'
type Tab = 'devices' | 'config' | 'preview'
const auth = useAuthStore()
const tab = ref<Tab>('devices')
const loading = ref(false)
const message = ref('')
const devices = ref<VideoDevice[]>([])
const config = ref<VideoCommConfig | null>(null)
const selectedId = ref('')
const testing = ref(false)
const testResult = ref('')
const form = reactive({
name: '',
channel: 7,
location: '',
deviceType: 'nvr-ch' as DeviceType,
ip: '',
remark: '',
})
const cfgForm = reactive({
host: '',
port: 8000,
rtspPort: 554,
username: '',
password: '',
protocol: 'sdk' as VideoProtocol,
streamType: 'sub' as StreamType,
previewTimeoutMs: 8000,
enabled: true,
})
const statusLabel: Record<DeviceStatus, string> = {
online: '在线',
offline: '离线',
alarm: '报警',
}
const typeLabel: Record<DeviceType, string> = {
ipc: '海康网络摄像机',
'nvr-ch': '海康 NVR 通道',
}
const protocolLabel: Record<VideoProtocol, string> = {
sdk: 'HCNetSDK',
rtsp: 'RTSP',
isapi: 'ISAPI',
}
const onlineCount = computed(() => devices.value.filter((d) => d.enabled && d.status === 'online').length)
const previewList = computed(() => devices.value.filter((d) => d.enabled))
const selected = computed(
() => previewList.value.find((d) => d.id === selectedId.value) || previewList.value[0] || null,
)
async function loadAll() {
loading.value = true
message.value = ''
try {
const [devRes, cfg] = await Promise.all([
api<{ devices: VideoDevice[] }>('/api/video/devices', { token: auth.token }),
api<VideoCommConfig>('/api/video/config', { token: auth.token }),
])
devices.value = devRes.devices
config.value = cfg
cfgForm.host = cfg.host
cfgForm.port = cfg.port
cfgForm.rtspPort = cfg.rtspPort ?? 554
cfgForm.username = cfg.username
cfgForm.password = cfg.passwordMasked || ''
cfgForm.protocol = (cfg.protocol as string) === 'tcp' ? 'isapi' : cfg.protocol
cfgForm.streamType = cfg.streamType
cfgForm.previewTimeoutMs = cfg.previewTimeoutMs
cfgForm.enabled = cfg.enabled
if (!selectedId.value && previewList.value[0]) selectedId.value = previewList.value[0].id
} catch (e) {
message.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function createDevice() {
if (!auth.isAdmin) return
message.value = ''
try {
await api('/api/video/devices', {
method: 'POST',
token: auth.token,
body: JSON.stringify(form),
})
form.name = ''
form.location = ''
form.ip = ''
form.remark = ''
form.channel += 1
message.value = '设备已添加'
await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '添加失败'
}
}
async function toggleEnabled(d: VideoDevice) {
if (!auth.isAdmin) return
message.value = ''
try {
await api(`/api/video/devices/${d.id}`, {
method: 'PATCH',
token: auth.token,
body: JSON.stringify({ enabled: !d.enabled }),
})
await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '更新失败'
}
}
async function removeDevice(d: VideoDevice) {
if (!auth.isAdmin) return
if (!confirm(`确认删除设备「${d.name}」?`)) return
message.value = ''
try {
await api(`/api/video/devices/${d.id}`, { method: 'DELETE', token: auth.token })
if (selectedId.value === d.id) selectedId.value = ''
message.value = '已删除'
await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '删除失败'
}
}
async function saveConfig() {
if (!auth.isAdmin) return
message.value = ''
testResult.value = ''
try {
const body: Record<string, unknown> = {
host: cfgForm.host,
port: cfgForm.port,
rtspPort: cfgForm.rtspPort,
username: cfgForm.username,
protocol: cfgForm.protocol,
streamType: cfgForm.streamType,
previewTimeoutMs: cfgForm.previewTimeoutMs,
enabled: cfgForm.enabled,
}
if (cfgForm.password && cfgForm.password !== '********') body.password = cfgForm.password
const cfg = await api<VideoCommConfig>('/api/video/config', {
method: 'PUT',
token: auth.token,
body: JSON.stringify(body),
})
config.value = cfg
cfgForm.password = cfg.passwordMasked || ''
message.value = '通信配置已保存'
} catch (e) {
message.value = e instanceof Error ? e.message : '保存失败'
}
}
async function testConfig() {
if (!auth.isAdmin) return
testing.value = true
testResult.value = ''
try {
const r = await api<{ ok: boolean; message: string; latencyMs: number }>('/api/video/config/test', {
method: 'POST',
token: auth.token,
})
testResult.value = `${r.message} · ${r.latencyMs} ms`
message.value = r.ok ? '连通性测试通过' : r.message
} catch (e) {
testResult.value = e instanceof Error ? e.message : '测试失败'
} finally {
testing.value = false
}
}
function openPreview(d: VideoDevice) {
selectedId.value = d.id
tab.value = 'preview'
}
function fmtTime(t: string) {
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
onMounted(loadAll)
</script>
<template>
<div class="page-fill video-page">
<div class="video-tabs">
<button type="button" class="tab" :class="{ active: tab === 'devices' }" @click="tab = 'devices'">
设备管理
</button>
<button type="button" class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">
通信配置
</button>
<button type="button" class="tab" :class="{ active: tab === 'preview' }" @click="tab = 'preview'">
查看监控
</button>
<div class="tab-spacer" />
<div class="badge">海康威视</div>
<div class="badge">在线 {{ onlineCount }} / {{ devices.length }}</div>
<button type="button" class="btn ghost" :disabled="loading" @click="loadAll">刷新</button>
<span v-if="message" class="msg">{{ message }}</span>
</div>
<!-- 设备管理 -->
<template v-if="tab === 'devices'">
<section v-if="auth.isAdmin" class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>添加海康监控设备</h3></div>
<div class="badge">管理员</div>
</div>
<div class="form-grid three">
<div class="field"><label>名称</label><input v-model="form.name" placeholder="如:走廊东" /></div>
<div class="field"><label>NVR 通道号</label><input v-model.number="form.channel" type="number" min="1" max="256" /></div>
<div class="field"><label>安装位置</label><input v-model="form.location" placeholder="楼层/区域" /></div>
<div class="field">
<label>类型</label>
<select v-model="form.deviceType">
<option value="nvr-ch">海康 NVR 通道</option>
<option value="ipc">海康网络摄像机</option>
</select>
</div>
<div class="field"><label>摄像机 IP(可选)</label><input v-model="form.ip" placeholder="IPC 直连时填写" /></div>
<div class="field"><label>备注</label><input v-model="form.remark" /></div>
</div>
<div class="row-actions">
<button type="button" class="btn" @click="createDevice">添加</button>
</div>
</section>
<section class="panel list-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>海康监控设备列表</h3></div>
<div class="badge amber">Hikvision NVR</div>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>通道</th>
<th>名称</th>
<th>位置</th>
<th>类型</th>
<th>IP</th>
<th>状态</th>
<th>启用</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="d in devices" :key="d.id">
<td>{{ d.channel }}</td>
<td>{{ d.name }}</td>
<td>{{ d.location }}</td>
<td>{{ typeLabel[d.deviceType] }}</td>
<td>{{ d.ip || '—' }}</td>
<td>
<span class="st" :class="d.status">{{ statusLabel[d.status] }}</span>
</td>
<td>{{ d.enabled ? '是' : '否' }}</td>
<td>{{ fmtTime(d.updatedAt) }}</td>
<td class="ops">
<button type="button" class="btn ghost" @click="openPreview(d)">查看</button>
<button
v-if="auth.isAdmin"
type="button"
class="btn ghost"
@click="toggleEnabled(d)"
>
{{ d.enabled ? '禁用' : '启用' }}
</button>
<button
v-if="auth.isAdmin"
type="button"
class="btn danger"
@click="removeDevice(d)"
>
删除
</button>
</td>
</tr>
<tr v-if="!devices.length">
<td colspan="9" class="empty">暂无设备,请先添加或检查通信配置</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<!-- 通信配置 -->
<template v-else-if="tab === 'config'">
<section class="panel config-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>海康通信配置</h3></div>
<div class="head-right">
<div class="badge">厂商:海康威视</div>
<div class="badge" :class="auth.isAdmin ? '' : 'amber'">
{{ auth.isAdmin ? '可编辑' : '只读' }}
</div>
</div>
</div>
<p class="hint">
对接海康威视 NVR / 摄像机。推荐 HCNetSDK(设备端口 8000)预览;RTSP(554)或 ISAPI 作备选。
一期保存配置并 Mock 登录测试;二期经 Windows HCNetSDK 桥接拉取实况(与门禁桥接同类)。
</p>
<div class="form-grid three">
<div class="field">
<label>海康 NVR IP</label>
<input v-model="cfgForm.host" :disabled="!auth.isAdmin" placeholder="192.168.x.x" />
</div>
<div class="field">
<label>设备端口(SDK)</label>
<input v-model.number="cfgForm.port" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>RTSP 端口</label>
<input v-model.number="cfgForm.rtspPort" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>接入方式</label>
<select v-model="cfgForm.protocol" :disabled="!auth.isAdmin">
<option value="sdk">HCNetSDK(推荐)</option>
<option value="rtsp">RTSP</option>
<option value="isapi">ISAPI</option>
</select>
</div>
<div class="field">
<label>设备用户名</label>
<input v-model="cfgForm.username" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>设备密码</label>
<input
v-model="cfgForm.password"
type="password"
:disabled="!auth.isAdmin"
placeholder="********"
autocomplete="new-password"
/>
</div>
<div class="field">
<label>预览码流</label>
<select v-model="cfgForm.streamType" :disabled="!auth.isAdmin">
<option value="sub">子码流(推荐)</option>
<option value="main">主码流</option>
</select>
</div>
<div class="field">
<label>预览超时 (ms)</label>
<input v-model.number="cfgForm.previewTimeoutMs" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field switch-field">
<label>启用海康通信</label>
<label class="switch">
<input v-model="cfgForm.enabled" type="checkbox" :disabled="!auth.isAdmin" />
<span>{{ cfgForm.enabled ? '已启用' : '已关闭' }}</span>
</label>
</div>
</div>
<div class="row-actions">
<button v-if="auth.isAdmin" type="button" class="btn" @click="saveConfig">保存配置</button>
<button
v-if="auth.isAdmin"
type="button"
class="btn ghost"
:disabled="testing"
@click="testConfig"
>
{{ testing ? '测试中…' : '测试连通性' }}
</button>
<span v-if="testResult" class="msg">{{ testResult }}</span>
<span v-if="config" class="hint">
最近更新:{{ fmtTime(config.updatedAt) }} · {{ config.updatedBy }}
</span>
</div>
</section>
</template>
<!-- 查看监控 -->
<template v-else>
<div class="preview-layout">
<section class="panel preview-main">
<div class="panel-head">
<div class="panel-title">
<div class="vbar" />
<h3>{{ selected ? selected.name : '未选择通道' }}</h3>
</div>
<div v-if="selected" class="badge" :class="selected.status === 'online' ? '' : 'amber'">
CH{{ selected.channel }} · {{ statusLabel[selected.status] }}
</div>
</div>
<div class="stage" :class="selected?.status || 'offline'">
<div class="scan" />
<div class="live-tag" v-if="selected?.status === 'online'">
<i /><span>海康 · LIVE · Mock</span>
</div>
<div class="stage-body">
<template v-if="selected">
<div class="cam-title">{{ selected.name }}</div>
<div class="cam-sub">{{ selected.location }}</div>
<div class="cam-meta">
海康威视 · {{ typeLabel[selected.deviceType] }}
· 通道 {{ selected.channel }}
<template v-if="selected.ip"> · {{ selected.ip }}</template>
· {{ protocolLabel[config?.protocol || 'sdk'] }}
</div>
<p class="cam-note">
实况待 Windows HCNetSDK 桥接接入;当前为占位画面,设备与海康通信配置已可管理。
</p>
</template>
<template v-else>
<div class="cam-title">无可用通道</div>
<p class="cam-note">请先在设备管理中启用海康监控通道。</p>
</template>
</div>
</div>
</section>
<section class="panel preview-side">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>通道列表</h3></div>
</div>
<div class="thumb-grid">
<button
v-for="d in previewList"
:key="d.id"
type="button"
class="thumb"
:class="[{ active: selected?.id === d.id }, d.status]"
@click="selectedId = d.id"
>
<div class="thumb-top">
<span class="ch">CH{{ d.channel }}</span>
<span class="st" :class="d.status">{{ statusLabel[d.status] }}</span>
</div>
<div class="thumb-name">{{ d.name }}</div>
<div class="thumb-loc">{{ d.location }}</div>
</button>
</div>
</section>
</div>
</template>
</div>
</template>
<style scoped>
.video-page { gap: 12px; }
.head-right { display: flex; align-items: center; gap: 8px; }
.video-tabs {
display: flex; align-items: center; gap: 8px; flex: 0 0 auto;
}
.tab {
border-radius: 6px;
border: 1px solid rgba(62, 200, 255, 0.22);
background: rgba(18, 42, 78, 0.4);
color: var(--muted);
padding: 6px 14px; font-size: 12px; cursor: pointer;
}
.tab.active {
background: linear-gradient(90deg, #1a6fff, #0aa6e5);
color: #fff; border-color: transparent; font-weight: 600;
}
.tab-spacer { flex: 1; }
.msg { font-size: 12px; color: var(--cyan-lt); }
.hint { font-size: 12px; color: var(--label); line-height: 1.6; margin-bottom: 12px; }
.form-grid.three { grid-template-columns: repeat(3, 1fr); max-width: 1100px; }
.row-actions {
display: flex; align-items: center; flex-wrap: wrap; gap: 12px;
margin-top: 14px;
}
.switch-field .switch {
display: flex; align-items: center; gap: 8px;
height: 36px; color: var(--body); font-size: 13px; cursor: pointer;
}
.list-panel, .config-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.table-wrap { flex: 1; min-height: 0; overflow: auto; }
.ops { display: flex; flex-wrap: wrap; gap: 6px; }
.empty { text-align: center; color: var(--label); padding: 24px !important; }
.st {
display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 11px;
}
.st.online { background: rgba(47, 214, 161, 0.15); color: var(--green); }
.st.offline { background: rgba(142, 180, 212, 0.12); color: var(--muted); }
.st.alarm { background: rgba(240, 180, 41, 0.18); color: var(--amber); }
.preview-layout {
flex: 1; min-height: 0;
display: grid; grid-template-columns: 1.6fr 1fr; gap: 12px;
}
.preview-main, .preview-side {
min-height: 0; display: flex; flex-direction: column;
}
.stage {
position: relative; flex: 1; min-height: 280px;
border-radius: 8px; overflow: hidden;
border: 1px solid rgba(62, 200, 255, 0.28);
background:
radial-gradient(ellipse at 30% 20%, rgba(58, 140, 220, 0.22), transparent 55%),
linear-gradient(160deg, #0a1a30 0%, #07111f 55%, #040a14 100%);
}
.stage.alarm {
background:
radial-gradient(ellipse at 40% 30%, rgba(240, 180, 41, 0.18), transparent 50%),
linear-gradient(160deg, #1a1520 0%, #0c1018 100%);
}
.stage.offline { filter: saturate(0.55); }
.scan {
pointer-events: none; position: absolute; inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(122, 216, 255, 0.03) 2px,
rgba(122, 216, 255, 0.03) 4px
);
}
.live-tag {
position: absolute; left: 12px; top: 12px; z-index: 2;
display: flex; align-items: center; gap: 6px;
font-size: 11px; color: #fff;
background: rgba(4, 16, 31, 0.65); padding: 3px 8px; border-radius: 4px;
}
.live-tag i {
width: 7px; height: 7px; border-radius: 50%; background: #ff5a5a;
box-shadow: 0 0 0 0 rgba(255, 90, 90, 0.6);
animation: pulse 1.4s ease infinite;
}
@keyframes pulse {
70% { box-shadow: 0 0 0 6px rgba(255, 90, 90, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 90, 90, 0); }
}
.stage-body {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 24px; z-index: 1;
}
.cam-title { font-size: 22px; font-weight: 600; color: var(--title); }
.cam-sub { margin-top: 8px; font-size: 13px; color: var(--cyan-lt); }
.cam-meta { margin-top: 10px; font-size: 12px; color: var(--label); font-family: var(--font-num); }
.cam-note { margin-top: 16px; max-width: 420px; font-size: 12px; color: var(--muted); line-height: 1.7; }
.thumb-grid {
flex: 1; min-height: 0; overflow: auto;
display: grid; grid-template-columns: 1fr 1fr; gap: 8px; align-content: start;
}
.thumb {
text-align: left; cursor: pointer;
border-radius: 8px; padding: 10px 12px;
border: 1px solid rgba(62, 200, 255, 0.2);
background: rgba(14, 36, 68, 0.35);
color: inherit;
}
.thumb:hover { border-color: rgba(62, 200, 255, 0.45); }
.thumb.active {
border-color: rgba(58, 200, 255, 0.7);
background: rgba(26, 111, 255, 0.22);
box-shadow: inset 0 0 0 1px rgba(122, 216, 255, 0.15);
}
.thumb-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.ch { font-size: 11px; color: var(--cyan-lt); font-family: var(--font-num); }
.thumb-name { font-size: 13px; font-weight: 600; color: #fff; }
.thumb-loc { margin-top: 4px; font-size: 11px; color: var(--label); }
</style>
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE: string
readonly VITE_WS_URL: string
readonly VITE_VIEWPORT_MODE: 'glb' | 'ue-stream'
readonly VITE_UE_STREAM_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag === 'model-viewer',
},
},
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3001',
'/ws': {
target: 'ws://localhost:3001',
ws: true,
},
},
},
})
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