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; }
}
const token = () => localStorage.getItem('gw_token') || '';
async function api(path, opts = {}) {
const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
if (token()) headers.Authorization = 'Bearer ' + token();
const res = await fetch(path, { ...opts, headers });
if (res.status === 401) {
localStorage.removeItem('gw_token');
location.replace('/ui/login.html');
throw new Error('未登录');
}
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) throw new Error(data.message || '请求失败');
return data;
}
function banner(msg, ok) {
const el = document.getElementById('banner');
el.textContent = msg || '';
el.className = 'msg ' + (ok ? 'ok' : msg ? 'err' : '');
}
function showPage(name) {
document.querySelectorAll('[id^="page-"]').forEach((p) => p.classList.add('hidden'));
document.getElementById('page-' + name).classList.remove('hidden');
document.querySelectorAll('#nav button').forEach((b) => {
b.classList.toggle('active', b.dataset.page === name);
});
}
async function loadHealth() {
const h = await api('/admin/api/health');
document.getElementById('verLabel').textContent =
(h.version || '') + ' · 点表 v' + (h.pointsVersion || 0);
const cards = document.getElementById('healthCards');
const items = [
['服务', h.ok ? '正常' : '异常', h.ok],
['平台', h.platformUrl || '—', true],
['监听', (h.listen || '') + ':' + (h.port || ''), true],
['Modbus 主机', String((h.modbus || []).length), true],
['摄像头', String((h.cameras || []).length), true],
['门禁', String((h.doors || []).length), true],
];
cards.innerHTML = items
.map(
([k, v, ok]) =>
`<div class="panel" style="margin:0"><div style="color:var(--muted);font-size:12px">${k}</div><div style="font-size:18px;margin-top:6px">${v}</div><span class="badge ${ok ? 'ok' : 'off'}">${ok ? 'OK' : '—'}</span></div>`,
)
.join('');
}
let cfgCache = null;
async function loadConfig() {
cfgCache = await api('/admin/api/config');
document.getElementById('cfgListen').value = cfgCache.listen || '0.0.0.0';
document.getElementById('cfgPort').value = cfgCache.port || 38080;
document.getElementById('cfgPlatform').value = cfgCache.platformUrl || '';
document.getElementById('cfgPoll').value = cfgCache.pollMs || 2000;
renderModbus(cfgCache.modbusHosts || []);
renderDevices(cfgCache);
renderPreviews(cfgCache.cameras || []);
}
function renderModbus(list) {
const tb = document.querySelector('#modbusTable tbody');
tb.innerHTML = '';
list.forEach((m, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input data-k="id" value="${esc(m.id || '')}" /></td>
<td><input data-k="name" value="${esc(m.name || '')}" /></td>
<td><input data-k="host" value="${esc(m.host || '')}" /></td>
<td><input data-k="port" type="number" value="${m.port || 502}" style="width:90px" /></td>
<td><select data-k="enabled"><option value="true" ${m.enabled !== false ? 'selected' : ''}>是</option><option value="false" ${m.enabled === false ? 'selected' : ''}>否</option></select></td>
<td><button class="btn ghost" type="button" data-del="${idx}">删</button></td>`;
tb.appendChild(tr);
});
tb.querySelectorAll('[data-del]').forEach((btn) => {
btn.onclick = () => {
const i = Number(btn.getAttribute('data-del'));
cfgCache.modbusHosts.splice(i, 1);
renderModbus(cfgCache.modbusHosts);
};
});
}
function readModbusFromTable() {
return [...document.querySelectorAll('#modbusTable tbody tr')].map((tr) => {
const get = (k) => tr.querySelector(`[data-k="${k}"]`).value;
return {
id: get('id').trim(),
name: get('name').trim(),
host: get('host').trim(),
port: Number(get('port') || 502),
enabled: get('enabled') === 'true',
};
});
}
function renderDevices(cfg) {
const rows = [];
(cfg.modbusHosts || []).forEach((m) =>
rows.push({
type: 'modbus',
id: m.id,
name: m.name || m.id,
addr: `${m.host}:${m.port || 502}`,
ok: m.enabled !== false,
}),
);
(cfg.cameras || []).forEach((c) =>
rows.push({
type: 'camera',
id: c.id,
name: c.name || c.id,
addr: `${c.host}:${c.port || 8000}`,
ok: c.enabled !== false,
}),
);
(cfg.doors || []).forEach((d) =>
rows.push({
type: 'door',
id: d.id,
name: d.name || d.id,
addr: `${d.host}:${d.port || 8000}`,
ok: d.enabled !== false,
}),
);
if (cfg.nvr) {
rows.push({
type: 'nvr',
id: cfg.nvr.id || 'nvr',
name: cfg.nvr.name || 'NVR',
addr: `${cfg.nvr.host}:${cfg.nvr.port || 8000}`,
ok: cfg.nvr.enabled !== false,
});
}
const tb = document.querySelector('#deviceTable tbody');
tb.innerHTML = rows
.map(
(r) => `<tr>
<td>${r.type}</td><td>${esc(r.name)}</td><td>${esc(r.addr)}</td>
<td><span class="badge ${r.ok ? 'ok' : 'off'}">${r.ok ? '启用' : '停用'}</span></td>
<td class="row-ops"><button class="btn ghost" type="button" data-edit="${r.type}:${r.id}">配置</button></td>
</tr>`,
)
.join('');
tb.querySelectorAll('[data-edit]').forEach((btn) => {
btn.onclick = () => fillDeviceEditor(btn.getAttribute('data-edit'));
});
}
function fillDeviceEditor(key) {
const [type, id] = key.split(':');
showPage('deviceEdit');
document.getElementById('devType').value = type === 'modbus' ? 'camera' : type;
let d = null;
if (type === 'camera') d = (cfgCache.cameras || []).find((x) => x.id === id);
if (type === 'door') d = (cfgCache.doors || []).find((x) => x.id === id);
if (type === 'nvr') d = cfgCache.nvr;
if (!d) {
document.getElementById('devId').value = id || '';
return;
}
document.getElementById('devType').value = type === 'nvr' ? 'nvr' : type;
document.getElementById('devId').value = d.id || id || '';
document.getElementById('devName').value = d.name || '';
document.getElementById('devHost').value = d.host || '';
document.getElementById('devPort').value = d.port || 8000;
document.getElementById('devUser').value = d.username || 'admin';
document.getElementById('devPass').value = d.password || '';
document.getElementById('devChannel').value = d.channel || 1;
document.getElementById('devEnabled').value = d.enabled === false ? 'false' : 'true';
}
function renderPreviews(cameras) {
const grid = document.getElementById('previewGrid');
const list = (cameras || []).filter((c) => c.enabled !== false);
if (!list.length) {
grid.innerHTML = '<p style="color:var(--muted)">暂无启用摄像头,请先在「设备配置」中添加。</p>';
return;
}
const t = Date.now();
grid.innerHTML = list
.map(
(c) => `<div class="preview-card">
<img src="/admin/api/cameras/${encodeURIComponent(c.id)}/preview.jpg?t=${t}" alt="${esc(c.name || c.id)}" />
<div class="meta"><strong>${esc(c.name || c.id)}</strong>${esc(c.host || '')}:${c.port || 8000} · 通道 ${c.channel || 1}</div>
</div>`,
)
.join('');
}
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/"/g, '&quot;');
}
async function boot() {
if (!token()) {
location.replace('/ui/login.html');
return;
}
try {
await api('/admin/api/me');
} catch {
return;
}
document.querySelectorAll('#nav button').forEach((b) => {
b.onclick = () => showPage(b.dataset.page);
});
document.getElementById('btnLogout').onclick = () => {
localStorage.removeItem('gw_token');
location.replace('/ui/login.html');
};
document.getElementById('btnRefreshHealth').onclick = () => loadHealth().catch((e) => banner(e.message));
document.getElementById('btnReloadDevices').onclick = () => loadConfig().catch((e) => banner(e.message));
document.getElementById('btnRefreshPreview').onclick = () =>
renderPreviews((cfgCache && cfgCache.cameras) || []);
document.getElementById('btnAddModbus').onclick = () => {
cfgCache.modbusHosts = cfgCache.modbusHosts || [];
cfgCache.modbusHosts.push({
id: 'mb-new',
name: '新主机',
host: '192.168.10.1',
port: 502,
enabled: true,
});
renderModbus(cfgCache.modbusHosts);
};
document.getElementById('btnSaveNetwork').onclick = async () => {
try {
const body = {
listen: document.getElementById('cfgListen').value.trim(),
port: Number(document.getElementById('cfgPort').value),
platformUrl: document.getElementById('cfgPlatform').value.trim(),
pollMs: Number(document.getElementById('cfgPoll').value),
modbusHosts: readModbusFromTable(),
};
cfgCache = await api('/admin/api/config', { method: 'PUT', body: JSON.stringify(body) });
banner('网络配置已保存(监听地址/端口变更需重启网关进程)', true);
renderModbus(cfgCache.modbusHosts || []);
await loadHealth();
} catch (e) {
banner(e.message);
}
};
document.getElementById('btnSaveDevice').onclick = async () => {
const msg = document.getElementById('devMsg');
msg.textContent = '';
try {
const body = {
type: document.getElementById('devType').value,
id: document.getElementById('devId').value.trim(),
name: document.getElementById('devName').value.trim(),
host: document.getElementById('devHost').value.trim(),
port: Number(document.getElementById('devPort').value || 8000),
username: document.getElementById('devUser').value.trim(),
password: document.getElementById('devPass').value,
channel: Number(document.getElementById('devChannel').value || 1),
enabled: document.getElementById('devEnabled').value === 'true',
};
cfgCache = await api('/admin/api/devices', { method: 'PUT', body: JSON.stringify(body) });
msg.className = 'msg ok';
msg.textContent = '设备已保存';
renderDevices(cfgCache);
renderPreviews(cfgCache.cameras || []);
} catch (e) {
msg.className = 'msg err';
msg.textContent = e.message;
}
};
document.getElementById('btnDeleteDevice').onclick = async () => {
const id = document.getElementById('devId').value.trim();
const type = document.getElementById('devType').value;
if (!id || !confirm('确认删除 ' + type + ':' + id + '?')) return;
try {
cfgCache = await api('/admin/api/devices?type=' + encodeURIComponent(type) + '&id=' + encodeURIComponent(id), {
method: 'DELETE',
});
document.getElementById('devMsg').className = 'msg ok';
document.getElementById('devMsg').textContent = '已删除';
renderDevices(cfgCache);
renderPreviews(cfgCache.cameras || []);
} catch (e) {
document.getElementById('devMsg').className = 'msg err';
document.getElementById('devMsg').textContent = e.message;
}
};
await loadHealth();
await loadConfig();
}
boot().catch((e) => banner(e.message || String(e)));
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理台 · JhLab 协议网关</title>
<link rel="stylesheet" href="/ui/app.css" />
</head>
<body>
<div class="wrap">
<header class="top">
<div class="brand">JhLab 协议网关<small id="verLabel">加载中…</small></div>
<nav class="nav" id="nav">
<button type="button" data-page="overview" class="active">总览</button>
<button type="button" data-page="network">网络/IP</button>
<button type="button" data-page="devices">设备列表</button>
<button type="button" data-page="deviceEdit">设备配置</button>
<button type="button" data-page="cameras">摄像头预览</button>
</nav>
<button class="btn ghost" type="button" id="btnLogout">退出</button>
</header>
<p class="msg" id="banner"></p>
<section class="panel" id="page-overview">
<h2>运行总览</h2>
<div class="grid" id="healthCards"></div>
<div class="actions">
<button class="btn" type="button" id="btnRefreshHealth">刷新状态</button>
<a class="btn ghost" href="/health" target="_blank">打开 /health</a>
</div>
</section>
<section class="panel hidden" id="page-network">
<h2>网络与平台配置</h2>
<div class="grid">
<div class="field"><label>监听地址</label><input id="cfgListen" /></div>
<div class="field"><label>服务端口</label><input id="cfgPort" type="number" /></div>
<div class="field"><label>平台 URL</label><input id="cfgPlatform" /></div>
<div class="field"><label>采集周期 ms</label><input id="cfgPoll" type="number" /></div>
</div>
<h2 style="margin-top:18px">Modbus TCP 主机</h2>
<table class="table" id="modbusTable">
<thead><tr><th>ID</th><th>名称</th><th>IP</th><th>端口</th><th>启用</th><th></th></tr></thead>
<tbody></tbody>
</table>
<div class="actions">
<button class="btn" type="button" id="btnAddModbus">添加主机</button>
<button class="btn primary" type="button" id="btnSaveNetwork">保存配置</button>
</div>
</section>
<section class="panel hidden" id="page-devices">
<h2>设备列表</h2>
<table class="table" id="deviceTable">
<thead>
<tr><th>类型</th><th>名称</th><th>地址</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody></tbody>
</table>
<div class="actions">
<button class="btn" type="button" id="btnReloadDevices">刷新</button>
</div>
</section>
<section class="panel hidden" id="page-deviceEdit">
<h2>设备配置</h2>
<div class="grid">
<div class="field">
<label>类型</label>
<select id="devType">
<option value="camera">摄像头</option>
<option value="door">门禁</option>
<option value="nvr">NVR</option>
</select>
</div>
<div class="field"><label>ID</label><input id="devId" placeholder="cam-142" /></div>
<div class="field"><label>名称</label><input id="devName" /></div>
<div class="field"><label>IP</label><input id="devHost" placeholder="192.168.10.142" /></div>
<div class="field"><label>端口</label><input id="devPort" type="number" value="8000" /></div>
<div class="field"><label>用户名</label><input id="devUser" value="admin" /></div>
<div class="field"><label>密码</label><input id="devPass" type="password" /></div>
<div class="field"><label>通道号</label><input id="devChannel" type="number" value="1" /></div>
<div class="field">
<label>启用</label>
<select id="devEnabled"><option value="true">是</option><option value="false">否</option></select>
</div>
</div>
<div class="actions">
<button class="btn primary" type="button" id="btnSaveDevice">保存设备</button>
<button class="btn danger" type="button" id="btnDeleteDevice">删除</button>
</div>
<p class="msg" id="devMsg"></p>
</section>
<section class="panel hidden" id="page-cameras">
<h2>摄像头预览</h2>
<p style="color:var(--muted);font-size:13px;margin-top:0">
管理台缩略图为抓图通道;大屏/平台后台实况统一使用 <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>
551102312e19b704cd22bd7254a9444b9ea14e96 release-0.1.0
23fb87bddda14ce9faec90f774085634106aded4 release-0.1.1
295d97d70c698585705345f1a8f92b02e63d6d0d release-0.1.2
ded1284520cc939ad5ae6ddab39925375e64237d release-0.1.3
0491b909ef7612d8411f1f59054186c1f3471b52 release-0.1.4
a88a3e4e158fade0aaa6f3eb25597d5ced2c1075 release-0.1.5
1f31dc6d33a3a4e65240b08066bf186df9e33b79 release-0.1.6
5aecc125bc33d81d6214c91d73eb44230a903dde release-0.1.7
bbd6b0b4a2b15ef8c8f1aaf7b027b6da47303524 release-0.1.8
2ff194b74f1e60cd04670986973e3b1a6aa3bece release-0.1.9
31ee1b50354fb829564b81a6f34e8d6ceb2d3f48 release-0.1.10
8e8f3af115b5b903b2b8f3335de971f18891246f release-0.1.11
c3c2848fc081e19aec5ffa97e468ad20ddb81df0 release-0.1.12
ad1e9ebf93bb5ae4c748d471fad2de8a0afc4d2a release-0.1.13
c5240858380136a67bec261c59b1532560b57885 release-0.1.14
fd661d14a7fad212e326a7dad6234ea0de992fbf release-0.1.15
621229427cba1b0af417ff2a101fc4f17a7d93c8 release-0.1.16
4ebe09b07e3021f1a63b459903ec58f162183b26 release-0.1.17
31ff3e943e1675a2caf745ba7a981244445d4c98 release-0.1.18
45a460f82aec80b0f61136aa09f412436d42203a release-0.1.19
0f836f0288eee4980f57736d50a7a60fa082d8e9 release-0.1.20
975f62e77f0244f1b631f740be77c72c8f2da1de release-0.1.21
fc9909c369b2b4716304ac8e38da57b8fb781211 release-0.1.22
d7c90bb5ce83dab08715e98f9c7b81c7df4b37be release-0.1.23
64d9afb209da0cd4a917202b7b77e51cc23e2229 release-0.1.24
d4ea69372b946dc4ec37fc3f5ddd93ff7c3da675 release-0.1.25
b1648294f6935e993e436fd8a68bca75c74c826d release-0.1.26
ee66921ecd47a7fa459f70f4a9d660f91f6a1b94 release-0.1.27
cd3117ad9aab9c58c6f7e677e551e1adbdeaba54 release-0.1.28
9b8c906f6e63ec2c71cecebfff35819a7d32227d release-0.1.29
c12967aadd8726daf2d85e3f3e622d89c42db176 release-0.1.30
fbbf16224844e7d560c00043e8ade8a560415bba release-0.1.31
417a087c9c4d9abb9b0b9b3f787aff515c43c035 release-0.1.32
dadfa78d227027348d7f9d1e7b7093d06ba545a0 release-0.1.33
12234c998d83bfbbaa305273b3dd1b855ca325dc release-0.1.34
6f00349b98e5f706b82115c6e4dc84456fc0d770 release-0.1.35
2019117e6b38cc3e89fe4f56a23b271479c627a6 release-0.1.36
09b42134ac0c42625340f16628e29690a04f8db5 release-0.1.37
7fa11e5c6e9612ecff5eb58274cc846ae742d1d2 release-0.1.38
e5d7d0334fdb946133c17523c198800142ac9fe9 release-0.1.39
c3bd8cdabb8f73e5600a91f198eb7df6fac65e92 release-0.1.40
d6e48c08d718bf5a9e58c20a37e8ae172bff1139 release-0.1.41
563ad09abf5042eb41e8ecaf5b4e6c9deaa42731 release-0.1.42
c9ad0d9c7d59b2fa2a5fe669f1e88debd03e6c04 release-0.1.43
371c1cee100d7a1b0e6cad4d188e05c98a641ee7 release-0.1.44
b09ee85d0ac823e36861491eedfc4dfafe282997 release-0.1.45
511a89da35ada16ae806667d699f9610b4f8499a release-0.2.0
0148586012ab3dde69b394ec5a389d44bb11c869 release-0.2.1
818fbd4750b99d14d2736212c939855a11b1f1ef release-0.2.2
e16a8d574da511622b97d6237d005f40f2cddb30 release-0.2.3
483cca23060331f2078b1c2984870d80f288ad41 release-0.2.4
45033d85b30e3f12c407b7cfc518d76e0eda0263 release-0.2.5
7bd37aef1e7e87858c12b124e253e98558889b50 release-0.2.6
ecd9c160f25b7a7075dd93383d98a0fc8d8c0a41 release-0.3.0
c1f965ef97188fd7ef81342dcf8719da18c554d2 release-0.3.1
e48ebafc69393fc94fecfdf9997c4179fd1ce473 release-0.3.2
9c2f3ed7a24711d3b42b124d5f831155c8beff95 release-0.3.3
7c1369d37c7eb0017c28ebcaa0778046f5aafdcc release-0.3.4
1af2fcb3be8a63796b6b23a488049c92a6bc12f4 release-0.3.5
174f1e853e1e831b01000aeccfd06a9c8d4d95a2 release-0.3.6
458b6c3fea65a894c99dd429334a77bb164c7e83 release-0.3.7
58475592100cb792c125101b6d2d898f5adada30 release-0.3.8
fcd6fc7ff7f9b132c35193d834e6e7d05026c716 release-0.3.9
4d9ea73a627a914d364e83e20c58eb1283f4031d release-0.3.10
4c5c2c55975c1152b5ca5d5d55b32d4dd7945f7a release-0.3.11
326634fb9d47912ad94221dc2f8fa4bec424d40c release-0.3.12
4e296b7d25bf62390ca2afb599e395426b94f785 release-0.3.13
401de5a43ba5a8acdb9c52465193c0ea7354afe7 release-0.3.14
284cc140593bb16ac71094acd509ab415ff4837d release-0.3.15
d4e858a5751a7fd08e64586795ed7d336011fbc0 release-0.3.16
8c0cdd81580eb76d774cfc5724de68e7e5cbbdc2 release-0.3.17
425af804d968f30eeff01e33b808bc2e8c467f2c release-0.3.18
ebc68d8ca4962fe3531b7e13444f7ac4395d9c6e release-0.3.19
9262f520ce214d3d5fd7c842891519336ef85ca6 release-0.3.20
869b6444d2341a587183859d4df736c7f3381169 release-0.3.21
77f77f53214a0e3a68fef8226c15532b54f2c365 release-0.3.22
858700ae46b453ea111b966b6d03f2c21ddcb94e release-0.3.23
5dac8c7fb71b86aafed8ea352305e7f85759f72e release-0.3.24
77cdfe394a94a625955e7585e09983b3af9b889b release-0.3.25
608cf78b24ef7baaf9705e4715a361f26bb16ba9 release-0.3.26
3f8a2132b93d66ac19bec006205a304a68524a0b release-0.3.27
c73c5c58c619c22dd3a5a26c91bb0567a62c6930 release-0.3.28
5ef026a2ac7481f04154f29ab49377bf99aaf96f release-0.3.29
51b27717f140b71a2e9158807d79da17c888ce4c release-0.3.30
7a16e281c01f1c7ab3b79c64b43ddb754ea7935e release-0.3.31
93e85a79757c49d502e42a1cb8264a0f133b0b00 release-0.3.32
0216fd1471f386168545f772836156761eddec08 release-0.3.33
fbed40ce7cb4fd7203fecc22a617b9ce5b950fb3 release-0.3.34
387450de0b4d21652f0b6242a5e26a31e3be8d8c release-0.3.35
65bf042c0b4f39f18a235464c52f980e9fa24f6b release-0.3.36
5d2b8078c1c2593b95ec50acfeeafbefa65be344 release-0.3.37
f971949ffb585d400e0f15508a56232a0f897c80 release-0.3.38
18268abd340cb351e0c01b9c44e9f8cc05492364 release-0.3.39
e60fe4cf1d4ea3c34be8c49047c712c6d46c1727 release-0.3.40
715d243270806d38be776fc3ed826d97514a73d6 release-0.3.41
5e8fb59c18c19347a5607fb5af075fe1e2925b9a release-0.3.42
947c6fd27699e0199249ad592151f844c8a900b0 release-0.3.43
4946078f0a79e6cc952d3e410813aac9b8bda650 release-0.3.44
95d7da23ea5315a6e9255ce036ed2c51f091f180 release-0.3.45
1e720b0be7ecd92358da8a60944669fa493e78cd release-0.3.46
39b7d7b33c918d8f4abc86c4075052d8c19da3c7 release-0.3.47
7cbef16c71a1f43a07f8141f02e0135c775f0f5b release-0.3.48
4c8cd5ae5cc100add5c08c252d991b82b1838c6b release-0.3.49
400711951595aef7cd2ef865b84b31df52b15782 release-0.3.50
649c9063d0fda23620eaeaf0f6393be0a672ebe7 release-0.3.51
9079ee4735aefa98165bb2cb26dee4f58d58c1d7 release-0.3.52
6d5c1535bb9dcd891c5963971f767421a334a728 release-0.3.53
5fd7a5e990477189c40718c8c3e01002a2c20b81 release-0.3.54
63a820b0bc6ca629c8e45a069b52d622ddc27a2d release-0.3.55
562806624c4afb1687cba83bc1852f5d0fecbac3 release-0.3.56
cec32b3753acf610ac1a6227d14032c1a89d6319 release-0.3.57
b80f94fa2197b99db5e033fec92e0426d1fe5026 release-0.3.58
e924670896abe2769ea0fcfd2058b405bed8e8ec release-0.3.59
921a7ce4baf42fd1091b7e40f89c858c6b23053e release-0.3.60
df95dcff753a6dc5e94257302aea02c18c7a7c87 release-0.3.61
7e24168b0853ee7e46c9c7b943ef077dc64f17f5 release-0.4.0
8183d4ba50f8500465efb27e66dd23f98775dd21 release-0.4.1
610267a772c7bf911b499d37f66c21ce8f2ebaf7 release-0.4.2
39dd0b045441e21512e0a6061a03d0df63414d8b release-0.4.3
5e42c1615f4de0079bd4d8913886d588ce6a295d release-0.4.4
40266f92b829a870808b3d4ee54c8fccdecbd2d6 release-0.4.5
56e33c6efee7ff63cdc52bd1cf172bde195079df release-0.4.6
119bad43bfd493400c57a05848eada2c35a46810 release-0.4.7
0f404f82a1343cb4e4b277a44e3417385798e5e5 release-0.4.8
d24a717314365c857b9f283d6072c2a427d5e342 release-0.4.9
d6f0a00015fdef861fd67fb583b9690638650656 release-0.4.10
e372368dadd7b2ecd0182b2f1b11db86fc27b2c3 release-0.4.11
fd57967d850d2361072c72562d1ed03598473478 release-0.4.12
979045fdcbd20cf7188545c1c589ff240251f890 release-0.4.13
93c94cfa9f78f0a5740595dde4466ec4fba664f8 release-0.4.14
589ee12e8d7c2ae5e4f4676bcc7a1279a76f9e8e release-0.5.0
13416db8a807e5acb4021bc3c581203de57e2f50 release-0.5.1
06c58edc88831fb31c492a8eddcf2c6056567f18 release-0.5.2
e2ac5fa41bcba14adbbb722d45c083c30c07bb5c release-0.5.3
393dbc659df15ccd411680b5c1ce87ed86d4c144 release-0.5.4
38cc7bd8e04f2c519fd4526c12841a876be353cb release-0.5.5
6d1fcec2ea79101c756316c015f72e75f601a5ab release-0.5.6
aed8a9de62456c4b360358bc112ccca32ce02e8d release-0.5.7
7642f45af67d805452df2667486201c36efaff85 release-0.5.8
779216610662c3a459935d506f66a9b16b9c9576 release-0.5.9
9eeb585454f3daa30cf768e95c088a092fe229b9 release-0.5.10
bb491c8197e38ca10ae63b1f1ecb36bf6fdaf950 release-0.5.11
613369e08810f36bbcc9734ef1059a03ccbf5e16 release-0.5.12
bd796ef5c9c9dd34bfac20261b98685e0410122a release-0.5.13
8a730c49f906d783b47e4b44d735efd083936c64 release-0.5.14
cb447039152d85e9145139ff2575a6199b9af9d4 release-0.5.15
64854c7c95d04f838585ca08492823000503fa61 release-0.5.16
d1ffcf84ea1244f659145c36ff28de6fcdf528b2 release-0.5.17
796a6e30ca9d29504195c10210dbc8deced0ae83 release-0.5.18
1f81c711d2a039e1f93b9b515065a2235372d455 release-0.5.19
8e8f6082654aedb4438c8fca408cfc316c7c5a2a release-0.5.20
e9551132f7dd40da5719dd5bcf924c86f1436f85 release-0.5.21
533a252896c4d1cff1586ae42129d610f7497811 release-0.5.22
f461a49b6c747e0b67f721f2be172902afea5528 release-0.5.23
2d5ef73671f690b65bf6d9e22e7155f68f484d5a release-0.5.24
77bf42576050862c268e267ef3e508b145845a25 release-0.5.25
2aefee4d4ed69eb7567680bf27a2efd212232488 release-0.6.0
7ac0fe9bec9a2b5f8e191f6fdd6922bfd916a6cb release-0.6.1
4882735ebc71eeec0fbfe645bdfdb31306872d82 release-0.6.2
b94731c73d0922f472ff938b9d252ba29020f20c release-0.6.3
13e649b813d6ccba5db33a61e08ebe09d683cd5b release-0.6.4
80de622646b0059fd4c553eff47c391bf7503b89 release-0.6.5
3b05edb2619d5935023b979ee7a9611b61b6c9e5 release-0.6.6
1dcfd375100c4479611f71efb99271d0a3059215 release-0.6.7
0228185d4c5772947b842e856ad74cf7f7fd52f3 release-0.6.8
d1879c52326ecac45c713203670f54220879911e release-0.6.9
5a80c6ccbe2ad24fa3d4ff6f9fe4a2b07408d19d release-0.6.10
f88a8b0b39601b19cd740e4db614ab0b5b874686 release-0.6.11
5557460a7247a1602ae96efd1d0ccf781344cb58 release-0.6.12
451b02cc770a794cd41363461b446948ae1d8bc8 release-0.6.13
537b6ef014c4a133e0ab0b7dc817508e0647e315 release-0.6.14
5e68764f0d6e91a983170fa806e7450a9e9b33fe release-0.6.15
158aa4e8cc46fcf9504a61469d22daf3476b17bf release-0.6.16
d8fcca555542619228d9fab89e1665b993f8c3ee release-0.6.17
60707ebc037086cf004736a0d4979e2a608da033 release-0.6.18
3c2a99d3a71af846855be35e62edb9a12f363f44 release-0.6.19
3e0a27f9358ffc1b5249e0ea2311ce7da5c8967e release-0.6.20
143f4d65b1c875d6563ccb7f653d9157afc72194 release-0.6.21
95e6160d2b7d0af8ffd1b95a23cadadf8f0b3f6d release-0.6.22
69a03d5e3b6e6660079ef1ef172db7ac08d8370e release-0.6.23
3e2a58fb48f1e1a99ebf851e0d47a7034c52ae22 release-0.6.24
3b8607c05a8bebcfa59235c2126a70d737f0ccf5 release-0.6.25
07ad5b2606614c4be4ee720c46cf4af126059d31 release-0.6.26
be531addfabe5214f409d457140c1038af10d199 release-0.6.27
58f05255d3a345d04baef5cff0ca1ae0ac7ecebb release-0.6.28
eb2bd21dc8d03f6c94016f04ffb9adaf83a2b606 release-0.6.29
55408deb3cd171efa9b81d23d7a1dd1ccde0b839 release-0.6.30
d4288915bba73c4c3c9cf5d39d34e86879eb2b45 release-0.6.31
0a189588830b8629c4dfea68feb49af36b59e4a9 release-0.7.0
6ab27a06f3346cf9ec8737f5dbcc82dd4031e30f release-0.7.1
a07e258cef3b0a0b6e76a6ff4ba4651c5facc85a release-0.7.2
9992c4583513d2804fc2e7fec860fbc7ab043009 release-0.7.3
4dc24d50230fbadfc037a414a86390db2de69dd2 release-0.7.4
9527137b4354a648a229c7169850c7c65272c00d release-0.7.5
c2f0f7cf306f302254beae512bda18713922375c release-0.7.6
bbcf6d75556fdcee8bd4aba8f6c27014be9920ee release-0.7.7
43bde71f0bbe5a33b161760d7f9f980d50386597 release-0.7.8
769f0dd7081e9011394f264aa22aa66fd79730d8 release-0.7.9
511edfa732da637f5f0c9476335df7dca994706d release-0.7.10
0e7023bf6b2461309c29885935443449a41be807 release-0.7.11
9ad1bd2b21d93902863807528e426862aedee737 release-0.7.12
d90ea21e24ea35379aef50c5d70564158e110a15 release-0.7.13
c07d2d20d95c83d804079bbdcecbce4a0c8282f0 release-0.7.14
0cd7bb051f67eac2b179fb9f9cc988b9ba18ed76 release-0.7.15
eab2e87deba73ae6abd9cc740e8d4365bed96322 release-0.7.16
91d7a9eb8ade90e9421d7b1e3c2e47a6bc427876 release-0.7.17
fc10f7b5cb1305fb930f8ac40b46882d0828d61e release-0.7.18
9dba9779e37e5969a2d408c792084fd7acfec062 release-0.7.19
61838d1bcbddc7bc4dd9f30d535573a6fddca8f9 release-0.7.20
5f665d0fa6a5f6e748157f2ccbc445b2db8125d0 release-0.7.21
24763afa5efe91e54f00b2ae5b87666eb6c08c3b release-0.7.22
0562fb355a25266150cbe8c8d4e00f55e3654df3 release-0.7.23
19c452ecd083550816873a8a31eb3ed9879085e6 release-0.7.24
46b68faf271d6fdcaaf3ad2c69f6167ea9e9fa28 release-0.7.25
d04bfca0c7e3ae2e4422bc1d383553139d6f0a19 release-0.7.26
9425d9c7f8ead95b00a3929a9a5e487e0e3c8499 release-0.7.27
fbc3e7e8b3ee756568a875f87d8a954a2f9d3bf6 release-0.7.28
5176dfdf153fc785b18604197d58806f919829ad release-0.7.29
87e07ccdf0a4ec53458d9d7a4ea66e1239910968 release-0.7.30
9fddd7e1a7a27f8463867f41a461aad57df461b2 release-0.7.31
780b2ba1ec6daf6e3773774e26b05b9ff0d5483e release-0.7.32
83027471a25385b1c671968be761e9aa7a8591a7 release-0.7.33
1e9a362c3dcee221ca6e34308c483ed93867aca2 release-0.7.34
c7ee9e15717b54ead5f4a554686e74abe66c6b07 release-0.7.35
b84548abe9b9d4f4e203f848696e52c8c82c308f release-0.7.36
3286f0bab8e77dbc7ebb370b1dc379592ccff123 release-0.7.37
11a4e2ed5b166b9c9f119171aa399a9e3aa4684a release-0.7.38
f822655d4120629977794c32d3b969343b6c30db release-0.7.39
8a350e49d2b6751296db6d8e27277ccf63ed412a release-0.7.40
c4a56c197eeafd71fc1caef7a9d890a330e3c23d release-0.7.41
a9575a57a5443df39611774cf3840e9088132b0e release-0.7.42
7503d95d6eadad14c28b2db183ba09848265274b release-0.7.43
9be652e9114435fc6f1fdec84c0458d56702db91 release-0.7.44
797e070d480a34b31ddac0d364784773f1bbbcf9 release-0.7.45
9b5037e7ec7db25875c40f9d1cf20a853388b124 release-0.7.46
d1d0e6d7ff0ca3c0dd1be1ef1cfff2e3fd0b4e1c release-0.7.47
9816fb28eda599bfd53940e6d3b6617d1ecb6323 release-0.7.48
452b9d09df8e3f2fb04b2a33d04d2f3a6436eb34 release-0.7.49
e4350efa7cf7a0e868c2236a1137de8a33bd8ec6 release-0.7.50
f51f2bec766c8b6d7e1799d904f18f8ea631bd44 release-0.7.51
18e39e566781c9c187e2eb62bebd9d669d68f08c release-0.7.52
b073eaa1dcea296a3488b83d455fab6621a73932 release-0.7.53
01c6fe6c2a55998434cd3b05dd10ca487ac3fb6c release-0.7.54
3ed9377e686f2521e6ec15873084381033fb490d release-0.7.55
a1e44954549c35023b409f728c678be8bf898148 release-0.7.56
fbb1918a85e38a7becdb1a001dbaf5933f23a919 release-0.7.57
87f4a49a9cc34a5b11c8784cc5ea89e97b4b2bd8 release-0.7.58
0c22cb4862c8beb4ee1b9e4627125162a29a5304 release-0.7.59
82d56c2425ef857cd430b8530a3f9e1127145a67 release-0.8.0
f4acb784b53cd952559567971b97dde1e818a2b6 release-0.8.1
b3503597c1a0f0f378afdc5e5e5b85e2c095a4be release-0.8.2
c98da980514a02ba81c421b25bf91803ffffddf3 release-0.8.3
db34ec0c53c4b9dec12ffdf70caf89a325ab9577 release-0.8.4
0914802433b8678ba2cdf91280766f00f4b9b76e release-0.8.5
ff52ee9e6422f3759f43a442b7ba615595b3a3d4 release-0.8.6
7607237b4829fff1f60999f4663c50ed9d5182f7 release-0.8.7
1cef1807bc12cb05ac52fb0e7a0f111d3760b569 release-0.8.8
a40f8475511d74a468ade29c1505e8986600d7a3 release-0.8.9
2d9faf2260df6c3e5d4aa1781493c31f27a557d0 release-0.8.10
d0d61c32331a6505381b5218318f7b69db167ca8 release-0.8.11
ca7a1c6c798a7eb5b294d4ac3179ec87ecf297d3 release-0.8.12
81c8277cd8ed55febcb2dd9d9213076f6c0ccb09 release-0.8.13
3089486a8dc5844b5b6e9f78d536b4b26f7ffa16 release-0.8.14
d364c2c12dd9723a2dfac3f096f5e55d4cfe6838 release-0.8.15
52163a1027c3efd6b4c461b60a2ca6266c23e193 release-0.8.16
06564e9a2d9ec5852132c212e85eda0bf1300307 release-0.8.17
7aaa959da85e09e29bcac3b1cadec35b0a25b64d release-0.8.18
4bc73c644329a510da4e96b7241b80ead7772f83 release-0.8.19
ea3d168fb99c32a5c3545717ecc61e85a375e5dd release-0.8.20
27951ca037e63dae45ff5b6279124c224ae1255a release-0.8.21
d56c8b5df517c2bf6e7bc2827b8bf3e08cda90e1 release-0.8.22
3c6ac062b379b126212cbb27e98a3c8275ef381a release-0.8.23
89b9173476de14688b1418fbf7df10f91d1719ef release-0.8.24
aa550cb4159ae0d566006e091fb1c7a888771050 release-0.8.25
06ce92293f6a65651b08c466f90f55bd69984b98 release-0.8.26
ea50b0d79ef1d7d901cd0e4dcd7373447849d719 release-0.8.27
e68b1c35cad86105ff1c5b240f53442f4c36356e release-0.8.28
78d3582a30afe63fc0adb17c3ac8891a64e47146 release-0.8.29
9852c5965a3292a1b6127dbb4da9fce4912d898a release-0.8.30
4f84115914490e572bcbee5069157b7334df2744 release-0.8.31
59dee6f7f3afeb1fad6ed5983756e48c81ad2a5c release-0.8.32
a4456378d234c07038456cf32bfe3c651f1d5e82 release-0.8.33
21cb50799a20575a42f9733342d37a426f79db4d release-0.8.34
7cb3cb8d78ef7ae63561733ed91fd07933896bc8 release-0.8.35
aed68639d4eb6afe944b7fb50499c16f7f3f503c release-0.8.36
265b7fd2ae21c75bbffa5115b83a0123d6c4acb4 release-0.8.37
fa5f1ca353c0c5aa5415f51d72fd7bbcc02d1ed7 release-0.8.38
af10bf9d4c6532850aa1f70cdf7504bd109b284c release-0.8.39
4846ec9f83cb5bc4c8519d5641b35fb9b190430c release-0.8.40
718b4cb3faf7efe4e0648140f064bf7a92c3f7e8 release-0.8.41
b5a3065749093282ddd19845e0b77ffc2e54333e release-0.8.42
34df9fb22fed415cdad52def04095dc6d4b48222 release-0.8.43
00ec8cd76fb89af27363b76c40d9f88bf4679c3b release-0.8.44
e16dd52a0d226c23dcae9a11252564a04753bbed release-0.8.45
f034d9173df0a433e0bbcf5974f12ea9eb9076c0 release-0.8.46
4434dc967087315efcd0658206a67fe6c85528f3 release-0.8.47
0b65c962e0cd6783a854877b52c903cb058eec8c release-0.8.48
a2b7e94b9807e981866bf07e37b715847d1b7120 release-0.8.49
e7bdb8edc1bab2bc352a9fb6ce765c46575c35bf release-0.8.50
21dacebd12f65cb57ceb8d2688db5b07fad6e06d release-0.8.51
67dd7533b99c8945b5b8b5b393504d4e003a1c50 release-0.8.52
010468d890dbac33a4cae6dfb2017db70721b2fe release-0.8.53
62b599022a2fa625b526c2ad1711dc6db7d66786 release-0.9.0
71281dd73b17a0ead5535d531afaee098da723cb release-0.9.1
16cff36b0e49fc9fdeee13b2e92690286bcc1b3d release-0.9.2
b7b306325972661117694879d3e22faf4cf0df32 release-0.9.3
fe671505a8ea86a76f0358b3ec4de84a9037ac2b release-0.9.4
70542931bc5436d1bbd38f152245d93ac063968d release-0.9.5
27e2f3b7a3db1819c5d0ba28327ceaba84a13c4e release-0.9.6
657d05d63915ce2f6c4d763091059f5f85bb10e5 release-0.9.7
e0fd9f36005923b8f98d1ba1ea583cb7625f318f release-1.0.0
f8f89eb4e0c27e857ec517d893d4f9a454985084 release-1.0.1
c50df367648e53d55e80b60a447c9c66caa0d326 release-1.0.2
80d586db316512b5a9d39f00fe185f7f91523f52 release-1.0.3
c9c2805ac9245cc48ce6efeba2b4a444f859d6aa release-1.0.4
fa2c37b1122c2c983b6e91d1188e387d72dde4d6 release-1.0.5
f31aea5b06654c9163be5acd6d9b7aaf0fdf6b33 release-1.1.0
44bf95f670656fae01ccb266b3863843ea13d324 release-1.1.1
da1289482a143dfa016769649bdff636c26f53c8 release-1.1.2
bac8ba08a6570bac2ecd3bf2ad64b0ac3030c903 release-1.1.3
911060bc8221d4113a693ae97952a1fa88663ca8 release-1.1.4
e47531dfabbf8e5f8b8aff9ff353642ea4aa7abb release-1.1.5
f9ddecfe331462f870a95e4c1c3ba1bb8f19f2d3 release-1.1.6
378c297bb7459fb99aa9c77decac0d35391a3932 release-1.1.7
71600ce67510af093d4bc0117a78b3b4678c6b3a release-1.1.8
482d7d907f1ab92b78084d8b8631ed0eb7dd08f7 release-1.1.9
c7e65deabf0db5109e8d8f6cf64cd3fb7633a3d1 release-1.1.10
9590f0cf5aab8e6e0b0c8ae59c70187b2b97d886 release-1.1.11
ade8fc136430cfc04a8d0885c757968b0987d56c release-1.1.12
6a6836e65827fd3cb10a406e7bbbe36e0dad8736 release-1.1.13
6845f4ac909233f5a08ed8a51de137713a888328 release-1.1.14
2397e9c72f1bc5eac67006e12ad3e33e0ea9ba74 release-1.1.15
7b7c49639a7bceecabf4963c60b26b65a77d6ce0 release-1.1.16
f7e1113a9a1648cad122543e7080e895cf2d88f4 release-1.1.17
2b22743c3079b41233ded0fc35af8aa89bcfab91 release-1.1.18
0f0b425659e0b26f5bc8ea14a42dbf34de2eaba6 release-1.1.19
f582d662cc408eb7a132c21f4b298b71d0701abb release-1.2.0
9ee68d629722f583d43d92271f2eb84281afc630 release-1.3.0
61b6a3438afef630774e568eefd89c53e3b93287 release-1.3.1
7ccd50a0a455f2f2d3b241f376e1193ad956196d release-1.2.1
0000000000000000000000000000000000000000 release-1.2.1
50107e2d96bbfc2c59e46f889b1a5f68dd10cf19 release-1.3.2
2c5e1e88c8cf710caf551c5c67eba00443601efe release-1.3.3
a43447fb82aa03eabcd85352758ae14606a84d35 release-1.3.4
90f3b4ea7992a7bf9385851a3e77173363091eea release-1.3.5
3aeb14f88daeb973e4708310daa3dc68ac1200f7 release-1.3.6
dafd375f1c882b15fa4a9b7aa7c801c55082395e release-1.3.7
ab7ce0eb4cf78a656750ab1d8e55ef61f7e535ec release-1.3.8
1b1a9337a7399ad3cdc5e3a2f9fbaaec990271d5 release-1.3.9
2c053b2572694eb9cd4aed26a498b6cb1f51bbcc release-1.3.10
36409ac209872ce53019f084e4e07467c5d9d25e release-1.3.11
560dc55e90c13860a79d8f3e0d67a81c7b0257bb release-1.3.12
dc195ffe0965b2b9072f8e213fe74ecce38f6773 release-1.3.13
e04428778567dd4de329bbbe97ad653e22801612 release-1.3.14
cd84e467c72967b9f5fb4d96bfc708c93edeb634 release-1.3.15
23159600bdea695db8f9d2890aaf73424303e49c release-1.3.16
7809529022b83157067e7d1e2fb65d57db5f4d99 release-1.4.0
48a84bc3ff074a65a63e353b9796ff2b14239699 release-1.5.0
99eed1a88fc33f32d66e2ec913874dfef3e12fcc release-1.5.1
5bdca4812974011731e5719a6c398b54f14a6d61 release-1.5.2
644a079526295aca11c52c46cb81e3754e6ad4ad release-1.5.3
376a5e7694004048a9d073e4feb81bb54ee3ba91 release-1.5.4
60e0409b9ec7ee194c6d8102f0656598cc4a6cfe release-1.5.5
70c5cd3a61cb476c2afb3a61826e59c7cda0b7a7 release-1.5.6
9ba2542d75bf62a3972278c63561fc2ef5ec573a release-1.5.7
eaa76f24975948b0ce8be01838d949122d44ed67 release-1.5.8
5a1759f33b7fa6270e1617c08d7e655b7b127f26 release-1.5.9
b798fc020e3a84ef68e6c9f47865a319c826d33c release-1.5.10
f995a10d4c7e9a817157a6ce7b753297ad32897e release-1.5.11
97b47d95e4449cbde976657cf8cbbc118351ffe0 release-1.5.12
fd722b890eabc600394349730a093f50dac31639 release-1.5.13
d161d68df8be32e5cbf72b07db1a707714827803 release-1.7.0
0351a6d89c3dbcc7a76295024ba6b70e27b9a497 release-1.7.1
0bd223a546192fdf2e862f33938f4ec2a3b5b283 release-1.7.2
fe7cd01828d5ca7491059f0690bb4453645eb28b release-1.7.3
cbb146b120296852e781079d5138b04495bab6df release-1.7.4
fe129aa02db9001d220f1db7c3c056f79482c111 release-1.7.5
a8d111bb68847f61d682a3c8792fecb2e52efa2c release-1.7.6
6d2fbc30f8a7f70136cf08f32d5ff3179d524873 release-1.7.7
d5ea659b8bab2d6402a2266efa691f705e84001e release-1.7.8
34b201c1abd1e2d4faeae4650a21574771a03c0e release-1.7.9
860cfbcc4606ee36d898a9cd0c5ae8858db984d6 release-1.7.10
2b3b737b5456c05cd63d3d834f4fb4d3776953d0 release-1.7.11
3ef00a71f56420a9c3e9cec311c9a2109a015d67 release-1.7.12
53d850fe292f157d2fb999c52788ec1dc53c91ed release-1.9.0
884a967c369f73ab16ea859670d690fb094d3850 release-1.9.1
3a32d6e7404a79a0973bcd8d0b83181c5bf66074 release-1.9.2
e27a215601292872f545a733859e06d01af1017d release-1.9.3
5cb7e2eed2031e32d2e5422caf9402758c38a6ad release-1.9.4
942475e10cb47654205ede7ccbe7d568698e665b release-1.9.5
b78018cfaa2f0ec20494fccb16252daa87c48a31 release-1.9.6
54117529e40b988590ea2d38aae909b0b191663f release-1.9.7
1bdc497c81607d854e3edf8b9a3be324c3d136b6 release-1.9.8
ef107f3ddc237a3007e2769ec04adde0dcf627fa release-1.9.9
be00ca08e41a69e585b6aff70a725ed6c9e1a876 release-1.9.10
fe66cff450a95beed36a2515210eb2d7ef62c9d3 release-1.9.11
ead3907d74f90a14d1646f1b2b56ba01d3d11702 release-1.9.12
5936b7ed929237f1a73b467f662611cdc0309e51 release-1.9.13
4106db71cbcb9c8274700199ac17e520902c6c0f release-1.9.14
13070ecfda67397985f0e986eb9c42ecb46d05b5 release-1.9.15
271ee30c6791847980cd139d31807541f5e569bf release-1.11.0
cb783d9cc19761e14e1285d91c38f4b84d0b8756 release-1.11.1
4d3b3a13a8cf5fc3351a7f167d1c13325e00f21c release-1.11.2
b83a067949a3384a49fd3d943eb8d0997b31f87b release-1.11.3
953512ca02c6f63b4fcbbc3e10d0d9835896bf99 release-1.11.4
5253015a339aaca0a3111473d3e931b6d4752393 release-1.11.5
5e371426b3bcba4312ce08606194b89b758927d1 release-1.11.6
5c8f60faf33ca8926473d2da27b4c3c417bd4630 release-1.11.7
4591da489a30f790def29bc5987f43409b503cae release-1.11.8
20a45c768e5ed26b740679d0e22045c98727c3cc release-1.11.9
1ad0999a7ded3d4fb01c7acf8ff57c80b643da7e release-1.11.10
d8b321a876d6254e9e98795e3b194ef053290354 release-1.11.11
7f394e433f0003222aa6531931ecc0b24740d5e4 release-1.11.12
3d0e8655f897959e48cc74e87670bb5492a58871 release-1.11.13
3671096a45bce570a2afa20b9faf42c7fb0f7e66 release-1.13.0
539f7893ecb96bee60965528c8958d7eb2f1ce6b release-1.13.1
5be2b25bdc65775a85f18f68a4be4f58c7384415 release-1.13.2
8457ce87640f9bfe6221c4ac4466ced20e03bebe release-1.13.3
bbc642c813c829963ce8197c0ca237ab7601f3d4 release-1.13.4
0d45b4cf7c2e4e626a5a16e1fe604402ace1cea5 release-1.13.5
f87da7d9ca02b8ced4caa6c5eb9013ccd47b0117 release-1.13.6
47cca243d0ed39bf5dcb9859184affc958b79b6f release-1.13.7
20ca4bcff108d3e66977f4d97508637093492287 release-1.13.8
fb1212c7eca4c5328fe17d6cd95b010c67336aac release-1.13.9
31c929e16910c38492581ef474e72fa67c28f124 release-1.13.10
64179f242cb55fc206bca59de9bfdc4cf5ebcec7 release-1.13.11
051e5fa03b92b8a564f6b12debd483d267391e82 release-1.13.12
990b3e885636d763b97ed02d0d2cfc161a4e0c09 release-1.15.0
4189160cb946bb38d0bc0a452b5eb4cdd8979fb5 release-1.15.1
b234199c7ed8a156a6bb98f7ff58302c857c954f release-1.15.2
28b3e17ca7eba1e6a0891afde0e4bc5bcc99c861 release-1.15.3
49d49835653857daa418e68d6cbfed4958c78fca release-1.15.4
f062e43d74fc2578bb100a9e82a953efa1eb9e4e release-1.15.5
2351853ce6867b6166823bdf94333c0a76633c0a release-1.15.6
051a039ce1c7e09144de4a4846669ec7116cecea release-1.15.7
ee551e3f6dba336c0d875e266d7d55385f379b42 release-1.15.8
d2fd76709909767fc727a5b4affcf1dc9ca488a7 release-1.15.9
75f5c7f628411c79c7044102049f7ab4f7a246e7 release-1.15.10
5155d0296a5ef9841f035920527ffdb771076b44 release-1.15.11
0130ca3d58437b3c7c707cdddd813d530c68da9a release-1.15.12
054c1c46395caff79bb4caf16f40b331f71bb6dd release-1.17.0
7816bd7dabf6ee86c53c073b90a7143161546e06 release-1.17.1
2fc9f853a6b7cd29dc84e0af2ed3cf78e0da6ca8 release-1.17.2
ed4303aa1b31a9aad5440640c0840d9d0af45fed release-1.17.3
ce2ced3856909f36f8130c99eaa4dbdbae636ddc release-1.17.4
9af0dddbddb2c368bfedd2801bc100ffad01e19b release-1.17.5
de68d0d94320cbf033599c6f3ca37e5335c67fd7 release-1.17.6
e56295fe0ea76bf53b06bffa77a2d3a9a335cb8c release-1.17.7
fdacd273711ddf20f778c1fb91529ab53979a454 release-1.17.8
5e8d52bca714d4b85284ddb649d1ba4a3ca978a8 release-1.17.9
c44970de01474f6f3e01b0adea85ec1d03e3a5f2 release-1.17.10
cbe6ba650211541310618849168631ce0b788f35 release-1.19.0
062920e2f3bf871ef7a3d8496edec1b3065faf80 release-1.19.1
a7b46539f507e6c64efa0efda69ad60b6f4ffbce release-1.19.2
3cbc2602325f0ac08917a4397d76f5155c34b7b1 release-1.19.3
dc0cc425fa63a80315f6efb68697cadb6626cdf2 release-1.19.4
8e5b068f761cd512d10c9671fbde0b568c1fd08b release-1.19.5
f618488eb769e0ed74ef0d93cd118d2ad79ef94d release-1.19.6
3fa6e2095a7a51acc630517e1c27a7b7ac41f7b3 release-1.19.7
8c65d21464aaa5923775f80c32474adc7a320068 release-1.19.8
da571b8eaf8f30f36c43b3c9b25e01e31f47149c release-1.19.9
ffcbb9980ee2bad27b4d7b1cd680b14ff47b29aa release-1.19.10
df34dcc9ac072ffd0945e5a1f3eb7987e8275375 release-1.21.0
a68ac0677f8553b1f84d357bc9da114731ab5f47 release-1.21.1
bfbc52374adcbf2f9060afd62de940f6fab3bba5 release-1.21.2
2217a9c1d0b86026f22700b3c089545db1964f55 release-1.21.3
39be8a682c58308d9399cddd57e37f9fdb7bdf3e release-1.21.4
d986378168fd4d70e0121cabac274c560cca9bdf release-1.21.5
714eb4b2c09e712fb2572a2164ce2bf67638ccac release-1.21.6
5da2c0902e8e2aa4534008a582a60c61c135960e release-1.23.0
a63d0a70afea96813ba6667997bc7d68b5863f0d release-1.23.1
aa901551a7ebad1e8b0f8c11cb44e3424ba29707 release-1.23.2
ff3afd1ce6a6b65057741df442adfaa71a0e2588 release-1.23.3
ac779115ed6ee4f3039e9aea414a54e560450ee2 release-1.23.4
12dcf92b0c2c68552398f19644ce3104459807d7 release-1.25.0
f8134640e8615448205785cf00b0bc810489b495 release-1.25.1
1d839f05409d1a50d0f15a2bf36547001f99ae40 release-1.25.2
294a3d07234f8f65d7b0e0b0e2c5b05c12c5da0a release-1.25.3
173a0a7dbce569adbb70257c6ec4f0f6bc585009 release-1.25.4
8618e4d900cc71082fbe7dc72af087937d64faf5 release-1.25.5
a58202a8c41bf0bd97eef1b946e13105a105520d release-1.26.0
a63c124e34bcf2d1d1feb8d40ff075103b967c4c release-1.26.1
e4c5da06073ca24e2ffc5c8f8b8d7833a926356f release-1.26.2
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.
*/
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
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();
};
}
/** 基础版能耗计费:费率/日用量存 PostgreSQL,统计与报表由库内数据计算 */
import { query } from '../db/pool.js';
import { isPgEnabled } from '../db/state.js';
/** 无 PG 时的内存费率与日用量 */
let memTariff = null;
const memUsage = new Map();
const defaultTariff = {
powerPeak: 1.08,
powerFlat: 0.72,
powerValley: 0.38,
water: 4.5,
cooling: 0.55,
serviceFeePct: 3,
taxPct: 6,
settleDay: 1,
enabled: true,
updatedAt: new Date().toISOString(),
updatedBy: 'system',
};
function pad(n) {
return n < 10 ? `0${n}` : `${n}`;
}
function dateStr(d) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** pg DATE → YYYY-MM-DD(UTC 午夜,避免时区偏移) */
function pgDate(d) {
if (typeof d === 'string')
return d.slice(0, 10);
return d.toISOString().slice(0, 10);
}
function daySeed(date) {
let h = 0;
for (let i = 0; i < date.length; i++)
h = (h * 31 + date.charCodeAt(i)) >>> 0;
return h;
}
function rand(seed, i, min, max) {
const x = Math.sin(seed * 0.001 + i * 12.9898) * 43758.5453;
const f = x - Math.floor(x);
return min + f * (max - min);
}
function mockUsageForDate(date) {
const seed = daySeed(date);
const dow = new Date(date + 'T12:00:00').getDay();
const weekend = dow === 0 || dow === 6;
const factor = weekend ? 0.72 : 1;
const powerTotal = rand(seed, 1, 380, 560) * factor;
return {
date,
powerPeakKwh: +(powerTotal * 0.28).toFixed(1),
powerFlatKwh: +(powerTotal * 0.45).toFixed(1),
powerValleyKwh: +(powerTotal * 0.27).toFixed(1),
waterM3: +rand(seed, 2, 8, 18 * factor).toFixed(1),
coolingKwh: +rand(seed, 3, 900, 1500 * factor).toFixed(0),
};
}
function rangeDates(from, to) {
const out = [];
const cur = new Date(from + 'T12:00:00');
const end = new Date(to + 'T12:00:00');
while (cur <= end) {
out.push(dateStr(cur));
cur.setDate(cur.getDate() + 1);
}
return out;
}
export function calcBill(usage, t) {
const powerCost = usage.powerPeakKwh * t.powerPeak +
usage.powerFlatKwh * t.powerFlat +
usage.powerValleyKwh * t.powerValley;
const waterCost = usage.waterM3 * t.water;
const coolingCost = usage.coolingKwh * t.cooling;
const subtotal = powerCost + waterCost + coolingCost;
const serviceFee = subtotal * (t.serviceFeePct / 100);
const tax = (subtotal + serviceFee) * (t.taxPct / 100);
const total = subtotal + serviceFee + tax;
return {
...usage,
powerCost: +powerCost.toFixed(2),
waterCost: +waterCost.toFixed(2),
coolingCost: +coolingCost.toFixed(2),
subtotal: +subtotal.toFixed(2),
serviceFee: +serviceFee.toFixed(2),
tax: +tax.toFixed(2),
total: +total.toFixed(2),
};
}
export async function ensureBillingSeed() {
if (!isPgEnabled()) {
memTariff = { ...defaultTariff };
const today = dateStr(new Date());
const start = new Date();
start.setDate(start.getDate() - 400);
for (const d of rangeDates(dateStr(start), today)) {
memUsage.set(d, mockUsageForDate(d));
}
return;
}
const { rows } = await query(`SELECT id FROM billing_tariff WHERE id = 1`);
if (!rows.length) {
const t = defaultTariff;
await query(`INSERT INTO billing_tariff (
id, power_peak, power_flat, power_valley, water, cooling,
service_fee_pct, tax_pct, settle_day, enabled, updated_by
) VALUES (1,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, [
t.powerPeak,
t.powerFlat,
t.powerValley,
t.water,
t.cooling,
t.serviceFeePct,
t.taxPct,
t.settleDay,
t.enabled,
t.updatedBy,
]);
}
// 预置近 400 天 Mock 日用量(仅补缺失日期,不覆盖已有实采)
const { rows: cnt } = await query(`SELECT COUNT(*)::text AS n FROM billing_daily_usage`);
if (Number(cnt[0]?.n || 0) > 30)
return;
const today = dateStr(new Date());
const start = new Date();
start.setDate(start.getDate() - 400);
const dates = rangeDates(dateStr(start), today);
const chunk = 50;
for (let i = 0; i < dates.length; i += chunk) {
const slice = dates.slice(i, i + chunk);
const values = [];
const places = slice
.map((d, idx) => {
const u = mockUsageForDate(d);
const o = idx * 6;
values.push(u.date, u.powerPeakKwh, u.powerFlatKwh, u.powerValleyKwh, u.waterM3, u.coolingKwh);
return `($${o + 1},$${o + 2},$${o + 3},$${o + 4},$${o + 5},$${o + 6})`;
})
.join(',');
await query(`INSERT INTO billing_daily_usage (
usage_date, power_peak_kwh, power_flat_kwh, power_valley_kwh, water_m3, cooling_kwh
) VALUES ${places}
ON CONFLICT (usage_date) DO NOTHING`, values);
}
}
export async function getTariff() {
if (!isPgEnabled())
return { ...(memTariff || defaultTariff) };
const { rows } = await query(`SELECT * FROM billing_tariff WHERE id = 1`);
const r = rows[0];
if (!r)
return { ...defaultTariff };
return {
powerPeak: Number(r.power_peak),
powerFlat: Number(r.power_flat),
powerValley: Number(r.power_valley),
water: Number(r.water),
cooling: Number(r.cooling),
serviceFeePct: Number(r.service_fee_pct),
taxPct: Number(r.tax_pct),
settleDay: r.settle_day,
enabled: r.enabled,
updatedAt: r.updated_at.toISOString(),
updatedBy: r.updated_by,
};
}
export async function updateTariff(patch, by) {
const cur = await getTariff();
const next = {
...cur,
...patch,
updatedBy: by,
updatedAt: new Date().toISOString(),
};
if (!isPgEnabled()) {
memTariff = next;
return { ...next };
}
await query(`UPDATE billing_tariff SET
power_peak=$1, power_flat=$2, power_valley=$3, water=$4, cooling=$5,
service_fee_pct=$6, tax_pct=$7, settle_day=$8, enabled=$9,
updated_at=NOW(), updated_by=$10
WHERE id=1`, [
next.powerPeak,
next.powerFlat,
next.powerValley,
next.water,
next.cooling,
next.serviceFeePct,
next.taxPct,
next.settleDay,
next.enabled,
by,
]);
return getTariff();
}
export async function getUsageHistory(from, to) {
if (!isPgEnabled()) {
return rangeDates(from, to).map((d) => {
let u = memUsage.get(d);
if (!u) {
u = mockUsageForDate(d);
memUsage.set(d, u);
}
return u;
});
}
const { rows } = await query(`SELECT * FROM billing_daily_usage
WHERE usage_date BETWEEN $1::date AND $2::date
ORDER BY usage_date ASC`, [from, to]);
const map = new Map(rows.map((r) => [
pgDate(r.usage_date),
{
date: pgDate(r.usage_date),
powerPeakKwh: Number(r.power_peak_kwh),
powerFlatKwh: Number(r.power_flat_kwh),
powerValleyKwh: Number(r.power_valley_kwh),
waterM3: Number(r.water_m3),
coolingKwh: Number(r.cooling_kwh),
},
]));
// 缺口用稳定 Mock 补齐并写库
const out = [];
for (const d of rangeDates(from, to)) {
let u = map.get(d);
if (!u) {
u = mockUsageForDate(d);
await query(`INSERT INTO billing_daily_usage (
usage_date, power_peak_kwh, power_flat_kwh, power_valley_kwh, water_m3, cooling_kwh
) VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (usage_date) DO NOTHING`, [u.date, u.powerPeakKwh, u.powerFlatKwh, u.powerValleyKwh, u.waterM3, u.coolingKwh]);
}
out.push(u);
}
return out;
}
export async function getReport(from, to) {
const t = await getTariff();
const usage = await getUsageHistory(from, to);
return usage.map((u) => calcBill(u, t));
}
export async function getStats() {
const t = await getTariff();
const today = dateStr(new Date());
const todayUsage = (await getUsageHistory(today, today))[0] || mockUsageForDate(today);
const todayBill = calcBill(todayUsage, t);
const monthStart = `${today.slice(0, 7)}-01`;
const monthBills = await getReport(monthStart, today);
const month = {
days: monthBills.length,
powerKwh: +monthBills
.reduce((s, b) => s + b.powerPeakKwh + b.powerFlatKwh + b.powerValleyKwh, 0)
.toFixed(1),
waterM3: +monthBills.reduce((s, b) => s + b.waterM3, 0).toFixed(1),
coolingKwh: +monthBills.reduce((s, b) => s + b.coolingKwh, 0).toFixed(0),
powerCost: +monthBills.reduce((s, b) => s + b.powerCost, 0).toFixed(2),
waterCost: +monthBills.reduce((s, b) => s + b.waterCost, 0).toFixed(2),
coolingCost: +monthBills.reduce((s, b) => s + b.coolingCost, 0).toFixed(2),
total: +monthBills.reduce((s, b) => s + b.total, 0).toFixed(2),
};
const kindRaw = [
{ kind: 'power', label: '电费', usage: month.powerKwh, unit: 'kWh', cost: month.powerCost },
{ kind: 'water', label: '水费', usage: month.waterM3, unit: 'm³', cost: month.waterCost },
{ kind: 'cooling', label: '冷量费', usage: month.coolingKwh, unit: 'kWh', cost: month.coolingCost },
];
const costSum = kindRaw.reduce((s, k) => s + k.cost, 0) || 1;
const byKind = kindRaw.map((k) => ({ ...k, pct: +((k.cost / costSum) * 100).toFixed(1) }));
const d7 = new Date();
d7.setDate(d7.getDate() - 6);
const trend7d = (await getReport(dateStr(d7), today)).map((b) => ({
date: b.date.slice(5),
total: b.total,
powerCost: b.powerCost,
waterCost: b.waterCost,
coolingCost: b.coolingCost,
}));
const trend12m = [];
const cursor = new Date();
cursor.setDate(1);
for (let i = 11; i >= 0; i--) {
const mDate = new Date(cursor.getFullYear(), cursor.getMonth() - i, 1);
const y = mDate.getFullYear();
const m = mDate.getMonth() + 1;
const ym = `${y}-${pad(m)}`;
const from = `${ym}-01`;
const lastDay = new Date(y, m, 0).getDate();
let to = `${ym}-${pad(lastDay)}`;
if (to > today)
to = today;
if (from > today)
continue;
const bills = await getReport(from, to);
trend12m.push({
month: ym,
label: `${m}月`,
total: +bills.reduce((s, b) => s + b.total, 0).toFixed(2),
powerCost: +bills.reduce((s, b) => s + b.powerCost, 0).toFixed(2),
waterCost: +bills.reduce((s, b) => s + b.waterCost, 0).toFixed(2),
coolingCost: +bills.reduce((s, b) => s + b.coolingCost, 0).toFixed(2),
});
}
const d30 = new Date();
d30.setDate(d30.getDate() - 29);
const usage30d = (await getUsageHistory(dateStr(d30), today)).map((u) => ({
date: u.date,
label: u.date.slice(5),
powerKwh: +(u.powerPeakKwh + u.powerFlatKwh + u.powerValleyKwh).toFixed(1),
waterM3: u.waterM3,
coolingKwh: u.coolingKwh,
}));
return { today: todayBill, month, byKind, trend7d, trend12m, usage30d };
}
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const config = {
port: Number(process.env.PORT || 3001),
jwtSecret: process.env.JWT_SECRET || 'jh-lab-dev-secret',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '12h',
dataMode: (process.env.DATA_MODE || 'mock'),
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;
}
import { config } from '../config.js';
import { pool, query } from './pool.js';
import { isPgEnabled } from './state.js';
/** 窄表时序:一设备一指标一行,便于按 series 范围扫描 */
export const TS_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS ts_samples (
ts TIMESTAMPTZ NOT NULL,
series_id TEXT NOT NULL,
device_id TEXT NOT NULL,
source TEXT NOT NULL,
metric TEXT NOT NULL,
eng DOUBLE PRECISION NOT NULL,
raw DOUBLE PRECISION
);
CREATE INDEX IF NOT EXISTS idx_ts_samples_series_ts ON ts_samples (series_id, ts DESC);
CREATE INDEX IF NOT EXISTS idx_ts_samples_source_metric_ts ON ts_samples (source, metric, ts DESC);
CREATE INDEX IF NOT EXISTS idx_ts_samples_ts ON ts_samples (ts DESC);
CREATE TABLE IF NOT EXISTS ts_rollups (
bucket TIMESTAMPTZ NOT NULL,
bucket_minutes INT NOT NULL,
series_id TEXT NOT NULL,
device_id TEXT NOT NULL,
source TEXT NOT NULL,
metric TEXT NOT NULL,
avg_eng DOUBLE PRECISION NOT NULL,
min_eng DOUBLE PRECISION NOT NULL,
max_eng DOUBLE PRECISION NOT NULL,
n INT NOT NULL,
PRIMARY KEY (series_id, bucket_minutes, bucket)
);
CREATE INDEX IF NOT EXISTS idx_ts_rollups_source_metric_bucket
ON ts_rollups (source, metric, bucket_minutes, bucket DESC);
CREATE INDEX IF NOT EXISTS idx_ts_rollups_device_metric_bucket
ON ts_rollups (device_id, metric, bucket_minutes, bucket DESC);
`;
let timescaleTried = false;
let timescaleOk = false;
/** 可选:若镜像带 TimescaleDB 则转成超表,压缩与保留策略更省 */
export async function ensureTimeseriesSchema() {
if (!isPgEnabled())
return;
await query(TS_SCHEMA_SQL);
if (!timescaleTried) {
timescaleTried = true;
try {
const { rows } = await query(`SELECT name FROM pg_available_extensions WHERE name = 'timescaledb'`);
if (!rows.length) {
console.log('[db] 时序表已就绪(原生 PG + 15min 预聚合;未安装 TimescaleDB)');
}
else {
await query(`CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE`);
await query(`SELECT create_hypertable('ts_samples', 'ts', if_not_exists => TRUE, migrate_data => TRUE)`);
timescaleOk = true;
console.log('[db] TimescaleDB hypertable ready: ts_samples');
try {
await query(`SELECT add_retention_policy('ts_samples', INTERVAL '${Math.max(1, config.collectRetentionDays)} days', if_not_exists => TRUE)`);
}
catch {
/* 社区版/权限不足时忽略 */
}
}
}
catch (e) {
timescaleOk = false;
console.warn('[db] timeseries extension probe failed', e instanceof Error ? e.message : e);
}
}
}
export function seriesId(source, deviceId, metric) {
return `${source}:${deviceId}:${metric}`;
}
const lastWriteMs = new Map();
function minRawIntervalMs() {
return Number(process.env.TS_MIN_INTERVAL_MS || 1000);
}
function allowWrite(series, tsMs) {
const prev = lastWriteMs.get(series) || 0;
if (tsMs - prev < minRawIntervalMs())
return false;
lastWriteMs.set(series, tsMs);
return true;
}
function bucketStart(ts, bucketMinutes) {
const ms = bucketMinutes * 60_000;
return new Date(Math.floor(ts.getTime() / ms) * ms);
}
const ROLLUP_BUCKETS = [1, 15]; // 1min 密曲线 + 15min 长周期
async function upsertRollup(client, r, bucketMinutes) {
const bucket = bucketStart(r.ts, bucketMinutes);
await client.query(`INSERT INTO ts_rollups (
bucket, bucket_minutes, series_id, device_id, source, metric,
avg_eng, min_eng, max_eng, n
) VALUES ($1,$2,$3,$4,$5,$6,$7,$7,$7,1)
ON CONFLICT (series_id, bucket_minutes, bucket) DO UPDATE SET
avg_eng = (ts_rollups.avg_eng * ts_rollups.n + EXCLUDED.avg_eng) / (ts_rollups.n + 1),
min_eng = LEAST(ts_rollups.min_eng, EXCLUDED.min_eng),
max_eng = GREATEST(ts_rollups.max_eng, EXCLUDED.max_eng),
n = ts_rollups.n + 1`, [bucket, bucketMinutes, r.seriesId, r.deviceId, r.source, r.metric, r.eng]);
}
/** 批量写入原始点,并增量更新 1min/15min 预聚合 */
export async function insertTsSamples(rows, _bucketMinutes = 15) {
if (!isPgEnabled() || !rows.length)
return 0;
const filtered = rows.filter((r) => Number.isFinite(r.eng) && allowWrite(r.seriesId, r.ts.getTime()));
if (!filtered.length)
return 0;
const client = await pool.connect();
try {
await client.query('BEGIN');
const values = [];
const places = filtered
.map((r, i) => {
const o = i * 7;
values.push(r.ts, r.seriesId, r.deviceId, r.source, r.metric, r.eng, r.raw ?? null);
return `($${o + 1},$${o + 2},$${o + 3},$${o + 4},$${o + 5},$${o + 6},$${o + 7})`;
})
.join(',');
await client.query(`INSERT INTO ts_samples (ts, series_id, device_id, source, metric, eng, raw)
VALUES ${places}`, values);
for (const r of filtered) {
for (const bm of ROLLUP_BUCKETS) {
await upsertRollup(client, r, bm);
}
}
await client.query('COMMIT');
return filtered.length;
}
catch (e) {
await client.query('ROLLBACK');
console.error('[db] insertTsSamples failed', e);
return 0;
}
finally {
client.release();
}
}
/** 从业务快照抽取关键指标写入时序(避免 800 点全量 JSON 落库) */
export async function persistSnapshotTimeseries(snap) {
if (!isPgEnabled())
return;
const ts = new Date(snap.ts || Date.now());
const rows = [];
for (const r of snap.rooms) {
rows.push({
ts,
seriesId: seriesId('room', r.id, 'temp'),
deviceId: r.id,
source: 'room',
metric: 'temp',
eng: r.temp,
}, {
ts,
seriesId: seriesId('room', r.id, 'humidity'),
deviceId: r.id,
source: 'room',
metric: 'humidity',
eng: r.humidity,
}, {
ts,
seriesId: seriesId('room', r.id, 'pressure'),
deviceId: r.id,
source: 'room',
metric: 'pressure',
eng: r.pressure,
});
}
for (const f of snap.fridges) {
rows.push({
ts,
seriesId: seriesId('fridge', f.id, 'temp'),
deviceId: f.id,
source: 'fridge',
metric: 'temp',
eng: f.temp,
}, {
ts,
seriesId: seriesId('fridge', f.id, 'humidity'),
deviceId: f.id,
source: 'fridge',
metric: 'humidity',
eng: f.humidity,
});
}
for (const v of snap.vav) {
rows.push({
ts,
seriesId: seriesId('vav', v.id, 'airflow'),
deviceId: v.id,
source: 'vav',
metric: 'airflow',
eng: v.airflow,
}, {
ts,
seriesId: seriesId('vav', v.id, 'face_velocity'),
deviceId: v.id,
source: 'vav',
metric: 'face_velocity',
eng: v.faceVelocity,
});
}
const h = snap.hvac;
rows.push({
ts,
seriesId: seriesId('hvac', 'PAU201', 'supply_temp'),
deviceId: 'PAU201',
source: 'hvac',
metric: 'supply_temp',
eng: h.supplyTemp,
}, {
ts,
seriesId: seriesId('hvac', 'PAU201', 'supply_humidity'),
deviceId: 'PAU201',
source: 'hvac',
metric: 'supply_humidity',
eng: h.supplyHumidity,
}, {
ts,
seriesId: seriesId('hvac', 'PAU201', 'supply_pressure'),
deviceId: 'PAU201',
source: 'hvac',
metric: 'supply_pressure',
eng: h.supplyPressure,
}, {
ts,
seriesId: seriesId('hvac', 'PAU201', 'fresh_air_temp'),
deviceId: 'PAU201',
source: 'hvac',
metric: 'fresh_air_temp',
eng: h.freshAirTemp,
}, {
ts,
seriesId: seriesId('hvac', 'PAU201', 'exhaust_pressure'),
deviceId: 'PAU201',
source: 'hvac',
metric: 'exhaust_pressure',
eng: h.exhaustPressure,
});
const m = snap.metering;
rows.push({
ts,
seriesId: seriesId('metering', 'lab', 'power_kwh'),
deviceId: 'lab',
source: 'metering',
metric: 'power_kwh',
eng: m.powerKwh,
}, {
ts,
seriesId: seriesId('metering', 'lab', 'water_m3'),
deviceId: 'lab',
source: 'metering',
metric: 'water_m3',
eng: m.waterM3,
}, {
ts,
seriesId: seriesId('metering', 'lab', 'cooling_kwh'),
deviceId: 'lab',
source: 'metering',
metric: 'cooling_kwh',
eng: m.coolingKwh,
});
await insertTsSamples(rows);
}
/** 优先读 15min 预聚合;无数据时回退原始点桶聚合 */
export async function queryTsSeries(opts) {
if (!isPgEnabled())
return [];
const hours = Math.min(168, Math.max(1, opts.hours ?? 24));
// 短窗口优先 1min 桶(更密);长窗口用请求粒度或 15min
const wantBucket = Math.min(60, Math.max(1, opts.bucketMinutes ?? 15));
const bucket = hours <= 24 && wantBucket <= 5 ? 1 : wantBucket === 1 ? 1 : wantBucket;
const params = [opts.source, opts.metric, String(hours), bucket];
let deviceFilter = '';
if (opts.deviceIds?.length) {
params.push(opts.deviceIds);
deviceFilter = ` AND device_id = ANY($${params.length}::text[])`;
}
let { rows: rollupRows } = await query(`SELECT device_id, bucket, avg_eng
FROM ts_rollups
WHERE source = $1 AND metric = $2 AND bucket_minutes = $4
AND bucket >= NOW() - ($3::text || ' hours')::interval
${deviceFilter}
ORDER BY device_id, bucket`, params);
// 1min 尚无数据时回退 15min
if (!rollupRows.length && bucket === 1) {
const fallback = await query(`SELECT device_id, bucket, avg_eng
FROM ts_rollups
WHERE source = $1 AND metric = $2 AND bucket_minutes = 15
AND bucket >= NOW() - ($3::text || ' hours')::interval
${deviceFilter}
ORDER BY device_id, bucket`, params);
rollupRows = fallback.rows;
}
if (rollupRows.length) {
const map = new Map();
for (const r of rollupRows) {
let s = map.get(r.device_id);
if (!s) {
s = { id: r.device_id, points: [] };
map.set(r.device_id, s);
}
const v = Number(r.avg_eng);
if (!Number.isFinite(v))
continue;
const t = r.bucket instanceof Date ? r.bucket.getTime() : new Date(r.bucket).getTime();
s.points.push({ t, v });
}
return [...map.values()];
}
// 回退:原始点按桶聚合
const { rows } = await query(`SELECT
device_id,
to_timestamp(floor(extract(epoch FROM ts) / ($4 * 60)) * ($4 * 60)) AS bucket,
AVG(eng) AS avg_eng
FROM ts_samples
WHERE source = $1 AND metric = $2
AND ts >= NOW() - ($3::text || ' hours')::interval
${deviceFilter}
GROUP BY device_id, bucket
ORDER BY device_id, bucket`, params);
const map = new Map();
for (const r of rows) {
let s = map.get(r.device_id);
if (!s) {
s = { id: r.device_id, points: [] };
map.set(r.device_id, s);
}
const v = Number(r.avg_eng);
if (!Number.isFinite(v))
continue;
const t = r.bucket instanceof Date ? r.bucket.getTime() : new Date(r.bucket).getTime();
s.points.push({ t, v });
}
return [...map.values()];
}
export async function pruneTimeseriesData() {
if (!isPgEnabled())
return;
const days = config.collectRetentionDays;
if (days <= 0)
return;
// 原始点保留较短窗口;预聚合保留全周期
const rawDays = Math.min(days, Number(process.env.TS_RAW_RETENTION_DAYS || 14));
await query(`DELETE FROM ts_samples WHERE ts < NOW() - ($1::text || ' days')::interval`, [
String(rawDays),
]);
await query(`DELETE FROM ts_rollups WHERE bucket < NOW() - ($1::text || ' days')::interval`, [
String(days),
]);
}
export function isTimescaleEnabled() {
return timescaleOk;
}
/** 已落库的可查序列目录(优先 rollup,回退 samples) */
export async function listTsCatalog() {
if (!isPgEnabled())
return [];
const { rows } = await query(`SELECT source, metric, device_id, series_id,
COUNT(*)::int AS points,
MAX(bucket) AS last_bucket
FROM ts_rollups
GROUP BY source, metric, device_id, series_id
ORDER BY source, metric, device_id`);
if (rows.length) {
return rows.map((r) => ({
source: r.source,
metric: r.metric,
deviceId: r.device_id,
seriesId: r.series_id,
points: Number(r.points) || 0,
lastBucket: r.last_bucket
? r.last_bucket instanceof Date
? r.last_bucket.toISOString()
: String(r.last_bucket)
: null,
}));
}
const { rows: raw } = await query(`SELECT source, metric, device_id, series_id,
COUNT(*)::int AS points,
MAX(ts) AS last_ts
FROM ts_samples
GROUP BY source, metric, device_id, series_id
ORDER BY source, metric, device_id`);
return raw.map((r) => ({
source: r.source,
metric: r.metric,
deviceId: r.device_id,
seriesId: r.series_id,
points: Number(r.points) || 0,
lastBucket: r.last_ts
? r.last_ts instanceof Date
? r.last_ts.toISOString()
: String(r.last_ts)
: null,
}));
}
/** 批量拉取多组 source/metric,并附设备名 */
export async function queryNamedTsSeries(queries, opts) {
const hours = opts?.hours ?? 24;
const bucketMinutes = opts?.bucketMinutes ?? 15;
const out = {};
await Promise.all(queries.map(async (q) => {
out[q.key] = await queryTsSeries({
source: q.source,
metric: q.metric,
hours,
bucketMinutes,
deviceIds: q.deviceIds,
});
}));
return out;
}
/** 按点名给出合理默认告警高低限(与量程字段分离;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 fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { config } from '../config.js';
import { decodeCsvBuffer, parsePointsCsv } from './csv.js';
import { defaultAlarmLimits } from './alarmDefaults.js';
import { inferPointRw } from './pointRw.js';
function now() {
return new Date().toISOString();
}
/** 解析协议目录:优先环境变量,否则相对仓库旁的「设备对接」 */
export function resolveProtocolDir() {
const fromEnv = (process.env.DEVICE_POINTS_DIR || '').trim();
if (fromEnv && fs.existsSync(fromEnv))
return path.resolve(fromEnv);
const fallback = config.devicePointsDir;
if (fallback && fs.existsSync(fallback))
return fallback;
return null;
}
function walkCsvFiles(dir) {
const out = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name.startsWith('.') || e.name === 'HCNetSDK' || /HCNetSDK/i.test(e.name))
continue;
out.push(...walkCsvFiles(full));
}
else if (e.isFile() && /\.csv$/i.test(e.name)) {
out.push(full);
}
}
return out.sort();
}
function gatewayIdFromFilename(name) {
if (/192\.?168\.?10\.?102|19216810102/i.test(name))
return 'mb-102';
if (/192\.?168\.?10\.?103|19216810103/i.test(name))
return 'mb-103';
if (/192\.?168\.?10\.?104|19216810104/i.test(name))
return 'mb-104';
return 'mb-104';
}
function slugify(s) {
return s
.trim()
.replace(/[\\/:*?"<>|]+/g, '')
.replace(/\s+/g, '-')
.replace(/_+/g, '_')
.slice(0, 80);
}
function categoryFromName(name, fileBase) {
const t = `${name} ${fileBase}`;
if (/冰箱|冷链|fridge/i.test(t))
return 'fridge';
if (/通风柜|VAV/i.test(t))
return 'vav';
if (/屏|压差|房间屏/i.test(t))
return 'room';
if (/流量计|水/i.test(t))
return 'meter_water';
if (/能量计|冷量|DN\d+/i.test(t))
return 'meter_cooling';
if (/电表|N电|E电|电能/i.test(t))
return 'meter_power';
if (/PAU|机组|除湿|排风|空调/i.test(t))
return 'pau';
if (/PLC|控制器/i.test(t))
return 'other';
return 'other';
}
function bindUiFromCategory(c) {
switch (c) {
case 'fridge':
return 'cold';
case 'vav':
return 'vav';
case 'room':
return 'rooms';
case 'meter_power':
case 'meter_water':
case 'meter_cooling':
return 'billing';
case 'pau':
return 'hvac';
default:
return '';
}
}
/** 从 ItemID 取设备键:去掉最后一段测点名 */
export function deviceKeyFromItemId(itemId, fileBase) {
const id = (itemId || '').trim();
if (id && !/^\d+(\.\d+)?$/.test(id)) {
// RTU.COM3.1_试剂准备间冰箱1.冰箱温度 → 试剂准备间冰箱1
const mNum = id.match(/RTU\.[^.]+\.(\d+)_([^.]+)\.(.+)$/i);
if (mNum) {
return { key: `rtu-${mNum[1]}-${slugify(mNum[2])}`, name: mNum[2] };
}
// RTU.COM2.2ATN01_DB.相电压UA → 2ATN01_DB / N电电表
const mPlain = id.match(/RTU\.[^.]+\.([^.]+)\.(.+)$/i);
if (mPlain) {
const mid = mPlain[1];
// 屏:收样咨询室;电表:2ATN01_DB;能量计_DN80 等
const pretty = mid.includes('_') && !/^\d/.test(mid) ? mid.split('_').slice(-1)[0] || mid : mid;
return { key: slugify(mid).toLowerCase(), name: pretty || mid };
}
const parts = id.split('.');
if (parts.length >= 2) {
const mid = parts[parts.length - 2];
return { key: slugify(mid).toLowerCase(), name: mid };
}
}
const name = fileBase.replace(/\.csv$/i, '').replace(/^19216810\d{3}[-_\s]*/, '').trim() || fileBase;
return { key: slugify(name).toLowerCase() || 'device', name };
}
function defaultGateways() {
const t = now();
return [
{
id: 'mb-102',
name: 'PAU-201-1 机组',
kind: 'modbus_host',
host: config.modbus.pau1,
port: config.modbus.portPau1,
enabled: true,
meta: { role: 'pau' },
lastOkAt: null,
createdAt: t,
updatedAt: t,
},
{
id: 'mb-103',
name: 'PAU-201-2 计量/通风柜',
kind: 'modbus_host',
host: config.modbus.pau2,
port: config.modbus.portPau2,
enabled: true,
meta: { role: 'meter_vav' },
lastOkAt: null,
createdAt: t,
updatedAt: t,
},
{
id: 'mb-104',
name: '房间屏/PLC/冰箱/N·E电',
kind: 'modbus_host',
host: config.modbus.room,
port: config.modbus.portRoom,
enabled: true,
meta: { role: 'room' },
lastOkAt: null,
createdAt: t,
updatedAt: t,
},
];
}
/** 与 Python 模拟器一致:温/湿/压差/冰箱 Short·Word 在 Scale=0 时按 *0.1 */
function resolveScaleOverride(row) {
if (row.scale !== 0)
return null;
const dtype = (row.dataType || '').toLowerCase();
if (dtype.includes('float') || dtype.includes('double') || dtype.includes('bool'))
return null;
const n = `${row.name} ${row.itemId}`;
if (/温度|湿度|压差|Temp|Humid|冰箱|冷链/i.test(n))
return 0.1;
return null;
}
function pointFromRow(row, gatewayId, deviceId, t, sourceCsv) {
return {
id: randomUUID(),
gatewayId,
deviceId,
name: row.name,
regType: row.regType,
regAddress: row.regAddress,
dataType: row.dataType,
modbusId: row.modbusId,
modbusRegister: row.modbusRegister,
modbusAddress: row.modbusAddress,
description: row.description,
maskBit: row.maskBit,
bit: row.bit,
scale: row.scale,
bias: row.bias,
minRawRange: row.minRawRange,
maxRawRange: row.maxRawRange,
minEngRange: row.minEngRange,
maxEngRange: row.maxEngRange,
itemId: row.itemId,
snapshotPath: '',
rw: inferPointRw(row.regType, 'r', {
name: row.name,
itemId: row.itemId,
sourceCsv,
}),
energyKind: /电表|电能|电压|电流|功率|有功/.test(`${row.name} ${sourceCsv}`)
? 'power'
: /流量计|水/.test(`${row.name} ${sourceCsv}`)
? 'water'
: /能量计|冷量/.test(`${row.name} ${sourceCsv}`)
? 'cooling'
: null,
scaleOverride: resolveScaleOverride(row),
...defaultAlarmLimits(row.name, row.itemId),
enabled: true,
lastRaw: null,
lastEng: null,
lastTs: null,
createdAt: t,
updatedAt: t,
};
}
/** 从设备对接目录全部 CSV 构建 gateways / devices / points */
export function buildBundleFromProtocolDir(dir) {
const t = now();
const gateways = defaultGateways();
const deviceMap = new Map();
const points = [];
const files = walkCsvFiles(dir);
if (!files.length) {
throw new Error(`协议目录无 CSV:${dir}`);
}
for (const file of files) {
const fileBase = path.basename(file);
const fileTitle = fileBase
.replace(/\.csv$/i, '')
.replace(/^19216810\d{3}[-_\s]*/, '')
.trim();
const gatewayId = gatewayIdFromFilename(fileBase);
const text = decodeCsvBuffer(fs.readFileSync(file));
let rows;
try {
rows = parsePointsCsv(text);
}
catch (e) {
console.warn(`[devices] skip CSV ${fileBase}:`, e instanceof Error ? e.message : e);
continue;
}
if (!rows.length)
continue;
// 先按 ItemID 归组;单组文件用 CSV 文件名作设备名,多组(冰箱/屏/回路)用 ItemID 名
const groups = new Map();
for (const row of rows) {
const { key, name } = deviceKeyFromItemId(row.itemId, fileBase);
const g = groups.get(key);
if (g)
g.rows.push(row);
else
groups.set(key, { name, rows: [row] });
}
const multi = groups.size > 1;
for (const [key, group] of groups) {
const name = multi ? group.name : fileTitle || group.name;
const deviceId = `${gatewayId}-${key}`;
if (!deviceMap.has(deviceId)) {
const category = categoryFromName(name, fileBase);
deviceMap.set(deviceId, {
id: deviceId,
gatewayId,
category,
name,
code: deviceId,
bindUi: bindUiFromCategory(category),
status: 'unknown',
meta: { sourceCsv: fileBase, sourceDir: path.relative(dir, file) },
enabled: true,
createdAt: t,
updatedAt: t,
});
}
for (const row of group.rows) {
points.push(pointFromRow(row, gatewayId, deviceId, t, fileBase));
}
}
}
const devices = [...deviceMap.values()].sort((a, b) => `${a.gatewayId}-${a.name}`.localeCompare(`${b.gatewayId}-${b.name}`, 'zh'));
console.log(`[devices] protocol CSV → ${files.length} files, ${devices.length} devices, ${points.length} points (${dir})`);
return { gateways, devices, points, pointsVersion: 1 };
}
import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import { config } from '../config.js';
import { query } from '../db/pool.js';
import { isPgEnabled } from '../db/state.js';
import { defaultAlarmLimits } from './alarmDefaults.js';
import { inferPointRw, isReadablePoint } from './pointRw.js';
import { decodeCsvBuffer, exportPointsCsv, parsePointsCsv, toEngineering, } from './csv.js';
import { buildBundleFromProtocolDir, resolveProtocolDir } from './seedFromCsv.js';
let mem = {
gateways: [],
devices: [],
points: [],
pointsVersion: 1,
};
function now() {
return new Date().toISOString();
}
function normalizePoint(p) {
// 旧 devices.json 可能缺字段;缺则按点名回填默认高低限
const hasAlarmFields = Object.prototype.hasOwnProperty.call(p, 'alarmLow') ||
Object.prototype.hasOwnProperty.call(p, 'alarmHigh');
const defaults = hasAlarmFields
? {
alarmLow: p.alarmLow ?? null,
alarmHigh: p.alarmHigh ?? null,
}
: defaultAlarmLimits(p.name, p.itemId);
return {
...p,
alarmLow: defaults.alarmLow,
alarmHigh: defaults.alarmHigh,
};
}
function loadFile() {
try {
if (!fs.existsSync(config.devicesFile))
return null;
const raw = JSON.parse(fs.readFileSync(config.devicesFile, 'utf8'));
const needsBackfill = (raw.points || []).some((p) => !('alarmLow' in p) && !('alarmHigh' in p));
const bundle = {
...raw,
points: (raw.points || []).map((p) => normalizePoint(p)),
};
if (needsBackfill) {
fs.mkdirSync(config.dataDir, { recursive: true });
fs.writeFileSync(config.devicesFile, JSON.stringify(bundle, null, 2), 'utf8');
}
return bundle;
}
catch {
return null;
}
}
function saveFile() {
fs.mkdirSync(config.dataDir, { recursive: true });
fs.writeFileSync(config.devicesFile, JSON.stringify(mem, null, 2), 'utf8');
}
function defaultGateways() {
const t = now();
return [
{
id: 'mb-102',
name: 'PAU-201-1 机组',
kind: 'modbus_host',
host: config.modbus.pau1,
port: config.modbus.portPau1,
enabled: true,
meta: { role: 'pau' },
lastOkAt: null,
createdAt: t,
updatedAt: t,
},
{
id: 'mb-103',
name: 'PAU-201-2 计量/通风柜',
kind: 'modbus_host',
host: config.modbus.pau2,
port: config.modbus.portPau2,
enabled: true,
meta: { role: 'meter_vav' },
lastOkAt: null,
createdAt: t,
updatedAt: t,
},
{
id: 'mb-104',
name: '房间屏/PLC/冰箱/N·E电',
kind: 'modbus_host',
host: config.modbus.room,
port: config.modbus.portRoom,
enabled: true,
meta: { role: 'room' },
lastOkAt: null,
createdAt: t,
updatedAt: t,
},
];
}
/**
* 种子优先级(与网关是否在线无关,主数据必须落在平台本地):
* 1) 现场协议 CSV 目录
* 2) 已有 server/data/devices.json(PG 空库时从文件灌入)
* 3) 仅网关占位(无设备/点位)
*/
function seedBundle() {
const dir = resolveProtocolDir();
if (dir) {
try {
const fromCsv = buildBundleFromProtocolDir(dir);
if (fromCsv.devices.length || fromCsv.points.length) {
console.log(`[devices] seed source: protocol CSV → ${fromCsv.devices.length} devices, ${fromCsv.points.length} points`);
return fromCsv;
}
console.warn('[devices] protocol CSV 目录无有效设备/点位,尝试 devices.json');
}
catch (e) {
console.warn('[devices] protocol CSV seed failed, try devices.json:', e);
}
}
else {
console.warn('[devices] DEVICE_POINTS_DIR 未找到,尝试 devices.json');
}
const file = loadFile();
if (file && (file.devices.length || file.points.length)) {
console.log(`[devices] seed source: devices.json → ${file.devices.length} devices, ${file.points.length} points`);
return {
gateways: file.gateways?.length ? file.gateways : defaultGateways(),
devices: file.devices || [],
points: file.points || [],
pointsVersion: file.pointsVersion || 1,
};
}
console.warn('[devices] 无 CSV/json 种子,仅写入网关占位(无设备/点位)');
return { gateways: defaultGateways(), devices: [], points: [], pointsVersion: 1 };
}
async function clearDevicesDataPg() {
await query(`DELETE FROM device_points`);
await query(`DELETE FROM devices`);
await query(`DELETE FROM gateways`);
await query(`DELETE FROM device_meta`);
}
function mapGw(r) {
return {
id: String(r.id),
name: String(r.name),
kind: r.kind,
host: String(r.host),
port: Number(r.port),
enabled: Boolean(r.enabled),
meta: r.meta || {},
lastOkAt: r.last_ok_at ? new Date(r.last_ok_at).toISOString() : null,
createdAt: new Date(r.created_at).toISOString(),
updatedAt: new Date(r.updated_at).toISOString(),
};
}
function mapDev(r) {
return {
id: String(r.id),
gatewayId: r.gateway_id ? String(r.gateway_id) : null,
category: r.category,
name: String(r.name),
code: String(r.code),
bindUi: String(r.bind_ui || ''),
status: r.status || 'unknown',
meta: r.meta || {},
enabled: Boolean(r.enabled),
createdAt: new Date(r.created_at).toISOString(),
updatedAt: new Date(r.updated_at).toISOString(),
};
}
function mapPt(r) {
return {
id: String(r.id),
gatewayId: String(r.gateway_id),
deviceId: r.device_id ? String(r.device_id) : null,
name: String(r.name),
regType: String(r.reg_type || ''),
regAddress: Number(r.reg_address || 0),
dataType: String(r.data_type || 'Short'),
modbusId: Number(r.modbus_id || 1),
modbusRegister: String(r.modbus_register || ''),
modbusAddress: Number(r.modbus_address || 0),
description: String(r.description || ''),
maskBit: Number(r.mask_bit || 0),
bit: Number(r.bit || 0),
scale: Number(r.scale || 0),
bias: Number(r.bias || 0),
minRawRange: Number(r.min_raw_range || 0),
maxRawRange: Number(r.max_raw_range || 0),
minEngRange: Number(r.min_eng_range || 0),
maxEngRange: Number(r.max_eng_range || 0),
itemId: String(r.item_id || ''),
snapshotPath: String(r.snapshot_path || ''),
rw: r.rw || 'r',
energyKind: r.energy_kind ?? null,
scaleOverride: r.scale_override === null || r.scale_override === undefined ? null : Number(r.scale_override),
alarmLow: r.alarm_low === null || r.alarm_low === undefined ? null : Number(r.alarm_low),
alarmHigh: r.alarm_high === null || r.alarm_high === undefined ? null : Number(r.alarm_high),
enabled: Boolean(r.enabled),
lastRaw: r.last_raw === null || r.last_raw === undefined ? null : Number(r.last_raw),
lastEng: r.last_eng === null || r.last_eng === undefined ? null : Number(r.last_eng),
lastTs: r.last_ts ? new Date(r.last_ts).toISOString() : null,
createdAt: new Date(r.created_at).toISOString(),
updatedAt: new Date(r.updated_at).toISOString(),
};
}
async function bumpVersionPg() {
await query(`INSERT INTO device_meta (id, points_version, updated_at)
VALUES (1, 1, NOW())
ON CONFLICT (id) DO UPDATE SET points_version = device_meta.points_version + 1, updated_at = NOW()`);
}
async function getPointsVersionPg() {
const { rows } = await query(`SELECT points_version FROM device_meta WHERE id = 1`);
return rows[0]?.points_version ?? 1;
}
function isStubFridgeOnly(bundle) {
if (!bundle.devices.length)
return true;
const allFridge = bundle.devices.every((d) => d.category === 'fridge');
return allFridge && bundle.devices.length <= 8 && bundle.points.length <= 20;
}
/** ALTER 后全表 alarm_* 均为 NULL 时,按点名回填默认高低限(仅一次启发式) */
async function backfillAlarmLimitsPgOnce() {
const { rows } = await query(`SELECT
COUNT(*) FILTER (WHERE alarm_low IS NULL AND alarm_high IS NULL)::text AS both_null,
COUNT(*)::text AS total
FROM device_points`);
const bothNull = Number(rows[0]?.both_null || 0);
const total = Number(rows[0]?.total || 0);
if (!total || bothNull !== total)
return;
const points = await listPoints();
let n = 0;
for (const p of points) {
const d = defaultAlarmLimits(p.name, p.itemId);
if (d.alarmLow === null && d.alarmHigh === null)
continue;
await updatePoint(p.id, d);
n += 1;
}
if (n)
console.log(`[devices] backfilled alarm limits for ${n}/${total} points`);
}
/** 按 RegType 纠正 rw:F5/F6 等为写控点;电表/流量计/能量计强制只读 */
async function backfillPointRwPg() {
const points = await listPoints();
const devices = await listDevices();
const byId = new Map(devices.map((d) => [d.id, d]));
let n = 0;
for (const p of points) {
const d = p.deviceId ? byId.get(p.deviceId) : undefined;
const sourceCsv = d?.meta && typeof d.meta.sourceCsv === 'string'
? d.meta.sourceCsv
: '';
const inferred = inferPointRw(p.regType, p.rw || 'r', {
name: p.name,
itemId: p.itemId,
sourceCsv,
category: d?.category,
});
if (inferred === p.rw)
continue;
await updatePoint(p.id, { rw: inferred });
n += 1;
}
if (n)
console.log(`[devices] backfilled rw from regType for ${n} points`);
}
async function writeSeedPg(seed) {
for (const g of seed.gateways) {
await query(`INSERT INTO gateways (id, name, kind, host, port, enabled, meta, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,NOW(),NOW())
ON CONFLICT (id) DO NOTHING`, [g.id, g.name, g.kind, g.host, g.port, g.enabled, JSON.stringify(g.meta)]);
}
for (const d of seed.devices) {
await insertDevicePg(d);
}
for (const p of seed.points) {
await insertPointPg(p);
}
await query(`INSERT INTO device_meta (id, points_version, updated_at) VALUES (1, $1, NOW())
ON CONFLICT (id) DO UPDATE SET points_version = $1, updated_at = NOW()`, [seed.pointsVersion]);
}
/** PG 主数据备份到 devices.json,避免仅存库时容器重建丢种源 */
async function backupPgToDevicesFile() {
try {
const [gateways, devices, points, pointsVersion] = await Promise.all([
listGateways(),
listDevices(),
listPoints(),
getPointsVersion(),
]);
if (!devices.length && !points.length)
return;
const bundle = { gateways, devices, points, pointsVersion };
fs.mkdirSync(config.dataDir, { recursive: true });
fs.writeFileSync(config.devicesFile, JSON.stringify(bundle, null, 2), 'utf8');
}
catch (e) {
console.warn('[devices] backup to devices.json failed:', e instanceof Error ? e.message : e);
}
}
export async function ensureDevicesSeed() {
const force = process.env.DEVICES_RESEED === '1';
const protocolDir = resolveProtocolDir();
if (isPgEnabled()) {
const { rows: gwRows } = await query(`SELECT COUNT(*)::text AS n FROM gateways`);
const { rows: devRows } = await query(`SELECT COUNT(*)::text AS n FROM devices`);
const { rows: ptRows } = await query(`SELECT COUNT(*)::text AS n FROM device_points`);
const gwN = Number(gwRows[0]?.n || 0);
const devN = Number(devRows[0]?.n || 0);
const ptN = Number(ptRows[0]?.n || 0);
const stub = gwN > 0 &&
((devN <= 8 && ptN <= 20) ||
(await (async () => {
const { rows } = await query(`SELECT COUNT(*)::text AS c FROM devices WHERE category <> 'fridge'`);
return Number(rows[0]?.c || 0) === 0 && devN > 0;
})()));
// PG 已有完整设备主数据:只做字段回填,绝不因网关离线而清空
if (!force && gwN > 0 && devN > 0 && !stub) {
await backfillAlarmLimitsPgOnce();
await backfillPointRwPg();
await backupPgToDevicesFile();
return;
}
// PG 空库 / 仅冰箱占位 / 强制重种:从 CSV 或 devices.json 灌入
// 含「有网关但 0 设备」——Docker 里常因找不到协议目录而只种了网关占位
const needSeed = force || gwN === 0 || stub || (gwN > 0 && devN === 0);
if (needSeed) {
const seed = seedBundle();
if (!seed.devices.length && !force) {
if (devN > 0) {
console.warn('[devices] seed 无设备,拒绝用空数据覆盖 PostgreSQL 现有设备(保留本地库)');
await backfillAlarmLimitsPgOnce();
await backfillPointRwPg();
return;
}
// 库也是空的且没有种源:写网关占位即可,但不要误报成「已同步网关设备」
console.warn('[devices] PostgreSQL 设备表为空且无 CSV/devices.json 种源;列表与 Windows 网关无关,请导入点位或挂载 DEVICE_POINTS_DIR');
}
if ((gwN > 0 || devN > 0 || ptN > 0) && (seed.devices.length || force)) {
console.log(`[devices] replacing PG seed (gw=${gwN} dev=${devN} pt=${ptN}, stub=${stub}, csv=${Boolean(protocolDir)})…`);
await clearDevicesDataPg();
}
await writeSeedPg(seed);
console.log(`[devices] seeded ${seed.gateways.length} gateways, ${seed.devices.length} devices, ${seed.points.length} points`);
if (seed.devices.length)
await backupPgToDevicesFile();
}
else {
await backfillAlarmLimitsPgOnce();
await backfillPointRwPg();
}
return;
}
const file = loadFile();
if (file && file.gateways.length && !force && !isStubFridgeOnly(file)) {
mem = file;
const byId = new Map(mem.devices.map((d) => [d.id, d]));
mem.points = mem.points.map((p) => {
const d = p.deviceId ? byId.get(p.deviceId) : undefined;
const sourceCsv = d?.meta && typeof d.meta.sourceCsv === 'string'
? d.meta.sourceCsv
: '';
const inferred = inferPointRw(p.regType, p.rw || 'r', {
name: p.name,
itemId: p.itemId,
sourceCsv,
category: d?.category,
});
return inferred === p.rw ? p : { ...p, rw: inferred };
});
saveFile();
return;
}
mem = seedBundle();
saveFile();
console.log(`[devices] seeded fallback file: ${mem.devices.length} devices, ${mem.points.length} points`);
}
async function insertDevicePg(d) {
await query(`INSERT INTO devices (id, gateway_id, category, name, code, bind_ui, status, meta, enabled, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,NOW(),NOW())
ON CONFLICT (id) DO NOTHING`, [
d.id,
d.gatewayId,
d.category,
d.name,
d.code,
d.bindUi,
d.status,
JSON.stringify(d.meta),
d.enabled,
]);
}
async function insertPointPg(p) {
await query(`INSERT INTO device_points (
id, gateway_id, device_id, name, reg_type, reg_address, data_type, modbus_id, modbus_register,
modbus_address, description, mask_bit, bit, scale, bias, min_raw_range, max_raw_range,
min_eng_range, max_eng_range, item_id, snapshot_path, rw, energy_kind, scale_override,
alarm_low, alarm_high, enabled,
created_at, updated_at
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,NOW(),NOW()
) ON CONFLICT (id) DO NOTHING`, [
p.id,
p.gatewayId,
p.deviceId,
p.name,
p.regType,
p.regAddress,
p.dataType,
p.modbusId,
p.modbusRegister,
p.modbusAddress,
p.description,
p.maskBit,
p.bit,
p.scale,
p.bias,
p.minRawRange,
p.maxRawRange,
p.minEngRange,
p.maxEngRange,
p.itemId,
p.snapshotPath,
p.rw,
p.energyKind,
p.scaleOverride,
p.alarmLow,
p.alarmHigh,
p.enabled,
]);
}
export async function listGateways() {
if (!isPgEnabled())
return mem.gateways;
const { rows } = await query(`SELECT * FROM gateways ORDER BY host`);
return rows.map((r) => mapGw(r));
}
export async function updateGateway(id, patch) {
if (!isPgEnabled()) {
const g = mem.gateways.find((x) => x.id === id);
if (!g)
return null;
Object.assign(g, patch, { updatedAt: now() });
saveFile();
return g;
}
const cur = (await listGateways()).find((g) => g.id === id);
if (!cur)
return null;
const next = {
...cur,
...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)),
updatedAt: now(),
};
await query(`UPDATE gateways SET name=$2, host=$3, port=$4, enabled=$5, meta=$6::jsonb, updated_at=NOW() WHERE id=$1`, [id, next.name, next.host, next.port, next.enabled, JSON.stringify(next.meta ?? {})]);
return next;
}
export async function listDevices(filter) {
if (!isPgEnabled()) {
return mem.devices.filter((d) => {
if (filter?.gatewayId && d.gatewayId !== filter.gatewayId)
return false;
if (filter?.category && d.category !== filter.category)
return false;
return true;
});
}
const conds = [];
const args = [];
if (filter?.gatewayId) {
args.push(filter.gatewayId);
conds.push(`gateway_id = $${args.length}`);
}
if (filter?.category) {
args.push(filter.category);
conds.push(`category = $${args.length}`);
}
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
const { rows } = await query(`SELECT * FROM devices ${where} ORDER BY category, name`, args);
return rows.map((r) => mapDev(r));
}
export async function createDevice(input) {
const t = now();
const d = {
id: randomUUID(),
gatewayId: input.gatewayId ?? null,
category: input.category,
name: input.name.trim(),
code: input.code.trim(),
bindUi: input.bindUi || '',
status: 'unknown',
meta: input.meta || {},
enabled: true,
createdAt: t,
updatedAt: t,
};
if (!d.name || !d.code)
throw new Error('名称与编码必填');
if (!isPgEnabled()) {
if (mem.devices.some((x) => x.code === d.code))
throw new Error('设备编码已存在');
mem.devices.push(d);
saveFile();
return d;
}
await insertDevicePg(d);
return d;
}
export async function updateDevice(id, patch) {
if (!isPgEnabled()) {
const d = mem.devices.find((x) => x.id === id);
if (!d)
return null;
Object.assign(d, patch, { updatedAt: now() });
saveFile();
return d;
}
const list = await listDevices();
const cur = list.find((x) => x.id === id);
if (!cur)
return null;
const next = { ...cur, ...patch, updatedAt: now() };
await query(`UPDATE devices SET gateway_id=$2, category=$3, name=$4, bind_ui=$5, status=$6, meta=$7::jsonb, enabled=$8, updated_at=NOW()
WHERE id=$1`, [
id,
next.gatewayId,
next.category,
next.name,
next.bindUi,
next.status,
JSON.stringify(next.meta),
next.enabled,
]);
return next;
}
export async function deleteDevice(id) {
if (!isPgEnabled()) {
const n = mem.devices.length;
mem.devices = mem.devices.filter((d) => d.id !== id);
mem.points = mem.points.map((p) => (p.deviceId === id ? { ...p, deviceId: null } : p));
if (mem.devices.length === n)
return false;
saveFile();
return true;
}
await query(`UPDATE device_points SET device_id = NULL WHERE device_id = $1`, [id]);
const { rowCount } = await query(`DELETE FROM devices WHERE id = $1`, [id]);
return (rowCount ?? 0) > 0;
}
export async function listPoints(filter) {
if (!isPgEnabled()) {
return mem.points.filter((p) => {
if (filter?.gatewayId && p.gatewayId !== filter.gatewayId)
return false;
if (filter?.deviceId && p.deviceId !== filter.deviceId)
return false;
return true;
});
}
const conds = [];
const args = [];
if (filter?.gatewayId) {
args.push(filter.gatewayId);
conds.push(`gateway_id = $${args.length}`);
}
if (filter?.deviceId) {
args.push(filter.deviceId);
conds.push(`device_id = $${args.length}`);
}
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
const { rows } = await query(`SELECT * FROM device_points ${where} ORDER BY gateway_id, modbus_address, bit`, args);
return rows.map((r) => mapPt(r));
}
export async function getPointsVersion() {
if (!isPgEnabled())
return mem.pointsVersion;
return getPointsVersionPg();
}
function pointFromParsed(row, gatewayId, deviceId) {
const t = now();
return {
id: randomUUID(),
gatewayId,
deviceId,
name: row.name,
regType: row.regType,
regAddress: row.regAddress,
dataType: row.dataType,
modbusId: row.modbusId,
modbusRegister: row.modbusRegister,
modbusAddress: row.modbusAddress,
description: row.description,
maskBit: row.maskBit,
bit: row.bit,
scale: row.scale,
bias: row.bias,
minRawRange: row.minRawRange,
maxRawRange: row.maxRawRange,
minEngRange: row.minEngRange,
maxEngRange: row.maxEngRange,
itemId: row.itemId,
snapshotPath: '',
rw: inferPointRw(row.regType, 'r', { name: row.name, itemId: row.itemId }),
energyKind: null,
scaleOverride: row.scale !== 0
? null
: /温度|湿度|压差|Temp|Humid|冰箱/i.test(`${row.name} ${row.itemId}`) &&
!/float|double|bool/i.test(row.dataType)
? 0.1
: null,
...defaultAlarmLimits(row.name, row.itemId),
enabled: true,
lastRaw: null,
lastEng: null,
lastTs: null,
createdAt: t,
updatedAt: t,
};
}
export async function createPoint(input) {
const t = now();
const p = {
id: randomUUID(),
gatewayId: input.gatewayId,
deviceId: input.deviceId ?? null,
name: input.name.trim(),
regType: input.regType || '',
regAddress: input.regAddress ?? 0,
dataType: input.dataType || 'Short',
modbusId: input.modbusId ?? 1,
modbusRegister: input.modbusRegister || '4X(Holding Register)',
modbusAddress: input.modbusAddress,
description: input.description || '',
maskBit: input.maskBit ?? 0,
bit: input.bit ?? 0,
scale: input.scale ?? 0,
bias: input.bias ?? 0,
minRawRange: input.minRawRange ?? 0,
maxRawRange: input.maxRawRange ?? 0,
minEngRange: input.minEngRange ?? 0,
maxEngRange: input.maxEngRange ?? 0,
itemId: input.itemId || `${input.name}@${input.modbusAddress}`,
snapshotPath: input.snapshotPath || '',
rw: input.rw ||
inferPointRw(input.regType || '', 'r', {
name: input.name,
itemId: input.itemId || '',
}),
energyKind: input.energyKind ?? null,
scaleOverride: input.scaleOverride ?? null,
alarmLow: input.alarmLow !== undefined
? input.alarmLow
: defaultAlarmLimits(input.name, input.itemId || '').alarmLow,
alarmHigh: input.alarmHigh !== undefined
? input.alarmHigh
: defaultAlarmLimits(input.name, input.itemId || '').alarmHigh,
enabled: input.enabled ?? true,
lastRaw: null,
lastEng: null,
lastTs: null,
createdAt: t,
updatedAt: t,
};
if (!isPgEnabled()) {
mem.points.push(p);
mem.pointsVersion += 1;
saveFile();
return p;
}
await insertPointPg(p);
await bumpVersionPg();
return p;
}
export async function updatePoint(id, patch) {
const list = await listPoints();
const cur = list.find((x) => x.id === id);
if (!cur)
return null;
const next = {
...cur,
...patch,
id: cur.id,
updatedAt: now(),
};
if (!isPgEnabled()) {
const i = mem.points.findIndex((x) => x.id === id);
mem.points[i] = next;
mem.pointsVersion += 1;
saveFile();
return next;
}
await query(`UPDATE device_points SET
gateway_id=$2, device_id=$3, name=$4, reg_type=$5, reg_address=$6, data_type=$7,
modbus_id=$8, modbus_register=$9, modbus_address=$10, description=$11, mask_bit=$12, bit=$13,
scale=$14, bias=$15, min_raw_range=$16, max_raw_range=$17, min_eng_range=$18, max_eng_range=$19,
item_id=$20, snapshot_path=$21, rw=$22, energy_kind=$23, scale_override=$24,
alarm_low=$25, alarm_high=$26, enabled=$27, updated_at=NOW()
WHERE id=$1`, [
id,
next.gatewayId,
next.deviceId,
next.name,
next.regType,
next.regAddress,
next.dataType,
next.modbusId,
next.modbusRegister,
next.modbusAddress,
next.description,
next.maskBit,
next.bit,
next.scale,
next.bias,
next.minRawRange,
next.maxRawRange,
next.minEngRange,
next.maxEngRange,
next.itemId,
next.snapshotPath,
next.rw,
next.energyKind,
next.scaleOverride,
next.alarmLow,
next.alarmHigh,
next.enabled,
]);
await bumpVersionPg();
return next;
}
export async function deletePoint(id) {
if (!isPgEnabled()) {
const n = mem.points.length;
mem.points = mem.points.filter((p) => p.id !== id);
if (mem.points.length === n)
return false;
mem.pointsVersion += 1;
saveFile();
return true;
}
const { rowCount } = await query(`DELETE FROM device_points WHERE id = $1`, [id]);
if ((rowCount ?? 0) > 0)
await bumpVersionPg();
return (rowCount ?? 0) > 0;
}
export async function importPointsCsv(opts) {
const text = decodeCsvBuffer(opts.buffer, opts.encoding);
const rows = parsePointsCsv(text);
if (!rows.length)
throw new Error('CSV 无有效点位行');
if (!isPgEnabled()) {
if (opts.replaceGateway) {
mem.points = mem.points.filter((p) => p.gatewayId !== opts.gatewayId);
}
for (const row of rows) {
mem.points.push(pointFromParsed(row, opts.gatewayId, opts.deviceId ?? null));
}
mem.pointsVersion += 1;
saveFile();
return { imported: rows.length, pointsVersion: mem.pointsVersion };
}
if (opts.replaceGateway) {
await query(`DELETE FROM device_points WHERE gateway_id = $1`, [opts.gatewayId]);
}
for (const row of rows) {
await insertPointPg(pointFromParsed(row, opts.gatewayId, opts.deviceId ?? null));
}
await bumpVersionPg();
return { imported: rows.length, pointsVersion: await getPointsVersionPg() };
}
export async function exportPoints(gatewayId) {
const points = await listPoints(gatewayId ? { gatewayId } : undefined);
return exportPointsCsv(points);
}
export async function getBundleForGateway() {
const [gateways, points, pointsVersion] = await Promise.all([
listGateways(),
listPoints(),
getPointsVersion(),
]);
return {
pointsVersion,
gateways: gateways.filter((g) => g.enabled && g.kind === 'modbus_host'),
// 下发全量启用点(含写控);网关侧按 rw/regType 决定读或写
points: points.filter((p) => p.enabled),
};
}
/** 将采集值写回点位 last_*(供后台设备/点位页展示),不 bump 点表版本 */
export async function applyPointTelemetry(samples) {
if (!samples.length)
return 0;
const tsDefault = now();
if (!isPgEnabled()) {
const byId = new Map(mem.points.map((p) => [p.id, p]));
const byItem = new Map(mem.points.filter((p) => p.itemId).map((p) => [p.itemId, p]));
let n = 0;
for (const s of samples) {
const p = (s.id && byId.get(s.id)) || (s.itemId && byItem.get(s.itemId)) || null;
if (!p)
continue;
if (s.raw !== undefined && s.raw !== null)
p.lastRaw = Number(s.raw);
if (s.eng !== undefined && s.eng !== null)
p.lastEng = Number(s.eng);
p.lastTs = s.ts || tsDefault;
n += 1;
}
if (n)
saveFile();
return n;
}
// 按 id / item_id 批量更新(分块,避免超长 SQL)
const withId = samples.filter((s) => s.id);
const withItem = samples.filter((s) => !s.id && s.itemId);
let updated = 0;
const chunk = 200;
for (let i = 0; i < withId.length; i += chunk) {
const part = withId.slice(i, i + chunk);
const ids = part.map((s) => s.id);
const raws = part.map((s) => (s.raw === undefined || s.raw === null ? null : Number(s.raw)));
const engs = part.map((s) => (s.eng === undefined || s.eng === null ? null : Number(s.eng)));
const tss = part.map((s) => s.ts || tsDefault);
const { rowCount } = await query(`UPDATE device_points AS dp SET
last_raw = COALESCE(v.raw, dp.last_raw),
last_eng = COALESCE(v.eng, dp.last_eng),
last_ts = v.ts
FROM (
SELECT * FROM unnest($1::uuid[], $2::float8[], $3::float8[], $4::timestamptz[])
AS t(id, raw, eng, ts)
) v
WHERE dp.id = v.id`, [ids, raws, engs, tss]);
updated += rowCount ?? 0;
}
for (let i = 0; i < withItem.length; i += chunk) {
const part = withItem.slice(i, i + chunk);
const items = part.map((s) => s.itemId);
const raws = part.map((s) => (s.raw === undefined || s.raw === null ? null : Number(s.raw)));
const engs = part.map((s) => (s.eng === undefined || s.eng === null ? null : Number(s.eng)));
const tss = part.map((s) => s.ts || tsDefault);
const { rowCount } = await query(`UPDATE device_points AS dp SET
last_raw = COALESCE(v.raw, dp.last_raw),
last_eng = COALESCE(v.eng, dp.last_eng),
last_ts = v.ts
FROM (
SELECT * FROM unnest($1::text[], $2::float8[], $3::float8[], $4::timestamptz[])
AS t(item_id, raw, eng, ts)
) v
WHERE dp.item_id = v.item_id`, [items, raws, engs, tss]);
updated += rowCount ?? 0;
}
return updated;
}
/** Mock/网关旁路:按点位生成可读工程值,便于后台联调 */
export async function applyMockPointTelemetry() {
const points = (await listPoints()).filter((p) => p.enabled && isReadablePoint(p));
return applyMockSamples(points);
}
/**
* 仅对超过 staleMs 未更新的可读点补发模拟值(网关部分在线时保持时序不断流)。
*/
export async function applyMockPointTelemetryStale(staleMs) {
if (staleMs <= 0)
return 0;
const cutoff = Date.now() - staleMs;
const points = (await listPoints()).filter((p) => {
if (!p.enabled || !isReadablePoint(p))
return false;
if (!p.lastTs)
return true;
const t = Date.parse(p.lastTs);
return !Number.isFinite(t) || t < cutoff;
});
return applyMockSamples(points);
}
async function applyMockSamples(points) {
if (!points.length)
return 0;
const t = Date.now();
const samples = points.map((p, i) => {
const raw = mockRaw(p, t, i);
const eng = toEngineering(raw, p.scale, p.bias, p.scaleOverride, p.minRawRange, p.maxRawRange, p.minEngRange, p.maxEngRange);
return { id: p.id, itemId: p.itemId, raw, eng, ts: now(), quality: 'simulated' };
});
return applyPointTelemetry(samples);
}
function mockRaw(p, t, i) {
const wave = Math.sin(t / 8000 + i * 0.17);
const name = `${p.name} ${p.itemId}`;
if (/Boolean|Bit/i.test(p.dataType) || /开停|状态|到位|信号|开关/.test(name)) {
return wave > 0 ? 1 : 0;
}
// 原始寄存器常按 ×0.1 工程化;按业务给合理 raw
if (/冰箱|冷链|fridge/i.test(name) && /温度|temp/i.test(name)) {
return Math.round((-20 + wave * 1.2) * 10);
}
if (/冰箱|冷链|fridge/i.test(name) && /湿度|humid/i.test(name)) {
return Math.round((40 + wave * 8) * 10);
}
if (/送风温度|新风温度|排风温度|表冷后温度|实时温度|^温度$|temp/i.test(name)) {
return Math.round((22.5 + wave * 1.2) * 10);
}
if (/送风湿度|新风湿度|排风湿度|表冷后湿度|实时湿度|^湿度$|humid/i.test(name)) {
return Math.round((48 + wave * 6) * 10);
}
if (/送风压力|排风压力|实时压差|压差|pressure|压力/.test(name)) {
return Math.round((-12 + wave * 4) * 10);
}
if (/实际风量/.test(name))
return Math.round((650 + wave * 80) * 10);
if (/实际面风速/.test(name))
return Math.round((0.45 + wave * 0.08) * 100);
if (/用电量|有功电能/.test(name))
return Math.round((480 + wave * 20 + (i % 9)) * 10);
if (/电压|电流|功率|电|流量|流速|能量/.test(name))
return Math.round((100 + wave * 40 + (i % 7)) * 10) / 10;
if (/Float|Double/i.test(p.dataType))
return +(12 + wave * 5 + (i % 5)).toFixed(3);
return Math.round(100 + wave * 30 + (i % 11));
}
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 });
});
import { Router } from 'express';
import { authRequired, requireRole } from '../auth/middleware.js';
import { encodeCsv } from '../devices/csv.js';
import { createDevice, createPoint, deleteDevice, deletePoint, exportPoints, getPointsVersion, importPointsCsv, listDevices, listGateways, listPoints, updateDevice, updateGateway, updatePoint, } from '../devices/store.js';
import { fetchGatewayHealth, gatewayModbusWrite, syncPointsToGateway } from '../gateway/client.js';
import { applyWriteLog } from '../realtime/store.js';
import { inferPointRw } from '../devices/pointRw.js';
export const devicesRouter = Router();
devicesRouter.get('/gateways', authRequired, async (_req, res) => {
res.json({ gateways: await listGateways(), pointsVersion: await getPointsVersion() });
});
devicesRouter.get('/gateways/health', authRequired, async (_req, res) => {
res.json(await fetchGatewayHealth());
});
devicesRouter.patch('/gateways/:id', authRequired, requireRole('admin'), async (req, res) => {
try {
const g = await updateGateway(req.params.id, {
name: req.body?.name,
host: req.body?.host,
port: req.body?.port !== undefined ? Number(req.body.port) : undefined,
enabled: req.body?.enabled,
meta: req.body?.meta,
});
if (!g) {
res.status(404).json({ message: '网关不存在' });
return;
}
res.json(g);
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' });
}
});
devicesRouter.get('/', authRequired, async (req, res) => {
const gatewayId = req.query.gatewayId ? String(req.query.gatewayId) : undefined;
const category = req.query.category ? String(req.query.category) : undefined;
res.json({ devices: await listDevices({ gatewayId, category }) });
});
devicesRouter.post('/', authRequired, requireRole('admin'), async (req, res) => {
try {
const body = req.body || {};
const device = await createDevice({
gatewayId: body.gatewayId ?? null,
category: body.category,
name: String(body.name || ''),
code: String(body.code || ''),
bindUi: body.bindUi,
meta: body.meta,
});
res.status(201).json(device);
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' });
}
});
devicesRouter.patch('/:id', authRequired, requireRole('admin'), async (req, res) => {
try {
const d = await updateDevice(req.params.id, req.body || {});
if (!d) {
res.status(404).json({ message: '设备不存在' });
return;
}
res.json(d);
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' });
}
});
devicesRouter.delete('/:id', authRequired, requireRole('admin'), async (req, res) => {
const ok = await deleteDevice(req.params.id);
if (!ok) {
res.status(404).json({ message: '设备不存在' });
return;
}
res.json({ ok: true });
});
devicesRouter.get('/points', authRequired, async (req, res) => {
const gatewayId = req.query.gatewayId ? String(req.query.gatewayId) : undefined;
const deviceId = req.query.deviceId ? String(req.query.deviceId) : undefined;
res.json({
points: await listPoints({ gatewayId, deviceId }),
pointsVersion: await getPointsVersion(),
});
});
devicesRouter.post('/points', authRequired, requireRole('admin'), async (req, res) => {
try {
const body = req.body || {};
const point = await createPoint({
gatewayId: String(body.gatewayId),
deviceId: body.deviceId ?? null,
name: String(body.name || ''),
modbusAddress: Number(body.modbusAddress),
...body,
});
res.status(201).json(point);
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' });
}
});
devicesRouter.patch('/points/:id', authRequired, requireRole('admin'), async (req, res) => {
try {
const p = await updatePoint(req.params.id, req.body || {});
if (!p) {
res.status(404).json({ message: '点位不存在' });
return;
}
res.json(p);
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' });
}
});
devicesRouter.delete('/points/:id', authRequired, requireRole('admin'), async (req, res) => {
const ok = await deletePoint(req.params.id);
if (!ok) {
res.status(404).json({ message: '点位不存在' });
return;
}
res.json({ ok: true });
});
devicesRouter.post('/points/import', authRequired, requireRole('admin'), async (req, res) => {
try {
const gatewayId = String(req.body?.gatewayId || '');
if (!gatewayId) {
res.status(400).json({ message: '请指定 gatewayId' });
return;
}
let buffer;
if (typeof req.body?.csv === 'string') {
buffer = Buffer.from(req.body.csv, 'utf8');
}
else if (typeof req.body?.csvBase64 === 'string') {
buffer = Buffer.from(req.body.csvBase64, 'base64');
}
else {
res.status(400).json({ message: '请提供 csv 或 csvBase64' });
return;
}
const result = await importPointsCsv({
gatewayId,
deviceId: req.body?.deviceId ?? null,
buffer,
encoding: req.body?.encoding,
replaceGateway: Boolean(req.body?.replaceGateway),
});
res.json(result);
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '导入失败' });
}
});
devicesRouter.get('/points/export', authRequired, async (req, res) => {
const gatewayId = req.query.gatewayId ? String(req.query.gatewayId) : undefined;
const encoding = String(req.query.encoding || 'utf-8');
const csv = await exportPoints(gatewayId);
const buf = encodeCsv(csv, encoding);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="device-points${gatewayId ? '-' + gatewayId : ''}.csv"`);
res.send(buf);
});
devicesRouter.post('/points/sync-gateway', authRequired, requireRole('admin'), async (_req, res) => {
res.json(await syncPointsToGateway());
});
/** 向可写点位下发 Modbus 写(F5 线圈 / F6 寄存器) */
devicesRouter.post('/points/:id/write', authRequired, requireRole('admin'), async (req, res) => {
try {
const points = await listPoints();
const point = points.find((p) => p.id === req.params.id);
if (!point) {
res.status(404).json({ message: '点位不存在' });
return;
}
const rw = inferPointRw(point.regType, point.rw || 'r', {
name: point.name,
itemId: point.itemId,
});
if (rw !== 'w' && rw !== 'rw') {
res.status(400).json({ message: '该点位为只读采集点,不可写' });
return;
}
const value = Number(req.body?.value);
if (!Number.isFinite(value)) {
res.status(400).json({ message: '请提供数值 value' });
return;
}
const result = await gatewayModbusWrite({
pointId: point.id,
itemId: point.itemId,
value,
});
applyWriteLog('modbus_write', {
pointId: point.id,
itemId: point.itemId,
name: point.name,
regType: point.regType,
address: point.modbusAddress,
value,
by: req.user.username,
gateway: result,
});
if (!result.ok) {
res.status(502).json({ ok: false, message: result.message });
return;
}
res.json({ ok: true, message: result.message, pointId: point.id, value });
}
catch (e) {
res.status(500).json({ message: e instanceof Error ? e.message : '写入失败' });
}
});
import { Router } from 'express';
import { config } from '../config.js';
import { applyPointTelemetry, getBundleForGateway, } from '../devices/store.js';
import { refreshPointAlarms } from '../realtime/store.js';
export const gatewayRouter = Router();
function ingestAuthorized(req) {
const key = (process.env.GATEWAY_INGEST_KEY || '').trim();
if (!key)
return true; // 开发联调默认放行;生产请设置 GATEWAY_INGEST_KEY
const hdr = String(req.headers['x-gateway-key'] || '');
const auth = String(req.headers.authorization || '');
if (hdr && hdr === key)
return true;
if (auth === `Bearer ${key}`)
return true;
return false;
}
/** Windows 网关拉取全量点表(与后台「下发网关」同源) */
gatewayRouter.get('/points-bundle', async (req, res) => {
if (!ingestAuthorized(req)) {
res.status(401).json({ ok: false, message: '网关鉴权失败' });
return;
}
const bundle = await getBundleForGateway();
res.json({
ok: true,
pointsVersion: bundle.pointsVersion,
gateways: bundle.gateways,
points: bundle.points,
});
});
/**
* Windows 网关主动上报测点 → 写入 device_points.last_*(落库/内存)
* Body: { ts?, pointsVersion?, values: [{ id, itemId, raw, eng, quality, ts }] }
*/
gatewayRouter.post('/telemetry', async (req, res) => {
if (!ingestAuthorized(req)) {
res.status(401).json({ ok: false, message: '网关鉴权失败' });
return;
}
const values = req.body?.values;
if (!Array.isArray(values)) {
res.status(400).json({ ok: false, message: '缺少 values 数组' });
return;
}
const samples = [];
for (const row of values) {
if (!row || typeof row !== 'object')
continue;
const id = typeof row.id === 'string' && row.id ? row.id : undefined;
const itemId = typeof row.itemId === 'string' && row.itemId ? row.itemId : undefined;
if (!id && !itemId)
continue;
samples.push({
id,
itemId,
raw: row.raw === undefined || row.raw === null ? null : Number(row.raw),
eng: row.eng === undefined || row.eng === null ? null : Number(row.eng),
ts: typeof row.ts === 'string' ? row.ts : null,
quality: typeof row.quality === 'string' ? row.quality : undefined,
});
}
try {
const updated = await applyPointTelemetry(samples);
if (updated > 0) {
void refreshPointAlarms().catch((e) => console.warn('[gateway] refresh point alarms failed', e));
}
res.json({
ok: true,
updated,
received: samples.length,
mode: config.dataMode,
});
}
catch (e) {
res.status(500).json({
ok: false,
message: e instanceof Error ? e.message : '写入失败',
});
}
});
import { Router } from 'express';
import { authRequired, requireRole } from '../auth/middleware.js';
import { applyWriteLog } from '../realtime/store.js';
import { createDevice, deleteDevice, getCommConfig, listDevices, switchAll, switchDevice, testCommConfig, updateCommConfig, updateDevice, } from '../light/store.js';
export const lightRouter = Router();
lightRouter.get('/config', (_req, res) => {
res.json(getCommConfig());
});
lightRouter.put('/config', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {};
const port = body.port !== undefined ? Number(body.port) : undefined;
const unitId = body.unitId !== undefined ? Number(body.unitId) : undefined;
const pollMs = body.pollMs !== undefined ? Number(body.pollMs) : undefined;
if (port !== undefined && (!Number.isFinite(port) || port < 1 || port > 65535)) {
res.status(400).json({ message: '端口需在 1–65535' });
return;
}
if (unitId !== undefined && (!Number.isFinite(unitId) || unitId < 0 || unitId > 247)) {
res.status(400).json({ message: 'Modbus 从站地址需在 0–247' });
return;
}
if (pollMs !== undefined && (!Number.isFinite(pollMs) || pollMs < 500 || pollMs > 600000)) {
res.status(400).json({ message: '轮询周期需在 500–600000 ms' });
return;
}
const protocol = body.protocol;
if (protocol && !['modbus', 'http', 'tcp'].includes(protocol)) {
res.status(400).json({ message: '协议无效' });
return;
}
const cfg = updateCommConfig({
host: body.host,
port,
protocol,
unitId,
username: body.username,
password: body.password,
pollMs,
enabled: body.enabled,
}, req.user.username);
res.json(cfg);
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '保存失败' });
}
});
lightRouter.post('/config/test', authRequired, requireRole('admin'), (_req, res) => {
res.json(testCommConfig());
});
lightRouter.get('/devices', (_req, res) => {
res.json({ devices: listDevices() });
});
lightRouter.post('/devices', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {};
const name = String(body.name || '').trim();
const zone = String(body.zone || '').trim();
const circuit = Number(body.circuit);
if (!name || !zone) {
res.status(400).json({ message: '请填写名称与区域' });
return;
}
if (!Number.isFinite(circuit) || circuit < 1 || circuit > 256) {
res.status(400).json({ message: '回路号需在 1–256' });
return;
}
const device = createDevice({
name,
zone,
circuit,
powerOn: body.powerOn,
status: body.status,
enabled: body.enabled,
brightness: body.brightness,
remark: body.remark,
});
res.status(201).json({ device });
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' });
}
});
lightRouter.patch('/devices/:id', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {};
const device = updateDevice(req.params.id, {
name: body.name,
zone: body.zone,
circuit: body.circuit !== undefined ? Number(body.circuit) : undefined,
status: body.status,
enabled: body.enabled,
brightness: body.brightness !== undefined ? Number(body.brightness) : undefined,
remark: body.remark,
});
res.json({ device });
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' });
}
});
lightRouter.delete('/devices/:id', authRequired, requireRole('admin'), (req, res) => {
try {
deleteDevice(req.params.id);
res.json({ ok: true });
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '删除失败' });
}
});
lightRouter.post('/devices/:id/switch', authRequired, requireRole('admin'), (req, res) => {
try {
const powerOn = Boolean(req.body?.powerOn ?? req.body?.on);
if (req.body?.powerOn === undefined && req.body?.on === undefined) {
res.status(400).json({ message: '请指定 powerOn / on' });
return;
}
const device = switchDevice(req.params.id, powerOn, req.user.username);
applyWriteLog('light_switch', {
deviceId: device.id,
name: device.name,
powerOn,
by: req.user.username,
});
res.json({
ok: true,
device,
message: `${device.name} 已远程${powerOn ? '开启' : '关闭'}(Mock)`,
});
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '开关失败' });
}
});
lightRouter.post('/switch-all', authRequired, requireRole('admin'), (req, res) => {
try {
const powerOn = Boolean(req.body?.powerOn ?? req.body?.on);
if (req.body?.powerOn === undefined && req.body?.on === undefined) {
res.status(400).json({ message: '请指定 powerOn / on' });
return;
}
const result = switchAll(powerOn, req.user.username);
applyWriteLog('light_switch_all', {
powerOn,
count: result.count,
by: req.user.username,
});
res.json({
ok: true,
count: result.count,
devices: result.devices,
message: `已批量${powerOn ? '开启' : '关闭'} ${result.count} 路照明(Mock)`,
});
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '批量开关失败' });
}
});
import { Router } from 'express';
import { authRequired, requireRole } from '../auth/middleware.js';
import { queryFridgeTempSeries } from '../db/collect.js';
import { getHistoryCatalog, getHistorySeries } from '../realtime/history.js';
import { buildScreenDashboard } from '../realtime/screen.js';
import { applyWriteLog, getSnapshot } from '../realtime/store.js';
export const realtimeRouter = Router();
/** 大屏只读,无需登录;写指令仍需鉴权 */
realtimeRouter.get('/snapshot', (_req, res) => {
res.json(getSnapshot());
});
/** 大屏面板:设备台账 + 点位实值 + 图表聚合(无假数) */
realtimeRouter.get('/screen', async (_req, res) => {
try {
const data = await buildScreenDashboard();
res.json(data);
}
catch (e) {
console.error('[realtime] screen dashboard failed', e);
res.status(500).json({ message: '大屏数据组装失败' });
}
});
/** 时序目录:所有已落库可查序列 */
realtimeRouter.get('/history/catalog', async (_req, res) => {
try {
const catalog = await getHistoryCatalog();
res.json({ catalog });
}
catch (e) {
console.error('[realtime] history catalog failed', e);
res.status(500).json({ message: '历史目录查询失败', catalog: [] });
}
});
/** 通用历史曲线:source + metric(可选 deviceId) */
realtimeRouter.get('/history', async (req, res) => {
const source = String(req.query.source || '');
const metric = String(req.query.metric || '');
if (!source || !metric) {
res.status(400).json({ message: '缺少 source / metric' });
return;
}
const hours = Number(req.query.hours) || 24;
const bucketMinutes = Number(req.query.bucketMinutes) || 15;
const deviceId = req.query.deviceId ? String(req.query.deviceId) : undefined;
try {
const data = await getHistorySeries({ source, metric, hours, bucketMinutes, deviceId });
res.json(data);
}
catch (e) {
console.error('[realtime] history query failed', e);
res.status(500).json({ message: '历史曲线查询失败', series: [] });
}
});
/** 各冰箱近 24h 温度曲线(采集落库聚合) */
realtimeRouter.get('/fridges/temp-history', async (req, res) => {
const hours = Number(req.query.hours) || 24;
const bucketMinutes = Number(req.query.bucketMinutes) || 15;
try {
const series = await queryFridgeTempSeries({ hours, bucketMinutes });
res.json({ hours, bucketMinutes, series });
}
catch (e) {
console.error('[realtime] fridge temp-history failed', e);
res.status(500).json({ message: '温度历史查询失败', series: [] });
}
});
realtimeRouter.post('/write', authRequired, requireRole('admin'), (req, res) => {
const { target, action, value } = req.body || {};
if (!target || !action) {
res.status(400).json({ message: '缺少 target/action' });
return;
}
applyWriteLog(String(action), {
target,
value,
by: req.user.username,
at: new Date().toISOString(),
});
res.json({ ok: true, message: '指令已受理(当前为 Mock/只记日志)' });
});
realtimeRouter.post('/access/open', authRequired, requireRole('admin'), (req, res) => {
const { doorId } = req.body || {};
if (!doorId) {
res.status(400).json({ message: '缺少 doorId' });
return;
}
applyWriteLog('remote_open', { doorId, by: req.user.username });
res.json({ ok: true, message: `门 ${doorId} 远程开门指令已下发(Mock)` });
});
import { Router } from 'express';
import { authRequired, requireRole } from '../auth/middleware.js';
import { getSettings, updateCollectPollMs, MIN_POLL_MS, MAX_POLL_MS, } from '../settings/store.js';
import { setCollectPollMs } from '../realtime/store.js';
export const settingsRouter = Router();
settingsRouter.get('/', async (_req, res) => {
res.json(await getSettings());
});
settingsRouter.put('/collect-poll', authRequired, requireRole('admin'), async (req, res) => {
const ms = Number(req.body?.collectPollMs);
if (!Number.isFinite(ms)) {
res.status(400).json({ message: '请输入有效的采集周期(毫秒)' });
return;
}
if (ms < MIN_POLL_MS || ms > MAX_POLL_MS) {
res.status(400).json({
message: `采集周期需在 ${MIN_POLL_MS}–${MAX_POLL_MS} ms 之间`,
});
return;
}
const settings = await updateCollectPollMs(ms, req.user.username);
await setCollectPollMs(settings.collectPollMs);
res.json(settings);
});
import { Router } from 'express';
import { authRequired, requireRole } from '../auth/middleware.js';
import { createUser, deleteUser, listUsers, updateUser } from '../users/store.js';
export const usersRouter = Router();
usersRouter.use(authRequired, requireRole('admin'));
usersRouter.get('/', async (_req, res) => {
res.json({ users: await listUsers() });
});
usersRouter.post('/', async (req, res) => {
try {
const { username, displayName, password, role } = req.body || {};
if (!username || !displayName || !password || !role) {
res.status(400).json({ message: '参数不完整' });
return;
}
if (!['admin', 'viewer'].includes(role)) {
res.status(400).json({ message: '角色无效' });
return;
}
const user = await createUser({
username: String(username),
displayName: String(displayName),
password: String(password),
role: role,
});
res.status(201).json({ user });
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' });
}
});
usersRouter.patch('/:id', async (req, res) => {
try {
const user = await updateUser(req.params.id, req.body || {});
res.json({ user });
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' });
}
});
usersRouter.delete('/:id', async (req, res) => {
try {
await deleteUser(req.params.id);
res.json({ ok: true });
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '删除失败' });
}
});
import { Router } from 'express';
import { authRequired, requireRole } from '../auth/middleware.js';
import { deviceStreamId, proxyWhepOffer } from '../gateway/client.js';
import { createDevice, deleteDevice, getCommConfig, listDevices, testCommConfig, updateCommConfig, updateDevice, } from '../video/store.js';
export const videoRouter = Router();
videoRouter.get('/config', (_req, res) => {
res.json(getCommConfig());
});
videoRouter.put('/config', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {};
const port = body.port !== undefined ? Number(body.port) : undefined;
const rtspPort = body.rtspPort !== undefined ? Number(body.rtspPort) : undefined;
const previewTimeoutMs = body.previewTimeoutMs !== undefined ? Number(body.previewTimeoutMs) : undefined;
if (port !== undefined && (!Number.isFinite(port) || port < 1 || port > 65535)) {
res.status(400).json({ message: '海康设备端口需在 1–65535' });
return;
}
if (rtspPort !== undefined && (!Number.isFinite(rtspPort) || rtspPort < 1 || rtspPort > 65535)) {
res.status(400).json({ message: 'RTSP 端口需在 1–65535' });
return;
}
if (previewTimeoutMs !== undefined &&
(!Number.isFinite(previewTimeoutMs) || previewTimeoutMs < 1000 || previewTimeoutMs > 60000)) {
res.status(400).json({ message: '预览超时需在 1000–60000 ms' });
return;
}
const protocol = body.protocol;
if (protocol && !['sdk', 'rtsp', 'isapi', 'tcp'].includes(protocol)) {
res.status(400).json({ message: '协议无效(支持 HCNetSDK / RTSP / ISAPI)' });
return;
}
const streamType = body.streamType;
if (streamType && !['main', 'sub'].includes(streamType)) {
res.status(400).json({ message: '码流类型无效' });
return;
}
const cfg = updateCommConfig({
host: body.host,
port,
rtspPort,
username: body.username,
password: body.password,
protocol,
streamType,
previewTimeoutMs,
enabled: body.enabled,
}, req.user.username);
res.json(cfg);
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '保存失败' });
}
});
videoRouter.post('/config/test', authRequired, requireRole('admin'), (_req, res) => {
res.json(testCommConfig());
});
videoRouter.get('/devices', (_req, res) => {
res.json({ devices: listDevices() });
});
videoRouter.post('/devices', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {};
const name = String(body.name || '').trim();
const location = String(body.location || '').trim();
const channel = Number(body.channel);
const deviceType = body.deviceType;
if (!name || !location) {
res.status(400).json({ message: '请填写名称与安装位置' });
return;
}
if (!Number.isFinite(channel) || channel < 1 || channel > 256) {
res.status(400).json({ message: '通道号需在 1–256' });
return;
}
if (!['ipc', 'nvr-ch'].includes(deviceType)) {
res.status(400).json({ message: '设备类型无效' });
return;
}
const device = createDevice({
name,
channel,
location,
deviceType,
status: body.status,
enabled: body.enabled,
ip: body.ip,
remark: body.remark,
scenePoint: body.scenePoint,
});
res.status(201).json({ device });
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' });
}
});
videoRouter.patch('/devices/:id', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {};
const device = updateDevice(req.params.id, {
name: body.name,
channel: body.channel !== undefined ? Number(body.channel) : undefined,
location: body.location,
deviceType: body.deviceType,
status: body.status,
enabled: body.enabled,
ip: body.ip,
remark: body.remark,
...(Object.prototype.hasOwnProperty.call(body, 'scenePoint')
? { scenePoint: body.scenePoint }
: {}),
});
res.json({ device });
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' });
}
});
videoRouter.delete('/devices/:id', authRequired, requireRole('admin'), (req, res) => {
try {
deleteDevice(req.params.id);
res.json({ ok: true });
}
catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '删除失败' });
}
});
/**
* WebRTC WHEP:浏览器 POST application/sdp(offer),返回 answer SDP
* 媒体面由 Windows 网关 / ZLMediaKit 提供
*/
videoRouter.post('/devices/:id/whep', authRequired, expressSdp, async (req, res) => {
const devices = listDevices();
const device = devices.find((d) => d.id === req.params.id);
if (!device) {
res.status(404).json({ ok: false, message: '设备不存在' });
return;
}
if (!device.enabled) {
res.status(400).json({ ok: false, message: '设备未启用' });
return;
}
const offerSdp = typeof req.body === 'string' ? req.body : String(req.body || '');
if (!offerSdp.includes('v=0')) {
res.status(400).json({ ok: false, message: '请提交 SDP offer(Content-Type: application/sdp)' });
return;
}
const streamId = deviceStreamId(device);
const result = await proxyWhepOffer(streamId, offerSdp);
if (!result.ok) {
res.status(result.status || 502).json({
ok: false,
message: result.message || 'WebRTC 协商失败',
streamId,
});
return;
}
if (result.location)
res.setHeader('Location', result.location);
res.status(201);
res.type('application/sdp');
res.send(result.answerSdp);
});
/** 解析 text/plain 与 application/sdp body */
function expressSdp(req, res, next) {
const ct = String(req.headers['content-type'] || '');
if (ct.includes('application/sdp') || ct.includes('text/plain')) {
const chunks = [];
req.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)));
req.on('end', () => {
req.body = Buffer.concat(chunks).toString('utf8');
next();
});
req.on('error', next);
return;
}
next();
}
import { Router } from 'express';
export const weatherRouter = Router();
const SMG_BRIEF = 'https://xml.smg.gov.mo/c_actual_brief.xml';
const CACHE_MS = 10 * 60_000;
let cache = null;
function pickTag(xml, tag) {
const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
const m = xml.match(re);
return m ? m[1].trim() : null;
}
function pickTypedValue(xml, blockTag, type) {
const re = new RegExp(`<${blockTag}>[\\s\\S]*?<Type>${type}</Type>[\\s\\S]*?<Value>([^<]*)</Value>[\\s\\S]*?</${blockTag}>`, 'i');
const m = xml.match(re);
return m ? m[1].trim() : null;
}
function parseBrief(xml) {
const temp = pickTypedValue(xml, 'Temperature', '3') || pickTag(xml, 'Value');
const humidity = pickTypedValue(xml, 'Humidity', '3');
const text = pickTag(xml, 'IconDescription') ||
pickTag(xml, 'FeelsLikeDescription') ||
'—';
const windDir = pickTag(xml, 'WindDescription');
const windSpd = pickTag(xml, 'WindSpeedDescription');
const feels = pickTag(xml, 'FeelsLikeDescription');
const updated = pickTag(xml, 'ValidFor') || pickTag(xml, 'SysPubdate');
return {
place: '澳门',
temp: temp != null && temp !== '' ? `${Math.round(Number(temp))}℃` : '--',
text,
humidity: humidity != null && humidity !== '' ? `${Math.round(Number(humidity))}%` : '--',
feelsLike: feels || undefined,
wind: [windDir, windSpd].filter(Boolean).join(' · ') || undefined,
source: 'https://www.smg.gov.mo/zh',
updatedAt: updated,
};
}
async function fetchMacauWeather() {
if (cache && Date.now() - cache.at < CACHE_MS)
return cache.data;
const res = await fetch(SMG_BRIEF, {
headers: { Accept: 'application/xml,text/xml,*/*', 'User-Agent': 'jh-lab-dashboard/1.0' },
});
if (!res.ok)
throw new Error(`SMG HTTP ${res.status}`);
const xml = await res.text();
const data = parseBrief(xml);
cache = { at: Date.now(), data };
return data;
}
weatherRouter.get('/macau', async (_req, res) => {
try {
const data = await fetchMacauWeather();
res.json(data);
}
catch (e) {
console.warn('[weather] SMG fetch failed', e);
if (cache?.data) {
res.json({ ...cache.data, stale: true });
return;
}
res.status(502).json({ message: '澳门天文台天气暂不可用' });
}
});
import { config } from '../config.js';
import { query } from '../db/pool.js';
import { isPgEnabled } from '../db/state.js';
const MIN_POLL_MS = 500;
const MAX_POLL_MS = 600_000;
export function clampPollMs(ms) {
if (!Number.isFinite(ms))
return config.modbus.pollMs;
return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, Math.round(ms)));
}
let memPollMs = clampPollMs(config.modbus.pollMs);
let memUpdatedAt = new Date().toISOString();
let memUpdatedBy = 'system';
function base(extra = {}) {
return {
collectPollMs: memPollMs,
dataMode: config.dataMode,
collectRetentionDays: config.collectRetentionDays,
updatedAt: memUpdatedAt,
updatedBy: memUpdatedBy,
storage: isPgEnabled() ? 'postgres' : 'fallback',
...extra,
};
}
export async function ensureSettingsSeed() {
if (!isPgEnabled())
return;
const { rows } = await query(`SELECT id FROM system_settings WHERE id = 1`);
if (rows.length)
return;
await query(`INSERT INTO system_settings (id, collect_poll_ms, updated_by)
VALUES (1, $1, 'system')`, [clampPollMs(config.modbus.pollMs)]);
}
export async function getSettings() {
if (!isPgEnabled())
return base();
const { rows } = await query(`SELECT collect_poll_ms, updated_at, updated_by FROM system_settings WHERE id = 1`);
const r = rows[0];
return {
collectPollMs: r ? Number(r.collect_poll_ms) : clampPollMs(config.modbus.pollMs),
dataMode: config.dataMode,
collectRetentionDays: config.collectRetentionDays,
updatedAt: r?.updated_at?.toISOString() || new Date().toISOString(),
updatedBy: r?.updated_by || 'system',
storage: 'postgres',
};
}
export async function getCollectPollMs() {
const s = await getSettings();
return clampPollMs(s.collectPollMs);
}
export async function updateCollectPollMs(ms, by) {
const next = clampPollMs(ms);
if (!isPgEnabled()) {
memPollMs = next;
memUpdatedAt = new Date().toISOString();
memUpdatedBy = by;
return base();
}
await query(`UPDATE system_settings
SET collect_poll_ms = $1, updated_at = NOW(), updated_by = $2
WHERE id = 1`, [next, by]);
return getSettings();
}
export { MIN_POLL_MS, MAX_POLL_MS };
import fs from 'node:fs';
import path from 'node:path';
import bcrypt from 'bcryptjs';
import { randomUUID } from 'node:crypto';
import { config } from '../config.js';
function ensureDir() {
fs.mkdirSync(path.dirname(config.usersFile), { recursive: true });
}
function load() {
ensureDir();
if (!fs.existsSync(config.usersFile))
return [];
return JSON.parse(fs.readFileSync(config.usersFile, 'utf-8'));
}
function save(users) {
ensureDir();
fs.writeFileSync(config.usersFile, JSON.stringify(users, null, 2), 'utf-8');
}
export function toPublic(u) {
const { passwordHash: _, ...rest } = u;
return rest;
}
export async function seedAdminIfNeeded() {
const users = load();
if (users.some((u) => u.role === 'admin'))
return;
const passwordHash = await bcrypt.hash(config.adminPassword, 10);
users.push({
id: randomUUID(),
username: config.adminUsername,
displayName: '系统管理员',
role: 'admin',
passwordHash,
enabled: true,
createdAt: new Date().toISOString(),
});
users.push({
id: randomUUID(),
username: 'viewer',
displayName: '值班查看',
role: 'viewer',
passwordHash: await bcrypt.hash('Viewer@123', 10),
enabled: true,
createdAt: new Date().toISOString(),
});
save(users);
}
export function listUsers() {
return load().map(toPublic);
}
export function findByUsername(username) {
return load().find((u) => u.username === username);
}
export function findById(id) {
return load().find((u) => u.id === id);
}
export async function verifyLogin(username, password) {
const user = findByUsername(username);
if (!user || !user.enabled)
return null;
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok)
return null;
const users = load();
const idx = users.findIndex((u) => u.id === user.id);
users[idx].lastLoginAt = new Date().toISOString();
save(users);
return users[idx];
}
export async function createUser(input) {
const users = load();
if (users.some((u) => u.username === input.username))
throw new Error('用户名已存在');
const record = {
id: randomUUID(),
username: input.username,
displayName: input.displayName,
role: input.role,
passwordHash: await bcrypt.hash(input.password, 10),
enabled: true,
createdAt: new Date().toISOString(),
};
users.push(record);
save(users);
return toPublic(record);
}
export async function updateUser(id, patch) {
const users = load();
const idx = users.findIndex((u) => u.id === id);
if (idx < 0)
throw new Error('用户不存在');
if (patch.role === 'viewer' || patch.enabled === false) {
const admins = users.filter((u) => u.role === 'admin' && u.enabled && u.id !== id);
if (users[idx].role === 'admin' && admins.length === 0) {
throw new Error('不能禁用或降级最后一个管理员');
}
}
if (patch.displayName !== undefined)
users[idx].displayName = patch.displayName;
if (patch.role !== undefined)
users[idx].role = patch.role;
if (patch.enabled !== undefined)
users[idx].enabled = patch.enabled;
if (patch.password)
users[idx].passwordHash = await bcrypt.hash(patch.password, 10);
save(users);
return toPublic(users[idx]);
}
export function deleteUser(id) {
const users = load();
const target = users.find((u) => u.id === id);
if (!target)
throw new Error('用户不存在');
const admins = users.filter((u) => u.role === 'admin' && u.enabled && u.id !== id);
if (target.role === 'admin' && admins.length === 0) {
throw new Error('不能删除最后一个管理员');
}
save(users.filter((u) => u.id !== id));
}
import bcrypt from 'bcryptjs';
import { randomUUID } from 'node:crypto';
import { config } from '../config.js';
import { query } from '../db/pool.js';
import { isPgEnabled } from '../db/state.js';
import * as file from './fileStore.js';
function toIso(v) {
if (v == null)
return undefined;
if (typeof v === 'string')
return new Date(v).toISOString();
return v.toISOString();
}
function mapRow(r) {
return {
id: r.id,
username: r.username,
displayName: r.display_name,
role: r.role,
passwordHash: r.password_hash,
enabled: r.enabled,
createdAt: toIso(r.created_at) || new Date().toISOString(),
lastLoginAt: toIso(r.last_login_at),
};
}
export function toPublic(u) {
return file.toPublic(u);
}
export async function seedAdminIfNeeded() {
if (!isPgEnabled())
return file.seedAdminIfNeeded();
const { rows } = await query(`SELECT COUNT(*)::text AS n FROM users WHERE role = 'admin'`);
if (Number(rows[0]?.n || 0) > 0)
return;
const adminHash = await bcrypt.hash(config.adminPassword, 10);
const viewerHash = await bcrypt.hash('Viewer@123', 10);
await query(`INSERT INTO users (id, username, display_name, role, password_hash, enabled)
VALUES ($1,$2,$3,'admin',$4,TRUE), ($5,'viewer','值班查看','viewer',$6,TRUE)`, [
randomUUID(),
config.adminUsername,
'系统管理员',
adminHash,
randomUUID(),
viewerHash,
]);
console.log('[db] seeded admin/viewer');
}
export async function listUsers() {
if (!isPgEnabled())
return file.listUsers();
const { rows } = await query(`SELECT * FROM users ORDER BY created_at ASC`);
return rows.map((r) => toPublic(mapRow(r)));
}
export async function findByUsername(username) {
if (!isPgEnabled())
return file.findByUsername(username);
const { rows } = await query(`SELECT * FROM users WHERE username = $1`, [username]);
return rows[0] ? mapRow(rows[0]) : undefined;
}
export async function findById(id) {
if (!isPgEnabled())
return file.findById(id);
const { rows } = await query(`SELECT * FROM users WHERE id = $1`, [id]);
return rows[0] ? mapRow(rows[0]) : undefined;
}
export async function verifyLogin(username, password) {
if (!isPgEnabled())
return file.verifyLogin(username, password);
const user = await findByUsername(username);
if (!user || !user.enabled)
return null;
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok)
return null;
await query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]);
return { ...user, lastLoginAt: new Date().toISOString() };
}
export async function createUser(input) {
if (!isPgEnabled())
return file.createUser(input);
const exists = await findByUsername(input.username);
if (exists)
throw new Error('用户名已存在');
const id = randomUUID();
const passwordHash = await bcrypt.hash(input.password, 10);
await query(`INSERT INTO users (id, username, display_name, role, password_hash, enabled)
VALUES ($1,$2,$3,$4,$5,TRUE)`, [id, input.username, input.displayName, input.role, passwordHash]);
const created = await findById(id);
return toPublic(created);
}
export async function updateUser(id, patch) {
if (!isPgEnabled())
return file.updateUser(id, patch);
const current = await findById(id);
if (!current)
throw new Error('用户不存在');
if (patch.role === 'viewer' || patch.enabled === false) {
const { rows } = await query(`SELECT COUNT(*)::text AS n FROM users WHERE role='admin' AND enabled=TRUE AND id <> $1`, [id]);
if (current.role === 'admin' && Number(rows[0]?.n || 0) === 0) {
throw new Error('不能禁用或降级最后一个管理员');
}
}
const displayName = patch.displayName ?? current.displayName;
const role = patch.role ?? current.role;
const enabled = patch.enabled ?? current.enabled;
const passwordHash = patch.password
? await bcrypt.hash(patch.password, 10)
: current.passwordHash;
await query(`UPDATE users SET display_name=$2, role=$3, enabled=$4, password_hash=$5 WHERE id=$1`, [id, displayName, role, enabled, passwordHash]);
const updated = await findById(id);
return toPublic(updated);
}
export async function deleteUser(id) {
if (!isPgEnabled())
return file.deleteUser(id);
const target = await findById(id);
if (!target)
throw new Error('用户不存在');
const { rows } = await query(`SELECT COUNT(*)::text AS n FROM users WHERE role='admin' AND enabled=TRUE AND id <> $1`, [id]);
if (target.role === 'admin' && Number(rows[0]?.n || 0) === 0) {
throw new Error('不能删除最后一个管理员');
}
await query(`DELETE FROM users WHERE id = $1`, [id]);
}
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { config } from '../config.js';
const filePath = () => config.videoFile;
function defaultConfig() {
return {
vendor: 'hikvision',
host: '192.168.10.200',
port: 8000,
rtspPort: 554,
username: 'admin',
password: 'Admin@123',
protocol: 'sdk',
streamType: 'sub',
previewTimeoutMs: 8000,
enabled: true,
updatedAt: new Date().toISOString(),
updatedBy: 'system',
};
}
function seedDevices() {
const now = new Date().toISOString();
const rows = [
{ name: '走廊东', channel: 1, location: 'E栋一层走廊', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: 'PCR区', channel: 2, location: '分子诊断 PCR', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '样本库', channel: 3, location: '样本库入口', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '机房', channel: 4, location: '弱电/机房', deviceType: 'ipc', status: 'online', enabled: true, ip: '192.168.10.211', remark: '海康网络摄像机' },
{ name: '门禁口', channel: 5, location: '主入口门禁', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '冷链库', channel: 6, location: '冷链监测区', deviceType: 'nvr-ch', status: 'alarm', enabled: true, ip: '', remark: '海康通道 · 联动冷柜超温' },
];
return rows.map((r) => ({
...r,
scenePoint: null,
id: randomUUID(),
createdAt: now,
updatedAt: now,
}));
}
function normalizeProtocol(p) {
if (p === 'rtsp' || p === 'isapi' || p === 'sdk')
return p;
if (p === 'tcp')
return 'isapi';
return 'sdk';
}
function normalizeScenePoint(raw) {
if (!raw || typeof raw !== 'object')
return null;
const o = raw;
const x = Number(o.x ?? o.lng ?? o.longitude);
const y = Number(o.y ?? o.lat ?? o.latitude);
const z = Number(o.z ?? o.height ?? o.alt ?? 0);
if (!Number.isFinite(x) || !Number.isFinite(y))
return null;
const icon = String(o.icon || o.markerIcon || '').trim();
const point = { x, y, z: Number.isFinite(z) ? z : 0 };
if (icon)
point.icon = icon.replace(/\\/g, '/').split('/').pop() || icon;
return point;
}
function normalizeDevice(raw) {
const now = new Date().toISOString();
return {
id: raw.id,
name: String(raw.name || ''),
channel: Number(raw.channel) || 1,
location: String(raw.location || ''),
deviceType: raw.deviceType === 'ipc' ? 'ipc' : 'nvr-ch',
status: raw.status === 'offline' || raw.status === 'alarm' ? raw.status : 'online',
enabled: raw.enabled !== false,
ip: String(raw.ip || ''),
remark: String(raw.remark || ''),
scenePoint: normalizeScenePoint(raw.scenePoint),
createdAt: raw.createdAt || now,
updatedAt: raw.updatedAt || now,
};
}
function normalizeData(raw) {
const base = defaultConfig();
const c = { ...base, ...(raw.config || {}) };
c.vendor = 'hikvision';
c.protocol = normalizeProtocol(c.protocol);
c.rtspPort = Number(c.rtspPort) || 554;
c.port = Number(c.port) || 8000;
const devices = Array.isArray(raw.devices)
? raw.devices.filter((d) => d && d.id).map((d) => normalizeDevice(d))
: [];
return { config: c, devices };
}
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;
}
const raw = JSON.parse(fs.readFileSync(filePath(), 'utf-8'));
const data = normalizeData(raw);
// 补齐海康字段后写回,避免旧配置缺 vendor/rtspPort
if (raw.config?.vendor !== 'hikvision' ||
raw.config?.rtspPort == null ||
raw.config?.protocol === 'tcp') {
save(data);
}
return data;
}
function save(data) {
ensureDir();
fs.writeFileSync(filePath(), JSON.stringify(data, null, 2), 'utf-8');
}
export function ensureVideoSeed() {
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;
c.vendor = 'hikvision';
if (patch.host !== undefined)
c.host = String(patch.host).trim();
if (patch.port !== undefined)
c.port = Number(patch.port);
if (patch.rtspPort !== undefined)
c.rtspPort = Number(patch.rtspPort);
if (patch.username !== undefined)
c.username = String(patch.username).trim();
if (patch.password !== undefined && String(patch.password).trim() && patch.password !== '********') {
c.password = String(patch.password);
}
if (patch.protocol !== undefined)
c.protocol = normalizeProtocol(patch.protocol);
if (patch.streamType !== undefined)
c.streamType = patch.streamType;
if (patch.previewTimeoutMs !== undefined)
c.previewTimeoutMs = Number(patch.previewTimeoutMs);
if (patch.enabled !== undefined)
c.enabled = Boolean(patch.enabled);
c.updatedAt = new Date().toISOString();
c.updatedBy = byUser;
save(data);
return toPublicConfig(c);
}
export function testCommConfig() {
const c = load().config;
if (!c.enabled) {
return { ok: false, message: '海康通信配置已禁用', latencyMs: 0 };
}
if (!c.host || !c.port) {
return { ok: false, message: '请填写海康 NVR IP 与设备端口(默认 8000)', latencyMs: 0 };
}
// 一期 Mock:校验配置完整性;二期经 Windows HCNetSDK 桥接登录预览
const latencyMs = 40 + Math.floor(Math.random() * 80);
const proto = c.protocol === 'sdk' ? 'HCNetSDK' : c.protocol === 'rtsp' ? 'RTSP' : 'ISAPI';
return {
ok: true,
message: `海康设备 ${c.host}:${c.port} 登录成功(${proto} · Mock)`,
latencyMs,
};
}
export function listDevices() {
return load().devices.slice().sort((a, b) => a.channel - b.channel);
}
export function createDevice(input) {
const data = load();
if (data.devices.some((d) => d.channel === input.channel)) {
throw new Error(`通道 ${input.channel} 已存在`);
}
const now = new Date().toISOString();
const device = {
id: randomUUID(),
name: input.name.trim(),
channel: Number(input.channel),
location: input.location.trim(),
deviceType: input.deviceType,
status: input.status || 'online',
enabled: input.enabled !== false,
ip: (input.ip || '').trim(),
remark: (input.remark || '').trim(),
scenePoint: normalizeScenePoint(input.scenePoint),
createdAt: now,
updatedAt: now,
};
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.channel !== undefined) {
const ch = Number(patch.channel);
if (data.devices.some((d) => d.id !== id && d.channel === ch)) {
throw new Error(`通道 ${ch} 已存在`);
}
data.devices[idx].channel = ch;
}
if (patch.name !== undefined)
data.devices[idx].name = String(patch.name).trim();
if (patch.location !== undefined)
data.devices[idx].location = String(patch.location).trim();
if (patch.deviceType !== undefined)
data.devices[idx].deviceType = patch.deviceType;
if (patch.status !== undefined)
data.devices[idx].status = patch.status;
if (patch.enabled !== undefined)
data.devices[idx].enabled = Boolean(patch.enabled);
if (patch.ip !== undefined)
data.devices[idx].ip = String(patch.ip).trim();
if (patch.remark !== undefined)
data.devices[idx].remark = String(patch.remark).trim();
if (patch.scenePoint !== undefined) {
data.devices[idx].scenePoint =
patch.scenePoint === null ? null : normalizeScenePoint(patch.scenePoint);
}
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);
}
{
"alarms": [
{
"id": "2c9dfaae-90ed-480a-bbb5-783ae89ce229",
"source": "point",
"sourceKey": "point:11111111-1111-1111-1111-111111111111:low",
"level": "red",
"status": "cleared",
"title": "冰箱温度",
"message": "冰箱温度 低于下限(-28 < -25)",
"deviceId": "dev-1",
"pointId": "11111111-1111-1111-1111-111111111111",
"itemId": "fridge.temp",
"value": -28,
"thresholdLow": -25,
"thresholdHigh": -18,
"kind": "low",
"payload": {
"name": "冰箱温度",
"alarmLow": -25,
"alarmHigh": -18
},
"raisedAt": "2026-10-20T15:42:54.576Z",
"updatedAt": "2026-10-20T15:42:54.579Z",
"clearedAt": "2026-10-20T15:42:54.579Z",
"ackedAt": "2026-10-20T15:42:54.579Z",
"ackedBy": "admin"
}
]
}
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"config": {
"host": "192.168.10.120",
"port": 502,
"protocol": "modbus",
"unitId": 1,
"username": "",
"password": "",
"pollMs": 3000,
"enabled": true,
"updatedAt": "2026-10-19T02:27:49.971Z",
"updatedBy": "system"
},
"devices": [
{
"name": "PCR走廊照明",
"zone": "PCR走廊",
"circuit": 1,
"powerOn": false,
"status": "online",
"enabled": true,
"brightness": 0,
"remark": "",
"id": "690a3243-d5df-4fb4-97a1-5f292e644528",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-21T02:09:55.413Z",
"lastSwitchAt": "2026-10-21T02:09:55.413Z",
"lastSwitchBy": "admin"
},
{
"name": "样本制备区1照明",
"zone": "样本制备区1",
"circuit": 2,
"powerOn": true,
"status": "online",
"enabled": true,
"brightness": 90,
"remark": "",
"id": "97950e5b-2eef-4d2f-af69-25aeaa28bb78",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-19T02:27:49.971Z",
"lastSwitchAt": null,
"lastSwitchBy": null
},
{
"name": "样本制备区2照明",
"zone": "样本制备区2",
"circuit": 3,
"powerOn": false,
"status": "online",
"enabled": true,
"brightness": 0,
"remark": "",
"id": "090b0f82-b4c8-42f4-893d-627ab3e9e3c0",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-19T02:27:49.971Z",
"lastSwitchAt": null,
"lastSwitchBy": null
},
{
"name": "文库制备区1照明",
"zone": "文库制备区1",
"circuit": 4,
"powerOn": true,
"status": "online",
"enabled": true,
"brightness": 85,
"remark": "",
"id": "299808c1-2d6d-47c0-88fb-0f4f21f78948",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-19T02:27:49.971Z",
"lastSwitchAt": null,
"lastSwitchBy": null
},
{
"name": "文库制备区2照明",
"zone": "文库制备区2",
"circuit": 5,
"powerOn": true,
"status": "online",
"enabled": true,
"brightness": 80,
"remark": "",
"id": "8b51a54a-ec22-4cc5-8301-68d2cb9f7e83",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-19T02:27:49.971Z",
"lastSwitchAt": null,
"lastSwitchBy": null
},
{
"name": "测序区照明",
"zone": "测序区",
"circuit": 6,
"powerOn": true,
"status": "online",
"enabled": true,
"brightness": 75,
"remark": "",
"id": "15fe54fe-2d1b-4176-a8b3-ae581e479ada",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-19T02:27:49.971Z",
"lastSwitchAt": null,
"lastSwitchBy": null
},
{
"name": "测序区缓冲照明",
"zone": "测序区缓冲",
"circuit": 7,
"powerOn": false,
"status": "online",
"enabled": true,
"brightness": 0,
"remark": "",
"id": "ba84ac2b-1e0c-4b34-91ef-e5dfd40cc763",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-19T02:27:49.971Z",
"lastSwitchAt": null,
"lastSwitchBy": null
},
{
"name": "机房照明",
"zone": "弱电/机房",
"circuit": 8,
"powerOn": true,
"status": "fault",
"enabled": true,
"brightness": 60,
"remark": "回路反馈异常",
"id": "22175489-5f92-4aed-9f71-f1fe7e3ce40d",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-19T02:27:49.971Z",
"lastSwitchAt": null,
"lastSwitchBy": null
}
]
}
\ No newline at end of file
[
{
"id": "6d2e7959-d838-411d-9f18-0c212991687e",
"username": "admin",
"displayName": "系统管理员",
"role": "admin",
"passwordHash": "$2b$10$ZW3X/9LZBeZJOfzCEpHN0er.5nTFceq8ew4q/.heG5vf.pmz5g1Me",
"enabled": true,
"createdAt": "2026-10-20T02:14:49.182Z",
"lastLoginAt": "2026-10-21T01:36:20.388Z"
},
{
"id": "f99a9f5d-6013-41cd-b48a-e361ea9cbc09",
"username": "viewer",
"displayName": "值班查看",
"role": "viewer",
"passwordHash": "$2b$10$qSpT0ntpqTQ3eMLujPOOvObQAnt8KGjwIaEAX9MDUu2r0BpFB58bO",
"enabled": true,
"createdAt": "2026-10-20T02:14:49.255Z"
}
]
\ No newline at end of file
{
"config": {
"vendor": "hikvision",
"host": "192.168.10.200",
"port": 8000,
"rtspPort": 554,
"username": "admin",
"password": "Admin@123",
"protocol": "sdk",
"streamType": "sub",
"previewTimeoutMs": 8000,
"enabled": true,
"updatedAt": "2026-10-19T02:27:49.970Z",
"updatedBy": "system"
},
"devices": [
{
"name": "走廊东",
"channel": 1,
"location": "E栋一层走廊",
"deviceType": "nvr-ch",
"status": "online",
"enabled": true,
"ip": "",
"remark": "海康 NVR 通道",
"id": "a3b47d6d-6001-443c-b9ff-7125b49dc3ac",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-19T02:27:49.971Z"
},
{
"name": "PCR区",
"channel": 2,
"location": "分子诊断 PCR",
"deviceType": "nvr-ch",
"status": "online",
"enabled": true,
"ip": "",
"remark": "海康 NVR 通道",
"id": "9f997785-cc1e-4ec4-bd15-5ddd3f8f0ef1",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-19T02:27:49.971Z"
},
{
"name": "样本库",
"channel": 3,
"location": "样本库入口",
"deviceType": "nvr-ch",
"status": "online",
"enabled": true,
"ip": "",
"remark": "海康 NVR 通道",
"id": "fd79dfdc-7e56-4d81-9235-8270dbf38d2e",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-19T02:27:49.971Z"
},
{
"name": "机房",
"channel": 4,
"location": "弱电/机房",
"deviceType": "ipc",
"status": "online",
"enabled": true,
"ip": "192.168.10.211",
"remark": "海康网络摄像机",
"id": "f620e42a-3f58-42f9-9159-3a94a2af3517",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-19T02:27:49.971Z"
},
{
"name": "门禁口",
"channel": 5,
"location": "主入口门禁",
"deviceType": "nvr-ch",
"status": "online",
"enabled": true,
"ip": "",
"remark": "海康 NVR 通道",
"id": "96633788-41e7-4df9-ba4a-5711d38d2b62",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-19T02:27:49.971Z"
},
{
"name": "冷链库",
"channel": 6,
"location": "冷链监测区",
"deviceType": "nvr-ch",
"status": "alarm",
"enabled": true,
"ip": "",
"remark": "海康通道 · 联动冷柜超温",
"id": "be64a649-787b-4b06-9e60-6f70d88791be",
"createdAt": "2026-10-19T02:27:49.971Z",
"updatedAt": "2026-10-19T02:27:49.971Z"
}
]
}
\ No newline at end of file
{
"name": "jh-lab-server",
"private": true,
"version": "0.1.0",
"type": "module",
"dependencies": {
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"express": "^4.21.2",
"iconv-lite": "^0.6.3",
"jsonwebtoken": "^9.0.2",
"modbus-serial": "^8.0.23",
"pg": "^8.23.1",
"ws": "^8.18.1"
}
}
@echo off
cd /d %~dp0
set NODE_ENV=production
set PORT=3001
set DATA_MODE=gateway
set GATEWAY_URL=http://127.0.0.1:38080
set DATABASE_URL=postgres://jh_lab:jh_lab_pass@127.0.0.1:5432/jh_lab
set JWT_SECRET=jh-lab-prod-change-me
set ADMIN_USERNAME=admin
set ADMIN_PASSWORD=Admin@123
..\node\node.exe dist\index.js
@echo off
cd /d D:\apps\jh-lab\platform
start "jh-lab-server" cmd /c "cd /d D:\apps\jh-lab\platform\server && start.bat"
ping -n 3 127.0.0.1 >nul
cd /d D:\apps\jh-lab\platform\nginx
start "" nginx.exe
echo Platform started: http://192.168.32.110/
@echo off
taskkill /F /IM node.exe /FI "WINDOWTITLE eq jh-lab-server*" >nul 2>nul
taskkill /F /IM node.exe >nul 2>nul
cd /d D:\apps\jh-lab\platform\nginx
nginx.exe -s quit 2>nul
taskkill /F /IM nginx.exe >nul 2>nul
echo stopped
<!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>
import{d as f,u as h,c as n,b as t,t as e,h as i,F as u,B as c,o,l as b}from"./index-BRA9HJ0O.js";import{u as v}from"./realtime-BbiS_VD3.js";const _={class:"page-fill",style:{"flex-direction":"row"}},y={class:"panel",style:{flex:"1",overflow:"auto"}},k={class:"panel-head"},g={class:"badge"},w={class:"table"},x=["disabled","onClick"],S={class:"panel",style:{flex:"1",overflow:"auto"}},C={class:"table"},z=f({__name:"AccessView",setup(N){const r=h(),d=v();function p(a){return new Date(a).toLocaleString("zh-CN",{hour12:!1})}async function m(a){if(!r.isAdmin)return;const s=await b("/api/realtime/access/open",{method:"POST",token:r.token,body:JSON.stringify({doorId:a})});alert(s.message)}return(a,s)=>(o(),n("div",_,[t("section",y,[t("div",k,[s[0]||(s[0]=t("div",{class:"panel-title"},[t("div",{class:"vbar"}),t("h3",null,"门禁状态")],-1)),t("div",g,"今日通行 "+e(i(d).snapshot.access.todayPassCount),1)]),t("table",w,[s[1]||(s[1]=t("thead",null,[t("tr",null,[t("th",null,"门点"),t("th",null,"状态"),t("th",null,"操作")])],-1)),t("tbody",null,[(o(!0),n(u,null,c(i(d).snapshot.access.doors,l=>(o(),n("tr",{key:l.id},[t("td",null,e(l.name),1),t("td",null,e({open:"开启",closed:"关闭",alarm:"报警",offline:"离线"}[l.status]),1),t("td",null,[t("button",{class:"btn",disabled:!i(r).isAdmin,onClick:A=>m(l.id)},"远程开门",8,x)])]))),128))])]),s[2]||(s[2]=t("p",{style:{"margin-top":"12px","font-size":"12px",color:"var(--label)"}}," 协议:海康 HCNetSDK(Windows 桥接)。一期为 Mock,二期接入桥接服务。 ",-1))]),t("section",S,[s[4]||(s[4]=t("div",{class:"panel-head"},[t("div",{class:"panel-title"},[t("div",{class:"vbar"}),t("h3",null,"通行记录")])],-1)),t("table",C,[s[3]||(s[3]=t("thead",null,[t("tr",null,[t("th",null,"时间"),t("th",null,"门点"),t("th",null,"人员"),t("th",null,"结果")])],-1)),t("tbody",null,[(o(!0),n(u,null,c(i(d).snapshot.access.recentEvents,l=>(o(),n("tr",{key:l.id},[t("td",null,e(p(l.time)),1),t("td",null,e(l.doorName),1),t("td",null,e(l.person),1),t("td",null,e({success:"成功",denied:"拒绝",alarm:"报警"}[l.result]),1)]))),128))])])])]))}});export{z as default};
import{d as w,u as z,c as i,b as s,a as B,t as e,h as r,F as h,B as m,z as N,y as R,i as S,o as a,s as x,C as E,n as I,E as v,I as O}from"./index-BRA9HJ0O.js";import{u as V}from"./useClock-FtQkLZqk.js";const D={class:"tops-root"},H={class:"tops-header"},F={class:"tops-header__chips"},W={class:"tops-chip tops-tone--ok"},$={class:"tops-header__end"},j={class:"tops-clock"},J={class:"tops-body"},P={class:"tops-side","aria-label":"管理菜单"},T={class:"tops-menu"},U={class:"tops-menu__icon","aria-hidden":"true"},q={viewBox:"0 0 18 18",fill:"none",stroke:"currentColor","stroke-width":"1.3"},G=["d"],K={class:"tops-menu__copy"},Q={class:"tops-menu__label"},X={class:"tops-menu__hint"},Y={class:"tops-main"},os=w({__name:"AdminLayout",setup(Z){const n=z(),c=S(),d=O(),{time:b}=V(),k={devices:["M3 4.2h12v9.6H3z","M6.2 15.4h5.6","M7.4 13.8v1.6","M10.6 13.8v1.6"],cameras:["M3.2 6.2h8.2l1.6-1.8h3.2v11.2H3.2z","M9.2 11.2a2.2 2.2 0 1 0 0-4.4 2.2 2.2 0 0 0 0 4.4z"],users:["M6.6 8.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2z","M12.2 8.4a2.1 2.1 0 1 0 0-4.2","M2 15.4c0-2.4 2.1-3.8 4.6-3.8s4.6 1.4 4.6 3.8","M13.6 15.4c0-1.7-.8-2.9-2.2-3.4"],settings:["M9 11.2a2.2 2.2 0 1 0 0-4.4 2.2 2.2 0 0 0 0 4.4z","M9 2.4l1.1 1.8 2.1-.2.9 1.9 1.8 1.1-.2 2.1 1.8 1.1-1.8 1.1.2 2.1-1.8 1.1-.9 1.9-2.1-.2L9 15.6l-1.1 1.8-2.1.2-.9-1.9-1.8-1.1.2-2.1L1.5 11l1.8-1.1-.2-2.1 1.8-1.1.9-1.9 2.1.2L9 2.4z"]},g=R(()=>[{to:"/admin/devices",label:"设备管理",hint:"点位 / 网关",tone:"accent",icon:"devices"},{to:"/admin/cameras",label:"摄像头管理",hint:"列表 / 三维打点",tone:"accent",icon:"cameras"},{to:"/admin/history",label:"历史曲线",hint:"时序 / 指标",tone:"accent",icon:"devices"},{to:"/admin/users",label:"用户管理",hint:"账号 / 角色",tone:"warn",icon:"users",adminOnly:!0},{to:"/admin/settings",label:"系统设置",hint:"采集 / 模式",tone:"muted",icon:"settings"}].filter(l=>!l.adminOnly||n.isAdmin));function M(l){return d.path===l||d.path.startsWith(`${l}/`)}function y(){n.logout(),c.push("/login")}function u(){c.push("/dashboard")}return(l,t)=>{var p,_;const f=v("RouterLink"),C=v("RouterView");return a(),i("div",D,[s("header",H,[s("div",{class:"tops-brand",onClick:u},[...t[0]||(t[0]=[B('<span class="tops-brand__mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 12c0-2.5 2-4.5 4.5-4.5S21 9.4 21 12c0 4.4-3.6 8-8 8-5 0-9-4-9-9 0-5.5 4.5-10 10-10" stroke-linecap="round"></path><circle cx="12" cy="12" r="1.7"></circle></svg></span><span class="tops-brand__copy"><strong>管理后台</strong><small>ADMIN CONSOLE</small></span>',2)])]),s("div",F,[t[3]||(t[3]=s("span",{class:"tops-chip"},[s("small",null,"系统"),s("b",null,"JH-LAB")],-1)),s("span",W,[t[1]||(t[1]=s("span",{class:"tops-dot","aria-hidden":"true"},null,-1)),t[2]||(t[2]=s("small",null,"角色",-1)),s("b",null,e(r(n).isAdmin?"ADMIN":"VIEWER"),1)])]),s("div",$,[s("div",j,[s("small",null,e(((p=r(n).user)==null?void 0:p.displayName)||((_=r(n).user)==null?void 0:_.username))+" · "+e(r(n).isAdmin?"管理员":"只读"),1),s("b",null,e(r(b)),1)]),s("button",{type:"button",class:"tops-logout",onClick:u},"返回大屏"),s("button",{type:"button",class:"tops-logout",onClick:y},"退出")])]),s("div",J,[s("nav",P,[t[4]||(t[4]=s("div",{class:"tops-side__head"},[s("strong",null,"菜单"),s("small",null,"MODULES")],-1)),s("div",T,[(a(!0),i(h,null,m(g.value,o=>(a(),x(f,{key:o.to,to:o.to,class:I(["tops-menu__item",[`tops-tone--${o.tone}`,{"is-on":M(o.to)}]])},{default:E(()=>[s("span",U,[(a(),i("svg",q,[(a(!0),i(h,null,m(k[o.icon],(A,L)=>(a(),i("path",{key:L,d:A,"stroke-linejoin":"round"},null,8,G))),128))]))]),s("span",K,[s("span",Q,e(o.label),1),s("span",X,e(o.hint),1)])]),_:2},1032,["to","class"]))),128))]),t[5]||(t[5]=s("div",{class:"tops-side__foot"},[s("span",{class:"tops-dot tops-tone--ok","aria-hidden":"true"}),s("span",null,"协议点表已接入")],-1))]),s("main",Y,[N(C)])])])}}});export{os as default};
import{d as s,o as i,c as n,n as o,b as e,a as r,f as d,_ as c}from"./index-BRA9HJ0O.js";const k=["stroke"],h=s({__name:"AppIcon",props:{name:{},className:{default:""}},setup(t){return(F,l)=>{var a;return t.name==="logo"?(i(),n("svg",{key:0,class:o(["icon",t.className]),viewBox:"0 0 34 34",fill:"none"},[...l[0]||(l[0]=[e("path",{d:"M17 3.4 29.5 10.6v12.8L17 30.6 4.5 23.4V10.6L17 3.4Z",stroke:"#37C8FF","stroke-width":"1.6","stroke-linejoin":"round"},null,-1),e("path",{d:"M17 3.4v13.4M17 16.8 29.5 10.6M17 16.8 4.5 23.4",stroke:"#37C8FF","stroke-width":"1.2",opacity:".7"},null,-1),e("circle",{cx:"17",cy:"16.8",r:"2.8",fill:"#2EE6A8"},null,-1)])],2)):t.name==="panel-env"?(i(),n("svg",{key:1,class:o(["icon p-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[1]||(l[1]=[e("rect",{width:"24",height:"24",rx:"7",fill:"#37C8FF","fill-opacity":".14"},null,-1),e("rect",{x:".75",y:".75",width:"22.5",height:"22.5",rx:"6.25",stroke:"#37C8FF","stroke-opacity":".45"},null,-1),e("path",{d:"M10 13.8V5a2 2 0 1 1 4 0v8.8a4.5 4.5 0 1 1-4 0Z",stroke:"#5BD4FF","stroke-width":"1.8","stroke-linecap":"round","stroke-linejoin":"round"},null,-1),e("circle",{cx:"12",cy:"17",r:"1.6",fill:"#5BD4FF"},null,-1)])],2)):t.name==="panel-vav"?(i(),n("svg",{key:2,class:o(["icon p-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[2]||(l[2]=[r('<rect width="24" height="24" rx="7" fill="#37C8FF" fill-opacity=".14" data-v-682d4798></rect><rect x=".75" y=".75" width="22.5" height="22.5" rx="6.25" stroke="#37C8FF" stroke-opacity=".45" data-v-682d4798></rect><circle cx="12" cy="12" r="7.4" stroke="#5BD4FF" stroke-width="1.7" data-v-682d4798></circle><circle cx="12" cy="12" r="1.7" fill="#5BD4FF" data-v-682d4798></circle><path d="M12 10.3V5.2M13.5 12.9l4.4 2.4M10.5 12.9l-4.4 2.4" stroke="#5BD4FF" stroke-width="1.7" stroke-linecap="round" data-v-682d4798></path>',5)])],2)):t.name==="panel-cold"?(i(),n("svg",{key:3,class:o(["icon p-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[3]||(l[3]=[e("rect",{width:"24",height:"24",rx:"7",fill:"#37C8FF","fill-opacity":".14"},null,-1),e("rect",{x:".75",y:".75",width:"22.5",height:"22.5",rx:"6.25",stroke:"#37C8FF","stroke-opacity":".45"},null,-1),e("path",{d:"M12 3.5v17M4.6 7.75l14.8 8.5M19.4 7.75l-14.8 8.5",stroke:"#5BD4FF","stroke-width":"1.8","stroke-linecap":"round"},null,-1)])],2)):t.name==="panel-hvac"?(i(),n("svg",{key:4,class:o(["icon p-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[4]||(l[4]=[e("rect",{width:"24",height:"24",rx:"7",fill:"#37C8FF","fill-opacity":".14"},null,-1),e("rect",{x:".75",y:".75",width:"22.5",height:"22.5",rx:"6.25",stroke:"#37C8FF","stroke-opacity":".45"},null,-1),e("path",{d:"M3 8.5h9a2.2 2.2 0 1 0-2.2-2.2M3 12h13a2.2 2.2 0 1 1-2.2 2.2M3 15.5h6",stroke:"#5BD4FF","stroke-width":"1.8","stroke-linecap":"round"},null,-1)])],2)):t.name==="panel-alarm"?(i(),n("svg",{key:5,class:o(["icon p-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[5]||(l[5]=[e("rect",{width:"24",height:"24",rx:"7",fill:"#FF5252","fill-opacity":".12"},null,-1),e("rect",{x:".75",y:".75",width:"22.5",height:"22.5",rx:"6.25",stroke:"#FF5252","stroke-opacity":".45"},null,-1),e("path",{d:"M12 3.5a4.5 4.5 0 0 0-4.5 4.5v2.8L6 13.5h12l-1.5-2.7V8A4.5 4.5 0 0 0 12 3.5Z",stroke:"#FF7B7B","stroke-width":"1.8","stroke-linejoin":"round"},null,-1),e("path",{d:"M10 16.5a2 2 0 0 0 4 0",stroke:"#FF7B7B","stroke-width":"1.8","stroke-linecap":"round"},null,-1)])],2)):t.name==="panel-video"?(i(),n("svg",{key:6,class:o(["icon p-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[6]||(l[6]=[e("rect",{width:"24",height:"24",rx:"7",fill:"#37C8FF","fill-opacity":".14"},null,-1),e("rect",{x:".75",y:".75",width:"22.5",height:"22.5",rx:"6.25",stroke:"#37C8FF","stroke-opacity":".45"},null,-1),e("rect",{x:"3",y:"7",width:"12",height:"10",rx:"2",stroke:"#5BD4FF","stroke-width":"1.8"},null,-1),e("path",{d:"M15 10.8 20 8v8l-5-2.8",stroke:"#5BD4FF","stroke-width":"1.8","stroke-linejoin":"round"},null,-1)])],2)):t.name==="metric-pressure"?(i(),n("svg",{key:7,class:o(["icon m-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[7]||(l[7]=[e("rect",{width:"24",height:"24",rx:"7",fill:"#2EE6A8","fill-opacity":".10"},null,-1),e("path",{d:"M4.5 15a7.5 7.5 0 0 1 15 0",stroke:"#5BD4FF","stroke-width":"2","stroke-linecap":"round"},null,-1),e("path",{d:"M12 15l3.2-3.8",stroke:"#5BD4FF","stroke-width":"2","stroke-linecap":"round"},null,-1),e("circle",{cx:"12",cy:"15",r:"1.5",fill:"#2EE6A8"},null,-1)])],2)):t.name==="metric-temp"?(i(),n("svg",{key:8,class:o(["icon m-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[8]||(l[8]=[e("rect",{width:"24",height:"24",rx:"7",fill:"#37C8FF","fill-opacity":".12"},null,-1),e("path",{d:"M10 13.8V5a2 2 0 1 1 4 0v8.8a4.5 4.5 0 1 1-4 0Z",stroke:"#5BD4FF","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},null,-1),e("circle",{cx:"12",cy:"17",r:"1.7",fill:"#5BD4FF"},null,-1)])],2)):t.name==="metric-humidity"?(i(),n("svg",{key:9,class:o(["icon m-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[9]||(l[9]=[e("rect",{width:"24",height:"24",rx:"7",fill:"#37C8FF","fill-opacity":".12"},null,-1),e("path",{d:"M12 3.5s5.6 6.2 5.6 10a5.6 5.6 0 1 1-11.2 0c0-3.8 5.6-10 5.6-10Z",stroke:"#5BD4FF","stroke-width":"2","stroke-linejoin":"round"},null,-1)])],2)):t.name==="metric-air"?(i(),n("svg",{key:10,class:o(["icon m-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[10]||(l[10]=[e("rect",{width:"24",height:"24",rx:"7",fill:"#37C8FF","fill-opacity":".12"},null,-1),e("path",{d:"M4.5 12a7.5 7.5 0 0 1 13-5.1M19.5 12a7.5 7.5 0 0 1-13 5.1",stroke:"#5BD4FF","stroke-width":"2","stroke-linecap":"round"},null,-1),e("path",{d:"M17.5 3.5v3.6h-3.6M6.5 20.5v-3.6h3.6",stroke:"#5BD4FF","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},null,-1)])],2)):t.name==="vav-speed"?(i(),n("svg",{key:11,class:o(["icon v-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[11]||(l[11]=[e("path",{d:"M3 9h9.5a2.4 2.4 0 1 0-2.4-2.4M3 13h13.5a2.4 2.4 0 1 1-2.4 2.4M3 17h6",stroke:"#5BD4FF","stroke-width":"2.2","stroke-linecap":"round"},null,-1)])],2)):t.name==="vav-sash"?(i(),n("svg",{key:12,class:o(["icon v-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[12]||(l[12]=[e("path",{d:"M12 3.5v17M8.2 7.2 12 3.5l3.8 3.7M8.2 16.8l3.8 3.7 3.8-3.7",stroke:"#5BD4FF","stroke-width":"2.2","stroke-linecap":"round","stroke-linejoin":"round"},null,-1)])],2)):t.name==="vav-valve"?(i(),n("svg",{key:13,class:o(["icon v-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[13]||(l[13]=[e("circle",{cx:"12",cy:"12",r:"8",stroke:"#5BD4FF","stroke-width":"2.2"},null,-1),e("path",{d:"M12 7v5.2l3.8 2.6",stroke:"#5BD4FF","stroke-width":"2.2","stroke-linecap":"round","stroke-linejoin":"round"},null,-1)])],2)):t.name==="fridge"?(i(),n("svg",{key:14,class:o(["icon f-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[e("path",{d:"M12 3.5v17M4.6 7.75l14.8 8.5M19.4 7.75l-14.8 8.5",stroke:(a=t.className)!=null&&a.includes("warn")?"#FFCF5C":"#7FD8FF","stroke-width":"2.4","stroke-linecap":"round"},null,8,k)],2)):t.name==="alarm-warn"?(i(),n("svg",{key:15,class:o(["icon a-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[14]||(l[14]=[e("path",{d:"M12 3.5 2.8 19.5h18.4L12 3.5Z",stroke:"#FF6B6B","stroke-width":"2","stroke-linejoin":"round"},null,-1),e("path",{d:"M12 9.5v4.5",stroke:"#FF6B6B","stroke-width":"2","stroke-linecap":"round"},null,-1),e("circle",{cx:"12",cy:"16.8",r:"1.1",fill:"#FF6B6B"},null,-1)])],2)):t.name==="alarm-amber"?(i(),n("svg",{key:16,class:o(["icon a-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[15]||(l[15]=[e("path",{d:"M12 3.5 2.8 19.5h18.4L12 3.5Z",stroke:"#FFCF5C","stroke-width":"2","stroke-linejoin":"round"},null,-1),e("path",{d:"M12 9.5v4.5",stroke:"#FFCF5C","stroke-width":"2","stroke-linecap":"round"},null,-1),e("circle",{cx:"12",cy:"16.8",r:"1.1",fill:"#FFCF5C"},null,-1)])],2)):t.name==="alarm-ok"?(i(),n("svg",{key:17,class:o(["icon a-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[16]||(l[16]=[e("circle",{cx:"12",cy:"12",r:"8.3",stroke:"#2EE6A8","stroke-width":"2"},null,-1),e("path",{d:"M8.2 12.4l2.6 2.6 5-5.4",stroke:"#2EE6A8","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},null,-1)])],2)):t.name==="cam-badge"?(i(),n("svg",{key:18,class:o(["icon corner",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[17]||(l[17]=[r('<rect width="24" height="24" rx="6" fill="#04101F" fill-opacity=".6" data-v-682d4798></rect><rect x=".5" y=".5" width="23" height="23" rx="5.5" stroke="#37C8FF" stroke-opacity=".45" data-v-682d4798></rect><rect x="4.5" y="8" width="10.5" height="8.5" rx="1.6" stroke="#D6ECFF" stroke-width="1.5" data-v-682d4798></rect><path d="M15 10.8l4.5-2.3v7.5L15 13.7" stroke="#D6ECFF" stroke-width="1.5" stroke-linejoin="round" data-v-682d4798></path><circle cx="8" cy="5.6" r="1.6" fill="#FF5252" data-v-682d4798></circle>',5)])],2)):t.name==="pin"?(i(),n("svg",{key:19,class:o(["icon pin",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[18]||(l[18]=[e("path",{d:"M12 21s-6.6-5.7-6.6-10.4a6.6 6.6 0 1 1 13.2 0C18.6 15.3 12 21 12 21Z",stroke:"#5BD4FF","stroke-width":"2","stroke-linejoin":"round"},null,-1),e("circle",{cx:"12",cy:"10.4",r:"2.3",stroke:"#5BD4FF","stroke-width":"2"},null,-1)])],2)):t.name==="hand"?(i(),n("svg",{key:20,class:o(["icon",t.className]),viewBox:"0 0 24 24",fill:"none",stroke:"#5BD4FF","stroke-width":"2","stroke-linecap":"round"},[...l[19]||(l[19]=[e("path",{d:"M9 11.5V6.5a1.5 1.5 0 0 1 3 0v4m0-1.5a1.5 1.5 0 0 1 3 0v1m0 0a1.5 1.5 0 0 1 3 0v4.5a6 6 0 0 1-6 6h-.8a6 6 0 0 1-4.9-2.5L4 15.6a1.6 1.6 0 0 1 2.5-2l1.5 1.9v-7a1.5 1.5 0 0 1 3 0"},null,-1)])],2)):t.name==="kpi-power"?(i(),n("svg",{key:21,class:o(["icon k-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[20]||(l[20]=[e("rect",{width:"24",height:"24",rx:"6",fill:"#FFB400","fill-opacity":".10"},null,-1),e("rect",{x:".75",y:".75",width:"22.5",height:"22.5",rx:"5.25",stroke:"#FFB400","stroke-opacity":".40"},null,-1),e("path",{d:"M13 2.5 5.5 13.5H11l-1.5 8L17 10.5h-5.5L13 2.5Z",stroke:"#FFCF5C","stroke-width":"1.8","stroke-linejoin":"round"},null,-1)])],2)):t.name==="kpi-water"?(i(),n("svg",{key:22,class:o(["icon k-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[21]||(l[21]=[e("rect",{width:"24",height:"24",rx:"6",fill:"#37C8FF","fill-opacity":".10"},null,-1),e("rect",{x:".75",y:".75",width:"22.5",height:"22.5",rx:"5.25",stroke:"#37C8FF","stroke-opacity":".35"},null,-1),e("path",{d:"M12 3s6 6.6 6 10.7a6 6 0 1 1-12 0C6 9.6 12 3 12 3Z",stroke:"#5BD4FF","stroke-width":"1.8","stroke-linejoin":"round"},null,-1),e("path",{d:"M9.3 14a2.7 2.7 0 0 0 2.7 2.7",stroke:"#5BD4FF","stroke-width":"1.5","stroke-linecap":"round"},null,-1)])],2)):t.name==="kpi-cool"?(i(),n("svg",{key:23,class:o(["icon k-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[22]||(l[22]=[e("rect",{width:"24",height:"24",rx:"6",fill:"#67E8F9","fill-opacity":".10"},null,-1),e("rect",{x:".75",y:".75",width:"22.5",height:"22.5",rx:"5.25",stroke:"#67E8F9","stroke-opacity":".40"},null,-1),e("path",{d:"M12 2.5v19M3.7 7.25l16.6 9.5M20.3 7.25 3.7 16.75",stroke:"#A5F3FC","stroke-width":"1.7","stroke-linecap":"round"},null,-1)])],2)):t.name==="kpi-device"?(i(),n("svg",{key:24,class:o(["icon k-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[23]||(l[23]=[r('<rect width="24" height="24" rx="6" fill="#34D399" fill-opacity=".10" data-v-682d4798></rect><rect x=".75" y=".75" width="22.5" height="22.5" rx="5.25" stroke="#34D399" stroke-opacity=".40" data-v-682d4798></rect><rect x="3" y="4" width="18" height="7" rx="1.8" stroke="#6EE7B7" stroke-width="1.7" data-v-682d4798></rect><rect x="3" y="13" width="18" height="7" rx="1.8" stroke="#6EE7B7" stroke-width="1.7" data-v-682d4798></rect><circle cx="7" cy="7.5" r="1.3" fill="#34D399" data-v-682d4798></circle><circle cx="7" cy="16.5" r="1.3" fill="#34D399" data-v-682d4798></circle>',6)])],2)):t.name==="kpi-access"?(i(),n("svg",{key:25,class:o(["icon k-icon",t.className]),viewBox:"0 0 24 24",fill:"none"},[...l[24]||(l[24]=[r('<rect width="24" height="24" rx="6" fill="#A78BFA" fill-opacity=".14" data-v-682d4798></rect><rect x=".75" y=".75" width="22.5" height="22.5" rx="5.25" stroke="#A78BFA" stroke-opacity=".5" data-v-682d4798></rect><path d="M6.5 4.5h8.2a1.8 1.8 0 0 1 1.8 1.8v11.4a1.8 1.8 0 0 1-1.8 1.8H6.5V4.5Z" stroke="#C4B5FD" stroke-width="1.7" stroke-linejoin="round" data-v-682d4798></path><circle cx="13.2" cy="12" r="1.15" fill="#C4B5FD" data-v-682d4798></circle><path d="M4.8 8.2h2.2M4.8 12h2.2M4.8 15.8h2.2" stroke="#C4B5FD" stroke-width="1.5" stroke-linecap="round" data-v-682d4798></path>',5)])],2)):d("",!0)}}}),u=c(h,[["__scopeId","data-v-682d4798"]]);export{u as A};
.icon[data-v-682d4798]{display:block;flex:0 0 auto}.logo[data-v-682d4798]{width:34px;height:34px}.p-icon[data-v-682d4798]{width:18px;height:18px}.m-icon[data-v-682d4798]{width:20px;height:20px}.v-icon[data-v-682d4798]{width:15px;height:15px}.f-icon[data-v-682d4798]{width:14px;height:14px}.a-icon[data-v-682d4798]{width:18px;height:18px}.corner[data-v-682d4798]{width:22px;height:22px}.pin[data-v-682d4798]{width:16px;height:16px}.k-icon[data-v-682d4798]{width:40px;height:40px}
.mask[data-v-980a3158]{position:fixed;top:0;right:0;bottom:0;left:0;z-index:80;background:#000814b8;display:grid;place-items:center;padding:24px}.dialog[data-v-980a3158]{width:min(1440px,98vw);height:min(860px,94vh);display:flex;flex-direction:column;background:#071426;border:1px solid rgba(0,247,255,.28);border-radius:8px;overflow:hidden;box-shadow:0 20px 60px #00000073}.head[data-v-980a3158]{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid rgba(0,247,255,.16)}.head h3[data-v-980a3158]{margin:0;font-size:16px;color:#f4feff;letter-spacing:.06em}.hint[data-v-980a3158]{margin:0;padding:8px 16px;font-size:12px;color:#7eb6d6;background:#00284859}.body[data-v-980a3158]{flex:1;min-height:0;display:grid;grid-template-columns:280px 1fr}.icon-pane[data-v-980a3158]{display:flex;flex-direction:column;min-height:0;border-right:1px solid rgba(0,247,255,.14);background:#001024a6}.icon-head[data-v-980a3158]{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px;font-size:12px;color:#f4feff;border-bottom:1px solid rgba(0,247,255,.12)}.icon-search[data-v-980a3158]{width:120px;height:28px;border-radius:4px;border:1px solid rgba(0,247,255,.25);background:#00000059;color:#d7f4ff;padding:0 8px;font-size:12px}.icon-err[data-v-980a3158]{margin:8px 12px 0;font-size:12px;color:#ffb0b0}.icon-grid[data-v-980a3158]{flex:1;min-height:0;overflow:auto;padding:8px;display:grid;grid-template-columns:1fr 1fr;gap:6px;align-content:start}.icon-item[data-v-980a3158]{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 4px;border-radius:6px;border:1px solid rgba(0,247,255,.16);background:#081c3873;color:#9ec9e0;cursor:pointer;font-size:10px}.icon-item img[data-v-980a3158]{width:36px;height:36px;object-fit:contain}.icon-item span[data-v-980a3158]{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.icon-item[data-v-980a3158]:hover{border-color:#00f7ff73}.icon-item.active[data-v-980a3158]{border-color:#00f7ff;background:#0064a059;color:#fff;box-shadow:0 0 0 1px #00f7ff40 inset}.icon-current[data-v-980a3158]{display:flex;align-items:center;gap:8px;padding:8px 12px;border-top:1px solid rgba(0,247,255,.12);font-size:11px;color:#7eb6d6}.icon-current img[data-v-980a3158]{width:22px;height:22px;object-fit:contain}.icon-current b[data-v-980a3158]{color:#f4feff;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-wrap[data-v-980a3158]{min-height:0;background:#000}.frame[data-v-980a3158]{width:100%;height:100%;border:0;display:block}.foot[data-v-980a3158]{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px;border-top:1px solid rgba(0,247,255,.16)}.coords[data-v-980a3158]{font-family:var(--digital-font);font-size:13px;color:var(--accent)}.coords.muted[data-v-980a3158]{color:var(--panel-muted)}.actions[data-v-980a3158]{display:flex;gap:8px}.mask[data-v-edc21f1c]{position:fixed;top:0;right:0;bottom:0;left:0;z-index:330;background:#000814a6;display:grid;place-items:center;padding:24px;pointer-events:auto}.dialog[data-v-edc21f1c]{width:min(960px,92vw);background:#071426;border:1px solid rgba(0,247,255,.28);border-radius:8px;overflow:hidden;box-shadow:0 20px 60px #00000073}.head[data-v-edc21f1c]{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid rgba(0,247,255,.16)}.head h3[data-v-edc21f1c]{margin:0;font-size:16px;color:#f4feff}.body[data-v-edc21f1c]{padding:12px;min-height:360px;background:#000}.empty[data-v-edc21f1c]{min-height:320px;display:grid;place-items:center;color:#b9d6f08c;font-size:13px}.btn[data-v-edc21f1c]{border:1px solid rgba(0,247,255,.28);background:transparent;color:#d7f7ff;border-radius:4px;padding:6px 12px;cursor:pointer;font-size:12px}.btn[data-v-edc21f1c]:hover{border-color:#00f7ff8c}.cameras-page[data-v-29152d20]{min-height:0}.list-panel[data-v-29152d20]{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}.head-actions[data-v-29152d20]{display:flex;gap:8px}.create-row[data-v-29152d20]{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;margin-bottom:12px}.create-row .field[data-v-29152d20]{min-width:120px}.table-wrap[data-v-29152d20]{flex:1;min-height:0;overflow:auto}.ops[data-v-29152d20]{white-space:nowrap}.ops .btn[data-v-29152d20]{margin-right:4px}.mono[data-v-29152d20]{font-family:var(--digital-font);font-size:12px}.st[data-v-29152d20]{display:inline-block;padding:1px 6px;border-radius:2px;font-size:11px;margin-right:4px}.st.online[data-v-29152d20]{background:#52c41a26;color:#7dff9a}.st.offline[data-v-29152d20]{background:#8ea3b826;color:#9bb0c4}.st.alarm[data-v-29152d20]{background:#ff4d4f26;color:#ffb0b0}.st.off[data-v-29152d20]{background:#faad141f;color:#ffd666}.empty[data-v-29152d20]{text-align:center;color:var(--panel-muted);padding:24px}.msg.ok[data-v-29152d20]{color:#7dcea0}.msg.err[data-v-29152d20]{color:#ffb0b0}.tip[data-v-29152d20]{margin-top:10px;font-size:12px;color:var(--panel-muted)}
import{d as O,r as y,p as J,j as W,q as K,c as d,w as B,b as e,t as r,e as T,v as S,f as g,F as X,B as Y,g as G,h as V,y as Z,o as a,n as j,_ as L,s as H,T as Q,u as ee,L as te,z as q,K as ne,l as x}from"./index-BRA9HJ0O.js";import{u as se,d as le,r as oe,p as ie,f as ae}from"./useRtengineBridge-y7e7bneC.js";import{W as ce}from"./WebRtcPlayer-CbYWvtn2.js";const ue={class:"dialog"},de={class:"head"},re={class:"hint"},ve={class:"body"},pe={class:"icon-pane"},me={class:"icon-head"},fe={key:0,class:"icon-err"},ye={class:"icon-grid"},be=["title","onClick"],ke=["src","alt"],ge={class:"icon-current"},he=["src"],_e={class:"frame-wrap"},$e=["src"],Ce={class:"foot"},we={key:0,class:"coords"},Pe={key:1,class:"coords muted"},xe={class:"actions"},Te=["disabled"],Ee=O({__name:"ScenePickDialog",props:{open:{type:Boolean},title:{},initial:{}},emits:["close","save"],setup(_,{emit:v}){var I;const f=_,k=v,i=le(),h=y(null),s=y(f.initial?{...f.initial}:null),p=y(((I=f.initial)==null?void 0:I.icon)||"cctv-blue.png"),m=y([]),C=y(""),P=y(""),$=y("场景加载后,点击地面拾取摄像头位置"),A=["cctv-blue.png","cctv-green.png","cctv-orange.png","cctv-red.png","cctv.png","ai-camera-blue.png","ai-camera-green.png","ai-camera-orange.png","ai-camera-red.png"],M=Z(()=>{const u=P.value.trim().toLowerCase();let l=m.value.slice();return l.sort((c,n)=>{const t=A.indexOf(c),w=A.indexOf(n);return t>=0||w>=0?(t<0?999:t)-(w<0?999:w):c.localeCompare(n)}),u&&(l=l.filter(c=>c.toLowerCase().includes(u))),l}),b=se({iframeRef:h,cloudUrl:i,onMouseClick:({point:u,markerId:l})=>{u&&(s.value={...u,icon:p.value},$.value=l?`已拾取(对象 ${l})`:`已拾取 X=${u.x.toFixed(6)} Y=${u.y.toFixed(6)} Z=${u.z.toFixed(2)}`)},onReady:()=>{$.value="引擎已就绪 · 正在加载预设图标…",b.setPickMode(!0),b.showScenePresets(),E(!0)}});async function E(u=!1){C.value="";try{if(m.value=u?await ie(void 0,i):await ae(i),!m.value.includes(p.value)&&m.value.length){const l=m.value.find(c=>/cctv|camera/i.test(c));p.value=l||m.value[0]}u&&($.value=`已加载 ${m.value.length} 个场景预设图标 · 点击地面拾取`)}catch(l){C.value=l instanceof Error?l.message:"图标列表加载失败"}}J(()=>f.open,u=>{var l;u?(s.value=f.initial?{...f.initial}:null,p.value=((l=f.initial)==null?void 0:l.icon)||"cctv-blue.png",$.value="场景加载后,点击地面拾取摄像头位置",E(),window.setTimeout(()=>b.setPickMode(!0),1500)):b.setPickMode(!1)}),J(p,u=>{s.value&&(s.value={...s.value,icon:u})}),W(()=>{f.open&&(E(),window.setTimeout(()=>b.setPickMode(!0),1500))}),K(()=>b.setPickMode(!1));function N(){if(!s.value){$.value="请先在场景中点击拾取位置";return}k("save",{...s.value,icon:p.value})}function U(){s.value=null,$.value="已清除,请重新点击拾取"}function F(u){return oe(u,i)}return(u,l)=>_.open?(a(),d("div",{key:0,class:"mask",onClick:l[3]||(l[3]=B(c=>k("close"),["self"]))},[e("div",ue,[e("div",de,[e("h3",null,r(_.title||"三维场景打点"),1),e("button",{class:"btn ghost",type:"button",onClick:l[0]||(l[0]=c=>k("close"))},"关闭")]),e("p",re,r($.value),1),e("div",ve,[e("aside",pe,[e("div",me,[l[4]||(l[4]=e("span",null,"引擎图标",-1)),T(e("input",{"onUpdate:modelValue":l[1]||(l[1]=c=>P.value=c),class:"icon-search",placeholder:"筛选…"},null,512),[[S,P.value]])]),C.value?(a(),d("p",fe,r(C.value),1)):g("",!0),e("div",ye,[(a(!0),d(X,null,Y(M.value,c=>(a(),d("button",{key:c,type:"button",class:j(["icon-item",{active:p.value===c}]),title:c,onClick:n=>p.value=c},[e("img",{src:F(c),alt:c,loading:"lazy"},null,8,ke),e("span",null,r(c.replace(/\.png$/i,"")),1)],10,be))),128))]),e("div",ge,[l[5]||(l[5]=G(" 当前: ",-1)),p.value?(a(),d("img",{key:0,src:F(p.value),alt:""},null,8,he)):g("",!0),e("b",null,r(p.value||"—"),1)])]),e("div",_e,[e("iframe",{ref_key:"iframeRef",ref:h,class:"frame",src:V(i),title:"RTEngine pick",allow:"autoplay; fullscreen; microphone; camera; clipboard-read; clipboard-write; display-capture",allowfullscreen:"",referrerpolicy:"no-referrer-when-downgrade"},null,8,$e)])]),e("div",Ce,[s.value?(a(),d("div",we," X "+r(s.value.x.toFixed(6))+" · Y "+r(s.value.y.toFixed(6))+" · Z "+r(s.value.z.toFixed(2)),1)):(a(),d("div",Pe,"尚未拾取")),e("div",xe,[e("button",{class:"btn ghost",type:"button",onClick:U},"清除"),e("button",{class:"btn ghost",type:"button",onClick:l[2]||(l[2]=c=>k("close"))},"取消"),e("button",{class:"btn",type:"button",disabled:!s.value,onClick:N},"保存点位",8,Te)])])])])):g("",!0)}}),Se=L(Ee,[["__scopeId","data-v-980a3158"]]),Ve={class:"head"},Ae={class:"body"},Fe={key:1,class:"empty"},Ie=O({__name:"CameraPlayDialog",props:{open:{type:Boolean},deviceId:{},title:{}},emits:["close"],setup(_,{emit:v}){const f=v;return(k,i)=>(a(),H(Q,{to:"body"},[_.open?(a(),d("div",{key:0,class:"mask",onClick:i[2]||(i[2]=B(h=>f("close"),["self"]))},[e("div",{class:"dialog",onClick:i[1]||(i[1]=B(()=>{},["stop"]))},[e("div",Ve,[e("h3",null,r(_.title||"视频预览"),1),e("button",{class:"btn ghost",type:"button",onClick:i[0]||(i[0]=h=>f("close"))},"关闭")]),e("div",Ae,[_.deviceId?(a(),H(ce,{key:0,"device-id":_.deviceId},null,8,["device-id"])):(a(),d("div",Fe,"未绑定视频设备"))])])])):g("",!0)]))}}),Re=L(Ie,[["__scopeId","data-v-edc21f1c"]]),De={class:"page-fill cameras-page"},Me={class:"panel list-panel"},Ne={class:"panel-head"},Ue={class:"panel-title"},Be={class:"badge"},Oe={class:"head-actions"},Le=["disabled"],ze={key:0,class:"msg ok"},Je={key:1,class:"msg err"},He={key:2,class:"create-row"},qe={class:"field"},We={class:"field"},Xe={class:"field"},Ye={class:"field"},Ze={class:"field"},je={class:"table-wrap"},Ke={class:"table"},Ge={key:0},Qe={key:0,class:"st off"},et={class:"mono"},tt={key:0,class:"ops"},nt=["onClick"],st=["onClick"],lt=["onClick"],ot=["onClick"],it=["onClick"],at={key:0},ct=["colspan"],ut=O({__name:"CamerasView",setup(_){const v=ee(),f=y(!1),k=y(""),i=y(""),h=y([]),s=ne({name:"",channel:1,location:"",deviceType:"nvr-ch",ip:"",remark:""}),p=y(!1),m=y(null),C=y(!1),P=y(null),$={online:"在线",offline:"离线",alarm:"报警"},A={ipc:"网络摄像机","nvr-ch":"NVR 通道"},M=Z(()=>h.value.filter(n=>n.scenePoint).length);async function b(){f.value=!0,i.value="";try{const n=await x("/api/video/devices",{token:v.token});h.value=n.devices;const t=h.value.reduce((w,R)=>Math.max(w,R.channel),0);s.channel=t+1}catch(n){i.value=n instanceof Error?n.message:"加载失败"}finally{f.value=!1}}async function E(){if(v.isAdmin){k.value="",i.value="";try{await x("/api/video/devices",{method:"POST",token:v.token,body:JSON.stringify({...s})}),k.value="已新增摄像头",s.name="",s.location="",s.ip="",s.remark="",await b()}catch(n){i.value=n instanceof Error?n.message:"创建失败"}}}async function N(n){if(v.isAdmin)try{await x(`/api/video/devices/${n.id}`,{method:"PATCH",token:v.token,body:JSON.stringify({enabled:!n.enabled})}),await b()}catch(t){i.value=t instanceof Error?t.message:"更新失败"}}async function U(n){if(v.isAdmin&&confirm(`确定删除摄像头「${n.name}」?`))try{await x(`/api/video/devices/${n.id}`,{method:"DELETE",token:v.token}),k.value="已删除",await b()}catch(t){i.value=t instanceof Error?t.message:"删除失败"}}function F(n){m.value=n,p.value=!0}async function I(n){if(m.value)try{await x(`/api/video/devices/${m.value.id}`,{method:"PATCH",token:v.token,body:JSON.stringify({scenePoint:n})}),k.value=`已保存「${m.value.name}」三维点位`,p.value=!1,m.value=null,await b()}catch(t){i.value=t instanceof Error?t.message:"保存点位失败"}}async function u(n){if(v.isAdmin)try{await x(`/api/video/devices/${n.id}`,{method:"PATCH",token:v.token,body:JSON.stringify({scenePoint:null})}),k.value=`已清除「${n.name}」点位`,await b()}catch(t){i.value=t instanceof Error?t.message:"清除失败"}}function l(n){P.value=n,C.value=!0}function c(n){if(!n)return"—";const t=n.icon?` · ${n.icon}`:"";return`${n.x.toFixed(5)}, ${n.y.toFixed(5)}, ${n.z.toFixed(1)}${t}`}return W(b),(n,t)=>{var w,R,z;return a(),d("div",De,[e("section",Me,[e("div",Ne,[e("div",Ue,[t[7]||(t[7]=e("div",{class:"vbar"},null,-1)),t[8]||(t[8]=e("h3",null,"摄像头管理",-1)),e("div",Be,"已打点 "+r(M.value)+"/"+r(h.value.length),1)]),e("div",Oe,[e("button",{class:"btn ghost",type:"button",disabled:f.value,onClick:b},"刷新",8,Le)])]),k.value?(a(),d("p",ze,r(k.value),1)):g("",!0),i.value?(a(),d("p",Je,r(i.value),1)):g("",!0),V(v).isAdmin?(a(),d("div",He,[e("div",qe,[t[9]||(t[9]=e("label",null,"名称",-1)),T(e("input",{"onUpdate:modelValue":t[0]||(t[0]=o=>s.name=o),placeholder:"如:走廊东"},null,512),[[S,s.name]])]),e("div",We,[t[10]||(t[10]=e("label",null,"通道",-1)),T(e("input",{"onUpdate:modelValue":t[1]||(t[1]=o=>s.channel=o),type:"number",min:"1",max:"256"},null,512),[[S,s.channel,void 0,{number:!0}]])]),e("div",Xe,[t[11]||(t[11]=e("label",null,"安装位置",-1)),T(e("input",{"onUpdate:modelValue":t[2]||(t[2]=o=>s.location=o),placeholder:"楼层/区域"},null,512),[[S,s.location]])]),e("div",Ye,[t[13]||(t[13]=e("label",null,"类型",-1)),T(e("select",{"onUpdate:modelValue":t[3]||(t[3]=o=>s.deviceType=o)},[...t[12]||(t[12]=[e("option",{value:"nvr-ch"},"NVR 通道",-1),e("option",{value:"ipc"},"网络摄像机",-1)])],512),[[te,s.deviceType]])]),e("div",Ze,[t[14]||(t[14]=e("label",null,"IP(可选)",-1)),T(e("input",{"onUpdate:modelValue":t[4]||(t[4]=o=>s.ip=o)},null,512),[[S,s.ip]])]),e("button",{class:"btn",type:"button",onClick:E},"新增摄像头")])):g("",!0),e("div",je,[e("table",Ke,[e("thead",null,[e("tr",null,[t[15]||(t[15]=e("th",null,"名称",-1)),t[16]||(t[16]=e("th",null,"通道",-1)),t[17]||(t[17]=e("th",null,"位置",-1)),t[18]||(t[18]=e("th",null,"类型",-1)),t[19]||(t[19]=e("th",null,"状态",-1)),t[20]||(t[20]=e("th",null,"三维点位",-1)),V(v).isAdmin?(a(),d("th",Ge,"操作")):g("",!0)])]),e("tbody",null,[(a(!0),d(X,null,Y(h.value,o=>(a(),d("tr",{key:o.id},[e("td",null,r(o.name),1),e("td",null,r(o.channel),1),e("td",null,r(o.location),1),e("td",null,r(A[o.deviceType]),1),e("td",null,[e("span",{class:j(["st",o.status])},r($[o.status]),3),o.enabled?g("",!0):(a(),d("span",Qe,"停用"))]),e("td",et,r(c(o.scenePoint)),1),V(v).isAdmin?(a(),d("td",tt,[e("button",{class:"btn ghost",type:"button",onClick:D=>F(o)},"三维打点",8,nt),o.scenePoint?(a(),d("button",{key:0,class:"btn ghost",type:"button",onClick:D=>u(o)}," 清点 ",8,st)):g("",!0),e("button",{class:"btn ghost",type:"button",onClick:D=>l(o)},"预览",8,lt),e("button",{class:"btn ghost",type:"button",onClick:D=>N(o)},r(o.enabled?"停用":"启用"),9,ot),e("button",{class:"btn ghost",type:"button",onClick:D=>U(o)},"删",8,it)])):g("",!0)]))),128)),h.value.length?g("",!0):(a(),d("tr",at,[e("td",{colspan:V(v).isAdmin?7:6,class:"empty"},"暂无摄像头",8,ct)]))])])]),t[21]||(t[21]=e("p",{class:"tip"}," 三维打点:打开 RTEngine 场景后点击地面拾取坐标并保存;大屏加载后自动显示摄像头标记,点击标记可播放视频。 ",-1))]),q(Se,{open:p.value,title:m.value?`打点 · ${m.value.name}`:"三维打点",initial:((w=m.value)==null?void 0:w.scenePoint)||null,onClose:t[5]||(t[5]=o=>p.value=!1),onSave:I},null,8,["open","title","initial"]),q(Re,{open:C.value,"device-id":((R=P.value)==null?void 0:R.id)||"",title:(z=P.value)==null?void 0:z.name,onClose:t[6]||(t[6]=o=>C.value=!1)},null,8,["open","device-id","title"])])}}}),pt=L(ut,[["__scopeId","data-v-29152d20"]]);export{pt as default};
import{d as b,j as w,q as k,c as o,b as t,z as p,F as B,B as S,h as C,r as n,y as v,o as r,t as l,x as E,l as h,_ as V}from"./index-BRA9HJ0O.js";import{E as y,m as f}from"./historyChart-ytIpUdNw.js";import{u as F}from"./realtime-BbiS_VD3.js";const I={class:"page-fill cold-page"},M={class:"panel",style:{flex:"1","min-height":"0",display:"flex","flex-direction":"column",gap:"10px"}},O={class:"charts"},z={class:"chart-card"},L={class:"chart-card"},N={class:"table"},R=b({__name:"ColdView",setup(j){const _=F(),d=n([]),u=n([]),a=n(!1);let i;const g=v(()=>f(d.value,{unit:"℃",maxSeries:12})),x=v(()=>f(u.value,{unit:"%",maxSeries:12}));async function c(){a.value=!0;try{const[m,e]=await Promise.all([h("/api/realtime/history?source=fridge&metric=temp&hours=24&bucketMinutes=15"),h("/api/realtime/history?source=fridge&metric=humidity&hours=24&bucketMinutes=15")]);d.value=m.series||[],u.value=e.series||[]}finally{a.value=!1}}return w(()=>{c(),i=window.setInterval(()=>void c(),6e4)}),k(()=>{i&&window.clearInterval(i)}),(m,e)=>(r(),o("div",I,[t("section",M,[e[3]||(e[3]=t("div",{class:"panel-head"},[t("div",{class:"panel-title"},[t("div",{class:"vbar"}),t("h3",null,"冷链监测 · 历史曲线")]),t("div",{class:"badge"},"E栋 · 近 24h")],-1)),t("div",O,[t("div",z,[e[0]||(e[0]=t("div",{class:"chart-title"},"温度 ℃",-1)),p(y,{option:g.value,loading:a.value,"empty-text":"温度历史积累中"},null,8,["option","loading"])]),t("div",L,[e[1]||(e[1]=t("div",{class:"chart-title"},"湿度 %RH",-1)),p(y,{option:x.value,loading:a.value,"empty-text":"湿度历史积累中"},null,8,["option","loading"])])]),t("table",N,[e[2]||(e[2]=t("thead",null,[t("tr",null,[t("th",null,"设备"),t("th",null,"温度 (℃)"),t("th",null,"湿度 (%)"),t("th",null,"状态")])],-1)),t("tbody",null,[(r(!0),o(B,null,S(C(_).snapshot.fridges,s=>(r(),o("tr",{key:s.id},[t("td",null,l(s.name),1),t("td",{style:E({color:s.temp>-19?"var(--amber)":"var(--green)"})},l(s.temp.toFixed(1))+" ℃ ",5),t("td",null,l(s.humidity)+" %",1),t("td",null,l(s.temp>-19?"超限":"正常"),1)]))),128))])])])]))}}),P=V(R,[["__scopeId","data-v-00aa9c1c"]]);export{P as default};
.cold-page[data-v-00aa9c1c]{display:flex;min-height:0}.charts[data-v-00aa9c1c]{display:grid;grid-template-columns:1fr 1fr;gap:10px;min-height:260px;flex:1}.chart-card[data-v-00aa9c1c]{min-height:0;display:flex;flex-direction:column;border:1px solid rgba(0,247,255,.16);border-radius:6px;padding:8px;background:#04102059}.chart-title[data-v-00aa9c1c]{font-size:12px;color:var(--label);margin-bottom:4px}.chart-card[data-v-00aa9c1c] .echart-wrap{flex:1;min-height:200px}
import{d as Ue,u as _e,j as Ae,k as Se,c as a,b as e,t as n,h as U,f as v,F as S,n as j,e as r,L,B as T,w as N,J as re,g as ve,v as y,a as De,r as w,K as Y,y as oe,l as D,o,X as me,_ as $e}from"./index-BRA9HJ0O.js";const Te={class:"page-fill devices-page"},Ne={class:"panel list-panel"},Oe={class:"panel-head"},Le={class:"panel-title"},Pe={class:"badge"},Ee={class:"head-actions"},He=["disabled"],xe={class:"tab-row"},Re={key:0,class:"filters"},Fe={class:"field"},Me=["value"],Ge={class:"field"},Be=["value"],Ke={class:"filter-hint"},Je={key:1,class:"msg ok"},We={key:2,class:"msg err"},je={class:"table-wrap"},ze={key:0,class:"table"},Xe=["onClick"],qe={class:"mono"},Qe={class:"mono"},Ye={key:0},Ze={key:1,class:"table"},et={key:0},tt={key:1},lt={class:"mono"},st={key:0},nt={key:0,class:"write-cell"},at=["disabled","onClick"],ot=["disabled","onClick"],it={key:1,class:"write-cell"},ut=["value","disabled","onInput","onKeydown"],dt=["disabled","onClick"],rt={key:2,class:"muted"},vt={key:1,class:"ops"},mt=["onClick"],bt=["onClick"],pt={key:0},yt=["colspan"],ct={class:"gw-status"},ft={class:"table"},gt={class:"modal panel modal-xl"},kt={class:"panel-head"},wt={class:"panel-title"},It={class:"badge"},Ct={class:"device-meta"},ht={class:"mono"},Vt={key:0},Ut={class:"mono"},_t={key:0,class:"msg err"},At={key:1,class:"msg ok"},St={class:"detail-table-wrap"},Dt={class:"table"},$t={key:0},Tt={key:1},Nt={class:"mono ts"},Ot={key:0},Lt={key:0,class:"write-cell"},Pt=["disabled","onClick"],Et=["disabled","onClick"],Ht={key:1,class:"write-cell"},xt=["value","disabled","onInput","onKeydown"],Rt=["disabled","onClick"],Ft={key:2,class:"muted"},Mt={key:1,class:"ops"},Gt=["onClick"],Bt={key:0},Kt=["colspan"],Jt={class:"modal panel modal-lg"},Wt={class:"filters"},jt={class:"field"},zt=["value"],Xt={class:"field"},qt={class:"check"},Qt={key:0,class:"msg err"},Yt={class:"modal-actions"},Zt=["disabled"],el={class:"modal panel"},tl={class:"form-grid"},ll={class:"field"},sl={class:"field"},nl={class:"field"},al={class:"field"},ol=["value"],il={class:"field"},ul={key:0,class:"msg err"},dl={class:"modal-actions"},rl=["disabled"],vl={class:"modal panel"},ml={class:"form-grid"},bl={class:"field"},pl={class:"field"},yl=["value"],cl={class:"field"},fl={class:"field"},gl={class:"field"},kl={class:"field"},wl={class:"field"},Il=["value"],Cl={class:"field"},hl={class:"field"},Vl={class:"field"},Ul={key:0,class:"msg err"},_l={class:"modal-actions"},Al=["disabled"],Sl={class:"modal panel"},Dl={class:"form-grid"},$l={class:"field"},Tl={class:"field"},Nl={class:"field"},Ol={class:"field"},Ll={class:"field"},Pl={class:"field"},El={class:"field"},Hl={class:"field"},xl={class:"field"},Rl={class:"field"},Fl=["value"],Ml={class:"check"},Gl={key:0,class:"msg err"},Bl={class:"modal-actions"},Kl=["disabled"],Jl=Ue({__name:"DevicesView",setup(Wl){function ie(s){const t=s.rw||"r";return t==="w"?"写控":t==="rw"?"读写":"采集"}function B(s){const t=s.rw||"r";return t==="w"||t==="rw"}function Z(s){const t=(s.dataType||"").toLowerCase(),b=(s.regType||"").toUpperCase();return!!(/bool|bit|coil/.test(t)||b.includes("-F5")||/\b0X\b/.test(b)||b.includes("COIL"))}const m=_e(),C=w("devices"),h=w(null),ee=w(!1),I=w(!1),_=w(""),d=w(""),$=w([]),P=w([]),M=w([]),K=w(1),G=w(null),V=w(null),R=w(!1),J=Y({}),A=w(null);let z;const E=w(""),W=w(""),c=Y({gatewayId:"mb-104",encoding:"gbk",replaceGateway:!0,csv:""}),p=Y({name:"",code:"",category:"other",gatewayId:"",bindUi:""}),u=Y({name:"",gatewayId:"mb-104",deviceId:null,modbusAddress:0,dataType:"Short",modbusId:1,itemId:"",scale:0,scaleOverride:.1,alarmLow:null,alarmHigh:null,enabled:!0}),i=w(null),te=oe(()=>M.value.filter(s=>!(E.value&&s.gatewayId!==E.value||W.value&&s.deviceId!==W.value))),be=oe(()=>P.value.map(s=>({...s,pointCount:M.value.filter(t=>t.deviceId===s.id).length}))),le=oe(()=>V.value?M.value.filter(s=>s.deviceId===V.value.id).sort((s,t)=>s.modbusAddress-t.modbusAddress||s.name.localeCompare(t.name,"zh")):[]);function X(){_.value="",d.value=""}function pe(s){return s==null||Number.isNaN(s)?"—":Number.isInteger(s)?String(s):s.toFixed(2)}function ye(s){if(!s)return"—";try{return new Date(s).toLocaleString("zh-CN",{hour12:!1})}catch{return s}}function q(){z!==void 0&&(window.clearInterval(z),z=void 0)}async function Q(){try{const s=await D("/api/devices/points",{token:m.token});M.value=s.points,K.value=s.pointsVersion}catch{}}function se(){q(),Q(),z=window.setInterval(()=>{Q()},3e3)}function ce(s){X(),V.value=s,R.value=!1,h.value="viewDevice",se()}function ne(s){var t,b;X(),s==="import"&&(!c.gatewayId&&$.value[0]&&(c.gatewayId=$.value[0].id),c.csv=""),s==="createDevice"&&(p.name="",p.code="",p.category="other",p.gatewayId=((t=$.value[0])==null?void 0:t.id)||null,p.bindUi=""),s==="createPoint"&&(u.name="",u.gatewayId=E.value||((b=$.value[0])==null?void 0:b.id)||"mb-104",u.deviceId=W.value||null,u.modbusAddress=0,u.dataType="Short",u.modbusId=1,u.itemId="",u.scale=0,u.scaleOverride=.1,u.alarmLow=null,u.alarmHigh=null,u.enabled=!0),h.value=s}function f(){if(h.value==="editPoint"&&R.value&&V.value){i.value=null,d.value="",R.value=!1,h.value="viewDevice",se();return}q(),h.value=null,i.value=null,V.value=null,R.value=!1,d.value=""}function ue(s){X(),R.value=h.value==="viewDevice",q(),i.value={...s},h.value="editPoint"}async function H(){ee.value=!0,d.value="";try{const[s,t,b,g]=await Promise.allSettled([D("/api/devices/gateways",{token:m.token}),D("/api/devices",{token:m.token}),D("/api/devices/points",{token:m.token}),D("/api/devices/gateways/health",{token:m.token})]);s.status==="fulfilled"&&($.value=s.value.gateways,!c.gatewayId&&s.value.gateways[0]&&(c.gatewayId=s.value.gateways[0].id),s.value.pointsVersion&&(K.value=s.value.pointsVersion)),t.status==="fulfilled"&&(P.value=t.value.devices),b.status==="fulfilled"&&(M.value=b.value.points,K.value=b.value.pointsVersion||K.value),g.status==="fulfilled"?G.value=g.value:G.value={ok:!1,message:"Windows 网关未连接(不影响本地设备列表)"};const F=[s,t,b].filter(O=>O.status==="rejected");if(F.length===3){const O=F[0].reason;d.value=O instanceof Error?O.message:"加载失败"}else if(t.status==="rejected"){const O=t.reason;d.value=O instanceof Error?O.message:"设备列表加载失败"}}catch(s){d.value=s instanceof Error?s.message:"加载失败"}finally{ee.value=!1}}async function fe(){if(i.value){d.value="",I.value=!0;try{await D(`/api/devices/points/${i.value.id}`,{method:"PATCH",token:m.token,body:JSON.stringify({name:i.value.name,modbusAddress:Number(i.value.modbusAddress),dataType:i.value.dataType,modbusId:Number(i.value.modbusId),itemId:i.value.itemId,scale:Number(i.value.scale),scaleOverride:i.value.scaleOverride===null||i.value.scaleOverride===""?null:Number(i.value.scaleOverride),alarmLow:i.value.alarmLow===null||i.value.alarmLow===""?null:Number(i.value.alarmLow),alarmHigh:i.value.alarmHigh===null||i.value.alarmHigh===""?null:Number(i.value.alarmHigh),deviceId:i.value.deviceId||null,enabled:i.value.enabled})}),_.value="点位已保存",i.value=null,d.value="",R.value&&V.value?(R.value=!1,h.value="viewDevice",await H(),se()):(f(),await H())}catch(s){d.value=s instanceof Error?s.message:"保存失败"}finally{I.value=!1}}}async function ge(){if(d.value="",!u.name.trim()){d.value="请填写点位名称";return}I.value=!0;try{await D("/api/devices/points",{method:"POST",token:m.token,body:JSON.stringify({name:u.name,gatewayId:u.gatewayId,deviceId:u.deviceId,modbusAddress:Number(u.modbusAddress),dataType:u.dataType,modbusId:Number(u.modbusId),itemId:u.itemId||void 0,scale:Number(u.scale),scaleOverride:u.scaleOverride,alarmLow:u.alarmLow,alarmHigh:u.alarmHigh,enabled:u.enabled})}),_.value="点位已创建",f(),C.value="points",await H()}catch(s){d.value=s instanceof Error?s.message:"创建失败"}finally{I.value=!1}}async function ke(){if(d.value="",!p.name.trim()||!p.code.trim()){d.value="名称与编码必填";return}I.value=!0;try{await D("/api/devices",{method:"POST",token:m.token,body:JSON.stringify({name:p.name,code:p.code,category:p.category,gatewayId:p.gatewayId,bindUi:p.bindUi})}),_.value="设备已创建",f(),C.value="devices",await H()}catch(s){d.value=s instanceof Error?s.message:"创建失败"}finally{I.value=!1}}async function we(s){confirm(`删除点位「${s.name}」?`)&&(await D(`/api/devices/points/${s.id}`,{method:"DELETE",token:m.token}),_.value="点位已删除",await H())}async function Ie(){if(d.value="",!c.csv.trim()){d.value="请粘贴 CSV 内容";return}I.value=!0;try{const s=await D("/api/devices/points/import",{method:"POST",token:m.token,body:JSON.stringify({gatewayId:c.gatewayId,encoding:c.encoding==="utf-8"?"utf-8":"gbk",replaceGateway:c.replaceGateway,csv:c.csv})});_.value=`已导入 ${s.imported} 个点位,版本 ${s.pointsVersion}`,E.value=c.gatewayId,f(),C.value="points",await H()}catch(s){d.value=s instanceof Error?s.message:"导入失败"}finally{I.value=!1}}async function Ce(){const s=new URLSearchParams;E.value&&s.set("gatewayId",E.value),s.set("encoding","utf-8");const t=await fetch(`/api/devices/points/export?${s.toString()}`,{headers:{Authorization:`Bearer ${m.token}`}});if(!t.ok){d.value="导出失败";return}const b=await t.blob(),g=document.createElement("a");g.href=URL.createObjectURL(b),g.download="device-points.csv",g.click(),URL.revokeObjectURL(g.href),_.value="CSV 已导出"}async function he(){X();try{const s=await D("/api/devices/points/sync-gateway",{method:"POST",token:m.token});s.ok?_.value=s.message:d.value=s.message,G.value=await D("/api/devices/gateways/health",{token:m.token})}catch(s){d.value=s instanceof Error?s.message:"同步失败"}}function ae(s){return J[s.id]!==void 0?J[s.id]:s.lastEng!==null&&s.lastEng!==void 0?String(s.lastEng):""}async function x(s,t){if(!m.isAdmin||!B(s))return;let b;if(t!==void 0)b=t;else{const g=ae(s).trim();if(b=Number(g),!Number.isFinite(b)){d.value=`请为「${s.name}」填写有效数值`;return}}A.value=s.id,d.value="";try{const g=await D(`/api/devices/points/${s.id}/write`,{method:"POST",token:m.token,body:JSON.stringify({value:b})}),F=Z(s)?b?"开":"关":String(b);_.value=`已写入 ${s.name} = ${F}${g.message?`(${g.message})`:""}`,J[s.id]=String(b),await Q()}catch(g){d.value=g instanceof Error?g.message:"写入失败"}finally{A.value=null}}function de(s){var t;return((t=$.value.find(b=>b.id===s))==null?void 0:t.name)||s}function Ve(s){var t;return s?((t=P.value.find(b=>b.id===s))==null?void 0:t.name)||s:"—"}return Ae(H),Se(q),(s,t)=>{var b,g,F,O;return o(),a("div",Te,[e("section",Ne,[e("div",Oe,[e("div",Le,[t[38]||(t[38]=e("div",{class:"vbar"},null,-1)),t[39]||(t[39]=e("h3",null,"设备与点位",-1)),e("div",Pe,"点表 v"+n(K.value),1)]),e("div",Ee,[e("button",{class:"btn ghost",type:"button",disabled:ee.value,onClick:H},"刷新",8,He),U(m).isAdmin?(o(),a("button",{key:0,class:"btn ghost",type:"button",onClick:he}," 下发网关 ")):v("",!0),C.value==="devices"&&U(m).isAdmin?(o(),a("button",{key:1,class:"btn",type:"button",onClick:t[0]||(t[0]=l=>ne("createDevice"))},"新增设备")):v("",!0),C.value==="points"?(o(),a(S,{key:2},[e("button",{class:"btn ghost",type:"button",onClick:Ce},"导出 CSV"),U(m).isAdmin?(o(),a("button",{key:0,class:"btn ghost",type:"button",onClick:t[1]||(t[1]=l=>ne("import"))}," 导入 CSV ")):v("",!0),U(m).isAdmin?(o(),a("button",{key:1,class:"btn",type:"button",onClick:t[2]||(t[2]=l=>ne("createPoint"))}," 新增点位 ")):v("",!0)],64)):v("",!0)])]),e("div",xe,[e("button",{class:j(["btn",{ghost:C.value!=="devices"}]),type:"button",onClick:t[3]||(t[3]=l=>C.value="devices")}," 设备("+n(P.value.length)+") ",3),e("button",{class:j(["btn",{ghost:C.value!=="points"}]),type:"button",onClick:t[4]||(t[4]=l=>C.value="points")}," 点位("+n(M.value.length)+") ",3),e("button",{class:j(["btn",{ghost:C.value!=="gateways"}]),type:"button",onClick:t[5]||(t[5]=l=>C.value="gateways")}," 网关("+n($.value.length)+") ",3)]),C.value==="points"?(o(),a("div",Re,[e("div",Fe,[t[41]||(t[41]=e("label",null,"网关",-1)),r(e("select",{"onUpdate:modelValue":t[6]||(t[6]=l=>E.value=l)},[t[40]||(t[40]=e("option",{value:""},"全部",-1)),(o(!0),a(S,null,T($.value,l=>(o(),a("option",{key:l.id,value:l.id},n(l.name),9,Me))),128))],512),[[L,E.value]])]),e("div",Ge,[t[43]||(t[43]=e("label",null,"设备",-1)),r(e("select",{"onUpdate:modelValue":t[7]||(t[7]=l=>W.value=l)},[t[42]||(t[42]=e("option",{value:""},"全部",-1)),(o(!0),a(S,null,T(P.value,l=>(o(),a("option",{key:l.id,value:l.id},n(l.name),9,Be))),128))],512),[[L,W.value]])]),e("span",Ke,"当前 "+n(te.value.length)+" 条",1)])):v("",!0),_.value?(o(),a("p",Je,n(_.value),1)):v("",!0),d.value&&!h.value?(o(),a("p",We,n(d.value),1)):v("",!0),e("div",je,[C.value==="devices"?(o(),a("table",ze,[t[45]||(t[45]=e("thead",null,[e("tr",null,[e("th",null,"名称"),e("th",null,"编码"),e("th",null,"点位数"),e("th",null,"网关"),e("th",null,"来源 CSV"),e("th",null,"状态")])],-1)),e("tbody",null,[(o(!0),a(S,null,T(be.value,l=>{var k;return o(),a("tr",{key:l.id,class:"row-click",onClick:jl=>ce(l)},[e("td",null,n(l.name),1),e("td",qe,n(l.code),1),e("td",null,n(l.pointCount),1),e("td",null,n(l.gatewayId?de(l.gatewayId):"—"),1),e("td",Qe,n(((k=l.meta)==null?void 0:k.sourceCsv)||"—"),1),e("td",null,n(l.status),1)],8,Xe)}),128)),P.value.length?v("",!0):(o(),a("tr",Ye,[...t[44]||(t[44]=[e("td",{colspan:"6",class:"empty"},"暂无设备,点击右上角「新增设备」或导入点位 CSV",-1)])]))])])):C.value==="points"?(o(),a("table",Ze,[e("thead",null,[e("tr",null,[t[46]||(t[46]=e("th",null,"名称",-1)),t[47]||(t[47]=e("th",null,"地址",-1)),t[48]||(t[48]=e("th",null,"读写",-1)),t[49]||(t[49]=e("th",null,"类型",-1)),t[50]||(t[50]=e("th",null,"设备",-1)),t[51]||(t[51]=e("th",null,"ItemID",-1)),t[52]||(t[52]=e("th",null,"倍率",-1)),t[53]||(t[53]=e("th",null,"告警下限",-1)),t[54]||(t[54]=e("th",null,"告警上限",-1)),t[55]||(t[55]=e("th",null,"实时值",-1)),U(m).isAdmin?(o(),a("th",et,"写入")):v("",!0),U(m).isAdmin?(o(),a("th",tt,"操作")):v("",!0)])]),e("tbody",null,[(o(!0),a(S,null,T(te.value,l=>(o(),a("tr",{key:l.id},[e("td",null,n(l.name),1),e("td",null,n(l.modbusAddress),1),e("td",null,n(ie(l)),1),e("td",null,n(l.dataType),1),e("td",null,n(Ve(l.deviceId)),1),e("td",lt,n(l.itemId),1),e("td",null,n(l.scaleOverride??l.scale),1),e("td",null,n(l.alarmLow??"—"),1),e("td",null,n(l.alarmHigh??"—"),1),e("td",null,n(l.rw==="w"?"—":l.lastEng??"—"),1),U(m).isAdmin?(o(),a("td",st,[B(l)&&Z(l)?(o(),a("div",nt,[e("button",{class:"btn bool-on",type:"button",disabled:A.value===l.id,onClick:k=>x(l,1)}," 开 ",8,at),e("button",{class:"btn ghost bool-off",type:"button",disabled:A.value===l.id,onClick:k=>x(l,0)}," 关 ",8,ot)])):B(l)?(o(),a("div",it,[e("input",{class:"write-input",type:"number",step:"any",value:ae(l),disabled:A.value===l.id,onInput:k=>J[l.id]=k.target.value,onKeydown:me(N(k=>x(l),["prevent"]),["enter"])},null,40,ut),e("button",{class:"btn",type:"button",disabled:A.value===l.id,onClick:k=>x(l)},n(A.value===l.id?"…":"写"),9,dt)])):(o(),a("span",rt,"—"))])):v("",!0),U(m).isAdmin?(o(),a("td",vt,[e("button",{class:"btn ghost",type:"button",onClick:k=>ue(l)},"编辑",8,mt),e("button",{class:"btn ghost",type:"button",onClick:k=>we(l)},"删",8,bt)])):v("",!0)]))),128)),te.value.length?v("",!0):(o(),a("tr",pt,[e("td",{colspan:U(m).isAdmin?12:10,class:"empty"}," 暂无点位,点击右上角「导入 CSV」或「新增点位」 ",8,yt)]))])])):(o(),a(S,{key:2},[e("div",ct,[e("span",{class:j(["badge",(b=G.value)!=null&&b.ok?"":"amber"])}," Windows 网关 "+n((g=G.value)!=null&&g.ok?"在线":((F=G.value)==null?void 0:F.message)||"未连接"),3)]),e("table",ft,[t[56]||(t[56]=e("thead",null,[e("tr",null,[e("th",null,"ID"),e("th",null,"名称"),e("th",null,"主机"),e("th",null,"端口"),e("th",null,"启用")])],-1)),e("tbody",null,[(o(!0),a(S,null,T($.value,l=>(o(),a("tr",{key:l.id},[e("td",null,n(l.id),1),e("td",null,n(l.name),1),e("td",null,n(l.host),1),e("td",null,n(l.port),1),e("td",null,n(l.enabled?"是":"否"),1)]))),128))])]),t[57]||(t[57]=e("p",{class:"hint"},"实际协议由单一 Windows 网关连接上述主机;平台仅维护主数据与点位。",-1))],64))])]),h.value==="viewDevice"&&V.value?(o(),a("div",{key:0,class:"modal-mask",onClick:N(f,["self"])},[e("div",gt,[e("div",kt,[e("div",wt,[t[58]||(t[58]=e("div",{class:"vbar"},null,-1)),e("h3",null,n(V.value.name),1),e("div",It,n(le.value.length)+" 个点位",1)]),e("div",{class:"head-actions"},[e("button",{class:"btn ghost",type:"button",onClick:Q},"刷新数值"),e("button",{class:"btn ghost",type:"button",onClick:f},"关闭")])]),e("div",Ct,[e("span",null,[t[59]||(t[59]=e("small",null,"编码",-1)),e("b",ht,n(V.value.code),1)]),e("span",null,[t[60]||(t[60]=e("small",null,"网关",-1)),e("b",null,n(V.value.gatewayId?de(V.value.gatewayId):"—"),1)]),e("span",null,[t[61]||(t[61]=e("small",null,"状态",-1)),e("b",null,n(V.value.status),1)]),(O=V.value.meta)!=null&&O.sourceCsv?(o(),a("span",Vt,[t[62]||(t[62]=e("small",null,"来源",-1)),e("b",Ut,n(V.value.meta.sourceCsv),1)])):v("",!0)]),d.value?(o(),a("p",_t,n(d.value),1)):v("",!0),_.value?(o(),a("p",At,n(_.value),1)):v("",!0),e("div",St,[e("table",Dt,[e("thead",null,[e("tr",null,[t[63]||(t[63]=e("th",null,"点位名称",-1)),t[64]||(t[64]=e("th",null,"地址",-1)),t[65]||(t[65]=e("th",null,"读写",-1)),t[66]||(t[66]=e("th",null,"类型",-1)),t[67]||(t[67]=e("th",null,"工程值",-1)),t[68]||(t[68]=e("th",null,"告警下限",-1)),t[69]||(t[69]=e("th",null,"告警上限",-1)),t[70]||(t[70]=e("th",null,"倍率",-1)),t[71]||(t[71]=e("th",null,"更新时间",-1)),U(m).isAdmin?(o(),a("th",$t,"写入")):v("",!0),U(m).isAdmin?(o(),a("th",Tt,"操作")):v("",!0)])]),e("tbody",null,[(o(!0),a(S,null,T(le.value,l=>(o(),a("tr",{key:l.id},[e("td",null,n(l.name),1),e("td",null,n(l.modbusAddress),1),e("td",null,n(ie(l)),1),e("td",null,n(l.dataType),1),e("td",{class:j(["eng",{muted:l.lastEng===null}])},n(l.rw==="w"?"—":pe(l.lastEng)),3),e("td",null,n(l.alarmLow??"—"),1),e("td",null,n(l.alarmHigh??"—"),1),e("td",null,n(l.scaleOverride??l.scale),1),e("td",Nt,n(ye(l.lastTs)),1),U(m).isAdmin?(o(),a("td",Ot,[B(l)&&Z(l)?(o(),a("div",Lt,[e("button",{class:"btn bool-on",type:"button",disabled:A.value===l.id,onClick:N(k=>x(l,1),["stop"])},n(A.value===l.id?"…":"开"),9,Pt),e("button",{class:"btn ghost bool-off",type:"button",disabled:A.value===l.id,onClick:N(k=>x(l,0),["stop"])}," 关 ",8,Et)])):B(l)?(o(),a("div",Ht,[e("input",{class:"write-input",type:"number",step:"any",value:ae(l),disabled:A.value===l.id,onInput:k=>J[l.id]=k.target.value,onKeydown:me(N(k=>x(l),["prevent"]),["enter"])},null,40,xt),e("button",{class:"btn",type:"button",disabled:A.value===l.id,onClick:N(k=>x(l),["stop"])},n(A.value===l.id?"写入中…":"写入"),9,Rt)])):(o(),a("span",Ft,"—"))])):v("",!0),U(m).isAdmin?(o(),a("td",Mt,[e("button",{class:"btn ghost",type:"button",onClick:N(k=>ue(l),["stop"])}," 编辑 ",8,Gt)])):v("",!0)]))),128)),le.value.length?v("",!0):(o(),a("tr",Bt,[e("td",{colspan:U(m).isAdmin?11:9,class:"empty"},"该设备暂无绑定点位",8,Kt)]))])])]),t[72]||(t[72]=e("p",{class:"hint"}," 对话框打开期间每 3 秒自动刷新采集点数值;写控点可在「写入」列下发(管理员)。 ",-1))])])):v("",!0),h.value==="import"?(o(),a("div",{key:1,class:"modal-mask",onClick:N(f,["self"])},[e("div",Jt,[e("div",{class:"panel-head"},[t[73]||(t[73]=e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"导入 CSV")],-1)),e("button",{class:"btn ghost",type:"button",onClick:f},"关闭")]),e("div",Wt,[e("div",jt,[t[74]||(t[74]=e("label",null,"目标网关",-1)),r(e("select",{"onUpdate:modelValue":t[8]||(t[8]=l=>c.gatewayId=l)},[(o(!0),a(S,null,T($.value,l=>(o(),a("option",{key:l.id,value:l.id},n(l.name),9,zt))),128))],512),[[L,c.gatewayId]])]),e("div",Xt,[t[76]||(t[76]=e("label",null,"编码",-1)),r(e("select",{"onUpdate:modelValue":t[9]||(t[9]=l=>c.encoding=l)},[...t[75]||(t[75]=[e("option",{value:"gbk"},"GBK(现场表)",-1),e("option",{value:"utf-8"},"UTF-8",-1)])],512),[[L,c.encoding]])]),e("label",qt,[r(e("input",{"onUpdate:modelValue":t[10]||(t[10]=l=>c.replaceGateway=l),type:"checkbox"},null,512),[[re,c.replaceGateway]]),t[77]||(t[77]=ve(" 替换该网关已有点位 ",-1))])]),r(e("textarea",{"onUpdate:modelValue":t[11]||(t[11]=l=>c.csv=l),class:"csv-area",placeholder:"粘贴现场点位 CSV(含表头 Name, Modbus Address, ItemID …)"},null,512),[[y,c.csv]]),d.value?(o(),a("p",Qt,n(d.value),1)):v("",!0),e("div",Yt,[e("button",{class:"btn",type:"button",disabled:I.value,onClick:Ie},n(I.value?"导入中…":"确认导入"),9,Zt),e("button",{class:"btn ghost",type:"button",onClick:f},"取消")])])])):v("",!0),h.value==="createDevice"?(o(),a("div",{key:2,class:"modal-mask",onClick:N(f,["self"])},[e("div",el,[e("div",{class:"panel-head"},[t[78]||(t[78]=e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"新增设备")],-1)),e("button",{class:"btn ghost",type:"button",onClick:f},"关闭")]),e("div",tl,[e("div",ll,[t[79]||(t[79]=e("label",null,"名称",-1)),r(e("input",{"onUpdate:modelValue":t[12]||(t[12]=l=>p.name=l)},null,512),[[y,p.name]])]),e("div",sl,[t[80]||(t[80]=e("label",null,"编码",-1)),r(e("input",{"onUpdate:modelValue":t[13]||(t[13]=l=>p.code=l)},null,512),[[y,p.code]])]),e("div",nl,[t[82]||(t[82]=e("label",null,"类别",-1)),r(e("select",{"onUpdate:modelValue":t[14]||(t[14]=l=>p.category=l)},[...t[81]||(t[81]=[De('<option value="fridge" data-v-4d7cc5d2>冰箱</option><option value="vav" data-v-4d7cc5d2>通风柜</option><option value="room" data-v-4d7cc5d2>房间屏</option><option value="pau" data-v-4d7cc5d2>机组</option><option value="meter_power" data-v-4d7cc5d2>电表</option><option value="meter_water" data-v-4d7cc5d2>水表/流量计</option><option value="meter_cooling" data-v-4d7cc5d2>能量计</option><option value="other" data-v-4d7cc5d2>其它</option>',8)])],512),[[L,p.category]])]),e("div",al,[t[84]||(t[84]=e("label",null,"网关",-1)),r(e("select",{"onUpdate:modelValue":t[15]||(t[15]=l=>p.gatewayId=l)},[t[83]||(t[83]=e("option",{value:null},"无",-1)),(o(!0),a(S,null,T($.value,l=>(o(),a("option",{key:l.id,value:l.id},n(l.name),9,ol))),128))],512),[[L,p.gatewayId]])]),e("div",il,[t[85]||(t[85]=e("label",null,"界面绑定",-1)),r(e("input",{"onUpdate:modelValue":t[16]||(t[16]=l=>p.bindUi=l),placeholder:"cold / vav / …"},null,512),[[y,p.bindUi]])])]),d.value?(o(),a("p",ul,n(d.value),1)):v("",!0),e("div",dl,[e("button",{class:"btn",type:"button",disabled:I.value,onClick:ke},n(I.value?"创建中…":"创建"),9,rl),e("button",{class:"btn ghost",type:"button",onClick:f},"取消")])])])):v("",!0),h.value==="createPoint"?(o(),a("div",{key:3,class:"modal-mask",onClick:N(f,["self"])},[e("div",vl,[e("div",{class:"panel-head"},[t[86]||(t[86]=e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"新增点位")],-1)),e("button",{class:"btn ghost",type:"button",onClick:f},"关闭")]),e("div",ml,[e("div",bl,[t[87]||(t[87]=e("label",null,"名称",-1)),r(e("input",{"onUpdate:modelValue":t[17]||(t[17]=l=>u.name=l)},null,512),[[y,u.name]])]),e("div",pl,[t[88]||(t[88]=e("label",null,"网关",-1)),r(e("select",{"onUpdate:modelValue":t[18]||(t[18]=l=>u.gatewayId=l)},[(o(!0),a(S,null,T($.value,l=>(o(),a("option",{key:l.id,value:l.id},n(l.name),9,yl))),128))],512),[[L,u.gatewayId]])]),e("div",cl,[t[89]||(t[89]=e("label",null,"Modbus Address",-1)),r(e("input",{"onUpdate:modelValue":t[19]||(t[19]=l=>u.modbusAddress=l),type:"number"},null,512),[[y,u.modbusAddress,void 0,{number:!0}]])]),e("div",fl,[t[90]||(t[90]=e("label",null,"DataType",-1)),r(e("input",{"onUpdate:modelValue":t[20]||(t[20]=l=>u.dataType=l)},null,512),[[y,u.dataType]])]),e("div",gl,[t[91]||(t[91]=e("label",null,"Modbus ID",-1)),r(e("input",{"onUpdate:modelValue":t[21]||(t[21]=l=>u.modbusId=l),type:"number"},null,512),[[y,u.modbusId,void 0,{number:!0}]])]),e("div",kl,[t[92]||(t[92]=e("label",null,"ItemID",-1)),r(e("input",{"onUpdate:modelValue":t[22]||(t[22]=l=>u.itemId=l),placeholder:"可选"},null,512),[[y,u.itemId]])]),e("div",wl,[t[94]||(t[94]=e("label",null,"设备",-1)),r(e("select",{"onUpdate:modelValue":t[23]||(t[23]=l=>u.deviceId=l)},[t[93]||(t[93]=e("option",{value:null},"无",-1)),(o(!0),a(S,null,T(P.value,l=>(o(),a("option",{key:l.id,value:l.id},n(l.name),9,Il))),128))],512),[[L,u.deviceId]])]),e("div",Cl,[t[95]||(t[95]=e("label",null,"Scale 覆盖",-1)),r(e("input",{"onUpdate:modelValue":t[24]||(t[24]=l=>u.scaleOverride=l),type:"number",step:"any"},null,512),[[y,u.scaleOverride,void 0,{number:!0}]])]),e("div",hl,[t[96]||(t[96]=e("label",null,"告警下限",-1)),r(e("input",{"onUpdate:modelValue":t[25]||(t[25]=l=>u.alarmLow=l),type:"number",step:"any",placeholder:"空=不启用"},null,512),[[y,u.alarmLow,void 0,{number:!0}]])]),e("div",Vl,[t[97]||(t[97]=e("label",null,"告警上限",-1)),r(e("input",{"onUpdate:modelValue":t[26]||(t[26]=l=>u.alarmHigh=l),type:"number",step:"any",placeholder:"空=不启用"},null,512),[[y,u.alarmHigh,void 0,{number:!0}]])])]),d.value?(o(),a("p",Ul,n(d.value),1)):v("",!0),e("div",_l,[e("button",{class:"btn",type:"button",disabled:I.value,onClick:ge},n(I.value?"创建中…":"创建"),9,Al),e("button",{class:"btn ghost",type:"button",onClick:f},"取消")])])])):v("",!0),h.value==="editPoint"&&i.value?(o(),a("div",{key:4,class:"modal-mask",onClick:N(f,["self"])},[e("div",Sl,[e("div",{class:"panel-head"},[t[98]||(t[98]=e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"编辑点位")],-1)),e("button",{class:"btn ghost",type:"button",onClick:f},"关闭")]),e("div",Dl,[e("div",$l,[t[99]||(t[99]=e("label",null,"名称",-1)),r(e("input",{"onUpdate:modelValue":t[27]||(t[27]=l=>i.value.name=l)},null,512),[[y,i.value.name]])]),e("div",Tl,[t[100]||(t[100]=e("label",null,"Modbus Address",-1)),r(e("input",{"onUpdate:modelValue":t[28]||(t[28]=l=>i.value.modbusAddress=l),type:"number"},null,512),[[y,i.value.modbusAddress,void 0,{number:!0}]])]),e("div",Nl,[t[101]||(t[101]=e("label",null,"DataType",-1)),r(e("input",{"onUpdate:modelValue":t[29]||(t[29]=l=>i.value.dataType=l)},null,512),[[y,i.value.dataType]])]),e("div",Ol,[t[102]||(t[102]=e("label",null,"Modbus ID",-1)),r(e("input",{"onUpdate:modelValue":t[30]||(t[30]=l=>i.value.modbusId=l),type:"number"},null,512),[[y,i.value.modbusId,void 0,{number:!0}]])]),e("div",Ll,[t[103]||(t[103]=e("label",null,"ItemID",-1)),r(e("input",{"onUpdate:modelValue":t[31]||(t[31]=l=>i.value.itemId=l)},null,512),[[y,i.value.itemId]])]),e("div",Pl,[t[104]||(t[104]=e("label",null,"Scale",-1)),r(e("input",{"onUpdate:modelValue":t[32]||(t[32]=l=>i.value.scale=l),type:"number",step:"any"},null,512),[[y,i.value.scale,void 0,{number:!0}]])]),e("div",El,[t[105]||(t[105]=e("label",null,"Scale 覆盖",-1)),r(e("input",{"onUpdate:modelValue":t[33]||(t[33]=l=>i.value.scaleOverride=l),type:"number",step:"any"},null,512),[[y,i.value.scaleOverride,void 0,{number:!0}]])]),e("div",Hl,[t[106]||(t[106]=e("label",null,"告警下限",-1)),r(e("input",{"onUpdate:modelValue":t[34]||(t[34]=l=>i.value.alarmLow=l),type:"number",step:"any",placeholder:"空=不启用"},null,512),[[y,i.value.alarmLow,void 0,{number:!0}]])]),e("div",xl,[t[107]||(t[107]=e("label",null,"告警上限",-1)),r(e("input",{"onUpdate:modelValue":t[35]||(t[35]=l=>i.value.alarmHigh=l),type:"number",step:"any",placeholder:"空=不启用"},null,512),[[y,i.value.alarmHigh,void 0,{number:!0}]])]),e("div",Rl,[t[109]||(t[109]=e("label",null,"设备",-1)),r(e("select",{"onUpdate:modelValue":t[36]||(t[36]=l=>i.value.deviceId=l)},[t[108]||(t[108]=e("option",{value:null},"无",-1)),(o(!0),a(S,null,T(P.value,l=>(o(),a("option",{key:l.id,value:l.id},n(l.name),9,Fl))),128))],512),[[L,i.value.deviceId]])]),e("label",Ml,[r(e("input",{"onUpdate:modelValue":t[37]||(t[37]=l=>i.value.enabled=l),type:"checkbox"},null,512),[[re,i.value.enabled]]),t[110]||(t[110]=ve(" 启用采集",-1))])]),d.value?(o(),a("p",Gl,n(d.value),1)):v("",!0),e("div",Bl,[e("button",{class:"btn",type:"button",disabled:I.value,onClick:fe},n(I.value?"保存中…":"保存"),9,Kl),e("button",{class:"btn ghost",type:"button",onClick:f},"取消")])])])):v("",!0)])}}}),Xl=$e(Jl,[["__scopeId","data-v-4d7cc5d2"]]);export{Xl as default};
.devices-page[data-v-4d7cc5d2]{min-height:0}.list-panel[data-v-4d7cc5d2]{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden;gap:10px}.panel-title[data-v-4d7cc5d2]{gap:10px}.head-actions[data-v-4d7cc5d2]{display:flex;flex-wrap:wrap;align-items:center;gap:8px;justify-content:flex-end}.tab-row[data-v-4d7cc5d2],.filters[data-v-4d7cc5d2]{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end}.field[data-v-4d7cc5d2]{display:flex;flex-direction:column;gap:4px;min-width:140px}.field label[data-v-4d7cc5d2]{font-size:12px;opacity:.75}.field input[data-v-4d7cc5d2],.field select[data-v-4d7cc5d2],.csv-area[data-v-4d7cc5d2]{background:#060c14a6;box-shadow:0 0 0 1px #3dbeff47 inset;border:none;color:inherit;border-radius:2px;padding:6px 8px}.csv-area[data-v-4d7cc5d2]{width:100%;min-height:160px;margin-top:4px;font-family:var(--digital-font),ui-monospace,monospace;font-size:12px}.filter-hint[data-v-4d7cc5d2]{font-size:12px;color:var(--panel-muted);margin-bottom:6px}.check[data-v-4d7cc5d2]{display:flex;align-items:center;gap:6px;font-size:13px;margin-bottom:4px}.table-wrap[data-v-4d7cc5d2]{flex:1;min-height:0;overflow:auto}.msg.ok[data-v-4d7cc5d2]{color:var(--ok);font-size:13px}.msg.err[data-v-4d7cc5d2]{color:#ffb0b0;font-size:13px}.hint[data-v-4d7cc5d2]{font-size:12px;opacity:.7;margin-top:10px}.mono[data-v-4d7cc5d2]{font-family:var(--digital-font),ui-monospace,monospace;font-size:11px;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ops[data-v-4d7cc5d2]{display:flex;gap:6px}.row-click[data-v-4d7cc5d2]{cursor:pointer}.row-click:hover td[data-v-4d7cc5d2]{background:#3dbeff1f}.device-meta[data-v-4d7cc5d2]{display:flex;flex-wrap:wrap;gap:10px 16px;margin-bottom:12px}.device-meta span[data-v-4d7cc5d2]{display:inline-flex;align-items:baseline;gap:6px;padding:4px 8px;border:1px solid var(--panel-border);border-radius:2px;background:#060c1473}.device-meta small[data-v-4d7cc5d2]{font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--panel-muted)}.device-meta b[data-v-4d7cc5d2]{font-size:12px;font-weight:600;color:var(--panel-title)}.detail-table-wrap[data-v-4d7cc5d2]{max-height:min(68vh,720px);overflow:auto}.eng[data-v-4d7cc5d2]{font-family:var(--digital-font),ui-monospace,monospace;font-size:14px;font-weight:700;color:var(--accent)}.eng.muted[data-v-4d7cc5d2]{color:var(--panel-muted);font-weight:600}.ts[data-v-4d7cc5d2]{font-size:11px;color:var(--panel-muted)}.empty[data-v-4d7cc5d2]{text-align:center;color:var(--panel-muted);padding:28px 10px!important}.gw-status[data-v-4d7cc5d2]{margin-bottom:8px}.modal-mask[data-v-4d7cc5d2]{position:fixed;top:0;right:0;bottom:0;left:0;background:#04080eb8;display:flex;align-items:center;justify-content:center;z-index:50}.modal[data-v-4d7cc5d2]{width:min(640px,92vw);max-height:90vh;overflow:auto}.modal-lg[data-v-4d7cc5d2]{width:min(860px,96vw)}.modal-xl[data-v-4d7cc5d2]{width:min(1280px,98vw);max-height:92vh}.modal-actions[data-v-4d7cc5d2]{margin-top:14px;display:flex;gap:10px}.form-grid[data-v-4d7cc5d2]{display:grid;grid-template-columns:1fr 1fr;gap:10px}.write-cell[data-v-4d7cc5d2]{display:inline-flex;align-items:center;gap:6px;min-width:140px;white-space:nowrap}.ops[data-v-4d7cc5d2]{white-space:nowrap}.ops .btn[data-v-4d7cc5d2]{margin-right:4px}.write-input[data-v-4d7cc5d2]{width:88px;padding:4px 6px;border-radius:6px;border:1px solid rgba(255,255,255,.12);background:#00000040;color:inherit}.bool-on[data-v-4d7cc5d2],.bool-off[data-v-4d7cc5d2]{min-width:44px;padding:4px 10px}.muted[data-v-4d7cc5d2]{color:var(--panel-muted)}.msg.ok[data-v-4d7cc5d2]{color:#7dcea0}
.history-page[data-v-4ad3bfa1]{display:flex;min-height:0}.history-panel[data-v-4ad3bfa1]{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}.filters[data-v-4ad3bfa1]{display:flex;flex-wrap:wrap;gap:10px 14px;align-items:end;padding:8px 4px 12px}.filters label[data-v-4ad3bfa1]{display:flex;flex-direction:column;gap:4px;font-size:11px;color:var(--label, #8eb0c4)}.filters select[data-v-4ad3bfa1]{min-width:120px;height:30px;border-radius:4px;border:1px solid rgba(0,247,255,.25);background:#041428d9;color:#e8f6ff;padding:0 8px}.btn[data-v-4ad3bfa1]{height:30px;padding:0 14px;border-radius:4px;border:1px solid rgba(0,247,255,.35);background:#0078a059;color:#dff8ff;cursor:pointer}.btn[data-v-4ad3bfa1]:disabled{opacity:.5;cursor:default}.chart-box[data-v-4ad3bfa1]{flex:1;min-height:280px;border:1px solid rgba(0,247,255,.15);border-radius:6px;background:#04102066;padding:6px}.table-wrap[data-v-4ad3bfa1]{margin-top:10px;max-height:220px;overflow:auto}.muted[data-v-4ad3bfa1]{text-align:center;color:var(--label)}.err[data-v-4ad3bfa1]{color:#ffb0b0;font-size:12px;margin-bottom:8px}
import{d as $,p as M,j as D,c as u,b as t,t as s,g,e as y,L as h,F as k,B as V,f as S,z as H,r,y as w,l as F,o as i,_ as O}from"./index-BRA9HJ0O.js";import{E as P,m as j}from"./historyChart-ytIpUdNw.js";const q={class:"page-fill history-page"},z={class:"panel history-panel"},R={class:"panel-head"},T={class:"badge"},A={class:"filters"},G=["value"],J=["value"],K=["value"],Q=["disabled"],W={key:0,class:"err"},X={class:"chart-box"},Y={class:"table-wrap"},Z={class:"table"},ee={key:0},te=$({__name:"HistoryView",setup(le){const d=r([]),c=r(!1),m=r(""),f=r(24),x=r(15),n=r(""),o=r(""),b=r(""),v=r([]),L=w(()=>{const a=new Map;for(const e of d.value)a.set(e.source,e.sourceLabel);return[...a.entries()].map(([e,l])=>({value:e,label:l}))}),U=w(()=>{const a=new Map;for(const e of d.value)n.value&&e.source!==n.value||a.set(e.metric,e.metricLabel);return[...a.entries()].map(([e,l])=>({value:e,label:l}))}),C=w(()=>d.value.filter(a=>(!n.value||a.source===n.value)&&(!o.value||a.metric===o.value)).map(a=>({value:a.deviceId,label:a.name,points:a.points}))),E=w(()=>j(v.value,{maxSeries:16,unit:o.value==="temp"||o.value.includes("temp")?"℃":o.value==="pressure"?"Pa":""})),B=w(()=>{const a=v.value.length,e=v.value.reduce((l,p)=>l+p.points.length,0);return`${a} 条序列 · ${e} 个聚合点 · 近 ${f.value}h`});async function I(){c.value=!0,m.value="";try{const a=await F("/api/realtime/history/catalog");d.value=a.catalog||[],!n.value&&d.value.length&&(n.value=d.value[0].source,o.value=d.value[0].metric)}catch(a){m.value=a instanceof Error?a.message:"目录加载失败"}finally{c.value=!1}}async function _(){if(!n.value||!o.value){v.value=[];return}c.value=!0,m.value="";try{const a=new URLSearchParams({source:n.value,metric:o.value,hours:String(f.value),bucketMinutes:String(x.value)});b.value&&a.set("deviceId",b.value);const e=await F(`/api/realtime/history?${a}`);v.value=e.series||[]}catch(a){m.value=a instanceof Error?a.message:"曲线加载失败",v.value=[]}finally{c.value=!1}}return M([n,o],()=>{b.value=""}),M([n,o,b,f,x],()=>{_()}),D(async()=>{await I(),await _()}),(a,e)=>(i(),u("div",q,[t("section",z,[t("div",R,[e[5]||(e[5]=t("div",{class:"panel-title"},[t("div",{class:"vbar"}),t("h3",null,"历史数据与曲线")],-1)),t("div",T,"时序预聚合 · "+s(B.value),1)]),t("div",A,[t("label",null,[e[6]||(e[6]=g(" 类别 ",-1)),y(t("select",{"onUpdate:modelValue":e[0]||(e[0]=l=>n.value=l)},[(i(!0),u(k,null,V(L.value,l=>(i(),u("option",{key:l.value,value:l.value},s(l.label),9,G))),128))],512),[[h,n.value]])]),t("label",null,[e[7]||(e[7]=g(" 指标 ",-1)),y(t("select",{"onUpdate:modelValue":e[1]||(e[1]=l=>o.value=l)},[(i(!0),u(k,null,V(U.value,l=>(i(),u("option",{key:l.value,value:l.value},s(l.label),9,J))),128))],512),[[h,o.value]])]),t("label",null,[e[9]||(e[9]=g(" 设备 ",-1)),y(t("select",{"onUpdate:modelValue":e[2]||(e[2]=l=>b.value=l)},[e[8]||(e[8]=t("option",{value:""},"全部设备",-1)),(i(!0),u(k,null,V(C.value,l=>(i(),u("option",{key:l.value,value:l.value},s(l.label)+" ("+s(l.points)+") ",9,K))),128))],512),[[h,b.value]])]),t("label",null,[e[11]||(e[11]=g(" 时长 ",-1)),y(t("select",{"onUpdate:modelValue":e[3]||(e[3]=l=>f.value=l)},[...e[10]||(e[10]=[t("option",{value:6},"近 6 小时",-1),t("option",{value:24},"近 24 小时",-1),t("option",{value:72},"近 3 天",-1),t("option",{value:168},"近 7 天",-1)])],512),[[h,f.value,void 0,{number:!0}]])]),t("label",null,[e[13]||(e[13]=g(" 粒度 ",-1)),y(t("select",{"onUpdate:modelValue":e[4]||(e[4]=l=>x.value=l)},[...e[12]||(e[12]=[t("option",{value:5},"5 分钟",-1),t("option",{value:15},"15 分钟",-1),t("option",{value:60},"1 小时",-1)])],512),[[h,x.value,void 0,{number:!0}]])]),t("button",{type:"button",class:"btn",disabled:c.value,onClick:_},"刷新",8,Q)]),m.value?(i(),u("div",W,s(m.value),1)):S("",!0),t("div",X,[H(P,{option:E.value,loading:c.value,"empty-text":"暂无该指标历史数据,采集后将自动出现"},null,8,["option","loading"])]),t("div",Y,[t("table",Z,[e[15]||(e[15]=t("thead",null,[t("tr",null,[t("th",null,"设备"),t("th",null,"点数"),t("th",null,"最新"),t("th",null,"最低"),t("th",null,"最高"),t("th",null,"平均")])],-1)),t("tbody",null,[(i(!0),u(k,null,V(v.value,l=>(i(),u("tr",{key:l.id},[t("td",null,s(l.name||l.id),1),t("td",null,s(l.points.length),1),t("td",null,s(l.points.length?l.points[l.points.length-1].v.toFixed(2):"—"),1),t("td",null,s(l.points.length?Math.min(...l.points.map(p=>p.v)).toFixed(2):"—"),1),t("td",null,s(l.points.length?Math.max(...l.points.map(p=>p.v)).toFixed(2):"—"),1),t("td",null,s(l.points.length?(l.points.reduce((p,N)=>p+N.v,0)/l.points.length).toFixed(2):"—"),1)]))),128)),v.value.length?S("",!0):(i(),u("tr",ee,[...e[14]||(e[14]=[t("td",{colspan:"6",class:"muted"},"无数据",-1)])]))])])])])]))}}),oe=O(te,[["__scopeId","data-v-4ad3bfa1"]]);export{oe as default};
import{d as g,u as x,c as A,b as s,t as i,h as t,z as P,n as S,e as k,v as w,r as H,y as r,l as T,o as V,_ as C}from"./index-BRA9HJ0O.js";import{u as E}from"./realtime-BbiS_VD3.js";import{E as F,m as O}from"./historyChart-ytIpUdNw.js";import{u as d}from"./useMetricHistory-DAj0vDDt.js";const z={class:"page-fill"},B={class:"panel"},M={class:"panel-head"},N={class:"badge"},D={class:"metric-grid",style:{"grid-template-columns":"repeat(3, 1fr)"}},L={class:"metric"},R={class:"m-value"},U={class:"metric"},j={class:"m-value"},I={class:"metric"},J={class:"m-value"},q={class:"metric"},G={class:"m-value"},K={class:"metric"},Q={class:"m-value"},W={class:"metric"},X={class:"m-value"},Y={class:"hist"},Z={class:"panel"},$={class:"panel-head"},ss={class:"form-grid",style:{"max-width":"520px"}},es={class:"field"},ts=["disabled"],is={class:"field",style:{"justify-content":"flex-end"}},ls=["disabled"],as=g({__name:"HvacView",setup(os){const a=x(),l=E(),n=H(180),{series:u,loading:v}=d(()=>({source:"hvac",metric:"supply_temp"})),{series:c,loading:p}=d(()=>({source:"hvac",metric:"supply_pressure"})),{series:m,loading:_}=d(()=>({source:"hvac",metric:"exhaust_pressure"})),h=r(()=>O([...u.value.map(o=>({...o,name:"送风温度"})),...c.value.map(o=>({...o,name:"送风压力"})),...m.value.map(o=>({...o,name:"排风压力"}))],{maxSeries:6})),y=r(()=>v.value||p.value||_.value);async function b(){a.isAdmin&&(await T("/api/realtime/write",{method:"POST",token:a.token,body:JSON.stringify({target:"PAU201",action:"set_supply_pressure",value:n.value})}),alert("送风压力设定已下发(Mock)"))}return(o,e)=>(V(),A("div",z,[s("section",B,[s("div",M,[e[1]||(e[1]=s("div",{class:"panel-title"},[s("div",{class:"vbar"}),s("h3",null,"空调及排风机组")],-1)),s("div",N,i(t(l).snapshot.hvac.systemRunning?"运行":"停机"),1)]),s("div",D,[s("div",L,[s("div",null,[e[2]||(e[2]=s("div",{class:"m-label"},"送风温度",-1)),s("div",R,i(t(l).snapshot.hvac.supplyTemp.toFixed(1)),1)])]),s("div",U,[s("div",null,[e[3]||(e[3]=s("div",{class:"m-label"},"送风湿度",-1)),s("div",j,i(t(l).snapshot.hvac.supplyHumidity),1)])]),s("div",I,[s("div",null,[e[4]||(e[4]=s("div",{class:"m-label"},"送风压力",-1)),s("div",J,i(t(l).snapshot.hvac.supplyPressure),1)])]),s("div",q,[s("div",null,[e[5]||(e[5]=s("div",{class:"m-label"},"风机频率",-1)),s("div",G,i(t(l).snapshot.hvac.supplyFanHz.toFixed(1)),1)])]),s("div",K,[s("div",null,[e[6]||(e[6]=s("div",{class:"m-label"},"排风压力",-1)),s("div",Q,i(t(l).snapshot.hvac.exhaustPressure),1)])]),s("div",W,[s("div",null,[e[7]||(e[7]=s("div",{class:"m-label"},"新风温度",-1)),s("div",X,i(t(l).snapshot.hvac.freshAirTemp.toFixed(1)),1)])])]),s("div",Y,[e[8]||(e[8]=s("div",{class:"chart-title"},"近 24h 历史曲线",-1)),P(F,{option:h.value,loading:y.value,"empty-text":"机组历史积累中"},null,8,["option","loading"])])]),s("section",Z,[s("div",$,[e[9]||(e[9]=s("div",{class:"panel-title"},[s("div",{class:"vbar"}),s("h3",null,"参数设定")],-1)),s("div",{class:S(["badge",t(a).isAdmin?"":"amber"])},i(t(a).isAdmin?"管理员可写":"只读"),3)]),s("div",ss,[s("div",es,[e[10]||(e[10]=s("label",null,"送风压力设定 (Pa)",-1)),k(s("input",{"onUpdate:modelValue":e[0]||(e[0]=f=>n.value=f),type:"number",disabled:!t(a).isAdmin},null,8,ts),[[w,n.value,void 0,{number:!0}]])]),s("div",is,[e[11]||(e[11]=s("label",null," ",-1)),s("button",{class:"btn",disabled:!t(a).isAdmin,onClick:b},"下发设定",8,ls)])])])]))}}),vs=C(as,[["__scopeId","data-v-310ea492"]]);export{vs as default};
.hist[data-v-310ea492]{margin-top:12px;min-height:220px;border:1px solid rgba(0,247,255,.16);border-radius:6px;padding:8px;background:#04102059}.chart-title[data-v-310ea492]{font-size:12px;color:var(--label);margin-bottom:4px}.hist[data-v-310ea492] .echart-wrap{height:200px}
.light-page[data-v-1aa8fc55]{gap:12px}.light-tabs[data-v-1aa8fc55]{display:flex;align-items:center;gap:8px;flex:0 0 auto}.tab[data-v-1aa8fc55]{border-radius:6px;border:1px solid rgba(62,200,255,.22);background:#122a4e66;color:var(--muted);padding:6px 14px;font-size:12px;cursor:pointer}.tab.active[data-v-1aa8fc55]{background:linear-gradient(90deg,#1a6fff,#0aa6e5);color:#fff;border-color:transparent;font-weight:600}.tab-spacer[data-v-1aa8fc55]{flex:1}.msg[data-v-1aa8fc55]{font-size:12px;color:var(--cyan-lt)}.hint[data-v-1aa8fc55]{font-size:12px;color:var(--label);line-height:1.6;margin-bottom:12px}.hint.inline[data-v-1aa8fc55]{margin-bottom:0}.foot-hint[data-v-1aa8fc55]{margin-top:10px;font-size:12px;color:var(--label)}.form-grid.three[data-v-1aa8fc55]{grid-template-columns:repeat(3,1fr);max-width:1100px}.row-actions[data-v-1aa8fc55]{display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin-top:14px}.head-right[data-v-1aa8fc55]{display:flex;align-items:center;gap:8px}.switch-field .switch[data-v-1aa8fc55]{display:flex;align-items:center;gap:8px;height:36px;color:var(--body);font-size:13px;cursor:pointer}.list-panel[data-v-1aa8fc55],.config-panel[data-v-1aa8fc55]{flex:1;min-height:0;display:flex;flex-direction:column}.table-wrap[data-v-1aa8fc55]{flex:1;min-height:0;overflow:auto}.ops[data-v-1aa8fc55]{display:flex;flex-wrap:wrap;gap:6px}.empty[data-v-1aa8fc55]{text-align:center;color:var(--label);padding:24px!important}.muted[data-v-1aa8fc55]{color:var(--label);font-size:11px}.st[data-v-1aa8fc55],.pwr[data-v-1aa8fc55]{display:inline-block;padding:1px 8px;border-radius:4px;font-size:11px}.st.online[data-v-1aa8fc55]{background:#2fd6a126;color:var(--green)}.st.offline[data-v-1aa8fc55]{background:#8eb4d41f;color:var(--muted)}.st.fault[data-v-1aa8fc55]{background:#f0b4292e;color:var(--amber)}.pwr.on[data-v-1aa8fc55]{background:#fbbf2433;color:#fbbf24}.pwr.off[data-v-1aa8fc55]{background:#8eb4d41f;color:var(--muted)}.switch-btn.is-on[data-v-1aa8fc55]{border-color:#fbbf248c;background:#fbbf2438;color:#ffe08a}.switch-btn.is-on[data-v-1aa8fc55]:hover:not(:disabled){background:#fbbf2461}.switch-btn.is-off[data-v-1aa8fc55]{border-color:#3ac8ff73;background:#1a6fff47;color:#eaf9ff}.switch-btn.is-off[data-v-1aa8fc55]:hover:not(:disabled){background:#1a6fff73}
import{d as H,u as R,j,c as u,b as e,n as w,t as a,f as c,F as T,h as d,e as p,v as b,B as K,L as q,J as G,r as y,K as U,y as $,l as f,o as r,g as E,_ as Q}from"./index-BRA9HJ0O.js";const W={class:"page-fill light-page"},X={class:"light-tabs"},Y={class:"badge"},Z={class:"badge"},ee=["disabled"],te={key:0,class:"msg"},se={key:0,class:"panel"},le={class:"panel-head"},ne={class:"head-right"},oe=["disabled"],ie=["disabled"],ae={class:"form-grid three"},de={class:"field"},ue={class:"field"},re={class:"field"},ve={class:"field"},pe={class:"panel list-panel"},ce={class:"table-wrap"},be={class:"table"},me={class:"muted"},fe={class:"ops"},ge=["disabled","onClick"],ye=["onClick"],he=["onClick"],ke={key:0},we={key:1,class:"panel config-panel"},_e={class:"panel-head"},Ae={class:"form-grid three"},Ce={class:"field"},Me=["disabled"],Se={class:"field"},Te=["disabled"],Ve={class:"field"},Oe=["disabled"],Ue={class:"field"},$e=["disabled"],Ee={class:"field"},Pe=["disabled"],xe={class:"field"},Ie=["disabled"],Ne={class:"field"},ze=["disabled"],Be={class:"field switch-field"},De={class:"switch"},Le=["disabled"],Je={class:"row-actions"},Fe=["disabled"],He={key:2,class:"msg"},Re={key:3,class:"hint inline"},je=H({__name:"LightView",setup(Ke){const o=R(),_=y("devices"),S=y(!1),g=y(""),i=y(""),m=y([]),A=y(null),M=y(!1),h=y(""),v=U({name:"",zone:"",circuit:9,remark:""}),n=U({host:"",port:502,protocol:"modbus",unitId:1,username:"",password:"",pollMs:3e3,enabled:!0}),P={online:"在线",offline:"离线",fault:"故障"},x=$(()=>m.value.filter(l=>l.enabled&&l.powerOn).length),I=$(()=>m.value.filter(l=>l.enabled&&l.status==="online").length);async function k(){S.value=!0,i.value="";try{const[l,t]=await Promise.all([f("/api/light/devices",{token:o.token}),f("/api/light/config",{token:o.token})]);m.value=l.devices,A.value=t,n.host=t.host,n.port=t.port,n.protocol=t.protocol,n.unitId=t.unitId,n.username=t.username,n.password=t.passwordMasked||"",n.pollMs=t.pollMs,n.enabled=t.enabled}catch(l){i.value=l instanceof Error?l.message:"加载失败"}finally{S.value=!1}}async function N(){if(o.isAdmin){i.value="";try{await f("/api/light/devices",{method:"POST",token:o.token,body:JSON.stringify(v)}),v.name="",v.zone="",v.remark="",v.circuit+=1,i.value="设备已添加",await k()}catch(l){i.value=l instanceof Error?l.message:"添加失败"}}}async function z(l){if(o.isAdmin){i.value="";try{await f(`/api/light/devices/${l.id}`,{method:"PATCH",token:o.token,body:JSON.stringify({enabled:!l.enabled})}),await k()}catch(t){i.value=t instanceof Error?t.message:"更新失败"}}}async function B(l){if(o.isAdmin&&confirm(`确认删除「${l.name}」?`)){i.value="";try{await f(`/api/light/devices/${l.id}`,{method:"DELETE",token:o.token}),i.value="已删除",await k()}catch(t){i.value=t instanceof Error?t.message:"删除失败"}}}async function D(l,t){if(o.isAdmin){g.value=l.id,i.value="";try{const s=await f(`/api/light/devices/${l.id}/switch`,{method:"POST",token:o.token,body:JSON.stringify({powerOn:t})});i.value=s.message;const C=m.value.findIndex(F=>F.id===l.id);C>=0?m.value[C]=s.device:await k()}catch(s){i.value=s instanceof Error?s.message:"开关失败"}finally{g.value=""}}}async function V(l){if(!(!o.isAdmin||!confirm(`确认远程${l?"全部开启":"全部关闭"}所有可用回路?`))){g.value="all",i.value="";try{const s=await f("/api/light/switch-all",{method:"POST",token:o.token,body:JSON.stringify({powerOn:l})});m.value=s.devices,i.value=s.message}catch(s){i.value=s instanceof Error?s.message:"批量开关失败"}finally{g.value=""}}}async function L(){if(o.isAdmin){i.value="",h.value="";try{const l={host:n.host,port:n.port,protocol:n.protocol,unitId:n.unitId,username:n.username,pollMs:n.pollMs,enabled:n.enabled};n.password&&n.password!=="********"&&(l.password=n.password);const t=await f("/api/light/config",{method:"PUT",token:o.token,body:JSON.stringify(l)});A.value=t,n.password=t.passwordMasked||"",i.value="通信配置已保存"}catch(l){i.value=l instanceof Error?l.message:"保存失败"}}}async function J(){if(o.isAdmin){M.value=!0,h.value="";try{const l=await f("/api/light/config/test",{method:"POST",token:o.token});h.value=`${l.message} · ${l.latencyMs} ms`,i.value=l.ok?"连通性测试通过":l.message}catch(l){h.value=l instanceof Error?l.message:"测试失败"}finally{M.value=!1}}}function O(l){return l?new Date(l).toLocaleString("zh-CN",{hour12:!1}):"—"}return j(k),(l,t)=>(r(),u("div",W,[e("div",X,[e("button",{type:"button",class:w(["tab",{active:_.value==="devices"}]),onClick:t[0]||(t[0]=s=>_.value="devices")}," 设备管理 ",2),e("button",{type:"button",class:w(["tab",{active:_.value==="config"}]),onClick:t[1]||(t[1]=s=>_.value="config")}," 通信配置 ",2),t[16]||(t[16]=e("div",{class:"tab-spacer"},null,-1)),e("div",Y,"开启 "+a(x.value),1),e("div",Z,"在线 "+a(I.value)+" / "+a(m.value.length),1),e("button",{type:"button",class:"btn ghost",disabled:S.value,onClick:k},"刷新",8,ee),i.value?(r(),u("span",te,a(i.value),1)):c("",!0)]),_.value==="devices"?(r(),u(T,{key:0},[d(o).isAdmin?(r(),u("section",se,[e("div",le,[t[17]||(t[17]=e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"添加照明回路")],-1)),e("div",ne,[e("button",{type:"button",class:"btn ghost",disabled:!!g.value,onClick:t[2]||(t[2]=s=>V(!0))}," 全部开启 ",8,oe),e("button",{type:"button",class:"btn ghost",disabled:!!g.value,onClick:t[3]||(t[3]=s=>V(!1))}," 全部关闭 ",8,ie)])]),e("div",ae,[e("div",de,[t[18]||(t[18]=e("label",null,"名称",-1)),p(e("input",{"onUpdate:modelValue":t[4]||(t[4]=s=>v.name=s),placeholder:"如:PCR走廊照明"},null,512),[[b,v.name]])]),e("div",ue,[t[19]||(t[19]=e("label",null,"区域",-1)),p(e("input",{"onUpdate:modelValue":t[5]||(t[5]=s=>v.zone=s),placeholder:"房间/分区"},null,512),[[b,v.zone]])]),e("div",re,[t[20]||(t[20]=e("label",null,"回路号",-1)),p(e("input",{"onUpdate:modelValue":t[6]||(t[6]=s=>v.circuit=s),type:"number",min:"1",max:"256"},null,512),[[b,v.circuit,void 0,{number:!0}]])]),e("div",ve,[t[21]||(t[21]=e("label",null,"备注",-1)),p(e("input",{"onUpdate:modelValue":t[7]||(t[7]=s=>v.remark=s)},null,512),[[b,v.remark]])])]),e("div",{class:"row-actions"},[e("button",{type:"button",class:"btn",onClick:N},"添加")])])):c("",!0),e("section",pe,[t[24]||(t[24]=e("div",{class:"panel-head"},[e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"照明设备列表")]),e("div",{class:"badge amber"},"厂家接口 · Mock")],-1)),e("div",ce,[e("table",be,[t[23]||(t[23]=e("thead",null,[e("tr",null,[e("th",null,"回路"),e("th",null,"名称"),e("th",null,"区域"),e("th",null,"开关"),e("th",null,"亮度"),e("th",null,"状态"),e("th",null,"启用"),e("th",null,"最近控制"),e("th",null,"操作")])],-1)),e("tbody",null,[(r(!0),u(T,null,K(m.value,s=>(r(),u("tr",{key:s.id},[e("td",null,a(s.circuit),1),e("td",null,a(s.name),1),e("td",null,a(s.zone),1),e("td",null,[e("span",{class:w(["pwr",s.powerOn?"on":"off"])},a(s.powerOn?"开":"关"),3)]),e("td",null,a(s.brightness)+"%",1),e("td",null,[e("span",{class:w(["st",s.status])},a(P[s.status]),3)]),e("td",null,a(s.enabled?"是":"否"),1),e("td",me,[E(a(O(s.lastSwitchAt))+" ",1),s.lastSwitchBy?(r(),u(T,{key:0},[E(" · "+a(s.lastSwitchBy),1)],64)):c("",!0)]),e("td",fe,[e("button",{type:"button",class:w(["btn switch-btn",s.powerOn?"is-on":"is-off"]),disabled:!d(o).isAdmin||!s.enabled||s.status==="offline"||g.value===s.id,onClick:C=>D(s,!s.powerOn)},a(g.value===s.id?"下发中…":s.powerOn?"远程关灯":"远程开灯"),11,ge),d(o).isAdmin?(r(),u("button",{key:0,type:"button",class:"btn ghost",onClick:C=>z(s)},a(s.enabled?"禁用":"启用"),9,ye)):c("",!0),d(o).isAdmin?(r(),u("button",{key:1,type:"button",class:"btn danger",onClick:C=>B(s)}," 删除 ",8,he)):c("",!0)])]))),128)),m.value.length?c("",!0):(r(),u("tr",ke,[...t[22]||(t[22]=[e("td",{colspan:"9",class:"empty"},"暂无照明设备",-1)])]))])])]),t[25]||(t[25]=e("p",{class:"foot-hint"}," 厂家提供数据调用接口,本页负责显示集成与远程开关。一期为 Mock 落库;二期对接照明网关后实时同步。 ",-1))])],64)):(r(),u("section",we,[e("div",_e,[t[26]||(t[26]=e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"照明通信配置")],-1)),e("div",{class:w(["badge",d(o).isAdmin?"":"amber"])},a(d(o).isAdmin?"可编辑":"只读"),3)]),t[36]||(t[36]=e("p",{class:"hint"}," 对接厂家智能照明网关(Modbus / HTTP / TCP)。配置保存后可用于远程开关下发。 ",-1)),e("div",Ae,[e("div",Ce,[t[27]||(t[27]=e("label",null,"网关地址",-1)),p(e("input",{"onUpdate:modelValue":t[8]||(t[8]=s=>n.host=s),disabled:!d(o).isAdmin,placeholder:"192.168.x.x"},null,8,Me),[[b,n.host]])]),e("div",Se,[t[28]||(t[28]=e("label",null,"端口",-1)),p(e("input",{"onUpdate:modelValue":t[9]||(t[9]=s=>n.port=s),type:"number",disabled:!d(o).isAdmin},null,8,Te),[[b,n.port,void 0,{number:!0}]])]),e("div",Ve,[t[30]||(t[30]=e("label",null,"协议",-1)),p(e("select",{"onUpdate:modelValue":t[10]||(t[10]=s=>n.protocol=s),disabled:!d(o).isAdmin},[...t[29]||(t[29]=[e("option",{value:"modbus"},"Modbus TCP",-1),e("option",{value:"http"},"HTTP",-1),e("option",{value:"tcp"},"TCP",-1)])],8,Oe),[[q,n.protocol]])]),e("div",Ue,[t[31]||(t[31]=e("label",null,"从站地址 (Unit ID)",-1)),p(e("input",{"onUpdate:modelValue":t[11]||(t[11]=s=>n.unitId=s),type:"number",disabled:!d(o).isAdmin},null,8,$e),[[b,n.unitId,void 0,{number:!0}]])]),e("div",Ee,[t[32]||(t[32]=e("label",null,"用户名(可选)",-1)),p(e("input",{"onUpdate:modelValue":t[12]||(t[12]=s=>n.username=s),disabled:!d(o).isAdmin},null,8,Pe),[[b,n.username]])]),e("div",xe,[t[33]||(t[33]=e("label",null,"密码(可选)",-1)),p(e("input",{"onUpdate:modelValue":t[13]||(t[13]=s=>n.password=s),type:"password",disabled:!d(o).isAdmin,placeholder:"********",autocomplete:"new-password"},null,8,Ie),[[b,n.password]])]),e("div",Ne,[t[34]||(t[34]=e("label",null,"状态轮询 (ms)",-1)),p(e("input",{"onUpdate:modelValue":t[14]||(t[14]=s=>n.pollMs=s),type:"number",disabled:!d(o).isAdmin},null,8,ze),[[b,n.pollMs,void 0,{number:!0}]])]),e("div",Be,[t[35]||(t[35]=e("label",null,"启用通信",-1)),e("label",De,[p(e("input",{"onUpdate:modelValue":t[15]||(t[15]=s=>n.enabled=s),type:"checkbox",disabled:!d(o).isAdmin},null,8,Le),[[G,n.enabled]]),e("span",null,a(n.enabled?"已启用":"已关闭"),1)])])]),e("div",Je,[d(o).isAdmin?(r(),u("button",{key:0,type:"button",class:"btn",onClick:L},"保存配置")):c("",!0),d(o).isAdmin?(r(),u("button",{key:1,type:"button",class:"btn ghost",disabled:M.value,onClick:J},a(M.value?"测试中…":"测试连通性"),9,Fe)):c("",!0),h.value?(r(),u("span",He,a(h.value),1)):c("",!0),A.value?(r(),u("span",Re," 最近更新:"+a(O(A.value.updatedAt))+" · "+a(A.value.updatedBy),1)):c("",!0)])]))]))}}),Ge=Q(je,[["__scopeId","data-v-1aa8fc55"]]);export{Ge as default};
import{d as b,u as y,c as p,a as V,b as t,w as k,n as A,e as v,v as _,t as g,f as C,g as m,h as q,i as x,r as o,o as f,A as N}from"./index-BRA9HJ0O.js";const S={class:"tops-login"},h={class:"tops-login__stage"},B={class:"tops-login__field"},R={class:"tops-login__field"},T={key:0,class:"tops-login__error",role:"alert"},z=["disabled"],L=b({__name:"LoginView",setup(D){const c=y(),l=x(),r=o("admin"),u=o("Admin@123"),i=o(""),a=o(!1),d=o(!1);async function w(){i.value="",a.value=!0;try{await c.login(r.value.trim(),u.value);const e=typeof l.currentRoute.value.query.redirect=="string"?l.currentRoute.value.query.redirect:"/admin";await l.push(e.startsWith("/")?e:"/admin")}catch(e){i.value=e instanceof N?e.message:"登录失败",d.value=!0,window.setTimeout(()=>{d.value=!1},480)}finally{a.value=!1}}return(e,s)=>(f(),p("div",S,[s[10]||(s[10]=V('<div class="tops-login__atmosphere" aria-hidden="true"><div class="tops-login__grid"></div><div class="tops-login__glow tops-login__glow--a"></div><div class="tops-login__glow tops-login__glow--b"></div><div class="tops-login__horizon"></div></div>',1)),t("div",h,[s[6]||(s[6]=t("div",{class:"tops-login__mark"},"JH",-1)),s[7]||(s[7]=t("h1",{class:"tops-login__title"},"分子实验室三维可视化管理平台",-1)),s[8]||(s[8]=t("p",{class:"tops-login__tagline"},"Lab Intelligent Control",-1)),s[9]||(s[9]=t("p",{class:"tops-login__support"},"登录进入管理后台;大屏无需登录即可查看实时态势。",-1)),t("form",{class:A(["tops-login__form",{"is-shake":d.value}]),onSubmit:k(w,["prevent"])},[t("label",B,[s[3]||(s[3]=t("span",null,"用户名",-1)),v(t("input",{"onUpdate:modelValue":s[0]||(s[0]=n=>r.value=n),autocomplete:"username",required:""},null,512),[[_,r.value]])]),t("label",R,[s[4]||(s[4]=t("span",null,"密码",-1)),v(t("input",{"onUpdate:modelValue":s[1]||(s[1]=n=>u.value=n),type:"password",autocomplete:"current-password",required:""},null,512),[[_,u.value]])]),i.value?(f(),p("p",T,g(i.value),1)):C("",!0),t("button",{class:"tops-login__submit",type:"submit",disabled:a.value},[t("span",null,g(a.value?"登录中…":"进入后台"),1)],8,z),s[5]||(s[5]=t("p",{class:"tops-login__hint"},[m(" 演示账号 "),t("code",null,"admin / Admin@123"),m(" · "),t("code",null,"viewer / Viewer@123")],-1)),t("button",{class:"tops-login__dash",type:"button",onClick:s[2]||(s[2]=n=>q(l).push("/dashboard"))}," 无需登录,进入大屏 ")],34)])]))}});export{L as default};
import{d as Q,G as et,j as X,q as lt,c as d,x as H,b as t,F as A,B as L,f as T,t as l,r as M,y as K,o as u,_ as tt,u as st,n as U,h as m,z as j,e as C,v as V,J as at,g as q,K as nt,l as G}from"./index-BRA9HJ0O.js";import{u as ot}from"./realtime-BbiS_VD3.js";const it=["viewBox","width","height"],dt=["stop-color"],ut=["stop-color"],rt=["x1","x2","y1","y2"],ct=["x","y"],vt=["d","fill"],pt=["d","stroke"],mt=["cx","cy","r","fill","onMouseenter"],bt=["x","y"],ht=Q({__name:"CostLineChart",props:{points:{},color:{default:"#37c8ff"},unit:{default:"元"},height:{default:180},labelEvery:{default:0}},setup(k){const c=k,x=`lg-${et().replace(/:/g,"")}`,y=M(null),w=M(null),$=M(640),b={top:16,right:16,bottom:28,left:48};let r;X(()=>{if(!y.value)return;const o=()=>{var h;const v=((h=y.value)==null?void 0:h.clientWidth)||640;$.value=Math.max(280,Math.round(v))};o(),r=new ResizeObserver(o),r.observe(y.value)}),lt(()=>r==null?void 0:r.disconnect());const a=K(()=>{const o=c.points,v=$.value;if(!o.length)return{path:"",area:"",dots:[],yTicks:[],xLabels:[]};const h=o.map(i=>i.value),g=Math.min(...h),D=Math.max(...h),E=D-g||D*.1||1,N=Math.max(0,g-E*.12),W=D+E*.12,z=v-b.left-b.right,R=c.height-b.top-b.bottom,p=W-N||1,O=i=>b.left+(o.length===1?z/2:i/(o.length-1)*z),I=i=>b.top+(W-i)/p*R,F=o.map((i,f)=>({x:O(f),y:I(i.value),label:i.label,value:i.value})),S=F.map((i,f)=>`${f===0?"M":"L"}${i.x.toFixed(1)},${i.y.toFixed(1)}`).join(" "),J=`${S} L${F[F.length-1].x.toFixed(1)},${(b.top+R).toFixed(1)} L${F[0].x.toFixed(1)},${(b.top+R).toFixed(1)} Z`,Y=[];for(let i=0;i<=4;i++){const f=N+p*i/4;Y.push({v:f,y:I(f)})}const n=c.labelEvery>0?c.labelEvery:o.length<=8?1:o.length<=16?2:Math.ceil(o.length/8),e=F.map((i,f)=>({x:i.x,label:i.label,show:f%n===0||f===F.length-1})).filter(i=>i.show);return{path:S,area:J,dots:F,yTicks:Y,xLabels:e}});function P(o){return o>=1e4?`${(o/1e4).toFixed(1)}万`:o>=1e3?o.toFixed(0):o.toFixed(o>=100?0:1)}return(o,v)=>(u(),d("div",{ref_key:"rootRef",ref:y,class:"line-chart",style:H({height:`${k.height}px`})},[(u(),d("svg",{class:"svg",viewBox:`0 0 ${$.value} ${k.height}`,width:$.value,height:k.height,preserveAspectRatio:"xMidYMid meet"},[t("defs",null,[t("linearGradient",{id:x,x1:"0",y1:"0",x2:"0",y2:"1"},[t("stop",{offset:"0%","stop-color":k.color,"stop-opacity":"0.35"},null,8,dt),t("stop",{offset:"100%","stop-color":k.color,"stop-opacity":"0.02"},null,8,ut)])]),(u(!0),d(A,null,L(a.value.yTicks,(h,g)=>(u(),d("line",{key:`g-${g}`,x1:b.left,x2:$.value-b.right,y1:h.y,y2:h.y,stroke:"rgba(62,200,255,0.12)","stroke-width":"1"},null,8,rt))),128)),(u(!0),d(A,null,L(a.value.yTicks,(h,g)=>(u(),d("text",{key:`yl-${g}`,x:b.left-8,y:h.y+4,"text-anchor":"end",class:"axis"},l(P(h.v)),9,ct))),128)),a.value.area?(u(),d("path",{key:0,d:a.value.area,fill:`url(#${x})`},null,8,vt)):T("",!0),a.value.path?(u(),d("path",{key:1,d:a.value.path,fill:"none",stroke:k.color,"stroke-width":"2.2","stroke-linejoin":"round","stroke-linecap":"round"},null,8,pt)):T("",!0),(u(!0),d(A,null,L(a.value.dots,(h,g)=>(u(),d("g",{key:`d-${g}`},[t("circle",{cx:h.x,cy:h.y,r:w.value===g?5:3.2,fill:k.color,stroke:"rgba(4,16,31,0.9)","stroke-width":"1.5",onMouseenter:D=>w.value=g,onMouseleave:v[0]||(v[0]=D=>w.value=null)},null,40,mt)]))),128)),(u(!0),d(A,null,L(a.value.xLabels,(h,g)=>(u(),d("text",{key:`xl-${g}`,x:h.x,y:k.height-8,"text-anchor":"middle",class:"axis"},l(h.label),9,bt))),128))],8,it)),w.value!=null&&a.value.dots[w.value]?(u(),d("div",{key:0,class:"tip",style:H({left:`${a.value.dots[w.value].x/$.value*100}%`})},[t("b",null,l(a.value.dots[w.value].label),1),t("span",null,l(a.value.dots[w.value].value.toLocaleString("zh-CN",{minimumFractionDigits:2,maximumFractionDigits:2}))+" "+l(k.unit),1)],4)):T("",!0)],4))}}),B=tt(ht,[["__scopeId","data-v-7577b21a"]]),yt={class:"page-fill billing-page"},ft={class:"billing-tabs"},gt=["disabled"],wt={key:0,class:"msg"},_t={class:"metric-grid three"},kt={class:"panel metric-card"},xt={class:"m-value"},$t={class:"m-sub"},Ft={class:"panel metric-card"},Ct={class:"m-value"},Mt={class:"m-sub"},Vt={class:"panel metric-card"},Pt={class:"m-value"},At={class:"m-sub"},Dt={class:"usage-charts"},Wt={class:"panel chart-panel"},Ut={class:"panel chart-panel"},Kt={class:"panel chart-panel"},Lt={class:"metric-grid four"},Tt={class:"panel metric-card tone-amber"},Rt={class:"m-value"},St={class:"panel metric-card tone-cyan"},jt={class:"m-value"},Bt={class:"m-sub"},Et={class:"panel metric-card tone-green"},Nt={class:"m-value"},zt={class:"m-sub"},Ot={class:"panel metric-card tone-violet"},It={class:"m-value"},Yt={class:"m-sub"},Gt={class:"split"},Jt={class:"panel"},qt={class:"kind-list"},Ht={class:"kind-meta"},Zt={class:"kind-name"},Qt={class:"kind-usage"},Xt={class:"kind-bar"},te={class:"kind-cost"},ee={class:"panel chart-panel"},le={class:"panel chart-panel month-trend"},se={key:2,class:"panel tariff-panel"},ae={class:"panel-head"},ne={class:"form-grid tariff-grid"},oe={class:"field"},ie=["disabled"],de={class:"field"},ue=["disabled"],re={class:"field"},ce=["disabled"],ve={class:"field"},pe=["disabled"],me={class:"field"},be=["disabled"],he={class:"field"},ye=["disabled"],fe={class:"field"},ge=["disabled"],we={class:"field"},_e=["disabled"],ke={class:"field switch-field"},xe={class:"switch"},$e=["disabled"],Fe={class:"tariff-actions"},Ce=["disabled"],Me={class:"hint"},Ve={key:3,class:"panel report-panel"},Pe={class:"panel-head"},Ae={class:"report-tools"},De=["disabled"],We={key:0,class:"report-summary"},Ue={class:"table-wrap"},Ke={class:"table"},Le={key:4,class:"loading"},Te=Q({__name:"MeteringView",setup(k){const c=st(),x=ot(),y=M("stats"),w=M(!1),$=M(!1),b=M(""),r=M(null),a=nt({powerPeak:0,powerFlat:0,powerValley:0,water:0,cooling:0,serviceFeePct:0,taxPct:0,settleDay:1,enabled:!0,updatedAt:"",updatedBy:""}),P=M(""),o=M(""),v=M(null),h=K(()=>{var n;return(((n=r.value)==null?void 0:n.usage30d)||[]).map(e=>({label:e.label,value:e.powerKwh}))}),g=K(()=>{var n;return(((n=r.value)==null?void 0:n.usage30d)||[]).map(e=>({label:e.label,value:e.waterM3}))}),D=K(()=>{var n;return(((n=r.value)==null?void 0:n.usage30d)||[]).map(e=>({label:e.label,value:e.coolingKwh}))}),E=K(()=>{var n;return(((n=r.value)==null?void 0:n.trend7d)||[]).map(e=>({label:e.date,value:e.total}))}),N=K(()=>{var n;return(((n=r.value)==null?void 0:n.trend12m)||[]).map(e=>({label:e.label,value:e.total}))});function W(n){return n<10?`0${n}`:`${n}`}function z(){const n=new Date;return`${n.getFullYear()}-${W(n.getMonth()+1)}-${W(n.getDate())}`}function R(n){const e=new Date;return e.setDate(e.getDate()-n),`${e.getFullYear()}-${W(e.getMonth()+1)}-${W(e.getDate())}`}function p(n){return n.toLocaleString("zh-CN",{minimumFractionDigits:2,maximumFractionDigits:2})}async function O(){r.value=await G("/api/billing/stats",{token:c.token})}async function I(){const n=await G("/api/billing/tariff",{token:c.token});Object.assign(a,n)}async function F(){!P.value||!o.value||(v.value=await G(`/api/billing/report?from=${P.value}&to=${o.value}`,{token:c.token}))}async function S(){w.value=!0,b.value="";try{P.value||(P.value=R(29)),o.value||(o.value=z()),await Promise.all([O(),I(),F()])}catch(n){b.value=n instanceof Error?n.message:"加载失败"}finally{w.value=!1}}async function J(){if(c.isAdmin){$.value=!0,b.value="";try{const n=await G("/api/billing/tariff",{method:"PUT",token:c.token,body:JSON.stringify({powerPeak:a.powerPeak,powerFlat:a.powerFlat,powerValley:a.powerValley,water:a.water,cooling:a.cooling,serviceFeePct:a.serviceFeePct,taxPct:a.taxPct,settleDay:a.settleDay,enabled:a.enabled})});Object.assign(a,n),b.value="计费设置已保存",await Promise.all([O(),F()])}catch(n){b.value=n instanceof Error?n.message:"保存失败"}finally{$.value=!1}}}function Y(){var Z;if(!((Z=v.value)!=null&&Z.rows.length))return;const e=[["日期","峰电kWh","平电kWh","谷电kWh","用水m³","冷量kWh","电费","水费","冷量费","小计","服务费","税费","合计"].join(",")];for(const _ of v.value.rows)e.push([_.date,_.powerPeakKwh,_.powerFlatKwh,_.powerValleyKwh,_.waterM3,_.coolingKwh,_.powerCost,_.waterCost,_.coolingCost,_.subtotal,_.serviceFee,_.tax,_.total].join(","));const i=new Blob(["\uFEFF"+e.join(`
`)],{type:"text/csv;charset=utf-8"}),f=URL.createObjectURL(i),s=document.createElement("a");s.href=f,s.download=`能耗计费报表_${P.value}_${o.value}.csv`,s.click(),URL.revokeObjectURL(f)}return X(S),(n,e)=>{var i,f;return u(),d("div",yt,[t("div",ft,[t("button",{type:"button",class:U(["tab",{active:y.value==="meter"}]),onClick:e[0]||(e[0]=s=>y.value="meter")},"计量概览",2),t("button",{type:"button",class:U(["tab",{active:y.value==="stats"}]),onClick:e[1]||(e[1]=s=>y.value="stats")},"费用统计",2),t("button",{type:"button",class:U(["tab",{active:y.value==="tariff"}]),onClick:e[2]||(e[2]=s=>y.value="tariff")},"计费设置",2),t("button",{type:"button",class:U(["tab",{active:y.value==="report"}]),onClick:e[3]||(e[3]=s=>y.value="report")},"费用报表",2),e[15]||(e[15]=t("div",{class:"tab-spacer"},null,-1)),t("button",{type:"button",class:"btn ghost",disabled:w.value,onClick:S},"刷新",8,gt),b.value?(u(),d("span",wt,l(b.value),1)):T("",!0)]),y.value==="meter"?(u(),d(A,{key:0},[t("div",_t,[t("section",kt,[e[16]||(e[16]=t("div",{class:"m-label"},"今日用电 (kWh)",-1)),t("div",xt,l(m(x).snapshot.metering.powerKwh.toFixed(1)),1),t("div",$t,"较昨日 "+l(m(x).snapshot.metering.powerTrendPct>0?"↑":"↓")+" "+l(Math.abs(m(x).snapshot.metering.powerTrendPct))+"%",1)]),t("section",Ft,[e[17]||(e[17]=t("div",{class:"m-label"},"今日用水 (m³)",-1)),t("div",Ct,l(m(x).snapshot.metering.waterM3.toFixed(1)),1),t("div",Mt,"较昨日 "+l(m(x).snapshot.metering.waterTrendPct>0?"↑":"↓")+" "+l(Math.abs(m(x).snapshot.metering.waterTrendPct))+"%",1)]),t("section",Vt,[e[18]||(e[18]=t("div",{class:"m-label"},"冷冻水冷量 (kWh)",-1)),t("div",Pt,l(m(x).snapshot.metering.coolingKwh),1),t("div",At,"较昨日 ↑ "+l(m(x).snapshot.metering.coolingTrendPct)+"%",1)])]),t("div",Dt,[t("section",Wt,[e[19]||(e[19]=t("div",{class:"panel-head"},[t("div",{class:"panel-title"},[t("div",{class:"vbar"}),t("h3",null,"近一个月用电趋势")]),t("div",{class:"badge"},"kWh")],-1)),j(B,{points:h.value,color:"#fbbf24",unit:"kWh",height:220},null,8,["points"])]),t("section",Ut,[e[20]||(e[20]=t("div",{class:"panel-head"},[t("div",{class:"panel-title"},[t("div",{class:"vbar"}),t("h3",null,"近一个月用水趋势")]),t("div",{class:"badge"},"m³")],-1)),j(B,{points:g.value,color:"#38bdf8",unit:"m³",height:220},null,8,["points"])]),t("section",Kt,[e[21]||(e[21]=t("div",{class:"panel-head"},[t("div",{class:"panel-title"},[t("div",{class:"vbar"}),t("h3",null,"近一个月冷冻水趋势")]),t("div",{class:"badge"},"kWh")],-1)),j(B,{points:D.value,color:"#67e8f9",unit:"kWh",height:220},null,8,["points"])])])],64)):y.value==="stats"&&r.value?(u(),d(A,{key:1},[t("div",Lt,[t("section",Tt,[e[22]||(e[22]=t("div",{class:"m-label"},"今日费用 (元)",-1)),t("div",Rt,l(p(r.value.today.total)),1),e[23]||(e[23]=t("div",{class:"m-sub"},"含服务费与税",-1))]),t("section",St,[e[24]||(e[24]=t("div",{class:"m-label"},"本月累计 (元)",-1)),t("div",jt,l(p(r.value.month.total)),1),t("div",Bt,"已计 "+l(r.value.month.days)+" 天",1)]),t("section",Et,[e[25]||(e[25]=t("div",{class:"m-label"},"本月电费 (元)",-1)),t("div",Nt,l(p(r.value.month.powerCost)),1),t("div",zt,"用电 "+l(r.value.month.powerKwh)+" kWh",1)]),t("section",Ot,[e[26]||(e[26]=t("div",{class:"m-label"},"本月水+冷 (元)",-1)),t("div",It,l(p(r.value.month.waterCost+r.value.month.coolingCost)),1),t("div",Yt,"水 "+l(r.value.month.waterM3)+" m³ · 冷 "+l(r.value.month.coolingKwh)+" kWh",1)])]),t("div",Gt,[t("section",Jt,[e[27]||(e[27]=t("div",{class:"panel-head"},[t("div",{class:"panel-title"},[t("div",{class:"vbar"}),t("h3",null,"本月费用构成")])],-1)),t("div",qt,[(u(!0),d(A,null,L(r.value.byKind,s=>(u(),d("div",{key:s.kind,class:U(["kind-row",s.kind])},[t("div",Ht,[t("span",Zt,l(s.label),1),t("span",Qt,l(s.usage)+" "+l(s.unit),1)]),t("div",Xt,[t("i",{style:H({width:`${s.pct}%`})},null,4)]),t("div",te,[q("¥ "+l(p(s.cost))+" ",1),t("em",null,l(s.pct)+"%",1)])],2))),128))])]),t("section",ee,[e[28]||(e[28]=t("div",{class:"panel-head"},[t("div",{class:"panel-title"},[t("div",{class:"vbar"}),t("h3",null,"近 7 日费用趋势")]),t("div",{class:"badge"},"元")],-1)),j(B,{points:E.value,color:"#7ad8ff",unit:"元",height:160},null,8,["points"])])]),t("section",le,[e[29]||(e[29]=t("div",{class:"panel-head"},[t("div",{class:"panel-title"},[t("div",{class:"vbar"}),t("h3",null,"近 12 个月费用趋势")]),t("div",{class:"badge"},"元")],-1)),j(B,{points:N.value,color:"#a78bfa",unit:"元",height:170},null,8,["points"])])],64)):y.value==="tariff"?(u(),d("section",se,[t("div",ae,[e[30]||(e[30]=t("div",{class:"panel-title"},[t("div",{class:"vbar"}),t("h3",null,"费率与结算规则")],-1)),t("div",{class:U(["badge",m(c).isAdmin?"":"amber"])},l(m(c).isAdmin?"管理员可改":"只读"),3)]),t("div",ne,[t("div",oe,[e[31]||(e[31]=t("label",null,"峰电价 (元/kWh)",-1)),C(t("input",{"onUpdate:modelValue":e[4]||(e[4]=s=>a.powerPeak=s),type:"number",step:"0.01",min:"0",disabled:!m(c).isAdmin},null,8,ie),[[V,a.powerPeak,void 0,{number:!0}]])]),t("div",de,[e[32]||(e[32]=t("label",null,"平电价 (元/kWh)",-1)),C(t("input",{"onUpdate:modelValue":e[5]||(e[5]=s=>a.powerFlat=s),type:"number",step:"0.01",min:"0",disabled:!m(c).isAdmin},null,8,ue),[[V,a.powerFlat,void 0,{number:!0}]])]),t("div",re,[e[33]||(e[33]=t("label",null,"谷电价 (元/kWh)",-1)),C(t("input",{"onUpdate:modelValue":e[6]||(e[6]=s=>a.powerValley=s),type:"number",step:"0.01",min:"0",disabled:!m(c).isAdmin},null,8,ce),[[V,a.powerValley,void 0,{number:!0}]])]),t("div",ve,[e[34]||(e[34]=t("label",null,"水价 (元/m³)",-1)),C(t("input",{"onUpdate:modelValue":e[7]||(e[7]=s=>a.water=s),type:"number",step:"0.01",min:"0",disabled:!m(c).isAdmin},null,8,pe),[[V,a.water,void 0,{number:!0}]])]),t("div",me,[e[35]||(e[35]=t("label",null,"冷量单价 (元/kWh)",-1)),C(t("input",{"onUpdate:modelValue":e[8]||(e[8]=s=>a.cooling=s),type:"number",step:"0.01",min:"0",disabled:!m(c).isAdmin},null,8,be),[[V,a.cooling,void 0,{number:!0}]])]),t("div",he,[e[36]||(e[36]=t("label",null,"服务费率 (%)",-1)),C(t("input",{"onUpdate:modelValue":e[9]||(e[9]=s=>a.serviceFeePct=s),type:"number",step:"0.1",min:"0",disabled:!m(c).isAdmin},null,8,ye),[[V,a.serviceFeePct,void 0,{number:!0}]])]),t("div",fe,[e[37]||(e[37]=t("label",null,"税率 (%)",-1)),C(t("input",{"onUpdate:modelValue":e[10]||(e[10]=s=>a.taxPct=s),type:"number",step:"0.1",min:"0",disabled:!m(c).isAdmin},null,8,ge),[[V,a.taxPct,void 0,{number:!0}]])]),t("div",we,[e[38]||(e[38]=t("label",null,"每月结算日",-1)),C(t("input",{"onUpdate:modelValue":e[11]||(e[11]=s=>a.settleDay=s),type:"number",min:"1",max:"28",disabled:!m(c).isAdmin},null,8,_e),[[V,a.settleDay,void 0,{number:!0}]])]),t("div",ke,[e[39]||(e[39]=t("label",null,"启用计费",-1)),t("label",xe,[C(t("input",{"onUpdate:modelValue":e[12]||(e[12]=s=>a.enabled=s),type:"checkbox",disabled:!m(c).isAdmin},null,8,$e),[[at,a.enabled]]),t("span",null,l(a.enabled?"已启用":"已停用"),1)])])]),t("div",Fe,[t("button",{type:"button",class:"btn",disabled:!m(c).isAdmin||$.value,onClick:J},l($.value?"保存中…":"保存设置"),9,Ce),t("span",Me," 最近更新:"+l(a.updatedBy||"—")+" · "+l(a.updatedAt?new Date(a.updatedAt).toLocaleString("zh-CN",{hour12:!1}):"—"),1)]),e[40]||(e[40]=t("p",{class:"tip"}," 参考同类能耗计费软件:支持分时电价(峰/平/谷)、水与冷量单价、服务费与税费、按月结算日。费用 = 用量 × 单价 + 服务费 + 税。 ",-1))])):y.value==="report"?(u(),d("section",Ve,[t("div",Pe,[e[43]||(e[43]=t("div",{class:"panel-title"},[t("div",{class:"vbar"}),t("h3",null,"费用明细报表")],-1)),t("div",Ae,[t("label",null,[e[41]||(e[41]=q("自 ",-1)),C(t("input",{"onUpdate:modelValue":e[13]||(e[13]=s=>P.value=s),type:"date"},null,512),[[V,P.value]])]),t("label",null,[e[42]||(e[42]=q("至 ",-1)),C(t("input",{"onUpdate:modelValue":e[14]||(e[14]=s=>o.value=s),type:"date"},null,512),[[V,o.value]])]),t("button",{type:"button",class:"btn",onClick:F},"查询"),t("button",{type:"button",class:"btn ghost",disabled:!((i=v.value)!=null&&i.rows.length),onClick:Y},"导出 CSV",8,De)])]),v.value?(u(),d("div",We,[t("span",null,"区间 "+l(v.value.summary.from)+" ~ "+l(v.value.summary.to)+" · "+l(v.value.summary.days)+" 天",1),t("span",null,"电费 ¥"+l(p(v.value.summary.powerCost)),1),t("span",null,"水费 ¥"+l(p(v.value.summary.waterCost)),1),t("span",null,"冷量费 ¥"+l(p(v.value.summary.coolingCost)),1),t("span",null,"服务费 ¥"+l(p(v.value.summary.serviceFee)),1),t("span",null,"税费 ¥"+l(p(v.value.summary.tax)),1),t("b",null,"合计 ¥"+l(p(v.value.summary.total)),1)])):T("",!0),t("div",Ue,[t("table",Ke,[e[44]||(e[44]=t("thead",null,[t("tr",null,[t("th",null,"日期"),t("th",null,"峰电"),t("th",null,"平电"),t("th",null,"谷电"),t("th",null,"用水"),t("th",null,"冷量"),t("th",null,"电费"),t("th",null,"水费"),t("th",null,"冷量费"),t("th",null,"服务费"),t("th",null,"税费"),t("th",null,"合计")])],-1)),t("tbody",null,[(u(!0),d(A,null,L(((f=v.value)==null?void 0:f.rows)||[],s=>(u(),d("tr",{key:s.date},[t("td",null,l(s.date),1),t("td",null,l(s.powerPeakKwh),1),t("td",null,l(s.powerFlatKwh),1),t("td",null,l(s.powerValleyKwh),1),t("td",null,l(s.waterM3),1),t("td",null,l(s.coolingKwh),1),t("td",null,l(p(s.powerCost)),1),t("td",null,l(p(s.waterCost)),1),t("td",null,l(p(s.coolingCost)),1),t("td",null,l(p(s.serviceFee)),1),t("td",null,l(p(s.tax)),1),t("td",null,[t("b",null,l(p(s.total)),1)])]))),128))])])])])):w.value?(u(),d("div",Le,"加载计费数据…")):T("",!0)])}}}),je=tt(Te,[["__scopeId","data-v-75284264"]]);export{je as default};
.line-chart[data-v-7577b21a]{position:relative;width:100%;min-height:140px}.svg[data-v-7577b21a]{width:100%;height:100%;display:block;overflow:visible}.axis[data-v-7577b21a]{fill:#7fa6c9e6;font-size:11px;font-family:var(--font-num)}.tip[data-v-7577b21a]{position:absolute;top:4px;transform:translate(-50%);display:flex;flex-direction:column;gap:2px;padding:6px 10px;border-radius:6px;background:#06142aeb;border:1px solid rgba(62,200,255,.35);pointer-events:none;white-space:nowrap;z-index:2}.tip b[data-v-7577b21a]{font-size:11px;color:#fff}.tip span[data-v-7577b21a]{font-size:12px;color:var(--cyan-lt);font-family:var(--font-num)}.billing-page[data-v-75284264]{gap:12px}.billing-tabs[data-v-75284264]{display:flex;align-items:center;gap:8px;flex:0 0 auto}.tab[data-v-75284264]{border-radius:6px;border:1px solid rgba(62,200,255,.22);background:#122a4e66;color:var(--muted);padding:6px 14px;font-size:12px;cursor:pointer}.tab.active[data-v-75284264]{background:linear-gradient(90deg,#1a6fff,#0aa6e5);color:#fff;border-color:transparent;font-weight:600}.tab-spacer[data-v-75284264]{flex:1}.msg[data-v-75284264]{font-size:12px;color:var(--cyan-lt)}.metric-grid[data-v-75284264]{display:grid;gap:12px;flex:0 0 auto}.metric-grid.three[data-v-75284264]{grid-template-columns:repeat(3,1fr)}.metric-grid.four[data-v-75284264]{grid-template-columns:repeat(4,1fr)}.metric-card[data-v-75284264]{padding:16px 18px}.metric-card .m-label[data-v-75284264]{font-size:12px;color:var(--label);margin-bottom:8px}.metric-card .m-value[data-v-75284264]{font-size:28px;font-weight:700;font-family:var(--font-num);color:#fff;line-height:1.1}.metric-card .m-sub[data-v-75284264]{margin-top:8px;font-size:11px;color:var(--muted)}.tone-amber .m-value[data-v-75284264]{color:#ffcf5c}.tone-cyan .m-value[data-v-75284264]{color:var(--cyan-lt)}.tone-green .m-value[data-v-75284264]{color:var(--green)}.tone-violet .m-value[data-v-75284264]{color:#c4b5fd}.usage-charts[data-v-75284264]{flex:0 0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:12px;align-items:start}.usage-charts .chart-panel[data-v-75284264]{height:fit-content;padding-bottom:10px}.chart-panel[data-v-75284264]{min-height:0;display:flex;flex-direction:column}.month-trend[data-v-75284264]{flex:0 0 auto}.split[data-v-75284264]{flex:1;min-height:0;display:grid;grid-template-columns:1fr 1.2fr;gap:12px}.split .panel[data-v-75284264]{min-height:0;display:flex;flex-direction:column}.kind-list[data-v-75284264]{display:flex;flex-direction:column;gap:14px}.kind-row[data-v-75284264]{display:grid;grid-template-columns:120px 1fr 140px;gap:12px;align-items:center}.kind-name[data-v-75284264]{display:block;font-size:13px;font-weight:600;color:#fff}.kind-usage[data-v-75284264]{font-size:11px;color:var(--label)}.kind-bar[data-v-75284264]{height:10px;border-radius:5px;background:#ffffff0f;overflow:hidden}.kind-bar i[data-v-75284264]{display:block;height:100%;border-radius:5px}.kind-row.power .kind-bar i[data-v-75284264]{background:linear-gradient(90deg,#d97706,#fbbf24)}.kind-row.water .kind-bar i[data-v-75284264]{background:linear-gradient(90deg,#0284c7,#38bdf8)}.kind-row.cooling .kind-bar i[data-v-75284264]{background:linear-gradient(90deg,#0891b2,#67e8f9)}.kind-cost[data-v-75284264]{text-align:right;font-family:var(--font-num);font-size:13px;color:#fff}.kind-cost em[data-v-75284264]{font-style:normal;color:var(--label);margin-left:6px;font-size:11px}.tariff-panel[data-v-75284264]{flex:1;min-height:0;overflow:auto}.tariff-grid[data-v-75284264]{grid-template-columns:repeat(3,1fr);max-width:960px}.switch-field .switch[data-v-75284264]{display:flex;align-items:center;gap:8px;height:36px;color:var(--body);font-size:13px;cursor:pointer}.tariff-actions[data-v-75284264]{display:flex;align-items:center;gap:14px;margin-top:18px;margin-bottom:12px}.hint[data-v-75284264]{font-size:12px;color:var(--label)}.report-panel[data-v-75284264]{flex:1;min-height:0;display:flex;flex-direction:column}.report-tools[data-v-75284264]{display:flex;align-items:center;gap:10px;font-size:12px;color:var(--label)}.report-tools input[type=date][data-v-75284264]{margin-left:4px;background:#051a3db3;border:1px solid rgba(62,200,255,.3);color:#fff;border-radius:6px;padding:4px 8px}.report-summary[data-v-75284264]{display:flex;flex-wrap:wrap;gap:12px 18px;font-size:12px;color:var(--label);padding:8px 0 12px;border-bottom:1px solid rgba(62,200,255,.12);margin-bottom:8px}.report-summary b[data-v-75284264]{color:var(--cyan-lt);font-family:var(--font-num)}.table-wrap[data-v-75284264]{flex:1;min-height:0;overflow:auto}.table-wrap[data-v-75284264] thead th{position:sticky;top:0;z-index:2;background:#0a1a30;box-shadow:0 1px #3ec8ff38}.loading[data-v-75284264]{padding:24px;color:var(--label);font-size:13px}
.panel-box[data-v-cff92950]{width:100%;height:100%;min-height:0;display:flex;flex-direction:column;background:#00122a6b;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border:1px solid rgba(0,247,255,.12);box-shadow:0 8px 28px #00000047}.title-bar[data-v-cff92950]{position:relative;height:32px;flex-shrink:0;display:flex;align-items:center;justify-content:space-between;padding:0 12px 2px 10px}.title-bar.line[data-v-cff92950]{height:28px;justify-content:flex-start;gap:8px;padding:0 4px 0 6px}.title-frame[data-v-cff92950]{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;pointer-events:none;filter:drop-shadow(0 0 6px rgba(0,247,255,.18))}.title-main[data-v-cff92950],.title-extra[data-v-cff92950]{position:relative;z-index:1;display:flex;align-items:center}.title-main[data-v-cff92950]{gap:8px}.title-icon[data-v-cff92950]{width:10px;height:10px;border:1px solid #00f7ff;background:linear-gradient(#00f7ff,#00f7ff) center / 4px 4px no-repeat,#00284899;box-shadow:0 0 8px #00f7ffb3}.plus-icon[data-v-cff92950]{width:12px;height:12px;flex-shrink:0;background:linear-gradient(#00f7ff,#00f7ff) center / 12px 2px no-repeat,linear-gradient(#00f7ff,#00f7ff) center / 2px 12px no-repeat;box-shadow:0 0 8px #00f7ffb3}.hairline[data-v-cff92950]{flex:1;height:1px;margin-left:4px;background:linear-gradient(90deg,rgba(0,247,255,.7) 0%,rgba(0,247,255,.18) 42%,transparent 46%,transparent 52%,rgba(0,247,255,.2) 56%,rgba(0,247,255,.05) 100%)}.title-text[data-v-cff92950]{font-family:var(--title-font);color:#f4feff;font-size:15px;font-weight:400;letter-spacing:1px;text-shadow:0 0 8px rgba(0,247,255,.35);white-space:nowrap}.title-extra[data-v-cff92950]{gap:8px}.title-dots[data-v-cff92950]{display:flex;gap:4px}.title-dots i[data-v-cff92950]{width:6px;height:6px;background:#00f7ff;box-shadow:0 0 6px #00f7ff}.panel-body[data-v-cff92950]{flex:1;min-height:0;padding:8px 10px 10px;overflow:hidden;display:flex;flex-direction:column}.panel-body.line[data-v-cff92950]{padding:4px 8px 8px}.flip-digit[data-v-56730cea]{position:relative;display:inline-block;width:24px;height:38px;margin:0 1.5px;perspective:180px;font-family:var(--font-num);font-weight:700;font-size:24px;line-height:38px;color:#e8fbff;vertical-align:middle}.flip-digit.punct[data-v-56730cea]{width:12px;background:transparent;box-shadow:none}.flip-digit.punct .static[data-v-56730cea],.flip-digit.punct .hinge[data-v-56730cea]{background:transparent;border:none;box-shadow:none}.static[data-v-56730cea],.hinge[data-v-56730cea]{position:absolute;left:0;width:100%;height:50%;overflow:hidden;background:linear-gradient(180deg,#0d2a4a,#071828);border:1px solid rgba(0,247,255,.28);box-shadow:inset 0 0 8px #00000059}.static.top[data-v-56730cea],.hinge.top[data-v-56730cea]{top:0;border-radius:3px 3px 0 0;border-bottom-color:#00f7ff1f}.static.bottom[data-v-56730cea],.hinge.bottom[data-v-56730cea]{bottom:0;border-radius:0 0 3px 3px;border-top-color:#00000059}.static i[data-v-56730cea],.hinge i[data-v-56730cea]{position:absolute;left:0;width:100%;height:38px;display:flex;align-items:center;justify-content:center;font-style:normal}.static.top i[data-v-56730cea],.hinge.top i[data-v-56730cea]{top:0}.static.bottom i[data-v-56730cea],.hinge.bottom i[data-v-56730cea]{bottom:0}.hinge[data-v-56730cea]{z-index:2;backface-visibility:hidden;transform-style:preserve-3d;pointer-events:none;opacity:0}.hinge.top[data-v-56730cea]{transform-origin:bottom center}.hinge.bottom[data-v-56730cea]{transform-origin:top center;transform:rotateX(90deg)}.hinge.top.active[data-v-56730cea]{opacity:1;animation:flip-top-56730cea .5s ease-in forwards}.hinge.bottom.active[data-v-56730cea]{opacity:1;animation:flip-bottom-56730cea .5s ease-out forwards}@keyframes flip-top-56730cea{0%{transform:rotateX(0)}to{transform:rotateX(-90deg)}}@keyframes flip-bottom-56730cea{0%{transform:rotateX(90deg)}to{transform:rotateX(0)}}.kpi-flip-board[data-v-9e22c451]{display:flex;gap:10px;width:100%;pointer-events:auto}.kpi-flip[data-v-9e22c451]{flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:10px 12px 12px;border-radius:4px;border:1px solid rgba(0,247,255,.28);background:linear-gradient(180deg,#082444e0,#030e20c7);box-shadow:inset 0 1px #ffffff0f,0 8px 20px #00000047;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.kpi-head[data-v-9e22c451]{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;min-width:0}.kpi-texts[data-v-9e22c451]{min-width:0;display:flex;flex-direction:column;align-items:flex-start;gap:2px}.kpi-label[data-v-9e22c451]{font-size:13px;font-weight:600;color:#9ad8f0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}.kpi-hint[data-v-9e22c451]{font-size:10px;color:#9ed2e8b8;white-space:nowrap}.kpi-digits[data-v-9e22c451]{display:flex;align-items:center;justify-content:center;width:100%;min-height:36px}.kpi-flip[data-v-9e22c451] .kpi-ico{width:22px;height:22px;flex:0 0 auto}.tone-power[data-v-9e22c451]{border-color:#ffb44073}.tone-power .kpi-label[data-v-9e22c451]{color:#f0c878}.tone-water[data-v-9e22c451]{border-color:#38bdf873}.tone-water .kpi-label[data-v-9e22c451]{color:#7dd3fc}.tone-cool[data-v-9e22c451]{border-color:#67e8f96b}.tone-cool .kpi-label[data-v-9e22c451]{color:#a5f3fc}.tone-device[data-v-9e22c451]{border-color:#34d39973}.tone-device .kpi-label[data-v-9e22c451]{color:#6ee7b7}.tone-access[data-v-9e22c451]{border-color:#a78bfa73}.tone-access .kpi-label[data-v-9e22c451]{color:#c4b5fd}.overview-float[data-v-5b50b0a3]{position:relative;width:100%;height:100%;min-height:0;display:grid;grid-template-columns:var(--screen-side-w, clamp(280px, 24vw, 480px)) minmax(0,1fr) var( --screen-side-w, clamp(280px, 24vw, 480px) );grid-template-rows:auto 1fr;gap:10px 12px;pointer-events:none}.kpi-top[data-v-5b50b0a3]{grid-column:2;grid-row:1;min-width:0;z-index:2}.side[data-v-5b50b0a3]{min-width:0;min-height:0;height:100%;display:grid;gap:10px;pointer-events:auto}.left[data-v-5b50b0a3],.right[data-v-5b50b0a3]{grid-row:1 / -1;grid-template-rows:minmax(0,1.05fr) minmax(0,1fr) minmax(0,.95fr)}.left[data-v-5b50b0a3]{grid-column:1}.right[data-v-5b50b0a3]{grid-column:3}.center-pass[data-v-5b50b0a3]{grid-column:2;grid-row:2;pointer-events:none}.badge[data-v-5b50b0a3]{padding:1px 7px;border-radius:2px;font-size:10px;letter-spacing:.06em;background:#52c41a24;color:#7dff9a;border:1px solid rgba(82,196,26,.28)}.badge.amber[data-v-5b50b0a3]{background:#faad1424;color:#ffd666;border-color:#faad1459}.panel-stack[data-v-5b50b0a3]{flex:1;min-height:0;display:flex;flex-direction:column;gap:6px}.chart-main[data-v-5b50b0a3]{flex:1.2;min-height:0}.chart-sub[data-v-5b50b0a3]{flex:.9;min-height:0}.panel-split[data-v-5b50b0a3]{flex:1;min-height:0;display:grid;grid-template-columns:1fr 1fr;gap:6px}.panel-split--pies[data-v-5b50b0a3]{grid-template-rows:minmax(0,1fr)}.pie-col[data-v-5b50b0a3]{min-height:0;display:flex;flex-direction:column}.pie-cap[data-v-5b50b0a3]{text-align:center;font-size:10px;color:var(--panel-muted);margin-top:-2px}.digit-row[data-v-5b50b0a3]{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;flex-shrink:0}.digit-card[data-v-5b50b0a3]{padding:6px 8px;border-radius:4px;background:#061c3873;border:1px solid rgba(0,247,255,.14)}.digit-card.warn[data-v-5b50b0a3]{border-color:#faad1466}.d-label[data-v-5b50b0a3]{font-size:10px;color:var(--panel-muted)}.d-value[data-v-5b50b0a3]{margin-top:2px;font-size:15px;font-weight:600;font-family:var(--font-num);color:#fff;line-height:1.2}.d-value small[data-v-5b50b0a3]{font-size:10px;color:#c8e6f5a6;font-weight:500}.energy-digits[data-v-5b50b0a3]{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;flex-shrink:0}.ed[data-v-5b50b0a3]{padding:8px;border-radius:4px;background:#061c3873;border:1px solid rgba(0,247,255,.14)}.ed span[data-v-5b50b0a3]{display:block;font-size:10px;color:var(--panel-muted)}.ed b[data-v-5b50b0a3]{display:block;margin-top:4px;font-size:16px;font-family:var(--font-num);color:#fff}.ed small[data-v-5b50b0a3]{font-size:10px;color:#c8e6f5a6;font-weight:500}.screen-err[data-v-5b50b0a3]{position:absolute;left:50%;bottom:12px;transform:translate(-50%);z-index:8;padding:4px 12px;border-radius:4px;background:#781414bf;color:#ffb0b0;font-size:12px;pointer-events:none}.side[data-v-5b50b0a3] .echart-wrap{flex:1;min-height:0}
import{d as A,c as d,b as t,n as b,F as O,t as u,m as R,G as Z,o as c,_ as D,r as P,p as Q,B as V,y as p,s as K,f as I,j as X,q as Y,l as ee,H as te,z as f,C as y,h as W,g as F}from"./index-BRA9HJ0O.js";import{A as ae}from"./AppIcon-9A2sZQ-4.js";import{E as $,c as se,r as L,m as T,t as le,p as j}from"./historyChart-ytIpUdNw.js";import{u as oe}from"./realtime-BbiS_VD3.js";const ne={class:"panel-box"},ie={class:"title-text"},re={class:"title-main"},ue={class:"title-text"},ce={class:"title-extra"},pe={class:"title-frame",viewBox:"0 0 460 32",preserveAspectRatio:"none"},de=["fill"],ve=A({__name:"PanelBox",props:{title:{},variant:{default:"bar"}},setup(r){const v=`titleFill-${Z()}`;return(o,l)=>(c(),d("div",ne,[t("div",{class:b(["title-bar",r.variant])},[r.variant==="line"?(c(),d(O,{key:0},[l[0]||(l[0]=t("i",{class:"plus-icon"},null,-1)),t("span",ie,u(r.title),1),l[1]||(l[1]=t("b",{class:"hairline"},null,-1))],64)):(c(),d(O,{key:1},[t("div",re,[l[2]||(l[2]=t("i",{class:"title-icon"},null,-1)),t("span",ue,u(r.title),1)]),t("div",ce,[R(o.$slots,"extra",{},void 0),l[3]||(l[3]=t("span",{class:"title-dots"},[t("i"),t("i")],-1))]),(c(),d("svg",pe,[t("defs",null,[t("linearGradient",{id:v,x1:"0",y1:"0",x2:"1",y2:"0"},[...l[4]||(l[4]=[t("stop",{offset:"0%","stop-color":"#0b4d86","stop-opacity":"0.72"},null,-1),t("stop",{offset:"55%","stop-color":"#07325a","stop-opacity":"0.38"},null,-1),t("stop",{offset:"100%","stop-color":"#031528","stop-opacity":"0.08"},null,-1)])])]),t("path",{d:"M0 0 H460 V32 H0 Z",fill:`url(#${v})`},null,8,de),l[5]||(l[5]=t("path",{d:"M0 31.3 H460",fill:"none",stroke:"#00f7ff","stroke-width":"1.4"},null,-1))]))],64))],2),t("div",{class:b(["panel-body",r.variant])},[R(o.$slots,"default",{},void 0)],2)]))}}),B=D(ve,[["__scopeId","data-v-cff92950"]]),me={class:"static top"},fe={class:"static bottom"},ge=A({__name:"FlipDigit",props:{char:{}},setup(r){const v=r,o=P(v.char||"0"),l=P(v.char||"0"),n=P(!1);return Q(()=>v.char,g=>{const i=g||" ";i!==o.value&&(l.value=o.value,o.value=i,!(!/[0-9]/.test(i)&&!/[0-9]/.test(l.value))&&(n.value=!1,requestAnimationFrame(()=>{n.value=!0,window.setTimeout(()=>{n.value=!1,l.value=i},520)})))}),(g,i)=>(c(),d("span",{class:b(["flip-digit",{flip:n.value,punct:!/[0-9]/.test(o.value)}])},[t("span",me,[t("i",null,u(o.value),1)]),t("span",fe,[t("i",null,u(o.value),1)]),t("span",{class:b(["hinge top",{active:n.value}])},[t("i",null,u(l.value),1)],2),t("span",{class:b(["hinge bottom",{active:n.value}])},[t("i",null,u(o.value),1)],2)],2))}}),he=D(ge,[["__scopeId","data-v-56730cea"]]),ye={class:"kpi-flip-board"},be={class:"kpi-head"},ke={class:"kpi-texts"},_e={class:"kpi-label"},xe={key:0,class:"kpi-hint"},$e=["aria-label"],we=A({__name:"KpiFlipBoard",props:{items:{}},setup(r){const v=r;function o(n){return String(n??"").split("")}const l=p(()=>v.items||[]);return(n,g)=>(c(),d("div",ye,[(c(!0),d(O,null,V(l.value,i=>(c(),d("div",{key:i.key,class:b(["kpi-flip",i.tone?`tone-${i.tone}`:""])},[t("div",be,[i.icon?(c(),K(ae,{key:0,name:i.icon,"class-name":"kpi-ico"},null,8,["name"])):I("",!0),t("div",ke,[t("div",_e,u(i.label),1),i.hint?(c(),d("div",xe,u(i.hint),1)):I("",!0)])]),t("div",{class:"kpi-digits","aria-label":i.label},[(c(!0),d(O,null,V(o(i.value),(k,m)=>(c(),K(he,{key:`${i.key}-${m}`,char:k},null,8,["char"]))),128))],8,$e)],2))),128))]))}}),Oe=D(we,[["__scopeId","data-v-9e22c451"]]),Se={deviceTotal:0,deviceOnline:0,roomOk:0,roomTotal:0,fridgeOk:0,fridgeTotal:0,alarmOpen:0,gatewayOk:!1,powerKwh:0,waterM3:0,coolingKwh:0};function Te(r){var o,l,n;const v=g=>g?Object.entries(g).map(([i,k])=>{const m=k||[];return`${i}:${m.length}:${m.map(_=>{var C,M;const x=(C=_.points)==null?void 0:C[_.points.length-1];return`${_.id}:${((M=_.points)==null?void 0:M.length)||0}:${(x==null?void 0:x.t)??""}:${(x==null?void 0:x.v)??""}`}).join(",")}`}).join("|"):"";return JSON.stringify({digits:r.digits,statusPie:r.statusPie,categoryPie:r.categoryPie,envBubbles:r.envBubbles,pauCards:r.pauCards,pressureBars:r.pressureBars,fridgeBars:r.fridgeBars,curves:v(r.curves),alarmN:((l=(o=r.snapshot)==null?void 0:o.pointAlarms)==null?void 0:l.length)??0,snapTs:((n=r.snapshot)==null?void 0:n.ts)??0})}function Be(r=5e3){const v=te(null),o=P(!1),l=P("");let n,g=!1,i="";async function k(){if(!g){g=!0;try{const m=await ee("/api/realtime/screen"),_=Te(m);_!==i&&(i=_,v.value=m),l.value=""}catch(m){v.value||(l.value=m instanceof Error?m.message:"大屏数据加载失败")}finally{o.value=!1,g=!1}}}return X(()=>{k(),n=window.setInterval(()=>void k(),r)}),Y(()=>{n&&window.clearInterval(n)}),{data:v,loading:o,error:l,refresh:k,emptyDigits:Se}}const Ce={class:"overview-float"},Fe={class:"kpi-top"},Pe={class:"side left"},Me={class:"panel-stack"},ze={class:"panel-split panel-split--pies"},He={class:"pie-col"},Ie={class:"pie-col"},Ae={class:"side right"},De={class:"panel-stack"},Le={class:"digit-row"},Ve={class:"d-label"},Ne={class:"d-value"},Re={key:0},Ke={class:"panel-stack"},We={class:"energy-digits"},je={class:"ed"},Ee={class:"ed"},Ue={class:"ed"},qe={key:0,class:"screen-err"},z="#9ec8dc",H="rgba(158,200,220,0.35)",Ge=A({__name:"OverviewView",setup(r){const v=oe(),{data:o,error:l}=Be(5e3),n=p(()=>{var e;return(e=o.value)==null?void 0:e.digits}),g=p(()=>{var e;return((e=o.value)==null?void 0:e.snapshot)||v.snapshot}),i=p(()=>{const e=n.value;return e?[{key:"online",label:"设备在线率 %",value:e.deviceTotal?(e.deviceOnline/e.deviceTotal*100).toFixed(0):"0",hint:`${e.deviceOnline}/${e.deviceTotal} · 网关${e.gatewayOk?"通":"断"}`,tone:"device",icon:"kpi-device"},{key:"cold",label:"冷链正常",value:`${e.fridgeOk}/${e.fridgeTotal}`,hint:e.fridgeOk===e.fridgeTotal?"全部达标":"有关注项",tone:"cool",icon:"kpi-cool"},{key:"env",label:"环境合格区",value:`${e.roomOk}/${e.roomTotal}`,hint:e.roomOk===e.roomTotal?"全部正常":"有关注区",tone:"access",icon:"kpi-access"},{key:"alarm",label:"当前告警",value:String(e.alarmOpen),hint:e.alarmOpen?"值班跟进中":"无未闭环",tone:e.alarmOpen?"power":"device",icon:"kpi-power"}]:[{key:"online",label:"设备在线率 %",value:"—",tone:"device",icon:"kpi-device"},{key:"cold",label:"冷链正常",value:"—",tone:"cool",icon:"kpi-cool"},{key:"env",label:"环境合格区",value:"—",tone:"access",icon:"kpi-access"},{key:"alarm",label:"当前告警",value:"—",tone:"power",icon:"kpi-power"}]}),k=p(()=>{var s;const e=(((s=o.value)==null?void 0:s.envBubbles)||[]).filter(a=>a.temp!==null&&a.humidity!==null);return{backgroundColor:"transparent",animation:!1,animationDurationUpdate:0,grid:{left:36,right:12,top:28,bottom:28},tooltip:{...se,trigger:"item",formatter:a=>{const w=a.data;return w!=null&&w.value?`<div style="font-size:11px;color:#e8fbff;font-weight:600">${w.name??""}</div><div style="font-size:10px;margin-top:2px">温度 ${L(w.value[0])}℃ · 湿度 ${L(w.value[1])}%</div><div style="font-size:10px">压差 ${L(w.pressure)} Pa</div>`:""}},xAxis:{name:"℃",nameTextStyle:{color:H,fontSize:10},axisLabel:{color:z,fontSize:10},splitLine:{lineStyle:{color:"rgba(0,247,255,0.08)"}},axisLine:{lineStyle:{color:H}}},yAxis:{name:"%RH",nameTextStyle:{color:H,fontSize:10},axisLabel:{color:z,fontSize:10},splitLine:{lineStyle:{color:"rgba(0,247,255,0.08)"}},axisLine:{lineStyle:{color:H}}},series:[{type:"scatter",symbolSize:a=>Math.max(10,Math.min(36,Math.abs(Number(a[2])||0)*1.2+10)),data:e.map(a=>({value:[Math.round(a.temp*10)/10,Math.round(a.humidity*10)/10,Math.round(Math.abs(a.pressure??0)*10)/10],name:a.name,pressure:a.pressure==null?null:Math.round(a.pressure*10)/10,itemStyle:{color:a.ok?"rgba(82,196,26,0.85)":"rgba(250,173,20,0.9)"}}))}]}}),m=p(()=>{var e;return(e=o.value)==null?void 0:e.curves}),_=p(()=>{var e;return T(((e=m.value)==null?void 0:e.roomTemp)||[],{unit:"℃",maxSeries:10})}),x=p(()=>{var s,a;const e=((s=m.value)==null?void 0:s.fridgeTemp)||[];return e.length?T(e,{unit:"℃",maxSeries:10}):T(le(((a=o.value)==null?void 0:a.fridgeSeries)||[]),{unit:"℃",maxSeries:10})}),C=p(()=>{const e=n.value;return e?e.fridgeTotal===0?{text:"无冰箱设备",amber:!0}:e.fridgeOk<e.fridgeTotal?{text:`${e.fridgeTotal-e.fridgeOk}台关注`,amber:!0}:{text:"全部正常",amber:!1}:{text:"采集中",amber:!1}}),M=p(()=>{var a;const e=((a=o.value)==null?void 0:a.statusPie)||[],s={在线:"#52c41a",告警:"#faad14",离线:"#ff4d4f",未知:"#5a7a90"};return{backgroundColor:"transparent",animation:!1,animationDurationUpdate:0,tooltip:j(),series:[{type:"pie",radius:["42%","68%"],center:["50%","52%"],animation:!1,label:{color:z,fontSize:10,formatter:`{b}
{c}`},data:e.map(h=>({name:h.name,value:h.value,itemStyle:{color:s[h.name]||"#37c8ff"}}))}]}}),E=p(()=>{var s;const e=((s=o.value)==null?void 0:s.categoryPie)||[];return{backgroundColor:"transparent",animation:!1,animationDurationUpdate:0,tooltip:j(),series:[{type:"pie",radius:["36%","62%"],center:["50%","52%"],roseType:"radius",animation:!1,label:{color:z,fontSize:9,formatter:"{b}"},data:e.map(a=>({name:a.name,value:a.value}))}]}}),U=p(()=>{var e;return((e=o.value)==null?void 0:e.pauCards)||[]}),q=p(()=>{const e=m.value,s=[...((e==null?void 0:e.hvacSupplyTemp)||[]).map(a=>({...a,name:`${a.name||a.id}·送风温`})),...((e==null?void 0:e.vavAirflow)||[]).map(a=>({...a,name:`${a.name||a.id}·风量`}))];return T(s,{maxSeries:8})}),G=p(()=>{var a;const e=((a=m.value)==null?void 0:a.roomPressure)||[],s=e.filter(h=>/缓冲|气锁|负压|PCR/i.test(h.name||h.id));return T(s.length?s:e,{unit:"Pa",maxSeries:10})}),N=p(()=>{var s;const e=(((s=o.value)==null?void 0:s.pressureBars)||[]).filter(a=>/缓冲|气锁|负压/i.test(a.name));return e.length?e.every(a=>a.ok):!0}),J=p(()=>{const e=m.value,s=[...((e==null?void 0:e.meteringPower)||[]).map(a=>({...a,name:"用电 kWh"})),...((e==null?void 0:e.meteringWater)||[]).map(a=>({...a,name:"用水 m³"})),...((e==null?void 0:e.meteringCooling)||[]).map(a=>({...a,name:"冷量 kWh"}))];return T(s,{maxSeries:6})}),S=p(()=>n.value);return(e,s)=>(c(),d("div",Ce,[t("div",Fe,[f(Oe,{items:i.value},null,8,["items"])]),t("div",Pe,[f(B,{title:"环境总览"},{extra:y(()=>{var a,h;return[t("span",{class:b(["badge",{amber:(((a=n.value)==null?void 0:a.roomOk)??0)<(((h=n.value)==null?void 0:h.roomTotal)??0)}])},u(n.value?`${n.value.roomOk}/${n.value.roomTotal} 合格`:"设备台账"),3)]}),default:y(()=>[t("div",Me,[f($,{class:"chart-main",option:k.value,"empty-text":"房间温湿度待采集"},null,8,["option"]),f($,{class:"chart-sub",option:_.value,"empty-text":"房间温度历史积累中"},null,8,["option"])])]),_:1}),f(B,{title:"冷链监测 · 24h"},{extra:y(()=>[t("span",{class:b(["badge",{amber:C.value.amber}])},u(C.value.text),3)]),default:y(()=>[f($,{option:x.value,"empty-text":"冰箱温度历史积累中"},null,8,["option"])]),_:1}),f(B,{title:"设备状态"},{extra:y(()=>{var a,h;return[t("span",{class:b(["badge",{amber:(((a=n.value)==null?void 0:a.alarmOpen)??0)>0}])},u((h=n.value)!=null&&h.alarmOpen?`${n.value.alarmOpen}项告警`:"运行正常"),3)]}),default:y(()=>[t("div",ze,[t("div",He,[f($,{option:M.value,"empty-text":"无设备"},null,8,["option"]),s[0]||(s[0]=t("div",{class:"pie-cap"},"在线状态",-1))]),t("div",Ie,[f($,{option:E.value,"empty-text":"无分类"},null,8,["option"]),s[1]||(s[1]=t("div",{class:"pie-cap"},"设备分类",-1))])])]),_:1})]),s[9]||(s[9]=t("div",{class:"center-pass","aria-hidden":"true"},null,-1)),t("div",Ae,[f(B,{title:"系统运行状态"},{extra:y(()=>[t("span",{class:b(["badge",{amber:!g.value.hvac.systemRunning}])},u(g.value.hvac.systemRunning?"机组运行":"未启动/无信号"),3)]),default:y(()=>[t("div",De,[t("div",Le,[(c(!0),d(O,null,V(U.value,a=>(c(),d("div",{key:a.label,class:b(["digit-card",{warn:a.ok===!1}])},[t("div",Ve,u(a.label),1),t("div",Ne,[a.value===null?(c(),d(O,{key:0},[F("—")],64)):(c(),d(O,{key:1},[F(u(a.unit===""?a.value?"开":"关":a.value.toFixed(1))+" ",1),a.unit?(c(),d("small",Re,u(a.unit),1)):I("",!0)],64))])],2))),128))]),f($,{class:"chart-sub",option:q.value,"empty-text":"机组/VAV 历史积累中"},null,8,["option"])])]),_:1}),f(B,{title:"生物安全压差 · 24h"},{extra:y(()=>[t("span",{class:b(["badge",{amber:!N.value}])},u(N.value?"联锁正常":"压差关注"),3)]),default:y(()=>[f($,{option:G.value,"empty-text":"压差历史积累中"},null,8,["option"])]),_:1}),f(B,{title:"能耗与绿色 · 24h"},{extra:y(()=>[...s[2]||(s[2]=[t("span",{class:"badge"},"时序",-1)])]),default:y(()=>[t("div",Ke,[t("div",We,[t("div",je,[s[4]||(s[4]=t("span",null,"总用电",-1)),t("b",null,[F(u(S.value?S.value.powerKwh.toFixed(1):"—")+" ",1),s[3]||(s[3]=t("small",null,"kWh",-1))])]),t("div",Ee,[s[6]||(s[6]=t("span",null,"用水",-1)),t("b",null,[F(u(S.value?S.value.waterM3.toFixed(1):"—")+" ",1),s[5]||(s[5]=t("small",null,"m³",-1))])]),t("div",Ue,[s[8]||(s[8]=t("span",null,"冷量",-1)),t("b",null,[F(u(S.value?S.value.coolingKwh.toFixed(0):"—")+" ",1),s[7]||(s[7]=t("small",null,"kWh",-1))])])]),f($,{class:"chart-main",option:J.value,"empty-text":"能耗历史积累中"},null,8,["option"])])]),_:1})]),W(l)?(c(),d("div",qe,u(W(l)),1)):I("",!0)]))}}),Ye=D(Ge,[["__scopeId","data-v-5b50b0a3"]]);export{Ye as default};
.pressure-page[data-v-e6c5cb40]{display:flex;min-height:0}.charts[data-v-e6c5cb40]{display:grid;grid-template-columns:1fr 1fr;gap:10px;min-height:240px;flex:1}.chart-card[data-v-e6c5cb40]{min-height:0;display:flex;flex-direction:column;border:1px solid rgba(0,247,255,.16);border-radius:6px;padding:8px;background:#04102059}.chart-title[data-v-e6c5cb40]{font-size:12px;color:var(--label);margin-bottom:4px}.chart-card[data-v-e6c5cb40] .echart-wrap{flex:1;min-height:180px}.table-wrap[data-v-e6c5cb40]{max-height:260px;overflow:auto}
import{E as r,m as n}from"./historyChart-ytIpUdNw.js";import{u as d}from"./useMetricHistory-DAj0vDDt.js";import{u as x}from"./realtime-BbiS_VD3.js";import{d as y,c as a,b as e,z as u,h as o,F as b,B as S,y as p,o as i,t as l,x as P,_ as w}from"./index-BRA9HJ0O.js";const B={class:"page-fill pressure-page"},F={class:"panel",style:{flex:"1","min-height":"0",display:"flex","flex-direction":"column",gap:"10px",overflow:"hidden"}},L={class:"charts"},V={class:"chart-card"},k={class:"chart-card"},E={class:"table-wrap"},O={class:"table"},z=y({__name:"PressureView",setup(C){const c=x(),{series:m,loading:h}=d(()=>({source:"room",metric:"pressure"})),{series:v,loading:_}=d(()=>({source:"room",metric:"temp"})),g=p(()=>n(m.value,{unit:"Pa",maxSeries:14})),f=p(()=>n(v.value,{unit:"℃",maxSeries:14}));return(N,t)=>(i(),a("div",B,[e("section",F,[t[3]||(t[3]=e("div",{class:"panel-head"},[e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"房间压差集中监控")]),e("div",{class:"badge"},"实时 + 近24h")],-1)),e("div",L,[e("div",V,[t[0]||(t[0]=e("div",{class:"chart-title"},"压差历史 Pa",-1)),u(r,{option:g.value,loading:o(h),"empty-text":"压差历史积累中"},null,8,["option","loading"])]),e("div",k,[t[1]||(t[1]=e("div",{class:"chart-title"},"温度历史 ℃",-1)),u(r,{option:f.value,loading:o(_),"empty-text":"温度历史积累中"},null,8,["option","loading"])])]),e("div",E,[e("table",O,[t[2]||(t[2]=e("thead",null,[e("tr",null,[e("th",null,"房间"),e("th",null,"压差 (Pa)"),e("th",null,"温度 (℃)"),e("th",null,"湿度 (%)"),e("th",null,"告警")])],-1)),e("tbody",null,[(i(!0),a(b,null,S(o(c).snapshot.rooms,s=>(i(),a("tr",{key:s.id},[e("td",null,l(s.name),1),e("td",{style:P({color:s.alarms.length?"var(--red)":"var(--green)"})},l(s.pressure.toFixed(1)),5),e("td",null,l(s.temp.toFixed(1)),1),e("td",null,l(s.humidity),1),e("td",null,l(s.alarms.join("、")||"正常"),1)]))),128))])])])])]))}}),M=w(z,[["__scopeId","data-v-e6c5cb40"]]);export{M as default};
.screen-header-hy[data-v-b48a19b4]{position:relative;width:100%;height:clamp(64px,7.8vh,84px);flex-shrink:0;pointer-events:auto}.banner[data-v-b48a19b4]{display:block;width:100%;height:100%;object-fit:cover;object-position:center top}h1[data-v-b48a19b4]{position:absolute;left:12%;right:12%;top:50%;transform:translateY(-55%);z-index:1;margin:0;height:auto;line-height:1.2;text-align:center;font-family:var(--title-font);font-size:clamp(16px,1.7vw,30px);font-weight:900;font-synthesis:style;letter-spacing:clamp(2px,.35vw,6px);color:#f4feff;font-style:italic;text-shadow:0 0 14px rgba(0,200,255,.45);pointer-events:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.slot-left[data-v-b48a19b4],.slot-right[data-v-b48a19b4]{position:absolute;top:0;bottom:0;z-index:2;display:flex;align-items:center;gap:clamp(6px,.8vw,12px);pointer-events:auto;max-width:min(36vw,420px)}.slot-left[data-v-b48a19b4]{left:clamp(8px,1.2vw,24px)}.slot-right[data-v-b48a19b4]{right:clamp(8px,1.2vw,24px);justify-content:flex-end}.near-layer[data-v-ffe02a6e]{position:fixed;top:0;right:0;bottom:0;left:0;z-index:340;pointer-events:none}.near-backdrop[data-v-ffe02a6e]{position:absolute;top:0;right:0;bottom:0;left:0;pointer-events:auto;background:transparent}.near-dialog[data-v-ffe02a6e]{position:absolute;pointer-events:auto;display:flex;flex-direction:column;overflow:hidden;border-radius:6px;border:1px solid rgba(0,247,255,.4);background:#071426;box-shadow:0 12px 36px #00000080,0 0 0 1px #00000059}.near-head[data-v-ffe02a6e]{flex:0 0 36px;height:36px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 8px 0 12px;border-bottom:1px solid rgba(0,247,255,.18);background:linear-gradient(180deg,#0c2a48fa,#06162afa);cursor:move;-webkit-user-select:none;user-select:none;touch-action:none}.near-head h3[data-v-ffe02a6e]{margin:0;min-width:0;flex:1;font-size:13px;font-weight:600;color:#e8fbff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none}.near-close[data-v-ffe02a6e]{flex:0 0 auto;width:28px;height:28px;border:none;border-radius:4px;background:transparent;color:#9ec8dc;font-size:18px;line-height:1;cursor:pointer}.near-close[data-v-ffe02a6e]:hover{color:#fff;background:#ffffff14}.near-body[data-v-ffe02a6e]{flex:1;min-height:0;background:#000}.near-video[data-v-ffe02a6e]{display:block;width:100%;height:100%;object-fit:cover;background:#000}.viewport[data-v-03eb4370]{flex:1;min-width:0;position:relative;overflow:hidden;border-radius:10px;border:1px solid rgba(62,200,255,.4);background:linear-gradient(180deg,#0a2148,#041022);box-shadow:inset 0 0 0 1px #ffffff0a,0 14px 34px #02081780}.viewport--fill[data-v-03eb4370]{width:100%;height:100%;border:none;border-radius:0;box-shadow:none;background:#000}.scene[data-v-03eb4370],.ue-frame[data-v-03eb4370]{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;border:0;background:#000;transform:translateZ(0);contain:strict;content-visibility:auto}.vp-topbar[data-v-03eb4370],.vp-tip[data-v-03eb4370]{position:absolute;z-index:5;display:flex;align-items:center;gap:10px;padding:7px 12px;border-radius:7px;background:#081a3dd9;border:1px solid rgba(91,212,255,.42);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);pointer-events:none}.vp-topbar[data-v-03eb4370]{left:14px;top:14px}.vp-room-panel[data-v-03eb4370]{position:absolute;z-index:6;right:14px;top:14px;width:max-content;min-width:108px;max-width:148px;max-height:calc(100% - 72px);display:flex;flex-direction:column;border-radius:6px;background:#06122a8c;border:1px solid rgba(91,212,255,.28);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);overflow:hidden;pointer-events:auto}.vp-room-panel--fill[data-v-03eb4370]{left:50%;right:auto;top:auto;bottom:clamp(14px,2.2vh,28px);width:max-content;max-width:min(960px,calc(100vw - 2 * var(--screen-side-w, 24vw) - 48px));max-height:none;transform:translate(-50%);flex-direction:row;align-items:center;justify-content:center;border:none;border-radius:0;background:transparent;-webkit-backdrop-filter:none;backdrop-filter:none;box-shadow:none;overflow:visible;z-index:8;pointer-events:auto}.vp-room-head[data-v-03eb4370]{display:flex;align-items:center;justify-content:center;gap:6px;padding:7px 8px;border-bottom:1px solid rgba(62,200,255,.12);flex-shrink:0}.vp-room-head[data-v-03eb4370] .pin{width:12px;height:12px;opacity:.85}.vp-room-head>div[data-v-03eb4370]{text-align:center}.vp-room-label[data-v-03eb4370]{font-size:11px;font-weight:600;color:#ffffffeb}.vp-room-sub[data-v-03eb4370]{display:block;margin-top:2px;font-size:9px;color:#b9d6f0b8}.vp-room-list[data-v-03eb4370]{flex:1;min-height:0;overflow-x:hidden;overflow-y:auto;padding:4px;display:flex;flex-direction:column;gap:2px}.vp-room-panel--fill .vp-room-list[data-v-03eb4370]{flex:0 0 auto;flex-direction:row;align-items:center;justify-content:center;overflow-x:auto;overflow-y:hidden;gap:8px;padding:0}.vp-room-item[data-v-03eb4370]{display:flex;align-items:center;justify-content:center;gap:5px;width:100%;border:1px solid rgba(62,200,255,.18);background:#163a6e73;border-radius:4px;padding:4px 6px;cursor:pointer;color:#ebf5fff2;transition:.15s}.vp-room-panel--fill .vp-room-item[data-v-03eb4370]{width:auto;flex:0 0 auto;min-width:88px;padding:8px 12px;background:#081c3a8c;border:1px solid rgba(91,212,255,.32);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}.vp-room-item[data-v-03eb4370] .room-ico{width:12px;height:12px;flex:0 0 auto;opacity:.9}.vp-room-item[data-v-03eb4370]:hover{border-color:#3ec8ff6b;background:#1e52969e;color:#fff}.vp-room-item.active[data-v-03eb4370]{border-color:#5bd4ffa6;background:#1a6fff8c;color:#fff;box-shadow:inset 0 0 0 1px #5bd4ff26}.vp-room-item.alarm .name[data-v-03eb4370]{color:#ffb0b0}.vp-room-item .name[data-v-03eb4370]{font-size:11px;display:block;white-space:nowrap;line-height:1.25;text-align:center}.vp-room-panel--fill .vp-room-item .name[data-v-03eb4370]{font-size:12px;font-weight:600}.vp-tip[data-v-03eb4370]{right:14px;bottom:16px;font-size:11px;color:#b9d6f0}.vp-tip[data-v-03eb4370] .hand{width:14px;height:14px}.vp-tip b[data-v-03eb4370]{color:var(--cyan-lt);font-weight:600}.area[data-v-03eb4370]{font-size:12px;color:#fff}.sep[data-v-03eb4370]{width:1px;height:14px;background:#3ec8ff59}.view[data-v-03eb4370]{font-size:11px;color:var(--cyan-lt)}.mk[data-v-03eb4370]{position:absolute;z-index:5;display:flex;align-items:center;gap:6px;padding:5px 9px;border-radius:6px;background:#052433e0;border:1px solid rgba(47,214,161,.6);font-size:11px;color:#b9f5de;white-space:nowrap;cursor:pointer}.mk.err[data-v-03eb4370]{background:#3d0d14e6;border-color:#ff5252a6;color:#ffc9c9}.vp-corners[data-v-03eb4370]{position:absolute;top:0;right:0;bottom:0;left:0;pointer-events:none;z-index:4}.vp-corners i[data-v-03eb4370]{position:absolute;width:16px;height:16px;border:2px solid rgba(91,212,255,.7)}.vp-corners i[data-v-03eb4370]:nth-child(1){left:10px;top:10px;border-right:none;border-bottom:none}.vp-corners i[data-v-03eb4370]:nth-child(2){right:10px;top:10px;border-left:none;border-bottom:none}.vp-corners i[data-v-03eb4370]:nth-child(3){left:10px;bottom:10px;border-right:none;border-top:none}.vp-corners i[data-v-03eb4370]:nth-child(4){right:10px;bottom:10px;border-left:none;border-top:none}.stage--immersive[data-v-d8523050]{background:#00060f;width:100%;height:100%}.stage--immersive[data-v-d8523050]:before{display:none}.scene-layer[data-v-d8523050]{position:absolute;top:0;right:0;bottom:0;left:0;z-index:0}.edge-mask[data-v-d8523050]{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:none}.edge[data-v-d8523050]{position:absolute;display:block;pointer-events:none}.edge-t[data-v-d8523050]{left:0;right:0;top:0;height:220px;background:linear-gradient(180deg,#000612eb,#000a1c8c 42%,#000a1c00)}.edge-b[data-v-d8523050]{left:0;right:0;bottom:0;height:140px;background:linear-gradient(0deg,#000612d1,#000a1c66 48%,#000a1c00)}.edge-l[data-v-d8523050]{left:0;top:0;bottom:0;width:calc(var(--screen-side-w, 24vw) + 40px);background:linear-gradient(90deg,#000816d1,#000c2073 38%,#000c2000)}.edge-r[data-v-d8523050]{right:0;top:0;bottom:0;width:calc(var(--screen-side-w, 24vw) + 40px);background:linear-gradient(270deg,#000816d1,#000c2073 38%,#000c2000)}.ui-layer[data-v-d8523050]{position:absolute;top:0;right:0;bottom:0;left:0;z-index:2;display:flex;flex-direction:column;pointer-events:none}.screen-main--float[data-v-d8523050]{flex:1;min-height:0;padding:8px 16px 14px;pointer-events:none}.screen-float-overlays[data-v-d8523050]{position:absolute;top:0;right:0;bottom:0;left:0;z-index:5;pointer-events:none}.screen-float-overlays[data-v-d8523050]>*{pointer-events:auto}.screen-nav--side[data-v-d8523050]{position:absolute;z-index:6;top:clamp(200px,26vh,280px);bottom:clamp(56px,8vh,88px);right:calc(var(--screen-side-w, 24vw) + 28px);width:clamp(88px,6vw,108px);height:auto!important;flex:none!important;display:flex;flex-direction:column;align-items:stretch;gap:6px;padding:0;border:none;border-radius:0;background:transparent;-webkit-backdrop-filter:none;backdrop-filter:none;box-shadow:none;pointer-events:auto;overflow-x:hidden;overflow-y:auto}.screen-nav--side[data-v-d8523050] .nav-chip,.screen-nav--side .nav-chip[data-v-d8523050]{display:flex;align-items:center;justify-content:center;flex:0 0 auto;width:100%;min-height:34px;padding:7px 6px;text-align:center;font-size:11px;line-height:1.25;white-space:normal;border-radius:4px;background:#081c3a8c;border:1px solid rgba(91,212,255,.32);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}.weather-wrap[data-v-d8523050],.clock-wrap[data-v-d8523050]{position:static;transform:none;display:flex;align-items:baseline;gap:clamp(4px,.6vw,10px);white-space:nowrap;max-width:100%;overflow:hidden}@media(max-width:1280px){.weather-wrap .w-text[data-v-d8523050],.weather-wrap .humid[data-v-d8523050]{display:none}}@media(max-width:960px){.weather-wrap[data-v-d8523050]{display:none}}.weather-wrap .place[data-v-d8523050],.weather-wrap .temp[data-v-d8523050]{font-size:16px;font-weight:600;font-family:var(--digital-font);color:#7cf0c4;line-height:1}.weather-wrap .w-text[data-v-d8523050],.weather-wrap .humid[data-v-d8523050]{font-size:12px;color:#7cf0c4}.clock-wrap .time[data-v-d8523050]{font-size:16px;font-weight:600;font-family:var(--digital-font);color:var(--accent);text-shadow:0 0 .55em rgba(0,247,255,.45)}.clock-wrap .date[data-v-d8523050]{font-size:12px;color:var(--panel-muted)}.admin-entry[data-v-d8523050]{font-size:12px!important;letter-spacing:.12em!important;color:var(--accent)!important}.alarm-wrap[data-v-d8523050]{position:relative;pointer-events:auto}.alarm-badge[data-v-d8523050]{display:inline-flex;align-items:center;gap:6px;font:inherit;cursor:pointer}.alarm-badge.open[data-v-d8523050]{border-color:#ff7878bf;background:#5010188c}.alarm-backdrop[data-v-d8523050]{position:absolute;top:0;right:0;bottom:0;left:0;z-index:200;background:transparent;pointer-events:auto}.alarm-dropdown[data-v-d8523050]{position:absolute;z-index:201;width:min(360px,72vw);max-height:min(420px,60vh);display:flex;flex-direction:column;border-radius:6px;border:1px solid rgba(255,120,120,.35);background:linear-gradient(180deg,#1c0a10f5,#080e1cf5);box-shadow:0 12px 32px #00000073;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);overflow:hidden;pointer-events:auto}.alarm-dd-head[data-v-d8523050]{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;font-size:12px;color:#ffc9c9;border-bottom:1px solid rgba(255,120,120,.22)}.alarm-dd-head b[data-v-d8523050]{font-family:var(--digital-font);color:#ff8f8f}.alarm-dd-list[data-v-d8523050]{flex:1;min-height:0;overflow:auto;padding:6px;display:flex;flex-direction:column;gap:4px}.alarm-dd-item[data-v-d8523050]{display:flex;align-items:center;gap:8px;min-height:40px;padding:6px 8px;border-radius:5px;font-size:12px}.alarm-dd-item.red[data-v-d8523050]{background:#ff52521f;border:1px solid rgba(255,82,82,.35)}.alarm-dd-item.amber[data-v-d8523050]{background:#ffb4001a;border:1px solid rgba(255,180,0,.3)}.alarm-dd-item.calm[data-v-d8523050]{background:#1a4d9938;border:1px solid transparent}.alarm-dd-item .a-txt[data-v-d8523050]{flex:1;min-width:0}.alarm-dd-item .a-title[data-v-d8523050]{color:#f2f7ff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.alarm-dd-item .a-sub[data-v-d8523050]{margin-top:2px;font-size:10px;color:#b9d6f0b8}.alarm-dd-item .a-state[data-v-d8523050]{flex-shrink:0;font-size:10px;padding:2px 6px;border-radius:3px;background:#00000047;color:#ffd0d0;white-space:nowrap}
import{j as oe,k as pe,r as h,l as Re,d as ae,c as p,b as a,h as M,t as w,m as xe,o as m,_ as se,p as $e,q as Se,s as Ie,w as W,x as Q,f as X,T as Le,y as V,n as q,z as P,F as G,B as J,g as ne,u as Ke,a as Ge,C as me,D as Je,E as Ce,i as Qe}from"./index-BRA9HJ0O.js";import{u as Ee}from"./realtime-BbiS_VD3.js";import{u as Ze}from"./useClock-FtQkLZqk.js";import{A as Y}from"./AppIcon-9A2sZQ-4.js";import{u as et,p as tt}from"./useRtengineBridge-y7e7bneC.js";const nt=900*1e3;function ot(){const b=h("澳门"),u=h("--"),d=h("获取中"),x=h("--");let g;async function $(){try{const v=await Re("/api/weather/macau");b.value=v.place||"澳门",u.value=v.temp||"--",d.value=v.text||"—",x.value=v.humidity||"--"}catch{d.value="暂无数据"}}return oe(()=>{$(),g=window.setInterval(()=>void $(),nt)}),pe(()=>{g&&window.clearInterval(g)}),{place:b,temp:u,text:d,humidity:x}}function at(){const b=h({position:"fixed",inset:"0",width:"100%",height:"100%",transform:"none",left:"0",top:"0"});function u(){const d=window.innerWidth,x=Math.round(Math.min(480,Math.max(280,d*.24)));document.documentElement.style.setProperty("--screen-side-w",`${x}px`),document.documentElement.style.setProperty("--screen-vw",`${d}px`),document.documentElement.style.setProperty("--screen-vh",`${window.innerHeight}px`)}return oe(()=>{u(),window.addEventListener("resize",u)}),pe(()=>window.removeEventListener("resize",u)),{style:b}}const st="/assets/header-bar-CT0XVael.png",it={class:"screen-header-hy"},lt=["src"],rt={class:"slot-left"},ct={class:"slot-right"},ut=ae({__name:"ScreenHeader",props:{title:{}},setup(b){return(u,d)=>(m(),p("header",it,[a("img",{class:"banner",src:M(st),alt:""},null,8,lt),a("h1",null,w(b.title||"分子实验室三维可视化管理平台"),1),a("div",rt,[xe(u.$slots,"left",{},void 0)]),a("div",ct,[xe(u.$slots,"right",{},void 0)])]))}}),dt=se(ut,[["__scopeId","data-v-b48a19b4"]]),Me=[{id:"seq",name:"测序区",viewpoint:"测序区",roomId:"seq"},{id:"lib-2",name:"文库制备区2",viewpoint:"文库制备区2",roomId:"lib-2"},{id:"lib-1",name:"文库制备区1",viewpoint:"文库制备区1",roomId:"lib-1"},{id:"sample-2",name:"样本制备区2",viewpoint:"样本制备区2",roomId:"sample-2"},{id:"sample-1",name:"样本制备区1",viewpoint:"样本制备区1",roomId:"sample-1"},{id:"consult",name:"咨询室",viewpoint:"咨询室",roomId:"recv"},{id:"prep",name:"准备区",viewpoint:"准备区",roomId:"reagent"},{id:"report",name:"报告室",viewpoint:"报告室",roomId:"report"}],mt={key:0,class:"near-layer"},ft=["aria-label"],pt=["title"],vt=["src"],Pe=400,fe=240,ht=36,yt=ae({__name:"MarkerNearDialog",props:{open:{type:Boolean},title:{},left:{},top:{},src:{default:"/cams/lab-seq.mp4"}},emits:["close"],setup(b,{emit:u}){const d=b,x=u,g=h(0),$=h(0),v=h(!1);let E=0,R=0;function T(i,y){const f=window.innerWidth,r=window.innerHeight;return{left:Math.max(0,Math.min(Math.round(i),Math.max(0,f-Pe))),top:Math.max(0,Math.min(Math.round(y),Math.max(0,r-fe)))}}function j(){const i=T(d.left,d.top);g.value=i.left,$.value=i.top}$e(()=>[d.open,d.left,d.top],([i])=>{i&&j()},{immediate:!0});const C=V(()=>({left:`${g.value}px`,top:`${$.value}px`,width:`${Pe}px`,height:`${fe}px`})),A=fe-ht;function S(i){i.key==="Escape"&&L()}function z(i){var f,r;if(i.button!==0||(r=(f=i.target)==null?void 0:f.closest)!=null&&r.call(f,".near-close"))return;i.currentTarget.setPointerCapture(i.pointerId),v.value=!0,E=i.clientX-g.value,R=i.clientY-$.value}function N(i){if(!v.value)return;const y=T(i.clientX-E,i.clientY-R);g.value=y.left,$.value=y.top}function I(i){if(v.value){v.value=!1;try{i.currentTarget.releasePointerCapture(i.pointerId)}catch{}}}function L(i){i==null||i.preventDefault(),i==null||i.stopPropagation(),v.value&&(v.value=!1),x("close")}return $e(()=>d.open,i=>{i?window.addEventListener("keydown",S):window.removeEventListener("keydown",S)}),Se(()=>window.removeEventListener("keydown",S)),(i,y)=>(m(),Ie(Le,{to:"body"},[b.open?(m(),p("div",mt,[a("div",{class:"near-backdrop",onPointerdown:L,onClick:y[0]||(y[0]=W(()=>{},["prevent","stop"]))},null,32),a("div",{class:"near-dialog",style:Q(C.value),role:"dialog","aria-label":b.title,onPointerdown:y[1]||(y[1]=W(()=>{},["stop"])),onClick:y[2]||(y[2]=W(()=>{},["stop"]))},[a("header",{class:"near-head",onPointerdown:z,onPointermove:N,onPointerup:I,onPointercancel:I},[a("h3",{title:b.title},w(b.title),9,pt),a("button",{class:"near-close",type:"button","aria-label":"关闭",onPointerdown:W(L,["stop"]),onClick:W(L,["stop","prevent"])}," × ",32)],32),a("div",{class:"near-body",style:Q({height:`${A}px`})},[a("video",{class:"near-video",src:b.src,autoplay:"",muted:"",loop:"",playsinline:"",controls:""},null,8,vt)],4)],44,ft)])):X("",!0)]))}}),gt=se(yt,[["__scopeId","data-v-ffe02a6e"]]),wt={key:0,class:"vp-corners"},bt={key:1,class:"vp-topbar"},_t={class:"area"},kt={class:"view"},xt={class:"vp-room-panel vp-room-panel--fill"},$t={class:"vp-room-list"},Ct=["onClick"],Mt={class:"name"},Pt={key:3,class:"vp-room-panel"},Rt={class:"vp-room-head"},St={class:"vp-room-list"},It=["onClick"],Lt={class:"name"},Et=["src"],Tt=["onClick"],Bt={key:6,class:"vp-tip"},Dt={key:0},Ht={key:1},Ft=ae({__name:"SceneViewport",props:{areaLabel:{default:""},layout:{default:"inset"}},setup(b){var ke;const u=b,d=Ee(),x="/rtengine/gis.html?scene=mvg8x4b453b45f38".trim()||"/rtengine/gis.html?scene=mvg8x4b453b45f38".trim()||"/rtengine/gis.html?scene=mvg8x4b453b45f38",g=V(()=>!!x),$=h(null),v=h(null),E=h(((ke=Me[0])==null?void 0:ke.id)||"seq"),R=h([]),T=h(!1),j=h("监控预览"),C=h(120),A=h(120);let S=0;const z=h(null),N=h(!1);let I=!1,L=!1,i=!1,y=null;const f=h(null);function r(){var e,t;try{return((t=(e=v.value)==null?void 0:e.contentDocument)==null?void 0:t.querySelector("video"))??null}catch{return null}}function O(e,t){var K;const o=r(),n=(K=v.value)==null?void 0:K.getBoundingClientRect();if(!n)return null;const s=(o==null?void 0:o.clientWidth)||n.width,c=(o==null?void 0:o.clientHeight)||n.height,l=(o==null?void 0:o.videoWidth)||0,k=(o==null?void 0:o.videoHeight)||0;if(!l||!k||s<=0||c<=0)return{x:e,y:t};const H=o?getComputedStyle(o).objectFit:"fill";if(H==="contain"||H==="scale-down"){const F=Math.min(s/l,c/k),re=l*F,ce=k*F,ue=(s-re)/2,de=(c-ce)/2;return{x:ue+e*F,y:de+t*F}}if(H==="cover"){const F=Math.max(s/l,c/k),re=l*F,ce=k*F,ue=(s-re)/2,de=(c-ce)/2;return{x:ue+e*F,y:de+t*F}}return{x:e/l*s,y:t/k*c}}function B(e){const t=v.value;if(!t)return null;const o=t.getBoundingClientRect(),n=getComputedStyle(t),s=parseFloat(n.borderLeftWidth)||0,c=parseFloat(n.borderTopWidth)||0;return{left:o.left+s+e.x,top:o.top+c+e.y}}function _(e){const t=B({x:e.clientX,y:e.clientY});t&&(f.value=t),z.value={x:e.clientX,y:e.clientY}}function Z(){var t;ve();const e=(t=v.value)==null?void 0:t.contentDocument;e&&(e.addEventListener("pointerdown",_,!0),e.addEventListener("click",_,!0),y=e)}function ve(){y&&(y.removeEventListener("pointerdown",_,!0),y.removeEventListener("click",_,!0),y=null)}function Te(){return{left:Math.max(0,Math.round(window.innerWidth/2-200)),top:Math.max(0,Math.round(window.innerHeight/2-120))}}function he(e){if(!e||typeof e!="object")return null;const t=[e],o=e;o.data&&typeof o.data=="object"&&t.push(o.data),o.metaData&&typeof o.metaData=="object"&&t.push(o.metaData);for(const n of t){const s=Number(n.screenX??n.clientX??n.mouseX??n.px??n.screen_x??n.ScreenX??n.viewportX),c=Number(n.screenY??n.clientY??n.mouseY??n.py??n.screen_y??n.ScreenY??n.viewportY);if(Number.isFinite(s)&&Number.isFinite(c)&&(s!==0||c!==0))return{x:s,y:c}}return null}function ye(e){var l;const t=(l=v.value)==null?void 0:l.getBoundingClientRect();if(!t)return null;const o=r(),n=(o==null?void 0:o.videoWidth)||0,s=(o==null?void 0:o.videoHeight)||0;if(n>t.width+8&&s>t.height+8&&(e.x>t.width+1||e.y>t.height+1)&&e.x>=0&&e.y>=0&&e.x<=n+2&&e.y<=s+2){const k=O(e.x,e.y);return k?B(k):null}return e.x>=0&&e.y>=0&&e.x<=t.width+2&&e.y<=t.height+2?B(e):e.x>=0&&e.y>=0&&e.x<=window.innerWidth&&e.y<=window.innerHeight?{left:e.x,top:e.y}:null}function Be(e,t){var s,c;if(f.value){const l=f.value;return f.value=null,l}const o=z.value;if(o){const l=B(o);if(l)return l}const n=he(t);if(n){const l=ye(n);if(l)return l}if(e)try{const l=(s=v.value)==null?void 0:s.contentWindow,k=(c=l==null?void 0:l.$gisObj)==null?void 0:c.getMarker;if(typeof k=="function"){const H=k.call(l==null?void 0:l.$gisObj,e),K=he(H);if(K)return ye(K)}}catch{}return null}function De(e,t,o){if(Date.now()<S)return;const n=Be(t,o)||Te();j.value=e,C.value=Math.round(n.left),A.value=Math.round(n.top),T.value=!0}function He(){T.value=!1,S=Date.now()+400,f.value=null}function Fe(){return R.value.filter(e=>e.enabled&&e.scenePoint).map(e=>({id:e.id,name:e.name,text:e.name,x:e.scenePoint.x,y:e.scenePoint.y,z:e.scenePoint.z,icon:e.scenePoint.icon||"cctv-blue.png",tag0:"camera",tag1:e.id}))}async function Ne(){if(!(!g.value||i||L)){L=!0;try{await tt(void 0,x),U.showScenePresets(),await be(),ie(!0),window.setTimeout(()=>{I||ie(!0)},2e3)}catch{ie(!0)}finally{L=!1,i=!0}}}function ie(e=!1){if(!g.value||I&&!e)return;const t=Fe();t.length&&(U.addMarkers(t),I=!0)}function le(e){return String(e||"").trim().replace(/^cam:/i,"")}function ge(e){const t=e.trim();return!!(!t||/^cam:/i.test(t)||/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(t)||/^[0-9a-f]{16,}$/i.test(t))}function We(e,t){const o=[t,e.id,e.tag1,e.tag2,e.name].map(le).filter(Boolean);for(const s of o){const c=R.value.find(l=>l.id===s);if(c)return c}const n=(e.name||"").trim();if(n&&!ge(n)){const s=R.value.find(c=>c.name===n||c.location===n);if(s)return s}return null}function Ae(e,t,o){const n=((e==null?void 0:e.name)||(e==null?void 0:e.location)||"").trim();if(n)return n;const s=(t.name||"").trim(),c=new Set([o,t.id,t.tag1,t.tag0,t.tag2].map(le).filter(Boolean));return s&&!ge(s)&&!c.has(le(s))?s:"监控预览"}function Ve(e){if(Date.now()<S||T.value)return;const{markerId:t,marker:o,raw:n}=e;if(!t&&!o.id&&!o.tag0&&!o.name)return;const s=We(o,t);De(Ae(s,o,t),t||o.id,n)}const U=et({iframeRef:v,cloudUrl:x,onReady:()=>{Ne(),Z()},onMouseClick:Ve});function we(e){return e.filter(t=>t.enabled&&t.scenePoint).map(t=>{const o=t.scenePoint;return`${t.id}:${o.x},${o.y},${o.z},${o.icon||""}`}).sort().join("|")}async function be(){try{const t=(await Re("/api/video/devices")).devices||[],o=we(R.value)!==we(t);R.value=t,o&&(I=!1)}catch{}}function qe(){Z(),window.setTimeout(()=>U.markReady(),2500)}function Xe(e){for(const t of Object.getOwnPropertySymbols(e)){const o=e[t];if(o&&typeof o=="object"&&typeof o.initializePan=="function"&&typeof o.onMouseDown=="function")return o}return null}function ee(){const e=$.value;if(!e)return!1;const t=Xe(e);if(!(t!=null&&t.onMouseDown)||t.__mouseSwapped)return!!(t!=null&&t.__mouseSwapped);const o=t.onMouseDown.bind(t);return t.onMouseDown=n=>{const s=n.button===0?2:n.button===2?0:n.button,c=new Proxy(n,{get(l,k){if(k==="button")return s;const H=Reflect.get(l,k,l);return typeof H=="function"?H.bind(l):H}});o(c)},t.__mouseSwapped=!0,!0}function _e(){ee()||requestAnimationFrame(()=>{ee()||window.setTimeout(()=>ee(),300)})}const D=V(()=>{const t=u.layout==="fill"&&g.value?null:d.snapshot.rooms;return Me.map(o=>{const n=t==null?void 0:t.find(s=>s.id===o.roomId);return{id:o.id,name:o.name,viewpoint:o.viewpoint,roomId:o.roomId,temp:(n==null?void 0:n.temp)??null,humidity:(n==null?void 0:n.humidity)??null,pressure:(n==null?void 0:n.pressure)??null,alarms:(n==null?void 0:n.alarms)??[]}})}),je=V(()=>D.value.find(e=>e.id===E.value)||D.value.find(e=>e.roomId===d.selectedRoomId)||D.value[0]),ze=V(()=>g.value?[]:D.value.filter(e=>["sample-1","lib-1","seq"].includes(e.id)).map((e,t)=>({id:e.id,label:`${e.name} ${e.pressure!=null?`${e.pressure.toFixed(1)}Pa`:"--"}`,err:e.alarms.length>0,style:{left:`${18+t*26}%`,top:`${42+t%2*12}%`}}))),Oe=V(()=>"RTEngineHD 云渲染");function Ye(e){e&&U.flyToBookmark(e)}function te(e){const t=D.value.find(o=>o.id===e);t&&(E.value=t.id,d.selectRoom(t.roomId),g.value&&Ye(t.viewpoint))}function Ue(e){const t=D.value.find(n=>n.id===e);if(t){te(t.id);return}const o=D.value.find(n=>n.roomId===e);o?te(o.id):d.selectRoom(e)}return oe(()=>{if(be(),u.layout==="fill"&&(N.value=!!document.getElementById("screen-float-overlays"),N.value||requestAnimationFrame(()=>{N.value=!!document.getElementById("screen-float-overlays")})),g.value){window.setTimeout(()=>U.markReady(),6e3);return}const e=$.value;e==null||e.addEventListener("load",_e),ee()}),Se(()=>{var e;(e=$.value)==null||e.removeEventListener("load",_e),ve()}),(e,t)=>{var o;return m(),p("div",{class:q(["viewport",{"viewport--fill":u.layout==="fill"}])},[u.layout!=="fill"?(m(),p("div",wt,[...t[1]||(t[1]=[a("i",null,null,-1),a("i",null,null,-1),a("i",null,null,-1),a("i",null,null,-1)])])):X("",!0),u.layout!=="fill"?(m(),p("div",bt,[P(Y,{name:"pin"}),a("span",_t,w(b.areaLabel||`当前区域:${((o=je.value)==null?void 0:o.name)||"—"}`),1),t[2]||(t[2]=a("span",{class:"sep"},null,-1)),a("span",kt,w(Oe.value),1)])):X("",!0),u.layout==="fill"&&N.value?(m(),Ie(Le,{key:2,to:"#screen-float-overlays",defer:""},[a("div",xt,[a("div",$t,[(m(!0),p(G,null,J(D.value,n=>(m(),p("button",{key:n.id,type:"button",class:q(["vp-room-item",{active:n.id===E.value,alarm:n.alarms.length>0}]),onClick:s=>te(n.id)},[P(Y,{name:"pin","class-name":"room-ico"}),a("span",Mt,w(n.name),1)],10,Ct))),128))])])])):u.layout!=="fill"?(m(),p("div",Pt,[a("div",Rt,[P(Y,{name:"pin"}),t[3]||(t[3]=a("div",null,[a("div",{class:"vp-room-label"},"区域导航"),a("div",{class:"vp-room-sub"},"点击飞视点")],-1))]),a("div",St,[(m(!0),p(G,null,J(D.value,n=>(m(),p("button",{key:n.id,type:"button",class:q(["vp-room-item",{active:n.id===E.value,alarm:n.alarms.length>0}]),onClick:s=>te(n.id)},[P(Y,{name:"pin","class-name":"room-ico"}),a("span",Lt,w(n.name),1)],10,It))),128))])])):X("",!0),g.value?(m(),p("iframe",{key:4,ref_key:"cloudFrameRef",ref:v,class:"ue-frame",src:M(x),title:"RTEngineHD Cloud Render",allow:"autoplay; fullscreen; microphone; camera; clipboard-read; clipboard-write; display-capture",allowfullscreen:"",referrerpolicy:"no-referrer-when-downgrade",onLoad:qe},null,40,Et)):(m(),p("model-viewer",{key:5,ref_key:"viewerRef",ref:$,class:"scene",src:"/models/scene-model.glb","camera-controls":"","auto-rotate":"","shadow-intensity":"0.6",exposure:"1.1","interaction-prompt":"none",onContextmenu:t[0]||(t[0]=W(()=>{},["prevent"]))},null,544)),(m(!0),p(G,null,J(ze.value,n=>(m(),p("div",{key:n.id,class:q(["mk",{err:n.err}]),style:Q(n.style),onClick:s=>Ue(n.id)},[a("span",{class:q(["dot",{red:n.err}])},null,2),a("span",null,w(n.label),1)],14,Tt))),128)),u.layout!=="fill"?(m(),p("div",Bt,[P(Y,{name:"hand","class-name":"hand"}),g.value?(m(),p("span",Dt," RTEngineHD 云渲染 · 在画面内操作漫游 · 底部点选区域飞视点 ")):(m(),p("span",Ht,[...t[4]||(t[4]=[a("b",null,"左键",-1),ne(" 拖动 · ",-1),a("b",null,"右键",-1),ne(" 旋转 · ",-1),a("b",null,"滚轮",-1),ne(" 缩放 · 底部点选区域 ",-1)])]))])):X("",!0),P(gt,{open:T.value,title:j.value,left:C.value,top:A.value,src:"/cams/lab-seq.mp4",onClose:He},null,8,["open","title","left","top"])],2)}}}),Nt=se(Ft,[["__scopeId","data-v-03eb4370"]]),Wt={class:"scene-layer"},At={class:"ui-layer"},Vt={class:"weather-wrap"},qt={class:"place"},Xt={class:"temp"},jt={class:"w-text"},zt={class:"humid"},Ot={class:"clock-wrap"},Yt={class:"time"},Ut={class:"date"},Kt={class:"alarm-wrap"},Gt=["aria-expanded"],Jt={class:"screen-main screen-main--float"},Qt={class:"screen-nav screen-nav--side","aria-label":"功能导航"},Zt={class:"alarm-dd-head"},en={class:"alarm-dd-list"},tn={class:"a-txt"},nn={class:"a-title"},on={class:"a-sub"},an={class:"a-state"},sn=ae({__name:"ScreenLayout",setup(b){const u=Ke(),d=Ee(),x=Qe(),{time:g,date:$}=Ze(),{place:v,temp:E,text:R,humidity:T}=ot(),{style:j}=at(),C=h(!1),A=h(null),S=h({}),z=[{to:"/dashboard/overview",label:"全景总览"},{to:"/dashboard/roam",label:"漫游巡检"},{to:"/dashboard/vav",label:"通风柜VAV"},{to:"/dashboard/pressure",label:"房间压差"},{to:"/dashboard/hvac",label:"空调排风"},{to:"/dashboard/metering",label:"计量计费"},{to:"/dashboard/video",label:"视频监控"},{to:"/dashboard/access",label:"门禁系统"},{to:"/dashboard/cold",label:"冷链监测"},{to:"/dashboard/history",label:"历史曲线"},{to:"/dashboard/light",label:"智能照明"}],N=V(()=>{const f=d.snapshot.pointAlarms||[];return f.length?f.slice(0,12).map(r=>({level:r.level==="red"?"red":"amber",title:r.message,sub:r.name||"点位超限",state:"关注中"})):[{level:"calm",title:"系统运行正常",sub:"点位阈值监测中",state:"正常"}]});oe(()=>{d.connect(),u.isLoggedIn&&!u.user&&u.fetchMe().catch(()=>{}),window.addEventListener("keydown",I)}),pe(()=>{d.disconnect(),window.removeEventListener("keydown",I)});function I(f){f.key==="Escape"&&(C.value=!1)}function L(){const f=A.value,r=f==null?void 0:f.closest(".stage");if(!f||!r)return;const O=f.getBoundingClientRect(),B=r.getBoundingClientRect();S.value={top:`${Math.round(O.bottom-B.top+8)}px`,right:`${Math.round(B.right-O.right)}px`}}async function i(){C.value=!C.value,C.value&&(await Je(),L())}function y(){u.isLoggedIn?x.push("/admin"):x.push({path:"/login",query:{redirect:"/admin"}})}return(f,r)=>{const O=Ce("RouterView"),B=Ce("RouterLink");return m(),p("div",{class:"stage stage--immersive",style:Q(M(j))},[r[4]||(r[4]=a("div",{id:"screen-float-overlays",class:"screen-float-overlays"},null,-1)),a("div",Wt,[P(Nt,{layout:"fill"})]),r[5]||(r[5]=Ge('<div class="edge-mask" aria-hidden="true" data-v-d8523050><i class="edge edge-t" data-v-d8523050></i><i class="edge edge-b" data-v-d8523050></i><i class="edge edge-l" data-v-d8523050></i><i class="edge edge-r" data-v-d8523050></i></div>',1)),a("div",At,[P(dt,null,{left:me(()=>[a("div",Vt,[a("span",qt,w(M(v)),1),a("span",Xt,w(M(E)),1),a("span",jt,w(M(R)),1),a("span",zt,"RH:"+w(M(T)),1)])]),right:me(()=>[a("div",Ot,[a("span",Yt,w(M(g)),1),a("span",Ut,w(M($)),1)]),a("div",Kt,[a("button",{ref_key:"alarmBtnRef",ref:A,type:"button",class:q(["alarm-badge",{open:C.value}]),"aria-haspopup":"true","aria-expanded":C.value,onClick:W(i,["stop"])},[r[2]||(r[2]=a("span",{class:"dot red"},null,-1)),a("span",null,"告警 "+w(M(d).alarmCount),1)],10,Gt)]),a("button",{class:"tops-logout admin-entry",type:"button",onClick:y},"管理")]),_:1}),a("main",Jt,[P(O)]),a("nav",Qt,[(m(),p(G,null,J(z,_=>P(B,{key:_.to,class:"nav-chip",to:_.to},{default:me(()=>[ne(w(_.label),1)]),_:2},1032,["to"])),64))])]),C.value?(m(),p("div",{key:0,class:"alarm-backdrop",onClick:r[0]||(r[0]=_=>C.value=!1)})):X("",!0),C.value?(m(),p("div",{key:1,class:"alarm-dropdown",role:"dialog","aria-label":"告警列表",style:Q(S.value),onClick:r[1]||(r[1]=W(()=>{},["stop"]))},[a("div",Zt,[r[3]||(r[3]=a("span",null,"当前告警",-1)),a("b",null,w(M(d).alarmCount),1)]),a("div",en,[(m(!0),p(G,null,J(N.value,(_,Z)=>(m(),p("div",{key:`${_.title}-${Z}`,class:q(["alarm-dd-item",_.level])},[P(Y,{name:_.level==="red"?"alarm-warn":_.level==="amber"?"alarm-amber":"alarm-ok"},null,8,["name"]),a("div",tn,[a("div",nn,w(_.title),1),a("div",on,w(_.sub),1)]),a("div",an,w(_.state),1)],2))),128))])],4)):X("",!0)],4)}}}),mn=se(sn,[["__scopeId","data-v-d8523050"]]);export{mn as default};
import{d as D,u as U,j as V,c as n,b as e,n as C,h,t as i,f as r,F as w,B as P,w as L,r as d,y as R,l as S,o,_ as z}from"./index-BRA9HJ0O.js";const I={class:"page-fill admin-list-page"},j={class:"panel list-panel"},G={class:"panel-head"},J={class:"head-actions"},O={key:0,class:"msg ok"},T={key:1,class:"msg err"},q={class:"table-wrap"},H={class:"table"},K={key:1,class:"muted"},Q={key:2,class:"hint"},W={class:"modal panel"},X={class:"presets"},Y=["onClick"],Z={class:"form-grid poll-form"},ee={class:"field"},le=["value"],te={class:"field"},se=["value"],ae={key:0,class:"msg err"},ne={class:"modal-actions"},oe=["disabled"],ue=D({__name:"SettingsView",setup(ie){const u=U(),A="rtengine",E="/rtengine/gis.html?scene=mvg8x4b453b45f38",a=d(null),v=d(2e3),m=d(2),p=d(!1),y=d(""),c=d(""),_=d(!1),x=[{label:"1 秒",ms:1e3},{label:"2 秒",ms:2e3},{label:"5 秒",ms:5e3},{label:"10 秒",ms:1e4},{label:"30 秒",ms:3e4},{label:"1 分钟",ms:6e4}],N=R(()=>{var t,l,s,f,M;return[{key:"user",label:"当前用户",value:`${((t=u.user)==null?void 0:t.displayName)||"—"} (${((l=u.user)==null?void 0:l.username)||"—"})`},{key:"viewport",label:"三维视口模式",value:A},{key:"ue",label:"云渲染 / UE 地址",value:E},{key:"dataMode",label:"数据采集模式",value:((s=a.value)==null?void 0:s.dataMode)==="modbus"?"Modbus 实采":((f=a.value)==null?void 0:f.dataMode)==="gateway"?"网关实采":"Mock 模拟"},{key:"retention",label:"采集数据保留",value:`${((M=a.value)==null?void 0:M.collectRetentionDays)??"—"} 天`},{key:"poll",label:"数据采集频率",value:a.value?`${a.value.collectPollMs/1e3} 秒`:"—",editable:!0},{key:"access",label:"门禁桥接",value:"ACCESS_BRIDGE_URL 未配置时使用 Mock"}]});function b(t){v.value=t,m.value=+(t/1e3).toFixed(t%1e3===0?0:1)}function B(t){const l=Math.max(.5,Math.min(600,t||.5));m.value=l,v.value=Math.round(l*1e3)}function g(){u.isAdmin&&(a.value&&b(a.value.collectPollMs),c.value="",_.value=!0)}function k(){_.value=!1,c.value=""}async function $(){y.value="";try{const t=await S("/api/settings",{token:u.token});a.value=t,b(t.collectPollMs)}catch(t){c.value=t instanceof Error?t.message:"加载失败"}}async function F(){if(u.isAdmin){p.value=!0,c.value="";try{const t=await S("/api/settings/collect-poll",{method:"PUT",token:u.token,body:JSON.stringify({collectPollMs:v.value})});a.value=t,b(t.collectPollMs),y.value=`采集频率已更新为 ${m.value} 秒`,k()}catch(t){c.value=t instanceof Error?t.message:"保存失败"}finally{p.value=!1}}}return V($),(t,l)=>(o(),n("div",I,[e("section",j,[e("div",G,[l[2]||(l[2]=e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"系统设置")],-1)),e("div",J,[e("div",{class:C(["badge",h(u).isAdmin?"":"amber"])},i(h(u).isAdmin?"管理员":"只读"),3),h(u).isAdmin?(o(),n("button",{key:0,class:"btn",type:"button",onClick:g}," 编辑采集频率 ")):r("",!0)])]),y.value?(o(),n("p",O,i(y.value),1)):r("",!0),c.value&&!_.value?(o(),n("p",T,i(c.value),1)):r("",!0),e("div",q,[e("table",H,[l[3]||(l[3]=e("thead",null,[e("tr",null,[e("th",null,"配置项"),e("th",null,"当前值"),e("th",null,"操作")])],-1)),e("tbody",null,[(o(!0),n(w,null,P(N.value,s=>(o(),n("tr",{key:s.key},[e("td",null,i(s.label),1),e("td",null,i(s.value),1),e("td",null,[s.editable&&h(u).isAdmin?(o(),n("button",{key:0,class:"btn ghost",type:"button",onClick:g}," 编辑 ")):(o(),n("span",K,"—"))])]))),128))])])]),a.value?(o(),n("p",Q," 最近更新:"+i(a.value.updatedBy||"—")+" · "+i(new Date(a.value.updatedAt).toLocaleString("zh-CN",{hour12:!1})),1)):r("",!0)]),_.value?(o(),n("div",{key:0,class:"modal-mask",onClick:L(k,["self"])},[e("div",W,[e("div",{class:"panel-head"},[l[4]||(l[4]=e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"编辑采集频率")],-1)),e("button",{class:"btn ghost",type:"button",onClick:k},"关闭")]),l[7]||(l[7]=e("p",{class:"desc"}," 控制服务端轮询周期,保存后立即生效。允许范围 0.5–600 秒。 ",-1)),e("div",X,[(o(),n(w,null,P(x,s=>e("button",{key:s.ms,type:"button",class:C(["preset",{active:v.value===s.ms}]),onClick:f=>b(s.ms)},i(s.label),11,Y)),64))]),e("div",Z,[e("div",ee,[l[5]||(l[5]=e("label",null,"周期(秒)",-1)),e("input",{value:m.value,type:"number",min:"0.5",max:"600",step:"0.5",onChange:l[0]||(l[0]=s=>B(Number(s.target.value)))},null,40,le)]),e("div",te,[l[6]||(l[6]=e("label",null,"周期(毫秒)",-1)),e("input",{value:v.value,type:"number",min:"500",max:"600000",step:"100",onChange:l[1]||(l[1]=s=>b(Number(s.target.value)))},null,40,se)])]),c.value?(o(),n("p",ae,i(c.value),1)):r("",!0),e("div",ne,[e("button",{class:"btn",type:"button",disabled:p.value,onClick:F},i(p.value?"保存中…":"保存并生效"),9,oe),e("button",{class:"btn ghost",type:"button",onClick:k},"取消")])])])):r("",!0)]))}}),de=z(ue,[["__scopeId","data-v-b6ca3592"]]);export{de as default};
.admin-list-page[data-v-b6ca3592]{min-height:0}.list-panel[data-v-b6ca3592]{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}.head-actions[data-v-b6ca3592]{display:flex;align-items:center;gap:10px}.table-wrap[data-v-b6ca3592]{flex:1;min-height:0;overflow:auto}.muted[data-v-b6ca3592]{color:var(--panel-muted)}.hint[data-v-b6ca3592]{margin-top:12px;font-size:12px;color:var(--panel-muted)}.msg.ok[data-v-b6ca3592]{color:var(--ok);font-size:13px;margin-bottom:8px}.msg.err[data-v-b6ca3592]{color:#ffb0b0;font-size:13px;margin:8px 0}.desc[data-v-b6ca3592]{margin:0 0 12px;font-size:12px;color:var(--panel-muted);line-height:1.6}.presets[data-v-b6ca3592]{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}.preset[data-v-b6ca3592]{border-radius:2px;border:1px solid var(--panel-border);background:#060c148c;color:var(--panel-muted);padding:6px 12px;font-size:12px;cursor:pointer;font-weight:600}.preset.active[data-v-b6ca3592]{background:#3dbeff38;color:#eaf4ff;border-color:#3dbeff8c}.poll-form[data-v-b6ca3592]{grid-template-columns:1fr 1fr}.modal-mask[data-v-b6ca3592]{position:fixed;top:0;right:0;bottom:0;left:0;background:#04080eb8;display:flex;align-items:center;justify-content:center;z-index:50}.modal[data-v-b6ca3592]{width:min(560px,92vw);max-height:90vh;overflow:auto}.modal-actions[data-v-b6ca3592]{margin-top:14px;display:flex;gap:10px}
import{d as c,c as l,b as e,t as s,y as o,o as i,I as u}from"./index-BRA9HJ0O.js";const r={class:"page-fill"},d={class:"stub-box"},v=c({__name:"StubView",setup(_){const t=u(),a=o(()=>String(t.meta.title||"功能页")),n=o(()=>String(t.meta.desc||"该模块骨架已就绪,待数据接口与交互深化。"));return(p,m)=>(i(),l("div",r,[e("div",d,[e("div",null,[e("h2",null,s(a.value),1),e("p",null,s(n.value),1)])])]))}});export{v as default};
.admin-list-page[data-v-edd81bd7]{min-height:0}.list-panel[data-v-edd81bd7]{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}.head-actions[data-v-edd81bd7]{display:flex;align-items:center;gap:10px}.table-wrap[data-v-edd81bd7]{flex:1;min-height:0;overflow:auto}.ops[data-v-edd81bd7]{display:flex;gap:6px}.empty[data-v-edd81bd7]{text-align:center;color:var(--panel-muted);padding:28px 10px!important}.msg.ok[data-v-edd81bd7]{color:var(--ok);font-size:13px;margin-bottom:8px}.msg.err[data-v-edd81bd7]{color:#ffb0b0;font-size:13px;margin:8px 0}.modal-mask[data-v-edd81bd7]{position:fixed;top:0;right:0;bottom:0;left:0;background:#04080eb8;display:flex;align-items:center;justify-content:center;z-index:50}.modal[data-v-edd81bd7]{width:min(560px,92vw);max-height:90vh;overflow:auto}.modal-actions[data-v-edd81bd7]{margin-top:14px;display:flex;gap:10px}
import{d as S,u as $,j as L,c as i,b as e,t as o,f as r,F as M,B as U,w as x,e as g,v as _,L as A,r as u,K as B,l as k,o as d,_ as D}from"./index-BRA9HJ0O.js";const T={class:"page-fill admin-list-page"},F={class:"panel list-panel"},O={class:"panel-head"},J={class:"panel-title"},P={key:0,class:"msg ok"},j={key:1,class:"msg err"},z={class:"table-wrap"},H={class:"table"},I={class:"ops"},K=["onClick"],q=["onClick"],G={key:0},Q={class:"modal panel"},R={class:"form-grid"},W={class:"field"},X={class:"field"},Y={class:"field"},Z={class:"field"},ee={key:0,class:"msg err"},te={class:"modal-actions"},se=["disabled"],le=S({__name:"UserManageView",setup(ae){const v=$(),c=u([]),a=u(""),p=u(""),m=u(!1),b=u(!1),l=B({username:"",displayName:"",password:"",role:"viewer"});function w(){l.username="",l.displayName="",l.password="",l.role="viewer",a.value=""}function h(){w(),m.value=!0}function f(){m.value=!1,a.value=""}async function y(){const n=await k("/api/users",{token:v.token});c.value=n.users}async function C(){a.value="",b.value=!0;try{await k("/api/users",{method:"POST",token:v.token,body:JSON.stringify(l)}),p.value=`已创建用户 ${l.username}`,f(),w(),await y()}catch(n){a.value=n instanceof Error?n.message:"创建失败"}finally{b.value=!1}}async function N(n){a.value="";try{await k(`/api/users/${n.id}`,{method:"PATCH",token:v.token,body:JSON.stringify({enabled:!n.enabled})}),await y()}catch(t){a.value=t instanceof Error?t.message:"更新失败"}}async function V(n){if(confirm(`确认删除用户 ${n.username}?`)){a.value="";try{await k(`/api/users/${n.id}`,{method:"DELETE",token:v.token}),p.value=`已删除 ${n.username}`,await y()}catch(t){a.value=t instanceof Error?t.message:"删除失败"}}}return L(y),(n,t)=>(d(),i("div",T,[e("section",F,[e("div",O,[e("div",J,[t[4]||(t[4]=e("div",{class:"vbar"},null,-1)),e("h3",null,"用户列表("+o(c.value.length)+")",1)]),e("div",{class:"head-actions"},[t[5]||(t[5]=e("div",{class:"badge"},"仅管理员",-1)),e("button",{class:"btn",type:"button",onClick:h},"新增用户")])]),p.value?(d(),i("p",P,o(p.value),1)):r("",!0),a.value&&!m.value?(d(),i("p",j,o(a.value),1)):r("",!0),e("div",z,[e("table",H,[t[7]||(t[7]=e("thead",null,[e("tr",null,[e("th",null,"用户名"),e("th",null,"显示名"),e("th",null,"角色"),e("th",null,"状态"),e("th",null,"最近登录"),e("th",null,"操作")])],-1)),e("tbody",null,[(d(!0),i(M,null,U(c.value,s=>(d(),i("tr",{key:s.id},[e("td",null,o(s.username),1),e("td",null,o(s.displayName),1),e("td",null,o(s.role==="admin"?"管理员":"只读"),1),e("td",null,o(s.enabled?"启用":"禁用"),1),e("td",null,o(s.lastLoginAt?new Date(s.lastLoginAt).toLocaleString("zh-CN"):"—"),1),e("td",I,[e("button",{class:"btn ghost",type:"button",onClick:E=>N(s)},o(s.enabled?"禁用":"启用"),9,K),e("button",{class:"btn danger",type:"button",onClick:E=>V(s)},"删除",8,q)])]))),128)),c.value.length?r("",!0):(d(),i("tr",G,[...t[6]||(t[6]=[e("td",{colspan:"6",class:"empty"},"暂无用户,点击右上角「新增用户」",-1)])]))])])])]),m.value?(d(),i("div",{key:0,class:"modal-mask",onClick:x(f,["self"])},[e("div",Q,[e("div",{class:"panel-head"},[t[8]||(t[8]=e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"新增用户")],-1)),e("button",{class:"btn ghost",type:"button",onClick:f},"关闭")]),e("div",R,[e("div",W,[t[9]||(t[9]=e("label",null,"用户名",-1)),g(e("input",{"onUpdate:modelValue":t[0]||(t[0]=s=>l.username=s),autocomplete:"off"},null,512),[[_,l.username]])]),e("div",X,[t[10]||(t[10]=e("label",null,"显示名",-1)),g(e("input",{"onUpdate:modelValue":t[1]||(t[1]=s=>l.displayName=s)},null,512),[[_,l.displayName]])]),e("div",Y,[t[11]||(t[11]=e("label",null,"密码",-1)),g(e("input",{"onUpdate:modelValue":t[2]||(t[2]=s=>l.password=s),type:"password"},null,512),[[_,l.password]])]),e("div",Z,[t[13]||(t[13]=e("label",null,"角色",-1)),g(e("select",{"onUpdate:modelValue":t[3]||(t[3]=s=>l.role=s)},[...t[12]||(t[12]=[e("option",{value:"viewer"},"只读 (viewer)",-1),e("option",{value:"admin"},"管理员 (admin)",-1)])],512),[[A,l.role]])])]),a.value?(d(),i("p",ee,o(a.value),1)):r("",!0),e("div",te,[e("button",{class:"btn",type:"button",disabled:b.value,onClick:C},o(b.value?"创建中…":"创建"),9,se),e("button",{class:"btn ghost",type:"button",onClick:f},"取消")])])])):r("",!0)]))}}),oe=D(le,[["__scopeId","data-v-edd81bd7"]]);export{oe as default};
import{d as y,u as V,c as i,b as t,t as l,h as o,z as k,F as w,B as A,y as u,o as d,l as C,_ as S}from"./index-BRA9HJ0O.js";import{u as E}from"./realtime-BbiS_VD3.js";import{E as O,m as B}from"./historyChart-ytIpUdNw.js";import{u as c}from"./useMetricHistory-DAj0vDDt.js";const F={class:"page-fill"},$={class:"panel vav-page"},H={class:"panel-head"},L={class:"badge"},M={class:"hist"},N={class:"table-wrap"},T={class:"table vav-table"},z={class:"name"},D={class:"ops"},I=["disabled","onClick"],J=["disabled","onClick"],P=y({__name:"VavView",setup(R){const n=V(),m=E(),{series:h,loading:p}=c(()=>({source:"vav",metric:"airflow"})),{series:_,loading:f}=c(()=>({source:"vav",metric:"face_velocity"})),v=u(()=>B([...h.value.map(e=>({...e,name:`${e.name||e.id}·风量`})),..._.value.map(e=>({...e,name:`${e.name||e.id}·面风速`}))],{maxSeries:8})),b=u(()=>p.value||f.value);async function r(e,a){n.isAdmin&&(await C("/api/realtime/write",{method:"POST",token:n.token,body:JSON.stringify({target:e,action:a?"start":"stop"})}),alert(a?"开机指令已下发(Mock)":"关机指令已下发(Mock)"))}function g(e){return e.maxExhaust?"最大排风":e.minAirflow?"最小风量":"常态"}return(e,a)=>(d(),i("div",F,[t("section",$,[t("div",H,[a[0]||(a[0]=t("div",{class:"panel-title"},[t("div",{class:"vbar"}),t("h3",null,"通风柜 VAV 集中监控")],-1)),t("div",L,l(o(n).isAdmin?"可控制":"只读")+" · 近24h曲线",1)]),t("div",M,[k(O,{option:v.value,loading:b.value,"empty-text":"VAV 历史积累中"},null,8,["option","loading"])]),t("div",N,[t("table",T,[a[1]||(a[1]=t("thead",null,[t("tr",null,[t("th",null,"设备"),t("th",null,"开停状态"),t("th",null,"实际面风速"),t("th",null,"实际门高"),t("th",null,"实际风量"),t("th",null,"阀开度"),t("th",null,"最大排风"),t("th",null,"最小风量"),t("th",null,"排风模式"),t("th",null,"报警代码"),t("th",null,"操作")])],-1)),t("tbody",null,[(d(!0),i(w,null,A(o(m).snapshot.vav,s=>(d(),i("tr",{key:s.id},[t("td",z,l(s.name),1),t("td",null,l(s.running?"运行":"停止"),1),t("td",null,l(s.faceVelocity.toFixed(3))+" m/s",1),t("td",null,l(s.sashHeight)+" mm",1),t("td",null,l(s.airflow)+" m³/h",1),t("td",null,l(s.valveOpen)+" %",1),t("td",null,l(s.maxExhaust?"是":"否"),1),t("td",null,l(s.minAirflow?"是":"否"),1),t("td",null,l(g(s)),1),t("td",null,l(s.alarmCode||"无"),1),t("td",D,[t("button",{class:"btn sm",disabled:!o(n).isAdmin,onClick:x=>r(s.id,!0)},"开机",8,I),t("button",{class:"btn ghost sm",disabled:!o(n).isAdmin,onClick:x=>r(s.id,!1)},"关机",8,J)])]))),128))])])]),a[2]||(a[2]=t("p",{class:"foot"}," 点位来源:文库制备1/2通风柜(开停、面风速×0.001、门高、风量、最大/最小风量、报警代码)。阀开度按报价清单监控项展示。 ",-1))])]))}}),Q=S(P,[["__scopeId","data-v-0beef163"]]);export{Q as default};
.hist[data-v-0beef163]{min-height:200px;margin-bottom:10px;border:1px solid rgba(0,247,255,.16);border-radius:6px;padding:8px;background:#04102059}.hist[data-v-0beef163] .echart-wrap{height:180px}.vav-page[data-v-0beef163]{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}.table-wrap[data-v-0beef163]{flex:1;min-height:0;overflow:auto}.vav-table[data-v-0beef163]{width:auto;max-width:100%;table-layout:auto;border-collapse:collapse;font-size:12px}.vav-table[data-v-0beef163] th,.vav-table th[data-v-0beef163],.vav-table td[data-v-0beef163]{padding:6px 10px;white-space:nowrap;width:1%}.vav-table .name[data-v-0beef163]{width:auto;max-width:160px;overflow:hidden;text-overflow:ellipsis}.vav-table .ops[data-v-0beef163]{display:flex;gap:6px;width:1%}.vav-table[data-v-0beef163] .btn.sm,.vav-table .btn.sm[data-v-0beef163]{padding:4px 10px;font-size:11px;line-height:1.2}.foot[data-v-0beef163]{margin-top:12px;flex:0 0 auto;font-size:12px;color:var(--label);line-height:1.7}
import{d as z,u as j,j as q,c as d,b as e,n as y,t as o,f as b,F as S,h as p,e as m,v as c,L as M,B as D,J as G,s as Q,g as R,r as k,K as I,y as E,l as w,o as a,_ as X}from"./index-BRA9HJ0O.js";import{W as Y}from"./WebRtcPlayer-CbYWvtn2.js";const Z={class:"page-fill video-page"},ee={class:"video-tabs"},se={class:"badge"},te=["disabled"],le={key:0,class:"msg"},ne={key:0,class:"panel"},oe={class:"form-grid three"},ie={class:"field"},ae={class:"field"},de={class:"field"},ue={class:"field"},re={class:"field"},ve={class:"field"},pe={class:"panel list-panel"},me={class:"table-wrap"},be={class:"table"},ce={class:"ops"},fe=["onClick"],ye=["onClick"],ke=["onClick"],ge={key:0},we={key:1,class:"panel config-panel"},he={class:"panel-head"},_e={class:"head-right"},Ce={class:"form-grid three"},Te={class:"field"},Ae=["disabled"],Ve={class:"field"},Pe=["disabled"],Se={class:"field"},Ne=["disabled"],Ue={class:"field"},Me=["disabled"],Re={class:"field"},Ee=["disabled"],$e={class:"field"},xe=["disabled"],De={class:"field"},Ie=["disabled"],He={class:"field"},Le=["disabled"],Be={class:"field switch-field"},Ke={class:"switch"},Oe=["disabled"],We={class:"row-actions"},Je=["disabled"],Fe={key:2,class:"msg"},ze={key:3,class:"hint"},je={key:2,class:"preview-layout"},qe={class:"panel preview-main"},Ge={class:"panel-head"},Qe={class:"panel-title"},Xe={key:1,class:"stage-body"},Ye={key:2,class:"cam-meta bar"},Ze={class:"panel preview-side"},es={class:"thumb-grid"},ss=["onClick"],ts={class:"thumb-top"},ls={class:"ch"},ns={class:"thumb-name"},os={class:"thumb-loc"},is=z({__name:"VideoView",setup(as){const i=j(),f=k("devices"),N=k(!1),v=k(""),h=k([]),C=k(null),g=k(""),P=k(!1),_=k(""),u=I({name:"",channel:7,location:"",deviceType:"nvr-ch",ip:"",remark:""}),l=I({host:"",port:8e3,rtspPort:554,username:"",password:"",protocol:"sdk",streamType:"sub",previewTimeoutMs:8e3,enabled:!0}),U={online:"在线",offline:"离线",alarm:"报警"},H={ipc:"海康网络摄像机","nvr-ch":"海康 NVR 通道"},L=E(()=>h.value.filter(n=>n.enabled&&n.status==="online").length),T=E(()=>h.value.filter(n=>n.enabled)),r=E(()=>T.value.find(n=>n.id===g.value)||T.value[0]||null);async function A(){N.value=!0,v.value="";try{const[n,s]=await Promise.all([w("/api/video/devices",{token:i.token}),w("/api/video/config",{token:i.token})]);h.value=n.devices,C.value=s,l.host=s.host,l.port=s.port,l.rtspPort=s.rtspPort??554,l.username=s.username,l.password=s.passwordMasked||"",l.protocol=s.protocol==="tcp"?"isapi":s.protocol,l.streamType=s.streamType,l.previewTimeoutMs=s.previewTimeoutMs,l.enabled=s.enabled,!g.value&&T.value[0]&&(g.value=T.value[0].id)}catch(n){v.value=n instanceof Error?n.message:"加载失败"}finally{N.value=!1}}async function B(){if(i.isAdmin){v.value="";try{await w("/api/video/devices",{method:"POST",token:i.token,body:JSON.stringify(u)}),u.name="",u.location="",u.ip="",u.remark="",u.channel+=1,v.value="设备已添加",await A()}catch(n){v.value=n instanceof Error?n.message:"添加失败"}}}async function K(n){if(i.isAdmin){v.value="";try{await w(`/api/video/devices/${n.id}`,{method:"PATCH",token:i.token,body:JSON.stringify({enabled:!n.enabled})}),await A()}catch(s){v.value=s instanceof Error?s.message:"更新失败"}}}async function O(n){if(i.isAdmin&&confirm(`确认删除设备「${n.name}」?`)){v.value="";try{await w(`/api/video/devices/${n.id}`,{method:"DELETE",token:i.token}),g.value===n.id&&(g.value=""),v.value="已删除",await A()}catch(s){v.value=s instanceof Error?s.message:"删除失败"}}}async function W(){if(i.isAdmin){v.value="",_.value="";try{const n={host:l.host,port:l.port,rtspPort:l.rtspPort,username:l.username,protocol:l.protocol,streamType:l.streamType,previewTimeoutMs:l.previewTimeoutMs,enabled:l.enabled};l.password&&l.password!=="********"&&(n.password=l.password);const s=await w("/api/video/config",{method:"PUT",token:i.token,body:JSON.stringify(n)});C.value=s,l.password=s.passwordMasked||"",v.value="通信配置已保存"}catch(n){v.value=n instanceof Error?n.message:"保存失败"}}}async function J(){if(i.isAdmin){P.value=!0,_.value="";try{const n=await w("/api/video/config/test",{method:"POST",token:i.token});_.value=`${n.message} · ${n.latencyMs} ms`,v.value=n.ok?"连通性测试通过":n.message}catch(n){_.value=n instanceof Error?n.message:"测试失败"}finally{P.value=!1}}}function F(n){g.value=n.id,f.value="preview"}function $(n){return new Date(n).toLocaleString("zh-CN",{hour12:!1})}return q(A),(n,s)=>{var x;return a(),d("div",Z,[e("div",ee,[e("button",{type:"button",class:y(["tab",{active:f.value==="devices"}]),onClick:s[0]||(s[0]=t=>f.value="devices")}," 设备管理 ",2),e("button",{type:"button",class:y(["tab",{active:f.value==="config"}]),onClick:s[1]||(s[1]=t=>f.value="config")}," 通信配置 ",2),e("button",{type:"button",class:y(["tab",{active:f.value==="preview"}]),onClick:s[2]||(s[2]=t=>f.value="preview")}," 查看监控 ",2),s[18]||(s[18]=e("div",{class:"tab-spacer"},null,-1)),s[19]||(s[19]=e("div",{class:"badge"},"海康威视",-1)),e("div",se,"在线 "+o(L.value)+" / "+o(h.value.length),1),e("button",{type:"button",class:"btn ghost",disabled:N.value,onClick:A},"刷新",8,te),v.value?(a(),d("span",le,o(v.value),1)):b("",!0)]),f.value==="devices"?(a(),d(S,{key:0},[p(i).isAdmin?(a(),d("section",ne,[s[27]||(s[27]=e("div",{class:"panel-head"},[e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"添加海康监控设备")]),e("div",{class:"badge"},"管理员")],-1)),e("div",oe,[e("div",ie,[s[20]||(s[20]=e("label",null,"名称",-1)),m(e("input",{"onUpdate:modelValue":s[3]||(s[3]=t=>u.name=t),placeholder:"如:走廊东"},null,512),[[c,u.name]])]),e("div",ae,[s[21]||(s[21]=e("label",null,"NVR 通道号",-1)),m(e("input",{"onUpdate:modelValue":s[4]||(s[4]=t=>u.channel=t),type:"number",min:"1",max:"256"},null,512),[[c,u.channel,void 0,{number:!0}]])]),e("div",de,[s[22]||(s[22]=e("label",null,"安装位置",-1)),m(e("input",{"onUpdate:modelValue":s[5]||(s[5]=t=>u.location=t),placeholder:"楼层/区域"},null,512),[[c,u.location]])]),e("div",ue,[s[24]||(s[24]=e("label",null,"类型",-1)),m(e("select",{"onUpdate:modelValue":s[6]||(s[6]=t=>u.deviceType=t)},[...s[23]||(s[23]=[e("option",{value:"nvr-ch"},"海康 NVR 通道",-1),e("option",{value:"ipc"},"海康网络摄像机",-1)])],512),[[M,u.deviceType]])]),e("div",re,[s[25]||(s[25]=e("label",null,"摄像机 IP(可选)",-1)),m(e("input",{"onUpdate:modelValue":s[7]||(s[7]=t=>u.ip=t),placeholder:"IPC 直连时填写"},null,512),[[c,u.ip]])]),e("div",ve,[s[26]||(s[26]=e("label",null,"备注",-1)),m(e("input",{"onUpdate:modelValue":s[8]||(s[8]=t=>u.remark=t)},null,512),[[c,u.remark]])])]),e("div",{class:"row-actions"},[e("button",{type:"button",class:"btn",onClick:B},"添加")])])):b("",!0),e("section",pe,[s[30]||(s[30]=e("div",{class:"panel-head"},[e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"海康监控设备列表")]),e("div",{class:"badge amber"},"Hikvision NVR")],-1)),e("div",me,[e("table",be,[s[29]||(s[29]=e("thead",null,[e("tr",null,[e("th",null,"通道"),e("th",null,"名称"),e("th",null,"位置"),e("th",null,"类型"),e("th",null,"IP"),e("th",null,"状态"),e("th",null,"启用"),e("th",null,"更新时间"),e("th",null,"操作")])],-1)),e("tbody",null,[(a(!0),d(S,null,D(h.value,t=>(a(),d("tr",{key:t.id},[e("td",null,o(t.channel),1),e("td",null,o(t.name),1),e("td",null,o(t.location),1),e("td",null,o(H[t.deviceType]),1),e("td",null,o(t.ip||"—"),1),e("td",null,[e("span",{class:y(["st",t.status])},o(U[t.status]),3)]),e("td",null,o(t.enabled?"是":"否"),1),e("td",null,o($(t.updatedAt)),1),e("td",ce,[e("button",{type:"button",class:"btn ghost",onClick:V=>F(t)},"查看",8,fe),p(i).isAdmin?(a(),d("button",{key:0,type:"button",class:"btn ghost",onClick:V=>K(t)},o(t.enabled?"禁用":"启用"),9,ye)):b("",!0),p(i).isAdmin?(a(),d("button",{key:1,type:"button",class:"btn danger",onClick:V=>O(t)}," 删除 ",8,ke)):b("",!0)])]))),128)),h.value.length?b("",!0):(a(),d("tr",ge,[...s[28]||(s[28]=[e("td",{colspan:"9",class:"empty"},"暂无设备,请先添加或检查通信配置",-1)])]))])])])])],64)):f.value==="config"?(a(),d("section",we,[e("div",he,[s[32]||(s[32]=e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"海康通信配置")],-1)),e("div",_e,[s[31]||(s[31]=e("div",{class:"badge"},"厂商:海康威视",-1)),e("div",{class:y(["badge",p(i).isAdmin?"":"amber"])},o(p(i).isAdmin?"可编辑":"只读"),3)])]),s[44]||(s[44]=e("p",{class:"hint"}," 对接海康威视 NVR / 摄像机。推荐 HCNetSDK(设备端口 8000)预览;RTSP(554)或 ISAPI 作备选。 一期保存配置并 Mock 登录测试;二期经 Windows HCNetSDK 桥接拉取实况(与门禁桥接同类)。 ",-1)),e("div",Ce,[e("div",Te,[s[33]||(s[33]=e("label",null,"海康 NVR IP",-1)),m(e("input",{"onUpdate:modelValue":s[9]||(s[9]=t=>l.host=t),disabled:!p(i).isAdmin,placeholder:"192.168.x.x"},null,8,Ae),[[c,l.host]])]),e("div",Ve,[s[34]||(s[34]=e("label",null,"设备端口(SDK)",-1)),m(e("input",{"onUpdate:modelValue":s[10]||(s[10]=t=>l.port=t),type:"number",disabled:!p(i).isAdmin},null,8,Pe),[[c,l.port,void 0,{number:!0}]])]),e("div",Se,[s[35]||(s[35]=e("label",null,"RTSP 端口",-1)),m(e("input",{"onUpdate:modelValue":s[11]||(s[11]=t=>l.rtspPort=t),type:"number",disabled:!p(i).isAdmin},null,8,Ne),[[c,l.rtspPort,void 0,{number:!0}]])]),e("div",Ue,[s[37]||(s[37]=e("label",null,"接入方式",-1)),m(e("select",{"onUpdate:modelValue":s[12]||(s[12]=t=>l.protocol=t),disabled:!p(i).isAdmin},[...s[36]||(s[36]=[e("option",{value:"sdk"},"HCNetSDK(推荐)",-1),e("option",{value:"rtsp"},"RTSP",-1),e("option",{value:"isapi"},"ISAPI",-1)])],8,Me),[[M,l.protocol]])]),e("div",Re,[s[38]||(s[38]=e("label",null,"设备用户名",-1)),m(e("input",{"onUpdate:modelValue":s[13]||(s[13]=t=>l.username=t),disabled:!p(i).isAdmin},null,8,Ee),[[c,l.username]])]),e("div",$e,[s[39]||(s[39]=e("label",null,"设备密码",-1)),m(e("input",{"onUpdate:modelValue":s[14]||(s[14]=t=>l.password=t),type:"password",disabled:!p(i).isAdmin,placeholder:"********",autocomplete:"new-password"},null,8,xe),[[c,l.password]])]),e("div",De,[s[41]||(s[41]=e("label",null,"预览码流",-1)),m(e("select",{"onUpdate:modelValue":s[15]||(s[15]=t=>l.streamType=t),disabled:!p(i).isAdmin},[...s[40]||(s[40]=[e("option",{value:"sub"},"子码流(推荐)",-1),e("option",{value:"main"},"主码流",-1)])],8,Ie),[[M,l.streamType]])]),e("div",He,[s[42]||(s[42]=e("label",null,"预览超时 (ms)",-1)),m(e("input",{"onUpdate:modelValue":s[16]||(s[16]=t=>l.previewTimeoutMs=t),type:"number",disabled:!p(i).isAdmin},null,8,Le),[[c,l.previewTimeoutMs,void 0,{number:!0}]])]),e("div",Be,[s[43]||(s[43]=e("label",null,"启用海康通信",-1)),e("label",Ke,[m(e("input",{"onUpdate:modelValue":s[17]||(s[17]=t=>l.enabled=t),type:"checkbox",disabled:!p(i).isAdmin},null,8,Oe),[[G,l.enabled]]),e("span",null,o(l.enabled?"已启用":"已关闭"),1)])])]),e("div",We,[p(i).isAdmin?(a(),d("button",{key:0,type:"button",class:"btn",onClick:W},"保存配置")):b("",!0),p(i).isAdmin?(a(),d("button",{key:1,type:"button",class:"btn ghost",disabled:P.value,onClick:J},o(P.value?"测试中…":"测试连通性"),9,Je)):b("",!0),_.value?(a(),d("span",Fe,o(_.value),1)):b("",!0),C.value?(a(),d("span",ze," 最近更新:"+o($(C.value.updatedAt))+" · "+o(C.value.updatedBy),1)):b("",!0)])])):(a(),d("div",je,[e("section",qe,[e("div",Ge,[e("div",Qe,[s[45]||(s[45]=e("div",{class:"vbar"},null,-1)),e("h3",null,o(r.value?r.value.name:"未选择通道"),1)]),r.value?(a(),d("div",{key:0,class:y(["badge",r.value.status==="online"?"":"amber"])}," CH"+o(r.value.channel)+" · "+o(U[r.value.status]),3)):b("",!0)]),e("div",{class:y(["stage webrtc-stage",((x=r.value)==null?void 0:x.status)||"offline"])},[r.value?(a(),Q(Y,{key:r.value.id,"device-id":r.value.id},null,8,["device-id"])):(a(),d("div",Xe,[...s[46]||(s[46]=[e("div",{class:"cam-title"},"无可用通道",-1),e("p",{class:"cam-note"},"请先在设备管理中启用海康监控通道。",-1)])])),r.value?(a(),d("div",Ye,[R(o(r.value.name)+" · "+o(r.value.location)+" · CH"+o(r.value.channel)+" ",1),r.value.ip?(a(),d(S,{key:0},[R(" · "+o(r.value.ip),1)],64)):b("",!0),s[47]||(s[47]=R(" · WebRTC / WHEP ",-1))])):b("",!0)],2)]),e("section",Ze,[s[48]||(s[48]=e("div",{class:"panel-head"},[e("div",{class:"panel-title"},[e("div",{class:"vbar"}),e("h3",null,"通道列表")])],-1)),e("div",es,[(a(!0),d(S,null,D(T.value,t=>{var V;return a(),d("button",{key:t.id,type:"button",class:y(["thumb",[{active:((V=r.value)==null?void 0:V.id)===t.id},t.status]]),onClick:ds=>g.value=t.id},[e("div",ts,[e("span",ls,"CH"+o(t.channel),1),e("span",{class:y(["st",t.status])},o(U[t.status]),3)]),e("div",ns,o(t.name),1),e("div",os,o(t.location),1)],10,ss)}),128))])])]))])}}}),vs=X(is,[["__scopeId","data-v-088a7476"]]);export{vs as default};
.video-page[data-v-088a7476]{gap:12px}.head-right[data-v-088a7476]{display:flex;align-items:center;gap:8px}.video-tabs[data-v-088a7476]{display:flex;align-items:center;gap:8px;flex:0 0 auto}.tab[data-v-088a7476]{border-radius:6px;border:1px solid rgba(62,200,255,.22);background:#122a4e66;color:var(--muted);padding:6px 14px;font-size:12px;cursor:pointer}.tab.active[data-v-088a7476]{background:linear-gradient(90deg,#1a6fff,#0aa6e5);color:#fff;border-color:transparent;font-weight:600}.tab-spacer[data-v-088a7476]{flex:1}.msg[data-v-088a7476]{font-size:12px;color:var(--cyan-lt)}.hint[data-v-088a7476]{font-size:12px;color:var(--label);line-height:1.6;margin-bottom:12px}.form-grid.three[data-v-088a7476]{grid-template-columns:repeat(3,1fr);max-width:1100px}.row-actions[data-v-088a7476]{display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin-top:14px}.switch-field .switch[data-v-088a7476]{display:flex;align-items:center;gap:8px;height:36px;color:var(--body);font-size:13px;cursor:pointer}.list-panel[data-v-088a7476],.config-panel[data-v-088a7476]{flex:1;min-height:0;display:flex;flex-direction:column}.table-wrap[data-v-088a7476]{flex:1;min-height:0;overflow:auto}.ops[data-v-088a7476]{display:flex;flex-wrap:wrap;gap:6px}.empty[data-v-088a7476]{text-align:center;color:var(--label);padding:24px!important}.st[data-v-088a7476]{display:inline-block;padding:1px 8px;border-radius:4px;font-size:11px}.st.online[data-v-088a7476]{background:#2fd6a126;color:var(--green)}.st.offline[data-v-088a7476]{background:#8eb4d41f;color:var(--muted)}.st.alarm[data-v-088a7476]{background:#f0b4292e;color:var(--amber)}.preview-layout[data-v-088a7476]{flex:1;min-height:0;display:grid;grid-template-columns:1.6fr 1fr;gap:12px}.webrtc-stage[data-v-088a7476]{display:flex;flex-direction:column;min-height:360px;padding:0;overflow:hidden}.webrtc-stage[data-v-088a7476] .webrtc-player{flex:1;min-height:320px;border-radius:0}.cam-meta.bar[data-v-088a7476]{flex:0 0 auto;padding:8px 12px;font-size:12px;color:var(--muted);border-top:1px solid rgba(62,200,255,.15);background:#00000040}.preview-main[data-v-088a7476],.preview-side[data-v-088a7476]{min-height:0;display:flex;flex-direction:column}.stage[data-v-088a7476]{position:relative;flex:1;min-height:280px;border-radius:8px;overflow:hidden;border:1px solid rgba(62,200,255,.28);background:radial-gradient(ellipse at 30% 20%,rgba(58,140,220,.22),transparent 55%),linear-gradient(160deg,#0a1a30,#07111f 55%,#040a14)}.stage.alarm[data-v-088a7476]{background:radial-gradient(ellipse at 40% 30%,rgba(240,180,41,.18),transparent 50%),linear-gradient(160deg,#1a1520,#0c1018)}.stage.offline[data-v-088a7476]{filter:saturate(.55)}.scan[data-v-088a7476]{pointer-events:none;position:absolute;top:0;right:0;bottom:0;left:0;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(122,216,255,.03) 2px,rgba(122,216,255,.03) 4px)}.live-tag[data-v-088a7476]{position:absolute;left:12px;top:12px;z-index:2;display:flex;align-items:center;gap:6px;font-size:11px;color:#fff;background:#04101fa6;padding:3px 8px;border-radius:4px}.live-tag i[data-v-088a7476]{width:7px;height:7px;border-radius:50%;background:#ff5a5a;box-shadow:0 0 #ff5a5a99;animation:pulse-088a7476 1.4s ease infinite}@keyframes pulse-088a7476{70%{box-shadow:0 0 0 6px #ff5a5a00}to{box-shadow:0 0 #ff5a5a00}}.stage-body[data-v-088a7476]{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:24px;z-index:1}.cam-title[data-v-088a7476]{font-size:22px;font-weight:600;color:var(--title)}.cam-sub[data-v-088a7476]{margin-top:8px;font-size:13px;color:var(--cyan-lt)}.cam-meta[data-v-088a7476]{margin-top:10px;font-size:12px;color:var(--label);font-family:var(--font-num)}.cam-note[data-v-088a7476]{margin-top:16px;max-width:420px;font-size:12px;color:var(--muted);line-height:1.7}.thumb-grid[data-v-088a7476]{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:1fr 1fr;gap:8px;align-content:start}.thumb[data-v-088a7476]{text-align:left;cursor:pointer;border-radius:8px;padding:10px 12px;border:1px solid rgba(62,200,255,.2);background:#0e244459;color:inherit}.thumb[data-v-088a7476]:hover{border-color:#3ec8ff73}.thumb.active[data-v-088a7476]{border-color:#3ac8ffb3;background:#1a6fff38;box-shadow:inset 0 0 0 1px #7ad8ff26}.thumb-top[data-v-088a7476]{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}.ch[data-v-088a7476]{font-size:11px;color:var(--cyan-lt);font-family:var(--font-num)}.thumb-name[data-v-088a7476]{font-size:13px;font-weight:600;color:#fff}.thumb-loc[data-v-088a7476]{margin-top:4px;font-size:11px;color:var(--label)}
.webrtc-player[data-v-7c02508e]{position:relative;width:100%;height:100%;min-height:280px;background:#050a14;border-radius:8px;overflow:hidden}.video[data-v-7c02508e]{width:100%;height:100%;object-fit:contain;background:#000}.overlay[data-v-7c02508e]{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:#c9d7f0;font-size:14px;background:#050a14b8;text-align:center;padding:16px}.sub[data-v-7c02508e]{font-size:12px;color:#8fa3c4;max-width:80%;line-height:1.5;word-break:break-all}.btn[data-v-7c02508e]{border:1px solid rgba(61,139,253,.45);background:#3d8bfd33;color:#fff;border-radius:6px;padding:6px 14px;cursor:pointer}.live-tag[data-v-7c02508e]{position:absolute;top:12px;left:12px;display:flex;align-items:center;gap:6px;font-size:11px;padding:4px 8px;border-radius:4px;background:#0000008c;color:#9dffc0}.live-tag i[data-v-7c02508e]{width:7px;height:7px;border-radius:50%;background:#3dd68c;box-shadow:0 0 8px #3dd68c}
import{d as S,u as E,p as P,q as W,c as s,n as I,b as i,F as w,g as k,t as x,r as g,o as r,_ as B}from"./index-BRA9HJ0O.js";const D={key:0,class:"overlay"},O={class:"sub"},A={key:1,class:"live-tag"},L=S({__name:"WebRtcPlayer",props:{deviceId:{},whepPath:{}},setup(_,{expose:C}){const c=_,h=E(),n=g(null),a=g("idle"),l=g("");let e=null,d=null;async function u(){try{d&&h.token&&await fetch(d,{method:"DELETE",headers:{Authorization:`Bearer ${h.token}`}}).catch(()=>{})}finally{d=null,e==null||e.close(),e=null,n.value&&(n.value.srcObject=null),a.value="idle"}}async function f(){var o;if(await u(),!!c.deviceId){a.value="connecting",l.value="",e=new RTCPeerConnection({iceServers:[{urls:"stun:stun.l.google.com:19302"}]}),e.addTransceiver("video",{direction:"recvonly"}),e.addTransceiver("audio",{direction:"recvonly"}),e.ontrack=t=>{n.value&&(n.value.srcObject=t.streams[0]||new MediaStream([t.track]),n.value.play().catch(()=>{}),a.value="playing")},e.onconnectionstatechange=()=>{e&&(e.connectionState==="failed"||e.connectionState==="disconnected")&&(a.value="error",l.value="WebRTC 连接中断")};try{const t=await e.createOffer();await e.setLocalDescription(t),await R(e);const m=c.whepPath||`/api/video/devices/${encodeURIComponent(c.deviceId)}/whep`,p=await fetch(m,{method:"POST",headers:{"Content-Type":"application/sdp",Accept:"application/sdp",Authorization:`Bearer ${h.token}`},body:((o=e.localDescription)==null?void 0:o.sdp)||t.sdp||""}),v=p.headers.get("Location");v&&(d=(v.startsWith("http"),v));const y=await p.text();if(!p.ok){let b=y||`WHEP HTTP ${p.status}`;try{const T=JSON.parse(y);T.message&&(b=T.message)}catch{}throw new Error(b)}await e.setRemoteDescription({type:"answer",sdp:y})}catch(t){a.value="error",l.value=t instanceof Error?t.message:"WebRTC 启动失败",await u(),a.value="error"}}}function R(o){return o.iceGatheringState==="complete"?Promise.resolve():new Promise(t=>{const m=window.setTimeout(()=>t(),2e3);o.onicegatheringstatechange=()=>{o.iceGatheringState==="complete"&&(window.clearTimeout(m),t())}})}return P(()=>c.deviceId,()=>{f()},{immediate:!0}),W(()=>{u()}),C({restart:f,stop:u}),(o,t)=>(r(),s("div",{class:I(["webrtc-player",a.value])},[i("video",{ref_key:"videoEl",ref:n,class:"video",autoplay:"",playsinline:"",muted:""},null,512),a.value!=="playing"?(r(),s("div",D,[a.value==="connecting"?(r(),s(w,{key:0},[k("正在建立 WebRTC…")],64)):a.value==="error"?(r(),s(w,{key:1},[t[0]||(t[0]=i("div",null,"WebRTC 未就绪",-1)),i("div",O,x(l.value),1),i("button",{type:"button",class:"btn",onClick:f},"重试")],64)):(r(),s(w,{key:2},[k("空闲")],64))])):(r(),s("div",A,[...t[1]||(t[1]=[i("i",null,null,-1),i("span",null,"WebRTC · LIVE",-1)])]))],2))}}),j=B(L,[["__scopeId","data-v-7c02508e"]]);export{j as W};
.echart-wrap[data-v-5cb78322]{position:relative;min-width:0;min-height:0}.echart[data-v-5cb78322]{width:100%;height:100%}.echart-empty[data-v-5cb78322]{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--label, #7a9bb0);border:1px dashed rgba(0,247,255,.18);border-radius:4px;background:#04122459;pointer-events:none}
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.
@font-face{font-family:AlibabaPuHuiTi-3-115-Black;src:url(/fonts/AlibabaPuHuiTi-3-115-Black/AlibabaPuHuiTi-3-115-Black.woff2) format("woff2"),url(/fonts/AlibabaPuHuiTi-3-115-Black/AlibabaPuHuiTi-3-115-Black.woff) format("woff"),url(/fonts/AlibabaPuHuiTi-3-115-Black/AlibabaPuHuiTi-3-115-Black.ttf) format("truetype");font-weight:900;font-style:normal;font-display:swap}:root{--screen-bg: #000b1d;--panel-bg: rgba(0, 18, 42, .55);--panel-border: rgba(0, 247, 255, .22);--panel-title: #f4feff;--panel-muted: #7eb6d6;--text: #d7f4ff;--accent: #00f7ff;--cyan: #00f7ff;--alert: #ff4d4f;--ok: #52c41a;--warn: #faad14;--title-font: "AlibabaPuHuiTi-3-115-Black", "Noto Sans SC", "PingFang SC", sans-serif;--body-font: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--digital-font: "Share Tech Mono", ui-monospace, monospace;--bg: var(--screen-bg);--bg-deep: #04080f;--cyan-lt: #7ad8ff;--green: var(--ok);--amber: var(--warn);--red: var(--alert);--title: var(--panel-title);--body: var(--text);--label: var(--panel-muted);--muted: var(--panel-muted);--panel-bd: var(--panel-border);--card-bg: rgba(6, 12, 20, .55);--card-bd: rgba(61, 190, 255, .18);--track: rgba(61, 190, 255, .16);--bar-grad: linear-gradient(90deg, rgba(61, 190, 255, .55), #3dbeff);--font-cn: var(--body-font);--font-num: var(--digital-font);--shadow: 0 6px 20px rgba(0, 0, 0, .22)}*{margin:0;padding:0;box-sizing:border-box}html,body,#app{width:100%;height:100%;overflow:hidden;background:var(--bg-deep)}body{font-family:var(--body-font);font-weight:600;letter-spacing:.02em;color:var(--text);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}button,input,select,textarea{font:inherit}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-thumb{background:#3dbeff59;border-radius:2px}::-webkit-scrollbar-track{background:transparent}.stage{position:fixed;top:0;right:0;bottom:0;left:0;width:100%;height:100%;max-width:100vw;max-height:100vh;transform:none;display:flex;flex-direction:column;overflow:hidden;background:#00060f;color:var(--text);font-family:var(--body-font);--screen-side-w: clamp(280px, 24vw, 480px)}.stage:before{content:none}.screen-header{position:relative;height:84px;flex:0 0 84px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;border-bottom:1px solid var(--panel-border);background:linear-gradient(90deg,#3dbeff29,#0810188c 42%,#060c1459)}.screen-header:after{content:"";position:absolute;left:0;right:0;bottom:0;height:1px;background:linear-gradient(90deg,var(--accent),transparent 62%);opacity:.85;pointer-events:none}.brand{display:flex;align-items:center;gap:12px}.brand-mark{width:34px;height:34px;border-radius:3px;border:1px solid rgba(61,190,255,.55);background:radial-gradient(circle at 30% 25%,#3dbeff8c,#3dbeff0f);box-shadow:0 0 1.1em #3dbeff52;display:grid;place-items:center;color:#eaf4ff;font-weight:700;font-family:var(--digital-font)}.brand-txt .sys{font-size:14px;font-weight:600;color:var(--panel-title);letter-spacing:.04em}.brand-txt .en{font-size:9px;color:var(--accent);letter-spacing:.16em;text-transform:uppercase;font-family:var(--body-font)}.title-group{position:absolute;left:50%;top:18px;transform:translate(-50%);display:flex;align-items:center;gap:18px}.title-group h1{font-size:28px;font-weight:700;letter-spacing:.12em;white-space:nowrap;font-family:var(--title-font);background:linear-gradient(180deg,#fff 20%,#b7d7ff);-webkit-background-clip:text;background-clip:text;color:transparent}.wing{display:flex;align-items:center;gap:8px;height:28px}.wing-line{width:96px;height:3px;border-radius:1px;background:linear-gradient(90deg,transparent 0%,rgba(61,190,255,.15) 20%,#3dbeff 100%);box-shadow:0 0 10px #3dbeff8c;position:relative}.wing-line:before{content:"";position:absolute;left:18%;right:0;top:50%;transform:translateY(-50%);height:1px;background:linear-gradient(90deg,transparent,rgba(255,255,255,.75))}.wing-chevron{width:0;height:0;border-style:solid;border-width:8px 0 8px 12px;border-color:transparent transparent transparent #3dbeff;filter:drop-shadow(0 0 6px rgba(61,190,255,.85));flex:0 0 auto}.wing-dot{width:8px;height:8px;border-radius:50%;background:#7ad8ff;box-shadow:0 0 0 3px #3dbeff38,0 0 12px #3dbeffe6;flex:0 0 auto}.wing.right .wing-line{background:linear-gradient(90deg,#3dbeff 0%,rgba(61,190,255,.15) 80%,transparent 100%)}.wing.right .wing-line:before{left:0;right:18%;background:linear-gradient(90deg,rgba(255,255,255,.75),transparent)}.wing.right .wing-chevron{border-width:8px 12px 8px 0;border-color:transparent #3dbeff transparent transparent}.hdr-status{display:flex;align-items:center;gap:12px}.alarm-badge,.user-chip,.server-chip{display:flex;align-items:center;gap:6px;padding:6px 12px;border-radius:2px;border:1px solid var(--panel-border);background:#060c148c;font-size:12px;letter-spacing:.04em}.alarm-badge{border-color:#e25b5b80;color:#ffb0b0;cursor:pointer}.user-chip{color:var(--text)}.user-chip button{border:none;background:transparent;color:var(--cyan-lt);cursor:pointer;margin-left:4px}.weather-wrap,.clock-wrap{position:absolute;top:50%;transform:translate(-50%,-50%);display:flex;align-items:baseline;gap:10px;white-space:nowrap}.weather-wrap{left:26%}.clock-wrap{left:74%}.weather-wrap .place,.weather-wrap .temp{font-size:18px;font-weight:600;font-family:var(--digital-font);color:var(--ok);line-height:1}.weather-wrap .w-text,.weather-wrap .humid{font-size:12px;color:#7cf0c4;letter-spacing:.04em}.clock-wrap .time{font-size:18px;font-weight:600;font-family:var(--digital-font);color:var(--accent);line-height:1;text-shadow:0 0 .55em rgba(61,190,255,.5)}.clock-wrap .date{font-size:12px;color:var(--panel-muted);letter-spacing:.08em}.dot{width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 #3cba7a99;animation:pulse 2s infinite}.dot.red{background:var(--alert)}@keyframes pulse{0%{box-shadow:0 0 #3cba7a8c}70%{box-shadow:0 0 0 8px #3cba7a00}to{box-shadow:0 0 #3cba7a00}}.screen-nav{height:48px;flex:0 0 48px;display:flex;align-items:center;gap:8px;padding:0 20px}.nav-chip{border-radius:3px;border:1px solid var(--panel-border);background:#060c148c;cursor:pointer;color:var(--panel-muted);padding:6px 12px;font-size:12px;font-weight:600;letter-spacing:.04em;text-decoration:none;transition:.2s}.nav-chip.active,.nav-chip.router-link-active{background:linear-gradient(90deg,#3dbeff59,#3dbeff1f);color:#eaf4ff;border-color:#3dbeff8c}.nav-chip:hover{color:var(--cyan-lt);border-color:#3dbeff73}.nav-spacer{flex:1}.server-chip{color:#7cf0c4;border-color:#3cba7a59}.screen-main{flex:1;min-height:0;display:flex;gap:14px;padding:0 20px 14px}.panel{background:var(--panel-bg);border:1px solid var(--panel-border);border-radius:4px;padding:14px;box-shadow:var(--shadow)}.panel-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;flex:0 0 auto}.panel-title{display:flex;align-items:center;gap:8px}.panel-title .vbar{width:2px;height:14px;border-radius:1px;background:var(--accent);box-shadow:0 0 .5em var(--accent)}.panel-title h3{font-size:14px;font-weight:700;color:var(--panel-title);letter-spacing:.08em;text-transform:uppercase;font-family:var(--title-font)}.badge{padding:2px 8px;border-radius:2px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;background:#3cba7a1f;color:var(--ok)}.badge.amber{background:#e0a04a26;color:var(--warn)}.badge.red{background:#e25b5b26;color:#ffb0b0}.metric-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.metric{display:flex;align-items:center;gap:8px;padding:10px;min-height:64px;border-radius:4px;background:var(--card-bg);border:1px solid var(--card-bd)}.metric .m-label{font-size:11px;color:var(--panel-muted);letter-spacing:.06em;text-transform:uppercase}.metric .m-value{font-size:20px;font-weight:600;font-family:var(--digital-font);color:#fff}.metric .m-value.green{color:var(--ok)}.kpi-bar{height:108px;flex:0 0 108px;display:flex;gap:14px;padding:0 20px 16px}.kpi{flex:1;padding:12px;border-radius:4px;background:var(--panel-bg);border:1px solid var(--panel-border);display:flex;flex-direction:column;justify-content:center;gap:6px}.kpi .k-label{font-size:11px;color:var(--panel-muted);letter-spacing:.06em;text-transform:uppercase}.kpi .digital{align-self:flex-start;padding:3px 10px;border-radius:2px;background:#060c14e6;border:1px solid rgba(61,190,255,.45);font-size:20px;font-weight:600;font-family:var(--digital-font);color:var(--accent)}.kpi .k-trend{font-size:10px;color:var(--warn)}.kpi .k-trend.green{color:var(--ok)}.kpi .k-trend.cyan{color:var(--cyan-lt)}.btn{border:1px solid rgba(61,190,255,.45);background:#3dbeff24;color:#e8f4ff;border-radius:2px;padding:8px 14px;cursor:pointer;font-weight:600;letter-spacing:.04em}.btn:hover{background:#3dbeff47;border-color:var(--accent)}.btn:disabled{opacity:.45;cursor:not-allowed}.btn.danger{border-color:#e25b5b80;background:#e25b5b33;color:#ffe8e8}.btn.ghost{background:transparent}.table{width:100%;border-collapse:collapse;font-size:12px}.table th,.table td{padding:8px 10px;border-bottom:1px solid rgba(61,190,255,.16);text-align:left}.table th{color:var(--panel-muted);font-weight:600;letter-spacing:.05em;text-transform:uppercase;font-size:11px;background:#060c1459}.table td{color:var(--text)}.table tbody tr:hover td{background:#3dbeff14}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.field{display:flex;flex-direction:column;gap:6px}.field label{font-size:12px;color:var(--panel-muted);letter-spacing:.04em;font-weight:600}.field input,.field select,.field textarea{height:36px;border-radius:2px;padding:0 10px;border:none;box-shadow:0 0 0 1px #3dbeff47 inset;background:#060c14a6;color:var(--panel-title);outline:none}.field textarea{height:auto;padding:8px 10px}.field input:focus,.field select:focus,.field textarea:focus{box-shadow:0 0 0 1px #3dbeffa6 inset}.page-fill{flex:1;min-height:0;display:flex;flex-direction:column;gap:14px;width:100%;height:100%;pointer-events:auto}.page-fill>.panel{min-height:0}.stub-box{flex:1;display:grid;place-items:center;border-radius:4px;border:1px solid var(--panel-border);background:#081018b8;text-align:center;padding:40px}.stub-box h2{font-size:24px;color:var(--panel-title);margin-bottom:10px;letter-spacing:.08em;font-family:var(--title-font)}.stub-box p{font-size:13px;color:var(--panel-muted);line-height:1.8;max-width:560px}.mono{font-family:var(--digital-font);font-variant-numeric:tabular-nums}.tops-root{box-sizing:border-box;width:100%;height:100%;min-height:0;display:flex;flex-direction:column;gap:.6em;padding:.6em .75em .75em;overflow:hidden;background:radial-gradient(ellipse at 18% -8%,rgba(61,190,255,.14),transparent 48%),radial-gradient(ellipse at 92% 108%,rgba(226,91,91,.08),transparent 42%),linear-gradient(rgba(61,190,255,.035) 1px,transparent 1px),linear-gradient(90deg,rgba(61,190,255,.035) 1px,transparent 1px),var(--screen-bg);background-size:100% 100%,100% 100%,2.8em 2.8em,2.8em 2.8em,auto;color:var(--text);font-family:var(--body-font)}.tops-tone--ok{--tone: var(--ok)}.tops-tone--accent{--tone: var(--accent)}.tops-tone--warn{--tone: var(--warn)}.tops-tone--alert{--tone: var(--alert)}.tops-tone--muted{--tone: var(--panel-muted)}.tops-header{position:relative;flex:none;display:flex;align-items:center;gap:.9em;padding:.5em .85em;border:1px solid var(--panel-border);border-radius:4px;overflow:hidden;background:linear-gradient(90deg,#3dbeff2e,#081018db 42%,#060c14eb)}.tops-header:after{content:"";position:absolute;left:0;right:0;bottom:0;height:1px;background:linear-gradient(90deg,var(--accent),transparent 62%);opacity:.85}.tops-brand{display:flex;align-items:center;gap:.55em;min-width:0;cursor:pointer}.tops-brand__mark{flex:none;width:2.1em;height:2.1em;display:grid;place-items:center;border-radius:3px;border:1px solid rgba(61,190,255,.55);background:radial-gradient(circle at 30% 25%,#3dbeff8c,#3dbeff0f);box-shadow:0 0 1.1em #3dbeff52,inset 0 0 .8em #3dbeff38;color:#eaf4ff}.tops-brand__mark :deep(svg),.tops-brand__mark svg{width:1.3em;height:1.3em}.tops-brand__copy{display:flex;flex-direction:column;gap:.1em;min-width:0}.tops-brand__copy strong{font-size:1.04em;font-weight:600;letter-spacing:.02em;color:var(--panel-title);white-space:nowrap}.tops-brand__copy small{font-size:.62em;letter-spacing:.16em;text-transform:uppercase;color:var(--panel-muted);white-space:nowrap}.tops-header__chips{display:flex;flex-wrap:wrap;align-items:center;gap:.4em;min-width:0}.tops-chip{display:inline-flex;align-items:center;gap:.42em;padding:.24em .48em;border:1px solid var(--panel-border);border-radius:2px;background:#060c148c;white-space:nowrap}.tops-chip small{font-size:.6em;letter-spacing:.06em;text-transform:uppercase;color:var(--panel-muted)}.tops-chip b{font-size:.76em;font-weight:600;color:var(--panel-title);font-family:var(--digital-font)}.tops-dot{width:.42em;height:.42em;border-radius:50%;background:var(--tone, var(--ok));box-shadow:0 0 .45em var(--tone, var(--ok))}.tops-header__end{margin-left:auto;display:flex;align-items:center;gap:.7em}.tops-clock{display:flex;flex-direction:column;align-items:flex-end;line-height:1.1}.tops-clock small{font-size:.6em;letter-spacing:.12em;color:var(--panel-muted)}.tops-clock b{font-family:var(--digital-font);font-size:1.18em;font-weight:600;color:var(--accent);text-shadow:0 0 .55em rgba(61,190,255,.5)}.tops-logout{font:inherit;font-size:.68em;font-weight:600;letter-spacing:.06em;padding:.38em .75em;border:1px solid var(--panel-border);border-radius:3px;background:#060c148c;color:var(--panel-muted);cursor:pointer;transition:color .18s ease,border-color .18s ease,background .18s ease;text-decoration:none}.tops-logout:hover:not(:disabled){color:var(--panel-title);border-color:#3dbeff8c;background:#3dbeff24}.tops-body{flex:1;min-height:0;display:flex;gap:.6em}.tops-main{flex:1;min-width:0;min-height:0;overflow:auto;display:flex;flex-direction:column}.tops-side{flex:none;width:13.4em;display:flex;flex-direction:column;gap:.45em;padding:.6em .55em;border:1px solid var(--panel-border);border-radius:4px;background:linear-gradient(180deg,#081018f0,#060c14f0)}.tops-side__head{display:flex;align-items:baseline;justify-content:space-between;gap:.4em;padding:0 .2em .45em;border-bottom:1px solid var(--panel-border)}.tops-side__head strong{font-size:.82em;font-weight:600;letter-spacing:.06em;color:var(--panel-title)}.tops-side__head small{font-size:.64em;letter-spacing:.12em;color:var(--panel-muted)}.tops-menu{flex:1;min-height:0;display:flex;flex-direction:column;gap:.42em;padding-top:.1em;overflow-y:auto}.tops-menu__item{position:relative;display:flex;align-items:center;gap:.6em;width:100%;padding:.55em .6em .55em .75em;font:inherit;text-align:left;text-decoration:none;color:var(--text);border:1px solid var(--panel-border);border-radius:3px;background:linear-gradient(180deg,#081018e0,#060c14e0);cursor:pointer;transition:border-color .2s ease,background .2s ease,transform .2s ease;box-sizing:border-box}.tops-menu__item:before{content:"";position:absolute;left:0;top:.35em;bottom:.35em;width:2px;border-radius:1px;background:transparent;transition:background .2s ease,box-shadow .2s ease}.tops-menu__item:hover{transform:translate(2px);border-color:#3dbeff73;background:linear-gradient(180deg,#3dbeff29,#060c14e6)}.tops-menu__item.router-link-active,.tops-menu__item.is-on{border-color:#3dbeff99;background:linear-gradient(90deg,#3dbeff47,#081018b8 72%)}.tops-menu__item.router-link-active:before,.tops-menu__item.is-on:before{background:var(--accent);box-shadow:0 0 .6em var(--accent)}.tops-menu__icon{flex:none;width:1.7em;height:1.7em;display:grid;place-items:center;border-radius:3px;border:1px solid rgba(61,190,255,.28);color:var(--panel-muted);background:#060c148c}.tops-menu__item.router-link-active .tops-menu__icon{color:var(--accent);border-color:#3dbeff80}.tops-menu__icon svg{width:1em;height:1em}.tops-menu__copy{display:flex;flex-direction:column;gap:.12em;min-width:0}.tops-menu__label{font-size:.82em;font-weight:600;color:var(--panel-title)}.tops-menu__hint{font-size:.58em;letter-spacing:.04em;color:var(--panel-muted)}.tops-side__foot{display:flex;align-items:center;gap:.45em;padding:.35em .2em 0;border-top:1px solid var(--panel-border);font-size:.62em;letter-spacing:.08em;color:var(--panel-muted)}.tops-login{position:relative;isolation:isolate;width:100%;height:100%;min-height:100vh;overflow:hidden;display:grid;place-items:center;background:radial-gradient(120% 90% at 50% -10%,#123048,#081018 52%,#04080f);color:var(--text);font-family:var(--body-font);font-weight:600;letter-spacing:.02em}.tops-login__atmosphere{position:absolute;top:0;right:0;bottom:0;left:0;z-index:0;pointer-events:none}.tops-login__grid{position:absolute;top:18%;right:-10%;bottom:-28%;left:-10%;background-image:linear-gradient(rgba(61,190,255,.16) 1px,transparent 1px),linear-gradient(90deg,rgba(61,190,255,.16) 1px,transparent 1px);background-size:48px 48px;transform:perspective(720px) rotateX(58deg);transform-origin:50% 0%;-webkit-mask-image:linear-gradient(180deg,transparent 0%,#000 28%,#000 72%,transparent 100%);mask-image:linear-gradient(180deg,transparent 0%,#000 28%,#000 72%,transparent 100%);animation:tops-login-grid 18s linear infinite;opacity:.55}.tops-login__glow{position:absolute;border-radius:50%;filter:blur(48px)}.tops-login__glow--a{width:min(48vw,520px);height:min(48vw,520px);left:50%;top:12%;transform:translate(-50%);background:radial-gradient(circle,rgba(61,190,255,.28),transparent 68%);animation:tops-login-breathe 5.5s ease-in-out infinite}.tops-login__glow--b{width:min(36vw,380px);height:min(36vw,380px);left:50%;top:42%;transform:translate(-50%);background:radial-gradient(circle,rgba(242,230,168,.12),transparent 70%)}.tops-login__horizon{position:absolute;left:10%;right:10%;top:58%;height:1px;background:linear-gradient(90deg,transparent,rgba(61,190,255,.55),transparent);opacity:.7}.tops-login__stage{position:relative;z-index:1;width:min(28rem,calc(100vw - 2.5rem));display:flex;flex-direction:column;align-items:center;text-align:center;padding:1.25rem 0 2rem}.tops-login__mark{width:4.2rem;height:4.2rem;display:grid;place-items:center;border-radius:8px;border:1px solid rgba(61,190,255,.55);background:radial-gradient(circle at 30% 25%,#3dbeff80,#3dbeff0f);box-shadow:0 0 1.4em #3dbeff59;color:#eaf4ff;font-size:1.4rem;font-weight:700;letter-spacing:.08em;margin-bottom:1rem;animation:tops-login-rise .9s cubic-bezier(.22,1,.36,1) both}.tops-login__title{margin:0;font-size:clamp(1.15rem,2.4vw,1.45rem);font-weight:700;letter-spacing:.08em;color:var(--panel-title);animation:tops-login-rise .9s .06s cubic-bezier(.22,1,.36,1) both}.tops-login__tagline{margin:.45rem 0 0;font-size:.78rem;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);animation:tops-login-rise .9s .1s cubic-bezier(.22,1,.36,1) both}.tops-login__support{margin:.35rem 0 1.25rem;font-size:.78rem;color:var(--panel-muted);line-height:1.5;max-width:22rem;animation:tops-login-rise .9s .14s cubic-bezier(.22,1,.36,1) both}.tops-login__form{width:100%;display:flex;flex-direction:column;gap:.75rem;padding:1.15rem 1.2rem 1.25rem;border:1px solid var(--panel-border);border-radius:4px;background:#081018e0;box-shadow:0 12px 40px #00000059;text-align:left;animation:tops-login-rise .9s .18s cubic-bezier(.22,1,.36,1) both}.tops-login__form.is-shake{animation:tops-login-shake .45s ease}.tops-login__field{display:flex;flex-direction:column;gap:.35rem}.tops-login__field span{font-size:.68rem;letter-spacing:.1em;text-transform:uppercase;color:var(--panel-muted)}.tops-login__field input{height:2.4rem;padding:0 .75rem;border-radius:2px;border:none;background:#060c14a6;box-shadow:0 0 0 1px #3dbeff47 inset;color:var(--panel-title);outline:none}.tops-login__field input:focus{box-shadow:0 0 0 1px #3dbeffa6 inset,0 0 .6em #3dbeff40}.tops-login__error{margin:0;font-size:.78rem;color:#ffb0b0}.tops-login__submit{margin-top:.25rem;height:2.55rem;border:1px solid rgba(61,190,255,.55);border-radius:2px;background:#3dbeff38;color:#f4fbff;font-weight:700;letter-spacing:.12em;cursor:pointer;transition:background .18s ease,border-color .18s ease}.tops-login__submit:hover:not(:disabled){background:#3dbeff57;border-color:var(--accent)}.tops-login__submit:disabled{opacity:.55;cursor:wait}.tops-login__hint{margin:.15rem 0 0;font-size:.68rem;color:var(--panel-muted);line-height:1.55;text-align:center}.tops-login__hint code{font-family:var(--digital-font);color:var(--accent)}.tops-login__dash{margin-top:.35rem;background:transparent;border:none;color:var(--accent);font-size:.78rem;letter-spacing:.06em;cursor:pointer;text-decoration:underline;opacity:.9}@keyframes tops-login-grid{0%{background-position:0 0,0 0}to{background-position:0 48px,48px 0}}@keyframes tops-login-breathe{0%,to{opacity:.75;transform:translate(-50%) scale(1)}50%{opacity:1;transform:translate(-50%) scale(1.06)}}@keyframes tops-login-rise{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}@keyframes tops-login-shake{0%,to{transform:translate(0)}25%{transform:translate(-6px)}75%{transform:translate(6px)}}
import{M as y,r as c,y as u}from"./index-BRA9HJ0O.js";const g={ts:0,rooms:[],hvac:{systemRunning:!1,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:!1},pointAlarms:[]},T=y("realtime",()=>{const t=c(g),n=c(!1),r=c("sample-1");let e=null,a;const m=u(()=>{if(Array.isArray(t.value.pointAlarms))return t.value.pointAlarms.length;const o=t.value.rooms.reduce((s,h)=>s+h.alarms.length,0),l=t.value.fridges.filter(s=>s.temp>-19).length;return o+l+t.value.hvac.alarms.length}),p=u(()=>t.value.rooms.find(o=>o.id===r.value)||t.value.rooms[0]||{id:"-",name:"-",temp:0,humidity:0,pressure:0,alarms:[]});function d(o){r.value=o}function f(o){t.value=o}function i(){const o=`${location.protocol==="https:"?"wss":"ws"}://${location.host}/ws`;e==null||e.close(),e=new WebSocket(o),e.onopen=()=>{n.value=!0},e.onmessage=l=>{try{const s=JSON.parse(l.data);s.type==="snapshot"&&(t.value=s.data)}catch{}},e.onclose=()=>{n.value=!1,window.clearTimeout(a),a=window.setTimeout(i,2e3)},e.onerror=()=>e==null?void 0:e.close()}function v(){window.clearTimeout(a),e==null||e.close(),e=null}return{snapshot:t,connected:n,selectedRoomId:r,alarmCount:m,primaryRoom:p,selectRoom:d,setSnapshot:f,connect:i,disconnect:v}});export{T as u};
import{j as s,k as c,r as u}from"./index-BRA9HJ0O.js";const i=["星期日","星期一","星期二","星期三","星期四","星期五","星期六"];function n(t){return t<10?`0${t}`:`${t}`}function $(){const t=u("--:--:--"),o=u("");let a;function r(){const e=new Date;t.value=`${n(e.getHours())}:${n(e.getMinutes())}:${n(e.getSeconds())}`,o.value=`${e.getFullYear()}-${n(e.getMonth()+1)}-${n(e.getDate())} ${i[e.getDay()]}`}return s(()=>{r(),a=window.setInterval(r,1e3)}),c(()=>window.clearInterval(a)),{time:t,date:o}}export{$ as u};
import{j as f,q as m,p as v,l as d,r as n}from"./index-BRA9HJ0O.js";function p(i,u=6e4){const c=n([]),s=n(!1),a=n("");let o;async function r(){const e=i();if(!(!e.source||!e.metric)){s.value=!0,a.value="";try{const t=new URLSearchParams({source:e.source,metric:e.metric,hours:String(e.hours??24),bucketMinutes:String(e.bucketMinutes??15)}),l=await d(`/api/realtime/history?${t}`);c.value=l.series||[]}catch(t){a.value=t instanceof Error?t.message:"历史加载失败"}finally{s.value=!1}}}return f(()=>{r(),o=window.setInterval(()=>void r(),u)}),m(()=>{o&&window.clearInterval(o)}),v(i,()=>void r(),{deep:!0}),{series:c,loading:s,error:a,refresh:r}}export{p as u};
import{q as $,r as B}from"./index-BRA9HJ0O.js";const S="scene=mvg8x4b453b45f38",p="/rtengine";function h(){return"/rtengine/gis.html?scene=mvg8x4b453b45f38".trim()||"/rtengine/gis.html?scene=mvg8x4b453b45f38".trim()||`${p}/gis.html?${S}`}function v(n=h()){try{return new URL(n,typeof window<"u"?window.location.href:"http://localhost").origin}catch{return typeof window<"u"?window.location.origin:"http://localhost"}}function j(n=h()){try{const t=new URL(n,typeof window<"u"?window.location.href:"http://localhost");return t.pathname.startsWith(`${p}/`)||t.pathname===p?p:""}catch{return p}}function D(n,t=h()){const s=String(n||"").trim().replace(/\\/g,"/").split("/").pop()||"";if(!s)return"";const a=j(t);return`${v(t)}${a}/api/icons/${encodeURIComponent(s)}`}async function F(n=h()){const t=j(n),s=await fetch(`${v(n)}${t}/api/icons`);if(!s.ok)throw new Error(`图标列表加载失败 (${s.status})`);const a=await s.json();return(Array.isArray(a)?a:a.files||[]).filter(c=>typeof c=="string"&&c.toLowerCase().endsWith(".png"))}async function _(n,t=h()){const s=n!=null&&n.length?n:await F(t);return await Promise.all(s.map(a=>new Promise(i=>{const c=new Image;c.onload=()=>i(),c.onerror=()=>i(),c.src=D(a,t)}))),s}function U(n,t,s){if(!n)return;let a="*";try{a=new URL(t,window.location.href).origin}catch{}try{n.postMessage(s,a)}catch{try{n.postMessage(s,"*")}catch{}}}function L(n){if(!n||typeof n!="object")return null;const t=n,s=[t,t.data,t.location,t.coord,t.coordinates,t.metaData];if(typeof t.metaData=="string")try{s.push(JSON.parse(t.metaData))}catch{}for(const a of s){if(!a||typeof a!="object")continue;const i=a,c=Number(i.x??i.lng??i.longitude??i.lon),m=Number(i.y??i.lat??i.latitude),d=Number(i.z??i.height??i.alt??i.altitude??0);if(Number.isFinite(c)&&Number.isFinite(m))return{x:c,y:m,z:Number.isFinite(d)?d:0}}return null}function A(n){if(!n||typeof n!="object")return[];const t=n,s=[t];if(t.data&&typeof t.data=="object"&&s.push(t.data),t.metaData&&typeof t.metaData=="object"&&s.push(t.metaData),typeof t.metaData=="string")try{s.push(JSON.parse(t.metaData))}catch{}return t.params&&typeof t.params=="object"&&s.push(t.params),s}function O(n){const t={id:null,name:null,type:null,tag0:null,tag1:null,tag2:null,icon:null,eventId:null},s=A(n);if(!s.length)return t;const a=(...b)=>{for(const g of s)for(const T of b){const y=g[T];if(y!=null&&String(y).trim())return String(y).trim()}return null},i=a("id","markerId","objectId"),c=a("name","text","title","label"),m=a("text","title","label","name");return{id:i,name:c&&i&&c===i&&m&&m!==i?m:c,type:a("type","objectType"),tag0:a("tag0","Tag0"),tag1:a("tag1","Tag1"),tag2:a("tag2","Tag2"),icon:a("icon","url","Icon"),eventId:a("eventId")}}function W(n){const t=B(!1);function s(){var e;return((e=n.iframeRef.value)==null?void 0:e.contentWindow)??null}function a(){try{const e=s();return e?(e.location.href,e.$gisObj??null):null}catch{return null}}function i(e,o){const r=a(),u=r==null?void 0:r[e];if(typeof u=="function")try{if(e==="flyToBookmark"||e==="flyToViewpoint"||e==="flyToMarker"||e==="flyToHome"){const l=typeof o=="string"||typeof o=="number"?o:o&&typeof o=="object"?o.id??o.name??o.bookmarkName??o:o;u.call(r,l,o);return}u.call(r,o??{});return}catch{}U(s(),n.cloudUrl,{type:e,data:o??{},messageId:`web-${Date.now()}-${Math.random().toString(36).slice(2,8)}`})}const c=[];let m=!1;async function d(){if(m)return;const e=a();if(!e||typeof e.registerBookmarks!="function")return;let o="";try{o=new URL(n.cloudUrl,window.location.href).searchParams.get("scene")||""}catch{}if(!o)return;const r=[`/designer/data/scenes/${encodeURIComponent(o)}.json`,`/designer/api/embed/scenes/${encodeURIComponent(o)}`];for(const u of r)try{const l=await fetch(u);if(!l.ok)continue;const f=await l.json();if(!Array.isArray(f.bookmarks)||!f.bookmarks.length)continue;e.registerBookmarks(f.bookmarks),e._bookmarkFlyReady=!0,m=!0;return}catch{}}function b(e,o){const r=String(e||"").trim();if(!r)return;const u=()=>{const l=a();if(!l||typeof l.flyToBookmark!="function")return!1;try{const f=o?l.flyToBookmark(r,o):l.flyToBookmark(r);return f&&typeof f=="object"&&f.queued&&(c.includes(r)||c.push(r)),!0}catch{return!1}};u()||(c.includes(r)||c.push(r),i("flyToBookmark",r),d().then(()=>{u()}),window.setTimeout(()=>u(),600),window.setTimeout(()=>u(),2e3))}function g(){const e=c.splice(0,c.length);for(const o of e)b(o)}function T(e){i("setMapPickMode",{state:e,enable:e})}function y(e){const o=(e.icon||"").trim();return{id:e.id,name:e.name,text:e.text??e.name,showText:e.showText!==!1,type:e.type||"marker",x:e.x,y:e.y,z:e.z,scale:e.scale??1,scalex:e.scale??1,scaley:e.scale??1,scalez:e.scale??1,tag0:e.tag0||"camera",tag1:e.tag1||e.id,...o?{icon:o,url:o}:{}}}function x(e){i("addMarker",y(e))}function C(e){i("addMarkers",e.map(o=>y(o)))}function N(e){i("removeMarker",{id:e})}function P(){i("clearMarkers",{})}let I=!1;function E(){I||(I=!0,i("setMarkerVisible",{visible:!0}),i("showPOIs",{visible:!0}))}let M=!1;function w(){var e;if(M){c.length&&g();return}M=!0,t.value=!0,(e=n.onReady)==null||e.call(n),d().then(()=>g()),window.setTimeout(()=>{d().then(()=>g())},800),window.setTimeout(()=>g(),2500)}function R(e){var f;const o=e.data;if(!o||typeof o!="object")return;const r=String(o.type||""),u=o.data??o,l=u&&typeof u=="object"?u:null;if(!M&&l&&(l.bookmarksReady===!0||Array.isArray(l.bookmarks)&&l.bookmarks.length>0||typeof l.bookmarkCount=="number"&&l.bookmarkCount>0)&&w(),r==="sceneEmbedLoaded"||r==="sceneEmbedProgress"||r==="ueConnected"||r==="peerConnected"||r==="psConnected"||r==="streamPlaying"||r==="bookmarksReady"||r==="sceneBookmarks"){if(r==="sceneEmbedProgress"){const k=Number(l==null?void 0:l.percent);Number.isFinite(k)&&k>=100&&w();return}w();return}if(r==="mouseClick"||r==="mouseClickCallback"||r==="modelClick"||r==="modelClickCallback"||r==="selectMarker"||u&&typeof u=="object"&&u.eventId==="mouseClick"){const k=O(u);(f=n.onMouseClick)==null||f.call(n,{point:L(u),markerId:k.id||k.tag1||k.tag0,marker:k,raw:u})}}return window.addEventListener("message",R),$(()=>window.removeEventListener("message",R)),{ready:t,send:i,flyToBookmark:b,setPickMode:T,addMarker:x,addMarkers:C,removeMarker:N,clearMarkers:P,showScenePresets:E,markReady:w,flushPendingFlies:g}}export{h as d,F as f,_ as p,D as r,W as u};
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="JH-LAB">
<defs>
<linearGradient id="bg" x1="6" y1="2" x2="28" y2="30" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#123048"/>
<stop offset="55%" stop-color="#0a1624"/>
<stop offset="100%" stop-color="#050b14"/>
</linearGradient>
<linearGradient id="hex" x1="8" y1="6" x2="24" y2="26" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#7ADBFF"/>
<stop offset="100%" stop-color="#2BB8F0"/>
</linearGradient>
<radialGradient id="core" cx="50%" cy="42%" r="55%">
<stop offset="0%" stop-color="#6FF5C8"/>
<stop offset="70%" stop-color="#2EE6A8"/>
<stop offset="100%" stop-color="#1AB88A"/>
</radialGradient>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="1.1" result="b"/>
<feMerge>
<feMergeNode in="b"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 圆角底板,适配浏览器标签小尺寸 -->
<rect width="32" height="32" rx="7" fill="url(#bg)"/>
<rect x="0.75" y="0.75" width="30.5" height="30.5" rx="6.25" fill="none" stroke="#37C8FF" stroke-opacity="0.35" stroke-width="1"/>
<!-- 六边形晶体:实验室三维可视化 -->
<g filter="url(#glow)">
<path
d="M16 5.2 25.6 10.8v10.4L16 26.8 6.4 21.2V10.8L16 5.2Z"
fill="rgba(55,200,255,0.08)"
stroke="url(#hex)"
stroke-width="1.7"
stroke-linejoin="round"
/>
<path
d="M16 5.2v11.2M16 16.4 25.6 10.8M16 16.4 6.4 21.2"
stroke="#37C8FF"
stroke-width="1.15"
stroke-opacity="0.55"
stroke-linecap="round"
/>
<circle cx="16" cy="16.4" r="3.1" fill="url(#core)"/>
<circle cx="16" cy="16.4" r="1.15" fill="#E8FFF6" fill-opacity="0.9"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<meta name="theme-color" content="#0a1624" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>分子实验室三维可视化管理平台</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet" />
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
<script type="module" crossorigin src="/assets/index-BRA9HJ0O.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Yzi0d50E.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
JhLab 生产部署(192.168.32.110)
目录: D:\apps\jh-lab(无项目源码)
访问:
http://192.168.32.110/ 大屏与后台
http://192.168.32.110:3001/api/health
http://192.168.32.110:38080/ui/ 网关
账号 admin / Admin@123
桌面: 镜湖协议网关.lnk / 镜湖平台服务.lnk
自启: 计划任务 JhLabNative、JhLabGateway
说明: 本机无可用 Hypervisor,Docker Linux 引擎无法启动;
已用 Windows 原生 Node+Nginx+网关 部署生产包。images\ 内为 Docker 镜像备用。
$ErrorActionPreference = 'Continue'
$docker = 'C:\Program Files\Docker\Docker\resources\bin\docker.exe'
Write-Host '=== docker version ==='
& $docker version 2>&1 | Out-String | Write-Host
Write-Host '=== docker info error ==='
& $docker info 2>&1 | Select-Object -First 40 | Out-String | Write-Host
Write-Host '=== wsl ==='
where.exe wsl 2>&1 | Write-Host
Write-Host '=== optional features ==='
try {
Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux |
Format-List FeatureName, State | Out-String | Write-Host
Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform |
Format-List FeatureName, State | Out-String | Write-Host
} catch {
Write-Host $_.Exception.Message
}
Write-Host '=== done ==='
$ErrorActionPreference = 'Continue'
Write-Host '=== process ==='
Get-Process JhLabGateway -ErrorAction SilentlyContinue | Format-List Id, StartTime, Path
Write-Host '=== netstat ==='
netstat -ano | findstr 38080
Write-Host '=== logs ==='
Get-Content D:\apps\jh-lab\gateway\out.log -ErrorAction SilentlyContinue
Get-Content D:\apps\jh-lab\gateway\err.log -ErrorAction SilentlyContinue
Write-Host '=== curl ==='
try {
$r = Invoke-WebRequest 'http://127.0.0.1:38080/health' -UseBasicParsing -TimeoutSec 5
Write-Host $r.StatusCode
Write-Host $r.Content
} catch {
Write-Host $_.Exception.Message
}
# restart foreground test briefly
Write-Host '=== restart ==='
Stop-Process -Name JhLabGateway -Force -ErrorAction SilentlyContinue
Start-Sleep 1
$p = Start-Process -FilePath 'D:\apps\jh-lab\gateway\JhLabGateway.exe' -ArgumentList 'config.json' -WorkingDirectory 'D:\apps\jh-lab\gateway' -PassThru -WindowStyle Normal
Start-Sleep 5
Write-Host ("pid=" + $p.Id + " hasExited=" + $p.HasExited)
netstat -ano | findstr 38080
try {
(Invoke-WebRequest 'http://127.0.0.1:38080/health' -UseBasicParsing -TimeoutSec 5).Content
} catch { $_.Exception.Message }
Get-Content D:\apps\jh-lab\gateway\out.log -Tail 30 -ErrorAction SilentlyContinue
# Enable WSL / Virtual Machine Platform / Hypervisor for Docker Desktop
$ErrorActionPreference = 'Continue'
$log = 'D:\apps\jh-lab\enable-virt.log'
function Log($m) {
$line = "$(Get-Date -Format o) $m"
Add-Content -Path $log -Value $line -Encoding UTF8
Write-Host $line
}
Log '=== enable-virt-full start ==='
# Restore OpenSSH Server so remote management works after reboot
Log 'Ensure OpenSSH Server installed/started'
try {
$cap = Get-WindowsCapability -Online | Where-Object { $_.Name -like 'OpenSSH.Server*' }
Log ("OpenSSH.Server state=" + ($cap | ForEach-Object { "$($_.Name)=$($_.State)" } | Out-String).Trim())
if ($cap -and $cap.State -ne 'Installed') {
Add-WindowsCapability -Online -Name $cap.Name | Out-Null
Log 'OpenSSH.Server capability installed'
}
} catch { Log ("OpenSSH capability: " + $_.Exception.Message) }
try {
if (-not (Get-Service sshd -ErrorAction SilentlyContinue)) {
# fallback: optional feature path on some builds
Log 'sshd service missing after capability check'
}
Set-Service -Name sshd -StartupType Automatic -ErrorAction SilentlyContinue
Start-Service sshd -ErrorAction SilentlyContinue
# firewall
if (-not (Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | Out-Null
}
Log ("sshd status=" + (Get-Service sshd -ErrorAction SilentlyContinue).Status)
} catch { Log ("sshd start: " + $_.Exception.Message) }
Log '=== CPU / Hypervisor ==='
try {
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1 Name, VirtualizationFirmwareEnabled, SecondLevelAddressTranslationExtensions
Log ("CPU=" + $cpu.Name + " VirtFirmware=" + $cpu.VirtualizationFirmwareEnabled + " SLAT=" + $cpu.SecondLevelAddressTranslationExtensions)
} catch { Log $_.Exception.Message }
try {
$ci = Get-ComputerInfo | Select-Object HyperVisorPresent, HyperVRequirementVirtualizationFirmwareEnabled, HyperVRequirementSecondLevelAddressTranslation, HyperVRequirementVMMonitorModeExtensions
Log ($ci | Format-List | Out-String)
} catch { Log $_.Exception.Message }
Log 'systeminfo Hyper-V section:'
systeminfo.exe | findstr /i "Hyper-V" | ForEach-Object { Log $_ }
Log 'bcdedit: set hypervisorlaunchtype Auto'
bcdedit /set hypervisorlaunchtype Auto 2>&1 | ForEach-Object { Log $_ }
# Dump matching optional features
Log '=== optional features (match) ==='
$featOut = 'D:\apps\jh-lab\features-virt.txt'
try {
Get-WindowsOptionalFeature -Online |
Where-Object { $_.FeatureName -match 'Hyper|Virtual|Subsystem|Container|Hypervisor' } |
Select-Object FeatureName, State |
Tee-Object -FilePath $featOut |
ForEach-Object { Log ("FEATURE $($_.FeatureName)=$($_.State)") }
} catch {
Log ("Get-WindowsOptionalFeature failed: " + $_.Exception.Message)
dism.exe /online /get-features /format:table /english > $featOut 2>&1
}
$wanted = @(
'Microsoft-Windows-Subsystem-Linux',
'VirtualMachinePlatform',
'HypervisorPlatform',
'Microsoft-Hyper-V-All',
'Microsoft-Hyper-V',
'Microsoft-Hyper-V-Tools-All',
'Microsoft-Hyper-V-Management-PowerShell',
'Microsoft-Hyper-V-Management-Clients',
'Containers-DisposableClientVM'
)
$restartNeeded = $false
foreach ($name in $wanted) {
Log "Enable-WindowsOptionalFeature $name"
try {
$cur = Get-WindowsOptionalFeature -Online -FeatureName $name -ErrorAction Stop
Log (" current State=" + $cur.State)
if ($cur.State -ne 'Enabled') {
$r = Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart -ErrorAction Stop
Log (" RestartNeeded=" + $r.RestartNeeded)
if ($r.RestartNeeded) { $restartNeeded = $true }
}
} catch {
Log (" optional fail: " + $_.Exception.Message)
Log " try dism /english $name"
$d = & dism.exe /online /enable-feature /featurename:$name /all /norestart /english 2>&1 | Out-String
Log $d
if ($d -match 'Restart|重新启动|重启') { $restartNeeded = $true }
}
}
Log '=== WSL install/update ==='
try {
$wslOut = & wsl.exe --install --no-distribution --web-download 2>&1 | Out-String
Log $wslOut
} catch { Log ("wsl --install: " + $_.Exception.Message) }
try {
$wslOut = & wsl.exe --update --web-download 2>&1 | Out-String
Log $wslOut
} catch { Log ("wsl --update: " + $_.Exception.Message) }
try {
& wsl.exe --set-default-version 2 2>&1 | ForEach-Object { Log $_ }
} catch { Log $_.Exception.Message }
# Download WSL kernel MSI as fallback
$msi = 'D:\apps\jh-lab\wsl_update_x64.msi'
if (-not (Test-Path $msi)) {
Log 'Download WSL kernel update MSI'
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri 'https://wslstorestorage.blob.core.windows.net/wslblob/wsl_update_x64.msi' -OutFile $msi -UseBasicParsing -TimeoutSec 180
Log 'downloaded wsl_update_x64.msi'
} catch { Log ("download fail: " + $_.Exception.Message) }
}
if (Test-Path $msi) {
Log 'Install wsl_update_x64.msi'
Start-Process msiexec.exe -ArgumentList '/i', $msi, '/qn', '/norestart' -Wait -NoNewWindow
}
# Schedule post-reboot: start sshd, docker, verify virt
$post = @'
$ErrorActionPreference = "Continue"
$log = "D:\apps\jh-lab\enable-virt-post.log"
function Log($m) { Add-Content $log "$(Get-Date -Format o) $m"; Write-Host $m }
Log "post start"
Start-Sleep 20
try { Set-Service sshd -StartupType Automatic; Start-Service sshd } catch {}
try { Start-Service ssh-agent -ErrorAction SilentlyContinue } catch {}
Log ("sshd=" + (Get-Service sshd -EA SilentlyContinue).Status)
Log ("HyperVisorPresent=" + (Get-ComputerInfo).HyperVisorPresent)
Get-WindowsOptionalFeature -Online |
Where-Object { $_.FeatureName -match "Hyper|Virtual|Subsystem" } |
ForEach-Object { Log ("$($_.FeatureName)=$($_.State)") }
bcdedit /enum | findstr /i hypervisor | ForEach-Object { Log $_ }
$dd = "C:\Program Files\Docker\Docker\Docker Desktop.exe"
if (Test-Path $dd) { Start-Process $dd; Log "started Docker Desktop" }
$docker = "C:\Program Files\Docker\Docker\resources\bin\docker.exe"
for ($i=0; $i -lt 60; $i++) {
& $docker info 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { Log "docker ready after $i"; break }
Start-Sleep 5
}
& $docker info 2>&1 | Out-File D:\apps\jh-lab\docker-info.txt -Encoding utf8
# keep native stack up meanwhile
if (Test-Path D:\apps\jh-lab\scripts\start-native-now.ps1) {
powershell -NoProfile -ExecutionPolicy Bypass -File D:\apps\jh-lab\scripts\start-native-now.ps1
}
Log "post done"
'@
Set-Content -Path 'D:\apps\jh-lab\scripts\enable-virt-post.ps1' -Value $post -Encoding UTF8
$tr = 'powershell -NoProfile -ExecutionPolicy Bypass -File D:\apps\jh-lab\scripts\enable-virt-post.ps1'
schtasks /Delete /TN JhLabEnableVirt /F 2>$null | Out-Null
schtasks /Create /TN JhLabEnableVirt /SC ONSTART /RU Administrator /RP 123 /RL HIGHEST /F /TR $tr /DELAY 0001:00 2>&1 | ForEach-Object { Log $_ }
Log ("restartNeeded=$restartNeeded")
Log 'Rebooting in 15 seconds to apply virtualization features...'
shutdown /r /t 15 /c "Enable WSL/Hypervisor for Docker"
Log '=== enable-virt-full queued reboot ==='
# 启用 WSL2 / 虚拟机平台(Docker Desktop Linux 引擎依赖)
$ErrorActionPreference = 'Continue'
Write-Host 'Enabling Windows features for Docker Desktop...'
$features = @(
'Microsoft-Windows-Subsystem-Linux',
'VirtualMachinePlatform',
'Microsoft-Hyper-V-All'
)
foreach ($f in $features) {
Write-Host "-> $f"
try {
$r = Enable-WindowsOptionalFeature -Online -FeatureName $f -All -NoRestart
Write-Host (" RestartNeeded=" + $r.RestartNeeded + " State will apply after reboot if needed")
} catch {
# Hyper-V 在部分 SKU 不可用,忽略
Write-Host (" skip: " + $_.Exception.Message)
}
}
# 尝试安装 WSL(Win11 可带默认发行版)
Write-Host 'Installing WSL...'
try {
& wsl.exe --install --no-distribution --web-download 2>&1 | Out-String | Write-Host
} catch {
Write-Host $_.Exception.Message
}
try {
& wsl.exe --update --web-download 2>&1 | Out-String | Write-Host
} catch {
Write-Host $_.Exception.Message
}
try {
& wsl.exe --set-default-version 2 2>&1 | Out-String | Write-Host
} catch {
Write-Host $_.Exception.Message
}
Write-Host '=== status ==='
& dism.exe /online /get-featureinfo /featurename:Microsoft-Windows-Subsystem-Linux | Select-String 'State'
& dism.exe /online /get-featureinfo /featurename:VirtualMachinePlatform | Select-String 'State'
Write-Host 'If RestartNeeded, reboot then start Docker Desktop again.'
Write-Host 'DONE'
$ErrorActionPreference = 'Continue'
$gw = 'D:\apps\jh-lab\gateway'
$desktop = [Environment]::GetFolderPath('Desktop')
@'
@echo off
cd /d D:\apps\jh-lab\gateway
taskkill /F /IM JhLabGateway.exe >nul 2>nul
ping -n 2 127.0.0.1 >nul
start "" JhLabGateway.exe config.json
'@ | Set-Content -Path (Join-Path $gw 'start-gateway.bat') -Encoding ASCII
# ensure running
if (-not (Get-Process JhLabGateway -ErrorAction SilentlyContinue)) {
Start-Process -FilePath (Join-Path $gw 'JhLabGateway.exe') -ArgumentList 'config.json' -WorkingDirectory $gw
}
# desktop shortcut
$shell = New-Object -ComObject WScript.Shell
$lnk = $shell.CreateShortcut((Join-Path $desktop '镜湖协议网关.lnk'))
$lnk.TargetPath = Join-Path $gw 'JhLabGateway.exe'
$lnk.Arguments = 'config.json'
$lnk.WorkingDirectory = $gw
$lnk.WindowStyle = 1
$lnk.Description = 'JhLab Protocol Gateway'
$lnk.Save()
schtasks /Delete /TN JhLabGateway /F 2>$null | Out-Null
schtasks /Create /TN JhLabGateway /SC ONLOGON /RL HIGHEST /F /TR (Join-Path $gw 'start-gateway.bat') | Out-Null
netsh advfirewall firewall delete rule name='JhLab Gateway 38080' 2>$null | Out-Null
netsh advfirewall firewall add rule name='JhLab Gateway 38080' dir=in action=allow protocol=TCP localport=38080 | Out-Null
netsh advfirewall firewall delete rule name='JhLab Web 80' 2>$null | Out-Null
netsh advfirewall firewall add rule name='JhLab Web 80' dir=in action=allow protocol=TCP localport=80 | Out-Null
netsh advfirewall firewall delete rule name='JhLab API 3001' 2>$null | Out-Null
netsh advfirewall firewall add rule name='JhLab API 3001' dir=in action=allow protocol=TCP localport=3001 | Out-Null
Write-Host 'Gateway finalized'
try { (Invoke-WebRequest 'http://127.0.0.1:38080/health' -UseBasicParsing).Content } catch { $_.Exception.Message }
Get-ChildItem $desktop -Filter '*网关*' | Format-Table Name, Length
$ErrorActionPreference = 'Continue'
$p = 'D:\apps\jh-lab\platform'
Write-Host 'before:'
Get-ChildItem $p | ForEach-Object { $_.Name }
if (Test-Path (Join-Path $p 'server')) {
Remove-Item (Join-Path $p 'server') -Recurse -Force
}
if (Test-Path (Join-Path $p 'server-run')) {
Rename-Item (Join-Path $p 'server-run') 'server'
}
Write-Host 'after:'
Get-ChildItem $p | ForEach-Object { $_.Name }
Write-Host 'server contents:'
Get-ChildItem (Join-Path $p 'server') | ForEach-Object { $_.Name }
& powershell -NoProfile -ExecutionPolicy Bypass -File 'D:\apps\jh-lab\scripts\install-native.ps1'
$ErrorActionPreference = 'Continue'
Write-Host 'Current HyperVisorPresent:'
(Get-ComputerInfo).HyperVisorPresent
Write-Host 'Set hypervisorlaunchtype auto'
bcdedit /set hypervisorlaunchtype auto 2>&1 | Out-String | Write-Host
Write-Host 'Try enable features via dism English names'
foreach ($f in @(
'Microsoft-Windows-Subsystem-Linux',
'VirtualMachinePlatform',
'Microsoft-Hyper-V',
'Microsoft-Hyper-V-All',
'Microsoft-Hyper-V-Tools-All',
'Microsoft-Hyper-V-Management-PowerShell'
)) {
Write-Host "dism enable $f"
dism.exe /online /enable-feature /featurename:$f /all /norestart 2>&1 | Select-String '操作|Error|State|完成|成功|失败|0x' | ForEach-Object { $_.Line }
}
Write-Host 'Install WSL appx if possible'
$ProgressPreference = 'SilentlyContinue'
try {
Invoke-WebRequest -Uri 'https://aka.ms/wsl-x64' -OutFile 'D:\apps\jh-lab\wsl.msi' -UseBasicParsing -TimeoutSec 120
Write-Host 'downloaded wsl.msi'
Start-Process msiexec.exe -ArgumentList '/i','D:\apps\jh-lab\wsl.msi','/qn','/norestart' -Wait
} catch {
Write-Host ("wsl download/install: " + $_.Exception.Message)
}
Write-Host 'Schedule continue after reboot'
$cmd = 'powershell -NoProfile -ExecutionPolicy Bypass -File D:\apps\jh-lab\scripts\post-reboot-install.ps1'
schtasks /Delete /TN JhLabPostReboot /F 2>$null | Out-Null
schtasks /Create /TN JhLabPostReboot /SC ONSTART /RU Administrator /RP 123 /RL HIGHEST /F /TR $cmd /DELAY 0001:00 2>&1 | Out-String | Write-Host
Write-Host 'Rebooting in 5 seconds...'
shutdown /r /t 5 /c "JhLab enable hypervisor for Docker"
Write-Host 'DONE'
$ErrorActionPreference = 'Continue'
Write-Host '=== OS ==='
systeminfo | Select-String 'OS Name','OS Version','System Type','Hyper-V'
Write-Host '=== edition ==='
(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').ProductName
(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').EditionID
(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').DisplayVersion
Write-Host '=== DISM features (subset) ==='
dism.exe /online /get-features /format:table 2>&1 |
Select-String -Pattern 'Subsystem|VirtualMachine|Hyper-V|Container' |
ForEach-Object { $_.Line }
Write-Host '=== virt ==='
Get-ComputerInfo -Property HyperV*,OsName,WindowsEditionId -ErrorAction SilentlyContinue |
Format-List | Out-String | Write-Host
Write-Host 'DONE'
# 生产机:Docker Desktop 登录自启 + 网关计划任务 + 桌面快捷方式
$ErrorActionPreference = 'Stop'
$apps = 'D:\apps\jh-lab'
$gateway = Join-Path $apps 'gateway'
$desktop = [Environment]::GetFolderPath('Desktop')
$dockerExe = 'C:\Program Files\Docker\Docker\Docker Desktop.exe'
# 1) Docker Desktop 开机启动(当前用户 Run)
$runKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
if (Test-Path $dockerExe) {
Set-ItemProperty -Path $runKey -Name 'Docker Desktop' -Value "`"$dockerExe`" -Autostart"
Write-Host 'Docker Desktop autostart registered'
}
# 2) 登录后延迟拉起 Compose(等 Docker 引擎就绪)
$bootBat = Join-Path $apps 'scripts\boot-stack.bat'
@'
@echo off
set DOCKER=%ProgramFiles%\Docker\Docker\resources\bin\docker.exe
:wait
"%DOCKER%" info >nul 2>nul
if errorlevel 1 (
timeout /t 5 /nobreak >nul
goto wait
)
cd /d D:\apps\jh-lab
"%DOCKER%" compose --env-file .env -f docker-compose.yml up -d
call D:\apps\jh-lab\scripts\start-gateway.bat
'@ | Set-Content -Path $bootBat -Encoding ASCII
schtasks /Delete /TN JhLabStack /F 2>$null
schtasks /Create /TN JhLabStack /SC ONLOGON /RL HIGHEST /F /TR $bootBat | Out-Null
Write-Host 'Scheduled task JhLabStack (ONLOGON) created'
# 3) 网关桌面快捷方式
$shell = New-Object -ComObject WScript.Shell
$lnk = $shell.CreateShortcut((Join-Path $desktop '镜湖协议网关.lnk'))
$lnk.TargetPath = Join-Path $gateway 'JhLabGateway.exe'
$lnk.Arguments = 'config.json'
$lnk.WorkingDirectory = $gateway
$lnk.WindowStyle = 1
$lnk.Description = 'JhLab Windows Protocol Gateway'
$lnk.Save()
Write-Host "Desktop shortcut: $(Join-Path $desktop '镜湖协议网关.lnk')"
# 4) 防火墙放行
foreach ($rule in @(
@{ Name = 'JhLab Web 80'; Port = 80 },
@{ Name = 'JhLab API 3001'; Port = 3001 },
@{ Name = 'JhLab Gateway 38080'; Port = 38080 },
@{ Name = 'JhLab WebRTC 8889'; Port = 8889 }
)) {
netsh advfirewall firewall delete rule name="$($rule.Name)" 2>$null | Out-Null
netsh advfirewall firewall add rule name="$($rule.Name)" dir=in action=allow protocol=TCP localport=$($rule.Port) | Out-Null
}
Write-Host 'Firewall rules added'
Write-Host 'Done.'
$ErrorActionPreference = 'Continue'
$Root = 'D:\apps\jh-lab'
$gateway = Join-Path $Root 'gateway'
$desktop = [Environment]::GetFolderPath('Desktop')
# start gateway
Stop-Process -Name JhLabGateway -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
Start-Process -FilePath (Join-Path $gateway 'JhLabGateway.exe') -ArgumentList 'config.json' -WorkingDirectory $gateway -WindowStyle Minimized
# desktop shortcut
$shell = New-Object -ComObject WScript.Shell
$lnkPath = Join-Path $desktop '镜湖协议网关.lnk'
$lnk = $shell.CreateShortcut($lnkPath)
$lnk.TargetPath = Join-Path $gateway 'JhLabGateway.exe'
$lnk.Arguments = 'config.json'
$lnk.WorkingDirectory = $gateway
$lnk.WindowStyle = 1
$lnk.Description = 'JhLab Windows Protocol Gateway'
$lnk.Save()
Write-Host "Shortcut: $lnkPath"
# autostart gateway on logon
$bat = Join-Path $gateway 'start-gateway.bat'
schtasks /Delete /TN JhLabGateway /F 2>$null | Out-Null
schtasks /Create /TN JhLabGateway /SC ONLOGON /RL HIGHEST /F /TR $bat | Out-Null
Write-Host 'Scheduled task JhLabGateway created'
# firewall
netsh advfirewall firewall delete rule name='JhLab Gateway 38080' 2>$null | Out-Null
netsh advfirewall firewall add rule name='JhLab Gateway 38080' dir=in action=allow protocol=TCP localport=38080 | Out-Null
Start-Sleep -Seconds 3
try {
$r = Invoke-WebRequest 'http://127.0.0.1:38080/health' -UseBasicParsing -TimeoutSec 5
Write-Host ("health: " + $r.Content)
} catch { Write-Host $_.Exception.Message }
Write-Host 'GATEWAY_ONLY_DONE'
# 无 Hypervisor 时的原生生产部署(无源码):Node + Nginx + 网关
$ErrorActionPreference = 'Continue'
$Root = 'D:\apps\jh-lab'
$Platform = Join-Path $Root 'platform'
$log = Join-Path $Root 'native-install.log'
function Log($m){ $s="$(Get-Date -Format o) $m"; Add-Content $log $s; Write-Host $s }
Log 'install-native start'
Set-Location $Platform
$npm = Join-Path $Platform 'node\npm.cmd'
$node = Join-Path $Platform 'node\node.exe'
if (-not (Test-Path $node)) { throw 'node.exe missing' }
Log 'npm install --omit=dev in server'
Push-Location (Join-Path $Platform 'server')
cmd /c "`"$npm`" install --omit=dev --registry=https://registry.npmmirror.com > `"$Root\npm-install.log`" 2>&1"
Log ("npm exit=" + $LASTEXITCODE)
Pop-Location
if (-not (Test-Path (Join-Path $Platform 'server\node_modules\express'))) {
Log 'ERROR: express not installed'
Get-Content (Join-Path $Root 'npm-install.log') -Tail 40 | ForEach-Object { Log $_ }
exit 1
}
taskkill /F /IM nginx.exe 2>$null | Out-Null
# 只杀本平台 node(避免误杀)
Get-CimInstance Win32_Process -Filter "Name='node.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -match 'jh-lab\\platform\\server' } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
Log 'start server'
Start-Process -FilePath 'cmd.exe' -ArgumentList '/c','start.bat' -WorkingDirectory (Join-Path $Platform 'server') -WindowStyle Minimized
Start-Sleep -Seconds 5
Log 'start nginx'
$nginxDir = Join-Path $Platform 'nginx'
# 确保日志目录
New-Item -ItemType Directory -Force -Path (Join-Path $nginxDir 'logs') | Out-Null
Push-Location $nginxDir
Start-Process -FilePath (Join-Path $nginxDir 'nginx.exe') -WorkingDirectory $nginxDir
Pop-Location
if (-not (Get-Process JhLabGateway -ErrorAction SilentlyContinue)) {
Start-Process -FilePath (Join-Path $Root 'gateway\JhLabGateway.exe') -ArgumentList 'config.json' -WorkingDirectory (Join-Path $Root 'gateway')
}
$boot = Join-Path $Root 'scripts\boot-native.bat'
@'
@echo off
call D:\apps\jh-lab\platform\start-platform.bat
call D:\apps\jh-lab\gateway\start-gateway.bat
'@ | Set-Content -Path $boot -Encoding ASCII
schtasks /Delete /TN JhLabNative /F 2>$null | Out-Null
schtasks /Create /TN JhLabNative /SC ONLOGON /RL HIGHEST /F /TR $boot | Out-Null
$desktop = [Environment]::GetFolderPath('Desktop')
$shell = New-Object -ComObject WScript.Shell
$lnk = $shell.CreateShortcut((Join-Path $desktop '镜湖平台服务.lnk'))
$lnk.TargetPath = Join-Path $Platform 'start-platform.bat'
$lnk.WorkingDirectory = $Platform
$lnk.WindowStyle = 1
$lnk.Description = 'Start JhLab platform'
$lnk.Save()
Start-Sleep -Seconds 6
Log 'health checks'
try { Log ((Invoke-WebRequest 'http://127.0.0.1:3001/api/health' -UseBasicParsing -TimeoutSec 8).Content) } catch { Log ("api: " + $_.Exception.Message) }
try { Log ("web status=" + (Invoke-WebRequest 'http://127.0.0.1/' -UseBasicParsing -TimeoutSec 8).StatusCode) } catch { Log ("web: " + $_.Exception.Message) }
try { Log ((Invoke-WebRequest 'http://127.0.0.1:38080/health' -UseBasicParsing -TimeoutSec 8).Content) } catch { Log ("gw: " + $_.Exception.Message) }
Log 'install-native DONE'
# 在 D:\apps\jh-lab 执行:导入镜像、启动 Compose、安装自启与桌面快捷方式
$ErrorActionPreference = 'Continue'
$Root = 'D:\apps\jh-lab'
$Docker = 'C:\Program Files\Docker\Docker\resources\bin\docker.exe'
if (-not (Test-Path $Docker)) { $Docker = 'docker' }
Set-Location $Root
Write-Host "=== wait docker ==="
for ($i = 0; $i -lt 60; $i++) {
& $Docker info 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { break }
Start-Sleep -Seconds 5
}
& $Docker version
if ($LASTEXITCODE -ne 0) { throw 'Docker engine not ready' }
Write-Host "=== load images ==="
Get-ChildItem (Join-Path $Root 'images\*.tar') | ForEach-Object {
Write-Host "loading $($_.Name) ..."
& $Docker load -i $_.FullName
}
Write-Host "=== compose up ==="
& $Docker compose --env-file .env -f docker-compose.yml up -d
& $Docker compose -f docker-compose.yml ps
Write-Host "=== gateway ==="
$gw = Join-Path $Root 'gateway'
Push-Location $gw
Stop-Process -Name JhLabGateway -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
Start-Process -FilePath (Join-Path $gw 'JhLabGateway.exe') -ArgumentList 'config.json' -WorkingDirectory $gw -WindowStyle Minimized
Pop-Location
Write-Host "=== autostart + desktop shortcut ==="
& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $Root 'scripts\install-autostart.ps1')
Write-Host "=== health ==="
Start-Sleep -Seconds 8
try { Invoke-WebRequest 'http://127.0.0.1:3001/api/health' -UseBasicParsing -TimeoutSec 10 | Select-Object -ExpandProperty Content } catch { $_.Exception.Message }
try { Invoke-WebRequest 'http://127.0.0.1:38080/health' -UseBasicParsing -TimeoutSec 10 | Select-Object -ExpandProperty Content } catch { $_.Exception.Message }
try { Invoke-WebRequest 'http://127.0.0.1/' -UseBasicParsing -TimeoutSec 10 | Select-Object StatusCode } catch { $_.Exception.Message }
Write-Host 'INSTALL DONE'
$ErrorActionPreference = 'Continue'
$out = 'D:\apps\jh-lab\features.txt'
dism.exe /online /get-features /format:table > $out
Write-Host '--- matching ---'
Select-String -Path $out -Pattern 'Hyper|Virtual|Subsystem|Container' | ForEach-Object { $_.Line }
Write-Host '--- systeminfo virt ---'
systeminfo.exe | findstr /i "Hyper-V"
Write-Host '--- bcdedit ---'
bcdedit /enum | findstr /i "hypervisor"
Write-Host 'DONE'
$ErrorActionPreference = 'Continue'
$log = 'D:\apps\jh-lab\post-reboot.log'
function Log($m) { $line = "$(Get-Date -Format o) $m"; Add-Content -Path $log -Value $line; Write-Host $line }
Log 'post-reboot start'
Start-Sleep -Seconds 30
# Start Docker Desktop
$dd = 'C:\Program Files\Docker\Docker\Docker Desktop.exe'
if (Test-Path $dd) {
Log 'starting Docker Desktop'
Start-Process $dd
}
# wait engine
$docker = 'C:\Program Files\Docker\Docker\resources\bin\docker.exe'
for ($i=0; $i -lt 90; $i++) {
& $docker info 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { Log "docker ready after $i"; break }
Start-Sleep -Seconds 5
}
& $docker info 2>&1 | Out-File -FilePath 'D:\apps\jh-lab\docker-info.txt' -Encoding utf8
if ($LASTEXITCODE -ne 0) {
Log 'Docker engine still not ready — Hypervisor may be unavailable on this host'
exit 1
}
Log 'run install-remote'
& powershell -NoProfile -ExecutionPolicy Bypass -File 'D:\apps\jh-lab\scripts\install-remote.ps1' *>> $log
Log 'post-reboot done'
schtasks /Delete /TN JhLabPostReboot /F 2>$null | Out-Null
# Repair Windows component store, then enable WSL/VMP/Hyper-V, restore sshd
$ErrorActionPreference = 'Continue'
$log = 'D:\apps\jh-lab\repair-virt.log'
function Log($m) {
$line = "$(Get-Date -Format o) $m"
Add-Content -Path $log -Value $line -Encoding UTF8
Write-Host $line
}
Log '=== repair-and-enable-virt start ==='
Log 'Edition / build'
try {
Get-ComputerInfo | Select-Object WindowsProductName, WindowsEditionId, WindowsVersion, OsBuildNumber, HyperVisorPresent, HyperVRequirementVirtualizationFirmwareEnabled |
Format-List | Out-String | ForEach-Object { Log $_ }
} catch { Log $_.Exception.Message }
Log 'bcdedit hypervisor'
bcdedit /enum '{current}' 2>&1 | ForEach-Object { Log $_ }
bcdedit /set hypervisorlaunchtype Auto 2>&1 | ForEach-Object { Log $_ }
# List ALL features containing keywords via dism english
$featFile = 'D:\apps\jh-lab\features-all.txt'
Log 'dism /get-features'
cmd /c "dism.exe /online /get-features /format:table /english > $featFile 2>&1"
Select-String -Path $featFile -Pattern 'Hyper|Virtual|Subsystem|Container|Hypervisor|WSL' -ErrorAction SilentlyContinue |
ForEach-Object { Log $_.Line }
# Capability list for OpenSSH / WSL
$capFile = 'D:\apps\jh-lab\capabilities.txt'
Log 'Get-WindowsCapability'
try {
Get-WindowsCapability -Online | Where-Object { $_.Name -match 'OpenSSH|WSL|Hyper|Virtual' } |
Tee-Object -FilePath $capFile |
ForEach-Object { Log ("CAP $($_.Name)=$($_.State)") }
} catch { Log ("capability list fail: " + $_.Exception.Message) }
# Attempt component store repair (may take long)
Log 'DISM RestoreHealth (may take several minutes)...'
$rh = cmd /c 'dism.exe /online /cleanup-image /restorehealth /english' 2>&1 | Out-String
Log $rh
Log 'SFC quick note: skipped unless restorehealth fails'
# Re-try enable via dism after repair
$names = @(
'Microsoft-Windows-Subsystem-Linux',
'VirtualMachinePlatform',
'HypervisorPlatform',
'Microsoft-Hyper-V-All',
'Containers-DisposableClientVM'
)
foreach ($n in $names) {
Log "enable $n"
$o = cmd /c "dism.exe /online /enable-feature /featurename:$n /all /norestart /english" 2>&1 | Out-String
Log $o
}
# Try enabling by scanning actual feature names from file
Log 'enable any matching feature names from features-all.txt'
if (Test-Path $featFile) {
$matches = Select-String -Path $featFile -Pattern 'Microsoft-Windows-Subsystem-Linux|VirtualMachinePlatform|HypervisorPlatform|Microsoft-Hyper-V' |
ForEach-Object { ($_.Line -split '\s+')[0] } | Select-Object -Unique
foreach ($n in $matches) {
if (-not $n) { continue }
Log "matched enable $n"
$o = cmd /c "dism.exe /online /enable-feature /featurename:$n /all /norestart /english" 2>&1 | Out-String
Log $o
}
}
# OpenSSH Server
Log 'Install OpenSSH.Server capability'
try {
$cap = Get-WindowsCapability -Online | Where-Object { $_.Name -like 'OpenSSH.Server*' } | Select-Object -First 1
Log ("found " + $cap.Name + " state=" + $cap.State)
if ($cap -and $cap.State -ne 'Installed') {
Add-WindowsCapability -Online -Name $cap.Name 2>&1 | Out-String | ForEach-Object { Log $_ }
}
} catch { Log $_.Exception.Message }
# dism capability fallback
cmd /c 'dism.exe /online /add-capability /capabilityname:OpenSSH.Server~~~~0.0.1.0 /english' 2>&1 | Out-String | ForEach-Object { Log $_ }
try {
Set-Service sshd -StartupType Automatic -ErrorAction Stop
Start-Service sshd -ErrorAction Stop
New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 -ErrorAction SilentlyContinue | Out-Null
Log ("sshd=" + (Get-Service sshd).Status)
} catch { Log ("sshd: " + $_.Exception.Message) }
# Install WSL from Microsoft (modern package)
Log 'Download/install WSL appx/msix bundle if possible'
$ProgressPreference = 'SilentlyContinue'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$wslDir = 'D:\apps\jh-lab\wsl-setup'
New-Item -ItemType Directory -Force -Path $wslDir | Out-Null
# Official installer script approach: download wsl.msi (new unified installer)
$wslMsi = Join-Path $wslDir 'wsl.msi'
try {
Invoke-WebRequest -Uri 'https://github.com/microsoft/WSL/releases/latest/download/wsl.5.0.0.0.x64.msi' -OutFile $wslMsi -UseBasicParsing -TimeoutSec 120
} catch {
Log ("github wsl msi: " + $_.Exception.Message)
try {
Invoke-WebRequest -Uri 'https://aka.ms/wslstorestorage/wslblob/wsl_update_x64.msi' -OutFile (Join-Path $wslDir 'wsl_update_x64.msi') -UseBasicParsing -TimeoutSec 120
} catch { Log $_.Exception.Message }
}
Get-ChildItem $wslDir -Filter *.msi | ForEach-Object {
Log ("msiexec " + $_.Name)
Start-Process msiexec.exe -ArgumentList '/i', $_.FullName, '/qn', '/norestart' -Wait -NoNewWindow
}
# Also try winget if present
if (Get-Command winget -ErrorAction SilentlyContinue) {
Log 'winget install WSL'
winget install --id Microsoft.WSL -e --accept-source-agreements --accept-package-agreements --silent 2>&1 | Out-String | ForEach-Object { Log $_ }
}
# Check wsl.exe locations
@(
'C:\Windows\System32\wsl.exe',
'C:\Windows\Sysnative\wsl.exe',
"$env:LOCALAPPDATA\Microsoft\WindowsApps\wsl.exe"
) | ForEach-Object {
Log ("exists wsl? $_ = " + (Test-Path $_))
}
Log 'Final HyperVisorPresent check'
try { Log ("HyperVisorPresent=" + (Get-ComputerInfo).HyperVisorPresent) } catch {}
# If features enabled needing reboot, reboot once more
$need = Select-String -Path $log -Pattern 'Restart Windows to complete|The operation completed successfully' -SimpleMatch -ErrorAction SilentlyContinue
Log 'If features were enabled, reboot may still be required.'
Log '=== repair-and-enable-virt done ==='
@echo off
cd /d D:\apps\jh-lab\gateway
taskkill /F /IM JhLabGateway.exe >nul 2>nul
timeout /t 1 /nobreak >nul
start "" /B JhLabGateway.exe config.json
echo Gateway started on http://127.0.0.1:38080
$ErrorActionPreference = 'Continue'
$env:Path = 'D:\apps\jh-lab\platform\node;' + $env:Path
# kill old nginx only
taskkill /F /IM nginx.exe 2>$null | Out-Null
Write-Host 'start server'
Start-Process -FilePath 'cmd.exe' -ArgumentList '/c','start.bat' -WorkingDirectory 'D:\apps\jh-lab\platform\server' -WindowStyle Minimized
Start-Sleep -Seconds 6
Write-Host 'start nginx'
New-Item -ItemType Directory -Force -Path 'D:\apps\jh-lab\platform\nginx\logs' | Out-Null
Push-Location 'D:\apps\jh-lab\platform\nginx'
Start-Process -FilePath '.\nginx.exe' -WorkingDirectory (Get-Location)
Pop-Location
Start-Sleep -Seconds 3
if (-not (Get-Process JhLabGateway -ErrorAction SilentlyContinue)) {
Start-Process -FilePath 'D:\apps\jh-lab\gateway\JhLabGateway.exe' -ArgumentList 'config.json' -WorkingDirectory 'D:\apps\jh-lab\gateway'
}
Write-Host '=== processes ==='
Get-Process node,nginx,JhLabGateway -ErrorAction SilentlyContinue | Format-Table Name, Id -AutoSize
Write-Host '=== health ==='
try { Write-Host ((Invoke-WebRequest 'http://127.0.0.1:3001/api/health' -UseBasicParsing -TimeoutSec 8).Content) } catch { Write-Host ('api: ' + $_.Exception.Message) }
try { Write-Host ('web: ' + (Invoke-WebRequest 'http://127.0.0.1/' -UseBasicParsing -TimeoutSec 8).StatusCode) } catch { Write-Host ('web: ' + $_.Exception.Message) }
try { Write-Host ((Invoke-WebRequest 'http://127.0.0.1:38080/health' -UseBasicParsing -TimeoutSec 8).Content) } catch { Write-Host ('gw: ' + $_.Exception.Message) }
# autostart + shortcuts
$boot = 'D:\apps\jh-lab\scripts\boot-native.bat'
@'
@echo off
call D:\apps\jh-lab\platform\start-platform.bat
call D:\apps\jh-lab\gateway\start-gateway.bat
'@ | Set-Content -Path $boot -Encoding ASCII
schtasks /Delete /TN JhLabNative /F 2>$null | Out-Null
schtasks /Create /TN JhLabNative /SC ONLOGON /RL HIGHEST /F /TR $boot | Out-Null
$desktop = [Environment]::GetFolderPath('Desktop')
$shell = New-Object -ComObject WScript.Shell
$lnk = $shell.CreateShortcut((Join-Path $desktop '镜湖平台服务.lnk'))
$lnk.TargetPath = 'D:\apps\jh-lab\platform\start-platform.bat'
$lnk.WorkingDirectory = 'D:\apps\jh-lab\platform'
$lnk.Save()
Write-Host 'DONE'
@echo off
cd /d D:\apps\jh-lab
set DOCKER=%ProgramFiles%\Docker\Docker\resources\bin\docker.exe
if not exist "%DOCKER%" set DOCKER=docker
"%DOCKER%" compose --env-file .env -f docker-compose.yml up -d
echo.
"%DOCKER%" compose -f docker-compose.yml ps
echo.
echo Web: http://192.168.32.110/
echo API: http://192.168.32.110:3001/api/health
echo Gateway: http://192.168.32.110:38080/ui/
@echo off
cd /d D:\apps\jh-lab
set DOCKER=%ProgramFiles%\Docker\Docker\resources\bin\docker.exe
if not exist "%DOCKER%" set DOCKER=docker
"%DOCKER%" compose -f docker-compose.yml down
taskkill /F /IM JhLabGateway.exe >nul 2>nul
echo Stopped.
$ErrorActionPreference = 'Continue'
$log = 'D:\apps\jh-lab\enable-docker.log'
function Log($m){ Add-Content $log "$(Get-Date -Format o) $m"; Write-Host $m }
Log ("HyperVisorPresent=" + (Get-ComputerInfo).HyperVisorPresent)
bcdedit /set hypervisorlaunchtype auto 2>&1 | Out-String | ForEach-Object { Log $_ }
# Enable features (ignore unknown)
foreach ($f in @('VirtualMachinePlatform','Microsoft-Windows-Subsystem-Linux','Microsoft-Hyper-V-All')) {
Log "enable $f"
$out = dism.exe /online /enable-feature /featurename:$f /all /norestart 2>&1 | Out-String
Log $out
}
# Register post-reboot docker install
$tr = 'powershell -NoProfile -ExecutionPolicy Bypass -File D:\apps\jh-lab\scripts\post-reboot-install.ps1'
schtasks /Delete /TN JhLabPostReboot /F 2>$null | Out-Null
schtasks /Create /TN JhLabPostReboot /SC ONSTART /RU SYSTEM /RL HIGHEST /F /TR $tr /DELAY 0002:00 2>&1 | Out-String | ForEach-Object { Log $_ }
Log 'Rebooting to enable hypervisor/Docker...'
shutdown /r /t 8 /c "JhLab: enable Hyper-V/WSL for Docker containers"
$ErrorActionPreference = 'Continue'
Write-Host '=== processes ==='
Get-Process node,nginx,JhLabGateway -ErrorAction SilentlyContinue | Format-Table Name,Id -AutoSize
if (-not (Get-Process node -ErrorAction SilentlyContinue)) {
Write-Host 'restarting server...'
Start-Process -FilePath 'cmd.exe' -ArgumentList '/c','start.bat' -WorkingDirectory 'D:\apps\jh-lab\platform\server' -WindowStyle Minimized
Start-Sleep 5
}
if (-not (Get-Process nginx -ErrorAction SilentlyContinue)) {
Write-Host 'restarting nginx...'
Push-Location 'D:\apps\jh-lab\platform\nginx'
Start-Process '.\nginx.exe' -WorkingDirectory (Get-Location)
Pop-Location
Start-Sleep 2
}
if (-not (Get-Process JhLabGateway -ErrorAction SilentlyContinue)) {
Start-Process 'D:\apps\jh-lab\gateway\JhLabGateway.exe' -ArgumentList 'config.json' -WorkingDirectory 'D:\apps\jh-lab\gateway'
Start-Sleep 2
}
Write-Host '=== health ==='
try { Write-Host ((Invoke-WebRequest 'http://127.0.0.1:3001/api/health' -UseBasicParsing -TimeoutSec 8).Content) } catch { Write-Host $_.Exception.Message }
try { Write-Host ('web=' + (Invoke-WebRequest 'http://127.0.0.1/' -UseBasicParsing -TimeoutSec 8).StatusCode) } catch { Write-Host $_.Exception.Message }
try { Write-Host ((Invoke-WebRequest 'http://127.0.0.1:38080/health' -UseBasicParsing -TimeoutSec 8).Content) } catch { Write-Host $_.Exception.Message }
try {
$login = Invoke-RestMethod -Uri 'http://127.0.0.1:3001/api/auth/login' -Method Post -ContentType 'application/json' -Body '{"username":"admin","password":"Admin@123"}'
$h = @{ Authorization = ('Bearer ' + $login.token) }
$devs = Invoke-RestMethod -Uri 'http://127.0.0.1:3001/api/devices' -Headers $h
$pts = Invoke-RestMethod -Uri 'http://127.0.0.1:3001/api/devices/points' -Headers $h
Write-Host ('devices=' + $devs.devices.Count)
Write-Host ('points=' + $pts.points.Count)
} catch {
Write-Host ('auth/devices: ' + $_.Exception.Message)
}
Write-Host '=== desktop ==='
Get-ChildItem ([Environment]::GetFolderPath('Desktop')) -Filter '*.lnk' | ForEach-Object { $_.Name }
Write-Host 'VERIFY_DONE'
...@@ -83,6 +83,9 @@ services: ...@@ -83,6 +83,9 @@ services:
- server - server
ports: ports:
- "${WEB_PORT:-18080}:80" - "${WEB_PORT:-18080}:80"
# 热更新 nginx 反代(/rtengine → 121:6088),避免旧镜像缺云渲染路由
volumes:
- ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
volumes: volumes:
pgdata: pgdata:
...@@ -6,9 +6,27 @@ ...@@ -6,9 +6,27 @@
"adminUsername": "admin", "adminUsername": "admin",
"adminPassword": "Admin@123", "adminPassword": "Admin@123",
"modbusHosts": [ "modbusHosts": [
{ "id": "mb-102", "name": "PAU-201-1(Windows 本机模拟)", "host": "127.0.0.1", "port": 11502, "enabled": true }, {
{ "id": "mb-103", "name": "计量/通风柜(Windows 本机模拟)", "host": "127.0.0.1", "port": 11503, "enabled": true }, "id": "mb-102",
{ "id": "mb-104", "name": "房间/冰箱(Windows 本机模拟)", "host": "127.0.0.1", "port": 11504, "enabled": true } "name": "PAU-201-1 (Mac sim)",
"host": "192.168.32.122",
"port": 1502,
"enabled": true
},
{
"id": "mb-103",
"name": "meter/VAV (Windows builtin)",
"host": "127.0.0.1",
"port": 1503,
"enabled": true
},
{
"id": "mb-104",
"name": "room/fridge (Mac sim)",
"host": "192.168.32.122",
"port": 1504,
"enabled": true
}
], ],
"nvr": { "nvr": {
"id": "nvr-140", "id": "nvr-140",
......
<script setup lang="ts">
import type { ScenePoint } from '@/composables/useRtengineBridge'
export type ScenePropertyField = {
label: string
value: string
tone?: 'ok' | 'warn' | 'muted'
}
const props = defineProps<{
open: boolean
title: string
subtitle?: string
kindLabel?: string
fields: ScenePropertyField[]
point?: ScenePoint | null
showPlay?: boolean
}>()
const emit = defineEmits<{
close: []
play: []
}>()
</script>
<template>
<Teleport to="body">
<div v-if="open" class="prop-mask" @click.self="emit('close')">
<aside class="prop-panel" role="dialog" :aria-label="title" @click.stop>
<header class="prop-head">
<div class="prop-titles">
<div class="kind" v-if="kindLabel">{{ kindLabel }}</div>
<h3>{{ title }}</h3>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
</div>
<button class="btn ghost" type="button" @click="emit('close')">关闭</button>
</header>
<div class="prop-body">
<div v-if="fields.length" class="fields">
<div v-for="f in fields" :key="f.label" class="field" :class="f.tone">
<span class="label">{{ f.label }}</span>
<b class="value">{{ f.value }}</b>
</div>
</div>
<div v-else class="empty">暂无属性</div>
<div v-if="point" class="coords">
<span>坐标</span>
<code>
X {{ point.x.toFixed(6) }} · Y {{ point.y.toFixed(6) }} · Z {{ point.z.toFixed(2) }}
</code>
</div>
</div>
<footer v-if="showPlay" class="prop-foot">
<button class="btn primary" type="button" @click="emit('play')">打开视频</button>
</footer>
</aside>
</div>
</Teleport>
</template>
<style scoped>
.prop-mask {
position: fixed;
inset: 0;
z-index: 320;
background: rgba(0, 8, 20, 0.35);
display: flex;
justify-content: flex-end;
align-items: stretch;
padding: 72px 18px 18px;
pointer-events: auto;
}
.prop-panel {
width: min(360px, 92vw);
max-height: calc(100vh - 90px);
display: flex;
flex-direction: column;
border-radius: 8px;
border: 1px solid rgba(0, 247, 255, 0.28);
background: linear-gradient(180deg, rgba(8, 28, 52, 0.98), rgba(3, 12, 28, 0.98));
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(10px);
overflow: hidden;
}
.prop-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 14px 12px;
border-bottom: 1px solid rgba(0, 247, 255, 0.14);
}
.kind {
display: inline-block;
margin-bottom: 4px;
padding: 1px 7px;
border-radius: 3px;
font-size: 10px;
letter-spacing: 0.08em;
color: #7cf0c4;
border: 1px solid rgba(124, 240, 196, 0.35);
background: rgba(124, 240, 196, 0.08);
}
.prop-titles h3 {
margin: 0;
font-size: 16px;
color: #f4feff;
line-height: 1.3;
}
.sub {
margin: 4px 0 0;
font-size: 12px;
color: rgba(185, 214, 240, 0.75);
}
.prop-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px 14px;
}
.fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.field {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-radius: 5px;
background: rgba(6, 28, 56, 0.55);
border: 1px solid rgba(0, 247, 255, 0.12);
}
.field .label {
flex-shrink: 0;
font-size: 11px;
color: rgba(158, 200, 220, 0.8);
}
.field .value {
text-align: right;
font-size: 13px;
font-weight: 600;
color: #e8fbff;
word-break: break-all;
}
.field.warn .value {
color: #ffd666;
}
.field.ok .value {
color: #7dff9a;
}
.field.muted .value {
color: rgba(185, 214, 240, 0.65);
font-weight: 500;
}
.empty {
padding: 24px 8px;
text-align: center;
font-size: 12px;
color: rgba(158, 200, 220, 0.55);
}
.coords {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed rgba(0, 247, 255, 0.14);
display: flex;
flex-direction: column;
gap: 4px;
}
.coords > span {
font-size: 10px;
color: rgba(158, 200, 220, 0.65);
}
.coords code {
font-size: 11px;
color: #9ec8dc;
word-break: break-all;
}
.prop-foot {
padding: 10px 14px 14px;
border-top: 1px solid rgba(0, 247, 255, 0.12);
}
.btn {
border: 1px solid rgba(0, 247, 255, 0.28);
background: rgba(8, 36, 68, 0.7);
color: #d7f7ff;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 12px;
}
.btn.ghost {
background: transparent;
}
.btn.primary {
width: 100%;
padding: 9px 12px;
border-color: rgba(0, 247, 255, 0.45);
background: linear-gradient(180deg, rgba(18, 96, 160, 0.85), rgba(8, 48, 96, 0.9));
color: #fff;
font-weight: 600;
}
.btn:hover {
border-color: rgba(0, 247, 255, 0.55);
}
</style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment