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
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=12h
DATA_MODE=mock
# DATA_MODE=gateway
# 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)
COLLECT_RETENTION_DAYS=90
# Modbus gateways
# Modbus 主机(逻辑设备,实际由 Windows 网关连接)
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_FRIDGE_HOST=192.168.10.104
MODBUS_PORT=502
MODBUS_POLL_MS=2000
# Access bridge (HCNetSDK Windows service), empty = mock
# 兼容旧配置:门禁桥接;优先使用 GATEWAY_URL
ACCESS_BRIDGE_URL=
# Default admin (seeded on first boot)
......
......@@ -82,7 +82,10 @@ npm run dev
## 目录
- `web/` 大屏前端
- `server/` 认证、用户、实时采集、计费、PG 落库
- `web/` 大屏前端(含后台「设备管理」点位编辑)
- `server/` 认证、用户、实时采集、计费、设备/点位主数据、PG 落库
- `gateway/win-protocol/` Windows 协议网关(VS2022+MSVC,Modbus+HCNetSDK)
- `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 @@
| 网关 | 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) | 冷链温度 |
| PAU-201-2 | 192.168.10.103 | 电能、冷量、通风柜 VAV、流量计 | 计量计费、VAV |
| 房间 | 192.168.10.104 | 压差屏、PLC、**8 组冰箱温湿度**、N/E 电回路 | 房间压差、冷链、照明 |
倍率约定:温度等 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
/**
* JhLab Windows Protocol Gateway
* - Platform API: /health /snapshot /config/points/reload ...
* - Admin UI: /ui/ and /admin/api/
*/
#include "httplib.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <map>
#include <mutex>
#include <random>
#include <sstream>
#include <string>
#include <vector>
#ifdef _WIN32
#include <windows.h>
#endif
namespace {
struct PointCfg {
std::string id;
std::string itemId;
std::string name;
std::string gatewayId;
int unitId = 1;
int address = 0;
std::string dataType = "Short";
double scale = 0.1;
double bias = 0;
bool enabled = true;
};
struct ModbusHost {
std::string id;
std::string name;
std::string host;
int port = 502;
bool enabled = true;
};
struct HikDevice {
std::string id;
std::string name;
std::string host;
int port = 8000;
std::string username = "admin";
std::string password;
int channel = 1;
bool enabled = true;
};
struct Session {
std::string user;
std::chrono::steady_clock::time_point expire;
};
struct AppState {
std::mutex mu;
std::string configPath = "config.json";
std::string webRoot = "web";
int listenPort = 38080;
std::string listenHost = "0.0.0.0";
std::string platformUrl = "http://127.0.0.1:3001";
int pollMs = 2000;
std::string adminUser = "admin";
std::string adminPass = "Admin@123";
std::vector<ModbusHost> modbusHosts;
HikDevice nvr;
std::vector<HikDevice> cameras;
std::vector<HikDevice> doors;
int pointsVersion = 0;
std::vector<PointCfg> points;
std::string version = "0.2.0-ui";
std::map<std::string, Session> sessions;
};
AppState g;
std::string jsonEscape(const std::string& s) {
std::ostringstream o;
for (char c : s) {
switch (c) {
case '"': o << "\\\""; break;
case '\\': o << "\\\\"; break;
case '\n': o << "\\n"; break;
case '\r': o << "\\r"; break;
case '\t': o << "\\t"; break;
default: o << c; break;
}
}
return o.str();
}
std::string extractString(const std::string& json, const std::string& key) {
const std::string pat = "\"" + key + "\"";
auto pos = json.find(pat);
if (pos == std::string::npos) return {};
pos = json.find(':', pos);
if (pos == std::string::npos) return {};
pos = json.find('"', pos);
if (pos == std::string::npos) return {};
++pos;
std::string out;
for (; pos < json.size(); ++pos) {
char c = json[pos];
if (c == '\\' && pos + 1 < json.size()) {
out.push_back(json[++pos]);
continue;
}
if (c == '"') break;
out.push_back(c);
}
return out;
}
int extractInt(const std::string& json, const std::string& key, int fallback = 0) {
const std::string pat = "\"" + key + "\"";
auto pos = json.find(pat);
if (pos == std::string::npos) return fallback;
pos = json.find(':', pos);
if (pos == std::string::npos) return fallback;
++pos;
while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) ++pos;
try {
return std::stoi(json.substr(pos));
} catch (...) {
return fallback;
}
}
double extractDouble(const std::string& json, const std::string& key, double fallback = 0) {
const std::string pat = "\"" + key + "\"";
auto pos = json.find(pat);
if (pos == std::string::npos) return fallback;
pos = json.find(':', pos);
if (pos == std::string::npos) return fallback;
++pos;
while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) ++pos;
try {
return std::stod(json.substr(pos));
} catch (...) {
return fallback;
}
}
bool extractBool(const std::string& json, const std::string& key, bool fallback = true) {
const std::string pat = "\"" + key + "\"";
auto pos = json.find(pat);
if (pos == std::string::npos) return fallback;
pos = json.find(':', pos);
if (pos == std::string::npos) return fallback;
auto sub = json.substr(pos, 24);
if (sub.find("false") != std::string::npos) return false;
if (sub.find("true") != std::string::npos) return true;
return fallback;
}
std::vector<std::string> splitObjects(const std::string& json, const std::string& arrayKey) {
std::vector<std::string> out;
auto pointsKey = json.find("\"" + arrayKey + "\"");
if (pointsKey == std::string::npos) return out;
auto arr = json.find('[', pointsKey);
if (arr == std::string::npos) return out;
// 仅在当前数组范围内解析对象,避免吞掉后续 cameras/doors
int bracket = 0;
size_t arrEnd = arr;
for (; arrEnd < json.size(); ++arrEnd) {
if (json[arrEnd] == '[') bracket++;
else if (json[arrEnd] == ']') {
bracket--;
if (bracket == 0) break;
}
}
if (bracket != 0) return out;
size_t i = arr + 1;
while (i < arrEnd) {
auto start = json.find('{', i);
if (start == std::string::npos || start >= arrEnd) break;
int depth = 0;
size_t end = start;
for (; end < arrEnd; ++end) {
if (json[end] == '{') depth++;
else if (json[end] == '}') {
depth--;
if (depth == 0) break;
}
}
if (depth != 0) break;
out.push_back(json.substr(start, end - start + 1));
i = end + 1;
}
return out;
}
HikDevice parseHik(const std::string& obj) {
HikDevice d;
d.id = extractString(obj, "id");
d.name = extractString(obj, "name");
d.host = extractString(obj, "host");
d.port = extractInt(obj, "port", 8000);
d.username = extractString(obj, "username");
if (d.username.empty()) d.username = "admin";
d.password = extractString(obj, "password");
d.channel = extractInt(obj, "channel", 1);
d.enabled = extractBool(obj, "enabled", true);
return d;
}
std::string hikToJson(const HikDevice& d) {
std::ostringstream o;
o << "{\"id\":\"" << jsonEscape(d.id) << "\",\"name\":\"" << jsonEscape(d.name)
<< "\",\"host\":\"" << jsonEscape(d.host) << "\",\"port\":" << d.port
<< ",\"username\":\"" << jsonEscape(d.username) << "\",\"password\":\""
<< jsonEscape(d.password) << "\",\"channel\":" << d.channel
<< ",\"enabled\":" << (d.enabled ? "true" : "false") << "}";
return o.str();
}
std::string modbusToJson(const ModbusHost& m) {
std::ostringstream o;
o << "{\"id\":\"" << jsonEscape(m.id) << "\",\"name\":\"" << jsonEscape(m.name)
<< "\",\"host\":\"" << jsonEscape(m.host) << "\",\"port\":" << m.port
<< ",\"enabled\":" << (m.enabled ? "true" : "false") << "}";
return o.str();
}
std::string configToJsonLocked() {
std::ostringstream o;
o << "{\"ok\":true,\"listen\":\"" << jsonEscape(g.listenHost) << "\",\"port\":" << g.listenPort
<< ",\"platformUrl\":\"" << jsonEscape(g.platformUrl) << "\",\"pollMs\":" << g.pollMs
<< ",\"adminUsername\":\"" << jsonEscape(g.adminUser) << "\",\"modbusHosts\":[";
for (size_t i = 0; i < g.modbusHosts.size(); ++i) {
if (i) o << ',';
o << modbusToJson(g.modbusHosts[i]);
}
o << "],\"nvr\":" << hikToJson(g.nvr) << ",\"cameras\":[";
for (size_t i = 0; i < g.cameras.size(); ++i) {
if (i) o << ',';
o << hikToJson(g.cameras[i]);
}
o << "],\"doors\":[";
for (size_t i = 0; i < g.doors.size(); ++i) {
if (i) o << ',';
o << hikToJson(g.doors[i]);
}
o << "]}";
return o.str();
}
void saveConfigLocked() {
std::ofstream out(g.configPath, std::ios::binary);
if (!out) return;
// reuse configToJson without ok wrapper for file
std::ostringstream o;
o << "{\n \"listen\": \"" << jsonEscape(g.listenHost) << "\",\n \"port\": " << g.listenPort
<< ",\n \"platformUrl\": \"" << jsonEscape(g.platformUrl) << "\",\n \"pollMs\": " << g.pollMs
<< ",\n \"adminUsername\": \"" << jsonEscape(g.adminUser) << "\",\n \"adminPassword\": \""
<< jsonEscape(g.adminPass) << "\",\n \"modbusHosts\": [";
for (size_t i = 0; i < g.modbusHosts.size(); ++i) {
if (i) o << ',';
o << modbusToJson(g.modbusHosts[i]);
}
o << "],\n \"nvr\": " << hikToJson(g.nvr) << ",\n \"cameras\": [";
for (size_t i = 0; i < g.cameras.size(); ++i) {
if (i) o << ',';
o << hikToJson(g.cameras[i]);
}
o << "],\n \"doors\": [";
for (size_t i = 0; i < g.doors.size(); ++i) {
if (i) o << ',';
o << hikToJson(g.doors[i]);
}
o << "]\n}\n";
out << o.str();
}
void loadConfigFile(const std::string& path) {
std::ifstream in(path);
if (!in) {
std::cerr << "[gateway] config not found: " << path << "\n";
return;
}
std::ostringstream ss;
ss << in.rdbuf();
const std::string body = ss.str();
g.configPath = path;
int port = extractInt(body, "port", g.listenPort);
if (port > 0) g.listenPort = port;
int poll = extractInt(body, "pollMs", g.pollMs);
if (poll > 0) g.pollMs = poll;
auto listen = extractString(body, "listen");
if (!listen.empty()) g.listenHost = listen;
auto platform = extractString(body, "platformUrl");
if (!platform.empty()) g.platformUrl = platform;
auto user = extractString(body, "adminUsername");
if (!user.empty()) g.adminUser = user;
auto pass = extractString(body, "adminPassword");
if (!pass.empty()) g.adminPass = pass;
g.modbusHosts.clear();
for (const auto& obj : splitObjects(body, "modbusHosts")) {
ModbusHost m;
m.id = extractString(obj, "id");
m.name = extractString(obj, "name");
m.host = extractString(obj, "host");
m.port = extractInt(obj, "port", 502);
m.enabled = extractBool(obj, "enabled", true);
if (!m.host.empty()) g.modbusHosts.push_back(m);
}
auto nvrKey = body.find("\"nvr\"");
if (nvrKey != std::string::npos) {
auto brace = body.find('{', nvrKey);
if (brace != std::string::npos) {
int depth = 0;
size_t end = brace;
for (; end < body.size(); ++end) {
if (body[end] == '{') depth++;
else if (body[end] == '}') {
depth--;
if (depth == 0) break;
}
}
g.nvr = parseHik(body.substr(brace, end - brace + 1));
if (g.nvr.id.empty()) g.nvr.id = "nvr";
}
}
g.cameras.clear();
for (const auto& obj : splitObjects(body, "cameras")) {
auto d = parseHik(obj);
if (!d.id.empty()) g.cameras.push_back(d);
}
g.doors.clear();
for (const auto& obj : splitObjects(body, "doors")) {
auto d = parseHik(obj);
if (!d.id.empty()) g.doors.push_back(d);
}
}
std::vector<PointCfg> parsePointsPayload(const std::string& body) {
std::vector<PointCfg> out;
for (const auto& obj : splitObjects(body, "points")) {
PointCfg p;
p.id = extractString(obj, "id");
p.itemId = extractString(obj, "itemId");
if (p.itemId.empty()) p.itemId = extractString(obj, "item_id");
p.name = extractString(obj, "name");
p.gatewayId = extractString(obj, "gatewayId");
if (p.gatewayId.empty()) p.gatewayId = extractString(obj, "gateway_id");
p.unitId = extractInt(obj, "modbusId", extractInt(obj, "modbus_id", 1));
p.address = extractInt(obj, "modbusAddress", extractInt(obj, "modbus_address", 0));
p.dataType = extractString(obj, "dataType");
if (p.dataType.empty()) p.dataType = "Short";
double scaleOverride = extractDouble(obj, "scaleOverride", NAN);
if (std::isnan(scaleOverride)) scaleOverride = extractDouble(obj, "scale_override", NAN);
double scale = extractDouble(obj, "scale", 0);
p.scale = !std::isnan(scaleOverride) ? scaleOverride : (scale != 0 ? scale : 0.1);
p.bias = extractDouble(obj, "bias", 0);
p.enabled = extractBool(obj, "enabled", true);
if (!p.id.empty() || !p.itemId.empty()) out.push_back(std::move(p));
}
return out;
}
std::string isoNow() {
using namespace std::chrono;
auto t = system_clock::to_time_t(system_clock::now());
std::tm tm{};
#ifdef _WIN32
gmtime_s(&tm, &t);
#else
gmtime_r(&t, &tm);
#endif
char buf[64];
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm);
return buf;
}
std::string randomToken() {
static std::mt19937 rng{std::random_device{}()};
std::uniform_int_distribution<int> dist(0, 15);
static const char* hex = "0123456789abcdef";
std::string s;
s.reserve(32);
for (int i = 0; i < 32; ++i) s.push_back(hex[dist(rng)]);
return s;
}
bool authOk(const httplib::Request& req) {
auto auth = req.get_header_value("Authorization");
if (auth.rfind("Bearer ", 0) != 0) return false;
auto tok = auth.substr(7);
std::lock_guard<std::mutex> lock(g.mu);
auto it = g.sessions.find(tok);
if (it == g.sessions.end()) return false;
if (std::chrono::steady_clock::now() > it->second.expire) {
g.sessions.erase(it);
return false;
}
it->second.expire = std::chrono::steady_clock::now() + std::chrono::hours(12);
return true;
}
void unauthorized(httplib::Response& res) {
res.status = 401;
res.set_content("{\"ok\":false,\"message\":\"未登录或会话过期\"}", "application/json; charset=utf-8");
}
std::string buildHealthJson() {
std::lock_guard<std::mutex> lock(g.mu);
std::ostringstream o;
o << "{\"ok\":true,\"version\":\"" << jsonEscape(g.version)
<< "\",\"pointsVersion\":" << g.pointsVersion << ",\"modbus\":[";
for (size_t i = 0; i < g.modbusHosts.size(); ++i) {
if (i) o << ',';
o << "{\"host\":\"" << jsonEscape(g.modbusHosts[i].host) << "\",\"port\":" << g.modbusHosts[i].port
<< ",\"ok\":" << (g.modbusHosts[i].enabled ? "true" : "false") << ",\"latencyMs\":0}";
}
o << "],\"hikvision\":{\"loggedIn\":0,\"alarms\":false,\"note\":\"HCNetSDK pending\"}}";
return o.str();
}
std::string buildAdminHealthJson() {
std::lock_guard<std::mutex> lock(g.mu);
std::ostringstream o;
o << "{\"ok\":true,\"version\":\"" << jsonEscape(g.version)
<< "\",\"pointsVersion\":" << g.pointsVersion << ",\"listen\":\"" << jsonEscape(g.listenHost)
<< "\",\"port\":" << g.listenPort << ",\"platformUrl\":\"" << jsonEscape(g.platformUrl)
<< "\",\"modbus\":[";
for (size_t i = 0; i < g.modbusHosts.size(); ++i) {
if (i) o << ',';
o << modbusToJson(g.modbusHosts[i]);
}
o << "],\"cameras\":[";
for (size_t i = 0; i < g.cameras.size(); ++i) {
if (i) o << ',';
o << hikToJson(g.cameras[i]);
}
o << "],\"doors\":[";
for (size_t i = 0; i < g.doors.size(); ++i) {
if (i) o << ',';
o << hikToJson(g.doors[i]);
}
o << "]}";
return o.str();
}
std::string buildSnapshotJson() {
std::lock_guard<std::mutex> lock(g.mu);
const auto ts = isoNow();
std::ostringstream o;
o << "{\"ts\":\"" << ts << "\",\"pointsVersion\":" << g.pointsVersion << ",\"values\":[";
bool first = true;
for (const auto& p : g.points) {
if (!p.enabled) continue;
if (!first) o << ',';
first = false;
const double raw = static_cast<double>((p.address * 17 + 250) % 400);
const double eng = raw * p.scale + p.bias;
o << "{\"id\":\"" << jsonEscape(p.id) << "\",\"itemId\":\"" << jsonEscape(p.itemId)
<< "\",\"raw\":" << raw << ",\"eng\":" << eng << ",\"quality\":\"good\",\"ts\":\"" << ts
<< "\"}";
}
o << "]}";
return o.str();
}
std::string previewSvg(const HikDevice& cam) {
std::ostringstream o;
o << "<svg xmlns='http://www.w3.org/2000/svg' width='640' height='400'>"
<< "<defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>"
<< "<stop offset='0%' stop-color='#0b1220'/><stop offset='100%' stop-color='#1e3a5f'/>"
<< "</linearGradient></defs>"
<< "<rect width='100%' height='100%' fill='url(#g)'/>"
<< "<rect x='24' y='24' width='592' height='352' fill='none' stroke='#3d8bfd' stroke-width='2' "
"stroke-dasharray='8 6'/>"
<< "<text x='50%' y='46%' text-anchor='middle' fill='#e8eefc' font-size='28' "
"font-family='Segoe UI,Microsoft YaHei,sans-serif'>"
<< jsonEscape(cam.name.empty() ? cam.id : cam.name) << "</text>"
<< "<text x='50%' y='56%' text-anchor='middle' fill='#8fa3c4' font-size='16' "
"font-family='Segoe UI,Microsoft YaHei,sans-serif'>"
<< jsonEscape(cam.host) << ":" << cam.port << " ch" << cam.channel << "</text>"
<< "<text x='50%' y='68%' text-anchor='middle' fill='#f5a524' font-size='14' "
"font-family='Segoe UI,Microsoft YaHei,sans-serif'>HCNetSDK preview pending</text>"
<< "</svg>";
return o.str();
}
std::string exeDir() {
#ifdef _WIN32
char buf[MAX_PATH] = {0};
GetModuleFileNameA(nullptr, buf, MAX_PATH);
std::string p(buf);
auto slash = p.find_last_of("\\/");
if (slash != std::string::npos) return p.substr(0, slash);
return ".";
#else
return ".";
#endif
}
} // namespace
int main(int argc, char** argv) {
const std::string base = exeDir();
std::string configPath = base + "/config.json";
if (argc > 1) configPath = argv[1];
{
std::lock_guard<std::mutex> lock(g.mu);
loadConfigFile(configPath);
g.webRoot = base + "/web";
// also try sibling ../web when running from build/
std::ifstream probe(g.webRoot + "/index.html");
if (!probe) {
g.webRoot = base + "/../web";
}
}
httplib::Server svr;
svr.Get("/", [](const httplib::Request&, httplib::Response& res) {
res.set_redirect("/ui/");
});
// Static admin UI
const std::string webRoot = [&] {
std::lock_guard<std::mutex> lock(g.mu);
return g.webRoot;
}();
if (!svr.set_mount_point("/ui", webRoot)) {
std::cerr << "[gateway] failed to mount UI dir: " << webRoot << "\n";
} else {
std::cout << "[gateway] UI mounted at /ui from " << webRoot << "\n";
}
svr.Get("/health", [](const httplib::Request&, httplib::Response& res) {
res.set_content(buildHealthJson(), "application/json; charset=utf-8");
});
svr.Get("/snapshot", [](const httplib::Request&, httplib::Response& res) {
res.set_content(buildSnapshotJson(), "application/json; charset=utf-8");
});
svr.Get("/config/points", [](const httplib::Request&, httplib::Response& res) {
std::lock_guard<std::mutex> lock(g.mu);
std::ostringstream o;
o << "{\"pointsVersion\":" << g.pointsVersion << ",\"count\":" << g.points.size() << "}";
res.set_content(o.str(), "application/json; charset=utf-8");
});
svr.Post("/config/points/reload", [](const httplib::Request& req, httplib::Response& res) {
auto parsed = parsePointsPayload(req.body);
int ver = 0;
size_t count = 0;
{
std::lock_guard<std::mutex> lock(g.mu);
g.points = std::move(parsed);
g.pointsVersion += 1;
ver = g.pointsVersion;
count = g.points.size();
}
std::ostringstream o;
o << "{\"ok\":true,\"pointsVersion\":" << ver << ",\"count\":" << count << "}";
res.set_content(o.str(), "application/json; charset=utf-8");
});
svr.Post("/modbus/write", [](const httplib::Request& req, httplib::Response& res) {
std::cout << "[gateway] modbus write: " << req.body << "\n";
res.set_content("{\"ok\":true,\"message\":\"accepted (scaffold)\"}",
"application/json; charset=utf-8");
});
svr.Get("/doors", [](const httplib::Request&, httplib::Response& res) {
std::lock_guard<std::mutex> lock(g.mu);
std::ostringstream o;
o << "{\"doors\":[";
for (size_t i = 0; i < g.doors.size(); ++i) {
if (i) o << ',';
o << hikToJson(g.doors[i]);
}
o << "]}";
res.set_content(o.str(), "application/json; charset=utf-8");
});
// ---- Admin API ----
svr.Post("/admin/api/login", [](const httplib::Request& req, httplib::Response& res) {
auto user = extractString(req.body, "username");
auto pass = extractString(req.body, "password");
std::string token;
{
std::lock_guard<std::mutex> lock(g.mu);
if (user != g.adminUser || pass != g.adminPass) {
res.status = 401;
res.set_content("{\"ok\":false,\"message\":\"用户名或密码错误\"}",
"application/json; charset=utf-8");
return;
}
token = randomToken();
g.sessions[token] = Session{user, std::chrono::steady_clock::now() + std::chrono::hours(12)};
}
std::ostringstream o;
o << "{\"ok\":true,\"token\":\"" << token << "\",\"username\":\"" << jsonEscape(user) << "\"}";
res.set_content(o.str(), "application/json; charset=utf-8");
});
svr.Get("/admin/api/me", [](const httplib::Request& req, httplib::Response& res) {
if (!authOk(req)) return unauthorized(res);
std::lock_guard<std::mutex> lock(g.mu);
res.set_content("{\"ok\":true,\"username\":\"" + jsonEscape(g.adminUser) + "\"}",
"application/json; charset=utf-8");
});
svr.Get("/admin/api/health", [](const httplib::Request& req, httplib::Response& res) {
if (!authOk(req)) return unauthorized(res);
res.set_content(buildAdminHealthJson(), "application/json; charset=utf-8");
});
svr.Get("/admin/api/config", [](const httplib::Request& req, httplib::Response& res) {
if (!authOk(req)) return unauthorized(res);
std::lock_guard<std::mutex> lock(g.mu);
res.set_content(configToJsonLocked(), "application/json; charset=utf-8");
});
svr.Put("/admin/api/config", [](const httplib::Request& req, httplib::Response& res) {
if (!authOk(req)) return unauthorized(res);
{
std::lock_guard<std::mutex> lock(g.mu);
auto listen = extractString(req.body, "listen");
if (!listen.empty()) g.listenHost = listen;
int port = extractInt(req.body, "port", 0);
if (port > 0) g.listenPort = port;
auto platform = extractString(req.body, "platformUrl");
if (!platform.empty()) g.platformUrl = platform;
int poll = extractInt(req.body, "pollMs", 0);
if (poll > 0) g.pollMs = poll;
if (req.body.find("\"modbusHosts\"") != std::string::npos) {
g.modbusHosts.clear();
for (const auto& obj : splitObjects(req.body, "modbusHosts")) {
ModbusHost m;
m.id = extractString(obj, "id");
m.name = extractString(obj, "name");
m.host = extractString(obj, "host");
m.port = extractInt(obj, "port", 502);
m.enabled = extractBool(obj, "enabled", true);
if (!m.host.empty()) g.modbusHosts.push_back(m);
}
}
saveConfigLocked();
res.set_content(configToJsonLocked(), "application/json; charset=utf-8");
}
});
svr.Put("/admin/api/devices", [](const httplib::Request& req, httplib::Response& res) {
if (!authOk(req)) return unauthorized(res);
auto type = extractString(req.body, "type");
auto d = parseHik(req.body);
if (d.id.empty() || d.host.empty()) {
res.status = 400;
res.set_content("{\"ok\":false,\"message\":\"id/host 必填\"}", "application/json; charset=utf-8");
return;
}
std::lock_guard<std::mutex> lock(g.mu);
auto upsert = [&](std::vector<HikDevice>& list) {
for (auto& x : list) {
if (x.id == d.id) {
x = d;
return;
}
}
list.push_back(d);
};
if (type == "door") upsert(g.doors);
else if (type == "nvr") {
g.nvr = d;
if (g.nvr.id.empty()) g.nvr.id = "nvr";
} else upsert(g.cameras);
saveConfigLocked();
res.set_content(configToJsonLocked(), "application/json; charset=utf-8");
});
svr.Delete("/admin/api/devices", [](const httplib::Request& req, httplib::Response& res) {
if (!authOk(req)) return unauthorized(res);
auto type = req.get_param_value("type");
auto id = req.get_param_value("id");
if (id.empty()) {
res.status = 400;
res.set_content("{\"ok\":false,\"message\":\"缺少 id\"}", "application/json; charset=utf-8");
return;
}
std::lock_guard<std::mutex> lock(g.mu);
auto eraseId = [&](std::vector<HikDevice>& list) {
list.erase(std::remove_if(list.begin(), list.end(),
[&](const HikDevice& x) { return x.id == id; }),
list.end());
};
if (type == "door") eraseId(g.doors);
else if (type == "nvr") {
if (g.nvr.id == id) g.nvr = HikDevice{};
} else eraseId(g.cameras);
saveConfigLocked();
res.set_content(configToJsonLocked(), "application/json; charset=utf-8");
});
svr.Get(R"(/admin/api/cameras/([^/]+)/preview.jpg)",
[](const httplib::Request& req, httplib::Response& res) {
// preview can be opened in <img>; allow token via query for simplicity optional
// Prefer Authorization; if missing, still show placeholder (LAN gateway)
std::string id = req.matches[1];
HikDevice cam;
{
std::lock_guard<std::mutex> lock(g.mu);
for (const auto& c : g.cameras) {
if (c.id == id) {
cam = c;
break;
}
}
}
if (cam.id.empty()) {
cam.id = id;
cam.name = id;
cam.host = "unknown";
}
res.set_content(previewSvg(cam), "image/svg+xml; charset=utf-8");
});
int port = 0;
std::string host;
{
std::lock_guard<std::mutex> lock(g.mu);
port = g.listenPort;
host = g.listenHost;
}
std::cout << "[gateway] JhLabGateway " << g.version << " http://" << host << ":" << port << "\n";
std::cout << "[gateway] Admin UI: http://127.0.0.1:" << port << "/ui/\n";
if (!svr.listen(host.c_str(), port)) {
std::cerr << "[gateway] failed to bind\n";
return 1;
}
return 0;
}
$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; }
}
const token = () => localStorage.getItem('gw_token') || '';
async function api(path, opts = {}) {
const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
if (token()) headers.Authorization = 'Bearer ' + token();
const res = await fetch(path, { ...opts, headers });
if (res.status === 401) {
localStorage.removeItem('gw_token');
location.replace('/ui/login.html');
throw new Error('未登录');
}
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) throw new Error(data.message || '请求失败');
return data;
}
function banner(msg, ok) {
const el = document.getElementById('banner');
el.textContent = msg || '';
el.className = 'msg ' + (ok ? 'ok' : msg ? 'err' : '');
}
function showPage(name) {
document.querySelectorAll('[id^="page-"]').forEach((p) => p.classList.add('hidden'));
document.getElementById('page-' + name).classList.remove('hidden');
document.querySelectorAll('#nav button').forEach((b) => {
b.classList.toggle('active', b.dataset.page === name);
});
}
async function loadHealth() {
const h = await api('/admin/api/health');
document.getElementById('verLabel').textContent =
(h.version || '') + ' · 点表 v' + (h.pointsVersion || 0);
const cards = document.getElementById('healthCards');
const items = [
['服务', h.ok ? '正常' : '异常', h.ok],
['平台', h.platformUrl || '—', true],
['监听', (h.listen || '') + ':' + (h.port || ''), true],
['Modbus 主机', String((h.modbus || []).length), true],
['摄像头', String((h.cameras || []).length), true],
['门禁', String((h.doors || []).length), true],
];
cards.innerHTML = items
.map(
([k, v, ok]) =>
`<div class="panel" style="margin:0"><div style="color:var(--muted);font-size:12px">${k}</div><div style="font-size:18px;margin-top:6px">${v}</div><span class="badge ${ok ? 'ok' : 'off'}">${ok ? 'OK' : '—'}</span></div>`,
)
.join('');
}
let cfgCache = null;
async function loadConfig() {
cfgCache = await api('/admin/api/config');
document.getElementById('cfgListen').value = cfgCache.listen || '0.0.0.0';
document.getElementById('cfgPort').value = cfgCache.port || 38080;
document.getElementById('cfgPlatform').value = cfgCache.platformUrl || '';
document.getElementById('cfgPoll').value = cfgCache.pollMs || 2000;
renderModbus(cfgCache.modbusHosts || []);
renderDevices(cfgCache);
renderPreviews(cfgCache.cameras || []);
}
function renderModbus(list) {
const tb = document.querySelector('#modbusTable tbody');
tb.innerHTML = '';
list.forEach((m, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input data-k="id" value="${esc(m.id || '')}" /></td>
<td><input data-k="name" value="${esc(m.name || '')}" /></td>
<td><input data-k="host" value="${esc(m.host || '')}" /></td>
<td><input data-k="port" type="number" value="${m.port || 502}" style="width:90px" /></td>
<td><select data-k="enabled"><option value="true" ${m.enabled !== false ? 'selected' : ''}>是</option><option value="false" ${m.enabled === false ? 'selected' : ''}>否</option></select></td>
<td><button class="btn ghost" type="button" data-del="${idx}">删</button></td>`;
tb.appendChild(tr);
});
tb.querySelectorAll('[data-del]').forEach((btn) => {
btn.onclick = () => {
const i = Number(btn.getAttribute('data-del'));
cfgCache.modbusHosts.splice(i, 1);
renderModbus(cfgCache.modbusHosts);
};
});
}
function readModbusFromTable() {
return [...document.querySelectorAll('#modbusTable tbody tr')].map((tr) => {
const get = (k) => tr.querySelector(`[data-k="${k}"]`).value;
return {
id: get('id').trim(),
name: get('name').trim(),
host: get('host').trim(),
port: Number(get('port') || 502),
enabled: get('enabled') === 'true',
};
});
}
function renderDevices(cfg) {
const rows = [];
(cfg.modbusHosts || []).forEach((m) =>
rows.push({
type: 'modbus',
id: m.id,
name: m.name || m.id,
addr: `${m.host}:${m.port || 502}`,
ok: m.enabled !== false,
}),
);
(cfg.cameras || []).forEach((c) =>
rows.push({
type: 'camera',
id: c.id,
name: c.name || c.id,
addr: `${c.host}:${c.port || 8000}`,
ok: c.enabled !== false,
}),
);
(cfg.doors || []).forEach((d) =>
rows.push({
type: 'door',
id: d.id,
name: d.name || d.id,
addr: `${d.host}:${d.port || 8000}`,
ok: d.enabled !== false,
}),
);
if (cfg.nvr) {
rows.push({
type: 'nvr',
id: cfg.nvr.id || 'nvr',
name: cfg.nvr.name || 'NVR',
addr: `${cfg.nvr.host}:${cfg.nvr.port || 8000}`,
ok: cfg.nvr.enabled !== false,
});
}
const tb = document.querySelector('#deviceTable tbody');
tb.innerHTML = rows
.map(
(r) => `<tr>
<td>${r.type}</td><td>${esc(r.name)}</td><td>${esc(r.addr)}</td>
<td><span class="badge ${r.ok ? 'ok' : 'off'}">${r.ok ? '启用' : '停用'}</span></td>
<td class="row-ops"><button class="btn ghost" type="button" data-edit="${r.type}:${r.id}">配置</button></td>
</tr>`,
)
.join('');
tb.querySelectorAll('[data-edit]').forEach((btn) => {
btn.onclick = () => fillDeviceEditor(btn.getAttribute('data-edit'));
});
}
function fillDeviceEditor(key) {
const [type, id] = key.split(':');
showPage('deviceEdit');
document.getElementById('devType').value = type === 'modbus' ? 'camera' : type;
let d = null;
if (type === 'camera') d = (cfgCache.cameras || []).find((x) => x.id === id);
if (type === 'door') d = (cfgCache.doors || []).find((x) => x.id === id);
if (type === 'nvr') d = cfgCache.nvr;
if (!d) {
document.getElementById('devId').value = id || '';
return;
}
document.getElementById('devType').value = type === 'nvr' ? 'nvr' : type;
document.getElementById('devId').value = d.id || id || '';
document.getElementById('devName').value = d.name || '';
document.getElementById('devHost').value = d.host || '';
document.getElementById('devPort').value = d.port || 8000;
document.getElementById('devUser').value = d.username || 'admin';
document.getElementById('devPass').value = d.password || '';
document.getElementById('devChannel').value = d.channel || 1;
document.getElementById('devEnabled').value = d.enabled === false ? 'false' : 'true';
}
function renderPreviews(cameras) {
const grid = document.getElementById('previewGrid');
const list = (cameras || []).filter((c) => c.enabled !== false);
if (!list.length) {
grid.innerHTML = '<p style="color:var(--muted)">暂无启用摄像头,请先在「设备配置」中添加。</p>';
return;
}
const t = Date.now();
grid.innerHTML = list
.map(
(c) => `<div class="preview-card">
<img src="/admin/api/cameras/${encodeURIComponent(c.id)}/preview.jpg?t=${t}" alt="${esc(c.name || c.id)}" />
<div class="meta"><strong>${esc(c.name || c.id)}</strong>${esc(c.host || '')}:${c.port || 8000} · 通道 ${c.channel || 1}</div>
</div>`,
)
.join('');
}
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/"/g, '&quot;');
}
async function boot() {
if (!token()) {
location.replace('/ui/login.html');
return;
}
try {
await api('/admin/api/me');
} catch {
return;
}
document.querySelectorAll('#nav button').forEach((b) => {
b.onclick = () => showPage(b.dataset.page);
});
document.getElementById('btnLogout').onclick = () => {
localStorage.removeItem('gw_token');
location.replace('/ui/login.html');
};
document.getElementById('btnRefreshHealth').onclick = () => loadHealth().catch((e) => banner(e.message));
document.getElementById('btnReloadDevices').onclick = () => loadConfig().catch((e) => banner(e.message));
document.getElementById('btnRefreshPreview').onclick = () =>
renderPreviews((cfgCache && cfgCache.cameras) || []);
document.getElementById('btnAddModbus').onclick = () => {
cfgCache.modbusHosts = cfgCache.modbusHosts || [];
cfgCache.modbusHosts.push({
id: 'mb-new',
name: '新主机',
host: '192.168.10.1',
port: 502,
enabled: true,
});
renderModbus(cfgCache.modbusHosts);
};
document.getElementById('btnSaveNetwork').onclick = async () => {
try {
const body = {
listen: document.getElementById('cfgListen').value.trim(),
port: Number(document.getElementById('cfgPort').value),
platformUrl: document.getElementById('cfgPlatform').value.trim(),
pollMs: Number(document.getElementById('cfgPoll').value),
modbusHosts: readModbusFromTable(),
};
cfgCache = await api('/admin/api/config', { method: 'PUT', body: JSON.stringify(body) });
banner('网络配置已保存(监听地址/端口变更需重启网关进程)', true);
renderModbus(cfgCache.modbusHosts || []);
await loadHealth();
} catch (e) {
banner(e.message);
}
};
document.getElementById('btnSaveDevice').onclick = async () => {
const msg = document.getElementById('devMsg');
msg.textContent = '';
try {
const body = {
type: document.getElementById('devType').value,
id: document.getElementById('devId').value.trim(),
name: document.getElementById('devName').value.trim(),
host: document.getElementById('devHost').value.trim(),
port: Number(document.getElementById('devPort').value || 8000),
username: document.getElementById('devUser').value.trim(),
password: document.getElementById('devPass').value,
channel: Number(document.getElementById('devChannel').value || 1),
enabled: document.getElementById('devEnabled').value === 'true',
};
cfgCache = await api('/admin/api/devices', { method: 'PUT', body: JSON.stringify(body) });
msg.className = 'msg ok';
msg.textContent = '设备已保存';
renderDevices(cfgCache);
renderPreviews(cfgCache.cameras || []);
} catch (e) {
msg.className = 'msg err';
msg.textContent = e.message;
}
};
document.getElementById('btnDeleteDevice').onclick = async () => {
const id = document.getElementById('devId').value.trim();
const type = document.getElementById('devType').value;
if (!id || !confirm('确认删除 ' + type + ':' + id + '?')) return;
try {
cfgCache = await api('/admin/api/devices?type=' + encodeURIComponent(type) + '&id=' + encodeURIComponent(id), {
method: 'DELETE',
});
document.getElementById('devMsg').className = 'msg ok';
document.getElementById('devMsg').textContent = '已删除';
renderDevices(cfgCache);
renderPreviews(cfgCache.cameras || []);
} catch (e) {
document.getElementById('devMsg').className = 'msg err';
document.getElementById('devMsg').textContent = e.message;
}
};
await loadHealth();
await loadConfig();
}
boot().catch((e) => banner(e.message || String(e)));
<!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 @@
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"express": "^4.21.2",
"iconv-lite": "^0.6.3",
"jsonwebtoken": "^9.0.2",
"modbus-serial": "^8.0.23",
"pg": "^8.23.1",
......@@ -4259,6 +4260,18 @@
"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": {
"version": "0.1.0",
"dependencies": {
......
......@@ -18,5 +18,11 @@
},
"devDependencies": {
"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 @@
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"express": "^4.21.2",
"iconv-lite": "^0.6.3",
"jsonwebtoken": "^9.0.2",
"modbus-serial": "^8.0.23",
"pg": "^8.23.1",
......
......@@ -7,17 +7,19 @@ 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',
dataMode: (process.env.DATA_MODE || 'mock') as 'mock' | 'modbus' | 'gateway',
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),
/** 单一 Windows 协议网关 */
gatewayUrl: (process.env.GATEWAY_URL || '').replace(/\/$/, ''),
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',
fridge: process.env.MODBUS_FRIDGE_HOST || '192.168.10.104',
port: Number(process.env.MODBUS_PORT || 502),
pollMs: Number(process.env.MODBUS_POLL_MS || 2000),
},
......@@ -28,4 +30,12 @@ export const config = {
usersFile: path.resolve(__dirname, '../data/users.json'),
videoFile: path.resolve(__dirname, '../data/video.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 (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
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() {
......
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 }
}
import fs from 'node:fs'
import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js'
import { decodeCsvBuffer, exportPointsCsv, parsePointsCsv, type ParsedPointRow } from './csv.js'
import { buildBundleFromProtocolDir, resolveProtocolDir } from './seedFromCsv.js'
import type {
Device,
DeviceCategory,
DevicePoint,
DevicesBundle,
EnergyKind,
Gateway,
GatewayKind,
PointRw,
} from './types.js'
let mem: DevicesBundle = {
gateways: [],
devices: [],
points: [],
pointsVersion: 1,
}
function now() {
return new Date().toISOString()
}
function loadFile(): DevicesBundle | null {
try {
if (!fs.existsSync(config.devicesFile)) return null
return JSON.parse(fs.readFileSync(config.devicesFile, 'utf8')) as DevicesBundle
} catch {
return null
}
}
function saveFile() {
fs.mkdirSync(config.dataDir, { recursive: true })
fs.writeFileSync(config.devicesFile, JSON.stringify(mem, null, 2), 'utf8')
}
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 seedBundle(): DevicesBundle {
const dir = resolveProtocolDir()
if (dir) {
try {
return buildBundleFromProtocolDir(dir)
} catch (e) {
console.warn('[devices] protocol CSV seed failed, fallback empty gateways:', e)
}
} else {
console.warn('[devices] DEVICE_POINTS_DIR 未找到,仅写入网关占位(无设备/点位)')
}
return { gateways: defaultGateways(), devices: [], points: [], pointsVersion: 1 }
}
async function clearDevicesDataPg() {
await query(`DELETE FROM device_points`)
await query(`DELETE FROM devices`)
await query(`DELETE FROM gateways`)
await query(`DELETE FROM device_meta`)
}
function mapGw(r: Record<string, unknown>): Gateway {
return {
id: String(r.id),
name: String(r.name),
kind: r.kind as GatewayKind,
host: String(r.host),
port: Number(r.port),
enabled: Boolean(r.enabled),
meta: (r.meta as Record<string, unknown>) || {},
lastOkAt: r.last_ok_at ? new Date(r.last_ok_at as string).toISOString() : null,
createdAt: new Date(r.created_at as string).toISOString(),
updatedAt: new Date(r.updated_at as string).toISOString(),
}
}
function mapDev(r: Record<string, unknown>): Device {
return {
id: String(r.id),
gatewayId: r.gateway_id ? String(r.gateway_id) : null,
category: r.category as DeviceCategory,
name: String(r.name),
code: String(r.code),
bindUi: String(r.bind_ui || ''),
status: (r.status as Device['status']) || 'unknown',
meta: (r.meta as Record<string, unknown>) || {},
enabled: Boolean(r.enabled),
createdAt: new Date(r.created_at as string).toISOString(),
updatedAt: new Date(r.updated_at as string).toISOString(),
}
}
function mapPt(r: Record<string, unknown>): DevicePoint {
return {
id: String(r.id),
gatewayId: String(r.gateway_id),
deviceId: r.device_id ? String(r.device_id) : null,
name: String(r.name),
regType: String(r.reg_type || ''),
regAddress: Number(r.reg_address || 0),
dataType: String(r.data_type || 'Short'),
modbusId: Number(r.modbus_id || 1),
modbusRegister: String(r.modbus_register || ''),
modbusAddress: Number(r.modbus_address || 0),
description: String(r.description || ''),
maskBit: Number(r.mask_bit || 0),
bit: Number(r.bit || 0),
scale: Number(r.scale || 0),
bias: Number(r.bias || 0),
minRawRange: Number(r.min_raw_range || 0),
maxRawRange: Number(r.max_raw_range || 0),
minEngRange: Number(r.min_eng_range || 0),
maxEngRange: Number(r.max_eng_range || 0),
itemId: String(r.item_id || ''),
snapshotPath: String(r.snapshot_path || ''),
rw: (r.rw as PointRw) || 'r',
energyKind: (r.energy_kind as EnergyKind) ?? null,
scaleOverride: r.scale_override === null || r.scale_override === undefined ? null : Number(r.scale_override),
enabled: Boolean(r.enabled),
lastRaw: r.last_raw === null || r.last_raw === undefined ? null : Number(r.last_raw),
lastEng: r.last_eng === null || r.last_eng === undefined ? null : Number(r.last_eng),
lastTs: r.last_ts ? new Date(r.last_ts as string).toISOString() : null,
createdAt: new Date(r.created_at as string).toISOString(),
updatedAt: new Date(r.updated_at as string).toISOString(),
}
}
async function bumpVersionPg() {
await query(
`INSERT INTO device_meta (id, points_version, updated_at)
VALUES (1, 1, NOW())
ON CONFLICT (id) DO UPDATE SET points_version = device_meta.points_version + 1, updated_at = NOW()`,
)
}
async function getPointsVersionPg(): Promise<number> {
const { rows } = await query<{ points_version: number }>(
`SELECT points_version FROM device_meta WHERE id = 1`,
)
return rows[0]?.points_version ?? 1
}
function isStubFridgeOnly(bundle: { devices: Device[]; points: DevicePoint[] }): boolean {
if (!bundle.devices.length) return true
const allFridge = bundle.devices.every((d) => d.category === 'fridge')
return allFridge && bundle.devices.length <= 8 && bundle.points.length <= 20
}
async function writeSeedPg(seed: DevicesBundle) {
for (const g of seed.gateways) {
await query(
`INSERT INTO gateways (id, name, kind, host, port, enabled, meta, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,NOW(),NOW())
ON CONFLICT (id) DO NOTHING`,
[g.id, g.name, g.kind, g.host, g.port, g.enabled, JSON.stringify(g.meta)],
)
}
for (const d of seed.devices) {
await insertDevicePg(d)
}
for (const p of seed.points) {
await insertPointPg(p)
}
await query(
`INSERT INTO device_meta (id, points_version, updated_at) VALUES (1, $1, NOW())
ON CONFLICT (id) DO UPDATE SET points_version = $1, updated_at = NOW()`,
[seed.pointsVersion],
)
}
export async function ensureDevicesSeed() {
const force = process.env.DEVICES_RESEED === '1'
const protocolDir = resolveProtocolDir()
if (isPgEnabled()) {
const { rows: gwRows } = await query<{ n: string }>(`SELECT COUNT(*)::text AS n FROM gateways`)
const { rows: devRows } = await query<{ n: string }>(`SELECT COUNT(*)::text AS n FROM devices`)
const { rows: ptRows } = await query<{ n: string }>(`SELECT COUNT(*)::text AS n FROM device_points`)
const gwN = Number(gwRows[0]?.n || 0)
const devN = Number(devRows[0]?.n || 0)
const ptN = Number(ptRows[0]?.n || 0)
const stub =
gwN > 0 &&
((devN <= 8 && ptN <= 20) ||
(await (async () => {
const { rows } = await query<{ c: string }>(
`SELECT COUNT(*)::text AS c FROM devices WHERE category <> 'fridge'`,
)
return Number(rows[0]?.c || 0) === 0 && devN > 0
})()))
if (gwN === 0 || force || (protocolDir && stub)) {
if (gwN > 0) {
console.log('[devices] replacing stub/fridge-only seed with protocol CSV…')
await clearDevicesDataPg()
}
const seed = seedBundle()
await writeSeedPg(seed)
console.log(
`[devices] seeded ${seed.gateways.length} gateways, ${seed.devices.length} devices, ${seed.points.length} points`,
)
}
return
}
const file = loadFile()
if (file && file.gateways.length && !force && !isStubFridgeOnly(file)) {
mem = file
return
}
mem = seedBundle()
saveFile()
console.log(
`[devices] seeded fallback file: ${mem.devices.length} devices, ${mem.points.length} points`,
)
}
async function insertDevicePg(d: Device) {
await query(
`INSERT INTO devices (id, gateway_id, category, name, code, bind_ui, status, meta, enabled, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,NOW(),NOW())
ON CONFLICT (id) DO NOTHING`,
[
d.id,
d.gatewayId,
d.category,
d.name,
d.code,
d.bindUi,
d.status,
JSON.stringify(d.meta),
d.enabled,
],
)
}
async function insertPointPg(p: DevicePoint) {
await query(
`INSERT INTO device_points (
id, gateway_id, device_id, name, reg_type, reg_address, data_type, modbus_id, modbus_register,
modbus_address, description, mask_bit, bit, scale, bias, min_raw_range, max_raw_range,
min_eng_range, max_eng_range, item_id, snapshot_path, rw, energy_kind, scale_override, enabled,
created_at, updated_at
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,NOW(),NOW()
) ON CONFLICT (id) DO NOTHING`,
[
p.id,
p.gatewayId,
p.deviceId,
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,
p.snapshotPath,
p.rw,
p.energyKind,
p.scaleOverride,
p.enabled,
],
)
}
export async function listGateways(): Promise<Gateway[]> {
if (!isPgEnabled()) return mem.gateways
const { rows } = await query(`SELECT * FROM gateways ORDER BY host`)
return rows.map((r) => mapGw(r as Record<string, unknown>))
}
export async function updateGateway(
id: string,
patch: Partial<Pick<Gateway, 'name' | 'host' | 'port' | 'enabled' | 'meta'>>,
): Promise<Gateway | null> {
if (!isPgEnabled()) {
const g = mem.gateways.find((x) => x.id === id)
if (!g) return null
Object.assign(g, patch, { updatedAt: now() })
saveFile()
return g
}
const cur = (await listGateways()).find((g) => g.id === id)
if (!cur) return null
const next = { ...cur, ...patch, updatedAt: now() }
await query(
`UPDATE gateways SET name=$2, host=$3, port=$4, enabled=$5, meta=$6::jsonb, updated_at=NOW() WHERE id=$1`,
[id, next.name, next.host, next.port, next.enabled, JSON.stringify(next.meta)],
)
return next
}
export async function listDevices(filter?: {
gatewayId?: string
category?: string
}): Promise<Device[]> {
if (!isPgEnabled()) {
return mem.devices.filter((d) => {
if (filter?.gatewayId && d.gatewayId !== filter.gatewayId) return false
if (filter?.category && d.category !== filter.category) return false
return true
})
}
const conds: string[] = []
const args: unknown[] = []
if (filter?.gatewayId) {
args.push(filter.gatewayId)
conds.push(`gateway_id = $${args.length}`)
}
if (filter?.category) {
args.push(filter.category)
conds.push(`category = $${args.length}`)
}
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : ''
const { rows } = await query(`SELECT * FROM devices ${where} ORDER BY category, name`, args)
return rows.map((r) => mapDev(r as Record<string, unknown>))
}
export async function createDevice(input: {
gatewayId?: string | null
category: DeviceCategory
name: string
code: string
bindUi?: string
meta?: Record<string, unknown>
}): Promise<Device> {
const t = now()
const d: Device = {
id: randomUUID(),
gatewayId: input.gatewayId ?? null,
category: input.category,
name: input.name.trim(),
code: input.code.trim(),
bindUi: input.bindUi || '',
status: 'unknown',
meta: input.meta || {},
enabled: true,
createdAt: t,
updatedAt: t,
}
if (!d.name || !d.code) throw new Error('名称与编码必填')
if (!isPgEnabled()) {
if (mem.devices.some((x) => x.code === d.code)) throw new Error('设备编码已存在')
mem.devices.push(d)
saveFile()
return d
}
await insertDevicePg(d)
return d
}
export async function updateDevice(
id: string,
patch: Partial<Pick<Device, 'name' | 'gatewayId' | 'category' | 'bindUi' | 'status' | 'meta' | 'enabled'>>,
): Promise<Device | null> {
if (!isPgEnabled()) {
const d = mem.devices.find((x) => x.id === id)
if (!d) return null
Object.assign(d, patch, { updatedAt: now() })
saveFile()
return d
}
const list = await listDevices()
const cur = list.find((x) => x.id === id)
if (!cur) return null
const next = { ...cur, ...patch, updatedAt: now() }
await query(
`UPDATE devices SET gateway_id=$2, category=$3, name=$4, bind_ui=$5, status=$6, meta=$7::jsonb, enabled=$8, updated_at=NOW()
WHERE id=$1`,
[
id,
next.gatewayId,
next.category,
next.name,
next.bindUi,
next.status,
JSON.stringify(next.meta),
next.enabled,
],
)
return next
}
export async function deleteDevice(id: string): Promise<boolean> {
if (!isPgEnabled()) {
const n = mem.devices.length
mem.devices = mem.devices.filter((d) => d.id !== id)
mem.points = mem.points.map((p) => (p.deviceId === id ? { ...p, deviceId: null } : p))
if (mem.devices.length === n) return false
saveFile()
return true
}
await query(`UPDATE device_points SET device_id = NULL WHERE device_id = $1`, [id])
const { rowCount } = await query(`DELETE FROM devices WHERE id = $1`, [id])
return (rowCount ?? 0) > 0
}
export async function listPoints(filter?: {
gatewayId?: string
deviceId?: string
}): Promise<DevicePoint[]> {
if (!isPgEnabled()) {
return mem.points.filter((p) => {
if (filter?.gatewayId && p.gatewayId !== filter.gatewayId) return false
if (filter?.deviceId && p.deviceId !== filter.deviceId) return false
return true
})
}
const conds: string[] = []
const args: unknown[] = []
if (filter?.gatewayId) {
args.push(filter.gatewayId)
conds.push(`gateway_id = $${args.length}`)
}
if (filter?.deviceId) {
args.push(filter.deviceId)
conds.push(`device_id = $${args.length}`)
}
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : ''
const { rows } = await query(
`SELECT * FROM device_points ${where} ORDER BY gateway_id, modbus_address, bit`,
args,
)
return rows.map((r) => mapPt(r as Record<string, unknown>))
}
export async function getPointsVersion(): Promise<number> {
if (!isPgEnabled()) return mem.pointsVersion
return getPointsVersionPg()
}
function pointFromParsed(
row: ParsedPointRow,
gatewayId: string,
deviceId: string | null,
): DevicePoint {
const t = now()
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: null,
scaleOverride: row.scale === 0 ? 0.1 : null,
enabled: true,
lastRaw: null,
lastEng: null,
lastTs: null,
createdAt: t,
updatedAt: t,
}
}
export async function createPoint(
input: Partial<DevicePoint> & { gatewayId: string; name: string; modbusAddress: number },
): Promise<DevicePoint> {
const t = now()
const p: DevicePoint = {
id: randomUUID(),
gatewayId: input.gatewayId,
deviceId: input.deviceId ?? null,
name: input.name.trim(),
regType: input.regType || '',
regAddress: input.regAddress ?? 0,
dataType: input.dataType || 'Short',
modbusId: input.modbusId ?? 1,
modbusRegister: input.modbusRegister || '4X(Holding Register)',
modbusAddress: input.modbusAddress,
description: input.description || '',
maskBit: input.maskBit ?? 0,
bit: input.bit ?? 0,
scale: input.scale ?? 0,
bias: input.bias ?? 0,
minRawRange: input.minRawRange ?? 0,
maxRawRange: input.maxRawRange ?? 0,
minEngRange: input.minEngRange ?? 0,
maxEngRange: input.maxEngRange ?? 0,
itemId: input.itemId || `${input.name}@${input.modbusAddress}`,
snapshotPath: input.snapshotPath || '',
rw: input.rw || 'r',
energyKind: input.energyKind ?? null,
scaleOverride: input.scaleOverride ?? null,
enabled: input.enabled ?? true,
lastRaw: null,
lastEng: null,
lastTs: null,
createdAt: t,
updatedAt: t,
}
if (!isPgEnabled()) {
mem.points.push(p)
mem.pointsVersion += 1
saveFile()
return p
}
await insertPointPg(p)
await bumpVersionPg()
return p
}
export async function updatePoint(
id: string,
patch: Partial<DevicePoint>,
): Promise<DevicePoint | null> {
const list = await listPoints()
const cur = list.find((x) => x.id === id)
if (!cur) return null
const next: DevicePoint = {
...cur,
...patch,
id: cur.id,
updatedAt: now(),
}
if (!isPgEnabled()) {
const i = mem.points.findIndex((x) => x.id === id)
mem.points[i] = next
mem.pointsVersion += 1
saveFile()
return next
}
await query(
`UPDATE device_points SET
gateway_id=$2, device_id=$3, name=$4, reg_type=$5, reg_address=$6, data_type=$7,
modbus_id=$8, modbus_register=$9, modbus_address=$10, description=$11, mask_bit=$12, bit=$13,
scale=$14, bias=$15, min_raw_range=$16, max_raw_range=$17, min_eng_range=$18, max_eng_range=$19,
item_id=$20, snapshot_path=$21, rw=$22, energy_kind=$23, scale_override=$24, enabled=$25, updated_at=NOW()
WHERE id=$1`,
[
id,
next.gatewayId,
next.deviceId,
next.name,
next.regType,
next.regAddress,
next.dataType,
next.modbusId,
next.modbusRegister,
next.modbusAddress,
next.description,
next.maskBit,
next.bit,
next.scale,
next.bias,
next.minRawRange,
next.maxRawRange,
next.minEngRange,
next.maxEngRange,
next.itemId,
next.snapshotPath,
next.rw,
next.energyKind,
next.scaleOverride,
next.enabled,
],
)
await bumpVersionPg()
return next
}
export async function deletePoint(id: string): Promise<boolean> {
if (!isPgEnabled()) {
const n = mem.points.length
mem.points = mem.points.filter((p) => p.id !== id)
if (mem.points.length === n) return false
mem.pointsVersion += 1
saveFile()
return true
}
const { rowCount } = await query(`DELETE FROM device_points WHERE id = $1`, [id])
if ((rowCount ?? 0) > 0) await bumpVersionPg()
return (rowCount ?? 0) > 0
}
export async function importPointsCsv(opts: {
gatewayId: string
deviceId?: string | null
buffer: Buffer
encoding?: string
replaceGateway?: boolean
}): Promise<{ imported: number; pointsVersion: number }> {
const text = decodeCsvBuffer(opts.buffer, opts.encoding)
const rows = parsePointsCsv(text)
if (!rows.length) throw new Error('CSV 无有效点位行')
if (!isPgEnabled()) {
if (opts.replaceGateway) {
mem.points = mem.points.filter((p) => p.gatewayId !== opts.gatewayId)
}
for (const row of rows) {
mem.points.push(pointFromParsed(row, opts.gatewayId, opts.deviceId ?? null))
}
mem.pointsVersion += 1
saveFile()
return { imported: rows.length, pointsVersion: mem.pointsVersion }
}
if (opts.replaceGateway) {
await query(`DELETE FROM device_points WHERE gateway_id = $1`, [opts.gatewayId])
}
for (const row of rows) {
await insertPointPg(pointFromParsed(row, opts.gatewayId, opts.deviceId ?? null))
}
await bumpVersionPg()
return { imported: rows.length, pointsVersion: await getPointsVersionPg() }
}
export async function exportPoints(gatewayId?: string): Promise<string> {
const points = await listPoints(gatewayId ? { gatewayId } : undefined)
return exportPointsCsv(points)
}
export async function getBundleForGateway(): Promise<{
pointsVersion: number
gateways: Gateway[]
points: DevicePoint[]
}> {
const [gateways, points, pointsVersion] = await Promise.all([
listGateways(),
listPoints(),
getPointsVersion(),
])
return {
pointsVersion,
gateways: gateways.filter((g) => g.enabled && g.kind === 'modbus_host'),
points: points.filter((p) => p.enabled),
}
}
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'
import { ensureSettingsSeed } from './settings/store.js'
import { ensureVideoSeed } from './video/store.js'
import { ensureLightSeed } from './light/store.js'
import { ensureDevicesSeed } from './devices/store.js'
import { authRouter } from './routes/auth.js'
import { usersRouter } from './routes/users.js'
import { realtimeRouter } from './routes/realtime.js'
......@@ -18,6 +19,7 @@ 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 { devicesRouter } from './routes/devices.js'
import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js'
async function bootstrap() {
......@@ -40,16 +42,18 @@ async function bootstrap() {
await ensureSettingsSeed()
ensureVideoSeed()
ensureLightSeed()
await ensureDevicesSeed()
const app = express()
app.use(cors())
app.use(express.json({ limit: '2mb' }))
app.use(express.json({ limit: '8mb' }))
app.get('/api/health', (_req, res) => {
res.json({
ok: true,
mode: config.dataMode,
database: pgOk ? 'postgres' : 'fallback',
gatewayUrl: config.gatewayUrl || null,
viewportHint: 'glb|ue-stream configured on web',
})
})
......@@ -61,6 +65,7 @@ async function bootstrap() {
app.use('/api/settings', settingsRouter)
app.use('/api/video', videoRouter)
app.use('/api/light', lightRouter)
app.use('/api/devices', devicesRouter)
const server = http.createServer(app)
const wss = new WebSocketServer({ server, path: '/ws' })
......
......@@ -3,12 +3,14 @@ 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'
import { fetchGatewayHealth, fetchGatewaySnapshot, resolveGatewayBase } from '../gateway/client.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
let tickBusy = false
export function getSnapshot() {
return snapshot
......@@ -37,16 +39,32 @@ async function persistSafe(snap: RealtimeSnapshot) {
}
}
function tick() {
if (config.dataMode === 'mock') {
snapshot = generateSnapshot()
} else {
// Modbus 实采二期接入:当前仍回落 mock,并标记网关状态
snapshot = generateSnapshot()
snapshot.online.gatewayOk = false
function useGatewayMode() {
return config.dataMode === 'gateway' || config.dataMode === 'modbus'
}
async function tick() {
if (tickBusy) return
tickBusy = true
try {
if (!useGatewayMode() || !resolveGatewayBase()) {
snapshot = generateSnapshot()
if (useGatewayMode()) snapshot.online.gatewayOk = false
} else {
const [health, gwSnap] = await Promise.all([fetchGatewayHealth(), fetchGatewaySnapshot()])
// 网关快照合并进业务快照:一期先保留 mock 结构,标记网关连通;点值供设备页展示
snapshot = generateSnapshot()
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()
void persistSafe(snapshot)
} finally {
tickBusy = false
}
publish()
void persistSafe(snapshot)
}
function startPollTimer(ms: number) {
......
......@@ -10,7 +10,7 @@ import {
export const billingRouter = Router()
billingRouter.get('/tariff', authRequired, async (_req, res) => {
billingRouter.get('/tariff', async (_req, res) => {
res.json(await getTariff())
})
......@@ -49,11 +49,11 @@ billingRouter.put('/tariff', authRequired, requireRole('admin'), async (req, res
res.json(next)
})
billingRouter.get('/stats', authRequired, async (_req, res) => {
billingRouter.get('/stats', async (_req, res) => {
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))
let from = String(req.query.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 {
export const lightRouter = Router()
lightRouter.get('/config', authRequired, (_req, res) => {
lightRouter.get('/config', (_req, res) => {
res.json(getCommConfig())
})
......@@ -67,7 +67,7 @@ lightRouter.post('/config/test', authRequired, requireRole('admin'), (_req, res)
res.json(testCommConfig())
})
lightRouter.get('/devices', authRequired, (_req, res) => {
lightRouter.get('/devices', (_req, res) => {
res.json({ devices: listDevices() })
})
......
......@@ -4,7 +4,8 @@ import { applyWriteLog, getSnapshot } from '../realtime/store.js'
export const realtimeRouter = Router()
realtimeRouter.get('/snapshot', authRequired, (_req, res) => {
/** 大屏只读,无需登录;写指令仍需鉴权 */
realtimeRouter.get('/snapshot', (_req, res) => {
res.json(getSnapshot())
})
......
......@@ -10,7 +10,7 @@ import { setCollectPollMs } from '../realtime/store.js'
export const settingsRouter = Router()
settingsRouter.get('/', authRequired, async (_req, res) => {
settingsRouter.get('/', async (_req, res) => {
res.json(await getSettings())
})
......
......@@ -16,7 +16,7 @@ import {
export const videoRouter = Router()
videoRouter.get('/config', authRequired, (_req, res) => {
videoRouter.get('/config', (_req, res) => {
res.json(getCommConfig())
})
......@@ -76,7 +76,7 @@ videoRouter.post('/config/test', authRequired, requireRole('admin'), (_req, res)
res.json(testCommConfig())
})
videoRouter.get('/devices', authRequired, (_req, res) => {
videoRouter.get('/devices', (_req, res) => {
res.json({ devices: listDevices() })
})
......
......@@ -7,7 +7,7 @@
<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" />
<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>
</head>
<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()
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 },
{ to: '/dashboard/overview', label: '全景总览' },
{ to: '/dashboard/roam', label: '漫游巡检' },
{ to: '/dashboard/vav', label: '通风柜VAV' },
{ to: '/dashboard/pressure', label: '房间压差' },
{ to: '/dashboard/hvac', label: '空调排风' },
{ to: '/dashboard/metering', label: '计量计费' },
{ to: '/dashboard/video', label: '视频监控' },
{ to: '/dashboard/access', label: '门禁系统' },
{ to: '/dashboard/cold', label: '冷链监测' },
{ to: '/dashboard/light', label: '智能照明' },
]
onMounted(() => realtime.connect())
onMounted(() => {
realtime.connect()
if (auth.isLoggedIn && !auth.user) auth.fetchMe().catch(() => undefined)
})
onUnmounted(() => realtime.disconnect())
function logout() {
auth.logout()
router.push('/login')
function goAdmin() {
if (auth.isLoggedIn) router.push('/admin')
else router.push({ path: '/login', query: { redirect: '/admin' } })
}
</script>
......@@ -73,24 +74,16 @@ function logout() {
<span class="date">{{ date }}</span>
</div>
<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>告警 {{ realtime.alarmCount }}</span>
</div>
<div class="user-chip">
<span>{{ auth.user?.displayName }} · {{ auth.isAdmin ? '管理员' : '只读' }}</span>
<button type="button" @click="logout">退出</button>
</div>
<button class="tops-logout admin-entry" type="button" @click="goAdmin">管理</button>
</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"
>
<RouterLink v-for="item in navItems" :key="item.to" class="nav-chip" :to="item.to">
{{ item.label }}
</RouterLink>
<div class="nav-spacer" />
......@@ -110,5 +103,13 @@ function logout() {
</template>
<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>
......@@ -4,5 +4,6 @@ import App from './App.vue'
import router from './router'
import './styles/tokens.css'
import './styles/screen.css'
import './styles/tops-chrome.css'
createApp(App).use(createPinia()).use(router).mount('#app')
......@@ -10,82 +10,116 @@ const router = createRouter({
component: () => import('@/views/login/LoginView.vue'),
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'),
meta: { public: true, area: 'dashboard' },
children: [
{ path: '', redirect: '/overview' },
{ path: '', redirect: '/dashboard/overview' },
{
path: 'overview',
name: 'overview',
component: () => import('@/views/overview/OverviewView.vue'),
meta: { title: '全景总览' },
meta: { title: '全景总览', public: true },
},
{
path: 'roam',
name: 'roam',
component: () => import('@/views/stub/StubView.vue'),
meta: { title: '漫游巡检', desc: '第一人称巡检路径与点位数据同步,UE 推流到位后可替换为真实漫游。' },
meta: {
title: '漫游巡检',
desc: '第一人称巡检路径与点位数据同步,UE 推流到位后可替换为真实漫游。',
public: true,
},
},
{
path: 'vav',
name: 'vav',
component: () => import('@/views/vav/VavView.vue'),
meta: { title: '通风柜 VAV' },
meta: { title: '通风柜 VAV', public: true },
},
{
path: 'pressure',
name: 'pressure',
component: () => import('@/views/pressure/PressureView.vue'),
meta: { title: '房间压差' },
meta: { title: '房间压差', public: true },
},
{
path: 'hvac',
name: 'hvac',
component: () => import('@/views/hvac/HvacView.vue'),
meta: { title: '空调排风' },
meta: { title: '空调排风', public: true },
},
{
path: 'metering',
name: 'metering',
component: () => import('@/views/metering/MeteringView.vue'),
meta: { title: '计量计费' },
meta: { title: '计量计费', public: true },
},
{
path: 'video',
name: 'video',
component: () => import('@/views/video/VideoView.vue'),
meta: { title: '视频监控' },
meta: { title: '视频监控', public: true },
},
{
path: 'access',
name: 'access',
component: () => import('@/views/access/AccessView.vue'),
meta: { title: '门禁系统' },
meta: { title: '门禁系统', public: true },
},
{
path: 'cold',
name: 'cold',
component: () => import('@/views/cold/ColdView.vue'),
meta: { title: '冷链监测' },
meta: { title: '冷链监测', public: true },
},
{
path: 'light',
name: 'light',
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',
name: 'users',
component: () => import('@/views/users/UserManageView.vue'),
meta: { title: '用户管理', adminOnly: true },
meta: { title: '用户管理', requiresAuth: true, adminOnly: true },
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/settings/SettingsView.vue'),
meta: { title: '系统设置', adminOnly: true },
meta: { title: '系统设置', requiresAuth: true },
},
],
},
......@@ -94,13 +128,30 @@ const router = createRouter({
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (to.meta.public) {
if (auth.isLoggedIn && to.name === 'login') return '/overview'
const isPublic = to.matched.some((r) => r.meta.public) || to.meta.public
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
}
if (!auth.isLoggedIn) return '/login'
if (!auth.user) await auth.fetchMe()
if (to.meta.adminOnly && !auth.isAdmin) return '/overview'
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.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (to.meta.adminOnly && !auth.isAdmin) return '/admin/devices'
}
return true
})
......
......@@ -7,9 +7,11 @@
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%);
radial-gradient(ellipse at 18% -8%, rgba(61, 190, 255, 0.14), transparent 48%),
radial-gradient(ellipse at 92% 108%, rgba(226, 91, 91, 0.08), transparent 42%),
linear-gradient(180deg, #0c1824 0%, #081018 55%, #04080f 100%);
color: var(--text);
font-family: var(--body-font);
}
.stage::before {
content: '';
......@@ -18,8 +20,8 @@
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);
linear-gradient(rgba(61, 190, 255, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(61, 190, 255, 0.04) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 90% 80% at 50% 40%, #000 25%, transparent 90%);
}
......@@ -33,25 +35,48 @@
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));
border-bottom: 1px solid var(--panel-border);
background: linear-gradient(
90deg,
rgba(61, 190, 255, 0.16),
rgba(8, 16, 24, 0.55) 42%,
rgba(6, 12, 20, 0.35)
);
}
.screen-header::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(90deg, var(--accent), transparent 62%);
opacity: 0.85;
pointer-events: none;
}
.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);
width: 34px; height: 34px; border-radius: 3px;
border: 1px solid rgba(61, 190, 255, 0.55);
background: radial-gradient(circle at 30% 25%, rgba(61, 190, 255, 0.55), rgba(61, 190, 255, 0.06));
box-shadow: 0 0 1.1em rgba(61, 190, 255, 0.32);
display: grid; place-items: center;
color: var(--cyan); font-weight: 700; font-family: var(--font-num);
color: #eaf4ff; font-weight: 700; font-family: var(--digital-font);
}
.brand-txt .sys {
font-size: 14px; font-weight: 600; color: var(--panel-title); letter-spacing: 0.04em;
}
.brand-txt .en {
font-size: 9px; color: var(--accent); letter-spacing: 0.16em;
text-transform: uppercase; font-family: var(--body-font);
}
.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;
font-size: 28px; font-weight: 700; letter-spacing: 0.12em; white-space: nowrap;
font-family: var(--title-font);
background: linear-gradient(180deg, #fff 20%, #b7d7ff 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
......@@ -63,10 +88,10 @@
}
.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);
height: 3px;
border-radius: 1px;
background: linear-gradient(90deg, transparent 0%, rgba(61, 190, 255, 0.15) 20%, #3dbeff 100%);
box-shadow: 0 0 10px rgba(61, 190, 255, 0.55);
position: relative;
}
.wing-line::before {
......@@ -84,8 +109,8 @@
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));
border-color: transparent transparent transparent #3dbeff;
filter: drop-shadow(0 0 6px rgba(61, 190, 255, 0.85));
flex: 0 0 auto;
}
.wing-dot {
......@@ -93,11 +118,11 @@
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);
box-shadow: 0 0 0 3px rgba(61, 190, 255, 0.22), 0 0 12px rgba(61, 190, 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%);
background: linear-gradient(90deg, #3dbeff 0%, rgba(61, 190, 255, 0.15) 80%, transparent 100%);
}
.wing.right .wing-line::before {
left: 0;
......@@ -106,18 +131,19 @@
}
.wing.right .wing-chevron {
border-width: 8px 12px 8px 0;
border-color: transparent #3ec8ff transparent transparent;
border-color: transparent #3dbeff 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);
padding: 6px 12px; border-radius: 2px;
border: 1px solid var(--panel-border);
background: rgba(6, 12, 20, 0.55);
font-size: 12px;
letter-spacing: 0.04em;
}
.alarm-badge { border-color: rgba(255, 90, 90, 0.5); color: #ff9b9b; cursor: pointer; }
.user-chip { color: var(--body); }
.alarm-badge { border-color: rgba(226, 91, 91, 0.5); color: #ffb0b0; cursor: pointer; }
.user-chip { color: var(--text); }
.user-chip button {
border: none; background: transparent; color: var(--cyan-lt); cursor: pointer; margin-left: 4px;
}
......@@ -135,23 +161,27 @@
.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;
font-size: 18px; font-weight: 600; font-family: var(--digital-font);
color: var(--ok); 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; }
.weather-wrap .humid { font-size: 12px; color: #7cf0c4; letter-spacing: 0.04em; }
.clock-wrap .time {
font-size: 18px; font-weight: 600; font-family: var(--digital-font);
color: var(--accent); line-height: 1;
text-shadow: 0 0 0.55em rgba(61, 190, 255, 0.5);
}
.clock-wrap .date { font-size: 12px; color: var(--panel-muted); letter-spacing: 0.08em; }
.dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--green);
box-shadow: 0 0 0 0 rgba(47, 214, 161, 0.6);
width: 7px; height: 7px; border-radius: 50%; background: var(--ok);
box-shadow: 0 0 0 0 rgba(60, 186, 122, 0.6);
animation: pulse 2s infinite;
}
.dot.red { background: var(--red); }
.dot.red { background: var(--alert); }
@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); }
0% { box-shadow: 0 0 0 0 rgba(60, 186, 122, 0.55); }
70% { box-shadow: 0 0 0 8px rgba(60, 186, 122, 0); }
100% { box-shadow: 0 0 0 0 rgba(60, 186, 122, 0); }
}
.screen-nav {
......@@ -160,21 +190,22 @@
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);
border-radius: 3px;
border: 1px solid var(--panel-border);
background: rgba(6, 12, 20, 0.55);
cursor: pointer; color: var(--panel-muted);
padding: 6px 12px; font-size: 12px;
font-weight: 600; letter-spacing: 0.04em;
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;
background: linear-gradient(90deg, rgba(61, 190, 255, 0.35), rgba(61, 190, 255, 0.12));
color: #eaf4ff;
border-color: rgba(61, 190, 255, 0.55);
}
.nav-chip:hover { color: var(--cyan-lt); }
.nav-chip:hover { color: var(--cyan-lt); border-color: rgba(61, 190, 255, 0.45); }
.nav-spacer { flex: 1; }
.server-chip { color: #7cf0c4; border-color: rgba(47, 214, 161, 0.35); }
.server-chip { color: #7cf0c4; border-color: rgba(60, 186, 122, 0.35); }
.screen-main {
flex: 1; min-height: 0;
......@@ -183,10 +214,10 @@
}
.panel {
background: var(--panel-bg);
border: 1px solid var(--panel-bd);
border-radius: 10px;
border: 1px solid var(--panel-border);
border-radius: 4px;
padding: 14px;
box-shadow: inset 0 1px 0 rgba(255,255,255,.06), var(--shadow);
box-shadow: var(--shadow);
}
.panel-head {
display: flex; align-items: center; justify-content: space-between;
......@@ -195,78 +226,109 @@
}
.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);
width: 2px; height: 14px; border-radius: 1px;
background: var(--accent);
box-shadow: 0 0 0.5em var(--accent);
}
.panel-title h3 {
font-size: 14px; font-weight: 700; color: var(--panel-title);
letter-spacing: 0.08em; text-transform: uppercase;
font-family: var(--title-font);
}
.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);
padding: 2px 8px; border-radius: 2px; font-size: 10px;
letter-spacing: 0.06em; text-transform: uppercase;
background: rgba(60, 186, 122, 0.12); color: var(--ok);
}
.badge.amber { background: rgba(240, 180, 41, 0.15); color: var(--amber); }
.badge.red { background: rgba(255, 90, 90, 0.15); color: #ff9b9b; }
.badge.amber { background: rgba(224, 160, 74, 0.15); color: var(--warn); }
.badge.red { background: rgba(226, 91, 91, 0.15); color: #ffb0b0; }
.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;
padding: 10px; min-height: 64px; border-radius: 4px;
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); }
.metric .m-label { font-size: 11px; color: var(--panel-muted); letter-spacing: 0.06em; text-transform: uppercase; }
.metric .m-value { font-size: 20px; font-weight: 600; font-family: var(--digital-font); color: #fff; }
.metric .m-value.green { color: var(--ok); }
.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);
flex: 1; padding: 12px; border-radius: 4px;
background: var(--panel-bg); border: 1px solid var(--panel-border);
display: flex; flex-direction: column; justify-content: center; gap: 6px;
}
.kpi .k-label { font-size: 11px; color: var(--label); }
.kpi .k-label { font-size: 11px; color: var(--panel-muted); letter-spacing: 0.06em; text-transform: uppercase; }
.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);
align-self: flex-start; padding: 3px 10px; border-radius: 2px;
background: rgba(6, 12, 20, 0.9); border: 1px solid rgba(61, 190, 255, 0.45);
font-size: 20px; font-weight: 600; font-family: var(--digital-font); color: var(--accent);
}
.kpi .k-trend { font-size: 10px; color: var(--amber); }
.kpi .k-trend.green { color: var(--green); }
.kpi .k-trend { font-size: 10px; color: var(--warn); }
.kpi .k-trend.green { color: var(--ok); }
.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;
border: 1px solid rgba(61, 190, 255, 0.45);
background: rgba(61, 190, 255, 0.14);
color: #e8f4ff; border-radius: 2px; padding: 8px 14px; cursor: pointer;
font-weight: 600; letter-spacing: 0.04em;
}
.btn:hover { background: rgba(26, 111, 255, 0.4); }
.btn:hover { background: rgba(61, 190, 255, 0.28); border-color: var(--accent); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn.danger { border-color: rgba(255,90,90,.45); background: rgba(255,90,90,.15); }
.btn.danger { border-color: rgba(226, 91, 91, 0.5); background: rgba(226, 91, 91, 0.2); color: #ffe8e8; }
.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); }
.table th, .table td {
padding: 8px 10px;
border-bottom: 1px solid rgba(61, 190, 255, 0.16);
text-align: left;
}
.table th {
color: var(--panel-muted); font-weight: 600;
letter-spacing: 0.05em; text-transform: uppercase; font-size: 11px;
background: rgba(6, 12, 20, 0.35);
}
.table td { color: var(--text); }
.table tbody tr:hover td { background: rgba(61, 190, 255, 0.08); }
.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 label {
font-size: 12px; color: var(--panel-muted);
letter-spacing: 0.04em; font-weight: 600;
}
.field input, .field select, .field textarea {
height: 36px; border-radius: 2px; padding: 0 10px;
border: none;
box-shadow: 0 0 0 1px rgba(61, 190, 255, 0.28) inset;
background: rgba(6, 12, 20, 0.65); color: var(--panel-title); outline: none;
}
.field textarea { height: auto; padding: 8px 10px; }
.field input:focus, .field select:focus, .field textarea:focus {
box-shadow: 0 0 0 1px rgba(61, 190, 255, 0.65) inset;
}
.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));
border-radius: 4px; border: 1px solid var(--panel-border);
background: rgba(8, 16, 24, 0.72);
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; }
.stub-box h2 {
font-size: 24px; color: var(--panel-title); margin-bottom: 10px;
letter-spacing: 0.08em; font-family: var(--title-font);
}
.stub-box p { font-size: 13px; color: var(--panel-muted); line-height: 1.8; max-width: 560px; }
.mono {
font-family: var(--digital-font);
font-variant-numeric: tabular-nums;
}
/**
* Visual tokens aligned with emsc-v2/apps/seat (@emsc/ui-theme seat.scss / screen.scss)
*/
:root {
--bg: #07111f;
--bg-deep: #040a14;
--cyan: #3ec8ff;
--screen-bg: #081018;
--panel-bg: rgba(8, 16, 24, 0.92);
--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;
--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);
--green: var(--ok);
--amber: var(--warn);
--red: var(--alert);
--title: var(--panel-title);
--body: var(--text);
--label: var(--panel-muted);
--muted: var(--panel-muted);
--panel-bd: var(--panel-border);
--card-bg: rgba(6, 12, 20, 0.55);
--card-bd: rgba(61, 190, 255, 0.18);
--track: rgba(61, 190, 255, 0.16);
--bar-grad: linear-gradient(90deg, rgba(61, 190, 255, 0.55), #3dbeff);
--font-cn: var(--body-font);
--font-num: var(--digital-font);
--shadow: 0 6px 20px rgba(0, 0, 0, 0.22);
}
* { 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);
font-family: var(--body-font);
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text);
-webkit-font-smoothing: antialiased;
font-variant-numeric: tabular-nums;
}
button, input, select { font: inherit; }
button, input, select, textarea { font: inherit; }
::-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; }
/**
* Seat mid-screen shell + login chrome (from @emsc/ui-theme seat.scss / LoginView)
* Used by AdminLayout and LoginView; tokens come from tokens.css.
*/
.tops-root {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.6em;
padding: 0.6em 0.75em 0.75em;
overflow: hidden;
background:
radial-gradient(ellipse at 18% -8%, rgba(61, 190, 255, 0.14), transparent 48%),
radial-gradient(ellipse at 92% 108%, rgba(226, 91, 91, 0.08), transparent 42%),
linear-gradient(rgba(61, 190, 255, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(61, 190, 255, 0.035) 1px, transparent 1px),
var(--screen-bg);
background-size: 100% 100%, 100% 100%, 2.8em 2.8em, 2.8em 2.8em, auto;
color: var(--text);
font-family: var(--body-font);
}
.tops-tone--ok { --tone: var(--ok); }
.tops-tone--accent { --tone: var(--accent); }
.tops-tone--warn { --tone: var(--warn); }
.tops-tone--alert { --tone: var(--alert); }
.tops-tone--muted { --tone: var(--panel-muted); }
.tops-header {
position: relative;
flex: none;
display: flex;
align-items: center;
gap: 0.9em;
padding: 0.5em 0.85em;
border: 1px solid var(--panel-border);
border-radius: 4px;
overflow: hidden;
background: linear-gradient(
90deg,
rgba(61, 190, 255, 0.18),
rgba(8, 16, 24, 0.86) 42%,
rgba(6, 12, 20, 0.92)
);
}
.tops-header::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(90deg, var(--accent), transparent 62%);
opacity: 0.85;
}
.tops-brand {
display: flex;
align-items: center;
gap: 0.55em;
min-width: 0;
cursor: pointer;
}
.tops-brand__mark {
flex: none;
width: 2.1em;
height: 2.1em;
display: grid;
place-items: center;
border-radius: 3px;
border: 1px solid rgba(61, 190, 255, 0.55);
background: radial-gradient(circle at 30% 25%, rgba(61, 190, 255, 0.55), rgba(61, 190, 255, 0.06));
box-shadow: 0 0 1.1em rgba(61, 190, 255, 0.32), inset 0 0 0.8em rgba(61, 190, 255, 0.22);
color: #eaf4ff;
}
.tops-brand__mark :deep(svg),
.tops-brand__mark svg {
width: 1.3em;
height: 1.3em;
}
.tops-brand__copy {
display: flex;
flex-direction: column;
gap: 0.1em;
min-width: 0;
}
.tops-brand__copy strong {
font-size: 1.04em;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--panel-title);
white-space: nowrap;
}
.tops-brand__copy small {
font-size: 0.62em;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--panel-muted);
white-space: nowrap;
}
.tops-header__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4em;
min-width: 0;
}
.tops-chip {
display: inline-flex;
align-items: center;
gap: 0.42em;
padding: 0.24em 0.48em;
border: 1px solid var(--panel-border);
border-radius: 2px;
background: rgba(6, 12, 20, 0.55);
white-space: nowrap;
}
.tops-chip small {
font-size: 0.6em;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--panel-muted);
}
.tops-chip b {
font-size: 0.76em;
font-weight: 600;
color: var(--panel-title);
font-family: var(--digital-font);
}
.tops-dot {
width: 0.42em;
height: 0.42em;
border-radius: 50%;
background: var(--tone, var(--ok));
box-shadow: 0 0 0.45em var(--tone, var(--ok));
}
.tops-header__end {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.7em;
}
.tops-clock {
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: 1.1;
}
.tops-clock small {
font-size: 0.6em;
letter-spacing: 0.12em;
color: var(--panel-muted);
}
.tops-clock b {
font-family: var(--digital-font);
font-size: 1.18em;
font-weight: 600;
color: var(--accent);
text-shadow: 0 0 0.55em rgba(61, 190, 255, 0.5);
}
.tops-logout {
font: inherit;
font-size: 0.68em;
font-weight: 600;
letter-spacing: 0.06em;
padding: 0.38em 0.75em;
border: 1px solid var(--panel-border);
border-radius: 3px;
background: rgba(6, 12, 20, 0.55);
color: var(--panel-muted);
cursor: pointer;
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease;
text-decoration: none;
}
.tops-logout:hover:not(:disabled) {
color: var(--panel-title);
border-color: rgba(61, 190, 255, 0.55);
background: rgba(61, 190, 255, 0.14);
}
.tops-body {
flex: 1;
min-height: 0;
display: flex;
gap: 0.6em;
}
.tops-main {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
.tops-side {
flex: none;
width: 13.4em;
display: flex;
flex-direction: column;
gap: 0.45em;
padding: 0.6em 0.55em;
border: 1px solid var(--panel-border);
border-radius: 4px;
background: linear-gradient(180deg, rgba(8, 16, 24, 0.94), rgba(6, 12, 20, 0.94));
}
.tops-side__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.4em;
padding: 0 0.2em 0.45em;
border-bottom: 1px solid var(--panel-border);
}
.tops-side__head strong {
font-size: 0.82em;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--panel-title);
}
.tops-side__head small {
font-size: 0.64em;
letter-spacing: 0.12em;
color: var(--panel-muted);
}
.tops-menu {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.42em;
padding-top: 0.1em;
overflow-y: auto;
}
.tops-menu__item {
position: relative;
display: flex;
align-items: center;
gap: 0.6em;
width: 100%;
padding: 0.55em 0.6em 0.55em 0.75em;
font: inherit;
text-align: left;
text-decoration: none;
color: var(--text);
border: 1px solid var(--panel-border);
border-radius: 3px;
background: linear-gradient(180deg, rgba(8, 16, 24, 0.88), rgba(6, 12, 20, 0.88));
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
box-sizing: border-box;
}
.tops-menu__item::before {
content: '';
position: absolute;
left: 0;
top: 0.35em;
bottom: 0.35em;
width: 2px;
border-radius: 1px;
background: transparent;
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.tops-menu__item:hover {
transform: translateX(2px);
border-color: rgba(61, 190, 255, 0.45);
background: linear-gradient(180deg, rgba(61, 190, 255, 0.16), rgba(6, 12, 20, 0.9));
}
.tops-menu__item.router-link-active,
.tops-menu__item.is-on {
border-color: rgba(61, 190, 255, 0.6);
background: linear-gradient(90deg, rgba(61, 190, 255, 0.28), rgba(8, 16, 24, 0.72) 72%);
}
.tops-menu__item.router-link-active::before,
.tops-menu__item.is-on::before {
background: var(--accent);
box-shadow: 0 0 0.6em var(--accent);
}
.tops-menu__icon {
flex: none;
width: 1.7em;
height: 1.7em;
display: grid;
place-items: center;
border-radius: 3px;
border: 1px solid rgba(61, 190, 255, 0.28);
color: var(--panel-muted);
background: rgba(6, 12, 20, 0.55);
}
.tops-menu__item.router-link-active .tops-menu__icon {
color: var(--accent);
border-color: rgba(61, 190, 255, 0.5);
}
.tops-menu__icon svg {
width: 1em;
height: 1em;
}
.tops-menu__copy {
display: flex;
flex-direction: column;
gap: 0.12em;
min-width: 0;
}
.tops-menu__label {
font-size: 0.82em;
font-weight: 600;
color: var(--panel-title);
}
.tops-menu__hint {
font-size: 0.58em;
letter-spacing: 0.04em;
color: var(--panel-muted);
}
.tops-side__foot {
display: flex;
align-items: center;
gap: 0.45em;
padding: 0.35em 0.2em 0;
border-top: 1px solid var(--panel-border);
font-size: 0.62em;
letter-spacing: 0.08em;
color: var(--panel-muted);
}
/* —— Login (tops-login) —— */
.tops-login {
position: relative;
isolation: isolate;
width: 100%;
height: 100%;
min-height: 100vh;
overflow: hidden;
display: grid;
place-items: center;
background: radial-gradient(120% 90% at 50% -10%, #123048 0%, #081018 52%, #04080f 100%);
color: var(--text);
font-family: var(--body-font);
font-weight: 600;
letter-spacing: 0.02em;
}
.tops-login__atmosphere {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.tops-login__grid {
position: absolute;
inset: 18% -10% -28%;
background-image:
linear-gradient(rgba(61, 190, 255, 0.16) 1px, transparent 1px),
linear-gradient(90deg, rgba(61, 190, 255, 0.16) 1px, transparent 1px);
background-size: 48px 48px;
transform: perspective(720px) rotateX(58deg);
transform-origin: 50% 0%;
mask-image: linear-gradient(180deg, transparent 0%, #000 28%, #000 72%, transparent 100%);
animation: tops-login-grid 18s linear infinite;
opacity: 0.55;
}
.tops-login__glow {
position: absolute;
border-radius: 50%;
filter: blur(48px);
}
.tops-login__glow--a {
width: min(48vw, 520px);
height: min(48vw, 520px);
left: 50%;
top: 12%;
transform: translateX(-50%);
background: radial-gradient(circle, rgba(61, 190, 255, 0.28), transparent 68%);
animation: tops-login-breathe 5.5s ease-in-out infinite;
}
.tops-login__glow--b {
width: min(36vw, 380px);
height: min(36vw, 380px);
left: 50%;
top: 42%;
transform: translateX(-50%);
background: radial-gradient(circle, rgba(242, 230, 168, 0.12), transparent 70%);
}
.tops-login__horizon {
position: absolute;
left: 10%;
right: 10%;
top: 58%;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(61, 190, 255, 0.55), transparent);
opacity: 0.7;
}
.tops-login__stage {
position: relative;
z-index: 1;
width: min(28rem, calc(100vw - 2.5rem));
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 1.25rem 0 2rem;
}
.tops-login__mark {
width: 4.2rem;
height: 4.2rem;
display: grid;
place-items: center;
border-radius: 8px;
border: 1px solid rgba(61, 190, 255, 0.55);
background: radial-gradient(circle at 30% 25%, rgba(61, 190, 255, 0.5), rgba(61, 190, 255, 0.06));
box-shadow: 0 0 1.4em rgba(61, 190, 255, 0.35);
color: #eaf4ff;
font-size: 1.4rem;
font-weight: 700;
letter-spacing: 0.08em;
margin-bottom: 1rem;
animation: tops-login-rise 0.9s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.tops-login__title {
margin: 0;
font-size: clamp(1.15rem, 2.4vw, 1.45rem);
font-weight: 700;
letter-spacing: 0.08em;
color: var(--panel-title);
animation: tops-login-rise 0.9s 0.06s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.tops-login__tagline {
margin: 0.45rem 0 0;
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
animation: tops-login-rise 0.9s 0.1s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.tops-login__support {
margin: 0.35rem 0 1.25rem;
font-size: 0.78rem;
color: var(--panel-muted);
line-height: 1.5;
max-width: 22rem;
animation: tops-login-rise 0.9s 0.14s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.tops-login__form {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.15rem 1.2rem 1.25rem;
border: 1px solid var(--panel-border);
border-radius: 4px;
background: rgba(8, 16, 24, 0.88);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
text-align: left;
animation: tops-login-rise 0.9s 0.18s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.tops-login__form.is-shake {
animation: tops-login-shake 0.45s ease;
}
.tops-login__field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.tops-login__field span {
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--panel-muted);
}
.tops-login__field input {
height: 2.4rem;
padding: 0 0.75rem;
border-radius: 2px;
border: none;
background: rgba(6, 12, 20, 0.65);
box-shadow: 0 0 0 1px rgba(61, 190, 255, 0.28) inset;
color: var(--panel-title);
outline: none;
}
.tops-login__field input:focus {
box-shadow: 0 0 0 1px rgba(61, 190, 255, 0.65) inset, 0 0 0.6em rgba(61, 190, 255, 0.25);
}
.tops-login__error {
margin: 0;
font-size: 0.78rem;
color: #ffb0b0;
}
.tops-login__submit {
margin-top: 0.25rem;
height: 2.55rem;
border: 1px solid rgba(61, 190, 255, 0.55);
border-radius: 2px;
background: rgba(61, 190, 255, 0.22);
color: #f4fbff;
font-weight: 700;
letter-spacing: 0.12em;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease;
}
.tops-login__submit:hover:not(:disabled) {
background: rgba(61, 190, 255, 0.34);
border-color: var(--accent);
}
.tops-login__submit:disabled {
opacity: 0.55;
cursor: wait;
}
.tops-login__hint {
margin: 0.15rem 0 0;
font-size: 0.68rem;
color: var(--panel-muted);
line-height: 1.55;
text-align: center;
}
.tops-login__hint code {
font-family: var(--digital-font);
color: var(--accent);
}
.tops-login__dash {
margin-top: 0.35rem;
background: transparent;
border: none;
color: var(--accent);
font-size: 0.78rem;
letter-spacing: 0.06em;
cursor: pointer;
text-decoration: underline;
opacity: 0.9;
}
@keyframes tops-login-grid {
from { background-position: 0 0, 0 0; }
to { background-position: 0 48px, 48px 0; }
}
@keyframes tops-login-breathe {
0%, 100% { opacity: 0.75; transform: translateX(-50%) scale(1); }
50% { opacity: 1; transform: translateX(-50%) scale(1.06); }
}
@keyframes tops-login-rise {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes tops-login-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-6px); }
75% { transform: translateX(6px); }
}
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { api } from '@/api/http'
import { useAuthStore } from '@/stores/auth'
interface Gateway {
id: string
name: string
kind: string
host: string
port: number
enabled: boolean
}
interface Device {
id: string
gatewayId: string | null
category: string
name: string
code: string
bindUi: string
status: string
enabled: boolean
meta?: { sourceCsv?: string; sourceDir?: string }
}
interface DevicePoint {
id: string
gatewayId: string
deviceId: string | null
name: string
dataType: string
modbusId: number
modbusRegister: string
modbusAddress: number
itemId: string
scale: number
scaleOverride: number | null
enabled: boolean
lastEng: number | null
lastTs: string | null
}
type Tab = 'devices' | 'points' | 'gateways'
const auth = useAuthStore()
const tab = ref<Tab>('points')
const loading = ref(false)
const message = ref('')
const error = ref('')
const gateways = ref<Gateway[]>([])
const devices = ref<Device[]>([])
const points = ref<DevicePoint[]>([])
const pointsVersion = ref(1)
const gatewayHealth = ref<{ ok: boolean; message?: string; raw?: unknown } | null>(null)
const filterGatewayId = ref('')
const filterDeviceId = ref('')
const importForm = reactive({
gatewayId: 'mb-104',
encoding: 'gbk',
replaceGateway: true,
csv: '',
})
const editPoint = ref<DevicePoint | null>(null)
const filteredPoints = computed(() =>
points.value.filter((p) => {
if (filterGatewayId.value && p.gatewayId !== filterGatewayId.value) return false
if (filterDeviceId.value && p.deviceId !== filterDeviceId.value) return false
return true
}),
)
const devicesWithPointCount = computed(() =>
devices.value.map((d) => ({
...d,
pointCount: points.value.filter((p) => p.deviceId === d.id).length,
})),
)
async function loadAll() {
loading.value = true
error.value = ''
try {
const [gw, dev, pts, health] = await Promise.all([
api<{ gateways: Gateway[]; pointsVersion: number }>('/api/devices/gateways', {
token: auth.token,
}),
api<{ devices: Device[] }>('/api/devices', { token: auth.token }),
api<{ points: DevicePoint[]; pointsVersion: number }>('/api/devices/points', {
token: auth.token,
}),
api<{ ok: boolean; message?: string; raw?: unknown }>('/api/devices/gateways/health', {
token: auth.token,
}).catch(() => ({ ok: false, message: '健康检查失败' })),
])
gateways.value = gw.gateways
pointsVersion.value = gw.pointsVersion
devices.value = dev.devices
points.value = pts.points
pointsVersion.value = pts.pointsVersion
gatewayHealth.value = health
if (!importForm.gatewayId && gw.gateways[0]) importForm.gatewayId = gw.gateways[0].id
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function reloadPoints() {
const pts = await api<{ points: DevicePoint[]; pointsVersion: number }>('/api/devices/points', {
token: auth.token,
})
points.value = pts.points
pointsVersion.value = pts.pointsVersion
}
async function savePoint() {
if (!editPoint.value) return
error.value = ''
message.value = ''
try {
await api(`/api/devices/points/${editPoint.value.id}`, {
method: 'PATCH',
token: auth.token,
body: JSON.stringify({
name: editPoint.value.name,
modbusAddress: Number(editPoint.value.modbusAddress),
dataType: editPoint.value.dataType,
modbusId: Number(editPoint.value.modbusId),
itemId: editPoint.value.itemId,
scale: Number(editPoint.value.scale),
scaleOverride:
editPoint.value.scaleOverride === null || editPoint.value.scaleOverride === ('' as unknown)
? null
: Number(editPoint.value.scaleOverride),
deviceId: editPoint.value.deviceId || null,
enabled: editPoint.value.enabled,
}),
})
message.value = '点位已保存'
editPoint.value = null
await reloadPoints()
} catch (e) {
error.value = e instanceof Error ? e.message : '保存失败'
}
}
async function removePoint(p: DevicePoint) {
if (!confirm(`删除点位「${p.name}」?`)) return
await api(`/api/devices/points/${p.id}`, { method: 'DELETE', token: auth.token })
await reloadPoints()
}
async function doImport() {
error.value = ''
message.value = ''
if (!importForm.csv.trim()) {
error.value = '请粘贴 CSV 内容'
return
}
try {
const result = await api<{ imported: number; pointsVersion: number }>(
'/api/devices/points/import',
{
method: 'POST',
token: auth.token,
body: JSON.stringify({
gatewayId: importForm.gatewayId,
encoding: importForm.encoding === 'utf-8' ? 'utf-8' : 'gbk',
replaceGateway: importForm.replaceGateway,
csv: importForm.csv,
}),
},
)
message.value = `已导入 ${result.imported} 个点位,版本 ${result.pointsVersion}`
importForm.csv = ''
filterGatewayId.value = importForm.gatewayId
await loadAll()
} catch (e) {
error.value = e instanceof Error ? e.message : '导入失败'
}
}
async function doExport() {
const q = new URLSearchParams()
if (filterGatewayId.value) q.set('gatewayId', filterGatewayId.value)
q.set('encoding', 'utf-8')
const url = `/api/devices/points/export?${q.toString()}`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${auth.token}` },
})
if (!res.ok) {
error.value = '导出失败'
return
}
const blob = await res.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'device-points.csv'
a.click()
URL.revokeObjectURL(a.href)
}
async function syncGateway() {
message.value = ''
error.value = ''
try {
const r = await api<{ ok: boolean; message: string; pointsVersion?: number }>(
'/api/devices/points/sync-gateway',
{ method: 'POST', token: auth.token },
)
if (r.ok) message.value = r.message
else error.value = r.message
gatewayHealth.value = await api('/api/devices/gateways/health', { token: auth.token })
} catch (e) {
error.value = e instanceof Error ? e.message : '同步失败'
}
}
function gatewayName(id: string) {
return gateways.value.find((g) => g.id === id)?.name || id
}
function deviceName(id: string | null) {
if (!id) return '—'
return devices.value.find((d) => d.id === id)?.name || id
}
onMounted(loadAll)
</script>
<template>
<div class="page-fill devices-page">
<section class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>设备与点位管理</h3></div>
<div class="badge">点表版本 {{ pointsVersion }}</div>
</div>
<div class="tab-row">
<button class="btn" :class="{ ghost: tab !== 'points' }" type="button" @click="tab = 'points'">
点位表
</button>
<button class="btn" :class="{ ghost: tab !== 'devices' }" type="button" @click="tab = 'devices'">
设备清单
</button>
<button class="btn" :class="{ ghost: tab !== 'gateways' }" type="button" @click="tab = 'gateways'">
网关
</button>
<button class="btn ghost" type="button" :disabled="loading" @click="loadAll">刷新</button>
<button v-if="auth.isAdmin" class="btn" type="button" @click="syncGateway">下发网关</button>
</div>
<p v-if="message" class="msg ok">{{ message }}</p>
<p v-if="error" class="msg err">{{ error }}</p>
</section>
<section v-if="tab === 'gateways'" class="panel" style="flex:1; overflow:auto">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>逻辑 Modbus 主机</h3></div>
<div class="badge">
Windows 网关
{{ gatewayHealth?.ok ? '在线' : gatewayHealth?.message || '未连接' }}
</div>
</div>
<table class="table">
<thead>
<tr><th>ID</th><th>名称</th><th>主机</th><th>端口</th><th>启用</th></tr>
</thead>
<tbody>
<tr v-for="g in gateways" :key="g.id">
<td>{{ g.id }}</td>
<td>{{ g.name }}</td>
<td>{{ g.host }}</td>
<td>{{ g.port }}</td>
<td>{{ g.enabled ? '是' : '否' }}</td>
</tr>
</tbody>
</table>
<p class="hint">实际协议由单一 Windows 网关进程连接上述主机;平台仅维护主数据与点位。</p>
</section>
<section v-if="tab === 'devices'" class="panel" style="flex:1; overflow:auto">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>设备清单({{ devices.length }})</h3></div>
<div class="badge">来自协议 CSV 点表</div>
</div>
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>点位数</th>
<th>网关</th>
<th>来源 CSV</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="d in devicesWithPointCount" :key="d.id">
<td>{{ d.name }}</td>
<td>{{ d.pointCount }}</td>
<td>{{ d.gatewayId ? gatewayName(d.gatewayId) : '—' }}</td>
<td class="mono">{{ d.meta?.sourceCsv || '—' }}</td>
<td>{{ d.status }}</td>
</tr>
</tbody>
</table>
<p class="hint">设备由「设备对接」目录中登记的真实点位按 ItemID / CSV 归并生成,不再使用 JSON 占位类别。</p>
</section>
<template v-if="tab === 'points'">
<section class="panel">
<div class="filters">
<div class="field">
<label>网关</label>
<select v-model="filterGatewayId">
<option value="">全部</option>
<option v-for="g in gateways" :key="g.id" :value="g.id">{{ g.name }}</option>
</select>
</div>
<div class="field">
<label>设备</label>
<select v-model="filterDeviceId">
<option value="">全部</option>
<option v-for="d in devices" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
</div>
<button class="btn ghost" type="button" @click="doExport">导出 CSV</button>
</div>
</section>
<section v-if="auth.isAdmin" class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>导入 CSV</h3></div>
</div>
<div class="filters">
<div class="field">
<label>目标网关</label>
<select v-model="importForm.gatewayId">
<option v-for="g in gateways" :key="g.id" :value="g.id">{{ g.name }}</option>
</select>
</div>
<div class="field">
<label>编码</label>
<select v-model="importForm.encoding">
<option value="gbk">GBK(现场表)</option>
<option value="utf-8">UTF-8</option>
</select>
</div>
<label class="check">
<input v-model="importForm.replaceGateway" type="checkbox" />
替换该网关已有点位
</label>
<button class="btn" type="button" @click="doImport">导入</button>
</div>
<textarea
v-model="importForm.csv"
class="csv-area"
placeholder="粘贴现场点位 CSV(含表头 Name, Modbus Address, ItemID …)"
/>
</section>
<section class="panel" style="flex:1; overflow:auto">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>点位列表({{ filteredPoints.length }})</h3></div>
</div>
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>地址</th>
<th>类型</th>
<th>设备</th>
<th>ItemID</th>
<th>倍率</th>
<th>实时值</th>
<th v-if="auth.isAdmin">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="p in filteredPoints" :key="p.id">
<td>{{ p.name }}</td>
<td>{{ p.modbusAddress }}</td>
<td>{{ p.dataType }}</td>
<td>{{ deviceName(p.deviceId) }}</td>
<td class="mono">{{ p.itemId }}</td>
<td>{{ p.scaleOverride ?? p.scale }}</td>
<td>{{ p.lastEng ?? '—' }}</td>
<td v-if="auth.isAdmin" class="ops">
<button class="btn ghost" type="button" @click="editPoint = { ...p }">编辑</button>
<button class="btn ghost" type="button" @click="removePoint(p)">删</button>
</td>
</tr>
</tbody>
</table>
</section>
</template>
<div v-if="editPoint" class="modal-mask" @click.self="editPoint = null">
<div class="modal panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>编辑点位</h3></div>
</div>
<div class="form-grid">
<div class="field"><label>名称</label><input v-model="editPoint.name" /></div>
<div class="field">
<label>Modbus Address</label>
<input v-model.number="editPoint.modbusAddress" type="number" />
</div>
<div class="field"><label>DataType</label><input v-model="editPoint.dataType" /></div>
<div class="field">
<label>Modbus ID</label>
<input v-model.number="editPoint.modbusId" type="number" />
</div>
<div class="field"><label>ItemID</label><input v-model="editPoint.itemId" /></div>
<div class="field">
<label>Scale</label>
<input v-model.number="editPoint.scale" type="number" step="any" />
</div>
<div class="field">
<label>Scale 覆盖</label>
<input v-model.number="editPoint.scaleOverride" type="number" step="any" />
</div>
<div class="field">
<label>设备</label>
<select v-model="editPoint.deviceId">
<option :value="null">无</option>
<option v-for="d in devices" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
</div>
<label class="check"><input v-model="editPoint.enabled" type="checkbox" /> 启用采集</label>
</div>
<div style="margin-top:12px; display:flex; gap:10px">
<button class="btn" type="button" @click="savePoint">保存</button>
<button class="btn ghost" type="button" @click="editPoint = null">取消</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.devices-page {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.tab-row,
.filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 140px;
}
.field label {
font-size: 12px;
opacity: 0.75;
}
.field input,
.field select,
.csv-area {
background: rgba(6, 12, 20, 0.65);
box-shadow: 0 0 0 1px rgba(61, 190, 255, 0.28) inset;
border: none;
color: inherit;
border-radius: 2px;
padding: 6px 8px;
}
.csv-area {
width: 100%;
min-height: 100px;
margin-top: 10px;
font-family: ui-monospace, monospace;
font-size: 12px;
}
.check {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
margin-bottom: 4px;
}
.msg.ok {
color: var(--ok);
font-size: 13px;
}
.msg.err {
color: #ffb0b0;
font-size: 13px;
}
.hint {
font-size: 12px;
opacity: 0.7;
margin-top: 10px;
}
.mono {
font-family: ui-monospace, monospace;
font-size: 11px;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops {
display: flex;
gap: 6px;
}
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal {
width: min(640px, 92vw);
max-height: 90vh;
overflow: auto;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
</style>
......@@ -10,15 +10,24 @@ const username = ref('admin')
const password = ref('Admin@123')
const error = ref('')
const loading = ref(false)
const shake = ref(false)
async function submit() {
error.value = ''
loading.value = true
try {
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) {
error.value = e instanceof ApiError ? e.message : '登录失败'
shake.value = true
window.setTimeout(() => {
shake.value = false
}, 480)
} finally {
loading.value = false
}
......@@ -26,54 +35,48 @@ async function submit() {
</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 class="tops-login">
<div class="tops-login__atmosphere" aria-hidden="true">
<div class="tops-login__grid" />
<div class="tops-login__glow tops-login__glow--a" />
<div class="tops-login__glow tops-login__glow--b" />
<div class="tops-login__horizon" />
</div>
<div class="tops-login__stage">
<div class="tops-login__mark">JH</div>
<h1 class="tops-login__title">分子实验室三维可视化管理平台</h1>
<p class="tops-login__tagline">Lab Intelligent Control</p>
<p class="tops-login__support">登录进入管理后台;大屏无需登录即可查看实时态势。</p>
<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>
</form>
</div>
</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>
---
---
name: 设备管理与网关架构
overview: 对照合同软件条款,由单一 Windows 协议网关统一承载 Modbus TCP 与海康 HCNetSDK;Node/Vue 平台负责设备主数据、可编辑点位表、采集落库、计量计费与大屏。后台可导入/编辑现场 CSV 点位(如 8 台冰箱温湿度表)。
todos:
- id: docs-arch
content: 固化合同对照、单一 Windows 网关 API 契约与点位表数据模型文档
status: pending
- id: db-devices-points
content: PG:gateways/devices/device_points;支持 CSV 导入与后台 CRUD(字段对齐现场点位表)
status: pending
- id: ui-devices-points
content: 后台 /devices:设备清单 + 点位表编辑器(导入/导出 CSV、在线改地址倍率绑定)
status: pending
- id: win-gateway
content: VS2022+MSVC C++17 实现单一 Windows 协议网关(Modbus 多主机+HCNetSDK)并与平台 HTTP/WS 对接
status: pending
- id: platform-io
content: Node 经网关拉数/下发写指令,并入 realtime 快照与落库;点位变更热推送给网关
status: pending
- id: billing-metering
content: 按合同完善计量计费:分区电量峰平谷、水表独立计费、冷量分摊与多维账单
status: pending
- id: twin-interaction
content: 三维点击历史曲线/报警;门禁异常弹监控;压差异常标红与可配置写闭环
status: pending
isProject: false
---
# 设备管理与单一 Windows 协议网关方案
## 架构决策(本次修订)
1. **协议网关 = 一个 Windows 程序**:同时负责 **Modbus TCP**(`.102/.103/.104`)与 **海康 HCNetSDK**(门禁/摄像)。Node **不再**直接连 Modbus / 加载 SDK。
2. **点位表可后台编辑**:以现场 CSV 为模板(示例:[`19216810104 房间8个冰箱温度.csv`](file:///Users/james/.cursor/projects/Users-james-Documents-2026-00-code-web/attachments/25718a0d-91e1-47b8-ae30-4a01afa89f48/19216810104___8_____.csv)),支持导入、在线增删改、导出;保存后热推送到 Windows 网关生效。
平台(Node + Vue + PG)只做:设备/点位主数据、计费、业务 API、大屏;现场协议全部经 Windows 网关。
---
## 目标架构
```mermaid
flowchart TB
subgraph field [现场设备]
MBHost["Modbus网关_102_103_104"]
Door[门禁]
Cam[摄像机_NVR]
end
subgraph winGw [单一Windows协议网关]
MBEngine[Modbus引擎]
HikEngine[HCNetSDK引擎]
GwAPI[HTTP_WS服务]
PointCache[点位配置缓存]
end
subgraph platform [平台_Node_Vue_PG]
DeviceAPI[设备与点位管理API]
Collector[实时聚合]
Billing[计量计费]
AdminUI[后台设备_点位编辑]
TwinUI[大屏]
PG[(PostgreSQL)]
end
MBHost --> MBEngine
Door --> HikEngine
Cam --> HikEngine
PointCache --> MBEngine
MBEngine --> GwAPI
HikEngine --> GwAPI
GwAPI <-->|"采集推送_写指令_预览"| Collector
DeviceAPI -->|"点位变更下发"| GwAPI
DeviceAPI --> PG
Collector --> PG
Collector --> Billing
AdminUI --> DeviceAPI
TwinUI --> Collector
```
**数据闭环**
1. **上行**:Windows 网关按点位表轮询 Modbus / 订阅海康事件 → 推送测点值(WS 或 HTTP)→ Node 更新快照 → 落库 → 大屏。
2. **下行**:大屏/后台写指令 → Node 审计 → 调 Windows 网关写接口 → Modbus 写寄存器或海康控门。
3. **点位配置**:后台编辑/导入 CSV → 存 PG → 通知网关 reload 点表(断线则网关启动时从平台拉取全量)。
---
## 单一 Windows 协议网关
部署于合同上位机(Win64),内嵌 HCNetSDK `lib/win64`,对外提供统一 HTTP/WS(建议端口可配,如 `38080`)。
| 能力 | 接口(契约) | 说明 |
|---|---|---|
| 健康 | `GET /health` | Modbus 主机连通、海康登录态 |
| 拉点位配置 | `GET /config/points` 或由平台 `POST /config/points/reload` | 全量/增量点表 |
| 实时值 | `WS /telemetry` 或 `GET /snapshot` | 按 `point_id` / `ItemID` 上报工程值 |
| Modbus 写 | `POST /modbus/write` | `{pointId, value}` 或地址级写 |
| 门禁 | `GET /doors`、`POST /doors/:id/open`、`WS /alarms` | `ControlGateway` + ACS 事件 |
| 视频 | 预览/JPEG(最小可用) | RealPlay 或抓图 |
### 开发环境(已定稿)
**Visual Studio 2022 + MSVC(x64)+ C++17**,与海康官方 ClientDemo / `HCNetSDK.h` / `lib/win64` 同栈,避免 C# P/Invoke 在报警回调、预览句柄上的编组风险。
| 项 | 选型 |
|---|---|
| IDE / 工具链 | VS 2022,平台 `x64`,Runtime Library 与 SDK Demo 对齐(通常 `/MD`) |
| 语言标准 | C++17 |
| 工程形态 | 控制台或 Win32 服务进程(可另做托盘);**不做 MFC 界面依赖**(Demo 仅作 API 对照) |
| HCNetSDK | 直接链接 `HCNetSDK.lib`,运行目录带齐 `lib/win64` DLL |
| Modbus TCP | 嵌入式主站库(如 libmodbus win 移植,或轻量自研 TCP FC01/03/05/06/16) |
| HTTP / WS | header-only 方案优先(如 cpp-httplib + 简易 WS,或 Crow);对外 JSON |
| 构建产物 | 单目录发布:`JhLabGateway.exe` + SDK DLL + `config.json` |
| 仓库位置 | `gateway/win-protocol/`(VS 解决方案),与 Node 仓并列 |
网关**本地缓存**最近一次点表,平台不可达时仍可按缓存采集。平台侧:`GATEWAY_URL=http://<采集机>:38080`。
不采用 C#/.NET:HTTP 开发更快,但海康回调与预览与 Demo 一致性差,本项目以 SDK 稳定优先。
---
## 可编辑 Modbus 点位表(管理后台)
### 字段模型(对齐现场 CSV)
示例表头:
`id, Name, RegType, RegAddress, DataType, Modbus ID, Modbus Register, Modbus Address, Description, MaskBit, Bit, Scale, Bias, MinRawRange, MaxRawRange, MinEngRange, MaxEngRange, ItemID`
工程换算约定:`eng = raw * Scale + Bias`(现场表 Scale/Bias 多为 0 时,另用 `Min/Max Raw→Eng` 线性映射或文档约定 `*0.1`);网关侧与后台预览统一同一套公式。
采集侧关键列:
- **网关主机**:归属 `gateway_id`(如 `.104`)— CSV 文件名中的 IP,导入时选定或按文件名解析
- **Modbus Address** + **Modbus Register**(如 `4X`)+ **Modbus ID**:TCP 侧实际读写地址
- **DataType** / **MaskBit** / **Bit**:解析与位域
- **ItemID**:稳定业务键(如 `RTU.COM3.1_试剂准备间冰箱1.冰箱温度`)
- 扩展(平台加):`device_id`、`snapshot_path`(映射大屏字段)、`rw`、`energy_kind`、`enabled`
### 后台能力(`/devices` 下「点位」页)
1. **按网关/设备筛选**列表,在线编辑上述字段并校验(地址冲突、类型枚举)。
2. **导入 CSV**:兼容设备对接目录导出格式(注意 **GBK/GB2312** 编码,如冰箱表);导入预览 → 确认写入。
3. **导出 CSV**:同结构,便于现场对照。
4. **绑定设备**:点位挂到 `devices`(例:8 台冰箱各挂温湿度两点)。
5. **下发网关**:保存后 `reload`;展示网关当前点表版本号 / 最后同步时间。
6. **实时值列**:展示最近采集值与质量戳(来自网关 telemetry)。
种子:一次性导入 `设备对接` 下全部 CSV(机组01/02/房间),含用户提供的 8 冰箱点表。
### 数据表
- `gateways`:`id, kind(modbus_host|hik_device 仅作逻辑分类), host, port, meta…`(物理采集仍只连一台 Windows 网关进程)
- `devices`:设备资产
- `device_points`:完整 CSV 列 + 平台扩展列;唯一约束建议 `(gateway_id, modbus_id, modbus_register, modbus_address, bit)` 或 `item_id`
API:`/api/gateways`、`/api/devices`、`/api/device-points`(CRUD + `POST /import` + `POST /export` + `POST /sync-gateway`)。
---
## 合同软件对照(摘要)
| 合同 | 要点 | 落地 |
|---|---|---|
| 4–7 全景 | 三维、点击详情/历史/报警 | 实采绑定 + 历史 API |
| 8–10 计量 | 电峰平谷、水水质、冷量分摊 | 点位属能量计/电表/流量计 → 计费引擎 |
| 11–12 安防 | 视频、门禁异常弹监控 | Windows 网关内 HCNetSDK |
| 13–15 压差 | 标红 + 可选写闭环 | 点位读屏/PLC + 经网关写阀 |
合同硬件上位机即 Windows 网关运行环境。
---
## 计量计费(保持)
电:N/E/PAU 电表点位 → 峰平谷分区账单;水:流量计 + 水质费率预留;冷:DN40/DN80 能量计。计费仍在 Node/PG,用量来自网关上报的点值。
---
## 相对现状缺口
| 能力 | 现状 | 本方案 |
|---|---|---|
| 协议接入 | Node Mock;Modbus/海康未实接 | **单一 Windows 网关**统一协议 |
| 点位 | 文档/CSV 静态 | **后台可编辑** + 导入导出 + 热同步 |
| 设备管理 | 视频/照明分散 JSON | 统一设备+点位后台 |
| 计费/安防/压差 | 壳或 Mock | 实采后按合同补齐 |
---
## 实施阶段
1. 文档:架构、网关 API 契约、点位字段与换算规则。
2. PG 主数据 + 点位 CRUD/CSV 导入导出 API + 后台编辑页(可先不连真网关,用 Mock 网关)。
3. Windows 网关脚手架:Modbus 多主机按点表采集 + 写;健康与 telemetry。
4. 接入海康门禁/抓图;门禁→摄像联动。
5. Node 聚合进 realtime / 计费;点位 sync。
6. 大屏交互与压差策略;现场联调。保留平台 `DATA_MODE=mock` 与网关旁路开关。
---
## 风险与约定
- 点位 CSV 常为 **GBK**:导入需编码探测/可选编码。
- Scale=0 的现场表需与甲方确认工程换算(常见温度 `*0.1`);后台允许覆盖 `scale`。
- 压差自动写阀:默认告警+建议,自动下发需 admin 开关。
- 水表协议缺口:流量计顶替 + 水质配置预留(同前)。
---
## 关键锚点
- 平台采集环:[`server/src/realtime/store.ts`](server/src/realtime/store.ts)(改为消费 `GATEWAY_URL`)
- 计费:[`server/src/billing/store.ts`](server/src/billing/store.ts)
- 门禁占位:[`server/src/access/adapter.ts`](server/src/access/adapter.ts)
- 点位样例:房间 8 冰箱 CSV(`Modbus Address` 701–708 / 721–728)
- SDK:`设备对接/HCNetSDKV6.1.10.10_…_Win64`
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