Переглянути джерело

feat(waterSupply): 供水首页蓝白工作台 A/B/C 三段式落地 (#48)

Kazerin 1 місяць тому
батько
коміт
d8425a3ff6

+ 251 - 0
src/utils/waterHomeModel.js

@@ -0,0 +1,251 @@
+import { normalizeDeviceRow } from './waterSupplyUiModel.js'
+import { normalizeMapPoints } from './waterSupplyModel.js'
+
+/**
+ * 供水首页工作台数据模型(T05 #48)。
+ *
+ * 首页只组合既有只读接口,不在本模块调用后端;所有口径(供水范围、工单状态、
+ * 报警等级、在线率)都收敛到这里,便于用 node 单测锁定。
+ */
+
+// 工单状态 0-7,与 ADR-0010 权威枚举一致
+const WORK_ORDER_STATUS_META = {
+  0: { text: '待派单', type: 'danger' },
+  1: { text: '待接单', type: 'warning' },
+  2: { text: '已接单', type: 'primary' },
+  3: { text: '处理中', type: 'warning' },
+  4: { text: '待验收', type: 'primary' },
+  5: { text: '已结案', type: 'success' },
+  6: { text: '延期中', type: 'warning' },
+  7: { text: '已取消', type: 'info' }
+}
+
+const WORK_ORDER_LEVEL_META = {
+  0: { text: '紧急', type: 'danger' },
+  1: { text: '一般', type: 'warning' },
+  2: { text: '低', type: 'info' }
+}
+
+const ALARM_LEVEL_META = {
+  1: { text: '一级报警', type: 'danger' },
+  2: { text: '二级报警', type: 'warning' },
+  3: { text: '三级报警', type: 'primary' },
+  4: { text: '四级报警', type: 'success' }
+}
+
+export const HOME_QUICK_ENTRIES = [
+  { key: 'abnormal', label: '设备异常管理', suffix: '/device/abnormal', icon: 'WarningFilled' },
+  { key: 'alarm', label: '报警管理', suffix: '/monitorAlarm/index', icon: 'BellFilled' },
+  { key: 'audit', label: '报警审核', suffix: '/monitorAlarm/audit', icon: 'Finished' },
+  { key: 'map', label: '监测报警一张图', suffix: '/monitorAlarm/map', icon: 'MapLocation' }
+]
+
+/** 供水工单统计/列表的固定范围参数:设备父类别=供水。 */
+export function buildHomeWorkOrderQuery(extra = {}) {
+  return {
+    deviceTypeParentId: '1',
+    pageNum: 1,
+    pageSize: 5,
+    ...extra
+  }
+}
+
+/** 供水设施总数:overview 会追加 facilityType=total 合计行,须排除避免重复累计。 */
+export function normalizeFacilityTotal(facilityOverview = []) {
+  const rows = Array.isArray(facilityOverview) ? facilityOverview : []
+  return rows
+    .filter(item => item && item.facilityType !== 'total')
+    .reduce((sum, item) => sum + Number(item.totalCount ?? item.count ?? 0), 0)
+}
+
+/** A 区指挥台四项核心总量。 */
+export function normalizeHomeKpis({ facilityRows = [], ledgerStats = {}, alarmStats = {} } = {}) {
+  const realtimeAlarm = Number(alarmStats.realtimeAlarmCount ?? alarmStats.realtimeCount ?? 0)
+  return [
+    { key: 'facility', label: '设施总数', value: normalizeFacilityTotal(facilityRows), unit: '处', tone: 'primary' },
+    { key: 'device', label: '监测设备', value: Number(ledgerStats.total ?? 0), unit: '台', tone: 'primary' },
+    { key: 'online', label: '在线设备', value: Number(ledgerStats.online ?? 0), unit: '台', tone: 'primary' },
+    { key: 'alarm', label: '实时报警', value: realtimeAlarm, unit: '条', tone: realtimeAlarm > 0 ? 'danger' : 'primary' }
+  ]
+}
+
+function countByStatus(stat, status) {
+  const counts = Array.isArray(stat?.counts) ? stat.counts : []
+  const hit = counts.find(item => Number(item?.status) === Number(status))
+  return Number(hit?.count ?? 0)
+}
+
+/** 工单状态统计:待处理 0/1,处理中 2/3/6,待验收 4,已完成 5/7。 */
+export function normalizeWorkOrderStatusStat(stat = {}) {
+  const pendingCount = countByStatus(stat, 0) + countByStatus(stat, 1)
+  const processingCount = countByStatus(stat, 2) + countByStatus(stat, 3) + countByStatus(stat, 6)
+  const verifyingCount = countByStatus(stat, 4)
+  const finishedCount = countByStatus(stat, 5) + countByStatus(stat, 7)
+  const byStatus = Object.entries(WORK_ORDER_STATUS_META)
+    .map(([status, meta]) => ({
+      status: Number(status),
+      label: meta.text,
+      type: meta.type,
+      count: countByStatus(stat, Number(status))
+    }))
+  return {
+    total: Number(stat?.total ?? 0),
+    pendingCount,
+    processingCount,
+    verifyingCount,
+    finishedCount,
+    byStatus
+  }
+}
+
+/** C 区数据总览卡片:报警总数、报警中设备、待验收工单、在线率。 */
+export function normalizeAlarmOverviewCards({ dashboard = {}, deviceStats = {}, workOrderStat = {} } = {}) {
+  const normalized = normalizeWorkOrderStatusStat(workOrderStat)
+  return [
+    { key: 'alarmTotal', label: '报警总数', value: Number(dashboard.totalCount ?? 0), unit: '条', tone: 'primary' },
+    { key: 'alarmingDevice', label: '报警中设备', value: Number(dashboard.alarmingDeviceCount ?? 0), unit: '台', tone: Number(dashboard.alarmingDeviceCount ?? 0) > 0 ? 'danger' : 'primary' },
+    { key: 'verifyingOrder', label: '待验收工单', value: normalized.verifyingCount, unit: '单', tone: normalized.verifyingCount > 0 ? 'warning' : 'primary' },
+    { key: 'onlineRate', label: '在线率', value: formatOnlineRate(deviceStats.onlineRate), unit: '%', tone: 'primary' }
+  ]
+}
+
+function formatOnlineRate(value) {
+  const numeric = Number(value)
+  return Number.isFinite(numeric) ? Number(numeric.toFixed(1)) : 0
+}
+
+/** 报警趋势:按四个等级分别成系列,空天补 0。 */
+export function normalizeAlarmTrend(trend = []) {
+  const items = Array.isArray(trend) ? trend : []
+  const dates = items.map(item => item?.date || '-')
+  const levels = [1, 2, 3, 4].map(level => ({
+    name: `级${level}`,
+    text: (ALARM_LEVEL_META[level] || {}).text || `${level}级报警`,
+    data: items.map(item => Number(item[`level${level}Count`] ?? 0))
+  }))
+  return { dates, levels }
+}
+
+/** 占比饼图:空值名称兜底、数量转数字、只取最大值前若干项。 */
+export function normalizePieRatio(items = [], fallbackName = '未分类', top = 6) {
+  const rows = (Array.isArray(items) ? items : [])
+    .map(item => ({
+      name: String(item?.name || '').trim() || fallbackName,
+      count: Number(item?.count ?? 0)
+    }))
+    .filter(item => item.count > 0)
+    .sort((a, b) => b.count - a.count)
+  return rows.slice(0, top)
+}
+
+export function alarmLevelText(level) {
+  const numeric = Number(level)
+  const meta = ALARM_LEVEL_META[numeric]
+  return meta?.text || (Number.isFinite(numeric) && numeric > 0 ? `${numeric}级报警` : '未分级')
+}
+
+/** A 区实时报警列表。 */
+export function normalizeRealtimeAlarmRows(rows = [], limit = 5) {
+  const list = (Array.isArray(rows) ? rows : [])
+    .slice()
+    .sort((a, b) => String(b?.alarmTime || '').localeCompare(String(a?.alarmTime || '')))
+    .slice(0, limit)
+    .map(row => ({
+      id: row?.id ?? row?.alarmId,
+      deviceCode: row?.deviceCode ?? '-',
+      deviceName: row?.deviceName || row?.deviceCode || '监测设备',
+      warningType: row?.warningType || '监测报警',
+      alarmLevel: Number(row?.alarmLevel ?? 0),
+      alarmLevelText: row?.alarmLevelText || alarmLevelText(row?.alarmLevel),
+      alarmTime: row?.alarmTime || '-',
+      location: row?.equipmentLocation || '-',
+      displayStatus: row?.displayStatus,
+      displayStatusText: row?.displayStatusText || '-'
+    }))
+  return list
+}
+
+/** B 区异常待办清单:复用设备行标准化,避免口径漂移。 */
+export function normalizeAbnormalTodoRows(rows = [], limit = 6) {
+  return (Array.isArray(rows) ? rows : []).slice(0, limit).map(normalizeDeviceRow)
+}
+
+/** B 区选中设备摘要。 */
+export function normalizeDeviceSummary(row = {}) {
+  const normalized = normalizeDeviceRow(row)
+  return {
+    ...normalized,
+    name: row.equipmentName || row.deviceName || '-',
+    code: row.equipmentCode || row.deviceCode || '-',
+    location: row.equipmentLocation || row.location || '-',
+    district: row.district || '-',
+    typeName: normalized.equipmentTypeName || '-'
+  }
+}
+
+/** A 区工单进展列表。 */
+export function normalizeWorkOrderRows(rows = [], limit = 5) {
+  return (Array.isArray(rows) ? rows : [])
+    .slice()
+    .sort((a, b) => String(b?.createTime || '').localeCompare(String(a?.createTime || '')))
+    .slice(0, limit)
+    .map(row => {
+      const orderStatus = Number(row?.orderStatus ?? -1)
+      const orderLevel = Number(row?.orderLevel ?? -1)
+      const statusMeta = WORK_ORDER_STATUS_META[orderStatus] || { text: '未知', type: 'info' }
+      const levelMeta = WORK_ORDER_LEVEL_META[orderLevel] || { text: '-', type: 'info' }
+      return {
+        orderId: row?.orderId,
+        orderNo: row?.orderNo || '-',
+        orderStatus,
+        orderStatusText: statusMeta.text,
+        orderStatusType: statusMeta.type,
+        orderLevel,
+        orderLevelText: levelMeta.text,
+        orderLevelType: levelMeta.type,
+        deviceCode: row?.deviceCode || '-',
+        deviceName: row?.deviceName || '-',
+        orderDesc: row?.orderDesc || '-',
+        createTime: row?.createTime || '-'
+      }
+    })
+}
+
+/** 全域态势点位:设备点 + 实时报警点,按状态着色,去重后归一化到面板坐标。 */
+export function buildOverviewMapPoints({ devicePoints = [], alarmRows = [] } = {}) {
+  const alarmList = (Array.isArray(alarmRows) ? alarmRows : [])
+    .filter(row => Number.isFinite(Number(row?.longitude ?? row?.lng)) && Number.isFinite(Number(row?.latitude ?? row?.lat)))
+    .map(row => ({
+      id: `alarm-${row?.id ?? row?.alarmId ?? row?.deviceCode ?? Math.random()}`,
+      deviceCode: row?.deviceCode || '-',
+      name: row?.deviceName || row?.deviceCode || '报警点',
+      longitude: Number(row?.longitude ?? row?.lng),
+      latitude: Number(row?.latitude ?? row?.lat),
+      state: 'alarm',
+      level: Number(row?.alarmLevel ?? 0),
+      meta: row?.warningType || '监测报警'
+    }))
+
+  const deviceList = (Array.isArray(devicePoints) ? devicePoints : [])
+    .filter(point => Number.isFinite(Number(point?.longitude ?? point?.lng)) && Number.isFinite(Number(point?.latitude ?? point?.lat)))
+    .map(point => ({
+      id: `device-${point?.equipmentId ?? point?.id ?? point?.deviceCode}`,
+      deviceCode: point?.equipmentCode || point?.deviceCode || '-',
+      name: point?.equipmentName || point?.deviceName || point?.equipmentCode || '监测设备',
+      longitude: Number(point?.longitude ?? point?.lng),
+      latitude: Number(point?.latitude ?? point?.lat),
+      state: Number(point?.alarmStatus) === 1 ? 'alarm' : Number(point?.onlineStatus) === 0 ? 'offline' : 'normal',
+      meta: point?.equipmentTypeName || '监测设备'
+    }))
+
+  const merged = new Map()
+  ;[...deviceList, ...alarmList].forEach(point => {
+    const key = point.deviceCode || point.id
+    const existing = merged.get(key)
+    if (!existing || (point.state === 'alarm' && existing.state !== 'alarm')) {
+      merged.set(key, point)
+    }
+  })
+
+  return normalizeMapPoints(Array.from(merged.values()))
+}

+ 778 - 1163
src/views/subSystem/waterSupply/components/WaterSupplyHome.vue

@@ -1,1376 +1,991 @@
-<script setup>
-import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
-import * as echarts from 'echarts'
-import { Odometer, CircleCheckFilled, VideoCameraFilled, WarnTriangleFilled } from '@element-plus/icons-vue'
-import { convertPipeline, convertPoints } from '@/utils/coordTransform'
-import {
-  getAlarmStats,
-  getWaterFacilityFeatures,
-  getWaterFacilityOverview,
-  getDeviceGisAllPoints,
-  getDeviceGisStatistics,
-  listRealtimeAlarms,
-  listDeviceLedger,
-  listFlowMonitoring,
-  listPressureMonitoring,
-  listLeakageMonitoring
-} from '@/api/pipeNetwork/waterSupply'
-import { normalizeGeoJson, normalizeMonitoringRecord, normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
-
-const nowText = ref('')
-const weekText = ref('')
-const mapError = ref('')
-const loading = ref(false)
-
-const dashboardData = ref({})
-const pipelineLines = ref([])
-const pipePoints = ref([])
-
-const facilityTypes = [
-  { label: '水源地', value: 'source', color: '#38a3ff' },
-  { label: '水厂', value: 'plant', color: '#38d0a0' },
-  { label: '泵站', value: 'pumpStation', color: '#ffc15e' },
-  { label: '管网', value: 'pipe', color: '#7b9bb8' },
-  { label: '用水户', value: 'user', color: '#ff7894' }
-]
-
-const typeCount = type => {
-  const row = (dashboardData.value.facilityOverview || []).find(item => item.facilityType === type)
-  return Number(row?.totalCount ?? row?.count ?? 0)
-}
-
-const baseCards = computed(() => {
-  const fs = dashboardData.value.facilityOverview || []
-  const es = dashboardData.value.equipmentStats || {}
-  const alarm = dashboardData.value.alarmStats || {}
-  const totalFacility = fs.reduce((sum, item) => sum + Number(item.totalCount || 0), 0)
-  const totalDevice = Number(es.total ?? es.totalDeviceCount ?? 0)
-  const onlineDevice = Number(es.online ?? es.onlineCount ?? 0)
-  const realtimeAlarm = Number(alarm.realtimeAlarmCount ?? alarm.realtimeCount ?? 0)
-  return [
-    { label: '设施总数', value: totalFacility, unit: '处', icon: Odometer, color: '#60b8ff' },
-    { label: '设备总数', value: totalDevice, unit: '台', icon: CircleCheckFilled, color: '#52e0a8' },
-    { label: '在线设备', value: onlineDevice, unit: '台', icon: VideoCameraFilled, color: '#ff8e8e' },
-    { label: '实时报警', value: realtimeAlarm, unit: '条', icon: WarnTriangleFilled, color: '#ffb54d' }
-  ]
-})
-
-const operationSummary = computed(() => {
-  return [
-    { label: '供水管网', value: typeCount('pipe') + '条' },
-    { label: '水厂', value: typeCount('plant') + '座' },
-    { label: '泵站', value: typeCount('pumpStation') + '座' }
-  ]
-})
-
-const repairRecords = computed(() => {
-  const list = dashboardData.value.facilityOverview || []
-  if (list.length) {
-    return list.map(item => ({ month: item.facilityTypeName || item.facilityType || '设施', value: Number(item.totalCount || 0) }))
-  }
-  return [
-    { month: '水源地', value: 0 }, { month: '水厂', value: 0 }, { month: '泵站', value: 0 },
-    { month: '管网', value: 0 }, { month: '用水户', value: 0 }
-  ]
-})
-
-const pointRows = computed(() => {
-  const list = dashboardData.value.pointRows || []
-  return list.length > 0 ? list : [{ id: 1, name: '暂无数据', time: '--', value: '--', status: '离线' }]
-})
-
-const equipmentRows = computed(() => {
-  const list = dashboardData.value.equipmentRows || []
-  return list.length > 0 ? list : [{ id: 1, name: '暂无数据', code: '--', location: '--', status: '--' }]
-})
-
-const warningRows = computed(() => {
-  const list = dashboardData.value.warningRows || []
-  return list.length > 0 ? list : [{ level: '中', title: '暂无告警', code: '--', time: '--:--:--', desc: '当前无告警信息', tag: '正常' }]
-})
-
-const hazardSummary = computed(() => {
-  const alarm = dashboardData.value.alarmStats || {}
-  return [
-    { label: '实时报警', value: alarm.realtimeAlarmCount ?? alarm.realtimeCount ?? 0, color: '#ff6d79' },
-    { label: '今日报警', value: alarm.todayAlarmCount ?? alarm.todayCount ?? 0, color: '#ffb54d' },
-    { label: '异常率', value: alarm.deviceAbnormalRate ?? alarm.abnormalRate ?? 0, color: '#f3e64d' }
-  ]
-})
-
-const mapKpis = computed(() => {
-  const es = dashboardData.value.equipmentStats || {}
-  const alarm = dashboardData.value.alarmStats || {}
-  const totalDevice = Number(es.total ?? es.totalDeviceCount ?? 0)
-  const onlineDevice = Number(es.online ?? es.onlineCount ?? 0)
-  return [
-    { label: '设备在线率', value: totalDevice > 0 ? (onlineDevice / totalDevice * 100).toFixed(1) + '%' : '--' },
-    { label: '供水设施', value: dashboardData.value.facilityOverview?.reduce((sum, item) => sum + Number(item.totalCount || 0), 0) || 0 },
-    { label: '今日报警', value: (alarm.todayAlarmCount ?? alarm.todayCount ?? 0) + '条' }
-  ]
-})
-
-const mapWrapRef = ref(null)
-const repairChartRef = ref(null)
-const hazardChartRef = ref(null)
-let mapInstance = null
-let repairChart = null
-let hazardChart = null
-let clockTimer = null
-
-function formatWeek(day) {
-  return ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][day]
-}
-
-function pad(value) {
-  return String(value).padStart(2, '0')
-}
-
-function updateClock() {
-  const now = new Date()
-  nowText.value = `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
-  weekText.value = formatWeek(now.getDay())
-}
-
-function initRepairChart() {
-  if (!repairChartRef.value) return
-  repairChart?.dispose()
-  repairChart = echarts.init(repairChartRef.value)
-  const data = repairRecords.value
-  repairChart.setOption({
-    grid: { left: 30, right: 12, top: 20, bottom: 24, containLabel: true },
-    xAxis: {
-      type: 'category',
-      data: data.map(item => item.month),
-      axisLine: { lineStyle: { color: 'rgba(91, 167, 219, 0.8)' } },
-      axisLabel: { color: '#d2efff' }
-    },
-    yAxis: {
-      type: 'value',
-      splitLine: { lineStyle: { color: 'rgba(91, 167, 219, 0.18)' } },
-      axisLine: { show: false },
-      axisLabel: { color: '#d2efff' }
-    },
-    tooltip: { trigger: 'axis' },
-    series: [{
-      type: 'bar',
-      barWidth: 24,
-      data: data.map(item => item.value),
-      itemStyle: {
-        borderRadius: [6, 6, 0, 0],
-        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-          { offset: 0, color: '#38d8ff' },
-          { offset: 1, color: '#166dff' }
-        ])
-      }
-    }]
-  })
-}
-
-function initHazardChart() {
-  if (!hazardChartRef.value) return
-  hazardChart?.dispose()
-  hazardChart = echarts.init(hazardChartRef.value)
-  const data = hazardSummary.value
-  hazardChart.setOption({
-    grid: { left: 18, right: 18, top: 10, bottom: 10, containLabel: true },
-    xAxis: {
-      type: 'value',
-      splitLine: { show: false },
-      axisLine: { show: false },
-      axisTick: { show: false },
-      axisLabel: { show: false }
-    },
-    yAxis: {
-      type: 'category',
-      data: data.map(item => item.label),
-      axisLine: { show: false },
-      axisTick: { show: false },
-      axisLabel: { color: '#d2efff', fontSize: 13 }
-    },
-    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
-    series: [{
-      type: 'bar',
-      data: data.map(item => ({ value: item.value, itemStyle: { color: item.color, borderRadius: 10 } })),
-      barWidth: 14,
-      label: { show: true, position: 'right', color: '#fff', fontWeight: 'bold' },
-      showBackground: true,
-      backgroundStyle: { color: 'rgba(255,255,255,0.08)', borderRadius: 10 }
-    }]
-  })
-}
-
-function waitForBMapGL() {
-  return new Promise((resolve, reject) => {
-    if (window.BMapGL) {
-      resolve(window.BMapGL)
-      return
-    }
-    let attempts = 0
-    const timer = setInterval(() => {
-      attempts += 1
-      if (window.BMapGL) {
-        clearInterval(timer)
-        resolve(window.BMapGL)
-        return
-      }
-      if (attempts >= 100) {
-        clearInterval(timer)
-        reject(new Error('百度地图加载超时'))
-      }
-    }, 200)
-  })
-}
-
-function addPipeLine(BMapGL, pipe) {
-  const bmapPoints = pipe.points.map(([lng, lat]) => new BMapGL.Point(lng, lat))
-  const polyline = new BMapGL.Polyline(bmapPoints, {
-    strokeColor: pipe.color,
-    strokeWeight: 4,
-    strokeOpacity: 0.95
-  })
-  mapInstance.addOverlay(polyline)
-
-  bmapPoints.forEach((point) => {
-    const ring = new BMapGL.Circle(point, 14, {
-      strokeColor: pipe.color,
-      strokeWeight: 2,
-      strokeOpacity: 0.7,
-      fillColor: 'rgba(56,182,233,0.08)',
-      fillOpacity: 0.18
-    })
-    mapInstance.addOverlay(ring)
-  })
-}
-
-function addPipePoint(BMapGL, point) {
-  const markerPoint = new BMapGL.Point(point.lng, point.lat)
-  const marker = new BMapGL.Marker(markerPoint)
-  mapInstance.addOverlay(marker)
-  const label = new BMapGL.Label(`${point.name} ${point.value}`, { offset: new BMapGL.Size(-42, -40) })
-  label.setPosition(markerPoint)
-  label.setStyle({
-    color: '#d8faff',
-    background: 'rgba(18, 68, 112, 0.75)',
-    border: '1px solid rgba(56, 182, 233, 0.55)',
-    borderRadius: '8px',
-    padding: '5px 8px',
-    fontSize: '12px'
-  })
-  mapInstance.addOverlay(label)
-}
-
-async function initMap() {
-  if (!mapWrapRef.value) return
-  try {
-    const BMapGL = await waitForBMapGL()
-    mapInstance = new BMapGL.Map(mapWrapRef.value, { enableMapClick: true })
-    const center = new BMapGL.Point(110.3965, 28.4638)
-    mapInstance.centerAndZoom(center, 15)
-    mapInstance.enableScrollWheelZoom(true)
-    mapInstance.setMapStyleV2({ styleId: 'a0f4e6e6f9e7a6c5d8e7f7e7f7e7f7e5' })
-
-    const bdPipelineLines = pipelineLines.value.map(pipe => convertPipeline(pipe))
-    const bdPipePoints = convertPoints(pipePoints.value)
-    bdPipelineLines.forEach(pipe => addPipeLine(BMapGL, pipe))
-    bdPipePoints.forEach(point => addPipePoint(BMapGL, point))
-  } catch (error) {
-    mapError.value = '百度地图加载失败,请检查地图脚本或网络连接。'
-  }
-}
-
-function handleResize() {
-  repairChart?.resize()
-  hazardChart?.resize()
-}
-
-function parseFeatures(features) {
-  const lines = []
-  const points = []
-  ;(features || []).forEach(feature => {
-    const coordinates = feature.geometry?.coordinates || []
-    const properties = feature.properties || {}
-    if (feature.geometry?.type === 'LineString' && coordinates.length > 1) {
-      lines.push({
-        id: feature.id || `line-${lines.length}`,
-        name: properties.facilityTypeName || properties.name || '供水管网',
-        color: '#37b6e9',
-        points: coordinates
-      })
-    }
-    if (feature.geometry?.type === 'Point' && coordinates.length >= 2) {
-      points.push({
-        id: feature.id || properties.id,
-        name: properties.facilityName || properties.name || properties.facilityTypeName || '供水设施',
-        value: properties.facilityTypeName || properties.facilityType || '',
-        lng: coordinates[0],
-        lat: coordinates[1],
-        type: properties.facilityType,
-        properties
-      })
-    }
-  })
-  pipelineLines.value = lines
-  pipePoints.value = points
-}
-
-function buildPointRows(flowPage, pressurePage, leakagePage) {
-  const flow = normalizePage(flowPage).records.map(item => normalizeMonitoringRecord(item, 'flow'))
-  const pressure = normalizePage(pressurePage).records.map(item => normalizeMonitoringRecord(item, 'pressure'))
-  const leakage = normalizePage(leakagePage).records.map(item => normalizeMonitoringRecord(item, 'power'))
-  return [...flow, ...pressure, ...leakage].slice(0, 8).map(item => ({
-    id: item.id,
-    name: item.equipmentName,
-    time: item.updateTime,
-    value: item.value == null ? '--' : item.value,
-    status: item.status === 1 || item.status === '1' ? '预警' : '在线'
-  }))
-}
-
-async function loadDashboardData() {
-  loading.value = true
-  try {
-    const [overviewResponse, featuresResponse, devicePointsResponse, deviceStatsResponse, alarmResponse, alarmSummaryResponse, devicePageResponse, flowResponse, pressureResponse, leakageResponse] = await Promise.all([
-      getWaterFacilityOverview(),
-      getWaterFacilityFeatures(),
-      getDeviceGisAllPoints(),
-      getDeviceGisStatistics(),
-      listRealtimeAlarms(),
-      getAlarmStats(),
-      listDeviceLedger({ pageNum: 1, pageSize: 8 }),
-      listFlowMonitoring({ pageNum: 1, pageSize: 5 }),
-      listPressureMonitoring({ pageNum: 1, pageSize: 5 }),
-      listLeakageMonitoring({ pageNum: 1, pageSize: 5 })
-    ])
-    const overviewData = unwrapResponse(overviewResponse)
-    const facilityOverview = Array.isArray(overviewData) ? overviewData : (overviewData?.facilityOverview || [])
-    const features = normalizeGeoJson(featuresResponse).features || []
-    const deviceData = unwrapResponse(devicePointsResponse)
-    const deviceRows = Array.isArray(deviceData) ? deviceData : (deviceData?.features || deviceData?.records || [])
-    const devicePage = normalizePage(devicePageResponse)
-    const rawStats = unwrapResponse(deviceStatsResponse) || {}
-    const typeStatistics = rawStats.typeStatistics || []
-    const flatten = list => (Array.isArray(list) ? list : []).reduce((sum, item) => sum + Number(item.deviceCount || item.count || 0) + flatten(item.children), 0)
-    const equipmentStats = {
-      ...rawStats,
-      total: Number(rawStats.total ?? rawStats.totalDeviceCount ?? 0) || flatten(typeStatistics) || deviceRows.length,
-      online: Number(rawStats.online ?? rawStats.onlineCount ?? deviceRows.filter(item => Number(item.onlineStatus) === 1).length),
-      abnormal: Number(rawStats.abnormal ?? rawStats.abnormalCount ?? deviceRows.filter(item => Number(item.alarmStatus) === 1).length)
-    }
-    const alarmList = Array.isArray(unwrapResponse(alarmResponse)) ? unwrapResponse(alarmResponse) : []
-    const alarmStats = unwrapResponse(alarmSummaryResponse) || {}
-    const equipmentRows = devicePage.records.slice(0, 8).map(item => ({
-      id: item.equipmentId || item.id,
-      name: item.equipmentName || item.name || '-',
-      code: item.equipmentCode || item.code || '-',
-      location: item.equipmentLocation || item.location || '-',
-      status: Number(item.status || item.onlineStatus) === 1 ? '异常' : '正常'
-    }))
-    const warningRows = alarmList.slice(0, 6).map(item => ({
-      level: Number(item.alarmLevel) <= 2 ? '高' : '中',
-      title: item.deviceName || item.deviceCode || '监测设备',
-      code: item.warningType || item.alarmCode || '-',
-      time: item.alarmTime || '--',
-      desc: item.equipmentLocation || '报警详情待查看',
-      tag: Number(item.alarmLevel) <= 2 ? '紧急' : '一般'
-    }))
-    dashboardData.value = {
-      facilityOverview,
-      equipmentStats,
-      alarmStats,
-      warningRows,
-      equipmentRows,
-      pointRows: buildPointRows(flowResponse, pressureResponse, leakageResponse)
-    }
-    parseFeatures(features)
-  } catch (error) {
-    console.warn('供水首页数据加载失败', error)
-    dashboardData.value = { facilityOverview: [], equipmentStats: {}, alarmStats: {}, warningRows: [], equipmentRows: [], pointRows: [] }
-    pipelineLines.value = []
-    pipePoints.value = []
-  } finally {
-    loading.value = false
-  }
-}
-
-onMounted(async () => {
-  updateClock()
-  clockTimer = window.setInterval(updateClock, 1000)
-  await loadDashboardData()
-  await nextTick()
-  initRepairChart()
-  initHazardChart()
-  initMap()
-  window.addEventListener('resize', handleResize)
-})
-
-onBeforeUnmount(() => {
-  if (clockTimer) window.clearInterval(clockTimer)
-  window.removeEventListener('resize', handleResize)
-  repairChart?.dispose()
-  hazardChart?.dispose()
-  mapInstance = null
-})
-</script>
-
-
 <template>
-  <div class="gas-home-screen">
-    <div class="screen-header">
-      <div class="header-time">{{ nowText }} {{ weekText }}</div>
-      <div class="header-title-wrap">
-        <div class="header-title">供水管网安全运行监测系统</div>
-        <div class="header-subtitle">Water Supply Operation Dashboard</div>
+  <div class="water-page home-page">
+    <div class="page-heading">
+      <div class="page-title">
+        <span class="page-title-icon"><el-icon><HomeFilled /></el-icon></span>
+        <div>
+          <h2>供水首页工作台</h2>
+          <span class="muted">面向值班态的一站式供水运行工作台,仅统计供水设施与供水监测设备</span>
+        </div>
       </div>
-      <div class="header-tags">
-        <span class="header-tag">实时监测</span>
-        <span class="header-tag">供水设施</span>
-        <span class="header-tag">报警预警</span>
+      <div class="heading-actions">
+        <el-button :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
       </div>
     </div>
 
-    <div class="screen-body">
-      <div class="side-panel left-panel">
-        <section class="panel-card base-card">
-          <div class="panel-title">基础概况</div>
-          <div class="base-grid">
-            <div v-for="item in baseCards" :key="item.label" class="base-item">
-              <div class="base-icon" :style="{ background: item.color }">
-                <component :is="item.icon" class="base-icon-svg" />
-              </div>
-              <div class="base-text">
-                <div class="base-label">{{ item.label }}</div>
-                <div class="base-value">{{ item.value }}<span>{{ item.unit }}</span></div>
-              </div>
+    <el-alert v-if="loadError" class="load-error" type="error" show-icon :closable="false"
+              title="部分数据加载失败" :description="loadError">
+      <el-button size="small" type="primary" :loading="loading" @click="loadAll">重试</el-button>
+    </el-alert>
+
+    <!-- A 指挥台 -->
+    <section class="home-section">
+      <div class="section-heading"><el-icon><Monitor /></el-icon><span>A · 指挥台</span></div>
+      <div class="metric-grid">
+        <div v-for="card in homeKpis" :key="card.key" class="metric">
+          <div class="metric-icon"><el-icon><component :is="kpiIcon(card.key)"/></el-icon></div>
+          <div class="metric-body">
+            <span>{{ card.label }}</span>
+            <div class="metric-value">
+              <strong :class="card.tone">{{ card.value }}</strong><small>{{ card.unit }}</small>
             </div>
           </div>
-        </section>
-
-        <section class="panel-card">
-          <div class="panel-title">设施统计</div>
-          <div ref="repairChartRef" class="chart-box repair-chart"></div>
-        </section>
-
-        <section class="panel-card table-card">
-          <div class="panel-title">监测点数据</div>
-          <div class="table-wrap">
-            <table class="data-table">
-              <thead>
-                <tr>
-                  <th>序号</th>
-                  <th>监测点</th>
-                  <th>时间</th>
-                  <th>数值</th>
-                  <th>状态</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr v-for="row in pointRows" :key="row.id">
-                  <td>{{ row.id }}</td>
-                  <td>{{ row.name }}</td>
-                  <td>{{ row.time }}</td>
-                  <td>{{ row.value }}</td>
-                  <td :class="['status-cell', row.status === '在线' ? 'ok' : row.status === '预警' ? 'warn' : 'off']">{{ row.status }}</td>
-                </tr>
-              </tbody>
-            </table>
-          </div>
-        </section>
+        </div>
       </div>
-
-      <div class="center-panel">
-        <div class="map-stage">
-          <div class="map-stage-top">
-            <div class="summary-strip">
-              <div v-for="item in operationSummary" :key="item.label" class="summary-item">
-                <span class="summary-label">{{ item.label }}</span>
-                <strong class="summary-value">{{ item.value }}</strong>
-              </div>
+      <div class="workbench-grid">
+        <el-card shadow="never" class="water-panel home-panel">
+          <template #header>
+            <div class="card-title">
+              <el-icon><Bell /></el-icon><span>实时报警</span>
+              <el-button link type="primary" class="panel-link" @click="goPage('/monitorAlarm/index')">前往报警管理</el-button>
             </div>
-            <div class="map-kpis">
-              <div v-for="item in mapKpis" :key="item.label" class="map-kpi">
-                <span>{{ item.label }}</span>
-                <strong>{{ item.value }}</strong>
+          </template>
+          <div class="compact-list">
+            <button v-for="item in alarmRows" :key="item.id || item.deviceCode" type="button"
+                    class="compact-item" @click="goPage('/monitorAlarm/audit', { alarmId: item.id || undefined, deviceCode: item.deviceCode })">
+              <span class="status-dot dot-alarm" />
+              <span class="compact-main">
+                <b>{{ item.deviceName }}</b>
+                <small>{{ item.deviceCode }}</small>
+              </span>
+              <span class="compact-meta">
+                <el-tag size="small" :type="levelType(item.alarmLevel)">{{ item.alarmLevelText }}</el-tag>
+                <small>{{ item.alarmTime }}</small>
+              </span>
+            </button>
+            <el-empty v-if="!alarmRows.length" description="当前无实时报警" :image-size="60" />
+          </div>
+        </el-card>
+
+        <el-card shadow="never" class="water-panel home-panel map-panel">
+          <template #header>
+            <div class="card-title">
+              <el-icon><MapLocation /></el-icon><span>全域态势</span>
+              <div class="map-legend">
+                <i class="legend-dot dot-alarm" />报警
+                <i class="legend-dot dot-offline" />离线
+                <i class="legend-dot dot-normal" />正常
               </div>
+              <el-button link type="primary" class="panel-link" @click="goPage('/device/gis')">监测设备一张图</el-button>
+            </div>
+          </template>
+          <div class="map-stage">
+            <div v-for="point in overviewMapPoints" :key="point.id" class="map-point"
+                 :class="`state-${point.state}`" :style="{ left: point.mapX + '%', top: point.mapY + '%' }"
+                 :title="`${point.name} · ${point.meta}`">
+              <span />
+              <b>{{ point.name }}</b>
             </div>
+            <el-empty v-if="!overviewMapPoints.length" description="暂无可展示的设备点位" :image-size="70" />
+            <div class="map-note">点位归一化示意 · 精确位置请进入各一张图</div>
           </div>
+        </el-card>
 
-          <div ref="mapWrapRef" class="map-view"></div>
-          <div v-if="mapError" class="map-error">{{ mapError }}</div>
-
-          <div class="map-panel info-panel">
-            <div class="mini-title">供水设施信息</div>
-            <div class="mini-row"><span>供水管网</span><strong>{{ typeCount('pipe') }}条</strong></div>
-            <div class="mini-row"><span>水厂</span><strong>{{ typeCount('plant') }}座</strong></div>
-            <div class="mini-row"><span>泵站</span><strong>{{ typeCount('pumpStation') }}座</strong></div>
+        <el-card shadow="never" class="water-panel home-panel">
+          <template #header>
+            <div class="card-title">
+              <el-icon><Tickets /></el-icon><span>工单进展</span>
+              <el-button link type="primary" class="panel-link" @click="goPage('/monitorAlarm/workOrder')">统一工单</el-button>
+            </div>
+          </template>
+          <div class="compact-list">
+            <button v-for="item in workOrderRows" :key="item.orderId || item.orderNo" type="button"
+                    class="compact-item" @click="goPage('/monitorAlarm/workOrder', { keyword: item.orderNo })">
+              <span class="status-dot" :class="dotByOrderType(item.orderStatusType)" />
+              <span class="compact-main">
+                <b>{{ item.orderNo }}</b>
+                <small>{{ item.deviceName || item.deviceCode }}</small>
+              </span>
+              <span class="compact-meta">
+                <el-tag size="small" :type="item.orderStatusType">{{ item.orderStatusText }}</el-tag>
+              </span>
+            </button>
+            <el-empty v-if="!workOrderRows.length" description="当前暂无供水工单" :image-size="60" />
           </div>
-
-          <div class="map-panel point-panel">
-            <div class="mini-title">当前监测信息</div>
-            <div class="mini-row"><span>监测点总数</span><strong>{{ pointRows.length }}个</strong></div>
-            <div class="mini-row"><span>在线设备</span><strong>{{ dashboardData.equipmentStats?.online || 0 }}台</strong></div>
-            <div class="mini-row"><span>实时报警</span><strong>{{ dashboardData.alarmStats?.realtimeAlarmCount ?? dashboardData.alarmStats?.realtimeCount ?? 0 }}条</strong></div>
+          <div class="order-brief">
+            <span>待处理 <b>{{ workOrderStat.pendingCount }}</b></span>
+            <span>处理中 <b>{{ workOrderStat.processingCount }}</b></span>
+            <span>待验收 <b>{{ workOrderStat.verifyingCount }}</b></span>
+            <span>已完成 <b>{{ workOrderStat.finishedCount }}</b></span>
           </div>
-        </div>
+        </el-card>
       </div>
-
-      <div class="side-panel right-panel">
-        <section class="panel-card">
-          <div class="panel-title">设备运行</div>
-          <div class="table-wrap equipment-wrap">
-            <table class="data-table compact-table">
-              <thead>
-                <tr>
-                  <th>序号</th>
-                  <th>设备名称</th>
-                  <th>设备编号</th>
-                  <th>位置</th>
-                  <th>状态</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr v-for="row in equipmentRows" :key="row.id">
-                  <td>{{ row.id }}</td>
-                  <td>{{ row.name }}</td>
-                  <td>{{ row.code }}</td>
-                  <td>{{ row.location }}</td>
-                  <td>{{ row.status }}</td>
-                </tr>
-              </tbody>
-            </table>
+    </section>
+
+    <!-- B 异常待办 -->
+    <section class="home-section">
+      <div class="section-heading"><el-icon><WarningFilled /></el-icon><span>B · 异常待办</span></div>
+      <div class="workbench-grid">
+        <el-card shadow="never" class="water-panel home-panel">
+          <template #header>
+            <div class="card-title">
+              <el-icon><Warning /></el-icon><span>异常待办</span>
+              <el-button link type="primary" class="panel-link" @click="goPage('/device/abnormal')">查看全部</el-button>
+            </div>
+          </template>
+          <div class="compact-list">
+            <button v-for="item in abnormalTodoRows" :key="item.equipmentId" type="button"
+                    class="compact-item" :class="{ active: selectedDevice && selectedDevice.equipmentId === item.equipmentId }"
+                    @click="selectAbnormal(item)">
+              <span class="status-dot" :class="abnormalDot(item)" />
+              <span class="compact-main">
+                <b>{{ item.equipmentName || item.equipmentCode }}</b>
+                <small>{{ item.equipmentCode }}</small>
+              </span>
+              <span class="compact-meta">
+                <el-tag size="small" :type="abnormalType(item)">{{ item.abnormalReasonText || '设备异常' }}</el-tag>
+              </span>
+            </button>
+            <el-empty v-if="!abnormalTodoRows.length" description="当前无异常设备待办" :image-size="60" />
           </div>
-        </section>
-
-        <section class="panel-card warning-card">
-          <div class="panel-title">告警动态</div>
-          <div class="warning-scroll">
-            <div class="warning-list">
-              <div v-for="(item, index) in warningRows" :key="index" class="warning-item" :class="item.level === '高' ? 'danger' : 'notice'">
-                <div class="warning-side">
-                  <div class="warning-badge">{{ item.level }}</div>
-                  <div class="warning-tag">{{ item.tag }}</div>
-                </div>
-                <div class="warning-main">
-                  <div class="warning-title-row">
-                    <span class="warning-title">{{ item.title }}</span>
-                    <span class="warning-time">{{ item.time }}</span>
-                  </div>
-                  <div class="warning-code">{{ item.code }}</div>
-                  <div class="warning-desc">{{ item.desc }}</div>
-                </div>
+        </el-card>
+
+        <el-card shadow="never" class="water-panel home-panel detail-panel">
+          <template #header>
+            <div class="card-title"><el-icon><Cpu /></el-icon><span>设备详情</span></div>
+          </template>
+          <div v-if="selectedDevice" class="device-summary">
+            <div class="summary-top">
+              <div class="summary-name">{{ selectedDevice.name }}</div>
+              <div class="summary-tags">
+                <el-tag size="small" :type="Number(selectedDevice.alarmStatus) === 1 ? 'danger' : 'success'">{{ selectedDevice.alarmStatusText }}</el-tag>
+                <el-tag size="small" :type="Number(selectedDevice.onlineStatus) === 1 ? 'success' : 'info'">{{ selectedDevice.onlineStatusText }}</el-tag>
+                <el-tag v-if="selectedDevice.relatedOrderNo && selectedDevice.relatedOrderNo !== '-'" size="small"
+                        :type="selectedDevice.workOrderStatusCode != null ? orderType(selectedDevice.workOrderStatusCode) : 'info'">
+                  {{ selectedDevice.workOrderStatusText }}
+                </el-tag>
               </div>
             </div>
+            <el-descriptions :column="1" border size="small">
+              <el-descriptions-item label="设备编码">{{ selectedDevice.code }}</el-descriptions-item>
+              <el-descriptions-item label="设备类型">{{ selectedDevice.typeName }}</el-descriptions-item>
+              <el-descriptions-item label="所属区域">{{ selectedDevice.district || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="安装位置">{{ selectedDevice.location || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="报警等级">{{ selectedDevice.alarmLevelText }}</el-descriptions-item>
+              <el-descriptions-item label="异常原因">{{ selectedDevice.abnormalReasonText || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="关联工单">
+                <el-button v-if="selectedDevice.relatedOrderNo && selectedDevice.relatedOrderNo !== '-'" link type="primary"
+                           @click="goPage('/monitorAlarm/workOrder', { keyword: selectedDevice.relatedOrderNo })">
+                  {{ selectedDevice.relatedOrderNo }}
+                </el-button>
+                <span v-else>-</span>
+              </el-descriptions-item>
+            </el-descriptions>
+          </div>
+          <el-empty v-else description="从左侧异常待办中选择设备" :image-size="70" />
+        </el-card>
+
+        <el-card shadow="never" class="water-panel home-panel">
+          <template #header>
+            <div class="card-title"><el-icon><Grid /></el-icon><span>快捷入口</span></div>
+          </template>
+          <div class="quick-grid">
+            <button v-for="entry in quickEntries" :key="entry.key" type="button" class="quick-entry"
+                    @click="goPage(entry.suffix)">
+              <el-icon><component :is="entryIcon(entry.key)"/></el-icon>
+              <span>{{ entry.label }}</span>
+            </button>
           </div>
-        </section>
-
-        <section class="panel-card hazard-card">
-          <div class="panel-title">报警统计</div>
-          <div class="hazard-summary three-col">
-            <div v-for="item in hazardSummary" :key="item.label" class="hazard-summary-item">
-              <span class="hazard-dot" :style="{ background: item.color }"></span>
-              <span>{{ item.label }}</span>
-              <strong :style="{ color: item.color }">{{ item.value }}项</strong>
+        </el-card>
+      </div>
+    </section>
+
+    <!-- C 数据总览 -->
+    <section class="home-section">
+      <div class="section-heading"><el-icon><DataAnalysis /></el-icon><span>C · 数据总览</span></div>
+      <div class="metric-grid">
+        <div v-for="card in overviewCards" :key="card.key" class="metric">
+          <div class="metric-icon"><el-icon><component :is="overviewIcon(card.key)"/></el-icon></div>
+          <div class="metric-body">
+            <span>{{ card.label }}</span>
+            <div class="metric-value">
+              <strong :class="card.tone">{{ card.value }}</strong><small>{{ card.unit }}</small>
             </div>
           </div>
-          <div ref="hazardChartRef" class="chart-box hazard-chart"></div>
-        </section>
+        </div>
       </div>
-    </div>
-
-    <a href="https://deerflow.tech" target="_blank" class="deerflow-mark" title="Created By Deerflow">
-      <span class="deerflow-diamond">◆</span>
-      <span class="deerflow-text">Deerflow</span>
-    </a>
+      <div class="chart-grid">
+        <el-card shadow="never" class="water-panel chart-card trend-card">
+          <template #header>
+            <div class="card-title">
+              <el-icon><TrendCharts /></el-icon><span>报警趋势</span>
+              <span class="muted">近 {{ trendDays }} 天 · 按四个报警等级分别统计</span>
+            </div>
+          </template>
+          <div ref="trendChartRef" class="chart" />
+        </el-card>
+        <el-card shadow="never" class="water-panel chart-card">
+          <template #header>
+            <div class="card-title"><el-icon><PieChart /></el-icon><span>报警分类占比</span></div>
+          </template>
+          <div ref="warningPieRef" class="chart" />
+        </el-card>
+        <el-card shadow="never" class="water-panel chart-card">
+          <template #header>
+            <div class="card-title"><el-icon><DataAnalysis /></el-icon><span>设备分类占比</span></div>
+          </template>
+          <div ref="devicePieRef" class="chart" />
+        </el-card>
+      </div>
+    </section>
   </div>
 </template>
 
-<style scoped lang="scss">
-.gas-home-screen {
-  min-height: 100vh;
-  color: #fff;
-  background:
-    linear-gradient(180deg, rgba(14, 42, 70, 0.96), rgba(8, 20, 37, 1)),
-    radial-gradient(circle at center, rgba(36, 122, 184, 0.18), transparent 58%),
-    #081425;
-  position: relative;
-}
-
-.gas-home-screen::before {
-  content: '';
-  position: absolute;
-  inset: 0;
-  pointer-events: none;
-  z-index: 0;
-  background:
-    radial-gradient(circle at 28% 32%, rgba(56, 216, 255, 0.06) 0%, transparent 45%),
-    radial-gradient(circle at 72% 58%, rgba(22, 109, 255, 0.05) 0%, transparent 40%),
-    radial-gradient(circle at 50% 85%, rgba(56, 216, 255, 0.04) 0%, transparent 35%);
-  animation: bgPulse 8s ease-in-out infinite alternate;
+<script setup>
+import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import * as echarts from 'echarts'
+import {
+  Bell, BellFilled, Connection, Cpu, DataAnalysis, Finished, Grid, HomeFilled,
+  MapLocation, Monitor, OfficeBuilding, PieChart, Refresh, Tickets, TrendCharts,
+  Warning, WarningFilled
+} from '@element-plus/icons-vue'
+import { getAlarmLedgerDashboard, getAlarmStats, getDeviceGisAllPoints, getDeviceLedgerStatistics,
+  getWaterFacilityOverview, listAbnormalDevices, listRealtimeAlarms } from '@/api/pipeNetwork/waterSupply'
+import { listWorkOrder, workOrderStatusCount } from '@/api/pipeNetwork/ops/workOrder'
+import { normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
+import {
+  HOME_QUICK_ENTRIES, buildHomeWorkOrderQuery, buildOverviewMapPoints,
+  normalizeAbnormalTodoRows, normalizeAlarmOverviewCards, normalizeAlarmTrend, normalizeDeviceSummary,
+  normalizeHomeKpis, normalizePieRatio, normalizeRealtimeAlarmRows,
+  normalizeWorkOrderRows, normalizeWorkOrderStatusStat
+} from '@/utils/waterHomeModel'
+
+const router = useRouter()
+
+const loading = ref(true)
+const loadError = ref('')
+const trendDays = 7
+const quickEntries = HOME_QUICK_ENTRIES
+
+const facilityRows = ref([])
+const ledgerStats = ref({})
+const alarmStats = ref({})
+const alarmDashboard = ref({})
+const alarmRows = ref([])
+const workOrderRows = ref([])
+const workOrderStat = ref({})
+const abnormalTodoRows = ref([])
+const overviewMapPoints = ref([])
+const selectedDevice = ref(null)
+
+const homeKpis = ref([])
+const overviewCards = ref([])
+const trendData = ref({ dates: [], levels: [] })
+const warningRatio = ref([])
+const deviceRatio = ref([])
+
+const trendChartRef = ref(null)
+const warningPieRef = ref(null)
+const devicePieRef = ref(null)
+let trendChart = null
+let warningPie = null
+let devicePie = null
+
+const KPI_ICON = {
+  facility: OfficeBuilding,
+  device: Cpu,
+  online: Connection,
+  alarm: BellFilled
+}
+
+const OVERVIEW_ICON = {
+  alarmTotal: BellFilled,
+  alarmingDevice: WarningFilled,
+  verifyingOrder: Tickets,
+  onlineRate: TrendCharts
+}
+
+const ENTRY_ICON = {
+  abnormal: WarningFilled,
+  alarm: BellFilled,
+  audit: Finished,
+  map: MapLocation
+}
+
+function kpiIcon(key) { return KPI_ICON[key] || OfficeBuilding }
+function overviewIcon(key) { return OVERVIEW_ICON[key] || DataAnalysis }
+function entryIcon(key) { return ENTRY_ICON[key] || Grid }
+function levelType(level) {
+  const meta = { 1: 'danger', 2: 'warning', 3: 'primary', 4: 'success' }
+  return meta[Number(level)] || 'info'
+}
+function orderType(status) {
+  const meta = {
+    0: 'danger', 1: 'warning', 2: 'primary', 3: 'warning',
+    4: 'primary', 5: 'success', 6: 'warning', 7: 'info'
+  }
+  return meta[Number(status)] || 'info'
 }
-
-@keyframes bgPulse {
-  0% { opacity: 0.6; }
-  100% { opacity: 1; }
+function dotByOrderType(type) {
+  const map = { danger: 'dot-danger', warning: 'dot-warning', primary: 'dot-primary', success: 'dot-success', info: 'dot-info' }
+  return map[type] || 'dot-info'
 }
-
-.screen-header {
-  height: 72px;
-  padding: 0 24px;
-  display: grid;
-  grid-template-columns: 320px 1fr 320px;
-  align-items: center;
-  position: relative;
-  z-index: 1;
-  background: linear-gradient(90deg, rgba(27, 98, 155, 0.95), rgba(18, 53, 98, 0.95));
-  border-bottom: 1px solid rgba(93, 194, 255, 0.28);
+function abnormalDot(row) {
+  if (Number(row.alarmStatus) === 1) return 'dot-alarm'
+  if (Number(row.onlineStatus) === 0) return 'dot-offline'
+  return 'dot-warning'
 }
-
-.screen-header::after {
-  content: '';
-  position: absolute;
-  bottom: -1px;
-  left: 5%;
-  width: 90%;
-  height: 1px;
-  background: linear-gradient(90deg, transparent, rgba(56, 216, 255, 0.7), rgba(22, 109, 255, 0.7), transparent);
-  animation: headerGlow 4s ease-in-out infinite alternate;
+function abnormalType(row) {
+  if (Number(row.alarmStatus) === 1) return 'danger'
+  if (Number(row.onlineStatus) === 0) return 'info'
+  return 'warning'
 }
 
-@keyframes headerGlow {
-  0% { opacity: 0.4; }
-  100% { opacity: 1; }
+async function settle(fn, fallback = null) {
+  try {
+    return await fn()
+  } catch (error) {
+    console.warn('供水首页数据片段加载失败', error)
+    if (fallback !== null) return fallback
+    throw error
+  }
 }
 
-.header-time {
-  font-size: 18px;
-  font-weight: 700;
-  letter-spacing: 1.5px;
-  color: #ddf6ff;
-  font-variant-numeric: tabular-nums;
-  text-shadow: 0 0 18px rgba(56, 216, 255, 0.35);
-}
+async function loadAll() {
+  loading.value = true
+  loadError.value = ''
+  const problems = []
+  const recordProblem = label => () => { problems.push(label) }
+
+  const [overview, ledger, alarm, dashboard, realtime, abnormal, orderStat, orders, deviceGis] = await Promise.allSettled([
+    settle(() => getWaterFacilityOverview(), []).catch(recordProblem('设施概览')),
+    settle(() => getDeviceLedgerStatistics(), {}).catch(recordProblem('设备统计')),
+    settle(() => getAlarmStats(), {}).catch(recordProblem('报警统计')),
+    settle(() => getAlarmLedgerDashboard(trendDays), {}).catch(recordProblem('报警态势')),
+    settle(() => listRealtimeAlarms(), []).catch(recordProblem('实时报警')),
+    settle(() => listAbnormalDevices({ pageNum: 1, pageSize: 8 }), { records: [], total: 0 }).catch(recordProblem('异常待办')),
+    settle(() => workOrderStatusCount(buildHomeWorkOrderQuery()), { total: 0, counts: [] }).catch(recordProblem('工单统计')),
+    settle(() => listWorkOrder(buildHomeWorkOrderQuery({ pageSize: 6 })), { records: [], total: 0 }).catch(recordProblem('工单列表')),
+    settle(() => getDeviceGisAllPoints(), { features: [] }).catch(recordProblem('设备点位'))
+  ])
+
+  const overviewData = unwrapResponse(overview.value || [])
+  facilityRows.value = Array.isArray(overviewData) ? overviewData : (overviewData?.facilityOverview || [])
+  ledgerStats.value = unwrapResponse(ledger.value || {})
+  alarmStats.value = unwrapResponse(alarm.value || {})
+  alarmDashboard.value = unwrapResponse(dashboard.value || {})
+  alarmRows.value = normalizeRealtimeAlarmRows(unwrapResponse(realtime.value || []), 6)
+  const abnormalPage = normalizePage(abnormal.value || { records: [], total: 0 })
+  abnormalTodoRows.value = normalizeAbnormalTodoRows(abnormalPage.records, 7)
+  workOrderStat.value = normalizeWorkOrderStatusStat(unwrapResponse(orderStat.value || { total: 0, counts: [] }))
+  const orderList = unwrapResponse(orders.value || { records: [] })
+  workOrderRows.value = normalizeWorkOrderRows(orderList?.records || [], 6)
+
+  const gisData = unwrapResponse(deviceGis.value || {})
+  const devicePoints = Array.isArray(gisData) ? gisData : (gisData?.features || [])
+  const alarmSource = normalizeRealtimeAlarmRows(unwrapResponse(realtime.value || []), 50)
+  overviewMapPoints.value = buildOverviewMapPoints({ devicePoints, alarmRows: alarmSource })
+
+  homeKpis.value = normalizeHomeKpis({ facilityRows: facilityRows.value, ledgerStats: ledgerStats.value, alarmStats: alarmStats.value })
+  overviewCards.value = normalizeAlarmOverviewCards({
+    dashboard: alarmDashboard.value,
+    deviceStats: ledgerStats.value,
+    workOrderStat: workOrderStat.value
+  })
+  trendData.value = normalizeAlarmTrend(alarmDashboard.value?.trend || [])
+  warningRatio.value = normalizePieRatio(alarmDashboard.value?.warningTypeRatio || [], '未分类', 6)
+  deviceRatio.value = normalizePieRatio(alarmDashboard.value?.deviceTypeRatio || [], '未分类', 6)
 
-.header-title-wrap {
-  text-align: center;
+  if (problems.length) loadError.value = problems.join(';')
+  loading.value = false
+  await nextTick()
+  renderCharts()
 }
 
-.header-title {
-  font-size: 30px;
-  font-weight: 800;
-  letter-spacing: 3px;
-  background: linear-gradient(180deg, #e8f6ff 0%, #8fd6ff 100%);
-  -webkit-background-clip: text;
-  -webkit-text-fill-color: transparent;
-  background-clip: text;
+function selectAbnormal(row) {
+  selectedDevice.value = normalizeDeviceSummary(row)
 }
 
-.header-subtitle {
-  margin-top: 4px;
-  color: rgba(196, 234, 255, 0.65);
-  font-size: 11px;
-  letter-spacing: 3px;
-  text-transform: uppercase;
+function resolveRoutePath(suffix) {
+  const matched = router.getRoutes().find(item => item.path.endsWith(suffix))
+  return matched ? matched.path : ''
 }
 
-.header-tags {
-  display: flex;
-  justify-content: flex-end;
-  gap: 8px;
+function goPage(suffix, query = {}) {
+  const path = resolveRoutePath(suffix)
+  if (!path) {
+    console.warn(`未找到供水入口路由: ${suffix}`)
+    return
+  }
+  router.push({ path, query }).catch(() => {})
 }
 
-.header-tag {
-  padding: 6px 14px;
-  border: 1px solid rgba(117, 206, 255, 0.35);
-  border-radius: 16px;
-  color: #d8f5ff;
-  background: rgba(13, 77, 128, 0.45);
-  font-size: 12px;
-  letter-spacing: 1px;
-  transition: all 0.3s;
-  cursor: default;
+function renderCharts() {
+  renderTrendChart()
+  warningPie?.dispose()
+  warningPie = renderPieChart(warningPieRef.value, warningRatio.value, '报警数')
+  devicePie?.dispose()
+  devicePie = renderPieChart(devicePieRef.value, deviceRatio.value, '设备数')
 }
 
-.header-tag:hover {
-  border-color: rgba(117, 206, 255, 0.7);
-  background: rgba(13, 77, 128, 0.65);
-  box-shadow: 0 0 16px rgba(56, 216, 255, 0.15);
+function renderTrendChart() {
+  if (!trendChartRef.value) return
+  trendChart?.dispose()
+  trendChart = echarts.init(trendChartRef.value)
+  const { dates, levels } = trendData.value
+  const palette = ['#f56c6c', '#e6a23c', '#409eff', '#67c23a']
+  trendChart.setOption({
+    tooltip: { trigger: 'axis' },
+    legend: { bottom: 0, icon: 'circle', itemWidth: 9, itemHeight: 9, textStyle: { color: '#475569' } },
+    grid: { left: 34, right: 14, top: 18, bottom: 48, containLabel: true },
+    xAxis: {
+      type: 'category',
+      data: dates,
+      axisLine: { lineStyle: { color: '#cbd5e1' } },
+      axisLabel: { color: '#64748b' }
+    },
+    yAxis: {
+      type: 'value',
+      minInterval: 1,
+      splitLine: { lineStyle: { color: '#eef2f7' } },
+      axisLabel: { color: '#64748b' }
+    },
+    color: palette,
+    series: levels.map((level, index) => ({
+      name: level.text || `级${index + 1}`,
+      type: 'bar',
+      stack: 'alarm',
+      barWidth: 26,
+      data: level.data,
+      itemStyle: { borderRadius: index === levels.length - 1 ? [5, 5, 0, 0] : 0 }
+    }))
+  })
 }
 
-.screen-body {
-  height: calc(100vh - 72px);
-  padding: 12px;
-  display: grid;
-  grid-template-columns: 360px 1fr 360px;
-  gap: 12px;
-  position: relative;
-  z-index: 1;
+function renderPieChart(container, data, unit) {
+  if (!container) return null
+  const total = data.reduce((sum, item) => sum + item.count, 0)
+  const chart = echarts.init(container)
+  chart.setOption({
+    tooltip: { trigger: 'item', formatter: `{b}:{c} ${unit}({d}%)` },
+    legend: { bottom: 0, icon: 'circle', itemWidth: 9, itemHeight: 9, textStyle: { color: '#475569' } },
+    title: {
+      text: String(total),
+      subtext: unit,
+      left: 'center',
+      top: '32%',
+      textStyle: { color: '#1e293b', fontSize: 24, fontWeight: 700 },
+      subtextStyle: { color: '#64748b', fontSize: 12 }
+    },
+    color: ['#2563eb', '#14b8a6', '#f59e0b', '#ef4444', '#8b5cf6', '#64748b'],
+    series: [{
+      type: 'pie',
+      radius: ['48%', '68%'],
+      center: ['50%', '43%'],
+      data: data.map(item => ({ name: item.name, value: item.count })),
+      label: { formatter: '{b}\n{d}%', color: '#475569', lineHeight: 17 },
+      itemStyle: { borderColor: '#fff', borderWidth: 3, borderRadius: 8 },
+      emphasis: { scaleSize: 7 }
+    }]
+  })
+  return chart
 }
 
-.side-panel,
-.center-panel {
-  min-height: 0;
+function handleResize() {
+  trendChart?.resize()
+  warningPie?.resize()
+  devicePie?.resize()
 }
 
-.left-panel,
-.right-panel {
-  display: grid;
-  gap: 12px;
-}
+onMounted(async () => {
+  await loadAll()
+  window.addEventListener('resize', handleResize)
+})
 
-.left-panel {
-  grid-template-rows: 220px 220px 1fr;
-}
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  trendChart?.dispose()
+  warningPie?.dispose()
+  devicePie?.dispose()
+})
+</script>
 
-.right-panel {
-  grid-template-rows: 250px 260px 1fr;
-}
+<style lang="scss">
+@use './waterSupplyPageTheme.scss';
+</style>
 
-.panel-card {
-  min-height: 0;
-  padding: 14px;
-  position: relative;
-  background: linear-gradient(180deg, rgba(13, 33, 56, 0.88), rgba(10, 25, 42, 0.88));
-  border: 1px solid rgba(65, 158, 219, 0.22);
-  border-radius: 12px;
-  box-shadow: 0 2px 24px rgba(0, 0, 0, 0.25), inset 0 0 0 1px rgba(255, 255, 255, 0.025);
-  transition: border-color 0.4s;
+<style scoped>
+.home-page {
+  background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 45%, #eef2f7 100%);
 }
 
-.panel-card::after {
-  content: '';
-  position: absolute;
-  top: -1px;
-  left: 20px;
-  right: 20px;
-  height: 1px;
-  border-radius: 1px;
-  background: linear-gradient(90deg, transparent, rgba(56, 216, 255, 0.25), transparent);
-  pointer-events: none;
+.heading-actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
 }
 
-.panel-card:hover {
-  border-color: rgba(65, 158, 219, 0.38);
+.page-title-icon {
+  display: grid;
+  place-items: center;
+  width: 42px;
+  height: 42px;
+  color: #fff;
+  background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);
+  border-radius: 8px;
+  box-shadow: 0 8px 16px rgba(37, 99, 235, 0.2);
 }
 
-.panel-title {
-  position: relative;
-  padding-left: 16px;
+.load-error {
   margin-bottom: 14px;
-  color: #8fe5ff;
-  font-size: 18px;
-  font-weight: 800;
-  letter-spacing: 1px;
 }
 
-.panel-title::before {
-  content: '';
-  position: absolute;
-  left: 0;
-  top: 3px;
-  width: 4px;
-  height: 20px;
-  border-radius: 2px;
-  background: linear-gradient(180deg, #28d7ff, #1776ff);
-  box-shadow: 0 0 10px rgba(40, 215, 255, 0.5);
+.home-section {
+  margin-bottom: 20px;
 }
 
-.base-grid {
-  display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 14px;
+.section-heading {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 12px;
+  color: #17324d;
+  font-size: 15px;
+  font-weight: 700;
 }
 
-.base-card {
-  background: linear-gradient(180deg, rgba(10, 30, 53, 0.95), rgba(9, 24, 42, 0.95));
+.section-heading :deep(.el-icon) {
+  color: #2563eb;
+  font-size: 18px;
 }
 
-.base-grid {
-  grid-template-columns: repeat(2, minmax(0, 1fr));
+.metric-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
   gap: 12px;
+  margin-bottom: 14px;
 }
 
-.base-item {
+.metric {
   display: flex;
   align-items: center;
   gap: 14px;
-  min-height: 72px;
-  padding: 14px;
-  border-radius: 14px;
-  background: linear-gradient(180deg, rgba(20, 57, 89, 0.45), rgba(18, 44, 72, 0.35));
-  border: 1px solid rgba(97, 190, 255, 0.12);
-  transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
-  cursor: default;
-  position: relative;
-  overflow: hidden;
+  min-width: 0;
+  padding: 14px 16px;
+  background: #fff;
+  border: 1px solid #e1ebf3;
+  border-radius: 8px;
+  box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
 }
 
-.base-item::before {
-  content: '';
-  position: absolute;
-  inset: 0;
-  border-radius: 14px;
-  background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.03), transparent 70%);
-  pointer-events: none;
-  transition: opacity 0.35s;
-  opacity: 0;
+.metric-icon {
+  position: static;
+  flex: 0 0 42px;
+  display: grid;
+  place-items: center;
+  width: 42px;
+  height: 42px;
+  color: #2563eb;
+  background: linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%);
+  border-radius: 8px;
+  font-size: 22px;
 }
 
-.base-item:hover {
-  transform: translateY(-2px);
-  border-color: rgba(97, 190, 255, 0.35);
-  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(97, 190, 255, 0.1) inset;
+.metric-body {
+  min-width: 0;
 }
 
-.base-item:hover::before {
-  opacity: 1;
+.metric-body span {
+  display: block;
+  overflow: hidden;
+  margin-bottom: 3px;
+  color: #64748b;
+  font-size: 13px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.base-icon {
-  width: 48px;
-  height: 48px;
-  border-radius: 16px;
+.metric-value {
   display: flex;
-  align-items: center;
-  justify-content: center;
-  flex: 0 0 48px;
-  position: relative;
-  transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.35s;
+  align-items: baseline;
+  gap: 5px;
 }
 
-.base-item:hover .base-icon {
-  transform: scale(1.08);
-  box-shadow: 0 0 28px rgba(56, 216, 255, 0.3);
+.metric-value strong {
+  color: #1e293b;
+  font-size: 24px;
+  line-height: 1.2;
 }
 
-.base-icon-svg {
-  width: 26px;
-  height: 26px;
-  color: #fff;
-  transition: transform 0.35s;
+.metric-value strong.danger { color: #dc2626; }
+.metric-value strong.warning { color: #d97706; }
+
+.metric-value small {
+  color: #94a3b8;
 }
 
-.base-item:hover .base-icon-svg {
-  transform: scale(1.1);
+.workbench-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 12px;
 }
 
-.base-text {
+.home-panel {
   min-width: 0;
+  border: 1px solid #e1ebf3;
+  border-radius: 8px;
+  box-shadow: 0 5px 16px rgba(28, 75, 110, 0.05);
 }
 
-.base-label {
-  color: #c0dff5;
-  font-size: 12px;
-  letter-spacing: 1px;
-  text-transform: uppercase;
+.home-panel :deep(.el-card__header) {
+  padding: 12px 16px;
+  border-bottom: 1px solid #e1ebf3;
+  background: #fbfdff;
 }
 
-.base-value {
-  margin-top: 4px;
-  color: #fff;
-  font-size: 32px;
-  font-weight: 800;
-  line-height: 1;
-  font-variant-numeric: tabular-nums;
-  letter-spacing: -0.5px;
+.home-panel :deep(.el-card__body) {
+  padding: 12px 14px;
 }
 
-.base-value span {
-  margin-left: 4px;
-  color: #90d9ff;
-  font-size: 12px;
-  font-weight: 500;
-  letter-spacing: 0;
+.card-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: #1e293b;
+  font-weight: 600;
 }
 
-.chart-box {
-  width: 100%;
-  height: calc(100% - 36px);
+.card-title :deep(.el-icon) {
+  color: #2563eb;
+  font-size: 18px;
 }
 
-.table-card,
-.warning-card,
-.hazard-card {
-  overflow: hidden;
+.card-title .muted {
+  margin-left: auto;
+  font-size: 12px;
+  font-weight: 400;
 }
 
-.table-wrap {
-  height: calc(100% - 38px);
-  overflow: auto;
+.panel-link {
+  margin-left: auto;
 }
 
-.data-table {
-  width: 100%;
-  border-collapse: collapse;
-  table-layout: fixed;
+.compact-list {
+  display: flex;
+  flex-direction: column;
+  gap: 7px;
+  min-height: 220px;
 }
 
-.data-table thead {
-  background: rgba(24, 122, 198, 0.55);
+.compact-item {
+  display: grid;
+  grid-template-columns: 10px minmax(0, 1fr) auto;
+  gap: 9px;
+  align-items: center;
+  width: 100%;
+  padding: 9px 10px;
+  text-align: left;
+  background: #fff;
+  border: 1px solid #eef2f7;
+  border-radius: 8px;
+  cursor: pointer;
+  transition: all 0.15s ease;
 }
 
-.data-table thead th {
-  font-weight: 700;
-  letter-spacing: 0.5px;
-  color: #b9e4ff;
+.compact-item:hover {
+  background: #f6fbff;
+  border-color: #bfdbfe;
 }
 
-.data-table th,
-.data-table td {
-  padding: 10px 6px;
-  color: #f4fbff;
-  text-align: center;
-  font-size: 13px;
-  border-bottom: 1px solid rgba(130, 194, 231, 0.1);
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
+.compact-item.active {
+  background: #eef6ff;
+  border-color: #93c5fd;
 }
 
-.data-table tbody tr {
-  transition: background 0.25s;
+.status-dot {
+  width: 9px;
+  height: 9px;
+  border-radius: 50%;
+  background: #94a3b8;
 }
 
-.data-table tbody tr:nth-child(odd) {
-  background: rgba(133, 196, 242, 0.10);
+.dot-alarm { background: #dc2626; }
+.dot-offline { background: #94a3b8; }
+.dot-normal { background: #16a34a; }
+.dot-warning { background: #d97706; }
+.dot-danger { background: #dc2626; }
+.dot-primary { background: #2563eb; }
+.dot-success { background: #16a34a; }
+.dot-info { background: #94a3b8; }
+
+.compact-main {
+  display: block;
+  min-width: 0;
 }
 
-.data-table tbody tr:nth-child(even) {
-  background: rgba(133, 196, 242, 0.04);
+.compact-main b,
+.compact-main small {
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.data-table tbody tr:hover {
-  background: rgba(56, 216, 255, 0.12);
+.compact-main b {
+  color: #1e293b;
+  font-size: 13px;
+  font-weight: 600;
 }
 
-.compact-table th,
-.compact-table td {
-  padding: 9px 4px;
+.compact-main small {
+  margin-top: 2px;
+  color: #94a3b8;
+  font-size: 12px;
 }
 
-.status-cell.ok {
-  color: #6effc4;
-  position: relative;
+.compact-meta {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 3px;
+  min-width: 0;
 }
 
-.status-cell.warn {
-  color: #ffe16a;
+.compact-meta small {
+  color: #94a3b8;
+  font-size: 11px;
+  white-space: nowrap;
 }
 
-.status-cell.off {
-  color: #ff8e8e;
+.map-legend {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  margin-left: auto;
+  color: #64748b;
+  font-size: 12px;
 }
 
-.status-cell::before {
-  content: '';
+.legend-dot {
   display: inline-block;
-  width: 6px;
-  height: 6px;
+  width: 8px;
+  height: 8px;
   border-radius: 50%;
-  margin-right: 6px;
-  vertical-align: middle;
-}
-
-.status-cell.ok::before {
-  background: #6effc4;
-  box-shadow: 0 0 6px rgba(110, 255, 196, 0.6);
-}
-
-.status-cell.warn::before {
-  background: #ffe16a;
-  box-shadow: 0 0 6px rgba(255, 225, 106, 0.6);
-}
-
-.status-cell.off::before {
-  background: #ff8e8e;
-  box-shadow: 0 0 6px rgba(255, 142, 142, 0.6);
-}
-
-.center-panel {
-  position: relative;
 }
 
 .map-stage {
   position: relative;
-  height: 100%;
+  min-height: 226px;
   overflow: hidden;
-  border-radius: 12px;
-  border: 1px solid rgba(56, 159, 223, 0.2);
-  background: rgba(8, 20, 37, 0.64);
-  box-shadow: 0 0 40px rgba(0, 0, 0, 0.3), inset 0 0 60px rgba(8, 30, 60, 0.3);
+  border: 1px solid #e1ebf3;
+  border-radius: 8px;
+  background:
+    linear-gradient(rgba(219, 234, 254, 0.35) 1px, transparent 1px),
+    linear-gradient(90deg, rgba(219, 234, 254, 0.35) 1px, transparent 1px),
+    linear-gradient(135deg, #eef6ff 0%, #f8fbff 100%);
+  background-size: 28px 28px;
 }
 
-.map-stage-top {
+.map-point {
   position: absolute;
-  top: 14px;
-  left: 14px;
-  right: 14px;
-  z-index: 3;
+  transform: translate(-50%, -50%);
+  z-index: 2;
   display: flex;
-  justify-content: space-between;
-  gap: 12px;
-  pointer-events: none;
-}
-
-.summary-strip,
-.map-kpis {
-  display: flex;
-  gap: 10px;
-}
-
-.summary-item,
-.map-kpi {
-  min-width: 110px;
-  padding: 10px 14px;
-  border-radius: 10px;
-  background: rgba(8, 31, 54, 0.82);
-  border: 1px solid rgba(79, 177, 238, 0.18);
-  backdrop-filter: blur(8px);
-  transition: all 0.3s;
+  flex-direction: column;
+  align-items: center;
+  gap: 3px;
+  cursor: default;
 }
 
-.summary-label,
-.map-kpi span {
+.map-point span {
   display: block;
-  color: #91d7ff;
-  font-size: 12px;
-  letter-spacing: 0.5px;
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  border: 2px solid #fff;
+  box-shadow: 0 1px 4px rgba(15, 23, 42, 0.28);
 }
 
-.summary-value,
-.map-kpi strong {
-  display: block;
-  margin-top: 4px;
-  color: #fff;
-  font-size: 22px;
-  font-weight: 800;
-  letter-spacing: 1px;
+.map-point.state-alarm span {
+  background: #dc2626;
+  box-shadow: 0 0 0 4px rgba(220, 38, 38, 0.16);
 }
 
-.map-view {
-  width: 100%;
-  height: 100%;
-}
+.map-point.state-offline span { background: #94a3b8; }
+.map-point.state-normal span { background: #16a34a; }
 
-.map-error {
-  position: absolute;
-  inset: 0;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffd3d3;
-  background: rgba(5, 10, 20, 0.72);
-  z-index: 4;
+.map-point b {
+  max-width: 110px;
+  overflow: hidden;
+  padding: 1px 5px;
+  color: #334155;
+  background: rgba(255, 255, 255, 0.88);
+  border-radius: 4px;
+  font-size: 10px;
+  font-weight: 600;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.map-panel {
+.map-note {
   position: absolute;
-  z-index: 3;
-  width: 190px;
-  padding: 14px;
-  border-radius: 12px;
-  background: rgba(8, 31, 54, 0.82);
-  border: 1px solid rgba(79, 177, 238, 0.18);
-  backdrop-filter: blur(10px);
+  left: 10px;
+  bottom: 8px;
+  z-index: 1;
+  color: #94a3b8;
+  font-size: 11px;
 }
 
-.info-panel {
-  left: 18px;
-  bottom: 20px;
+.order-brief {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 6px;
+  margin-top: 10px;
+  padding-top: 10px;
+  border-top: 1px solid #eef2f7;
 }
 
-.point-panel {
-  right: 18px;
-  bottom: 20px;
+.order-brief span {
+  color: #64748b;
+  font-size: 12px;
 }
 
-.mini-title {
-  margin-bottom: 10px;
-  color: #8fe5ff;
+.order-brief b {
+  margin-left: 3px;
+  color: #17324d;
   font-size: 14px;
-  font-weight: 700;
-  letter-spacing: 0.5px;
 }
 
-.mini-row {
+.device-summary {
   display: flex;
-  justify-content: space-between;
-  margin-top: 6px;
-  padding: 4px 0;
-  color: #d6efff;
-  font-size: 13px;
-  border-bottom: 1px solid rgba(130, 194, 231, 0.06);
-}
-
-.mini-row:last-child {
-  border-bottom: none;
+  flex-direction: column;
+  gap: 12px;
 }
 
-.warning-scroll {
-  height: calc(100% - 38px);
-  overflow-y: auto;
-  padding-right: 4px;
+.summary-top {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 10px;
+  flex-wrap: wrap;
 }
 
-.warning-scroll::-webkit-scrollbar {
-  width: 5px;
+.summary-name {
+  color: #17324d;
+  font-size: 16px;
+  font-weight: 700;
 }
 
-.warning-scroll::-webkit-scrollbar-thumb {
-  border-radius: 999px;
-  background: rgba(96, 180, 235, 0.3);
+.summary-tags {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  gap: 6px;
 }
 
-.warning-scroll::-webkit-scrollbar-track {
-  background: transparent;
+.detail-panel :deep(.el-descriptions__label) {
+  width: 86px;
+  color: #64748b;
+  background: #f8fafc;
+  white-space: nowrap;
 }
 
-.warning-list {
+.quick-grid {
   display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
   gap: 10px;
 }
 
-.warning-item {
-  display: flex;
-  gap: 12px;
-  align-items: stretch;
-  padding: 14px;
-  border-radius: 14px;
-  border: 1px solid rgba(255, 108, 108, 0.1);
-  background: linear-gradient(180deg, rgba(72, 24, 38, 0.42), rgba(49, 19, 31, 0.30));
-  transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
-  position: relative;
-  overflow: hidden;
-}
-
-.warning-item::before {
-  content: '';
-  position: absolute;
-  left: 0;
-  top: 12px;
-  bottom: 12px;
-  width: 3px;
-  border-radius: 0 3px 3px 0;
-  transition: all 0.35s;
-}
-
-.warning-item.danger::before {
-  background: linear-gradient(180deg, #ff5858, #ff3030);
-  box-shadow: 0 0 12px rgba(255, 58, 58, 0.5);
-  animation: dangerPulse 2s ease-in-out infinite;
-}
-
-.warning-item.notice::before {
-  background: linear-gradient(180deg, #ffc635, #ff9f1a);
-  box-shadow: 0 0 10px rgba(255, 198, 53, 0.35);
-}
-
-@keyframes dangerPulse {
-  0%, 100% { opacity: 0.6; }
-  50% { opacity: 1; }
-}
-
-.warning-item:hover {
-  transform: translateX(3px);
-}
-
-.warning-item.danger:hover {
-  border-color: rgba(255, 108, 108, 0.25);
-  box-shadow: 0 4px 20px rgba(255, 58, 58, 0.1);
-}
-
-.warning-item.notice:hover {
-  border-color: rgba(255, 201, 92, 0.2);
-  box-shadow: 0 4px 20px rgba(255, 198, 53, 0.08);
-}
-
-.warning-item.notice {
-  border-color: rgba(255, 201, 92, 0.1);
-  background: linear-gradient(180deg, rgba(78, 63, 23, 0.28), rgba(48, 43, 20, 0.18));
-}
-
-.warning-side {
-  width: 52px;
-  flex: 0 0 52px;
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-  align-items: center;
-}
-
-.warning-badge {
-  width: 34px;
-  height: 34px;
-  border-radius: 10px;
+.quick-entry {
   display: flex;
   align-items: center;
-  justify-content: center;
-  font-size: 13px;
-  font-weight: 800;
-  color: #fff;
-  transition: transform 0.3s;
-}
-
-.warning-item:hover .warning-badge {
-  transform: scale(1.1);
-}
-
-.warning-tag {
-  width: 100%;
-  padding: 3px 0;
-  border-radius: 999px;
-  text-align: center;
-  font-size: 10px;
-  letter-spacing: 0.5px;
-  color: #dcefff;
-  background: rgba(255, 255, 255, 0.06);
-}
-
-.warning-item.danger .warning-badge {
-  background: rgba(255, 58, 58, 0.28);
-  color: #ff8787;
-}
-
-.warning-item.danger .warning-title {
-  color: #ffd8d8;
-}
-
-.warning-item.notice .warning-badge {
-  background: rgba(255, 198, 53, 0.2);
-  color: #ffd45d;
-}
-
-.warning-item.notice .warning-title {
-  color: #fff2c4;
-}
-
-.warning-main {
+  gap: 10px;
   min-width: 0;
-  flex: 1;
-}
-
-.warning-title-row {
-  display: flex;
-  align-items: flex-start;
-  justify-content: space-between;
-  gap: 8px;
+  padding: 14px 12px;
+  color: #1e293b;
+  background: #fff;
+  border: 1px solid #e1ebf3;
+  border-radius: 8px;
+  cursor: pointer;
+  transition: all 0.15s ease;
 }
 
-.warning-title {
-  font-weight: 700;
-  font-size: 16px;
-  line-height: 1.2;
+.quick-entry:hover {
+  background: #f6fbff;
+  border-color: #bfdbfe;
 }
 
-.warning-time,
-.warning-code,
-.warning-desc {
-  color: #dcefff;
-  font-size: 12px;
+.quick-entry :deep(.el-icon) {
+  flex: 0 0 20px;
+  color: #2563eb;
+  font-size: 20px;
 }
 
-.warning-time {
-  color: #b6dfff;
+.quick-entry span {
+  overflow: hidden;
+  font-size: 13px;
+  font-weight: 600;
+  text-overflow: ellipsis;
   white-space: nowrap;
 }
 
-.warning-code {
-  margin: 6px 0 4px;
-  color: #9ad8ff;
-}
-
-.warning-desc {
-  line-height: 1.5;
-}
-
-.hazard-summary {
-  display: flex;
+.chart-grid {
+  display: grid;
+  grid-template-columns: 1.5fr 1fr 1fr;
   gap: 12px;
-  margin-bottom: 10px;
 }
 
-.hazard-summary.three-col {
-  justify-content: space-between;
+.chart-card {
+  border: 1px solid #e1ebf3;
+  border-radius: 8px;
+  box-shadow: 0 5px 16px rgba(28, 75, 110, 0.05);
 }
 
-.hazard-summary-item {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  font-size: 13px;
-  padding: 8px 12px;
-  border-radius: 10px;
-  background: rgba(255, 255, 255, 0.03);
-  border: 1px solid rgba(255, 255, 255, 0.04);
-  transition: all 0.3s;
+.chart-card .chart {
+  height: 260px;
 }
 
-.hazard-summary-item:hover {
-  background: rgba(255, 255, 255, 0.06);
-  border-color: rgba(255, 255, 255, 0.08);
+.chart-card :deep(.el-card__header) {
+  padding: 12px 16px;
+  border-bottom: 1px solid #e1ebf3;
+  background: #fbfdff;
 }
 
-.hazard-dot {
-  width: 8px;
-  height: 24px;
-  display: inline-block;
-  border-radius: 4px;
-  box-shadow: 0 0 10px currentColor;
-}
-
-.hazard-chart {
-  height: 160px;
+.chart-card :deep(.el-card__body) {
+  padding: 10px 12px;
 }
 
-.equipment-wrap {
-  height: calc(100% - 38px);
-}
+@media (max-width: 1200px) {
+  .metric-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
 
-:deep(.BMap_cpyCtrl),
-:deep(.anchorBL) {
-  display: none !important;
-}
+  .workbench-grid {
+    grid-template-columns: 1fr;
+  }
 
-.deerflow-mark {
-  position: fixed;
-  bottom: 14px;
-  right: 18px;
-  z-index: 10;
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  text-decoration: none;
-  opacity: 0.28;
-  transition: opacity 0.4s;
-}
+  .chart-grid {
+    grid-template-columns: 1fr 1fr;
+  }
 
-.deerflow-mark:hover {
-  opacity: 0.6;
+  .trend-card {
+    grid-column: 1 / -1;
+  }
 }
 
-.deerflow-diamond {
-  font-size: 10px;
-  color: #8fe5ff;
-}
+@media (max-width: 768px) {
+  .page-heading,
+  .heading-actions {
+    align-items: stretch;
+    flex-direction: column;
+  }
 
-.deerflow-text {
-  font-size: 11px;
-  letter-spacing: 1px;
-  color: #8fe5ff;
-  font-weight: 500;
-}
+  .metric-grid,
+  .chart-grid {
+    grid-template-columns: 1fr;
+  }
 
-@media (max-width: 1500px) {
-  .screen-body {
-    grid-template-columns: 320px 1fr 320px;
+  .trend-card {
+    grid-column: auto;
   }
 
-  .header-title {
-    font-size: 24px;
+  .map-legend {
+    display: none;
   }
 
-  .summary-item,
-  .map-kpi {
-    min-width: 96px;
+  .quick-grid {
+    grid-template-columns: 1fr;
   }
 }
 </style>

+ 212 - 0
tests/waterHomeModel.test.mjs

@@ -0,0 +1,212 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+  HOME_QUICK_ENTRIES,
+  buildHomeWorkOrderQuery,
+  normalizeFacilityTotal,
+  normalizeHomeKpis,
+  normalizeWorkOrderStatusStat,
+  normalizeAlarmOverviewCards,
+  normalizeAlarmTrend,
+  normalizePieRatio,
+  normalizeRealtimeAlarmRows,
+  normalizeAbnormalTodoRows,
+  normalizeDeviceSummary,
+  normalizeWorkOrderRows,
+  buildOverviewMapPoints
+} from '../src/utils/waterHomeModel.js'
+
+test('home work order scope is constrained to the water-supply type tree', () => {
+  assert.deepEqual(buildHomeWorkOrderQuery(), {
+    deviceTypeParentId: '1',
+    pageNum: 1,
+    pageSize: 5
+  })
+  assert.deepEqual(buildHomeWorkOrderQuery({ pageSize: 8 }), {
+    deviceTypeParentId: '1',
+    pageNum: 1,
+    pageSize: 8
+  })
+})
+
+test('facility total excludes the aggregated total row', () => {
+  const rows = [
+    { facilityType: 'source', totalCount: 3 },
+    { facilityType: 'plant', totalCount: 2 },
+    { facilityType: 'pipe', totalCount: 996 },
+    { facilityType: 'total', totalCount: 1000 }
+  ]
+  assert.equal(normalizeFacilityTotal(rows), 1001)
+  assert.equal(normalizeFacilityTotal(null), 0)
+  assert.equal(normalizeFacilityTotal([]), 0)
+})
+
+test('home KPIs map api payloads to workbench cards', () => {
+  const kpis = normalizeHomeKpis({
+    facilityRows: [{ facilityType: 'pipe', totalCount: 996 }, { facilityType: 'total', totalCount: 1000 }],
+    ledgerStats: { total: 15, online: 13 },
+    alarmStats: { realtimeAlarmCount: 3 }
+  })
+  assert.deepEqual(kpis, [
+    { key: 'facility', label: '设施总数', value: 996, unit: '处', tone: 'primary' },
+    { key: 'device', label: '监测设备', value: 15, unit: '台', tone: 'primary' },
+    { key: 'online', label: '在线设备', value: 13, unit: '台', tone: 'primary' },
+    { key: 'alarm', label: '实时报警', value: 3, unit: '条', tone: 'danger' }
+  ])
+})
+
+test('home KPIs keep realtime alarm card neutral when no alarm is active', () => {
+  const alarm = normalizeHomeKpis({ alarmStats: { realtimeAlarmCount: 0 } }).find(item => item.key === 'alarm')
+  assert.equal(alarm.tone, 'primary')
+})
+
+test('work order status statistic groups by authoritative status contract', () => {
+  const stat = normalizeWorkOrderStatusStat({
+    total: 10,
+    counts: [
+      { status: 0, count: 2 },
+      { status: 1, count: 1 },
+      { status: 3, count: 2 },
+      { status: 4, count: 2 },
+      { status: 5, count: 2 },
+      { status: 7, count: 1 }
+    ]
+  })
+  assert.equal(stat.total, 10)
+  assert.equal(stat.pendingCount, 3)
+  assert.equal(stat.processingCount, 2)
+  assert.equal(stat.verifyingCount, 2)
+  assert.equal(stat.finishedCount, 3)
+  assert.equal(stat.byStatus.find(item => item.status === 6).count, 0)
+})
+
+test('alarm overview cards derive online rate and verifying orders', () => {
+  const cards = normalizeAlarmOverviewCards({
+    dashboard: { totalCount: 16, alarmingDeviceCount: 4 },
+    deviceStats: { onlineRate: 86.666666 },
+    workOrderStat: {
+      counts: [{ status: 4, count: 3 }]
+    }
+  })
+  assert.deepEqual(cards, [
+    { key: 'alarmTotal', label: '报警总数', value: 16, unit: '条', tone: 'primary' },
+    { key: 'alarmingDevice', label: '报警中设备', value: 4, unit: '台', tone: 'danger' },
+    { key: 'verifyingOrder', label: '待验收工单', value: 3, unit: '单', tone: 'warning' },
+    { key: 'onlineRate', label: '在线率', value: 86.7, unit: '%', tone: 'primary' }
+  ])
+})
+
+test('alarm trend returns four level series with zero-filled missing days', () => {
+  const trend = normalizeAlarmTrend([
+    { date: '2026-09-15', level1Count: 1, level2Count: 2 },
+    { date: '2026-09-16' }
+  ])
+  assert.deepEqual(trend.dates, ['2026-09-15', '2026-09-16'])
+  assert.equal(trend.levels.length, 4)
+  const level2 = trend.levels.find(item => item.name === '级2')
+  assert.deepEqual(level2.data, [2, 0])
+  assert.equal(normalizeAlarmTrend(null).levels.length, 4)
+})
+
+test('ratio pie drops empty values and caps the number of segments', () => {
+  const items = [
+    { name: ' ' },
+    { name: '压力', count: '8' },
+    { name: '流量', count: '5' },
+    { name: '离线', count: '3' },
+    { name: '低', count: 0 }
+  ]
+  assert.deepEqual(normalizePieRatio(items, '未分类', 3), [
+    { name: '压力', count: 8 },
+    { name: '流量', count: 5 },
+    { name: '离线', count: 3 }
+  ])
+  assert.deepEqual(normalizePieRatio(undefined, '未分类'), [])
+})
+
+test('realtime alarm rows expose chinese friendly display fields', () => {
+  const rows = normalizeRealtimeAlarmRows([
+    { id: 'a1', deviceCode: 'CN-GS-202609020001', deviceName: '一号压力点', warningType: '压力过低', alarmLevel: 1, alarmTime: '2026-09-15 10:00:00', equipmentLocation: '城南' }
+  ])
+  assert.equal(rows.length, 1)
+  assert.equal(rows[0].deviceName, '一号压力点')
+  assert.equal(rows[0].alarmLevelText, '一级报警')
+  assert.equal(rows[0].location, '城南')
+  assert.equal(normalizeRealtimeAlarmRows([], 5).length, 0)
+})
+
+test('abnormal todo rows reuse the shared device normalization contract', () => {
+  const rows = normalizeAbnormalTodoRows([{
+    equipmentId: 'eq-1',
+    equipmentCode: 'CN-GS-202609020002',
+    equipmentName: '二号流量点',
+    alarmStatus: 1,
+    onlineStatus: 1,
+    currentStatus: 1,
+    alertLevel: 2,
+    relatedWorkOrderNo: 'WO202609150001',
+    relatedWorkOrderStatus: 3
+  }], 1)
+  assert.equal(rows.length, 1)
+  assert.equal(rows[0].alarmStatusText, '报警')
+  assert.equal(rows[0].onlineStatusText, '在线')
+  assert.equal(rows[0].alarmLevelText, '二级报警')
+  assert.equal(rows[0].relatedOrderNo, 'WO202609150001')
+  assert.equal(rows[0].workOrderStatusText, '处理中')
+})
+
+test('device summary keeps key text fields for the workbench detail panel', () => {
+  const summary = normalizeDeviceSummary({
+    equipmentId: 'eq-9',
+    equipmentCode: 'CB-GS-202609020003',
+    equipmentName: '三号漏失点',
+    equipmentLocation: '城北',
+    district: '城北',
+    alarmStatus: 0,
+    onlineStatus: 0,
+    currentStatus: 3,
+    equipmentTypeName: '漏失在线监测仪'
+  })
+  assert.equal(summary.name, '三号漏失点')
+  assert.equal(summary.alarmStatusText, '正常')
+  assert.equal(summary.onlineStatusText, '离线')
+  assert.equal(summary.currentStatusText, '维修')
+  assert.equal(summary.abnormalReasonText, '设备维修中')
+  assert.equal(summary.typeName, '漏失在线监测仪')
+})
+
+test('work order rows translate status and level codes to chinese', () => {
+  const rows = normalizeWorkOrderRows([
+    { orderId: 5, orderNo: 'WO202609150001', orderStatus: 4, orderLevel: 0, deviceCode: 'CN-GS-202609020001', createTime: '2026-09-15 10:00:00' }
+  ], 5)
+  assert.equal(rows[0].orderStatusText, '待验收')
+  assert.equal(rows[0].orderStatusType, 'primary')
+  assert.equal(rows[0].orderLevelText, '紧急')
+  assert.equal(rows[0].orderLevelType, 'danger')
+})
+
+test('overview map points merge devices and alarms with alarm priority', () => {
+  const points = buildOverviewMapPoints({
+    devicePoints: [
+      { equipmentId: 'eq-1', equipmentCode: 'CN-GS-202609020001', equipmentName: '一号点', longitude: 110.1, latitude: 28.1, alarmStatus: 0, onlineStatus: 1 },
+      { equipmentId: 'eq-2', equipmentCode: 'CB-GS-202609020002', equipmentName: '二号点', longitude: 110.2, latitude: 28.2, alarmStatus: 1, onlineStatus: 1 }
+    ],
+    alarmRows: [
+      { deviceCode: 'CN-GS-202609020001', deviceName: '一号点', longitude: 110.1, latitude: 28.1, warningType: '压力过低', alarmLevel: 1 }
+    ]
+  })
+  assert.equal(points.length, 2)
+  const one = points.find(item => item.deviceCode === 'CN-GS-202609020001')
+  assert.equal(one.state, 'alarm')
+  assert.equal(one.meta, '压力过低')
+  const two = points.find(item => item.deviceCode === 'CB-GS-202609020002')
+  assert.equal(two.state, 'alarm')
+  assert.ok(points.every(item => Number.isFinite(item.mapX) && Number.isFinite(item.mapY)))
+  assert.deepEqual(buildOverviewMapPoints({ devicePoints: [], alarmRows: [] }), [])
+})
+
+test('home quick entries cover the four workbench jumps', () => {
+  assert.deepEqual(HOME_QUICK_ENTRIES.map(item => item.label), ['设备异常管理', '报警管理', '报警审核', '监测报警一张图'])
+  assert.ok(HOME_QUICK_ENTRIES.every(item => item.suffix.startsWith('/')))
+})