Jelajahi Sumber

fix(cockpit): 开发态GIS中间件路径穿越加固,点位特效顺序按维护者规则

localGisStatic 在 normalize 前显式拒绝父目录段(normalize 会吞掉绝对URL中的 /../);设备点显示序改为维护者2026-11-06《点位特效显示规则》:报警>工单>预警>独立设备,与监测点聚合序刻意分开;新增采集记录配色 domain(等级色与业务状态色分开,报警·未分级如实标注)。
ChenJZ 1 hari lalu
induk
melakukan
f7fc01fc64

+ 15 - 8
src/common/localGisStaticPlugin.js

@@ -50,14 +50,16 @@ export function parseGisMounts(raw) {
 
 /** 把请求路径解析成挂载目录内的真实文件;越界、目录、不存在返回 null。 */
 function resolveMountedFile(dir, requestUrl) {
-    const clean = decodeURIComponent((requestUrl || '').split('?')[0].split('#')[0])
-    const relative = path.posix.normalize(clean).replace(/^\/+/, '')
-    if (!relative || relative.startsWith('..')) return null
+    try {
+        const clean = decodeURIComponent((requestUrl || '').split('?')[0].split('#')[0])
+        // 先拒绝父目录段;normalize 会吞掉绝对 URL 路径中的 /../。
+        if (clean.split(/[\\/]/).includes('..')) return null
+        const relative = path.posix.normalize(clean).replace(/^\/+/, '')
+        if (!relative || relative.startsWith('..')) return null
 
-    const file = path.join(dir, relative)
-    if (!file.startsWith(dir + path.sep)) return null
+        const file = path.join(dir, relative)
+        if (!file.startsWith(dir + path.sep)) return null
 
-    try {
         return fs.statSync(file).isFile() ? file : null
     } catch {
         return null
@@ -83,9 +85,14 @@ export default function localGisStatic({ mounts, log } = {}) {
                 return
             }
             for (const mount of parsed) {
-                server.middlewares.use(mount.prefix, (req, res, next) => {
+                server.middlewares.use(mount.prefix, (req, res) => {
                     const file = resolveMountedFile(mount.dir, req.url)
-                    if (!file) return next()
+                    if (!file) {
+                        res.statusCode = 404
+                        res.setHeader('Content-Type', 'text/plain; charset=utf-8')
+                        res.end('Not Found')
+                        return
+                    }
                     res.setHeader('Content-Type', STATIC_MIME[path.extname(file).toLowerCase()] || 'application/octet-stream')
                     res.setHeader('Cache-Control', 'no-cache')
                     fs.createReadStream(file).pipe(res)

+ 11 - 3
src/modules/cockpit/domain/deviceStatus.js

@@ -36,12 +36,18 @@ export const DEVICE_STATUS = {
  *
  * 形状**复用** `pointStatus.js` 的 `SHAPES` 片段(同风格、同一份 SVG 来源,避免两套图形定义),
  * 但**键与语义在本表独立成立**:例如点位表的 `EMPTY`(无设备)在这里根本不存在。
- * `priority` 数值沿用后端给的状态优先级(1 最紧急),前端不自定序。
+ *
+ * `priority` 是**设备点自己的显示序**(维护者 2026-11-06《点位特效显示规则》「设备点」一节:
+ * 报警 > 工单 > 预警 > 独立设备),**不沿用**后端点位的枚举 priority —— 后者是监测点的**聚合**序
+ * (报警 > 预警 > 工单),两张序刻意不同(见后端 `CockpitMonitoringPointStatus` 的类注释)。
+ * 此前这里沿用枚举值,2026-11-02 的对调曾让它短暂「正确」,但那只是巧合;现在由规则直接钉住。
+ * 关心的是「多个标签同时存在时展示谁」,所以排列顺序与聚合符号取色都以本表为准。
  */
 export const DEVICE_STATUS_VISUALS = {
     ALARM: { key: 'ALARM', tone: 'lv1', shape: 'triangle', label: '报警', priority: 1, effect: 'pulse' },
-    WARNING: { key: 'WARNING', tone: 'lv2', shape: 'diamond', label: '预警', priority: 2, effect: 'blink' },
-    WORK_ORDER: { key: 'WORK_ORDER', tone: 'lv3', shape: 'square', label: '工单处置', priority: 3, effect: 'spin' },
+    // 工单处置排在预警之前:两地都「在处置中」时,处置动作是更进展的那一步(规则原文②③)。
+    WORK_ORDER: { key: 'WORK_ORDER', tone: 'lv3', shape: 'square', label: '工单处置', priority: 2, effect: 'spin' },
+    WARNING: { key: 'WARNING', tone: 'lv2', shape: 'diamond', label: '预警', priority: 3, effect: 'blink' },
     NORMAL: { key: 'NORMAL', tone: 'lv4', shape: 'circle', label: '正常', priority: 4, effect: null },
     OFFLINE: { key: 'OFFLINE', tone: 'point-offline', shape: 'ring', label: '离线', priority: 5, effect: null },
 }
@@ -65,6 +71,8 @@ export function deviceShapeOf(device) {
 /**
  * 设备状态图例行(**独立于监测点摘要图例**,规格与票 09 的边界都要求分开)。
  *
+ * 行序 = 设备点的显示序(报警 > 工单处置 > 预警 > 正常 > 离线 > 未发布,见上面的词表注释):
+ * 图例是「优先级」的对外说明,若它按另一套顺序排,读者会以为预警比工单优先。
  * `count` = 当前模式实际绘制的该状态设备数(由调用方给,本层只负责行与顺序)。
  * 0 不隐藏行(形状的含义要能查到)。
  * @param {object} [byStatus] `{ [visualKey]: count }`

+ 33 - 0
src/modules/cockpit/domain/equipmentDetailColors.js

@@ -0,0 +1,33 @@
+import { eventLevelFromBucket, eventLevelTag } from './eventList.js'
+
+const COLORS = {
+    lv1: 'var(--lv1, #ff3b52)',
+    lv2: 'var(--lv2, #ffb020)',
+    lv3: 'var(--lv3, #01abea)',
+    lv4: 'var(--lv4, #27d17a)',
+}
+
+/** 等级色与业务状态色分开:四级报警仍是报警,等级点可以是绿色。 */
+export function detailEventLevel(event) {
+    const level = eventLevelFromBucket(event?.level, event?.levelColor)
+    const family = String(event?.family || '').toUpperCase() === 'ALARM' ? '报警' : '预警'
+    const tone = level ? eventLevelTag(level).tone : family === '报警' ? 'lv1' : 'lv2'
+    return { level, tone, color: COLORS[tone], label: level ? family + ' · ' + level + '级' : family + ' · 未分级' }
+}
+
+/**
+ * 当前采集契约只发布逐格 alarm 布尔值,无预警/等级/规则配置。
+ * 只消费这条明确结论,不拿历史事件阈值或设备总体状态重判每个采集值。
+ */
+export function collectionCellAlert(cell) {
+    if (cell?.alarm !== true) return null
+    return { state: 'alarm', color: COLORS.lv1, dotColor: COLORS.lv1, label: '报警 · 未分级(采集记录未提供等级)' }
+}
+
+/** 最新记录按整组最高严重性取色;绿色是无已发布异常标记时的默认强调,不是阈值合格证明。 */
+export function collectionGroupAccent(rows) {
+    const alerts = rows.flatMap((row) => row.cells.map((cell) => cell.alert)).filter(Boolean)
+    const state = alerts.some((alert) => alert.state === 'alarm') ? 'alarm'
+        : alerts.some((alert) => alert.state === 'warning') ? 'warning' : 'default'
+    return { state, color: state === 'alarm' ? COLORS.lv1 : state === 'warning' ? COLORS.lv2 : COLORS.lv4 }
+}