Commit 67c6cffb by 久林

Initial import of the Kiang Wu lab dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
parents
node_modules
**/node_modules
**/dist
.git
.cursor
**/.env
web/dist
server/data
*.log
agent-transcripts
_research_extract.txt
# ========== Docker / 本地共用 ==========
POSTGRES_DB=jh_lab
POSTGRES_USER=jh_lab
POSTGRES_PASSWORD=jh_lab_pass
POSTGRES_PORT=5432
# 服务连接串(本地开发直连本机 PG;Compose 内由 compose 覆盖为 @db)
DATABASE_URL=postgres://jh_lab:jh_lab_pass@127.0.0.1:5432/jh_lab
# Server
PORT=3001
SERVER_PORT=3001
WEB_PORT=8080
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=12h
DATA_MODE=mock
# DATA_MODE=modbus
# 采集数据保留天数(PostgreSQL)
COLLECT_RETENTION_DAYS=90
# Modbus gateways
MODBUS_PAU1_HOST=192.168.10.102
MODBUS_PAU2_HOST=192.168.10.103
MODBUS_ROOM_HOST=192.168.10.104
MODBUS_FRIDGE_HOST=192.168.10.105
MODBUS_PORT=502
MODBUS_POLL_MS=2000
# Access bridge (HCNetSDK Windows service), empty = mock
ACCESS_BRIDGE_URL=
# Default admin (seeded on first boot)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=Admin@123
# Web (Vite 开发用;容器部署走 Nginx 同域反代,构建时 VITE_API_BASE 留空)
VITE_API_BASE=
VITE_WS_URL=
VITE_VIEWPORT_MODE=glb
VITE_UE_STREAM_URL=
node_modules
dist
.DS_Store
*.log
.env
.env.local
server/data/*.db
server/data/*.json
web/dist
.vscode
.idea
# 分子实验室三维可视化管理平台
Vue 3 大屏 + Node 采集服务 + PostgreSQL 本地落库。一期使用 demo GLB 模型,预留 UE 推流;自控数据 Mock/Modbus;门禁预留海康 HCNetSDK 桥接;含用户管理、RBAC 与基础计费。
## 容器化部署(推荐)
依赖:Docker / Docker Compose。
```bash
cd dashboard
cp .env.example .env
# 按需修改 JWT_SECRET、数据库密码等
docker compose up -d --build
```
| 服务 | 地址 | 说明 |
|---|---|---|
| 大屏 Web | http://localhost:8080 | Nginx 静态资源,反代 `/api`、`/ws` |
| API | http://localhost:3001 | Node 采集与业务服务 |
| PostgreSQL | localhost:5432 | 用户、计费、全部采集快照 |
停止:
```bash
docker compose down
```
数据卷 `pgdata` 会保留数据库;彻底清空:
```bash
docker compose down -v
```
## 本地开发
1. 启动 PostgreSQL(可用 Compose 仅起库):
```bash
docker compose up -d db
```
2. 安装依赖并启动前后端:
```bash
npm install
npm run dev
```
- 前端:http://localhost:5173(Vite 代理 `/api`、`/ws`)
- 后端:http://localhost:3001
`DATABASE_URL` 默认:`postgres://jh_lab:jh_lab_pass@127.0.0.1:5432/jh_lab`
## 演示账号
| 用户 | 密码 | 角色 |
|---|---|---|
| admin | Admin@123 | 管理员(可写) |
| viewer | Viewer@123 | 只读 |
## 数据落库说明
启动时自动迁移表结构并写入种子数据。轮询采集的每一次快照都会写入:
- `collect_snapshots`:整包 JSON 快照
- `collect_samples`:房间 / VAV / 冷链 / 空调 / 计量 / 门禁 / 在线状态分项
- `billing_daily_usage`:当日计量汇总(供计费统计)
- `users` / `billing_tariff` / `write_audit_log`:用户、费率、写指令审计
默认保留最近 `COLLECT_RETENTION_DAYS`(90)天采集明细。
## 环境变量
见 `.env.example`。关键项:
- `DATABASE_URL` PostgreSQL 连接串
- `DATA_MODE=mock|modbus`
- `VITE_VIEWPORT_MODE=glb|ue-stream`
- `VITE_UE_STREAM_URL=` UE 推流地址
- `ACCESS_BRIDGE_URL=` 门禁桥接
## 目录
- `web/` 大屏前端
- `server/` 认证、用户、实时采集、计费、PG 落库
- `docker-compose.yml` 一键部署
- `docs/` 点位与门禁对接说明
services:
db:
image: postgres:16-alpine
container_name: jh-lab-db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-jh_lab}
POSTGRES_USER: ${POSTGRES_USER:-jh_lab}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-jh_lab_pass}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
server:
build:
context: .
dockerfile: server/Dockerfile
container_name: jh-lab-server
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PORT: 3001
DATABASE_URL: postgres://${POSTGRES_USER:-jh_lab}:${POSTGRES_PASSWORD:-jh_lab_pass}@db:5432/${POSTGRES_DB:-jh_lab}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-12h}
DATA_MODE: ${DATA_MODE:-mock}
MODBUS_PAU1_HOST: ${MODBUS_PAU1_HOST:-192.168.10.102}
MODBUS_PAU2_HOST: ${MODBUS_PAU2_HOST:-192.168.10.103}
MODBUS_ROOM_HOST: ${MODBUS_ROOM_HOST:-192.168.10.104}
MODBUS_FRIDGE_HOST: ${MODBUS_FRIDGE_HOST:-192.168.10.105}
MODBUS_PORT: ${MODBUS_PORT:-502}
MODBUS_POLL_MS: ${MODBUS_POLL_MS:-2000}
ACCESS_BRIDGE_URL: ${ACCESS_BRIDGE_URL:-}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-Admin@123}
COLLECT_RETENTION_DAYS: ${COLLECT_RETENTION_DAYS:-90}
ports:
- "${SERVER_PORT:-3001}:3001"
web:
build:
context: .
dockerfile: web/Dockerfile
args:
VITE_API_BASE: ""
VITE_VIEWPORT_MODE: ${VITE_VIEWPORT_MODE:-glb}
VITE_UE_STREAM_URL: ${VITE_UE_STREAM_URL:-}
container_name: jh-lab-web
restart: unless-stopped
depends_on:
- server
ports:
- "${WEB_PORT:-8080}:80"
volumes:
pgdata:
# 门禁 HCNetSDK 接入说明
## 协议来源
- `接口/HCNetSDKV6.1.10.10_build20250123_Win64_ZH_demosign(1).zip`
- 含 ClientDemo、`lib/win64`、设备网络 SDK 使用手册 `.chm`
- 门禁一体机快速入门 PDF 作设备侧参考
## 一期现状
- 大屏通过 Node 服务统一快照字段 `access.doors / todayPassCount / recentEvents`
- `POST /api/realtime/access/open` 远程开门(**仅 admin**),当前为 Mock 记日志
- 环境变量 `ACCESS_BRIDGE_URL` 为空时使用 Mock
## 二期桥接建议
HCNetSDK 为 Windows 原生 DLL,不能在浏览器或纯 Node 中直接调用。建议:
1. 在 Windows 采集机部署独立桥接进程(C++/C#),封装:
- 设备登录
- 门状态查询
- 通行事件订阅(报警回调)
- 远程控门
2. 桥接对外提供 HTTP/WS,例如:
- `GET /doors`
- `GET /events`
- `POST /doors/:id/open`
3. Node `server/src/access/adapter.ts` 将桥接数据写入统一快照
若门禁一体机开启 ISAPI,可优先用 HTTP 直连,SDK 作能力对照与备选。
## 与大屏门点对照(示例)
| 大屏 doorId | 名称 | 设备侧通道(待填) |
|---|---|---|
| d-main | 主入口门禁 | |
| d-pcr | PCR区门禁 | |
| d-sample | 样本制备门禁 | |
| d-lib | 文库制备门禁 | |
| d-seq | 测序区门禁 | |
| d-cold | 冷链库门禁 | |
# Modbus 点位与 UI 对照(摘要)
数据模式:`DATA_MODE=mock|modbus`(一期默认 mock)
| 网关 | IP | 主要用途 | UI 模块 |
|---|---|---|---|
| PAU-201-1 | 192.168.10.102 | 空调/排风启停、阀门、温湿度压力、PID | 空调排风、全景 HVAC |
| PAU-201-2 | 192.168.10.103 | 电能、冷量、通风柜 VAV | 计量计费、VAV |
| 房间 | 192.168.10.104 | 各房间温湿度压差、告警、阀门开度 | 房间压差、环境参数 |
| E栋冰箱 | 192.168.10.105 | 8 组冰箱温湿度(×0.1) | 冷链温度 |
倍率约定:温度等 Short/Word 常见 `*0.1`;面风速 `*0.001`。
写指令统一经 `POST /api/realtime/write`,需管理员 JWT。
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "jh-lab-dashboard",
"private": true,
"version": "0.1.0",
"workspaces": [
"web",
"server"
],
"scripts": {
"dev": "concurrently -n web,server -c cyan,magenta \"npm run dev -w web\" \"npm run dev -w server\"",
"dev:web": "npm run dev -w web",
"dev:server": "npm run dev -w server",
"build": "npm run build -w web",
"start": "npm run start -w server",
"docker:up": "docker compose up -d --build",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f --tail=200"
},
"devDependencies": {
"concurrently": "^9.1.2"
}
}
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
COPY server/package.json ./server/
COPY web/package.json ./web/
RUN npm install --workspace=server --include=dev
COPY server ./server
WORKDIR /app/server
ENV NODE_ENV=production
EXPOSE 3001
CMD ["npx", "tsx", "src/index.ts"]
{
"name": "server",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"modbus-serial": "^8.0.23",
"pg": "^8.23.1",
"tsx": "^4.19.3",
"ws": "^8.18.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22.13.10",
"@types/pg": "^8.23.1",
"@types/ws": "^8.18.0",
"typescript": "^5.8.2"
}
}
import { config } from '../config.js'
import type { AccessEvent, DoorSnapshot } from '../types.js'
/**
* 门禁适配器:ACCESS_BRIDGE_URL 有值时走 HCNetSDK Windows 桥接,否则 Mock。
* 二期由 realtime loop 调用 fetchAccessSnapshot() 合并进总快照。
*/
export async function fetchAccessSnapshot(): Promise<{
doors: DoorSnapshot[]
todayPassCount: number
recentEvents: AccessEvent[]
} | null> {
if (!config.accessBridgeUrl) return null
try {
const res = await fetch(`${config.accessBridgeUrl.replace(/\/$/, '')}/snapshot`)
if (!res.ok) throw new Error(`bridge ${res.status}`)
return (await res.json()) as {
doors: DoorSnapshot[]
todayPassCount: number
recentEvents: AccessEvent[]
}
} catch (e) {
console.warn('[access] bridge unavailable', e)
return null
}
}
import type { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { config } from '../config.js'
import type { Role } from '../types.js'
export interface AuthPayload {
sub: string
username: string
role: Role
displayName: string
}
declare global {
namespace Express {
interface Request {
user?: AuthPayload
}
}
}
export function signToken(payload: AuthPayload): string {
return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtExpiresIn } as jwt.SignOptions)
}
export function authRequired(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization
const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined
if (!token) {
res.status(401).json({ message: '未登录' })
return
}
try {
req.user = jwt.verify(token, config.jwtSecret) as AuthPayload
next()
} catch {
res.status(401).json({ message: '登录已失效' })
}
}
export function requireRole(...roles: Role[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
res.status(401).json({ message: '未登录' })
return
}
if (!roles.includes(req.user.role)) {
res.status(403).json({ message: '权限不足,仅管理员可操作' })
return
}
next()
}
}
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export const config = {
port: Number(process.env.PORT || 3001),
jwtSecret: process.env.JWT_SECRET || 'jh-lab-dev-secret',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '12h',
dataMode: (process.env.DATA_MODE || 'mock') as 'mock' | 'modbus',
databaseUrl:
process.env.DATABASE_URL ||
'postgres://jh_lab:jh_lab_pass@127.0.0.1:5432/jh_lab',
/** 采集明细保留天数,0 表示不清理 */
collectRetentionDays: Number(process.env.COLLECT_RETENTION_DAYS || 90),
modbus: {
pau1: process.env.MODBUS_PAU1_HOST || '192.168.10.102',
pau2: process.env.MODBUS_PAU2_HOST || '192.168.10.103',
room: process.env.MODBUS_ROOM_HOST || '192.168.10.104',
fridge: process.env.MODBUS_FRIDGE_HOST || '192.168.10.105',
port: Number(process.env.MODBUS_PORT || 502),
pollMs: Number(process.env.MODBUS_POLL_MS || 2000),
},
accessBridgeUrl: process.env.ACCESS_BRIDGE_URL || '',
adminUsername: process.env.ADMIN_USERNAME || 'admin',
adminPassword: process.env.ADMIN_PASSWORD || 'Admin@123',
dataDir: path.resolve(__dirname, '../data'),
usersFile: path.resolve(__dirname, '../data/users.json'),
videoFile: path.resolve(__dirname, '../data/video.json'),
lightFile: path.resolve(__dirname, '../data/light.json'),
}
import type { RealtimeSnapshot } from '../types.js'
import { config } from '../config.js'
import { pool, query } from './pool.js'
import { isPgEnabled } from './state.js'
/** 将一次采集快照完整入库(整包 + 分项),供本地追溯与计费汇总 */
export async function persistSnapshot(snap: RealtimeSnapshot) {
if (!isPgEnabled()) return
const ts = new Date(snap.ts || Date.now())
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query(
`INSERT INTO collect_snapshots (ts, payload) VALUES ($1, $2::jsonb)`,
[ts, JSON.stringify(snap)],
)
const rows: [Date, string, string | null, string][] = []
for (const r of snap.rooms) {
rows.push([ts, 'room', r.id, JSON.stringify(r)])
}
for (const v of snap.vav) {
rows.push([ts, 'vav', v.id, JSON.stringify(v)])
}
for (const f of snap.fridges) {
rows.push([ts, 'fridge', f.id, JSON.stringify(f)])
}
rows.push([ts, 'hvac', 'PAU201', JSON.stringify(snap.hvac)])
rows.push([ts, 'metering', 'lab', JSON.stringify(snap.metering)])
rows.push([ts, 'access', 'system', JSON.stringify(snap.access)])
rows.push([ts, 'online', 'gateway', JSON.stringify(snap.online)])
if (rows.length) {
const values: unknown[] = []
const places = rows
.map((row, i) => {
const o = i * 4
values.push(row[0], row[1], row[2], row[3])
return `($${o + 1}, $${o + 2}, $${o + 3}, $${o + 4}::jsonb)`
})
.join(',')
await client.query(
`INSERT INTO collect_samples (ts, source, device_id, payload) VALUES ${places}`,
values,
)
}
// 当日计量累计写入计费日表(取最新快照计量值作为日用量参考)
const day = ts.toISOString().slice(0, 10)
const power = snap.metering.powerKwh
const water = snap.metering.waterM3
const cooling = snap.metering.coolingKwh
await client.query(
`INSERT INTO billing_daily_usage (
usage_date, power_peak_kwh, power_flat_kwh, power_valley_kwh, water_m3, cooling_kwh, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,NOW())
ON CONFLICT (usage_date) DO UPDATE SET
power_peak_kwh = EXCLUDED.power_peak_kwh,
power_flat_kwh = EXCLUDED.power_flat_kwh,
power_valley_kwh = EXCLUDED.power_valley_kwh,
water_m3 = EXCLUDED.water_m3,
cooling_kwh = EXCLUDED.cooling_kwh,
updated_at = NOW()`,
[
day,
+(power * 0.28).toFixed(3),
+(power * 0.45).toFixed(3),
+(power * 0.27).toFixed(3),
water,
cooling,
],
)
await client.query('COMMIT')
} catch (e) {
await client.query('ROLLBACK')
console.error('[db] persistSnapshot failed', e)
} finally {
client.release()
}
}
export async function persistWriteLog(action: string, detail: Record<string, unknown>, byUser?: string) {
if (!isPgEnabled()) return
await query(
`INSERT INTO write_audit_log (action, detail, by_user) VALUES ($1, $2::jsonb, $3)`,
[action, JSON.stringify(detail), byUser || null],
)
}
/** 清理过期采集数据,默认保留 90 天 */
export async function pruneCollectData() {
if (!isPgEnabled()) return
const days = config.collectRetentionDays
if (days <= 0) return
await query(`DELETE FROM collect_samples WHERE ts < NOW() - ($1::text || ' days')::interval`, [
String(days),
])
await query(`DELETE FROM collect_snapshots WHERE ts < NOW() - ($1::text || ' days')::interval`, [
String(days),
])
}
import { pool, query } from './pool.js'
const SQL = `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'viewer')),
password_hash TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS billing_tariff (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
power_peak NUMERIC(12,4) NOT NULL,
power_flat NUMERIC(12,4) NOT NULL,
power_valley NUMERIC(12,4) NOT NULL,
water NUMERIC(12,4) NOT NULL,
cooling NUMERIC(12,4) NOT NULL,
service_fee_pct NUMERIC(8,4) NOT NULL,
tax_pct NUMERIC(8,4) NOT NULL,
settle_day INT NOT NULL CHECK (settle_day BETWEEN 1 AND 28),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system'
);
CREATE TABLE IF NOT EXISTS billing_daily_usage (
usage_date DATE PRIMARY KEY,
power_peak_kwh NUMERIC(14,3) NOT NULL DEFAULT 0,
power_flat_kwh NUMERIC(14,3) NOT NULL DEFAULT 0,
power_valley_kwh NUMERIC(14,3) NOT NULL DEFAULT 0,
water_m3 NUMERIC(14,3) NOT NULL DEFAULT 0,
cooling_kwh NUMERIC(14,3) NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS collect_samples (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL,
source TEXT NOT NULL,
device_id TEXT,
payload JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_collect_ts ON collect_samples (ts DESC);
CREATE INDEX IF NOT EXISTS idx_collect_source_ts ON collect_samples (source, ts DESC);
CREATE INDEX IF NOT EXISTS idx_collect_device_ts ON collect_samples (source, device_id, ts DESC);
CREATE TABLE IF NOT EXISTS collect_snapshots (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL,
payload JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshots_ts ON collect_snapshots (ts DESC);
CREATE TABLE IF NOT EXISTS write_audit_log (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
action TEXT NOT NULL,
detail JSONB NOT NULL DEFAULT '{}'::jsonb,
by_user TEXT
);
CREATE TABLE IF NOT EXISTS system_settings (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
collect_poll_ms INT NOT NULL DEFAULT 2000 CHECK (collect_poll_ms BETWEEN 500 AND 600000),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system'
);
`
export async function migrate() {
await query(SQL)
console.log('[db] schema migrated')
}
export async function closeDb() {
await pool.end()
}
import pg from 'pg'
import { config } from '../config.js'
import { isPgEnabled } from './state.js'
const { Pool } = pg
export const pool = new Pool({
connectionString: config.databaseUrl,
max: 10,
})
export async function query<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params?: unknown[],
) {
if (!isPgEnabled()) {
throw new Error('PostgreSQL 未启用')
}
return pool.query<T>(text, params)
}
export async function withClient<T>(fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect()
try {
return await fn(client)
} finally {
client.release()
}
}
/** 探测数据库是否可连接 */
export async function pingDb(): Promise<boolean> {
try {
const client = await pool.connect()
await client.query('SELECT 1')
client.release()
return true
} catch (e) {
console.warn('[db] ping failed:', e instanceof Error ? e.message : e)
return false
}
}
/** PostgreSQL 是否可用;不可用时走本地文件/内存降级,保证登录与演示可用 */
let pgEnabled = false
export function setPgEnabled(ok: boolean) {
pgEnabled = ok
}
export function isPgEnabled() {
return pgEnabled
}
import http from 'node:http'
import express from 'express'
import cors from 'cors'
import { WebSocketServer } from 'ws'
import { config } from './config.js'
import { migrate } from './db/migrate.js'
import { pingDb } from './db/pool.js'
import { setPgEnabled } from './db/state.js'
import { seedAdminIfNeeded } from './users/store.js'
import { ensureBillingSeed } from './billing/store.js'
import { ensureSettingsSeed } from './settings/store.js'
import { ensureVideoSeed } from './video/store.js'
import { ensureLightSeed } from './light/store.js'
import { authRouter } from './routes/auth.js'
import { usersRouter } from './routes/users.js'
import { realtimeRouter } from './routes/realtime.js'
import { billingRouter } from './routes/billing.js'
import { settingsRouter } from './routes/settings.js'
import { videoRouter } from './routes/video.js'
import { lightRouter } from './routes/light.js'
import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js'
async function bootstrap() {
const pgOk = await pingDb()
setPgEnabled(pgOk)
if (pgOk) {
await migrate()
console.log('[db] PostgreSQL 已连接,采集数据将落库')
} else {
console.warn(
'[db] PostgreSQL 未连接,已降级为本地文件/内存模式(登录可用)。\n' +
` 请启动数据库后重启服务,例如: docker compose up -d db\n` +
` 当前 DATABASE_URL=${config.databaseUrl.replace(/:[^:@/]+@/, ':****@')}`,
)
}
await seedAdminIfNeeded()
await ensureBillingSeed()
await ensureSettingsSeed()
ensureVideoSeed()
ensureLightSeed()
const app = express()
app.use(cors())
app.use(express.json({ limit: '2mb' }))
app.get('/api/health', (_req, res) => {
res.json({
ok: true,
mode: config.dataMode,
database: pgOk ? 'postgres' : 'fallback',
viewportHint: 'glb|ue-stream configured on web',
})
})
app.use('/api/auth', authRouter)
app.use('/api/users', usersRouter)
app.use('/api/realtime', realtimeRouter)
app.use('/api/billing', billingRouter)
app.use('/api/settings', settingsRouter)
app.use('/api/video', videoRouter)
app.use('/api/light', lightRouter)
const server = http.createServer(app)
const wss = new WebSocketServer({ server, path: '/ws' })
wss.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'snapshot', data: getSnapshot() }))
const unsub = subscribe((snap) => {
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify({ type: 'snapshot', data: snap }))
}
})
socket.on('close', () => unsub())
})
await startRealtimeLoop()
server.listen(config.port, () => {
console.log(`[server] http://localhost:${config.port}`)
console.log(`[server] dataMode=${config.dataMode}`)
console.log(`[server] database=${pgOk ? 'postgres' : 'fallback(file/memory)'}`)
console.log(`[server] default admin: ${config.adminUsername} / ${config.adminPassword}`)
})
}
bootstrap().catch((err) => {
console.error('[server] bootstrap failed', err)
process.exit(1)
})
import fs from 'node:fs'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
export type LightStatus = 'online' | 'offline' | 'fault'
export type LightProtocol = 'modbus' | 'http' | 'tcp'
export interface LightDevice {
id: string
name: string
zone: string
circuit: number
/** 开关状态 */
powerOn: boolean
status: LightStatus
enabled: boolean
brightness: number
remark: string
createdAt: string
updatedAt: string
lastSwitchAt: string | null
lastSwitchBy: string | null
}
export interface LightCommConfig {
host: string
port: number
protocol: LightProtocol
unitId: number
username: string
password: string
pollMs: number
enabled: boolean
updatedAt: string
updatedBy: string
}
export type PublicCommConfig = Omit<LightCommConfig, 'password'> & {
passwordSet: boolean
passwordMasked: string
}
interface LightData {
config: LightCommConfig
devices: LightDevice[]
}
const filePath = () => config.lightFile
function defaultConfig(): LightCommConfig {
return {
host: '192.168.10.120',
port: 502,
protocol: 'modbus',
unitId: 1,
username: '',
password: '',
pollMs: 3000,
enabled: true,
updatedAt: new Date().toISOString(),
updatedBy: 'system',
}
}
function seedDevices(): LightDevice[] {
const now = new Date().toISOString()
const rows: Omit<LightDevice, 'id' | 'createdAt' | 'updatedAt' | 'lastSwitchAt' | 'lastSwitchBy'>[] = [
{ name: 'PCR走廊照明', zone: 'PCR走廊', circuit: 1, powerOn: true, status: 'online', enabled: true, brightness: 80, remark: '' },
{ name: '样本制备区1照明', zone: '样本制备区1', circuit: 2, powerOn: true, status: 'online', enabled: true, brightness: 90, remark: '' },
{ name: '样本制备区2照明', zone: '样本制备区2', circuit: 3, powerOn: false, status: 'online', enabled: true, brightness: 0, remark: '' },
{ name: '文库制备区1照明', zone: '文库制备区1', circuit: 4, powerOn: true, status: 'online', enabled: true, brightness: 85, remark: '' },
{ name: '文库制备区2照明', zone: '文库制备区2', circuit: 5, powerOn: true, status: 'online', enabled: true, brightness: 80, remark: '' },
{ name: '测序区照明', zone: '测序区', circuit: 6, powerOn: true, status: 'online', enabled: true, brightness: 75, remark: '' },
{ name: '测序区缓冲照明', zone: '测序区缓冲', circuit: 7, powerOn: false, status: 'online', enabled: true, brightness: 0, remark: '' },
{ name: '机房照明', zone: '弱电/机房', circuit: 8, powerOn: true, status: 'fault', enabled: true, brightness: 60, remark: '回路反馈异常' },
]
return rows.map((r) => ({
...r,
id: randomUUID(),
createdAt: now,
updatedAt: now,
lastSwitchAt: null,
lastSwitchBy: null,
}))
}
function ensureDir() {
fs.mkdirSync(path.dirname(filePath()), { recursive: true })
}
function load(): LightData {
ensureDir()
if (!fs.existsSync(filePath())) {
const data: LightData = { config: defaultConfig(), devices: seedDevices() }
save(data)
return data
}
return JSON.parse(fs.readFileSync(filePath(), 'utf-8')) as LightData
}
function save(data: LightData) {
ensureDir()
fs.writeFileSync(filePath(), JSON.stringify(data, null, 2), 'utf-8')
}
export function ensureLightSeed() {
load()
}
function toPublicConfig(c: LightCommConfig): PublicCommConfig {
const { password, ...rest } = c
return {
...rest,
passwordSet: Boolean(password),
passwordMasked: password ? '********' : '',
}
}
export function getCommConfig(): PublicCommConfig {
return toPublicConfig(load().config)
}
export function updateCommConfig(
patch: Partial<{
host: string
port: number
protocol: LightProtocol
unitId: number
username: string
password: string
pollMs: number
enabled: boolean
}>,
byUser: string,
): PublicCommConfig {
const data = load()
const c = data.config
if (patch.host !== undefined) c.host = String(patch.host).trim()
if (patch.port !== undefined) c.port = Number(patch.port)
if (patch.protocol !== undefined) c.protocol = patch.protocol
if (patch.unitId !== undefined) c.unitId = Number(patch.unitId)
if (patch.username !== undefined) c.username = String(patch.username).trim()
if (patch.password !== undefined && String(patch.password).trim() && patch.password !== '********') {
c.password = String(patch.password)
}
if (patch.pollMs !== undefined) c.pollMs = Number(patch.pollMs)
if (patch.enabled !== undefined) c.enabled = Boolean(patch.enabled)
c.updatedAt = new Date().toISOString()
c.updatedBy = byUser
save(data)
return toPublicConfig(c)
}
export function testCommConfig(): { ok: boolean; message: string; latencyMs: number } {
const c = load().config
if (!c.enabled) return { ok: false, message: '照明通信配置已禁用', latencyMs: 0 }
if (!c.host || !c.port) {
return { ok: false, message: '请填写照明网关地址与端口', latencyMs: 0 }
}
const latencyMs = 30 + Math.floor(Math.random() * 70)
return {
ok: true,
message: `照明网关 ${c.host}:${c.port} 连通(${c.protocol.toUpperCase()} · Mock)`,
latencyMs,
}
}
export function listDevices(): LightDevice[] {
return load().devices.slice().sort((a, b) => a.circuit - b.circuit)
}
export function createDevice(input: {
name: string
zone: string
circuit: number
powerOn?: boolean
status?: LightStatus
enabled?: boolean
brightness?: number
remark?: string
}): LightDevice {
const data = load()
if (data.devices.some((d) => d.circuit === input.circuit)) {
throw new Error(`回路 ${input.circuit} 已存在`)
}
const now = new Date().toISOString()
const powerOn = input.powerOn === true
const device: LightDevice = {
id: randomUUID(),
name: input.name.trim(),
zone: input.zone.trim(),
circuit: Number(input.circuit),
powerOn,
status: input.status || 'online',
enabled: input.enabled !== false,
brightness: powerOn ? Math.min(100, Math.max(0, Number(input.brightness ?? 80))) : 0,
remark: (input.remark || '').trim(),
createdAt: now,
updatedAt: now,
lastSwitchAt: null,
lastSwitchBy: null,
}
data.devices.push(device)
save(data)
return device
}
export function updateDevice(
id: string,
patch: Partial<{
name: string
zone: string
circuit: number
status: LightStatus
enabled: boolean
brightness: number
remark: string
}>,
): LightDevice {
const data = load()
const idx = data.devices.findIndex((d) => d.id === id)
if (idx < 0) throw new Error('设备不存在')
if (patch.circuit !== undefined) {
const ch = Number(patch.circuit)
if (data.devices.some((d) => d.id !== id && d.circuit === ch)) {
throw new Error(`回路 ${ch} 已存在`)
}
data.devices[idx].circuit = ch
}
if (patch.name !== undefined) data.devices[idx].name = String(patch.name).trim()
if (patch.zone !== undefined) data.devices[idx].zone = String(patch.zone).trim()
if (patch.status !== undefined) data.devices[idx].status = patch.status
if (patch.enabled !== undefined) data.devices[idx].enabled = Boolean(patch.enabled)
if (patch.brightness !== undefined) {
data.devices[idx].brightness = Math.min(100, Math.max(0, Number(patch.brightness)))
}
if (patch.remark !== undefined) data.devices[idx].remark = String(patch.remark).trim()
data.devices[idx].updatedAt = new Date().toISOString()
save(data)
return data.devices[idx]
}
export function deleteDevice(id: string) {
const data = load()
if (!data.devices.some((d) => d.id === id)) throw new Error('设备不存在')
data.devices = data.devices.filter((d) => d.id !== id)
save(data)
}
/** 远程开关:一期 Mock 落库状态,二期走厂家接口 */
export function switchDevice(
id: string,
powerOn: boolean,
byUser: string,
): LightDevice {
const data = load()
if (!data.config.enabled) throw new Error('照明通信未启用,无法下发开关')
const idx = data.devices.findIndex((d) => d.id === id)
if (idx < 0) throw new Error('设备不存在')
const d = data.devices[idx]
if (!d.enabled) throw new Error('设备已禁用')
if (d.status === 'offline') throw new Error('设备离线,无法控制')
const now = new Date().toISOString()
d.powerOn = Boolean(powerOn)
d.brightness = d.powerOn ? (d.brightness > 0 ? d.brightness : 80) : 0
d.updatedAt = now
d.lastSwitchAt = now
d.lastSwitchBy = byUser
save(data)
return d
}
export function switchAll(powerOn: boolean, byUser: string): { count: number; devices: LightDevice[] } {
const data = load()
if (!data.config.enabled) throw new Error('照明通信未启用,无法下发开关')
const now = new Date().toISOString()
let count = 0
for (const d of data.devices) {
if (!d.enabled || d.status === 'offline') continue
d.powerOn = Boolean(powerOn)
d.brightness = d.powerOn ? (d.brightness > 0 ? d.brightness : 80) : 0
d.updatedAt = now
d.lastSwitchAt = now
d.lastSwitchBy = byUser
count += 1
}
save(data)
return { count, devices: listDevices() }
}
import { randomUUID } from 'node:crypto'
import type { RealtimeSnapshot } from '../types.js'
const ROOM_DEFS = [
{ id: 'recv', name: '收样咨询室' },
{ id: 'report', name: '生信分析报告室' },
{ id: 'reagent', name: '试剂准备间' },
{ id: 'pcr-corridor', name: 'PCR走廊' },
{ id: 'sample-1', name: '样本制备区1' },
{ id: 'sample-2', name: '样本制备区2' },
{ id: 'sample-buf', name: '样本制备区缓冲' },
{ id: 'lib-1', name: '文库制备区1' },
{ id: 'lib-1-buf', name: '文库制备区1缓冲' },
{ id: 'lib-2', name: '文库制备区2' },
{ id: 'lib-2-buf', name: '文库制备区2缓冲' },
{ id: 'seq', name: '测序区' },
{ id: 'seq-buf', name: '测序区缓冲' },
]
const DOORS = [
{ id: 'd-main', name: '主入口门禁' },
{ id: 'd-pcr', name: 'PCR区门禁' },
{ id: 'd-sample', name: '样本制备门禁' },
{ id: 'd-lib', name: '文库制备门禁' },
{ id: 'd-seq', name: '测序区门禁' },
{ id: 'd-cold', name: '冷链库门禁' },
]
function jitter(base: number, amp: number, digits = 1) {
const v = base + (Math.random() * 2 - 1) * amp
return Number(v.toFixed(digits))
}
let passCount = 86
const events: RealtimeSnapshot['access']['recentEvents'] = [
{
id: randomUUID(),
time: new Date(Date.now() - 8 * 60000).toISOString(),
doorId: 'd-main',
doorName: '主入口门禁',
person: '张工',
result: 'success',
},
{
id: randomUUID(),
time: new Date(Date.now() - 25 * 60000).toISOString(),
doorId: 'd-pcr',
doorName: 'PCR区门禁',
person: '李技师',
result: 'success',
},
]
export function generateSnapshot(): RealtimeSnapshot {
const rooms = ROOM_DEFS.map((r, i) => {
const pressure = jitter(-15 + (i % 5) * 1.2, 1.5)
const alarms: string[] = []
if (r.id === 'sample-1' && pressure > -5) alarms.push('压差异常')
return {
id: r.id,
name: r.name,
temp: jitter(22.5, 0.6),
humidity: jitter(48, 3, 0),
pressure,
alarms,
}
})
if (Math.random() < 0.08) {
passCount += 1
const door = DOORS[Math.floor(Math.random() * DOORS.length)]
events.unshift({
id: randomUUID(),
time: new Date().toISOString(),
doorId: door.id,
doorName: door.name,
person: ['张工', '李技师', '王护士', '访客'][Math.floor(Math.random() * 4)],
result: Math.random() < 0.9 ? 'success' : 'denied',
})
if (events.length > 30) events.length = 30
}
return {
ts: Date.now(),
rooms,
hvac: {
systemRunning: true,
supplyFanHz: jitter(42, 1.2),
exhaustPressure: jitter(320, 12, 0),
filterRemainPct: 68,
mode: 'auto',
supplyTemp: jitter(18.5, 0.4),
supplyHumidity: jitter(45, 2, 0),
supplyPressure: jitter(180, 8, 0),
freshAirTemp: jitter(26, 1.5),
alarms: rooms.some((r) => r.alarms.length) ? ['房间压差联动调频中'] : [],
},
vav: [
{
id: 'vav-lib1',
name: '文库制备1通风柜',
running: true,
faceVelocity: jitter(0.52, 0.04, 3),
sashHeight: jitter(350, 20, 0),
airflow: jitter(780, 40, 0),
valveOpen: jitter(62, 6, 0),
maxExhaust: Math.random() < 0.15,
minAirflow: Math.random() < 0.1,
alarmCode: Math.random() < 0.08 ? 1 : 0,
},
{
id: 'vav-lib2',
name: '文库制备2通风柜',
running: Math.random() > 0.1,
faceVelocity: jitter(0.48, 0.04, 3),
sashHeight: jitter(280, 20, 0),
airflow: jitter(720, 40, 0),
valveOpen: jitter(58, 6, 0),
maxExhaust: false,
minAirflow: Math.random() < 0.12,
alarmCode: 0,
},
],
fridges: Array.from({ length: 8 }, (_, i) => ({
id: `fridge-${i + 1}`,
name: `冰箱${i + 1}`,
temp: i === 2 ? jitter(-18.6, 0.3) : jitter(-20 + (i % 3), 0.4),
humidity: jitter(35, 4, 0),
})),
metering: {
powerKwh: jitter(486.2, 2),
waterM3: jitter(12.8, 0.2),
coolingKwh: jitter(1286, 8, 0),
powerTrendPct: 4.2,
waterTrendPct: -1.8,
coolingTrendPct: 6.5,
},
access: {
doors: DOORS.map((d, i) => ({
id: d.id,
name: d.name,
status: i === 5 && Math.random() < 0.05 ? 'alarm' : 'closed',
})),
todayPassCount: passCount,
recentEvents: [...events],
},
online: {
devicesOnline: 42,
devicesTotal: 45,
gatewayOk: true,
},
}
}
import { generateSnapshot } from '../mock/generator.js'
import type { RealtimeSnapshot } from '../types.js'
import { config } from '../config.js'
import { persistSnapshot, persistWriteLog, pruneCollectData } from '../db/collect.js'
import { getCollectPollMs } from '../settings/store.js'
let snapshot: RealtimeSnapshot = generateSnapshot()
const listeners = new Set<(s: RealtimeSnapshot) => void>()
let persistBusy = false
let pollTimer: ReturnType<typeof setInterval> | undefined
let currentPollMs = config.modbus.pollMs
export function getSnapshot() {
return snapshot
}
export function getCurrentPollMs() {
return currentPollMs
}
export function subscribe(fn: (s: RealtimeSnapshot) => void) {
listeners.add(fn)
return () => listeners.delete(fn)
}
function publish() {
for (const fn of listeners) fn(snapshot)
}
async function persistSafe(snap: RealtimeSnapshot) {
if (persistBusy) return
persistBusy = true
try {
await persistSnapshot(snap)
} finally {
persistBusy = false
}
}
function tick() {
if (config.dataMode === 'mock') {
snapshot = generateSnapshot()
} else {
// Modbus 实采二期接入:当前仍回落 mock,并标记网关状态
snapshot = generateSnapshot()
snapshot.online.gatewayOk = false
}
publish()
void persistSafe(snapshot)
}
function startPollTimer(ms: number) {
if (pollTimer) clearInterval(pollTimer)
currentPollMs = ms
pollTimer = setInterval(tick, currentPollMs)
console.log(`[realtime] collect poll = ${currentPollMs} ms`)
}
/** 运行时热更新采集频率 */
export async function setCollectPollMs(ms: number) {
startPollTimer(ms)
}
export async function startRealtimeLoop() {
const ms = await getCollectPollMs()
tick()
startPollTimer(ms)
// 每天清理一次过期采集数据
setInterval(() => void pruneCollectData(), 24 * 60 * 60 * 1000)
void pruneCollectData()
}
export function applyWriteLog(action: string, detail: Record<string, unknown>) {
console.log(`[WRITE] ${action}`, detail)
void persistWriteLog(action, detail, typeof detail.by === 'string' ? detail.by : undefined)
}
import { Router } from 'express'
import { signToken, authRequired } from '../auth/middleware.js'
import { toPublic, verifyLogin } from '../users/store.js'
export const authRouter = Router()
authRouter.post('/login', async (req, res) => {
try {
const { username, password } = req.body || {}
if (!username || !password) {
res.status(400).json({ message: '请输入用户名和密码' })
return
}
const user = await verifyLogin(String(username), String(password))
if (!user) {
res.status(401).json({ message: '用户名或密码错误' })
return
}
const token = signToken({
sub: user.id,
username: user.username,
role: user.role,
displayName: user.displayName,
})
res.json({ token, user: toPublic(user) })
} catch (e) {
console.error('[auth] login failed', e)
res.status(500).json({
message: e instanceof Error ? `登录失败:${e.message}` : '登录失败,请检查服务与数据库',
})
}
})
authRouter.get('/me', authRequired, (req, res) => {
res.json({
user: {
id: req.user!.sub,
username: req.user!.username,
displayName: req.user!.displayName,
role: req.user!.role,
},
})
})
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import {
getTariff,
updateTariff,
getStats,
getReport,
type TariffSettings,
} from '../billing/store.js'
export const billingRouter = Router()
billingRouter.get('/tariff', authRequired, async (_req, res) => {
res.json(await getTariff())
})
billingRouter.put('/tariff', authRequired, requireRole('admin'), async (req, res) => {
const body = (req.body || {}) as Partial<TariffSettings>
const numKeys: (keyof TariffSettings)[] = [
'powerPeak',
'powerFlat',
'powerValley',
'water',
'cooling',
'serviceFeePct',
'taxPct',
'settleDay',
]
const patch: Partial<TariffSettings> = {}
for (const k of numKeys) {
if (body[k] !== undefined) {
const n = Number(body[k])
if (!Number.isFinite(n) || n < 0) {
res.status(400).json({ message: `参数 ${k} 无效` })
return
}
;(patch as Record<string, number>)[k] = n
}
}
if (body.settleDay !== undefined) {
const d = Number(body.settleDay)
if (d < 1 || d > 28) {
res.status(400).json({ message: '结算日需在 1–28' })
return
}
}
if (typeof body.enabled === 'boolean') patch.enabled = body.enabled
const next = await updateTariff(patch, req.user!.username)
res.json(next)
})
billingRouter.get('/stats', authRequired, async (_req, res) => {
res.json(await getStats())
})
billingRouter.get('/report', authRequired, async (req, res) => {
const to = String(req.query.to || new Date().toISOString().slice(0, 10))
let from = String(req.query.from || '')
if (!from) {
const d = new Date(to + 'T12:00:00')
d.setDate(d.getDate() - 29)
from = d.toISOString().slice(0, 10)
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
res.status(400).json({ message: '日期格式应为 YYYY-MM-DD' })
return
}
if (from > to) {
res.status(400).json({ message: '开始日期不能晚于结束日期' })
return
}
const rows = await getReport(from, to)
const summary = {
from,
to,
days: rows.length,
powerKwh: +rows
.reduce((s, b) => s + b.powerPeakKwh + b.powerFlatKwh + b.powerValleyKwh, 0)
.toFixed(1),
waterM3: +rows.reduce((s, b) => s + b.waterM3, 0).toFixed(1),
coolingKwh: +rows.reduce((s, b) => s + b.coolingKwh, 0).toFixed(0),
powerCost: +rows.reduce((s, b) => s + b.powerCost, 0).toFixed(2),
waterCost: +rows.reduce((s, b) => s + b.waterCost, 0).toFixed(2),
coolingCost: +rows.reduce((s, b) => s + b.coolingCost, 0).toFixed(2),
serviceFee: +rows.reduce((s, b) => s + b.serviceFee, 0).toFixed(2),
tax: +rows.reduce((s, b) => s + b.tax, 0).toFixed(2),
total: +rows.reduce((s, b) => s + b.total, 0).toFixed(2),
}
res.json({ summary, rows })
})
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { applyWriteLog } from '../realtime/store.js'
import {
createDevice,
deleteDevice,
getCommConfig,
listDevices,
switchAll,
switchDevice,
testCommConfig,
updateCommConfig,
updateDevice,
type LightProtocol,
type LightStatus,
} from '../light/store.js'
export const lightRouter = Router()
lightRouter.get('/config', authRequired, (_req, res) => {
res.json(getCommConfig())
})
lightRouter.put('/config', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {}
const port = body.port !== undefined ? Number(body.port) : undefined
const unitId = body.unitId !== undefined ? Number(body.unitId) : undefined
const pollMs = body.pollMs !== undefined ? Number(body.pollMs) : undefined
if (port !== undefined && (!Number.isFinite(port) || port < 1 || port > 65535)) {
res.status(400).json({ message: '端口需在 1–65535' })
return
}
if (unitId !== undefined && (!Number.isFinite(unitId) || unitId < 0 || unitId > 247)) {
res.status(400).json({ message: 'Modbus 从站地址需在 0–247' })
return
}
if (pollMs !== undefined && (!Number.isFinite(pollMs) || pollMs < 500 || pollMs > 600000)) {
res.status(400).json({ message: '轮询周期需在 500–600000 ms' })
return
}
const protocol = body.protocol as LightProtocol | undefined
if (protocol && !['modbus', 'http', 'tcp'].includes(protocol)) {
res.status(400).json({ message: '协议无效' })
return
}
const cfg = updateCommConfig(
{
host: body.host,
port,
protocol,
unitId,
username: body.username,
password: body.password,
pollMs,
enabled: body.enabled,
},
req.user!.username,
)
res.json(cfg)
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '保存失败' })
}
})
lightRouter.post('/config/test', authRequired, requireRole('admin'), (_req, res) => {
res.json(testCommConfig())
})
lightRouter.get('/devices', authRequired, (_req, res) => {
res.json({ devices: listDevices() })
})
lightRouter.post('/devices', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {}
const name = String(body.name || '').trim()
const zone = String(body.zone || '').trim()
const circuit = Number(body.circuit)
if (!name || !zone) {
res.status(400).json({ message: '请填写名称与区域' })
return
}
if (!Number.isFinite(circuit) || circuit < 1 || circuit > 256) {
res.status(400).json({ message: '回路号需在 1–256' })
return
}
const device = createDevice({
name,
zone,
circuit,
powerOn: body.powerOn,
status: body.status as LightStatus | undefined,
enabled: body.enabled,
brightness: body.brightness,
remark: body.remark,
})
res.status(201).json({ device })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' })
}
})
lightRouter.patch('/devices/:id', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {}
const device = updateDevice(req.params.id, {
name: body.name,
zone: body.zone,
circuit: body.circuit !== undefined ? Number(body.circuit) : undefined,
status: body.status,
enabled: body.enabled,
brightness: body.brightness !== undefined ? Number(body.brightness) : undefined,
remark: body.remark,
})
res.json({ device })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' })
}
})
lightRouter.delete('/devices/:id', authRequired, requireRole('admin'), (req, res) => {
try {
deleteDevice(req.params.id)
res.json({ ok: true })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '删除失败' })
}
})
lightRouter.post('/devices/:id/switch', authRequired, requireRole('admin'), (req, res) => {
try {
const powerOn = Boolean(req.body?.powerOn ?? req.body?.on)
if (req.body?.powerOn === undefined && req.body?.on === undefined) {
res.status(400).json({ message: '请指定 powerOn / on' })
return
}
const device = switchDevice(req.params.id, powerOn, req.user!.username)
applyWriteLog('light_switch', {
deviceId: device.id,
name: device.name,
powerOn,
by: req.user!.username,
})
res.json({
ok: true,
device,
message: `${device.name} 已远程${powerOn ? '开启' : '关闭'}(Mock)`,
})
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '开关失败' })
}
})
lightRouter.post('/switch-all', authRequired, requireRole('admin'), (req, res) => {
try {
const powerOn = Boolean(req.body?.powerOn ?? req.body?.on)
if (req.body?.powerOn === undefined && req.body?.on === undefined) {
res.status(400).json({ message: '请指定 powerOn / on' })
return
}
const result = switchAll(powerOn, req.user!.username)
applyWriteLog('light_switch_all', {
powerOn,
count: result.count,
by: req.user!.username,
})
res.json({
ok: true,
count: result.count,
devices: result.devices,
message: `已批量${powerOn ? '开启' : '关闭'} ${result.count} 路照明(Mock)`,
})
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '批量开关失败' })
}
})
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { applyWriteLog, getSnapshot } from '../realtime/store.js'
export const realtimeRouter = Router()
realtimeRouter.get('/snapshot', authRequired, (_req, res) => {
res.json(getSnapshot())
})
realtimeRouter.post('/write', authRequired, requireRole('admin'), (req, res) => {
const { target, action, value } = req.body || {}
if (!target || !action) {
res.status(400).json({ message: '缺少 target/action' })
return
}
applyWriteLog(String(action), {
target,
value,
by: req.user!.username,
at: new Date().toISOString(),
})
res.json({ ok: true, message: '指令已受理(当前为 Mock/只记日志)' })
})
realtimeRouter.post('/access/open', authRequired, requireRole('admin'), (req, res) => {
const { doorId } = req.body || {}
if (!doorId) {
res.status(400).json({ message: '缺少 doorId' })
return
}
applyWriteLog('remote_open', { doorId, by: req.user!.username })
res.json({ ok: true, message: `门 ${doorId} 远程开门指令已下发(Mock)` })
})
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import {
getSettings,
updateCollectPollMs,
MIN_POLL_MS,
MAX_POLL_MS,
} from '../settings/store.js'
import { setCollectPollMs } from '../realtime/store.js'
export const settingsRouter = Router()
settingsRouter.get('/', authRequired, async (_req, res) => {
res.json(await getSettings())
})
settingsRouter.put('/collect-poll', authRequired, requireRole('admin'), async (req, res) => {
const ms = Number(req.body?.collectPollMs)
if (!Number.isFinite(ms)) {
res.status(400).json({ message: '请输入有效的采集周期(毫秒)' })
return
}
if (ms < MIN_POLL_MS || ms > MAX_POLL_MS) {
res.status(400).json({
message: `采集周期需在 ${MIN_POLL_MS}–${MAX_POLL_MS} ms 之间`,
})
return
}
const settings = await updateCollectPollMs(ms, req.user!.username)
await setCollectPollMs(settings.collectPollMs)
res.json(settings)
})
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { createUser, deleteUser, listUsers, updateUser } from '../users/store.js'
import type { Role } from '../types.js'
export const usersRouter = Router()
usersRouter.use(authRequired, requireRole('admin'))
usersRouter.get('/', async (_req, res) => {
res.json({ users: await listUsers() })
})
usersRouter.post('/', async (req, res) => {
try {
const { username, displayName, password, role } = req.body || {}
if (!username || !displayName || !password || !role) {
res.status(400).json({ message: '参数不完整' })
return
}
if (!['admin', 'viewer'].includes(role)) {
res.status(400).json({ message: '角色无效' })
return
}
const user = await createUser({
username: String(username),
displayName: String(displayName),
password: String(password),
role: role as Role,
})
res.status(201).json({ user })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' })
}
})
usersRouter.patch('/:id', async (req, res) => {
try {
const user = await updateUser(req.params.id, req.body || {})
res.json({ user })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' })
}
})
usersRouter.delete('/:id', async (req, res) => {
try {
await deleteUser(req.params.id)
res.json({ ok: true })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '删除失败' })
}
})
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import {
createDevice,
deleteDevice,
getCommConfig,
listDevices,
testCommConfig,
updateCommConfig,
updateDevice,
type DeviceStatus,
type DeviceType,
type StreamType,
type VideoProtocol,
} from '../video/store.js'
export const videoRouter = Router()
videoRouter.get('/config', authRequired, (_req, res) => {
res.json(getCommConfig())
})
videoRouter.put('/config', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {}
const port = body.port !== undefined ? Number(body.port) : undefined
const rtspPort = body.rtspPort !== undefined ? Number(body.rtspPort) : undefined
const previewTimeoutMs =
body.previewTimeoutMs !== undefined ? Number(body.previewTimeoutMs) : undefined
if (port !== undefined && (!Number.isFinite(port) || port < 1 || port > 65535)) {
res.status(400).json({ message: '海康设备端口需在 1–65535' })
return
}
if (rtspPort !== undefined && (!Number.isFinite(rtspPort) || rtspPort < 1 || rtspPort > 65535)) {
res.status(400).json({ message: 'RTSP 端口需在 1–65535' })
return
}
if (
previewTimeoutMs !== undefined &&
(!Number.isFinite(previewTimeoutMs) || previewTimeoutMs < 1000 || previewTimeoutMs > 60000)
) {
res.status(400).json({ message: '预览超时需在 1000–60000 ms' })
return
}
const protocol = body.protocol as VideoProtocol | undefined
if (protocol && !['sdk', 'rtsp', 'isapi', 'tcp'].includes(protocol)) {
res.status(400).json({ message: '协议无效(支持 HCNetSDK / RTSP / ISAPI)' })
return
}
const streamType = body.streamType as StreamType | undefined
if (streamType && !['main', 'sub'].includes(streamType)) {
res.status(400).json({ message: '码流类型无效' })
return
}
const cfg = updateCommConfig(
{
host: body.host,
port,
rtspPort,
username: body.username,
password: body.password,
protocol,
streamType,
previewTimeoutMs,
enabled: body.enabled,
},
req.user!.username,
)
res.json(cfg)
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '保存失败' })
}
})
videoRouter.post('/config/test', authRequired, requireRole('admin'), (_req, res) => {
res.json(testCommConfig())
})
videoRouter.get('/devices', authRequired, (_req, res) => {
res.json({ devices: listDevices() })
})
videoRouter.post('/devices', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {}
const name = String(body.name || '').trim()
const location = String(body.location || '').trim()
const channel = Number(body.channel)
const deviceType = body.deviceType as DeviceType
if (!name || !location) {
res.status(400).json({ message: '请填写名称与安装位置' })
return
}
if (!Number.isFinite(channel) || channel < 1 || channel > 256) {
res.status(400).json({ message: '通道号需在 1–256' })
return
}
if (!['ipc', 'nvr-ch'].includes(deviceType)) {
res.status(400).json({ message: '设备类型无效' })
return
}
const device = createDevice({
name,
channel,
location,
deviceType,
status: body.status as DeviceStatus | undefined,
enabled: body.enabled,
ip: body.ip,
remark: body.remark,
})
res.status(201).json({ device })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '创建失败' })
}
})
videoRouter.patch('/devices/:id', authRequired, requireRole('admin'), (req, res) => {
try {
const body = req.body || {}
const device = updateDevice(req.params.id, {
name: body.name,
channel: body.channel !== undefined ? Number(body.channel) : undefined,
location: body.location,
deviceType: body.deviceType,
status: body.status,
enabled: body.enabled,
ip: body.ip,
remark: body.remark,
})
res.json({ device })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '更新失败' })
}
})
videoRouter.delete('/devices/:id', authRequired, requireRole('admin'), (req, res) => {
try {
deleteDevice(req.params.id)
res.json({ ok: true })
} catch (e) {
res.status(400).json({ message: e instanceof Error ? e.message : '删除失败' })
}
})
import { config } from '../config.js'
import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js'
export interface SystemSettings {
collectPollMs: number
dataMode: string
collectRetentionDays: number
updatedAt: string
updatedBy: string
storage: 'postgres' | 'fallback'
}
const MIN_POLL_MS = 500
const MAX_POLL_MS = 600_000
export function clampPollMs(ms: number) {
if (!Number.isFinite(ms)) return config.modbus.pollMs
return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, Math.round(ms)))
}
let memPollMs = clampPollMs(config.modbus.pollMs)
let memUpdatedAt = new Date().toISOString()
let memUpdatedBy = 'system'
function base(extra: Partial<SystemSettings> = {}): SystemSettings {
return {
collectPollMs: memPollMs,
dataMode: config.dataMode,
collectRetentionDays: config.collectRetentionDays,
updatedAt: memUpdatedAt,
updatedBy: memUpdatedBy,
storage: isPgEnabled() ? 'postgres' : 'fallback',
...extra,
}
}
export async function ensureSettingsSeed() {
if (!isPgEnabled()) return
const { rows } = await query(`SELECT id FROM system_settings WHERE id = 1`)
if (rows.length) return
await query(
`INSERT INTO system_settings (id, collect_poll_ms, updated_by)
VALUES (1, $1, 'system')`,
[clampPollMs(config.modbus.pollMs)],
)
}
export async function getSettings(): Promise<SystemSettings> {
if (!isPgEnabled()) return base()
const { rows } = await query<{
collect_poll_ms: number
updated_at: Date
updated_by: string
}>(`SELECT collect_poll_ms, updated_at, updated_by FROM system_settings WHERE id = 1`)
const r = rows[0]
return {
collectPollMs: r ? Number(r.collect_poll_ms) : clampPollMs(config.modbus.pollMs),
dataMode: config.dataMode,
collectRetentionDays: config.collectRetentionDays,
updatedAt: r?.updated_at?.toISOString() || new Date().toISOString(),
updatedBy: r?.updated_by || 'system',
storage: 'postgres',
}
}
export async function getCollectPollMs(): Promise<number> {
const s = await getSettings()
return clampPollMs(s.collectPollMs)
}
export async function updateCollectPollMs(ms: number, by: string): Promise<SystemSettings> {
const next = clampPollMs(ms)
if (!isPgEnabled()) {
memPollMs = next
memUpdatedAt = new Date().toISOString()
memUpdatedBy = by
return base()
}
await query(
`UPDATE system_settings
SET collect_poll_ms = $1, updated_at = NOW(), updated_by = $2
WHERE id = 1`,
[next, by],
)
return getSettings()
}
export { MIN_POLL_MS, MAX_POLL_MS }
export type Role = 'admin' | 'viewer'
export interface UserRecord {
id: string
username: string
displayName: string
role: Role
passwordHash: string
enabled: boolean
createdAt: string
lastLoginAt?: string
}
export interface PublicUser {
id: string
username: string
displayName: string
role: Role
enabled: boolean
createdAt: string
lastLoginAt?: string
}
export interface RoomSnapshot {
id: string
name: string
temp: number
humidity: number
pressure: number
alarms: string[]
}
export interface VavSnapshot {
id: string
name: string
running: boolean
faceVelocity: number
sashHeight: number
airflow: number
/** 变风量阀开度 %(报价清单要求;协议无独立寄存器时由风量估算/Mock) */
valveOpen: number
maxExhaust: boolean
minAirflow: boolean
alarmCode: number
}
export interface FridgeSnapshot {
id: string
name: string
temp: number
humidity: number
}
export interface DoorSnapshot {
id: string
name: string
status: 'open' | 'closed' | 'alarm' | 'offline'
}
export interface AccessEvent {
id: string
time: string
doorId: string
doorName: string
person: string
result: 'success' | 'denied' | 'alarm'
}
export interface RealtimeSnapshot {
ts: number
rooms: RoomSnapshot[]
hvac: {
systemRunning: boolean
supplyFanHz: number
exhaustPressure: number
filterRemainPct: number
mode: 'auto' | 'manual'
supplyTemp: number
supplyHumidity: number
supplyPressure: number
freshAirTemp: number
alarms: string[]
}
vav: VavSnapshot[]
fridges: FridgeSnapshot[]
metering: {
powerKwh: number
waterM3: number
coolingKwh: number
powerTrendPct: number
waterTrendPct: number
coolingTrendPct: number
}
access: {
doors: DoorSnapshot[]
todayPassCount: number
recentEvents: AccessEvent[]
}
online: {
devicesOnline: number
devicesTotal: number
gatewayOk: boolean
}
}
import fs from 'node:fs'
import path from 'node:path'
import bcrypt from 'bcryptjs'
import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
import type { PublicUser, Role, UserRecord } from '../types.js'
function ensureDir() {
fs.mkdirSync(path.dirname(config.usersFile), { recursive: true })
}
function load(): UserRecord[] {
ensureDir()
if (!fs.existsSync(config.usersFile)) return []
return JSON.parse(fs.readFileSync(config.usersFile, 'utf-8')) as UserRecord[]
}
function save(users: UserRecord[]) {
ensureDir()
fs.writeFileSync(config.usersFile, JSON.stringify(users, null, 2), 'utf-8')
}
export function toPublic(u: UserRecord): PublicUser {
const { passwordHash: _, ...rest } = u
return rest
}
export async function seedAdminIfNeeded() {
const users = load()
if (users.some((u) => u.role === 'admin')) return
const passwordHash = await bcrypt.hash(config.adminPassword, 10)
users.push({
id: randomUUID(),
username: config.adminUsername,
displayName: '系统管理员',
role: 'admin',
passwordHash,
enabled: true,
createdAt: new Date().toISOString(),
})
users.push({
id: randomUUID(),
username: 'viewer',
displayName: '值班查看',
role: 'viewer',
passwordHash: await bcrypt.hash('Viewer@123', 10),
enabled: true,
createdAt: new Date().toISOString(),
})
save(users)
}
export function listUsers(): PublicUser[] {
return load().map(toPublic)
}
export function findByUsername(username: string): UserRecord | undefined {
return load().find((u) => u.username === username)
}
export function findById(id: string): UserRecord | undefined {
return load().find((u) => u.id === id)
}
export async function verifyLogin(username: string, password: string): Promise<UserRecord | null> {
const user = findByUsername(username)
if (!user || !user.enabled) return null
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) return null
const users = load()
const idx = users.findIndex((u) => u.id === user.id)
users[idx].lastLoginAt = new Date().toISOString()
save(users)
return users[idx]
}
export async function createUser(input: {
username: string
displayName: string
password: string
role: Role
}): Promise<PublicUser> {
const users = load()
if (users.some((u) => u.username === input.username)) throw new Error('用户名已存在')
const record: UserRecord = {
id: randomUUID(),
username: input.username,
displayName: input.displayName,
role: input.role,
passwordHash: await bcrypt.hash(input.password, 10),
enabled: true,
createdAt: new Date().toISOString(),
}
users.push(record)
save(users)
return toPublic(record)
}
export async function updateUser(
id: string,
patch: Partial<Pick<UserRecord, 'displayName' | 'role' | 'enabled'>> & { password?: string },
): Promise<PublicUser> {
const users = load()
const idx = users.findIndex((u) => u.id === id)
if (idx < 0) throw new Error('用户不存在')
if (patch.role === 'viewer' || patch.enabled === false) {
const admins = users.filter((u) => u.role === 'admin' && u.enabled && u.id !== id)
if (users[idx].role === 'admin' && admins.length === 0) {
throw new Error('不能禁用或降级最后一个管理员')
}
}
if (patch.displayName !== undefined) users[idx].displayName = patch.displayName
if (patch.role !== undefined) users[idx].role = patch.role
if (patch.enabled !== undefined) users[idx].enabled = patch.enabled
if (patch.password) users[idx].passwordHash = await bcrypt.hash(patch.password, 10)
save(users)
return toPublic(users[idx])
}
export function deleteUser(id: string) {
const users = load()
const target = users.find((u) => u.id === id)
if (!target) throw new Error('用户不存在')
const admins = users.filter((u) => u.role === 'admin' && u.enabled && u.id !== id)
if (target.role === 'admin' && admins.length === 0) {
throw new Error('不能删除最后一个管理员')
}
save(users.filter((u) => u.id !== id))
}
import bcrypt from 'bcryptjs'
import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js'
import type { PublicUser, Role, UserRecord } from '../types.js'
import * as file from './fileStore.js'
interface UserRow {
id: string
username: string
display_name: string
role: Role
password_hash: string
enabled: boolean
created_at: Date | string
last_login_at: Date | string | null
}
function toIso(v: Date | string | null | undefined): string | undefined {
if (v == null) return undefined
if (typeof v === 'string') return new Date(v).toISOString()
return v.toISOString()
}
function mapRow(r: UserRow): UserRecord {
return {
id: r.id,
username: r.username,
displayName: r.display_name,
role: r.role,
passwordHash: r.password_hash,
enabled: r.enabled,
createdAt: toIso(r.created_at) || new Date().toISOString(),
lastLoginAt: toIso(r.last_login_at),
}
}
export function toPublic(u: UserRecord): PublicUser {
return file.toPublic(u)
}
export async function seedAdminIfNeeded() {
if (!isPgEnabled()) return file.seedAdminIfNeeded()
const { rows } = await query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM users WHERE role = 'admin'`,
)
if (Number(rows[0]?.n || 0) > 0) return
const adminHash = await bcrypt.hash(config.adminPassword, 10)
const viewerHash = await bcrypt.hash('Viewer@123', 10)
await query(
`INSERT INTO users (id, username, display_name, role, password_hash, enabled)
VALUES ($1,$2,$3,'admin',$4,TRUE), ($5,'viewer','值班查看','viewer',$6,TRUE)`,
[
randomUUID(),
config.adminUsername,
'系统管理员',
adminHash,
randomUUID(),
viewerHash,
],
)
console.log('[db] seeded admin/viewer')
}
export async function listUsers(): Promise<PublicUser[]> {
if (!isPgEnabled()) return file.listUsers()
const { rows } = await query<UserRow>(`SELECT * FROM users ORDER BY created_at ASC`)
return rows.map((r) => toPublic(mapRow(r)))
}
export async function findByUsername(username: string): Promise<UserRecord | undefined> {
if (!isPgEnabled()) return file.findByUsername(username)
const { rows } = await query<UserRow>(`SELECT * FROM users WHERE username = $1`, [username])
return rows[0] ? mapRow(rows[0]) : undefined
}
export async function findById(id: string): Promise<UserRecord | undefined> {
if (!isPgEnabled()) return file.findById(id)
const { rows } = await query<UserRow>(`SELECT * FROM users WHERE id = $1`, [id])
return rows[0] ? mapRow(rows[0]) : undefined
}
export async function verifyLogin(username: string, password: string): Promise<UserRecord | null> {
if (!isPgEnabled()) return file.verifyLogin(username, password)
const user = await findByUsername(username)
if (!user || !user.enabled) return null
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) return null
await query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
return { ...user, lastLoginAt: new Date().toISOString() }
}
export async function createUser(input: {
username: string
displayName: string
password: string
role: Role
}): Promise<PublicUser> {
if (!isPgEnabled()) return file.createUser(input)
const exists = await findByUsername(input.username)
if (exists) throw new Error('用户名已存在')
const id = randomUUID()
const passwordHash = await bcrypt.hash(input.password, 10)
await query(
`INSERT INTO users (id, username, display_name, role, password_hash, enabled)
VALUES ($1,$2,$3,$4,$5,TRUE)`,
[id, input.username, input.displayName, input.role, passwordHash],
)
const created = await findById(id)
return toPublic(created!)
}
export async function updateUser(
id: string,
patch: Partial<Pick<UserRecord, 'displayName' | 'role' | 'enabled'>> & { password?: string },
): Promise<PublicUser> {
if (!isPgEnabled()) return file.updateUser(id, patch)
const current = await findById(id)
if (!current) throw new Error('用户不存在')
if (patch.role === 'viewer' || patch.enabled === false) {
const { rows } = await query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM users WHERE role='admin' AND enabled=TRUE AND id <> $1`,
[id],
)
if (current.role === 'admin' && Number(rows[0]?.n || 0) === 0) {
throw new Error('不能禁用或降级最后一个管理员')
}
}
const displayName = patch.displayName ?? current.displayName
const role = patch.role ?? current.role
const enabled = patch.enabled ?? current.enabled
const passwordHash = patch.password
? await bcrypt.hash(patch.password, 10)
: current.passwordHash
await query(
`UPDATE users SET display_name=$2, role=$3, enabled=$4, password_hash=$5 WHERE id=$1`,
[id, displayName, role, enabled, passwordHash],
)
const updated = await findById(id)
return toPublic(updated!)
}
export async function deleteUser(id: string) {
if (!isPgEnabled()) return file.deleteUser(id)
const target = await findById(id)
if (!target) throw new Error('用户不存在')
const { rows } = await query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM users WHERE role='admin' AND enabled=TRUE AND id <> $1`,
[id],
)
if (target.role === 'admin' && Number(rows[0]?.n || 0) === 0) {
throw new Error('不能删除最后一个管理员')
}
await query(`DELETE FROM users WHERE id = $1`, [id])
}
import fs from 'node:fs'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
export type DeviceStatus = 'online' | 'offline' | 'alarm'
export type DeviceType = 'ipc' | 'nvr-ch'
/** 海康:HCNetSDK / RTSP / ISAPI */
export type VideoProtocol = 'sdk' | 'rtsp' | 'isapi'
export type StreamType = 'main' | 'sub'
export interface VideoDevice {
id: string
name: string
channel: number
location: string
deviceType: DeviceType
status: DeviceStatus
enabled: boolean
ip: string
remark: string
createdAt: string
updatedAt: string
}
export interface VideoCommConfig {
/** 固定海康威视 */
vendor: 'hikvision'
host: string
/** HCNetSDK 设备服务端口,默认 8000 */
port: number
/** RTSP 端口,默认 554 */
rtspPort: number
username: string
/** 明文仅服务端持有;API 对外返回 masked */
password: string
protocol: VideoProtocol
streamType: StreamType
previewTimeoutMs: number
enabled: boolean
updatedAt: string
updatedBy: string
}
export type PublicCommConfig = Omit<VideoCommConfig, 'password'> & {
passwordSet: boolean
passwordMasked: string
}
interface VideoData {
config: VideoCommConfig
devices: VideoDevice[]
}
const filePath = () => config.videoFile
function defaultConfig(): VideoCommConfig {
return {
vendor: 'hikvision',
host: '192.168.10.200',
port: 8000,
rtspPort: 554,
username: 'admin',
password: 'Admin@123',
protocol: 'sdk',
streamType: 'sub',
previewTimeoutMs: 8000,
enabled: true,
updatedAt: new Date().toISOString(),
updatedBy: 'system',
}
}
function seedDevices(): VideoDevice[] {
const now = new Date().toISOString()
const rows: Omit<VideoDevice, 'id' | 'createdAt' | 'updatedAt'>[] = [
{ name: '走廊东', channel: 1, location: 'E栋一层走廊', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: 'PCR区', channel: 2, location: '分子诊断 PCR', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '样本库', channel: 3, location: '样本库入口', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '机房', channel: 4, location: '弱电/机房', deviceType: 'ipc', status: 'online', enabled: true, ip: '192.168.10.211', remark: '海康网络摄像机' },
{ name: '门禁口', channel: 5, location: '主入口门禁', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '冷链库', channel: 6, location: '冷链监测区', deviceType: 'nvr-ch', status: 'alarm', enabled: true, ip: '', remark: '海康通道 · 联动冷柜超温' },
]
return rows.map((r) => ({
...r,
id: randomUUID(),
createdAt: now,
updatedAt: now,
}))
}
function normalizeProtocol(p: string | undefined): VideoProtocol {
if (p === 'rtsp' || p === 'isapi' || p === 'sdk') return p
if (p === 'tcp') return 'isapi'
return 'sdk'
}
function normalizeData(raw: VideoData): VideoData {
const base = defaultConfig()
const c = { ...base, ...(raw.config || {}) }
c.vendor = 'hikvision'
c.protocol = normalizeProtocol(c.protocol as string)
c.rtspPort = Number(c.rtspPort) || 554
c.port = Number(c.port) || 8000
return { config: c, devices: Array.isArray(raw.devices) ? raw.devices : [] }
}
function ensureDir() {
fs.mkdirSync(path.dirname(filePath()), { recursive: true })
}
function load(): VideoData {
ensureDir()
if (!fs.existsSync(filePath())) {
const data: VideoData = { config: defaultConfig(), devices: seedDevices() }
save(data)
return data
}
const raw = JSON.parse(fs.readFileSync(filePath(), 'utf-8')) as VideoData
const data = normalizeData(raw)
// 补齐海康字段后写回,避免旧配置缺 vendor/rtspPort
if (
(raw.config as VideoCommConfig | undefined)?.vendor !== 'hikvision' ||
(raw.config as { rtspPort?: number } | undefined)?.rtspPort == null ||
(raw.config as { protocol?: string } | undefined)?.protocol === 'tcp'
) {
save(data)
}
return data
}
function save(data: VideoData) {
ensureDir()
fs.writeFileSync(filePath(), JSON.stringify(data, null, 2), 'utf-8')
}
export function ensureVideoSeed() {
load()
}
function toPublicConfig(c: VideoCommConfig): PublicCommConfig {
const { password, ...rest } = c
return {
...rest,
passwordSet: Boolean(password),
passwordMasked: password ? '********' : '',
}
}
export function getCommConfig(): PublicCommConfig {
return toPublicConfig(load().config)
}
export function updateCommConfig(
patch: Partial<{
host: string
port: number
rtspPort: number
username: string
password: string
protocol: VideoProtocol
streamType: StreamType
previewTimeoutMs: number
enabled: boolean
}>,
byUser: string,
): PublicCommConfig {
const data = load()
const c = data.config
c.vendor = 'hikvision'
if (patch.host !== undefined) c.host = String(patch.host).trim()
if (patch.port !== undefined) c.port = Number(patch.port)
if (patch.rtspPort !== undefined) c.rtspPort = Number(patch.rtspPort)
if (patch.username !== undefined) c.username = String(patch.username).trim()
if (patch.password !== undefined && String(patch.password).trim() && patch.password !== '********') {
c.password = String(patch.password)
}
if (patch.protocol !== undefined) c.protocol = normalizeProtocol(patch.protocol)
if (patch.streamType !== undefined) c.streamType = patch.streamType
if (patch.previewTimeoutMs !== undefined) c.previewTimeoutMs = Number(patch.previewTimeoutMs)
if (patch.enabled !== undefined) c.enabled = Boolean(patch.enabled)
c.updatedAt = new Date().toISOString()
c.updatedBy = byUser
save(data)
return toPublicConfig(c)
}
export function testCommConfig(): { ok: boolean; message: string; latencyMs: number } {
const c = load().config
if (!c.enabled) {
return { ok: false, message: '海康通信配置已禁用', latencyMs: 0 }
}
if (!c.host || !c.port) {
return { ok: false, message: '请填写海康 NVR IP 与设备端口(默认 8000)', latencyMs: 0 }
}
// 一期 Mock:校验配置完整性;二期经 Windows HCNetSDK 桥接登录预览
const latencyMs = 40 + Math.floor(Math.random() * 80)
const proto =
c.protocol === 'sdk' ? 'HCNetSDK' : c.protocol === 'rtsp' ? 'RTSP' : 'ISAPI'
return {
ok: true,
message: `海康设备 ${c.host}:${c.port} 登录成功(${proto} · Mock)`,
latencyMs,
}
}
export function listDevices(): VideoDevice[] {
return load().devices.slice().sort((a, b) => a.channel - b.channel)
}
export function createDevice(input: {
name: string
channel: number
location: string
deviceType: DeviceType
status?: DeviceStatus
enabled?: boolean
ip?: string
remark?: string
}): VideoDevice {
const data = load()
if (data.devices.some((d) => d.channel === input.channel)) {
throw new Error(`通道 ${input.channel} 已存在`)
}
const now = new Date().toISOString()
const device: VideoDevice = {
id: randomUUID(),
name: input.name.trim(),
channel: Number(input.channel),
location: input.location.trim(),
deviceType: input.deviceType,
status: input.status || 'online',
enabled: input.enabled !== false,
ip: (input.ip || '').trim(),
remark: (input.remark || '').trim(),
createdAt: now,
updatedAt: now,
}
data.devices.push(device)
save(data)
return device
}
export function updateDevice(
id: string,
patch: Partial<{
name: string
channel: number
location: string
deviceType: DeviceType
status: DeviceStatus
enabled: boolean
ip: string
remark: string
}>,
): VideoDevice {
const data = load()
const idx = data.devices.findIndex((d) => d.id === id)
if (idx < 0) throw new Error('设备不存在')
if (patch.channel !== undefined) {
const ch = Number(patch.channel)
if (data.devices.some((d) => d.id !== id && d.channel === ch)) {
throw new Error(`通道 ${ch} 已存在`)
}
data.devices[idx].channel = ch
}
if (patch.name !== undefined) data.devices[idx].name = String(patch.name).trim()
if (patch.location !== undefined) data.devices[idx].location = String(patch.location).trim()
if (patch.deviceType !== undefined) data.devices[idx].deviceType = patch.deviceType
if (patch.status !== undefined) data.devices[idx].status = patch.status
if (patch.enabled !== undefined) data.devices[idx].enabled = Boolean(patch.enabled)
if (patch.ip !== undefined) data.devices[idx].ip = String(patch.ip).trim()
if (patch.remark !== undefined) data.devices[idx].remark = String(patch.remark).trim()
data.devices[idx].updatedAt = new Date().toISOString()
save(data)
return data.devices[idx]
}
export function deleteDevice(id: string) {
const data = load()
if (!data.devices.some((d) => d.id === id)) throw new Error('设备不存在')
data.devices = data.devices.filter((d) => d.id !== id)
save(data)
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
COPY web/package.json ./web/
COPY server/package.json ./server/
RUN npm install --workspace=web --include=dev
COPY web ./web
WORKDIR /app/web
ARG VITE_API_BASE=
ARG VITE_VIEWPORT_MODE=glb
ARG VITE_UE_STREAM_URL=
ENV VITE_API_BASE=$VITE_API_BASE \
VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \
VITE_UE_STREAM_URL=$VITE_UE_STREAM_URL
RUN npx vite build
FROM nginx:1.27-alpine
COPY web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/web/dist /usr/share/nginx/html
EXPOSE 80
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>分子实验室三维可视化管理平台</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=IBM+Plex+Sans:wght@500;600;700&display=swap" rel="stylesheet" />
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 20m;
location /api/ {
proxy_pass http://server:3001/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws {
proxy_pass http://server:3001/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
location / {
try_files $uri $uri/ /index.html;
}
}
{
"name": "web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^3.0.1",
"vue": "^3.5.42",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"typescript": "~5.8.2",
"vite": "^6.2.2",
"vue-tsc": "^2.2.8"
}
}
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>
<template>
<RouterView />
</template>
const API_BASE = import.meta.env.VITE_API_BASE || ''
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
export async function api<T>(
path: string,
options: RequestInit & { token?: string | null } = {},
): Promise<T> {
const headers = new Headers(options.headers || {})
if (!headers.has('Content-Type') && options.body) {
headers.set('Content-Type', 'application/json')
}
if (options.token) headers.set('Authorization', `Bearer ${options.token}`)
const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new ApiError(res.status, data.message || `请求失败 (${res.status})`)
}
return data as T
}
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, useId } from 'vue'
export interface LinePoint {
label: string
value: number
}
const props = withDefaults(
defineProps<{
points: LinePoint[]
color?: string
unit?: string
height?: number
/** 横轴每隔几个点显示标签,默认自动 */
labelEvery?: number
}>(),
{
color: '#37c8ff',
unit: '元',
height: 180,
labelEvery: 0,
},
)
const gradId = `lg-${useId().replace(/:/g, '')}`
const rootRef = ref<HTMLElement | null>(null)
const hover = ref<number | null>(null)
/** 与容器同宽,避免 preserveAspectRatio=none 拉伸文字 */
const width = ref(640)
const pad = { top: 16, right: 16, bottom: 28, left: 48 }
let ro: ResizeObserver | undefined
onMounted(() => {
if (!rootRef.value) return
const update = () => {
const w = rootRef.value?.clientWidth || 640
width.value = Math.max(280, Math.round(w))
}
update()
ro = new ResizeObserver(update)
ro.observe(rootRef.value)
})
onBeforeUnmount(() => ro?.disconnect())
const chart = computed(() => {
const pts = props.points
const w = width.value
if (!pts.length) {
return {
path: '',
area: '',
dots: [] as { x: number; y: number; label: string; value: number }[],
yTicks: [] as { v: number; y: number }[],
xLabels: [] as { x: number; label: string }[],
}
}
const values = pts.map((p) => p.value)
const minV = Math.min(...values)
const maxV = Math.max(...values)
const span = maxV - minV || maxV * 0.1 || 1
const yMin = Math.max(0, minV - span * 0.12)
const yMax = maxV + span * 0.12
const innerW = w - pad.left - pad.right
const innerH = props.height - pad.top - pad.bottom
const yRange = yMax - yMin || 1
const xAt = (i: number) => pad.left + (pts.length === 1 ? innerW / 2 : (i / (pts.length - 1)) * innerW)
const yAt = (v: number) => pad.top + ((yMax - v) / yRange) * innerH
const dots = pts.map((p, i) => ({
x: xAt(i),
y: yAt(p.value),
label: p.label,
value: p.value,
}))
const path = dots.map((d, i) => `${i === 0 ? 'M' : 'L'}${d.x.toFixed(1)},${d.y.toFixed(1)}`).join(' ')
const area = `${path} L${dots[dots.length - 1].x.toFixed(1)},${(pad.top + innerH).toFixed(1)} L${dots[0].x.toFixed(1)},${(pad.top + innerH).toFixed(1)} Z`
const yTicks: { v: number; y: number }[] = []
for (let i = 0; i <= 4; i++) {
const v = yMin + (yRange * i) / 4
yTicks.push({ v, y: yAt(v) })
}
const step =
props.labelEvery > 0
? props.labelEvery
: pts.length <= 8
? 1
: pts.length <= 16
? 2
: Math.ceil(pts.length / 8)
const xLabels = dots
.map((d, i) => ({ x: d.x, label: d.label, show: i % step === 0 || i === dots.length - 1 }))
.filter((d) => d.show)
return { path, area, dots, yTicks, xLabels }
})
function fmt(n: number) {
if (n >= 10000) return `${(n / 10000).toFixed(1)}万`
if (n >= 1000) return n.toFixed(0)
return n.toFixed(n >= 100 ? 0 : 1)
}
</script>
<template>
<div ref="rootRef" class="line-chart" :style="{ height: `${height}px` }">
<svg
class="svg"
:viewBox="`0 0 ${width} ${height}`"
:width="width"
:height="height"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" :stop-color="color" stop-opacity="0.35" />
<stop offset="100%" :stop-color="color" stop-opacity="0.02" />
</linearGradient>
</defs>
<line
v-for="(t, i) in chart.yTicks"
:key="`g-${i}`"
:x1="pad.left"
:x2="width - pad.right"
:y1="t.y"
:y2="t.y"
stroke="rgba(62,200,255,0.12)"
stroke-width="1"
/>
<text
v-for="(t, i) in chart.yTicks"
:key="`yl-${i}`"
:x="pad.left - 8"
:y="t.y + 4"
text-anchor="end"
class="axis"
>
{{ fmt(t.v) }}
</text>
<path v-if="chart.area" :d="chart.area" :fill="`url(#${gradId})`" />
<path
v-if="chart.path"
:d="chart.path"
fill="none"
:stroke="color"
stroke-width="2.2"
stroke-linejoin="round"
stroke-linecap="round"
/>
<g v-for="(d, i) in chart.dots" :key="`d-${i}`">
<circle
:cx="d.x"
:cy="d.y"
:r="hover === i ? 5 : 3.2"
:fill="color"
stroke="rgba(4,16,31,0.9)"
stroke-width="1.5"
@mouseenter="hover = i"
@mouseleave="hover = null"
/>
</g>
<text
v-for="(x, i) in chart.xLabels"
:key="`xl-${i}`"
:x="x.x"
:y="height - 8"
text-anchor="middle"
class="axis"
>
{{ x.label }}
</text>
</svg>
<div
v-if="hover != null && chart.dots[hover]"
class="tip"
:style="{ left: `${(chart.dots[hover].x / width) * 100}%` }"
>
<b>{{ chart.dots[hover].label }}</b>
<span>
{{ chart.dots[hover].value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
{{ unit }}
</span>
</div>
</div>
</template>
<style scoped>
.line-chart {
position: relative;
width: 100%;
min-height: 140px;
}
.svg {
width: 100%;
height: 100%;
display: block;
overflow: visible;
}
.axis {
fill: rgba(127, 166, 201, 0.9);
font-size: 11px;
font-family: var(--font-num);
}
.tip {
position: absolute;
top: 4px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 10px;
border-radius: 6px;
background: rgba(6, 20, 42, 0.92);
border: 1px solid rgba(62, 200, 255, 0.35);
pointer-events: none;
white-space: nowrap;
z-index: 2;
}
.tip b { font-size: 11px; color: #fff; }
.tip span { font-size: 12px; color: var(--cyan-lt); font-family: var(--font-num); }
</style>
<script setup lang="ts">
import { computed } from 'vue'
import AppIcon from '@/components/icons/AppIcon.vue'
export interface AlarmItem {
level: 'red' | 'amber' | 'calm'
title: string
sub: string
state: string
}
const props = defineProps<{
items: AlarmItem[]
}>()
/** 不足 4 条时补演示数据,保证可滚动;至少复制一份做无缝循环 */
const feed = computed(() => {
const base = props.items.length
? [...props.items]
: [{ level: 'calm' as const, title: '系统运行正常', sub: '实时监测中', state: '正常' }]
const pad: AlarmItem[] = [
{ level: 'amber', title: '文库制备区1 压差接近下限', sub: '自控联动调频中', state: '处理中' },
{ level: 'calm', title: 'PAU-201 滤网更换完成', sub: '机组已恢复', state: '已消除' },
{ level: 'red', title: '样本制备区缓冲 压差异常', sub: '已推送值班人员', state: '未消除' },
{ level: 'amber', title: '冰箱3 温度波动', sub: '冷链监测', state: '处理中' },
{ level: 'calm', title: '门禁主入口 通行正常', sub: '安防联动', state: '已消除' },
]
let list = base
while (list.length < 4) {
list = list.concat(pad.slice(0, 4 - list.length))
}
return list
})
const loop = computed(() => [...feed.value, ...feed.value])
const trackStyle = computed(() => ({
'--count': String(feed.value.length),
animationDuration: `${Math.max(feed.value.length, 1) * 2.8}s`,
}))
function alarmIcon(level: string) {
if (level === 'red') return 'alarm-warn'
if (level === 'amber') return 'alarm-amber'
return 'alarm-ok'
}
</script>
<template>
<div class="alarm-viewport" aria-label="报警滚动列表">
<div class="alarm-track" :style="trackStyle">
<div
v-for="(a, i) in loop"
:key="`alarm-${i}-${a.level}`"
class="alarm-item"
:class="a.level"
>
<AppIcon :name="alarmIcon(a.level)" />
<div class="a-txt">
<div class="a-title">{{ a.title }}</div>
<div class="a-sub">{{ a.sub }}</div>
</div>
<div class="a-state">{{ a.state }}</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 固定展示 3 条高度:每条 52px + 间距 8px → 3*52 + 2*8 = 172 */
.alarm-viewport {
height: 172px;
overflow: hidden;
position: relative;
mask-image: linear-gradient(180deg, transparent 0%, #000 8%, #000 92%, transparent 100%);
}
.alarm-track {
display: flex;
flex-direction: column;
gap: 8px;
animation-name: alarm-up;
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform;
}
.alarm-track:hover { animation-play-state: paused; }
@keyframes alarm-up {
from { transform: translateY(0); }
to { transform: translateY(calc(var(--count) * -60px)); }
}
.alarm-item {
display: flex;
align-items: center;
gap: 8px;
height: 52px;
box-sizing: border-box;
padding: 0 9px;
border-radius: 7px;
flex: 0 0 52px;
}
.alarm-item.red { background: rgba(255,82,82,.1); border: 1px solid rgba(255,82,82,.4); }
.alarm-item.amber { background: rgba(255,180,0,.08); border: 1px solid rgba(255,180,0,.35); }
.alarm-item.calm { background: rgba(26,77,153,.2); border: 1px solid transparent; }
.a-txt { flex: 1; min-width: 0; }
.a-title {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.a-sub { font-size: 10px; color: var(--label); margin-top: 2px; }
.a-state {
font-size: 10px;
white-space: nowrap;
padding: 3px 7px;
border-radius: 3px;
background: rgba(0,0,0,.2);
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import AppIcon from '@/components/icons/AppIcon.vue'
import type { FridgeSnapshot } from '@/types'
const props = defineProps<{
items: FridgeSnapshot[]
}>()
const feed = computed(() => (props.items.length ? props.items : []))
const count = computed(() => Math.max(feed.value.length, 4))
const loop = computed(() => {
if (!feed.value.length) return []
// 少于 4 条时复制,保证可滚动
let list = [...feed.value]
while (list.length > 0 && list.length < 4) list = list.concat(feed.value)
return [...list, ...list]
})
const trackStyle = computed(() => ({
'--count': String(count.value),
animationDuration: `${count.value * 2.6}s`,
}))
</script>
<template>
<div v-if="!feed.length" class="cold-empty">暂无冷链设备数据</div>
<div v-else class="cold-viewport" aria-label="冷链监测滚动列表">
<div class="cold-track" :style="trackStyle">
<div
v-for="(f, i) in loop"
:key="`${f.id}-${i}`"
class="fridge"
:class="{ warn: f.temp > -19 }"
>
<div class="f-left">
<AppIcon name="fridge" :class-name="f.temp > -19 ? 'warn' : ''" />
<span class="f-name">{{ f.name }}</span>
</div>
<div class="f-vals">
<span class="f-temp"><em>T:</em>{{ f.temp.toFixed(1) }} ℃</span>
<span class="f-hum"><em>RH:</em>{{ f.humidity }} %</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 可视约 4 行:每行 40px + 间距 6px */
.cold-viewport {
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
position: relative;
mask-image: linear-gradient(180deg, transparent 0%, #000 6%, #000 94%, transparent 100%);
}
.cold-track {
display: flex;
flex-direction: column;
gap: 6px;
animation-name: cold-up;
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform;
}
.cold-track:hover { animation-play-state: paused; }
@keyframes cold-up {
from { transform: translateY(0); }
to { transform: translateY(calc(var(--count) * -46px)); }
}
.fridge {
display: flex;
justify-content: space-between;
align-items: center;
height: 40px;
flex: 0 0 40px;
box-sizing: border-box;
padding: 0 10px;
border-radius: 7px;
background: rgba(26,77,153,.25);
}
.fridge.warn { background: rgba(255,180,0,.1); border: 1px solid rgba(255,180,0,.4); }
.f-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
.f-name {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.f-vals { display: flex; flex-direction: column; align-items: flex-end; gap: 1px; }
.f-temp { font-size: 12px; font-weight: 600; font-family: var(--font-num); color: var(--green); }
.f-hum { font-size: 10px; font-family: var(--font-num); color: var(--cyan-lt); }
.f-temp em, .f-hum em {
font-style: normal; font-weight: 500; font-size: 10px;
margin-right: 3px; color: var(--label);
}
.fridge.warn .f-temp { color: var(--amber); }
.cold-empty {
font-size: 12px; color: var(--label); padding: 12px 0;
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import AppIcon from '@/components/icons/AppIcon.vue'
import type { VavSnapshot } from '@/types'
const props = defineProps<{
items: VavSnapshot[]
}>()
type ParamTone = 'run' | 'face' | 'sash' | 'air' | 'valve' | 'mode' | 'alarm'
interface VavRow {
key: string
icon: string
unit: string
param: string
tone: ParamTone
value: string
pct: number
warn?: boolean
}
const feed = computed(() => {
const rows: VavRow[] = []
props.items.forEach((v) => {
const exhaust = v.maxExhaust ? '最大排风' : v.minAirflow ? '最小风量' : '常态'
rows.push(
{
key: `${v.id}-run`,
icon: 'vav-valve',
unit: v.name,
param: '开停状态',
tone: 'run',
value: v.running ? '运行' : '停止',
pct: v.running ? 100 : 8,
warn: !v.running,
},
{
key: `${v.id}-face`,
icon: 'vav-speed',
unit: v.name,
param: '实际面风速',
tone: 'face',
value: `${v.faceVelocity.toFixed(2)} m/s`,
pct: Math.min(100, (v.faceVelocity / 0.8) * 100),
},
{
key: `${v.id}-sash`,
icon: 'vav-sash',
unit: v.name,
param: '实际门高',
tone: 'sash',
value: `${v.sashHeight} mm`,
pct: Math.min(100, (v.sashHeight / 500) * 100),
},
{
key: `${v.id}-air`,
icon: 'vav-speed',
unit: v.name,
param: '实际风量',
tone: 'air',
value: `${v.airflow} m³/h`,
pct: Math.min(100, (v.airflow / 1200) * 100),
},
{
key: `${v.id}-valve`,
icon: 'vav-valve',
unit: v.name,
param: '阀开度',
tone: 'valve',
value: `${v.valveOpen} %`,
pct: Math.min(100, v.valveOpen),
},
{
key: `${v.id}-ex`,
icon: 'vav-sash',
unit: v.name,
param: '排风模式',
tone: 'mode',
value: exhaust,
pct: v.maxExhaust ? 100 : v.minAirflow ? 30 : 65,
},
{
key: `${v.id}-alarm`,
icon: 'vav-valve',
unit: v.name,
param: '报警代码',
tone: 'alarm',
value: v.alarmCode ? `码 ${v.alarmCode}` : '正常',
pct: v.alarmCode ? 100 : 12,
warn: v.alarmCode > 0,
},
)
})
return rows
})
const loop = computed(() => {
if (!feed.value.length) return []
let list = [...feed.value]
while (list.length > 0 && list.length < 4) list = list.concat(feed.value)
return [...list, ...list]
})
const count = computed(() => Math.max(feed.value.length, 4))
const trackStyle = computed(() => ({
'--count': String(count.value),
animationDuration: `${count.value * 2.4}s`,
}))
</script>
<template>
<div v-if="!feed.length" class="vav-empty">暂无通风柜数据</div>
<div v-else class="vav-viewport" aria-label="通风柜参数滚动">
<div class="vav-track" :style="trackStyle">
<div
v-for="(row, i) in loop"
:key="`${row.key}-${i}`"
class="vav-row"
:class="[row.tone, { warn: row.warn }]"
>
<div class="vav-line">
<AppIcon :name="row.icon" />
<span class="v-label">
<span class="unit">{{ row.unit }}</span>
<span class="sep">·</span>
<span class="param">{{ row.param }}</span>
</span>
<span class="v-value">{{ row.value }}</span>
</div>
<div class="track">
<div class="fill" :style="{ width: `${row.pct}%` }" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.vav-viewport {
flex: 1;
min-height: 0;
overflow: hidden;
mask-image: linear-gradient(180deg, transparent 0%, #000 8%, #000 92%, transparent 100%);
}
.vav-track {
display: flex;
flex-direction: column;
gap: 10px;
animation-name: vav-up;
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform;
}
.vav-track:hover { animation-play-state: paused; }
@keyframes vav-up {
from { transform: translateY(0); }
to { transform: translateY(calc(var(--count) * -48px)); }
}
.vav-row {
flex: 0 0 38px;
height: 38px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.vav-line {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.v-label {
flex: 1; min-width: 0;
display: flex; align-items: center; gap: 4px;
white-space: nowrap; overflow: hidden;
}
.unit {
color: rgba(180, 210, 235, 0.72);
overflow: hidden; text-overflow: ellipsis;
}
.sep { color: rgba(143, 182, 219, 0.45); flex: 0 0 auto; }
.param {
flex: 0 0 auto;
font-weight: 600;
letter-spacing: 0.3px;
}
.v-value { font-family: var(--font-num); white-space: nowrap; font-weight: 600; }
.track {
height: 5px;
border-radius: 3px;
background: var(--track);
overflow: hidden;
}
.fill {
height: 100%;
border-radius: 3px;
}
/* 参数名配色 */
.vav-row.run .param,
.vav-row.run .v-value { color: #2ee6a8; }
.vav-row.run .fill { background: linear-gradient(90deg, #128a5e, #2ee6a8); }
.vav-row.face .param,
.vav-row.face .v-value { color: #5bd4ff; }
.vav-row.face .fill { background: linear-gradient(90deg, #1f70ff, #37c8ff); }
.vav-row.sash .param,
.vav-row.sash .v-value { color: #a78bfa; }
.vav-row.sash .fill { background: linear-gradient(90deg, #6d28d9, #a78bfa); }
.vav-row.air .param,
.vav-row.air .v-value { color: #38bdf8; }
.vav-row.air .fill { background: linear-gradient(90deg, #0284c7, #38bdf8); }
.vav-row.valve .param,
.vav-row.valve .v-value { color: #fbbf24; }
.vav-row.valve .fill { background: linear-gradient(90deg, #d97706, #fbbf24); }
.vav-row.mode .param,
.vav-row.mode .v-value { color: #34d399; }
.vav-row.mode .fill { background: linear-gradient(90deg, #059669, #34d399); }
.vav-row.alarm .param,
.vav-row.alarm .v-value { color: #fb7185; }
.vav-row.alarm .fill { background: linear-gradient(90deg, #e11d48, #fb7185); }
.vav-row.warn .param,
.vav-row.warn .v-value { color: #ffb400; }
.vav-row.warn .fill { background: linear-gradient(90deg, #c97a12, #ffb400); }
.vav-empty { font-size: 12px; color: var(--label); padding: 8px 0; }
</style>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useRealtimeStore } from '@/stores/realtime'
import { PROTOCOL_ROOMS } from '@/data/rooms'
import AppIcon from '@/components/icons/AppIcon.vue'
withDefaults(
defineProps<{
areaLabel?: string
}>(),
{ areaLabel: '' },
)
const realtime = useRealtimeStore()
const mode = (import.meta.env.VITE_VIEWPORT_MODE || 'glb') as 'glb' | 'ue-stream'
const ueUrl = import.meta.env.VITE_UE_STREAM_URL || ''
const viewerRef = ref<HTMLElement | null>(null)
type SmoothControlsLike = {
onMouseDown?: (event: MouseEvent) => void
initializePan?: () => void
__mouseSwapped?: boolean
}
/** 在 model-viewer 内部控件上交换:左键平移、右键旋转 */
function findControls(el: object): SmoothControlsLike | null {
for (const sym of Object.getOwnPropertySymbols(el)) {
const val = (el as Record<symbol, unknown>)[sym] as SmoothControlsLike | null
if (
val &&
typeof val === 'object' &&
typeof val.initializePan === 'function' &&
typeof val.onMouseDown === 'function'
) {
return val
}
}
return null
}
function applyMouseMapping() {
const el = viewerRef.value
if (!el) return false
const controls = findControls(el)
if (!controls?.onMouseDown || controls.__mouseSwapped) return !!controls?.__mouseSwapped
const original = controls.onMouseDown.bind(controls)
controls.onMouseDown = (event: MouseEvent) => {
const button = event.button === 0 ? 2 : event.button === 2 ? 0 : event.button
const swapped = new Proxy(event, {
get(target, prop) {
if (prop === 'button') return button
const value = Reflect.get(target, prop, target)
return typeof value === 'function' ? value.bind(target) : value
},
}) as MouseEvent
original(swapped)
}
controls.__mouseSwapped = true
return true
}
function onViewerLoad() {
if (applyMouseMapping()) return
requestAnimationFrame(() => {
if (applyMouseMapping()) return
window.setTimeout(() => applyMouseMapping(), 300)
})
}
/** 协议房间清单 + 实时测点;无快照时仍展示协议名称 */
const roomList = computed(() => {
const live = realtime.snapshot.rooms
return PROTOCOL_ROOMS.map((def) => {
const hit = live.find((r) => r.id === def.id)
return {
id: def.id,
name: def.name,
temp: hit?.temp ?? null,
humidity: hit?.humidity ?? null,
pressure: hit?.pressure ?? null,
alarms: hit?.alarms ?? [],
}
})
})
const selected = computed(() => {
return roomList.value.find((r) => r.id === realtime.selectedRoomId) || roomList.value[0]
})
const markers = computed(() => {
return roomList.value
.filter((r) => ['sample-1', 'lib-1', 'seq'].includes(r.id))
.map((r, i) => ({
id: r.id,
label: `${r.name} ${r.pressure != null ? `${r.pressure.toFixed(1)}Pa` : '--'}`,
err: r.alarms.length > 0,
style: {
left: `${18 + i * 26}%`,
top: `${42 + (i % 2) * 12}%`,
},
}))
})
function onSelect(id: string) {
realtime.selectRoom(id)
}
onMounted(() => {
const el = viewerRef.value
el?.addEventListener('load', onViewerLoad)
applyMouseMapping()
})
onBeforeUnmount(() => {
viewerRef.value?.removeEventListener('load', onViewerLoad)
})
</script>
<template>
<div class="viewport">
<div class="vp-corners"><i /><i /><i /><i /></div>
<div class="vp-topbar">
<AppIcon name="pin" />
<span class="area">{{ areaLabel || `当前区域:${selected?.name || '—'}` }}</span>
<span class="sep" />
<span class="view">{{ mode === 'ue-stream' ? 'UE 推流模式' : '实景扫描模型 · 自由视角' }}</span>
</div>
<div class="vp-room-panel">
<div class="vp-room-head">
<AppIcon name="pin" />
<div>
<div class="vp-room-label">房间 / 区域</div>
<div class="vp-room-sub">来自网关协议 · 共 {{ roomList.length }} 处</div>
</div>
</div>
<div class="vp-room-list">
<button
v-for="r in roomList"
:key="r.id"
type="button"
class="vp-room-item"
:class="{ active: r.id === realtime.selectedRoomId, alarm: r.alarms.length > 0 }"
@click="onSelect(r.id)"
>
<span class="name">{{ r.name }}</span>
</button>
</div>
</div>
<template v-if="mode === 'ue-stream' && ueUrl">
<iframe class="ue-frame" :src="ueUrl" title="UE Stream" allow="autoplay; fullscreen" />
</template>
<template v-else>
<model-viewer
ref="viewerRef"
class="scene"
src="/models/scene-model.glb"
camera-controls
auto-rotate
shadow-intensity="0.6"
exposure="1.1"
interaction-prompt="none"
@contextmenu.prevent
/>
</template>
<div
v-for="m in markers"
:key="m.id"
class="mk"
:class="{ err: m.err }"
:style="m.style"
@click="onSelect(m.id)"
>
<span class="dot" :class="{ red: m.err }" />
<span>{{ m.label }}</span>
</div>
<div class="vp-tip">
<AppIcon name="hand" class-name="hand" />
<span><b>左键</b> 拖动 · <b>右键</b> 旋转 · <b>滚轮</b> 缩放 · 右侧点选房间</span>
</div>
</div>
</template>
<style scoped>
.viewport {
flex: 1; min-width: 0; position: relative; overflow: hidden;
border-radius: 10px; border: 1px solid rgba(62,200,255,.4);
background: linear-gradient(180deg, #0a2148, #041022);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.04), 0 14px 34px rgba(2,8,23,.5);
}
.scene, .ue-frame {
position: absolute; inset: 0; width: 100%; height: 100%; border: 0;
}
.vp-topbar, .vp-tip {
position: absolute; z-index: 5;
display: flex; align-items: center; gap: 10px;
padding: 7px 12px; border-radius: 7px;
background: rgba(8, 26, 61, 0.85);
border: 1px solid rgba(91, 212, 255, 0.42);
backdrop-filter: blur(10px);
}
.vp-topbar { left: 14px; top: 14px; }
.vp-room-panel {
position: absolute; z-index: 6;
right: 14px; top: 14px;
width: max-content;
max-width: 128px;
max-height: calc(100% - 72px);
display: flex; flex-direction: column;
border-radius: 6px;
background: rgba(6, 18, 42, 0.42);
border: 1px solid rgba(91, 212, 255, 0.22);
backdrop-filter: blur(8px);
overflow: hidden;
}
.vp-room-head {
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 6px 8px;
border-bottom: 1px solid rgba(62,200,255,.12);
}
.vp-room-head :deep(.pin) { width: 12px; height: 12px; opacity: .85; }
.vp-room-head > div { text-align: center; }
.vp-room-label { font-size: 10px; font-weight: 600; color: rgba(255,255,255,.9); }
.vp-room-sub { display: none; }
.vp-room-list {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 4px;
display: flex; flex-direction: column; gap: 2px;
}
.vp-room-item {
display: block;
width: 100%;
border: 1px solid rgba(62, 200, 255, 0.18);
background: rgba(22, 58, 110, 0.45);
border-radius: 4px;
padding: 4px 6px;
cursor: pointer; text-align: center;
color: rgba(235, 245, 255, 0.95);
transition: .15s;
}
.vp-room-item:hover {
border-color: rgba(62,200,255,.42);
background: rgba(30, 82, 150, 0.62);
color: #fff;
}
.vp-room-item.active {
border-color: rgba(91, 212, 255, 0.65);
background: rgba(26, 111, 255, 0.55);
color: #fff;
box-shadow: inset 0 0 0 1px rgba(91, 212, 255, 0.15);
}
.vp-room-item.alarm .name { color: #ffb0b0; }
.vp-room-item .name {
font-size: 11px; display: block;
white-space: nowrap; line-height: 1.25;
text-align: center;
}
.vp-tip { right: 14px; bottom: 16px; font-size: 11px; color: #b9d6f0; }
.vp-tip :deep(.hand) { width: 14px; height: 14px; }
.vp-tip b { color: var(--cyan-lt); font-weight: 600; }
.area { font-size: 12px; color: #fff; }
.sep { width: 1px; height: 14px; background: rgba(62,200,255,.35); }
.view { font-size: 11px; color: var(--cyan-lt); }
.mk {
position: absolute; z-index: 5;
display: flex; align-items: center; gap: 6px;
padding: 5px 9px; border-radius: 6px;
background: rgba(5, 36, 51, 0.88);
border: 1px solid rgba(47, 214, 161, 0.6);
font-size: 11px; color: #b9f5de; white-space: nowrap;
cursor: pointer;
}
.mk.err { background: rgba(61,13,20,.9); border-color: rgba(255,82,82,.65); color: #ffc9c9; }
.vp-corners { position: absolute; inset: 0; pointer-events: none; z-index: 4; }
.vp-corners i {
position: absolute; width: 16px; height: 16px;
border: 2px solid rgba(91,212,255,.7);
}
.vp-corners i:nth-child(1) { left: 10px; top: 10px; border-right: none; border-bottom: none; }
.vp-corners i:nth-child(2) { right: 10px; top: 10px; border-left: none; border-bottom: none; }
.vp-corners i:nth-child(3) { left: 10px; bottom: 10px; border-right: none; border-top: none; }
.vp-corners i:nth-child(4) { right: 10px; bottom: 10px; border-left: none; border-top: none; }
</style>
import { onMounted, onUnmounted, ref } from 'vue'
const weekMap = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
function p2(n: number) {
return n < 10 ? `0${n}` : `${n}`
}
export function useClock() {
const time = ref('--:--:--')
const date = ref('')
let timer: number | undefined
function tick() {
const d = new Date()
time.value = `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`
date.value = `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${weekMap[d.getDay()]}`
}
onMounted(() => {
tick()
timer = window.setInterval(tick, 1000)
})
onUnmounted(() => window.clearInterval(timer))
return { time, date }
}
import { onMounted, onUnmounted, ref } from 'vue'
/** WMO Weather interpretation codes → 中文简述 */
function weatherText(code: number): string {
if (code === 0) return '晴'
if (code <= 3) return '多云'
if (code <= 48) return '雾'
if (code <= 57) return '毛毛雨'
if (code <= 67) return '雨'
if (code <= 77) return '雪'
if (code <= 82) return '阵雨'
if (code <= 86) return '阵雪'
if (code <= 99) return '雷雨'
return '未知'
}
const MACAU = { lat: 22.1987, lon: 113.5439 }
const REFRESH_MS = 15 * 60 * 1000
export function useMacauWeather() {
const place = ref('澳门')
const temp = ref('--')
const text = ref('获取中')
const humidity = ref('--')
let timer: number | undefined
async function fetchWeather() {
try {
const url =
`https://api.open-meteo.com/v1/forecast` +
`?latitude=${MACAU.lat}&longitude=${MACAU.lon}` +
`&current=temperature_2m,relative_humidity_2m,weather_code` +
`&timezone=Asia%2FMacau`
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
const cur = data.current
if (!cur) throw new Error('no current')
temp.value = `${Math.round(cur.temperature_2m)}℃`
text.value = weatherText(Number(cur.weather_code))
humidity.value = `${cur.relative_humidity_2m}%`
} catch {
text.value = '暂无数据'
}
}
onMounted(() => {
fetchWeather()
timer = window.setInterval(fetchWeather, REFRESH_MS)
})
onUnmounted(() => window.clearInterval(timer))
return { place, temp, text, humidity }
}
import { onMounted, onUnmounted, ref } from 'vue'
export function useStageScale() {
const style = ref({
transform: 'scale(1)',
left: '0px',
top: '0px',
})
function fit() {
const s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080)
style.value = {
transform: `scale(${s})`,
left: `${(window.innerWidth - 1920 * s) / 2}px`,
top: `${(window.innerHeight - 1080 * s) / 2}px`,
}
}
onMounted(() => {
fit()
window.addEventListener('resize', fit)
})
onUnmounted(() => window.removeEventListener('resize', fit))
return { style }
}
/** 来自网关协议「房间.xlsx」压差/温湿度测点对应的房间与区域 */
export const PROTOCOL_ROOMS = [
{ id: 'recv', name: '收样咨询室' },
{ id: 'report', name: '生信分析报告室' },
{ id: 'reagent', name: '试剂准备间' },
{ id: 'pcr-corridor', name: 'PCR走廊' },
{ id: 'sample-1', name: '样本制备区1' },
{ id: 'sample-2', name: '样本制备区2' },
{ id: 'sample-buf', name: '样本制备区缓冲' },
{ id: 'lib-1', name: '文库制备区1' },
{ id: 'lib-1-buf', name: '文库制备区1缓冲' },
{ id: 'lib-2', name: '文库制备区2' },
{ id: 'lib-2-buf', name: '文库制备区2缓冲' },
{ id: 'seq', name: '测序区' },
{ id: 'seq-buf', name: '测序区缓冲' },
] as const
export type ProtocolRoomId = (typeof PROTOCOL_ROOMS)[number]['id']
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime'
import { useClock } from '@/composables/useClock'
import { useMacauWeather } from '@/composables/useMacauWeather'
import { useStageScale } from '@/composables/useStageScale'
import AppIcon from '@/components/icons/AppIcon.vue'
const auth = useAuthStore()
const realtime = useRealtimeStore()
const router = useRouter()
const { time, date } = useClock()
const { place, temp, text: weatherText, humidity } = useMacauWeather()
const { style } = useStageScale()
const navItems = [
{ to: '/overview', label: '全景总览' },
{ to: '/roam', label: '漫游巡检' },
{ to: '/vav', label: '通风柜VAV' },
{ to: '/pressure', label: '房间压差' },
{ to: '/hvac', label: '空调排风' },
{ to: '/metering', label: '计量计费' },
{ to: '/video', label: '视频监控' },
{ to: '/access', label: '门禁系统' },
{ to: '/cold', label: '冷链监测' },
{ to: '/light', label: '智能照明' },
{ to: '/users', label: '用户管理', adminOnly: true },
{ to: '/settings', label: '系统设置', adminOnly: true },
]
onMounted(() => realtime.connect())
onUnmounted(() => realtime.disconnect())
function logout() {
auth.logout()
router.push('/login')
}
</script>
<template>
<div class="stage" :style="style">
<header class="screen-header">
<div class="brand">
<AppIcon name="logo" class-name="logo" />
<div class="brand-txt">
<div class="sys">实验室智慧管控中心</div>
<div class="en">LAB INTELLIGENT CONTROL CENTER</div>
</div>
</div>
<div class="title-group">
<div class="wing" aria-hidden="true">
<span class="wing-line" />
<span class="wing-chevron" />
<span class="wing-dot" />
</div>
<h1>分子实验室三维可视化管理平台</h1>
<div class="wing right" aria-hidden="true">
<span class="wing-dot" />
<span class="wing-chevron" />
<span class="wing-line" />
</div>
</div>
<div class="weather-wrap">
<span class="place">{{ place }}</span>
<span class="temp">{{ temp }}</span>
<span class="w-text">{{ weatherText }}</span>
<span class="humid">RH:{{ humidity }}</span>
</div>
<div class="clock-wrap">
<span class="time">{{ time }}</span>
<span class="date">{{ date }}</span>
</div>
<div class="hdr-status">
<div class="alarm-badge" @click="router.push('/overview')">
<span class="dot red" />
<span>告警 {{ realtime.alarmCount }}</span>
</div>
<div class="user-chip">
<span>{{ auth.user?.displayName }} · {{ auth.isAdmin ? '管理员' : '只读' }}</span>
<button type="button" @click="logout">退出</button>
</div>
</div>
</header>
<nav class="screen-nav">
<RouterLink
v-for="item in navItems.filter((n) => !n.adminOnly || auth.isAdmin)"
:key="item.to"
class="nav-chip"
:to="item.to"
>
{{ item.label }}
</RouterLink>
<div class="nav-spacer" />
<div class="server-chip">
<span class="dot" :class="{ red: !realtime.connected }" />
<span>
{{ realtime.connected ? '实时链路在线' : '实时链路断开' }}
· 设备 {{ realtime.snapshot.online.devicesOnline }}/{{ realtime.snapshot.online.devicesTotal }}
</span>
</div>
</nav>
<main class="screen-main">
<RouterView />
</main>
</div>
</template>
<style scoped>
.brand :deep(.logo) { width: 34px; height: 34px; }
</style>
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './styles/tokens.css'
import './styles/screen.css'
createApp(App).use(createPinia()).use(router).mount('#app')
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/login/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
component: () => import('@/layouts/ScreenLayout.vue'),
children: [
{ path: '', redirect: '/overview' },
{
path: 'overview',
name: 'overview',
component: () => import('@/views/overview/OverviewView.vue'),
meta: { title: '全景总览' },
},
{
path: 'roam',
name: 'roam',
component: () => import('@/views/stub/StubView.vue'),
meta: { title: '漫游巡检', desc: '第一人称巡检路径与点位数据同步,UE 推流到位后可替换为真实漫游。' },
},
{
path: 'vav',
name: 'vav',
component: () => import('@/views/vav/VavView.vue'),
meta: { title: '通风柜 VAV' },
},
{
path: 'pressure',
name: 'pressure',
component: () => import('@/views/pressure/PressureView.vue'),
meta: { title: '房间压差' },
},
{
path: 'hvac',
name: 'hvac',
component: () => import('@/views/hvac/HvacView.vue'),
meta: { title: '空调排风' },
},
{
path: 'metering',
name: 'metering',
component: () => import('@/views/metering/MeteringView.vue'),
meta: { title: '计量计费' },
},
{
path: 'video',
name: 'video',
component: () => import('@/views/video/VideoView.vue'),
meta: { title: '视频监控' },
},
{
path: 'access',
name: 'access',
component: () => import('@/views/access/AccessView.vue'),
meta: { title: '门禁系统' },
},
{
path: 'cold',
name: 'cold',
component: () => import('@/views/cold/ColdView.vue'),
meta: { title: '冷链监测' },
},
{
path: 'light',
name: 'light',
component: () => import('@/views/light/LightView.vue'),
meta: { title: '智能照明' },
},
{
path: 'users',
name: 'users',
component: () => import('@/views/users/UserManageView.vue'),
meta: { title: '用户管理', adminOnly: true },
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/settings/SettingsView.vue'),
meta: { title: '系统设置', adminOnly: true },
},
],
},
],
})
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (to.meta.public) {
if (auth.isLoggedIn && to.name === 'login') return '/overview'
return true
}
if (!auth.isLoggedIn) return '/login'
if (!auth.user) await auth.fetchMe()
if (to.meta.adminOnly && !auth.isAdmin) return '/overview'
return true
})
export default router
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { api } from '@/api/http'
import type { Role, User } from '@/types'
const TOKEN_KEY = 'jh_lab_token'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
const user = ref<User | null>(null)
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const role = computed<Role | null>(() => user.value?.role ?? null)
async function login(username: string, password: string) {
const data = await api<{ token: string; user: User }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
token.value = data.token
user.value = data.user
localStorage.setItem(TOKEN_KEY, data.token)
}
async function fetchMe() {
if (!token.value) return
try {
const data = await api<{ user: User }>('/api/auth/me', { token: token.value })
user.value = data.user
} catch {
logout()
}
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem(TOKEN_KEY)
}
return { token, user, isLoggedIn, isAdmin, role, login, fetchMe, logout }
})
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { RealtimeSnapshot } from '@/types'
const empty: RealtimeSnapshot = {
ts: 0,
rooms: [],
hvac: {
systemRunning: false,
supplyFanHz: 0,
exhaustPressure: 0,
filterRemainPct: 0,
mode: 'auto',
supplyTemp: 0,
supplyHumidity: 0,
supplyPressure: 0,
freshAirTemp: 0,
alarms: [],
},
vav: [],
fridges: [],
metering: {
powerKwh: 0,
waterM3: 0,
coolingKwh: 0,
powerTrendPct: 0,
waterTrendPct: 0,
coolingTrendPct: 0,
},
access: { doors: [], todayPassCount: 0, recentEvents: [] },
online: { devicesOnline: 0, devicesTotal: 0, gatewayOk: false },
}
export const useRealtimeStore = defineStore('realtime', () => {
const snapshot = ref<RealtimeSnapshot>(empty)
const connected = ref(false)
const selectedRoomId = ref('sample-1')
let ws: WebSocket | null = null
let retryTimer: number | undefined
const alarmCount = computed(() => {
const roomAlarms = snapshot.value.rooms.reduce((n, r) => n + r.alarms.length, 0)
const fridgeWarn = snapshot.value.fridges.filter((f) => f.temp > -19).length
return roomAlarms + fridgeWarn + snapshot.value.hvac.alarms.length
})
const primaryRoom = computed(() => {
return (
snapshot.value.rooms.find((r) => r.id === selectedRoomId.value) ||
snapshot.value.rooms[0] || {
id: '-',
name: '-',
temp: 0,
humidity: 0,
pressure: 0,
alarms: [] as string[],
}
)
})
function selectRoom(id: string) {
selectedRoomId.value = id
}
function connect() {
const url = import.meta.env.VITE_WS_URL || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`
ws?.close()
ws = new WebSocket(url)
ws.onopen = () => {
connected.value = true
}
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data)
if (msg.type === 'snapshot') snapshot.value = msg.data
} catch {
/* ignore */
}
}
ws.onclose = () => {
connected.value = false
window.clearTimeout(retryTimer)
retryTimer = window.setTimeout(connect, 2000)
}
ws.onerror = () => ws?.close()
}
function disconnect() {
window.clearTimeout(retryTimer)
ws?.close()
ws = null
}
return {
snapshot,
connected,
selectedRoomId,
alarmCount,
primaryRoom,
selectRoom,
connect,
disconnect,
}
})
.stage {
position: absolute;
width: 1920px;
height: 1080px;
transform-origin: top left;
display: flex;
flex-direction: column;
overflow: hidden;
background:
radial-gradient(900px 420px at 960px -80px, rgba(62, 200, 255, 0.1), transparent 70%),
radial-gradient(520px 520px at -80px 980px, rgba(26, 111, 255, 0.08), transparent 70%),
linear-gradient(180deg, #0a1628 0%, #07111f 55%, #050d18 100%);
}
.stage::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background:
linear-gradient(rgba(86, 212, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(86, 212, 255, 0.03) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 90% 80% at 50% 40%, #000 25%, transparent 90%);
}
.stage > * { position: relative; z-index: 1; }
.screen-header {
position: relative;
height: 84px;
flex: 0 0 84px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
border-bottom: 1px solid rgba(62, 200, 255, 0.28);
background: linear-gradient(to bottom, rgba(14, 42, 88, 0.55), rgba(8, 20, 42, 0.12));
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
width: 34px; height: 34px; border-radius: 8px;
border: 1px solid rgba(62, 200, 255, 0.55);
background: rgba(62, 200, 255, 0.1);
display: grid; place-items: center;
color: var(--cyan); font-weight: 700; font-family: var(--font-num);
}
.brand-txt .sys { font-size: 14px; font-weight: 600; color: var(--title); letter-spacing: 1px; }
.brand-txt .en { font-size: 9px; color: var(--cyan); letter-spacing: 1.4px; font-family: var(--font-num); }
.title-group {
position: absolute; left: 50%; top: 18px; transform: translateX(-50%);
display: flex; align-items: center; gap: 18px;
}
.title-group h1 {
font-size: 28px; font-weight: 700; letter-spacing: 3px; white-space: nowrap;
background: linear-gradient(180deg, #fff 20%, #b7d7ff 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.wing {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
}
.wing-line {
width: 96px;
height: 4px;
border-radius: 2px;
background: linear-gradient(90deg, transparent 0%, rgba(62, 200, 255, 0.15) 20%, #3ec8ff 100%);
box-shadow: 0 0 10px rgba(62, 200, 255, 0.55), 0 0 22px rgba(62, 200, 255, 0.25);
position: relative;
}
.wing-line::before {
content: '';
position: absolute;
left: 18%;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.75));
}
.wing-chevron {
width: 0;
height: 0;
border-style: solid;
border-width: 8px 0 8px 12px;
border-color: transparent transparent transparent #3ec8ff;
filter: drop-shadow(0 0 6px rgba(62, 200, 255, 0.85));
flex: 0 0 auto;
}
.wing-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #7ad8ff;
box-shadow: 0 0 0 3px rgba(62, 200, 255, 0.22), 0 0 12px rgba(62, 200, 255, 0.9);
flex: 0 0 auto;
}
.wing.right .wing-line {
background: linear-gradient(90deg, #3ec8ff 0%, rgba(62, 200, 255, 0.15) 80%, transparent 100%);
}
.wing.right .wing-line::before {
left: 0;
right: 18%;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.75), transparent);
}
.wing.right .wing-chevron {
border-width: 8px 12px 8px 0;
border-color: transparent #3ec8ff transparent transparent;
}
.hdr-status { display: flex; align-items: center; gap: 12px; }
.alarm-badge, .user-chip, .server-chip {
display: flex; align-items: center; gap: 6px;
padding: 6px 12px; border-radius: 6px;
border: 1px solid rgba(62, 200, 255, 0.3);
background: rgba(16, 40, 78, 0.45);
font-size: 12px;
}
.alarm-badge { border-color: rgba(255, 90, 90, 0.5); color: #ff9b9b; cursor: pointer; }
.user-chip { color: var(--body); }
.user-chip button {
border: none; background: transparent; color: var(--cyan-lt); cursor: pointer; margin-left: 4px;
}
.weather-wrap,
.clock-wrap {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: baseline;
gap: 10px;
white-space: nowrap;
}
.weather-wrap { left: 26%; }
.clock-wrap { left: 74%; }
.weather-wrap .place,
.weather-wrap .temp {
font-size: 18px; font-weight: 600; font-family: var(--font-num);
color: var(--green); line-height: 1;
}
.weather-wrap .w-text,
.weather-wrap .humid { font-size: 12px; color: #7cf0c4; }
.clock-wrap .time { font-size: 18px; font-weight: 600; font-family: var(--font-num); color: var(--green); line-height: 1; }
.clock-wrap .date { font-size: 12px; color: #7cf0c4; }
.dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--green);
box-shadow: 0 0 0 0 rgba(47, 214, 161, 0.6);
animation: pulse 2s infinite;
}
.dot.red { background: var(--red); }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(47, 214, 161, 0.55); }
70% { box-shadow: 0 0 0 8px rgba(47, 214, 161, 0); }
100% { box-shadow: 0 0 0 0 rgba(47, 214, 161, 0); }
}
.screen-nav {
height: 48px; flex: 0 0 48px;
display: flex; align-items: center; gap: 8px;
padding: 0 20px;
}
.nav-chip {
border-radius: 6px;
border: 1px solid rgba(62, 200, 255, 0.22);
background: rgba(18, 42, 78, 0.4);
cursor: pointer; color: var(--muted);
padding: 6px 12px; font-size: 12px;
text-decoration: none; transition: .2s;
}
.nav-chip.active, .nav-chip.router-link-active {
background: linear-gradient(90deg, #1a6fff, #0aa6e5);
color: #fff; font-weight: 600;
border-color: transparent;
}
.nav-chip:hover { color: var(--cyan-lt); }
.nav-spacer { flex: 1; }
.server-chip { color: #7cf0c4; border-color: rgba(47, 214, 161, 0.35); }
.screen-main {
flex: 1; min-height: 0;
display: flex; gap: 14px;
padding: 0 20px 14px;
}
.panel {
background: var(--panel-bg);
border: 1px solid var(--panel-bd);
border-radius: 10px;
padding: 14px;
box-shadow: inset 0 1px 0 rgba(255,255,255,.06), var(--shadow);
}
.panel-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px;
flex: 0 0 auto;
}
.panel-title { display: flex; align-items: center; gap: 8px; }
.panel-title .vbar {
width: 3px; height: 14px; border-radius: 2px;
background: linear-gradient(180deg, #7ad8ff, #1a6fff);
}
.panel-title h3 { font-size: 14px; font-weight: 600; color: var(--title); }
.badge {
padding: 2px 8px; border-radius: 4px; font-size: 10px;
background: rgba(47, 214, 161, 0.12); color: var(--green);
}
.badge.amber { background: rgba(240, 180, 41, 0.15); color: var(--amber); }
.badge.red { background: rgba(255, 90, 90, 0.15); color: #ff9b9b; }
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.metric {
display: flex; align-items: center; gap: 8px;
padding: 10px; min-height: 64px; border-radius: 8px;
background: var(--card-bg); border: 1px solid var(--card-bd);
}
.metric .m-label { font-size: 11px; color: var(--label); }
.metric .m-value { font-size: 20px; font-weight: 600; font-family: var(--font-num); color: #fff; }
.metric .m-value.green { color: var(--green); }
.kpi-bar {
height: 108px; flex: 0 0 108px;
display: flex; gap: 14px; padding: 0 20px 16px;
}
.kpi {
flex: 1; padding: 12px; border-radius: 10px;
background: var(--panel-bg); border: 1px solid var(--panel-bd);
display: flex; flex-direction: column; justify-content: center; gap: 6px;
}
.kpi .k-label { font-size: 11px; color: var(--label); }
.kpi .digital {
align-self: flex-start; padding: 3px 10px; border-radius: 6px;
background: rgba(5, 26, 61, 0.9); border: 1px solid rgba(62, 200, 255, 0.45);
font-size: 20px; font-weight: 600; font-family: var(--font-num); color: var(--cyan-lt);
}
.kpi .k-trend { font-size: 10px; color: var(--amber); }
.kpi .k-trend.green { color: var(--green); }
.kpi .k-trend.cyan { color: var(--cyan-lt); }
.btn {
border: 1px solid rgba(62, 200, 255, 0.4);
background: rgba(26, 111, 255, 0.25);
color: #eaf9ff; border-radius: 6px; padding: 8px 14px; cursor: pointer;
}
.btn:hover { background: rgba(26, 111, 255, 0.4); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn.danger { border-color: rgba(255,90,90,.45); background: rgba(255,90,90,.15); }
.btn.ghost { background: transparent; }
.table { width: 100%; border-collapse: collapse; font-size: 12px; }
.table th, .table td { padding: 8px 10px; border-bottom: 1px solid rgba(62,200,255,.12); text-align: left; }
.table th { color: var(--label); font-weight: 500; }
.table td { color: var(--body); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 12px; color: var(--label); }
.field input, .field select {
height: 36px; border-radius: 6px; padding: 0 10px;
border: 1px solid rgba(62,200,255,.28);
background: rgba(8, 22, 44, 0.8); color: #fff; outline: none;
}
.field input:focus, .field select:focus { border-color: var(--cyan); }
.page-fill { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 14px; width: 100%; }
.page-fill > .panel { min-height: 0; }
.stub-box {
flex: 1; display: grid; place-items: center;
border-radius: 10px; border: 1px solid var(--panel-bd);
background: linear-gradient(180deg, rgba(20,51,107,.25), rgba(5,15,36,.4));
text-align: center; padding: 40px;
}
.stub-box h2 { font-size: 24px; color: var(--title); margin-bottom: 10px; }
.stub-box p { font-size: 13px; color: var(--label); line-height: 1.8; max-width: 560px; }
:root {
--bg: #07111f;
--bg-deep: #040a14;
--cyan: #3ec8ff;
--cyan-lt: #7ad8ff;
--green: #2fd6a1;
--amber: #f0b429;
--red: #ff5a5a;
--title: #eef6ff;
--body: #c9ddf2;
--label: #7fa6c9;
--muted: #8eb4d4;
--panel-bg: linear-gradient(165deg, rgba(22, 48, 88, 0.55), rgba(10, 24, 48, 0.5));
--panel-bd: rgba(62, 200, 255, 0.28);
--card-bg: rgba(22, 56, 104, 0.35);
--card-bd: rgba(62, 200, 255, 0.18);
--track: rgba(48, 110, 190, 0.22);
--bar-grad: linear-gradient(90deg, #1a6fff, #3ec8ff);
--font-cn: 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
--font-num: 'IBM Plex Sans', 'Segoe UI', sans-serif;
--shadow: 0 12px 28px rgba(2, 8, 18, 0.45);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app { width: 100%; height: 100%; overflow: hidden; background: var(--bg-deep); }
body {
font-family: var(--font-cn);
color: var(--body);
-webkit-font-smoothing: antialiased;
font-variant-numeric: tabular-nums;
}
button, input, select { font: inherit; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: rgba(122, 216, 255, 0.35); border-radius: 3px; }
export type Role = 'admin' | 'viewer'
export interface User {
id: string
username: string
displayName: string
role: Role
enabled?: boolean
createdAt?: string
lastLoginAt?: string
}
export interface RoomSnapshot {
id: string
name: string
temp: number
humidity: number
pressure: number
alarms: string[]
}
export interface VavSnapshot {
id: string
name: string
running: boolean
faceVelocity: number
sashHeight: number
airflow: number
valveOpen: number
maxExhaust: boolean
minAirflow: boolean
alarmCode: number
}
export interface FridgeSnapshot {
id: string
name: string
temp: number
humidity: number
}
export interface DoorSnapshot {
id: string
name: string
status: 'open' | 'closed' | 'alarm' | 'offline'
}
export interface AccessEvent {
id: string
time: string
doorId: string
doorName: string
person: string
result: 'success' | 'denied' | 'alarm'
}
export interface RealtimeSnapshot {
ts: number
rooms: RoomSnapshot[]
hvac: {
systemRunning: boolean
supplyFanHz: number
exhaustPressure: number
filterRemainPct: number
mode: 'auto' | 'manual'
supplyTemp: number
supplyHumidity: number
supplyPressure: number
freshAirTemp: number
alarms: string[]
}
vav: VavSnapshot[]
fridges: FridgeSnapshot[]
metering: {
powerKwh: number
waterM3: number
coolingKwh: number
powerTrendPct: number
waterTrendPct: number
coolingTrendPct: number
}
access: {
doors: DoorSnapshot[]
todayPassCount: number
recentEvents: AccessEvent[]
}
online: {
devicesOnline: number
devicesTotal: number
gatewayOk: boolean
}
}
export type EnergyKind = 'power' | 'water' | 'cooling'
export interface TariffSettings {
powerPeak: number
powerFlat: number
powerValley: number
water: number
cooling: number
serviceFeePct: number
taxPct: number
settleDay: number
enabled: boolean
updatedAt: string
updatedBy: string
}
export interface DailyBill {
date: string
powerPeakKwh: number
powerFlatKwh: number
powerValleyKwh: number
waterM3: number
coolingKwh: number
powerCost: number
waterCost: number
coolingCost: number
subtotal: number
serviceFee: number
tax: number
total: number
}
export interface BillingStats {
today: DailyBill
month: {
days: number
powerKwh: number
waterM3: number
coolingKwh: number
powerCost: number
waterCost: number
coolingCost: number
total: number
}
byKind: { kind: EnergyKind; label: string; usage: number; unit: string; cost: number; pct: number }[]
trend7d: { date: string; total: number; powerCost: number; waterCost: number; coolingCost: number }[]
trend12m: { month: string; label: string; total: number; powerCost: number; waterCost: number; coolingCost: number }[]
usage30d: {
date: string
label: string
powerKwh: number
waterM3: number
coolingKwh: number
}[]
}
export interface BillingReport {
summary: {
from: string
to: string
days: number
powerKwh: number
waterM3: number
coolingKwh: number
powerCost: number
waterCost: number
coolingCost: number
serviceFee: number
tax: number
total: number
}
rows: DailyBill[]
}
export type LightStatus = 'online' | 'offline' | 'fault'
export type LightProtocol = 'modbus' | 'http' | 'tcp'
export interface LightDevice {
id: string
name: string
zone: string
circuit: number
powerOn: boolean
status: LightStatus
enabled: boolean
brightness: number
remark: string
createdAt: string
updatedAt: string
lastSwitchAt: string | null
lastSwitchBy: string | null
}
export interface LightCommConfig {
host: string
port: number
protocol: LightProtocol
unitId: number
username: string
passwordSet: boolean
passwordMasked: string
pollMs: number
enabled: boolean
updatedAt: string
updatedBy: string
}
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