Commit 323abebd by James Yuan

feat: 设备点位主数据、前后台路由拆分与 seat 风格对齐

从设备对接协议 CSV 种子化真实设备/点位,新增 Windows 网关脚手架与后台 /admin;大屏 /dashboard 免登录,UI token 与 tops 壳层对齐 EMSC seat。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 67c6cffb
...@@ -14,20 +14,29 @@ WEB_PORT=8080 ...@@ -14,20 +14,29 @@ WEB_PORT=8080
JWT_SECRET=change-me-in-production JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=12h JWT_EXPIRES_IN=12h
DATA_MODE=mock DATA_MODE=mock
# DATA_MODE=gateway
# DATA_MODE=modbus # DATA_MODE=modbus
# 单一 Windows 协议网关(Modbus + HCNetSDK)
GATEWAY_URL=http://127.0.0.1:38080
# 现场点位 CSV 目录(默认:仓库旁「设备对接/…/澳门镜湖医院协议20261019」)
# DEVICE_POINTS_DIR=/absolute/path/to/protocol-csvs
# 启动时强制用 CSV 重载设备/点位(覆盖库内旧种子)
# DEVICES_RESEED=1
# 采集数据保留天数(PostgreSQL) # 采集数据保留天数(PostgreSQL)
COLLECT_RETENTION_DAYS=90 COLLECT_RETENTION_DAYS=90
# Modbus gateways # Modbus 主机(逻辑设备,实际由 Windows 网关连接)
MODBUS_PAU1_HOST=192.168.10.102 MODBUS_PAU1_HOST=192.168.10.102
MODBUS_PAU2_HOST=192.168.10.103 MODBUS_PAU2_HOST=192.168.10.103
MODBUS_ROOM_HOST=192.168.10.104 MODBUS_ROOM_HOST=192.168.10.104
MODBUS_FRIDGE_HOST=192.168.10.105 MODBUS_FRIDGE_HOST=192.168.10.104
MODBUS_PORT=502 MODBUS_PORT=502
MODBUS_POLL_MS=2000 MODBUS_POLL_MS=2000
# Access bridge (HCNetSDK Windows service), empty = mock # 兼容旧配置:门禁桥接;优先使用 GATEWAY_URL
ACCESS_BRIDGE_URL= ACCESS_BRIDGE_URL=
# Default admin (seeded on first boot) # Default admin (seeded on first boot)
......
...@@ -82,7 +82,10 @@ npm run dev ...@@ -82,7 +82,10 @@ npm run dev
## 目录 ## 目录
- `web/` 大屏前端 - `web/` 大屏前端(含后台「设备管理」点位编辑)
- `server/` 认证、用户、实时采集、计费、PG 落库 - `server/` 认证、用户、实时采集、计费、设备/点位主数据、PG 落库
- `gateway/win-protocol/` Windows 协议网关(VS2022+MSVC,Modbus+HCNetSDK)
- `docker-compose.yml` 一键部署 - `docker-compose.yml` 一键部署
- `docs/` 点位与门禁对接说明 - `docs/` 架构、网关 API、点位与门禁对接说明
设备/点位:登录管理员 → `/devices`;导入现场 CSV 后可「下发网关」。`GATEWAY_URL` 指向 Windows 网关(默认 `http://127.0.0.1:38080`)。
# 分子实验室数字孪生 — 系统架构
## 分层
```
现场设备 → 单一 Windows 协议网关 → Node 平台服务 → Vue 大屏/后台
│ │
Modbus TCP + HCNetSDK PostgreSQL 落库
```
- **Windows 协议网关**(`gateway/win-protocol/`,VS2022 + MSVC x64 C++17):统一采集/写控。
- **Node `server/`**:设备与点位主数据、计费、鉴权、WS 快照;通过 `GATEWAY_URL` 对接网关。
- **Vue `web/`**:业务大屏 + 后台设备/点位管理。
## 数据闭环
1. 上行:网关按点位表轮询 / 海康事件 → telemetry → Node 快照 → `collect_*` → WS。
2. 下行:页面写指令 → 审计 → 网关 Modbus 写或控门。
3. 点位:后台编辑/导入 CSV → PG → `POST` 通知网关 reload。
## 相关文档
- [gateway-api.md](./gateway-api.md) — Windows 网关 HTTP/WS 契约
- [device-points.md](./device-points.md) — 点位表字段与换算
- [point-map.md](./point-map.md) — 现场网关 IP 摘要
- [access-hcnetsdk.md](./access-hcnetsdk.md) — 海康 SDK 说明
# Modbus 点位表模型
对齐现场 CSV(如 `19216810104 房间8个冰箱温度.csv`),编码多为 **GBK**。
## 列
| 列 | 说明 |
|---|---|
| id | 行号(导入时可忽略,平台用 UUID) |
| Name | 点名 |
| RegType | 现场侧类型,如 `3X(Input Register)` |
| RegAddress | 现场侧地址 |
| DataType | `Short` / `Word` / `Float` / `Double` / `Bit` 等 |
| Modbus ID | 从站号(unit id) |
| Modbus Register | TCP 映射寄存器区,如 `4X(Holding Register)` |
| Modbus Address | **网关侧读写地址(关键)** |
| Description | 备注 |
| MaskBit / Bit | 位域 |
| Scale / Bias | `eng = raw * Scale + Bias`;Scale=0 时可用 Raw/Eng 量程线性映射或业务默认(温度常见 `*0.1`) |
| Min/Max Raw/Eng Range | 量程 |
| ItemID | 稳定业务键,如 `RTU.COM3.1_试剂准备间冰箱1.冰箱温度` |
## 平台扩展
- `gateway_id`:逻辑 Modbus 主机(`.102/.103/.104`)
- `device_id`:所属设备
- `snapshot_path`:映射大屏快照 JSON 路径
- `rw`:`r` / `w` / `rw`
- `energy_kind`:`power` / `water` / `cooling` / null
- `enabled`:是否参与采集
- `scale_override`:覆盖 CSV Scale(解决 Scale=0)
## 冰箱示例(`.104`)
| Item 摘要 | Modbus Address |
|---|---|
| 8 台冰箱温度 | 701–708 |
| 8 台冰箱湿度 | 721–728 |
## API
- `GET/POST /api/device-points`
- `PATCH/DELETE /api/device-points/:id`
- `POST /api/device-points/import`(multipart 或 raw text + `encoding=gbk|utf-8`)
- `GET /api/device-points/export?gatewayId=&encoding=`
- `POST /api/device-points/sync-gateway`
# Windows 协议网关 API 契约
默认监听 `0.0.0.0:38080`。平台环境变量:`GATEWAY_URL=http://<采集机>:38080`。
开发栈:**VS2022 + MSVC x64 + C++17**(脚手架可用 MinGW),工程目录 `gateway/win-protocol/`。
## 管理界面(内嵌 Web)
浏览器打开:`http://<采集机>:38080/ui/`
| 页面 | 说明 |
|---|---|
| 登录 | 默认 `admin` / `Admin@123`(`config.json`) |
| 总览 | 运行状态、主机/摄像/门禁数量 |
| 网络/IP | 监听地址、端口、平台 URL、Modbus 主机 IP |
| 设备列表 | Modbus / 摄像头 / 门禁 / NVR |
| 设备配置 | 增改删海康设备(IP/端口/账号/通道) |
| 摄像头预览 | JPEG/SVG 抓图通道(HCNetSDK 接入前为占位图) |
管理 API 前缀:`/admin/api/*`(需 `Authorization: Bearer <token>`)。
## 通用
- Content-Type:`application/json; charset=utf-8`
- 错误:`{ "ok": false, "message": "..." }`,HTTP 4xx/5xx
## 健康
`GET /health`
```json
{
"ok": true,
"version": "0.1.0",
"pointsVersion": 3,
"modbus": [
{ "host": "192.168.10.102", "port": 502, "ok": true, "latencyMs": 12 }
],
"hikvision": { "loggedIn": 4, "alarms": true }
}
```
## 点位配置
平台保存点位后调用:
`POST /config/points/reload`
Body:全量点位数组(与平台 `device_points` 导出字段一致),或空 body 表示网关主动向平台拉取(可选,需配置 `PLATFORM_URL`)。
网关也可:
`GET /config/points` — 返回当前缓存点表与 `pointsVersion`。
## 实时值
`GET /snapshot`
```json
{
"ts": "2026-10-20T02:00:00.000Z",
"pointsVersion": 3,
"values": [
{ "id": "uuid-or-item", "itemId": "RTU.COM3.1_…", "raw": 251, "eng": 25.1, "quality": "good", "ts": "..." }
]
}
```
`WS /telemetry` — 推送同上结构的增量或全量帧(`type: "telemetry"`)。
## Modbus 写
`POST /modbus/write`
```json
{ "pointId": "…", "value": 1 }
```
或地址级:
```json
{
"host": "192.168.10.104",
"port": 502,
"unitId": 1,
"register": "4X",
"address": 801,
"dataType": "Coil",
"value": 1
}
```
## 门禁 / 视频(HCNetSDK)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/doors` | 门状态列表 |
| POST | `/doors/:id/open` | 远程开门(body 可选 `mode`) |
| GET | `/events` | 最近通行事件 |
| WS | `/alarms` | ACS 报警推送 |
| GET | `/cameras/:id/snapshot.jpg` | JPEG 抓图(最小可用预览) |
## 配置文件(网关本地)
`config.json` 示例:
```json
{
"listen": "0.0.0.0",
"port": 38080,
"platformUrl": "http://127.0.0.1:3001",
"pollMs": 2000,
"modbusHosts": [
{ "id": "mb-102", "host": "192.168.10.102", "port": 502 },
{ "id": "mb-103", "host": "192.168.10.103", "port": 502 },
{ "id": "mb-104", "host": "192.168.10.104", "port": 502 }
]
}
```
...@@ -5,10 +5,13 @@ ...@@ -5,10 +5,13 @@
| 网关 | IP | 主要用途 | UI 模块 | | 网关 | IP | 主要用途 | UI 模块 |
|---|---|---|---| |---|---|---|---|
| PAU-201-1 | 192.168.10.102 | 空调/排风启停、阀门、温湿度压力、PID | 空调排风、全景 HVAC | | PAU-201-1 | 192.168.10.102 | 空调/排风启停、阀门、温湿度压力、PID | 空调排风、全景 HVAC |
| PAU-201-2 | 192.168.10.103 | 电能、冷量、通风柜 VAV | 计量计费、VAV | | PAU-201-2 | 192.168.10.103 | 电能、冷量、通风柜 VAV、流量计 | 计量计费、VAV |
| 房间 | 192.168.10.104 | 各房间温湿度压差、告警、阀门开度 | 房间压差、环境参数 | | 房间 | 192.168.10.104 | 压差屏、PLC、**8 组冰箱温湿度**、N/E 电回路 | 房间压差、冷链、照明 |
| E栋冰箱 | 192.168.10.105 | 8 组冰箱温湿度(×0.1) | 冷链温度 |
倍率约定:温度等 Short/Word 常见 `*0.1`;面风速 `*0.001`。 > 协议包 CSV 以 `.104` COM3 承载冰箱点位(地址约 701–708 / 721–728)。原文档 `.105` 冰箱网关作废,以 CSV 为准。
写指令统一经 `POST /api/realtime/write`,需管理员 JWT。 倍率约定:温度等 Short/Word 常见 `*0.1`;面风速 `*0.001`。点位可在后台 `/devices` 编辑,经 Windows 协议网关采集。
写指令统一经 `POST /api/realtime/write` → Windows 网关,需管理员 JWT。
采集模式:`DATA_MODE=mock|gateway`;`GATEWAY_URL` 指向 Windows 协议网关。
build/
out/
.vs/
*.user
*.suo
x64/
Debug/
Release/
cmake_minimum_required(VERSION 3.20)
project(JhLabGateway LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(JhLabGateway src/main.cpp)
target_include_directories(JhLabGateway PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/third_party)
if(WIN32)
target_compile_definitions(JhLabGateway PRIVATE _CRT_SECURE_NO_WARNINGS WIN32_LEAN_AND_MEAN)
target_link_libraries(JhLabGateway PRIVATE ws2_32)
endif()
if(MSVC)
target_compile_options(JhLabGateway PRIVATE /W3 /utf-8)
elseif(MINGW)
target_compile_options(JhLabGateway PRIVATE -Wall -O2)
endif()
# Copy runtime assets next to the binary
add_custom_command(TARGET JhLabGateway POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${CMAKE_SOURCE_DIR}/config.json $<TARGET_FILE_DIR:JhLabGateway>/config.json
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_SOURCE_DIR}/web $<TARGET_FILE_DIR:JhLabGateway>/web
)
install(TARGETS JhLabGateway RUNTIME DESTINATION bin)
install(FILES config.json DESTINATION bin)
install(DIRECTORY web/ DESTINATION bin/web)
# JhLab Windows 协议网关
**Visual Studio 2022 + MSVC x64 + C++17**
统一承载:
- Modbus TCP 多主机采集 / 写(`.102` / `.103` / `.104`)
- 海康 HCNetSDK(门禁 / 摄像)
对外 HTTP API 见仓库 [`docs/gateway-api.md`](../../docs/gateway-api.md)。
## 目录
```
win-protocol/
JhLabGateway.sln # VS 解决方案(可用 CMake 生成或直接打开)
CMakeLists.txt
config.json
src/main.cpp # HTTP 骨架(健康 / 点位 reload / snapshot)
third_party/httplib.h # cpp-httplib(单头文件)
```
## 构建(Windows)
### 推荐:VS2022 + MSVC(后续接 HCNetSDK 必须)
```bat
cmake -S . -B build -G "Visual Studio 17 2022" -A x64
cmake --build build --config Release
```
### 当前采集机(192.168.32.121)暂无 MSVC,用 MSYS2 MinGW 编译脚手架
机器上已部署:`C:\jh-lab\gateway\win-protocol\`
```bat
C:\jh-lab\gateway\win-protocol\build-mingw.bat
```
产物:`C:\jh-lab\gateway\win-protocol\build\JhLabGateway.exe`
> 接入海康 HCNetSDK 前请安装 **Visual Studio 2022 Build Tools(MSVC x64)**,再用 MSVC 重编。
### 运行
```bat
cd C:\jh-lab\gateway\win-protocol\build
JhLabGateway.exe config.json
```
已加防火墙入站规则 `JhLabGateway 38080`;可用计划任务 `JhLabGateway`(登录时启动)。
默认监听 `http://0.0.0.0:38080`。平台 `.env`:`GATEWAY_URL=http://192.168.32.121:38080`。
### 管理界面
浏览器打开:**http://192.168.32.121:38080/ui/**
- 登录(默认 `admin` / `Admin@123`)
- 网络/IP、设备列表、设备配置、摄像头预览
静态资源目录:`web/`(构建后复制到 `build/web/`)。
## 与平台联调
1. 平台 `.env`:`GATEWAY_URL=http://<本机IP>:38080`,`DATA_MODE=gateway`
2. 后台「设备管理」导入点位 →「下发网关」
3. `GET /health`、`GET /snapshot` 验证
当前脚手架为 **Mock 点值**,Modbus/HCNetSDK 实连在后续迭代接入。
@echo off
setlocal
set PATH=C:\msys64\ucrt64\bin;C:\Program Files\CMake\bin;%PATH%
cd /d C:\jh-lab\gateway\win-protocol
if exist build rmdir /s /q build
echo === cmake configure ===
cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=C:/msys64/ucrt64/bin/g++.exe -DCMAKE_C_COMPILER=C:/msys64/ucrt64/bin/gcc.exe
if errorlevel 1 exit /b 1
echo === cmake build ===
cmake --build build -j 4
if errorlevel 1 exit /b 1
echo === result ===
dir /b build\JhLabGateway.exe
copy /Y config.json build\config.json >nul
echo DONE
endlocal
{
"listen": "0.0.0.0",
"port": 38080,
"platformUrl": "http://192.168.32.122:3001",
"pollMs": 2000,
"adminUsername": "admin",
"adminPassword": "Admin@123",
"modbusHosts": [
{ "id": "mb-102", "name": "PAU-201-1", "host": "192.168.10.102", "port": 502, "enabled": true },
{ "id": "mb-103", "name": "计量/通风柜", "host": "192.168.10.103", "port": 502, "enabled": true },
{ "id": "mb-104", "name": "房间/冰箱", "host": "192.168.10.104", "port": 502, "enabled": true }
],
"nvr": {
"id": "nvr-140",
"name": "海康 NVR",
"host": "192.168.10.140",
"port": 8000,
"username": "admin",
"password": "",
"enabled": true
},
"cameras": [
{
"id": "cam-142",
"name": "收样咨询室",
"host": "192.168.10.142",
"port": 8000,
"username": "admin",
"password": "",
"channel": 1,
"enabled": true
},
{
"id": "cam-143",
"name": "样本制备前",
"host": "192.168.10.143",
"port": 8000,
"username": "admin",
"password": "",
"channel": 1,
"enabled": true
}
],
"doors": [
{
"id": "door-120",
"name": "咨询室门禁",
"host": "192.168.10.120",
"port": 8000,
"username": "admin",
"password": "",
"channel": 1,
"enabled": true
}
]
}
@echo off
REM Wrapper so working directory is correct
> C:\jh-lab\gateway\win-protocol\run-hidden.bat echo @echo off
>>C:\jh-lab\gateway\win-protocol\run-hidden.bat echo cd /d C:\jh-lab\gateway\win-protocol\build
>>C:\jh-lab\gateway\win-protocol\run-hidden.bat echo start "" /B JhLabGateway.exe config.json
schtasks /Delete /TN JhLabGateway /F >nul 2>nul
schtasks /Create /TN JhLabGateway /SC ONLOGON /RL LIMITED /F /TR "C:\jh-lab\gateway\win-protocol\run-hidden.bat"
schtasks /Query /TN JhLabGateway
echo.
echo To start now: schtasks /Run /TN JhLabGateway
$ErrorActionPreference = 'Stop'
$dir = 'C:\jh-lab\gateway\win-protocol\build'
$exe = Join-Path $dir 'JhLabGateway.exe'
$cfgSrc = 'C:\jh-lab\gateway\win-protocol\config.json'
Copy-Item -Force $cfgSrc (Join-Path $dir 'config.json')
Get-Process JhLabGateway -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Milliseconds 500
$p = Start-Process -FilePath $exe -ArgumentList 'config.json' `
-WorkingDirectory $dir `
-RedirectStandardOutput (Join-Path $dir 'out.log') `
-RedirectStandardError (Join-Path $dir 'err.log') `
-PassThru -WindowStyle Hidden
Start-Sleep -Seconds 2
Write-Output ("pid=" + $p.Id)
Write-Output '--- out.log ---'
Get-Content (Join-Path $dir 'out.log') -ErrorAction SilentlyContinue
Write-Output '--- err.log ---'
Get-Content (Join-Path $dir 'err.log') -ErrorAction SilentlyContinue
Write-Output '--- netstat ---'
netstat -ano | findstr 38080
This source diff could not be displayed because it is too large. You can view the blob instead.
:root {
--bg: #0b1220;
--panel: #121a2b;
--line: rgba(120, 180, 255, 0.22);
--text: #e8eefc;
--muted: #8fa3c4;
--accent: #3d8bfd;
--ok: #3dd68c;
--warn: #f5a524;
--danger: #f31260;
--radius: 10px;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: radial-gradient(1200px 600px at 10% -10%, #1a2744, var(--bg));
color: var(--text);
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
.wrap { max-width: 1180px; margin: 0 auto; padding: 20px; }
.top {
display: flex; align-items: center; gap: 16px; margin-bottom: 18px;
padding-bottom: 14px; border-bottom: 1px solid var(--line);
}
.brand { font-weight: 700; letter-spacing: 0.02em; font-size: 18px; }
.brand small { display: block; color: var(--muted); font-weight: 400; font-size: 12px; margin-top: 2px; }
.nav { display: flex; flex-wrap: wrap; gap: 8px; flex: 1; }
.nav button, .btn {
background: rgba(61, 139, 253, 0.15);
border: 1px solid var(--line);
color: var(--text);
border-radius: 999px;
padding: 7px 14px;
cursor: pointer;
font-size: 13px;
}
.nav button.active, .btn.primary {
background: var(--accent);
border-color: transparent;
color: #fff;
}
.btn.ghost { background: transparent; }
.btn.danger { background: rgba(243, 18, 96, 0.15); border-color: rgba(243, 18, 96, 0.4); color: #ffb4cb; }
.panel {
background: rgba(18, 26, 43, 0.92);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px 18px;
margin-bottom: 14px;
}
.panel h2 { margin: 0 0 12px; font-size: 16px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 12px; color: var(--muted); }
.field input, .field select, .field textarea {
background: #0a101c;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--text);
padding: 8px 10px;
font-size: 13px;
}
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th, .table td {
border-bottom: 1px solid var(--line);
padding: 9px 8px;
text-align: left;
vertical-align: middle;
}
.table th { color: var(--muted); font-weight: 600; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
border: 1px solid var(--line);
}
.badge.ok { color: var(--ok); border-color: rgba(61, 214, 140, 0.35); }
.badge.off { color: var(--muted); }
.msg { font-size: 13px; margin: 8px 0; }
.msg.ok { color: var(--ok); }
.msg.err { color: #ff8eab; }
.login-box {
max-width: 380px; margin: 12vh auto; padding: 28px;
background: rgba(18, 26, 43, 0.95);
border: 1px solid var(--line);
border-radius: 14px;
}
.login-box h1 { margin: 0 0 6px; font-size: 22px; }
.login-box p { margin: 0 0 18px; color: var(--muted); font-size: 13px; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
.preview-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
.preview-card {
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
background: #070c16;
}
.preview-card img {
width: 100%;
aspect-ratio: 16/10;
object-fit: cover;
background: #000;
display: block;
}
.preview-card .meta { padding: 10px 12px; font-size: 13px; }
.preview-card .meta strong { display: block; margin-bottom: 4px; }
.hidden { display: none !important; }
.row-ops { display: flex; gap: 6px; }
@media (max-width: 720px) {
.top { flex-direction: column; align-items: flex-start; }
}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理台 · JhLab 协议网关</title>
<link rel="stylesheet" href="/ui/app.css" />
</head>
<body>
<div class="wrap">
<header class="top">
<div class="brand">JhLab 协议网关<small id="verLabel">加载中…</small></div>
<nav class="nav" id="nav">
<button type="button" data-page="overview" class="active">总览</button>
<button type="button" data-page="network">网络/IP</button>
<button type="button" data-page="devices">设备列表</button>
<button type="button" data-page="deviceEdit">设备配置</button>
<button type="button" data-page="cameras">摄像头预览</button>
</nav>
<button class="btn ghost" type="button" id="btnLogout">退出</button>
</header>
<p class="msg" id="banner"></p>
<section class="panel" id="page-overview">
<h2>运行总览</h2>
<div class="grid" id="healthCards"></div>
<div class="actions">
<button class="btn" type="button" id="btnRefreshHealth">刷新状态</button>
<a class="btn ghost" href="/health" target="_blank">打开 /health</a>
</div>
</section>
<section class="panel hidden" id="page-network">
<h2>网络与平台配置</h2>
<div class="grid">
<div class="field"><label>监听地址</label><input id="cfgListen" /></div>
<div class="field"><label>服务端口</label><input id="cfgPort" type="number" /></div>
<div class="field"><label>平台 URL</label><input id="cfgPlatform" /></div>
<div class="field"><label>采集周期 ms</label><input id="cfgPoll" type="number" /></div>
</div>
<h2 style="margin-top:18px">Modbus TCP 主机</h2>
<table class="table" id="modbusTable">
<thead><tr><th>ID</th><th>名称</th><th>IP</th><th>端口</th><th>启用</th><th></th></tr></thead>
<tbody></tbody>
</table>
<div class="actions">
<button class="btn" type="button" id="btnAddModbus">添加主机</button>
<button class="btn primary" type="button" id="btnSaveNetwork">保存配置</button>
</div>
</section>
<section class="panel hidden" id="page-devices">
<h2>设备列表</h2>
<table class="table" id="deviceTable">
<thead>
<tr><th>类型</th><th>名称</th><th>地址</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody></tbody>
</table>
<div class="actions">
<button class="btn" type="button" id="btnReloadDevices">刷新</button>
</div>
</section>
<section class="panel hidden" id="page-deviceEdit">
<h2>设备配置</h2>
<div class="grid">
<div class="field">
<label>类型</label>
<select id="devType">
<option value="camera">摄像头</option>
<option value="door">门禁</option>
<option value="nvr">NVR</option>
</select>
</div>
<div class="field"><label>ID</label><input id="devId" placeholder="cam-142" /></div>
<div class="field"><label>名称</label><input id="devName" /></div>
<div class="field"><label>IP</label><input id="devHost" placeholder="192.168.10.142" /></div>
<div class="field"><label>端口</label><input id="devPort" type="number" value="8000" /></div>
<div class="field"><label>用户名</label><input id="devUser" value="admin" /></div>
<div class="field"><label>密码</label><input id="devPass" type="password" /></div>
<div class="field"><label>通道号</label><input id="devChannel" type="number" value="1" /></div>
<div class="field">
<label>启用</label>
<select id="devEnabled"><option value="true">是</option><option value="false">否</option></select>
</div>
</div>
<div class="actions">
<button class="btn primary" type="button" id="btnSaveDevice">保存设备</button>
<button class="btn danger" type="button" id="btnDeleteDevice">删除</button>
</div>
<p class="msg" id="devMsg"></p>
</section>
<section class="panel hidden" id="page-cameras">
<h2>摄像头预览</h2>
<p style="color:var(--muted);font-size:13px;margin-top:0">
当前为网关 JPEG 抓图通道(HCNetSDK 接入前显示占位图;接入后自动换成实况截帧)。
</p>
<div class="preview-grid" id="previewGrid"></div>
<div class="actions">
<button class="btn" type="button" id="btnRefreshPreview">刷新预览</button>
</div>
</section>
</div>
<script src="/ui/app.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登录 · JhLab 协议网关</title>
<link rel="stylesheet" href="/ui/app.css" />
</head>
<body>
<div class="login-box">
<h1>JhLab 协议网关</h1>
<p>Modbus + 海康采集服务 · 管理登录</p>
<div class="field">
<label>用户名</label>
<input id="user" value="admin" autocomplete="username" />
</div>
<div class="field" style="margin-top:10px">
<label>密码</label>
<input id="pass" type="password" autocomplete="current-password" />
</div>
<div class="actions">
<button class="btn primary" id="btnLogin" type="button">登录</button>
</div>
<p class="msg err" id="err"></p>
</div>
<script>
if (localStorage.getItem('gw_token')) location.replace('/ui/');
const err = document.getElementById('err');
document.getElementById('btnLogin').onclick = async () => {
err.textContent = '';
try {
const res = await fetch('/admin/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('user').value.trim(),
password: document.getElementById('pass').value,
}),
});
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.message || '登录失败');
localStorage.setItem('gw_token', data.token);
localStorage.setItem('gw_user', data.username || 'admin');
location.replace('/ui/');
} catch (e) {
err.textContent = e.message || String(e);
}
};
document.getElementById('pass').addEventListener('keydown', (e) => {
if (e.key === 'Enter') document.getElementById('btnLogin').click();
});
</script>
</body>
</html>
...@@ -4243,6 +4243,7 @@ ...@@ -4243,6 +4243,7 @@
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.2", "express": "^4.21.2",
"iconv-lite": "^0.6.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"modbus-serial": "^8.0.23", "modbus-serial": "^8.0.23",
"pg": "^8.23.1", "pg": "^8.23.1",
...@@ -4259,6 +4260,18 @@ ...@@ -4259,6 +4260,18 @@
"typescript": "^5.8.2" "typescript": "^5.8.2"
} }
}, },
"server/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"web": { "web": {
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
......
...@@ -18,5 +18,11 @@ ...@@ -18,5 +18,11 @@
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
},
"allowScripts": {
"esbuild@0.28.2": true,
"esbuild@0.25.12": true,
"fsevents@2.3.3": true,
"@serialport/bindings-cpp@13.0.0": true
} }
} }
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.2", "express": "^4.21.2",
"iconv-lite": "^0.6.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"modbus-serial": "^8.0.23", "modbus-serial": "^8.0.23",
"pg": "^8.23.1", "pg": "^8.23.1",
......
...@@ -7,17 +7,19 @@ export const config = { ...@@ -7,17 +7,19 @@ export const config = {
port: Number(process.env.PORT || 3001), port: Number(process.env.PORT || 3001),
jwtSecret: process.env.JWT_SECRET || 'jh-lab-dev-secret', jwtSecret: process.env.JWT_SECRET || 'jh-lab-dev-secret',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '12h', jwtExpiresIn: process.env.JWT_EXPIRES_IN || '12h',
dataMode: (process.env.DATA_MODE || 'mock') as 'mock' | 'modbus', dataMode: (process.env.DATA_MODE || 'mock') as 'mock' | 'modbus' | 'gateway',
databaseUrl: databaseUrl:
process.env.DATABASE_URL || process.env.DATABASE_URL ||
'postgres://jh_lab:jh_lab_pass@127.0.0.1:5432/jh_lab', 'postgres://jh_lab:jh_lab_pass@127.0.0.1:5432/jh_lab',
/** 采集明细保留天数,0 表示不清理 */ /** 采集明细保留天数,0 表示不清理 */
collectRetentionDays: Number(process.env.COLLECT_RETENTION_DAYS || 90), collectRetentionDays: Number(process.env.COLLECT_RETENTION_DAYS || 90),
/** 单一 Windows 协议网关 */
gatewayUrl: (process.env.GATEWAY_URL || '').replace(/\/$/, ''),
modbus: { modbus: {
pau1: process.env.MODBUS_PAU1_HOST || '192.168.10.102', pau1: process.env.MODBUS_PAU1_HOST || '192.168.10.102',
pau2: process.env.MODBUS_PAU2_HOST || '192.168.10.103', pau2: process.env.MODBUS_PAU2_HOST || '192.168.10.103',
room: process.env.MODBUS_ROOM_HOST || '192.168.10.104', room: process.env.MODBUS_ROOM_HOST || '192.168.10.104',
fridge: process.env.MODBUS_FRIDGE_HOST || '192.168.10.105', fridge: process.env.MODBUS_FRIDGE_HOST || '192.168.10.104',
port: Number(process.env.MODBUS_PORT || 502), port: Number(process.env.MODBUS_PORT || 502),
pollMs: Number(process.env.MODBUS_POLL_MS || 2000), pollMs: Number(process.env.MODBUS_POLL_MS || 2000),
}, },
...@@ -28,4 +30,12 @@ export const config = { ...@@ -28,4 +30,12 @@ export const config = {
usersFile: path.resolve(__dirname, '../data/users.json'), usersFile: path.resolve(__dirname, '../data/users.json'),
videoFile: path.resolve(__dirname, '../data/video.json'), videoFile: path.resolve(__dirname, '../data/video.json'),
lightFile: path.resolve(__dirname, '../data/light.json'), lightFile: path.resolve(__dirname, '../data/light.json'),
devicesFile: path.resolve(__dirname, '../data/devices.json'),
/** 现场协议 CSV 目录(默认:仓库旁「设备对接」协议包) */
devicePointsDir: process.env.DEVICE_POINTS_DIR
? path.resolve(process.env.DEVICE_POINTS_DIR)
: path.resolve(
__dirname,
'../../../../设备对接/澳门镜湖医院协议20261019/澳门镜湖医院协议20261019',
),
} }
...@@ -69,6 +69,77 @@ CREATE TABLE IF NOT EXISTS system_settings ( ...@@ -69,6 +69,77 @@ CREATE TABLE IF NOT EXISTS system_settings (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system' updated_by TEXT NOT NULL DEFAULT 'system'
); );
CREATE TABLE IF NOT EXISTS device_meta (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
points_version INT NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS gateways (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('modbus_host', 'hik_device')),
host TEXT NOT NULL,
port INT NOT NULL DEFAULT 502,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
last_ok_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
gateway_id TEXT REFERENCES gateways(id) ON DELETE SET NULL,
category TEXT NOT NULL,
name TEXT NOT NULL,
code TEXT NOT NULL UNIQUE,
bind_ui TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'unknown',
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_devices_gateway ON devices (gateway_id);
CREATE INDEX IF NOT EXISTS idx_devices_category ON devices (category);
CREATE TABLE IF NOT EXISTS device_points (
id UUID PRIMARY KEY,
gateway_id TEXT NOT NULL REFERENCES gateways(id) ON DELETE CASCADE,
device_id TEXT REFERENCES devices(id) ON DELETE SET NULL,
name TEXT NOT NULL,
reg_type TEXT NOT NULL DEFAULT '',
reg_address INT NOT NULL DEFAULT 0,
data_type TEXT NOT NULL DEFAULT 'Short',
modbus_id INT NOT NULL DEFAULT 1,
modbus_register TEXT NOT NULL DEFAULT '4X(Holding Register)',
modbus_address INT NOT NULL,
description TEXT NOT NULL DEFAULT '',
mask_bit INT NOT NULL DEFAULT 0,
bit INT NOT NULL DEFAULT 0,
scale DOUBLE PRECISION NOT NULL DEFAULT 0,
bias DOUBLE PRECISION NOT NULL DEFAULT 0,
min_raw_range DOUBLE PRECISION NOT NULL DEFAULT 0,
max_raw_range DOUBLE PRECISION NOT NULL DEFAULT 0,
min_eng_range DOUBLE PRECISION NOT NULL DEFAULT 0,
max_eng_range DOUBLE PRECISION NOT NULL DEFAULT 0,
item_id TEXT NOT NULL,
snapshot_path TEXT NOT NULL DEFAULT '',
rw TEXT NOT NULL DEFAULT 'r',
energy_kind TEXT,
scale_override DOUBLE PRECISION,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_raw DOUBLE PRECISION,
last_eng DOUBLE PRECISION,
last_ts TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_device_points_item ON device_points (item_id);
CREATE INDEX IF NOT EXISTS idx_device_points_gateway ON device_points (gateway_id, modbus_address);
CREATE INDEX IF NOT EXISTS idx_device_points_device ON device_points (device_id);
` `
export async function migrate() { export async function migrate() {
......
import iconv from 'iconv-lite'
import type { DevicePoint } from './types.js'
const HEADERS = [
'id',
'Name',
'RegType',
'RegAddress',
'DataType',
'Modbus ID',
'Modbus Register',
'Modbus Address',
'Description',
'MaskBit',
'Bit',
'Scale',
'Bias',
'MinRawRange',
'MaxRawRange',
'MinEngRange',
'MaxEngRange',
'ItemID',
] as const
function splitCsvLine(line: string): string[] {
const out: string[] = []
let cur = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const ch = line[i]
if (inQuotes) {
if (ch === '"' && line[i + 1] === '"') {
cur += '"'
i++
} else if (ch === '"') {
inQuotes = false
} else {
cur += ch
}
} else if (ch === '"') {
inQuotes = true
} else if (ch === ',') {
out.push(cur)
cur = ''
} else {
cur += ch
}
}
out.push(cur)
return out
}
function num(v: string | undefined, fallback = 0): number {
if (v === undefined || v === '') return fallback
const n = Number(v)
return Number.isFinite(n) ? n : fallback
}
/** 从可能错位的列中挑出真实 ItemID(优先 RTU.*) */
function pickItemId(cols: string[], iItem: number, headerLen: number): string {
const candidates: string[] = []
if (iItem >= 0) {
const v = (cols[iItem] || '').trim()
if (v) candidates.push(v)
}
const start = Math.max(0, Math.min(iItem >= 0 ? iItem : headerLen - 1, cols.length - 1))
for (let i = start; i < cols.length; i++) {
const v = (cols[i] || '').trim()
if (v) candidates.push(v)
}
const rtu = candidates.find((c) => /^RTU\./i.test(c))
if (rtu) return rtu
const nonNumeric = candidates.filter((c) => c && !/^\d+(\.\d+)?$/.test(c) && c !== '0')
if (nonNumeric.length) {
return nonNumeric.sort((a, b) => b.length - a.length)[0]
}
return candidates[0] || ''
}
/** 探测并解码 CSV 文本 */
export function decodeCsvBuffer(buf: Buffer, encoding?: string): string {
const enc = (encoding || '').toLowerCase()
if (enc === 'utf-8' || enc === 'utf8') return buf.toString('utf8')
if (enc === 'gbk' || enc === 'gb2312' || enc === 'gb18030') {
return iconv.decode(buf, 'gbk')
}
// 自动:若含替换字符且 GBK 更干净则用 GBK
const asUtf = buf.toString('utf8')
if (!asUtf.includes('\uFFFD') && !/[\x80-\xff]/.test(asUtf.slice(0, 200))) {
return asUtf
}
const asGbk = iconv.decode(buf, 'gbk')
if (asUtf.includes('\uFFFD') || /[\u0080-\u00ff]{3,}/.test(asUtf)) {
return asGbk
}
return asUtf
}
export interface ParsedPointRow {
name: string
regType: string
regAddress: number
dataType: string
modbusId: number
modbusRegister: string
modbusAddress: number
description: string
maskBit: number
bit: number
scale: number
bias: number
minRawRange: number
maxRawRange: number
minEngRange: number
maxEngRange: number
itemId: string
}
export function parsePointsCsv(text: string): ParsedPointRow[] {
const lines = text
.replace(/^\uFEFF/, '')
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0)
if (lines.length < 2) return []
const header = splitCsvLine(lines[0]).map((h) => h.trim())
const idx = (name: string) => header.findIndex((h) => h.toLowerCase() === name.toLowerCase())
const iName = idx('Name')
const iRegType = idx('RegType')
const iRegAddress = idx('RegAddress')
const iDataType = idx('DataType')
const iModbusId = idx('Modbus ID') >= 0 ? idx('Modbus ID') : idx('ModbusID')
const iModbusRegister = idx('Modbus Register') >= 0 ? idx('Modbus Register') : idx('ModbusRegister')
const iModbusAddress = idx('Modbus Address') >= 0 ? idx('Modbus Address') : idx('ModbusAddress')
const iDesc = idx('Description')
const iMask = idx('MaskBit')
const iBit = idx('Bit')
const iScale = idx('Scale')
const iBias = idx('Bias')
const iMinRaw = idx('MinRawRange')
const iMaxRaw = idx('MaxRawRange')
const iMinEng = idx('MinEngRange')
const iMaxEng = idx('MaxEngRange')
const iItem = idx('ItemID')
if (iName < 0 || iModbusAddress < 0) {
throw new Error('CSV 缺少 Name 或 Modbus Address 列')
}
const rows: ParsedPointRow[] = []
for (let li = 1; li < lines.length; li++) {
const cols = splitCsvLine(lines[li])
// 现场导出常多 1–2 列(EnableFormula 空列/备注),ItemID 列位可能对不上
const itemId = pickItemId(cols, iItem, header.length)
const name = (cols[iName] || '').trim()
if (!name && !itemId) continue
rows.push({
name: name || itemId || `point-${li}`,
regType: (cols[iRegType] || '').trim(),
regAddress: num(cols[iRegAddress]),
dataType: (cols[iDataType] || 'Short').trim(),
modbusId: num(cols[iModbusId], 1),
modbusRegister: (cols[iModbusRegister] || '4X(Holding Register)').trim(),
modbusAddress: num(cols[iModbusAddress]),
description: (cols[iDesc] || '').trim(),
maskBit: num(cols[iMask]),
bit: num(cols[iBit]),
scale: num(cols[iScale]),
bias: num(cols[iBias]),
minRawRange: num(cols[iMinRaw]),
maxRawRange: num(cols[iMaxRaw]),
minEngRange: num(cols[iMinEng]),
maxEngRange: num(cols[iMaxEng]),
itemId: itemId || `${name}@${num(cols[iModbusAddress])}`,
})
}
return rows
}
function esc(v: string | number): string {
const s = String(v ?? '')
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`
return s
}
export function exportPointsCsv(points: DevicePoint[]): string {
const lines = [HEADERS.join(',')]
points.forEach((p, i) => {
lines.push(
[
i + 1,
p.name,
p.regType,
p.regAddress,
p.dataType,
p.modbusId,
p.modbusRegister,
p.modbusAddress,
p.description,
p.maskBit,
p.bit,
p.scale,
p.bias,
p.minRawRange,
p.maxRawRange,
p.minEngRange,
p.maxEngRange,
p.itemId,
]
.map(esc)
.join(','),
)
})
return lines.join('\r\n') + '\r\n'
}
export function encodeCsv(text: string, encoding: string): Buffer {
const enc = encoding.toLowerCase()
if (enc === 'gbk' || enc === 'gb2312' || enc === 'gb18030') {
return iconv.encode(text, 'gbk')
}
return Buffer.from(text, 'utf8')
}
/** 工程值换算 */
export function toEngineering(
raw: number,
scale: number,
bias: number,
scaleOverride: number | null,
minRaw: number,
maxRaw: number,
minEng: number,
maxEng: number,
): number {
const s = scaleOverride !== null && scaleOverride !== undefined ? scaleOverride : scale
if (s !== 0) return raw * s + bias
if (maxRaw !== minRaw) {
return minEng + ((raw - minRaw) * (maxEng - minEng)) / (maxRaw - minRaw) + bias
}
// 现场 Scale=0 常见温度默认 *0.1
return raw * 0.1 + bias
}
import fs from 'node:fs'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
import { decodeCsvBuffer, parsePointsCsv, type ParsedPointRow } from './csv.js'
import type { Device, DeviceCategory, DevicePoint, DevicesBundle, Gateway } from './types.js'
function now() {
return new Date().toISOString()
}
/** 解析协议目录:优先环境变量,否则相对仓库旁的「设备对接」 */
export function resolveProtocolDir(): string | null {
const fromEnv = (process.env.DEVICE_POINTS_DIR || '').trim()
if (fromEnv && fs.existsSync(fromEnv)) return path.resolve(fromEnv)
const fallback = config.devicePointsDir
if (fallback && fs.existsSync(fallback)) return fallback
return null
}
function walkCsvFiles(dir: string): string[] {
const out: string[] = []
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const e of entries) {
const full = path.join(dir, e.name)
if (e.isDirectory()) {
if (e.name.startsWith('.') || e.name === 'HCNetSDK' || /HCNetSDK/i.test(e.name)) continue
out.push(...walkCsvFiles(full))
} else if (e.isFile() && /\.csv$/i.test(e.name)) {
out.push(full)
}
}
return out.sort()
}
function gatewayIdFromFilename(name: string): string {
if (/192\.?168\.?10\.?102|19216810102/i.test(name)) return 'mb-102'
if (/192\.?168\.?10\.?103|19216810103/i.test(name)) return 'mb-103'
if (/192\.?168\.?10\.?104|19216810104/i.test(name)) return 'mb-104'
return 'mb-104'
}
function slugify(s: string): string {
return s
.trim()
.replace(/[\\/:*?"<>|]+/g, '')
.replace(/\s+/g, '-')
.replace(/_+/g, '_')
.slice(0, 80)
}
function categoryFromName(name: string, fileBase: string): DeviceCategory {
const t = `${name} ${fileBase}`
if (/冰箱|冷链|fridge/i.test(t)) return 'fridge'
if (/通风柜|VAV/i.test(t)) return 'vav'
if (/屏|压差|房间屏/i.test(t)) return 'room'
if (/流量计|水/i.test(t)) return 'meter_water'
if (/能量计|冷量|DN\d+/i.test(t)) return 'meter_cooling'
if (/电表|N电|E电|电能/i.test(t)) return 'meter_power'
if (/PAU|机组|除湿|排风|空调/i.test(t)) return 'pau'
if (/PLC|控制器/i.test(t)) return 'other'
return 'other'
}
function bindUiFromCategory(c: DeviceCategory): string {
switch (c) {
case 'fridge':
return 'cold'
case 'vav':
return 'vav'
case 'room':
return 'rooms'
case 'meter_power':
case 'meter_water':
case 'meter_cooling':
return 'billing'
case 'pau':
return 'hvac'
default:
return ''
}
}
/** 从 ItemID 取设备键:去掉最后一段测点名 */
export function deviceKeyFromItemId(itemId: string, fileBase: string): { key: string; name: string } {
const id = (itemId || '').trim()
if (id && !/^\d+(\.\d+)?$/.test(id)) {
// RTU.COM3.1_试剂准备间冰箱1.冰箱温度 → 试剂准备间冰箱1
const mNum = id.match(/RTU\.[^.]+\.(\d+)_([^.]+)\.(.+)$/i)
if (mNum) {
return { key: `rtu-${mNum[1]}-${slugify(mNum[2])}`, name: mNum[2] }
}
// RTU.COM2.2ATN01_DB.相电压UA → 2ATN01_DB / N电电表
const mPlain = id.match(/RTU\.[^.]+\.([^.]+)\.(.+)$/i)
if (mPlain) {
const mid = mPlain[1]
// 屏:收样咨询室;电表:2ATN01_DB;能量计_DN80 等
const pretty = mid.includes('_') && !/^\d/.test(mid) ? mid.split('_').slice(-1)[0] || mid : mid
return { key: slugify(mid).toLowerCase(), name: pretty || mid }
}
const parts = id.split('.')
if (parts.length >= 2) {
const mid = parts[parts.length - 2]
return { key: slugify(mid).toLowerCase(), name: mid }
}
}
const name = fileBase.replace(/\.csv$/i, '').replace(/^19216810\d{3}[-_\s]*/, '').trim() || fileBase
return { key: slugify(name).toLowerCase() || 'device', name }
}
function defaultGateways(): Gateway[] {
const t = now()
return [
{
id: 'mb-102',
name: 'PAU-201-1 机组',
kind: 'modbus_host',
host: config.modbus.pau1,
port: config.modbus.port,
enabled: true,
meta: { role: 'pau' },
lastOkAt: null,
createdAt: t,
updatedAt: t,
},
{
id: 'mb-103',
name: 'PAU-201-2 计量/通风柜',
kind: 'modbus_host',
host: config.modbus.pau2,
port: config.modbus.port,
enabled: true,
meta: { role: 'meter_vav' },
lastOkAt: null,
createdAt: t,
updatedAt: t,
},
{
id: 'mb-104',
name: '房间屏/PLC/冰箱/N·E电',
kind: 'modbus_host',
host: config.modbus.room,
port: config.modbus.port,
enabled: true,
meta: { role: 'room' },
lastOkAt: null,
createdAt: t,
updatedAt: t,
},
]
}
function pointFromRow(
row: ParsedPointRow,
gatewayId: string,
deviceId: string,
t: string,
): DevicePoint {
const needScale =
row.scale === 0 &&
/温度|湿度|压差|风速|流量|电压|电流|功率|能量/.test(row.name)
return {
id: randomUUID(),
gatewayId,
deviceId,
name: row.name,
regType: row.regType,
regAddress: row.regAddress,
dataType: row.dataType,
modbusId: row.modbusId,
modbusRegister: row.modbusRegister,
modbusAddress: row.modbusAddress,
description: row.description,
maskBit: row.maskBit,
bit: row.bit,
scale: row.scale,
bias: row.bias,
minRawRange: row.minRawRange,
maxRawRange: row.maxRawRange,
minEngRange: row.minEngRange,
maxEngRange: row.maxEngRange,
itemId: row.itemId,
snapshotPath: '',
rw: 'r',
energyKind: /电表|电能|电压|电流|功率|有功/.test(row.name)
? 'power'
: /流量计|水/.test(row.name)
? 'water'
: /能量计|冷量/.test(row.name)
? 'cooling'
: null,
scaleOverride: needScale ? 0.1 : row.scale === 0 ? 0.1 : null,
enabled: true,
lastRaw: null,
lastEng: null,
lastTs: null,
createdAt: t,
updatedAt: t,
}
}
/** 从设备对接目录全部 CSV 构建 gateways / devices / points */
export function buildBundleFromProtocolDir(dir: string): DevicesBundle {
const t = now()
const gateways = defaultGateways()
const deviceMap = new Map<string, Device>()
const points: DevicePoint[] = []
const files = walkCsvFiles(dir)
if (!files.length) {
throw new Error(`协议目录无 CSV:${dir}`)
}
for (const file of files) {
const fileBase = path.basename(file)
const fileTitle = fileBase
.replace(/\.csv$/i, '')
.replace(/^19216810\d{3}[-_\s]*/, '')
.trim()
const gatewayId = gatewayIdFromFilename(fileBase)
const text = decodeCsvBuffer(fs.readFileSync(file))
let rows: ParsedPointRow[]
try {
rows = parsePointsCsv(text)
} catch (e) {
console.warn(`[devices] skip CSV ${fileBase}:`, e instanceof Error ? e.message : e)
continue
}
if (!rows.length) continue
// 先按 ItemID 归组;单组文件用 CSV 文件名作设备名,多组(冰箱/屏/回路)用 ItemID 名
const groups = new Map<string, { name: string; rows: ParsedPointRow[] }>()
for (const row of rows) {
const { key, name } = deviceKeyFromItemId(row.itemId, fileBase)
const g = groups.get(key)
if (g) g.rows.push(row)
else groups.set(key, { name, rows: [row] })
}
const multi = groups.size > 1
for (const [key, group] of groups) {
const name = multi ? group.name : fileTitle || group.name
const deviceId = `${gatewayId}-${key}`
if (!deviceMap.has(deviceId)) {
const category = categoryFromName(name, fileBase)
deviceMap.set(deviceId, {
id: deviceId,
gatewayId,
category,
name,
code: deviceId,
bindUi: bindUiFromCategory(category),
status: 'unknown',
meta: { sourceCsv: fileBase, sourceDir: path.relative(dir, file) },
enabled: true,
createdAt: t,
updatedAt: t,
})
}
for (const row of group.rows) {
points.push(pointFromRow(row, gatewayId, deviceId, t))
}
}
}
const devices = [...deviceMap.values()].sort((a, b) =>
`${a.gatewayId}-${a.name}`.localeCompare(`${b.gatewayId}-${b.name}`, 'zh'),
)
console.log(
`[devices] protocol CSV → ${files.length} files, ${devices.length} devices, ${points.length} points (${dir})`,
)
return { gateways, devices, points, pointsVersion: 1 }
}
export type GatewayKind = 'modbus_host' | 'hik_device'
export type DeviceCategory =
| 'pau'
| 'vav'
| 'room'
| 'fridge'
| 'meter_power'
| 'meter_water'
| 'meter_cooling'
| 'light'
| 'door'
| 'camera'
| 'nvr'
| 'other'
export type PointRw = 'r' | 'w' | 'rw'
export type EnergyKind = 'power' | 'water' | 'cooling' | null
export interface Gateway {
id: string
name: string
kind: GatewayKind
host: string
port: number
enabled: boolean
meta: Record<string, unknown>
lastOkAt: string | null
createdAt: string
updatedAt: string
}
export interface Device {
id: string
gatewayId: string | null
category: DeviceCategory
name: string
code: string
bindUi: string
status: 'unknown' | 'online' | 'offline' | 'alarm'
meta: Record<string, unknown>
enabled: boolean
createdAt: string
updatedAt: string
}
export interface DevicePoint {
id: string
gatewayId: string
deviceId: string | null
name: string
regType: string
regAddress: number
dataType: string
modbusId: number
modbusRegister: string
modbusAddress: number
description: string
maskBit: number
bit: number
scale: number
bias: number
minRawRange: number
maxRawRange: number
minEngRange: number
maxEngRange: number
itemId: string
snapshotPath: string
rw: PointRw
energyKind: EnergyKind
scaleOverride: number | null
enabled: boolean
lastRaw: number | null
lastEng: number | null
lastTs: string | null
createdAt: string
updatedAt: string
}
export interface DevicesBundle {
gateways: Gateway[]
devices: Device[]
points: DevicePoint[]
pointsVersion: number
}
import { config } from '../config.js'
import { getBundleForGateway } from '../devices/store.js'
export function resolveGatewayBase(): string {
return (config.gatewayUrl || config.accessBridgeUrl || '').replace(/\/$/, '')
}
export async function fetchGatewayHealth(): Promise<{
ok: boolean
raw?: unknown
message?: string
}> {
const base = resolveGatewayBase()
if (!base) return { ok: false, message: '未配置 GATEWAY_URL' }
try {
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(5000) })
const raw = await res.json().catch(() => ({}))
return { ok: res.ok, raw }
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : '网关不可达' }
}
}
export async function fetchGatewaySnapshot(): Promise<unknown | null> {
const base = resolveGatewayBase()
if (!base) return null
try {
const res = await fetch(`${base}/snapshot`, { signal: AbortSignal.timeout(8000) })
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
/** 将平台点位全量下发到 Windows 网关 */
export async function syncPointsToGateway(): Promise<{
ok: boolean
message: string
pointsVersion?: number
}> {
const base = resolveGatewayBase()
if (!base) {
return { ok: false, message: '未配置 GATEWAY_URL,已跳过下发(点位仅保存在平台)' }
}
const bundle = await getBundleForGateway()
try {
const res = await fetch(`${base}/config/points/reload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bundle),
signal: AbortSignal.timeout(15000),
})
const raw = (await res.json().catch(() => ({}))) as { message?: string }
if (!res.ok) {
return { ok: false, message: raw.message || `网关返回 HTTP ${res.status}` }
}
return {
ok: true,
message: '点位已下发到 Windows 网关',
pointsVersion: bundle.pointsVersion,
}
} catch (e) {
return {
ok: false,
message: e instanceof Error ? `下发失败:${e.message}` : '下发失败',
pointsVersion: bundle.pointsVersion,
}
}
}
export async function gatewayModbusWrite(body: unknown): Promise<{ ok: boolean; message: string }> {
const base = resolveGatewayBase()
if (!base) return { ok: false, message: '未配置 GATEWAY_URL' }
try {
const res = await fetch(`${base}/modbus/write`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
})
const raw = (await res.json().catch(() => ({}))) as { message?: string }
if (!res.ok) return { ok: false, message: raw.message || `HTTP ${res.status}` }
return { ok: true, message: 'ok' }
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : '写失败' }
}
}
...@@ -11,6 +11,7 @@ import { ensureBillingSeed } from './billing/store.js' ...@@ -11,6 +11,7 @@ import { ensureBillingSeed } from './billing/store.js'
import { ensureSettingsSeed } from './settings/store.js' import { ensureSettingsSeed } from './settings/store.js'
import { ensureVideoSeed } from './video/store.js' import { ensureVideoSeed } from './video/store.js'
import { ensureLightSeed } from './light/store.js' import { ensureLightSeed } from './light/store.js'
import { ensureDevicesSeed } from './devices/store.js'
import { authRouter } from './routes/auth.js' import { authRouter } from './routes/auth.js'
import { usersRouter } from './routes/users.js' import { usersRouter } from './routes/users.js'
import { realtimeRouter } from './routes/realtime.js' import { realtimeRouter } from './routes/realtime.js'
...@@ -18,6 +19,7 @@ import { billingRouter } from './routes/billing.js' ...@@ -18,6 +19,7 @@ import { billingRouter } from './routes/billing.js'
import { settingsRouter } from './routes/settings.js' import { settingsRouter } from './routes/settings.js'
import { videoRouter } from './routes/video.js' import { videoRouter } from './routes/video.js'
import { lightRouter } from './routes/light.js' import { lightRouter } from './routes/light.js'
import { devicesRouter } from './routes/devices.js'
import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js' import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js'
async function bootstrap() { async function bootstrap() {
...@@ -40,16 +42,18 @@ async function bootstrap() { ...@@ -40,16 +42,18 @@ async function bootstrap() {
await ensureSettingsSeed() await ensureSettingsSeed()
ensureVideoSeed() ensureVideoSeed()
ensureLightSeed() ensureLightSeed()
await ensureDevicesSeed()
const app = express() const app = express()
app.use(cors()) app.use(cors())
app.use(express.json({ limit: '2mb' })) app.use(express.json({ limit: '8mb' }))
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ res.json({
ok: true, ok: true,
mode: config.dataMode, mode: config.dataMode,
database: pgOk ? 'postgres' : 'fallback', database: pgOk ? 'postgres' : 'fallback',
gatewayUrl: config.gatewayUrl || null,
viewportHint: 'glb|ue-stream configured on web', viewportHint: 'glb|ue-stream configured on web',
}) })
}) })
...@@ -61,6 +65,7 @@ async function bootstrap() { ...@@ -61,6 +65,7 @@ async function bootstrap() {
app.use('/api/settings', settingsRouter) app.use('/api/settings', settingsRouter)
app.use('/api/video', videoRouter) app.use('/api/video', videoRouter)
app.use('/api/light', lightRouter) app.use('/api/light', lightRouter)
app.use('/api/devices', devicesRouter)
const server = http.createServer(app) const server = http.createServer(app)
const wss = new WebSocketServer({ server, path: '/ws' }) const wss = new WebSocketServer({ server, path: '/ws' })
......
...@@ -3,12 +3,14 @@ import type { RealtimeSnapshot } from '../types.js' ...@@ -3,12 +3,14 @@ import type { RealtimeSnapshot } from '../types.js'
import { config } from '../config.js' import { config } from '../config.js'
import { persistSnapshot, persistWriteLog, pruneCollectData } from '../db/collect.js' import { persistSnapshot, persistWriteLog, pruneCollectData } from '../db/collect.js'
import { getCollectPollMs } from '../settings/store.js' import { getCollectPollMs } from '../settings/store.js'
import { fetchGatewayHealth, fetchGatewaySnapshot, resolveGatewayBase } from '../gateway/client.js'
let snapshot: RealtimeSnapshot = generateSnapshot() let snapshot: RealtimeSnapshot = generateSnapshot()
const listeners = new Set<(s: RealtimeSnapshot) => void>() const listeners = new Set<(s: RealtimeSnapshot) => void>()
let persistBusy = false let persistBusy = false
let pollTimer: ReturnType<typeof setInterval> | undefined let pollTimer: ReturnType<typeof setInterval> | undefined
let currentPollMs = config.modbus.pollMs let currentPollMs = config.modbus.pollMs
let tickBusy = false
export function getSnapshot() { export function getSnapshot() {
return snapshot return snapshot
...@@ -37,16 +39,32 @@ async function persistSafe(snap: RealtimeSnapshot) { ...@@ -37,16 +39,32 @@ async function persistSafe(snap: RealtimeSnapshot) {
} }
} }
function tick() { function useGatewayMode() {
if (config.dataMode === 'mock') { return config.dataMode === 'gateway' || config.dataMode === 'modbus'
}
async function tick() {
if (tickBusy) return
tickBusy = true
try {
if (!useGatewayMode() || !resolveGatewayBase()) {
snapshot = generateSnapshot() snapshot = generateSnapshot()
if (useGatewayMode()) snapshot.online.gatewayOk = false
} else { } else {
// Modbus 实采二期接入:当前仍回落 mock,并标记网关状态 const [health, gwSnap] = await Promise.all([fetchGatewayHealth(), fetchGatewaySnapshot()])
// 网关快照合并进业务快照:一期先保留 mock 结构,标记网关连通;点值供设备页展示
snapshot = generateSnapshot() snapshot = generateSnapshot()
snapshot.online.gatewayOk = false snapshot.online.gatewayOk = health.ok
if (gwSnap && typeof gwSnap === 'object') {
const values = (gwSnap as { values?: unknown }).values
;(snapshot as RealtimeSnapshot & { gatewayTelemetry?: unknown }).gatewayTelemetry = values
}
} }
publish() publish()
void persistSafe(snapshot) void persistSafe(snapshot)
} finally {
tickBusy = false
}
} }
function startPollTimer(ms: number) { function startPollTimer(ms: number) {
......
...@@ -10,7 +10,7 @@ import { ...@@ -10,7 +10,7 @@ import {
export const billingRouter = Router() export const billingRouter = Router()
billingRouter.get('/tariff', authRequired, async (_req, res) => { billingRouter.get('/tariff', async (_req, res) => {
res.json(await getTariff()) res.json(await getTariff())
}) })
...@@ -49,11 +49,11 @@ billingRouter.put('/tariff', authRequired, requireRole('admin'), async (req, res ...@@ -49,11 +49,11 @@ billingRouter.put('/tariff', authRequired, requireRole('admin'), async (req, res
res.json(next) res.json(next)
}) })
billingRouter.get('/stats', authRequired, async (_req, res) => { billingRouter.get('/stats', async (_req, res) => {
res.json(await getStats()) res.json(await getStats())
}) })
billingRouter.get('/report', authRequired, async (req, res) => { billingRouter.get('/report', async (req, res) => {
const to = String(req.query.to || new Date().toISOString().slice(0, 10)) const to = String(req.query.to || new Date().toISOString().slice(0, 10))
let from = String(req.query.from || '') let from = String(req.query.from || '')
if (!from) { if (!from) {
......
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { encodeCsv } from '../devices/csv.js'
import {
createDevice,
createPoint,
deleteDevice,
deletePoint,
exportPoints,
getPointsVersion,
importPointsCsv,
listDevices,
listGateways,
listPoints,
updateDevice,
updateGateway,
updatePoint,
} from '../devices/store.js'
import type { DeviceCategory } from '../devices/types.js'
import { fetchGatewayHealth, syncPointsToGateway } from '../gateway/client.js'
export const devicesRouter = Router()
devicesRouter.get('/gateways', authRequired, async (_req, res) => {
res.json({ gateways: await listGateways(), pointsVersion: await getPointsVersion() })
})
devicesRouter.get('/gateways/health', authRequired, async (_req, res) => {
res.json(await fetchGatewayHealth())
})
devicesRouter.patch('/gateways/:id', authRequired, requireRole('admin'), async (req, res) => {
try {
const g = await updateGateway(req.params.id, {
name: req.body?.name,
host: req.body?.host,
port: req.body?.port !== undefined ? Number(req.body.port) : undefined,
enabled: req.body?.enabled,
meta: req.body?.meta,
})
if (!g) {
res.status(404).json({ message: '网关不存在' })
return
}
res.json(g)
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' })
}
})
devicesRouter.get('/', authRequired, async (req, res) => {
const gatewayId = req.query.gatewayId ? String(req.query.gatewayId) : undefined
const category = req.query.category ? String(req.query.category) : undefined
res.json({ devices: await listDevices({ gatewayId, category }) })
})
devicesRouter.post('/', authRequired, requireRole('admin'), async (req, res) => {
try {
const body = req.body || {}
const device = await createDevice({
gatewayId: body.gatewayId ?? null,
category: body.category as DeviceCategory,
name: String(body.name || ''),
code: String(body.code || ''),
bindUi: body.bindUi,
meta: body.meta,
})
res.status(201).json(device)
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' })
}
})
devicesRouter.patch('/:id', authRequired, requireRole('admin'), async (req, res) => {
try {
const d = await updateDevice(req.params.id, req.body || {})
if (!d) {
res.status(404).json({ message: '设备不存在' })
return
}
res.json(d)
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' })
}
})
devicesRouter.delete('/:id', authRequired, requireRole('admin'), async (req, res) => {
const ok = await deleteDevice(req.params.id)
if (!ok) {
res.status(404).json({ message: '设备不存在' })
return
}
res.json({ ok: true })
})
devicesRouter.get('/points', authRequired, async (req, res) => {
const gatewayId = req.query.gatewayId ? String(req.query.gatewayId) : undefined
const deviceId = req.query.deviceId ? String(req.query.deviceId) : undefined
res.json({
points: await listPoints({ gatewayId, deviceId }),
pointsVersion: await getPointsVersion(),
})
})
devicesRouter.post('/points', authRequired, requireRole('admin'), async (req, res) => {
try {
const body = req.body || {}
const point = await createPoint({
gatewayId: String(body.gatewayId),
deviceId: body.deviceId ?? null,
name: String(body.name || ''),
modbusAddress: Number(body.modbusAddress),
...body,
})
res.status(201).json(point)
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' })
}
})
devicesRouter.patch('/points/:id', authRequired, requireRole('admin'), async (req, res) => {
try {
const p = await updatePoint(req.params.id, req.body || {})
if (!p) {
res.status(404).json({ message: '点位不存在' })
return
}
res.json(p)
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' })
}
})
devicesRouter.delete('/points/:id', authRequired, requireRole('admin'), async (req, res) => {
const ok = await deletePoint(req.params.id)
if (!ok) {
res.status(404).json({ message: '点位不存在' })
return
}
res.json({ ok: true })
})
devicesRouter.post('/points/import', authRequired, requireRole('admin'), async (req, res) => {
try {
const gatewayId = String(req.body?.gatewayId || '')
if (!gatewayId) {
res.status(400).json({ message: '请指定 gatewayId' })
return
}
let buffer: Buffer
if (typeof req.body?.csv === 'string') {
buffer = Buffer.from(req.body.csv, 'utf8')
} else if (typeof req.body?.csvBase64 === 'string') {
buffer = Buffer.from(req.body.csvBase64, 'base64')
} else {
res.status(400).json({ message: '请提供 csv 或 csvBase64' })
return
}
const result = await importPointsCsv({
gatewayId,
deviceId: req.body?.deviceId ?? null,
buffer,
encoding: req.body?.encoding,
replaceGateway: Boolean(req.body?.replaceGateway),
})
res.json(result)
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '导入失败' })
}
})
devicesRouter.get('/points/export', authRequired, async (req, res) => {
const gatewayId = req.query.gatewayId ? String(req.query.gatewayId) : undefined
const encoding = String(req.query.encoding || 'utf-8')
const csv = await exportPoints(gatewayId)
const buf = encodeCsv(csv, encoding)
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader(
'Content-Disposition',
`attachment; filename="device-points${gatewayId ? '-' + gatewayId : ''}.csv"`,
)
res.send(buf)
})
devicesRouter.post('/points/sync-gateway', authRequired, requireRole('admin'), async (_req, res) => {
res.json(await syncPointsToGateway())
})
...@@ -17,7 +17,7 @@ import { ...@@ -17,7 +17,7 @@ import {
export const lightRouter = Router() export const lightRouter = Router()
lightRouter.get('/config', authRequired, (_req, res) => { lightRouter.get('/config', (_req, res) => {
res.json(getCommConfig()) res.json(getCommConfig())
}) })
...@@ -67,7 +67,7 @@ lightRouter.post('/config/test', authRequired, requireRole('admin'), (_req, res) ...@@ -67,7 +67,7 @@ lightRouter.post('/config/test', authRequired, requireRole('admin'), (_req, res)
res.json(testCommConfig()) res.json(testCommConfig())
}) })
lightRouter.get('/devices', authRequired, (_req, res) => { lightRouter.get('/devices', (_req, res) => {
res.json({ devices: listDevices() }) res.json({ devices: listDevices() })
}) })
......
...@@ -4,7 +4,8 @@ import { applyWriteLog, getSnapshot } from '../realtime/store.js' ...@@ -4,7 +4,8 @@ import { applyWriteLog, getSnapshot } from '../realtime/store.js'
export const realtimeRouter = Router() export const realtimeRouter = Router()
realtimeRouter.get('/snapshot', authRequired, (_req, res) => { /** 大屏只读,无需登录;写指令仍需鉴权 */
realtimeRouter.get('/snapshot', (_req, res) => {
res.json(getSnapshot()) res.json(getSnapshot())
}) })
......
...@@ -10,7 +10,7 @@ import { setCollectPollMs } from '../realtime/store.js' ...@@ -10,7 +10,7 @@ import { setCollectPollMs } from '../realtime/store.js'
export const settingsRouter = Router() export const settingsRouter = Router()
settingsRouter.get('/', authRequired, async (_req, res) => { settingsRouter.get('/', async (_req, res) => {
res.json(await getSettings()) res.json(await getSettings())
}) })
......
...@@ -16,7 +16,7 @@ import { ...@@ -16,7 +16,7 @@ import {
export const videoRouter = Router() export const videoRouter = Router()
videoRouter.get('/config', authRequired, (_req, res) => { videoRouter.get('/config', (_req, res) => {
res.json(getCommConfig()) res.json(getCommConfig())
}) })
...@@ -76,7 +76,7 @@ videoRouter.post('/config/test', authRequired, requireRole('admin'), (_req, res) ...@@ -76,7 +76,7 @@ videoRouter.post('/config/test', authRequired, requireRole('admin'), (_req, res)
res.json(testCommConfig()) res.json(testCommConfig())
}) })
videoRouter.get('/devices', authRequired, (_req, res) => { videoRouter.get('/devices', (_req, res) => {
res.json({ devices: listDevices() }) res.json({ devices: listDevices() })
}) })
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
<title>分子实验室三维可视化管理平台</title> <title>分子实验室三维可视化管理平台</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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" /> <link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet" />
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script> <script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
</head> </head>
<body> <body>
......
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useClock } from '@/composables/useClock'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const { time } = useClock()
const ICON_PATHS: Record<string, string[]> = {
devices: ['M3 4.2h12v9.6H3z', 'M6.2 15.4h5.6', 'M7.4 13.8v1.6', 'M10.6 13.8v1.6'],
users: [
'M6.6 8.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2z',
'M12.2 8.4a2.1 2.1 0 1 0 0-4.2',
'M2 15.4c0-2.4 2.1-3.8 4.6-3.8s4.6 1.4 4.6 3.8',
'M13.6 15.4c0-1.7-.8-2.9-2.2-3.4',
],
settings: [
'M9 11.2a2.2 2.2 0 1 0 0-4.4 2.2 2.2 0 0 0 0 4.4z',
'M9 2.4l1.1 1.8 2.1-.2.9 1.9 1.8 1.1-.2 2.1 1.8 1.1-1.8 1.1.2 2.1-1.8 1.1-.9 1.9-2.1-.2L9 15.6l-1.1 1.8-2.1.2-.9-1.9-1.8-1.1.2-2.1L1.5 11l1.8-1.1-.2-2.1 1.8-1.1.9-1.9 2.1.2L9 2.4z',
],
}
const navItems = computed(() =>
[
{
to: '/admin/devices',
label: '设备管理',
hint: '点位 / 网关',
tone: 'accent' as const,
icon: 'devices',
},
{
to: '/admin/users',
label: '用户管理',
hint: '账号 / 角色',
tone: 'warn' as const,
icon: 'users',
adminOnly: true,
},
{
to: '/admin/settings',
label: '系统设置',
hint: '采集 / 模式',
tone: 'muted' as const,
icon: 'settings',
},
].filter((n) => !n.adminOnly || auth.isAdmin),
)
function isActive(to: string) {
return route.path === to || route.path.startsWith(`${to}/`)
}
function logout() {
auth.logout()
router.push('/login')
}
function goDashboard() {
router.push('/dashboard')
}
</script>
<template>
<div class="tops-root">
<header class="tops-header">
<div class="tops-brand" @click="goDashboard">
<span class="tops-brand__mark" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path
d="M12 12c0-2.5 2-4.5 4.5-4.5S21 9.4 21 12c0 4.4-3.6 8-8 8-5 0-9-4-9-9 0-5.5 4.5-10 10-10"
stroke-linecap="round"
/>
<circle cx="12" cy="12" r="1.7" />
</svg>
</span>
<span class="tops-brand__copy">
<strong>管理后台</strong>
<small>ADMIN CONSOLE</small>
</span>
</div>
<div class="tops-header__chips">
<span class="tops-chip">
<small>系统</small>
<b>JH-LAB</b>
</span>
<span class="tops-chip tops-tone--ok">
<span class="tops-dot" aria-hidden="true" />
<small>角色</small>
<b>{{ auth.isAdmin ? 'ADMIN' : 'VIEWER' }}</b>
</span>
</div>
<div class="tops-header__end">
<div class="tops-clock">
<small>{{ auth.user?.displayName || auth.user?.username }} · {{ auth.isAdmin ? '管理员' : '只读' }}</small>
<b>{{ time }}</b>
</div>
<button type="button" class="tops-logout" @click="goDashboard">返回大屏</button>
<button type="button" class="tops-logout" @click="logout">退出</button>
</div>
</header>
<div class="tops-body">
<nav class="tops-side" aria-label="管理菜单">
<div class="tops-side__head">
<strong>菜单</strong>
<small>MODULES</small>
</div>
<div class="tops-menu">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="tops-menu__item"
:class="[`tops-tone--${item.tone}`, { 'is-on': isActive(item.to) }]"
>
<span class="tops-menu__icon" aria-hidden="true">
<svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.3">
<path
v-for="(d, index) in ICON_PATHS[item.icon]"
:key="index"
:d="d"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="tops-menu__copy">
<span class="tops-menu__label">{{ item.label }}</span>
<span class="tops-menu__hint">{{ item.hint }}</span>
</span>
</RouterLink>
</div>
<div class="tops-side__foot">
<span class="tops-dot tops-tone--ok" aria-hidden="true" />
<span>协议点表已接入</span>
</div>
</nav>
<main class="tops-main">
<RouterView />
</main>
</div>
</div>
</template>
...@@ -16,26 +16,27 @@ const { place, temp, text: weatherText, humidity } = useMacauWeather() ...@@ -16,26 +16,27 @@ const { place, temp, text: weatherText, humidity } = useMacauWeather()
const { style } = useStageScale() const { style } = useStageScale()
const navItems = [ const navItems = [
{ to: '/overview', label: '全景总览' }, { to: '/dashboard/overview', label: '全景总览' },
{ to: '/roam', label: '漫游巡检' }, { to: '/dashboard/roam', label: '漫游巡检' },
{ to: '/vav', label: '通风柜VAV' }, { to: '/dashboard/vav', label: '通风柜VAV' },
{ to: '/pressure', label: '房间压差' }, { to: '/dashboard/pressure', label: '房间压差' },
{ to: '/hvac', label: '空调排风' }, { to: '/dashboard/hvac', label: '空调排风' },
{ to: '/metering', label: '计量计费' }, { to: '/dashboard/metering', label: '计量计费' },
{ to: '/video', label: '视频监控' }, { to: '/dashboard/video', label: '视频监控' },
{ to: '/access', label: '门禁系统' }, { to: '/dashboard/access', label: '门禁系统' },
{ to: '/cold', label: '冷链监测' }, { to: '/dashboard/cold', label: '冷链监测' },
{ to: '/light', label: '智能照明' }, { to: '/dashboard/light', label: '智能照明' },
{ to: '/users', label: '用户管理', adminOnly: true },
{ to: '/settings', label: '系统设置', adminOnly: true },
] ]
onMounted(() => realtime.connect()) onMounted(() => {
realtime.connect()
if (auth.isLoggedIn && !auth.user) auth.fetchMe().catch(() => undefined)
})
onUnmounted(() => realtime.disconnect()) onUnmounted(() => realtime.disconnect())
function logout() { function goAdmin() {
auth.logout() if (auth.isLoggedIn) router.push('/admin')
router.push('/login') else router.push({ path: '/login', query: { redirect: '/admin' } })
} }
</script> </script>
...@@ -73,24 +74,16 @@ function logout() { ...@@ -73,24 +74,16 @@ function logout() {
<span class="date">{{ date }}</span> <span class="date">{{ date }}</span>
</div> </div>
<div class="hdr-status"> <div class="hdr-status">
<div class="alarm-badge" @click="router.push('/overview')"> <div class="alarm-badge" @click="router.push('/dashboard/overview')">
<span class="dot red" /> <span class="dot red" />
<span>告警 {{ realtime.alarmCount }}</span> <span>告警 {{ realtime.alarmCount }}</span>
</div> </div>
<div class="user-chip"> <button class="tops-logout admin-entry" type="button" @click="goAdmin">管理</button>
<span>{{ auth.user?.displayName }} · {{ auth.isAdmin ? '管理员' : '只读' }}</span>
<button type="button" @click="logout">退出</button>
</div>
</div> </div>
</header> </header>
<nav class="screen-nav"> <nav class="screen-nav">
<RouterLink <RouterLink v-for="item in navItems" :key="item.to" class="nav-chip" :to="item.to">
v-for="item in navItems.filter((n) => !n.adminOnly || auth.isAdmin)"
:key="item.to"
class="nav-chip"
:to="item.to"
>
{{ item.label }} {{ item.label }}
</RouterLink> </RouterLink>
<div class="nav-spacer" /> <div class="nav-spacer" />
...@@ -110,5 +103,13 @@ function logout() { ...@@ -110,5 +103,13 @@ function logout() {
</template> </template>
<style scoped> <style scoped>
.brand :deep(.logo) { width: 34px; height: 34px; } .brand :deep(.logo) {
width: 34px;
height: 34px;
}
.admin-entry {
font-size: 12px !important;
letter-spacing: 0.12em !important;
color: var(--accent) !important;
}
</style> </style>
...@@ -4,5 +4,6 @@ import App from './App.vue' ...@@ -4,5 +4,6 @@ import App from './App.vue'
import router from './router' import router from './router'
import './styles/tokens.css' import './styles/tokens.css'
import './styles/screen.css' import './styles/screen.css'
import './styles/tops-chrome.css'
createApp(App).use(createPinia()).use(router).mount('#app') createApp(App).use(createPinia()).use(router).mount('#app')
...@@ -10,82 +10,116 @@ const router = createRouter({ ...@@ -10,82 +10,116 @@ const router = createRouter({
component: () => import('@/views/login/LoginView.vue'), component: () => import('@/views/login/LoginView.vue'),
meta: { public: true }, meta: { public: true },
}, },
{ path: '/', redirect: '/dashboard' },
// 旧路径兼容
{ path: '/overview', redirect: '/dashboard/overview' },
{ path: '/roam', redirect: '/dashboard/roam' },
{ path: '/vav', redirect: '/dashboard/vav' },
{ path: '/pressure', redirect: '/dashboard/pressure' },
{ path: '/hvac', redirect: '/dashboard/hvac' },
{ path: '/metering', redirect: '/dashboard/metering' },
{ path: '/video', redirect: '/dashboard/video' },
{ path: '/access', redirect: '/dashboard/access' },
{ path: '/cold', redirect: '/dashboard/cold' },
{ path: '/light', redirect: '/dashboard/light' },
{ path: '/devices', redirect: '/admin/devices' },
{ path: '/users', redirect: '/admin/users' },
{ path: '/settings', redirect: '/admin/settings' },
{ {
path: '/', path: '/dashboard',
component: () => import('@/layouts/ScreenLayout.vue'), component: () => import('@/layouts/ScreenLayout.vue'),
meta: { public: true, area: 'dashboard' },
children: [ children: [
{ path: '', redirect: '/overview' }, { path: '', redirect: '/dashboard/overview' },
{ {
path: 'overview', path: 'overview',
name: 'overview', name: 'overview',
component: () => import('@/views/overview/OverviewView.vue'), component: () => import('@/views/overview/OverviewView.vue'),
meta: { title: '全景总览' }, meta: { title: '全景总览', public: true },
}, },
{ {
path: 'roam', path: 'roam',
name: 'roam', name: 'roam',
component: () => import('@/views/stub/StubView.vue'), component: () => import('@/views/stub/StubView.vue'),
meta: { title: '漫游巡检', desc: '第一人称巡检路径与点位数据同步,UE 推流到位后可替换为真实漫游。' }, meta: {
title: '漫游巡检',
desc: '第一人称巡检路径与点位数据同步,UE 推流到位后可替换为真实漫游。',
public: true,
},
}, },
{ {
path: 'vav', path: 'vav',
name: 'vav', name: 'vav',
component: () => import('@/views/vav/VavView.vue'), component: () => import('@/views/vav/VavView.vue'),
meta: { title: '通风柜 VAV' }, meta: { title: '通风柜 VAV', public: true },
}, },
{ {
path: 'pressure', path: 'pressure',
name: 'pressure', name: 'pressure',
component: () => import('@/views/pressure/PressureView.vue'), component: () => import('@/views/pressure/PressureView.vue'),
meta: { title: '房间压差' }, meta: { title: '房间压差', public: true },
}, },
{ {
path: 'hvac', path: 'hvac',
name: 'hvac', name: 'hvac',
component: () => import('@/views/hvac/HvacView.vue'), component: () => import('@/views/hvac/HvacView.vue'),
meta: { title: '空调排风' }, meta: { title: '空调排风', public: true },
}, },
{ {
path: 'metering', path: 'metering',
name: 'metering', name: 'metering',
component: () => import('@/views/metering/MeteringView.vue'), component: () => import('@/views/metering/MeteringView.vue'),
meta: { title: '计量计费' }, meta: { title: '计量计费', public: true },
}, },
{ {
path: 'video', path: 'video',
name: 'video', name: 'video',
component: () => import('@/views/video/VideoView.vue'), component: () => import('@/views/video/VideoView.vue'),
meta: { title: '视频监控' }, meta: { title: '视频监控', public: true },
}, },
{ {
path: 'access', path: 'access',
name: 'access', name: 'access',
component: () => import('@/views/access/AccessView.vue'), component: () => import('@/views/access/AccessView.vue'),
meta: { title: '门禁系统' }, meta: { title: '门禁系统', public: true },
}, },
{ {
path: 'cold', path: 'cold',
name: 'cold', name: 'cold',
component: () => import('@/views/cold/ColdView.vue'), component: () => import('@/views/cold/ColdView.vue'),
meta: { title: '冷链监测' }, meta: { title: '冷链监测', public: true },
}, },
{ {
path: 'light', path: 'light',
name: 'light', name: 'light',
component: () => import('@/views/light/LightView.vue'), component: () => import('@/views/light/LightView.vue'),
meta: { title: '智能照明' }, meta: { title: '智能照明', public: true },
},
],
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
meta: { area: 'admin', requiresAuth: true },
children: [
{ path: '', redirect: '/admin/devices' },
{
path: 'devices',
name: 'devices',
component: () => import('@/views/devices/DevicesView.vue'),
meta: { title: '设备管理', requiresAuth: true },
}, },
{ {
path: 'users', path: 'users',
name: 'users', name: 'users',
component: () => import('@/views/users/UserManageView.vue'), component: () => import('@/views/users/UserManageView.vue'),
meta: { title: '用户管理', adminOnly: true }, meta: { title: '用户管理', requiresAuth: true, adminOnly: true },
}, },
{ {
path: 'settings', path: 'settings',
name: 'settings', name: 'settings',
component: () => import('@/views/settings/SettingsView.vue'), component: () => import('@/views/settings/SettingsView.vue'),
meta: { title: '系统设置', adminOnly: true }, meta: { title: '系统设置', requiresAuth: true },
}, },
], ],
}, },
...@@ -94,13 +128,30 @@ const router = createRouter({ ...@@ -94,13 +128,30 @@ const router = createRouter({
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
const auth = useAuthStore() const auth = useAuthStore()
if (to.meta.public) { const isPublic = to.matched.some((r) => r.meta.public) || to.meta.public
if (auth.isLoggedIn && to.name === 'login') return '/overview' const needsAuth = to.matched.some((r) => r.meta.requiresAuth) || to.meta.requiresAuth
if (to.name === 'login') {
if (auth.isLoggedIn) {
const redirect = typeof to.query.redirect === 'string' ? to.query.redirect : '/admin'
return redirect.startsWith('/') ? redirect : '/admin'
}
return true return true
} }
if (!auth.isLoggedIn) return '/login'
if (isPublic && !needsAuth) return true
if (needsAuth) {
if (!auth.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (!auth.user) await auth.fetchMe() if (!auth.user) await auth.fetchMe()
if (to.meta.adminOnly && !auth.isAdmin) return '/overview' if (!auth.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (to.meta.adminOnly && !auth.isAdmin) return '/admin/devices'
}
return true return true
}) })
......
/**
* Visual tokens aligned with emsc-v2/apps/seat (@emsc/ui-theme seat.scss / screen.scss)
*/
:root { :root {
--bg: #07111f; --screen-bg: #081018;
--bg-deep: #040a14; --panel-bg: rgba(8, 16, 24, 0.92);
--cyan: #3ec8ff; --panel-border: rgba(61, 190, 255, 0.28);
--panel-title: #e8f4ff;
--panel-muted: #8ea3b8;
--text: #d5e6f2;
--accent: #3dbeff;
--cyan: #3dbeff;
--alert: #e25b5b;
--ok: #3cba7a;
--warn: #e0a04a;
--title-font: Rajdhani, 'PingFang SC', 'Microsoft YaHei', sans-serif;
--body-font: Rajdhani, 'PingFang SC', 'Microsoft YaHei', sans-serif;
--digital-font: 'Share Tech Mono', ui-monospace, monospace;
/* Backward-compatible aliases used by existing views */
--bg: var(--screen-bg);
--bg-deep: #04080f;
--cyan-lt: #7ad8ff; --cyan-lt: #7ad8ff;
--green: #2fd6a1; --green: var(--ok);
--amber: #f0b429; --amber: var(--warn);
--red: #ff5a5a; --red: var(--alert);
--title: #eef6ff; --title: var(--panel-title);
--body: #c9ddf2; --body: var(--text);
--label: #7fa6c9; --label: var(--panel-muted);
--muted: #8eb4d4; --muted: var(--panel-muted);
--panel-bg: linear-gradient(165deg, rgba(22, 48, 88, 0.55), rgba(10, 24, 48, 0.5)); --panel-bd: var(--panel-border);
--panel-bd: rgba(62, 200, 255, 0.28); --card-bg: rgba(6, 12, 20, 0.55);
--card-bg: rgba(22, 56, 104, 0.35); --card-bd: rgba(61, 190, 255, 0.18);
--card-bd: rgba(62, 200, 255, 0.18); --track: rgba(61, 190, 255, 0.16);
--track: rgba(48, 110, 190, 0.22); --bar-grad: linear-gradient(90deg, rgba(61, 190, 255, 0.55), #3dbeff);
--bar-grad: linear-gradient(90deg, #1a6fff, #3ec8ff); --font-cn: var(--body-font);
--font-cn: 'Noto Sans SC', 'Microsoft YaHei', sans-serif; --font-num: var(--digital-font);
--font-num: 'IBM Plex Sans', 'Segoe UI', sans-serif; --shadow: 0 6px 20px rgba(0, 0, 0, 0.22);
--shadow: 0 12px 28px rgba(2, 8, 18, 0.45);
} }
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app { width: 100%; height: 100%; overflow: hidden; background: var(--bg-deep); } html, body, #app { width: 100%; height: 100%; overflow: hidden; background: var(--bg-deep); }
body { body {
font-family: var(--font-cn); font-family: var(--body-font);
color: var(--body); font-weight: 600;
letter-spacing: 0.02em;
color: var(--text);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
button, input, select { font: inherit; } button, input, select, textarea { font: inherit; }
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: rgba(122, 216, 255, 0.35); border-radius: 3px; } ::-webkit-scrollbar-thumb {
background: rgba(61, 190, 255, 0.35);
border-radius: 2px;
}
::-webkit-scrollbar-track { background: transparent; }
...@@ -10,15 +10,24 @@ const username = ref('admin') ...@@ -10,15 +10,24 @@ const username = ref('admin')
const password = ref('Admin@123') const password = ref('Admin@123')
const error = ref('') const error = ref('')
const loading = ref(false) const loading = ref(false)
const shake = ref(false)
async function submit() { async function submit() {
error.value = '' error.value = ''
loading.value = true loading.value = true
try { try {
await auth.login(username.value.trim(), password.value) await auth.login(username.value.trim(), password.value)
await router.push('/overview') const redirect =
typeof router.currentRoute.value.query.redirect === 'string'
? router.currentRoute.value.query.redirect
: '/admin'
await router.push(redirect.startsWith('/') ? redirect : '/admin')
} catch (e) { } catch (e) {
error.value = e instanceof ApiError ? e.message : '登录失败' error.value = e instanceof ApiError ? e.message : '登录失败'
shake.value = true
window.setTimeout(() => {
shake.value = false
}, 480)
} finally { } finally {
loading.value = false loading.value = false
} }
...@@ -26,54 +35,48 @@ async function submit() { ...@@ -26,54 +35,48 @@ async function submit() {
</script> </script>
<template> <template>
<div class="login-page"> <div class="tops-login">
<form class="login-card" @submit.prevent="submit"> <div class="tops-login__atmosphere" aria-hidden="true">
<div class="login-brand">ZB</div> <div class="tops-login__grid" />
<h1>分子实验室三维可视化管理平台</h1> <div class="tops-login__glow tops-login__glow--a" />
<p class="hint">请登录后进入管控大屏。管理员可进行设备管理与参数设置,其它角色仅可查看。</p> <div class="tops-login__glow tops-login__glow--b" />
<div class="field"> <div class="tops-login__horizon" />
<label>用户名</label>
<input v-model="username" autocomplete="username" />
</div> </div>
<div class="field">
<label>密码</label> <div class="tops-login__stage">
<input v-model="password" type="password" autocomplete="current-password" /> <div class="tops-login__mark">JH</div>
</div> <h1 class="tops-login__title">分子实验室三维可视化管理平台</h1>
<p v-if="error" class="error">{{ error }}</p> <p class="tops-login__tagline">Lab Intelligent Control</p>
<button class="btn" type="submit" :disabled="loading"> <p class="tops-login__support">登录进入管理后台;大屏无需登录即可查看实时态势。</p>
{{ loading ? '登录中…' : '进入系统' }}
<form class="tops-login__form" :class="{ 'is-shake': shake }" @submit.prevent="submit">
<label class="tops-login__field">
<span>用户名</span>
<input v-model="username" autocomplete="username" required />
</label>
<label class="tops-login__field">
<span>密码</span>
<input
v-model="password"
type="password"
autocomplete="current-password"
required
/>
</label>
<p v-if="error" class="tops-login__error" role="alert">{{ error }}</p>
<button class="tops-login__submit" type="submit" :disabled="loading">
<span>{{ loading ? '登录中…' : '进入后台' }}</span>
</button>
<p class="tops-login__hint">
演示账号 <code>admin / Admin@123</code> · <code>viewer / Viewer@123</code>
</p>
<button class="tops-login__dash" type="button" @click="router.push('/dashboard')">
无需登录,进入大屏
</button> </button>
<p class="demo">演示账号:admin / Admin@123(管理员)· viewer / Viewer@123(只读)</p>
</form> </form>
</div> </div>
</div>
</template> </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>
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