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())
......
/**
* 实验室两块冷冻水能量计:PAU201 DN40 / DN80
* - 瞬时冷量 kW(协议点「瞬时热量」)
* - 累积冷量 kWh(协议点「正累积热量」)→ 日差量统计
* 落库复用 energy_daily_meter(meter_key = dn40 | dn80)
*/
import type { Device, DevicePoint } from '../devices/types.js'
import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js'
import {
applyMonotonicReading,
memDayKey,
normalizeDailyRow,
type DailyMeterRow,
} from './dailyCumulative.js'
import { macauDateStr, macauMonthStr } from './powerMeters.js'
export type LabCoolingMeterKey = 'dn40' | 'dn80'
export type LabCoolingMeterReading = {
key: LabCoolingMeterKey
name: string
deviceId: string
/** 累积冷量 kWh(表计读数) */
energyKwh: number | null
/** 瞬时冷量 kW */
powerKw: number | null
}
export type LabCoolingMeterUsage = {
key: LabCoolingMeterKey
name: string
deviceId: string
usageKwh: number
energyKwh: number | null
powerKw: number | null
}
export type CoolingEnergyPeriod = {
totalKwh: number
meters: LabCoolingMeterUsage[]
}
export type CoolingEnergyStats = {
date: string
month: string
today: CoolingEnergyPeriod
monthPeriod: CoolingEnergyPeriod
trend30d: { date: string; label: string; totalKwh: number; dn40: number; dn80: number }[]
}
export const LAB_COOLING_METERS: {
key: LabCoolingMeterKey
name: string
nameExact: string
idHint: RegExp
}[] = [
{
key: 'dn40',
name: 'DN40能量计',
nameExact: 'PAU201 DN40能量计',
idHint: /能量计_dn40|dn40/i,
},
{
key: 'dn80',
name: 'DN80能量计',
nameExact: 'PAU201 DN80能量计',
idHint: /能量计_dn80|dn80/i,
},
]
/** 累积冷量(勿匹配瞬时) */
const CUM_COOLING_RE = /正累积热量|累积热量|累计热量|正累积冷量|累积冷量|累计冷量/
/** 瞬时冷量 */
const INST_COOLING_RE = /瞬时热量|瞬时冷量/
function num(v: number | null | undefined): number | null {
if (v === null || v === undefined) return null
const n = Number(v)
return Number.isFinite(n) ? n : null
}
function pickEng(points: DevicePoint[], re: RegExp): number | null {
for (const p of points) {
if (!re.test(p.name) && !re.test(p.itemId)) continue
const v = num(p.lastEng)
if (v !== null) return v
}
return null
}
function findMeterDevice(devices: Device[], def: (typeof LAB_COOLING_METERS)[number]): Device | undefined {
return (
devices.find((d) => d.enabled !== false && d.name === def.nameExact) ||
devices.find((d) => d.enabled !== false && def.idHint.test(d.id)) ||
devices.find((d) => d.enabled !== false && def.idHint.test(d.code || ''))
)
}
export function extractLabCoolingMeters(
devices: Device[],
byDevice: Map<string, DevicePoint[]>,
): LabCoolingMeterReading[] {
return LAB_COOLING_METERS.map((def) => {
const d = findMeterDevice(devices, def)
const pts = d ? byDevice.get(d.id) || [] : []
return {
key: def.key,
name: def.name,
deviceId: d?.id || '',
energyKwh: d ? pickEng(pts, CUM_COOLING_RE) : null,
powerKw: d ? pickEng(pts, INST_COOLING_RE) : null,
}
})
}
function round3(n: number) {
return +n.toFixed(3)
}
const memDaily = new Map<string, DailyMeterRow>()
let hydratePromise: Promise<void> | null = null
export async function ensureCoolingMeterHydrated() {
if (!isPgEnabled()) return
if (!hydratePromise) {
hydratePromise = (async () => {
const today = macauDateStr()
const yest = new Date()
yest.setDate(yest.getDate() - 1)
const yestDate = macauDateStr(yest)
const keys = LAB_COOLING_METERS.map((m) => m.key)
try {
const { rows } = await query<{
usage_date: Date | string
meter_key: string
start_kwh: string | null
end_kwh: string | null
usage_kwh: string
}>(
`SELECT usage_date, meter_key, start_kwh, end_kwh, usage_kwh
FROM energy_daily_meter
WHERE usage_date >= $1::date AND usage_date <= $2::date
AND meter_key = ANY($3::text[])`,
[yestDate, today, keys],
)
for (const r of rows) {
const date =
typeof r.usage_date === 'string'
? r.usage_date.slice(0, 10)
: macauDateStr(new Date(r.usage_date))
const start = r.start_kwh != null ? Number(r.start_kwh) : Number(r.end_kwh) || 0
const end = r.end_kwh != null ? Number(r.end_kwh) : start
memDaily.set(
memDayKey(date, r.meter_key),
normalizeDailyRow(start, end, Number(r.usage_kwh)),
)
}
} catch (e) {
console.warn('[metering] hydrate cooling energy_daily_meter failed', e)
hydratePromise = null
}
})()
}
await hydratePromise
}
export function applyLabCoolingBaselines(
readings: LabCoolingMeterReading[],
at = new Date(),
): Map<LabCoolingMeterKey, number> {
const date = macauDateStr(at)
const yest = new Date(at.getTime())
yest.setDate(yest.getDate() - 1)
const yestDate = macauDateStr(yest)
const today = new Map<LabCoolingMeterKey, number>()
for (const r of readings) {
if (r.energyKwh === null || !Number.isFinite(r.energyKwh)) {
today.set(r.key, memDaily.get(memDayKey(date, r.key))?.usage || 0)
continue
}
const usage = applyMonotonicReading(
memDaily,
memDayKey(date, r.key),
memDayKey(yestDate, r.key),
r.energyKwh,
)
today.set(r.key, usage)
}
return today
}
export async function recordLabCoolingDaily(readings: LabCoolingMeterReading[], at = new Date()) {
await ensureCoolingMeterHydrated()
const todayMap = applyLabCoolingBaselines(readings, at)
if (!isPgEnabled()) return todayMap
const date = macauDateStr(at)
for (const r of readings) {
if (r.energyKwh === null || !Number.isFinite(r.energyKwh)) continue
const row = memDaily.get(memDayKey(date, r.key))
if (!row) continue
await query(
`INSERT INTO energy_daily_meter (
usage_date, meter_key, meter_name, device_id, start_kwh, end_kwh, usage_kwh, updated_at
) VALUES ($1::date, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (usage_date, meter_key) DO UPDATE SET
meter_name = EXCLUDED.meter_name,
device_id = EXCLUDED.device_id,
end_kwh = GREATEST(EXCLUDED.end_kwh, energy_daily_meter.end_kwh),
usage_kwh = GREATEST(
0,
GREATEST(EXCLUDED.end_kwh, energy_daily_meter.end_kwh) - energy_daily_meter.start_kwh
),
updated_at = NOW()`,
[date, r.key, r.name, r.deviceId || null, row.start, row.end, row.usage],
)
}
return todayMap
}
export function todayCoolingUsageFromReadings(readings: LabCoolingMeterReading[]): {
totalKwh: number
byKey: Map<LabCoolingMeterKey, number>
} {
const byKey = applyLabCoolingBaselines(readings)
let totalKwh = 0
for (const v of byKey.values()) totalKwh += v
return { totalKwh: round3(totalKwh), byKey }
}
function emptyMeters(readings?: LabCoolingMeterReading[]): LabCoolingMeterUsage[] {
return LAB_COOLING_METERS.map((def) => {
const r = readings?.find((x) => x.key === def.key)
return {
key: def.key,
name: def.name,
deviceId: r?.deviceId || '',
usageKwh: 0,
energyKwh: r?.energyKwh ?? null,
powerKw: r?.powerKw ?? null,
}
})
}
function periodFromMap(
byKey: Map<LabCoolingMeterKey, number>,
readings?: LabCoolingMeterReading[],
): CoolingEnergyPeriod {
const meters = emptyMeters(readings).map((m) => ({
...m,
usageKwh: round3(byKey.get(m.key) || 0),
}))
const totalKwh = round3(meters.reduce((s, m) => s + m.usageKwh, 0))
return { totalKwh, meters }
}
async function loadUsageBetween(
from: string,
to: string,
): Promise<Map<string, Map<LabCoolingMeterKey, number>>> {
const out = new Map<string, Map<LabCoolingMeterKey, number>>()
const keys = new Set(LAB_COOLING_METERS.map((m) => m.key))
if (!isPgEnabled()) {
for (const [k, row] of memDaily) {
const [date, meterKey] = k.split('|')
if (date < from || date > to) continue
if (!keys.has(meterKey as LabCoolingMeterKey)) continue
if (!out.has(date)) out.set(date, new Map())
out.get(date)!.set(meterKey as LabCoolingMeterKey, row.usage)
}
return out
}
const { rows } = await query<{ usage_date: Date | string; meter_key: string; usage_kwh: string }>(
`SELECT usage_date, meter_key, usage_kwh
FROM energy_daily_meter
WHERE usage_date >= $1::date AND usage_date <= $2::date
AND meter_key = ANY($3::text[])`,
[from, to, [...keys]],
)
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)
if (!out.has(date)) out.set(date, new Map())
out.get(date)!.set(r.meter_key as LabCoolingMeterKey, Number(r.usage_kwh) || 0)
}
return out
}
function monthRange(month: string): { from: string; to: string } {
const [y, m] = month.split('-').map(Number)
const from = `${month}-01`
const last = new Date(Date.UTC(y, m, 0)).getUTCDate()
const to = `${month}-${String(last).padStart(2, '0')}`
return { from, to }
}
function sumKeys(dayMap: Map<LabCoolingMeterKey, number> | undefined): Map<LabCoolingMeterKey, number> {
const m = new Map<LabCoolingMeterKey, number>()
for (const def of LAB_COOLING_METERS) m.set(def.key, dayMap?.get(def.key) || 0)
return m
}
function accumulateDays(
byDate: Map<string, Map<LabCoolingMeterKey, number>>,
from: string,
to: string,
): Map<LabCoolingMeterKey, number> {
const acc = new Map<LabCoolingMeterKey, number>()
for (const def of LAB_COOLING_METERS) acc.set(def.key, 0)
for (const [date, day] of byDate) {
if (date < from || date > to) continue
for (const def of LAB_COOLING_METERS) {
acc.set(def.key, (acc.get(def.key) || 0) + (day.get(def.key) || 0))
}
}
return acc
}
export async function getCoolingEnergyStats(
readings?: LabCoolingMeterReading[],
): Promise<CoolingEnergyStats> {
await ensureCoolingMeterHydrated()
if (readings?.length) applyLabCoolingBaselines(readings)
const date = macauDateStr()
const month = macauMonthStr()
const { from: monthFrom, to: monthTo } = monthRange(month)
const start30 = new Date()
start30.setDate(start30.getDate() - 29)
const from30 = macauDateStr(start30)
const byDate = await loadUsageBetween(from30 < monthFrom ? from30 : monthFrom, date > monthTo ? date : monthTo)
const memToday = new Map<LabCoolingMeterKey, number>()
for (const def of LAB_COOLING_METERS) {
const row = memDaily.get(memDayKey(date, def.key))
memToday.set(def.key, row?.usage ?? byDate.get(date)?.get(def.key) ?? 0)
}
byDate.set(date, memToday)
const today = periodFromMap(sumKeys(byDate.get(date)), readings)
const monthPeriod = periodFromMap(accumulateDays(byDate, monthFrom, date), readings)
const trend30d: CoolingEnergyStats['trend30d'] = []
const cur = new Date(from30 + 'T12:00:00+08:00')
const end = new Date(date + 'T12:00:00+08:00')
while (cur <= end) {
const d = macauDateStr(cur)
const day = sumKeys(byDate.get(d))
const dn40 = round3(day.get('dn40') || 0)
const dn80 = round3(day.get('dn80') || 0)
trend30d.push({
date: d,
label: d.slice(5),
totalKwh: round3(dn40 + dn80),
dn40,
dn80,
})
cur.setDate(cur.getDate() + 1)
}
return { date, month, today, monthPeriod, trend30d }
}
/**
* 日差量公共逻辑:累积表计读数日内只进不退,避免通讯毛刺/仿真抖动导致「今日 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') }}:
......
...@@ -68,10 +68,11 @@ const cameras = ref<VideoDevice[]>([]) ...@@ -68,10 +68,11 @@ const cameras = ref<VideoDevice[]>([])
/** Marker 弹窗:摄像头/照明居中;视频窗加大 */ /** Marker 弹窗:摄像头/照明居中;视频窗加大 */
const nearOpen = ref(false) const nearOpen = ref(false)
const nearTitle = ref('') const nearTitle = ref('')
const nearKind = ref<'video' | 'values' | 'switch'>('video') const nearKind = ref<'video' | 'values' | 'switch' | 'door'>('video')
const nearLines = ref<{ label: string; value: string }[]>([]) const nearLines = ref<{ label: string; value: string }[]>([])
const nearHit = ref<SceneMarkerHit | null>(null) const nearHit = ref<SceneMarkerHit | null>(null)
const nearSensorId = ref<string | null>(null) const nearSensorId = ref<string | null>(null)
const nearDoorId = ref<string | null>(null)
const nearSwitchOn = ref<boolean | null>(null) const nearSwitchOn = ref<boolean | null>(null)
/** popup 展示用:始终跟当前传感器实时 on,写控/WS 回写都会自动切换开/关 */ /** popup 展示用:始终跟当前传感器实时 on,写控/WS 回写都会自动切换开/关 */
const liveSwitchOn = computed(() => { const liveSwitchOn = computed(() => {
...@@ -83,9 +84,24 @@ const liveSwitchOn = computed(() => { ...@@ -83,9 +84,24 @@ const liveSwitchOn = computed(() => {
) )
return s ? (s.on ?? null) : nearSwitchOn.value return s ? (s.on ?? null) : nearSwitchOn.value
}) })
const liveDoorStatusText = computed(() => {
if (!nearOpen.value || nearKind.value !== 'door' || !nearDoorId.value) return ''
const door = (realtime.snapshot.access?.doors || []).find((d) => d.id === nearDoorId.value)
if (!door) return t('screen.unknown')
const key = `doorStatus.${door.status}`
const hit = t(key)
return hit === key ? door.status : hit
})
const nearSwitching = ref(false) const nearSwitching = ref(false)
const nearSwitchError = ref('') const nearSwitchError = ref('')
const nearDeviceId = ref<string | null>(null) const nearDeviceId = ref<string | null>(null)
/** 大屏照明模式:一键全开/全关 */
const batchLightBusy = ref(false)
const batchLightMsg = ref('')
const batchDoorBusy = ref(false)
const batchDoorMsg = ref('')
const isLightMode = computed(() => activeMode.value === 'lightN' || activeMode.value === 'lightE')
const isAccessMode = computed(() => activeMode.value === 'access')
const nearLeft = ref(120) const nearLeft = ref(120)
const nearTop = ref(120) const nearTop = ref(120)
/** 关闭后短时忽略 Marker 点击,防止卸层点击穿透再次打开 */ /** 关闭后短时忽略 Marker 点击,防止卸层点击穿透再次打开 */
...@@ -319,6 +335,7 @@ function openNearDialog( ...@@ -319,6 +335,7 @@ function openNearDialog(
function closeNearDialog() { function closeNearDialog() {
nearOpen.value = false nearOpen.value = false
nearSensorId.value = null nearSensorId.value = null
nearDoorId.value = null
nearDeviceId.value = null nearDeviceId.value = null
nearSwitchError.value = '' nearSwitchError.value = ''
nearSwitching.value = false nearSwitching.value = false
...@@ -404,6 +421,141 @@ async function onLightControl(on: boolean) { ...@@ -404,6 +421,141 @@ async function onLightControl(on: boolean) {
} }
} }
function resolveDoorId(sensor: SensorSnapshot | null, marker: SceneMarkerHit): string | null {
const doors = realtime.snapshot.access?.doors || []
if (sensor) {
const hit = doors.find(
(d) =>
d.id === sensor.code ||
d.id === sensor.doorDeviceId ||
d.name === sensor.name ||
d.name === marker.name,
)
if (hit) return hit.id
if (sensor.code && /^door-/i.test(sensor.code)) return sensor.code
}
const name = (marker.name || sensor?.name || '').trim()
if (name) {
const byName = doors.find((d) => d.name === name || name.includes(d.name) || d.name.includes(name))
if (byName) return byName.id
}
return null
}
async function onDoorOpen() {
if (!nearDoorId.value) {
nearSwitchError.value = t('screen.doorNotBound')
return
}
const doorId = nearDoorId.value
nearSwitching.value = true
nearSwitchError.value = ''
try {
const data = await api<{ ok?: boolean; message?: string }>('/api/realtime/access/open', {
method: 'POST',
token: auth.token || undefined,
body: JSON.stringify({ doorId }),
})
if (data.message) nearSwitchError.value = data.message
// 乐观:远程开门后显示开启
const doors = (realtime.snapshot.access?.doors || []).map((d) =>
d.id === doorId ? { ...d, status: 'open' as const } : d,
)
realtime.setSnapshot({
...realtime.snapshot,
ts: Date.now(),
access: { ...realtime.snapshot.access, doors },
})
} catch (e) {
nearSwitchError.value = e instanceof Error ? e.message : t('screen.controlFailed')
} finally {
nearSwitching.value = false
}
}
/** 一键开/关全部 N电 + E电 */
async function onBatchLights(on: boolean) {
if (batchLightBusy.value) return
batchLightBusy.value = true
batchLightMsg.value = ''
const prev = (realtime.snapshot.sensors || []).map((s) => ({ ...s }))
const sensors = (realtime.snapshot.sensors || []).map((s) =>
s.category === 'lightN' || s.category === 'lightE' ? { ...s, on } : s,
)
realtime.setSnapshot({
...realtime.snapshot,
ts: Date.now(),
sensors,
})
window.setTimeout(() => syncLightEffects(), 0)
try {
const r = await api<{ ok: boolean; message?: string; okCount?: number; total?: number }>(
'/api/sensors/lights/switch-all',
{
method: 'POST',
token: auth.token || undefined,
body: JSON.stringify({ on }),
},
)
batchLightMsg.value =
r.message ||
t(on ? 'screen.lightsAllOnOk' : 'screen.lightsAllOffOk', {
n: r.okCount ?? r.total ?? 0,
})
} catch (e) {
batchLightMsg.value = e instanceof Error ? e.message : t('screen.controlFailed')
realtime.setSnapshot({
...realtime.snapshot,
ts: Date.now(),
sensors: prev,
})
window.setTimeout(() => syncLightEffects(), 0)
} finally {
batchLightBusy.value = false
window.setTimeout(() => {
if (!batchLightBusy.value) batchLightMsg.value = ''
}, 3200)
}
}
/** 门禁分类:一键远程开门 */
async function onBatchDoorsOpen() {
if (batchDoorBusy.value) return
batchDoorBusy.value = true
batchDoorMsg.value = ''
const prevDoors = (realtime.snapshot.access?.doors || []).map((d) => ({ ...d }))
const doors = prevDoors.map((d) => ({ ...d, status: 'open' as const }))
realtime.setSnapshot({
...realtime.snapshot,
ts: Date.now(),
access: { ...realtime.snapshot.access, doors },
})
try {
const r = await api<{ ok: boolean; message?: string; okCount?: number; total?: number }>(
'/api/realtime/access/open-all',
{
method: 'POST',
token: auth.token || undefined,
body: '{}',
},
)
batchDoorMsg.value =
r.message || t('screen.doorsAllOpenOk', { n: r.okCount ?? r.total ?? doors.length })
} catch (e) {
batchDoorMsg.value = e instanceof Error ? e.message : t('screen.controlFailed')
realtime.setSnapshot({
...realtime.snapshot,
ts: Date.now(),
access: { ...realtime.snapshot.access, doors: prevDoors },
})
} finally {
batchDoorBusy.value = false
window.setTimeout(() => {
if (!batchDoorBusy.value) batchDoorMsg.value = ''
}, 3200)
}
}
/** 沉浸(藏面板)/ 设备分类默认:主视图;展开两侧面板:主视图2 */ /** 沉浸(藏面板)/ 设备分类默认:主视图;展开两侧面板:主视图2 */
const DEFAULT_BOOKMARK = '主视图' const DEFAULT_BOOKMARK = '主视图'
const PANEL_BOOKMARK = '主视图2' const PANEL_BOOKMARK = '主视图2'
...@@ -484,30 +636,42 @@ function looksLikeRawId(s: string): boolean { ...@@ -484,30 +636,42 @@ function looksLikeRawId(s: string): boolean {
return false return false
} }
function findCameraSensor(marker: SceneMarkerHit, markerId: string | null): SensorSnapshot | null { function findSensorByKeys(
marker: SceneMarkerHit,
markerId: string | null,
category: SensorSnapshot['category'],
): SensorSnapshot | null {
const sensors = realtime.snapshot.sensors || [] const sensors = realtime.snapshot.sensors || []
const keys = [marker.tag1, markerId, marker.id] const keys = [marker.tag1, markerId, marker.id]
.map((k) => String(k || '').replace(/^sm:sensor:/i, '').trim()) .map((k) => String(k || '').replace(/^sm:sensor:/i, '').trim())
.filter(Boolean) .filter(Boolean)
for (const key of keys) { for (const key of keys) {
const hit = sensors.find( const hit = sensors.find(
(s) => s.category === 'camera' && (s.id === key || s.code === key || s.videoDeviceId === key), (s) =>
s.category === category &&
(s.id === key ||
s.code === key ||
(category === 'camera' && s.videoDeviceId === key) ||
(category === 'access' && s.doorDeviceId === key)),
) )
if (hit) return hit if (hit) return hit
} }
const name = (marker.name || '').trim() const name = (marker.name || '').trim()
if (name && !looksLikeRawId(name)) { if (name && !looksLikeRawId(name)) {
return ( // 仅精确名匹配,避免「后走廊门禁」被「后走廊」监控模糊命中
sensors.find( return sensors.find((s) => s.category === category && s.name === name) || null
(s) =>
s.category === 'camera' &&
(s.name === name || s.name.includes(name) || name.includes(s.name)),
) || null
)
} }
return null return null
} }
function findCameraSensor(marker: SceneMarkerHit, markerId: string | null): SensorSnapshot | null {
return findSensorByKeys(marker, markerId, 'camera')
}
function findAccessSensor(marker: SceneMarkerHit, markerId: string | null): SensorSnapshot | null {
return findSensorByKeys(marker, markerId, 'access')
}
function findCamera(marker: SceneMarkerHit, markerId: string | null): VideoDevice | null { function findCamera(marker: SceneMarkerHit, markerId: string | null): VideoDevice | null {
// 引擎回调里 id/name/tag1 都可能是设备 id // 引擎回调里 id/name/tag1 都可能是设备 id
const keys = [markerId, marker.id, marker.tag1, marker.tag2, marker.name] const keys = [markerId, marker.id, marker.tag1, marker.tag2, marker.name]
...@@ -566,10 +730,30 @@ function onMarkerClick(payload: { ...@@ -566,10 +730,30 @@ function onMarkerClick(payload: {
// 空白地面点击:无对象 id 则忽略 // 空白地面点击:无对象 id 则忽略
if (!markerId && !marker.id && !marker.tag0 && !marker.name) return if (!markerId && !marker.id && !marker.tag0 && !marker.name) return
const cam = findCamera(marker, markerId)
const camSensor = findCameraSensor(marker, markerId)
const category = categoryOfMarker(marker) const category = categoryOfMarker(marker)
const accessSensor = findAccessSensor(marker, markerId)
nearHit.value = marker nearHit.value = marker
// 门禁优先:弹出远程开门(类似照明开关),禁止落到监控视频
if (category === 'access' || accessSensor) {
const doorId = resolveDoorId(accessSensor, marker)
nearKind.value = 'door'
nearLines.value = []
nearSensorId.value = accessSensor?.id || null
nearDoorId.value = doorId
nearDeviceId.value = null
nearSwitchError.value = doorId ? '' : t('screen.doorNotBound')
openNearDialog(
accessSensor?.name || marker.name || t('screen.doorControl'),
markerId || marker.id,
raw,
{ center: true },
)
return
}
const cam = findCamera(marker, markerId)
const camSensor = findCameraSensor(marker, markerId)
if (category === 'camera' || cam || camSensor) { if (category === 'camera' || cam || camSensor) {
nearKind.value = 'video' nearKind.value = 'video'
nearLines.value = [] nearLines.value = []
...@@ -862,6 +1046,44 @@ onBeforeUnmount(() => { ...@@ -862,6 +1046,44 @@ onBeforeUnmount(() => {
<!-- 全屏:底部设备分类 + 右下角相机 HUD(单点触摸缩放/旋转) --> <!-- 全屏:底部设备分类 + 右下角相机 HUD(单点触摸缩放/旋转) -->
<Teleport v-if="props.layout === 'fill' && floatTargetReady" to="#screen-float-overlays" defer> <Teleport v-if="props.layout === 'fill' && floatTargetReady" to="#screen-float-overlays" defer>
<div
v-if="isLightMode"
class="vp-light-batch"
:aria-label="t('screen.lightControl')"
>
<button
type="button"
class="vp-light-batch-btn on"
:disabled="batchLightBusy"
@click="onBatchLights(true)"
>
{{ batchLightBusy ? t('screen.switching') : t('screen.lightsAllOn') }}
</button>
<button
type="button"
class="vp-light-batch-btn off"
:disabled="batchLightBusy"
@click="onBatchLights(false)"
>
{{ batchLightBusy ? t('screen.switching') : t('screen.lightsAllOff') }}
</button>
<span v-if="batchLightMsg" class="vp-light-batch-msg">{{ batchLightMsg }}</span>
</div>
<div
v-if="isAccessMode"
class="vp-light-batch"
:aria-label="t('screen.doorControl')"
>
<button
type="button"
class="vp-light-batch-btn on"
:disabled="batchDoorBusy"
@click="onBatchDoorsOpen"
>
{{ batchDoorBusy ? t('screen.switching') : t('screen.doorsAllOpen') }}
</button>
<span v-if="batchDoorMsg" class="vp-light-batch-msg">{{ batchDoorMsg }}</span>
</div>
<div class="vp-area-bottom" :aria-label="t('screen.deviceCategory')"> <div class="vp-area-bottom" :aria-label="t('screen.deviceCategory')">
<button <button
v-for="item in DEVICE_MENU" v-for="item in DEVICE_MENU"
...@@ -952,12 +1174,14 @@ onBeforeUnmount(() => { ...@@ -952,12 +1174,14 @@ onBeforeUnmount(() => {
:switch-on="liveSwitchOn" :switch-on="liveSwitchOn"
:switching="nearSwitching" :switching="nearSwitching"
:switch-error="nearSwitchError" :switch-error="nearSwitchError"
:door-status-text="liveDoorStatusText"
:left="nearLeft" :left="nearLeft"
:top="nearTop" :top="nearTop"
:device-id="nearDeviceId" :device-id="nearDeviceId"
src="/cams/lab-seq.mp4" src="/cams/lab-seq.mp4"
@close="closeNearDialog" @close="closeNearDialog"
@control="onLightControl" @control="onLightControl"
@door-open="onDoorOpen"
/> />
</div> </div>
</template> </template>
...@@ -1004,6 +1228,56 @@ onBeforeUnmount(() => { ...@@ -1004,6 +1228,56 @@ onBeforeUnmount(() => {
overflow: hidden; overflow: hidden;
pointer-events: auto; pointer-events: auto;
} }
/* 照明模式:一键开/关全部 N+E */
.vp-light-batch {
position: absolute;
z-index: 9;
left: 50%;
bottom: 72px;
transform: translateX(-50%);
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
pointer-events: auto;
}
.vp-light-batch-btn {
min-width: 112px;
padding: 10px 18px;
border-radius: 4px;
border: 1px solid rgba(91, 212, 255, 0.4);
background: rgba(8, 28, 58, 0.92);
color: #fff;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
}
.vp-light-batch-btn:disabled {
opacity: 0.55;
cursor: wait;
}
.vp-light-batch-btn.on {
border-color: rgba(250, 204, 80, 0.55);
background: linear-gradient(180deg, rgba(180, 120, 20, 0.85), rgba(90, 55, 8, 0.92));
}
.vp-light-batch-btn.off {
border-color: rgba(140, 170, 210, 0.4);
background: linear-gradient(180deg, rgba(40, 60, 95, 0.9), rgba(12, 24, 48, 0.95));
}
.vp-light-batch-msg {
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
transform: translateX(-50%);
white-space: nowrap;
padding: 4px 10px;
border-radius: 4px;
background: rgba(4, 18, 40, 0.88);
border: 1px solid rgba(91, 212, 255, 0.28);
color: rgba(200, 230, 255, 0.95);
font-size: 12px;
}
/* 大屏底部设备分类 */ /* 大屏底部设备分类 */
.vp-area-bottom { .vp-area-bottom {
position: absolute; position: absolute;
......
...@@ -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
}[]
}
...@@ -7,6 +7,7 @@ import CostLineChart from '@/components/charts/CostLineChart.vue' ...@@ -7,6 +7,7 @@ import CostLineChart from '@/components/charts/CostLineChart.vue'
import type { import type {
BillingReport, BillingReport,
BillingStats, BillingStats,
CoolingEnergyStats,
PowerEnergyStats, PowerEnergyStats,
TariffSettings, TariffSettings,
} from '@/types/billing' } from '@/types/billing'
...@@ -23,6 +24,7 @@ const message = ref('') ...@@ -23,6 +24,7 @@ const message = ref('')
const stats = ref<BillingStats | null>(null) const stats = ref<BillingStats | null>(null)
const powerEnergy = ref<PowerEnergyStats | null>(null) const powerEnergy = ref<PowerEnergyStats | null>(null)
const coolingEnergy = ref<CoolingEnergyStats | null>(null)
const tariff = reactive<TariffSettings>({ const tariff = reactive<TariffSettings>({
powerPeak: 0, powerPeak: 0,
powerFlat: 0, powerFlat: 0,
...@@ -49,11 +51,12 @@ const power30d = computed(() => ...@@ -49,11 +51,12 @@ const power30d = computed(() =>
) )
const powerMetersLive = computed(() => realtime.snapshot.metering.powerMeters || []) const powerMetersLive = computed(() => realtime.snapshot.metering.powerMeters || [])
const water30d = computed(() => const coolingMetersLive = computed(() => realtime.snapshot.metering.coolingMeters || [])
(stats.value?.usage30d || []).map((d) => ({ label: d.label, value: d.waterM3 })),
)
const cooling30d = computed(() => const cooling30d = computed(() =>
(stats.value?.usage30d || []).map((d) => ({ label: d.label, value: d.coolingKwh })), (coolingEnergy.value?.trend30d || stats.value?.usage30d || []).map((d) => ({
label: d.label,
value: 'totalKwh' in d ? d.totalKwh : d.coolingKwh,
})),
) )
/** 近 7 日用电量(不按费用) */ /** 近 7 日用电量(不按费用) */
const energy7d = computed(() => { const energy7d = computed(() => {
...@@ -71,7 +74,7 @@ const todayPowerKwh = computed(() => { ...@@ -71,7 +74,7 @@ const todayPowerKwh = computed(() => {
}) })
/** 用量构成(按 kWh/m³ 归一到占比,不展示元) */ /** 用量构成(按 kWh/m³ 归一到占比,不展示元) */
const usageByKind = computed(() => { const usageByKind = computed(() => {
const kinds = stats.value?.byKind || [] const kinds = (stats.value?.byKind || []).filter((k) => k.kind !== 'water')
if (!kinds.length) return [] if (!kinds.length) return []
const total = kinds.reduce((s, k) => s + (k.usage || 0), 0) || 1 const total = kinds.reduce((s, k) => s + (k.usage || 0), 0) || 1
return kinds.map((k) => ({ return kinds.map((k) => ({
...@@ -109,6 +112,12 @@ async function loadPowerEnergy() { ...@@ -109,6 +112,12 @@ async function loadPowerEnergy() {
}) })
} }
async function loadCoolingEnergy() {
coolingEnergy.value = await api<CoolingEnergyStats>('/api/billing/cooling-energy', {
token: auth.token,
})
}
async function loadTariff() { async function loadTariff() {
const data = await api<TariffSettings>('/api/billing/tariff', { token: auth.token }) const data = await api<TariffSettings>('/api/billing/tariff', { token: auth.token })
Object.assign(tariff, data) Object.assign(tariff, data)
...@@ -128,7 +137,13 @@ async function refreshAll() { ...@@ -128,7 +137,13 @@ async function refreshAll() {
try { try {
if (!reportFrom.value) reportFrom.value = daysAgo(29) if (!reportFrom.value) reportFrom.value = daysAgo(29)
if (!reportTo.value) reportTo.value = todayStr() if (!reportTo.value) reportTo.value = todayStr()
await Promise.all([loadStats(), loadPowerEnergy(), loadTariff(), loadReport()]) await Promise.all([
loadStats(),
loadPowerEnergy(),
loadCoolingEnergy(),
loadTariff(),
loadReport(),
])
} catch (e) { } catch (e) {
message.value = e instanceof Error ? e.message : '加载失败' message.value = e instanceof Error ? e.message : '加载失败'
} finally { } finally {
...@@ -148,7 +163,7 @@ async function saveTariff() { ...@@ -148,7 +163,7 @@ async function saveTariff() {
powerPeak: tariff.powerPeak, powerPeak: tariff.powerPeak,
powerFlat: tariff.powerFlat, powerFlat: tariff.powerFlat,
powerValley: tariff.powerValley, powerValley: tariff.powerValley,
water: tariff.water, water: 0,
cooling: tariff.cooling, cooling: tariff.cooling,
serviceFeePct: tariff.serviceFeePct, serviceFeePct: tariff.serviceFeePct,
taxPct: tariff.taxPct, taxPct: tariff.taxPct,
...@@ -173,10 +188,8 @@ function exportCsv() { ...@@ -173,10 +188,8 @@ function exportCsv() {
'峰电kWh', '峰电kWh',
'平电kWh', '平电kWh',
'谷电kWh', '谷电kWh',
'用水m³',
'冷量kWh', '冷量kWh',
'电费', '电费',
'水费',
'冷量费', '冷量费',
'小计', '小计',
'服务费', '服务费',
...@@ -191,10 +204,8 @@ function exportCsv() { ...@@ -191,10 +204,8 @@ function exportCsv() {
r.powerPeakKwh, r.powerPeakKwh,
r.powerFlatKwh, r.powerFlatKwh,
r.powerValleyKwh, r.powerValleyKwh,
r.waterM3,
r.coolingKwh, r.coolingKwh,
r.powerCost, r.powerCost,
r.waterCost,
r.coolingCost, r.coolingCost,
r.subtotal, r.subtotal,
r.serviceFee, r.serviceFee,
...@@ -234,23 +245,35 @@ onUnmounted(() => { ...@@ -234,23 +245,35 @@ onUnmounted(() => {
<span v-if="message" class="msg">{{ message }}</span> <span v-if="message" class="msg">{{ message }}</span>
</div> </div>
<!-- 计量概览:PAU201 / E电 / N电 --> <!-- 计量概览:三电表 + 两能量计 -->
<template v-if="tab === 'meter'"> <template v-if="tab === 'meter'">
<div class="metric-grid two"> <div class="metric-grid four">
<section class="panel metric-card tone-amber"> <section class="panel metric-card tone-amber">
<div class="m-label">今日总用电 (kWh)</div> <div class="m-label">今日总用电 (kWh)</div>
<div class="m-value"> <div class="m-value">
{{ (powerEnergy?.today.totalKwh ?? realtime.snapshot.metering.powerKwh).toFixed(1) }} {{ (powerEnergy?.today.totalKwh ?? realtime.snapshot.metering.powerKwh).toFixed(1) }}
</div> </div>
<div class="m-sub"> <div class="m-sub">PAU201 + E电 + N电 · {{ powerEnergy?.date || '今日' }}</div>
PAU201 + E电 + N电 · {{ powerEnergy?.date || '今日' }}
</div>
</section> </section>
<section class="panel metric-card tone-cyan"> <section class="panel metric-card tone-cyan">
<div class="m-label">本月总用电 (kWh)</div> <div class="m-label">本月总用电 (kWh)</div>
<div class="m-value">{{ (powerEnergy?.monthPeriod.totalKwh ?? 0).toFixed(1) }}</div> <div class="m-value">{{ (powerEnergy?.monthPeriod.totalKwh ?? 0).toFixed(1) }}</div>
<div class="m-sub">{{ powerEnergy?.month || '本月' }} 累计</div> <div class="m-sub">{{ powerEnergy?.month || '本月' }} 累计</div>
</section> </section>
<section class="panel metric-card tone-violet">
<div class="m-label">今日制冷量 (kWh)</div>
<div class="m-value">
{{
(coolingEnergy?.today.totalKwh ?? realtime.snapshot.metering.coolingKwh).toFixed(0)
}}
</div>
<div class="m-sub">DN40 + DN80 · {{ coolingEnergy?.date || '今日' }}</div>
</section>
<section class="panel metric-card tone-green">
<div class="m-label">本月制冷量 (kWh)</div>
<div class="m-value">{{ (coolingEnergy?.monthPeriod.totalKwh ?? 0).toFixed(0) }}</div>
<div class="m-sub">{{ coolingEnergy?.month || '本月' }} 累计</div>
</section>
</div> </div>
<div class="metric-grid three meter-split"> <div class="metric-grid three meter-split">
...@@ -280,35 +303,34 @@ onUnmounted(() => { ...@@ -280,35 +303,34 @@ onUnmounted(() => {
</section> </section>
</div> </div>
<div class="metric-grid three"> <div class="metric-grid two meter-split">
<section class="panel metric-card"> <section
<div class="m-label">今日用水 (m³)</div> v-for="m in coolingEnergy?.today.meters || coolingMetersLive"
<div class="m-value">{{ realtime.snapshot.metering.waterM3.toFixed(1) }}</div> :key="m.key || m.name"
<div class="m-sub"> class="panel metric-card"
较昨日 {{ realtime.snapshot.metering.waterTrendPct > 0 ? '↑' : '↓' }} >
{{ Math.abs(realtime.snapshot.metering.waterTrendPct) }}% <div class="m-label">{{ m.name }} · 今日冷量</div>
</div>
</section>
<section class="panel metric-card">
<div class="m-label">冷冻水冷量 (kWh)</div>
<div class="m-value">{{ realtime.snapshot.metering.coolingKwh }}</div>
<div class="m-sub">较昨日 ↑ {{ realtime.snapshot.metering.coolingTrendPct }}%</div>
</section>
<section class="panel metric-card">
<div class="m-label">三表瞬时功率合计</div>
<div class="m-value"> <div class="m-value">
{{ ('usageKwh' in m ? m.usageKwh : m.todayKwh).toFixed(0) }}
<small>kWh</small>
</div>
<div class="m-sub">
本月
{{ {{
powerMetersLive (
.reduce((s, m) => s + (m.powerKw ?? 0), 0) coolingEnergy?.monthPeriod.meters.find((x) => x.key === m.key)?.usageKwh ?? 0
.toFixed(1) ).toFixed(0)
}} }}
<small>kW</small> kWh
<template v-if="m.energyKwh != null">
· 累积 {{ m.energyKwh.toFixed(1) }}
</template>
<template v-if="m.powerKw != null"> · 瞬时 {{ m.powerKw.toFixed(2) }} kW</template>
</div> </div>
<div class="m-sub">PAU201 / E电 / N电</div>
</section> </section>
</div> </div>
<div class="usage-charts"> <div class="usage-charts two-col">
<section class="panel chart-panel"> <section class="panel chart-panel">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>近一个月用电趋势(三表合计)</h3></div> <div class="panel-title"><div class="vbar" /><h3>近一个月用电趋势(三表合计)</h3></div>
...@@ -318,14 +340,7 @@ onUnmounted(() => { ...@@ -318,14 +340,7 @@ onUnmounted(() => {
</section> </section>
<section class="panel chart-panel"> <section class="panel chart-panel">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>近一个月用水趋势</h3></div> <div class="panel-title"><div class="vbar" /><h3>近一个月制冷量趋势(两能量计)</h3></div>
<div class="badge">m³</div>
</div>
<CostLineChart :points="water30d" color="#38bdf8" unit="m³" :height="220" />
</section>
<section class="panel chart-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>近一个月冷冻水趋势</h3></div>
<div class="badge">kWh</div> <div class="badge">kWh</div>
</div> </div>
<CostLineChart :points="cooling30d" color="#67e8f9" unit="kWh" :height="220" /> <CostLineChart :points="cooling30d" color="#67e8f9" unit="kWh" :height="220" />
...@@ -348,19 +363,26 @@ onUnmounted(() => { ...@@ -348,19 +363,26 @@ onUnmounted(() => {
</div> </div>
<div class="m-sub">已计 {{ stats?.month.days ?? '—' }} 天</div> <div class="m-sub">已计 {{ stats?.month.days ?? '—' }} 天</div>
</section> </section>
<section class="panel metric-card tone-green"> <section class="panel metric-card tone-violet">
<div class="m-label">今日用水 (m³)</div> <div class="m-label">今日冷量 (kWh)</div>
<div class="m-value"> <div class="m-value">
{{ (stats?.today.waterM3 ?? realtime.snapshot.metering.waterM3).toFixed(1) }} {{
(coolingEnergy?.today.totalKwh ??
stats?.today.coolingKwh ??
realtime.snapshot.metering.coolingKwh
).toFixed(0)
}}
</div> </div>
<div class="m-sub">本月 {{ stats?.month.waterM3 ?? '—' }} m³</div> <div class="m-sub">DN40 + DN80</div>
</section> </section>
<section class="panel metric-card tone-violet"> <section class="panel metric-card tone-green">
<div class="m-label">今日冷量 (kWh)</div> <div class="m-label">本月冷量 (kWh)</div>
<div class="m-value"> <div class="m-value">
{{ (stats?.today.coolingKwh ?? realtime.snapshot.metering.coolingKwh).toFixed(0) }} {{
(coolingEnergy?.monthPeriod.totalKwh ?? stats?.month.coolingKwh ?? 0).toFixed(0)
}}
</div> </div>
<div class="m-sub">本月 {{ stats?.month.coolingKwh ?? '—' }} kWh</div> <div class="m-sub">已计 {{ stats?.month.days ?? '—' }} 天</div>
</section> </section>
</div> </div>
...@@ -386,6 +408,30 @@ onUnmounted(() => { ...@@ -386,6 +408,30 @@ onUnmounted(() => {
</section> </section>
</div> </div>
<div class="metric-grid two meter-split">
<section
v-for="m in coolingMetersLive.length
? coolingMetersLive
: coolingEnergy?.today.meters || []"
:key="m.key || m.name"
class="panel metric-card"
>
<div class="m-label">{{ m.name }} · 瞬时冷量</div>
<div class="m-value">
<template v-if="m.powerKw != null">{{ m.powerKw.toFixed(2) }} <small>kW</small></template>
<template v-else>—</template>
</div>
<div class="m-sub">
今日
{{
('todayKwh' in m ? m.todayKwh : 'usageKwh' in m ? m.usageKwh : 0).toFixed(0)
}}
kWh
<template v-if="m.energyKwh != null"> · 累积 {{ m.energyKwh.toFixed(1) }}</template>
</div>
</section>
</div>
<div class="split"> <div class="split">
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
...@@ -394,7 +440,7 @@ onUnmounted(() => { ...@@ -394,7 +440,7 @@ onUnmounted(() => {
<div class="kind-list"> <div class="kind-list">
<div v-for="k in usageByKind" :key="k.kind" class="kind-row" :class="k.kind"> <div v-for="k in usageByKind" :key="k.kind" class="kind-row" :class="k.kind">
<div class="kind-meta"> <div class="kind-meta">
<span class="kind-name">{{ k.kind === 'power' ? '用电' : k.kind === 'water' ? '用水' : '冷量' }}</span> <span class="kind-name">{{ k.kind === 'power' ? '用电' : '冷量' }}</span>
<span class="kind-usage">{{ k.usage }} {{ k.unit }}</span> <span class="kind-usage">{{ k.usage }} {{ k.unit }}</span>
</div> </div>
<div class="kind-bar"><i :style="{ width: `${k.pct}%` }" /></div> <div class="kind-bar"><i :style="{ width: `${k.pct}%` }" /></div>
...@@ -446,10 +492,6 @@ onUnmounted(() => { ...@@ -446,10 +492,6 @@ onUnmounted(() => {
<input v-model.number="tariff.powerValley" type="number" step="0.01" min="0" :disabled="!auth.isAdmin" /> <input v-model.number="tariff.powerValley" type="number" step="0.01" min="0" :disabled="!auth.isAdmin" />
</div> </div>
<div class="field"> <div class="field">
<label>水价 (元/m³)</label>
<input v-model.number="tariff.water" type="number" step="0.01" min="0" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>冷量单价 (元/kWh)</label> <label>冷量单价 (元/kWh)</label>
<input v-model.number="tariff.cooling" type="number" step="0.01" min="0" :disabled="!auth.isAdmin" /> <input v-model.number="tariff.cooling" type="number" step="0.01" min="0" :disabled="!auth.isAdmin" />
</div> </div>
...@@ -483,7 +525,7 @@ onUnmounted(() => { ...@@ -483,7 +525,7 @@ onUnmounted(() => {
</span> </span>
</div> </div>
<p class="tip"> <p class="tip">
参考同类能耗计费软件:支持分时电价(峰/平/谷)、水与冷量单价、服务费与税费、按月结算日。费用 = 用量 × 单价 + 服务费 + 税。 支持分时电价(峰/平/谷)、冷量单价、服务费与税费、按月结算日。本系统无水表,不计水费。费用 = 电量×电价 + 冷量×单价 + 服务费 + 税。
</p> </p>
</section> </section>
</template> </template>
...@@ -504,7 +546,6 @@ onUnmounted(() => { ...@@ -504,7 +546,6 @@ onUnmounted(() => {
<div v-if="report" class="report-summary"> <div v-if="report" class="report-summary">
<span>区间 {{ report.summary.from }} ~ {{ report.summary.to }} · {{ report.summary.days }} 天</span> <span>区间 {{ report.summary.from }} ~ {{ report.summary.to }} · {{ report.summary.days }} 天</span>
<span>电费 ¥{{ money(report.summary.powerCost) }}</span> <span>电费 ¥{{ money(report.summary.powerCost) }}</span>
<span>水费 ¥{{ money(report.summary.waterCost) }}</span>
<span>冷量费 ¥{{ money(report.summary.coolingCost) }}</span> <span>冷量费 ¥{{ money(report.summary.coolingCost) }}</span>
<span>服务费 ¥{{ money(report.summary.serviceFee) }}</span> <span>服务费 ¥{{ money(report.summary.serviceFee) }}</span>
<span>税费 ¥{{ money(report.summary.tax) }}</span> <span>税费 ¥{{ money(report.summary.tax) }}</span>
...@@ -519,10 +560,8 @@ onUnmounted(() => { ...@@ -519,10 +560,8 @@ onUnmounted(() => {
<th>峰电</th> <th>峰电</th>
<th>平电</th> <th>平电</th>
<th>谷电</th> <th>谷电</th>
<th>用水</th>
<th>冷量</th> <th>冷量</th>
<th>电费</th> <th>电费</th>
<th>水费</th>
<th>冷量费</th> <th>冷量费</th>
<th>服务费</th> <th>服务费</th>
<th>税费</th> <th>税费</th>
...@@ -535,10 +574,8 @@ onUnmounted(() => { ...@@ -535,10 +574,8 @@ onUnmounted(() => {
<td>{{ r.powerPeakKwh }}</td> <td>{{ r.powerPeakKwh }}</td>
<td>{{ r.powerFlatKwh }}</td> <td>{{ r.powerFlatKwh }}</td>
<td>{{ r.powerValleyKwh }}</td> <td>{{ r.powerValleyKwh }}</td>
<td>{{ r.waterM3 }}</td>
<td>{{ r.coolingKwh }}</td> <td>{{ r.coolingKwh }}</td>
<td>{{ money(r.powerCost) }}</td> <td>{{ money(r.powerCost) }}</td>
<td>{{ money(r.waterCost) }}</td>
<td>{{ money(r.coolingCost) }}</td> <td>{{ money(r.coolingCost) }}</td>
<td>{{ money(r.serviceFee) }}</td> <td>{{ money(r.serviceFee) }}</td>
<td>{{ money(r.tax) }}</td> <td>{{ money(r.tax) }}</td>
...@@ -603,6 +640,9 @@ onUnmounted(() => { ...@@ -603,6 +640,9 @@ onUnmounted(() => {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;
align-items: start; align-items: start;
} }
.usage-charts.two-col {
grid-template-columns: repeat(2, 1fr);
}
.usage-charts .chart-panel { .usage-charts .chart-panel {
height: fit-content; height: fit-content;
padding-bottom: 10px; padding-bottom: 10px;
......
...@@ -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