Commit 0903597b by James Yuan

feat: 大屏真数据面板、时序曲线与告警/天气体验

接入设备台账与时序落库驱动左右面板历史曲线,修复机组遥测误标写导致空值、三维刷新闪变与视点条丢失;告警改右上角顶层下拉,天气改拉澳门天文台。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 1716214e
services: services:
# 时序:默认 postgres;可选改 image 为 timescale/timescaledb:latest-pg16(需重建卷)
db: db:
image: postgres:16-alpine image: ${POSTGRES_IMAGE:-postgres:16-alpine}
container_name: jh-lab-db container_name: jh-lab-db
restart: unless-stopped restart: unless-stopped
environment: environment:
......
...@@ -1976,6 +1976,22 @@ ...@@ -1976,6 +1976,22 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
...@@ -4108,6 +4124,16 @@ ...@@ -4108,6 +4124,16 @@
} }
} }
}, },
"node_modules/vue-echarts": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-8.3.2.tgz",
"integrity": "sha512-FbJD49OZZZv1I8aclHQ+gcQxU+Y9nNWQMuod1sewE7/YjGQUJAaeVcf6WrSfQDBRPke+WoVsUtdRFIqPOsRrnw==",
"license": "MIT",
"peerDependencies": {
"echarts": "^6.0.0",
"vue": "^3.3.0"
}
},
"node_modules/vue-router": { "node_modules/vue-router": {
"version": "4.6.4", "version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
...@@ -4237,6 +4263,21 @@ ...@@ -4237,6 +4263,21 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"server": { "server": {
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
...@@ -4275,8 +4316,10 @@ ...@@ -4275,8 +4316,10 @@
"web": { "web": {
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"echarts": "^6.1.0",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"vue": "^3.5.42", "vue": "^3.5.42",
"vue-echarts": "^8.3.2",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
......
...@@ -11,6 +11,11 @@ export const config = { ...@@ -11,6 +11,11 @@ export const config = {
databaseUrl: databaseUrl:
process.env.DATABASE_URL || process.env.DATABASE_URL ||
'postgres://jh_lab:jh_lab_pass@127.0.0.1:5432/jh_lab', 'postgres://jh_lab:jh_lab_pass@127.0.0.1:5432/jh_lab',
/**
* 网关模式下,对超过该毫秒未更新的点位继续用模拟值补发,保证大屏/时序不断流。
* 0 = 关闭补发。
*/
mockFillStaleMs: Number(process.env.MOCK_FILL_STALE_MS || 60_000),
/** 采集明细保留天数,0 表示不清理 */ /** 采集明细保留天数,0 表示不清理 */
collectRetentionDays: Number(process.env.COLLECT_RETENTION_DAYS || 90), collectRetentionDays: Number(process.env.COLLECT_RETENTION_DAYS || 90),
/** 单一 Windows 协议网关 */ /** 单一 Windows 协议网关 */
......
import type { RealtimeSnapshot } from '../types.js' import type { RealtimeSnapshot } from '../types.js'
import { config } from '../config.js' import { config } from '../config.js'
import { listDevices } from '../devices/store.js'
import { pool, query } from './pool.js' import { pool, query } from './pool.js'
import { isPgEnabled } from './state.js' import { isPgEnabled } from './state.js'
import {
persistSnapshotTimeseries,
pruneTimeseriesData,
queryTsSeries,
} from './timeseries.js'
/** 将一次采集快照完整入库(整包 + 分项),供本地追溯与计费汇总 */ /** 整包快照降频落库(审计用);明细走时序窄表 */
function snapshotMinIntervalMs() {
return Number(process.env.COLLECT_SNAPSHOT_INTERVAL_MS || 15_000)
}
let lastSnapshotWriteMs = 0
/** 将一次采集快照入库:时序明细 + 降频整包 */
export async function persistSnapshot(snap: RealtimeSnapshot) { export async function persistSnapshot(snap: RealtimeSnapshot) {
if (!isPgEnabled()) return if (!isPgEnabled()) return
const ts = new Date(snap.ts || Date.now())
// 1) 窄表时序(主路径,供大屏曲线高效读取)
try {
await persistSnapshotTimeseries(snap)
} catch (e) {
console.error('[db] persist timeseries failed', e)
}
const now = Date.now()
if (now - lastSnapshotWriteMs < snapshotMinIntervalMs()) return
lastSnapshotWriteMs = now
const ts = new Date(snap.ts || now)
const client = await pool.connect() const client = await pool.connect()
try { try {
await client.query('BEGIN') await client.query('BEGIN')
await client.query( await client.query(`INSERT INTO collect_snapshots (ts, payload) VALUES ($1, $2::jsonb)`, [
`INSERT INTO collect_snapshots (ts, payload) VALUES ($1, $2::jsonb)`, ts,
[ts, JSON.stringify(snap)], JSON.stringify(snap),
) ])
// 兼容旧查询:保留少量 JSON 分项(同样降频)
const rows: [Date, string, string | null, string][] = [] const rows: [Date, string, string | null, string][] = []
for (const r of snap.rooms) { for (const r of snap.rooms) rows.push([ts, 'room', r.id, JSON.stringify(r)])
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)])
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, 'hvac', 'PAU201', JSON.stringify(snap.hvac)])
rows.push([ts, 'metering', 'lab', JSON.stringify(snap.metering)]) 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) { if (rows.length) {
const values: unknown[] = [] const values: unknown[] = []
...@@ -45,7 +62,6 @@ export async function persistSnapshot(snap: RealtimeSnapshot) { ...@@ -45,7 +62,6 @@ export async function persistSnapshot(snap: RealtimeSnapshot) {
) )
} }
// 当日计量累计写入计费日表(取最新快照计量值作为日用量参考)
const day = ts.toISOString().slice(0, 10) const day = ts.toISOString().slice(0, 10)
const power = snap.metering.powerKwh const power = snap.metering.powerKwh
const water = snap.metering.waterM3 const water = snap.metering.waterM3
...@@ -88,11 +104,91 @@ export async function persistWriteLog(action: string, detail: Record<string, unk ...@@ -88,11 +104,91 @@ export async function persistWriteLog(action: string, detail: Record<string, unk
) )
} }
/** 清理过期采集数据,默认保留 90 天 */ export type FridgeTempPoint = { t: number; temp: number }
export type FridgeTempSeries = {
id: string
name: string
points: FridgeTempPoint[]
}
/**
* 近 N 小时冰箱温度曲线。
* 优先 ts_rollups 预聚合;旧 collect_samples 作回退。
*/
export async function queryFridgeTempSeries(opts?: {
hours?: number
bucketMinutes?: number
}): Promise<FridgeTempSeries[]> {
if (!isPgEnabled()) return []
const hours = Math.min(168, Math.max(1, opts?.hours ?? 24))
const bucketMinutes = Math.min(60, Math.max(5, opts?.bucketMinutes ?? 15))
try {
const series = await queryTsSeries({
source: 'fridge',
metric: 'temp',
hours,
// 24h 内用 1min 桶,曲线更密
bucketMinutes: hours <= 24 ? Math.min(bucketMinutes, 1) || 1 : bucketMinutes,
})
if (series.length) {
const fridges = await listDevices({ category: 'fridge' })
const nameById = new Map(fridges.map((d) => [d.id, d.name]))
return series.map((s) => ({
id: s.id,
name: nameById.get(s.id) || s.name || s.id,
points: s.points.map((p) => ({ t: p.t, temp: p.v })),
}))
}
} catch (e) {
console.warn('[db] queryTsSeries fridge failed, fallback collect_samples', e)
}
const { rows } = await query<{
device_id: string
name: string | null
bucket: Date
temp: string | number
}>(
`SELECT
device_id,
MAX(payload->>'name') AS name,
to_timestamp(floor(extract(epoch FROM ts) / ($2 * 60)) * ($2 * 60)) AS bucket,
AVG(NULLIF(payload->>'temp', '')::float) AS temp
FROM collect_samples
WHERE source = 'fridge'
AND device_id IS NOT NULL
AND ts >= NOW() - ($1::text || ' hours')::interval
AND payload ? 'temp'
GROUP BY device_id, bucket
HAVING AVG(NULLIF(payload->>'temp', '')::float) IS NOT NULL
ORDER BY device_id, bucket`,
[String(hours), bucketMinutes],
)
const map = new Map<string, FridgeTempSeries>()
for (const r of rows) {
const id = String(r.device_id)
let series = map.get(id)
if (!series) {
series = { id, name: r.name || id, points: [] }
map.set(id, series)
}
if (r.name) series.name = r.name
const temp = Number(r.temp)
if (!Number.isFinite(temp)) continue
const t = r.bucket instanceof Date ? r.bucket.getTime() : new Date(r.bucket).getTime()
series.points.push({ t, temp })
}
return [...map.values()]
}
/** 清理过期采集数据 */
export async function pruneCollectData() { export async function pruneCollectData() {
if (!isPgEnabled()) return if (!isPgEnabled()) return
const days = config.collectRetentionDays const days = config.collectRetentionDays
if (days <= 0) return if (days <= 0) return
await pruneTimeseriesData()
await query(`DELETE FROM collect_samples WHERE ts < NOW() - ($1::text || ' days')::interval`, [ await query(`DELETE FROM collect_samples WHERE ts < NOW() - ($1::text || ' days')::interval`, [
String(days), String(days),
]) ])
......
import { pool, query } from './pool.js' import { pool, query } from './pool.js'
import { ensureTimeseriesSchema } from './timeseries.js'
const SQL = ` const SQL = `
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
...@@ -178,9 +179,23 @@ ALTER TABLE device_points ADD COLUMN IF NOT EXISTS alarm_low DOUBLE PRECISION; ...@@ -178,9 +179,23 @@ ALTER TABLE device_points ADD COLUMN IF NOT EXISTS alarm_low DOUBLE PRECISION;
ALTER TABLE device_points ADD COLUMN IF NOT EXISTS alarm_high DOUBLE PRECISION; ALTER TABLE device_points ADD COLUMN IF NOT EXISTS alarm_high DOUBLE PRECISION;
` `
/** 纠正房间/冰箱传感器被 CSV 误标为写点的问题 */
const FIX_SENSOR_RW = `
UPDATE device_points
SET rw = 'r', updated_at = NOW()
WHERE rw = 'w'
AND (
name ~ '实时温度|实时湿度|实时压差|冰箱温度|冰箱湿度'
OR item_id ~ '实时温度|实时湿度|实时压差|冰箱温度|冰箱湿度'
)
AND name !~ '设定|上限|下限|高限|低限|目标|地址'
`
export async function migrate() { export async function migrate() {
await query(SQL) await query(SQL)
await query(ALTER) await query(ALTER)
await query(FIX_SENSOR_RW)
await ensureTimeseriesSchema()
console.log('[db] schema migrated') console.log('[db] schema migrated')
} }
......
import type { RealtimeSnapshot } from '../types.js'
import { config } from '../config.js'
import { pool, query } from './pool.js'
import { isPgEnabled } from './state.js'
/** 窄表时序:一设备一指标一行,便于按 series 范围扫描 */
export const TS_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS ts_samples (
ts TIMESTAMPTZ NOT NULL,
series_id TEXT NOT NULL,
device_id TEXT NOT NULL,
source TEXT NOT NULL,
metric TEXT NOT NULL,
eng DOUBLE PRECISION NOT NULL,
raw DOUBLE PRECISION
);
CREATE INDEX IF NOT EXISTS idx_ts_samples_series_ts ON ts_samples (series_id, ts DESC);
CREATE INDEX IF NOT EXISTS idx_ts_samples_source_metric_ts ON ts_samples (source, metric, ts DESC);
CREATE INDEX IF NOT EXISTS idx_ts_samples_ts ON ts_samples (ts DESC);
CREATE TABLE IF NOT EXISTS ts_rollups (
bucket TIMESTAMPTZ NOT NULL,
bucket_minutes INT NOT NULL,
series_id TEXT NOT NULL,
device_id TEXT NOT NULL,
source TEXT NOT NULL,
metric TEXT NOT NULL,
avg_eng DOUBLE PRECISION NOT NULL,
min_eng DOUBLE PRECISION NOT NULL,
max_eng DOUBLE PRECISION NOT NULL,
n INT NOT NULL,
PRIMARY KEY (series_id, bucket_minutes, bucket)
);
CREATE INDEX IF NOT EXISTS idx_ts_rollups_source_metric_bucket
ON ts_rollups (source, metric, bucket_minutes, bucket DESC);
CREATE INDEX IF NOT EXISTS idx_ts_rollups_device_metric_bucket
ON ts_rollups (device_id, metric, bucket_minutes, bucket DESC);
`
let timescaleTried = false
let timescaleOk = false
/** 可选:若镜像带 TimescaleDB 则转成超表,压缩与保留策略更省 */
export async function ensureTimeseriesSchema() {
if (!isPgEnabled()) return
await query(TS_SCHEMA_SQL)
if (!timescaleTried) {
timescaleTried = true
try {
const { rows } = await query<{ name: string }>(
`SELECT name FROM pg_available_extensions WHERE name = 'timescaledb'`,
)
if (!rows.length) {
console.log('[db] 时序表已就绪(原生 PG + 15min 预聚合;未安装 TimescaleDB)')
} else {
await query(`CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE`)
await query(
`SELECT create_hypertable('ts_samples', 'ts', if_not_exists => TRUE, migrate_data => TRUE)`,
)
timescaleOk = true
console.log('[db] TimescaleDB hypertable ready: ts_samples')
try {
await query(
`SELECT add_retention_policy('ts_samples', INTERVAL '${Math.max(1, config.collectRetentionDays)} days', if_not_exists => TRUE)`,
)
} catch {
/* 社区版/权限不足时忽略 */
}
}
} catch (e) {
timescaleOk = false
console.warn('[db] timeseries extension probe failed', e instanceof Error ? e.message : e)
}
}
}
export function seriesId(source: string, deviceId: string, metric: string) {
return `${source}:${deviceId}:${metric}`
}
export type TsSampleRow = {
ts: Date
seriesId: string
deviceId: string
source: string
metric: string
eng: number
raw?: number | null
}
const lastWriteMs = new Map<string, number>()
function minRawIntervalMs() {
return Number(process.env.TS_MIN_INTERVAL_MS || 1000)
}
function allowWrite(series: string, tsMs: number) {
const prev = lastWriteMs.get(series) || 0
if (tsMs - prev < minRawIntervalMs()) return false
lastWriteMs.set(series, tsMs)
return true
}
function bucketStart(ts: Date, bucketMinutes: number) {
const ms = bucketMinutes * 60_000
return new Date(Math.floor(ts.getTime() / ms) * ms)
}
const ROLLUP_BUCKETS = [1, 15] // 1min 密曲线 + 15min 长周期
async function upsertRollup(
client: { query: (sql: string, params?: unknown[]) => Promise<unknown> },
r: TsSampleRow,
bucketMinutes: number,
) {
const bucket = bucketStart(r.ts, bucketMinutes)
await client.query(
`INSERT INTO ts_rollups (
bucket, bucket_minutes, series_id, device_id, source, metric,
avg_eng, min_eng, max_eng, n
) VALUES ($1,$2,$3,$4,$5,$6,$7,$7,$7,1)
ON CONFLICT (series_id, bucket_minutes, bucket) DO UPDATE SET
avg_eng = (ts_rollups.avg_eng * ts_rollups.n + EXCLUDED.avg_eng) / (ts_rollups.n + 1),
min_eng = LEAST(ts_rollups.min_eng, EXCLUDED.min_eng),
max_eng = GREATEST(ts_rollups.max_eng, EXCLUDED.max_eng),
n = ts_rollups.n + 1`,
[bucket, bucketMinutes, r.seriesId, r.deviceId, r.source, r.metric, r.eng],
)
}
/** 批量写入原始点,并增量更新 1min/15min 预聚合 */
export async function insertTsSamples(rows: TsSampleRow[], _bucketMinutes = 15) {
if (!isPgEnabled() || !rows.length) return 0
const filtered = rows.filter((r) => Number.isFinite(r.eng) && allowWrite(r.seriesId, r.ts.getTime()))
if (!filtered.length) return 0
const client = await pool.connect()
try {
await client.query('BEGIN')
const values: unknown[] = []
const places = filtered
.map((r, i) => {
const o = i * 7
values.push(r.ts, r.seriesId, r.deviceId, r.source, r.metric, r.eng, r.raw ?? null)
return `($${o + 1},$${o + 2},$${o + 3},$${o + 4},$${o + 5},$${o + 6},$${o + 7})`
})
.join(',')
await client.query(
`INSERT INTO ts_samples (ts, series_id, device_id, source, metric, eng, raw)
VALUES ${places}`,
values,
)
for (const r of filtered) {
for (const bm of ROLLUP_BUCKETS) {
await upsertRollup(client, r, bm)
}
}
await client.query('COMMIT')
return filtered.length
} catch (e) {
await client.query('ROLLBACK')
console.error('[db] insertTsSamples failed', e)
return 0
} finally {
client.release()
}
}
/** 从业务快照抽取关键指标写入时序(避免 800 点全量 JSON 落库) */
export async function persistSnapshotTimeseries(snap: RealtimeSnapshot) {
if (!isPgEnabled()) return
const ts = new Date(snap.ts || Date.now())
const rows: TsSampleRow[] = []
for (const r of snap.rooms) {
rows.push(
{
ts,
seriesId: seriesId('room', r.id, 'temp'),
deviceId: r.id,
source: 'room',
metric: 'temp',
eng: r.temp,
},
{
ts,
seriesId: seriesId('room', r.id, 'humidity'),
deviceId: r.id,
source: 'room',
metric: 'humidity',
eng: r.humidity,
},
{
ts,
seriesId: seriesId('room', r.id, 'pressure'),
deviceId: r.id,
source: 'room',
metric: 'pressure',
eng: r.pressure,
},
)
}
for (const f of snap.fridges) {
rows.push(
{
ts,
seriesId: seriesId('fridge', f.id, 'temp'),
deviceId: f.id,
source: 'fridge',
metric: 'temp',
eng: f.temp,
},
{
ts,
seriesId: seriesId('fridge', f.id, 'humidity'),
deviceId: f.id,
source: 'fridge',
metric: 'humidity',
eng: f.humidity,
},
)
}
for (const v of snap.vav) {
rows.push(
{
ts,
seriesId: seriesId('vav', v.id, 'airflow'),
deviceId: v.id,
source: 'vav',
metric: 'airflow',
eng: v.airflow,
},
{
ts,
seriesId: seriesId('vav', v.id, 'face_velocity'),
deviceId: v.id,
source: 'vav',
metric: 'face_velocity',
eng: v.faceVelocity,
},
)
}
const h = snap.hvac
rows.push(
{
ts,
seriesId: seriesId('hvac', 'PAU201', 'supply_temp'),
deviceId: 'PAU201',
source: 'hvac',
metric: 'supply_temp',
eng: h.supplyTemp,
},
{
ts,
seriesId: seriesId('hvac', 'PAU201', 'supply_humidity'),
deviceId: 'PAU201',
source: 'hvac',
metric: 'supply_humidity',
eng: h.supplyHumidity,
},
{
ts,
seriesId: seriesId('hvac', 'PAU201', 'supply_pressure'),
deviceId: 'PAU201',
source: 'hvac',
metric: 'supply_pressure',
eng: h.supplyPressure,
},
{
ts,
seriesId: seriesId('hvac', 'PAU201', 'fresh_air_temp'),
deviceId: 'PAU201',
source: 'hvac',
metric: 'fresh_air_temp',
eng: h.freshAirTemp,
},
{
ts,
seriesId: seriesId('hvac', 'PAU201', 'exhaust_pressure'),
deviceId: 'PAU201',
source: 'hvac',
metric: 'exhaust_pressure',
eng: h.exhaustPressure,
},
)
const m = snap.metering
rows.push(
{
ts,
seriesId: seriesId('metering', 'lab', 'power_kwh'),
deviceId: 'lab',
source: 'metering',
metric: 'power_kwh',
eng: m.powerKwh,
},
{
ts,
seriesId: seriesId('metering', 'lab', 'water_m3'),
deviceId: 'lab',
source: 'metering',
metric: 'water_m3',
eng: m.waterM3,
},
{
ts,
seriesId: seriesId('metering', 'lab', 'cooling_kwh'),
deviceId: 'lab',
source: 'metering',
metric: 'cooling_kwh',
eng: m.coolingKwh,
},
)
await insertTsSamples(rows)
}
export type TsSeriesPoint = { t: number; v: number }
export type TsSeries = { id: string; name?: string; points: TsSeriesPoint[] }
/** 优先读 15min 预聚合;无数据时回退原始点桶聚合 */
export async function queryTsSeries(opts: {
source: string
metric: string
hours?: number
bucketMinutes?: number
deviceIds?: string[]
}): Promise<TsSeries[]> {
if (!isPgEnabled()) return []
const hours = Math.min(168, Math.max(1, opts.hours ?? 24))
// 短窗口优先 1min 桶(更密);长窗口用请求粒度或 15min
const wantBucket = Math.min(60, Math.max(1, opts.bucketMinutes ?? 15))
const bucket = hours <= 24 && wantBucket <= 5 ? 1 : wantBucket === 1 ? 1 : wantBucket
const params: unknown[] = [opts.source, opts.metric, String(hours), bucket]
let deviceFilter = ''
if (opts.deviceIds?.length) {
params.push(opts.deviceIds)
deviceFilter = ` AND device_id = ANY($${params.length}::text[])`
}
let { rows: rollupRows } = await query<{
device_id: string
bucket: Date
avg_eng: string | number
}>(
`SELECT device_id, bucket, avg_eng
FROM ts_rollups
WHERE source = $1 AND metric = $2 AND bucket_minutes = $4
AND bucket >= NOW() - ($3::text || ' hours')::interval
${deviceFilter}
ORDER BY device_id, bucket`,
params,
)
// 1min 尚无数据时回退 15min
if (!rollupRows.length && bucket === 1) {
const fallback = await query<{
device_id: string
bucket: Date
avg_eng: string | number
}>(
`SELECT device_id, bucket, avg_eng
FROM ts_rollups
WHERE source = $1 AND metric = $2 AND bucket_minutes = 15
AND bucket >= NOW() - ($3::text || ' hours')::interval
${deviceFilter}
ORDER BY device_id, bucket`,
params,
)
rollupRows = fallback.rows
}
if (rollupRows.length) {
const map = new Map<string, TsSeries>()
for (const r of rollupRows) {
let s = map.get(r.device_id)
if (!s) {
s = { id: r.device_id, points: [] }
map.set(r.device_id, s)
}
const v = Number(r.avg_eng)
if (!Number.isFinite(v)) continue
const t = r.bucket instanceof Date ? r.bucket.getTime() : new Date(r.bucket).getTime()
s.points.push({ t, v })
}
return [...map.values()]
}
// 回退:原始点按桶聚合
const { rows } = await query<{
device_id: string
bucket: Date
avg_eng: string | number
}>(
`SELECT
device_id,
to_timestamp(floor(extract(epoch FROM ts) / ($4 * 60)) * ($4 * 60)) AS bucket,
AVG(eng) AS avg_eng
FROM ts_samples
WHERE source = $1 AND metric = $2
AND ts >= NOW() - ($3::text || ' hours')::interval
${deviceFilter}
GROUP BY device_id, bucket
ORDER BY device_id, bucket`,
params,
)
const map = new Map<string, TsSeries>()
for (const r of rows) {
let s = map.get(r.device_id)
if (!s) {
s = { id: r.device_id, points: [] }
map.set(r.device_id, s)
}
const v = Number(r.avg_eng)
if (!Number.isFinite(v)) continue
const t = r.bucket instanceof Date ? r.bucket.getTime() : new Date(r.bucket).getTime()
s.points.push({ t, v })
}
return [...map.values()]
}
export async function pruneTimeseriesData() {
if (!isPgEnabled()) return
const days = config.collectRetentionDays
if (days <= 0) return
// 原始点保留较短窗口;预聚合保留全周期
const rawDays = Math.min(days, Number(process.env.TS_RAW_RETENTION_DAYS || 14))
await query(`DELETE FROM ts_samples WHERE ts < NOW() - ($1::text || ' days')::interval`, [
String(rawDays),
])
await query(`DELETE FROM ts_rollups WHERE bucket < NOW() - ($1::text || ' days')::interval`, [
String(days),
])
}
export function isTimescaleEnabled() {
return timescaleOk
}
export type TsCatalogItem = {
source: string
metric: string
deviceId: string
seriesId: string
points: number
lastBucket: string | null
}
/** 已落库的可查序列目录(优先 rollup,回退 samples) */
export async function listTsCatalog(): Promise<TsCatalogItem[]> {
if (!isPgEnabled()) return []
const { rows } = await query<{
source: string
metric: string
device_id: string
series_id: string
points: string | number
last_bucket: Date | null
}>(
`SELECT source, metric, device_id, series_id,
COUNT(*)::int AS points,
MAX(bucket) AS last_bucket
FROM ts_rollups
GROUP BY source, metric, device_id, series_id
ORDER BY source, metric, device_id`,
)
if (rows.length) {
return rows.map((r) => ({
source: r.source,
metric: r.metric,
deviceId: r.device_id,
seriesId: r.series_id,
points: Number(r.points) || 0,
lastBucket: r.last_bucket
? r.last_bucket instanceof Date
? r.last_bucket.toISOString()
: String(r.last_bucket)
: null,
}))
}
const { rows: raw } = await query<{
source: string
metric: string
device_id: string
series_id: string
points: string | number
last_ts: Date | null
}>(
`SELECT source, metric, device_id, series_id,
COUNT(*)::int AS points,
MAX(ts) AS last_ts
FROM ts_samples
GROUP BY source, metric, device_id, series_id
ORDER BY source, metric, device_id`,
)
return raw.map((r) => ({
source: r.source,
metric: r.metric,
deviceId: r.device_id,
seriesId: r.series_id,
points: Number(r.points) || 0,
lastBucket: r.last_ts
? r.last_ts instanceof Date
? r.last_ts.toISOString()
: String(r.last_ts)
: null,
}))
}
/** 批量拉取多组 source/metric,并附设备名 */
export async function queryNamedTsSeries(
queries: { key: string; source: string; metric: string; deviceIds?: string[] }[],
opts?: { hours?: number; bucketMinutes?: number },
): Promise<Record<string, TsSeries[]>> {
const hours = opts?.hours ?? 24
const bucketMinutes = opts?.bucketMinutes ?? 15
const out: Record<string, TsSeries[]> = {}
await Promise.all(
queries.map(async (q) => {
out[q.key] = await queryTsSeries({
source: q.source,
metric: q.metric,
hours,
bucketMinutes,
deviceIds: q.deviceIds,
})
}),
)
return out
}
...@@ -25,12 +25,21 @@ function levelFor(p: DevicePoint, kind: PointAlarmKind): PointAlarmLevel { ...@@ -25,12 +25,21 @@ function levelFor(p: DevicePoint, kind: PointAlarmKind): PointAlarmLevel {
return 'amber' return 'amber'
} }
/** 根据点位 alarmLow/alarmHigh 与 lastEng 判定当前超限告警 */ const FRESH_MS = 5 * 60_000
function isFresh(p: DevicePoint): boolean {
if (!p.lastTs) return false
const t = Date.parse(p.lastTs)
return Number.isFinite(t) && Date.now() - t <= FRESH_MS
}
/** 根据点位 alarmLow/alarmHigh 与 lastEng 判定当前超限告警(仅新鲜遥测) */
export function evaluateAlarmsFromPoints(points: DevicePoint[]): PointAlarm[] { export function evaluateAlarmsFromPoints(points: DevicePoint[]): PointAlarm[] {
const out: PointAlarm[] = [] const out: PointAlarm[] = []
for (const p of points) { for (const p of points) {
if (!p.enabled) continue if (!p.enabled) continue
if (p.lastEng === null || p.lastEng === undefined || Number.isNaN(Number(p.lastEng))) continue if (p.lastEng === null || p.lastEng === undefined || Number.isNaN(Number(p.lastEng))) continue
if (!isFresh(p)) continue
const hasLow = p.alarmLow !== null && p.alarmLow !== undefined const hasLow = p.alarmLow !== null && p.alarmLow !== undefined
const hasHigh = p.alarmHigh !== null && p.alarmHigh !== undefined const hasHigh = p.alarmHigh !== null && p.alarmHigh !== undefined
if (!hasLow && !hasHigh) continue if (!hasLow && !hasHigh) continue
......
...@@ -9,6 +9,26 @@ export type PointRwContext = { ...@@ -9,6 +9,26 @@ export type PointRwContext = {
} }
/** /**
* 房间屏采集量:CSV 常误标 -F6 为写,但「实时温度/湿度/压差」实为传感器读数。
*/
export function isRoomSensorPoint(ctx: PointRwContext = {}): boolean {
const name = `${ctx.name || ''} ${ctx.itemId || ''}`
if (/设定|上限|下限|高限|低限|目标|地址/.test(name)) return false
return /实时温度|实时湿度|实时压差|冰箱温度|冰箱湿度/.test(name)
}
/**
* 机组/VAV 运行显示量:CSV 常把 Holding 标成 -F6、把状态线圈标成 -F5,
* 但「送风温度显示 / 实际风量 / 开停状态」等实为遥测,应参与周期采集。
*/
export function isHvacTelemetryPoint(ctx: PointRwContext = {}): boolean {
const name = `${ctx.name || ''} ${ctx.itemId || ''}`
if (/设定|校准|命令|复位|给定|手自动|地址|上限|下限/.test(name)) return false
if (/显示/.test(name)) return true
return /实际风量|实际面风速|开停状态|系统启动信号/.test(name)
}
/**
* 计量仪表测点:现场 CSV 常把 Holding 一律标成 `-F6`,但电表/水表(流量计)/能量计 * 计量仪表测点:现场 CSV 常把 Holding 一律标成 `-F6`,但电表/水表(流量计)/能量计
* 点名均为电压/电流/流量/热量等采集量,Description 为空,无写控说明 → 强制只读。 * 点名均为电压/电流/流量/热量等采集量,Description 为空,无写控说明 → 强制只读。
*/ */
...@@ -42,8 +62,8 @@ export function inferPointRw( ...@@ -42,8 +62,8 @@ export function inferPointRw(
fallback: PointRw = 'r', fallback: PointRw = 'r',
ctx: PointRwContext = {}, ctx: PointRwContext = {},
): PointRw { ): PointRw {
// 电表 / 流量计(水) / 能量计:业务上只采集,忽略 CSV 上的 -F6/-F16 误标 // 房间/冰箱传感器、机组显示量、电表等:业务上只采集,忽略 CSV 上的 -F6/-F16 误标
if (isMeterTelemetryPoint(ctx)) return 'r' if (isRoomSensorPoint(ctx) || isHvacTelemetryPoint(ctx) || isMeterTelemetryPoint(ctx)) return 'r'
const t = (regType || '').toUpperCase() const t = (regType || '').toUpperCase()
if (!t) return fallback if (!t) return fallback
...@@ -72,7 +92,7 @@ export function isReadablePoint(p: { ...@@ -72,7 +92,7 @@ export function isReadablePoint(p: {
sourceCsv: p.sourceCsv, sourceCsv: p.sourceCsv,
category: p.category, category: p.category,
} }
if (isMeterTelemetryPoint(ctx)) return true if (isRoomSensorPoint(ctx) || isHvacTelemetryPoint(ctx) || isMeterTelemetryPoint(ctx)) return true
const rw = inferPointRw(p.regType || '', (p.rw as 'r' | 'w' | 'rw') || 'r', ctx).toLowerCase() const rw = inferPointRw(p.regType || '', (p.rw as 'r' | 'w' | 'rw') || 'r', ctx).toLowerCase()
return rw !== 'w' return rw !== 'w'
} }
...@@ -921,6 +921,25 @@ export async function applyPointTelemetry(samples: PointTelemetrySample[]): Prom ...@@ -921,6 +921,25 @@ export async function applyPointTelemetry(samples: PointTelemetrySample[]): Prom
/** Mock/网关旁路:按点位生成可读工程值,便于后台联调 */ /** Mock/网关旁路:按点位生成可读工程值,便于后台联调 */
export async function applyMockPointTelemetry(): Promise<number> { export async function applyMockPointTelemetry(): Promise<number> {
const points = (await listPoints()).filter((p) => p.enabled && isReadablePoint(p)) const points = (await listPoints()).filter((p) => p.enabled && isReadablePoint(p))
return applyMockSamples(points)
}
/**
* 仅对超过 staleMs 未更新的可读点补发模拟值(网关部分在线时保持时序不断流)。
*/
export async function applyMockPointTelemetryStale(staleMs: number): Promise<number> {
if (staleMs <= 0) return 0
const cutoff = Date.now() - staleMs
const points = (await listPoints()).filter((p) => {
if (!p.enabled || !isReadablePoint(p)) return false
if (!p.lastTs) return true
const t = Date.parse(p.lastTs)
return !Number.isFinite(t) || t < cutoff
})
return applyMockSamples(points)
}
async function applyMockSamples(points: DevicePoint[]): Promise<number> {
if (!points.length) return 0 if (!points.length) return 0
const t = Date.now() const t = Date.now()
const samples: PointTelemetrySample[] = points.map((p, i) => { const samples: PointTelemetrySample[] = points.map((p, i) => {
...@@ -935,7 +954,7 @@ export async function applyMockPointTelemetry(): Promise<number> { ...@@ -935,7 +954,7 @@ export async function applyMockPointTelemetry(): Promise<number> {
p.minEngRange, p.minEngRange,
p.maxEngRange, p.maxEngRange,
) )
return { id: p.id, itemId: p.itemId, raw, eng, ts: now(), quality: 'good' } return { id: p.id, itemId: p.itemId, raw, eng, ts: now(), quality: 'simulated' }
}) })
return applyPointTelemetry(samples) return applyPointTelemetry(samples)
} }
...@@ -946,9 +965,25 @@ function mockRaw(p: DevicePoint, t: number, i: number): number { ...@@ -946,9 +965,25 @@ function mockRaw(p: DevicePoint, t: number, i: number): number {
if (/Boolean|Bit/i.test(p.dataType) || /开停|状态|到位|信号|开关/.test(name)) { if (/Boolean|Bit/i.test(p.dataType) || /开停|状态|到位|信号|开关/.test(name)) {
return wave > 0 ? 1 : 0 return wave > 0 ? 1 : 0
} }
if (/温度|temp/i.test(name)) return Math.round((-22 + wave * 3) * 10) // *0.1 → ℃ // 原始寄存器常按 ×0.1 工程化;按业务给合理 raw
if (/湿度|humid/i.test(name)) return Math.round((45 + wave * 15) * 10) if (/冰箱|冷链|fridge/i.test(name) && /温度|temp/i.test(name)) {
if (/压差|pressure|压力/.test(name)) return Math.round((-15 + wave * 8) * 10) return Math.round((-20 + wave * 1.2) * 10)
}
if (/冰箱|冷链|fridge/i.test(name) && /湿度|humid/i.test(name)) {
return Math.round((40 + wave * 8) * 10)
}
if (/送风温度|新风温度|排风温度|表冷后温度|实时温度|^温度$|temp/i.test(name)) {
return Math.round((22.5 + wave * 1.2) * 10)
}
if (/送风湿度|新风湿度|排风湿度|表冷后湿度|实时湿度|^湿度$|humid/i.test(name)) {
return Math.round((48 + wave * 6) * 10)
}
if (/送风压力|排风压力|实时压差|压差|pressure|压力/.test(name)) {
return Math.round((-12 + wave * 4) * 10)
}
if (/实际风量/.test(name)) return Math.round((650 + wave * 80) * 10)
if (/实际面风速/.test(name)) return Math.round((0.45 + wave * 0.08) * 100)
if (/用电量|有功电能/.test(name)) return Math.round((480 + wave * 20 + (i % 9)) * 10)
if (/电压|电流|功率|电|流量|流速|能量/.test(name)) return Math.round((100 + wave * 40 + (i % 7)) * 10) / 10 if (/电压|电流|功率|电|流量|流速|能量/.test(name)) return Math.round((100 + wave * 40 + (i % 7)) * 10) / 10
if (/Float|Double/i.test(p.dataType)) return +(12 + wave * 5 + (i % 5)).toFixed(3) if (/Float|Double/i.test(p.dataType)) return +(12 + wave * 5 + (i % 5)).toFixed(3)
return Math.round(100 + wave * 30 + (i % 11)) return Math.round(100 + wave * 30 + (i % 11))
......
...@@ -22,6 +22,7 @@ import { lightRouter } from './routes/light.js' ...@@ -22,6 +22,7 @@ import { lightRouter } from './routes/light.js'
import { devicesRouter } from './routes/devices.js' import { devicesRouter } from './routes/devices.js'
import { gatewayRouter } from './routes/gateway.js' import { gatewayRouter } from './routes/gateway.js'
import { alarmsRouter } from './routes/alarms.js' import { alarmsRouter } from './routes/alarms.js'
import { weatherRouter } from './routes/weather.js'
import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js' import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js'
async function bootstrap() { async function bootstrap() {
...@@ -69,6 +70,7 @@ async function bootstrap() { ...@@ -69,6 +70,7 @@ async function bootstrap() {
app.use('/api/light', lightRouter) app.use('/api/light', lightRouter)
app.use('/api/devices', devicesRouter) app.use('/api/devices', devicesRouter)
app.use('/api/alarms', alarmsRouter) app.use('/api/alarms', alarmsRouter)
app.use('/api/weather', weatherRouter)
/** Windows 网关上行:拉点表 / 推测点落库 */ /** Windows 网关上行:拉点表 / 推测点落库 */
app.use('/api/gateway', gatewayRouter) app.use('/api/gateway', gatewayRouter)
......
import { evaluatePointAlarms } from '../devices/alarms.js'
import { listDevices, listGateways, listPoints } from '../devices/store.js'
import type { Device, DevicePoint } from '../devices/types.js'
import type { FridgeSnapshot, RealtimeSnapshot, RoomSnapshot, VavSnapshot } from '../types.js'
/** 与前端 PROTOCOL_ROOMS / 场景书签对齐的稳定房间 id */
const ROOM_ID_BY_NAME: Record<string, string> = {
收样咨询室: 'recv',
生信分析报告室: 'report',
试剂准备间: 'reagent',
PCR走廊: 'pcr-corridor',
样本制备区1: 'sample-1',
样本制备区2: 'sample-2',
样本制备区缓冲: 'sample-buf',
文库制备区1: 'lib-1',
文库制备区1缓冲: 'lib-1-buf',
文库制备区2: 'lib-2',
文库制备区2缓冲: 'lib-2-buf',
测序区: 'seq',
测序区缓冲: 'seq-buf',
}
function stableRoomId(d: Device): string {
if (ROOM_ID_BY_NAME[d.name]) return ROOM_ID_BY_NAME[d.name]
for (const [name, id] of Object.entries(ROOM_ID_BY_NAME)) {
if (d.name.includes(name) || name.includes(d.name)) return id
}
return d.id
}
function num(v: number | null | undefined): number | null {
if (v === null || v === undefined) return null
const n = Number(v)
return Number.isFinite(n) ? n : null
}
function pickPoint(points: DevicePoint[], ...patterns: RegExp[]): DevicePoint | undefined {
for (const re of patterns) {
const hit = points.find((p) => re.test(p.name) || re.test(p.itemId))
if (hit) return hit
}
return undefined
}
function eng(points: DevicePoint[], ...patterns: RegExp[]): number | null {
return num(pickPoint(points, ...patterns)?.lastEng)
}
function boolOn(points: DevicePoint[], ...patterns: RegExp[]): boolean {
const v = eng(points, ...patterns)
return v !== null && v !== 0
}
function pointFresh(p: DevicePoint | undefined, maxAgeMs = 5 * 60_000): boolean {
if (!p?.lastTs) return false
const t = Date.parse(p.lastTs)
return Number.isFinite(t) && Date.now() - t <= maxAgeMs
}
function deviceOnline(points: DevicePoint[]): boolean {
return points.some((p) => pointFresh(p))
}
function roomFromDevice(d: Device, points: DevicePoint[]): RoomSnapshot {
const tempP = pickPoint(points, /实时温度|^温度$|Temp/i)
const humP = pickPoint(points, /实时湿度|^湿度$|Humid/i)
const pressP = pickPoint(points, /实时压差|压差(?!设定|上限|下限)/)
const temp = pointFresh(tempP) ? num(tempP?.lastEng) ?? 0 : 0
const humidity = pointFresh(humP) ? Math.round(num(humP?.lastEng) ?? 0) : 0
const pressure = pointFresh(pressP) ? num(pressP?.lastEng) ?? 0 : 0
const alarms: string[] = []
for (const p of [tempP, humP, pressP]) {
if (!p || !pointFresh(p) || p.lastEng === null || p.lastEng === undefined) continue
const v = Number(p.lastEng)
if (p.alarmLow !== null && v < p.alarmLow) alarms.push(`${p.name}偏低`)
if (p.alarmHigh !== null && v > p.alarmHigh) alarms.push(`${p.name}偏高`)
}
return { id: stableRoomId(d), name: d.name, temp, humidity, pressure, alarms }
}
function fridgeFromDevice(d: Device, points: DevicePoint[]): FridgeSnapshot {
const tempP = pickPoint(points, /冰箱温度|温度/)
const humP = pickPoint(points, /冰箱湿度|湿度/)
return {
id: d.id,
name: d.name,
temp: pointFresh(tempP) ? num(tempP?.lastEng) ?? 0 : 0,
humidity: pointFresh(humP) ? Math.round(num(humP?.lastEng) ?? 0) : 0,
}
}
function vavFromDevice(d: Device, points: DevicePoint[]): VavSnapshot {
const alarm = eng(points, /报警代码/)
return {
id: d.id,
name: d.name,
running: boolOn(points, /开停状态|^开机$/),
faceVelocity: eng(points, /实际面风速|面风速/) ?? 0,
sashHeight: eng(points, /实际门高|门高/) ?? 0,
airflow: eng(points, /实际风量|风量/) ?? 0,
valveOpen: 0,
maxExhaust: boolOn(points, /最大排风/),
minAirflow: boolOn(points, /最小风量/),
alarmCode: alarm !== null ? Math.round(alarm) : 0,
}
}
function sumEng(devices: Device[], byDevice: Map<string, DevicePoint[]>, nameRe: RegExp): number {
let sum = 0
let any = false
for (const d of devices) {
const v = eng(byDevice.get(d.id) || [], nameRe)
if (v === null) continue
sum += v
any = true
}
return any ? Number(sum.toFixed(3)) : 0
}
/**
* 从设备台账 + 点位 lastEng 组装大屏业务快照(无假数;无遥测则为 0 / 空告警)。
*/
export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> {
const [devices, points, gateways, pointAlarms] = await Promise.all([
listDevices(),
listPoints(),
listGateways(),
evaluatePointAlarms().catch(() => []),
])
const enabled = devices.filter((d) => d.enabled)
const byDevice = new Map<string, DevicePoint[]>()
for (const p of points) {
if (!p.deviceId || !p.enabled) continue
const list = byDevice.get(p.deviceId) || []
list.push(p)
byDevice.set(p.deviceId, list)
}
const rooms = enabled
.filter((d) => d.category === 'room')
.map((d) => roomFromDevice(d, byDevice.get(d.id) || []))
const fridges = enabled
.filter((d) => d.category === 'fridge')
.map((d) => fridgeFromDevice(d, byDevice.get(d.id) || []))
const vav = enabled
.filter((d) => d.category === 'vav')
.map((d) => vavFromDevice(d, byDevice.get(d.id) || []))
const pauDevs = enabled.filter((d) => d.category === 'pau')
const pauPts = pauDevs.flatMap((d) => byDevice.get(d.id) || [])
const systemRunning = boolOn(pauPts, /系统启动信号|^开机$|PAU201送风机启停/)
const modeAuto = !boolOn(pauPts, /PAU201手自动模式选择/) // 常见:0 自动 1 手动;无值当自动
const hvacAlarms: string[] = []
for (const tip of [
'机组故障报警',
'缺风报警',
'初效报警',
'消防报警',
'送风缺风故障提示',
'初/中效故障提示',
]) {
if (boolOn(pauPts, new RegExp(tip.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))) {
hvacAlarms.push(tip)
}
}
for (const a of pointAlarms) {
if (/PAU|EAF|送风|排风|滤/i.test(a.message) || /pau/i.test(a.deviceId || '')) {
hvacAlarms.push(a.message)
}
}
const powerMeters = enabled.filter((d) => d.category === 'meter_power')
const waterMeters = enabled.filter((d) => d.category === 'meter_water')
const coolMeters = enabled.filter((d) => d.category === 'meter_cooling')
let onlineCount = 0
for (const d of enabled) {
if (deviceOnline(byDevice.get(d.id) || [])) onlineCount += 1
}
const gwOk = gateways.some((g) => g.enabled && !!g.lastOkAt)
return {
ts: Date.now(),
rooms,
hvac: {
systemRunning,
supplyFanHz: eng(pauPts, /PAU201_输出值|PAU201风机频率/) ?? 0,
exhaustPressure: eng(pauPts, /EAF201排风压力显示|EAF201排风压力/) ?? 0,
filterRemainPct: boolOn(pauPts, /初效报警|初\/中效故障提示|初\/中效故障/) ? 0 : 0,
mode: modeAuto ? 'auto' : 'manual',
supplyTemp: eng(pauPts, /送风温度显示|送风温度/) ?? 0,
supplyHumidity: Math.round(eng(pauPts, /送风湿度显示|送风湿度/) ?? 0),
supplyPressure: eng(pauPts, /PAU201送风压力显示|送风压力/) ?? 0,
freshAirTemp: eng(pauPts, /新风温度显示|新风温度/) ?? 0,
alarms: [...new Set(hvacAlarms)].slice(0, 8),
},
vav,
fridges,
metering: {
powerKwh: sumEng(powerMeters, byDevice, /用电量|有功电能|总有功/),
waterM3: sumEng(waterMeters, byDevice, /用水量|累计流量|流量/),
coolingKwh: sumEng(coolMeters, byDevice, /冷量|累计|用电量/),
powerTrendPct: 0,
waterTrendPct: 0,
coolingTrendPct: 0,
},
access: {
doors: enabled
.filter((d) => d.category === 'door')
.map((d) => ({
id: d.id,
name: d.name,
status: (d.status === 'alarm' ? 'alarm' : d.status === 'offline' ? 'offline' : 'closed') as
| 'open'
| 'closed'
| 'alarm'
| 'offline',
})),
todayPassCount: 0,
recentEvents: [],
},
online: {
devicesOnline: onlineCount,
devicesTotal: enabled.length,
gatewayOk: gwOk,
},
pointAlarms,
}
}
import { listDevices } from '../devices/store.js'
import {
listTsCatalog,
queryTsSeries,
type TsCatalogItem,
type TsSeries,
} from '../db/timeseries.js'
const METRIC_LABEL: Record<string, string> = {
temp: '温度',
humidity: '湿度',
pressure: '压差',
airflow: '风量',
face_velocity: '面风速',
supply_temp: '送风温度',
supply_humidity: '送风湿度',
supply_pressure: '送风压力',
fresh_air_temp: '新风温度',
exhaust_pressure: '排风压力',
power_kwh: '用电量',
water_m3: '用水量',
cooling_kwh: '冷量',
}
const SOURCE_LABEL: Record<string, string> = {
room: '房间环境',
fridge: '冷链冰箱',
vav: '通风柜',
hvac: '空调排风',
metering: '计量',
}
export function metricLabel(metric: string) {
return METRIC_LABEL[metric] || metric
}
export function sourceLabel(source: string) {
return SOURCE_LABEL[source] || source
}
async function deviceNameMap() {
const devices = await listDevices()
const map = new Map<string, string>()
for (const d of devices) map.set(d.id, d.name)
map.set('PAU201', 'PAU-201 机组')
map.set('lab', '实验室汇总')
return map
}
export async function attachSeriesNames(series: TsSeries[]): Promise<TsSeries[]> {
const names = await deviceNameMap()
return series.map((s) => ({ ...s, name: names.get(s.id) || s.name || s.id }))
}
export async function getHistoryCatalog(): Promise<
(TsCatalogItem & { sourceLabel: string; metricLabel: string; name: string })[]
> {
const [catalog, names] = await Promise.all([listTsCatalog(), deviceNameMap()])
return catalog.map((c) => ({
...c,
sourceLabel: sourceLabel(c.source),
metricLabel: metricLabel(c.metric),
name: names.get(c.deviceId) || c.deviceId,
}))
}
export async function getHistorySeries(opts: {
source: string
metric: string
hours?: number
bucketMinutes?: number
deviceId?: string
}): Promise<{
source: string
metric: string
sourceLabel: string
metricLabel: string
hours: number
bucketMinutes: number
series: TsSeries[]
}> {
const hours = opts.hours ?? 24
const bucketMinutes = opts.bucketMinutes ?? 15
const raw = await queryTsSeries({
source: opts.source,
metric: opts.metric,
hours,
bucketMinutes,
deviceIds: opts.deviceId ? [opts.deviceId] : undefined,
})
const series = await attachSeriesNames(raw)
return {
source: opts.source,
metric: opts.metric,
sourceLabel: sourceLabel(opts.source),
metricLabel: metricLabel(opts.metric),
hours,
bucketMinutes,
series,
}
}
import { queryFridgeTempSeries, type FridgeTempSeries } from '../db/collect.js'
import { listDevices, listGateways, listPoints } from '../devices/store.js'
import type { DeviceCategory, DevicePoint } from '../devices/types.js'
import { queryNamedTsSeries, type TsSeries } from '../db/timeseries.js'
import { fetchGatewayHealth, resolveGatewayBase } from '../gateway/client.js'
import { attachSeriesNames } from './history.js'
import { buildSnapshotFromDevices } from './fromDevices.js'
import type { RealtimeSnapshot } from '../types.js'
const CATEGORY_LABEL: Record<DeviceCategory, string> = {
pau: '空调机组',
vav: '通风柜',
room: '房间环境',
fridge: '冷链冰箱',
meter_power: '电表',
meter_water: '水表',
meter_cooling: '冷量表',
light: '照明',
door: '门禁',
camera: '摄像',
nvr: '录像',
other: '其它',
}
function engOf(points: DevicePoint[], re: RegExp): number | null {
// 同名/多候选时跳过过期点,避免旧写控点挡住新鲜遥测
for (const p of points) {
if (!re.test(p.name) && !re.test(p.itemId)) continue
if (p.lastEng === null || p.lastEng === undefined) continue
if (!p.lastTs) continue
const age = Date.now() - Date.parse(p.lastTs)
if (!Number.isFinite(age) || age > 5 * 60_000) continue
const n = Number(p.lastEng)
if (Number.isFinite(n)) return n
}
return null
}
function fresh(points: DevicePoint[], maxAgeMs = 5 * 60_000): boolean {
return points.some((p) => {
if (!p.lastTs) return false
const t = Date.parse(p.lastTs)
return Number.isFinite(t) && Date.now() - t <= maxAgeMs
})
}
export type ScreenDashboard = {
ts: number
snapshot: RealtimeSnapshot
/** 设备分类占比(饼图) */
categoryPie: { name: string; value: number; category: string }[]
/** 在线状态占比(饼图) */
statusPie: { name: string; value: number }[]
/** 各电表用电量(柱状) */
powerBars: { name: string; value: number | null }[]
/** 各房间压差(柱状) */
pressureBars: { name: string; value: number | null; ok: boolean }[]
/** 环境气泡:温度×湿度,气泡大小=|压差| */
envBubbles: {
name: string
temp: number | null
humidity: number | null
pressure: number | null
ok: boolean
}[]
/** 冷链当前温度柱 */
fridgeBars: { id: string; name: string; temp: number | null; ok: boolean }[]
/** 冷链 24h 曲线 */
fridgeSeries: FridgeTempSeries[]
/** 各面板近 24h 历史曲线(时序预聚合) */
curves: {
roomTemp: TsSeries[]
roomHumidity: TsSeries[]
roomPressure: TsSeries[]
fridgeTemp: TsSeries[]
fridgeHumidity: TsSeries[]
vavAirflow: TsSeries[]
vavFaceVelocity: TsSeries[]
hvacSupplyTemp: TsSeries[]
hvacSupplyHumidity: TsSeries[]
hvacSupplyPressure: TsSeries[]
hvacFreshAirTemp: TsSeries[]
hvacExhaustPressure: TsSeries[]
meteringPower: TsSeries[]
meteringWater: TsSeries[]
meteringCooling: TsSeries[]
}
/** 系统数字卡 */
digits: {
deviceTotal: number
deviceOnline: number
roomOk: number
roomTotal: number
fridgeOk: number
fridgeTotal: number
alarmOpen: number
gatewayOk: boolean
powerKwh: number
waterM3: number
coolingKwh: number
}
/** PAU 关键运行点(无假数) */
pauCards: { label: string; value: number | null; unit: string; ok: boolean | null }[]
/** VAV 运行摘要 */
vavBars: { name: string; airflow: number | null; faceVelocity: number | null; running: boolean }[]
}
export async function buildScreenDashboard(): Promise<ScreenDashboard> {
const [snapshot, devices, points, gateways, fridgeSeries, gwHealth, namedCurves] =
await Promise.all([
buildSnapshotFromDevices(),
listDevices(),
listPoints(),
listGateways(),
queryFridgeTempSeries({ hours: 24, bucketMinutes: 15 }).catch(() => [] as FridgeTempSeries[]),
resolveGatewayBase()
? fetchGatewayHealth().catch(() => ({ ok: false as boolean }))
: Promise.resolve({ ok: false as boolean }),
queryNamedTsSeries(
[
{ key: 'roomTemp', source: 'room', metric: 'temp' },
{ key: 'roomHumidity', source: 'room', metric: 'humidity' },
{ key: 'roomPressure', source: 'room', metric: 'pressure' },
{ key: 'fridgeTemp', source: 'fridge', metric: 'temp' },
{ key: 'fridgeHumidity', source: 'fridge', metric: 'humidity' },
{ key: 'vavAirflow', source: 'vav', metric: 'airflow' },
{ key: 'vavFaceVelocity', source: 'vav', metric: 'face_velocity' },
{ key: 'hvacSupplyTemp', source: 'hvac', metric: 'supply_temp' },
{ key: 'hvacSupplyHumidity', source: 'hvac', metric: 'supply_humidity' },
{ key: 'hvacSupplyPressure', source: 'hvac', metric: 'supply_pressure' },
{ key: 'hvacFreshAirTemp', source: 'hvac', metric: 'fresh_air_temp' },
{ key: 'hvacExhaustPressure', source: 'hvac', metric: 'exhaust_pressure' },
{ key: 'meteringPower', source: 'metering', metric: 'power_kwh' },
{ key: 'meteringWater', source: 'metering', metric: 'water_m3' },
{ key: 'meteringCooling', source: 'metering', metric: 'cooling_kwh' },
],
{ hours: 24, bucketMinutes: 1 },
).catch(() => ({}) as Record<string, TsSeries[]>),
])
const curveKeys = [
'roomTemp',
'roomHumidity',
'roomPressure',
'fridgeTemp',
'fridgeHumidity',
'vavAirflow',
'vavFaceVelocity',
'hvacSupplyTemp',
'hvacSupplyHumidity',
'hvacSupplyPressure',
'hvacFreshAirTemp',
'hvacExhaustPressure',
'meteringPower',
'meteringWater',
'meteringCooling',
] as const
const namedList = await Promise.all(
curveKeys.map((k) => attachSeriesNames(namedCurves[k] || [])),
)
const curves = Object.fromEntries(curveKeys.map((k, i) => [k, namedList[i]])) as {
roomTemp: TsSeries[]
roomHumidity: TsSeries[]
roomPressure: TsSeries[]
fridgeTemp: TsSeries[]
fridgeHumidity: TsSeries[]
vavAirflow: TsSeries[]
vavFaceVelocity: TsSeries[]
hvacSupplyTemp: TsSeries[]
hvacSupplyHumidity: TsSeries[]
hvacSupplyPressure: TsSeries[]
hvacFreshAirTemp: TsSeries[]
hvacExhaustPressure: TsSeries[]
meteringPower: TsSeries[]
meteringWater: TsSeries[]
meteringCooling: TsSeries[]
}
const enabled = devices.filter((d) => d.enabled)
const byDevice = new Map<string, DevicePoint[]>()
for (const p of points) {
if (!p.deviceId || !p.enabled) continue
const list = byDevice.get(p.deviceId) || []
list.push(p)
byDevice.set(p.deviceId, list)
}
const catCount = new Map<string, number>()
for (const d of enabled) {
catCount.set(d.category, (catCount.get(d.category) || 0) + 1)
}
const categoryPie = [...catCount.entries()].map(([category, value]) => ({
category,
name: CATEGORY_LABEL[category as DeviceCategory] || category,
value,
}))
let online = 0
let offline = 0
let alarm = 0
let unknown = 0
for (const d of enabled) {
const pts = byDevice.get(d.id) || []
if (d.status === 'alarm' || snapshot.pointAlarms?.some((a) => a.deviceId === d.id)) {
alarm += 1
} else if (fresh(pts)) {
online += 1
} else if (d.status === 'offline') {
offline += 1
} else {
unknown += 1
}
}
const statusPie = [
{ name: '在线', value: online },
{ name: '告警', value: alarm },
{ name: '离线', value: offline },
{ name: '未知', value: unknown },
].filter((x) => x.value > 0)
const powerBars = enabled
.filter((d) => d.category === 'meter_power')
.map((d) => ({
name: d.name,
value: engOf(byDevice.get(d.id) || [], /用电量|有功电能/),
}))
const roomDevs = enabled.filter((d) => d.category === 'room')
const pressureBars = roomDevs.map((d) => {
const pts = byDevice.get(d.id) || []
const pressure = engOf(pts, /实时压差|压差(?!设定|上限|下限)/)
const room = snapshot.rooms.find((r) => r.name === d.name)
return {
name: d.name,
value: pressure,
ok: room ? room.alarms.length === 0 : true,
}
})
const envBubbles = roomDevs.map((d) => {
const pts = byDevice.get(d.id) || []
const temp = engOf(pts, /实时温度|^温度$/)
const humidity = engOf(pts, /实时湿度|^湿度$/)
const pressure = engOf(pts, /实时压差|压差(?!设定|上限|下限)/)
const room = snapshot.rooms.find((r) => r.name === d.name)
return {
name: d.name,
temp,
humidity,
pressure,
ok: room ? room.alarms.length === 0 : true,
}
})
const fridgeBars = enabled
.filter((d) => d.category === 'fridge')
.map((d) => {
const temp = engOf(byDevice.get(d.id) || [], /冰箱温度|温度/)
return {
id: d.id,
name: d.name,
temp,
ok: temp === null ? true : temp <= -19,
}
})
const pauPts = enabled
.filter((d) => d.category === 'pau')
.flatMap((d) => byDevice.get(d.id) || [])
const pauCards = [
{
label: '送风温度',
value: engOf(pauPts, /送风温度显示|送风温度(?!校准|设定)/),
unit: '℃',
ok: null as boolean | null,
},
{ label: '送风湿度', value: engOf(pauPts, /送风湿度显示|送风湿度(?!校准|设定)/), unit: '%', ok: null },
{ label: '送风压力', value: engOf(pauPts, /PAU201送风压力显示|送风压力显示|送风压力(?!设定)/), unit: 'Pa', ok: null },
{ label: '排风压力', value: engOf(pauPts, /EAF201排风压力显示|排风压力显示/), unit: 'Pa', ok: null },
{ label: '新风温度', value: engOf(pauPts, /新风温度显示|新风温度(?!设定)/), unit: '℃', ok: null },
(() => {
const v = engOf(pauPts, /系统启动信号|^开机$|PAU201送风机启停/)
return {
label: '系统启动',
value: v,
unit: '',
ok: v === null ? null : v !== 0,
}
})(),
]
const vavBars = enabled
.filter((d) => d.category === 'vav')
.map((d) => {
const pts = byDevice.get(d.id) || []
const running = (engOf(pts, /开停状态|^开机$/) ?? 0) !== 0
return {
name: d.name,
airflow: engOf(pts, /实际风量/),
faceVelocity: engOf(pts, /实际面风速/),
running,
}
})
const roomOk = snapshot.rooms.filter((r) => !r.alarms.length).length
const fridgeOk = fridgeBars.filter((f) => f.ok).length
const gwOk =
!!gwHealth.ok ||
gateways.some((g) => g.enabled && !!g.lastOkAt) ||
snapshot.online.gatewayOk
snapshot.online.gatewayOk = gwOk
return {
ts: Date.now(),
snapshot,
categoryPie,
statusPie,
powerBars,
pressureBars,
envBubbles,
fridgeBars,
fridgeSeries: fridgeSeries.length
? fridgeSeries
: curves.fridgeTemp.map((s) => ({
id: s.id,
name: s.name || s.id,
points: s.points.map((p) => ({ t: p.t, temp: p.v })),
})),
curves,
digits: {
deviceTotal: enabled.length,
deviceOnline: online,
roomOk,
roomTotal: snapshot.rooms.length,
fridgeOk,
fridgeTotal: fridgeBars.length,
alarmOpen: snapshot.pointAlarms?.length || 0,
gatewayOk: gwOk,
powerKwh: snapshot.metering.powerKwh,
waterM3: snapshot.metering.waterM3,
coolingKwh: snapshot.metering.coolingKwh,
},
pauCards,
vavBars,
}
}
import { generateSnapshot } from '../mock/generator.js'
import type { RealtimeSnapshot } from '../types.js' import type { RealtimeSnapshot } from '../types.js'
import { config } from '../config.js' import { config } from '../config.js'
import { persistSnapshot, persistWriteLog, pruneCollectData } from '../db/collect.js' import { persistSnapshot, persistWriteLog, pruneCollectData } from '../db/collect.js'
...@@ -8,16 +7,47 @@ import { syncPointAlarms } from '../alarms/store.js' ...@@ -8,16 +7,47 @@ import { syncPointAlarms } from '../alarms/store.js'
import { evaluatePointAlarms } from '../devices/alarms.js' import { evaluatePointAlarms } from '../devices/alarms.js'
import { import {
applyMockPointTelemetry, applyMockPointTelemetry,
applyMockPointTelemetryStale,
applyPointTelemetry, applyPointTelemetry,
type PointTelemetrySample, type PointTelemetrySample,
} from '../devices/store.js' } from '../devices/store.js'
import { buildSnapshotFromDevices } from './fromDevices.js'
let snapshot: RealtimeSnapshot = generateSnapshot() let snapshot: 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 },
pointAlarms: [],
}
const listeners = new Set<(s: RealtimeSnapshot) => void>() const listeners = new Set<(s: RealtimeSnapshot) => void>()
let persistBusy = false let persistBusy = false
let pollTimer: ReturnType<typeof setInterval> | undefined let pollTimer: ReturnType<typeof setInterval> | undefined
let currentPollMs = config.modbus.pollMs let currentPollMs = config.modbus.pollMs
let tickBusy = false let tickBusy = false
let lastMockFillLogMs = 0
export function getSnapshot() { export function getSnapshot() {
return snapshot return snapshot
...@@ -98,29 +128,11 @@ async function tick() { ...@@ -98,29 +128,11 @@ async function tick() {
if (tickBusy) return if (tickBusy) return
tickBusy = true tickBusy = true
try { try {
if (!useGatewayMode() || !resolveGatewayBase()) { let gatewayOk: boolean | undefined
snapshot = generateSnapshot() if (useGatewayMode() && resolveGatewayBase()) {
if (useGatewayMode()) snapshot.online.gatewayOk = false
// 仅 mock 模式本地造数;gateway 模式优先等 Windows 网关 POST /api/gateway/telemetry
if (config.dataMode === 'mock') {
try {
await applyMockPointTelemetry()
} catch (e) {
console.warn('[realtime] mock point telemetry failed', e)
}
}
} else {
const [health, gwSnap] = await Promise.all([fetchGatewayHealth(), fetchGatewaySnapshot()]) const [health, gwSnap] = await Promise.all([fetchGatewayHealth(), fetchGatewaySnapshot()])
// 网关快照合并进业务快照:一期先保留 mock 结构,标记网关连通;点值供设备页展示 gatewayOk = health.ok
snapshot = generateSnapshot()
snapshot.online.gatewayOk = health.ok
const samples = parseGatewayValues(gwSnap) const samples = parseGatewayValues(gwSnap)
if (gwSnap && typeof gwSnap === 'object') {
;(snapshot as RealtimeSnapshot & { gatewayTelemetry?: unknown }).gatewayTelemetry = (
gwSnap as { values?: unknown }
).values
}
// 拉取兜底(网关也会主动推送到 /api/gateway/telemetry);先写完再判定告警
if (samples.length) { if (samples.length) {
try { try {
await applyPointTelemetry(samples) await applyPointTelemetry(samples)
...@@ -128,7 +140,39 @@ async function tick() { ...@@ -128,7 +140,39 @@ async function tick() {
console.warn('[realtime] gateway point telemetry failed', e) console.warn('[realtime] gateway point telemetry failed', e)
} }
} }
// 网关部分断线时,过期点位继续模拟补发,保证时序入库与大屏不断流
const staleMs = Number(process.env.MOCK_FILL_STALE_MS || config.mockFillStaleMs || 0)
if (staleMs > 0) {
try {
const n = await applyMockPointTelemetryStale(staleMs)
const now = Date.now()
if (n > 0 && now - lastMockFillLogMs > 30_000) {
lastMockFillLogMs = now
console.log(`[realtime] mock-fill stale points: ${n}`)
}
} catch (e) {
console.warn('[realtime] mock-fill stale failed', e)
}
}
} else if (config.dataMode === 'mock') {
try {
await applyMockPointTelemetry()
} catch (e) {
console.warn('[realtime] mock point telemetry failed', e)
}
} else if (Number(process.env.MOCK_FILL_STALE_MS || config.mockFillStaleMs || 0) > 0) {
// gateway 模式但未配置 URL:仍用模拟保持发送
try {
await applyMockPointTelemetry()
} catch (e) {
console.warn('[realtime] mock telemetry failed', e)
}
} }
snapshot = await buildSnapshotFromDevices()
if (gatewayOk !== undefined) snapshot.online.gatewayOk = gatewayOk
else if (useGatewayMode()) snapshot.online.gatewayOk = false
await attachPointAlarms() await attachPointAlarms()
publish() publish()
void persistSafe(snapshot) void persistSafe(snapshot)
......
import { Router } from 'express' import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js' import { authRequired, requireRole } from '../auth/middleware.js'
import { queryFridgeTempSeries } from '../db/collect.js'
import { getHistoryCatalog, getHistorySeries } from '../realtime/history.js'
import { buildScreenDashboard } from '../realtime/screen.js'
import { applyWriteLog, getSnapshot } from '../realtime/store.js' import { applyWriteLog, getSnapshot } from '../realtime/store.js'
export const realtimeRouter = Router() export const realtimeRouter = Router()
...@@ -9,6 +12,61 @@ realtimeRouter.get('/snapshot', (_req, res) => { ...@@ -9,6 +12,61 @@ realtimeRouter.get('/snapshot', (_req, res) => {
res.json(getSnapshot()) res.json(getSnapshot())
}) })
/** 大屏面板:设备台账 + 点位实值 + 图表聚合(无假数) */
realtimeRouter.get('/screen', async (_req, res) => {
try {
const data = await buildScreenDashboard()
res.json(data)
} catch (e) {
console.error('[realtime] screen dashboard failed', e)
res.status(500).json({ message: '大屏数据组装失败' })
}
})
/** 时序目录:所有已落库可查序列 */
realtimeRouter.get('/history/catalog', async (_req, res) => {
try {
const catalog = await getHistoryCatalog()
res.json({ catalog })
} catch (e) {
console.error('[realtime] history catalog failed', e)
res.status(500).json({ message: '历史目录查询失败', catalog: [] })
}
})
/** 通用历史曲线:source + metric(可选 deviceId) */
realtimeRouter.get('/history', async (req, res) => {
const source = String(req.query.source || '')
const metric = String(req.query.metric || '')
if (!source || !metric) {
res.status(400).json({ message: '缺少 source / metric' })
return
}
const hours = Number(req.query.hours) || 24
const bucketMinutes = Number(req.query.bucketMinutes) || 15
const deviceId = req.query.deviceId ? String(req.query.deviceId) : undefined
try {
const data = await getHistorySeries({ source, metric, hours, bucketMinutes, deviceId })
res.json(data)
} catch (e) {
console.error('[realtime] history query failed', e)
res.status(500).json({ message: '历史曲线查询失败', series: [] })
}
})
/** 各冰箱近 24h 温度曲线(采集落库聚合) */
realtimeRouter.get('/fridges/temp-history', async (req, res) => {
const hours = Number(req.query.hours) || 24
const bucketMinutes = Number(req.query.bucketMinutes) || 15
try {
const series = await queryFridgeTempSeries({ hours, bucketMinutes })
res.json({ hours, bucketMinutes, series })
} catch (e) {
console.error('[realtime] fridge temp-history failed', e)
res.status(500).json({ message: '温度历史查询失败', series: [] })
}
})
realtimeRouter.post('/write', authRequired, requireRole('admin'), (req, res) => { realtimeRouter.post('/write', authRequired, requireRole('admin'), (req, res) => {
const { target, action, value } = req.body || {} const { target, action, value } = req.body || {}
if (!target || !action) { if (!target || !action) {
......
import { Router } from 'express'
export const weatherRouter = Router()
const SMG_BRIEF = 'https://xml.smg.gov.mo/c_actual_brief.xml'
const CACHE_MS = 10 * 60_000
type MacauWeather = {
place: string
temp: string
text: string
humidity: string
feelsLike?: string
wind?: string
source: string
updatedAt: string | null
}
let cache: { at: number; data: MacauWeather } | null = null
function pickTag(xml: string, tag: string): string | null {
const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i')
const m = xml.match(re)
return m ? m[1].trim() : null
}
function pickTypedValue(xml: string, blockTag: string, type: string): string | null {
const re = new RegExp(
`<${blockTag}>[\\s\\S]*?<Type>${type}</Type>[\\s\\S]*?<Value>([^<]*)</Value>[\\s\\S]*?</${blockTag}>`,
'i',
)
const m = xml.match(re)
return m ? m[1].trim() : null
}
function parseBrief(xml: string): MacauWeather {
const temp = pickTypedValue(xml, 'Temperature', '3') || pickTag(xml, 'Value')
const humidity = pickTypedValue(xml, 'Humidity', '3')
const text =
pickTag(xml, 'IconDescription') ||
pickTag(xml, 'FeelsLikeDescription') ||
'—'
const windDir = pickTag(xml, 'WindDescription')
const windSpd = pickTag(xml, 'WindSpeedDescription')
const feels = pickTag(xml, 'FeelsLikeDescription')
const updated = pickTag(xml, 'ValidFor') || pickTag(xml, 'SysPubdate')
return {
place: '澳门',
temp: temp != null && temp !== '' ? `${Math.round(Number(temp))}℃` : '--',
text,
humidity: humidity != null && humidity !== '' ? `${Math.round(Number(humidity))}%` : '--',
feelsLike: feels || undefined,
wind: [windDir, windSpd].filter(Boolean).join(' · ') || undefined,
source: 'https://www.smg.gov.mo/zh',
updatedAt: updated,
}
}
async function fetchMacauWeather(): Promise<MacauWeather> {
if (cache && Date.now() - cache.at < CACHE_MS) return cache.data
const res = await fetch(SMG_BRIEF, {
headers: { Accept: 'application/xml,text/xml,*/*', 'User-Agent': 'jh-lab-dashboard/1.0' },
})
if (!res.ok) throw new Error(`SMG HTTP ${res.status}`)
const xml = await res.text()
const data = parseBrief(xml)
cache = { at: Date.now(), data }
return data
}
weatherRouter.get('/macau', async (_req, res) => {
try {
const data = await fetchMacauWeather()
res.json(data)
} catch (e) {
console.warn('[weather] SMG fetch failed', e)
if (cache?.data) {
res.json({ ...cache.data, stale: true })
return
}
res.status(502).json({ message: '澳门天文台天气暂不可用' })
}
})
...@@ -9,8 +9,10 @@ ...@@ -9,8 +9,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"echarts": "^6.1.0",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"vue": "^3.5.42", "vue": "^3.5.42",
"vue-echarts": "^8.3.2",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
......
<script setup lang="ts">
import { computed } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart, LineChart, PieChart, ScatterChart } from 'echarts/charts'
import {
GridComponent,
LegendComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components'
import VChart from 'vue-echarts'
import type { ComposeOption } from 'echarts/core'
import type { BarSeriesOption, LineSeriesOption, PieSeriesOption, ScatterSeriesOption } from 'echarts/charts'
import type {
GridComponentOption,
LegendComponentOption,
TooltipComponentOption,
VisualMapComponentOption,
} from 'echarts/components'
use([
CanvasRenderer,
BarChart,
LineChart,
PieChart,
ScatterChart,
GridComponent,
LegendComponent,
TooltipComponent,
VisualMapComponent,
])
export type ECOption = ComposeOption<
| BarSeriesOption
| LineSeriesOption
| PieSeriesOption
| ScatterSeriesOption
| GridComponentOption
| LegendComponentOption
| TooltipComponentOption
| VisualMapComponentOption
>
const props = withDefaults(
defineProps<{
option: ECOption
height?: string | number
loading?: boolean
emptyText?: string
}>(),
{
height: '100%',
loading: false,
emptyText: '暂无采集数据',
},
)
const style = computed(() => ({
width: '100%',
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
minHeight: '80px',
}))
const isEmpty = computed(() => {
const series = props.option?.series
if (!series) return true
const list = Array.isArray(series) ? series : [series]
return list.every((s) => {
const data = (s as { data?: unknown[] }).data
return !data || data.length === 0
})
})
/** 空数据时仍给 ECharts 合法 option,避免 v-if 反复挂载触发 __vnode 错误 */
const EMPTY_OPTION: ECOption = {
backgroundColor: 'transparent',
series: [],
}
const chartOption = computed(() => (isEmpty.value ? EMPTY_OPTION : props.option))
</script>
<template>
<div class="echart-wrap" :style="style">
<VChart class="echart" :option="chartOption" autoresize :manual-update="false" />
<div v-if="isEmpty" class="echart-empty">{{ emptyText }}</div>
</div>
</template>
<style scoped>
.echart-wrap {
position: relative;
min-width: 0;
min-height: 0;
}
.echart {
width: 100%;
height: 100%;
}
.echart-empty {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--label, #7a9bb0);
border: 1px dashed rgba(0, 247, 255, 0.18);
border-radius: 4px;
background: rgba(4, 18, 36, 0.35);
pointer-events: none;
}
</style>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { api } from '@/api/http'
import type { FridgeSnapshot } from '@/types'
type HistPoint = { t: number; temp: number }
type HistSeries = { id: string; name: string; points: HistPoint[] }
const props = defineProps<{
items: FridgeSnapshot[]
}>()
const seriesMap = ref<Record<string, HistSeries>>({})
const loading = ref(false)
const error = ref('')
let timer: number | undefined
const fridges = computed(() => props.items || [])
const cards = computed(() => {
return fridges.value.map((f) => {
const hist = seriesMap.value[f.id]
const points = hist?.points?.length
? hist.points
: [{ t: Date.now() - 3600_000, temp: f.temp }, { t: Date.now(), temp: f.temp }]
const path = pathOf(points, 160, 44)
return {
id: f.id,
name: hist?.name || f.name,
temp: f.temp,
humidity: f.humidity,
warn: f.temp > -19,
path,
sparse: !hist?.points?.length,
}
})
})
async function loadHistory() {
loading.value = true
error.value = ''
try {
const res = await api<{ series: HistSeries[] }>('/api/realtime/fridges/temp-history?hours=24&bucketMinutes=15')
const next: Record<string, HistSeries> = {}
for (const s of res.series || []) next[s.id] = s
seriesMap.value = next
} catch (e) {
error.value = e instanceof Error ? e.message : '历史加载失败'
} finally {
loading.value = false
}
}
onMounted(() => {
void loadHistory()
timer = window.setInterval(() => void loadHistory(), 60_000)
})
onBeforeUnmount(() => {
if (timer) window.clearInterval(timer)
})
watch(
() => props.items.map((f) => f.id).join(','),
() => void loadHistory(),
)
function pathOf(points: HistPoint[], w: number, h: number) {
if (!points.length) return { line: '', area: '', min: 0, max: 0 }
const padX = 2
const padY = 4
const temps = points.map((p) => p.temp)
let min = Math.min(...temps)
let max = Math.max(...temps)
const span = max - min || 1
min -= span * 0.15
max += span * 0.15
const range = max - min || 1
const innerW = w - padX * 2
const innerH = h - padY * 2
const xy = points.map((p, i) => {
const x = padX + (points.length === 1 ? innerW / 2 : (i / (points.length - 1)) * innerW)
const y = padY + ((max - p.temp) / range) * innerH
return [x, y] as const
})
const line = xy.map((d, i) => `${i === 0 ? 'M' : 'L'}${d[0].toFixed(1)},${d[1].toFixed(1)}`).join(' ')
const area = `${line} L${xy[xy.length - 1][0].toFixed(1)},${(h - padY).toFixed(1)} L${xy[0][0].toFixed(1)},${(h - padY).toFixed(1)} Z`
return { line, area, min, max }
}
</script>
<template>
<div class="cold-charts">
<div v-if="!cards.length" class="cold-empty">暂无冷链设备数据</div>
<div v-else class="cold-grid">
<div
v-for="c in cards"
:key="c.id"
class="cold-card"
:class="{ warn: c.warn }"
>
<div class="cold-head">
<span class="cold-name">{{ c.name }}</span>
<span class="cold-now" :class="{ warn: c.warn }">{{ c.temp.toFixed(1) }}℃</span>
</div>
<svg class="spark" viewBox="0 0 160 44" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient :id="`cg-${c.id}`" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" :stop-color="c.warn ? '#faad14' : '#00f7ff'" stop-opacity="0.35" />
<stop offset="100%" :stop-color="c.warn ? '#faad14' : '#00f7ff'" stop-opacity="0" />
</linearGradient>
</defs>
<path v-if="c.path.area" :d="c.path.area" :fill="`url(#cg-${c.id})`" />
<path
v-if="c.path.line"
:d="c.path.line"
fill="none"
:stroke="c.warn ? '#ffd666' : '#7ad8ff'"
stroke-width="1.6"
stroke-linejoin="round"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<div class="cold-foot">
<span>近24h</span>
<span v-if="c.sparse" class="dim">采集积累中</span>
<span v-else>RH {{ c.humidity }}%</span>
</div>
</div>
</div>
<div v-if="error" class="cold-err">{{ error }}</div>
</div>
</template>
<style scoped>
.cold-charts {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.cold-grid {
flex: 1;
min-height: 0;
overflow: auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
align-content: start;
padding-right: 2px;
}
.cold-card {
min-width: 0;
padding: 6px 8px 5px;
border-radius: 4px;
border: 1px solid rgba(0, 247, 255, 0.18);
background: rgba(6, 28, 56, 0.4);
}
.cold-card.warn {
border-color: rgba(250, 173, 20, 0.4);
background: rgba(60, 36, 8, 0.35);
}
.cold-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin-bottom: 2px;
}
.cold-name {
font-size: 11px;
color: #cfe9f7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cold-now {
font-size: 12px;
font-weight: 700;
font-family: var(--font-num);
color: var(--green);
flex-shrink: 0;
}
.cold-now.warn { color: var(--amber); }
.spark {
display: block;
width: 100%;
height: 44px;
}
.cold-foot {
display: flex;
justify-content: space-between;
margin-top: 2px;
font-size: 9px;
color: rgba(158, 210, 232, 0.7);
}
.cold-foot .dim { color: rgba(158, 210, 232, 0.45); }
.cold-empty,
.cold-err {
font-size: 12px;
color: var(--label);
padding: 8px 0;
}
.cold-err { color: #ffb0b0; }
</style>
...@@ -46,8 +46,12 @@ const activeAreaId = ref<string>(SCREEN_AREAS[0]?.id || 'seq') ...@@ -46,8 +46,12 @@ const activeAreaId = ref<string>(SCREEN_AREAS[0]?.id || 'seq')
const cameras = ref<VideoDevice[]>([]) const cameras = ref<VideoDevice[]>([])
const playOpen = ref(false) const playOpen = ref(false)
const playCam = ref<VideoDevice | null>(null) const playCam = ref<VideoDevice | null>(null)
/** fill 模式:等 #screen-float-overlays 就绪再 Teleport,避免视点条丢失 */
const floatTargetReady = ref(false)
let markersSynced = false let markersSynced = false
let engineBootstrapping = false let engineBootstrapping = false
/** 就绪管线只跑一次,避免重复 addMarkers 导致光照/场景闪变 */
let engineReadyDone = false
function cameraMarkerList() { function cameraMarkerList() {
return cameras.value return cameras.value
...@@ -65,25 +69,24 @@ function cameraMarkerList() { ...@@ -65,25 +69,24 @@ function cameraMarkerList() {
})) }))
} }
/** 引擎就绪后:预加载全部预设图标 → 显示场景 POI → 打上摄像头点位 */ /** 引擎就绪后:预加载图标 → 显示 POI → 摄像头 Marker(只同步一次) */
async function onEngineReady() { async function onEngineReady() {
if (!useCloudFrame.value || engineBootstrapping) return if (!useCloudFrame.value || engineReadyDone || engineBootstrapping) return
engineBootstrapping = true engineBootstrapping = true
try { try {
// 1) 加载引擎全部预设图标资源
await preloadRtengineIcons(undefined, cloudUrl) await preloadRtengineIcons(undefined, cloudUrl)
// 2) 显示场景内已有 POI/图标
bridge.showScenePresets() bridge.showScenePresets()
// 3) 确保摄像头列表最新,再写入 Marker
await loadCameras() await loadCameras()
syncCameraMarkers(true) syncCameraMarkers(true)
// 再补两次,避免引擎尚未吃下 addMarkers // 仅当首轮未写入成功时补一次,避免固定 3 次同步打光照
window.setTimeout(() => syncCameraMarkers(true), 1200) window.setTimeout(() => {
window.setTimeout(() => syncCameraMarkers(true), 3000) if (!markersSynced) syncCameraMarkers(true)
}, 2000)
} catch { } catch {
syncCameraMarkers(true) syncCameraMarkers(true)
} finally { } finally {
engineBootstrapping = false engineBootstrapping = false
engineReadyDone = true
} }
} }
...@@ -92,8 +95,7 @@ function syncCameraMarkers(force = false) { ...@@ -92,8 +95,7 @@ function syncCameraMarkers(force = false) {
if (markersSynced && !force) return if (markersSynced && !force) return
const list = cameraMarkerList() const list = cameraMarkerList()
if (!list.length) return if (!list.length) return
// 先按 id 覆盖写入(引擎侧 upsert) // 批量一次写入即可(逐条 + 批量会触发多次场景刷新)
for (const m of list) bridge.addMarker(m)
bridge.addMarkers(list) bridge.addMarkers(list)
markersSynced = true markersSynced = true
} }
...@@ -257,6 +259,14 @@ function onSelect(id: string) { ...@@ -257,6 +259,14 @@ function onSelect(id: string) {
onMounted(() => { onMounted(() => {
void loadCameras() void loadCameras()
if (props.layout === 'fill') {
floatTargetReady.value = !!document.getElementById('screen-float-overlays')
if (!floatTargetReady.value) {
requestAnimationFrame(() => {
floatTargetReady.value = !!document.getElementById('screen-float-overlays')
})
}
}
if (useCloudFrame.value) { if (useCloudFrame.value) {
// 兜底:部分环境下无 sceneEmbedLoaded,延时强制走就绪管线 // 兜底:部分环境下无 sceneEmbedLoaded,延时强制走就绪管线
window.setTimeout(() => bridge.markReady(), 6000) window.setTimeout(() => bridge.markReady(), 6000)
...@@ -283,8 +293,25 @@ onBeforeUnmount(() => { ...@@ -283,8 +293,25 @@ onBeforeUnmount(() => {
<span class="view">{{ modeLabel }}</span> <span class="view">{{ modeLabel }}</span>
</div> </div>
<!-- 全屏模式下区域列表避开左右悬浮面板 --> <!-- 全屏:Teleport 到 float 层(高于 edge-mask);嵌入:右侧纵向 -->
<div class="vp-room-panel" :class="{ 'vp-room-panel--fill': props.layout === 'fill' }"> <Teleport v-if="props.layout === 'fill' && floatTargetReady" to="#screen-float-overlays" defer>
<div class="vp-room-panel vp-room-panel--fill">
<div class="vp-room-list">
<button
v-for="r in areaList"
:key="r.id"
type="button"
class="vp-room-item"
:class="{ active: r.id === activeAreaId, alarm: r.alarms.length > 0 }"
@click="onSelectArea(r.id)"
>
<AppIcon name="pin" class-name="room-ico" />
<span class="name">{{ r.name }}</span>
</button>
</div>
</div>
</Teleport>
<div v-else-if="props.layout !== 'fill'" class="vp-room-panel">
<div class="vp-room-head"> <div class="vp-room-head">
<AppIcon name="pin" /> <AppIcon name="pin" />
<div> <div>
...@@ -301,6 +328,7 @@ onBeforeUnmount(() => { ...@@ -301,6 +328,7 @@ onBeforeUnmount(() => {
:class="{ active: r.id === activeAreaId, alarm: r.alarms.length > 0 }" :class="{ active: r.id === activeAreaId, alarm: r.alarms.length > 0 }"
@click="onSelectArea(r.id)" @click="onSelectArea(r.id)"
> >
<AppIcon name="pin" class-name="room-ico" />
<span class="name">{{ r.name }}</span> <span class="name">{{ r.name }}</span>
</button> </button>
</div> </div>
...@@ -345,10 +373,10 @@ onBeforeUnmount(() => { ...@@ -345,10 +373,10 @@ onBeforeUnmount(() => {
<div v-if="props.layout !== 'fill'" class="vp-tip"> <div v-if="props.layout !== 'fill'" class="vp-tip">
<AppIcon name="hand" class-name="hand" /> <AppIcon name="hand" class-name="hand" />
<span v-if="useCloudFrame"> <span v-if="useCloudFrame">
RTEngineHD 云渲染 · 在画面内操作漫游 · 右侧点选区域飞视点 RTEngineHD 云渲染 · 在画面内操作漫游 · 底部点选区域飞视点
</span> </span>
<span v-else> <span v-else>
<b>左键</b> 拖动 · <b>右键</b> 旋转 · <b>滚轮</b> 缩放 · 右侧点选区域 <b>左键</b> 拖动 · <b>右键</b> 旋转 · <b>滚轮</b> 缩放 · 底部点选区域
</span> </span>
</div> </div>
...@@ -405,16 +433,33 @@ onBeforeUnmount(() => { ...@@ -405,16 +433,33 @@ onBeforeUnmount(() => {
overflow: hidden; overflow: hidden;
pointer-events: auto; pointer-events: auto;
} }
/* 夹在左右悬浮面板之间,避开顶栏 */ /* 全屏:夹在左右面板之间,贴地图底部横向;无外层背景面板 */
.vp-room-panel--fill { .vp-room-panel--fill {
right: 492px; left: 50%;
top: 148px; right: auto;
max-height: calc(100% - 260px); top: auto;
bottom: clamp(14px, 2.2vh, 28px);
width: max-content;
max-width: min(960px, calc(100vw - 2 * var(--screen-side-w, 24vw) - 48px));
max-height: none;
transform: translateX(-50%);
flex-direction: row;
align-items: center;
justify-content: center;
border: none;
border-radius: 0;
background: transparent;
backdrop-filter: none;
box-shadow: none;
overflow: visible;
z-index: 8;
pointer-events: auto;
} }
.vp-room-head { .vp-room-head {
display: flex; align-items: center; justify-content: center; gap: 6px; display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 7px 8px; padding: 7px 8px;
border-bottom: 1px solid rgba(62,200,255,.12); border-bottom: 1px solid rgba(62,200,255,.12);
flex-shrink: 0;
} }
.vp-room-head :deep(.pin) { width: 12px; height: 12px; opacity: .85; } .vp-room-head :deep(.pin) { width: 12px; height: 12px; opacity: .85; }
.vp-room-head > div { text-align: center; } .vp-room-head > div { text-align: center; }
...@@ -433,17 +478,45 @@ onBeforeUnmount(() => { ...@@ -433,17 +478,45 @@ onBeforeUnmount(() => {
padding: 4px; padding: 4px;
display: flex; flex-direction: column; gap: 2px; display: flex; flex-direction: column; gap: 2px;
} }
.vp-room-panel--fill .vp-room-list {
flex: 0 0 auto;
flex-direction: row;
align-items: center;
justify-content: center;
overflow-x: auto;
overflow-y: hidden;
gap: 8px;
padding: 0;
}
.vp-room-item { .vp-room-item {
display: block; display: flex;
align-items: center;
justify-content: center;
gap: 5px;
width: 100%; width: 100%;
border: 1px solid rgba(62, 200, 255, 0.18); border: 1px solid rgba(62, 200, 255, 0.18);
background: rgba(22, 58, 110, 0.45); background: rgba(22, 58, 110, 0.45);
border-radius: 4px; border-radius: 4px;
padding: 4px 6px; padding: 4px 6px;
cursor: pointer; text-align: center; cursor: pointer;
color: rgba(235, 245, 255, 0.95); color: rgba(235, 245, 255, 0.95);
transition: .15s; transition: .15s;
} }
.vp-room-panel--fill .vp-room-item {
width: auto;
flex: 0 0 auto;
min-width: 88px;
padding: 8px 12px;
background: rgba(8, 28, 58, 0.55);
border: 1px solid rgba(91, 212, 255, 0.32);
backdrop-filter: blur(6px);
}
.vp-room-item :deep(.room-ico) {
width: 12px;
height: 12px;
flex: 0 0 auto;
opacity: 0.9;
}
.vp-room-item:hover { .vp-room-item:hover {
border-color: rgba(62,200,255,.42); border-color: rgba(62,200,255,.42);
background: rgba(30, 82, 150, 0.62); background: rgba(30, 82, 150, 0.62);
...@@ -457,10 +530,16 @@ onBeforeUnmount(() => { ...@@ -457,10 +530,16 @@ onBeforeUnmount(() => {
} }
.vp-room-item.alarm .name { color: #ffb0b0; } .vp-room-item.alarm .name { color: #ffb0b0; }
.vp-room-item .name { .vp-room-item .name {
font-size: 11px; display: block; font-size: 11px;
white-space: nowrap; line-height: 1.25; display: block;
white-space: nowrap;
line-height: 1.25;
text-align: center; text-align: center;
} }
.vp-room-panel--fill .vp-room-item .name {
font-size: 12px;
font-weight: 600;
}
.vp-tip { right: 14px; bottom: 16px; font-size: 11px; color: #b9d6f0; } .vp-tip { right: 14px; bottom: 16px; font-size: 11px; color: #b9d6f0; }
.vp-tip :deep(.hand) { width: 14px; height: 14px; } .vp-tip :deep(.hand) { width: 14px; height: 14px; }
.vp-tip b { color: var(--cyan-lt); font-weight: 600; } .vp-tip b { color: var(--cyan-lt); font-weight: 600; }
......
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{ char: string }>()
const display = ref(props.char || '0')
const prev = ref(props.char || '0')
const flipping = ref(false)
watch(
() => props.char,
(next) => {
const n = next || ' '
if (n === display.value) return
prev.value = display.value
display.value = n
if (!/[0-9]/.test(n) && !/[0-9]/.test(prev.value)) return
flipping.value = false
requestAnimationFrame(() => {
flipping.value = true
window.setTimeout(() => {
flipping.value = false
prev.value = n
}, 520)
})
},
)
</script>
<template>
<span class="flip-digit" :class="{ flip: flipping, punct: !/[0-9]/.test(display) }">
<span class="static top"><i>{{ display }}</i></span>
<span class="static bottom"><i>{{ display }}</i></span>
<span class="hinge top" :class="{ active: flipping }"><i>{{ prev }}</i></span>
<span class="hinge bottom" :class="{ active: flipping }"><i>{{ display }}</i></span>
</span>
</template>
<style scoped>
.flip-digit {
position: relative;
display: inline-block;
width: 24px;
height: 38px;
margin: 0 1.5px;
perspective: 180px;
font-family: var(--font-num);
font-weight: 700;
font-size: 24px;
line-height: 38px;
color: #e8fbff;
vertical-align: middle;
}
.flip-digit.punct {
width: 12px;
background: transparent;
box-shadow: none;
}
.flip-digit.punct .static,
.flip-digit.punct .hinge {
background: transparent;
border: none;
box-shadow: none;
}
.static,
.hinge {
position: absolute;
left: 0;
width: 100%;
height: 50%;
overflow: hidden;
background: linear-gradient(180deg, #0d2a4a, #071828);
border: 1px solid rgba(0, 247, 255, 0.28);
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.35);
}
.static.top,
.hinge.top {
top: 0;
border-radius: 3px 3px 0 0;
border-bottom-color: rgba(0, 247, 255, 0.12);
}
.static.bottom,
.hinge.bottom {
bottom: 0;
border-radius: 0 0 3px 3px;
border-top-color: rgba(0, 0, 0, 0.35);
}
.static i,
.hinge i {
position: absolute;
left: 0;
width: 100%;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
font-style: normal;
}
.static.top i,
.hinge.top i {
top: 0;
}
.static.bottom i,
.hinge.bottom i {
bottom: 0;
}
.hinge {
z-index: 2;
backface-visibility: hidden;
transform-style: preserve-3d;
pointer-events: none;
opacity: 0;
}
.hinge.top {
transform-origin: bottom center;
}
.hinge.bottom {
transform-origin: top center;
transform: rotateX(90deg);
}
.hinge.top.active {
opacity: 1;
animation: flip-top 0.5s ease-in forwards;
}
.hinge.bottom.active {
opacity: 1;
animation: flip-bottom 0.5s ease-out forwards;
}
@keyframes flip-top {
0% { transform: rotateX(0deg); }
100% { transform: rotateX(-90deg); }
}
@keyframes flip-bottom {
0% { transform: rotateX(90deg); }
100% { transform: rotateX(0deg); }
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import FlipDigit from '@/components/screen/FlipDigit.vue'
import AppIcon from '@/components/icons/AppIcon.vue'
export type KpiFlipItem = {
key: string
label: string
value: string
hint?: string
tone?: 'power' | 'water' | 'cool' | 'device' | 'access'
icon?: 'kpi-power' | 'kpi-water' | 'kpi-cool' | 'kpi-device' | 'kpi-access'
}
const props = defineProps<{ items: KpiFlipItem[] }>()
function charsOf(value: string) {
return String(value ?? '').split('')
}
const list = computed(() => props.items || [])
</script>
<template>
<div class="kpi-flip-board">
<div
v-for="item in list"
:key="item.key"
class="kpi-flip"
:class="item.tone ? `tone-${item.tone}` : ''"
>
<div class="kpi-head">
<AppIcon v-if="item.icon" :name="item.icon" class-name="kpi-ico" />
<div class="kpi-texts">
<div class="kpi-label">{{ item.label }}</div>
<div v-if="item.hint" class="kpi-hint">{{ item.hint }}</div>
</div>
</div>
<div class="kpi-digits" :aria-label="item.label">
<FlipDigit v-for="(ch, i) in charsOf(item.value)" :key="`${item.key}-${i}`" :char="ch" />
</div>
</div>
</div>
</template>
<style scoped>
.kpi-flip-board {
display: flex;
gap: 10px;
width: 100%;
pointer-events: auto;
}
.kpi-flip {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 12px 12px;
border-radius: 4px;
border: 1px solid rgba(0, 247, 255, 0.28);
background: linear-gradient(180deg, rgba(8, 36, 68, 0.88), rgba(3, 14, 32, 0.78));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 8px 20px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(8px);
}
.kpi-head {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.kpi-texts {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.kpi-label {
font-size: 13px;
font-weight: 600;
color: #9ad8f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.kpi-hint {
font-size: 10px;
color: rgba(158, 210, 232, 0.72);
white-space: nowrap;
}
.kpi-digits {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 36px;
}
.kpi-flip :deep(.kpi-ico) {
width: 22px;
height: 22px;
flex: 0 0 auto;
}
.tone-power { border-color: rgba(255, 180, 64, 0.45); }
.tone-power .kpi-label { color: #f0c878; }
.tone-water { border-color: rgba(56, 189, 248, 0.45); }
.tone-water .kpi-label { color: #7dd3fc; }
.tone-cool { border-color: rgba(103, 232, 249, 0.42); }
.tone-cool .kpi-label { color: #a5f3fc; }
.tone-device { border-color: rgba(52, 211, 153, 0.45); }
.tone-device .kpi-label { color: #6ee7b7; }
.tone-access { border-color: rgba(167, 139, 250, 0.45); }
.tone-access .kpi-label { color: #c4b5fd; }
</style>
...@@ -22,35 +22,41 @@ defineProps<{ ...@@ -22,35 +22,41 @@ defineProps<{
<style scoped> <style scoped>
.screen-header-hy { .screen-header-hy {
position: relative; position: relative;
width: 1920px; width: 100%;
height: 84px; height: clamp(64px, 7.8vh, 84px);
flex-shrink: 0; flex-shrink: 0;
pointer-events: auto; pointer-events: auto;
} }
.banner { .banner {
display: block; display: block;
width: 1920px; width: 100%;
height: 84px; height: 100%;
object-fit: cover;
object-position: center top;
} }
h1 { h1 {
position: absolute; position: absolute;
left: 0; left: 12%;
right: 0; right: 12%;
top: 10px; top: 50%;
transform: translateY(-55%);
z-index: 1; z-index: 1;
margin: 0; margin: 0;
height: 42px; height: auto;
line-height: 42px; line-height: 1.2;
text-align: center; text-align: center;
font-family: var(--title-font); font-family: var(--title-font);
font-size: 30px; font-size: clamp(16px, 1.7vw, 30px);
font-weight: 900; font-weight: 900;
font-synthesis: style; font-synthesis: style;
letter-spacing: 6px; letter-spacing: clamp(2px, 0.35vw, 6px);
color: #f4feff; color: #f4feff;
font-style: italic; font-style: italic;
text-shadow: 0 0 14px rgba(0, 200, 255, 0.45); text-shadow: 0 0 14px rgba(0, 200, 255, 0.45);
pointer-events: none; pointer-events: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.slot-left, .slot-left,
.slot-right { .slot-right {
...@@ -60,13 +66,15 @@ h1 { ...@@ -60,13 +66,15 @@ h1 {
z-index: 2; z-index: 2;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: clamp(6px, 0.8vw, 12px);
pointer-events: auto; pointer-events: auto;
max-width: min(36vw, 420px);
} }
.slot-left { .slot-left {
left: 24px; left: clamp(8px, 1.2vw, 24px);
} }
.slot-right { .slot-right {
right: 24px; right: clamp(8px, 1.2vw, 24px);
justify-content: flex-end;
} }
</style> </style>
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { api } from '@/api/http'
/** 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 const REFRESH_MS = 15 * 60 * 1000
type MacauWeatherDto = {
place?: string
temp?: string
text?: string
humidity?: string
}
/**
* 澳门天气:经后端代理拉取地球物理气象局简报
* @see https://www.smg.gov.mo/zh
*/
export function useMacauWeather() { export function useMacauWeather() {
const place = ref('澳门') const place = ref('澳门')
const temp = ref('--') const temp = ref('--')
...@@ -26,29 +23,23 @@ export function useMacauWeather() { ...@@ -26,29 +23,23 @@ export function useMacauWeather() {
async function fetchWeather() { async function fetchWeather() {
try { try {
const url = const data = await api<MacauWeatherDto>('/api/weather/macau')
`https://api.open-meteo.com/v1/forecast` + place.value = data.place || '澳门'
`?latitude=${MACAU.lat}&longitude=${MACAU.lon}` + temp.value = data.temp || '--'
`&current=temperature_2m,relative_humidity_2m,weather_code` + text.value = data.text || '—'
`&timezone=Asia%2FMacau` humidity.value = data.humidity || '--'
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 { } catch {
text.value = '暂无数据' text.value = '暂无数据'
} }
} }
onMounted(() => { onMounted(() => {
fetchWeather() void fetchWeather()
timer = window.setInterval(fetchWeather, REFRESH_MS) timer = window.setInterval(() => void fetchWeather(), REFRESH_MS)
})
onUnmounted(() => {
if (timer) window.clearInterval(timer)
}) })
onUnmounted(() => window.clearInterval(timer))
return { place, temp, text, humidity } return { place, temp, text, humidity }
} }
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { api } from '@/api/http'
import type { HistorySeries } from '@/utils/historyChart'
export function useMetricHistory(
getQuery: () => { source: string; metric: string; hours?: number; bucketMinutes?: number },
pollMs = 60_000,
) {
const series = ref<HistorySeries[]>([])
const loading = ref(false)
const error = ref('')
let timer: number | undefined
async function refresh() {
const q = getQuery()
if (!q.source || !q.metric) return
loading.value = true
error.value = ''
try {
const params = new URLSearchParams({
source: q.source,
metric: q.metric,
hours: String(q.hours ?? 24),
bucketMinutes: String(q.bucketMinutes ?? 15),
})
const res = await api<{ series: HistorySeries[] }>(`/api/realtime/history?${params}`)
series.value = res.series || []
} catch (e) {
error.value = e instanceof Error ? e.message : '历史加载失败'
} finally {
loading.value = false
}
}
onMounted(() => {
void refresh()
timer = window.setInterval(() => void refresh(), pollMs)
})
onBeforeUnmount(() => {
if (timer) window.clearInterval(timer)
})
watch(getQuery, () => void refresh(), { deep: true })
return { series, loading, error, refresh }
}
...@@ -352,12 +352,10 @@ export function useRtengineBridge(opts: { ...@@ -352,12 +352,10 @@ export function useRtengineBridge(opts: {
send('clearMarkers', {}) send('clearMarkers', {})
} }
/** 显示场景内预设 POI / Marker */ /** 显示场景内预设 POI / Marker(合并为一次,减少场景刷新/光照闪变) */
function showScenePresets() { function showScenePresets() {
send('showPOIs', { visible: true })
send('setPoiVisible', { visible: true })
send('setPoiGroupVisible', { visible: true })
send('setMarkerVisible', { visible: true }) send('setMarkerVisible', { visible: true })
send('showPOIs', { visible: true })
} }
let readyNotified = false let readyNotified = false
......
import { onBeforeUnmount, onMounted, shallowRef, ref } from 'vue'
import { api } from '@/api/http'
import type { ScreenDashboard } from '@/types/screen'
const emptyDigits: ScreenDashboard['digits'] = {
deviceTotal: 0,
deviceOnline: 0,
roomOk: 0,
roomTotal: 0,
fridgeOk: 0,
fridgeTotal: 0,
alarmOpen: 0,
gatewayOk: false,
powerKwh: 0,
waterM3: 0,
coolingKwh: 0,
}
export function useScreenDashboard(pollMs = 5000) {
/** shallow:整包替换,避免对 curves/snapshot 深度代理导致渲染卡死 */
const data = shallowRef<ScreenDashboard | null>(null)
const loading = ref(false)
const error = ref('')
let timer: number | undefined
let inFlight = false
async function refresh() {
if (inFlight) return
inFlight = true
try {
const next = await api<ScreenDashboard>('/api/realtime/screen')
data.value = next
error.value = ''
} catch (e) {
if (!data.value) {
error.value = e instanceof Error ? e.message : '大屏数据加载失败'
}
} finally {
loading.value = false
inFlight = false
}
}
onMounted(() => {
void refresh()
timer = window.setInterval(() => void refresh(), pollMs)
})
onBeforeUnmount(() => {
if (timer) window.clearInterval(timer)
})
return { data, loading, error, refresh, emptyDigits }
}
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
/**
* 大屏铺满浏览器视口(无 1920×1080 黑边缩放)。
* 同步写入 --screen-side-w,供左右面板与侧栏对齐。
*/
export function useStageScale() { export function useStageScale() {
const style = ref({ const style = ref<Record<string, string>>({
transform: 'scale(1)', position: 'fixed',
left: '0px', inset: '0',
top: '0px', width: '100%',
height: '100%',
transform: 'none',
left: '0',
top: '0',
}) })
function fit() { function fit() {
const s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080) const w = window.innerWidth
style.value = { // 窄屏收窄侧栏,宽屏不超过 480
transform: `scale(${s})`, const side = Math.round(Math.min(480, Math.max(280, w * 0.24)))
left: `${(window.innerWidth - 1920 * s) / 2}px`, document.documentElement.style.setProperty('--screen-side-w', `${side}px`)
top: `${(window.innerHeight - 1080 * s) / 2}px`, document.documentElement.style.setProperty('--screen-vw', `${w}px`)
} document.documentElement.style.setProperty('--screen-vh', `${window.innerHeight}px`)
} }
onMounted(() => { onMounted(() => {
......
...@@ -44,6 +44,13 @@ const navItems = computed(() => ...@@ -44,6 +44,13 @@ const navItems = computed(() =>
icon: 'cameras', icon: 'cameras',
}, },
{ {
to: '/admin/history',
label: '历史曲线',
hint: '时序 / 指标',
tone: 'accent' as const,
icon: 'devices',
},
{
to: '/admin/users', to: '/admin/users',
label: '用户管理', label: '用户管理',
hint: '账号 / 角色', hint: '账号 / 角色',
......
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime' import { useRealtimeStore } from '@/stores/realtime'
...@@ -8,6 +8,7 @@ import { useMacauWeather } from '@/composables/useMacauWeather' ...@@ -8,6 +8,7 @@ import { useMacauWeather } from '@/composables/useMacauWeather'
import { useStageScale } from '@/composables/useStageScale' import { useStageScale } from '@/composables/useStageScale'
import ScreenHeader from '@/components/screen/ScreenHeader.vue' import ScreenHeader from '@/components/screen/ScreenHeader.vue'
import SceneViewport from '@/components/scene/SceneViewport.vue' import SceneViewport from '@/components/scene/SceneViewport.vue'
import AppIcon from '@/components/icons/AppIcon.vue'
const auth = useAuthStore() const auth = useAuthStore()
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
...@@ -15,6 +16,9 @@ const router = useRouter() ...@@ -15,6 +16,9 @@ const router = useRouter()
const { time, date } = useClock() const { time, date } = useClock()
const { place, temp, text: weatherText, humidity } = useMacauWeather() const { place, temp, text: weatherText, humidity } = useMacauWeather()
const { style } = useStageScale() const { style } = useStageScale()
const alarmOpen = ref(false)
const alarmBtnRef = ref<HTMLButtonElement | null>(null)
const alarmPanelStyle = ref<Record<string, string>>({})
const navItems = [ const navItems = [
{ to: '/dashboard/overview', label: '全景总览' }, { to: '/dashboard/overview', label: '全景总览' },
...@@ -26,14 +30,56 @@ const navItems = [ ...@@ -26,14 +30,56 @@ const navItems = [
{ to: '/dashboard/video', label: '视频监控' }, { to: '/dashboard/video', label: '视频监控' },
{ to: '/dashboard/access', label: '门禁系统' }, { to: '/dashboard/access', label: '门禁系统' },
{ to: '/dashboard/cold', label: '冷链监测' }, { to: '/dashboard/cold', label: '冷链监测' },
{ to: '/dashboard/history', label: '历史曲线' },
{ to: '/dashboard/light', label: '智能照明' }, { to: '/dashboard/light', label: '智能照明' },
] ]
const alarmItems = computed(() => {
const alarms = realtime.snapshot.pointAlarms || []
if (!alarms.length) {
return [{ level: 'calm' as const, title: '系统运行正常', sub: '点位阈值监测中', state: '正常' }]
}
return alarms.slice(0, 12).map((a) => ({
level: (a.level === 'red' ? 'red' : 'amber') as 'red' | 'amber',
title: a.message,
sub: a.name || '点位超限',
state: '关注中',
}))
})
onMounted(() => { onMounted(() => {
realtime.connect() realtime.connect()
if (auth.isLoggedIn && !auth.user) auth.fetchMe().catch(() => undefined) if (auth.isLoggedIn && !auth.user) auth.fetchMe().catch(() => undefined)
window.addEventListener('keydown', onKey)
})
onUnmounted(() => {
realtime.disconnect()
window.removeEventListener('keydown', onKey)
}) })
onUnmounted(() => realtime.disconnect())
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') alarmOpen.value = false
}
function placeAlarmPanel() {
const el = alarmBtnRef.value
const stage = el?.closest('.stage') as HTMLElement | null
if (!el || !stage) return
const br = el.getBoundingClientRect()
const sr = stage.getBoundingClientRect()
alarmPanelStyle.value = {
top: `${Math.round(br.bottom - sr.top + 8)}px`,
right: `${Math.round(sr.right - br.right)}px`,
}
}
async function toggleAlarm() {
alarmOpen.value = !alarmOpen.value
if (alarmOpen.value) {
await nextTick()
placeAlarmPanel()
}
}
function goAdmin() { function goAdmin() {
if (auth.isLoggedIn) router.push('/admin') if (auth.isLoggedIn) router.push('/admin')
...@@ -43,6 +89,9 @@ function goAdmin() { ...@@ -43,6 +89,9 @@ function goAdmin() {
<template> <template>
<div class="stage stage--immersive" :style="style"> <div class="stage stage--immersive" :style="style">
<!-- 浮层挂载点需先于 SceneViewport,供 Teleport;z-index 高于 edge-mask -->
<div id="screen-float-overlays" class="screen-float-overlays" />
<!-- 底层:RTEngine / 三维场景铺满 --> <!-- 底层:RTEngine / 三维场景铺满 -->
<div class="scene-layer"> <div class="scene-layer">
<SceneViewport layout="fill" /> <SceneViewport layout="fill" />
...@@ -72,33 +121,67 @@ function goAdmin() { ...@@ -72,33 +121,67 @@ function goAdmin() {
<span class="time">{{ time }}</span> <span class="time">{{ time }}</span>
<span class="date">{{ date }}</span> <span class="date">{{ date }}</span>
</div> </div>
<div class="alarm-badge" @click="router.push('/dashboard/overview')"> <div class="alarm-wrap">
<button
ref="alarmBtnRef"
type="button"
class="alarm-badge"
:class="{ open: alarmOpen }"
aria-haspopup="true"
:aria-expanded="alarmOpen"
@click.stop="toggleAlarm"
>
<span class="dot red" /> <span class="dot red" />
<span>告警 {{ realtime.alarmCount }}</span> <span>告警 {{ realtime.alarmCount }}</span>
</button>
</div> </div>
<button class="tops-logout admin-entry" type="button" @click="goAdmin">管理</button> <button class="tops-logout admin-entry" type="button" @click="goAdmin">管理</button>
</template> </template>
</ScreenHeader> </ScreenHeader>
<nav class="screen-nav screen-nav--float"> <main class="screen-main screen-main--float">
<RouterView />
</main>
<!-- 地图右侧功能导航(纵向) -->
<nav class="screen-nav screen-nav--side" aria-label="功能导航">
<RouterLink v-for="item in navItems" :key="item.to" class="nav-chip" :to="item.to"> <RouterLink v-for="item in navItems" :key="item.to" class="nav-chip" :to="item.to">
{{ item.label }} {{ item.label }}
</RouterLink> </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> </nav>
</div>
<main class="screen-main screen-main--float"> <!-- 告警面板:直接挂 stage 顶层(不用 Teleport,避免打不开) -->
<RouterView /> <div v-if="alarmOpen" class="alarm-backdrop" @click="alarmOpen = false" />
</main> <div
v-if="alarmOpen"
class="alarm-dropdown"
role="dialog"
aria-label="告警列表"
:style="alarmPanelStyle"
@click.stop
>
<div class="alarm-dd-head">
<span>当前告警</span>
<b>{{ realtime.alarmCount }}</b>
</div>
<div class="alarm-dd-list">
<div
v-for="(a, i) in alarmItems"
:key="`${a.title}-${i}`"
class="alarm-dd-item"
:class="a.level"
>
<AppIcon
:name="a.level === 'red' ? 'alarm-warn' : a.level === 'amber' ? 'alarm-amber' : 'alarm-ok'"
/>
<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> </div>
</div> </div>
</template> </template>
...@@ -106,6 +189,8 @@ function goAdmin() { ...@@ -106,6 +189,8 @@ function goAdmin() {
<style scoped> <style scoped>
.stage--immersive { .stage--immersive {
background: #00060f; background: #00060f;
width: 100%;
height: 100%;
} }
.stage--immersive::before { .stage--immersive::before {
display: none; display: none;
...@@ -142,11 +227,11 @@ function goAdmin() { ...@@ -142,11 +227,11 @@ function goAdmin() {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
height: 200px; height: 140px;
background: linear-gradient( background: linear-gradient(
0deg, 0deg,
rgba(0, 6, 18, 0.9) 0%, rgba(0, 6, 18, 0.82) 0%,
rgba(0, 10, 28, 0.5) 48%, rgba(0, 10, 28, 0.4) 48%,
rgba(0, 10, 28, 0) 100% rgba(0, 10, 28, 0) 100%
); );
} }
...@@ -154,7 +239,7 @@ function goAdmin() { ...@@ -154,7 +239,7 @@ function goAdmin() {
left: 0; left: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 520px; width: calc(var(--screen-side-w, 24vw) + 40px);
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
rgba(0, 8, 22, 0.82) 0%, rgba(0, 8, 22, 0.82) 0%,
...@@ -166,7 +251,7 @@ function goAdmin() { ...@@ -166,7 +251,7 @@ function goAdmin() {
right: 0; right: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 520px; width: calc(var(--screen-side-w, 24vw) + 40px);
background: linear-gradient( background: linear-gradient(
270deg, 270deg,
rgba(0, 8, 22, 0.82) 0%, rgba(0, 8, 22, 0.82) 0%,
...@@ -182,11 +267,6 @@ function goAdmin() { ...@@ -182,11 +267,6 @@ function goAdmin() {
flex-direction: column; flex-direction: column;
pointer-events: none; pointer-events: none;
} }
.screen-nav--float {
pointer-events: auto;
background: linear-gradient(180deg, rgba(0, 12, 28, 0.55), rgba(0, 12, 28, 0.12));
border-bottom: 1px solid rgba(0, 247, 255, 0.12);
}
.screen-main--float { .screen-main--float {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
...@@ -194,14 +274,81 @@ function goAdmin() { ...@@ -194,14 +274,81 @@ function goAdmin() {
/* 默认穿透到三维层;各页面自行给面板开 pointer-events */ /* 默认穿透到三维层;各页面自行给面板开 pointer-events */
pointer-events: none; pointer-events: none;
} }
.screen-float-overlays {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
}
.screen-float-overlays > * {
pointer-events: auto;
}
/* scene 0 < edge 1 < ui 2 < float 5 < side-nav 6 < alarm 200 */
/* 贴在右侧业务面板左侧,纵向功能按钮(无背景面板)
* 覆盖全局 .screen-nav 的 height:48px / flex:0 0 48px,否则只露出 1 个按钮 */
.screen-nav--side {
position: absolute;
z-index: 6;
/* 避开顶栏 + 中间翻牌器;贴在右侧面板内侧 */
top: clamp(200px, 26vh, 280px);
bottom: clamp(56px, 8vh, 88px);
right: calc(var(--screen-side-w, 24vw) + 28px);
width: clamp(88px, 6vw, 108px);
height: auto !important;
flex: none !important;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
backdrop-filter: none;
box-shadow: none;
pointer-events: auto;
overflow-x: hidden;
overflow-y: auto;
}
.screen-nav--side :deep(.nav-chip),
.screen-nav--side .nav-chip {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 100%;
min-height: 34px;
padding: 7px 6px;
text-align: center;
font-size: 11px;
line-height: 1.25;
white-space: normal;
border-radius: 4px;
background: rgba(8, 28, 58, 0.55);
border: 1px solid rgba(91, 212, 255, 0.32);
backdrop-filter: blur(6px);
}
.weather-wrap, .weather-wrap,
.clock-wrap { .clock-wrap {
position: static; position: static;
transform: none; transform: none;
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 10px; gap: clamp(4px, 0.6vw, 10px);
white-space: nowrap; white-space: nowrap;
max-width: 100%;
overflow: hidden;
}
@media (max-width: 1280px) {
.weather-wrap .w-text,
.weather-wrap .humid {
display: none;
}
}
@media (max-width: 960px) {
.weather-wrap {
display: none;
}
} }
.weather-wrap .place, .weather-wrap .place,
.weather-wrap .temp { .weather-wrap .temp {
...@@ -232,4 +379,108 @@ function goAdmin() { ...@@ -232,4 +379,108 @@ function goAdmin() {
letter-spacing: 0.12em !important; letter-spacing: 0.12em !important;
color: var(--accent) !important; color: var(--accent) !important;
} }
.alarm-wrap {
position: relative;
pointer-events: auto;
}
.alarm-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font: inherit;
cursor: pointer;
}
.alarm-badge.open {
border-color: rgba(255, 120, 120, 0.75);
background: rgba(80, 16, 24, 0.55);
}
.alarm-backdrop {
position: absolute;
inset: 0;
z-index: 200;
background: transparent;
pointer-events: auto;
}
.alarm-dropdown {
position: absolute;
z-index: 201;
width: min(360px, 72vw);
max-height: min(420px, 60vh);
display: flex;
flex-direction: column;
border-radius: 6px;
border: 1px solid rgba(255, 120, 120, 0.35);
background: linear-gradient(180deg, rgba(28, 10, 16, 0.96), rgba(8, 14, 28, 0.96));
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(10px);
overflow: hidden;
pointer-events: auto;
}
.alarm-dd-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
font-size: 12px;
color: #ffc9c9;
border-bottom: 1px solid rgba(255, 120, 120, 0.22);
}
.alarm-dd-head b {
font-family: var(--digital-font);
color: #ff8f8f;
}
.alarm-dd-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.alarm-dd-item {
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 6px 8px;
border-radius: 5px;
font-size: 12px;
}
.alarm-dd-item.red {
background: rgba(255, 82, 82, 0.12);
border: 1px solid rgba(255, 82, 82, 0.35);
}
.alarm-dd-item.amber {
background: rgba(255, 180, 0, 0.1);
border: 1px solid rgba(255, 180, 0, 0.3);
}
.alarm-dd-item.calm {
background: rgba(26, 77, 153, 0.22);
border: 1px solid transparent;
}
.alarm-dd-item .a-txt {
flex: 1;
min-width: 0;
}
.alarm-dd-item .a-title {
color: #f2f7ff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.alarm-dd-item .a-sub {
margin-top: 2px;
font-size: 10px;
color: rgba(185, 214, 240, 0.72);
}
.alarm-dd-item .a-state {
flex-shrink: 0;
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.28);
color: #ffd0d0;
white-space: nowrap;
}
</style> </style>
...@@ -90,6 +90,12 @@ const router = createRouter({ ...@@ -90,6 +90,12 @@ const router = createRouter({
meta: { title: '冷链监测', public: true }, meta: { title: '冷链监测', public: true },
}, },
{ {
path: 'history',
name: 'history',
component: () => import('@/views/history/HistoryView.vue'),
meta: { title: '历史曲线', public: true },
},
{
path: 'light', path: 'light',
name: 'light', name: 'light',
component: () => import('@/views/light/LightView.vue'), component: () => import('@/views/light/LightView.vue'),
...@@ -110,6 +116,12 @@ const router = createRouter({ ...@@ -110,6 +116,12 @@ const router = createRouter({
meta: { title: '设备管理', requiresAuth: true }, meta: { title: '设备管理', requiresAuth: true },
}, },
{ {
path: 'history',
name: 'admin-history',
component: () => import('@/views/history/HistoryView.vue'),
meta: { title: '历史曲线', requiresAuth: true },
},
{
path: 'cameras', path: 'cameras',
name: 'cameras', name: 'cameras',
component: () => import('@/views/cameras/CamerasView.vue'), component: () => import('@/views/cameras/CamerasView.vue'),
......
...@@ -65,6 +65,10 @@ export const useRealtimeStore = defineStore('realtime', () => { ...@@ -65,6 +65,10 @@ export const useRealtimeStore = defineStore('realtime', () => {
selectedRoomId.value = id selectedRoomId.value = id
} }
function setSnapshot(next: RealtimeSnapshot) {
snapshot.value = next
}
function connect() { function connect() {
const url = import.meta.env.VITE_WS_URL || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws` const url = import.meta.env.VITE_WS_URL || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`
ws?.close() ws?.close()
...@@ -101,6 +105,7 @@ export const useRealtimeStore = defineStore('realtime', () => { ...@@ -101,6 +105,7 @@ export const useRealtimeStore = defineStore('realtime', () => {
alarmCount, alarmCount,
primaryRoom, primaryRoom,
selectRoom, selectRoom,
setSnapshot,
connect, connect,
disconnect, disconnect,
} }
......
.stage { .stage {
position: absolute; position: fixed;
width: 1920px; inset: 0;
height: 1080px; width: 100%;
transform-origin: top left; height: 100%;
max-width: 100vw;
max-height: 100vh;
transform: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
background: #00060f; background: #00060f;
color: var(--text); color: var(--text);
font-family: var(--body-font); font-family: var(--body-font);
/* 左右业务栏宽度,随视口由 useStageScale 更新 */
--screen-side-w: clamp(280px, 24vw, 480px);
} }
.stage::before { .stage::before {
content: none; content: none;
......
import type { RealtimeSnapshot } from '@/types'
export type FridgeTempSeries = {
id: string
name: string
points: { t: number; temp: number }[]
}
export type TsSeries = {
id: string
name?: string
points: { t: number; v: number }[]
}
export type ScreenCurves = {
roomTemp: TsSeries[]
roomHumidity: TsSeries[]
roomPressure: TsSeries[]
fridgeTemp: TsSeries[]
fridgeHumidity: TsSeries[]
vavAirflow: TsSeries[]
vavFaceVelocity: TsSeries[]
hvacSupplyTemp: TsSeries[]
hvacSupplyHumidity: TsSeries[]
hvacSupplyPressure: TsSeries[]
hvacFreshAirTemp: TsSeries[]
hvacExhaustPressure: TsSeries[]
meteringPower: TsSeries[]
meteringWater: TsSeries[]
meteringCooling: TsSeries[]
}
export type ScreenDashboard = {
ts: number
snapshot: RealtimeSnapshot
categoryPie: { name: string; value: number; category: string }[]
statusPie: { name: string; value: number }[]
powerBars: { name: string; value: number | null }[]
pressureBars: { name: string; value: number | null; ok: boolean }[]
envBubbles: {
name: string
temp: number | null
humidity: number | null
pressure: number | null
ok: boolean
}[]
fridgeBars: { id: string; name: string; temp: number | null; ok: boolean }[]
fridgeSeries: FridgeTempSeries[]
curves?: ScreenCurves
digits: {
deviceTotal: number
deviceOnline: number
roomOk: number
roomTotal: number
fridgeOk: number
fridgeTotal: number
alarmOpen: number
gatewayOk: boolean
powerKwh: number
waterM3: number
coolingKwh: number
}
pauCards: { label: string; value: number | null; unit: string; ok: boolean | null }[]
vavBars: { name: string; airflow: number | null; faceVelocity: number | null; running: boolean }[]
}
/** 大屏图表统一:小字号、紧凑浮层、数值四舍五入 */
export function roundNum(v: unknown, digits = 1): string {
if (v === null || v === undefined || v === '') return '—'
const n = Number(v)
if (!Number.isFinite(n)) return String(v)
const f = 10 ** digits
return String(Math.round(n * f) / f)
}
export const compactTooltipBase = {
backgroundColor: 'rgba(4, 18, 36, 0.92)',
borderColor: 'rgba(0, 247, 255, 0.28)',
borderWidth: 1,
padding: [6, 8] as [number, number],
textStyle: {
color: '#b8d4e8',
fontSize: 11,
lineHeight: 16,
},
extraCssText: 'box-shadow:0 4px 12px rgba(0,0,0,.35);border-radius:4px;max-width:220px;',
}
/** 折线 axis tooltip:系列值四舍五入 */
export function axisTooltip(unit = '') {
return {
...compactTooltipBase,
trigger: 'axis' as const,
axisPointer: { type: 'line' as const, lineStyle: { color: 'rgba(0,247,255,0.25)' } },
formatter: (params: unknown) => {
const list = Array.isArray(params) ? params : [params]
if (!list.length) return ''
const axis = String((list[0] as { axisValueLabel?: string }).axisValueLabel ?? '')
const lines = list.map((raw) => {
const p = raw as { marker?: string; seriesName?: string; value?: unknown }
const val = Array.isArray(p.value) ? p.value[p.value.length - 1] : p.value
const num = roundNum(val, 1)
return `${p.marker || ''}${p.seriesName || ''} <b style="font-weight:600;color:#e8fbff">${num}${unit}</b>`
})
return `<div style="font-size:10px;color:#7a9bb0;margin-bottom:2px">${axis}</div>${lines.join('<br/>')}`
},
}
}
/** 饼图 item tooltip */
export function pieTooltip() {
return {
...compactTooltipBase,
trigger: 'item' as const,
formatter: (params: unknown) => {
const p = params as { marker?: string; name?: string; value?: unknown; percent?: number }
return `${p.marker || ''}${p.name || ''} <b style="color:#e8fbff">${roundNum(p.value, 0)}</b>` +
(p.percent != null ? ` <span style="color:#7a9bb0">(${Math.round(p.percent)}%)</span>` : '')
},
}
}
import type { ECOption } from '@/components/charts/EChart.vue'
import { axisTooltip } from '@/utils/chartTooltip'
export type HistoryPoint = { t: number; v: number }
export type HistorySeries = { id: string; name?: string; points: HistoryPoint[] }
const PALETTE = [
'#37c8ff',
'#52c41a',
'#faad14',
'#b37feb',
'#ff85c0',
'#5cdbd3',
'#69c0ff',
'#95de64',
'#ff9c6e',
'#85a5ff',
]
const chartText = '#9ec8dc'
const chartMuted = 'rgba(158,200,220,0.35)'
function formatTick(t: number) {
const d = new Date(t)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
/** 多设备历史折线(统一时间轴) */
export function multiLineOption(
series: HistorySeries[],
opts?: { unit?: string; maxSeries?: number; yName?: string },
): ECOption {
const maxSeries = opts?.maxSeries ?? 12
const list = series.filter((s) => s.points?.length).slice(0, maxSeries)
if (!list.length) {
return { backgroundColor: 'transparent', series: [{ type: 'line', data: [] }] }
}
const times = new Set<number>()
for (const s of list) for (const p of s.points) times.add(p.t)
const xs = [...times].sort((a, b) => a - b)
const unit = opts?.unit || ''
return {
backgroundColor: 'transparent',
legend: {
type: 'scroll',
bottom: 0,
textStyle: { color: chartText, fontSize: 9 },
pageTextStyle: { color: chartText },
},
grid: { left: 42, right: 10, top: 16, bottom: 36 },
tooltip: axisTooltip(unit),
xAxis: {
type: 'category',
data: xs.map(formatTick),
axisLabel: { color: chartText, fontSize: 9 },
axisLine: { lineStyle: { color: chartMuted } },
},
yAxis: {
type: 'value',
scale: true,
name: opts?.yName || unit,
nameTextStyle: { color: chartMuted, fontSize: 10 },
axisLabel: {
color: chartText,
fontSize: 10,
formatter: (v: number) => {
const n = Math.round(Number(v) * 10) / 10
return unit ? `${n}${unit}` : String(n)
},
},
splitLine: { lineStyle: { color: 'rgba(0,247,255,0.08)' } },
},
series: list.map((s, i) => {
const map = new Map(s.points.map((p) => [p.t, p.v]))
return {
name: s.name || s.id,
type: 'line' as const,
showSymbol: false,
smooth: true,
lineStyle: { width: 1.5 },
itemStyle: { color: PALETTE[i % PALETTE.length] },
data: xs.map((t) => {
const v = map.get(t)
return v == null ? null : Math.round(v * 10) / 10
}),
}
}),
}
}
export function toHistorySeries(
series: { id: string; name?: string; points: { t: number; temp?: number; v?: number }[] }[],
): HistorySeries[] {
return series.map((s) => ({
id: s.id,
name: s.name,
points: s.points.map((p) => ({
t: p.t,
v: p.v ?? p.temp ?? 0,
})),
}))
}
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import { api } from '@/api/http'
import EChart from '@/components/charts/EChart.vue'
import { useRealtimeStore } from '@/stores/realtime' import { useRealtimeStore } from '@/stores/realtime'
import { multiLineOption } from '@/utils/historyChart'
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
const series = ref<{ id: string; name?: string; points: { t: number; v: number }[] }[]>([])
const humidity = ref<{ id: string; name?: string; points: { t: number; v: number }[] }[]>([])
const loading = ref(false)
let timer: number | undefined
const tempOption = computed(() => multiLineOption(series.value, { unit: '℃', maxSeries: 12 }))
const humidityOption = computed(() => multiLineOption(humidity.value, { unit: '%', maxSeries: 12 }))
async function load() {
loading.value = true
try {
const [t, h] = await Promise.all([
api<{ series: typeof series.value }>('/api/realtime/history?source=fridge&metric=temp&hours=24&bucketMinutes=15'),
api<{ series: typeof humidity.value }>(
'/api/realtime/history?source=fridge&metric=humidity&hours=24&bucketMinutes=15',
),
])
series.value = t.series || []
humidity.value = h.series || []
} finally {
loading.value = false
}
}
onMounted(() => {
void load()
timer = window.setInterval(() => void load(), 60_000)
})
onBeforeUnmount(() => {
if (timer) window.clearInterval(timer)
})
</script> </script>
<template> <template>
<div class="page-fill"> <div class="page-fill cold-page">
<section class="panel" style="flex:1; overflow:auto"> <section class="panel" style="flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 10px">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>冷链监测</h3></div> <div class="panel-title"><div class="vbar" /><h3>冷链监测 · 历史曲线</h3></div>
<div class="badge">E栋 · 网关 192.168.10.105</div> <div class="badge">E栋 · 近 24h</div>
</div>
<div class="charts">
<div class="chart-card">
<div class="chart-title">温度 ℃</div>
<EChart :option="tempOption" :loading="loading" empty-text="温度历史积累中" />
</div>
<div class="chart-card">
<div class="chart-title">湿度 %RH</div>
<EChart :option="humidityOption" :loading="loading" empty-text="湿度历史积累中" />
</div>
</div> </div>
<table class="table"> <table class="table">
<thead> <thead>
<tr><th>设备</th><th>温度 (℃)</th><th>湿度 (%)</th><th>状态</th></tr> <tr><th>设备</th><th>温度 (℃)</th><th>湿度 (%)</th><th>状态</th></tr>
...@@ -17,8 +65,10 @@ const realtime = useRealtimeStore() ...@@ -17,8 +65,10 @@ const realtime = useRealtimeStore()
<tbody> <tbody>
<tr v-for="f in realtime.snapshot.fridges" :key="f.id"> <tr v-for="f in realtime.snapshot.fridges" :key="f.id">
<td>{{ f.name }}</td> <td>{{ f.name }}</td>
<td :style="{ color: f.temp > -19 ? 'var(--amber)' : 'var(--green)' }">T: {{ f.temp.toFixed(1) }} ℃</td> <td :style="{ color: f.temp > -19 ? 'var(--amber)' : 'var(--green)' }">
<td>RH: {{ f.humidity }} %</td> {{ f.temp.toFixed(1) }} ℃
</td>
<td>{{ f.humidity }} %</td>
<td>{{ f.temp > -19 ? '超限' : '正常' }}</td> <td>{{ f.temp > -19 ? '超限' : '正常' }}</td>
</tr> </tr>
</tbody> </tbody>
...@@ -26,3 +76,32 @@ const realtime = useRealtimeStore() ...@@ -26,3 +76,32 @@ const realtime = useRealtimeStore()
</section> </section>
</div> </div>
</template> </template>
<style scoped>
.cold-page { display: flex; min-height: 0; }
.charts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
min-height: 260px;
flex: 1;
}
.chart-card {
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid rgba(0, 247, 255, 0.16);
border-radius: 6px;
padding: 8px;
background: rgba(4, 16, 32, 0.35);
}
.chart-title {
font-size: 12px;
color: var(--label);
margin-bottom: 4px;
}
.chart-card :deep(.echart-wrap) {
flex: 1;
min-height: 200px;
}
</style>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { api } from '@/api/http'
import EChart from '@/components/charts/EChart.vue'
import { multiLineOption } from '@/utils/historyChart'
type CatalogItem = {
source: string
metric: string
deviceId: string
seriesId: string
points: number
lastBucket: string | null
sourceLabel: string
metricLabel: string
name: string
}
type HistSeries = { id: string; name?: string; points: { t: number; v: number }[] }
const catalog = ref<CatalogItem[]>([])
const loading = ref(false)
const error = ref('')
const hours = ref(24)
const bucketMinutes = ref(15)
const source = ref('')
const metric = ref('')
const deviceId = ref('')
const series = ref<HistSeries[]>([])
const sources = computed(() => {
const map = new Map<string, string>()
for (const c of catalog.value) map.set(c.source, c.sourceLabel)
return [...map.entries()].map(([value, label]) => ({ value, label }))
})
const metrics = computed(() => {
const map = new Map<string, string>()
for (const c of catalog.value) {
if (source.value && c.source !== source.value) continue
map.set(c.metric, c.metricLabel)
}
return [...map.entries()].map(([value, label]) => ({ value, label }))
})
const devices = computed(() => {
return catalog.value
.filter((c) => (!source.value || c.source === source.value) && (!metric.value || c.metric === metric.value))
.map((c) => ({ value: c.deviceId, label: c.name, points: c.points }))
})
const chartOption = computed(() =>
multiLineOption(series.value, {
maxSeries: 16,
unit: metric.value === 'temp' || metric.value.includes('temp') ? '℃' : metric.value === 'pressure' ? 'Pa' : '',
}),
)
const summary = computed(() => {
const n = series.value.length
const pts = series.value.reduce((s, x) => s + x.points.length, 0)
return `${n} 条序列 · ${pts} 个聚合点 · 近 ${hours.value}h`
})
async function loadCatalog() {
loading.value = true
error.value = ''
try {
const res = await api<{ catalog: CatalogItem[] }>('/api/realtime/history/catalog')
catalog.value = res.catalog || []
if (!source.value && catalog.value.length) {
source.value = catalog.value[0].source
metric.value = catalog.value[0].metric
}
} catch (e) {
error.value = e instanceof Error ? e.message : '目录加载失败'
} finally {
loading.value = false
}
}
async function loadSeries() {
if (!source.value || !metric.value) {
series.value = []
return
}
loading.value = true
error.value = ''
try {
const q = new URLSearchParams({
source: source.value,
metric: metric.value,
hours: String(hours.value),
bucketMinutes: String(bucketMinutes.value),
})
if (deviceId.value) q.set('deviceId', deviceId.value)
const res = await api<{ series: HistSeries[] }>(`/api/realtime/history?${q}`)
series.value = res.series || []
} catch (e) {
error.value = e instanceof Error ? e.message : '曲线加载失败'
series.value = []
} finally {
loading.value = false
}
}
watch([source, metric], () => {
deviceId.value = ''
})
watch([source, metric, deviceId, hours, bucketMinutes], () => {
void loadSeries()
})
onMounted(async () => {
await loadCatalog()
await loadSeries()
})
</script>
<template>
<div class="page-fill history-page">
<section class="panel history-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>历史数据与曲线</h3></div>
<div class="badge">时序预聚合 · {{ summary }}</div>
</div>
<div class="filters">
<label>
类别
<select v-model="source">
<option v-for="s in sources" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
</label>
<label>
指标
<select v-model="metric">
<option v-for="m in metrics" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</label>
<label>
设备
<select v-model="deviceId">
<option value="">全部设备</option>
<option v-for="d in devices" :key="d.value" :value="d.value">
{{ d.label }} ({{ d.points }})
</option>
</select>
</label>
<label>
时长
<select v-model.number="hours">
<option :value="6">近 6 小时</option>
<option :value="24">近 24 小时</option>
<option :value="72">近 3 天</option>
<option :value="168">近 7 天</option>
</select>
</label>
<label>
粒度
<select v-model.number="bucketMinutes">
<option :value="5">5 分钟</option>
<option :value="15">15 分钟</option>
<option :value="60">1 小时</option>
</select>
</label>
<button type="button" class="btn" :disabled="loading" @click="loadSeries">刷新</button>
</div>
<div v-if="error" class="err">{{ error }}</div>
<div class="chart-box">
<EChart :option="chartOption" :loading="loading" empty-text="暂无该指标历史数据,采集后将自动出现" />
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>设备</th>
<th>点数</th>
<th>最新</th>
<th>最低</th>
<th>最高</th>
<th>平均</th>
</tr>
</thead>
<tbody>
<tr v-for="s in series" :key="s.id">
<td>{{ s.name || s.id }}</td>
<td>{{ s.points.length }}</td>
<td>{{ s.points.length ? s.points[s.points.length - 1].v.toFixed(2) : '—' }}</td>
<td>
{{
s.points.length ? Math.min(...s.points.map((p) => p.v)).toFixed(2) : '—'
}}
</td>
<td>
{{
s.points.length ? Math.max(...s.points.map((p) => p.v)).toFixed(2) : '—'
}}
</td>
<td>
{{
s.points.length
? (s.points.reduce((a, p) => a + p.v, 0) / s.points.length).toFixed(2)
: '—'
}}
</td>
</tr>
<tr v-if="!series.length">
<td colspan="6" class="muted">无数据</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
<style scoped>
.history-page {
display: flex;
min-height: 0;
}
.history-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
align-items: end;
padding: 8px 4px 12px;
}
.filters label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--label, #8eb0c4);
}
.filters select {
min-width: 120px;
height: 30px;
border-radius: 4px;
border: 1px solid rgba(0, 247, 255, 0.25);
background: rgba(4, 20, 40, 0.85);
color: #e8f6ff;
padding: 0 8px;
}
.btn {
height: 30px;
padding: 0 14px;
border-radius: 4px;
border: 1px solid rgba(0, 247, 255, 0.35);
background: rgba(0, 120, 160, 0.35);
color: #dff8ff;
cursor: pointer;
}
.btn:disabled {
opacity: 0.5;
cursor: default;
}
.chart-box {
flex: 1;
min-height: 280px;
border: 1px solid rgba(0, 247, 255, 0.15);
border-radius: 6px;
background: rgba(4, 16, 32, 0.4);
padding: 6px;
}
.table-wrap {
margin-top: 10px;
max-height: 220px;
overflow: auto;
}
.muted {
text-align: center;
color: var(--label);
}
.err {
color: #ffb0b0;
font-size: 12px;
margin-bottom: 8px;
}
</style>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime' import { useRealtimeStore } from '@/stores/realtime'
import { api } from '@/api/http' import { api } from '@/api/http'
import EChart from '@/components/charts/EChart.vue'
import { useMetricHistory } from '@/composables/useMetricHistory'
import { multiLineOption } from '@/utils/historyChart'
const auth = useAuthStore() const auth = useAuthStore()
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
const pressureSet = ref(180) const pressureSet = ref(180)
const { series: supplyTemp, loading: loadTemp } = useMetricHistory(() => ({
source: 'hvac',
metric: 'supply_temp',
}))
const { series: supplyPressure, loading: loadPress } = useMetricHistory(() => ({
source: 'hvac',
metric: 'supply_pressure',
}))
const { series: exhaustPressure, loading: loadEx } = useMetricHistory(() => ({
source: 'hvac',
metric: 'exhaust_pressure',
}))
const hvacHistoryOpt = computed(() =>
multiLineOption(
[
...supplyTemp.value.map((s) => ({ ...s, name: '送风温度' })),
...supplyPressure.value.map((s) => ({ ...s, name: '送风压力' })),
...exhaustPressure.value.map((s) => ({ ...s, name: '排风压力' })),
],
{ maxSeries: 6 },
),
)
const histLoading = computed(() => loadTemp.value || loadPress.value || loadEx.value)
async function applySet() { async function applySet() {
if (!auth.isAdmin) return if (!auth.isAdmin) return
await api('/api/realtime/write', { await api('/api/realtime/write', {
...@@ -34,6 +62,10 @@ async function applySet() { ...@@ -34,6 +62,10 @@ async function applySet() {
<div class="metric"><div><div class="m-label">排风压力</div><div class="m-value">{{ realtime.snapshot.hvac.exhaustPressure }}</div></div></div> <div class="metric"><div><div class="m-label">排风压力</div><div class="m-value">{{ realtime.snapshot.hvac.exhaustPressure }}</div></div></div>
<div class="metric"><div><div class="m-label">新风温度</div><div class="m-value">{{ realtime.snapshot.hvac.freshAirTemp.toFixed(1) }}</div></div></div> <div class="metric"><div><div class="m-label">新风温度</div><div class="m-value">{{ realtime.snapshot.hvac.freshAirTemp.toFixed(1) }}</div></div></div>
</div> </div>
<div class="hist">
<div class="chart-title">近 24h 历史曲线</div>
<EChart :option="hvacHistoryOpt" :loading="histLoading" empty-text="机组历史积累中" />
</div>
</section> </section>
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
...@@ -53,3 +85,16 @@ async function applySet() { ...@@ -53,3 +85,16 @@ async function applySet() {
</section> </section>
</div> </div>
</template> </template>
<style scoped>
.hist {
margin-top: 12px;
min-height: 220px;
border: 1px solid rgba(0, 247, 255, 0.16);
border-radius: 6px;
padding: 8px;
background: rgba(4, 16, 32, 0.35);
}
.chart-title { font-size: 12px; color: var(--label); margin-bottom: 4px; }
.hist :deep(.echart-wrap) { height: 200px; }
</style>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import AppIcon from '@/components/icons/AppIcon.vue'
import PanelBox from '@/components/screen/PanelBox.vue' import PanelBox from '@/components/screen/PanelBox.vue'
import AlarmScroll from '@/components/panels/AlarmScroll.vue' import KpiFlipBoard, { type KpiFlipItem } from '@/components/screen/KpiFlipBoard.vue'
import ColdScroll from '@/components/panels/ColdScroll.vue' import EChart, { type ECOption } from '@/components/charts/EChart.vue'
import VavScroll from '@/components/panels/VavScroll.vue' import { useScreenDashboard } from '@/composables/useScreenDashboard'
import { useRealtimeStore } from '@/stores/realtime' import { useRealtimeStore } from '@/stores/realtime'
import { multiLineOption, toHistorySeries } from '@/utils/historyChart'
import { compactTooltipBase, pieTooltip, roundNum } from '@/utils/chartTooltip'
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
const snap = computed(() => realtime.snapshot) const { data, error } = useScreenDashboard(5000)
const room = computed(() => realtime.primaryRoom)
const vavRunning = computed(() => snap.value.vav.filter((v) => v.running).length) const digits = computed(() => data.value?.digits)
/** 面板用 screen 快照;三维场景继续走 WS,避免每 5s setSnapshot 打爆渲染 */
const snap = computed(() => data.value?.snapshot || realtime.snapshot)
const coldWarnCount = computed(() => snap.value.fridges.filter((f) => f.temp > -19).length) const kpiItems = computed<KpiFlipItem[]>(() => {
const d = digits.value
if (!d) {
return [
{ key: 'online', label: '设备在线率 %', value: '—', tone: 'device', icon: 'kpi-device' },
{ key: 'cold', label: '冷链正常', value: '—', tone: 'cool', icon: 'kpi-cool' },
{ key: 'env', label: '环境合格区', value: '—', tone: 'access', icon: 'kpi-access' },
{ key: 'alarm', label: '当前告警', value: '—', tone: 'power', icon: 'kpi-power' },
]
}
const rate = d.deviceTotal
? ((d.deviceOnline / d.deviceTotal) * 100).toFixed(0)
: '0'
return [
{
key: 'online',
label: '设备在线率 %',
value: rate,
hint: `${d.deviceOnline}/${d.deviceTotal} · 网关${d.gatewayOk ? '通' : '断'}`,
tone: 'device',
icon: 'kpi-device',
},
{
key: 'cold',
label: '冷链正常',
value: `${d.fridgeOk}/${d.fridgeTotal}`,
hint: d.fridgeOk === d.fridgeTotal ? '全部达标' : '有关注项',
tone: 'cool',
icon: 'kpi-cool',
},
{
key: 'env',
label: '环境合格区',
value: `${d.roomOk}/${d.roomTotal}`,
hint: d.roomOk === d.roomTotal ? '全部正常' : '有关注区',
tone: 'access',
icon: 'kpi-access',
},
{
key: 'alarm',
label: '当前告警',
value: String(d.alarmOpen),
hint: d.alarmOpen ? '值班跟进中' : '无未闭环',
tone: d.alarmOpen ? 'power' : 'device',
icon: 'kpi-power',
},
]
})
const LAB_VIDEO = '/cams/lab-seq.mp4' const chartText = '#9ec8dc'
const cameras = [ const chartMuted = 'rgba(158,200,220,0.35)'
{ name: '走廊', src: LAB_VIDEO },
{ name: 'PCR区', src: LAB_VIDEO },
{ name: '样本库', src: LAB_VIDEO },
{ name: '机房', src: LAB_VIDEO },
]
const alarms = computed(() => { /** 左1:环境气泡图 温度×湿度,大小=|压差| */
const items: { level: 'red' | 'amber' | 'calm'; title: string; sub: string; state: string }[] = [] const envBubbleOption = computed<ECOption>(() => {
if (Array.isArray(snap.value.pointAlarms)) { const rows = (data.value?.envBubbles || []).filter(
for (const a of snap.value.pointAlarms) { (b) => b.temp !== null && b.humidity !== null,
items.push({ )
level: a.level === 'red' ? 'red' : 'amber', return {
title: a.message, backgroundColor: 'transparent',
sub: `当前 ${a.value}${a.kind === 'high' ? ` > ${a.alarmHigh}` : ` < ${a.alarmLow}`}`, grid: { left: 36, right: 12, top: 28, bottom: 28 },
state: '未消除', tooltip: {
}) ...compactTooltipBase,
} trigger: 'item',
return items formatter: (p: unknown) => {
} const x = p as {
for (const r of snap.value.rooms) { data?: { name?: string; value?: number[]; pressure?: number | null }
for (const a of r.alarms) {
items.push({
level: 'red',
title: `${r.name}${a}`,
sub: `压差 ${r.pressure.toFixed(1)} Pa`,
state: '未消除',
})
} }
const d = x.data
if (!d?.value) return ''
return (
`<div style="font-size:11px;color:#e8fbff;font-weight:600">${d.name ?? ''}</div>` +
`<div style="font-size:10px;margin-top:2px">温度 ${roundNum(d.value[0])}℃ · 湿度 ${roundNum(d.value[1])}%</div>` +
`<div style="font-size:10px">压差 ${roundNum(d.pressure)} Pa</div>`
)
},
},
xAxis: {
name: '℃',
nameTextStyle: { color: chartMuted, fontSize: 10 },
axisLabel: { color: chartText, fontSize: 10 },
splitLine: { lineStyle: { color: 'rgba(0,247,255,0.08)' } },
axisLine: { lineStyle: { color: chartMuted } },
},
yAxis: {
name: '%RH',
nameTextStyle: { color: chartMuted, fontSize: 10 },
axisLabel: { color: chartText, fontSize: 10 },
splitLine: { lineStyle: { color: 'rgba(0,247,255,0.08)' } },
axisLine: { lineStyle: { color: chartMuted } },
},
series: [
{
type: 'scatter',
symbolSize: (val: number[]) =>
Math.max(10, Math.min(36, Math.abs(Number(val[2]) || 0) * 1.2 + 10)),
data: rows.map((b) => ({
value: [
Math.round(b.temp! * 10) / 10,
Math.round(b.humidity! * 10) / 10,
Math.round(Math.abs(b.pressure ?? 0) * 10) / 10,
],
name: b.name,
pressure: b.pressure == null ? null : Math.round(b.pressure * 10) / 10,
itemStyle: {
color: b.ok ? 'rgba(82,196,26,0.85)' : 'rgba(250,173,20,0.9)',
},
})),
},
],
} }
for (const f of snap.value.fridges) { })
if (f.temp > -19) {
items.push({ const curves = computed(() => data.value?.curves)
level: 'amber',
title: `${f.name}温度超限 ${f.temp.toFixed(1)} ℃`, /** 左1 副:房间温度 24h 曲线 */
sub: '冷链监测', const roomTempHistoryOption = computed<ECOption>(() =>
state: '处理中', multiLineOption(curves.value?.roomTemp || [], { unit: '℃', maxSeries: 10 }),
)
/** 左2:冷链多系列温度曲线 */
const coldLineOption = computed<ECOption>(() => {
const fromCurves = curves.value?.fridgeTemp || []
if (fromCurves.length) return multiLineOption(fromCurves, { unit: '℃', maxSeries: 10 })
return multiLineOption(toHistorySeries(data.value?.fridgeSeries || []), {
unit: '℃',
maxSeries: 10,
}) })
})
const coldBadge = computed(() => {
const d = digits.value
if (!d) return { text: '采集中', amber: false }
if (d.fridgeTotal === 0) return { text: '无冰箱设备', amber: true }
if (d.fridgeOk < d.fridgeTotal) return { text: `${d.fridgeTotal - d.fridgeOk}台关注`, amber: true }
return { text: '全部正常', amber: false }
})
/** 左3:告警 + 设备状态饼 */
const statusPieOption = computed<ECOption>(() => {
const pie = data.value?.statusPie || []
const colors: Record<string, string> = {
在线: '#52c41a',
告警: '#faad14',
离线: '#ff4d4f',
未知: '#5a7a90',
} }
return {
backgroundColor: 'transparent',
tooltip: pieTooltip(),
series: [
{
type: 'pie',
radius: ['42%', '68%'],
center: ['50%', '52%'],
label: { color: chartText, fontSize: 10, formatter: '{b}\n{c}' },
data: pie.map((p) => ({
name: p.name,
value: p.value,
itemStyle: { color: colors[p.name] || '#37c8ff' },
})),
},
],
} }
for (const tip of snap.value.hvac.alarms) { })
items.push({ level: 'amber', title: tip, sub: '空调排风联动', state: '处理中' })
const categoryPieOption = computed<ECOption>(() => {
const pie = data.value?.categoryPie || []
return {
backgroundColor: 'transparent',
tooltip: pieTooltip(),
series: [
{
type: 'pie',
radius: ['36%', '62%'],
center: ['50%', '52%'],
roseType: 'radius',
label: { color: chartText, fontSize: 9, formatter: '{b}' },
data: pie.map((p) => ({ name: p.name, value: p.value })),
},
],
} }
return items
}) })
/** 右1:系统状态 + PAU 数字 + VAV 柱 */
const pauCards = computed(() => data.value?.pauCards || [])
/** 右1:HVAC + VAV 历史曲线 */
const systemHistoryOption = computed<ECOption>(() => {
const c = curves.value
const merged = [
...(c?.hvacSupplyTemp || []).map((s) => ({ ...s, name: `${s.name || s.id}·送风温` })),
...(c?.vavAirflow || []).map((s) => ({ ...s, name: `${s.name || s.id}·风量` })),
]
return multiLineOption(merged, { maxSeries: 8 })
})
/** 右2:压差历史(优先缓冲间) */
const bioHistoryOption = computed<ECOption>(() => {
const all = curves.value?.roomPressure || []
const buf = all.filter((s) => /缓冲|气锁|负压|PCR/i.test(s.name || s.id))
return multiLineOption(buf.length ? buf : all, { unit: 'Pa', maxSeries: 10 })
})
const bioOk = computed(() => {
const bars = (data.value?.pressureBars || []).filter((b) => /缓冲|气锁|负压/i.test(b.name))
return bars.length ? bars.every((b) => b.ok) : true
})
/** 右3:能耗历史 */
const energyHistoryOption = computed<ECOption>(() => {
const c = curves.value
const merged = [
...(c?.meteringPower || []).map((s) => ({ ...s, name: '用电 kWh' })),
...(c?.meteringWater || []).map((s) => ({ ...s, name: '用水 m³' })),
...(c?.meteringCooling || []).map((s) => ({ ...s, name: '冷量 kWh' })),
]
return multiLineOption(merged, { maxSeries: 6 })
})
const energyDigits = computed(() => digits.value)
</script> </script>
<template> <template>
<div class="overview-float"> <div class="overview-float">
<div class="kpi-top">
<KpiFlipBoard :items="kpiItems" />
</div>
<div class="side left"> <div class="side left">
<PanelBox title="环境参数监测"> <PanelBox title="环境总览">
<template #extra> <template #extra>
<span class="badge">实时</span> <span class="badge" :class="{ amber: (digits?.roomOk ?? 0) < (digits?.roomTotal ?? 0) }">
{{ digits ? `${digits.roomOk}/${digits.roomTotal} 合格` : '设备台账' }}
</span>
</template> </template>
<div class="metric-grid"> <div class="panel-stack">
<div class="metric"> <EChart class="chart-main" :option="envBubbleOption" empty-text="房间温湿度待采集" />
<AppIcon name="metric-pressure" /> <EChart
<div class="m-txt"> class="chart-sub"
<div class="m-label">房间压差 (Pa)</div> :option="roomTempHistoryOption"
<div class="m-value green">{{ room.pressure.toFixed(1) }}</div> empty-text="房间温度历史积累中"
</div> />
</div>
<div class="metric">
<AppIcon name="metric-temp" />
<div class="m-txt">
<div class="m-label">温度 (℃)</div>
<div class="m-value">{{ room.temp.toFixed(1) }}</div>
</div>
</div>
<div class="metric">
<AppIcon name="metric-humidity" />
<div class="m-txt">
<div class="m-label">相对湿度 (%)</div>
<div class="m-value">{{ room.humidity }}</div>
</div>
</div>
<div class="metric">
<AppIcon name="metric-air" />
<div class="m-txt">
<div class="m-label">换气次数 (次/h)</div>
<div class="m-value">12</div>
</div>
</div>
</div> </div>
</PanelBox> </PanelBox>
<PanelBox title="通风柜 VAV 监控"> <PanelBox title="冷链监测 · 24h">
<template #extra> <template #extra>
<span class="badge">{{ vavRunning }}/{{ snap.vav.length }} 运行</span> <span class="badge" :class="{ amber: coldBadge.amber }">{{ coldBadge.text }}</span>
</template> </template>
<VavScroll :items="snap.vav" /> <EChart
:option="coldLineOption"
empty-text="冰箱温度历史积累中"
/>
</PanelBox> </PanelBox>
<PanelBox title="冷链监测"> <PanelBox title="设备状态">
<template #extra> <template #extra>
<span class="badge" :class="{ amber: coldWarnCount > 0 }"> <span class="badge" :class="{ amber: (digits?.alarmOpen ?? 0) > 0 }">
{{ coldWarnCount > 0 ? `${coldWarnCount}台超限` : '全部正常' }} {{ digits?.alarmOpen ? `${digits.alarmOpen}项告警` : '运行正常' }}
</span> </span>
</template> </template>
<ColdScroll :items="snap.fridges" /> <div class="panel-split panel-split--pies">
<div class="pie-col">
<EChart :option="statusPieOption" empty-text="无设备" />
<div class="pie-cap">在线状态</div>
</div>
<div class="pie-col">
<EChart :option="categoryPieOption" empty-text="无分类" />
<div class="pie-cap">设备分类</div>
</div>
</div>
</PanelBox> </PanelBox>
</div> </div>
<div class="center-pass" aria-hidden="true" /> <div class="center-pass" aria-hidden="true" />
<div class="side right"> <div class="side right">
<PanelBox title="空调及排风机组"> <PanelBox title="系统运行状态">
<template #extra> <template #extra>
<span class="badge">{{ snap.hvac.systemRunning ? '运行中' : '停机' }}</span> <span class="badge" :class="{ amber: !snap.hvac.systemRunning }">
{{ snap.hvac.systemRunning ? '机组运行' : '未启动/无信号' }}
</span>
</template>
<div class="panel-stack">
<div class="digit-row">
<div v-for="c in pauCards" :key="c.label" class="digit-card" :class="{ warn: c.ok === false }">
<div class="d-label">{{ c.label }}</div>
<div class="d-value">
<template v-if="c.value === null">—</template>
<template v-else>
{{ c.unit === '' ? (c.value ? '开' : '关') : c.value.toFixed(1) }}
<small v-if="c.unit">{{ c.unit }}</small>
</template> </template>
<div class="hvac-table" role="table">
<div class="hvac-thead" role="row">
<span role="columnheader">参数</span>
<span role="columnheader">数值</span>
</div>
<div class="hvac-tbody" role="rowgroup">
<div class="hvac-row" role="row">
<span>送风温度</span><b>{{ snap.hvac.supplyTemp.toFixed(1) }} ℃</b>
</div>
<div class="hvac-row" role="row">
<span>送风湿度</span><b>{{ snap.hvac.supplyHumidity }} %</b>
</div>
<div class="hvac-row" role="row">
<span>送风压力</span><b>{{ snap.hvac.supplyPressure }} Pa</b>
</div>
<div class="hvac-row" role="row">
<span>风机频率</span><b>{{ snap.hvac.supplyFanHz.toFixed(1) }} Hz</b>
</div>
<div class="hvac-row" role="row">
<span>排风压力</span><b>{{ snap.hvac.exhaustPressure }} Pa</b>
</div>
<div class="hvac-row" role="row">
<span>新风温度</span><b>{{ snap.hvac.freshAirTemp.toFixed(1) }} ℃</b>
</div>
<div class="hvac-row" role="row">
<span>滤网状态</span>
<b class="green">正常 · 剩余 {{ snap.hvac.filterRemainPct }}%</b>
</div> </div>
<div class="hvac-row" role="row">
<span>启停控制</span>
<b class="cyan">
{{ snap.hvac.mode === 'auto' ? '自动模式' : '手动模式' }} · 已授权
</b>
</div> </div>
</div> </div>
<EChart
class="chart-sub"
:option="systemHistoryOption"
empty-text="机组/VAV 历史积累中"
/>
</div> </div>
</PanelBox> </PanelBox>
<PanelBox title="报警响应"> <PanelBox title="生物安全压差 · 24h">
<AlarmScroll :items="alarms" />
</PanelBox>
<PanelBox title="视频监控">
<template #extra> <template #extra>
<span class="badge amber">海康 · 占位</span> <span class="badge" :class="{ amber: !bioOk }">
{{ bioOk ? '联锁正常' : '压差关注' }}
</span>
</template> </template>
<div class="cam-grid"> <EChart :option="bioHistoryOption" empty-text="压差历史积累中" />
<div v-for="c in cameras" :key="c.name" class="cam">
<video
class="cam-video"
:src="c.src"
muted
loop
autoplay
playsinline
:aria-label="c.name"
/>
<AppIcon name="cam-badge" class-name="cam-corner" />
<div class="cam-name">{{ c.name }}</div>
</div>
</div>
</PanelBox> </PanelBox>
</div>
<div class="kpi-bar"> <PanelBox title="能耗与绿色 · 24h">
<div class="kpi kpi--power"> <template #extra>
<div class="k-glow" /> <span class="badge">时序</span>
<div class="k-left"> </template>
<AppIcon name="kpi-power" class-name="kpi-icon" /> <div class="panel-stack">
<div class="k-label">今日用电 (kWh)</div> <div class="energy-digits">
</div> <div class="ed">
<div class="k-center"> <span>总用电</span>
<div class="digital">{{ snap.metering.powerKwh.toFixed(1) }}</div> <b>{{ energyDigits ? energyDigits.powerKwh.toFixed(1) : '—' }} <small>kWh</small></b>
<div class="k-trend">
较昨日 {{ snap.metering.powerTrendPct > 0 ? '↑' : '↓' }}
{{ Math.abs(snap.metering.powerTrendPct) }}%
</div>
</div>
</div>
<div class="kpi kpi--water">
<div class="k-glow" />
<div class="k-left">
<AppIcon name="kpi-water" class-name="kpi-icon" />
<div class="k-label">今日用水 (m³)</div>
</div>
<div class="k-center">
<div class="digital">{{ snap.metering.waterM3.toFixed(1) }}</div>
<div class="k-trend">
较昨日 {{ snap.metering.waterTrendPct > 0 ? '↑' : '↓' }}
{{ Math.abs(snap.metering.waterTrendPct) }}%
</div>
</div>
</div>
<div class="kpi kpi--cool">
<div class="k-glow" />
<div class="k-left">
<AppIcon name="kpi-cool" class-name="kpi-icon" />
<div class="k-label">冷冻水冷量 (kWh)</div>
</div>
<div class="k-center">
<div class="digital">{{ snap.metering.coolingKwh }}</div>
<div class="k-trend">较昨日 ↑ {{ snap.metering.coolingTrendPct }}%</div>
</div>
</div>
<div class="kpi kpi--device">
<div class="k-glow" />
<div class="k-left">
<AppIcon name="kpi-device" class-name="kpi-icon" />
<div class="k-label">设备在线</div>
</div>
<div class="k-center">
<div class="digital">
{{ snap.online.devicesOnline }}/{{ snap.online.devicesTotal }}
</div>
<div class="k-trend">
在线率
{{
((snap.online.devicesOnline / Math.max(1, snap.online.devicesTotal)) * 100).toFixed(1)
}}%
</div>
</div> </div>
<div class="ed">
<span>用水</span>
<b>{{ energyDigits ? energyDigits.waterM3.toFixed(1) : '—' }} <small>m³</small></b>
</div> </div>
<div class="kpi kpi--access"> <div class="ed">
<div class="k-glow" /> <span>冷量</span>
<div class="k-left"> <b>{{ energyDigits ? energyDigits.coolingKwh.toFixed(0) : '—' }} <small>kWh</small></b>
<AppIcon name="kpi-access" class-name="kpi-icon" />
<div class="k-label">门禁通行</div>
</div> </div>
<div class="k-center">
<div class="digital">{{ snap.access.todayPassCount }}</div>
<div class="k-trend">今日人次 · {{ snap.access.doors.length }} 樘门禁</div>
</div> </div>
<EChart
class="chart-main"
:option="energyHistoryOption"
empty-text="能耗历史积累中"
/>
</div> </div>
</PanelBox>
</div> </div>
<div v-if="error" class="screen-err">{{ error }}</div>
</div> </div>
</template> </template>
...@@ -270,35 +367,40 @@ const alarms = computed(() => { ...@@ -270,35 +367,40 @@ const alarms = computed(() => {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-columns: 460px 1fr 460px; grid-template-columns: var(--screen-side-w, clamp(280px, 24vw, 480px)) minmax(0, 1fr) var(
grid-template-rows: 1fr 96px; --screen-side-w,
clamp(280px, 24vw, 480px)
);
grid-template-rows: auto 1fr;
gap: 10px 12px; gap: 10px 12px;
pointer-events: none; pointer-events: none;
} }
.kpi-top {
grid-column: 2;
grid-row: 1;
min-width: 0;
z-index: 2;
}
.side { .side {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
height: 100%;
display: grid; display: grid;
gap: 10px; gap: 10px;
pointer-events: auto; pointer-events: auto;
} }
.left { .left,
grid-template-rows: minmax(140px, 0.55fr) minmax(0, 1fr) minmax(0, 1fr);
}
.right { .right {
/* 空调表自适应,告警约 3 条,剩余给视频(避免 1fr 被挤没) */ grid-row: 1 / -1;
grid-template-rows: auto minmax(210px, 0.7fr) minmax(240px, 1.2fr); grid-template-rows: minmax(0, 1.05fr) minmax(0, 1fr) minmax(0, 0.95fr);
} }
.left { grid-column: 1; }
.right { grid-column: 3; }
.center-pass { .center-pass {
grid-column: 2;
grid-row: 2;
pointer-events: none; pointer-events: none;
} }
.kpi-bar {
grid-column: 1 / -1;
height: 96px;
display: flex;
gap: 12px;
pointer-events: auto;
}
.badge { .badge {
padding: 1px 7px; padding: 1px 7px;
border-radius: 2px; border-radius: 2px;
...@@ -313,293 +415,99 @@ const alarms = computed(() => { ...@@ -313,293 +415,99 @@ const alarms = computed(() => {
color: #ffd666; color: #ffd666;
border-color: rgba(250, 173, 20, 0.35); border-color: rgba(250, 173, 20, 0.35);
} }
.metric-grid { .panel-stack {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.chart-main { flex: 1.2; min-height: 0; }
.chart-sub { flex: 0.9; min-height: 0; }
.panel-split {
flex: 1;
min-height: 0;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 6px;
} }
.metric { .panel-split--pies {
display: flex; grid-template-rows: minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 10px;
min-height: 58px;
border-radius: 4px;
background: rgba(6, 28, 56, 0.45);
border: 1px solid rgba(0, 247, 255, 0.14);
} }
.m-txt { .pie-col {
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 3px;
min-width: 0;
} }
.m-label { .pie-cap {
font-size: 11px; text-align: center;
font-size: 10px;
color: var(--panel-muted); color: var(--panel-muted);
margin-top: -2px;
} }
.m-value { .digit-row {
font-size: 20px; display: grid;
font-weight: 600; grid-template-columns: repeat(3, 1fr);
font-family: var(--font-num); gap: 6px;
color: #fff; flex-shrink: 0;
line-height: 1;
}
.m-value.green {
color: var(--green);
} }
.hvac-table { .digit-card {
border: 1px solid rgba(0, 247, 255, 0.22); padding: 6px 8px;
border-radius: 4px; border-radius: 4px;
overflow: hidden; background: rgba(6, 28, 56, 0.45);
background: rgba(6, 18, 36, 0.35); border: 1px solid rgba(0, 247, 255, 0.14);
}
.hvac-thead,
.hvac-row {
display: grid;
grid-template-columns: 1fr 1.35fr;
align-items: center;
gap: 8px;
min-height: 26px;
padding: 0 12px;
font-size: 12px;
} }
.hvac-thead { .digit-card.warn { border-color: rgba(250, 173, 20, 0.4); }
background: rgba(0, 247, 255, 0.1); .d-label { font-size: 10px; color: var(--panel-muted); }
border-bottom: 1px solid rgba(0, 247, 255, 0.28); .d-value {
color: var(--accent); margin-top: 2px;
font-size: 15px;
font-weight: 600; font-weight: 600;
}
.hvac-thead span:last-child {
text-align: right;
}
.hvac-row {
color: var(--panel-muted);
border-bottom: 1px solid rgba(0, 247, 255, 0.12);
background: rgba(14, 36, 68, 0.22);
}
.hvac-row:nth-child(even) {
background: rgba(22, 56, 104, 0.28);
}
.hvac-row:last-child {
border-bottom: none;
}
.hvac-row b {
color: #fff;
font-weight: 500;
font-family: var(--font-num); font-family: var(--font-num);
text-align: right; color: #fff;
justify-self: end; line-height: 1.2;
}
.hvac-row b.green {
color: var(--green);
} }
.hvac-row b.cyan { .d-value small {
color: var(--accent); font-size: 10px;
color: rgba(200, 230, 245, 0.65);
font-weight: 500;
} }
.cam-grid { .energy-digits {
flex: 1;
min-height: 160px;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(3, 1fr);
grid-template-rows: 1fr 1fr;
gap: 6px; gap: 6px;
flex-shrink: 0;
} }
.cam { .ed {
position: relative; padding: 8px;
min-height: 0;
height: 100%;
border-radius: 4px; border-radius: 4px;
overflow: hidden; background: rgba(6, 28, 56, 0.45);
border: 1px solid rgba(0, 247, 255, 0.22); border: 1px solid rgba(0, 247, 255, 0.14);
background: #04101f;
} }
.cam-video, .ed span { display: block; font-size: 10px; color: var(--panel-muted); }
.cam img { .ed b {
width: 100%;
height: 100%;
object-fit: cover;
display: block; display: block;
background: #000; margin-top: 4px;
} font-size: 16px;
.cam :deep(.cam-corner) { font-family: var(--font-num);
position: absolute; color: #fff;
left: 6px;
top: 6px;
width: 22px;
height: 22px;
} }
.cam-name { .ed small { font-size: 10px; color: rgba(200, 230, 245, 0.65); font-weight: 500; }
.screen-err {
position: absolute; position: absolute;
right: 6px; left: 50%;
top: 6px; bottom: 12px;
font-size: 10px; transform: translateX(-50%);
background: rgba(4, 16, 31, 0.6); z-index: 8;
padding: 1px 6px; padding: 4px 12px;
border-radius: 3px;
}
.kpi {
position: relative;
overflow: hidden;
flex: 1;
padding: 0 14px;
border-radius: 4px; border-radius: 4px;
border: 1px solid transparent; background: rgba(120, 20, 20, 0.75);
background: linear-gradient(145deg, rgba(0, 28, 56, 0.72), rgba(0, 12, 28, 0.62)); color: #ffb0b0;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); font-size: 12px;
backdrop-filter: blur(8px);
}
.kpi .k-glow {
position: absolute;
left: -16px;
top: -28px;
width: 100px;
height: 100px;
border-radius: 50%;
pointer-events: none; pointer-events: none;
opacity: 0.55;
filter: blur(2px);
}
.kpi .k-left {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
}
.kpi .k-label {
font-size: 14px;
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
}
.kpi .k-center {
position: absolute;
left: 70%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
}
.kpi .digital {
padding: 2px 10px;
border-radius: 4px;
font-size: 20px;
font-weight: 700;
font-family: var(--font-num);
letter-spacing: 0.4px;
line-height: 1.2;
white-space: nowrap;
}
.kpi .k-trend {
font-size: 11px;
white-space: nowrap;
}
.kpi :deep(.kpi-icon) {
width: 34px;
height: 34px;
flex: 0 0 auto;
}
.kpi--power {
border-color: rgba(255, 180, 64, 0.42);
}
.kpi--power .k-glow {
background: radial-gradient(circle, rgba(255, 180, 64, 0.35), transparent 68%);
}
.kpi--power .k-label {
color: #f0c878;
} }
.kpi--power .digital { .side :deep(.echart-wrap) {
color: #ffe08a;
background: rgba(40, 28, 8, 0.75);
border: 1px solid rgba(255, 180, 64, 0.5);
}
.kpi--power .k-trend {
color: #ffcf5c;
}
.kpi--water {
border-color: rgba(56, 189, 248, 0.42);
}
.kpi--water .k-glow {
background: radial-gradient(circle, rgba(56, 189, 248, 0.32), transparent 68%);
}
.kpi--water .k-label {
color: #7dd3fc;
}
.kpi--water .digital {
color: #7dd3fc;
background: rgba(6, 28, 48, 0.8);
border: 1px solid rgba(56, 189, 248, 0.5);
}
.kpi--water .k-trend {
color: #38bdf8;
}
.kpi--cool {
border-color: rgba(103, 232, 249, 0.4);
}
.kpi--cool .k-glow {
background: radial-gradient(circle, rgba(103, 232, 249, 0.3), transparent 68%);
}
.kpi--cool .k-label {
color: #a5f3fc;
}
.kpi--cool .digital {
color: #a5f3fc;
background: rgba(6, 36, 48, 0.8);
border: 1px solid rgba(103, 232, 249, 0.48);
}
.kpi--cool .k-trend {
color: #67e8f9;
}
.kpi--device {
border-color: rgba(52, 211, 153, 0.42);
}
.kpi--device .k-glow {
background: radial-gradient(circle, rgba(52, 211, 153, 0.3), transparent 68%);
}
.kpi--device .k-label {
color: #6ee7b7;
}
.kpi--device .digital {
color: #6ee7b7;
background: rgba(6, 36, 28, 0.8);
border: 1px solid rgba(52, 211, 153, 0.48);
}
.kpi--device .k-trend {
color: #34d399;
}
.kpi--access {
border-color: rgba(167, 139, 250, 0.42);
}
.kpi--access .k-glow {
background: radial-gradient(circle, rgba(167, 139, 250, 0.32), transparent 68%);
}
.kpi--access .k-label {
color: #c4b5fd;
}
.kpi--access .digital {
color: #ddd6fe;
background: rgba(28, 16, 48, 0.8);
border: 1px solid rgba(167, 139, 250, 0.5);
}
.kpi--access .k-trend {
color: #a78bfa;
}
/* 子滚动区撑满 PanelBox body */
.side :deep(.vav-viewport),
.side :deep(.cold-viewport),
.side :deep(.alarm-viewport) {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
height: auto !important;
} }
</style> </style>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import EChart from '@/components/charts/EChart.vue'
import { useMetricHistory } from '@/composables/useMetricHistory'
import { useRealtimeStore } from '@/stores/realtime' import { useRealtimeStore } from '@/stores/realtime'
import { multiLineOption } from '@/utils/historyChart'
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
const { series: pressureSeries, loading: pressureLoading } = useMetricHistory(() => ({
source: 'room',
metric: 'pressure',
}))
const { series: tempSeries, loading: tempLoading } = useMetricHistory(() => ({
source: 'room',
metric: 'temp',
}))
const pressureOpt = computed(() => multiLineOption(pressureSeries.value, { unit: 'Pa', maxSeries: 14 }))
const tempOpt = computed(() => multiLineOption(tempSeries.value, { unit: '℃', maxSeries: 14 }))
</script> </script>
<template> <template>
<div class="page-fill"> <div class="page-fill pressure-page">
<section class="panel" style="flex:1; overflow:auto"> <section class="panel" style="flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; overflow:hidden">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>房间压差集中监控</h3></div> <div class="panel-title"><div class="vbar" /><h3>房间压差集中监控</h3></div>
<div class="badge">实时</div> <div class="badge">实时 + 近24h</div>
</div>
<div class="charts">
<div class="chart-card">
<div class="chart-title">压差历史 Pa</div>
<EChart :option="pressureOpt" :loading="pressureLoading" empty-text="压差历史积累中" />
</div>
<div class="chart-card">
<div class="chart-title">温度历史 ℃</div>
<EChart :option="tempOpt" :loading="tempLoading" empty-text="温度历史积累中" />
</div> </div>
</div>
<div class="table-wrap">
<table class="table"> <table class="table">
<thead> <thead>
<tr><th>房间</th><th>压差 (Pa)</th><th>温度 (℃)</th><th>湿度 (%)</th><th>告警</th></tr> <tr><th>房间</th><th>压差 (Pa)</th><th>温度 (℃)</th><th>湿度 (%)</th><th>告警</th></tr>
...@@ -24,6 +51,30 @@ const realtime = useRealtimeStore() ...@@ -24,6 +51,30 @@ const realtime = useRealtimeStore()
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
</section> </section>
</div> </div>
</template> </template>
<style scoped>
.pressure-page { display: flex; min-height: 0; }
.charts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
min-height: 240px;
flex: 1;
}
.chart-card {
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid rgba(0, 247, 255, 0.16);
border-radius: 6px;
padding: 8px;
background: rgba(4, 16, 32, 0.35);
}
.chart-title { font-size: 12px; color: var(--label); margin-bottom: 4px; }
.chart-card :deep(.echart-wrap) { flex: 1; min-height: 180px; }
.table-wrap { max-height: 260px; overflow: auto; }
</style>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime' import { useRealtimeStore } from '@/stores/realtime'
import { api } from '@/api/http' import { api } from '@/api/http'
import EChart from '@/components/charts/EChart.vue'
import { useMetricHistory } from '@/composables/useMetricHistory'
import { multiLineOption } from '@/utils/historyChart'
const auth = useAuthStore() const auth = useAuthStore()
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
const { series: airflow, loading: loadAir } = useMetricHistory(() => ({
source: 'vav',
metric: 'airflow',
}))
const { series: faceVel, loading: loadFace } = useMetricHistory(() => ({
source: 'vav',
metric: 'face_velocity',
}))
const vavHistoryOpt = computed(() =>
multiLineOption(
[
...airflow.value.map((s) => ({ ...s, name: `${s.name || s.id}·风量` })),
...faceVel.value.map((s) => ({ ...s, name: `${s.name || s.id}·面风速` })),
],
{ maxSeries: 8 },
),
)
const histLoading = computed(() => loadAir.value || loadFace.value)
async function toggle(id: string, on: boolean) { async function toggle(id: string, on: boolean) {
if (!auth.isAdmin) return if (!auth.isAdmin) return
await api('/api/realtime/write', { await api('/api/realtime/write', {
...@@ -28,7 +51,10 @@ function exhaustText(v: { maxExhaust: boolean; minAirflow: boolean }) { ...@@ -28,7 +51,10 @@ function exhaustText(v: { maxExhaust: boolean; minAirflow: boolean }) {
<section class="panel vav-page"> <section class="panel vav-page">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>通风柜 VAV 集中监控</h3></div> <div class="panel-title"><div class="vbar" /><h3>通风柜 VAV 集中监控</h3></div>
<div class="badge">{{ auth.isAdmin ? '可控制' : '只读' }} · 协议 PAU-201-2</div> <div class="badge">{{ auth.isAdmin ? '可控制' : '只读' }} · 近24h曲线</div>
</div>
<div class="hist">
<EChart :option="vavHistoryOpt" :loading="histLoading" empty-text="VAV 历史积累中" />
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="table vav-table"> <table class="table vav-table">
...@@ -75,6 +101,15 @@ function exhaustText(v: { maxExhaust: boolean; minAirflow: boolean }) { ...@@ -75,6 +101,15 @@ function exhaustText(v: { maxExhaust: boolean; minAirflow: boolean }) {
</template> </template>
<style scoped> <style scoped>
.hist {
min-height: 200px;
margin-bottom: 10px;
border: 1px solid rgba(0, 247, 255, 0.16);
border-radius: 6px;
padding: 8px;
background: rgba(4, 16, 32, 0.35);
}
.hist :deep(.echart-wrap) { height: 180px; }
.vav-page { .vav-page {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
......
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