Commit a1ad7540 by James Yuan

fix: 挂载 nginx 云渲染反代并补充现场部署包

Docker web 热挂 nginx.conf 以恢复 /rtengine;网关 Modbus 指向 Mac 模拟与本机计量口;补充 110 现场部署脚本与产物(排除镜像 tar/exe)。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 466251ce
...@@ -11,3 +11,8 @@ web/dist ...@@ -11,3 +11,8 @@ web/dist
.idea .idea
.venv .venv
**/__pycache__/ **/__pycache__/
# deploy offline packages (too large for git)
deploy/prod/images/*.tar
deploy/**/*.exe
deploy/prod/native/node/
FROM docker.m.daocloud.io/library/postgres:16-alpine
# 生产 server(构建期可用国内基础镜像)
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
COPY server/package.json ./server/
COPY web/package.json ./web/
RUN npm install --workspace=server --include=dev
COPY server ./server
WORKDIR /app/server
ENV NODE_ENV=production
EXPOSE 3001
CMD ["npx", "tsx", "src/index.ts"]
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
COPY web/package.json ./web/
COPY server/package.json ./server/
RUN npm install --workspace=web --include=dev
COPY web ./web
WORKDIR /app/web
ARG VITE_API_BASE=
ARG VITE_VIEWPORT_MODE=rtengine
ARG VITE_RTENGINE_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38
ARG VITE_UE_STREAM_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38
ENV VITE_API_BASE=$VITE_API_BASE \
VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \
VITE_RTENGINE_URL=$VITE_RTENGINE_URL \
VITE_UE_STREAM_URL=$VITE_UE_STREAM_URL
RUN npx vite build
FROM nginx:1.27-alpine
COPY web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/web/dist /usr/share/nginx/html
EXPOSE 80
JhLab 生产部署包(无源码)
目录: D:\apps\jh-lab
结构:
docker-compose.yml 容器编排(restart: unless-stopped)
.env 生产环境变量
images\ Docker 镜像 tar(导入后可删)
gateway\ Windows 协议网关运行时(exe+config+web)
scripts\ 启停与自启动脚本
访问:
Web: http://192.168.32.110/
API: http://192.168.32.110:3001/api/health
网关UI: http://192.168.32.110:38080/ui/
账号: admin / Admin@123
# 镜湖实验室平台 — 生产 Compose(仅镜像,无源码构建)
services:
db:
image: jh-lab-postgres:16-alpine
container_name: jh-lab-db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-jh_lab}
POSTGRES_USER: ${POSTGRES_USER:-jh_lab}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-jh_lab_pass}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5434}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
mediamtx:
image: bluenviron/mediamtx:1
container_name: jh-lab-mediamtx
restart: unless-stopped
environment:
MTX_WEBRTCICEHOSTNAT1TO1IPS: ${WEBRTC_ICE_HOST:-192.168.32.110}
MTX_WEBRTCICETCPMUTUALLISTENINGPORT: "8189"
ports:
- "${MEDIAMTX_RTSP_PORT:-8554}:8554"
- "${MEDIAMTX_HLS_PORT:-8888}:8888"
- "${MEDIAMTX_WEBRTC_PORT:-8889}:8889"
- "${MEDIAMTX_WEBRTC_UDP:-8189}:8189/udp"
- "${MEDIAMTX_WEBRTC_TCP:-8189}:8189"
server:
image: jh-lab-server:prod
container_name: jh-lab-server
restart: unless-stopped
depends_on:
db:
condition: service_healthy
mediamtx:
condition: service_started
environment:
PORT: 3001
DATABASE_URL: postgres://${POSTGRES_USER:-jh_lab}:${POSTGRES_PASSWORD:-jh_lab_pass}@db:5432/${POSTGRES_DB:-jh_lab}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-12h}
DATA_MODE: ${DATA_MODE:-gateway}
GATEWAY_URL: ${GATEWAY_URL:-http://host.docker.internal:38080}
WEBRTC_WHEP_BASE: ${WEBRTC_WHEP_BASE:-http://mediamtx:8889}
MODBUS_PAU1_HOST: ${MODBUS_PAU1_HOST:-192.168.10.102}
MODBUS_PAU2_HOST: ${MODBUS_PAU2_HOST:-192.168.10.103}
MODBUS_ROOM_HOST: ${MODBUS_ROOM_HOST:-192.168.10.104}
MODBUS_FRIDGE_HOST: ${MODBUS_FRIDGE_HOST:-192.168.10.104}
MODBUS_PORT: ${MODBUS_PORT:-502}
MODBUS_POLL_MS: ${MODBUS_POLL_MS:-2000}
ACCESS_BRIDGE_URL: ${ACCESS_BRIDGE_URL:-}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-Admin@123}
COLLECT_RETENTION_DAYS: ${COLLECT_RETENTION_DAYS:-90}
MOCK_FILL_STALE_MS: ${MOCK_FILL_STALE_MS:-60000}
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${SERVER_PORT:-3001}:3001"
web:
image: jh-lab-web:prod
container_name: jh-lab-web
restart: unless-stopped
depends_on:
- server
ports:
- "${WEB_PORT:-80}:80"
volumes:
pgdata:
{
"listen": "0.0.0.0",
"port": 38080,
"platformUrl": "http://127.0.0.1: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
cd /d %~dp0
taskkill /F /IM JhLabGateway.exe >nul 2>nul
timeout /t 1 /nobreak >nul
start "" /B JhLabGateway.exe config.json
echo Gateway started: http://127.0.0.1:38080/ui/
:root {
--bg: #0b1220;
--panel: #121a2b;
--line: rgba(120, 180, 255, 0.22);
--text: #e8eefc;
--muted: #8fa3c4;
--accent: #3d8bfd;
--ok: #3dd68c;
--warn: #f5a524;
--danger: #f31260;
--radius: 10px;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: radial-gradient(1200px 600px at 10% -10%, #1a2744, var(--bg));
color: var(--text);
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
.wrap { max-width: 1180px; margin: 0 auto; padding: 20px; }
.top {
display: flex; align-items: center; gap: 16px; margin-bottom: 18px;
padding-bottom: 14px; border-bottom: 1px solid var(--line);
}
.brand { font-weight: 700; letter-spacing: 0.02em; font-size: 18px; }
.brand small { display: block; color: var(--muted); font-weight: 400; font-size: 12px; margin-top: 2px; }
.nav { display: flex; flex-wrap: wrap; gap: 8px; flex: 1; }
.nav button, .btn {
background: rgba(61, 139, 253, 0.15);
border: 1px solid var(--line);
color: var(--text);
border-radius: 999px;
padding: 7px 14px;
cursor: pointer;
font-size: 13px;
}
.nav button.active, .btn.primary {
background: var(--accent);
border-color: transparent;
color: #fff;
}
.btn.ghost { background: transparent; }
.btn.danger { background: rgba(243, 18, 96, 0.15); border-color: rgba(243, 18, 96, 0.4); color: #ffb4cb; }
.panel {
background: rgba(18, 26, 43, 0.92);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px 18px;
margin-bottom: 14px;
}
.panel h2 { margin: 0 0 12px; font-size: 16px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 12px; color: var(--muted); }
.field input, .field select, .field textarea {
background: #0a101c;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--text);
padding: 8px 10px;
font-size: 13px;
}
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th, .table td {
border-bottom: 1px solid var(--line);
padding: 9px 8px;
text-align: left;
vertical-align: middle;
}
.table th { color: var(--muted); font-weight: 600; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
border: 1px solid var(--line);
}
.badge.ok { color: var(--ok); border-color: rgba(61, 214, 140, 0.35); }
.badge.off { color: var(--muted); }
.msg { font-size: 13px; margin: 8px 0; }
.msg.ok { color: var(--ok); }
.msg.err { color: #ff8eab; }
.login-box {
max-width: 380px; margin: 12vh auto; padding: 28px;
background: rgba(18, 26, 43, 0.95);
border: 1px solid var(--line);
border-radius: 14px;
}
.login-box h1 { margin: 0 0 6px; font-size: 22px; }
.login-box p { margin: 0 0 18px; color: var(--muted); font-size: 13px; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
.preview-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
.preview-card {
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
background: #070c16;
}
.preview-card img {
width: 100%;
aspect-ratio: 16/10;
object-fit: cover;
background: #000;
display: block;
}
.preview-card .meta { padding: 10px 12px; font-size: 13px; }
.preview-card .meta strong { display: block; margin-bottom: 4px; }
.hidden { display: none !important; }
.row-ops { display: flex; gap: 6px; }
@media (max-width: 720px) {
.top { flex-direction: column; align-items: flex-start; }
}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理台 · JhLab 协议网关</title>
<link rel="stylesheet" href="/ui/app.css" />
</head>
<body>
<div class="wrap">
<header class="top">
<div class="brand">JhLab 协议网关<small id="verLabel">加载中…</small></div>
<nav class="nav" id="nav">
<button type="button" data-page="overview" class="active">总览</button>
<button type="button" data-page="network">网络/IP</button>
<button type="button" data-page="devices">设备列表</button>
<button type="button" data-page="deviceEdit">设备配置</button>
<button type="button" data-page="cameras">摄像头预览</button>
</nav>
<button class="btn ghost" type="button" id="btnLogout">退出</button>
</header>
<p class="msg" id="banner"></p>
<section class="panel" id="page-overview">
<h2>运行总览</h2>
<div class="grid" id="healthCards"></div>
<div class="actions">
<button class="btn" type="button" id="btnRefreshHealth">刷新状态</button>
<a class="btn ghost" href="/health" target="_blank">打开 /health</a>
</div>
</section>
<section class="panel hidden" id="page-network">
<h2>网络与平台配置</h2>
<div class="grid">
<div class="field"><label>监听地址</label><input id="cfgListen" /></div>
<div class="field"><label>服务端口</label><input id="cfgPort" type="number" /></div>
<div class="field"><label>平台 URL</label><input id="cfgPlatform" /></div>
<div class="field"><label>采集周期 ms</label><input id="cfgPoll" type="number" /></div>
</div>
<h2 style="margin-top:18px">Modbus TCP 主机</h2>
<table class="table" id="modbusTable">
<thead><tr><th>ID</th><th>名称</th><th>IP</th><th>端口</th><th>启用</th><th></th></tr></thead>
<tbody></tbody>
</table>
<div class="actions">
<button class="btn" type="button" id="btnAddModbus">添加主机</button>
<button class="btn primary" type="button" id="btnSaveNetwork">保存配置</button>
</div>
</section>
<section class="panel hidden" id="page-devices">
<h2>设备列表</h2>
<table class="table" id="deviceTable">
<thead>
<tr><th>类型</th><th>名称</th><th>地址</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody></tbody>
</table>
<div class="actions">
<button class="btn" type="button" id="btnReloadDevices">刷新</button>
</div>
</section>
<section class="panel hidden" id="page-deviceEdit">
<h2>设备配置</h2>
<div class="grid">
<div class="field">
<label>类型</label>
<select id="devType">
<option value="camera">摄像头</option>
<option value="door">门禁</option>
<option value="nvr">NVR</option>
</select>
</div>
<div class="field"><label>ID</label><input id="devId" placeholder="cam-142" /></div>
<div class="field"><label>名称</label><input id="devName" /></div>
<div class="field"><label>IP</label><input id="devHost" placeholder="192.168.10.142" /></div>
<div class="field"><label>端口</label><input id="devPort" type="number" value="8000" /></div>
<div class="field"><label>用户名</label><input id="devUser" value="admin" /></div>
<div class="field"><label>密码</label><input id="devPass" type="password" /></div>
<div class="field"><label>通道号</label><input id="devChannel" type="number" value="1" /></div>
<div class="field">
<label>启用</label>
<select id="devEnabled"><option value="true">是</option><option value="false">否</option></select>
</div>
</div>
<div class="actions">
<button class="btn primary" type="button" id="btnSaveDevice">保存设备</button>
<button class="btn danger" type="button" id="btnDeleteDevice">删除</button>
</div>
<p class="msg" id="devMsg"></p>
</section>
<section class="panel hidden" id="page-cameras">
<h2>摄像头预览</h2>
<p style="color:var(--muted);font-size:13px;margin-top:0">
管理台缩略图为抓图通道;大屏/平台后台实况统一使用 <strong>WebRTC(WHEP)</strong>。
媒体面就绪前此处仍显示占位图。
</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>
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_URI $document_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param REQUEST_SCHEME $scheme;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
fastcgi_param SERVER_ADDR $server_addr;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param SERVER_NAME $server_name;
# PHP only, required if PHP was built with --enable-force-cgi-redirect
fastcgi_param REDIRECT_STATUS 200;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_URI $document_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param REQUEST_SCHEME $scheme;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
fastcgi_param SERVER_ADDR $server_addr;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param SERVER_NAME $server_name;
# PHP only, required if PHP was built with --enable-force-cgi-redirect
fastcgi_param REDIRECT_STATUS 200;
worker_processes 1;
events { worker_connections 1024; }
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
server {
listen 80;
server_name localhost;
client_max_body_size 20m;
root D:/apps/jh-lab/platform/web;
index index.html;
location /api/ {
proxy_pass http://127.0.0.1:3001/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /ws {
proxy_pass http://127.0.0.1:3001/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
location /rtengine/ {
proxy_pass http://192.168.32.121:6088/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_buffering off;
}
location /peer-stream {
proxy_pass http://192.168.32.121:6088/peer-stream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $proxy_host;
proxy_read_timeout 86400;
proxy_buffering off;
}
location /designer/ {
proxy_pass http://192.168.32.121:6088/designer/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
}
location / {
try_files $uri $uri/ /index.html;
}
}
}
# This map is not a full koi8-r <> utf8 map: it does not contain
# box-drawing and some other characters. Besides this map contains
# several koi8-u and Byelorussian letters which are not in koi8-r.
# If you need a full and standard map, use contrib/unicode2nginx/koi-utf
# map instead.
charset_map koi8-r utf-8 {
80 E282AC ; # euro
95 E280A2 ; # bullet
9A C2A0 ; # &nbsp;
9E C2B7 ; # &middot;
A3 D191 ; # small yo
A4 D194 ; # small Ukrainian ye
A6 D196 ; # small Ukrainian i
A7 D197 ; # small Ukrainian yi
AD D291 ; # small Ukrainian soft g
AE D19E ; # small Byelorussian short u
B0 C2B0 ; # &deg;
B3 D081 ; # capital YO
B4 D084 ; # capital Ukrainian YE
B6 D086 ; # capital Ukrainian I
B7 D087 ; # capital Ukrainian YI
B9 E28496 ; # numero sign
BD D290 ; # capital Ukrainian soft G
BE D18E ; # capital Byelorussian short U
BF C2A9 ; # (C)
C0 D18E ; # small yu
C1 D0B0 ; # small a
C2 D0B1 ; # small b
C3 D186 ; # small ts
C4 D0B4 ; # small d
C5 D0B5 ; # small ye
C6 D184 ; # small f
C7 D0B3 ; # small g
C8 D185 ; # small kh
C9 D0B8 ; # small i
CA D0B9 ; # small j
CB D0BA ; # small k
CC D0BB ; # small l
CD D0BC ; # small m
CE D0BD ; # small n
CF D0BE ; # small o
D0 D0BF ; # small p
D1 D18F ; # small ya
D2 D180 ; # small r
D3 D181 ; # small s
D4 D182 ; # small t
D5 D183 ; # small u
D6 D0B6 ; # small zh
D7 D0B2 ; # small v
D8 D18C ; # small soft sign
D9 D18B ; # small y
DA D0B7 ; # small z
DB D188 ; # small sh
DC D18D ; # small e
DD D189 ; # small shch
DE D187 ; # small ch
DF D18A ; # small hard sign
E0 D0AE ; # capital YU
E1 D090 ; # capital A
E2 D091 ; # capital B
E3 D0A6 ; # capital TS
E4 D094 ; # capital D
E5 D095 ; # capital YE
E6 D0A4 ; # capital F
E7 D093 ; # capital G
E8 D0A5 ; # capital KH
E9 D098 ; # capital I
EA D099 ; # capital J
EB D09A ; # capital K
EC D09B ; # capital L
ED D09C ; # capital M
EE D09D ; # capital N
EF D09E ; # capital O
F0 D09F ; # capital P
F1 D0AF ; # capital YA
F2 D0A0 ; # capital R
F3 D0A1 ; # capital S
F4 D0A2 ; # capital T
F5 D0A3 ; # capital U
F6 D096 ; # capital ZH
F7 D092 ; # capital V
F8 D0AC ; # capital soft sign
F9 D0AB ; # capital Y
FA D097 ; # capital Z
FB D0A8 ; # capital SH
FC D0AD ; # capital E
FD D0A9 ; # capital SHCH
FE D0A7 ; # capital CH
FF D0AA ; # capital hard sign
}
charset_map koi8-r windows-1251 {
80 88 ; # euro
95 95 ; # bullet
9A A0 ; # &nbsp;
9E B7 ; # &middot;
A3 B8 ; # small yo
A4 BA ; # small Ukrainian ye
A6 B3 ; # small Ukrainian i
A7 BF ; # small Ukrainian yi
AD B4 ; # small Ukrainian soft g
AE A2 ; # small Byelorussian short u
B0 B0 ; # &deg;
B3 A8 ; # capital YO
B4 AA ; # capital Ukrainian YE
B6 B2 ; # capital Ukrainian I
B7 AF ; # capital Ukrainian YI
B9 B9 ; # numero sign
BD A5 ; # capital Ukrainian soft G
BE A1 ; # capital Byelorussian short U
BF A9 ; # (C)
C0 FE ; # small yu
C1 E0 ; # small a
C2 E1 ; # small b
C3 F6 ; # small ts
C4 E4 ; # small d
C5 E5 ; # small ye
C6 F4 ; # small f
C7 E3 ; # small g
C8 F5 ; # small kh
C9 E8 ; # small i
CA E9 ; # small j
CB EA ; # small k
CC EB ; # small l
CD EC ; # small m
CE ED ; # small n
CF EE ; # small o
D0 EF ; # small p
D1 FF ; # small ya
D2 F0 ; # small r
D3 F1 ; # small s
D4 F2 ; # small t
D5 F3 ; # small u
D6 E6 ; # small zh
D7 E2 ; # small v
D8 FC ; # small soft sign
D9 FB ; # small y
DA E7 ; # small z
DB F8 ; # small sh
DC FD ; # small e
DD F9 ; # small shch
DE F7 ; # small ch
DF FA ; # small hard sign
E0 DE ; # capital YU
E1 C0 ; # capital A
E2 C1 ; # capital B
E3 D6 ; # capital TS
E4 C4 ; # capital D
E5 C5 ; # capital YE
E6 D4 ; # capital F
E7 C3 ; # capital G
E8 D5 ; # capital KH
E9 C8 ; # capital I
EA C9 ; # capital J
EB CA ; # capital K
EC CB ; # capital L
ED CC ; # capital M
EE CD ; # capital N
EF CE ; # capital O
F0 CF ; # capital P
F1 DF ; # capital YA
F2 D0 ; # capital R
F3 D1 ; # capital S
F4 D2 ; # capital T
F5 D3 ; # capital U
F6 C6 ; # capital ZH
F7 C2 ; # capital V
F8 DC ; # capital soft sign
F9 DB ; # capital Y
FA C7 ; # capital Z
FB D8 ; # capital SH
FC DD ; # capital E
FD D9 ; # capital SHCH
FE D7 ; # capital CH
FF DA ; # capital hard sign
}
types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
application/javascript js;
application/atom+xml atom;
application/rss+xml rss;
text/mathml mml;
text/plain txt;
text/vnd.sun.j2me.app-descriptor jad;
text/vnd.wap.wml wml;
text/x-component htc;
image/avif avif;
image/png png;
image/svg+xml svg svgz;
image/tiff tif tiff;
image/vnd.wap.wbmp wbmp;
image/webp webp;
image/x-icon ico;
image/x-jng jng;
image/x-ms-bmp bmp;
font/woff woff;
font/woff2 woff2;
application/java-archive jar war ear;
application/json json;
application/mac-binhex40 hqx;
application/msword doc;
application/pdf pdf;
application/postscript ps eps ai;
application/rtf rtf;
application/vnd.apple.mpegurl m3u8;
application/vnd.google-earth.kml+xml kml;
application/vnd.google-earth.kmz kmz;
application/vnd.ms-excel xls;
application/vnd.ms-fontobject eot;
application/vnd.ms-powerpoint ppt;
application/vnd.oasis.opendocument.graphics odg;
application/vnd.oasis.opendocument.presentation odp;
application/vnd.oasis.opendocument.spreadsheet ods;
application/vnd.oasis.opendocument.text odt;
application/vnd.openxmlformats-officedocument.presentationml.presentation
pptx;
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
xlsx;
application/vnd.openxmlformats-officedocument.wordprocessingml.document
docx;
application/vnd.wap.wmlc wmlc;
application/wasm wasm;
application/x-7z-compressed 7z;
application/x-cocoa cco;
application/x-java-archive-diff jardiff;
application/x-java-jnlp-file jnlp;
application/x-makeself run;
application/x-perl pl pm;
application/x-pilot prc pdb;
application/x-rar-compressed rar;
application/x-redhat-package-manager rpm;
application/x-sea sea;
application/x-shockwave-flash swf;
application/x-stuffit sit;
application/x-tcl tcl tk;
application/x-x509-ca-cert der pem crt;
application/x-xpinstall xpi;
application/xhtml+xml xhtml;
application/xspf+xml xspf;
application/zip zip;
application/octet-stream bin exe dll;
application/octet-stream deb;
application/octet-stream dmg;
application/octet-stream iso img;
application/octet-stream msi msp msm;
audio/midi mid midi kar;
audio/mpeg mp3;
audio/ogg ogg;
audio/x-m4a m4a;
audio/x-realaudio ra;
video/3gpp 3gpp 3gp;
video/mp2t ts;
video/mp4 mp4;
video/mpeg mpeg mpg;
video/quicktime mov;
video/webm webm;
video/x-flv flv;
video/x-m4v m4v;
video/x-mng mng;
video/x-ms-asf asx asf;
video/x-ms-wmv wmv;
video/x-msvideo avi;
}
worker_processes 1;
events { worker_connections 1024; }
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
server {
listen 80;
server_name localhost;
client_max_body_size 20m;
root D:/apps/jh-lab/platform/web;
index index.html;
location /api/ {
proxy_pass http://127.0.0.1:3001/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /ws {
proxy_pass http://127.0.0.1:3001/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
location /rtengine/ {
proxy_pass http://192.168.32.121:6088/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_buffering off;
}
location /peer-stream {
proxy_pass http://192.168.32.121:6088/peer-stream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $proxy_host;
proxy_read_timeout 86400;
proxy_buffering off;
}
location /designer/ {
proxy_pass http://192.168.32.121:6088/designer/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
}
location / {
try_files $uri $uri/ /index.html;
}
}
}
scgi_param REQUEST_METHOD $request_method;
scgi_param REQUEST_URI $request_uri;
scgi_param QUERY_STRING $query_string;
scgi_param CONTENT_TYPE $content_type;
scgi_param DOCUMENT_URI $document_uri;
scgi_param DOCUMENT_ROOT $document_root;
scgi_param SCGI 1;
scgi_param SERVER_PROTOCOL $server_protocol;
scgi_param REQUEST_SCHEME $scheme;
scgi_param HTTPS $https if_not_empty;
scgi_param REMOTE_ADDR $remote_addr;
scgi_param REMOTE_PORT $remote_port;
scgi_param SERVER_PORT $server_port;
scgi_param SERVER_NAME $server_name;
uwsgi_param QUERY_STRING $query_string;
uwsgi_param REQUEST_METHOD $request_method;
uwsgi_param CONTENT_TYPE $content_type;
uwsgi_param CONTENT_LENGTH $content_length;
uwsgi_param REQUEST_URI $request_uri;
uwsgi_param PATH_INFO $document_uri;
uwsgi_param DOCUMENT_ROOT $document_root;
uwsgi_param SERVER_PROTOCOL $server_protocol;
uwsgi_param REQUEST_SCHEME $scheme;
uwsgi_param HTTPS $https if_not_empty;
uwsgi_param REMOTE_ADDR $remote_addr;
uwsgi_param REMOTE_PORT $remote_port;
uwsgi_param SERVER_PORT $server_port;
uwsgi_param SERVER_NAME $server_name;
# This map is not a full windows-1251 <> utf8 map: it does not
# contain Serbian and Macedonian letters. If you need a full map,
# use contrib/unicode2nginx/win-utf map instead.
charset_map windows-1251 utf-8 {
82 E2809A ; # single low-9 quotation mark
84 E2809E ; # double low-9 quotation mark
85 E280A6 ; # ellipsis
86 E280A0 ; # dagger
87 E280A1 ; # double dagger
88 E282AC ; # euro
89 E280B0 ; # per mille
91 E28098 ; # left single quotation mark
92 E28099 ; # right single quotation mark
93 E2809C ; # left double quotation mark
94 E2809D ; # right double quotation mark
95 E280A2 ; # bullet
96 E28093 ; # en dash
97 E28094 ; # em dash
99 E284A2 ; # trade mark sign
A0 C2A0 ; # &nbsp;
A1 D18E ; # capital Byelorussian short U
A2 D19E ; # small Byelorussian short u
A4 C2A4 ; # currency sign
A5 D290 ; # capital Ukrainian soft G
A6 C2A6 ; # borken bar
A7 C2A7 ; # section sign
A8 D081 ; # capital YO
A9 C2A9 ; # (C)
AA D084 ; # capital Ukrainian YE
AB C2AB ; # left-pointing double angle quotation mark
AC C2AC ; # not sign
AD C2AD ; # soft hypen
AE C2AE ; # (R)
AF D087 ; # capital Ukrainian YI
B0 C2B0 ; # &deg;
B1 C2B1 ; # plus-minus sign
B2 D086 ; # capital Ukrainian I
B3 D196 ; # small Ukrainian i
B4 D291 ; # small Ukrainian soft g
B5 C2B5 ; # micro sign
B6 C2B6 ; # pilcrow sign
B7 C2B7 ; # &middot;
B8 D191 ; # small yo
B9 E28496 ; # numero sign
BA D194 ; # small Ukrainian ye
BB C2BB ; # right-pointing double angle quotation mark
BF D197 ; # small Ukrainian yi
C0 D090 ; # capital A
C1 D091 ; # capital B
C2 D092 ; # capital V
C3 D093 ; # capital G
C4 D094 ; # capital D
C5 D095 ; # capital YE
C6 D096 ; # capital ZH
C7 D097 ; # capital Z
C8 D098 ; # capital I
C9 D099 ; # capital J
CA D09A ; # capital K
CB D09B ; # capital L
CC D09C ; # capital M
CD D09D ; # capital N
CE D09E ; # capital O
CF D09F ; # capital P
D0 D0A0 ; # capital R
D1 D0A1 ; # capital S
D2 D0A2 ; # capital T
D3 D0A3 ; # capital U
D4 D0A4 ; # capital F
D5 D0A5 ; # capital KH
D6 D0A6 ; # capital TS
D7 D0A7 ; # capital CH
D8 D0A8 ; # capital SH
D9 D0A9 ; # capital SHCH
DA D0AA ; # capital hard sign
DB D0AB ; # capital Y
DC D0AC ; # capital soft sign
DD D0AD ; # capital E
DE D0AE ; # capital YU
DF D0AF ; # capital YA
E0 D0B0 ; # small a
E1 D0B1 ; # small b
E2 D0B2 ; # small v
E3 D0B3 ; # small g
E4 D0B4 ; # small d
E5 D0B5 ; # small ye
E6 D0B6 ; # small zh
E7 D0B7 ; # small z
E8 D0B8 ; # small i
E9 D0B9 ; # small j
EA D0BA ; # small k
EB D0BB ; # small l
EC D0BC ; # small m
ED D0BD ; # small n
EE D0BE ; # small o
EF D0BF ; # small p
F0 D180 ; # small r
F1 D181 ; # small s
F2 D182 ; # small t
F3 D183 ; # small u
F4 D184 ; # small f
F5 D185 ; # small kh
F6 D186 ; # small ts
F7 D187 ; # small ch
F8 D188 ; # small sh
F9 D189 ; # small shch
FA D18A ; # small hard sign
FB D18B ; # small y
FC D18C ; # small soft sign
FD D18D ; # small e
FE D18E ; # small yu
FF D18F ; # small ya
}
geo2nginx.pl by Andrei Nigmatulin
The perl script to convert CSV geoip database ( free download
at http://www.maxmind.com/app/geoip_country ) to format, suitable
for use by the ngx_http_geo_module.
unicode2nginx by Maxim Dounin
The perl script to convert unicode mappings ( available
at http://www.unicode.org/Public/MAPPINGS/ ) to the nginx
configuration file format.
Two generated full maps for windows-1251 and koi8-r.
vim by Evan Miller
Syntax highlighting of nginx configuration for vim, to be
placed into ~/.vim/.
#!/usr/bin/perl -w
# (c) Andrei Nigmatulin, 2005
#
# this script provided "as is", without any warranties. use it at your own risk.
#
# special thanx to Andrew Sitnikov for perl port
#
# this script converts CSV geoip database (free download at http://www.maxmind.com/app/geoip_country)
# to format, suitable for use with nginx_http_geo module (http://sysoev.ru/nginx)
#
# for example, line with ip range
#
# "62.16.68.0","62.16.127.255","1041253376","1041268735","RU","Russian Federation"
#
# will be converted to four subnetworks:
#
# 62.16.68.0/22 RU;
# 62.16.72.0/21 RU;
# 62.16.80.0/20 RU;
# 62.16.96.0/19 RU;
use warnings;
use strict;
while( <STDIN> ){
if (/"[^"]+","[^"]+","([^"]+)","([^"]+)","([^"]+)"/){
print_subnets($1, $2, $3);
}
}
sub print_subnets {
my ($a1, $a2, $c) = @_;
my $l;
while ($a1 <= $a2) {
for ($l = 0; ($a1 & (1 << $l)) == 0 && ($a1 + ((1 << ($l + 1)) - 1)) <= $a2; $l++){};
print long2ip($a1) . "/" . (32 - $l) . " " . $c . ";\n";
$a1 += (1 << $l);
}
}
sub long2ip {
my $ip = shift;
my $str = 0;
$str = ($ip & 255);
$ip >>= 8;
$str = ($ip & 255).".$str";
$ip >>= 8;
$str = ($ip & 255).".$str";
$ip >>= 8;
$str = ($ip & 255).".$str";
}
charset_map koi8-r utf-8 {
80 E29480 ; # BOX DRAWINGS LIGHT HORIZONTAL
81 E29482 ; # BOX DRAWINGS LIGHT VERTICAL
82 E2948C ; # BOX DRAWINGS LIGHT DOWN AND RIGHT
83 E29490 ; # BOX DRAWINGS LIGHT DOWN AND LEFT
84 E29494 ; # BOX DRAWINGS LIGHT UP AND RIGHT
85 E29498 ; # BOX DRAWINGS LIGHT UP AND LEFT
86 E2949C ; # BOX DRAWINGS LIGHT VERTICAL AND RIGHT
87 E294A4 ; # BOX DRAWINGS LIGHT VERTICAL AND LEFT
88 E294AC ; # BOX DRAWINGS LIGHT DOWN AND HORIZONTAL
89 E294B4 ; # BOX DRAWINGS LIGHT UP AND HORIZONTAL
8A E294BC ; # BOX DRAWINGS LIGHT VERTICAL AND HORIZONTAL
8B E29680 ; # UPPER HALF BLOCK
8C E29684 ; # LOWER HALF BLOCK
8D E29688 ; # FULL BLOCK
8E E2968C ; # LEFT HALF BLOCK
8F E29690 ; # RIGHT HALF BLOCK
90 E29691 ; # LIGHT SHADE
91 E29692 ; # MEDIUM SHADE
92 E29693 ; # DARK SHADE
93 E28CA0 ; # TOP HALF INTEGRAL
94 E296A0 ; # BLACK SQUARE
95 E28899 ; # BULLET OPERATOR
96 E2889A ; # SQUARE ROOT
97 E28988 ; # ALMOST EQUAL TO
98 E289A4 ; # LESS-THAN OR EQUAL TO
99 E289A5 ; # GREATER-THAN OR EQUAL TO
9A C2A0 ; # NO-BREAK SPACE
9B E28CA1 ; # BOTTOM HALF INTEGRAL
9C C2B0 ; # DEGREE SIGN
9D C2B2 ; # SUPERSCRIPT TWO
9E C2B7 ; # MIDDLE DOT
9F C3B7 ; # DIVISION SIGN
A0 E29590 ; # BOX DRAWINGS DOUBLE HORIZONTAL
A1 E29591 ; # BOX DRAWINGS DOUBLE VERTICAL
A2 E29592 ; # BOX DRAWINGS DOWN SINGLE AND RIGHT DOUBLE
A3 D191 ; # CYRILLIC SMALL LETTER IO
A4 E29593 ; # BOX DRAWINGS DOWN DOUBLE AND RIGHT SINGLE
A5 E29594 ; # BOX DRAWINGS DOUBLE DOWN AND RIGHT
A6 E29595 ; # BOX DRAWINGS DOWN SINGLE AND LEFT DOUBLE
A7 E29596 ; # BOX DRAWINGS DOWN DOUBLE AND LEFT SINGLE
A8 E29597 ; # BOX DRAWINGS DOUBLE DOWN AND LEFT
A9 E29598 ; # BOX DRAWINGS UP SINGLE AND RIGHT DOUBLE
AA E29599 ; # BOX DRAWINGS UP DOUBLE AND RIGHT SINGLE
AB E2959A ; # BOX DRAWINGS DOUBLE UP AND RIGHT
AC E2959B ; # BOX DRAWINGS UP SINGLE AND LEFT DOUBLE
AD E2959C ; # BOX DRAWINGS UP DOUBLE AND LEFT SINGLE
AE E2959D ; # BOX DRAWINGS DOUBLE UP AND LEFT
AF E2959E ; # BOX DRAWINGS VERTICAL SINGLE AND RIGHT DOUBLE
B0 E2959F ; # BOX DRAWINGS VERTICAL DOUBLE AND RIGHT SINGLE
B1 E295A0 ; # BOX DRAWINGS DOUBLE VERTICAL AND RIGHT
B2 E295A1 ; # BOX DRAWINGS VERTICAL SINGLE AND LEFT DOUBLE
B3 D081 ; # CYRILLIC CAPITAL LETTER IO
B4 E295A2 ; # BOX DRAWINGS VERTICAL DOUBLE AND LEFT SINGLE
B5 E295A3 ; # BOX DRAWINGS DOUBLE VERTICAL AND LEFT
B6 E295A4 ; # BOX DRAWINGS DOWN SINGLE AND HORIZONTAL DOUBLE
B7 E295A5 ; # BOX DRAWINGS DOWN DOUBLE AND HORIZONTAL SINGLE
B8 E295A6 ; # BOX DRAWINGS DOUBLE DOWN AND HORIZONTAL
B9 E295A7 ; # BOX DRAWINGS UP SINGLE AND HORIZONTAL DOUBLE
BA E295A8 ; # BOX DRAWINGS UP DOUBLE AND HORIZONTAL SINGLE
BB E295A9 ; # BOX DRAWINGS DOUBLE UP AND HORIZONTAL
BC E295AA ; # BOX DRAWINGS VERTICAL SINGLE AND HORIZONTAL DOUBLE
BD E295AB ; # BOX DRAWINGS VERTICAL DOUBLE AND HORIZONTAL SINGLE
BE E295AC ; # BOX DRAWINGS DOUBLE VERTICAL AND HORIZONTAL
BF C2A9 ; # COPYRIGHT SIGN
C0 D18E ; # CYRILLIC SMALL LETTER YU
C1 D0B0 ; # CYRILLIC SMALL LETTER A
C2 D0B1 ; # CYRILLIC SMALL LETTER BE
C3 D186 ; # CYRILLIC SMALL LETTER TSE
C4 D0B4 ; # CYRILLIC SMALL LETTER DE
C5 D0B5 ; # CYRILLIC SMALL LETTER IE
C6 D184 ; # CYRILLIC SMALL LETTER EF
C7 D0B3 ; # CYRILLIC SMALL LETTER GHE
C8 D185 ; # CYRILLIC SMALL LETTER HA
C9 D0B8 ; # CYRILLIC SMALL LETTER I
CA D0B9 ; # CYRILLIC SMALL LETTER SHORT I
CB D0BA ; # CYRILLIC SMALL LETTER KA
CC D0BB ; # CYRILLIC SMALL LETTER EL
CD D0BC ; # CYRILLIC SMALL LETTER EM
CE D0BD ; # CYRILLIC SMALL LETTER EN
CF D0BE ; # CYRILLIC SMALL LETTER O
D0 D0BF ; # CYRILLIC SMALL LETTER PE
D1 D18F ; # CYRILLIC SMALL LETTER YA
D2 D180 ; # CYRILLIC SMALL LETTER ER
D3 D181 ; # CYRILLIC SMALL LETTER ES
D4 D182 ; # CYRILLIC SMALL LETTER TE
D5 D183 ; # CYRILLIC SMALL LETTER U
D6 D0B6 ; # CYRILLIC SMALL LETTER ZHE
D7 D0B2 ; # CYRILLIC SMALL LETTER VE
D8 D18C ; # CYRILLIC SMALL LETTER SOFT SIGN
D9 D18B ; # CYRILLIC SMALL LETTER YERU
DA D0B7 ; # CYRILLIC SMALL LETTER ZE
DB D188 ; # CYRILLIC SMALL LETTER SHA
DC D18D ; # CYRILLIC SMALL LETTER E
DD D189 ; # CYRILLIC SMALL LETTER SHCHA
DE D187 ; # CYRILLIC SMALL LETTER CHE
DF D18A ; # CYRILLIC SMALL LETTER HARD SIGN
E0 D0AE ; # CYRILLIC CAPITAL LETTER YU
E1 D090 ; # CYRILLIC CAPITAL LETTER A
E2 D091 ; # CYRILLIC CAPITAL LETTER BE
E3 D0A6 ; # CYRILLIC CAPITAL LETTER TSE
E4 D094 ; # CYRILLIC CAPITAL LETTER DE
E5 D095 ; # CYRILLIC CAPITAL LETTER IE
E6 D0A4 ; # CYRILLIC CAPITAL LETTER EF
E7 D093 ; # CYRILLIC CAPITAL LETTER GHE
E8 D0A5 ; # CYRILLIC CAPITAL LETTER HA
E9 D098 ; # CYRILLIC CAPITAL LETTER I
EA D099 ; # CYRILLIC CAPITAL LETTER SHORT I
EB D09A ; # CYRILLIC CAPITAL LETTER KA
EC D09B ; # CYRILLIC CAPITAL LETTER EL
ED D09C ; # CYRILLIC CAPITAL LETTER EM
EE D09D ; # CYRILLIC CAPITAL LETTER EN
EF D09E ; # CYRILLIC CAPITAL LETTER O
F0 D09F ; # CYRILLIC CAPITAL LETTER PE
F1 D0AF ; # CYRILLIC CAPITAL LETTER YA
F2 D0A0 ; # CYRILLIC CAPITAL LETTER ER
F3 D0A1 ; # CYRILLIC CAPITAL LETTER ES
F4 D0A2 ; # CYRILLIC CAPITAL LETTER TE
F5 D0A3 ; # CYRILLIC CAPITAL LETTER U
F6 D096 ; # CYRILLIC CAPITAL LETTER ZHE
F7 D092 ; # CYRILLIC CAPITAL LETTER VE
F8 D0AC ; # CYRILLIC CAPITAL LETTER SOFT SIGN
F9 D0AB ; # CYRILLIC CAPITAL LETTER YERU
FA D097 ; # CYRILLIC CAPITAL LETTER ZE
FB D0A8 ; # CYRILLIC CAPITAL LETTER SHA
FC D0AD ; # CYRILLIC CAPITAL LETTER E
FD D0A9 ; # CYRILLIC CAPITAL LETTER SHCHA
FE D0A7 ; # CYRILLIC CAPITAL LETTER CHE
FF D0AA ; # CYRILLIC CAPITAL LETTER HARD SIGN
}
#!/usr/bin/perl -w
# Convert unicode mappings to nginx configuration file format.
# You may find useful mappings in various places, including
# unicode.org official site:
#
# http://www.unicode.org/Public/MAPPINGS/VENDORS/MICSFT/WINDOWS/CP1251.TXT
# http://www.unicode.org/Public/MAPPINGS/VENDORS/MISC/KOI8-R.TXT
# Needs perl 5.6 or later.
# Written by Maxim Dounin, mdounin@mdounin.ru
###############################################################################
require 5.006;
while (<>) {
# Skip comments and empty lines
next if /^#/;
next if /^\s*$/;
chomp;
# Convert mappings
if (/^\s*0x(..)\s*0x(....)\s*(#.*)/) {
# Mapping <from-code> <unicode-code> "#" <unicode-name>
my $cs_code = $1;
my $un_code = $2;
my $un_name = $3;
# Produce UTF-8 sequence from character code;
my $un_utf8 = join('',
map { sprintf("%02X", $_) }
unpack("U0C*", pack("U", hex($un_code)))
);
print " $cs_code $un_utf8 ; $un_name\n";
} else {
warn "Unrecognized line: '$_'";
}
}
###############################################################################
charset_map windows-1251 utf-8 {
80 D082 ; #CYRILLIC CAPITAL LETTER DJE
81 D083 ; #CYRILLIC CAPITAL LETTER GJE
82 E2809A ; #SINGLE LOW-9 QUOTATION MARK
83 D193 ; #CYRILLIC SMALL LETTER GJE
84 E2809E ; #DOUBLE LOW-9 QUOTATION MARK
85 E280A6 ; #HORIZONTAL ELLIPSIS
86 E280A0 ; #DAGGER
87 E280A1 ; #DOUBLE DAGGER
88 E282AC ; #EURO SIGN
89 E280B0 ; #PER MILLE SIGN
8A D089 ; #CYRILLIC CAPITAL LETTER LJE
8B E280B9 ; #SINGLE LEFT-POINTING ANGLE QUOTATION MARK
8C D08A ; #CYRILLIC CAPITAL LETTER NJE
8D D08C ; #CYRILLIC CAPITAL LETTER KJE
8E D08B ; #CYRILLIC CAPITAL LETTER TSHE
8F D08F ; #CYRILLIC CAPITAL LETTER DZHE
90 D192 ; #CYRILLIC SMALL LETTER DJE
91 E28098 ; #LEFT SINGLE QUOTATION MARK
92 E28099 ; #RIGHT SINGLE QUOTATION MARK
93 E2809C ; #LEFT DOUBLE QUOTATION MARK
94 E2809D ; #RIGHT DOUBLE QUOTATION MARK
95 E280A2 ; #BULLET
96 E28093 ; #EN DASH
97 E28094 ; #EM DASH
99 E284A2 ; #TRADE MARK SIGN
9A D199 ; #CYRILLIC SMALL LETTER LJE
9B E280BA ; #SINGLE RIGHT-POINTING ANGLE QUOTATION MARK
9C D19A ; #CYRILLIC SMALL LETTER NJE
9D D19C ; #CYRILLIC SMALL LETTER KJE
9E D19B ; #CYRILLIC SMALL LETTER TSHE
9F D19F ; #CYRILLIC SMALL LETTER DZHE
A0 C2A0 ; #NO-BREAK SPACE
A1 D08E ; #CYRILLIC CAPITAL LETTER SHORT U
A2 D19E ; #CYRILLIC SMALL LETTER SHORT U
A3 D088 ; #CYRILLIC CAPITAL LETTER JE
A4 C2A4 ; #CURRENCY SIGN
A5 D290 ; #CYRILLIC CAPITAL LETTER GHE WITH UPTURN
A6 C2A6 ; #BROKEN BAR
A7 C2A7 ; #SECTION SIGN
A8 D081 ; #CYRILLIC CAPITAL LETTER IO
A9 C2A9 ; #COPYRIGHT SIGN
AA D084 ; #CYRILLIC CAPITAL LETTER UKRAINIAN IE
AB C2AB ; #LEFT-POINTING DOUBLE ANGLE QUOTATION MARK
AC C2AC ; #NOT SIGN
AD C2AD ; #SOFT HYPHEN
AE C2AE ; #REGISTERED SIGN
AF D087 ; #CYRILLIC CAPITAL LETTER YI
B0 C2B0 ; #DEGREE SIGN
B1 C2B1 ; #PLUS-MINUS SIGN
B2 D086 ; #CYRILLIC CAPITAL LETTER BYELORUSSIAN-UKRAINIAN I
B3 D196 ; #CYRILLIC SMALL LETTER BYELORUSSIAN-UKRAINIAN I
B4 D291 ; #CYRILLIC SMALL LETTER GHE WITH UPTURN
B5 C2B5 ; #MICRO SIGN
B6 C2B6 ; #PILCROW SIGN
B7 C2B7 ; #MIDDLE DOT
B8 D191 ; #CYRILLIC SMALL LETTER IO
B9 E28496 ; #NUMERO SIGN
BA D194 ; #CYRILLIC SMALL LETTER UKRAINIAN IE
BB C2BB ; #RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK
BC D198 ; #CYRILLIC SMALL LETTER JE
BD D085 ; #CYRILLIC CAPITAL LETTER DZE
BE D195 ; #CYRILLIC SMALL LETTER DZE
BF D197 ; #CYRILLIC SMALL LETTER YI
C0 D090 ; #CYRILLIC CAPITAL LETTER A
C1 D091 ; #CYRILLIC CAPITAL LETTER BE
C2 D092 ; #CYRILLIC CAPITAL LETTER VE
C3 D093 ; #CYRILLIC CAPITAL LETTER GHE
C4 D094 ; #CYRILLIC CAPITAL LETTER DE
C5 D095 ; #CYRILLIC CAPITAL LETTER IE
C6 D096 ; #CYRILLIC CAPITAL LETTER ZHE
C7 D097 ; #CYRILLIC CAPITAL LETTER ZE
C8 D098 ; #CYRILLIC CAPITAL LETTER I
C9 D099 ; #CYRILLIC CAPITAL LETTER SHORT I
CA D09A ; #CYRILLIC CAPITAL LETTER KA
CB D09B ; #CYRILLIC CAPITAL LETTER EL
CC D09C ; #CYRILLIC CAPITAL LETTER EM
CD D09D ; #CYRILLIC CAPITAL LETTER EN
CE D09E ; #CYRILLIC CAPITAL LETTER O
CF D09F ; #CYRILLIC CAPITAL LETTER PE
D0 D0A0 ; #CYRILLIC CAPITAL LETTER ER
D1 D0A1 ; #CYRILLIC CAPITAL LETTER ES
D2 D0A2 ; #CYRILLIC CAPITAL LETTER TE
D3 D0A3 ; #CYRILLIC CAPITAL LETTER U
D4 D0A4 ; #CYRILLIC CAPITAL LETTER EF
D5 D0A5 ; #CYRILLIC CAPITAL LETTER HA
D6 D0A6 ; #CYRILLIC CAPITAL LETTER TSE
D7 D0A7 ; #CYRILLIC CAPITAL LETTER CHE
D8 D0A8 ; #CYRILLIC CAPITAL LETTER SHA
D9 D0A9 ; #CYRILLIC CAPITAL LETTER SHCHA
DA D0AA ; #CYRILLIC CAPITAL LETTER HARD SIGN
DB D0AB ; #CYRILLIC CAPITAL LETTER YERU
DC D0AC ; #CYRILLIC CAPITAL LETTER SOFT SIGN
DD D0AD ; #CYRILLIC CAPITAL LETTER E
DE D0AE ; #CYRILLIC CAPITAL LETTER YU
DF D0AF ; #CYRILLIC CAPITAL LETTER YA
E0 D0B0 ; #CYRILLIC SMALL LETTER A
E1 D0B1 ; #CYRILLIC SMALL LETTER BE
E2 D0B2 ; #CYRILLIC SMALL LETTER VE
E3 D0B3 ; #CYRILLIC SMALL LETTER GHE
E4 D0B4 ; #CYRILLIC SMALL LETTER DE
E5 D0B5 ; #CYRILLIC SMALL LETTER IE
E6 D0B6 ; #CYRILLIC SMALL LETTER ZHE
E7 D0B7 ; #CYRILLIC SMALL LETTER ZE
E8 D0B8 ; #CYRILLIC SMALL LETTER I
E9 D0B9 ; #CYRILLIC SMALL LETTER SHORT I
EA D0BA ; #CYRILLIC SMALL LETTER KA
EB D0BB ; #CYRILLIC SMALL LETTER EL
EC D0BC ; #CYRILLIC SMALL LETTER EM
ED D0BD ; #CYRILLIC SMALL LETTER EN
EE D0BE ; #CYRILLIC SMALL LETTER O
EF D0BF ; #CYRILLIC SMALL LETTER PE
F0 D180 ; #CYRILLIC SMALL LETTER ER
F1 D181 ; #CYRILLIC SMALL LETTER ES
F2 D182 ; #CYRILLIC SMALL LETTER TE
F3 D183 ; #CYRILLIC SMALL LETTER U
F4 D184 ; #CYRILLIC SMALL LETTER EF
F5 D185 ; #CYRILLIC SMALL LETTER HA
F6 D186 ; #CYRILLIC SMALL LETTER TSE
F7 D187 ; #CYRILLIC SMALL LETTER CHE
F8 D188 ; #CYRILLIC SMALL LETTER SHA
F9 D189 ; #CYRILLIC SMALL LETTER SHCHA
FA D18A ; #CYRILLIC SMALL LETTER HARD SIGN
FB D18B ; #CYRILLIC SMALL LETTER YERU
FC D18C ; #CYRILLIC SMALL LETTER SOFT SIGN
FD D18D ; #CYRILLIC SMALL LETTER E
FE D18E ; #CYRILLIC SMALL LETTER YU
FF D18F ; #CYRILLIC SMALL LETTER YA
}
au BufRead,BufNewFile *.nginx set ft=nginx
au BufRead,BufNewFile */etc/nginx/* set ft=nginx
au BufRead,BufNewFile */usr/local/nginx/conf/* set ft=nginx
au BufRead,BufNewFile nginx.conf set ft=nginx
if exists("b:did_indent")
finish
endif
let b:did_indent = 1
setlocal indentexpr=
" cindent actually works for nginx' simple file structure
setlocal cindent
" Just make sure that the comments are not reset as defs would be.
setlocal cinkeys-=0#
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
/*
* Copyright (C) 2002-2021 Igor Sysoev
* Copyright (C) 2011-2024 Nginx, Inc.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*/
PCRE2 LICENCE
-------------
PCRE2 is a library of functions to support regular expressions whose syntax
and semantics are as close as possible to those of the Perl 5 language.
Releases 10.00 and above of PCRE2 are distributed under the terms of the "BSD"
licence, as specified below, with one exemption for certain binary
redistributions. The documentation for PCRE2, supplied in the "doc" directory,
is distributed under the same terms as the software itself. The data in the
testdata directory is not copyrighted and is in the public domain.
The basic library functions are written in C and are freestanding. Also
included in the distribution is a just-in-time compiler that can be used to
optimize pattern matching. This is an optional feature that can be omitted when
the library is built.
THE BASIC LIBRARY FUNCTIONS
---------------------------
Written by: Philip Hazel
Email local part: Philip.Hazel
Email domain: gmail.com
Retired from University of Cambridge Computing Service,
Cambridge, England.
Copyright (c) 1997-2021 University of Cambridge
All rights reserved.
PCRE2 JUST-IN-TIME COMPILATION SUPPORT
--------------------------------------
Written by: Zoltan Herczeg
Email local part: hzmester
Email domain: freemail.hu
Copyright(c) 2010-2021 Zoltan Herczeg
All rights reserved.
STACK-LESS JUST-IN-TIME COMPILER
--------------------------------
Written by: Zoltan Herczeg
Email local part: hzmester
Email domain: freemail.hu
Copyright(c) 2009-2021 Zoltan Herczeg
All rights reserved.
THE "BSD" LICENCE
-----------------
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notices,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notices, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the University of Cambridge nor the names of any
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
EXEMPTION FOR BINARY LIBRARY-LIKE PACKAGES
------------------------------------------
The second condition in the BSD licence (covering binary redistributions) does
not apply all the way down a chain of software. If binary package A includes
PCRE2, it must respect the condition, but if package B is software that
includes package A, the condition is not imposed on package B unless it uses
PCRE2 independently.
End
Documentation is available at http://nginx.org
(C) 1995-2024 Jean-loup Gailly and Mark Adler
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
Jean-loup Gailly Mark Adler
jloup@gzip.org madler@alumni.caltech.edu
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>An error occurred.</h1>
<p>Sorry, the page you are looking for is currently unavailable.<br/>
Please try again later.</p>
<p>If you are the system administrator of this resource then you should check
the error log for details.</p>
<p><em>Faithfully yours, nginx.</em></p>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
import { config } from '../config.js';
/**
* 门禁适配器:ACCESS_BRIDGE_URL 有值时走 HCNetSDK Windows 桥接,否则 Mock。
* 二期由 realtime loop 调用 fetchAccessSnapshot() 合并进总快照。
*/
export async function fetchAccessSnapshot() {
if (!config.accessBridgeUrl)
return null;
try {
const res = await fetch(`${config.accessBridgeUrl.replace(/\/$/, '')}/snapshot`);
if (!res.ok)
throw new Error(`bridge ${res.status}`);
return (await res.json());
}
catch (e) {
console.warn('[access] bridge unavailable', e);
return null;
}
}
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';
const OPEN_STATUSES = ['active', 'acked'];
let mem = [];
let fileLoaded = false;
function nowIso() {
return new Date().toISOString();
}
function loadFile() {
try {
if (!fs.existsSync(config.alarmsFile))
return [];
const raw = JSON.parse(fs.readFileSync(config.alarmsFile, 'utf8'));
return Array.isArray(raw.alarms) ? raw.alarms : [];
}
catch {
return [];
}
}
function saveFile() {
fs.mkdirSync(config.dataDir, { recursive: true });
fs.writeFileSync(config.alarmsFile, JSON.stringify({ alarms: mem }, null, 2), 'utf8');
}
function ensureMem() {
if (!fileLoaded) {
mem = loadFile();
fileLoaded = true;
}
}
function pointSourceKey(pointId, kind) {
return `point:${pointId}:${kind}`;
}
function fromPointAlarm(a, existing) {
const t = nowIso();
const sourceKey = pointSourceKey(a.pointId, a.kind);
return {
id: existing?.id || randomUUID(),
source: 'point',
sourceKey,
level: a.level,
status: existing?.status === 'acked' ? 'acked' : 'active',
title: a.name,
message: a.message,
deviceId: a.deviceId,
pointId: a.pointId,
itemId: a.itemId,
value: a.value,
thresholdLow: a.alarmLow,
thresholdHigh: a.alarmHigh,
kind: a.kind,
payload: {
name: a.name,
alarmLow: a.alarmLow,
alarmHigh: a.alarmHigh,
},
raisedAt: existing?.raisedAt || a.ts || t,
updatedAt: t,
clearedAt: null,
ackedAt: existing?.ackedAt ?? null,
ackedBy: existing?.ackedBy ?? null,
};
}
function mapPgRow(r) {
return {
id: String(r.id),
source: r.source,
sourceKey: String(r.source_key),
level: r.level,
status: r.status,
title: String(r.title),
message: String(r.message),
deviceId: r.device_id == null ? null : String(r.device_id),
pointId: r.point_id == null ? null : String(r.point_id),
itemId: r.item_id == null ? null : String(r.item_id),
value: r.value == null ? null : Number(r.value),
thresholdLow: r.threshold_low == null ? null : Number(r.threshold_low),
thresholdHigh: r.threshold_high == null ? null : Number(r.threshold_high),
kind: r.kind == null ? null : String(r.kind),
payload: r.payload && typeof r.payload === 'object' && !Array.isArray(r.payload)
? r.payload
: {},
raisedAt: new Date(String(r.raised_at)).toISOString(),
updatedAt: new Date(String(r.updated_at)).toISOString(),
clearedAt: r.cleared_at ? new Date(String(r.cleared_at)).toISOString() : null,
ackedAt: r.acked_at ? new Date(String(r.acked_at)).toISOString() : null,
ackedBy: r.acked_by == null ? null : String(r.acked_by),
};
}
async function insertAlarmPg(a) {
await query(`INSERT INTO alarms (
id, source, source_key, level, status, title, message,
device_id, point_id, item_id, value, threshold_low, threshold_high, kind, payload,
raised_at, updated_at, cleared_at, acked_at, acked_by
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15::jsonb,
$16,$17,$18,$19,$20
)`, [
a.id,
a.source,
a.sourceKey,
a.level,
a.status,
a.title,
a.message,
a.deviceId,
a.pointId,
a.itemId,
a.value,
a.thresholdLow,
a.thresholdHigh,
a.kind,
JSON.stringify(a.payload),
a.raisedAt,
a.updatedAt,
a.clearedAt,
a.ackedAt,
a.ackedBy,
]);
}
async function updateOpenAlarmPg(a) {
await query(`UPDATE alarms SET
level=$2, status=$3, title=$4, message=$5, device_id=$6, point_id=$7, item_id=$8,
value=$9, threshold_low=$10, threshold_high=$11, kind=$12, payload=$13::jsonb,
updated_at=$14, cleared_at=NULL
WHERE id=$1`, [
a.id,
a.level,
a.status,
a.title,
a.message,
a.deviceId,
a.pointId,
a.itemId,
a.value,
a.thresholdLow,
a.thresholdHigh,
a.kind,
JSON.stringify(a.payload),
a.updatedAt,
]);
}
/**
* 将当前点位超限列表同步进统一告警表:
* - 仍超限:插入或更新未关闭记录(保留 acked)
* - 已恢复:未关闭的 point 告警标记 cleared
*/
export async function syncPointAlarms(current) {
const byKey = new Map(current.map((a) => [pointSourceKey(a.pointId, a.kind), a]));
const t = nowIso();
if (!isPgEnabled()) {
ensureMem();
let upserted = 0;
let cleared = 0;
const open = mem.filter((a) => a.source === 'point' && OPEN_STATUSES.includes(a.status));
const openByKey = new Map(open.map((a) => [a.sourceKey, a]));
for (const [key, pa] of byKey) {
const existing = openByKey.get(key) || null;
const next = fromPointAlarm(pa, existing);
if (existing) {
const i = mem.findIndex((x) => x.id === existing.id);
mem[i] = next;
}
else {
mem.push(next);
}
upserted += 1;
}
for (const a of open) {
if (byKey.has(a.sourceKey))
continue;
a.status = 'cleared';
a.clearedAt = t;
a.updatedAt = t;
cleared += 1;
}
if (upserted || cleared)
saveFile();
return { upserted, cleared };
}
const { rows: openRows } = await query(`SELECT * FROM alarms
WHERE source = 'point' AND status = ANY($1::text[])`, [OPEN_STATUSES]);
const open = openRows.map(mapPgRow);
const openByKey = new Map(open.map((a) => [a.sourceKey, a]));
let upserted = 0;
let cleared = 0;
for (const [key, pa] of byKey) {
const existing = openByKey.get(key) || null;
const next = fromPointAlarm(pa, existing);
if (existing) {
await updateOpenAlarmPg(next);
}
else {
await insertAlarmPg(next);
}
upserted += 1;
}
const staleIds = open.filter((a) => !byKey.has(a.sourceKey)).map((a) => a.id);
if (staleIds.length) {
const { rowCount } = await query(`UPDATE alarms SET status='cleared', cleared_at=$2, updated_at=$2
WHERE id = ANY($1::uuid[])`, [staleIds, t]);
cleared = rowCount ?? staleIds.length;
}
return { upserted, cleared };
}
export async function listAlarms(opts = {}) {
const limit = Math.min(Math.max(opts.limit ?? 100, 1), 500);
if (!isPgEnabled()) {
ensureMem();
let rows = [...mem];
if (opts.status === 'open') {
rows = rows.filter((a) => OPEN_STATUSES.includes(a.status));
}
else if (opts.status) {
rows = rows.filter((a) => a.status === opts.status);
}
if (opts.source)
rows = rows.filter((a) => a.source === opts.source);
rows.sort((a, b) => (a.raisedAt < b.raisedAt ? 1 : -1));
return rows.slice(0, limit);
}
const where = [];
const params = [];
if (opts.status === 'open') {
params.push(OPEN_STATUSES);
where.push(`status = ANY($${params.length}::text[])`);
}
else if (opts.status) {
params.push(opts.status);
where.push(`status = $${params.length}`);
}
if (opts.source) {
params.push(opts.source);
where.push(`source = $${params.length}`);
}
params.push(limit);
const sql = `SELECT * FROM alarms
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY raised_at DESC
LIMIT $${params.length}`;
const { rows } = await query(sql, params);
return rows.map(mapPgRow);
}
export async function ackAlarm(id, by) {
const t = nowIso();
if (!isPgEnabled()) {
ensureMem();
const a = mem.find((x) => x.id === id);
if (!a || a.status === 'cleared')
return null;
a.status = 'acked';
a.ackedAt = t;
a.ackedBy = by;
a.updatedAt = t;
saveFile();
return a;
}
const { rows } = await query(`UPDATE alarms SET status='acked', acked_at=$2, acked_by=$3, updated_at=$2
WHERE id=$1 AND status IN ('active','acked')
RETURNING *`, [id, t, by]);
return rows[0] ? mapPgRow(rows[0]) : null;
}
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
export function signToken(payload) {
return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
}
export function authRequired(req, res, next) {
const header = req.headers.authorization;
const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined;
if (!token) {
res.status(401).json({ message: '未登录' });
return;
}
try {
req.user = jwt.verify(token, config.jwtSecret);
next();
}
catch {
res.status(401).json({ message: '登录已失效' });
}
}
export function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) {
res.status(401).json({ message: '未登录' });
return;
}
if (!roles.includes(req.user.role)) {
res.status(403).json({ message: '权限不足,仅管理员可操作' });
return;
}
next();
};
}
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const config = {
port: Number(process.env.PORT || 3001),
jwtSecret: process.env.JWT_SECRET || 'jh-lab-dev-secret',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '12h',
dataMode: (process.env.DATA_MODE || 'mock'),
databaseUrl: process.env.DATABASE_URL ||
'postgres://jh_lab:jh_lab_pass@127.0.0.1:5432/jh_lab',
/**
* 网关模式下,对超过该毫秒未更新的点位继续用模拟值补发,保证大屏/时序不断流。
* 0 = 关闭补发。
*/
mockFillStaleMs: Number(process.env.MOCK_FILL_STALE_MS || 60_000),
/** 采集明细保留天数,0 表示不清理 */
collectRetentionDays: Number(process.env.COLLECT_RETENTION_DAYS || 90),
/** 单一 Windows 协议网关 */
gatewayUrl: (process.env.GATEWAY_URL || '').replace(/\/$/, ''),
/**
* WebRTC WHEP 基址(可选)。
* 为空则使用 GATEWAY_URL;若媒体面为独立 ZLMediaKit,可设为 http://host:80
*/
webrtcWhepBase: (process.env.WEBRTC_WHEP_BASE || '').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.104',
port: Number(process.env.MODBUS_PORT || 502),
/** 三网关端口(联调模拟器默认 1502/1503/1504;现场多为同端口 502) */
portPau1: Number(process.env.MODBUS_PAU1_PORT || process.env.MODBUS_PORT || 502),
portPau2: Number(process.env.MODBUS_PAU2_PORT || process.env.MODBUS_PORT || 502),
portRoom: Number(process.env.MODBUS_ROOM_PORT || process.env.MODBUS_PORT || 502),
pollMs: Number(process.env.MODBUS_POLL_MS || 2000),
},
accessBridgeUrl: process.env.ACCESS_BRIDGE_URL || '',
adminUsername: process.env.ADMIN_USERNAME || 'admin',
adminPassword: process.env.ADMIN_PASSWORD || 'Admin@123',
dataDir: path.resolve(__dirname, '../data'),
usersFile: path.resolve(__dirname, '../data/users.json'),
videoFile: path.resolve(__dirname, '../data/video.json'),
lightFile: path.resolve(__dirname, '../data/light.json'),
devicesFile: path.resolve(__dirname, '../data/devices.json'),
alarmsFile: path.resolve(__dirname, '../data/alarms.json'),
/** 现场协议 CSV 目录(默认:仓库旁「设备对接」协议包) */
devicePointsDir: process.env.DEVICE_POINTS_DIR
? path.resolve(process.env.DEVICE_POINTS_DIR)
: path.resolve(__dirname, '../../../../设备对接/澳门镜湖医院协议20261019/澳门镜湖医院协议20261019'),
};
import { config } from '../config.js';
import { listDevices } from '../devices/store.js';
import { pool, query } from './pool.js';
import { isPgEnabled } from './state.js';
import { persistSnapshotTimeseries, pruneTimeseriesData, queryTsSeries, } from './timeseries.js';
/** 整包快照降频落库(审计用);明细走时序窄表 */
function snapshotMinIntervalMs() {
return Number(process.env.COLLECT_SNAPSHOT_INTERVAL_MS || 15_000);
}
let lastSnapshotWriteMs = 0;
/** 将一次采集快照入库:时序明细 + 降频整包 */
export async function persistSnapshot(snap) {
if (!isPgEnabled())
return;
// 1) 窄表时序(主路径,供大屏曲线高效读取)
try {
await persistSnapshotTimeseries(snap);
}
catch (e) {
console.error('[db] persist timeseries failed', e);
}
const now = Date.now();
if (now - lastSnapshotWriteMs < snapshotMinIntervalMs())
return;
lastSnapshotWriteMs = now;
const ts = new Date(snap.ts || now);
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(`INSERT INTO collect_snapshots (ts, payload) VALUES ($1, $2::jsonb)`, [
ts,
JSON.stringify(snap),
]);
// 兼容旧查询:保留少量 JSON 分项(同样降频)
const rows = [];
for (const r of snap.rooms)
rows.push([ts, 'room', r.id, JSON.stringify(r)]);
for (const v of snap.vav)
rows.push([ts, 'vav', v.id, JSON.stringify(v)]);
for (const f of snap.fridges)
rows.push([ts, 'fridge', f.id, JSON.stringify(f)]);
rows.push([ts, 'hvac', 'PAU201', JSON.stringify(snap.hvac)]);
rows.push([ts, 'metering', 'lab', JSON.stringify(snap.metering)]);
if (rows.length) {
const values = [];
const places = rows
.map((row, i) => {
const o = i * 4;
values.push(row[0], row[1], row[2], row[3]);
return `($${o + 1}, $${o + 2}, $${o + 3}, $${o + 4}::jsonb)`;
})
.join(',');
await client.query(`INSERT INTO collect_samples (ts, source, device_id, payload) VALUES ${places}`, values);
}
const day = ts.toISOString().slice(0, 10);
const power = snap.metering.powerKwh;
const water = snap.metering.waterM3;
const cooling = snap.metering.coolingKwh;
await client.query(`INSERT INTO billing_daily_usage (
usage_date, power_peak_kwh, power_flat_kwh, power_valley_kwh, water_m3, cooling_kwh, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,NOW())
ON CONFLICT (usage_date) DO UPDATE SET
power_peak_kwh = EXCLUDED.power_peak_kwh,
power_flat_kwh = EXCLUDED.power_flat_kwh,
power_valley_kwh = EXCLUDED.power_valley_kwh,
water_m3 = EXCLUDED.water_m3,
cooling_kwh = EXCLUDED.cooling_kwh,
updated_at = NOW()`, [
day,
+(power * 0.28).toFixed(3),
+(power * 0.45).toFixed(3),
+(power * 0.27).toFixed(3),
water,
cooling,
]);
await client.query('COMMIT');
}
catch (e) {
await client.query('ROLLBACK');
console.error('[db] persistSnapshot failed', e);
}
finally {
client.release();
}
}
export async function persistWriteLog(action, detail, byUser) {
if (!isPgEnabled())
return;
await query(`INSERT INTO write_audit_log (action, detail, by_user) VALUES ($1, $2::jsonb, $3)`, [action, JSON.stringify(detail), byUser || null]);
}
/**
* 近 N 小时冰箱温度曲线。
* 优先 ts_rollups 预聚合;旧 collect_samples 作回退。
*/
export async function queryFridgeTempSeries(opts) {
if (!isPgEnabled())
return [];
const hours = Math.min(168, Math.max(1, opts?.hours ?? 24));
const bucketMinutes = Math.min(60, Math.max(5, opts?.bucketMinutes ?? 15));
try {
const series = await queryTsSeries({
source: 'fridge',
metric: 'temp',
hours,
// 24h 内用 1min 桶,曲线更密
bucketMinutes: hours <= 24 ? Math.min(bucketMinutes, 1) || 1 : bucketMinutes,
});
if (series.length) {
const fridges = await listDevices({ category: 'fridge' });
const nameById = new Map(fridges.map((d) => [d.id, d.name]));
return series.map((s) => ({
id: s.id,
name: nameById.get(s.id) || s.name || s.id,
points: s.points.map((p) => ({ t: p.t, temp: p.v })),
}));
}
}
catch (e) {
console.warn('[db] queryTsSeries fridge failed, fallback collect_samples', e);
}
const { rows } = await query(`SELECT
device_id,
MAX(payload->>'name') AS name,
to_timestamp(floor(extract(epoch FROM ts) / ($2 * 60)) * ($2 * 60)) AS bucket,
AVG(NULLIF(payload->>'temp', '')::float) AS temp
FROM collect_samples
WHERE source = 'fridge'
AND device_id IS NOT NULL
AND ts >= NOW() - ($1::text || ' hours')::interval
AND payload ? 'temp'
GROUP BY device_id, bucket
HAVING AVG(NULLIF(payload->>'temp', '')::float) IS NOT NULL
ORDER BY device_id, bucket`, [String(hours), bucketMinutes]);
const map = new Map();
for (const r of rows) {
const id = String(r.device_id);
let series = map.get(id);
if (!series) {
series = { id, name: r.name || id, points: [] };
map.set(id, series);
}
if (r.name)
series.name = r.name;
const temp = Number(r.temp);
if (!Number.isFinite(temp))
continue;
const t = r.bucket instanceof Date ? r.bucket.getTime() : new Date(r.bucket).getTime();
series.points.push({ t, temp });
}
return [...map.values()];
}
/** 清理过期采集数据 */
export async function pruneCollectData() {
if (!isPgEnabled())
return;
const days = config.collectRetentionDays;
if (days <= 0)
return;
await pruneTimeseriesData();
await query(`DELETE FROM collect_samples WHERE ts < NOW() - ($1::text || ' days')::interval`, [
String(days),
]);
await query(`DELETE FROM collect_snapshots WHERE ts < NOW() - ($1::text || ' days')::interval`, [
String(days),
]);
}
import { pool, query } from './pool.js';
import { ensureTimeseriesSchema } from './timeseries.js';
const SQL = `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'viewer')),
password_hash TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS billing_tariff (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
power_peak NUMERIC(12,4) NOT NULL,
power_flat NUMERIC(12,4) NOT NULL,
power_valley NUMERIC(12,4) NOT NULL,
water NUMERIC(12,4) NOT NULL,
cooling NUMERIC(12,4) NOT NULL,
service_fee_pct NUMERIC(8,4) NOT NULL,
tax_pct NUMERIC(8,4) NOT NULL,
settle_day INT NOT NULL CHECK (settle_day BETWEEN 1 AND 28),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system'
);
CREATE TABLE IF NOT EXISTS billing_daily_usage (
usage_date DATE PRIMARY KEY,
power_peak_kwh NUMERIC(14,3) NOT NULL DEFAULT 0,
power_flat_kwh NUMERIC(14,3) NOT NULL DEFAULT 0,
power_valley_kwh NUMERIC(14,3) NOT NULL DEFAULT 0,
water_m3 NUMERIC(14,3) NOT NULL DEFAULT 0,
cooling_kwh NUMERIC(14,3) NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS collect_samples (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL,
source TEXT NOT NULL,
device_id TEXT,
payload JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_collect_ts ON collect_samples (ts DESC);
CREATE INDEX IF NOT EXISTS idx_collect_source_ts ON collect_samples (source, ts DESC);
CREATE INDEX IF NOT EXISTS idx_collect_device_ts ON collect_samples (source, device_id, ts DESC);
CREATE TABLE IF NOT EXISTS collect_snapshots (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL,
payload JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshots_ts ON collect_snapshots (ts DESC);
CREATE TABLE IF NOT EXISTS write_audit_log (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
action TEXT NOT NULL,
detail JSONB NOT NULL DEFAULT '{}'::jsonb,
by_user TEXT
);
CREATE TABLE IF NOT EXISTS system_settings (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
collect_poll_ms INT NOT NULL DEFAULT 2000 CHECK (collect_poll_ms BETWEEN 500 AND 600000),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system'
);
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,
alarm_low DOUBLE PRECISION,
alarm_high 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);
CREATE TABLE IF NOT EXISTS alarms (
id UUID PRIMARY KEY,
source TEXT NOT NULL,
source_key TEXT NOT NULL,
level TEXT NOT NULL CHECK (level IN ('red', 'amber', 'info')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'acked', 'cleared')),
title TEXT NOT NULL,
message TEXT NOT NULL,
device_id TEXT,
point_id UUID,
item_id TEXT,
value DOUBLE PRECISION,
threshold_low DOUBLE PRECISION,
threshold_high DOUBLE PRECISION,
kind TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
raised_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
cleared_at TIMESTAMPTZ,
acked_at TIMESTAMPTZ,
acked_by TEXT
);
CREATE INDEX IF NOT EXISTS idx_alarms_raised ON alarms (raised_at DESC);
CREATE INDEX IF NOT EXISTS idx_alarms_status_level ON alarms (status, level);
CREATE INDEX IF NOT EXISTS idx_alarms_source ON alarms (source, raised_at DESC);
CREATE INDEX IF NOT EXISTS idx_alarms_device ON alarms (device_id, raised_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_alarms_open_source_key
ON alarms (source, source_key) WHERE status IN ('active', 'acked');
`;
const ALTER = `
ALTER TABLE device_points ADD COLUMN IF NOT EXISTS alarm_low DOUBLE PRECISION;
ALTER TABLE device_points ADD COLUMN IF NOT EXISTS alarm_high DOUBLE PRECISION;
`;
/** 纠正房间/冰箱传感器被 CSV 误标为写点的问题 */
const FIX_SENSOR_RW = `
UPDATE device_points
SET rw = 'r', updated_at = NOW()
WHERE rw = 'w'
AND (
name ~ '实时温度|实时湿度|实时压差|冰箱温度|冰箱湿度'
OR item_id ~ '实时温度|实时湿度|实时压差|冰箱温度|冰箱湿度'
)
AND name !~ '设定|上限|下限|高限|低限|目标|地址'
`;
export async function migrate() {
await query(SQL);
await query(ALTER);
await query(FIX_SENSOR_RW);
await ensureTimeseriesSchema();
console.log('[db] schema migrated');
}
export async function closeDb() {
await pool.end();
}
import pg from 'pg';
import { config } from '../config.js';
import { isPgEnabled } from './state.js';
const { Pool } = pg;
export const pool = new Pool({
connectionString: config.databaseUrl,
max: 10,
});
export async function query(text, params) {
if (!isPgEnabled()) {
throw new Error('PostgreSQL 未启用');
}
return pool.query(text, params);
}
export async function withClient(fn) {
const client = await pool.connect();
try {
return await fn(client);
}
finally {
client.release();
}
}
/** 探测数据库是否可连接 */
export async function pingDb() {
try {
const client = await pool.connect();
await client.query('SELECT 1');
client.release();
return true;
}
catch (e) {
console.warn('[db] ping failed:', e instanceof Error ? e.message : e);
return false;
}
}
/** PostgreSQL 是否可用;不可用时走本地文件/内存降级,保证登录与演示可用 */
let pgEnabled = false;
export function setPgEnabled(ok) {
pgEnabled = ok;
}
export function isPgEnabled() {
return pgEnabled;
}
/** 按点名给出合理默认告警高低限(与量程字段分离;null=不启用该侧) */
export function defaultAlarmLimits(name, itemId = '') {
const n = `${name} ${itemId}`;
if (/设定|上限报警|下限报警|高限|低限|目标/.test(n)) {
return { alarmLow: null, alarmHigh: null };
}
if (/冰箱|冷链|fridge/i.test(n) && /温度|Temp/i.test(n)) {
return { alarmLow: -25, alarmHigh: -18 };
}
if (/冰箱|冷链|fridge/i.test(n) && /湿度|Humid/i.test(n)) {
return { alarmLow: null, alarmHigh: 80 };
}
if (/压差/.test(n)) {
return { alarmLow: -30, alarmHigh: -5 };
}
if (/温度|Temp/i.test(n)) {
return { alarmLow: 16, alarmHigh: 28 };
}
if (/湿度|Humid/i.test(n)) {
return { alarmLow: 30, alarmHigh: 70 };
}
return { alarmLow: null, alarmHigh: null };
}
import { listPoints } from './store.js';
function levelFor(p, kind) {
const n = `${p.name} ${p.itemId}`;
if (/冰箱|冷链|压差|fridge/i.test(n))
return 'red';
if (kind === 'high' && /温度|湿度/.test(n))
return 'amber';
return 'amber';
}
const FRESH_MS = 5 * 60_000;
function isFresh(p) {
if (!p.lastTs)
return false;
const t = Date.parse(p.lastTs);
return Number.isFinite(t) && Date.now() - t <= FRESH_MS;
}
/** 根据点位 alarmLow/alarmHigh 与 lastEng 判定当前超限告警(仅新鲜遥测) */
export function evaluateAlarmsFromPoints(points) {
const out = [];
for (const p of points) {
if (!p.enabled)
continue;
if (p.lastEng === null || p.lastEng === undefined || Number.isNaN(Number(p.lastEng)))
continue;
if (!isFresh(p))
continue;
const hasLow = p.alarmLow !== null && p.alarmLow !== undefined;
const hasHigh = p.alarmHigh !== null && p.alarmHigh !== undefined;
if (!hasLow && !hasHigh)
continue;
const v = Number(p.lastEng);
const ts = p.lastTs || new Date().toISOString();
if (hasLow && v < Number(p.alarmLow)) {
out.push({
pointId: p.id,
itemId: p.itemId,
deviceId: p.deviceId,
name: p.name,
value: v,
alarmLow: p.alarmLow,
alarmHigh: p.alarmHigh,
kind: 'low',
level: levelFor(p, 'low'),
message: `${p.name} 低于下限(${v} < ${p.alarmLow})`,
ts,
});
}
if (hasHigh && v > Number(p.alarmHigh)) {
out.push({
pointId: p.id,
itemId: p.itemId,
deviceId: p.deviceId,
name: p.name,
value: v,
alarmLow: p.alarmLow,
alarmHigh: p.alarmHigh,
kind: 'high',
level: levelFor(p, 'high'),
message: `${p.name} 超过上限(${v} > ${p.alarmHigh})`,
ts,
});
}
}
return out;
}
export async function evaluatePointAlarms() {
const points = await listPoints();
return evaluateAlarmsFromPoints(points);
}
import iconv from 'iconv-lite';
const HEADERS = [
'id',
'Name',
'RegType',
'RegAddress',
'DataType',
'Modbus ID',
'Modbus Register',
'Modbus Address',
'Description',
'MaskBit',
'Bit',
'Scale',
'Bias',
'MinRawRange',
'MaxRawRange',
'MinEngRange',
'MaxEngRange',
'ItemID',
];
function splitCsvLine(line) {
const out = [];
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, fallback = 0) {
if (v === undefined || v === '')
return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
/** 从可能错位的列中挑出真实 ItemID(优先 RTU.*) */
function pickItemId(cols, iItem, headerLen) {
const candidates = [];
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, encoding) {
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 function parsePointsCsv(text) {
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) => 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 = [];
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) {
const s = String(v ?? '');
if (/[",\r\n]/.test(s))
return `"${s.replace(/"/g, '""')}"`;
return s;
}
export function exportPointsCsv(points) {
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, encoding) {
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, scale, bias, scaleOverride, minRaw, maxRaw, minEng, maxEng) {
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;
}
/**
* 房间屏采集量:CSV 常误标 -F6 为写,但「实时温度/湿度/压差」实为传感器读数。
*/
export function isRoomSensorPoint(ctx = {}) {
const name = `${ctx.name || ''} ${ctx.itemId || ''}`;
if (/设定|上限|下限|高限|低限|目标|地址/.test(name))
return false;
return /实时温度|实时湿度|实时压差|冰箱温度|冰箱湿度/.test(name);
}
/**
* 机组/VAV 运行显示量:CSV 常把 Holding 标成 -F6、把状态线圈标成 -F5,
* 但「送风温度显示 / 实际风量 / 开停状态」等实为遥测,应参与周期采集。
*/
export function isHvacTelemetryPoint(ctx = {}) {
const name = `${ctx.name || ''} ${ctx.itemId || ''}`;
if (/设定|校准|命令|复位|给定|手自动|地址|上限|下限/.test(name))
return false;
if (/显示/.test(name))
return true;
return /实际风量|实际面风速|开停状态|系统启动信号/.test(name);
}
/**
* 计量仪表测点:现场 CSV 常把 Holding 一律标成 `-F6`,但电表/水表(流量计)/能量计
* 点名均为电压/电流/流量/热量等采集量,Description 为空,无写控说明 → 强制只读。
*/
export function isMeterTelemetryPoint(ctx = {}) {
const file = `${ctx.sourceCsv || ''} ${ctx.category || ''}`;
const name = `${ctx.name || ''} ${ctx.itemId || ''}`;
// 专用仪表点表(文件名),整表视为采集
if (/电表|能量计|流量计/.test(file))
return true;
// 测点名明显为计量量(即使挂在其它 CSV)
if (/电压|电流|有功|无功|功率因素|功率因数|电度量|电能|电量|流速|瞬时流量|累积流量|净累积|瞬时热量|累积热量|冷量/.test(name)) {
// 排除同名里偶发的控制词
if (/设定|启停|复位|清零命令|开关命令/.test(name))
return false;
return true;
}
return false;
}
/**
* 根据现场 RegType 推断读写属性(对齐 Modbus 功能码习惯):
* - F5 / 写单线圈 → 控制点(写)
* - F6 / F16 / F15 → 写寄存器(控制点);但计量仪表测点例外,仍为读
* - 1X / 3X / Input → 只读采集
* - 其余 Holding/Coil 未标写功能码 → 默认可读(可再写时标 rw)
*/
export function inferPointRw(regType, fallback = 'r', ctx = {}) {
// 房间/冰箱传感器、机组显示量、电表等:业务上只采集,忽略 CSV 上的 -F6/-F16 误标
if (isRoomSensorPoint(ctx) || isHvacTelemetryPoint(ctx) || isMeterTelemetryPoint(ctx))
return 'r';
const t = (regType || '').toUpperCase();
if (!t)
return fallback;
// 写功能码:F5 写线圈;F6 写单寄存器;F15/F16 写多线圈/多寄存器
if (/\bF5\b|-F5\b|WRITE\s*SINGLE\s*COIL|写.*线圈/.test(t))
return 'w';
if (/\bF6\b|-F6\b|\bF16\b|-F16\b|\bF15\b|-F15\b|WRITE\s*(SINGLE|MULTIPLE)\s*REGISTER|写.*寄存器/.test(t)) {
return 'w';
}
if (/1X|3X|INPUT\s*STATUS|INPUT\s*REGISTER|只读/.test(t))
return 'r';
if (/0X|4X|HOLDING|COIL/.test(t))
return 'rw';
return fallback;
}
/** 是否参与周期采集(写控点不读;计量点即使库内 rw 误标也为可读) */
export function isReadablePoint(p) {
const ctx = {
name: p.name || '',
itemId: p.itemId || '',
sourceCsv: p.sourceCsv,
category: p.category,
};
if (isRoomSensorPoint(ctx) || isHvacTelemetryPoint(ctx) || isMeterTelemetryPoint(ctx))
return true;
const rw = inferPointRw(p.regType || '', p.rw || 'r', ctx).toLowerCase();
return rw !== 'w';
}
import { config } from '../config.js';
import { getBundleForGateway } from '../devices/store.js';
export function resolveGatewayBase() {
return (config.gatewayUrl || config.accessBridgeUrl || '').replace(/\/$/, '');
}
export function resolveWhepBase() {
return (config.webrtcWhepBase || resolveGatewayBase()).replace(/\/$/, '');
}
/** 将平台设备映射为媒体流 ID */
export function deviceStreamId(device) {
if (device.deviceType === 'nvr-ch' && device.channel)
return `nvr-${device.channel}`;
return `cam-${device.id}`;
}
/**
* WHEP:把浏览器 SDP offer 转到网关/媒体面,返回 answer SDP 与 Location
*/
export async function proxyWhepOffer(streamId, offerSdp) {
const base = resolveWhepBase();
if (!base) {
return {
ok: false,
status: 503,
answerSdp: '',
message: '未配置 GATEWAY_URL / WEBRTC_WHEP_BASE,无法建立 WebRTC',
};
}
// MediaMTX: /{stream}/whep ;Windows 网关: /webrtc/whep/{stream}
const url = config.webrtcWhepBase
? `${base}/${encodeURIComponent(streamId)}/whep`
: `${base}/webrtc/whep/${encodeURIComponent(streamId)}`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp',
Accept: 'application/sdp',
},
body: offerSdp,
signal: AbortSignal.timeout(15000),
});
const answerSdp = await res.text();
const location = res.headers.get('Location') || undefined;
if (!res.ok) {
let message = answerSdp || `媒体面 HTTP ${res.status}`;
try {
const j = JSON.parse(answerSdp);
if (j.message)
message = j.message;
}
catch {
/* ignore */
}
return { ok: false, status: res.status, answerSdp, location, message };
}
return { ok: true, status: res.status, answerSdp, location };
}
catch (e) {
return {
ok: false,
status: 502,
answerSdp: '',
message: e instanceof Error ? e.message : 'WHEP 代理失败',
};
}
}
export async function fetchGatewayHealth() {
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() {
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() {
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(() => ({})));
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) {
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(() => ({})));
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 : '写失败' };
}
}
import http from 'node:http';
import express from 'express';
import cors from 'cors';
import { WebSocketServer } from 'ws';
import { config } from './config.js';
import { migrate } from './db/migrate.js';
import { pingDb } from './db/pool.js';
import { setPgEnabled } from './db/state.js';
import { seedAdminIfNeeded } from './users/store.js';
import { ensureBillingSeed } from './billing/store.js';
import { ensureSettingsSeed } from './settings/store.js';
import { ensureVideoSeed } from './video/store.js';
import { ensureLightSeed } from './light/store.js';
import { ensureDevicesSeed } from './devices/store.js';
import { authRouter } from './routes/auth.js';
import { usersRouter } from './routes/users.js';
import { realtimeRouter } from './routes/realtime.js';
import { billingRouter } from './routes/billing.js';
import { settingsRouter } from './routes/settings.js';
import { videoRouter } from './routes/video.js';
import { lightRouter } from './routes/light.js';
import { devicesRouter } from './routes/devices.js';
import { gatewayRouter } from './routes/gateway.js';
import { alarmsRouter } from './routes/alarms.js';
import { weatherRouter } from './routes/weather.js';
import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js';
async function bootstrap() {
const pgOk = await pingDb();
setPgEnabled(pgOk);
if (pgOk) {
await migrate();
console.log('[db] PostgreSQL 已连接,采集数据将落库');
}
else {
console.warn('[db] PostgreSQL 未连接,已降级为本地文件/内存模式(登录可用)。\n' +
` 请启动数据库后重启服务,例如: docker compose up -d db\n` +
` 当前 DATABASE_URL=${config.databaseUrl.replace(/:[^:@/]+@/, ':****@')}`);
}
await seedAdminIfNeeded();
await ensureBillingSeed();
await ensureSettingsSeed();
ensureVideoSeed();
ensureLightSeed();
await ensureDevicesSeed();
const app = express();
app.use(cors());
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',
});
});
app.use('/api/auth', authRouter);
app.use('/api/users', usersRouter);
app.use('/api/realtime', realtimeRouter);
app.use('/api/billing', billingRouter);
app.use('/api/settings', settingsRouter);
app.use('/api/video', videoRouter);
app.use('/api/light', lightRouter);
app.use('/api/devices', devicesRouter);
app.use('/api/alarms', alarmsRouter);
app.use('/api/weather', weatherRouter);
/** Windows 网关上行:拉点表 / 推测点落库 */
app.use('/api/gateway', gatewayRouter);
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'snapshot', data: getSnapshot() }));
const unsub = subscribe((snap) => {
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify({ type: 'snapshot', data: snap }));
}
});
socket.on('close', () => unsub());
});
await startRealtimeLoop();
server.listen(config.port, () => {
console.log(`[server] http://localhost:${config.port}`);
console.log(`[server] dataMode=${config.dataMode}`);
console.log(`[server] database=${pgOk ? 'postgres' : 'fallback(file/memory)'}`);
console.log(`[server] default admin: ${config.adminUsername} / ${config.adminPassword}`);
});
}
bootstrap().catch((err) => {
console.error('[server] bootstrap failed', err);
process.exit(1);
});
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { config } from '../config.js';
const filePath = () => config.lightFile;
function defaultConfig() {
return {
host: '192.168.10.120',
port: 502,
protocol: 'modbus',
unitId: 1,
username: '',
password: '',
pollMs: 3000,
enabled: true,
updatedAt: new Date().toISOString(),
updatedBy: 'system',
};
}
function seedDevices() {
const now = new Date().toISOString();
const rows = [
{ name: 'PCR走廊照明', zone: 'PCR走廊', circuit: 1, powerOn: true, status: 'online', enabled: true, brightness: 80, remark: '' },
{ name: '样本制备区1照明', zone: '样本制备区1', circuit: 2, powerOn: true, status: 'online', enabled: true, brightness: 90, remark: '' },
{ name: '样本制备区2照明', zone: '样本制备区2', circuit: 3, powerOn: false, status: 'online', enabled: true, brightness: 0, remark: '' },
{ name: '文库制备区1照明', zone: '文库制备区1', circuit: 4, powerOn: true, status: 'online', enabled: true, brightness: 85, remark: '' },
{ name: '文库制备区2照明', zone: '文库制备区2', circuit: 5, powerOn: true, status: 'online', enabled: true, brightness: 80, remark: '' },
{ name: '测序区照明', zone: '测序区', circuit: 6, powerOn: true, status: 'online', enabled: true, brightness: 75, remark: '' },
{ name: '测序区缓冲照明', zone: '测序区缓冲', circuit: 7, powerOn: false, status: 'online', enabled: true, brightness: 0, remark: '' },
{ name: '机房照明', zone: '弱电/机房', circuit: 8, powerOn: true, status: 'fault', enabled: true, brightness: 60, remark: '回路反馈异常' },
];
return rows.map((r) => ({
...r,
id: randomUUID(),
createdAt: now,
updatedAt: now,
lastSwitchAt: null,
lastSwitchBy: null,
}));
}
function ensureDir() {
fs.mkdirSync(path.dirname(filePath()), { recursive: true });
}
function load() {
ensureDir();
if (!fs.existsSync(filePath())) {
const data = { config: defaultConfig(), devices: seedDevices() };
save(data);
return data;
}
return JSON.parse(fs.readFileSync(filePath(), 'utf-8'));
}
function save(data) {
ensureDir();
fs.writeFileSync(filePath(), JSON.stringify(data, null, 2), 'utf-8');
}
export function ensureLightSeed() {
load();
}
function toPublicConfig(c) {
const { password, ...rest } = c;
return {
...rest,
passwordSet: Boolean(password),
passwordMasked: password ? '********' : '',
};
}
export function getCommConfig() {
return toPublicConfig(load().config);
}
export function updateCommConfig(patch, byUser) {
const data = load();
const c = data.config;
if (patch.host !== undefined)
c.host = String(patch.host).trim();
if (patch.port !== undefined)
c.port = Number(patch.port);
if (patch.protocol !== undefined)
c.protocol = patch.protocol;
if (patch.unitId !== undefined)
c.unitId = Number(patch.unitId);
if (patch.username !== undefined)
c.username = String(patch.username).trim();
if (patch.password !== undefined && String(patch.password).trim() && patch.password !== '********') {
c.password = String(patch.password);
}
if (patch.pollMs !== undefined)
c.pollMs = Number(patch.pollMs);
if (patch.enabled !== undefined)
c.enabled = Boolean(patch.enabled);
c.updatedAt = new Date().toISOString();
c.updatedBy = byUser;
save(data);
return toPublicConfig(c);
}
export function testCommConfig() {
const c = load().config;
if (!c.enabled)
return { ok: false, message: '照明通信配置已禁用', latencyMs: 0 };
if (!c.host || !c.port) {
return { ok: false, message: '请填写照明网关地址与端口', latencyMs: 0 };
}
const latencyMs = 30 + Math.floor(Math.random() * 70);
return {
ok: true,
message: `照明网关 ${c.host}:${c.port} 连通(${c.protocol.toUpperCase()} · Mock)`,
latencyMs,
};
}
export function listDevices() {
return load().devices.slice().sort((a, b) => a.circuit - b.circuit);
}
export function createDevice(input) {
const data = load();
if (data.devices.some((d) => d.circuit === input.circuit)) {
throw new Error(`回路 ${input.circuit} 已存在`);
}
const now = new Date().toISOString();
const powerOn = input.powerOn === true;
const device = {
id: randomUUID(),
name: input.name.trim(),
zone: input.zone.trim(),
circuit: Number(input.circuit),
powerOn,
status: input.status || 'online',
enabled: input.enabled !== false,
brightness: powerOn ? Math.min(100, Math.max(0, Number(input.brightness ?? 80))) : 0,
remark: (input.remark || '').trim(),
createdAt: now,
updatedAt: now,
lastSwitchAt: null,
lastSwitchBy: null,
};
data.devices.push(device);
save(data);
return device;
}
export function updateDevice(id, patch) {
const data = load();
const idx = data.devices.findIndex((d) => d.id === id);
if (idx < 0)
throw new Error('设备不存在');
if (patch.circuit !== undefined) {
const ch = Number(patch.circuit);
if (data.devices.some((d) => d.id !== id && d.circuit === ch)) {
throw new Error(`回路 ${ch} 已存在`);
}
data.devices[idx].circuit = ch;
}
if (patch.name !== undefined)
data.devices[idx].name = String(patch.name).trim();
if (patch.zone !== undefined)
data.devices[idx].zone = String(patch.zone).trim();
if (patch.status !== undefined)
data.devices[idx].status = patch.status;
if (patch.enabled !== undefined)
data.devices[idx].enabled = Boolean(patch.enabled);
if (patch.brightness !== undefined) {
data.devices[idx].brightness = Math.min(100, Math.max(0, Number(patch.brightness)));
}
if (patch.remark !== undefined)
data.devices[idx].remark = String(patch.remark).trim();
data.devices[idx].updatedAt = new Date().toISOString();
save(data);
return data.devices[idx];
}
export function deleteDevice(id) {
const data = load();
if (!data.devices.some((d) => d.id === id))
throw new Error('设备不存在');
data.devices = data.devices.filter((d) => d.id !== id);
save(data);
}
/** 远程开关:一期 Mock 落库状态,二期走厂家接口 */
export function switchDevice(id, powerOn, byUser) {
const data = load();
if (!data.config.enabled)
throw new Error('照明通信未启用,无法下发开关');
const idx = data.devices.findIndex((d) => d.id === id);
if (idx < 0)
throw new Error('设备不存在');
const d = data.devices[idx];
if (!d.enabled)
throw new Error('设备已禁用');
if (d.status === 'offline')
throw new Error('设备离线,无法控制');
const now = new Date().toISOString();
d.powerOn = Boolean(powerOn);
d.brightness = d.powerOn ? (d.brightness > 0 ? d.brightness : 80) : 0;
d.updatedAt = now;
d.lastSwitchAt = now;
d.lastSwitchBy = byUser;
save(data);
return d;
}
export function switchAll(powerOn, byUser) {
const data = load();
if (!data.config.enabled)
throw new Error('照明通信未启用,无法下发开关');
const now = new Date().toISOString();
let count = 0;
for (const d of data.devices) {
if (!d.enabled || d.status === 'offline')
continue;
d.powerOn = Boolean(powerOn);
d.brightness = d.powerOn ? (d.brightness > 0 ? d.brightness : 80) : 0;
d.updatedAt = now;
d.lastSwitchAt = now;
d.lastSwitchBy = byUser;
count += 1;
}
save(data);
return { count, devices: listDevices() };
}
import { randomUUID } from 'node:crypto';
const ROOM_DEFS = [
{ id: 'recv', name: '收样咨询室' },
{ id: 'report', name: '生信分析报告室' },
{ id: 'reagent', name: '试剂准备间' },
{ id: 'pcr-corridor', name: 'PCR走廊' },
{ id: 'sample-1', name: '样本制备区1' },
{ id: 'sample-2', name: '样本制备区2' },
{ id: 'sample-buf', name: '样本制备区缓冲' },
{ id: 'lib-1', name: '文库制备区1' },
{ id: 'lib-1-buf', name: '文库制备区1缓冲' },
{ id: 'lib-2', name: '文库制备区2' },
{ id: 'lib-2-buf', name: '文库制备区2缓冲' },
{ id: 'seq', name: '测序区' },
{ id: 'seq-buf', name: '测序区缓冲' },
];
const DOORS = [
{ id: 'd-main', name: '主入口门禁' },
{ id: 'd-pcr', name: 'PCR区门禁' },
{ id: 'd-sample', name: '样本制备门禁' },
{ id: 'd-lib', name: '文库制备门禁' },
{ id: 'd-seq', name: '测序区门禁' },
{ id: 'd-cold', name: '冷链库门禁' },
];
function jitter(base, amp, digits = 1) {
const v = base + (Math.random() * 2 - 1) * amp;
return Number(v.toFixed(digits));
}
let passCount = 86;
const events = [
{
id: randomUUID(),
time: new Date(Date.now() - 8 * 60000).toISOString(),
doorId: 'd-main',
doorName: '主入口门禁',
person: '张工',
result: 'success',
},
{
id: randomUUID(),
time: new Date(Date.now() - 25 * 60000).toISOString(),
doorId: 'd-pcr',
doorName: 'PCR区门禁',
person: '李技师',
result: 'success',
},
];
export function generateSnapshot() {
const rooms = ROOM_DEFS.map((r, i) => {
const pressure = jitter(-15 + (i % 5) * 1.2, 1.5);
const alarms = [];
if (r.id === 'sample-1' && pressure > -5)
alarms.push('压差异常');
return {
id: r.id,
name: r.name,
temp: jitter(22.5, 0.6),
humidity: jitter(48, 3, 0),
pressure,
alarms,
};
});
if (Math.random() < 0.08) {
passCount += 1;
const door = DOORS[Math.floor(Math.random() * DOORS.length)];
events.unshift({
id: randomUUID(),
time: new Date().toISOString(),
doorId: door.id,
doorName: door.name,
person: ['张工', '李技师', '王护士', '访客'][Math.floor(Math.random() * 4)],
result: Math.random() < 0.9 ? 'success' : 'denied',
});
if (events.length > 30)
events.length = 30;
}
return {
ts: Date.now(),
rooms,
hvac: {
systemRunning: true,
supplyFanHz: jitter(42, 1.2),
exhaustPressure: jitter(320, 12, 0),
filterRemainPct: 68,
mode: 'auto',
supplyTemp: jitter(18.5, 0.4),
supplyHumidity: jitter(45, 2, 0),
supplyPressure: jitter(180, 8, 0),
freshAirTemp: jitter(26, 1.5),
alarms: rooms.some((r) => r.alarms.length) ? ['房间压差联动调频中'] : [],
},
vav: [
{
id: 'vav-lib1',
name: '文库制备1通风柜',
running: true,
faceVelocity: jitter(0.52, 0.04, 3),
sashHeight: jitter(350, 20, 0),
airflow: jitter(780, 40, 0),
valveOpen: jitter(62, 6, 0),
maxExhaust: Math.random() < 0.15,
minAirflow: Math.random() < 0.1,
alarmCode: Math.random() < 0.08 ? 1 : 0,
},
{
id: 'vav-lib2',
name: '文库制备2通风柜',
running: Math.random() > 0.1,
faceVelocity: jitter(0.48, 0.04, 3),
sashHeight: jitter(280, 20, 0),
airflow: jitter(720, 40, 0),
valveOpen: jitter(58, 6, 0),
maxExhaust: false,
minAirflow: Math.random() < 0.12,
alarmCode: 0,
},
],
fridges: Array.from({ length: 8 }, (_, i) => ({
id: `fridge-${i + 1}`,
name: `冰箱${i + 1}`,
temp: i === 2 ? jitter(-18.6, 0.3) : jitter(-20 + (i % 3), 0.4),
humidity: jitter(35, 4, 0),
})),
metering: {
powerKwh: jitter(486.2, 2),
waterM3: jitter(12.8, 0.2),
coolingKwh: jitter(1286, 8, 0),
powerTrendPct: 4.2,
waterTrendPct: -1.8,
coolingTrendPct: 6.5,
},
access: {
doors: DOORS.map((d, i) => ({
id: d.id,
name: d.name,
status: i === 5 && Math.random() < 0.05 ? 'alarm' : 'closed',
})),
todayPassCount: passCount,
recentEvents: [...events],
},
online: {
devicesOnline: 42,
devicesTotal: 45,
gatewayOk: true,
},
pointAlarms: [],
};
}
import { evaluatePointAlarms } from '../devices/alarms.js';
import { listDevices, listGateways, listPoints } from '../devices/store.js';
/** 与前端 PROTOCOL_ROOMS / 场景书签对齐的稳定房间 id */
const ROOM_ID_BY_NAME = {
收样咨询室: 'recv',
生信分析报告室: 'report',
试剂准备间: 'reagent',
PCR走廊: 'pcr-corridor',
样本制备区1: 'sample-1',
样本制备区2: 'sample-2',
样本制备区缓冲: 'sample-buf',
文库制备区1: 'lib-1',
文库制备区1缓冲: 'lib-1-buf',
文库制备区2: 'lib-2',
文库制备区2缓冲: 'lib-2-buf',
测序区: 'seq',
测序区缓冲: 'seq-buf',
};
function stableRoomId(d) {
if (ROOM_ID_BY_NAME[d.name])
return ROOM_ID_BY_NAME[d.name];
for (const [name, id] of Object.entries(ROOM_ID_BY_NAME)) {
if (d.name.includes(name) || name.includes(d.name))
return id;
}
return d.id;
}
function num(v) {
if (v === null || v === undefined)
return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function pickPoint(points, ...patterns) {
for (const re of patterns) {
const hit = points.find((p) => re.test(p.name) || re.test(p.itemId));
if (hit)
return hit;
}
return undefined;
}
function eng(points, ...patterns) {
return num(pickPoint(points, ...patterns)?.lastEng);
}
function boolOn(points, ...patterns) {
const v = eng(points, ...patterns);
return v !== null && v !== 0;
}
function pointFresh(p, maxAgeMs = 5 * 60_000) {
if (!p?.lastTs)
return false;
const t = Date.parse(p.lastTs);
return Number.isFinite(t) && Date.now() - t <= maxAgeMs;
}
function deviceOnline(points) {
return points.some((p) => pointFresh(p));
}
function roomFromDevice(d, points) {
const tempP = pickPoint(points, /实时温度|^温度$|Temp/i);
const humP = pickPoint(points, /实时湿度|^湿度$|Humid/i);
const pressP = pickPoint(points, /实时压差|压差(?!设定|上限|下限)/);
const temp = pointFresh(tempP) ? num(tempP?.lastEng) ?? 0 : 0;
const humidity = pointFresh(humP) ? Math.round(num(humP?.lastEng) ?? 0) : 0;
const pressure = pointFresh(pressP) ? num(pressP?.lastEng) ?? 0 : 0;
const alarms = [];
for (const p of [tempP, humP, pressP]) {
if (!p || !pointFresh(p) || p.lastEng === null || p.lastEng === undefined)
continue;
const v = Number(p.lastEng);
if (p.alarmLow !== null && v < p.alarmLow)
alarms.push(`${p.name}偏低`);
if (p.alarmHigh !== null && v > p.alarmHigh)
alarms.push(`${p.name}偏高`);
}
return { id: stableRoomId(d), name: d.name, temp, humidity, pressure, alarms };
}
function fridgeFromDevice(d, points) {
const tempP = pickPoint(points, /冰箱温度|温度/);
const humP = pickPoint(points, /冰箱湿度|湿度/);
return {
id: d.id,
name: d.name,
temp: pointFresh(tempP) ? num(tempP?.lastEng) ?? 0 : 0,
humidity: pointFresh(humP) ? Math.round(num(humP?.lastEng) ?? 0) : 0,
};
}
function vavFromDevice(d, points) {
const alarm = eng(points, /报警代码/);
return {
id: d.id,
name: d.name,
running: boolOn(points, /开停状态|^开机$/),
faceVelocity: eng(points, /实际面风速|面风速/) ?? 0,
sashHeight: eng(points, /实际门高|门高/) ?? 0,
airflow: eng(points, /实际风量|风量/) ?? 0,
valveOpen: 0,
maxExhaust: boolOn(points, /最大排风/),
minAirflow: boolOn(points, /最小风量/),
alarmCode: alarm !== null ? Math.round(alarm) : 0,
};
}
function sumEng(devices, byDevice, nameRe) {
let sum = 0;
let any = false;
for (const d of devices) {
const v = eng(byDevice.get(d.id) || [], nameRe);
if (v === null)
continue;
sum += v;
any = true;
}
return any ? Number(sum.toFixed(3)) : 0;
}
/**
* 从设备台账 + 点位 lastEng 组装大屏业务快照(无假数;无遥测则为 0 / 空告警)。
*/
export async function buildSnapshotFromDevices() {
const [devices, points, gateways, pointAlarms] = await Promise.all([
listDevices(),
listPoints(),
listGateways(),
evaluatePointAlarms().catch(() => []),
]);
const enabled = devices.filter((d) => d.enabled);
const byDevice = new Map();
for (const p of points) {
if (!p.deviceId || !p.enabled)
continue;
const list = byDevice.get(p.deviceId) || [];
list.push(p);
byDevice.set(p.deviceId, list);
}
const rooms = enabled
.filter((d) => d.category === 'room')
.map((d) => roomFromDevice(d, byDevice.get(d.id) || []));
const fridges = enabled
.filter((d) => d.category === 'fridge')
.map((d) => fridgeFromDevice(d, byDevice.get(d.id) || []));
const vav = enabled
.filter((d) => d.category === 'vav')
.map((d) => vavFromDevice(d, byDevice.get(d.id) || []));
const pauDevs = enabled.filter((d) => d.category === 'pau');
const pauPts = pauDevs.flatMap((d) => byDevice.get(d.id) || []);
const systemRunning = boolOn(pauPts, /系统启动信号|^开机$|PAU201送风机启停/);
const modeAuto = !boolOn(pauPts, /PAU201手自动模式选择/); // 常见:0 自动 1 手动;无值当自动
const hvacAlarms = [];
for (const tip of [
'机组故障报警',
'缺风报警',
'初效报警',
'消防报警',
'送风缺风故障提示',
'初/中效故障提示',
]) {
if (boolOn(pauPts, new RegExp(tip.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))) {
hvacAlarms.push(tip);
}
}
for (const a of pointAlarms) {
if (/PAU|EAF|送风|排风|滤/i.test(a.message) || /pau/i.test(a.deviceId || '')) {
hvacAlarms.push(a.message);
}
}
const powerMeters = enabled.filter((d) => d.category === 'meter_power');
const waterMeters = enabled.filter((d) => d.category === 'meter_water');
const coolMeters = enabled.filter((d) => d.category === 'meter_cooling');
let onlineCount = 0;
for (const d of enabled) {
if (deviceOnline(byDevice.get(d.id) || []))
onlineCount += 1;
}
const gwOk = gateways.some((g) => g.enabled && !!g.lastOkAt);
return {
ts: Date.now(),
rooms,
hvac: {
systemRunning,
supplyFanHz: eng(pauPts, /PAU201_输出值|PAU201风机频率/) ?? 0,
exhaustPressure: eng(pauPts, /EAF201排风压力显示|EAF201排风压力/) ?? 0,
filterRemainPct: boolOn(pauPts, /初效报警|初\/中效故障提示|初\/中效故障/) ? 0 : 0,
mode: modeAuto ? 'auto' : 'manual',
supplyTemp: eng(pauPts, /送风温度显示|送风温度/) ?? 0,
supplyHumidity: Math.round(eng(pauPts, /送风湿度显示|送风湿度/) ?? 0),
supplyPressure: eng(pauPts, /PAU201送风压力显示|送风压力/) ?? 0,
freshAirTemp: eng(pauPts, /新风温度显示|新风温度/) ?? 0,
alarms: [...new Set(hvacAlarms)].slice(0, 8),
},
vav,
fridges,
metering: {
powerKwh: sumEng(powerMeters, byDevice, /用电量|有功电能|总有功/),
waterM3: sumEng(waterMeters, byDevice, /用水量|累计流量|流量/),
coolingKwh: sumEng(coolMeters, byDevice, /冷量|累计|用电量/),
powerTrendPct: 0,
waterTrendPct: 0,
coolingTrendPct: 0,
},
access: {
doors: enabled
.filter((d) => d.category === 'door')
.map((d) => ({
id: d.id,
name: d.name,
status: (d.status === 'alarm' ? 'alarm' : d.status === 'offline' ? 'offline' : 'closed'),
})),
todayPassCount: 0,
recentEvents: [],
},
online: {
devicesOnline: onlineCount,
devicesTotal: enabled.length,
gatewayOk: gwOk,
},
pointAlarms,
};
}
import { listDevices } from '../devices/store.js';
import { listTsCatalog, queryTsSeries, } from '../db/timeseries.js';
const METRIC_LABEL = {
temp: '温度',
humidity: '湿度',
pressure: '压差',
airflow: '风量',
face_velocity: '面风速',
supply_temp: '送风温度',
supply_humidity: '送风湿度',
supply_pressure: '送风压力',
fresh_air_temp: '新风温度',
exhaust_pressure: '排风压力',
power_kwh: '用电量',
water_m3: '用水量',
cooling_kwh: '冷量',
};
const SOURCE_LABEL = {
room: '房间环境',
fridge: '冷链冰箱',
vav: '通风柜',
hvac: '空调排风',
metering: '计量',
};
export function metricLabel(metric) {
return METRIC_LABEL[metric] || metric;
}
export function sourceLabel(source) {
return SOURCE_LABEL[source] || source;
}
async function deviceNameMap() {
const devices = await listDevices();
const map = new Map();
for (const d of devices)
map.set(d.id, d.name);
map.set('PAU201', 'PAU-201 机组');
map.set('lab', '实验室汇总');
return map;
}
export async function attachSeriesNames(series) {
const names = await deviceNameMap();
return series.map((s) => ({ ...s, name: names.get(s.id) || s.name || s.id }));
}
export async function getHistoryCatalog() {
const [catalog, names] = await Promise.all([listTsCatalog(), deviceNameMap()]);
return catalog.map((c) => ({
...c,
sourceLabel: sourceLabel(c.source),
metricLabel: metricLabel(c.metric),
name: names.get(c.deviceId) || c.deviceId,
}));
}
export async function getHistorySeries(opts) {
const hours = opts.hours ?? 24;
const bucketMinutes = opts.bucketMinutes ?? 15;
const raw = await queryTsSeries({
source: opts.source,
metric: opts.metric,
hours,
bucketMinutes,
deviceIds: opts.deviceId ? [opts.deviceId] : undefined,
});
const series = await attachSeriesNames(raw);
return {
source: opts.source,
metric: opts.metric,
sourceLabel: sourceLabel(opts.source),
metricLabel: metricLabel(opts.metric),
hours,
bucketMinutes,
series,
};
}
import { queryFridgeTempSeries } from '../db/collect.js';
import { listDevices, listGateways, listPoints } from '../devices/store.js';
import { queryNamedTsSeries } from '../db/timeseries.js';
import { fetchGatewayHealth, resolveGatewayBase } from '../gateway/client.js';
import { attachSeriesNames } from './history.js';
import { buildSnapshotFromDevices } from './fromDevices.js';
const CATEGORY_LABEL = {
pau: '空调机组',
vav: '通风柜',
room: '房间环境',
fridge: '冷链冰箱',
meter_power: '电表',
meter_water: '水表',
meter_cooling: '冷量表',
light: '照明',
door: '门禁',
camera: '摄像',
nvr: '录像',
other: '其它',
};
function engOf(points, re) {
// 同名/多候选时跳过过期点,避免旧写控点挡住新鲜遥测
for (const p of points) {
if (!re.test(p.name) && !re.test(p.itemId))
continue;
if (p.lastEng === null || p.lastEng === undefined)
continue;
if (!p.lastTs)
continue;
const age = Date.now() - Date.parse(p.lastTs);
if (!Number.isFinite(age) || age > 5 * 60_000)
continue;
const n = Number(p.lastEng);
if (Number.isFinite(n))
return n;
}
return null;
}
function fresh(points, maxAgeMs = 5 * 60_000) {
return points.some((p) => {
if (!p.lastTs)
return false;
const t = Date.parse(p.lastTs);
return Number.isFinite(t) && Date.now() - t <= maxAgeMs;
});
}
export async function buildScreenDashboard() {
const [snapshot, devices, points, gateways, fridgeSeries, gwHealth, namedCurves] = await Promise.all([
buildSnapshotFromDevices(),
listDevices(),
listPoints(),
listGateways(),
queryFridgeTempSeries({ hours: 24, bucketMinutes: 15 }).catch(() => []),
resolveGatewayBase()
? fetchGatewayHealth().catch(() => ({ ok: false }))
: Promise.resolve({ ok: false }),
queryNamedTsSeries([
{ key: 'roomTemp', source: 'room', metric: 'temp' },
{ key: 'roomHumidity', source: 'room', metric: 'humidity' },
{ key: 'roomPressure', source: 'room', metric: 'pressure' },
{ key: 'fridgeTemp', source: 'fridge', metric: 'temp' },
{ key: 'fridgeHumidity', source: 'fridge', metric: 'humidity' },
{ key: 'vavAirflow', source: 'vav', metric: 'airflow' },
{ key: 'vavFaceVelocity', source: 'vav', metric: 'face_velocity' },
{ key: 'hvacSupplyTemp', source: 'hvac', metric: 'supply_temp' },
{ key: 'hvacSupplyHumidity', source: 'hvac', metric: 'supply_humidity' },
{ key: 'hvacSupplyPressure', source: 'hvac', metric: 'supply_pressure' },
{ key: 'hvacFreshAirTemp', source: 'hvac', metric: 'fresh_air_temp' },
{ key: 'hvacExhaustPressure', source: 'hvac', metric: 'exhaust_pressure' },
{ key: 'meteringPower', source: 'metering', metric: 'power_kwh' },
{ key: 'meteringWater', source: 'metering', metric: 'water_m3' },
{ key: 'meteringCooling', source: 'metering', metric: 'cooling_kwh' },
], { hours: 24, bucketMinutes: 1 }).catch(() => ({})),
]);
const curveKeys = [
'roomTemp',
'roomHumidity',
'roomPressure',
'fridgeTemp',
'fridgeHumidity',
'vavAirflow',
'vavFaceVelocity',
'hvacSupplyTemp',
'hvacSupplyHumidity',
'hvacSupplyPressure',
'hvacFreshAirTemp',
'hvacExhaustPressure',
'meteringPower',
'meteringWater',
'meteringCooling',
];
const namedList = await Promise.all(curveKeys.map((k) => attachSeriesNames(namedCurves[k] || [])));
const curves = Object.fromEntries(curveKeys.map((k, i) => [k, namedList[i]]));
const enabled = devices.filter((d) => d.enabled);
const byDevice = new Map();
for (const p of points) {
if (!p.deviceId || !p.enabled)
continue;
const list = byDevice.get(p.deviceId) || [];
list.push(p);
byDevice.set(p.deviceId, list);
}
const catCount = new Map();
for (const d of enabled) {
catCount.set(d.category, (catCount.get(d.category) || 0) + 1);
}
const categoryPie = [...catCount.entries()].map(([category, value]) => ({
category,
name: CATEGORY_LABEL[category] || category,
value,
}));
let online = 0;
let offline = 0;
let alarm = 0;
let unknown = 0;
for (const d of enabled) {
const pts = byDevice.get(d.id) || [];
if (d.status === 'alarm' || snapshot.pointAlarms?.some((a) => a.deviceId === d.id)) {
alarm += 1;
}
else if (fresh(pts)) {
online += 1;
}
else if (d.status === 'offline') {
offline += 1;
}
else {
unknown += 1;
}
}
const statusPie = [
{ name: '在线', value: online },
{ name: '告警', value: alarm },
{ name: '离线', value: offline },
{ name: '未知', value: unknown },
].filter((x) => x.value > 0);
const powerBars = enabled
.filter((d) => d.category === 'meter_power')
.map((d) => ({
name: d.name,
value: engOf(byDevice.get(d.id) || [], /用电量|有功电能/),
}));
const roomDevs = enabled.filter((d) => d.category === 'room');
const pressureBars = roomDevs.map((d) => {
const pts = byDevice.get(d.id) || [];
const pressure = engOf(pts, /实时压差|压差(?!设定|上限|下限)/);
const room = snapshot.rooms.find((r) => r.name === d.name);
return {
name: d.name,
value: pressure,
ok: room ? room.alarms.length === 0 : true,
};
});
const envBubbles = roomDevs.map((d) => {
const pts = byDevice.get(d.id) || [];
const temp = engOf(pts, /实时温度|^温度$/);
const humidity = engOf(pts, /实时湿度|^湿度$/);
const pressure = engOf(pts, /实时压差|压差(?!设定|上限|下限)/);
const room = snapshot.rooms.find((r) => r.name === d.name);
return {
name: d.name,
temp,
humidity,
pressure,
ok: room ? room.alarms.length === 0 : true,
};
});
const fridgeBars = enabled
.filter((d) => d.category === 'fridge')
.map((d) => {
const temp = engOf(byDevice.get(d.id) || [], /冰箱温度|温度/);
return {
id: d.id,
name: d.name,
temp,
ok: temp === null ? true : temp <= -19,
};
});
const pauPts = enabled
.filter((d) => d.category === 'pau')
.flatMap((d) => byDevice.get(d.id) || []);
const pauCards = [
{
label: '送风温度',
value: engOf(pauPts, /送风温度显示|送风温度(?!校准|设定)/),
unit: '℃',
ok: null,
},
{ label: '送风湿度', value: engOf(pauPts, /送风湿度显示|送风湿度(?!校准|设定)/), unit: '%', ok: null },
{ label: '送风压力', value: engOf(pauPts, /PAU201送风压力显示|送风压力显示|送风压力(?!设定)/), unit: 'Pa', ok: null },
{ label: '排风压力', value: engOf(pauPts, /EAF201排风压力显示|排风压力显示/), unit: 'Pa', ok: null },
{ label: '新风温度', value: engOf(pauPts, /新风温度显示|新风温度(?!设定)/), unit: '℃', ok: null },
(() => {
const v = engOf(pauPts, /系统启动信号|^开机$|PAU201送风机启停/);
return {
label: '系统启动',
value: v,
unit: '',
ok: v === null ? null : v !== 0,
};
})(),
];
const vavBars = enabled
.filter((d) => d.category === 'vav')
.map((d) => {
const pts = byDevice.get(d.id) || [];
const running = (engOf(pts, /开停状态|^开机$/) ?? 0) !== 0;
return {
name: d.name,
airflow: engOf(pts, /实际风量/),
faceVelocity: engOf(pts, /实际面风速/),
running,
};
});
const roomOk = snapshot.rooms.filter((r) => !r.alarms.length).length;
const fridgeOk = fridgeBars.filter((f) => f.ok).length;
const gwOk = !!gwHealth.ok ||
gateways.some((g) => g.enabled && !!g.lastOkAt) ||
snapshot.online.gatewayOk;
snapshot.online.gatewayOk = gwOk;
return {
ts: Date.now(),
snapshot,
categoryPie,
statusPie,
powerBars,
pressureBars,
envBubbles,
fridgeBars,
fridgeSeries: fridgeSeries.length
? fridgeSeries
: curves.fridgeTemp.map((s) => ({
id: s.id,
name: s.name || s.id,
points: s.points.map((p) => ({ t: p.t, temp: p.v })),
})),
curves,
digits: {
deviceTotal: enabled.length,
deviceOnline: online,
roomOk,
roomTotal: snapshot.rooms.length,
fridgeOk,
fridgeTotal: fridgeBars.length,
alarmOpen: snapshot.pointAlarms?.length || 0,
gatewayOk: gwOk,
powerKwh: snapshot.metering.powerKwh,
waterM3: snapshot.metering.waterM3,
coolingKwh: snapshot.metering.coolingKwh,
},
pauCards,
vavBars,
};
}
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';
import { syncPointAlarms } from '../alarms/store.js';
import { evaluatePointAlarms } from '../devices/alarms.js';
import { applyMockPointTelemetry, applyMockPointTelemetryStale, applyPointTelemetry, } from '../devices/store.js';
import { buildSnapshotFromDevices } from './fromDevices.js';
let snapshot = {
ts: 0,
rooms: [],
hvac: {
systemRunning: false,
supplyFanHz: 0,
exhaustPressure: 0,
filterRemainPct: 0,
mode: 'auto',
supplyTemp: 0,
supplyHumidity: 0,
supplyPressure: 0,
freshAirTemp: 0,
alarms: [],
},
vav: [],
fridges: [],
metering: {
powerKwh: 0,
waterM3: 0,
coolingKwh: 0,
powerTrendPct: 0,
waterTrendPct: 0,
coolingTrendPct: 0,
},
access: { doors: [], todayPassCount: 0, recentEvents: [] },
online: { devicesOnline: 0, devicesTotal: 0, gatewayOk: false },
pointAlarms: [],
};
const listeners = new Set();
let persistBusy = false;
let pollTimer;
let currentPollMs = config.modbus.pollMs;
let tickBusy = false;
let lastMockFillLogMs = 0;
export function getSnapshot() {
return snapshot;
}
export function getCurrentPollMs() {
return currentPollMs;
}
export function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
function publish() {
for (const fn of listeners)
fn(snapshot);
}
async function persistSafe(snap) {
if (persistBusy)
return;
persistBusy = true;
try {
await persistSnapshot(snap);
}
finally {
persistBusy = false;
}
}
function useGatewayMode() {
return config.dataMode === 'gateway' || config.dataMode === 'modbus';
}
function parseGatewayValues(gwSnap) {
if (!gwSnap || typeof gwSnap !== 'object')
return [];
const values = gwSnap.values;
if (!Array.isArray(values))
return [];
const out = [];
for (const row of values) {
if (!row || typeof row !== 'object')
continue;
const r = row;
const id = typeof r.id === 'string' && r.id ? r.id : undefined;
const itemId = typeof r.itemId === 'string' && r.itemId ? r.itemId : undefined;
if (!id && !itemId)
continue;
out.push({
id,
itemId,
raw: r.raw === undefined || r.raw === null ? null : Number(r.raw),
eng: r.eng === undefined || r.eng === null ? null : Number(r.eng),
ts: typeof r.ts === 'string' ? r.ts : null,
quality: typeof r.quality === 'string' ? r.quality : undefined,
});
}
return out;
}
async function attachPointAlarms() {
try {
const current = await evaluatePointAlarms();
snapshot.pointAlarms = current;
try {
await syncPointAlarms(current);
}
catch (e) {
console.warn('[realtime] sync alarms table failed', e);
}
}
catch (e) {
console.warn('[realtime] evaluate point alarms failed', e);
snapshot.pointAlarms = [];
}
}
/** 网关主动推送遥测后立即重算点位超限告警并广播 */
export async function refreshPointAlarms() {
await attachPointAlarms();
publish();
}
async function tick() {
if (tickBusy)
return;
tickBusy = true;
try {
let gatewayOk;
if (useGatewayMode() && resolveGatewayBase()) {
const [health, gwSnap] = await Promise.all([fetchGatewayHealth(), fetchGatewaySnapshot()]);
gatewayOk = health.ok;
const samples = parseGatewayValues(gwSnap);
if (samples.length) {
try {
await applyPointTelemetry(samples);
}
catch (e) {
console.warn('[realtime] gateway point telemetry failed', e);
}
}
// 网关部分断线时,过期点位继续模拟补发,保证时序入库与大屏不断流
const staleMs = Number(process.env.MOCK_FILL_STALE_MS || config.mockFillStaleMs || 0);
if (staleMs > 0) {
try {
const n = await applyMockPointTelemetryStale(staleMs);
const now = Date.now();
if (n > 0 && now - lastMockFillLogMs > 30_000) {
lastMockFillLogMs = now;
console.log(`[realtime] mock-fill stale points: ${n}`);
}
}
catch (e) {
console.warn('[realtime] mock-fill stale failed', e);
}
}
}
else if (config.dataMode === 'mock') {
try {
await applyMockPointTelemetry();
}
catch (e) {
console.warn('[realtime] mock point telemetry failed', e);
}
}
else if (Number(process.env.MOCK_FILL_STALE_MS || config.mockFillStaleMs || 0) > 0) {
// gateway 模式但未配置 URL:仍用模拟保持发送
try {
await applyMockPointTelemetry();
}
catch (e) {
console.warn('[realtime] mock telemetry failed', e);
}
}
snapshot = await buildSnapshotFromDevices();
if (gatewayOk !== undefined)
snapshot.online.gatewayOk = gatewayOk;
else if (useGatewayMode())
snapshot.online.gatewayOk = false;
await attachPointAlarms();
publish();
void persistSafe(snapshot);
}
finally {
tickBusy = false;
}
}
function startPollTimer(ms) {
if (pollTimer)
clearInterval(pollTimer);
currentPollMs = ms;
pollTimer = setInterval(tick, currentPollMs);
console.log(`[realtime] collect poll = ${currentPollMs} ms`);
}
/** 运行时热更新采集频率 */
export async function setCollectPollMs(ms) {
startPollTimer(ms);
}
export async function startRealtimeLoop() {
const ms = await getCollectPollMs();
tick();
startPollTimer(ms);
// 每天清理一次过期采集数据
setInterval(() => void pruneCollectData(), 24 * 60 * 60 * 1000);
void pruneCollectData();
}
export function applyWriteLog(action, detail) {
console.log(`[WRITE] ${action}`, detail);
void persistWriteLog(action, detail, typeof detail.by === 'string' ? detail.by : undefined);
}
import { Router } from 'express';
import { authRequired, requireRole } from '../auth/middleware.js';
import { ackAlarm, listAlarms } from '../alarms/store.js';
export const alarmsRouter = Router();
/** 告警列表(统一表);大屏可匿名拉 open,后台需登录 */
alarmsRouter.get('/', async (req, res) => {
const statusRaw = typeof req.query.status === 'string' ? req.query.status : 'open';
const sourceRaw = typeof req.query.source === 'string' ? req.query.source : undefined;
const limit = req.query.limit ? Number(req.query.limit) : 100;
const status = statusRaw === 'all'
? undefined
: statusRaw;
const source = sourceRaw;
try {
const alarms = await listAlarms({ status, source, limit });
res.json({ alarms, count: alarms.length });
}
catch (e) {
res.status(500).json({ message: e instanceof Error ? e.message : '查询失败' });
}
});
alarmsRouter.post('/:id/ack', authRequired, requireRole('admin'), async (req, res) => {
try {
const row = await ackAlarm(req.params.id, req.user.username);
if (!row) {
res.status(404).json({ message: '告警不存在或已关闭' });
return;
}
res.json({ ok: true, alarm: row });
}
catch (e) {
res.status(500).json({ message: e instanceof Error ? e.message : '确认失败' });
}
});
import { Router } from 'express';
import { signToken, authRequired } from '../auth/middleware.js';
import { toPublic, verifyLogin } from '../users/store.js';
export const authRouter = Router();
authRouter.post('/login', async (req, res) => {
try {
const { username, password } = req.body || {};
if (!username || !password) {
res.status(400).json({ message: '请输入用户名和密码' });
return;
}
const user = await verifyLogin(String(username), String(password));
if (!user) {
res.status(401).json({ message: '用户名或密码错误' });
return;
}
const token = signToken({
sub: user.id,
username: user.username,
role: user.role,
displayName: user.displayName,
});
res.json({ token, user: toPublic(user) });
}
catch (e) {
console.error('[auth] login failed', e);
res.status(500).json({
message: e instanceof Error ? `登录失败:${e.message}` : '登录失败,请检查服务与数据库',
});
}
});
authRouter.get('/me', authRequired, (req, res) => {
res.json({
user: {
id: req.user.sub,
username: req.user.username,
displayName: req.user.displayName,
role: req.user.role,
},
});
});
import { Router } from 'express';
import { authRequired, requireRole } from '../auth/middleware.js';
import { getTariff, updateTariff, getStats, getReport, } from '../billing/store.js';
export const billingRouter = Router();
billingRouter.get('/tariff', async (_req, res) => {
res.json(await getTariff());
});
billingRouter.put('/tariff', authRequired, requireRole('admin'), async (req, res) => {
const body = (req.body || {});
const numKeys = [
'powerPeak',
'powerFlat',
'powerValley',
'water',
'cooling',
'serviceFeePct',
'taxPct',
'settleDay',
];
const patch = {};
for (const k of numKeys) {
if (body[k] !== undefined) {
const n = Number(body[k]);
if (!Number.isFinite(n) || n < 0) {
res.status(400).json({ message: `参数 ${k} 无效` });
return;
}
;
patch[k] = n;
}
}
if (body.settleDay !== undefined) {
const d = Number(body.settleDay);
if (d < 1 || d > 28) {
res.status(400).json({ message: '结算日需在 1–28' });
return;
}
}
if (typeof body.enabled === 'boolean')
patch.enabled = body.enabled;
const next = await updateTariff(patch, req.user.username);
res.json(next);
});
billingRouter.get('/stats', async (_req, res) => {
res.json(await getStats());
});
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) {
const d = new Date(to + 'T12:00:00');
d.setDate(d.getDate() - 29);
from = d.toISOString().slice(0, 10);
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
res.status(400).json({ message: '日期格式应为 YYYY-MM-DD' });
return;
}
if (from > to) {
res.status(400).json({ message: '开始日期不能晚于结束日期' });
return;
}
const rows = await getReport(from, to);
const summary = {
from,
to,
days: rows.length,
powerKwh: +rows
.reduce((s, b) => s + b.powerPeakKwh + b.powerFlatKwh + b.powerValleyKwh, 0)
.toFixed(1),
waterM3: +rows.reduce((s, b) => s + b.waterM3, 0).toFixed(1),
coolingKwh: +rows.reduce((s, b) => s + b.coolingKwh, 0).toFixed(0),
powerCost: +rows.reduce((s, b) => s + b.powerCost, 0).toFixed(2),
waterCost: +rows.reduce((s, b) => s + b.waterCost, 0).toFixed(2),
coolingCost: +rows.reduce((s, b) => s + b.coolingCost, 0).toFixed(2),
serviceFee: +rows.reduce((s, b) => s + b.serviceFee, 0).toFixed(2),
tax: +rows.reduce((s, b) => s + b.tax, 0).toFixed(2),
total: +rows.reduce((s, b) => s + b.total, 0).toFixed(2),
};
res.json({ summary, rows });
});
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
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