Commit 49833298 by James Yuan

feat: 点位高低限告警判定并统一写入告警表

采集后按 alarmLow/High 超限判定推送大屏,后台可编辑阈值;告警以统一格式落 PG/文件,支持确认与恢复清除。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 8a2514de
import fs from 'node:fs'
import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js'
import type { PointAlarm } from '../devices/alarms.js'
import type { AlarmLevel, AlarmRecord, AlarmSource, AlarmStatus } from './types.js'
const OPEN_STATUSES: AlarmStatus[] = ['active', 'acked']
let mem: AlarmRecord[] = []
let fileLoaded = false
function nowIso() {
return new Date().toISOString()
}
function loadFile(): AlarmRecord[] {
try {
if (!fs.existsSync(config.alarmsFile)) return []
const raw = JSON.parse(fs.readFileSync(config.alarmsFile, 'utf8')) as { alarms?: AlarmRecord[] }
return Array.isArray(raw.alarms) ? raw.alarms : []
} catch {
return []
}
}
function saveFile() {
fs.mkdirSync(config.dataDir, { recursive: true })
fs.writeFileSync(config.alarmsFile, JSON.stringify({ alarms: mem }, null, 2), 'utf8')
}
function ensureMem() {
if (!fileLoaded) {
mem = loadFile()
fileLoaded = true
}
}
function pointSourceKey(pointId: string, kind: string) {
return `point:${pointId}:${kind}`
}
function fromPointAlarm(a: PointAlarm, existing?: AlarmRecord | null): AlarmRecord {
const t = nowIso()
const sourceKey = pointSourceKey(a.pointId, a.kind)
return {
id: existing?.id || randomUUID(),
source: 'point',
sourceKey,
level: a.level,
status: existing?.status === 'acked' ? 'acked' : 'active',
title: a.name,
message: a.message,
deviceId: a.deviceId,
pointId: a.pointId,
itemId: a.itemId,
value: a.value,
thresholdLow: a.alarmLow,
thresholdHigh: a.alarmHigh,
kind: a.kind,
payload: {
name: a.name,
alarmLow: a.alarmLow,
alarmHigh: a.alarmHigh,
},
raisedAt: existing?.raisedAt || a.ts || t,
updatedAt: t,
clearedAt: null,
ackedAt: existing?.ackedAt ?? null,
ackedBy: existing?.ackedBy ?? null,
}
}
function mapPgRow(r: Record<string, unknown>): AlarmRecord {
return {
id: String(r.id),
source: r.source as AlarmSource,
sourceKey: String(r.source_key),
level: r.level as AlarmLevel,
status: r.status as AlarmStatus,
title: String(r.title),
message: String(r.message),
deviceId: r.device_id == null ? null : String(r.device_id),
pointId: r.point_id == null ? null : String(r.point_id),
itemId: r.item_id == null ? null : String(r.item_id),
value: r.value == null ? null : Number(r.value),
thresholdLow: r.threshold_low == null ? null : Number(r.threshold_low),
thresholdHigh: r.threshold_high == null ? null : Number(r.threshold_high),
kind: r.kind == null ? null : String(r.kind),
payload:
r.payload && typeof r.payload === 'object' && !Array.isArray(r.payload)
? (r.payload as Record<string, unknown>)
: {},
raisedAt: new Date(String(r.raised_at)).toISOString(),
updatedAt: new Date(String(r.updated_at)).toISOString(),
clearedAt: r.cleared_at ? new Date(String(r.cleared_at)).toISOString() : null,
ackedAt: r.acked_at ? new Date(String(r.acked_at)).toISOString() : null,
ackedBy: r.acked_by == null ? null : String(r.acked_by),
}
}
async function insertAlarmPg(a: AlarmRecord) {
await query(
`INSERT INTO alarms (
id, source, source_key, level, status, title, message,
device_id, point_id, item_id, value, threshold_low, threshold_high, kind, payload,
raised_at, updated_at, cleared_at, acked_at, acked_by
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15::jsonb,
$16,$17,$18,$19,$20
)`,
[
a.id,
a.source,
a.sourceKey,
a.level,
a.status,
a.title,
a.message,
a.deviceId,
a.pointId,
a.itemId,
a.value,
a.thresholdLow,
a.thresholdHigh,
a.kind,
JSON.stringify(a.payload),
a.raisedAt,
a.updatedAt,
a.clearedAt,
a.ackedAt,
a.ackedBy,
],
)
}
async function updateOpenAlarmPg(a: AlarmRecord) {
await query(
`UPDATE alarms SET
level=$2, status=$3, title=$4, message=$5, device_id=$6, point_id=$7, item_id=$8,
value=$9, threshold_low=$10, threshold_high=$11, kind=$12, payload=$13::jsonb,
updated_at=$14, cleared_at=NULL
WHERE id=$1`,
[
a.id,
a.level,
a.status,
a.title,
a.message,
a.deviceId,
a.pointId,
a.itemId,
a.value,
a.thresholdLow,
a.thresholdHigh,
a.kind,
JSON.stringify(a.payload),
a.updatedAt,
],
)
}
/**
* 将当前点位超限列表同步进统一告警表:
* - 仍超限:插入或更新未关闭记录(保留 acked)
* - 已恢复:未关闭的 point 告警标记 cleared
*/
export async function syncPointAlarms(current: PointAlarm[]): Promise<{ upserted: number; cleared: number }> {
const byKey = new Map(current.map((a) => [pointSourceKey(a.pointId, a.kind), a]))
const t = nowIso()
if (!isPgEnabled()) {
ensureMem()
let upserted = 0
let cleared = 0
const open = mem.filter((a) => a.source === 'point' && OPEN_STATUSES.includes(a.status))
const openByKey = new Map(open.map((a) => [a.sourceKey, a]))
for (const [key, pa] of byKey) {
const existing = openByKey.get(key) || null
const next = fromPointAlarm(pa, existing)
if (existing) {
const i = mem.findIndex((x) => x.id === existing.id)
mem[i] = next
} else {
mem.push(next)
}
upserted += 1
}
for (const a of open) {
if (byKey.has(a.sourceKey)) continue
a.status = 'cleared'
a.clearedAt = t
a.updatedAt = t
cleared += 1
}
if (upserted || cleared) saveFile()
return { upserted, cleared }
}
const { rows: openRows } = await query<Record<string, unknown>>(
`SELECT * FROM alarms
WHERE source = 'point' AND status = ANY($1::text[])`,
[OPEN_STATUSES],
)
const open = openRows.map(mapPgRow)
const openByKey = new Map(open.map((a) => [a.sourceKey, a]))
let upserted = 0
let cleared = 0
for (const [key, pa] of byKey) {
const existing = openByKey.get(key) || null
const next = fromPointAlarm(pa, existing)
if (existing) {
await updateOpenAlarmPg(next)
} else {
await insertAlarmPg(next)
}
upserted += 1
}
const staleIds = open.filter((a) => !byKey.has(a.sourceKey)).map((a) => a.id)
if (staleIds.length) {
const { rowCount } = await query(
`UPDATE alarms SET status='cleared', cleared_at=$2, updated_at=$2
WHERE id = ANY($1::uuid[])`,
[staleIds, t],
)
cleared = rowCount ?? staleIds.length
}
return { upserted, cleared }
}
export async function listAlarms(opts: {
status?: AlarmStatus | 'open'
source?: AlarmSource
limit?: number
} = {}): Promise<AlarmRecord[]> {
const limit = Math.min(Math.max(opts.limit ?? 100, 1), 500)
if (!isPgEnabled()) {
ensureMem()
let rows = [...mem]
if (opts.status === 'open') {
rows = rows.filter((a) => OPEN_STATUSES.includes(a.status))
} else if (opts.status) {
rows = rows.filter((a) => a.status === opts.status)
}
if (opts.source) rows = rows.filter((a) => a.source === opts.source)
rows.sort((a, b) => (a.raisedAt < b.raisedAt ? 1 : -1))
return rows.slice(0, limit)
}
const where: string[] = []
const params: unknown[] = []
if (opts.status === 'open') {
params.push(OPEN_STATUSES)
where.push(`status = ANY($${params.length}::text[])`)
} else if (opts.status) {
params.push(opts.status)
where.push(`status = $${params.length}`)
}
if (opts.source) {
params.push(opts.source)
where.push(`source = $${params.length}`)
}
params.push(limit)
const sql = `SELECT * FROM alarms
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY raised_at DESC
LIMIT $${params.length}`
const { rows } = await query<Record<string, unknown>>(sql, params)
return rows.map(mapPgRow)
}
export async function ackAlarm(id: string, by: string): Promise<AlarmRecord | null> {
const t = nowIso()
if (!isPgEnabled()) {
ensureMem()
const a = mem.find((x) => x.id === id)
if (!a || a.status === 'cleared') return null
a.status = 'acked'
a.ackedAt = t
a.ackedBy = by
a.updatedAt = t
saveFile()
return a
}
const { rows } = await query<Record<string, unknown>>(
`UPDATE alarms SET status='acked', acked_at=$2, acked_by=$3, updated_at=$2
WHERE id=$1 AND status IN ('active','acked')
RETURNING *`,
[id, t, by],
)
return rows[0] ? mapPgRow(rows[0]) : null
}
/** 告警来源:后续门禁/空调等也走同一张表 */
export type AlarmSource = 'point' | 'access' | 'hvac' | 'door' | 'system'
export type AlarmLevel = 'red' | 'amber' | 'info'
/** active=未处理;acked=已确认仍未恢复;cleared=已恢复/关闭 */
export type AlarmStatus = 'active' | 'acked' | 'cleared'
/** 统一告警记录(PG alarms 表 / 文件 alarms.json) */
export interface AlarmRecord {
id: string
source: AlarmSource
/** 同 source 下去重键,如 point:{pointId}:{kind} */
sourceKey: string
level: AlarmLevel
status: AlarmStatus
title: string
message: string
deviceId: string | null
pointId: string | null
itemId: string | null
value: number | null
thresholdLow: number | null
thresholdHigh: number | null
kind: string | null
payload: Record<string, unknown>
raisedAt: string
updatedAt: string
clearedAt: string | null
ackedAt: string | null
ackedBy: string | null
}
...@@ -40,6 +40,7 @@ export const config = { ...@@ -40,6 +40,7 @@ export const config = {
videoFile: path.resolve(__dirname, '../data/video.json'), videoFile: path.resolve(__dirname, '../data/video.json'),
lightFile: path.resolve(__dirname, '../data/light.json'), lightFile: path.resolve(__dirname, '../data/light.json'),
devicesFile: path.resolve(__dirname, '../data/devices.json'), devicesFile: path.resolve(__dirname, '../data/devices.json'),
alarmsFile: path.resolve(__dirname, '../data/alarms.json'),
/** 现场协议 CSV 目录(默认:仓库旁「设备对接」协议包) */ /** 现场协议 CSV 目录(默认:仓库旁「设备对接」协议包) */
devicePointsDir: process.env.DEVICE_POINTS_DIR devicePointsDir: process.env.DEVICE_POINTS_DIR
? path.resolve(process.env.DEVICE_POINTS_DIR) ? path.resolve(process.env.DEVICE_POINTS_DIR)
......
...@@ -130,6 +130,8 @@ CREATE TABLE IF NOT EXISTS device_points ( ...@@ -130,6 +130,8 @@ CREATE TABLE IF NOT EXISTS device_points (
rw TEXT NOT NULL DEFAULT 'r', rw TEXT NOT NULL DEFAULT 'r',
energy_kind TEXT, energy_kind TEXT,
scale_override DOUBLE PRECISION, scale_override DOUBLE PRECISION,
alarm_low DOUBLE PRECISION,
alarm_high DOUBLE PRECISION,
enabled BOOLEAN NOT NULL DEFAULT TRUE, enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_raw DOUBLE PRECISION, last_raw DOUBLE PRECISION,
last_eng DOUBLE PRECISION, last_eng DOUBLE PRECISION,
...@@ -140,10 +142,45 @@ CREATE TABLE IF NOT EXISTS device_points ( ...@@ -140,10 +142,45 @@ CREATE TABLE IF NOT EXISTS device_points (
CREATE UNIQUE INDEX IF NOT EXISTS idx_device_points_item ON device_points (item_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_device_points_item ON device_points (item_id);
CREATE INDEX IF NOT EXISTS idx_device_points_gateway ON device_points (gateway_id, modbus_address); CREATE INDEX IF NOT EXISTS idx_device_points_gateway ON device_points (gateway_id, modbus_address);
CREATE INDEX IF NOT EXISTS idx_device_points_device ON device_points (device_id); CREATE INDEX IF NOT EXISTS idx_device_points_device ON device_points (device_id);
CREATE TABLE IF NOT EXISTS alarms (
id UUID PRIMARY KEY,
source TEXT NOT NULL,
source_key TEXT NOT NULL,
level TEXT NOT NULL CHECK (level IN ('red', 'amber', 'info')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'acked', 'cleared')),
title TEXT NOT NULL,
message TEXT NOT NULL,
device_id TEXT,
point_id UUID,
item_id TEXT,
value DOUBLE PRECISION,
threshold_low DOUBLE PRECISION,
threshold_high DOUBLE PRECISION,
kind TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
raised_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
cleared_at TIMESTAMPTZ,
acked_at TIMESTAMPTZ,
acked_by TEXT
);
CREATE INDEX IF NOT EXISTS idx_alarms_raised ON alarms (raised_at DESC);
CREATE INDEX IF NOT EXISTS idx_alarms_status_level ON alarms (status, level);
CREATE INDEX IF NOT EXISTS idx_alarms_source ON alarms (source, raised_at DESC);
CREATE INDEX IF NOT EXISTS idx_alarms_device ON alarms (device_id, raised_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_alarms_open_source_key
ON alarms (source, source_key) WHERE status IN ('active', 'acked');
`
const ALTER = `
ALTER TABLE device_points ADD COLUMN IF NOT EXISTS alarm_low DOUBLE PRECISION;
ALTER TABLE device_points ADD COLUMN IF NOT EXISTS alarm_high DOUBLE PRECISION;
` `
export async function migrate() { export async function migrate() {
await query(SQL) await query(SQL)
await query(ALTER)
console.log('[db] schema migrated') console.log('[db] schema migrated')
} }
......
/** 按点名给出合理默认告警高低限(与量程字段分离;null=不启用该侧) */
export function defaultAlarmLimits(
name: string,
itemId = '',
): { alarmLow: number | null; alarmHigh: number | null } {
const n = `${name} ${itemId}`
if (/设定|上限报警|下限报警|高限|低限|目标/.test(n)) {
return { alarmLow: null, alarmHigh: null }
}
if (/冰箱|冷链|fridge/i.test(n) && /温度|Temp/i.test(n)) {
return { alarmLow: -25, alarmHigh: -18 }
}
if (/冰箱|冷链|fridge/i.test(n) && /湿度|Humid/i.test(n)) {
return { alarmLow: null, alarmHigh: 80 }
}
if (/压差/.test(n)) {
return { alarmLow: -30, alarmHigh: -5 }
}
if (/温度|Temp/i.test(n)) {
return { alarmLow: 16, alarmHigh: 28 }
}
if (/湿度|Humid/i.test(n)) {
return { alarmLow: 30, alarmHigh: 70 }
}
return { alarmLow: null, alarmHigh: null }
}
import { listPoints } from './store.js'
import type { DevicePoint } from './types.js'
export type PointAlarmLevel = 'red' | 'amber'
export type PointAlarmKind = 'low' | 'high'
export interface PointAlarm {
pointId: string
itemId: string
deviceId: string | null
name: string
value: number
alarmLow: number | null
alarmHigh: number | null
kind: PointAlarmKind
level: PointAlarmLevel
message: string
ts: string
}
function levelFor(p: DevicePoint, kind: PointAlarmKind): PointAlarmLevel {
const n = `${p.name} ${p.itemId}`
if (/冰箱|冷链|压差|fridge/i.test(n)) return 'red'
if (kind === 'high' && /温度|湿度/.test(n)) return 'amber'
return 'amber'
}
/** 根据点位 alarmLow/alarmHigh 与 lastEng 判定当前超限告警 */
export function evaluateAlarmsFromPoints(points: DevicePoint[]): PointAlarm[] {
const out: PointAlarm[] = []
for (const p of points) {
if (!p.enabled) continue
if (p.lastEng === null || p.lastEng === undefined || Number.isNaN(Number(p.lastEng))) continue
const hasLow = p.alarmLow !== null && p.alarmLow !== undefined
const hasHigh = p.alarmHigh !== null && p.alarmHigh !== undefined
if (!hasLow && !hasHigh) continue
const v = Number(p.lastEng)
const ts = p.lastTs || new Date().toISOString()
if (hasLow && v < Number(p.alarmLow)) {
out.push({
pointId: p.id,
itemId: p.itemId,
deviceId: p.deviceId,
name: p.name,
value: v,
alarmLow: p.alarmLow,
alarmHigh: p.alarmHigh,
kind: 'low',
level: levelFor(p, 'low'),
message: `${p.name} 低于下限(${v} < ${p.alarmLow})`,
ts,
})
}
if (hasHigh && v > Number(p.alarmHigh)) {
out.push({
pointId: p.id,
itemId: p.itemId,
deviceId: p.deviceId,
name: p.name,
value: v,
alarmLow: p.alarmLow,
alarmHigh: p.alarmHigh,
kind: 'high',
level: levelFor(p, 'high'),
message: `${p.name} 超过上限(${v} > ${p.alarmHigh})`,
ts,
})
}
}
return out
}
export async function evaluatePointAlarms(): Promise<PointAlarm[]> {
const points = await listPoints()
return evaluateAlarmsFromPoints(points)
}
...@@ -3,6 +3,7 @@ import path from 'node:path' ...@@ -3,6 +3,7 @@ import path from 'node:path'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { config } from '../config.js' import { config } from '../config.js'
import { decodeCsvBuffer, parsePointsCsv, type ParsedPointRow } from './csv.js' import { decodeCsvBuffer, parsePointsCsv, type ParsedPointRow } from './csv.js'
import { defaultAlarmLimits } from './alarmDefaults.js'
import type { Device, DeviceCategory, DevicePoint, DevicesBundle, Gateway } from './types.js' import type { Device, DeviceCategory, DevicePoint, DevicesBundle, Gateway } from './types.js'
function now() { function now() {
...@@ -197,6 +198,7 @@ function pointFromRow( ...@@ -197,6 +198,7 @@ function pointFromRow(
? 'cooling' ? 'cooling'
: null, : null,
scaleOverride: resolveScaleOverride(row), scaleOverride: resolveScaleOverride(row),
...defaultAlarmLimits(row.name, row.itemId),
enabled: true, enabled: true,
lastRaw: null, lastRaw: null,
lastEng: null, lastEng: null,
......
...@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto' ...@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'
import { config } from '../config.js' import { config } from '../config.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 { defaultAlarmLimits } from './alarmDefaults.js'
import { import {
decodeCsvBuffer, decodeCsvBuffer,
exportPointsCsv, exportPointsCsv,
...@@ -33,10 +34,39 @@ function now() { ...@@ -33,10 +34,39 @@ function now() {
return new Date().toISOString() return new Date().toISOString()
} }
function normalizePoint(p: Partial<DevicePoint> & Pick<DevicePoint, 'name' | 'itemId'>): DevicePoint {
// 旧 devices.json 可能缺字段;缺则按点名回填默认高低限
const hasAlarmFields = Object.prototype.hasOwnProperty.call(p, 'alarmLow') ||
Object.prototype.hasOwnProperty.call(p, 'alarmHigh')
const defaults = hasAlarmFields
? {
alarmLow: p.alarmLow ?? null,
alarmHigh: p.alarmHigh ?? null,
}
: defaultAlarmLimits(p.name, p.itemId)
return {
...(p as DevicePoint),
alarmLow: defaults.alarmLow,
alarmHigh: defaults.alarmHigh,
}
}
function loadFile(): DevicesBundle | null { function loadFile(): DevicesBundle | null {
try { try {
if (!fs.existsSync(config.devicesFile)) return null if (!fs.existsSync(config.devicesFile)) return null
return JSON.parse(fs.readFileSync(config.devicesFile, 'utf8')) as DevicesBundle const raw = JSON.parse(fs.readFileSync(config.devicesFile, 'utf8')) as DevicesBundle
const needsBackfill = (raw.points || []).some(
(p) => !('alarmLow' in p) && !('alarmHigh' in p),
)
const bundle: DevicesBundle = {
...raw,
points: (raw.points || []).map((p) => normalizePoint(p as DevicePoint)),
}
if (needsBackfill) {
fs.mkdirSync(config.dataDir, { recursive: true })
fs.writeFileSync(config.devicesFile, JSON.stringify(bundle, null, 2), 'utf8')
}
return bundle
} catch { } catch {
return null return null
} }
...@@ -167,6 +197,8 @@ function mapPt(r: Record<string, unknown>): DevicePoint { ...@@ -167,6 +197,8 @@ function mapPt(r: Record<string, unknown>): DevicePoint {
rw: (r.rw as PointRw) || 'r', rw: (r.rw as PointRw) || 'r',
energyKind: (r.energy_kind as EnergyKind) ?? null, energyKind: (r.energy_kind as EnergyKind) ?? null,
scaleOverride: r.scale_override === null || r.scale_override === undefined ? null : Number(r.scale_override), scaleOverride: r.scale_override === null || r.scale_override === undefined ? null : Number(r.scale_override),
alarmLow: r.alarm_low === null || r.alarm_low === undefined ? null : Number(r.alarm_low),
alarmHigh: r.alarm_high === null || r.alarm_high === undefined ? null : Number(r.alarm_high),
enabled: Boolean(r.enabled), enabled: Boolean(r.enabled),
lastRaw: r.last_raw === null || r.last_raw === undefined ? null : Number(r.last_raw), lastRaw: r.last_raw === null || r.last_raw === undefined ? null : Number(r.last_raw),
lastEng: r.last_eng === null || r.last_eng === undefined ? null : Number(r.last_eng), lastEng: r.last_eng === null || r.last_eng === undefined ? null : Number(r.last_eng),
...@@ -197,6 +229,29 @@ function isStubFridgeOnly(bundle: { devices: Device[]; points: DevicePoint[] }): ...@@ -197,6 +229,29 @@ function isStubFridgeOnly(bundle: { devices: Device[]; points: DevicePoint[] }):
return allFridge && bundle.devices.length <= 8 && bundle.points.length <= 20 return allFridge && bundle.devices.length <= 8 && bundle.points.length <= 20
} }
/** ALTER 后全表 alarm_* 均为 NULL 时,按点名回填默认高低限(仅一次启发式) */
async function backfillAlarmLimitsPgOnce() {
const { rows } = await query<{ both_null: string; total: string }>(
`SELECT
COUNT(*) FILTER (WHERE alarm_low IS NULL AND alarm_high IS NULL)::text AS both_null,
COUNT(*)::text AS total
FROM device_points`,
)
const bothNull = Number(rows[0]?.both_null || 0)
const total = Number(rows[0]?.total || 0)
if (!total || bothNull !== total) return
const points = await listPoints()
let n = 0
for (const p of points) {
const d = defaultAlarmLimits(p.name, p.itemId)
if (d.alarmLow === null && d.alarmHigh === null) continue
await updatePoint(p.id, d)
n += 1
}
if (n) console.log(`[devices] backfilled alarm limits for ${n}/${total} points`)
}
async function writeSeedPg(seed: DevicesBundle) { async function writeSeedPg(seed: DevicesBundle) {
for (const g of seed.gateways) { for (const g of seed.gateways) {
await query( await query(
...@@ -250,6 +305,8 @@ export async function ensureDevicesSeed() { ...@@ -250,6 +305,8 @@ export async function ensureDevicesSeed() {
console.log( console.log(
`[devices] seeded ${seed.gateways.length} gateways, ${seed.devices.length} devices, ${seed.points.length} points`, `[devices] seeded ${seed.gateways.length} gateways, ${seed.devices.length} devices, ${seed.points.length} points`,
) )
} else {
await backfillAlarmLimitsPgOnce()
} }
return return
} }
...@@ -290,10 +347,11 @@ async function insertPointPg(p: DevicePoint) { ...@@ -290,10 +347,11 @@ async function insertPointPg(p: DevicePoint) {
`INSERT INTO device_points ( `INSERT INTO device_points (
id, gateway_id, device_id, name, reg_type, reg_address, data_type, modbus_id, modbus_register, id, gateway_id, device_id, name, reg_type, reg_address, data_type, modbus_id, modbus_register,
modbus_address, description, mask_bit, bit, scale, bias, min_raw_range, max_raw_range, modbus_address, description, mask_bit, bit, scale, bias, min_raw_range, max_raw_range,
min_eng_range, max_eng_range, item_id, snapshot_path, rw, energy_kind, scale_override, enabled, min_eng_range, max_eng_range, item_id, snapshot_path, rw, energy_kind, scale_override,
alarm_low, alarm_high, enabled,
created_at, updated_at created_at, updated_at
) VALUES ( ) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,NOW(),NOW() $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,NOW(),NOW()
) ON CONFLICT (id) DO NOTHING`, ) ON CONFLICT (id) DO NOTHING`,
[ [
p.id, p.id,
...@@ -320,6 +378,8 @@ async function insertPointPg(p: DevicePoint) { ...@@ -320,6 +378,8 @@ async function insertPointPg(p: DevicePoint) {
p.rw, p.rw,
p.energyKind, p.energyKind,
p.scaleOverride, p.scaleOverride,
p.alarmLow,
p.alarmHigh,
p.enabled, p.enabled,
], ],
) )
...@@ -532,6 +592,7 @@ function pointFromParsed( ...@@ -532,6 +592,7 @@ function pointFromParsed(
!/float|double|bool/i.test(row.dataType) !/float|double|bool/i.test(row.dataType)
? 0.1 ? 0.1
: null, : null,
...defaultAlarmLimits(row.name, row.itemId),
enabled: true, enabled: true,
lastRaw: null, lastRaw: null,
lastEng: null, lastEng: null,
...@@ -570,6 +631,14 @@ export async function createPoint( ...@@ -570,6 +631,14 @@ export async function createPoint(
rw: input.rw || 'r', rw: input.rw || 'r',
energyKind: input.energyKind ?? null, energyKind: input.energyKind ?? null,
scaleOverride: input.scaleOverride ?? null, scaleOverride: input.scaleOverride ?? null,
alarmLow:
input.alarmLow !== undefined
? input.alarmLow
: defaultAlarmLimits(input.name, input.itemId || '').alarmLow,
alarmHigh:
input.alarmHigh !== undefined
? input.alarmHigh
: defaultAlarmLimits(input.name, input.itemId || '').alarmHigh,
enabled: input.enabled ?? true, enabled: input.enabled ?? true,
lastRaw: null, lastRaw: null,
lastEng: null, lastEng: null,
...@@ -613,7 +682,8 @@ export async function updatePoint( ...@@ -613,7 +682,8 @@ export async function updatePoint(
gateway_id=$2, device_id=$3, name=$4, reg_type=$5, reg_address=$6, data_type=$7, gateway_id=$2, device_id=$3, name=$4, reg_type=$5, reg_address=$6, data_type=$7,
modbus_id=$8, modbus_register=$9, modbus_address=$10, description=$11, mask_bit=$12, bit=$13, modbus_id=$8, modbus_register=$9, modbus_address=$10, description=$11, mask_bit=$12, bit=$13,
scale=$14, bias=$15, min_raw_range=$16, max_raw_range=$17, min_eng_range=$18, max_eng_range=$19, scale=$14, bias=$15, min_raw_range=$16, max_raw_range=$17, min_eng_range=$18, max_eng_range=$19,
item_id=$20, snapshot_path=$21, rw=$22, energy_kind=$23, scale_override=$24, enabled=$25, updated_at=NOW() item_id=$20, snapshot_path=$21, rw=$22, energy_kind=$23, scale_override=$24,
alarm_low=$25, alarm_high=$26, enabled=$27, updated_at=NOW()
WHERE id=$1`, WHERE id=$1`,
[ [
id, id,
...@@ -640,6 +710,8 @@ export async function updatePoint( ...@@ -640,6 +710,8 @@ export async function updatePoint(
next.rw, next.rw,
next.energyKind, next.energyKind,
next.scaleOverride, next.scaleOverride,
next.alarmLow,
next.alarmHigh,
next.enabled, next.enabled,
], ],
) )
......
...@@ -69,6 +69,10 @@ export interface DevicePoint { ...@@ -69,6 +69,10 @@ export interface DevicePoint {
rw: PointRw rw: PointRw
energyKind: EnergyKind energyKind: EnergyKind
scaleOverride: number | null scaleOverride: number | null
/** 告警下限(工程值);null=不启用 */
alarmLow: number | null
/** 告警上限(工程值);null=不启用 */
alarmHigh: number | null
enabled: boolean enabled: boolean
lastRaw: number | null lastRaw: number | null
lastEng: number | null lastEng: number | null
......
...@@ -21,6 +21,7 @@ import { videoRouter } from './routes/video.js' ...@@ -21,6 +21,7 @@ import { videoRouter } from './routes/video.js'
import { lightRouter } from './routes/light.js' import { lightRouter } from './routes/light.js'
import { devicesRouter } from './routes/devices.js' import { devicesRouter } from './routes/devices.js'
import { gatewayRouter } from './routes/gateway.js' import { gatewayRouter } from './routes/gateway.js'
import { alarmsRouter } from './routes/alarms.js'
import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js' import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js'
async function bootstrap() { async function bootstrap() {
...@@ -67,6 +68,7 @@ async function bootstrap() { ...@@ -67,6 +68,7 @@ async function bootstrap() {
app.use('/api/video', videoRouter) app.use('/api/video', videoRouter)
app.use('/api/light', lightRouter) app.use('/api/light', lightRouter)
app.use('/api/devices', devicesRouter) app.use('/api/devices', devicesRouter)
app.use('/api/alarms', alarmsRouter)
/** Windows 网关上行:拉点表 / 推测点落库 */ /** Windows 网关上行:拉点表 / 推测点落库 */
app.use('/api/gateway', gatewayRouter) app.use('/api/gateway', gatewayRouter)
......
...@@ -149,5 +149,6 @@ export function generateSnapshot(): RealtimeSnapshot { ...@@ -149,5 +149,6 @@ export function generateSnapshot(): RealtimeSnapshot {
devicesTotal: 45, devicesTotal: 45,
gatewayOk: true, gatewayOk: true,
}, },
pointAlarms: [],
} }
} }
...@@ -4,6 +4,8 @@ import { config } from '../config.js' ...@@ -4,6 +4,8 @@ import { config } from '../config.js'
import { persistSnapshot, persistWriteLog, pruneCollectData } from '../db/collect.js' import { persistSnapshot, persistWriteLog, pruneCollectData } from '../db/collect.js'
import { getCollectPollMs } from '../settings/store.js' import { getCollectPollMs } from '../settings/store.js'
import { fetchGatewayHealth, fetchGatewaySnapshot, resolveGatewayBase } from '../gateway/client.js' import { fetchGatewayHealth, fetchGatewaySnapshot, resolveGatewayBase } from '../gateway/client.js'
import { syncPointAlarms } from '../alarms/store.js'
import { evaluatePointAlarms } from '../devices/alarms.js'
import { import {
applyMockPointTelemetry, applyMockPointTelemetry,
applyPointTelemetry, applyPointTelemetry,
...@@ -71,6 +73,27 @@ function parseGatewayValues(gwSnap: unknown): PointTelemetrySample[] { ...@@ -71,6 +73,27 @@ function parseGatewayValues(gwSnap: unknown): PointTelemetrySample[] {
return out return out
} }
async function attachPointAlarms() {
try {
const current = await evaluatePointAlarms()
snapshot.pointAlarms = current
try {
await syncPointAlarms(current)
} catch (e) {
console.warn('[realtime] sync alarms table failed', e)
}
} catch (e) {
console.warn('[realtime] evaluate point alarms failed', e)
snapshot.pointAlarms = []
}
}
/** 网关主动推送遥测后立即重算点位超限告警并广播 */
export async function refreshPointAlarms() {
await attachPointAlarms()
publish()
}
async function tick() { async function tick() {
if (tickBusy) return if (tickBusy) return
tickBusy = true tickBusy = true
...@@ -80,9 +103,11 @@ async function tick() { ...@@ -80,9 +103,11 @@ async function tick() {
if (useGatewayMode()) snapshot.online.gatewayOk = false if (useGatewayMode()) snapshot.online.gatewayOk = false
// 仅 mock 模式本地造数;gateway 模式优先等 Windows 网关 POST /api/gateway/telemetry // 仅 mock 模式本地造数;gateway 模式优先等 Windows 网关 POST /api/gateway/telemetry
if (config.dataMode === 'mock') { if (config.dataMode === 'mock') {
void applyMockPointTelemetry().catch((e) => try {
console.warn('[realtime] mock point telemetry failed', e), await applyMockPointTelemetry()
) } catch (e) {
console.warn('[realtime] mock point telemetry failed', e)
}
} }
} else { } else {
const [health, gwSnap] = await Promise.all([fetchGatewayHealth(), fetchGatewaySnapshot()]) const [health, gwSnap] = await Promise.all([fetchGatewayHealth(), fetchGatewaySnapshot()])
...@@ -95,13 +120,16 @@ async function tick() { ...@@ -95,13 +120,16 @@ async function tick() {
gwSnap as { values?: unknown } gwSnap as { values?: unknown }
).values ).values
} }
// 拉取兜底(网关也会主动推送到 /api/gateway/telemetry) // 拉取兜底(网关也会主动推送到 /api/gateway/telemetry);先写完再判定告警
if (samples.length) { if (samples.length) {
void applyPointTelemetry(samples).catch((e) => try {
console.warn('[realtime] gateway point telemetry failed', e), await applyPointTelemetry(samples)
) } catch (e) {
console.warn('[realtime] gateway point telemetry failed', e)
}
} }
} }
await attachPointAlarms()
publish() publish()
void persistSafe(snapshot) void persistSafe(snapshot)
} finally { } finally {
......
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { ackAlarm, listAlarms } from '../alarms/store.js'
import type { AlarmSource, AlarmStatus } from '../alarms/types.js'
export const alarmsRouter = Router()
/** 告警列表(统一表);大屏可匿名拉 open,后台需登录 */
alarmsRouter.get('/', async (req, res) => {
const statusRaw = typeof req.query.status === 'string' ? req.query.status : 'open'
const sourceRaw = typeof req.query.source === 'string' ? req.query.source : undefined
const limit = req.query.limit ? Number(req.query.limit) : 100
const status =
statusRaw === 'all'
? undefined
: (statusRaw as AlarmStatus | 'open')
const source = sourceRaw as AlarmSource | undefined
try {
const alarms = await listAlarms({ status, source, limit })
res.json({ alarms, count: alarms.length })
} catch (e) {
res.status(500).json({ message: e instanceof Error ? e.message : '查询失败' })
}
})
alarmsRouter.post('/:id/ack', authRequired, requireRole('admin'), async (req, res) => {
try {
const row = await ackAlarm(req.params.id, req.user!.username)
if (!row) {
res.status(404).json({ message: '告警不存在或已关闭' })
return
}
res.json({ ok: true, alarm: row })
} catch (e) {
res.status(500).json({ message: e instanceof Error ? e.message : '确认失败' })
}
})
...@@ -5,6 +5,7 @@ import { ...@@ -5,6 +5,7 @@ import {
getBundleForGateway, getBundleForGateway,
type PointTelemetrySample, type PointTelemetrySample,
} from '../devices/store.js' } from '../devices/store.js'
import { refreshPointAlarms } from '../realtime/store.js'
export const gatewayRouter = Router() export const gatewayRouter = Router()
...@@ -64,6 +65,11 @@ gatewayRouter.post('/telemetry', async (req, res) => { ...@@ -64,6 +65,11 @@ gatewayRouter.post('/telemetry', async (req, res) => {
} }
try { try {
const updated = await applyPointTelemetry(samples) const updated = await applyPointTelemetry(samples)
if (updated > 0) {
void refreshPointAlarms().catch((e) =>
console.warn('[gateway] refresh point alarms failed', e),
)
}
res.json({ res.json({
ok: true, ok: true,
updated, updated,
......
...@@ -66,6 +66,20 @@ export interface AccessEvent { ...@@ -66,6 +66,20 @@ export interface AccessEvent {
result: 'success' | 'denied' | 'alarm' result: 'success' | 'denied' | 'alarm'
} }
export interface PointAlarmSnapshot {
pointId: string
itemId: string
deviceId: string | null
name: string
value: number
alarmLow: number | null
alarmHigh: number | null
kind: 'low' | 'high'
level: 'red' | 'amber'
message: string
ts: string
}
export interface RealtimeSnapshot { export interface RealtimeSnapshot {
ts: number ts: number
rooms: RoomSnapshot[] rooms: RoomSnapshot[]
...@@ -101,4 +115,6 @@ export interface RealtimeSnapshot { ...@@ -101,4 +115,6 @@ export interface RealtimeSnapshot {
devicesTotal: number devicesTotal: number
gatewayOk: boolean gatewayOk: boolean
} }
/** 点位阈值超限(采集后判定) */
pointAlarms?: PointAlarmSnapshot[]
} }
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<meta name="theme-color" content="#0a1624" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>分子实验室三维可视化管理平台</title> <title>分子实验室三维可视化管理平台</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
......
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="JH-LAB">
\ No newline at end of file <defs>
<linearGradient id="bg" x1="6" y1="2" x2="28" y2="30" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#123048"/>
<stop offset="55%" stop-color="#0a1624"/>
<stop offset="100%" stop-color="#050b14"/>
</linearGradient>
<linearGradient id="hex" x1="8" y1="6" x2="24" y2="26" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#7ADBFF"/>
<stop offset="100%" stop-color="#2BB8F0"/>
</linearGradient>
<radialGradient id="core" cx="50%" cy="42%" r="55%">
<stop offset="0%" stop-color="#6FF5C8"/>
<stop offset="70%" stop-color="#2EE6A8"/>
<stop offset="100%" stop-color="#1AB88A"/>
</radialGradient>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="1.1" result="b"/>
<feMerge>
<feMergeNode in="b"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 圆角底板,适配浏览器标签小尺寸 -->
<rect width="32" height="32" rx="7" fill="url(#bg)"/>
<rect x="0.75" y="0.75" width="30.5" height="30.5" rx="6.25" fill="none" stroke="#37C8FF" stroke-opacity="0.35" stroke-width="1"/>
<!-- 六边形晶体:实验室三维可视化 -->
<g filter="url(#glow)">
<path
d="M16 5.2 25.6 10.8v10.4L16 26.8 6.4 21.2V10.8L16 5.2Z"
fill="rgba(55,200,255,0.08)"
stroke="url(#hex)"
stroke-width="1.7"
stroke-linejoin="round"
/>
<path
d="M16 5.2v11.2M16 16.4 25.6 10.8M16 16.4 6.4 21.2"
stroke="#37C8FF"
stroke-width="1.15"
stroke-opacity="0.55"
stroke-linecap="round"
/>
<circle cx="16" cy="16.4" r="3.1" fill="url(#core)"/>
<circle cx="16" cy="16.4" r="1.15" fill="#E8FFF6" fill-opacity="0.9"/>
</g>
</svg>
...@@ -29,6 +29,7 @@ const empty: RealtimeSnapshot = { ...@@ -29,6 +29,7 @@ const empty: RealtimeSnapshot = {
}, },
access: { doors: [], todayPassCount: 0, recentEvents: [] }, access: { doors: [], todayPassCount: 0, recentEvents: [] },
online: { devicesOnline: 0, devicesTotal: 0, gatewayOk: false }, online: { devicesOnline: 0, devicesTotal: 0, gatewayOk: false },
pointAlarms: [],
} }
export const useRealtimeStore = defineStore('realtime', () => { export const useRealtimeStore = defineStore('realtime', () => {
...@@ -39,6 +40,8 @@ export const useRealtimeStore = defineStore('realtime', () => { ...@@ -39,6 +40,8 @@ export const useRealtimeStore = defineStore('realtime', () => {
let retryTimer: number | undefined let retryTimer: number | undefined
const alarmCount = computed(() => { const alarmCount = computed(() => {
// 后端已下发 pointAlarms(可为空数组)时以点位阈值为准
if (Array.isArray(snapshot.value.pointAlarms)) return snapshot.value.pointAlarms.length
const roomAlarms = snapshot.value.rooms.reduce((n, r) => n + r.alarms.length, 0) const roomAlarms = snapshot.value.rooms.reduce((n, r) => n + r.alarms.length, 0)
const fridgeWarn = snapshot.value.fridges.filter((f) => f.temp > -19).length const fridgeWarn = snapshot.value.fridges.filter((f) => f.temp > -19).length
return roomAlarms + fridgeWarn + snapshot.value.hvac.alarms.length return roomAlarms + fridgeWarn + snapshot.value.hvac.alarms.length
......
...@@ -54,6 +54,20 @@ export interface AccessEvent { ...@@ -54,6 +54,20 @@ export interface AccessEvent {
result: 'success' | 'denied' | 'alarm' result: 'success' | 'denied' | 'alarm'
} }
export interface PointAlarmSnapshot {
pointId: string
itemId: string
deviceId: string | null
name: string
value: number
alarmLow: number | null
alarmHigh: number | null
kind: 'low' | 'high'
level: 'red' | 'amber'
message: string
ts: string
}
export interface RealtimeSnapshot { export interface RealtimeSnapshot {
ts: number ts: number
rooms: RoomSnapshot[] rooms: RoomSnapshot[]
...@@ -89,4 +103,6 @@ export interface RealtimeSnapshot { ...@@ -89,4 +103,6 @@ export interface RealtimeSnapshot {
devicesTotal: number devicesTotal: number
gatewayOk: boolean gatewayOk: boolean
} }
/** 点位阈值超限(采集后判定) */
pointAlarms?: PointAlarmSnapshot[]
} }
...@@ -36,6 +36,8 @@ interface DevicePoint { ...@@ -36,6 +36,8 @@ interface DevicePoint {
itemId: string itemId: string
scale: number scale: number
scaleOverride: number | null scaleOverride: number | null
alarmLow: number | null
alarmHigh: number | null
enabled: boolean enabled: boolean
lastEng: number | null lastEng: number | null
lastTs: string | null lastTs: string | null
...@@ -89,6 +91,8 @@ const pointForm = reactive({ ...@@ -89,6 +91,8 @@ const pointForm = reactive({
itemId: '', itemId: '',
scale: 0, scale: 0,
scaleOverride: 0.1 as number | null, scaleOverride: 0.1 as number | null,
alarmLow: null as number | null,
alarmHigh: null as number | null,
enabled: true, enabled: true,
}) })
...@@ -193,6 +197,8 @@ function openDialog(kind: Dialog) { ...@@ -193,6 +197,8 @@ function openDialog(kind: Dialog) {
pointForm.itemId = '' pointForm.itemId = ''
pointForm.scale = 0 pointForm.scale = 0
pointForm.scaleOverride = 0.1 pointForm.scaleOverride = 0.1
pointForm.alarmLow = null
pointForm.alarmHigh = null
pointForm.enabled = true pointForm.enabled = true
} }
dialog.value = kind dialog.value = kind
...@@ -271,6 +277,14 @@ async function savePoint() { ...@@ -271,6 +277,14 @@ async function savePoint() {
editPoint.value.scaleOverride === null || editPoint.value.scaleOverride === ('' as unknown) editPoint.value.scaleOverride === null || editPoint.value.scaleOverride === ('' as unknown)
? null ? null
: Number(editPoint.value.scaleOverride), : Number(editPoint.value.scaleOverride),
alarmLow:
editPoint.value.alarmLow === null || editPoint.value.alarmLow === ('' as unknown)
? null
: Number(editPoint.value.alarmLow),
alarmHigh:
editPoint.value.alarmHigh === null || editPoint.value.alarmHigh === ('' as unknown)
? null
: Number(editPoint.value.alarmHigh),
deviceId: editPoint.value.deviceId || null, deviceId: editPoint.value.deviceId || null,
enabled: editPoint.value.enabled, enabled: editPoint.value.enabled,
}), }),
...@@ -315,6 +329,8 @@ async function createPoint() { ...@@ -315,6 +329,8 @@ async function createPoint() {
itemId: pointForm.itemId || undefined, itemId: pointForm.itemId || undefined,
scale: Number(pointForm.scale), scale: Number(pointForm.scale),
scaleOverride: pointForm.scaleOverride, scaleOverride: pointForm.scaleOverride,
alarmLow: pointForm.alarmLow,
alarmHigh: pointForm.alarmHigh,
enabled: pointForm.enabled, enabled: pointForm.enabled,
}), }),
}) })
...@@ -575,6 +591,8 @@ onUnmounted(stopViewRefresh) ...@@ -575,6 +591,8 @@ onUnmounted(stopViewRefresh)
<th>设备</th> <th>设备</th>
<th>ItemID</th> <th>ItemID</th>
<th>倍率</th> <th>倍率</th>
<th>告警下限</th>
<th>告警上限</th>
<th>实时值</th> <th>实时值</th>
<th v-if="auth.isAdmin">操作</th> <th v-if="auth.isAdmin">操作</th>
</tr> </tr>
...@@ -587,6 +605,8 @@ onUnmounted(stopViewRefresh) ...@@ -587,6 +605,8 @@ onUnmounted(stopViewRefresh)
<td>{{ deviceName(p.deviceId) }}</td> <td>{{ deviceName(p.deviceId) }}</td>
<td class="mono">{{ p.itemId }}</td> <td class="mono">{{ p.itemId }}</td>
<td>{{ p.scaleOverride ?? p.scale }}</td> <td>{{ p.scaleOverride ?? p.scale }}</td>
<td>{{ p.alarmLow ?? '—' }}</td>
<td>{{ p.alarmHigh ?? '—' }}</td>
<td>{{ p.lastEng ?? '—' }}</td> <td>{{ p.lastEng ?? '—' }}</td>
<td v-if="auth.isAdmin" class="ops"> <td v-if="auth.isAdmin" class="ops">
<button class="btn ghost" type="button" @click="openEditPoint(p)">编辑</button> <button class="btn ghost" type="button" @click="openEditPoint(p)">编辑</button>
...@@ -594,7 +614,7 @@ onUnmounted(stopViewRefresh) ...@@ -594,7 +614,7 @@ onUnmounted(stopViewRefresh)
</td> </td>
</tr> </tr>
<tr v-if="!filteredPoints.length"> <tr v-if="!filteredPoints.length">
<td :colspan="auth.isAdmin ? 8 : 7" class="empty"> <td :colspan="auth.isAdmin ? 10 : 9" class="empty">
暂无点位,点击右上角「导入 CSV」或「新增点位」 暂无点位,点击右上角「导入 CSV」或「新增点位」
</td> </td>
</tr> </tr>
...@@ -662,6 +682,8 @@ onUnmounted(stopViewRefresh) ...@@ -662,6 +682,8 @@ onUnmounted(stopViewRefresh)
<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>ItemID</th> <th>ItemID</th>
...@@ -676,6 +698,8 @@ onUnmounted(stopViewRefresh) ...@@ -676,6 +698,8 @@ onUnmounted(stopViewRefresh)
<td class="eng" :class="{ muted: p.lastEng === null }"> <td class="eng" :class="{ muted: p.lastEng === null }">
{{ formatEng(p.lastEng) }} {{ formatEng(p.lastEng) }}
</td> </td>
<td>{{ p.alarmLow ?? '—' }}</td>
<td>{{ p.alarmHigh ?? '—' }}</td>
<td>{{ p.scaleOverride ?? p.scale }}</td> <td>{{ p.scaleOverride ?? p.scale }}</td>
<td class="mono ts">{{ formatTs(p.lastTs) }}</td> <td class="mono ts">{{ formatTs(p.lastTs) }}</td>
<td class="mono">{{ p.itemId }}</td> <td class="mono">{{ p.itemId }}</td>
...@@ -690,7 +714,7 @@ onUnmounted(stopViewRefresh) ...@@ -690,7 +714,7 @@ onUnmounted(stopViewRefresh)
</td> </td>
</tr> </tr>
<tr v-if="!selectedDevicePoints.length"> <tr v-if="!selectedDevicePoints.length">
<td :colspan="auth.isAdmin ? 8 : 7" class="empty">该设备暂无绑定点位</td> <td :colspan="auth.isAdmin ? 10 : 9" class="empty">该设备暂无绑定点位</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
...@@ -818,6 +842,14 @@ onUnmounted(stopViewRefresh) ...@@ -818,6 +842,14 @@ onUnmounted(stopViewRefresh)
<label>Scale 覆盖</label> <label>Scale 覆盖</label>
<input v-model.number="pointForm.scaleOverride" type="number" step="any" /> <input v-model.number="pointForm.scaleOverride" type="number" step="any" />
</div> </div>
<div class="field">
<label>告警下限</label>
<input v-model.number="pointForm.alarmLow" type="number" step="any" placeholder="空=不启用" />
</div>
<div class="field">
<label>告警上限</label>
<input v-model.number="pointForm.alarmHigh" type="number" step="any" placeholder="空=不启用" />
</div>
</div> </div>
<p v-if="error" class="msg err">{{ error }}</p> <p v-if="error" class="msg err">{{ error }}</p>
<div class="modal-actions"> <div class="modal-actions">
...@@ -857,6 +889,14 @@ onUnmounted(stopViewRefresh) ...@@ -857,6 +889,14 @@ onUnmounted(stopViewRefresh)
<input v-model.number="editPoint.scaleOverride" type="number" step="any" /> <input v-model.number="editPoint.scaleOverride" type="number" step="any" />
</div> </div>
<div class="field"> <div class="field">
<label>告警下限</label>
<input v-model.number="editPoint.alarmLow" type="number" step="any" placeholder="空=不启用" />
</div>
<div class="field">
<label>告警上限</label>
<input v-model.number="editPoint.alarmHigh" type="number" step="any" placeholder="空=不启用" />
</div>
<div class="field">
<label>设备</label> <label>设备</label>
<select v-model="editPoint.deviceId"> <select v-model="editPoint.deviceId">
<option :value="null">无</option> <option :value="null">无</option>
......
...@@ -26,6 +26,19 @@ const cameras = [ ...@@ -26,6 +26,19 @@ const cameras = [
const alarms = computed(() => { const alarms = computed(() => {
const items: { level: 'red' | 'amber' | 'calm'; title: string; sub: string; state: string }[] = [] const items: { level: 'red' | 'amber' | 'calm'; title: string; sub: string; state: string }[] = []
// 后端已下发 pointAlarms(含空数组)时以点位阈值为准
if (Array.isArray(snap.value.pointAlarms)) {
for (const a of snap.value.pointAlarms) {
items.push({
level: a.level === 'red' ? 'red' : 'amber',
title: a.message,
sub: `当前 ${a.value}${a.kind === 'high' ? ` > ${a.alarmHigh}` : ` < ${a.alarmLow}`}`,
state: '未消除',
})
}
return items
}
// 旧后端无 pointAlarms 字段时回退 Mock
for (const r of snap.value.rooms) { for (const r of snap.value.rooms) {
for (const a of r.alarms) { for (const a of r.alarms) {
items.push({ level: 'red', title: `${r.name}${a}`, sub: `压差 ${r.pressure.toFixed(1)} Pa`, state: '未消除' }) items.push({ level: 'red', title: `${r.name}${a}`, sub: `压差 ${r.pressure.toFixed(1)} Pa`, state: '未消除' })
......
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