/** * 设备状态池的**全局**数据源(维护者 2026-11-04)。 * * 为什么单独成 store:状态池是全站口径——顶部状态栏在四个一级模式里都常驻,不能挂在 * 「运行监测」的面板分区上,否则不进那个模式就不刷新(这正是本次要修的缺陷)。 * 因此本 store 自己持有两个全局读模型:监测概览台账(在线/离线)与运行概况异常清单 * (逐设备异常状态),在任何一级模式下都能取到同一份证据。 * * 语义(见 domain 的 deviceStatusPools):工单/报警/预警可同时存在,只有正常与离线互斥。 * 数据没到时 `pools.available:false`,界面如实降级,不补 0。 */ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import { fetchMonitoringOverview, fetchMonitoringSituation } from '../services/monitoringApi.js' import { fetchCurrentEvents } from '../services/eventApi.js' import { EVENT_FAMILY } from '../domain/events.js' import { deviceStatusPools } from '../domain/monitoringPanels.js' import { useMapStore } from './mapStore.js' export const useDeviceStatusStore = defineStore('deviceStatusStore', () => { /** 监测概览分区(逐设备 monitoringState) */ const overview = ref(null) /** 运行概况分区(逐设备异常状态) */ const situation = ref(null) /** 当前预警/报警事件(用于「设备打了什么标签」:预警=有预警事件的设备,报警=有未处理报警的设备) */ const warnings = ref(null) const alarms = ref(null) const loading = ref(false) const loaded = ref(false) /** 请求序号:换筛选时旧的在途响应作废。 */ let token = 0 /** * 三条读模型 → 设备证据。 * 地图那份由 mapStore 提供(它本来就在全模式加载),所以未关联设备的业务状态不必重取。 */ /** 从事件分区取出「涉及的设备编码」(去重)。事件行缺设备编码时不计数,不臆造。 */ function eventDeviceCodes(section) { const rows = section && section.data && section.data.sections && section.data.sections.events ? section.data.sections.events.data : null const codes = new Set() for (const row of rows || []) { const code = row && (row.deviceCode || '') if (code) codes.add(String(code).trim()) } return codes } /** * 设备标签口径(维护者 2026-11-05): * · 基础属性只有「正常 / 离线」二选一(按台账 monitoringState); * · 报警、预警、工单、独立设备是**可叠加标签**,一台设备可同时带上多个; * · 图例只数设备,不数监测点。 * 因此四类标签各自独立去重,互不排斥,也不与正常/离线互斥。 */ function records() { const mapStore = useMapStore() const list = [] const ledger = overview.value && overview.value.data && overview.value.data.devices for (const device of ledger || []) { list.push({ deviceCode: device && device.code, monitoringState: device && device.status }) } for (const object of mapStore.summary.objects || []) { if (object && object.kind === 'unassociated-equipment') { list.push({ deviceCode: object.code, labels: ['UNASSOCIATED'] }) } } return list } const pools = computed(() => { const ledger = overview.value && overview.value.data && overview.value.data.devices const devices = Array.isArray(ledger) ? ledger : [] if (!devices.length) return { available: false, counts: {}, total: null } const mapStore = useMapStore() const unassociated = new Set( (mapStore.summary.objects || []) .filter((object) => object && object.kind === 'unassociated-equipment' && object.code) .map((object) => String(object.code).trim()), ) const rowsOf = (section) => { const events = section && section.data && section.data.sections && section.data.sections.events return (events && events.data) || [] } const workOrders = new Set() for (const section of [warnings.value, alarms.value]) { for (const row of rowsOf(section)) { const brief = row && row.workOrder const state = brief && typeof brief.stateCode === 'number' ? brief.stateCode : null const open = state !== null && ((state >= 0 && state <= 4) || state === 6) if (open && row.deviceCode) workOrders.add(String(row.deviceCode).trim()) } } // 台账里的设备编码集合:**状态池只数设备**(维护者 2026-11-04/05 口径)。 // 为什么要拿它做交集:现网事件表里存着**不是设备**的编码(实测 15/16/20、 // TEST-CBYL5432168 —— 设备档案接口逐个 404,它们是历史脏值或类型码), // 若直接按事件行的 deviceCode 入池,预警池会多报 4 台「不存在的设备」(图例 7 台, // 而地图最多只有 2 个坐标点)。设备池里只该有设备台账里存在的设备。 const ledgerCodes = new Set(devices.map((device) => String((device && device.code) || '').trim()).filter(Boolean)) const onlyDevices = (codes) => new Set([...codes].filter((code) => ledgerCodes.has(code))) // 设备明细:基础属性(NORMAL/OFFLINE,互斥)+ 可叠加标签(ALARM/WARNING/WORK_ORDER/UNASSOCIATED) const tagSets = { ALARM: onlyDevices(eventDeviceCodes(alarms.value)), WARNING: onlyDevices(eventDeviceCodes(warnings.value)), WORK_ORDER: onlyDevices(workOrders), UNASSOCIATED: onlyDevices(unassociated), } const byCode = new Map() for (const device of devices) { // 台账行是**领域字段**:code/name/status(适配层已从 deviceCode/monitoringState 改名)。 const code = String((device && device.code) || '').trim() if (!code) continue const state = String((device && device.status) || '').toUpperCase() byCode.set(code, { deviceCode: code, deviceName: (device && device.name) || code, // 只有正常与离线互斥;其余按标签判断 base: state === 'OFFLINE' ? 'OFFLINE' : state === 'ONLINE' ? 'NORMAL' : '', tags: Object.keys(tagSets).filter((key) => tagSets[key].has(code)), }) } // 在线/离线按台账状态**显式入桶**,不用「总数-离线」倒推:那样任何没有状态 // (UNKNOWN/空)的设备都会被算成在线,把「不知道」冒充成「在线」。 let online = 0 let offline = 0 let unknownState = 0 for (const device of devices) { const state = String((device && device.status) || '').toUpperCase() if (state === 'ONLINE') online += 1 else if (state === 'OFFLINE') offline += 1 else unknownState += 1 } return { available: true, total: devices.length, devices: [...byCode.values()], counts: { // 六个池与 devices[] 的标签**同源同数**(都读 tagSets),不重复计算第二遍。 ALARM: tagSets.ALARM.size, WARNING: tagSets.WARNING.size, WORK_ORDER: tagSets.WORK_ORDER.size, NORMAL: online, OFFLINE: offline, // 台账没给状态(或给了认不出的值)的台数:不并入在线,也不并入离线。 UNKNOWN_STATE: unknownState, UNASSOCIATED: tagSets.UNASSOCIATED.size, }, } }) async function refresh(options = {}) { const mine = ++token loading.value = true try { const query = { industry: options.industry, scope: options.scope, deviceType: options.deviceType, config: options.config, fetchImpl: options.fetchImpl, } const [overviewSection, situationSection, warningSection, alarmSection] = await Promise.all([ fetchMonitoringOverview(query), fetchMonitoringSituation(query), fetchCurrentEvents({ mode: EVENT_FAMILY.WARNING, page: 1, pageSize: 1000, config: options.config, fetchImpl: options.fetchImpl }), fetchCurrentEvents({ mode: EVENT_FAMILY.ALARM, page: 1, pageSize: 1000, config: options.config, fetchImpl: options.fetchImpl }), ]) if (mine !== token) return pools.value overview.value = overviewSection situation.value = situationSection warnings.value = warningSection alarms.value = alarmSection loaded.value = true } finally { if (mine === token) loading.value = false } return pools.value } /** 仅测试/重置用。 */ function reset() { token += 1 overview.value = null situation.value = null loading.value = false loaded.value = false } /** * 按选中的状态行筛选设备(与图例同一份台账/标签)。 * 空集合=不筛;「无设备监测点」不参与设备筛选(它是点位特殊状态)。 * @param {string[]} keys 选中的图例键 * @returns {Set|null} 命中的设备编码集;null=不筛 */ function filterDeviceCodes(keys) { const all = Array.isArray(keys) ? keys.filter(Boolean) : [] if (!all.length) return null // 「无设备监测点」是点位特殊状态:只筛没有关联设备的空白点位,不参与设备标签匹配。 const emptyPoints = all.includes('EMPTY') const selected = all.filter((key) => key !== 'EMPTY') if (!selected.length) return { codes: new Set(), emptyPoints } const current = pools.value if (!current.available) return new Set() const codes = new Set() for (const device of current.devices || []) { const hit = selected.some((key) => (key === 'NORMAL' || key === 'OFFLINE') ? device.base === key : device.tags.includes(key)) if (hit) codes.add(device.deviceCode) } return { codes, emptyPoints } } return { overview, situation, pools, filterDeviceCodes, loading, loaded, refresh, reset } })