Sfoglia il codice sorgente

优化报警一张图

zengy 5 giorni fa
parent
commit
fe2f2928d5

+ 1 - 1
package.json

@@ -7,7 +7,7 @@
   "type": "module",
   "scripts": {
     "dev": "vite",
-    "test": "node --test tests/homeScope.test.mjs tests/waterSupplyMonitoring.test.mjs tests/waterSupplyModel.test.mjs tests/equipmentTypeQuickFilter.test.mjs",
+    "test": "node --test tests/homeScope.test.mjs tests/warningIndicator.test.mjs tests/waterSupplyMonitoring.test.mjs tests/waterSupplyModel.test.mjs tests/equipmentTypeQuickFilter.test.mjs",
     "build:prod": "vite build",
     "build:stage": "vite build --mode staging",
     "preview": "vite preview"

+ 61 - 0
src/utils/warningIndicator.js

@@ -0,0 +1,61 @@
+import { ref } from 'vue'
+
+/**
+ * 监测指标展示名:必须以 app_user.warning_type 表的 warning_name 为准,
+ * 通过 warning_code 关联,禁止把 WARN-TILT / WARN_PRESSURE 这类编码直接显示给用户。
+ *
+ * 名称有两个来源,优先级如下:
+ *   1. 报警接口自带的 warningName / warningType(后端 AlarmVO 已按 warning_code 关联 warning_type)
+ *   2. 前端共享字典:GET /warning/config/types 读取 warning_type 全表,按 warning_code 查 warning_name
+ * 字典用 Vue ref 保存,异步加载完成后所有引用 indicatorName 的页面会自动重绘。
+ */
+const CODE_PREFIX = /^(WARN[-_]|ALARM[-_]|UNKNOWN[_-])/i
+const UPPER_CODE = /^[A-Z0-9_-]+$/
+
+const nameByCode = ref({})
+let loading = null
+let loaded = false
+
+/** 编码归一:去空白、大写、- → _;库内 warning_type.warning_code 与报警编码存在 -/_ 混用 */
+export function normalizeCode(code) {
+  return String(code ?? '').trim().toUpperCase().replace(/-/g, '_')
+}
+
+/** 把 /warning/config/types 的 dictValue-dictLabel(typeCode-typeName)转成 code→name */
+export function setIndicatorNames(rows = []) {
+  const map = {}
+  for (const row of (Array.isArray(rows) ? rows : [])) {
+    const code = row?.dictValue ?? row?.typeCode ?? row?.warningCode
+    const name = row?.dictLabel ?? row?.typeName ?? row?.warningName
+    if (code != null && name != null && String(name).trim()) {
+      map[normalizeCode(code)] = String(name).trim()
+    }
+  }
+  nameByCode.value = map
+}
+
+/** 只拉一次 warning_type 字典;失败时保持为空,界面走兜底文案而不是编码 */
+export function loadIndicatorNames() {
+  if (loaded) return Promise.resolve()
+  if (!loading) {
+    loading = import('@/api/warning/list')
+      .then(({ listWarningTypes }) => listWarningTypes())
+      .then(res => {
+        const payload = res?.data ?? res
+        setIndicatorNames(Array.isArray(payload) ? payload : (payload?.data ?? []))
+        loaded = true
+      })
+      .catch(error => console.warn('[warningIndicator] warning_type 指标字典加载失败', error))
+      .finally(() => { loading = null })
+  }
+  return loading
+}
+
+export function indicatorName(row = {}, fallback = '—') {
+  const fields = [row?.warningName, row?.warningTypeName, row?.warningType]
+  const fromAlarm = fields
+    .map(value => (typeof value === 'string' ? value.trim() : ''))
+    .find(text => text && !CODE_PREFIX.test(text) && !UPPER_CODE.test(text))
+  if (fromAlarm) return fromAlarm
+  return nameByCode.value[normalizeCode(row?.warningCode)] || fallback
+}

+ 3 - 1
src/views/subSystem/alarm/AlarmChart.vue

@@ -13,7 +13,9 @@ import { ref, computed, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
 import * as echarts from 'echarts'
 import { getAlarmAnalysis } from '@/api/alarm'
 import { seriesPoints, sampleTime } from './common/js/alarmOperationsModel'
+import { indicatorName, loadIndicatorNames } from '@/utils/warningIndicator'
 const props = defineProps({ alarmId: String, scope: { type: Object, required: true } })
+loadIndicatorNames()
 const period = ref('CURVE'), host = ref(), loading = ref(false), error = ref(''), result = ref({})
 const validCount = computed(() => (result.value.samples || []).reduce((sum, p) => sum + Number(p.validCount), 0))
 const sampleCount = computed(() => (result.value.samples || []).reduce((sum, p) => sum + Number(p.sampleCount), 0))
@@ -36,7 +38,7 @@ function draw() {
   const raw = period.value === 'CURVE' || period.value === 'DAY'
   const event = raw && alarm.actualValue != null && alarm.createTime ? [{ name: '报警触发值', type: 'scatter', symbolSize: 12, itemStyle: { color: '#f56c6c' }, data: [[sampleTime(alarm.createTime), Number(alarm.actualValue)]] }] : []
   const limits = [['下限', alarm.minValue], ['上限', alarm.maxValue]].filter(([, v]) => v !== null && v !== undefined).map(([name, v]) => ({ name, yAxis: Number(v) }))
-  chart.setOption({ tooltip: { trigger: 'axis', renderMode: 'richText' }, grid: { left: 65, right: 30, bottom: 60, top: 35 }, xAxis: { type: 'time', min: result.value.startTime ? sampleTime(result.value.startTime) : undefined, max: result.value.endTime ? sampleTime(result.value.endTime) : undefined }, yAxis: { type: 'value', scale: true, name: alarm.warningUnit || '' }, dataZoom: [{ type: 'inside' }, { type: 'slider', height: 20 }], series: [{ name: alarm.warningType || '监测值', type: 'line', showSymbol: points.length < 100, connectNulls: false, data: seriesPoints(points, raw), lineStyle: { color: '#409eff', width: 2 }, markLine: { silent: true, symbol: 'none', lineStyle: { color: '#e6a23c', type: 'dashed' }, data: limits } }, ...event] }, true)
+  chart.setOption({ tooltip: { trigger: 'axis', renderMode: 'richText' }, grid: { left: 65, right: 30, bottom: 60, top: 35 }, xAxis: { type: 'time', min: result.value.startTime ? sampleTime(result.value.startTime) : undefined, max: result.value.endTime ? sampleTime(result.value.endTime) : undefined }, yAxis: { type: 'value', scale: true, name: alarm.warningUnit || '' }, dataZoom: [{ type: 'inside' }, { type: 'slider', height: 20 }], series: [{ name: indicatorName(alarm, '监测值'), type: 'line', showSymbol: points.length < 100, connectNulls: false, data: seriesPoints(points, raw), lineStyle: { color: '#409eff', width: 2 }, markLine: { silent: true, symbol: 'none', lineStyle: { color: '#e6a23c', type: 'dashed' }, data: limits } }, ...event] }, true)
 }
 watch(() => [props.alarmId, JSON.stringify(props.scope)], () => { period.value = 'CURVE'; load() }, { immediate: true })
 onMounted(() => { observer = new ResizeObserver(() => chart?.resize()); observer.observe(host.value); if (result.value.samples) draw() })

+ 3 - 6
src/views/subSystem/alarm/common/alarmGIS.scss

@@ -85,16 +85,15 @@
 .gis-detail-grid .is-wide { grid-column: 1 / -1; }
 .gis-detail-grid dt { margin-bottom: 7px; color: #8794a7; font-size: 12px; font-weight: 400; }
 .gis-detail-grid dd { margin: 0; color: #45556d; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
-.gis-snapshot { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px; padding: 15px 16px; background: #f6f8fc; border: 1px solid #edf1f7; border-radius: 6px; }
+.gis-snapshot { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; margin-top: 20px; padding: 15px 16px; background: #f6f8fc; border: 1px solid #edf1f7; border-radius: 6px; }
 .gis-snapshot span { color: #8290a3; font-size: 11px; }
-.gis-snapshot strong { display: block; margin-top: 8px; color: #d66b58; font-size: 21px; font-weight: 500; overflow-wrap: anywhere; }
-.gis-snapshot p { margin: 10px 0 0; color: #526581; font-size: 13px; }
+.gis-snapshot p { margin: 10px 0 0; color: #45556d; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
 .gis-detail-tabs { margin-top: 20px; }
 .gis-detail-tabs :deep(.el-tabs__item) { padding: 0 18px; font-size: 13px; font-weight: 400; }
 .gis-detail-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; background: #e6eaf0; }
 .gis-detail-tabs :deep(.el-tabs__header) { margin-bottom: 22px; }
 .gis-panel-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 16px; }
-.gis-panel-heading h3, .gis-device-images h3 { margin: 0; color: #41536e; font-size: 14px; font-weight: 500; }
+.gis-panel-heading h3 { margin: 0; color: #41536e; font-size: 14px; font-weight: 500; }
 .gis-panel-heading > span { color: #94a0b1; font-size: 11px; }
 .gis-curve-select { width: 100%; margin-bottom: 18px; }
 .gis-detail-content :deep(.alarm-toolbar) { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
@@ -114,8 +113,6 @@
 .gis-history-actions :deep(.el-button) { margin: 0; font-size: 12px; font-weight: 400; }
 .gis-detail-content :deep(.pagination-container) { padding: 12px 0 0; }
 .gis-detail-content :deep(.el-pagination) { justify-content: flex-end; flex-wrap: wrap; gap: 6px; }
-.gis-device-images { margin-top: 24px; }
-.gis-device-images h3 { margin-bottom: 14px; }
 .gis-drawer-footer { display: flex; justify-content: flex-end; align-items: center; gap: 14px; }
 .gis-cluster-list { display: grid; gap: 8px; }
 .gis-cluster-list button { display: flex; align-items: center; gap: 12px; padding: 13px; border: 1px solid #e7edf5; background: #f8faff; border-radius: 5px; text-align: left; color: #526581; cursor: pointer; font: inherit; font-size: 13px; }

+ 9 - 10
src/views/subSystem/alarm/common/alarmGIS.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="alarm-page alarm-gis-page">
     <header class="gis-page-heading">
-      <div><h1>当前报警 GIS 一张图</h1><p>按设备查看报警分布,关联监测曲线与报警记录</p></div>
+      <div><h1>{{ route.meta.title || '报警GIS一张图' }}</h1><p>按设备查看报警分布,关联监测曲线与报警记录</p></div>
       <span class="gis-view-label"><el-icon><MapLocation /></el-icon>{{ route.meta.title || '报警GIS一张图' }}</span>
     </header>
 
@@ -64,13 +64,13 @@
       <template #header><div class="gis-drawer-heading"><span>设备详情</span><h2>{{ selected?.name }}</h2></div></template>
       <div v-if="selected" class="gis-detail-content">
         <div class="gis-detail-banner"><span class="gis-detail-grade" :style="levelStyle(selected.alarmLevel)"><span class="gis-grade-badge">{{ selected.alarmLevel }}</span>{{ levelLabel(selected.alarmLevel) }}</span><span>{{ selected.alarms.length }} 条报警 · 当前查询结果</span></div>
-        <dl class="gis-detail-grid"><div><dt>设备编码</dt><dd>{{ selected.deviceCode }}</dd></div><div><dt>最近报警时间</dt><dd>{{ latestAlarm?.createTime || '—' }}</dd></div><div class="is-wide"><dt>安装位置</dt><dd>{{ selected.equipmentLocation || '—' }}</dd></div></dl>
-        <div v-if="latestAlarm && hasActualValue(latestAlarm)" class="gis-snapshot"><div><span>最近报警触发值 · {{ latestAlarm.warningType || latestAlarm.warningCode }}</span><strong>{{ valueText(latestAlarm.actualValue, latestAlarm.warningUnit) }}</strong></div><div><span>触发时阈值</span><p>{{ thresholdText(latestAlarm) }}</p></div></div>
+        <dl class="gis-detail-grid"><div><dt>设备编码</dt><dd>{{ selected.deviceCode }}</dd></div><div class="is-wide"><dt>安装位置</dt><dd>{{ selected.equipmentLocation || '—' }}</dd></div></dl>
+        <div v-if="latestAlarm" class="gis-snapshot"><div><span>最新报警 · {{ latestAlarm.createTime || '—' }}</span><p>{{ latestAlarm.alarmContent || '—' }}</p></div></div>
 
         <el-tabs v-model="tab" class="gis-detail-tabs">
           <el-tab-pane label="监测曲线" name="curve">
             <div class="gis-panel-heading"><h3>监测趋势</h3><span>真实采集数据</span></div>
-            <el-select v-model="curveId" :disabled="!curveChoices.length" placeholder="选择报警指标" class="gis-curve-select"><el-option v-for="row in curveChoices" :key="row.id" :value="row.id" :label="`${row.warningType || row.warningCode} · ${row.alarmNo || row.createTime}`" /></el-select>
+            <el-select v-model="curveId" :disabled="!curveChoices.length" placeholder="选择报警指标" class="gis-curve-select"><el-option v-for="row in curveChoices" :key="row.id" :value="row.id" :label="`${indicatorName(row, '监测指标')} · ${row.alarmNo || row.createTime}`" /></el-select>
             <AlarmChart v-if="detailVisible && tab === 'curve' && currentCurve" :alarm-id="currentCurve.id" :scope="scope" />
             <el-empty v-if="!curveChoices.length" description="暂无有实际值的报警指标" :image-size="70" />
           </el-tab-pane>
@@ -81,7 +81,7 @@
               <article v-for="row in history" :key="row.id" class="gis-history-item" :style="levelStyle(row.alarmLevel)">
                 <div class="gis-history-heading"><el-tag size="small" effect="light" :type="levelTone(row.alarmLevel)">{{ row.alarmLevelName || levelLabel(row.alarmLevel) }}</el-tag><el-tag size="small" effect="light" :type="monitorStatusTone(row.alarmStatus)">{{ row.monitorStatusName }}</el-tag></div>
                 <p class="gis-history-description">{{ row.alarmContent || '—' }}</p>
-                <dl><dt>报警单号</dt><dd>{{ row.alarmNo || '—' }}</dd><dt>报警时间</dt><dd>{{ row.createTime || '—' }}</dd><dt>监测指标</dt><dd>{{ row.warningType || row.warningCode || '—' }}</dd></dl>
+                <dl><dt>报警单号</dt><dd>{{ row.alarmNo || '—' }}</dd><dt>报警时间</dt><dd>{{ row.createTime || '—' }}</dd><dt>监测指标</dt><dd>{{ indicatorName(row) }}</dd></dl>
                 <div class="gis-history-actions"><el-button link type="primary" @click="detailRef.open(row.id)">报警详情</el-button><el-button v-if="hasActualValue(row)" link type="primary" @click="curveId = row.id; tab = 'curve'">查看曲线</el-button></div>
               </article>
               <el-empty v-if="!history.length && !historyLoading && !historyError" description="暂无报警记录" :image-size="60" />
@@ -90,8 +90,7 @@
           </el-tab-pane>
           <el-tab-pane label="设备信息" name="device">
             <div class="gis-panel-heading"><h3>设备档案</h3><span>关联设备台账</span></div>
-            <dl class="gis-detail-grid"><div><dt>设备名称</dt><dd>{{ selected.name }}</dd></div><div><dt>设备编码</dt><dd>{{ selected.deviceCode }}</dd></div><div class="is-wide"><dt>安装位置</dt><dd>{{ selected.equipmentLocation || '—' }}</dd></div><div><dt>经度</dt><dd>{{ selected.hasCoordinates ? selected.lng : '暂无有效坐标' }}</dd></div><div><dt>纬度</dt><dd>{{ selected.hasCoordinates ? selected.lat : '暂无有效坐标' }}</dd></div></dl>
-            <section v-if="selected.equipmentImage" class="gis-device-images"><h3>设备图片</h3><AlarmAttachments :value="selected.equipmentImage" :base-url="baseUrl" /></section>
+            <dl class="gis-detail-grid"><div><dt>设备名称</dt><dd>{{ selected.name }}</dd></div><div><dt>设备编码</dt><dd>{{ selected.deviceCode }}</dd></div><div><dt>设备类型</dt><dd>{{ selected.equipmentTypeName || '—' }}</dd></div><div><dt>安装位置</dt><dd>{{ selected.equipmentLocation || '—' }}</dd></div><div><dt>经度</dt><dd>{{ selected.hasCoordinates ? selected.lng : '暂无有效坐标' }}</dd></div><div><dt>纬度</dt><dd>{{ selected.hasCoordinates ? selected.lat : '暂无有效坐标' }}</dd></div></dl>
           </el-tab-pane>
         </el-tabs>
       </div>
@@ -110,15 +109,15 @@ import GisMapCanvas from '@/components/GisMapCanvas.vue'
 import { getAlarmMap, getAlarmOptions, getAlarms } from '@/api/alarm'
 import AlarmDetail from '../../alarm/AlarmDetail.vue'
 import AlarmChart from '../../alarm/AlarmChart.vue'
-import AlarmAttachments from '../../alarm/AlarmAttachments.vue'
 import { alarmCurveChoices, groupAlarmDevices, levelColor, levelTone, monitorStatusTone } from '../../alarm/common/js/alarmOperationsModel'
-import { hasActualValue, thresholdText, valueText } from '../../alarm/common/js/alarmReviewModel'
+import { hasActualValue } from '../../alarm/common/js/alarmReviewModel'
 import { useAlarmScope } from '../../alarm/common/js/useAlarmScope'
+import { indicatorName, loadIndicatorNames } from '@/utils/warningIndicator'
 import '../../alarm/common/styles/alarmOperations.scss'
 
 const { scope, scopeKey } = useAlarmScope()
+loadIndicatorNames()
 const route = useRoute()
-const baseUrl = import.meta.env.VITE_APP_BASE_API
 const keyword = ref(''), level = ref(), options = ref({}), rows = ref([]), selected = ref(null), tab = ref('curve'), curveId = ref('')
 const loading = ref(false), error = ref(''), mapError = ref(''), mapReady = ref(false), mapKey = ref(0), mapRef = ref(), detailRef = ref(), cluster = ref([])
 const detailVisible = ref(false)

+ 5 - 13
src/views/subSystem/pipeNetwork/home/index.vue

@@ -5,9 +5,6 @@
     <header class="top-bar">
       <div class="brand">
         <div class="brand-title">{{ subsystemName }}监测中心</div>
-        <div class="scope-badge" :class="{ invalid: !rootTypeId }">
-          rootTypeId {{ rootTypeId ?? '—' }} · {{ scopeSourceText }}
-        </div>
       </div>
 
       <div class="health-band">
@@ -54,7 +51,7 @@
                 <span>{{ a.createTime || '—' }}</span>
               </div>
               <div class="alarm-desc">
-                {{ a.alarmContent || a.warningCode || '—' }}
+                {{ a.alarmContent || indicatorName(a) }}
                 <span v-if="a.actualValue != null">实测 {{ a.actualValue }}</span>
               </div>
             </div>
@@ -275,6 +272,7 @@ import { gisPointStatusIcons } from '@/utils/gisMapColors'
 import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
 import usePermissionStore from '@/store/modules/permission'
 import { HOME_ROOT_TYPES, resolveHomeScope } from '@/utils/homeScope'
+import { indicatorName, loadIndicatorNames } from '@/utils/warningIndicator'
 
 // ==================== 数据范围:sys_menu.query.rootTypeId ====================
 /**
@@ -291,7 +289,6 @@ const homeScope = computed(() => resolveHomeScope({
 }))
 const rootTypeId = computed(() => homeScope.value.rootTypeId)
 const subsystemName = computed(() => HOME_ROOT_TYPES[rootTypeId.value] || '未指定子系统')
-const scopeSourceText = computed(() => ({ url: 'URL参数', menu: '菜单配置', 'only-menu': '唯一授权菜单' }[homeScope.value.source] || '未配置'))
 const scopeError = computed(() => rootTypeId.value ? '' :
   '当前菜单未配置 sys_menu.query.rootTypeId(1-供水 / 2-排水 / 3-燃气 / 4-窨井),请执行 migration_unified_subsystem_home.sql 后重新登录刷新菜单;为避免越权,未加载任何数据。')
 
@@ -322,9 +319,9 @@ const statusText = (s) => ({ normal: '在线', alert: '告警', offline: '离线
 function districtLabel(code) {
   return code == null ? '-' : (DISTRICT_MAP[code] ?? String(code))
 }
-/** 报警类型展示:优先报警内容,其次报警编码 */
+/** 报警类型展示:优先报警内容,其次监测指标名称,绝不回退到编码 */
 function alarmTypeText(record) {
-  return record.alarmContent || record.warningCode || '其他告警'
+  return record.alarmContent || indicatorName(record, '其他告警')
 }
 /** 报警等级配色/名称:与系统 alarm_level 字典一致(1红 2橙 3蓝 4绿) */
 function levelColor(level) {
@@ -683,6 +680,7 @@ async function loadAlarmData() {
 
 // ==================== 生命周期 ====================
 onMounted(() => {
+  loadIndicatorNames()
   loadMarkerImages()
   loadAll()
 })
@@ -766,12 +764,6 @@ watch(rootTypeId, () => {
 }
 .brand { flex: none; min-width: 210px; }
 .brand-title { font-size: 20px; font-weight: 700; letter-spacing: 2px; color: #0a1628; }
-.scope-badge {
-  display: inline-flex; margin-top: 6px; padding: 2px 8px; border-radius: 10px;
-  background: #eef6ff; border: 1px solid #d6e6ff; color: #1677ff;
-  font-size: 11px; font-family: ui-monospace, Menlo, Consolas, monospace;
-}
-.scope-badge.invalid { background: #fff1f0; border-color: #ffccc7; color: #ef4444; }
 .scope-alert { flex: none; }
 .health-band { flex: 1; display: flex; align-items: center; gap: 24px; min-width: 0; }
 .hb-rate { flex: none; min-width: 108px; display: flex; flex-direction: column; gap: 3px; }

+ 3 - 1
src/views/subSystem/pipeNetwork/pinteControl/realTimeShow.vue

@@ -149,7 +149,7 @@
           <div class="card-head"><div class="card-title">报警类型分布</div></div>
           <div class="type-dist side">
             <div v-for="t in alarmTypeDist" :key="t.warningCode" class="td-row">
-              <span class="td-name">{{ t.warningName || t.warningCode }}</span>
+              <span class="td-name">{{ indicatorName(t, '监测指标') }}</span>
               <div class="td-track"><i :style="{ width: typeDistRate(t) + '%' }"></i></div>
               <span class="td-num">{{ t.count }} 条</span>
             </div>
@@ -265,6 +265,7 @@ import {
   getAlarmTypeDist
 } from "@/api/pipeNetwork/perception";
 import { buildMinioAttachmentUrl } from "@/utils/minioAttachment";
+import { indicatorName, loadIndicatorNames } from "@/utils/warningIndicator";
 
 const loading = ref(false);
 const stats = ref({});
@@ -536,6 +537,7 @@ function loadTypeOptions() {
 }
 
 onMounted(() => {
+  loadIndicatorNames();
   loadTypeOptions();
   refreshAll();
   refreshTimer = setInterval(refreshAll, 3000);

+ 0 - 126
src/views/subSystem/waterSupply/alarm/alarmGIS.scss

@@ -1,126 +0,0 @@
-.alarm-gis-page { color: #303b4e; }
-.gis-page-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 20px; }
-.gis-page-heading h1 { margin: 0 0 8px; font-size: 22px; font-weight: 600; letter-spacing: .3px; }
-.gis-page-heading p { margin: 0; color: #7b8799; font-size: 13px; line-height: 1.6; }
-.gis-filter-card, .gis-workspace { background: #fff; border: 1px solid #e6eaf0; border-radius: 8px; box-shadow: 0 2px 5px #273f5d03; }
-.gis-filter-card { padding: 16px 20px 20px; margin-bottom: 16px; }
-.gis-filters { display: grid; grid-template-columns: minmax(272px, 332px) minmax(212px, 252px) auto; justify-content: start; align-items: center; gap: 16px 20px; }
-.gis-filters :deep(.el-form-item) { margin: 0; min-width: 0; }
-.gis-filters :deep(.el-form-item__label) { height: 32px; margin-bottom: 0; font-size: 12px; font-weight: 400; color: #5f6d81; line-height: 32px; }
-.gis-filters :deep(.el-select), .gis-filters :deep(.el-input) { width: 100%; }
-.gis-filter-actions { display: flex; white-space: nowrap; }
-.gis-level-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
-.gis-level-metric { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 6px 12px; min-height: 82px; padding: 16px 18px; background: #fff; border: 1px solid #e6eaf0; border-radius: 8px; }
-.gis-grade-badge { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex-shrink: 0; border: 1px solid var(--grade-color); border-radius: 50%; background: var(--grade-tint); color: var(--grade-color); font-size: 14px; font-weight: 600; }
-.gis-metric-label { min-width: 0; font-size: 13px; line-height: 1.4; }
-.gis-metric-value { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: flex-end; flex-wrap: wrap; gap: 4px 8px; }
-.gis-metric-value > span { color: #8a95a5; font-size: 12px; }
-.gis-metric-value strong { color: #293b53; font-size: 26px; font-weight: 600; white-space: nowrap; font-variant-numeric: tabular-nums; }
-.gis-metric-value strong small { margin-left: 5px; font-size: 12px; font-weight: 400; color: #8a95a5; }
-.gis-workspace { overflow: hidden; }
-.gis-workspace-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 16px 20px; border-bottom: 1px solid #e6eaf0; }
-.gis-workspace-heading > div { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
-.gis-workspace-heading .el-icon { color: #409eff; font-size: 18px; }
-.gis-workspace-heading h2 { margin: 0; font-size: 15px; font-weight: 600; }
-.gis-workspace-heading span { color: #8390a2; font-size: 12px; }
-.gis-workspace-body { display: grid; grid-template-columns: 300px minmax(0, 1fr); height: clamp(520px, calc(100vh - 340px), 740px); }
-.gis-device-pane { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid #e6eaf0; background: #fff; }
-.gis-list-heading { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #edf0f5; color: #7c899b; font-size: 12px; }
-.gis-list-heading b { color: #3d536f; font-weight: 500; }
-.gis-device-list { min-height: 0; overflow: auto; padding: 8px; scrollbar-width: thin; scrollbar-color: #d5dfea transparent; }
-.gis-device-item { padding: 14px 12px; margin-bottom: 4px; border: 1px solid transparent; border-bottom-color: #edf0f5; border-radius: 6px; transition: background .15s, border-color .15s; }
-.gis-device-item:hover { background: #f8faff; }
-.gis-device-item.is-selected { background: #f0f6ff; border-color: #b8d4fa; }
-.gis-device-title { display: flex; align-items: flex-start; gap: 9px; }
-.gis-device-title .gis-grade-badge { width: 23px; height: 23px; font-size: 12px; }
-.gis-device-name { padding: 1px 0; border: 0; background: transparent; font: inherit; color: #34445c; font-size: 13px; font-weight: 600; text-align: left; line-height: 21px; overflow-wrap: anywhere; cursor: pointer; }
-.gis-device-name:hover { color: #409eff; }
-.gis-device-name:focus-visible, .gis-cluster-list button:focus-visible { outline: 2px solid #409eff; outline-offset: 3px; border-radius: 2px; }
-.gis-device-code { margin: 8px 0; color: #8290a5; font: 11px/1.6 Consolas, monospace; overflow-wrap: anywhere; }
-.gis-device-location { display: flex; align-items: flex-start; gap: 4px; color: #758297; font-size: 12px; line-height: 1.7; }
-.gis-device-location .el-icon { margin-top: 4px; flex-shrink: 0; }
-.gis-device-location span { overflow-wrap: anywhere; }
-.gis-device-meta { display: flex; gap: 8px; margin-top: 9px; color: #8190a3; font-size: 11px; }
-.gis-device-meta span:first-child { color: var(--grade-color); }
-.gis-device-meta span + span::before { content: '·'; margin-right: 8px; }
-.gis-device-actions { display: flex; justify-content: space-between; align-items: center; gap: 5px; margin-top: 10px; font-size: 11px; }
-.gis-device-actions :deep(.el-button) { font-size: 12px; font-weight: 400; padding: 3px 0; }
-.gis-device-actions :deep(.el-button + .el-button) { margin-left: 12px; }
-.gis-coordinate-warning { color: #b77b28; }
-.gis-coordinate-ready { display: flex; align-items: center; gap: 5px; color: #8090a3; }
-.gis-coordinate-ready i { width: 5px; height: 5px; border-radius: 50%; background: #69b58c; }
-.gis-map-stage { position: relative; min-width: 0; overflow: hidden; background: #f4f7fb; }
-.gis-map-stage :deep(.gis-map-canvas) { min-height: 0; background: #f4f7fb; }
-.gis-map-status, .gis-map-legend, .gis-map-tools :deep(.el-button) { background: #fff; border: 1px solid #e3e9f1; box-shadow: 0 2px 8px #263e5b0a; }
-.gis-map-status { position: absolute; left: 16px; top: 16px; z-index: 2; display: flex; align-items: center; gap: 7px; padding: 10px 12px; border-radius: 6px; color: #65768c; font-size: 12px; }
-.gis-map-status i { width: 7px; height: 7px; border-radius: 50%; background: #dca14d; }
-.gis-map-status i.is-ready { background: #52aa7a; }
-.gis-map-status i.is-error { background: #e89174; }
-.gis-map-tools { position: absolute; right: 16px; top: 16px; z-index: 2; display: flex; gap: 6px; }
-.gis-map-tools :deep(.el-button) { width: 36px; height: 36px; padding: 0; margin: 0; border-radius: 5px; font-size: 17px; }
-.gis-map-fallback { position: absolute; inset: 75px 24px 90px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; background: #f4f7fb; }
-.gis-fallback-icon { display: grid; place-items: center; width: 56px; height: 56px; margin-bottom: 18px; background: #e9eff7; border-radius: 14px; color: #8194ac; font-size: 26px; }
-.gis-map-fallback h3 { margin: 0 0 12px; font-size: 17px; font-weight: 500; color: #4c5f79; }
-.gis-map-fallback p { max-width: 400px; margin: 0 0 20px; font-size: 13px; color: #8895a6; line-height: 1.8; overflow-wrap: anywhere; }
-.gis-fallback-note { max-width: 380px; margin-top: 14px; color: #94a0b1; font-size: 11px; line-height: 1.7; }
-.gis-map-bottom { position: absolute; left: 16px; right: 16px; bottom: 38px; z-index: 2; display: flex; align-items: end; justify-content: space-between; gap: 10px; pointer-events: none; }
-.gis-map-legend { display: flex; flex-wrap: wrap; gap: 12px; padding: 10px 12px; border-radius: 6px; }
-.gis-map-legend span { display: flex; align-items: center; gap: 5px; color: #65768b; font-size: 11px; white-space: nowrap; }
-.gis-map-legend i { width: 7px; height: 7px; border-radius: 50%; background: var(--grade-color); }
-.gis-map-note { padding: 7px 9px; background: #fffffff2; border-radius: 4px; color: #8b7758; font-size: 11px; }
-.gis-workspace-footer { display: flex; justify-content: space-between; gap: 12px; padding: 12px 18px; border-top: 1px solid #e6eaf0; color: #8996a8; font-size: 12px; line-height: 1.6; }
-.gis-workspace-footer span:first-child { display: flex; align-items: center; gap: 5px; min-width: 0; overflow-wrap: anywhere; }
-.gis-workspace-footer .el-icon { flex-shrink: 0; }
-.gis-workspace-footer span:last-child { flex-shrink: 0; }
-:global(.alarm-gis-drawer .el-drawer__header) { margin: 0; padding: 20px 24px; border-bottom: 1px solid #e6eaf0; }
-:global(.alarm-gis-drawer .el-drawer__body) { padding: 22px 24px; }
-:global(.alarm-gis-drawer .el-drawer__footer) { padding: 15px 24px; border-top: 1px solid #e6eaf0; }
-.gis-drawer-heading > span { display: block; margin-bottom: 7px; font-size: 11px; color: #8a97a9; }
-.gis-drawer-heading h2 { margin: 0; color: #34445b; font-size: 19px; font-weight: 600; overflow-wrap: anywhere; }
-.gis-detail-banner { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 22px; }
-.gis-detail-banner > span:last-child { color: #8a97a9; font-size: 12px; }
-.gis-detail-grade { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--grade-color); }
-.gis-detail-grade .gis-grade-badge { width: 26px; height: 26px; font-size: 12px; }
-.gis-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 24px; margin: 0; }
-.gis-detail-grid .is-wide { grid-column: 1 / -1; }
-.gis-detail-grid dt { margin-bottom: 7px; color: #8794a7; font-size: 12px; font-weight: 400; }
-.gis-detail-grid dd { margin: 0; color: #45556d; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
-.gis-snapshot { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px; padding: 15px 16px; background: #f6f8fc; border: 1px solid #edf1f7; border-radius: 6px; }
-.gis-snapshot span { color: #8290a3; font-size: 11px; }
-.gis-snapshot strong { display: block; margin-top: 8px; color: #d66b58; font-size: 21px; font-weight: 500; overflow-wrap: anywhere; }
-.gis-snapshot p { margin: 10px 0 0; color: #526581; font-size: 13px; }
-.gis-detail-tabs { margin-top: 20px; }
-.gis-detail-tabs :deep(.el-tabs__item) { padding: 0 18px; font-size: 13px; font-weight: 400; }
-.gis-detail-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; background: #e6eaf0; }
-.gis-detail-tabs :deep(.el-tabs__header) { margin-bottom: 22px; }
-.gis-panel-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 16px; }
-.gis-panel-heading h3, .gis-device-images h3 { margin: 0; color: #41536e; font-size: 14px; font-weight: 500; }
-.gis-panel-heading > span { color: #94a0b1; font-size: 11px; }
-.gis-curve-select { width: 100%; margin-bottom: 18px; }
-.gis-detail-content :deep(.alarm-toolbar) { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
-.gis-detail-content :deep(.muted) { color: #8a97a9; font-size: 12px; line-height: 1.7; }
-.gis-detail-content :deep(.el-radio-button__inner) { padding: 8px 10px; font-size: 12px; }
-.gis-detail-content :deep(.alarm-chart) { height: 310px; border: 1px solid #edf1f7; border-radius: 6px; }
-.gis-alarm-history { min-height: 110px; margin-left: 6px; padding-left: 20px; border-left: 1px solid #e3eaf4; }
-.gis-history-item { position: relative; padding: 0 0 22px; }
-.gis-history-item::before { content: ''; position: absolute; left: -25px; top: 6px; width: 7px; height: 7px; border: 1px solid var(--grade-color); background: #fff; border-radius: 50%; }
-.gis-history-heading { display: flex; justify-content: space-between; gap: 12px; }
-.gis-history-heading :deep(.el-tag) { border: 0; border-radius: 3px; font-weight: 400; }
-.gis-history-description { margin: 10px 0; color: #526178; font-size: 13px; line-height: 1.8; overflow-wrap: anywhere; }
-.gis-history-item dl { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 6px 10px; margin: 0; color: #8492a6; font-size: 12px; }
-.gis-history-item dt { font-weight: 400; }
-.gis-history-item dd { margin: 0; overflow-wrap: anywhere; }
-.gis-history-actions { display: flex; justify-content: flex-end; gap: 14px; margin-top: 10px; }
-.gis-history-actions :deep(.el-button) { margin: 0; font-size: 12px; font-weight: 400; }
-.gis-detail-content :deep(.pagination-container) { padding: 12px 0 0; }
-.gis-detail-content :deep(.el-pagination) { justify-content: flex-end; flex-wrap: wrap; gap: 6px; }
-.gis-device-images { margin-top: 24px; }
-.gis-device-images h3 { margin-bottom: 14px; }
-.gis-drawer-footer { display: flex; justify-content: flex-end; align-items: center; gap: 14px; }
-.gis-cluster-list { display: grid; gap: 8px; }
-.gis-cluster-list button { display: flex; align-items: center; gap: 12px; padding: 13px; border: 1px solid #e7edf5; background: #f8faff; border-radius: 5px; text-align: left; color: #526581; cursor: pointer; font: inherit; font-size: 13px; }
-.gis-cluster-list button span { flex: 1; }
-.gis-cluster-list small { color: #95a1b2; }
-@media (max-width: 1250px) { .gis-filters { gap: 16px; } .gis-level-metric { padding: 14px 12px; gap: 8px; } .gis-workspace-body { grid-template-columns: 270px minmax(0, 1fr); } .gis-map-note { display: none; } }
-@media (max-width: 1000px) { .gis-filters { grid-template-columns: minmax(0, 332px) minmax(0, 252px); } .gis-filter-actions { grid-column: 1 / -1; justify-content: flex-end; } .gis-level-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .gis-workspace-hint { display: none; } .gis-map-status { top: 62px; } .gis-map-fallback { inset: 110px 18px 95px; } }
-@media (max-width: 680px) { .gis-page-heading h1 { font-size: 19px; } .gis-filter-card { padding: 14px; } .gis-filters { grid-template-columns: minmax(0, 1fr); } .gis-level-metric { gap: 7px; padding: 12px 10px; } .gis-metric-value strong { font-size: 23px; } .gis-metric-value > span { font-size: 11px; } .gis-metric-label { font-size: 12px; } .gis-level-metric .gis-grade-badge { width: 25px; height: 25px; } .gis-workspace-body { display: flex; flex-direction: column; height: auto; } .gis-device-pane { height: 280px; border-right: 0; border-bottom: 1px solid #e6eaf0; } .gis-map-stage { height: 450px; } .gis-workspace-heading { padding: 14px; } .gis-workspace-footer { padding: 12px; } .gis-workspace-footer span:last-child { display: none; } .gis-detail-grid { gap: 16px; } .gis-snapshot { gap: 14px; } .gis-detail-banner { align-items: flex-start; } :global(.alarm-gis-drawer .el-drawer__body) { padding: 18px; } }

+ 0 - 179
src/views/subSystem/waterSupply/alarm/alarmGIS.vue

@@ -1,179 +0,0 @@
-<template>
-  <div class="alarm-page alarm-gis-page">
-    <header class="gis-page-heading">
-      <div><h1>当前报警 GIS 一张图</h1><p>按设备查看报警分布,关联监测曲线与报警记录</p></div>
-      <span class="gis-view-label"><el-icon><MapLocation /></el-icon>{{ viewLabel }}</span>
-    </header>
-
-    <section class="gis-filter-card">
-      <el-form class="gis-filters" label-position="left" label-width="72px" @submit.prevent="load">
-        <el-form-item label="设备搜索" class="gis-search-field"><el-input v-model="keyword" clearable :prefix-icon="Search" placeholder="请输入设备编码/名称" @keyup.enter="load" /></el-form-item>
-        <el-form-item label="报警等级"><el-select v-model="level" clearable placeholder="全部等级"><el-option v-for="v in options.alarmLevels || []" :key="v.value" :value="v.value" :label="v.label" /></el-select></el-form-item>
-        <div class="gis-filter-actions"><el-button type="primary" :icon="Search" :loading="loading" @click="load">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></div>
-      </el-form>
-    </section>
-
-    <div class="gis-level-metrics" aria-label="当前查询结果的设备等级统计" :aria-busy="loading">
-      <article v-for="item in levelMetrics" :key="item.value" class="gis-level-metric" :style="levelStyle(item.value)">
-        <span class="gis-grade-badge">{{ item.value }}</span>
-        <div class="gis-metric-label">{{ item.label }}</div>
-        <div class="gis-metric-value"><span>报警设备</span><strong>{{ loading || error ? '—' : item.count }}<small>台</small></strong></div>
-      </article>
-    </div>
-    <el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="load-error"><el-button link @click="load">重新加载</el-button></el-alert>
-
-    <section class="gis-workspace">
-      <header class="gis-workspace-heading"><div><el-icon><MapLocation /></el-icon><h2>报警设备分布</h2><span>{{ devices.length }} 台设备 / {{ rows.length }} 条报警</span></div></header>
-      <div class="gis-workspace-body">
-        <aside class="gis-device-pane" aria-label="报警设备列表" v-loading="loading">
-          <div class="gis-list-heading"><span>筛选结果 <b>{{ devices.length }}</b> 台</span><span>等级优先</span></div>
-          <div class="gis-device-list">
-            <article v-for="device in devices" :key="device.key" :class="['gis-device-item', { 'is-selected': selected?.key === device.key }]" :style="levelStyle(device.alarmLevel)">
-              <div class="gis-device-title"><span class="gis-grade-badge">{{ device.alarmLevel }}</span><button type="button" class="gis-device-name" @click="choose(device)">{{ device.name }}</button></div>
-              <p class="gis-device-code">{{ device.deviceCode }}</p>
-              <div class="gis-device-location"><el-icon><Location /></el-icon><span>{{ device.equipmentLocation || '暂无安装位置' }}</span></div>
-              <div class="gis-device-meta"><span>{{ levelLabel(device.alarmLevel) }}</span><span>{{ device.alarms.length }} 条报警</span></div>
-              <div class="gis-device-actions"><span v-if="!device.hasCoordinates" class="gis-coordinate-warning">缺少有效坐标</span><span v-else class="gis-coordinate-ready"><i></i>已关联点位</span><div><el-button link type="primary" :disabled="!device.hasCoordinates || !mapReady" @click="locate(device)">定位</el-button><el-button link type="primary" @click="choose(device)">详情<el-icon><ArrowRight /></el-icon></el-button></div></div>
-            </article>
-            <el-empty v-if="!devices.length && !loading" :description="error ? '设备列表加载失败' : '暂无匹配的报警设备'" :image-size="72"><el-button v-if="!error" @click="reset">重置筛选</el-button></el-empty>
-          </div>
-        </aside>
-
-        <div class="gis-map-stage">
-          <GisMapCanvas :key="mapKey" ref="mapRef" :points="points" :marker-size="36" :map-style-id="''" @ready="ready" @error="failMap" @point-click="choose" @cluster-click="cluster = $event.points || []" />
-          <div class="gis-map-status" role="status"><i :class="{ 'is-ready': mapReady, 'is-error': mapError }"></i>{{ mapError ? '底图未就绪 · 列表可用' : mapReady ? '地图已就绪' : '正在加载底图' }}</div>
-          <div class="gis-map-tools" aria-label="地图工具">
-            <el-tooltip content="查看全部点位" placement="bottom"><el-button :icon="Aim" :disabled="!mapReady || !points.length" aria-label="查看全部点位" @click="mapRef?.fitBounds()" /></el-tooltip>
-            <el-tooltip content="放大地图" placement="bottom"><el-button :icon="Plus" :disabled="!mapReady" aria-label="放大地图" @click="mapRef?.zoomIn()" /></el-tooltip>
-            <el-tooltip content="缩小地图" placement="bottom"><el-button :icon="Minus" :disabled="!mapReady" aria-label="缩小地图" @click="mapRef?.zoomOut()" /></el-tooltip>
-          </div>
-          <div v-if="!mapReady" class="gis-map-fallback">
-            <span class="gis-fallback-icon"><el-icon :class="{ 'is-loading': !mapError }"><Warning v-if="mapError" /><Loading v-else /></el-icon></span>
-            <h3>{{ mapError ? '底图暂时无法加载' : '正在连接地图服务' }}</h3>
-            <p>{{ mapError || '地图加载中,设备列表与详情可继续查看。' }}</p>
-            <el-button v-if="mapError" type="primary" :icon="Refresh" @click="retryMap">重试底图</el-button>
-            <span v-if="mapError" class="gis-fallback-note">设备资料、报警记录与监测曲线仍可独立查看</span>
-          </div>
-          <div class="gis-map-bottom"><div class="gis-map-legend" aria-label="报警等级图例"><span v-for="item in options.alarmLevels || []" :key="item.value" :style="levelStyle(item.value)"><i></i>{{ item.label }}</span></div><span v-if="missingCoordinates" class="gis-map-note">{{ missingCoordinates }} 台设备缺少有效坐标</span></div>
-        </div>
-      </div>
-      <footer class="gis-workspace-footer"><span><el-icon><Location /></el-icon>{{ selected ? '已选中:' + selected.name + ' · ' + selected.deviceCode : '选择设备查看详情,点击定位查看所在位置' }}</span><span>点位数字代表报警等级</span></footer>
-    </section>
-
-    <el-drawer v-model="detailVisible" size="min(680px, 100vw)" append-to-body destroy-on-close class="alarm-gis-drawer">
-      <template #header><div class="gis-drawer-heading"><span>设备详情</span><h2>{{ selected?.name }}</h2></div></template>
-      <div v-if="selected" class="gis-detail-content">
-        <div class="gis-detail-banner"><span class="gis-detail-grade" :style="levelStyle(selected.alarmLevel)"><span class="gis-grade-badge">{{ selected.alarmLevel }}</span>{{ levelLabel(selected.alarmLevel) }}</span><span>{{ selected.alarms.length }} 条报警 · 当前查询结果</span></div>
-        <dl class="gis-detail-grid"><div><dt>设备编码</dt><dd>{{ selected.deviceCode }}</dd></div><div><dt>最近报警时间</dt><dd>{{ latestAlarm?.createTime || '—' }}</dd></div><div class="is-wide"><dt>安装位置</dt><dd>{{ selected.equipmentLocation || '—' }}</dd></div></dl>
-        <div v-if="latestAlarm && hasActualValue(latestAlarm)" class="gis-snapshot"><div><span>最近报警触发值 · {{ latestAlarm.warningType || latestAlarm.warningCode }}</span><strong>{{ valueText(latestAlarm.actualValue, latestAlarm.warningUnit) }}</strong></div><div><span>触发时阈值</span><p>{{ thresholdText(latestAlarm) }}</p></div></div>
-
-        <el-tabs v-model="tab" class="gis-detail-tabs">
-          <el-tab-pane label="监测曲线" name="curve">
-            <div class="gis-panel-heading"><h3>监测趋势</h3><span>真实采集数据</span></div>
-            <el-select v-model="curveId" :disabled="!curveChoices.length" placeholder="选择报警指标" class="gis-curve-select"><el-option v-for="row in curveChoices" :key="row.id" :value="row.id" :label="`${row.warningType || row.warningCode} · ${row.alarmNo || row.createTime}`" /></el-select>
-            <AlarmChart v-if="detailVisible && tab === 'curve' && currentCurve" :alarm-id="currentCurve.id" :scope="scope" />
-            <el-empty v-if="!curveChoices.length" description="暂无有实际值的报警指标" :image-size="70" />
-          </el-tab-pane>
-          <el-tab-pane label="报警记录" name="alarms">
-            <div class="gis-panel-heading"><h3>本设备报警记录</h3><span>共 {{ historyTotal }} 条 · 时间倒序</span></div>
-            <el-alert v-if="historyError" :title="historyError" type="error" :closable="false"><el-button link @click="loadHistory">重试</el-button></el-alert>
-            <div v-loading="historyLoading" class="gis-alarm-history">
-              <article v-for="row in history" :key="row.id" class="gis-history-item" :style="levelStyle(row.alarmLevel)">
-                <div class="gis-history-heading"><el-tag size="small" effect="light" :type="levelTone(row.alarmLevel)">{{ row.alarmLevelName || levelLabel(row.alarmLevel) }}</el-tag><el-tag size="small" effect="light" :type="monitorStatusTone(row.alarmStatus)">{{ row.monitorStatusName }}</el-tag></div>
-                <p class="gis-history-description">{{ row.alarmContent || '—' }}</p>
-                <dl><dt>报警单号</dt><dd>{{ row.alarmNo || '—' }}</dd><dt>报警时间</dt><dd>{{ row.createTime || '—' }}</dd><dt>监测指标</dt><dd>{{ row.warningType || row.warningCode || '—' }}</dd></dl>
-                <div class="gis-history-actions"><el-button link type="primary" @click="detailRef.open(row.id)">报警详情</el-button><el-button v-if="hasActualValue(row)" link type="primary" @click="curveId = row.id; tab = 'curve'">查看曲线</el-button></div>
-              </article>
-              <el-empty v-if="!history.length && !historyLoading && !historyError" description="暂无报警记录" :image-size="60" />
-            </div>
-            <pagination v-show="historyTotal > 0" v-model:page="historyPage" v-model:limit="historySize" :total="historyTotal" :auto-scroll="false" layout="total, prev, pager, next" @pagination="loadHistory" />
-          </el-tab-pane>
-          <el-tab-pane label="设备信息" name="device">
-            <div class="gis-panel-heading"><h3>设备档案</h3><span>关联设备台账</span></div>
-            <dl class="gis-detail-grid"><div><dt>设备名称</dt><dd>{{ selected.name }}</dd></div><div><dt>设备编码</dt><dd>{{ selected.deviceCode }}</dd></div><div class="is-wide"><dt>安装位置</dt><dd>{{ selected.equipmentLocation || '—' }}</dd></div><div><dt>经度</dt><dd>{{ selected.hasCoordinates ? selected.lng : '暂无有效坐标' }}</dd></div><div><dt>纬度</dt><dd>{{ selected.hasCoordinates ? selected.lat : '暂无有效坐标' }}</dd></div></dl>
-            <section v-if="selected.equipmentImage" class="gis-device-images"><h3>设备图片</h3><AlarmAttachments :value="selected.equipmentImage" :base-url="baseUrl" /></section>
-          </el-tab-pane>
-        </el-tabs>
-      </div>
-      <template #footer><div class="gis-drawer-footer"><el-button @click="detailVisible = false">关闭</el-button></div></template>
-    </el-drawer>
-    <el-dialog :model-value="cluster.length > 0" title="聚合点位" width="min(440px, 94vw)" @close="cluster = []"><div class="gis-cluster-list"><button v-for="device in cluster" :key="device.key" type="button" @click="choose(device); cluster = []"><span>{{ device.name }}</span><small>{{ device.alarms.length }} 条报警</small><el-icon><ArrowRight /></el-icon></button></div></el-dialog>
-    <AlarmDetail ref="detailRef" />
-  </div>
-</template>
-
-<script setup name="WaterSupplyAlarmGIS">
-import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
-import { Search, Refresh, MapLocation, Location, Aim, Plus, Minus, ArrowRight, Warning, Loading } from '@element-plus/icons-vue'
-import GisMapCanvas from '@/components/GisMapCanvas.vue'
-import { getAlarmMap, getAlarmOptions, getAlarms } from '@/api/alarm'
-import AlarmDetail from '../../alarm/AlarmDetail.vue'
-import AlarmChart from '../../alarm/AlarmChart.vue'
-import AlarmAttachments from '../../alarm/AlarmAttachments.vue'
-import { alarmCurveChoices, groupAlarmDevices, levelColor, levelTone, monitorStatusTone } from '../../alarm/common/js/alarmOperationsModel'
-import { hasActualValue, thresholdText, valueText } from '../../alarm/common/js/alarmReviewModel'
-import { useAlarmScope } from '../../alarm/common/js/useAlarmScope'
-import '../../alarm/common/styles/alarmOperations.scss'
-
-const { scope, scopeKey } = useAlarmScope()
-const viewLabel = computed(() => ({ 1: '供水监测视图', 3: '燃气监测视图', 4: '窨井监测视图' }[scope.value.rootTypeId] || '设备监测视图'))
-const baseUrl = import.meta.env.VITE_APP_BASE_API
-const keyword = ref(''), level = ref(), options = ref({}), rows = ref([]), selected = ref(null), tab = ref('curve'), curveId = ref('')
-const loading = ref(false), error = ref(''), mapError = ref(''), mapReady = ref(false), mapKey = ref(0), mapRef = ref(), detailRef = ref(), cluster = ref([])
-const detailVisible = ref(false)
-const devices = computed(() => groupAlarmDevices(rows.value).sort((a, b) => Number(a.alarmLevel) - Number(b.alarmLevel) || String(a.deviceCode).localeCompare(String(b.deviceCode))))
-const points = computed(() => devices.value.filter(d => d.hasCoordinates))
-const missingCoordinates = computed(() => devices.value.length - points.value.length)
-const levelMetrics = computed(() => (options.value.alarmLevels || []).map(item => ({ ...item, count: devices.value.filter(device => Number(device.alarmLevel) === Number(item.value)).length })))
-const latestAlarm = computed(() => selected.value?.alarms[0])
-const levelStyle = value => ({ '--grade-color': levelColor(value), '--grade-tint': levelColor(value) + '14' })
-const levelLabel = value => options.value.alarmLevels?.find(item => Number(item.value) === Number(value))?.label || '未知等级'
-const history = ref([]), historyPage = ref(1), historySize = ref(10), historyTotal = ref(0), historyLoading = ref(false), historyError = ref('')
-const curveChoices = computed(() => alarmCurveChoices([...(selected.value?.alarms || []), ...history.value]))
-const currentCurve = computed(() => curveChoices.value.find(row => row.id === curveId.value))
-watch(curveChoices, choices => { if (!choices.some(row => row.id === curveId.value)) curveId.value = choices[0]?.id || '' })
-
-let historyGeneration = 0
-async function loadHistory() {
-  const device = selected.value
-  if (!device) return
-  const token = ++historyGeneration; historyLoading.value = true; historyError.value = ''
-  try {
-    const result = await getAlarms({ ...scope.value, deviceCode: device.deviceCode, pageNum: historyPage.value, pageSize: historySize.value })
-    if (token === historyGeneration) { history.value = result.data.records || []; historyTotal.value = result.data.total || 0 }
-  } catch (e) { if (token === historyGeneration) { historyError.value = e.message || '设备报警记录加载失败'; history.value = [] } }
-  finally { if (token === historyGeneration) historyLoading.value = false }
-}
-watch(selected, device => { ++historyGeneration; history.value = []; historyTotal.value = 0; historyPage.value = 1; historyError.value = ''; historyLoading.value = false; if (device) loadHistory() })
-let generation = 0
-async function load() {
-  const token = ++generation; loading.value = true; error.value = ''; detailVisible.value = false; selected.value = null; cluster.value = []
-  try {
-    const result = await getAlarmMap({ ...scope.value, keyword: keyword.value, alarmLevel: level.value, alarmStatus: 0 })
-    if (token === generation) { rows.value = result.data || []; await nextTick(); mapRef.value?.fitBounds() }
-  } catch (e) { if (token === generation) { error.value = e.message || '点位加载失败'; rows.value = [] } }
-  finally { if (token === generation) loading.value = false }
-}
-function locate(device) { selected.value = device; if (device.hasCoordinates && mapReady.value) mapRef.value?.locatePoint(device) }
-function choose(device) {
-  selected.value = device
-  curveId.value = device.alarms.find(hasActualValue)?.id || ''
-  tab.value = curveId.value ? 'curve' : 'alarms'
-  detailVisible.value = true
-  if (device.hasCoordinates && mapReady.value) mapRef.value?.locatePoint(device)
-}
-function ready() { mapError.value = ''; mapReady.value = true; mapRef.value?.fitBounds() }
-function failMap(message) { mapReady.value = false; mapError.value = message || '请检查网络或地图服务配置,稍后重试。' }
-function retryMap() { mapError.value = ''; mapReady.value = false; ++mapKey.value }
-function reset() { keyword.value = ''; level.value = undefined; return load() }
-watch(scopeKey, async () => {
-  ++generation; rows.value = []; selected.value = null; detailVisible.value = false; loading.value = false; error.value = ''; options.value = {}
-  const key = scopeKey.value
-  try { const result = await getAlarmOptions({ ...scope.value }); if (key === scopeKey.value) options.value = result.data || {} }
-  catch (e) { if (key === scopeKey.value) { error.value = e.message || '查询选项加载失败'; return } }
-  if (key === scopeKey.value) await reset()
-}, { immediate: true })
-onBeforeUnmount(() => { ++generation; ++historyGeneration })
-</script>
-
-<style scoped lang="scss" src="./alarmGIS.scss"></style>

+ 1 - 1
tests/gisMapCanvas.test.mjs

@@ -169,7 +169,7 @@ test('water supply GIS pages share the common canvas component', async () => {
   assert.match(facilitySource, /getFacilityStatusIcon\(point\.type, point\.status\)/u)
   assert.match(facilitySource, /cluster-label="设施集合"/u)
   assert.match(facilitySource, /@point-click="locateFeature"/u)
-  const alarmSource = await readFile('src/views/subSystem/waterSupply/alarm/alarmGIS.vue', 'utf8')
+  const alarmSource = await readFile('src/views/subSystem/alarm/common/alarmGIS.vue', 'utf8')
 
   assert.match(alarmSource, /<GisMapCanvas/u)
   assert.match(alarmSource, /@cluster-click="cluster = \$event.points \|\| \[\]"/u)

+ 49 - 0
tests/warningIndicator.test.mjs

@@ -0,0 +1,49 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { indicatorName, setIndicatorNames, normalizeCode } from '../src/utils/warningIndicator.js'
+
+test('优先使用后端下发的监测指标中文名', () => {
+  setIndicatorNames([])
+  assert.equal(indicatorName({ warningName: '窨井倾斜预警', warningCode: 'WARN-TILT' }), '窨井倾斜预警')
+})
+
+test('没有 warningName 时使用 alarm_data 的中文类型名', () => {
+  setIndicatorNames([])
+  assert.equal(indicatorName({ warningType: '倾斜预警', warningCode: 'WARN-TILT' }), '倾斜预警')
+})
+
+test('通过 warning_code 从 warning_type 字典取 warning_name 显示', () => {
+  setIndicatorNames([
+    { dictValue: 'WARN-TILT', dictLabel: '窨井倾斜预警' },
+    { typeCode: 'WARN_PRESSURE', typeName: '压力预警' }
+  ])
+  assert.equal(indicatorName({ warningCode: 'WARN-TILT' }, '监测指标'), '窨井倾斜预警')
+  assert.equal(indicatorName({ warningCode: 'WARN_PRESSURE' }, '监测指标'), '压力预警')
+})
+
+test('warning_type 与 alarm_data 编码 -/_、大小写混用也能关联上', () => {
+  setIndicatorNames([{ dictValue: 'WARN_TILT', dictLabel: '窨井倾斜预警' }])
+  assert.equal(indicatorName({ warningCode: ' warn-tilt ' }, '监测指标'), '窨井倾斜预警')
+  assert.equal(normalizeCode(' WARN-Tilt '), 'WARN_TILT')
+})
+
+test('任何 WARN-*/WARN_* 编码都不作为展示文案', () => {
+  setIndicatorNames([])
+  assert.equal(indicatorName({ warningName: 'WARN-TILT' }, '监测指标'), '监测指标')
+  assert.equal(indicatorName({ warningType: 'WARN_PRESSURE' }, '监测指标'), '监测指标')
+  assert.equal(indicatorName({ warningType: 'UNKNOWN_EVENT_TYPE' }, '监测指标'), '监测指标')
+})
+
+test('全大写下划线编码也不当作名称', () => {
+  setIndicatorNames([])
+  assert.equal(indicatorName({ warningType: 'DRAINAGE_DEVICE' }, '监测指标'), '监测指标')
+})
+
+test('字段缺失或字典未命中时返回兜底文案而不是编码', () => {
+  setIndicatorNames([])
+  assert.equal(indicatorName({ warningCode: 'WARN-TILT' }), '—')
+  assert.equal(indicatorName({ warningCode: 'WARN-TILT' }, '其他告警'), '其他告警')
+  assert.equal(indicatorName(), '—')
+  setIndicatorNames([{ dictValue: 'WARN-PRESSURE', dictLabel: '压力预警' }])
+  assert.equal(indicatorName({ warningCode: 'WARN-TILT' }, '监测指标'), '监测指标')
+})

+ 1 - 1
tests/waterAlarmAuditWorkbench.test.mjs

@@ -73,7 +73,7 @@ test('timeline renders thumbnails with teleported full-size image preview', asyn
 })
 
 test('shared alarm detail navigates to authorized audit and supply map no longer implements auditing', async () => {
-  const ledger = await readFile('src/views/subSystem/waterSupply/alarm/alarmGIS.vue','utf8')
+  const ledger = await readFile('src/views/subSystem/alarm/common/alarmGIS.vue','utf8')
   const detail = await readFile('src/views/subSystem/alarm/AlarmDetail.vue','utf8')
   assert.ok(detail.includes("navigate('alarmAudit'"))
   assert.ok(!ledger.includes("mode === 'manage'"))

+ 1 - 1
tests/waterGisConsistency.test.mjs

@@ -121,7 +121,7 @@ test('the GIS page chrome theme is shared by the facility and device maps', asyn
 test('every GIS map renders its points from the filtered layer state', async () => {
   const facility = await readFile(pages.facility, 'utf8')
   const device = await readFile(pages.device, 'utf8')
-  const alarm = await readFile('src/views/subSystem/waterSupply/alarm/alarmGIS.vue', 'utf8')
+  const alarm = await readFile('src/views/subSystem/alarm/common/alarmGIS.vue', 'utf8')
 
   assert.match(facility, /const visibleMapModel = computed\(\(\) => filterFacilityMapModel\(fullMapModel\.value, layerState\)\)/u)
   assert.match(facility, /const mapPoints = computed\(\(\) => visibleMapModel\.value\.points\.map/u)

+ 0 - 103
tests/waterSupplyAlarmGIS.test.mjs

@@ -1,103 +0,0 @@
-import test from 'node:test'
-import assert from 'node:assert/strict'
-import { readFile } from 'node:fs/promises'
-import vm from 'node:vm'
-import { parse } from '@vue/compiler-sfc'
-import { computed, effectScope, nextTick, ref, watch } from 'vue'
-import * as operations from '../src/views/subSystem/alarm/common/js/alarmOperationsModel.js'
-import * as review from '../src/views/subSystem/alarm/common/js/alarmReviewModel.js'
-import { HOME_QUICK_ENTRIES, WATER_DEVICE_ROOT_TYPE_ID, buildHomeGisOverview } from '../src/utils/waterHomeModel.js'
-
-const { descriptor } = parse(await readFile(new URL('../src/views/subSystem/waterSupply/alarm/alarmGIS.vue', import.meta.url), 'utf8'))
-const script = descriptor.scriptSetup.content.replace(/^import .+$/gm, '')
-  .replace('import.meta.env.VITE_APP_BASE_API', "''")
-const alarms = [
-  { id: 'w1', equipmentId: 'eq1', deviceCode: 'W-1', deviceName: '供水压力设备', alarmLevel: 1,
-    alarmStatus: 0, actualValue: 0, longitude: 110.4, latitude: 28.4, createTime: '2026-09-24 10:00:00' },
-  { id: 'w2', equipmentId: 'eq1', deviceCode: 'W-1', alarmLevel: 2, alarmStatus: 0, actualValue: null,
-    longitude: 110.4, latitude: 28.4, createTime: '2026-09-24 09:00:00' },
-  { id: 'w3', equipmentId: 'eq2', deviceCode: 'W-2', alarmLevel: 3, alarmStatus: 0, actualValue: null }
-]
-
-async function flush() {
-  for (let i = 0; i < 6; i += 1) await Promise.resolve()
-  await nextTick()
-}
-
-function mountMap(t, rootTypeId = 1) {
-  const calls = { map: [], options: [], history: [] }, scope = ref({ rootTypeId })
-  let unmount
-  const context = {
-    ref, computed, watch, nextTick, ...operations, ...review, WATER_DEVICE_ROOT_TYPE_ID,
-    onBeforeUnmount: callback => { unmount = callback },
-    useAlarmScope: () => ({ scope, scopeKey: computed(() => JSON.stringify(scope.value)) }),
-    getAlarmMap: async params => { calls.map.push(params); return { data: alarms } },
-    getAlarmOptions: async params => {
-      calls.options.push(params)
-      return { data: { alarmLevels: [1, 2, 3, 4].map(value => ({ value, label: `${value}级报警` })) } }
-    },
-    getAlarms: async params => {
-      calls.history.push(params)
-      return { data: { records: alarms.filter(row => row.deviceCode === params.deviceCode), total: 2 } }
-    }
-  }
-  const effects = effectScope()
-  effects.run(() => vm.runInNewContext(`${script}
-    globalThis.page = { error, devices, points, missingCoordinates, levelMetrics,
-      selected, detailVisible, curveChoices, currentCurve, historyPage, historySize,
-      choose, loadHistory }
-  `, context))
-  t.after(() => { unmount(); effects.stop() })
-  return { calls, page: context.page, scope }
-}
-
-test('water GIS requests current water alarms and groups them by equipment', async t => {
-  const { page, calls } = mountMap(t)
-  await flush()
-  assert.equal(calls.map.length, 1)
-  assert.equal(calls.map[0].rootTypeId, 1)
-  assert.equal(calls.map[0].alarmStatus, 0)
-  assert.equal(calls.options[0].rootTypeId, 1)
-  assert.equal(page.devices.value.length, 2)
-  assert.equal(page.points.value.length, 1)
-  assert.equal(page.missingCoordinates.value, 1)
-  assert.equal(page.levelMetrics.value[0].count, 1)
-  assert.equal(page.levelMetrics.value[1].count, 0)
-})
-
-test('water GIS rejects a drainage scope before fetching any alarm data', async t => {
-  const { page, calls } = mountMap(t, 2)
-  await flush()
-  assert.equal(calls.map.length, 0)
-  assert.equal(calls.options.length, 0)
-  assert.match(page.error.value, /供水子系统/)
-})
-
-test('device history carries the water scope and curve choices retain zero but exclude null', async t => {
-  const { page, calls } = mountMap(t)
-  await flush()
-  page.choose(page.devices.value[0])
-  await flush()
-  assert.equal(page.detailVisible.value, true)
-  assert.equal(calls.history[0].rootTypeId, 1)
-  assert.equal(calls.history[0].deviceCode, 'W-1')
-  assert.equal(page.curveChoices.value.length, 1)
-  assert.equal(page.currentCurve.value.id, 'w1')
-  page.historyPage.value = 2
-  await page.loadHistory()
-  assert.equal(calls.history[1].pageNum, 2)
-  assert.equal(calls.history[1].pageSize, 10)
-})
-
-test('home map shortcut includes the required water scope', () => {
-  const entry = HOME_QUICK_ENTRIES.find(item => item.key === 'map')
-  assert.equal(entry.suffix, '/waterSupplyAlarmGIS')
-  assert.deepEqual(entry.query, { rootTypeId: 1 })
-})
-
-test('home overview accepts unified alarm records without duplicating device points', () => {
-  const overview = buildHomeGisOverview({ alarmPoints: alarms })
-  assert.equal(overview.points.length, 1)
-  assert.equal(overview.points[0].id, 'w1')
-  assert.equal(overview.points[0].statusKey, 'alarm')
-})