Commit a75df7d5 by James Yuan

feat: 门禁远程开门、监控点位校准与能耗冷量展示

大屏支持门禁远程/一键开门与状态色图标,纠偏在线率与 ACS 误报警;去重并恢复监控校准坐标,三维打点显示当前点位;能耗改为电表+能量计冷量。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 76fcdd1c
...@@ -172,10 +172,22 @@ BOOL CALLBACK onAlarm(LONG lCommand, NET_DVR_ALARMER* pAlarmer, char* pAlarmInfo ...@@ -172,10 +172,22 @@ BOOL CALLBACK onAlarm(LONG lCommand, NET_DVR_ALARMER* pAlarmer, char* pAlarmInfo
const bool pass = const bool pass =
info->dwMajor == MAJOR_EVENT && info->dwMajor == MAJOR_EVENT &&
(info->dwMinor == MINOR_LEGAL_CARD_PASS || info->dwMinor == MINOR_CARD_AND_PSW_PASS || (info->dwMinor == MINOR_LEGAL_CARD_PASS || info->dwMinor == MINOR_CARD_AND_PSW_PASS ||
info->dwMinor == 0x0c /*指纹通过*/ || info->dwMinor == 0x4b /*人脸通过*/); info->dwMinor == 0x0c /*指纹通过*/ || info->dwMinor == 0x4b /*人脸通过*/ ||
info->dwMinor == 0x0a /*远程开门*/);
const bool fail = const bool fail =
info->dwMinor == MINOR_CARD_AND_PSW_FAIL || info->dwMinor == MINOR_CARD_AND_PSW_TIMEOUT; info->dwMinor == MINOR_CARD_AND_PSW_FAIL || info->dwMinor == MINOR_CARD_AND_PSW_TIMEOUT;
ev.result = pass ? "success" : (fail ? "denied" : "alarm"); // 门锁/门磁状态事件(常见 0x15 开、0x16 关),不是报警;勿把门状态锁死成 alarm
const bool doorOpenEvt = info->dwMinor == 0x15;
const bool doorCloseEvt = info->dwMinor == 0x16;
const bool realAlarm = info->dwMajor == MAJOR_ALARM;
if (pass || doorOpenEvt)
ev.result = "success";
else if (fail || doorCloseEvt)
ev.result = fail ? "denied" : "success";
else if (realAlarm)
ev.result = "alarm";
else
ev.result = "success"; // 普通操作/状态事件不当报警展示
{ {
std::lock_guard<std::mutex> lock(gMu); std::lock_guard<std::mutex> lock(gMu);
...@@ -183,9 +195,13 @@ BOOL CALLBACK onAlarm(LONG lCommand, NET_DVR_ALARMER* pAlarmer, char* pAlarmInfo ...@@ -183,9 +195,13 @@ BOOL CALLBACK onAlarm(LONG lCommand, NET_DVR_ALARMER* pAlarmer, char* pAlarmInfo
if (d) { if (d) {
ev.doorId = d->cfg.id; ev.doorId = d->cfg.id;
ev.doorName = d->cfg.name; ev.doorName = d->cfg.name;
if (pass) d->status = "open"; if (pass || doorOpenEvt)
else if (fail) d->status = "closed"; d->status = "open";
else d->status = "alarm"; else if (fail || doorCloseEvt)
d->status = "closed";
else if (realAlarm)
d->status = "alarm";
// 其它事件保持原状态,避免误刷成 alarm
} else { } else {
ev.doorId = pAlarmer->sDeviceIP[0] ? pAlarmer->sDeviceIP : "unknown"; ev.doorId = pAlarmer->sDeviceIP[0] ? pAlarmer->sDeviceIP : "unknown";
ev.doorName = ev.doorId; ev.doorName = ev.doorId;
......
...@@ -7,6 +7,63 @@ function accessBase(): string { ...@@ -7,6 +7,63 @@ function accessBase(): string {
return (resolveGatewayBase() || config.accessBridgeUrl || '').replace(/\/$/, '') return (resolveGatewayBase() || config.accessBridgeUrl || '').replace(/\/$/, '')
} }
type RawAccessEvent = AccessEvent & { major?: number; minor?: number }
/** 远程开门后短时保持「开门」:海康多为脉冲开锁,门磁很快回关,否则 UI 瞬间又变关门 */
const REMOTE_OPEN_STICKY_MS = 15_000
const remoteOpenUntil = new Map<string, number>()
export function markDoorRemoteOpened(doorId: string, holdMs = REMOTE_OPEN_STICKY_MS) {
const id = String(doorId || '').trim()
if (!id) return
remoteOpenUntil.set(id, Date.now() + Math.max(3_000, holdMs))
}
function applyRemoteOpenSticky(doors: DoorSnapshot[]): DoorSnapshot[] {
const now = Date.now()
for (const [id, until] of [...remoteOpenUntil.entries()]) {
if (until <= now) remoteOpenUntil.delete(id)
}
if (!remoteOpenUntil.size) return doors
return doors.map((d) => {
const until = remoteOpenUntil.get(d.id)
if (until && until > now) return { ...d, status: 'open' as const }
return d
})
}
/**
* 网关旧逻辑会把门锁状态事件(minor 0x15/0x16 等)误标为 alarm。
* 平台侧按最近事件纠偏,避免大屏四门长期红灯。
*/
function normalizeAccessDoors(
doors: DoorSnapshot[],
recentEvents: RawAccessEvent[],
): DoorSnapshot[] {
const lastByDoor = new Map<string, RawAccessEvent>()
for (const ev of recentEvents) {
const id = String(ev.doorId || '')
if (!id || lastByDoor.has(id)) continue
lastByDoor.set(id, ev)
}
return doors.map((d) => {
if (d.status !== 'alarm') return d
const ev = lastByDoor.get(d.id)
if (!ev) return { ...d, status: 'closed' }
const major = Number(ev.major)
const minor = Number(ev.minor)
if (Number.isFinite(minor) && minor === 0x15) return { ...d, status: 'open' }
if (Number.isFinite(minor) && minor === 0x16) return { ...d, status: 'closed' }
// MAJOR_ALARM=1 才保留报警;事件类(5)/操作类(3)不当报警
if (Number.isFinite(major) && major !== 1) {
if (ev.result === 'success') return { ...d, status: 'open' }
if (ev.result === 'denied') return { ...d, status: 'closed' }
return { ...d, status: 'closed' }
}
return d
})
}
/** /**
* 门禁快照:走 Windows 网关 `GET /access/snapshot`(HCNetSDK ACS)。 * 门禁快照:走 Windows 网关 `GET /access/snapshot`(HCNetSDK ACS)。
* 未配置网关时返回 null,由上层保留设备台账门禁占位。 * 未配置网关时返回 null,由上层保留设备台账门禁占位。
...@@ -26,12 +83,14 @@ export async function fetchAccessSnapshot(): Promise<{ ...@@ -26,12 +83,14 @@ export async function fetchAccessSnapshot(): Promise<{
const raw = (await res.json()) as { const raw = (await res.json()) as {
doors?: DoorSnapshot[] doors?: DoorSnapshot[]
todayPassCount?: number todayPassCount?: number
recentEvents?: AccessEvent[] recentEvents?: RawAccessEvent[]
} }
const recentEvents = Array.isArray(raw.recentEvents) ? raw.recentEvents : []
const doors = Array.isArray(raw.doors) ? raw.doors : []
return { return {
doors: Array.isArray(raw.doors) ? raw.doors : [], doors: applyRemoteOpenSticky(normalizeAccessDoors(doors, recentEvents)),
todayPassCount: Number(raw.todayPassCount || 0), todayPassCount: Number(raw.todayPassCount || 0),
recentEvents: Array.isArray(raw.recentEvents) ? raw.recentEvents : [], recentEvents,
} }
} catch (e) { } catch (e) {
console.warn('[access] gateway snapshot unavailable', e) console.warn('[access] gateway snapshot unavailable', e)
...@@ -56,6 +115,7 @@ export async function openDoorRemote(doorId: string): Promise<{ ok: boolean; mes ...@@ -56,6 +115,7 @@ export async function openDoorRemote(doorId: string): Promise<{ ok: boolean; mes
if (!res.ok) { if (!res.ok) {
return { ok: false, message: raw.message || `开门失败 HTTP ${res.status}` } return { ok: false, message: raw.message || `开门失败 HTTP ${res.status}` }
} }
markDoorRemoteOpened(doorId)
return { ok: true, message: raw.message || '远程开门已下发' } return { ok: true, message: raw.message || '远程开门已下发' }
} catch (e) { } catch (e) {
return { return {
......
...@@ -117,7 +117,7 @@ function mockUsageForDate(date: string): DailyUsage { ...@@ -117,7 +117,7 @@ function mockUsageForDate(date: string): DailyUsage {
powerPeakKwh: +(powerTotal * 0.28).toFixed(1), powerPeakKwh: +(powerTotal * 0.28).toFixed(1),
powerFlatKwh: +(powerTotal * 0.45).toFixed(1), powerFlatKwh: +(powerTotal * 0.45).toFixed(1),
powerValleyKwh: +(powerTotal * 0.27).toFixed(1), powerValleyKwh: +(powerTotal * 0.27).toFixed(1),
waterM3: +rand(seed, 2, 8, 18 * factor).toFixed(1), waterM3: 0,
coolingKwh: +rand(seed, 3, 900, 1500 * factor).toFixed(0), coolingKwh: +rand(seed, 3, 900, 1500 * factor).toFixed(0),
} }
} }
...@@ -138,14 +138,17 @@ export function calcBill(usage: DailyUsage, t: TariffSettings): DailyBill { ...@@ -138,14 +138,17 @@ export function calcBill(usage: DailyUsage, t: TariffSettings): DailyBill {
usage.powerPeakKwh * t.powerPeak + usage.powerPeakKwh * t.powerPeak +
usage.powerFlatKwh * t.powerFlat + usage.powerFlatKwh * t.powerFlat +
usage.powerValleyKwh * t.powerValley usage.powerValleyKwh * t.powerValley
const waterCost = usage.waterM3 * t.water /** 本系统无水表:不计水费,用量按 0 出账 */
const waterM3 = 0
const waterCost = 0
const coolingCost = usage.coolingKwh * t.cooling const coolingCost = usage.coolingKwh * t.cooling
const subtotal = powerCost + waterCost + coolingCost const subtotal = powerCost + coolingCost
const serviceFee = subtotal * (t.serviceFeePct / 100) const serviceFee = subtotal * (t.serviceFeePct / 100)
const tax = (subtotal + serviceFee) * (t.taxPct / 100) const tax = (subtotal + serviceFee) * (t.taxPct / 100)
const total = subtotal + serviceFee + tax const total = subtotal + serviceFee + tax
return { return {
...usage, ...usage,
waterM3,
powerCost: +powerCost.toFixed(2), powerCost: +powerCost.toFixed(2),
waterCost: +waterCost.toFixed(2), waterCost: +waterCost.toFixed(2),
coolingCost: +coolingCost.toFixed(2), coolingCost: +coolingCost.toFixed(2),
...@@ -376,7 +379,6 @@ export async function getStats(): Promise<BillingStats> { ...@@ -376,7 +379,6 @@ export async function getStats(): Promise<BillingStats> {
const kindRaw = [ const kindRaw = [
{ kind: 'power' as const, label: '电费', usage: month.powerKwh, unit: 'kWh', cost: month.powerCost }, { kind: 'power' as const, label: '电费', usage: month.powerKwh, unit: 'kWh', cost: month.powerCost },
{ kind: 'water' as const, label: '水费', usage: month.waterM3, unit: 'm³', cost: month.waterCost },
{ kind: 'cooling' as const, label: '冷量费', usage: month.coolingKwh, unit: 'kWh', cost: month.coolingCost }, { kind: 'cooling' as const, label: '冷量费', usage: month.coolingKwh, unit: 'kWh', cost: month.coolingCost },
] ]
const costSum = kindRaw.reduce((s, k) => s + k.cost, 0) || 1 const costSum = kindRaw.reduce((s, k) => s + k.cost, 0) || 1
......
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 { listDevices } from '../devices/store.js'
import { recordLabCoolingDaily } from '../metering/coolingMeters.js'
import { macauDateStr, recordLabPowerDaily } from '../metering/powerMeters.js' import { macauDateStr, recordLabPowerDaily } from '../metering/powerMeters.js'
import { pool, query } from './pool.js' import { pool, query } from './pool.js'
import { isPgEnabled } from './state.js' import { isPgEnabled } from './state.js'
...@@ -18,7 +19,8 @@ let lastSnapshotWriteMs = 0 ...@@ -18,7 +19,8 @@ let lastSnapshotWriteMs = 0
/** 将一次采集快照入库:时序明细 + 降频整包 */ /** 将一次采集快照入库:时序明细 + 降频整包 */
export async function persistSnapshot(snap: RealtimeSnapshot) { export async function persistSnapshot(snap: RealtimeSnapshot) {
// 三块总表日差量:无 PG 时也更新内存,保证当日/当月统计可用 // 三电表 + 两能量计日差量:无 PG 时也更新内存,保证当日/当月统计可用
const at = new Date(snap.ts || Date.now())
try { try {
const readings = (snap.metering.powerMeters || []).map((m) => ({ const readings = (snap.metering.powerMeters || []).map((m) => ({
key: m.key, key: m.key,
...@@ -27,10 +29,22 @@ export async function persistSnapshot(snap: RealtimeSnapshot) { ...@@ -27,10 +29,22 @@ export async function persistSnapshot(snap: RealtimeSnapshot) {
energyKwh: m.energyKwh, energyKwh: m.energyKwh,
powerKw: m.powerKw, powerKw: m.powerKw,
})) }))
if (readings.length) await recordLabPowerDaily(readings, new Date(snap.ts || Date.now())) if (readings.length) await recordLabPowerDaily(readings, at)
} catch (e) { } catch (e) {
console.error('[db] record lab power daily failed', e) console.error('[db] record lab power daily failed', e)
} }
try {
const coolReadings = (snap.metering.coolingMeters || []).map((m) => ({
key: m.key,
name: m.name,
deviceId: m.deviceId,
energyKwh: m.energyKwh,
powerKw: m.powerKw,
}))
if (coolReadings.length) await recordLabCoolingDaily(coolReadings, at)
} catch (e) {
console.error('[db] record lab cooling daily failed', e)
}
if (!isPgEnabled()) return if (!isPgEnabled()) return
...@@ -80,7 +94,6 @@ export async function persistSnapshot(snap: RealtimeSnapshot) { ...@@ -80,7 +94,6 @@ export async function persistSnapshot(snap: RealtimeSnapshot) {
// 日费用用电量:以三块总表当日差量合计为准(澳门日历日) // 日费用用电量:以三块总表当日差量合计为准(澳门日历日)
const day = macauDateStr(ts) const day = macauDateStr(ts)
const power = snap.metering.powerKwh const power = snap.metering.powerKwh
const water = snap.metering.waterM3
const cooling = snap.metering.coolingKwh const cooling = snap.metering.coolingKwh
await client.query( await client.query(
`INSERT INTO billing_daily_usage ( `INSERT INTO billing_daily_usage (
...@@ -90,7 +103,7 @@ export async function persistSnapshot(snap: RealtimeSnapshot) { ...@@ -90,7 +103,7 @@ export async function persistSnapshot(snap: RealtimeSnapshot) {
power_peak_kwh = EXCLUDED.power_peak_kwh, power_peak_kwh = EXCLUDED.power_peak_kwh,
power_flat_kwh = EXCLUDED.power_flat_kwh, power_flat_kwh = EXCLUDED.power_flat_kwh,
power_valley_kwh = EXCLUDED.power_valley_kwh, power_valley_kwh = EXCLUDED.power_valley_kwh,
water_m3 = EXCLUDED.water_m3, water_m3 = 0,
cooling_kwh = EXCLUDED.cooling_kwh, cooling_kwh = EXCLUDED.cooling_kwh,
updated_at = NOW()`, updated_at = NOW()`,
[ [
...@@ -98,7 +111,7 @@ export async function persistSnapshot(snap: RealtimeSnapshot) { ...@@ -98,7 +111,7 @@ export async function persistSnapshot(snap: RealtimeSnapshot) {
+(power * 0.28).toFixed(3), +(power * 0.28).toFixed(3),
+(power * 0.45).toFixed(3), +(power * 0.45).toFixed(3),
+(power * 0.27).toFixed(3), +(power * 0.27).toFixed(3),
water, 0,
cooling, cooling,
], ],
) )
......
...@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS billing_daily_usage ( ...@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS billing_daily_usage (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
/** 实验室三块总表(PAU201 / E电 / N电)日耗电:由累计读数差量得到 */ /** 实验室总表日差量:电表(pau201/e_power/n_power)+ 能量计(dn40/dn80) */
CREATE TABLE IF NOT EXISTS energy_daily_meter ( CREATE TABLE IF NOT EXISTS energy_daily_meter (
usage_date DATE NOT NULL, usage_date DATE NOT NULL,
meter_key TEXT NOT NULL, meter_key TEXT NOT NULL,
......
...@@ -297,14 +297,6 @@ export async function persistSnapshotTimeseries(snap: RealtimeSnapshot) { ...@@ -297,14 +297,6 @@ export async function persistSnapshotTimeseries(snap: RealtimeSnapshot) {
}, },
{ {
ts, ts,
seriesId: seriesId('metering', 'lab', 'water_m3'),
deviceId: 'lab',
source: 'metering',
metric: 'water_m3',
eng: m.waterM3,
},
{
ts,
seriesId: seriesId('metering', 'lab', 'cooling_kwh'), seriesId: seriesId('metering', 'lab', 'cooling_kwh'),
deviceId: 'lab', deviceId: 'lab',
source: 'metering', source: 'metering',
...@@ -312,7 +304,7 @@ export async function persistSnapshotTimeseries(snap: RealtimeSnapshot) { ...@@ -312,7 +304,7 @@ export async function persistSnapshotTimeseries(snap: RealtimeSnapshot) {
eng: m.coolingKwh, eng: m.coolingKwh,
}, },
) )
for (const pm of m.powerMeters || []) { for (const pm of [...(m.powerMeters || []), ...(m.coolingMeters || [])]) {
if (pm.energyKwh !== null && Number.isFinite(pm.energyKwh)) { if (pm.energyKwh !== null && Number.isFinite(pm.energyKwh)) {
rows.push({ rows.push({
ts, ts,
......
...@@ -198,10 +198,10 @@ function pointFromRow( ...@@ -198,10 +198,10 @@ function pointFromRow(
}), }),
energyKind: /电表|电能|电压|电流|功率|有功/.test(`${row.name} ${sourceCsv}`) energyKind: /电表|电能|电压|电流|功率|有功/.test(`${row.name} ${sourceCsv}`)
? 'power' ? 'power'
: /流量计|水/.test(`${row.name} ${sourceCsv}`) : /能量计|冷量|热量/.test(`${row.name} ${sourceCsv}`)
? 'water' ? 'cooling'
: /能量计|冷量/.test(`${row.name} ${sourceCsv}`) : /流量计/.test(`${row.name} ${sourceCsv}`)
? 'cooling' ? 'water'
: null, : null,
scaleOverride: resolveScaleOverride(row), scaleOverride: resolveScaleOverride(row),
...defaultAlarmLimits(row.name, row.itemId), ...defaultAlarmLimits(row.name, row.itemId),
......
...@@ -21,8 +21,11 @@ import { ensureVideoSeed } from './video/store.js' ...@@ -21,8 +21,11 @@ import { ensureVideoSeed } from './video/store.js'
import { ensureLightSeed } from './light/store.js' import { ensureLightSeed } from './light/store.js'
import { ensureDevicesSeed } from './devices/store.js' import { ensureDevicesSeed } from './devices/store.js'
import { import {
disableDuplicateFacilitySensors,
ensureAccessSensorSeed, ensureAccessSensorSeed,
ensureCameraSensorSeed, ensureCameraSensorSeed,
purgeDisabledDuplicateFacilities,
restoreCameraScenesFromDisabledDuplicates,
ensureFridgeSensorSeed, ensureFridgeSensorSeed,
ensureNeLightSensorSeed, ensureNeLightSensorSeed,
ensurePressureSensorSeed, ensurePressureSensorSeed,
...@@ -138,6 +141,24 @@ async function bootstrap() { ...@@ -138,6 +141,24 @@ async function bootstrap() {
} catch (e) { } catch (e) {
console.warn('[sensors] access seed failed:', e instanceof Error ? e.message : e) console.warn('[sensors] access seed failed:', e instanceof Error ? e.message : e)
} }
try {
const dup = await disableDuplicateFacilitySensors()
console.log(`[sensors] duplicate facility disabled=${dup.disabled}`)
} catch (e) {
console.warn('[sensors] duplicate facility cleanup failed:', e instanceof Error ? e.message : e)
}
try {
const restored = await restoreCameraScenesFromDisabledDuplicates()
console.log(`[sensors] camera scene restored=${restored.restored}`)
} catch (e) {
console.warn('[sensors] camera scene restore failed:', e instanceof Error ? e.message : e)
}
try {
const purged = await purgeDisabledDuplicateFacilities()
console.log(`[sensors] duplicate facility purged=${purged.deleted}`)
} catch (e) {
console.warn('[sensors] duplicate facility purge failed:', e instanceof Error ? e.message : e)
}
const app = express() const app = express()
app.use(cors()) app.use(cors())
......
/**
* 日差量公共逻辑:累积表计读数日内只进不退,避免通讯毛刺/仿真抖动导致「今日 kWh」跳变。
*/
export type DailyMeterRow = { start: number; end: number; usage: number }
const EPS = 1e-6
export function memDayKey(date: string, meterKey: string) {
return `${date}|${meterKey}`
}
/** 规范化库内行:end 不得低于 start */
export function normalizeDailyRow(start: number, end: number, usage?: number): DailyMeterRow {
const s = Number.isFinite(start) ? start : 0
const e = Number.isFinite(end) ? Math.max(s, end) : s
const u = usage != null && Number.isFinite(usage) ? Math.max(0, usage) : Math.max(0, e - s)
return { start: s, end: e, usage: u }
}
/**
* 用最新累积读数刷新当日差量。
* - 新行:优先用昨末日值作 start;若当前读数已低于昨日 end,视为换表/错位,按当前起算
* - 已有行:读数回退则忽略(保持原 end/usage)
*/
export function applyMonotonicReading(
mem: Map<string, DailyMeterRow>,
dateKey: string,
yestKey: string,
reading: number,
): number {
let row = mem.get(dateKey)
if (!row) {
const prev = mem.get(yestKey)
let start = prev && Number.isFinite(prev.end) ? prev.end : reading
if (reading + EPS < start) start = reading
row = normalizeDailyRow(start, reading)
mem.set(dateKey, row)
return row.usage
}
// 读数已低于当日起点:基线被抬高或表计复位 → 重新锚定,避免长期 0 后突然暴涨
if (reading + EPS < row.start) {
row.start = reading
row.end = reading
row.usage = 0
return 0
}
// 日内累积只进不退:过滤通讯毛刺 / 仿真抖动回退
if (reading + EPS < row.end) {
return row.usage
}
row.end = reading
row.usage = Math.max(0, reading - row.start)
return row.usage
}
...@@ -7,6 +7,12 @@ ...@@ -7,6 +7,12 @@
import type { Device, DevicePoint } from '../devices/types.js' import type { Device, DevicePoint } from '../devices/types.js'
import { query } from '../db/pool.js' import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js' import { isPgEnabled } from '../db/state.js'
import {
applyMonotonicReading,
memDayKey,
normalizeDailyRow,
type DailyMeterRow,
} from './dailyCumulative.js'
export type LabPowerMeterKey = 'pau201' | 'e_power' | 'n_power' export type LabPowerMeterKey = 'pau201' | 'e_power' | 'n_power'
...@@ -123,56 +129,57 @@ function round3(n: number) { ...@@ -123,56 +129,57 @@ function round3(n: number) {
} }
/** 进程内日差量基线(快照同步可读;与 PG 表对齐) */ /** 进程内日差量基线(快照同步可读;与 PG 表对齐) */
const memDaily = new Map<string, { start: number; end: number; usage: number }>() const memDaily = new Map<string, DailyMeterRow>()
let memHydrated = false let hydratePromise: Promise<void> | null = null
function memKey(date: string, meterKey: string) {
return `${date}|${meterKey}`
}
/** 启动后首次从 PG 灌入近两日基线,避免进程重启丢当日差量 */ /** 启动后首次从 PG 灌入近两日基线,避免进程重启丢当日差量 */
export async function ensurePowerMeterHydrated() { export async function ensurePowerMeterHydrated() {
if (memHydrated || !isPgEnabled()) { if (!isPgEnabled()) return
memHydrated = true if (!hydratePromise) {
return hydratePromise = (async () => {
} const today = macauDateStr()
memHydrated = true const yest = new Date()
const today = macauDateStr() yest.setDate(yest.getDate() - 1)
const yest = new Date() const yestDate = macauDateStr(yest)
yest.setDate(yest.getDate() - 1) const keys = LAB_POWER_METERS.map((m) => m.key)
const yestDate = macauDateStr(yest) try {
try { const { rows } = await query<{
const { rows } = await query<{ usage_date: Date | string
usage_date: Date | string meter_key: string
meter_key: string start_kwh: string | null
start_kwh: string | null end_kwh: string | null
end_kwh: string | null usage_kwh: string
usage_kwh: string }>(
}>( `SELECT usage_date, meter_key, start_kwh, end_kwh, usage_kwh
`SELECT usage_date, meter_key, start_kwh, end_kwh, usage_kwh FROM energy_daily_meter
FROM energy_daily_meter WHERE usage_date >= $1::date AND usage_date <= $2::date
WHERE usage_date >= $1::date AND usage_date <= $2::date`, AND meter_key = ANY($3::text[])`,
[yestDate, today], [yestDate, today, keys],
) )
for (const r of rows) { for (const r of rows) {
const date = const date =
typeof r.usage_date === 'string' ? r.usage_date.slice(0, 10) : r.usage_date.toISOString().slice(0, 10) typeof r.usage_date === 'string'
const start = r.start_kwh != null ? Number(r.start_kwh) : Number(r.end_kwh) || 0 ? r.usage_date.slice(0, 10)
const end = r.end_kwh != null ? Number(r.end_kwh) : start : macauDateStr(new Date(r.usage_date))
memDaily.set(memKey(date, r.meter_key), { const start = r.start_kwh != null ? Number(r.start_kwh) : Number(r.end_kwh) || 0
start, const end = r.end_kwh != null ? Number(r.end_kwh) : start
end, memDaily.set(
usage: Number(r.usage_kwh) || Math.max(0, end - start), memDayKey(date, r.meter_key),
}) normalizeDailyRow(start, end, Number(r.usage_kwh)),
} )
} catch (e) { }
console.warn('[metering] hydrate energy_daily_meter failed', e) } catch (e) {
console.warn('[metering] hydrate energy_daily_meter failed', e)
hydratePromise = null
}
})()
} }
await hydratePromise
} }
/** /**
* 同步更新内存日差量,返回各表今日用量。 * 同步更新内存日差量,返回各表今日用量。
* start:已有当日行则保留;否则取昨日 end;再否则用当前读数(当日从 0 起)。 * 累积读数日内单调不减,回退抖动不计入。
*/ */
export function applyLabPowerBaselines( export function applyLabPowerBaselines(
readings: LabPowerMeterReading[], readings: LabPowerMeterReading[],
...@@ -186,22 +193,16 @@ export function applyLabPowerBaselines( ...@@ -186,22 +193,16 @@ export function applyLabPowerBaselines(
for (const r of readings) { for (const r of readings) {
if (r.energyKwh === null || !Number.isFinite(r.energyKwh)) { if (r.energyKwh === null || !Number.isFinite(r.energyKwh)) {
today.set(r.key, memDaily.get(memKey(date, r.key))?.usage || 0) today.set(r.key, memDaily.get(memDayKey(date, r.key))?.usage || 0)
continue continue
} }
const end = r.energyKwh const usage = applyMonotonicReading(
const k = memKey(date, r.key) memDaily,
let row = memDaily.get(k) memDayKey(date, r.key),
if (!row) { memDayKey(yestDate, r.key),
const prev = memDaily.get(memKey(yestDate, r.key)) r.energyKwh,
const start = prev && Number.isFinite(prev.end) ? prev.end : end )
row = { start, end, usage: Math.max(0, end - start) } today.set(r.key, usage)
memDaily.set(k, row)
} else {
row.end = end
row.usage = Math.max(0, end - row.start)
}
today.set(r.key, row.usage)
} }
return today return today
} }
...@@ -217,7 +218,7 @@ export async function recordLabPowerDaily(readings: LabPowerMeterReading[], at = ...@@ -217,7 +218,7 @@ export async function recordLabPowerDaily(readings: LabPowerMeterReading[], at =
const date = macauDateStr(at) const date = macauDateStr(at)
for (const r of readings) { for (const r of readings) {
if (r.energyKwh === null || !Number.isFinite(r.energyKwh)) continue if (r.energyKwh === null || !Number.isFinite(r.energyKwh)) continue
const row = memDaily.get(memKey(date, r.key)) const row = memDaily.get(memDayKey(date, r.key))
if (!row) continue if (!row) continue
await query( await query(
`INSERT INTO energy_daily_meter ( `INSERT INTO energy_daily_meter (
...@@ -226,8 +227,11 @@ export async function recordLabPowerDaily(readings: LabPowerMeterReading[], at = ...@@ -226,8 +227,11 @@ export async function recordLabPowerDaily(readings: LabPowerMeterReading[], at =
ON CONFLICT (usage_date, meter_key) DO UPDATE SET ON CONFLICT (usage_date, meter_key) DO UPDATE SET
meter_name = EXCLUDED.meter_name, meter_name = EXCLUDED.meter_name,
device_id = EXCLUDED.device_id, device_id = EXCLUDED.device_id,
end_kwh = EXCLUDED.end_kwh, end_kwh = GREATEST(EXCLUDED.end_kwh, energy_daily_meter.end_kwh),
usage_kwh = GREATEST(0, EXCLUDED.end_kwh - energy_daily_meter.start_kwh), usage_kwh = GREATEST(
0,
GREATEST(EXCLUDED.end_kwh, energy_daily_meter.end_kwh) - energy_daily_meter.start_kwh
),
updated_at = NOW()`, updated_at = NOW()`,
[date, r.key, r.name, r.deviceId || null, row.start, row.end, row.usage], [date, r.key, r.name, r.deviceId || null, row.start, row.end, row.usage],
) )
...@@ -278,7 +282,9 @@ async function loadUsageBetween(from: string, to: string): Promise<Map<string, M ...@@ -278,7 +282,9 @@ async function loadUsageBetween(from: string, to: string): Promise<Map<string, M
if (!isPgEnabled()) { if (!isPgEnabled()) {
for (const [k, row] of memDaily) { for (const [k, row] of memDaily) {
const [date, meterKey] = k.split('|') const sep = k.indexOf('|')
const date = k.slice(0, sep)
const meterKey = k.slice(sep + 1)
if (date < from || date > to) continue if (date < from || date > to) continue
if (!out.has(date)) out.set(date, new Map()) if (!out.has(date)) out.set(date, new Map())
out.get(date)!.set(meterKey as LabPowerMeterKey, row.usage) out.get(date)!.set(meterKey as LabPowerMeterKey, row.usage)
...@@ -286,11 +292,13 @@ async function loadUsageBetween(from: string, to: string): Promise<Map<string, M ...@@ -286,11 +292,13 @@ async function loadUsageBetween(from: string, to: string): Promise<Map<string, M
return out return out
} }
const keys = LAB_POWER_METERS.map((m) => m.key)
const { rows } = await query<{ usage_date: Date | string; meter_key: string; usage_kwh: string }>( const { rows } = await query<{ usage_date: Date | string; meter_key: string; usage_kwh: string }>(
`SELECT usage_date, meter_key, usage_kwh `SELECT usage_date, meter_key, usage_kwh
FROM energy_daily_meter FROM energy_daily_meter
WHERE usage_date >= $1::date AND usage_date <= $2::date`, WHERE usage_date >= $1::date AND usage_date <= $2::date
[from, to], AND meter_key = ANY($3::text[])`,
[from, to, keys],
) )
for (const r of rows) { for (const r of rows) {
const date = typeof r.usage_date === 'string' ? r.usage_date.slice(0, 10) : r.usage_date.toISOString().slice(0, 10) const date = typeof r.usage_date === 'string' ? r.usage_date.slice(0, 10) : r.usage_date.toISOString().slice(0, 10)
...@@ -348,7 +356,7 @@ export async function getPowerEnergyStats(readings?: LabPowerMeterReading[]): Pr ...@@ -348,7 +356,7 @@ export async function getPowerEnergyStats(readings?: LabPowerMeterReading[]): Pr
// 当日以进程内差量为准(比库更新更及时) // 当日以进程内差量为准(比库更新更及时)
const memToday = new Map<LabPowerMeterKey, number>() const memToday = new Map<LabPowerMeterKey, number>()
for (const def of LAB_POWER_METERS) { for (const def of LAB_POWER_METERS) {
const row = memDaily.get(memKey(date, def.key)) const row = memDaily.get(memDayKey(date, def.key))
memToday.set(def.key, row?.usage ?? byDate.get(date)?.get(def.key) ?? 0) memToday.set(def.key, row?.usage ?? byDate.get(date)?.get(def.key) ?? 0)
} }
byDate.set(date, memToday) byDate.set(date, memToday)
......
...@@ -138,10 +138,10 @@ export function generateSnapshot(): RealtimeSnapshot { ...@@ -138,10 +138,10 @@ export function generateSnapshot(): RealtimeSnapshot {
})), })),
metering: { metering: {
powerKwh: jitter(42.6, 1.2), powerKwh: jitter(42.6, 1.2),
waterM3: jitter(12.8, 0.2), waterM3: 0,
coolingKwh: jitter(1286, 8, 0), coolingKwh: jitter(1286, 8, 0),
powerTrendPct: 4.2, powerTrendPct: 4.2,
waterTrendPct: -1.8, waterTrendPct: 0,
coolingTrendPct: 6.5, coolingTrendPct: 6.5,
powerMeters: [ powerMeters: [
{ {
...@@ -169,6 +169,24 @@ export function generateSnapshot(): RealtimeSnapshot { ...@@ -169,6 +169,24 @@ export function generateSnapshot(): RealtimeSnapshot {
todayKwh: jitter(10.1, 0.4), todayKwh: jitter(10.1, 0.4),
}, },
], ],
coolingMeters: [
{
key: 'dn40',
name: 'DN40能量计',
deviceId: 'mb-103-能量计_dn40',
energyKwh: jitter(8600, 3),
powerKw: jitter(12.5, 0.6),
todayKwh: jitter(420, 8, 0),
},
{
key: 'dn80',
name: 'DN80能量计',
deviceId: 'mb-103-能量计_dn80',
energyKwh: jitter(12400, 4),
powerKw: jitter(28.2, 1.2),
todayKwh: jitter(860, 12, 0),
},
],
}, },
access: { access: {
doors: DOORS.map((d, i) => ({ doors: DOORS.map((d, i) => ({
......
...@@ -35,23 +35,26 @@ export type AccessFacilityDef = { ...@@ -35,23 +35,26 @@ export type AccessFacilityDef = {
scene: { x: number; y: number; z: number } scene: { x: number; y: number; z: number }
} }
/** 交底表 IPC(海康威视 · 8000 · admin);id=网关 cam-* */ /**
* 交底表 IPC(海康威视 · 8000 · admin);id=网关 cam-*。
* scene 取自现场已校准打点(原 cam-reagent 等场景点),勿再改回平面图 uv 估算。
*/
export const CAMERA_FACILITY_DEFS: CameraFacilityDef[] = [ export const CAMERA_FACILITY_DEFS: CameraFacilityDef[] = [
{ id: 'cam-150', name: '收样咨询室', ip: '192.168.10.150', port: 8000, channel: 1, nvrChannel: 1, scene: uv(0.88, 0.88) }, { id: 'cam-150', name: '收样咨询室', ip: '192.168.10.150', port: 8000, channel: 1, nvrChannel: 1, scene: { x: 133.54304535168657, y: 22.197154140678283, z: 52.89886360864085 } },
{ id: 'cam-144', name: '样本制备区2房前面', ip: '192.168.10.144', port: 8000, channel: 1, nvrChannel: 2, scene: uv(0.55, 0.42) }, { id: 'cam-144', name: '样本制备区2房前面', ip: '192.168.10.144', port: 8000, channel: 1, nvrChannel: 2, scene: { x: 133.54301302625632, y: 22.19720915097235, z: 52.34229319363857 } },
{ id: 'cam-146', name: '文库制备2房后面', ip: '192.168.10.146', port: 8000, channel: 1, nvrChannel: 3, scene: uv(0.25, 0.72) }, { id: 'cam-146', name: '文库制备2房后面', ip: '192.168.10.146', port: 8000, channel: 1, nvrChannel: 3, scene: { x: 133.54305446627677, y: 22.197261043662643, z: 52.7458649611248 } },
{ id: 'cam-152', name: '样本制备区2房后面', ip: '192.168.10.152', port: 8000, channel: 1, nvrChannel: 4, scene: uv(0.55, 0.72) }, { id: 'cam-152', name: '样本制备区2房后面', ip: '192.168.10.152', port: 8000, channel: 1, nvrChannel: 4, scene: { x: 133.54307373992305, y: 22.197208484875752, z: 53.198857399432214 } },
{ id: 'cam-147', name: '文库制备2房前面', ip: '192.168.10.147', port: 8000, channel: 1, nvrChannel: 5, scene: uv(0.25, 0.42) }, { id: 'cam-147', name: '文库制备2房前面', ip: '192.168.10.147', port: 8000, channel: 1, nvrChannel: 5, scene: { x: 133.54301091790347, y: 22.197241694266044, z: 53.198872485623305 } },
{ id: 'cam-153', name: '前走廊', ip: '192.168.10.153', port: 8000, channel: 1, nvrChannel: 6, scene: uv(0.62, 0.12) }, { id: 'cam-153', name: '前走廊', ip: '192.168.10.153', port: 8000, channel: 1, nvrChannel: 6, scene: { x: 133.5430021581302, y: 22.19713299907297, z: 51.29877586670629 } },
{ id: 'cam-148', name: '测序区后', ip: '192.168.10.148', port: 8000, channel: 1, nvrChannel: 7, scene: uv(0.1, 0.72) }, { id: 'cam-148', name: '测序区后', ip: '192.168.10.148', port: 8000, channel: 1, nvrChannel: 7, scene: { x: 133.54307386102286, y: 22.19727394812915, z: 51.298763564847555 } },
{ id: 'cam-151', name: '生信分析/报告室', ip: '192.168.10.151', port: 8000, channel: 1, nvrChannel: 8, scene: uv(0.88, 0.38) }, { id: 'cam-151', name: '生信分析/报告室', ip: '192.168.10.151', port: 8000, channel: 1, nvrChannel: 8, scene: { x: 133.5430115903164, y: 22.19712710731014, z: 52.198774068133666 } },
{ id: 'cam-145', name: '样本制备区1房后面', ip: '192.168.10.145', port: 8000, channel: 1, nvrChannel: 9, scene: uv(0.7, 0.72) }, { id: 'cam-145', name: '样本制备区1房后面', ip: '192.168.10.145', port: 8000, channel: 1, nvrChannel: 9, scene: { x: 133.5430681697317, y: 22.197182027748866, z: 53.19885813437149 } },
{ id: 'cam-142', name: '试剂准备间', ip: '192.168.10.142', port: 8000, channel: 1, nvrChannel: 10, scene: uv(0.85, 0.55) }, { id: 'cam-142', name: '试剂准备间', ip: '192.168.10.142', port: 8000, channel: 1, nvrChannel: 10, scene: { x: 133.54303985201588, y: 22.19714661524511, z: 52.89886535457025 } },
{ id: 'cam-156', name: '文库制备区1房前面', ip: '192.168.10.156', port: 8000, channel: 1, nvrChannel: 11, scene: uv(0.4, 0.42) }, { id: 'cam-156', name: '文库制备区1房前面', ip: '192.168.10.156', port: 8000, channel: 1, nvrChannel: 11, scene: { x: 133.54301229478236, y: 22.197236838030356, z: 52.198771690717315 } },
{ id: 'cam-154', name: '样本制备区1房前面', ip: '192.168.10.154', port: 8000, channel: 1, nvrChannel: 12, scene: uv(0.7, 0.42) }, { id: 'cam-154', name: '样本制备区1房前面', ip: '192.168.10.154', port: 8000, channel: 1, nvrChannel: 12, scene: { x: 133.543010512658, y: 22.197161385160943, z: 52.198771185865986 } },
{ id: 'cam-155', name: '测序区前', ip: '192.168.10.155', port: 8000, channel: 1, nvrChannel: 13, scene: uv(0.1, 0.42) }, { id: 'cam-155', name: '测序区前', ip: '192.168.10.155', port: 8000, channel: 1, nvrChannel: 13, scene: { x: 133.54303018350365, y: 22.197267685534975, z: 51.29877093643804 } },
{ id: 'cam-149', name: '文库制备区1房后面', ip: '192.168.10.149', port: 8000, channel: 1, nvrChannel: 14, scene: uv(0.4, 0.72) }, { id: 'cam-149', name: '文库制备区1房后面', ip: '192.168.10.149', port: 8000, channel: 1, nvrChannel: 14, scene: { x: 133.54307587541516, y: 22.19721701917911, z: 53.00885740952583 } },
{ id: 'cam-143', name: '后走廊', ip: '192.168.10.143', port: 8000, channel: 1, nvrChannel: 15, scene: uv(0.35, 0.12) }, { id: 'cam-143', name: '后走廊', ip: '192.168.10.143', port: 8000, channel: 1, nvrChannel: 15, scene: { x: 133.543001588049, y: 22.19728054869952, z: 51.298780141022775 } },
] ]
/** 交底表门禁控制器(网关 door-120…;平台设施 id 保留语义名,按 IP/名称匹配状态) */ /** 交底表门禁控制器(网关 door-120…;平台设施 id 保留语义名,按 IP/名称匹配状态) */
......
...@@ -2,6 +2,11 @@ import { evaluatePointAlarms } from '../devices/alarms.js' ...@@ -2,6 +2,11 @@ import { evaluatePointAlarms } from '../devices/alarms.js'
import { listDevices, listGateways, listPoints } from '../devices/store.js' import { listDevices, listGateways, listPoints } from '../devices/store.js'
import type { Device, DevicePoint } from '../devices/types.js' import type { Device, DevicePoint } from '../devices/types.js'
import { import {
ensureCoolingMeterHydrated,
extractLabCoolingMeters,
todayCoolingUsageFromReadings,
} from '../metering/coolingMeters.js'
import {
ensurePowerMeterHydrated, ensurePowerMeterHydrated,
extractLabPowerMeters, extractLabPowerMeters,
todayUsageFromReadings, todayUsageFromReadings,
...@@ -127,18 +132,6 @@ function vavFromDevice(d: Device, points: DevicePoint[]): VavSnapshot { ...@@ -127,18 +132,6 @@ function vavFromDevice(d: Device, points: DevicePoint[]): VavSnapshot {
} }
} }
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 / 空告警)。 * 从设备台账 + 点位 lastEng 组装大屏业务快照(无假数;无遥测则为 0 / 空告警)。
*/ */
...@@ -206,6 +199,44 @@ function sensorFromBinds( ...@@ -206,6 +199,44 @@ function sensorFromBinds(
} }
} }
function isBriefingFacilityCode(code: string, category: string): boolean {
if (category === 'camera') return /^cam-\d+$/i.test(code)
if (category === 'access') return /^door-\d+$/i.test(code)
return false
}
/** 大屏打点:摄像头/门禁去重,优先交底表编号 */
function dedupeFacilitySensorSnapshots(list: SensorSnapshot[]): SensorSnapshot[] {
const out: SensorSnapshot[] = []
const best = new Map<string, SensorSnapshot>()
for (const s of list) {
if (s.category !== 'camera' && s.category !== 'access') {
out.push(s)
continue
}
const key =
s.category === 'camera'
? `camera:${s.videoDeviceId || s.name}`
: `access:${s.name}`
const prev = best.get(key)
if (!prev) {
best.set(key, s)
continue
}
const scoreNew = isBriefingFacilityCode(s.code, s.category) ? 2 : 0
const scoreOld = isBriefingFacilityCode(prev.code, prev.category) ? 2 : 0
if (scoreNew > scoreOld) {
best.set(key, s)
} else if (scoreNew === scoreOld) {
const dzNew = Math.abs((s.scene?.z || 51) - 51)
const dzOld = Math.abs((prev.scene?.z || 51) - 51)
if (dzNew < dzOld) best.set(key, s)
}
}
out.push(...best.values())
return out
}
export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> { export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> {
const [devices, points, gateways, pointAlarms, sensors] = await Promise.all([ const [devices, points, gateways, pointAlarms, sensors] = await Promise.all([
listDevices(), listDevices(),
...@@ -226,7 +257,10 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> { ...@@ -226,7 +257,10 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> {
byDevice.set(p.deviceId, list) byDevice.set(p.deviceId, list)
} }
const sensorSnapshots: SensorSnapshot[] = sensors.map((s) => sensorFromBinds(s, pointById)) // 监控/门禁:同名或同视频源只保留交底表 cam-14x / door-12x,避免旧场景打点叠出双图标
const sensorSnapshots: SensorSnapshot[] = dedupeFacilitySensorSnapshots(
sensors.map((s) => sensorFromBinds(s, pointById)),
)
const rooms = enabled const rooms = enabled
.filter((d) => d.category === 'room') .filter((d) => d.category === 'room')
...@@ -275,11 +309,12 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> { ...@@ -275,11 +309,12 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> {
} }
} }
const waterMeters = enabled.filter((d) => d.category === 'meter_water')
const coolMeters = enabled.filter((d) => d.category === 'meter_cooling')
const labPowerReadings = extractLabPowerMeters(enabled, byDevice) const labPowerReadings = extractLabPowerMeters(enabled, byDevice)
await ensurePowerMeterHydrated() const labCoolingReadings = extractLabCoolingMeters(enabled, byDevice)
await Promise.all([ensurePowerMeterHydrated(), ensureCoolingMeterHydrated()])
const { totalKwh: todayTotal, byKey: todayByKey } = todayUsageFromReadings(labPowerReadings) const { totalKwh: todayTotal, byKey: todayByKey } = todayUsageFromReadings(labPowerReadings)
const { totalKwh: todayCooling, byKey: coolingByKey } =
todayCoolingUsageFromReadings(labCoolingReadings)
let onlineCount = 0 let onlineCount = 0
for (const d of enabled) { for (const d of enabled) {
...@@ -308,8 +343,9 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> { ...@@ -308,8 +343,9 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> {
sensors: sensorSnapshots, sensors: sensorSnapshots,
metering: { metering: {
powerKwh: todayTotal, powerKwh: todayTotal,
waterM3: sumEng(waterMeters, byDevice, /用水量|累计流量|流量/), /** 本系统无水表,固定 0 */
coolingKwh: sumEng(coolMeters, byDevice, /冷量|累计|用电量/), waterM3: 0,
coolingKwh: todayCooling,
powerTrendPct: 0, powerTrendPct: 0,
waterTrendPct: 0, waterTrendPct: 0,
coolingTrendPct: 0, coolingTrendPct: 0,
...@@ -321,6 +357,14 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> { ...@@ -321,6 +357,14 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> {
powerKw: r.powerKw, powerKw: r.powerKw,
todayKwh: todayByKey.get(r.key) || 0, todayKwh: todayByKey.get(r.key) || 0,
})), })),
coolingMeters: labCoolingReadings.map((r) => ({
key: r.key,
name: r.name,
deviceId: r.deviceId,
energyKwh: r.energyKwh,
powerKw: r.powerKw,
todayKwh: coolingByKey.get(r.key) || 0,
})),
}, },
access: { access: {
doors: enabled doors: enabled
......
...@@ -23,7 +23,6 @@ const METRIC_LABEL: Record<string, string> = { ...@@ -23,7 +23,6 @@ const METRIC_LABEL: Record<string, string> = {
fresh_air_temp: '新风温度', fresh_air_temp: '新风温度',
exhaust_pressure: '排风压力', exhaust_pressure: '排风压力',
power_kwh: '用电量', power_kwh: '用电量',
water_m3: '用水量',
cooling_kwh: '冷量', cooling_kwh: '冷量',
} }
......
...@@ -13,8 +13,8 @@ const CATEGORY_LABEL: Record<DeviceCategory, string> = { ...@@ -13,8 +13,8 @@ const CATEGORY_LABEL: Record<DeviceCategory, string> = {
room: '房间环境', room: '房间环境',
fridge: '冷链冰箱', fridge: '冷链冰箱',
meter_power: '电表', meter_power: '电表',
meter_water: '水表', meter_water: '流量计',
meter_cooling: '冷量表', meter_cooling: '能量计',
light: '照明', light: '照明',
door: '门禁', door: '门禁',
camera: '摄像', camera: '摄像',
...@@ -137,7 +137,6 @@ export async function buildScreenDashboard(): Promise<ScreenDashboard> { ...@@ -137,7 +137,6 @@ export async function buildScreenDashboard(): Promise<ScreenDashboard> {
{ key: 'hvacFreshAirTemp', source: 'hvac', metric: 'fresh_air_temp' }, { key: 'hvacFreshAirTemp', source: 'hvac', metric: 'fresh_air_temp' },
{ key: 'hvacExhaustPressure', source: 'hvac', metric: 'exhaust_pressure' }, { key: 'hvacExhaustPressure', source: 'hvac', metric: 'exhaust_pressure' },
{ key: 'meteringPower', source: 'metering', metric: 'power_kwh' }, { key: 'meteringPower', source: 'metering', metric: 'power_kwh' },
{ key: 'meteringWater', source: 'metering', metric: 'water_m3' },
{ key: 'meteringCooling', source: 'metering', metric: 'cooling_kwh' }, { key: 'meteringCooling', source: 'metering', metric: 'cooling_kwh' },
], ],
{ hours: 24, bucketMinutes: 60 }, { hours: 24, bucketMinutes: 60 },
...@@ -158,28 +157,18 @@ export async function buildScreenDashboard(): Promise<ScreenDashboard> { ...@@ -158,28 +157,18 @@ export async function buildScreenDashboard(): Promise<ScreenDashboard> {
'hvacFreshAirTemp', 'hvacFreshAirTemp',
'hvacExhaustPressure', 'hvacExhaustPressure',
'meteringPower', 'meteringPower',
'meteringWater',
'meteringCooling', 'meteringCooling',
] as const ] as const
const namedList = await Promise.all( const namedList = await Promise.all(
curveKeys.map((k) => attachSeriesNames(namedCurves[k] || [])), curveKeys.map((k) => attachSeriesNames(namedCurves[k] || [])),
) )
const curves = Object.fromEntries(curveKeys.map((k, i) => [k, namedList[i]])) as { const curves = {
roomTemp: TsSeries[] ...(Object.fromEntries(curveKeys.map((k, i) => [k, namedList[i]])) as Omit<
roomHumidity: TsSeries[] ScreenDashboard['curves'],
roomPressure: TsSeries[] 'meteringWater'
fridgeTemp: TsSeries[] >),
fridgeHumidity: TsSeries[] /** 本系统无水表,曲线固定空 */
vavAirflow: TsSeries[] meteringWater: [] as 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 enabled = devices.filter((d) => d.enabled)
...@@ -201,15 +190,19 @@ export async function buildScreenDashboard(): Promise<ScreenDashboard> { ...@@ -201,15 +190,19 @@ export async function buildScreenDashboard(): Promise<ScreenDashboard> {
value, value,
})) }))
// statusPie:互斥分桶(告警优先);deviceOnline:有新鲜遥测即算在线(告警设备仍算在线)
let online = 0 let online = 0
let offline = 0 let offline = 0
let alarm = 0 let alarm = 0
let unknown = 0 let unknown = 0
let deviceOnline = 0
for (const d of enabled) { for (const d of enabled) {
const pts = byDevice.get(d.id) || [] const pts = byDevice.get(d.id) || []
const isFresh = fresh(pts)
if (isFresh) deviceOnline += 1
if (d.status === 'alarm' || snapshot.pointAlarms?.some((a) => a.deviceId === d.id)) { if (d.status === 'alarm' || snapshot.pointAlarms?.some((a) => a.deviceId === d.id)) {
alarm += 1 alarm += 1
} else if (fresh(pts)) { } else if (isFresh) {
online += 1 online += 1
} else if (d.status === 'offline') { } else if (d.status === 'offline') {
offline += 1 offline += 1
...@@ -362,7 +355,7 @@ export async function buildScreenDashboard(): Promise<ScreenDashboard> { ...@@ -362,7 +355,7 @@ export async function buildScreenDashboard(): Promise<ScreenDashboard> {
curves, curves,
digits: { digits: {
deviceTotal: enabled.length, deviceTotal: enabled.length,
deviceOnline: online, deviceOnline,
roomOk, roomOk,
roomTotal: snapshot.rooms.length, roomTotal: snapshot.rooms.length,
fridgeOk, fridgeOk,
......
...@@ -43,6 +43,7 @@ let snapshot: RealtimeSnapshot = { ...@@ -43,6 +43,7 @@ let snapshot: RealtimeSnapshot = {
waterTrendPct: 0, waterTrendPct: 0,
coolingTrendPct: 0, coolingTrendPct: 0,
powerMeters: [], powerMeters: [],
coolingMeters: [],
}, },
access: { doors: [], todayPassCount: 0, recentEvents: [] }, access: { doors: [], todayPassCount: 0, recentEvents: [] },
online: { devicesOnline: 0, devicesTotal: 0, gatewayOk: false }, online: { devicesOnline: 0, devicesTotal: 0, gatewayOk: false },
...@@ -59,6 +60,24 @@ export function getSnapshot() { ...@@ -59,6 +60,24 @@ export function getSnapshot() {
return snapshot return snapshot
} }
/** 远程开门成功后立即把快照门状态改为 open,并推送前端 */
export function markAccessDoorsOpen(doorIds: string[]) {
const ids = new Set(doorIds.map((x) => String(x || '').trim()).filter(Boolean))
if (!ids.size) return
const doors = (snapshot.access?.doors || []).map((d) =>
ids.has(d.id) ? { ...d, status: 'open' as const } : d,
)
snapshot = {
...snapshot,
ts: Date.now(),
access: {
...snapshot.access,
doors,
},
}
publish()
}
export function getCurrentPollMs() { export function getCurrentPollMs() {
return currentPollMs return currentPollMs
} }
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
getReport, getReport,
type TariffSettings, type TariffSettings,
} from '../billing/store.js' } from '../billing/store.js'
import { getCoolingEnergyStats } from '../metering/coolingMeters.js'
import { getPowerEnergyStats } from '../metering/powerMeters.js' import { getPowerEnergyStats } from '../metering/powerMeters.js'
import { getSnapshot } from '../realtime/store.js' import { getSnapshot } from '../realtime/store.js'
...@@ -67,6 +68,18 @@ billingRouter.get('/power-energy', async (_req, res) => { ...@@ -67,6 +68,18 @@ billingRouter.get('/power-energy', async (_req, res) => {
res.json(await getPowerEnergyStats(readings)) res.json(await getPowerEnergyStats(readings))
}) })
/** DN40 / DN80 能量计:当天与当月制冷量 + 分项 */
billingRouter.get('/cooling-energy', async (_req, res) => {
const readings = (getSnapshot().metering.coolingMeters || []).map((m) => ({
key: m.key,
name: m.name,
deviceId: m.deviceId,
energyKwh: m.energyKwh,
powerKw: m.powerKw,
}))
res.json(await getCoolingEnergyStats(readings))
})
billingRouter.get('/report', async (req, res) => { billingRouter.get('/report', async (req, res) => {
const to = String(req.query.to || new Date().toISOString().slice(0, 10)) const to = String(req.query.to || new Date().toISOString().slice(0, 10))
let from = String(req.query.from || '') let from = String(req.query.from || '')
......
import { Router } from 'express' import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js' import { authOptional, authRequired, requireRole } from '../auth/middleware.js'
import { queryFridgeTempSeries } from '../db/collect.js' import { queryFridgeTempSeries } from '../db/collect.js'
import { exportHistoryExcel, getHistoryCatalog, getHistorySeries } from '../realtime/history.js' import { exportHistoryExcel, getHistoryCatalog, getHistorySeries } from '../realtime/history.js'
import { buildScreenDashboard } from '../realtime/screen.js' import { buildScreenDashboard } from '../realtime/screen.js'
import { applyWriteLog, getSnapshot } from '../realtime/store.js' import { applyWriteLog, getSnapshot, markAccessDoorsOpen } from '../realtime/store.js'
export const realtimeRouter = Router() export const realtimeRouter = Router()
...@@ -123,7 +123,8 @@ realtimeRouter.post('/write', authRequired, requireRole('admin'), (req, res) => ...@@ -123,7 +123,8 @@ realtimeRouter.post('/write', authRequired, requireRole('admin'), (req, res) =>
res.json({ ok: true, message: '指令已受理(当前为 Mock/只记日志)' }) res.json({ ok: true, message: '指令已受理(当前为 Mock/只记日志)' })
}) })
realtimeRouter.post('/access/open', authRequired, requireRole('admin'), async (req, res) => { /** 大屏远程开门:与照明一致,允许匿名;有登录则记操作人 */
realtimeRouter.post('/access/open', authOptional, async (req, res) => {
const { doorId } = req.body || {} const { doorId } = req.body || {}
if (!doorId) { if (!doorId) {
res.status(400).json({ message: '缺少 doorId' }) res.status(400).json({ message: '缺少 doorId' })
...@@ -133,7 +134,7 @@ realtimeRouter.post('/access/open', authRequired, requireRole('admin'), async (r ...@@ -133,7 +134,7 @@ realtimeRouter.post('/access/open', authRequired, requireRole('admin'), async (r
const result = await openDoorRemote(String(doorId)) const result = await openDoorRemote(String(doorId))
applyWriteLog('remote_open', { applyWriteLog('remote_open', {
doorId, doorId,
by: req.user!.username, by: req.user?.username || 'anonymous',
ok: result.ok, ok: result.ok,
message: result.message, message: result.message,
}) })
...@@ -141,5 +142,45 @@ realtimeRouter.post('/access/open', authRequired, requireRole('admin'), async (r ...@@ -141,5 +142,45 @@ realtimeRouter.post('/access/open', authRequired, requireRole('admin'), async (r
res.status(502).json({ ok: false, message: result.message }) res.status(502).json({ ok: false, message: result.message })
return return
} }
markAccessDoorsOpen([String(doorId)])
res.json({ ok: true, message: result.message }) res.json({ ok: true, message: result.message })
}) })
/** 大屏一键开门:对当前快照中的全部门点下发远程开门 */
realtimeRouter.post('/access/open-all', authOptional, async (req, res) => {
const { openDoorRemote } = await import('../access/adapter.js')
const doors = getSnapshot().access?.doors || []
if (!doors.length) {
res.status(400).json({ ok: false, message: '当前无门禁点位', okCount: 0, total: 0 })
return
}
const by = req.user?.username || 'anonymous'
let okCount = 0
const openedIds: string[] = []
const errors: string[] = []
for (const d of doors) {
const result = await openDoorRemote(d.id)
applyWriteLog('remote_open_all', {
doorId: d.id,
by,
ok: result.ok,
message: result.message,
})
if (result.ok) {
okCount += 1
openedIds.push(d.id)
} else errors.push(`${d.name || d.id}: ${result.message}`)
}
if (openedIds.length) markAccessDoorsOpen(openedIds)
const total = doors.length
const ok = okCount > 0
const message =
okCount === total
? `已远程开启 ${okCount} 扇门`
: `已开启 ${okCount}/${total} 扇门${errors.length ? `;失败:${errors.slice(0, 3).join(';')}` : ''}`
if (!ok) {
res.status(502).json({ ok: false, message, okCount, total })
return
}
res.json({ ok: true, message, okCount, total })
})
...@@ -2,7 +2,7 @@ import { Router } from 'express' ...@@ -2,7 +2,7 @@ import { Router } from 'express'
import { authOptional, authRequired, requireRole } from '../auth/middleware.js' import { authOptional, authRequired, requireRole } from '../auth/middleware.js'
import { applyPointTelemetry, listPoints } from '../devices/store.js' import { applyPointTelemetry, listPoints } from '../devices/store.js'
import { inferPointRw } from '../devices/pointRw.js' import { inferPointRw } from '../devices/pointRw.js'
import { gatewayModbusWrite } from '../gateway/client.js' import { fetchGatewayHealth, gatewayModbusWrite, resolveGatewayBase } from '../gateway/client.js'
import { applyWriteLog, refreshSnapshotNow } from '../realtime/store.js' import { applyWriteLog, refreshSnapshotNow } from '../realtime/store.js'
import { import {
createSensor, createSensor,
...@@ -13,7 +13,7 @@ import { ...@@ -13,7 +13,7 @@ import {
setSensorPoints, setSensorPoints,
updateSensor, updateSensor,
} from '../sensors/store.js' } from '../sensors/store.js'
import type { SensorCategory, SensorPointRole } from '../sensors/types.js' import type { Sensor, SensorCategory, SensorPointRole } from '../sensors/types.js'
export const sensorsRouter = Router() export const sensorsRouter = Router()
...@@ -26,6 +26,173 @@ sensorsRouter.get('/', async (req, res) => { ...@@ -26,6 +26,173 @@ sensorsRouter.get('/', async (req, res) => {
res.json({ sensors: await listSensors({ category, enabled }) }) res.json({ sensors: await listSensors({ category, enabled }) })
}) })
type LightControlOk = {
ok: true
sensorId: string
code: string
on: boolean
pointId: string
value: number
gatewayOk: boolean
message?: string
}
type LightControlFail = {
ok: false
sensorId: string
code: string
message: string
}
function isGatewayUnreachable(msg: string) {
return /fetch failed|ECONNREFUSED|EHOSTDOWN|ENETUNREACH|aborted|timeout|超时|未配置 GATEWAY_URL/i.test(
msg,
)
}
async function resolveLightWriteTarget(
sensor: Sensor,
pointsCache?: Awaited<ReturnType<typeof listPoints>>,
): Promise<{ ok: true; pointId: string; itemId: string } | { ok: false; message: string }> {
if (sensor.category !== 'lightN' && sensor.category !== 'lightE') {
return { ok: false, message: '非照明设施' }
}
const bind =
sensor.points.find((p) => p.role === 'status') ||
sensor.points.find((p) => p.role === 'value') ||
sensor.points[0]
if (!bind?.pointId) return { ok: false, message: '未绑定可写点位' }
const points = pointsCache || (await listPoints())
const point = points.find((p) => p.id === bind.pointId)
if (!point) return { ok: false, message: '绑定点位不存在' }
const rw = inferPointRw(point.regType, point.rw || 'r', {
name: point.name,
itemId: point.itemId,
})
if (rw !== 'w' && rw !== 'rw') return { ok: false, message: '该点位为只读,不可控制' }
return { ok: true, pointId: point.id, itemId: point.itemId }
}
async function controlOneLight(
sensor: Sensor,
on: boolean,
by: string,
opts?: { skipGateway?: boolean; pointsCache?: Awaited<ReturnType<typeof listPoints>> },
): Promise<LightControlOk | LightControlFail> {
const target = await resolveLightWriteTarget(sensor, opts?.pointsCache)
if (!target.ok) {
return { ok: false, sensorId: sensor.id, code: sensor.code, message: target.message }
}
const value = on ? 1 : 0
let gatewayOk = true
let message = 'ok'
if (!opts?.skipGateway) {
const result = await gatewayModbusWrite({
pointId: target.pointId,
itemId: target.itemId,
value,
})
if (!result.ok) {
const msg = result.message || '网关写点失败'
if (!isGatewayUnreachable(msg)) {
return { ok: false, sensorId: sensor.id, code: sensor.code, message: msg }
}
gatewayOk = false
message = `已切换(网关暂不可达:${msg})`
} else {
message = result.message || 'ok'
}
} else {
gatewayOk = false
message = '已切换(网关暂不可达,仅本地)'
}
applyWriteLog('facility_switch', {
sensorId: sensor.id,
code: sensor.code,
pointId: target.pointId,
itemId: target.itemId,
value,
on,
by,
gatewayOk,
})
await applyPointTelemetry([
{
id: target.pointId,
itemId: target.itemId,
eng: value,
raw: value,
ts: new Date().toISOString(),
},
])
return {
ok: true,
sensorId: sensor.id,
code: sensor.code,
on,
pointId: target.pointId,
value,
gatewayOk,
message,
}
}
/** 大屏一键开/关:全部 N电 + E电 照明(须在 /:id 之前注册) */
sensorsRouter.post('/lights/switch-all', authOptional, async (req, res) => {
try {
if (req.body?.on === undefined && req.body?.powerOn === undefined) {
res.status(400).json({ message: '请指定 on / powerOn' })
return
}
const on = Boolean(req.body?.on ?? req.body?.powerOn)
const by = req.user?.username || 'screen'
const [nList, eList] = await Promise.all([
listSensors({ category: 'lightN', enabled: true }),
listSensors({ category: 'lightE', enabled: true }),
])
const targets = [...nList, ...eList]
const pointsCache = await listPoints()
const results: (LightControlOk | LightControlFail)[] = []
// 先探活:网关挂了就整批只落本地,避免首路写控卡 8s×重试
let skipGateway = false
if (resolveGatewayBase()) {
const health = await fetchGatewayHealth().catch(() => ({ ok: false as boolean }))
if (!health?.ok) skipGateway = true
} else {
skipGateway = true
}
for (const s of targets) {
const r = await controlOneLight(s, on, by, { skipGateway, pointsCache })
results.push(r)
if (r.ok && !r.gatewayOk) skipGateway = true
}
const okCount = results.filter((r) => r.ok).length
const failCount = results.length - okCount
applyWriteLog('facility_switch_all', {
on,
by,
total: results.length,
okCount,
failCount,
gatewaySkipped: skipGateway,
codes: results.map((r) => ({ code: r.code, ok: r.ok })),
})
void refreshSnapshotNow().catch((e) =>
console.warn('[sensors] refresh snapshot after switch-all failed', e),
)
res.json({
ok: failCount === 0,
on,
total: results.length,
okCount,
failCount,
results,
message: `已${on ? '开启' : '关闭'} ${okCount}/${results.length} 路照明`,
})
} catch (e) {
res.status(500).json({ message: e instanceof Error ? e.message : '批量控制失败' })
}
})
sensorsRouter.get('/:id', async (req, res) => { sensorsRouter.get('/:id', async (req, res) => {
const s = await getSensor(req.params.id) const s = await getSensor(req.params.id)
if (!s) { if (!s) {
...@@ -125,69 +292,23 @@ sensorsRouter.post('/:id/control', authOptional, async (req, res) => { ...@@ -125,69 +292,23 @@ sensorsRouter.post('/:id/control', authOptional, async (req, res) => {
res.status(403).json({ message: '仅照明设施可匿名控制' }) res.status(403).json({ message: '仅照明设施可匿名控制' })
return return
} }
const bind = const by = req.user?.username || 'screen'
sensor.points.find((p) => p.role === 'status') || const result = await controlOneLight(sensor, on, by)
sensor.points.find((p) => p.role === 'value') ||
sensor.points[0]
if (!bind?.pointId) {
res.status(400).json({ message: '未绑定可写点位' })
return
}
const points = await listPoints()
const point = points.find((p) => p.id === bind.pointId)
if (!point) {
res.status(404).json({ message: '绑定点位不存在' })
return
}
const rw = inferPointRw(point.regType, point.rw || 'r', {
name: point.name,
itemId: point.itemId,
})
if (rw !== 'w' && rw !== 'rw') {
res.status(400).json({ message: '该点位为只读,不可控制' })
return
}
const value = on ? 1 : 0
const result = await gatewayModbusWrite({
pointId: point.id,
itemId: point.itemId,
value,
})
applyWriteLog('facility_switch', {
sensorId: sensor.id,
code: sensor.code,
pointId: point.id,
itemId: point.itemId,
value,
on,
by: req.user?.username || 'screen',
gateway: result,
})
// 先落本地遥测,保证大屏开关有反馈
await applyPointTelemetry([
{ id: point.id, itemId: point.itemId, eng: value, raw: value, ts: new Date().toISOString() },
])
void refreshSnapshotNow().catch((e) => void refreshSnapshotNow().catch((e) =>
console.warn('[sensors] refresh snapshot after control failed', e), console.warn('[sensors] refresh snapshot after control failed', e),
) )
if (!result.ok) { if (!result.ok) {
const msg = result.message || '网关写点失败' res.status(502).json({ ok: false, message: result.message, on })
// 网关瞬时不可达:本地已切换,避免前端报超时;业务写失败仍 502
if (/fetch failed|ECONNREFUSED|aborted|timeout|超时/i.test(msg)) {
res.json({
ok: true,
message: `已切换(网关暂不可达:${msg})`,
on,
pointId: point.id,
value,
gatewayDegraded: true,
})
return
}
res.status(502).json({ ok: false, message: msg, on, pointId: point.id, value })
return return
} }
res.json({ ok: true, message: result.message, on, pointId: point.id, value }) res.json({
ok: true,
message: result.message,
on: result.on,
pointId: result.pointId,
value: result.value,
gatewayDegraded: !result.gatewayOk,
})
} catch (e) { } catch (e) {
res.status(500).json({ message: e instanceof Error ? e.message : '控制失败' }) res.status(500).json({ message: e instanceof Error ? e.message : '控制失败' })
} }
......
...@@ -283,6 +283,7 @@ export async function setSensorPoints( ...@@ -283,6 +283,7 @@ export async function setSensorPoints(
export async function deleteSensor(id: string): Promise<boolean> { export async function deleteSensor(id: string): Promise<boolean> {
if (!isPgEnabled()) return memSensors.delete(id) if (!isPgEnabled()) return memSensors.delete(id)
await query(`DELETE FROM sensor_points WHERE sensor_id = $1`, [id]).catch(() => undefined)
const { rowCount } = await query(`DELETE FROM sensors WHERE id = $1`, [id]) const { rowCount } = await query(`DELETE FROM sensors WHERE id = $1`, [id])
return (rowCount || 0) > 0 return (rowCount || 0) > 0
} }
...@@ -574,6 +575,128 @@ export async function ensureCameraSensorSeed(): Promise<{ created: number; skipp ...@@ -574,6 +575,128 @@ export async function ensureCameraSensorSeed(): Promise<{ created: number; skipp
return { created, skipped } return { created, skipped }
} }
function isBriefingFacilityCode(code: string, category: string): boolean {
if (category === 'camera') return /^cam-\d+$/i.test(code)
if (category === 'access') return /^door-\d+$/i.test(code)
return false
}
function facilityDedupeKey(s: Sensor): string {
if (s.category === 'camera') {
const vid =
typeof s.meta?.videoDeviceId === 'string' && s.meta.videoDeviceId.trim()
? String(s.meta.videoDeviceId).trim()
: ''
return `camera:${vid || s.name}`
}
if (s.category === 'access') {
return `access:${s.name}`
}
return s.id
}
/**
* 关闭同名/同视频源的重复监控、门禁设施(保留交底表 cam-14x / door-12x,去掉旧场景打点)。
*/
export async function disableDuplicateFacilitySensors(): Promise<{ disabled: number }> {
const list = await listSensors({ enabled: true })
const groups = new Map<string, Sensor[]>()
for (const s of list) {
if (s.category !== 'camera' && s.category !== 'access') continue
const key = facilityDedupeKey(s)
const arr = groups.get(key) || []
arr.push(s)
groups.set(key, arr)
}
let disabled = 0
for (const arr of groups.values()) {
if (arr.length < 2) continue
arr.sort((a, b) => {
const ra = isBriefingFacilityCode(a.code, a.category) ? 2 : 0
const rb = isBriefingFacilityCode(b.code, b.category) ? 2 : 0
if (rb !== ra) return rb - ra
return Math.abs((a.sceneZ || 51) - 51) - Math.abs((b.sceneZ || 51) - 51)
})
const keep = arr[0]
for (const s of arr.slice(1)) {
await updateSensor(s.id, { enabled: false })
disabled += 1
console.log(
`[sensors] disable duplicate ${s.category} code=${s.code} name=${s.name} (keep ${keep.code})`,
)
}
}
return { disabled }
}
/**
* 物理删除已禁用的重复监控/门禁(非交底表 cam-14x / door-12x)。
* 须在坐标恢复之后执行。
*/
export async function purgeDisabledDuplicateFacilities(): Promise<{ deleted: number }> {
const keepCodes = new Set<string>([
...CAMERA_FACILITY_DEFS.map((d) => d.id),
...ACCESS_FACILITY_DEFS.map((d) => d.id),
])
const all = await listSensors()
const victims = all.filter(
(s) =>
!s.enabled &&
(s.category === 'camera' || s.category === 'access') &&
!keepCodes.has(s.code) &&
!isBriefingFacilityCode(s.code, s.category),
)
let deleted = 0
for (const s of victims) {
const ok = await deleteSensor(s.id)
if (ok) {
deleted += 1
console.log(`[sensors] purge duplicate ${s.category} code=${s.code} name=${s.name}`)
}
}
return { deleted }
}
/**
* 将被禁用的旧场景监控坐标恢复到当前启用的交底表监控上(现场校准点位优先)。
*/
export async function restoreCameraScenesFromDisabledDuplicates(): Promise<{ restored: number }> {
const all = await listSensors({ category: 'camera' })
const enabled = all.filter((s) => s.enabled && isBriefingFacilityCode(s.code, 'camera'))
const disabled = all.filter((s) => !s.enabled)
let restored = 0
for (const keep of enabled) {
const vid =
typeof keep.meta?.videoDeviceId === 'string' && keep.meta.videoDeviceId.trim()
? String(keep.meta.videoDeviceId).trim()
: keep.code
const srcSensor = disabled.find((d) => {
const dVid =
typeof d.meta?.videoDeviceId === 'string' && d.meta.videoDeviceId.trim()
? String(d.meta.videoDeviceId).trim()
: d.code
return dVid === vid || dVid === keep.code || d.name === keep.name
})
const def = CAMERA_FACILITY_DEFS.find((d) => d.id === keep.code)
const x = srcSensor ? srcSensor.sceneX : def?.scene.x
const y = srcSensor ? srcSensor.sceneY : def?.scene.y
const z = srcSensor ? srcSensor.sceneZ : def?.scene.z
if (x == null || y == null || z == null) continue
if (![x, y, z].every((n) => Number.isFinite(n))) continue
const same =
Math.abs(keep.sceneX - x) < 1e-12 &&
Math.abs(keep.sceneY - y) < 1e-12 &&
Math.abs(keep.sceneZ - z) < 1e-9
if (same) continue
await updateSensor(keep.id, { sceneX: x, sceneY: y, sceneZ: z })
restored += 1
console.log(
`[sensors] restore camera scene ${keep.code} <- ${srcSensor?.code || def?.id} (${x.toFixed(6)},${y.toFixed(6)},${z.toFixed(3)})`,
)
}
return { restored }
}
/** /**
* 注册门禁设施(交底表),并补齐 devices 台账 door 分类供大屏快照。 * 注册门禁设施(交底表),并补齐 devices 台账 door 分类供大屏快照。
*/ */
......
...@@ -144,7 +144,9 @@ export interface RealtimeSnapshot { ...@@ -144,7 +144,9 @@ export interface RealtimeSnapshot {
metering: { metering: {
/** 当日三块总表耗电合计 kWh(差量);无基线时为 0 */ /** 当日三块总表耗电合计 kWh(差量);无基线时为 0 */
powerKwh: number powerKwh: number
/** 本系统无水表,恒为 0(保留字段兼容) */
waterM3: number waterM3: number
/** 当日 DN40+DN80 能量计累积冷量差量合计 kWh */
coolingKwh: number coolingKwh: number
powerTrendPct: number powerTrendPct: number
waterTrendPct: number waterTrendPct: number
...@@ -158,6 +160,15 @@ export interface RealtimeSnapshot { ...@@ -158,6 +160,15 @@ export interface RealtimeSnapshot {
powerKw: number | null powerKw: number | null
todayKwh: number todayKwh: number
}[] }[]
/** DN40 / DN80 能量计(瞬时冷量 kW + 累积冷量差量) */
coolingMeters?: {
key: 'dn40' | 'dn80'
name: string
deviceId: string
energyKwh: number | null
powerKw: number | null
todayKwh: number
}[]
} }
access: { access: {
doors: DoorSnapshot[] doors: DoorSnapshot[]
......
...@@ -15,13 +15,15 @@ const props = withDefaults( ...@@ -15,13 +15,15 @@ const props = withDefaults(
src?: string src?: string
/** 绑定视频设备 id 时走 WebRTC;否则回退 src */ /** 绑定视频设备 id 时走 WebRTC;否则回退 src */
deviceId?: string | null deviceId?: string | null
/** video=监控预览;values=读数;switch=照明开/关 */ /** video=监控预览;values=读数;switch=照明开/关;door=门禁远程开门 */
kind?: 'video' | 'values' | 'switch' kind?: 'video' | 'values' | 'switch' | 'door'
lines?: { label: string; value: string }[] lines?: { label: string; value: string }[]
/** 当前开关态(switch) */ /** 当前开关态(switch) */
switchOn?: boolean | null switchOn?: boolean | null
switching?: boolean switching?: boolean
switchError?: string switchError?: string
/** 门禁当前状态文案(door) */
doorStatusText?: string
}>(), }>(),
{ {
src: '/cams/lab-seq.mp4', src: '/cams/lab-seq.mp4',
...@@ -31,22 +33,24 @@ const props = withDefaults( ...@@ -31,22 +33,24 @@ const props = withDefaults(
switchOn: null, switchOn: null,
switching: false, switching: false,
switchError: '', switchError: '',
doorStatusText: '',
}, },
) )
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
control: [on: boolean] control: [on: boolean]
doorOpen: []
}>() }>()
const TITLE_H = 36 const TITLE_H = 36
/** 监控预览加大;照明开关与读数保持紧凑 */ /** 监控预览加大;照明/门禁与读数保持紧凑 */
const boxW = computed(() => { const boxW = computed(() => {
if (props.kind === 'video') return Math.min(720, Math.max(480, window.innerWidth - 80)) if (props.kind === 'video') return Math.min(720, Math.max(480, window.innerWidth - 80))
return 400 return 400
}) })
const boxH = computed(() => { const boxH = computed(() => {
if (props.kind === 'switch') return TITLE_H + 120 if (props.kind === 'switch' || props.kind === 'door') return TITLE_H + 120
if (props.kind === 'values') { if (props.kind === 'values') {
const rows = Math.max(props.lines.length, 1) const rows = Math.max(props.lines.length, 1)
return Math.min(420, TITLE_H + rows * 36 + 16) return Math.min(420, TITLE_H + rows * 36 + 16)
...@@ -75,8 +79,8 @@ function centerPos() { ...@@ -75,8 +79,8 @@ function centerPos() {
} }
function syncFromProps() { function syncFromProps() {
// 摄像头 / 照明:固定屏幕居中;其它读数仍跟 Marker // 摄像头 / 照明 / 门禁:固定屏幕居中;其它读数仍跟 Marker
const centered = props.kind === 'video' || props.kind === 'switch' const centered = props.kind === 'video' || props.kind === 'switch' || props.kind === 'door'
const c = centered ? centerPos() : clampPos(props.left, props.top) const c = centered ? centerPos() : clampPos(props.left, props.top)
posLeft.value = c.left posLeft.value = c.left
posTop.value = c.top posTop.value = c.top
...@@ -190,7 +194,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) ...@@ -190,7 +194,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
class="near-body" class="near-body"
:class="{ :class="{
'near-body--values': kind === 'values', 'near-body--values': kind === 'values',
'near-body--switch': kind === 'switch', 'near-body--switch': kind === 'switch' || kind === 'door',
}" }"
:style="{ height: `${videoH}px` }" :style="{ height: `${videoH}px` }"
> >
...@@ -213,6 +217,23 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) ...@@ -213,6 +217,23 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<b>{{ row.value }}</b> <b>{{ row.value }}</b>
</li> </li>
</ul> </ul>
<div v-else-if="kind === 'door'" class="near-switch">
<p class="near-switch-status">
{{ t('screen.current') }}:
<b>{{ doorStatusText || t('screen.unknown') }}</b>
</p>
<div class="near-switch-actions">
<button
type="button"
class="sw-btn on"
:disabled="switching"
@click="emit('doorOpen')"
>
{{ t('pages.accessOpenRemote') }}
</button>
</div>
<p v-if="switchError" class="near-switch-err">{{ switchError }}</p>
</div>
<div v-else class="near-switch"> <div v-else class="near-switch">
<p class="near-switch-status"> <p class="near-switch-status">
{{ t('screen.current') }}: {{ t('screen.current') }}:
......
...@@ -100,8 +100,8 @@ const bridge = useRtengineBridge({ ...@@ -100,8 +100,8 @@ const bridge = useRtengineBridge({
picked.value = { ...point, icon } picked.value = { ...point, icon }
hint.value = markerId hint.value = markerId
? `已拾取(对象 ${markerId})· 效果已按设备类型预览` ? `已拾取(对象 ${markerId})· 效果已按设备类型预览`
: `已拾取 X=${point.x.toFixed(6)} Y=${point.y.toFixed(6)} Z=${point.z.toFixed(2)}` : `已拾取 ${fmtCoords(point)}`
syncPreview() syncPreview(false)
} }
}, },
onReady: () => { onReady: () => {
...@@ -111,10 +111,14 @@ const bridge = useRtengineBridge({ ...@@ -111,10 +111,14 @@ const bridge = useRtengineBridge({
bridge.setPickMode(true) bridge.setPickMode(true)
bridge.showScenePresets() bridge.showScenePresets()
void loadIcons(true) void loadIcons(true)
if (picked.value) syncPreview() showCurrentPoint(true)
}, },
}) })
function fmtCoords(p: Pick<ScenePoint, 'x' | 'y' | 'z'>) {
return `X=${p.x.toFixed(6)} Y=${p.y.toFixed(6)} Z=${p.z.toFixed(2)}`
}
function previewMarker(): RtengineMarker | null { function previewMarker(): RtengineMarker | null {
const p = picked.value const p = picked.value
if (!p) return null if (!p) return null
...@@ -140,10 +144,42 @@ function previewMarker(): RtengineMarker | null { ...@@ -140,10 +144,42 @@ function previewMarker(): RtengineMarker | null {
} }
} }
function syncPreview() { /** 预览必须 add:updateMarker 在点不存在时不会显示当前点位 */
function syncPreview(focus = false) {
const m = previewMarker() const m = previewMarker()
if (!m || !bridge.ready.value) return if (!m || !bridge.ready.value) return
bridge.updateMarker(m) try {
bridge.removeMarker(PREVIEW_ID)
} catch {
/* ignore */
}
bridge.addMarker(m)
bridge.setMarkerVisible(PREVIEW_ID, true)
if (focus) focusCurrentPoint(m)
}
function focusCurrentPoint(m: RtengineMarker) {
try {
bridge.setViewGeo(
{
x: m.x,
y: m.y,
// 略抬高观察,避免贴地看不见
z: Math.max(Number(m.z) || 51, 51) + 18,
pitch: -55,
armLength: 28,
},
{ duration: 0.8, instant: false },
)
} catch {
/* ignore */
}
}
function showCurrentPoint(focus: boolean) {
if (!picked.value) return
hint.value = `当前点位 ${fmtCoords(picked.value)} · 点击地面可重新拾取`
syncPreview(focus)
} }
function clearPreview() { function clearPreview() {
...@@ -174,8 +210,12 @@ async function loadIcons(fromEngine = false) { ...@@ -174,8 +210,12 @@ async function loadIcons(fromEngine = false) {
) )
selectedIcon.value = hit || cam || icons.value[0] selectedIcon.value = hit || cam || icons.value[0]
} }
if (fromEngine) hint.value = `已加载 ${icons.value.length} 个场景预设图标 · 点击地面拾取` if (fromEngine) {
syncPreview() hint.value = picked.value
? `当前点位 ${fmtCoords(picked.value)} · 已加载 ${icons.value.length} 个图标`
: `已加载 ${icons.value.length} 个场景预设图标 · 点击地面拾取`
}
showCurrentPoint(false)
} catch (e) { } catch (e) {
iconError.value = e instanceof Error ? e.message : '图标列表加载失败' iconError.value = e instanceof Error ? e.message : '图标列表加载失败'
} }
...@@ -189,12 +229,16 @@ watch( ...@@ -189,12 +229,16 @@ watch(
selectedIcon.value = isTextOnly.value selectedIcon.value = isTextOnly.value
? '' ? ''
: props.initial?.icon || props.defaultIcon : props.initial?.icon || props.defaultIcon
hint.value = props.pickHint hint.value = picked.value
? `当前点位 ${fmtCoords(picked.value)} · 等待场景加载…`
: props.pickHint
void loadIcons() void loadIcons()
window.setTimeout(() => { window.setTimeout(() => {
bridge.setPickMode(true) bridge.setPickMode(true)
syncPreview() showCurrentPoint(true)
}, 1500) }, 1500)
// 引擎就绪较慢时再补打一次,确保当前点位可见
window.setTimeout(() => showCurrentPoint(true), 3200)
} else { } else {
clearPreview() clearPreview()
bridge.setPickMode(false) bridge.setPickMode(false)
...@@ -206,21 +250,36 @@ watch(selectedIcon, (icon) => { ...@@ -206,21 +250,36 @@ watch(selectedIcon, (icon) => {
if (isTextOnly.value) return if (isTextOnly.value) return
if (picked.value) { if (picked.value) {
picked.value = { ...picked.value, icon } picked.value = { ...picked.value, icon }
syncPreview() syncPreview(false)
} }
}) })
watch( watch(
() => [props.previewText, props.markerType, props.showText, props.textOnly] as const, () => [props.previewText, props.markerType, props.showText, props.textOnly] as const,
() => syncPreview(), () => syncPreview(false),
)
watch(
() => props.initial,
(v) => {
if (!props.open) return
if (!v) return
// 打开过程中 initial 异步到位时,同步显示当前点位
if (!picked.value) {
picked.value = { ...v }
selectedIcon.value = isTextOnly.value ? '' : v.icon || props.defaultIcon
showCurrentPoint(true)
}
},
) )
onMounted(() => { onMounted(() => {
if (props.open) { if (props.open) {
picked.value = props.initial ? { ...props.initial } : null
void loadIcons() void loadIcons()
window.setTimeout(() => { window.setTimeout(() => {
bridge.setPickMode(true) bridge.setPickMode(true)
syncPreview() showCurrentPoint(true)
}, 1500) }, 1500)
} }
}) })
...@@ -306,10 +365,19 @@ function iconSrc(file: string) { ...@@ -306,10 +365,19 @@ function iconSrc(file: string) {
</div> </div>
<div class="foot"> <div class="foot">
<div class="coords" v-if="picked"> <div class="coords" v-if="picked">
X {{ picked.x.toFixed(6) }} · Y {{ picked.y.toFixed(6) }} · Z {{ picked.z.toFixed(2) }} 当前点位 · X {{ picked.x.toFixed(6) }} · Y {{ picked.y.toFixed(6) }} · Z
{{ picked.z.toFixed(2) }}
</div> </div>
<div class="coords muted" v-else>尚未拾取</div> <div class="coords muted" v-else>尚未拾取 · 请在场景中点击地面</div>
<div class="actions"> <div class="actions">
<button
class="btn ghost"
type="button"
:disabled="!picked"
@click="showCurrentPoint(true)"
>
定位当前
</button>
<button class="btn ghost" type="button" @click="clearPoint">清除</button> <button class="btn ghost" type="button" @click="clearPoint">清除</button>
<button class="btn ghost" type="button" @click="emit('close')">取消</button> <button class="btn ghost" type="button" @click="emit('close')">取消</button>
<button class="btn" type="button" :disabled="!picked" @click="confirm">保存点位</button> <button class="btn" type="button" :disabled="!picked" @click="confirm">保存点位</button>
......
...@@ -53,47 +53,82 @@ function sensorMarkerId(s: SensorSnapshot) { ...@@ -53,47 +53,82 @@ function sensorMarkerId(s: SensorSnapshot) {
return `${SENSOR_PREFIX}${s.id}` return `${SENSOR_PREFIX}${s.id}`
} }
function withDeviceName(name: string, body: string): string {
const title = (name || '').trim()
const rest = (body || '').trim()
if (!title) return rest
if (!rest) return title
return `${title}\n${rest}`
}
function buildText(def: ModePoiDef, snap: RealtimeSnapshot): string { function buildText(def: ModePoiDef, snap: RealtimeSnapshot): string {
const bind = def.bind const bind = def.bind
if (bind.kind === 'room') { if (bind.kind === 'room') {
const r = snap.rooms.find((x) => x.id === bind.roomId) const r = snap.rooms.find((x) => x.id === bind.roomId)
return `${fmtNum(r?.temp)}℃\n${fmtNum(r?.humidity, 0)}%\n${fmtNum(r?.pressure)}Pa` return withDeviceName(
def.label,
`${fmtNum(r?.temp)}℃\n${fmtNum(r?.humidity, 0)}%\n${fmtNum(r?.pressure)}Pa`,
)
} }
if (bind.kind === 'fridge') { if (bind.kind === 'fridge') {
const f = snap.fridges.find((x) => x.id === bind.deviceId) const f = snap.fridges.find((x) => x.id === bind.deviceId)
return `${fmtNum(f?.temp)}℃\n${fmtNum(f?.humidity, 0)}%` return withDeviceName(def.label, `${fmtNum(f?.temp)}℃\n${fmtNum(f?.humidity, 0)}%`)
} }
if (bind.kind === 'vav') { if (bind.kind === 'vav') {
const v = snap.vav.find((x) => x.id === bind.deviceId) const v = snap.vav.find((x) => x.id === bind.deviceId)
const run = v?.running ? tt('screen.running') : tt('screen.stopped') const run = v?.running ? tt('screen.running') : tt('screen.stopped')
return `${tt('screen.status')}:${run}\n${tt('screen.faceVelocity')}:${fmtNum(v?.faceVelocity)}m/s\n${tt('screen.airflow')}:${fmtNum(v?.airflow, 0)}` return withDeviceName(
def.label,
`${tt('screen.status')}:${run}\n${tt('screen.faceVelocity')}:${fmtNum(v?.faceVelocity)}m/s\n${tt('screen.airflow')}:${fmtNum(v?.airflow, 0)}`,
)
} }
if (bind.kind === 'neLight') { if (bind.kind === 'neLight') {
const lit = (snap.neLights || []).find((x) => x.id === bind.lightId) const lit = (snap.neLights || []).find((x) => x.id === bind.lightId)
const st = lit?.on ? tt('screen.switchOn') : tt('screen.switchOff') const st = lit?.on ? tt('screen.switchOn') : tt('screen.switchOff')
return `${tt('screen.status')}:${st}` return withDeviceName(def.label, `${tt('screen.status')}:${st}`)
} }
return def.label return def.label
} }
function sensorText(s: SensorSnapshot): string { function accessStatusText(s: SensorSnapshot, snap?: RealtimeSnapshot): string {
const doors = snap?.access?.doors || []
const door = doors.find(
(d) => d.id === s.doorDeviceId || d.name === s.name || d.id === s.code,
)
if (!door) return '—'
const key = `doorStatus.${door.status}`
const hit = tt(key)
return hit === key ? door.status : hit
}
function sensorText(s: SensorSnapshot, snap?: RealtimeSnapshot): string {
if (s.category === 'lightN' || s.category === 'lightE') { if (s.category === 'lightN' || s.category === 'lightE') {
return s.on == null ? '—' : s.on ? tt('screen.switchOn') : tt('screen.switchOff') const st = s.on == null ? '—' : s.on ? tt('screen.switchOn') : tt('screen.switchOff')
return withDeviceName(s.name, st)
} }
if (s.category === 'fridge') { if (s.category === 'fridge') {
const v = s.values || { temp: null, humidity: null, pressure: null } const v = s.values || { temp: null, humidity: null, pressure: null }
return `${fmtNum(v.temp)}℃\n${fmtNum(v.humidity, 0)}%` return withDeviceName(s.name, `${fmtNum(v.temp)}℃\n${fmtNum(v.humidity, 0)}%`)
} }
if (s.category === 'vav') { if (s.category === 'vav') {
const run = const run =
s.on == null ? '—' : s.on ? tt('screen.running') : tt('screen.stopped') s.on == null ? '—' : s.on ? tt('screen.running') : tt('screen.stopped')
return `${tt('screen.status')}:${run}\n${tt('screen.faceVelocity')}:${fmtNum(s.vav?.faceVelocity)}m/s\n${tt('screen.airflow')}:${fmtNum(s.vav?.airflow, 0)}` return withDeviceName(
s.name,
`${tt('screen.status')}:${run}\n${tt('screen.faceVelocity')}:${fmtNum(s.vav?.faceVelocity)}m/s\n${tt('screen.airflow')}:${fmtNum(s.vav?.airflow, 0)}`,
)
}
if (s.category === 'access') {
return withDeviceName(s.name, accessStatusText(s, snap))
} }
if (s.category === 'camera' || s.category === 'access') { if (s.category === 'camera') {
return s.name return s.name
} }
const v = s.values || { temp: null, humidity: null, pressure: null } const v = s.values || { temp: null, humidity: null, pressure: null }
return `${fmtNum(v.temp)}℃\n${fmtNum(v.humidity, 0)}%\n${fmtNum(v.pressure)}Pa` return withDeviceName(
s.name,
`${fmtNum(v.temp)}℃\n${fmtNum(v.humidity, 0)}%\n${fmtNum(v.pressure)}Pa`,
)
} }
function toPoiMarker(def: ModePoiDef, snap: RealtimeSnapshot): RtengineMarker { function toPoiMarker(def: ModePoiDef, snap: RealtimeSnapshot): RtengineMarker {
...@@ -123,25 +158,65 @@ const FACILITY_ICON_FALLBACK: Record<string, string> = { ...@@ -123,25 +158,65 @@ const FACILITY_ICON_FALLBACK: Record<string, string> = {
access: 'access-control-blue.png', access: 'access-control-blue.png',
} }
function resolveFacilityIcon(s: SensorSnapshot): string { type FacilityTone = 'ok' | 'alarm' | 'offline'
function accessDoorStatus(
s: SensorSnapshot,
snap?: RealtimeSnapshot,
): 'open' | 'closed' | 'alarm' | 'offline' | null {
const doors = snap?.access?.doors || []
const door = doors.find(
(d) => d.id === s.doorDeviceId || d.name === s.name || d.id === s.code,
)
return door?.status ?? null
}
/** 门禁/摄像:报警红、离线灰、正常蓝 */
function facilityTone(s: SensorSnapshot, snap?: RealtimeSnapshot): FacilityTone {
if (s.category === 'access') {
const st = accessDoorStatus(s, snap)
if (st === 'alarm') return 'alarm'
if (st === 'offline') return 'offline'
return 'ok'
}
if (s.category === 'camera') {
if (s.alarms?.length) return 'alarm'
if (s.on === false) return 'offline'
return 'ok'
}
return 'ok'
}
function resolveFacilityIcon(s: SensorSnapshot, snap?: RealtimeSnapshot): string {
if (s.category === 'access') {
const tone = facilityTone(s, snap)
if (tone === 'alarm') return 'access-control-red.png'
if (tone === 'offline') return 'access-control-gray.png'
return 'access-control-blue.png'
}
if (s.category === 'camera') {
const tone = facilityTone(s, snap)
if (tone === 'alarm') return 'cctv-red.png'
if (tone === 'offline') return 'cctv-gray.png'
return 'cctv-blue.png'
}
const fallback = FACILITY_ICON_FALLBACK[s.category] || MODE_MARKER_ICON.pressure const fallback = FACILITY_ICON_FALLBACK[s.category] || MODE_MARKER_ICON.pressure
const raw = (s.icon || '').trim() const raw = (s.icon || '').trim()
if (!raw) return fallback if (!raw) return fallback
if (s.category === 'camera' && raw === 'cctv.png') return 'cctv-blue.png'
return raw return raw
} }
function toSensorMarker(s: SensorSnapshot): RtengineMarker { function toSensorMarker(s: SensorSnapshot, snap?: RealtimeSnapshot): RtengineMarker {
const tag0 = const tag0 =
s.category in DEVICE_TAG s.category in DEVICE_TAG
? DEVICE_TAG[s.category as keyof typeof DEVICE_TAG] ? DEVICE_TAG[s.category as keyof typeof DEVICE_TAG]
: s.category || 'pressure' : s.category || 'pressure'
const isCamOrDoor = s.category === 'camera' || s.category === 'access' // 全部设备图标显示文案:监控仅名称,其它为「名称 + 参数」
return { return {
id: sensorMarkerId(s), id: sensorMarkerId(s),
name: s.name, name: s.name,
text: sensorText(s), text: sensorText(s, snap),
showText: !isCamOrDoor, showText: true,
type: 'marker', type: 'marker',
x: s.scene.x, x: s.scene.x,
y: s.scene.y, y: s.scene.y,
...@@ -152,7 +227,7 @@ function toSensorMarker(s: SensorSnapshot): RtengineMarker { ...@@ -152,7 +227,7 @@ function toSensorMarker(s: SensorSnapshot): RtengineMarker {
scaleX: MODE_MARKER_SCALE_X, scaleX: MODE_MARKER_SCALE_X,
scaleY: MODE_MARKER_SCALE_Y, scaleY: MODE_MARKER_SCALE_Y,
scaleZ: MODE_MARKER_SCALE_Z, scaleZ: MODE_MARKER_SCALE_Z,
icon: s.category === 'pressure' ? MODE_MARKER_ICON.pressure : resolveFacilityIcon(s), icon: s.category === 'pressure' ? MODE_MARKER_ICON.pressure : resolveFacilityIcon(s, snap),
...MARKER_LEADER, ...MARKER_LEADER,
} }
} }
...@@ -165,16 +240,17 @@ function sensorsOf( ...@@ -165,16 +240,17 @@ function sensorsOf(
} }
function markersForMode(mode: ScreenMode, snap: RealtimeSnapshot): RtengineMarker[] { function markersForMode(mode: ScreenMode, snap: RealtimeSnapshot): RtengineMarker[] {
if (mode === 'pressure') return sensorsOf(snap, 'pressure').map(toSensorMarker) const mapSensor = (s: SensorSnapshot) => toSensorMarker(s, snap)
if (mode === 'lightN') return sensorsOf(snap, 'lightN').map(toSensorMarker) if (mode === 'pressure') return sensorsOf(snap, 'pressure').map(mapSensor)
if (mode === 'lightE') return sensorsOf(snap, 'lightE').map(toSensorMarker) if (mode === 'lightN') return sensorsOf(snap, 'lightN').map(mapSensor)
if (mode === 'fridge') return sensorsOf(snap, 'fridge').map(toSensorMarker) if (mode === 'lightE') return sensorsOf(snap, 'lightE').map(mapSensor)
if (mode === 'vav') return sensorsOf(snap, 'vav').map(toSensorMarker) if (mode === 'fridge') return sensorsOf(snap, 'fridge').map(mapSensor)
if (mode === 'camera') return sensorsOf(snap, 'camera').map(toSensorMarker) if (mode === 'vav') return sensorsOf(snap, 'vav').map(mapSensor)
if (mode === 'access') return sensorsOf(snap, 'access').map(toSensorMarker) if (mode === 'camera') return sensorsOf(snap, 'camera').map(mapSensor)
if (mode === 'access') return sensorsOf(snap, 'access').map(mapSensor)
if (mode === 'home') { if (mode === 'home') {
return sensorsOf(snap, 'pressure', 'lightN', 'lightE', 'fridge', 'vav', 'access', 'camera').map( return sensorsOf(snap, 'pressure', 'lightN', 'lightE', 'fridge', 'vav', 'access', 'camera').map(
toSensorMarker, mapSensor,
) )
} }
return poisForMode(mode).map((d) => toPoiMarker(d, snap)) return poisForMode(mode).map((d) => toPoiMarker(d, snap))
...@@ -254,6 +330,9 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl ...@@ -254,6 +330,9 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
function paint(markers: RtengineMarker[], replace = false, readd = false) { function paint(markers: RtengineMarker[], replace = false, readd = false) {
const listed = bridge.listMarkers?.() || [] const listed = bridge.listMarkers?.() || []
const engineIds = new Set(listed.map((x) => x.id)) const engineIds = new Set(listed.map((x) => x.id))
const listedIcon = new Map(
listed.map((x) => [x.id, String(x.icon || '').split(/[/\\]/).pop() || '']),
)
// 跨域 iframe 时 listMarkers 恒为空;若仍信 activeIds,飞镜清点后永远不会再 add // 跨域 iframe 时 listMarkers 恒为空;若仍信 activeIds,飞镜清点后永远不会再 add
const blind = listed.length === 0 const blind = listed.length === 0
for (const m of markers) { for (const m of markers) {
...@@ -270,7 +349,12 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl ...@@ -270,7 +349,12 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
const aliveInEngine = engineIds.has(m.id) const aliveInEngine = engineIds.has(m.id)
const assumeAlive = !blind && !readd && (aliveInEngine || activeIds.includes(m.id)) const assumeAlive = !blind && !readd && (aliveInEngine || activeIds.includes(m.id))
if (assumeAlive) { if (assumeAlive) {
if (m.text != null && bridge.updateMarkerText) { const wantIcon = String(m.icon || '').split(/[/\\]/).pop() || ''
const curIcon = listedIcon.get(m.id) || ''
// 报警/离线切换图标时必须 updateMarker;纯数值仍只改文字避免闪
if (wantIcon && wantIcon !== curIcon) {
bridge.updateMarker(m)
} else if (m.text != null && bridge.updateMarkerText) {
bridge.updateMarkerText(m.id, String(m.text)) bridge.updateMarkerText(m.id, String(m.text))
} }
bridge.setMarkerVisible(m.id, true) bridge.setMarkerVisible(m.id, true)
......
...@@ -31,6 +31,8 @@ export default { ...@@ -31,6 +31,8 @@ export default {
camPitchDown: 'Pitch down', camPitchDown: 'Pitch down',
loginToControl: 'Please sign in as admin before controlling', loginToControl: 'Please sign in as admin before controlling',
lightNotBound: 'Lighting facility not found; check facility bindings', lightNotBound: 'Lighting facility not found; check facility bindings',
doorNotBound: 'Access door not found; check facility bindings',
doorControl: 'Access control',
controlFailed: 'Control failed', controlFailed: 'Control failed',
switchOn: 'On', switchOn: 'On',
switchOff: 'Off', switchOff: 'Off',
...@@ -45,6 +47,12 @@ export default { ...@@ -45,6 +47,12 @@ export default {
unknown: 'Unknown', unknown: 'Unknown',
camPreview: 'Camera preview', camPreview: 'Camera preview',
lightControl: 'Lighting control', lightControl: 'Lighting control',
lightsAllOn: 'All lights on',
lightsAllOff: 'All lights off',
lightsAllOnOk: 'Turned on {n} circuits',
lightsAllOffOk: 'Turned off {n} circuits',
doorsAllOpen: 'Unlock all',
doorsAllOpenOk: 'Remote-unlocked {n} doors',
loadFailed: 'Failed to load dashboard data', loadFailed: 'Failed to load dashboard data',
modeRtengine: 'RTEngineHD cloud render', modeRtengine: 'RTEngineHD cloud render',
modeUeStream: 'UE streaming mode', modeUeStream: 'UE streaming mode',
...@@ -69,8 +77,8 @@ export default { ...@@ -69,8 +77,8 @@ export default {
room: 'Rooms', room: 'Rooms',
fridge: 'Cold chain', fridge: 'Cold chain',
meter_power: 'Power meters', meter_power: 'Power meters',
meter_water: 'Water meters', meter_water: 'Flow meters',
meter_cooling: 'Cooling meters', meter_cooling: 'Energy meters',
light: 'Lighting', light: 'Lighting',
door: 'Access', door: 'Access',
camera: 'Cameras', camera: 'Cameras',
...@@ -114,6 +122,8 @@ export default { ...@@ -114,6 +122,8 @@ export default {
noFridge: 'No fridge devices', noFridge: 'No fridge devices',
fridgeWatch: '{n} to watch', fridgeWatch: '{n} to watch',
deviceStatus: 'Device status', deviceStatus: 'Device status',
metersTitle: 'Meters',
energyCurveTitle: 'Energy curve · 24h',
alarmCount: '{n} alarms', alarmCount: '{n} alarms',
runOk: 'Running OK', runOk: 'Running OK',
noDevice: 'No devices', noDevice: 'No devices',
...@@ -128,9 +138,9 @@ export default { ...@@ -128,9 +138,9 @@ export default {
interlockOk: 'Interlock OK', interlockOk: 'Interlock OK',
pressureWatch: 'Pressure watch', pressureWatch: 'Pressure watch',
pressureEmpty: 'Pressure history accumulating', pressureEmpty: 'Pressure history accumulating',
energyTitle: 'Power meters · live', energyTitle: 'Power / cooling · live',
timeseries: 'Timeseries', timeseries: 'Timeseries',
powerMetersBadge: '3 meters', powerMetersBadge: '5 meters',
powerTotal: 'Today total', powerTotal: 'Today total',
todayShort: 'Today', todayShort: 'Today',
meterReading: 'Register', meterReading: 'Register',
......
...@@ -31,6 +31,8 @@ export default { ...@@ -31,6 +31,8 @@ export default {
camPitchDown: 'Inclinar baixo', camPitchDown: 'Inclinar baixo',
loginToControl: 'Inicie sessão como administrador antes de controlar', loginToControl: 'Inicie sessão como administrador antes de controlar',
lightNotBound: 'Iluminação não encontrada; verifique as ligações', lightNotBound: 'Iluminação não encontrada; verifique as ligações',
doorNotBound: 'Porta de acesso não encontrada; verifique as ligações',
doorControl: 'Controlo de acesso',
controlFailed: 'Falha no controlo', controlFailed: 'Falha no controlo',
switchOn: 'Ligado', switchOn: 'Ligado',
switchOff: 'Desligado', switchOff: 'Desligado',
...@@ -45,6 +47,12 @@ export default { ...@@ -45,6 +47,12 @@ export default {
unknown: 'Desconhecido', unknown: 'Desconhecido',
camPreview: 'Pré-visualização de câmara', camPreview: 'Pré-visualização de câmara',
lightControl: 'Controlo de iluminação', lightControl: 'Controlo de iluminação',
lightsAllOn: 'Ligar todas',
lightsAllOff: 'Desligar todas',
lightsAllOnOk: 'Ligadas {n} circuitos',
lightsAllOffOk: 'Desligadas {n} circuitos',
doorsAllOpen: 'Abrir todas',
doorsAllOpenOk: 'Abriram-se {n} portas à distância',
loadFailed: 'Falha ao carregar o painel', loadFailed: 'Falha ao carregar o painel',
modeRtengine: 'Renderização RTEngineHD', modeRtengine: 'Renderização RTEngineHD',
modeUeStream: 'Modo de streaming UE', modeUeStream: 'Modo de streaming UE',
...@@ -69,8 +77,8 @@ export default { ...@@ -69,8 +77,8 @@ export default {
room: 'Salas', room: 'Salas',
fridge: 'Cadeia de frio', fridge: 'Cadeia de frio',
meter_power: 'Contadores elétricos', meter_power: 'Contadores elétricos',
meter_water: 'Contadores de água', meter_water: 'Medidores de caudal',
meter_cooling: 'Contadores de frio', meter_cooling: 'Medidores de energia',
light: 'Iluminação', light: 'Iluminação',
door: 'Acesso', door: 'Acesso',
camera: 'Câmaras', camera: 'Câmaras',
...@@ -114,6 +122,8 @@ export default { ...@@ -114,6 +122,8 @@ export default {
noFridge: 'Sem frigoríficos', noFridge: 'Sem frigoríficos',
fridgeWatch: '{n} a vigiar', fridgeWatch: '{n} a vigiar',
deviceStatus: 'Estado dos equipamentos', deviceStatus: 'Estado dos equipamentos',
metersTitle: 'Medidores',
energyCurveTitle: 'Curva de energia · 24h',
alarmCount: '{n} alarmes', alarmCount: '{n} alarmes',
runOk: 'A funcionar', runOk: 'A funcionar',
noDevice: 'Sem equipamentos', noDevice: 'Sem equipamentos',
...@@ -128,9 +138,9 @@ export default { ...@@ -128,9 +138,9 @@ export default {
interlockOk: 'Interbloqueio OK', interlockOk: 'Interbloqueio OK',
pressureWatch: 'Pressão a vigiar', pressureWatch: 'Pressão a vigiar',
pressureEmpty: 'Histórico de pressão a acumular', pressureEmpty: 'Histórico de pressão a acumular',
energyTitle: 'Medidores · tempo real', energyTitle: 'Energia / frio · tempo real',
timeseries: 'Séries', timeseries: 'Séries',
powerMetersBadge: '3 medidores', powerMetersBadge: '5 medidores',
powerTotal: 'Total do dia', powerTotal: 'Total do dia',
todayShort: 'Hoje', todayShort: 'Hoje',
meterReading: 'Registo', meterReading: 'Registo',
......
...@@ -31,6 +31,8 @@ export default { ...@@ -31,6 +31,8 @@ export default {
camPitchDown: '下俯', camPitchDown: '下俯',
loginToControl: '请先登录管理员账号后再控制', loginToControl: '请先登录管理员账号后再控制',
lightNotBound: '未找到对应照明设施,请检查设施管理绑定', lightNotBound: '未找到对应照明设施,请检查设施管理绑定',
doorNotBound: '未找到对应门禁,请检查设施绑定',
doorControl: '门禁控制',
controlFailed: '控制失败', controlFailed: '控制失败',
switchOn: '开', switchOn: '开',
switchOff: '关', switchOff: '关',
...@@ -45,6 +47,12 @@ export default { ...@@ -45,6 +47,12 @@ export default {
unknown: '未知', unknown: '未知',
camPreview: '监控预览', camPreview: '监控预览',
lightControl: '照明控制', lightControl: '照明控制',
lightsAllOn: '一键开灯',
lightsAllOff: '一键关灯',
lightsAllOnOk: '已开启 {n} 路照明',
lightsAllOffOk: '已关闭 {n} 路照明',
doorsAllOpen: '一键开门',
doorsAllOpenOk: '已远程开启 {n} 扇门',
loadFailed: '大屏数据加载失败', loadFailed: '大屏数据加载失败',
modeRtengine: 'RTEngineHD 云渲染', modeRtengine: 'RTEngineHD 云渲染',
modeUeStream: 'UE 推流模式', modeUeStream: 'UE 推流模式',
...@@ -69,8 +77,8 @@ export default { ...@@ -69,8 +77,8 @@ export default {
room: '房间环境', room: '房间环境',
fridge: '冷链冰箱', fridge: '冷链冰箱',
meter_power: '电表', meter_power: '电表',
meter_water: '水表', meter_water: '流量计',
meter_cooling: '冷量表', meter_cooling: '能量计',
light: '照明', light: '照明',
door: '门禁', door: '门禁',
camera: '摄像', camera: '摄像',
...@@ -114,6 +122,8 @@ export default { ...@@ -114,6 +122,8 @@ export default {
noFridge: '无冰箱设备', noFridge: '无冰箱设备',
fridgeWatch: '{n}台关注', fridgeWatch: '{n}台关注',
deviceStatus: '设备状态', deviceStatus: '设备状态',
metersTitle: '电表 / 能量计',
energyCurveTitle: '能耗曲线 · 24h',
alarmCount: '{n}项告警', alarmCount: '{n}项告警',
runOk: '运行正常', runOk: '运行正常',
noDevice: '无设备', noDevice: '无设备',
...@@ -128,9 +138,9 @@ export default { ...@@ -128,9 +138,9 @@ export default {
interlockOk: '联锁正常', interlockOk: '联锁正常',
pressureWatch: '压差关注', pressureWatch: '压差关注',
pressureEmpty: '压差历史积累中', pressureEmpty: '压差历史积累中',
energyTitle: '电能统计 · 实时', energyTitle: '电量 / 冷量 · 实时',
timeseries: '时序', timeseries: '时序',
powerMetersBadge: '三表', powerMetersBadge: '5 路',
powerTotal: '当日总用电', powerTotal: '当日总用电',
todayShort: '今日', todayShort: '今日',
meterReading: '表底', meterReading: '表底',
......
...@@ -31,6 +31,8 @@ export default { ...@@ -31,6 +31,8 @@ export default {
camPitchDown: '下俯', camPitchDown: '下俯',
loginToControl: '請先登入管理員帳號後再控制', loginToControl: '請先登入管理員帳號後再控制',
lightNotBound: '未找到對應照明設施,請檢查設施管理綁定', lightNotBound: '未找到對應照明設施,請檢查設施管理綁定',
doorNotBound: '未找到對應門禁,請檢查設施綁定',
doorControl: '門禁控制',
controlFailed: '控制失敗', controlFailed: '控制失敗',
switchOn: '開', switchOn: '開',
switchOff: '關', switchOff: '關',
...@@ -45,6 +47,12 @@ export default { ...@@ -45,6 +47,12 @@ export default {
unknown: '未知', unknown: '未知',
camPreview: '監控預覽', camPreview: '監控預覽',
lightControl: '照明控制', lightControl: '照明控制',
lightsAllOn: '一鍵開燈',
lightsAllOff: '一鍵關燈',
lightsAllOnOk: '已開啟 {n} 路照明',
lightsAllOffOk: '已關閉 {n} 路照明',
doorsAllOpen: '一鍵開門',
doorsAllOpenOk: '已遠端開啟 {n} 扇門',
loadFailed: '大屏資料載入失敗', loadFailed: '大屏資料載入失敗',
modeRtengine: 'RTEngineHD 雲渲染', modeRtengine: 'RTEngineHD 雲渲染',
modeUeStream: 'UE 推流模式', modeUeStream: 'UE 推流模式',
...@@ -69,8 +77,8 @@ export default { ...@@ -69,8 +77,8 @@ export default {
room: '房間環境', room: '房間環境',
fridge: '冷鏈冰箱', fridge: '冷鏈冰箱',
meter_power: '電表', meter_power: '電表',
meter_water: '水表', meter_water: '流量計',
meter_cooling: '冷量表', meter_cooling: '能量計',
light: '照明', light: '照明',
door: '門禁', door: '門禁',
camera: '攝像', camera: '攝像',
...@@ -114,6 +122,8 @@ export default { ...@@ -114,6 +122,8 @@ export default {
noFridge: '無冰箱設備', noFridge: '無冰箱設備',
fridgeWatch: '{n}台關注', fridgeWatch: '{n}台關注',
deviceStatus: '設備狀態', deviceStatus: '設備狀態',
metersTitle: '電表 / 能量計',
energyCurveTitle: '能耗曲線 · 24h',
alarmCount: '{n}項告警', alarmCount: '{n}項告警',
runOk: '運行正常', runOk: '運行正常',
noDevice: '無設備', noDevice: '無設備',
...@@ -128,9 +138,9 @@ export default { ...@@ -128,9 +138,9 @@ export default {
interlockOk: '聯鎖正常', interlockOk: '聯鎖正常',
pressureWatch: '壓差關注', pressureWatch: '壓差關注',
pressureEmpty: '壓差歷史累積中', pressureEmpty: '壓差歷史累積中',
energyTitle: '電能統計 · 即時', energyTitle: '電量 / 冷量 · 即時',
timeseries: '時序', timeseries: '時序',
powerMetersBadge: '三表', powerMetersBadge: '5 路',
powerTotal: '當日總用電', powerTotal: '當日總用電',
todayShort: '今日', todayShort: '今日',
meterReading: '表底', meterReading: '表底',
......
...@@ -28,6 +28,7 @@ const empty: RealtimeSnapshot = { ...@@ -28,6 +28,7 @@ const empty: RealtimeSnapshot = {
waterTrendPct: 0, waterTrendPct: 0,
coolingTrendPct: 0, coolingTrendPct: 0,
powerMeters: [], powerMeters: [],
coolingMeters: [],
}, },
access: { doors: [], todayPassCount: 0, recentEvents: [] }, access: { doors: [], todayPassCount: 0, recentEvents: [] },
online: { devicesOnline: 0, devicesTotal: 0, gatewayOk: false }, online: { devicesOnline: 0, devicesTotal: 0, gatewayOk: false },
......
...@@ -137,6 +137,14 @@ export interface RealtimeSnapshot { ...@@ -137,6 +137,14 @@ export interface RealtimeSnapshot {
powerKw: number | null powerKw: number | null
todayKwh: number todayKwh: number
}[] }[]
coolingMeters?: {
key: 'dn40' | 'dn80'
name: string
deviceId: string
energyKwh: number | null
powerKw: number | null
todayKwh: number
}[]
} }
access: { access: {
doors: DoorSnapshot[] doors: DoorSnapshot[]
......
...@@ -102,3 +102,33 @@ export interface PowerEnergyStats { ...@@ -102,3 +102,33 @@ export interface PowerEnergyStats {
nPower: number nPower: number
}[] }[]
} }
/** DN40 / DN80 能量计当天·当月制冷量 */
export interface CoolingEnergyMeterUsage {
key: 'dn40' | 'dn80'
name: string
deviceId: string
usageKwh: number
energyKwh: number | null
powerKw: number | null
}
export interface CoolingEnergyStats {
date: string
month: string
today: {
totalKwh: number
meters: CoolingEnergyMeterUsage[]
}
monthPeriod: {
totalKwh: number
meters: CoolingEnergyMeterUsage[]
}
trend30d: {
date: string
label: string
totalKwh: number
dn40: number
dn80: number
}[]
}
...@@ -9,7 +9,6 @@ import { useScreenDashboard } from '@/composables/useScreenDashboard' ...@@ -9,7 +9,6 @@ import { useScreenDashboard } from '@/composables/useScreenDashboard'
import { useRealtimeStore } from '@/stores/realtime' import { useRealtimeStore } from '@/stores/realtime'
import { useScreenUiStore } from '@/stores/screenUi' import { useScreenUiStore } from '@/stores/screenUi'
import { multiLineOption, toHistorySeries } from '@/utils/historyChart' import { multiLineOption, toHistorySeries } from '@/utils/historyChart'
import { pieTooltip } from '@/utils/chartTooltip'
const { t, te } = useI18n() const { t, te } = useI18n()
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
...@@ -68,26 +67,9 @@ const kpiItems = computed<KpiFlipItem[]>(() => { ...@@ -68,26 +67,9 @@ const kpiItems = computed<KpiFlipItem[]>(() => {
] ]
}) })
/** 右下角电能:三块电表实时读数 */ /** 左下角:上排三电表,下排两能量计(居中) */
const powerMeters = computed(() => snap.value.metering?.powerMeters || []) const powerMeters = computed(() => snap.value.metering?.powerMeters || [])
const coolingMeters = computed(() => snap.value.metering?.coolingMeters || [])
const chartText = '#9ec8dc'
function statusLabel(key: string | undefined, fallback: string) {
if (key && te(`status.${key}`)) return t(`status.${key}`)
const map: Record<string, string> = {
在线: t('status.online'),
告警: t('status.alarm'),
离线: t('status.offline'),
未知: t('status.unknown'),
}
return map[fallback] || fallback
}
function categoryLabel(category: string, fallback: string) {
if (te(`deviceCat.${category}`)) return t(`deviceCat.${category}`)
return fallback
}
function pauLabel(key: string | undefined, fallback: string) { function pauLabel(key: string | undefined, fallback: string) {
if (key && te(`pau.${key}`)) return t(`pau.${key}`) if (key && te(`pau.${key}`)) return t(`pau.${key}`)
...@@ -124,64 +106,6 @@ const coldBadge = computed(() => { ...@@ -124,64 +106,6 @@ const coldBadge = computed(() => {
return { text: t('overview.allNormal'), amber: false } return { text: t('overview.allNormal'), amber: false }
}) })
const statusPieOption = computed<ECOption>(() => {
const pie = data.value?.statusPie || []
const colors: Record<string, string> = {
online: '#52c41a',
alarm: '#faad14',
offline: '#ff4d4f',
unknown: '#5a7a90',
在线: '#52c41a',
告警: '#faad14',
离线: '#ff4d4f',
未知: '#5a7a90',
}
return {
backgroundColor: 'transparent',
animation: false,
animationDurationUpdate: 0,
tooltip: pieTooltip(),
series: [
{
type: 'pie',
radius: ['42%', '68%'],
center: ['50%', '52%'],
animation: false,
label: { color: chartText, fontSize: 10, formatter: '{b}\n{c}' },
data: pie.map((p) => ({
name: statusLabel(p.key, p.name),
value: p.value,
itemStyle: { color: colors[p.key || ''] || colors[p.name] || '#37c8ff' },
})),
},
],
}
})
const categoryPieOption = computed<ECOption>(() => {
const pie = data.value?.categoryPie || []
return {
backgroundColor: 'transparent',
animation: false,
animationDurationUpdate: 0,
tooltip: pieTooltip(),
series: [
{
type: 'pie',
radius: ['36%', '62%'],
center: ['50%', '52%'],
roseType: 'radius',
animation: false,
label: { color: chartText, fontSize: 9, formatter: '{b}' },
data: pie.map((p) => ({
name: categoryLabel(p.category, p.name),
value: p.value,
})),
},
],
}
})
const pauCards = computed(() => const pauCards = computed(() =>
(data.value?.pauCards || []).map((c) => ({ (data.value?.pauCards || []).map((c) => ({
...c, ...c,
...@@ -221,7 +145,6 @@ const energyHistoryOption = computed<ECOption>(() => { ...@@ -221,7 +145,6 @@ const energyHistoryOption = computed<ECOption>(() => {
const c = curves.value const c = curves.value
const merged = [ const merged = [
...(c?.meteringPower || []).map((s) => ({ ...s, name: t('overview.seriesPower') })), ...(c?.meteringPower || []).map((s) => ({ ...s, name: t('overview.seriesPower') })),
...(c?.meteringWater || []).map((s) => ({ ...s, name: t('overview.seriesWater') })),
...(c?.meteringCooling || []).map((s) => ({ ...s, name: t('overview.seriesCooling') })), ...(c?.meteringCooling || []).map((s) => ({ ...s, name: t('overview.seriesCooling') })),
] ]
return multiLineOption(merged, { maxSeries: 6 }) return multiLineOption(merged, { maxSeries: 6 })
...@@ -268,24 +191,44 @@ const energyDigits = computed(() => digits.value) ...@@ -268,24 +191,44 @@ const energyDigits = computed(() => digits.value)
<EChart :option="coldLineOption" :empty-text="t('overview.coldEmpty')" /> <EChart :option="coldLineOption" :empty-text="t('overview.coldEmpty')" />
</PanelBox> </PanelBox>
<PanelBox :title="t('overview.deviceStatus')"> <PanelBox :title="t('overview.metersTitle')">
<template #extra> <template #extra>
<span class="badge" :class="{ amber: (digits?.alarmOpen ?? 0) > 0 }"> <span class="badge">{{ t('overview.powerMetersBadge') }}</span>
{{
digits?.alarmOpen
? t('overview.alarmCount', { n: digits.alarmOpen })
: t('overview.runOk')
}}
</span>
</template> </template>
<div class="panel-split panel-split--pies"> <div class="meter-list">
<div class="pie-col"> <div class="meter-row meter-row--3">
<EChart :option="statusPieOption" :empty-text="t('overview.noDevice')" /> <div v-for="m in powerMeters" :key="m.key" class="ed">
<div class="pie-cap">{{ t('overview.onlineStatus') }}</div> <span>{{ m.name }}</span>
<b>
<template v-if="m.powerKw != null">{{ m.powerKw.toFixed(2) }} <small>kW</small></template>
<template v-else>—</template>
</b>
<em>{{ t('overview.todayShort') }} {{ m.todayKwh.toFixed(1) }} kWh</em>
</div>
<template v-if="!powerMeters.length">
<div class="ed">
<span>{{ t('overview.powerTotal') }}</span>
<b>{{ energyDigits ? energyDigits.powerKwh.toFixed(1) : '—' }} <small>kWh</small></b>
<em>—</em>
</div>
</template>
</div> </div>
<div class="pie-col"> <div class="meter-row meter-row--2">
<EChart :option="categoryPieOption" :empty-text="t('overview.noCategory')" /> <div v-for="m in coolingMeters" :key="m.key" class="ed ed-cool">
<div class="pie-cap">{{ t('overview.deviceCats') }}</div> <span>{{ m.name }}</span>
<b>
<template v-if="m.powerKw != null">{{ m.powerKw.toFixed(2) }} <small>kW</small></template>
<template v-else>—</template>
</b>
<em>{{ t('overview.todayShort') }} {{ m.todayKwh.toFixed(0) }} kWh</em>
</div>
<template v-if="!coolingMeters.length">
<div class="ed ed-cool">
<span>{{ t('overview.cooling') }}</span>
<b>{{ energyDigits ? energyDigits.coolingKwh.toFixed(0) : '—' }} <small>kWh</small></b>
<em>—</em>
</div>
</template>
</div> </div>
</div> </div>
</PanelBox> </PanelBox>
...@@ -341,48 +284,15 @@ const energyDigits = computed(() => digits.value) ...@@ -341,48 +284,15 @@ const energyDigits = computed(() => digits.value)
<EChart :option="bioHistoryOption" :empty-text="t('overview.pressureEmpty')" /> <EChart :option="bioHistoryOption" :empty-text="t('overview.pressureEmpty')" />
</PanelBox> </PanelBox>
<PanelBox :title="t('overview.energyTitle')"> <PanelBox :title="t('overview.energyCurveTitle')">
<template #extra> <template #extra>
<span class="badge">{{ t('overview.powerMetersBadge') }}</span> <span class="badge">{{ t('overview.timeseries') }}</span>
</template> </template>
<div class="panel-stack"> <EChart
<div class="energy-digits energy-digits--meters"> class="chart-main"
<div :option="energyHistoryOption"
v-for="m in powerMeters" :empty-text="t('overview.energyEmpty')"
:key="m.key" />
class="ed"
>
<span>{{ m.name }}</span>
<b>
<template v-if="m.powerKw != null">{{ m.powerKw.toFixed(2) }} <small>kW</small></template>
<template v-else>—</template>
</b>
<em>{{ t('overview.todayShort') }} {{ m.todayKwh.toFixed(1) }} kWh</em>
</div>
<template v-if="!powerMeters.length">
<div class="ed">
<span>{{ t('overview.powerTotal') }}</span>
<b>
{{ energyDigits ? energyDigits.powerKwh.toFixed(1) : '—' }}
<small>kWh</small>
</b>
</div>
<div class="ed">
<span>{{ t('overview.water') }}</span>
<b>{{ energyDigits ? energyDigits.waterM3.toFixed(1) : '—' }} <small>m³</small></b>
</div>
<div class="ed">
<span>{{ t('overview.cooling') }}</span>
<b>{{ energyDigits ? energyDigits.coolingKwh.toFixed(0) : '—' }} <small>kWh</small></b>
</div>
</template>
</div>
<EChart
class="chart-main"
:option="energyHistoryOption"
:empty-text="t('overview.energyEmpty')"
/>
</div>
</PanelBox> </PanelBox>
</div> </div>
...@@ -529,21 +439,41 @@ const energyDigits = computed(() => digits.value) ...@@ -529,21 +439,41 @@ const energyDigits = computed(() => digits.value)
color: #52c41a; color: #52c41a;
font-weight: 500; font-weight: 500;
} }
.energy-digits { .meter-list {
flex: 1;
width: 100%;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
}
.meter-row {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px; gap: 6px;
flex-shrink: 0; min-height: 0;
} }
.energy-digits--meters { .meter-row--3 {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.meter-row--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 68%;
margin-inline: auto;
}
.ed { .ed {
padding: 6px 8px; padding: 6px 8px;
border-radius: 4px; border-radius: 4px;
background: rgba(6, 28, 56, 0.45); background: rgba(6, 28, 56, 0.45);
border: 1px solid rgba(0, 247, 255, 0.14); border: 1px solid rgba(0, 247, 255, 0.14);
min-width: 0; min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
}
.ed-cool {
border-color: rgba(103, 232, 249, 0.28);
} }
.ed span { .ed span {
display: block; display: block;
...@@ -555,11 +485,11 @@ const energyDigits = computed(() => digits.value) ...@@ -555,11 +485,11 @@ const energyDigits = computed(() => digits.value)
} }
.ed b { .ed b {
display: block; display: block;
margin-top: 2px;
font-size: 15px; font-size: 15px;
font-family: var(--font-num); font-family: var(--font-num);
color: #fff; color: #fff;
white-space: nowrap; white-space: nowrap;
line-height: 1.2;
} }
.ed small { .ed small {
font-size: 10px; font-size: 10px;
...@@ -568,7 +498,6 @@ const energyDigits = computed(() => digits.value) ...@@ -568,7 +498,6 @@ const energyDigits = computed(() => digits.value)
} }
.ed em { .ed em {
display: block; display: block;
margin-top: 2px;
font-style: normal; font-style: normal;
font-size: 10px; font-size: 10px;
color: rgba(158, 210, 232, 0.78); color: rgba(158, 210, 232, 0.78);
......
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