소스 검색

merge origin/master updates

Kazerin 1 주 전
부모
커밋
c0cc7ea5e8
36개의 변경된 파일3916개의 추가작업 그리고 805개의 파일을 삭제
  1. 4 4
      src/api/pipeNetwork/basic.js
  2. 61 0
      src/api/pipeNetwork/earlyWarning.js
  3. 29 0
      src/api/warning/dashboard.js
  4. 102 0
      src/api/warning/list.js
  5. 22 0
      src/api/warning/statistics.js
  6. 2 2
      src/components/Breadcrumb/index.vue
  7. 3 4
      src/components/HeaderSearch/index.vue
  8. 3 3
      src/layout/components/Sidebar/Logo.vue
  9. 4 3
      src/layout/components/Sidebar/SidebarItem.vue
  10. 3 3
      src/layout/components/TagsView/index.vue
  11. 3 1
      src/router/index.js
  12. 18 6
      src/store/modules/permission.js
  13. 31 0
      src/utils/ruoyi.js
  14. 1 1
      src/views/index.vue
  15. 4 4
      src/views/portal/index.vue
  16. 9 8
      src/views/subSystem/basic/GasThreshold.vue
  17. 43 7
      src/views/subSystem/lifeCompany/lOverviewWarning/LTodayNot.vue
  18. 44 7
      src/views/subSystem/lifeCompany/lOverviewWarning/Lhistory.vue
  19. 54 7
      src/views/subSystem/lifeCompany/lOverviewWarning/Ltoday.vue
  20. 70 0
      src/views/subSystem/lifeCompany/lOverviewWarning/WarningDetailDrawer.vue
  21. 130 0
      src/views/subSystem/lifeCompany/lOverviewWarning/dashboardShared.js
  22. 668 609
      src/views/subSystem/lifeCompany/lhome/lHome.vue
  23. 25 0
      src/views/subSystem/lifeCompany/lstatistics/disposalEfficiency/index.vue
  24. 26 0
      src/views/subSystem/lifeCompany/lstatistics/disposalStatus/index.vue
  25. 24 0
      src/views/subSystem/lifeCompany/lstatistics/receiveEfficiency/index.vue
  26. 91 0
      src/views/subSystem/lifeCompany/lstatistics/statisticsShared.js
  27. 21 0
      src/views/subSystem/lifeCompany/lstatistics/today/index.vue
  28. 21 0
      src/views/subSystem/lifeCompany/lstatistics/trend/index.vue
  29. 14 0
      src/views/subSystem/lifeCompany/lwarningInfoCont/LwarningDisp.vue
  30. 340 136
      src/views/subSystem/pipeNetwork/pAlarmMonitor/PEqWarning.vue
  31. 236 0
      src/views/subSystem/warningList/codeGenerate/index.vue
  32. 224 0
      src/views/subSystem/warningList/itemDetail/index.vue
  33. 628 0
      src/views/subSystem/warningList/itemManage/index.vue
  34. 464 0
      src/views/subSystem/warningList/itemQuery/index.vue
  35. 427 0
      src/views/subSystem/warningList/reasonManage/index.vue
  36. 67 0
      src/views/subSystem/warningList/shared.js

+ 4 - 4
src/api/pipeNetwork/basic.js

@@ -466,11 +466,11 @@ export function getWarningThresholdList(params) {
 export function getWarningThresholdById(id) {
   return request({ url: '/warningThreshold/getById/' + id, method: 'get' })
 }
-export function saveWarningThreshold(data) {
-  return request({ url: '/warningThreshold/save', method: 'post', data })
+export function saveWarningThreshold(data, moduleType) {
+  return request({ url: '/warningThreshold/save', method: 'post', data, params: moduleType ? { moduleType } : undefined })
 }
-export function updateWarningThreshold(data) {
-  return request({ url: '/warningThreshold/update', method: 'post', data })
+export function updateWarningThreshold(data, moduleType) {
+  return request({ url: '/warningThreshold/update', method: 'post', data, params: moduleType ? { moduleType } : undefined })
 }
 export function deleteWarningThreshold(ids) {
   return request({ url: '/warningThreshold/deleteBatch', method: 'post', data: ids })

+ 61 - 0
src/api/pipeNetwork/earlyWarning.js

@@ -40,6 +40,51 @@ export function publishWarningData(warningId) {
   })
 }
 
+export function confirmWarningData(data) {
+  return request({
+    url: '/warning/confirm',
+    method: 'post',
+    params: data
+  })
+}
+
+export function misreportWarningData(data) {
+  return request({
+    url: '/warning/misreport',
+    method: 'post',
+    params: data
+  })
+}
+
+export function submitWarningProcessData(data) {
+  const formData = new FormData()
+  formData.append('process', JSON.stringify({
+    warningId: data.warningId,
+    processContent: data.processContent
+  }))
+  ;(data.files || []).forEach(file => formData.append('files', file))
+  return request({
+    url: '/warning/process',
+    method: 'post',
+    data: formData
+  })
+}
+
+export function clearWarningData(data) {
+  return request({
+    url: '/warning/clear',
+    method: 'post',
+    params: data
+  })
+}
+
+export function getWarningSupervisionListData(warningId) {
+  return request({
+    url: `/warning/supervision/${warningId}`,
+    method: 'get'
+  })
+}
+
 // 解除预警(误报/工单完成)
 export function resolveWarningData(data) {
   return request({
@@ -101,6 +146,22 @@ export function getWarningAttachmentsData(warningId) {
   })
 }
 
+export function getWarningAttachmentPreviewData(attachmentId) {
+  return request({
+    url: `/warning/attachments/${attachmentId}/preview`,
+    method: 'get',
+    responseType: 'blob'
+  })
+}
+
+export function getWarningAttachmentDownloadData(attachmentId) {
+  return request({
+    url: `/warning/attachments/${attachmentId}/download`,
+    method: 'get',
+    responseType: 'blob'
+  })
+}
+
 // 流程可视化数据(路线图节点+边)
 export function getWarningDiagramData(warningId) {
   return request({

+ 29 - 0
src/api/warning/dashboard.js

@@ -0,0 +1,29 @@
+import request from '@/utils/request'
+
+/**
+ * 预警总览接口。
+ * 后端返回若依 AjaxResult,统一由 request 拦截器处理鉴权和错误。
+ */
+export function getWarningTodayOverview() {
+  return request({ url: '/api/warning-dashboard/today-overview', method: 'get' })
+}
+
+export function pageWarningTodayOverview(params) {
+  return request({ url: '/api/warning-dashboard/today-overview/page', method: 'get', params })
+}
+
+export function getWarningHistoryOverview() {
+  return request({ url: '/api/warning-dashboard/history-overview', method: 'get' })
+}
+
+export function pageWarningHistory(params) {
+  return request({ url: '/api/warning-dashboard/history-overview/page', method: 'get', params })
+}
+
+export function getWarningTodayUnprocessed() {
+  return request({ url: '/api/warning-dashboard/today-unprocessed', method: 'get' })
+}
+
+export function pageWarningTodayUnprocessed(params) {
+  return request({ url: '/api/warning-dashboard/today-unprocessed', method: 'get', params })
+}

+ 102 - 0
src/api/warning/list.js

@@ -0,0 +1,102 @@
+import request from '@/utils/request'
+
+export function listWarningItems(params) {
+  return request({
+    url: '/warning/item/list',
+    method: 'get',
+    params
+  })
+}
+
+export function addWarningItem(data) {
+  return request({
+    url: '/warning/item/add',
+    method: 'post',
+    data
+  })
+}
+
+export function updateWarningItem(data) {
+  return request({
+    url: '/warning/item/update',
+    method: 'put',
+    data
+  })
+}
+
+export function updateWarningItemStatus(itemId, status) {
+  return request({
+    url: '/warning/item/status',
+    method: 'put',
+    params: { itemId, status }
+  })
+}
+
+export function getWarningItemDetail(itemId) {
+  return request({
+    url: `/warning/item/detail/${itemId}`,
+    method: 'get'
+  })
+}
+
+export function getWarningItemByCode(itemCode) {
+  return request({
+    url: `/warning/item/detail/code/${encodeURIComponent(itemCode)}`,
+    method: 'get'
+  })
+}
+
+export function generateWarningItemCode(warningType, warningLevel) {
+  return request({
+    url: '/warning/item/code/generate',
+    method: 'get',
+    params: { warningType, warningLevel }
+  })
+}
+
+export function listWarningReasons(params) {
+  return request({
+    url: '/warning/reason/list',
+    method: 'get',
+    params
+  })
+}
+
+export function listReasonsByTypeAndLevel(warningType, warningLevel) {
+  return request({
+    url: '/warning/reason/query',
+    method: 'get',
+    params: { warningType, warningLevel }
+  })
+}
+
+export function addWarningReason(data) {
+  return request({
+    url: '/warning/reason/add',
+    method: 'post',
+    data
+  })
+}
+
+export function updateWarningReason(data) {
+  return request({
+    url: '/warning/reason/update',
+    method: 'put',
+    data
+  })
+}
+
+export function updateWarningReasonStatus(reasonId, status) {
+  return request({
+    url: '/warning/reason/status',
+    method: 'put',
+    params: { reasonId, status }
+  })
+}
+
+export function getWarningReasonDetail(reasonId) {
+  return request({
+    url: `/warning/reason/detail/${reasonId}`,
+    method: 'get'
+  })
+}

+ 22 - 0
src/api/warning/statistics.js

@@ -0,0 +1,22 @@
+import request from '@/utils/request'
+
+const get = (url, params) => request({ url, method: 'get', params })
+
+export const getDisposeStatusStat = params => get('/warning/disposeStatus/stat', params)
+export const getDisposalTypeStat = params => get('/warning/disposeStatus/disposal-type', params)
+
+export const getDisposeEfficiencyOverview = params => get('/warning/disposeEfficiency/overview', params)
+export const getDisposeEfficiencyRange = params => get('/warning/disposeEfficiency/range', params)
+export const getDisposeEfficiencySpecial = params => get('/warning/disposeEfficiency/special', params)
+export const pageDisposeEfficiency = params => get('/warning/disposeEfficiency/page', params)
+
+export const getTodayWarningStats = () => get('/warning/today/stats')
+export const pageTodayWarnings = params => get('/warning/today/page', params)
+
+export const getWarningTrendByDay = params => get('/warning/trend/day', params)
+export const getWarningTrendByMonth = params => get('/warning/trend/month', params)
+
+export const getReceiveEfficiencyOverview = params => get('/warning/receiveEfficiency/overview', params)
+export const getReceiveEfficiencyRange = params => get('/warning/receiveEfficiency/range', params)
+export const getReceiveEfficiencyType = params => get('/warning/receiveEfficiency/type', params)
+export const pageReceiveEfficiency = params => get('/warning/receiveEfficiency/page', params)

+ 2 - 2
src/components/Breadcrumb/index.vue

@@ -50,7 +50,7 @@ function isDashboard(route) {
   if (!name) {
     return false
   }
-  return name.trim() === 'Index'
+  return ['Index', 'MainAdminHome'].includes(name.trim())
 }
 
 function handleLink(item) {
@@ -84,4 +84,4 @@ getBreadcrumb();
     cursor: text;
   }
 }
-</style>
+</style>

+ 3 - 4
src/components/HeaderSearch/index.vue

@@ -19,7 +19,7 @@
 
 <script setup>
 import Fuse from 'fuse.js'
-import { getNormalPath } from '@/utils/ruoyi'
+import { joinRoutePath } from '@/utils/ruoyi'
 import { isHttp } from '@/utils/validate'
 import usePermissionStore from '@/store/modules/permission'
 
@@ -88,9 +88,8 @@ function generateRoutes(routes, basePath = '', prefixTitle = []) {
   for (const r of routes) {
     // skip hidden router
     if (r.hidden) { continue }
-    const p = r.path.length > 0 && r.path[0] === '/' ? r.path : '/' + r.path;
     const data = {
-      path: !isHttp(r.path) ? getNormalPath(basePath + p) : r.path,
+      path: !isHttp(r.path) ? joinRoutePath(basePath, r.path) : r.path,
       title: [...prefixTitle]
     }
 
@@ -184,4 +183,4 @@ watch(searchPool, (list) => {
     }
   }
 }
-</style>
+</style>

+ 3 - 3
src/layout/components/Sidebar/Logo.vue

@@ -1,11 +1,11 @@
 <template>
   <div class="sidebar-logo-container" :class="{ 'collapse': collapse }" :style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
     <transition name="sidebarLogoFade">
-      <router-link v-if="collapse" key="collapse" class="sidebar-logo-link">
+      <router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
         <img v-if="logo" :src="logo" class="sidebar-logo" />
         <h1 v-else class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }}</h1>
       </router-link>
-      <router-link v-else key="expand" class="sidebar-logo-link">
+      <router-link v-else key="expand" class="sidebar-logo-link" to="/">
         <img v-if="logo" :src="logo" class="sidebar-logo" />
         <h1 class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }}</h1>
       </router-link>
@@ -78,4 +78,4 @@ const sideTheme = computed(() => settingsStore.sideTheme);
     }
   }
 }
-</style>
+</style>

+ 4 - 3
src/layout/components/Sidebar/SidebarItem.vue

@@ -30,7 +30,7 @@
 <script setup>
 import { isExternal } from '@/utils/validate'
 import AppLink from './Link'
-import { getNormalPath } from '@/utils/ruoyi'
+import { getNormalPath, joinRoutePath } from '@/utils/ruoyi'
 
 const props = defineProps({
   // route object
@@ -85,11 +85,12 @@ function resolvePath(routePath, routeQuery) {
   if (isExternal(props.basePath)) {
     return props.basePath
   }
+  const path = joinRoutePath(props.basePath, routePath)
   if (routeQuery) {
     let query = JSON.parse(routeQuery);
-    return { path: getNormalPath(props.basePath + '/' + routePath), query: query }
+    return { path: getNormalPath(path), query: query }
   }
-  return getNormalPath(props.basePath + '/' + routePath)
+  return getNormalPath(path)
 }
 
 // 修复后的 hasTitle 函数

+ 3 - 3
src/layout/components/TagsView/index.vue

@@ -52,7 +52,7 @@
 
 <script setup>
 import ScrollPane from './ScrollPane'
-import { getNormalPath } from '@/utils/ruoyi'
+import { joinRoutePath } from '@/utils/ruoyi'
 import useTagsViewStore from '@/store/modules/tagsView'
 import useSettingsStore from '@/store/modules/settings'
 import usePermissionStore from '@/store/modules/permission'
@@ -152,7 +152,7 @@ function filterAffixTags(routes, basePath = '') {
   let tags = []
   routes.forEach(route => {
     if (route.meta && route.meta.affix) {
-      const tagPath = getNormalPath(basePath + '/' + route.path)
+      const tagPath = joinRoutePath(basePath, route.path)
       tags.push({
         fullPath: tagPath,
         path: tagPath,
@@ -161,7 +161,7 @@ function filterAffixTags(routes, basePath = '') {
       })
     }
     if (route.children) {
-      const tempTags = filterAffixTags(route.children, route.path)
+      const tempTags = filterAffixTags(route.children, joinRoutePath(basePath, route.path))
       if (tempTags.length >= 1) {
         tags = [...tags, ...tempTags]
       }

+ 3 - 1
src/router/index.js

@@ -83,7 +83,9 @@ export const constantRoutes = mergeRouteRecords(
       {
         path: '/index',
         component: () => import('@/views/index'),
-        name: 'Index',
+        // Keep this name unique: backend menu paths such as "index" are also
+        // normalized to the route name "Index" by the dynamic router builder.
+        name: 'MainAdminHome',
         meta: { title: '首页', icon: 'dashboard', affix: true }
       }
     ]

+ 18 - 6
src/store/modules/permission.js

@@ -5,6 +5,7 @@ import Layout from '@/layout/index'
 import ParentView from '@/components/ParentView'
 import InnerLink from '@/layout/components/InnerLink'
 import { ensureUniqueSubsystemRouteNames } from '@/router/routeNameUtils'
+import { joinRoutePath } from '@/utils/ruoyi'
 
 // 匹配views里面所有的.vue文件
 const modules = import.meta.glob('./../../views/**/*.vue')
@@ -60,7 +61,7 @@ const usePermissionStore = defineStore(
 function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {
   return asyncRouterMap.filter(route => {
     if (type && route.children) {
-      route.children = filterChildren(route.children)
+      route.children = filterChildren(route.children, false, route.path)
     }
     if (route.component) {
       // Layout ParentView 组件特殊处理
@@ -84,15 +85,15 @@ function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {
   })
 }
 
-function filterChildren(childrenMap, lastRouter = false) {
+function filterChildren(childrenMap, lastRouter = false, rootPath = '') {
   var children = []
   childrenMap.forEach((el, index) => {
     if (el.children && el.children.length) {
       if (el.component === 'ParentView' && !lastRouter) {
         el.children.forEach(c => {
-          c.path = el.path + '/' + c.path
+          c.path = toNestedRoutePath(joinRoutePath(el.path, c.path), rootPath)
           if (c.children && c.children.length) {
-            children = children.concat(filterChildren(c.children, c))
+            children = children.concat(filterChildren(c.children, c, rootPath))
             return
           }
           children.push(c)
@@ -101,9 +102,9 @@ function filterChildren(childrenMap, lastRouter = false) {
       }
     }
     if (lastRouter) {
-      el.path = lastRouter.path + '/' + el.path
+      el.path = toNestedRoutePath(joinRoutePath(lastRouter.path, el.path), rootPath)
       if (el.children && el.children.length) {
-        children = children.concat(filterChildren(el.children, el))
+        children = children.concat(filterChildren(el.children, el, rootPath))
         return
       }
     }
@@ -112,6 +113,17 @@ function filterChildren(childrenMap, lastRouter = false) {
   return children
 }
 
+function toNestedRoutePath(path, rootPath) {
+  const normalizedPath = String(path || '').replace(/^\/+|\/+$/g, '')
+  const normalizedRoot = String(rootPath || '').replace(/^\/+|\/+$/g, '')
+  if (!normalizedRoot) return normalizedPath
+  if (normalizedPath === normalizedRoot) return ''
+  if (normalizedPath.startsWith(`${normalizedRoot}/`)) {
+    return normalizedPath.slice(normalizedRoot.length + 1)
+  }
+  return normalizedPath
+}
+
 // 动态路由遍历,验证是否具备权限
 export function filterDynamicRoutes(routes) {
   const res = []

+ 31 - 0
src/utils/ruoyi.js

@@ -240,6 +240,37 @@ export function getNormalPath(p) {
   return res;
 }
 
+// 菜单数据可能是相对路径,也可能已经包含父级路径;两种格式统一合并。
+export function joinRoutePath(parentPath = '', childPath = '') {
+  const parentRaw = String(parentPath || '')
+  const childRaw = String(childPath || '')
+  const parent = parentRaw.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean)
+  const child = childRaw.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean)
+  if (!parent.length) return child.length ? `/${child.join('/')}` : ''
+  if (!child.length) return `/${parent.join('/')}`
+  if (childRaw.startsWith('/')) return `/${child.join('/')}`
+
+  // Prefer the longest suffix/prefix overlap. This handles mixed menu data such as
+  // parent="subSystem/lifeCompany/lstatistics" and child="lstatistics/trend".
+  const maxOverlap = Math.min(parent.length, child.length)
+  for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
+    const parentSuffix = parent.slice(parent.length - overlap)
+    const childPrefix = child.slice(0, overlap)
+    if (parentSuffix.every((segment, index) => segment === childPrefix[index])) {
+      return `/${parent.concat(child.slice(overlap)).join('/')}`
+    }
+  }
+
+  // A legacy record can contain the complete path while the immediate parent is
+  // only one segment (for example parent="lstatistics"). Keep that path intact.
+  for (let start = 0; start <= child.length - parent.length; start += 1) {
+    if (parent.every((segment, index) => segment === child[start + index])) {
+      return `/${child.join('/')}`
+    }
+  }
+  return `/${parent.concat(child).join('/')}`
+}
+
 // 验证是否为blob格式
 export function blobValidate(data) {
   return data.type !== 'application/json'

+ 1 - 1
src/views/index.vue

@@ -2,7 +2,7 @@
   <div>首页</div>
 </template>
 
-<script setup name="Index">
+<script setup name="MainAdminHome">
 const version = ref('3.8.8')
 
 function goTarget(url) {

+ 4 - 4
src/views/portal/index.vue

@@ -8,7 +8,7 @@
       <h1 class="system-title">沅陵县城市地下管网管控平台</h1>
       <div class="header-right">
         <span class="welcome-text">欢迎,<span class="username">浏览账号</span></span>
-        <button class="header-btn" @click="enterBackend('/')">进入后台</button>
+        <button class="header-btn" @click="enterBackend">进入后台</button>
         <button class="header-btn" @click="logout">退出系统</button>
       </div>
     </header>
@@ -234,8 +234,8 @@ const loadDynamicModules = () => {
 // ==============================================
 // 进入后台
 // ==============================================
-const enterBackend = (path: string) => {
-  router.push(path)
+const enterBackend = () => {
+  router.push('/index')
 }
 
 // ==============================================
@@ -491,4 +491,4 @@ onUnmounted(() => {
 //    padding: 3px 10px;
 //  }
 //}
-</style>
+</style>

+ 9 - 8
src/views/subSystem/basic/GasThreshold.vue

@@ -101,8 +101,9 @@
 <script setup name="GasThreshold">
 import { ref, reactive, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { getWarningThresholdModulePage, saveWarningThreshold, updateWarningThreshold, deleteWarningThreshold } from '@/api/pipeNetwork/basic'
-import request from '@/utils/request'
+import { getWarningThresholdModulePage, saveWarningThreshold, updateWarningThreshold, deleteWarningThreshold, getEquipmentByTopLevelType } from '@/api/pipeNetwork/basic'
+
+const GAS_MODULE_TYPE = '燃气'
 
 // ==================== 燃气模块预警类型与预警编码参考 ====================
 const WARNING_TYPE_OPTIONS = [
@@ -148,7 +149,7 @@ async function loadData() {
     if (queryParams.deviceCode) params.deviceCode = queryParams.deviceCode
     if (queryParams.warningType) params.warningType = queryParams.warningType
     if (queryParams.warningCode) params.warningCode = queryParams.warningCode
-    const res = await getWarningThresholdModulePage(pageNum.value, pageSize.value, '燃气', params)
+    const res = await getWarningThresholdModulePage(pageNum.value, pageSize.value, GAS_MODULE_TYPE, params)
     const pageData = res.data || res
     tableData.value = pageData.records || []
     total.value = pageData.total || 0
@@ -169,7 +170,7 @@ const deviceInfoMap = ref({})
 async function ensureDeviceMap() {
   if (Object.keys(deviceInfoMap.value).length > 0) return
   try {
-    const res = await request({ url: '/EquipmentBase/findByTopLevelType', method: 'get', params: { typeName: '燃气' } })
+    const res = await getEquipmentByTopLevelType(GAS_MODULE_TYPE)
     const list = res.data || []
     const map = {}
     list.forEach(d => { map[d.equipmentCode] = d })
@@ -214,7 +215,7 @@ const deviceOptions = ref([])
 async function loadGasDeviceOptions() {
   if (deviceOptions.value.length > 0) return
   try {
-    const res = await request({ url: '/EquipmentBase/findByTopLevelType', method: 'get', params: { typeName: '燃气' } })
+    const res = await getEquipmentByTopLevelType(GAS_MODULE_TYPE)
     deviceOptions.value = res.data || []
   } catch (e) {
     console.error('加载燃气设备列表失败', e)
@@ -273,10 +274,10 @@ async function handleSave() {
     }
     if (isEdit.value) {
       data.id = formData.id
-      await updateWarningThreshold(data)
+      await updateWarningThreshold(data, GAS_MODULE_TYPE)
       ElMessage.success('修改成功')
     } else {
-      await saveWarningThreshold(data)
+      await saveWarningThreshold(data, GAS_MODULE_TYPE)
       ElMessage.success('新增成功')
     }
     dialogVisible.value = false
@@ -326,4 +327,4 @@ onMounted(async () => {
 .table-card {
   min-height: 400px;
 }
-</style>
+</style>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 43 - 7
src/views/subSystem/lifeCompany/lOverviewWarning/LTodayNot.vue


+ 44 - 7
src/views/subSystem/lifeCompany/lOverviewWarning/Lhistory.vue

@@ -1,11 +1,48 @@
-<script setup>
-
-</script>
-
 <template>
-<div>历史预警概览</div>
+  <div class="overview-page" v-loading="loading">
+    <section class="page-head"><div><span class="eyebrow">HISTORICAL SIGNALS</span><h1>历史预警概况</h1><p>数据更新:{{ result.generatedAt || '正在加载' }}</p></div><el-button type="primary" :loading="loading" @click="loadData"><el-icon><Refresh /></el-icon>刷新概况</el-button></section>
+    <el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon closable class="error-alert" @close="errorMessage = ''" />
+    <div class="history-layout">
+      <el-card shadow="never" class="total-card"><span>累计预警总量</span><strong>{{ totalCount }}</strong><small>统计全部非草稿预警事件</small><div class="total-line"><i :style="{ width: specialStats.length ? '100%' : '0%' }" /></div></el-card>
+      <el-card shadow="never" class="chart-card"><template #header><div class="card-title"><span>专项预警分布</span><em>{{ specialStats.length }} 个专项</em></div></template><div ref="chartRef" class="chart-box" /><el-empty v-if="!specialStats.length && !loading" description="暂无专项统计数据" /></el-card>
+    </div>
+    <el-card shadow="never" class="table-card">
+      <template #header><div class="card-title"><div><span>历史预警明细</span><em>{{ total }} 条</em></div><div class="filters"><el-select v-model="selectedSpecial" clearable placeholder="全部预警专项" @change="resetPage"><el-option v-for="item in specialStats" :key="item.specialName" :label="item.specialName" :value="item.specialName" /></el-select><el-input v-model="keyword" clearable placeholder="搜索名称、位置或单位" @keyup.enter="resetPage" @clear="resetPage"><template #prefix><el-icon><Search /></el-icon></template></el-input><el-button type="primary" @click="resetPage">查询</el-button></div></div></template>
+      <el-table v-loading="tableLoading" :data="rows" stripe highlight-current-row empty-text="暂无符合条件的历史预警" @row-click="openDetail">
+        <el-table-column label="预警事项" min-width="190" show-overflow-tooltip><template #default="{ row }">{{ row.warningName || row.warning_name || '未命名预警' }}</template></el-table-column>
+        <el-table-column label="预警类型" width="120"><template #default="{ row }">{{ warningTypeText(row) }}</template></el-table-column>
+        <el-table-column label="预警专项" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.warningSpecial || row.warning_special || '未设置' }}</template></el-table-column>
+        <el-table-column label="预警级别" width="105" align="center"><template #default="{ row }"><el-tag :type="warningLevelTag(row)">{{ warningLevelText(row) }}</el-tag></template></el-table-column>
+        <el-table-column label="权属单位" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.ownershipUnit || row.ownership_unit || '未设置' }}</template></el-table-column>
+        <el-table-column label="发布时间" min-width="165"><template #default="{ row }">{{ formatDashboardTime(row.publishTime || row.publish_time || row.createTime) }}</template></el-table-column>
+        <el-table-column label="状态" width="105" align="center"><template #default="{ row }"><el-tag :type="warningStatusTag(row)" effect="plain">{{ warningStatusText(row) }}</el-tag></template></el-table-column>
+        <el-table-column label="操作" width="90" fixed="right" align="center"><template #default="{ row }"><el-button link type="primary" @click.stop="openDetail(row)">查看详情</el-button></template></el-table-column>
+      </el-table>
+      <el-pagination v-if="total" v-model:current-page="pageNum" v-model:page-size="pageSize" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" :total="total" class="pagination" @current-change="loadPage" @size-change="handleSizeChange" />
+    </el-card>
+    <WarningDetailDrawer v-model="detailVisible" :row="selectedRow" />
+  </div>
 </template>
 
-<style scoped lang="scss">
+<script setup name="WarningHistoryOverview">
+import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Refresh, Search } from '@element-plus/icons-vue'
+import * as echarts from 'echarts'
+import { getWarningHistoryOverview, pageWarningHistory } from '@/api/warning/dashboard'
+import WarningDetailDrawer from './WarningDetailDrawer.vue'
+import { dashboardData, formatDashboardTime, pageRecords, pageTotal, warningLevelTag, warningLevelText, warningStatusTag, warningStatusText, warningTypeText } from './dashboardShared'
 
-</style>
+const loading=ref(false);const tableLoading=ref(false);const errorMessage=ref('');const result=ref({totalCount:0,specialStats:[],detailList:[]});const keyword=ref('');const selectedSpecial=ref('');const pageNum=ref(1);const pageSize=ref(10);const rows=ref([]);const total=ref(0);const chartRef=ref();const detailVisible=ref(false);const selectedRow=ref(null);let chart
+const totalCount=computed(()=>Number(result.value.totalCount||0));const specialStats=computed(()=>(result.value.specialStats||[]).map(item=>({specialName:item.specialName||item.special_name||'未分类',count:Number(item.count||0),percentage:Number(item.percentage||0)})))
+function openDetail(row){selectedRow.value=row;detailVisible.value=true}function resetPage(){pageNum.value=1;loadPage()}function resize(){chart?.resize()}
+function handleSizeChange(){pageNum.value=1;loadPage()}
+function renderChart(){if(!chartRef.value||!specialStats.value.length){chart?.clear();return}chart ||= echarts.init(chartRef.value);chart.setOption({tooltip:{trigger:'item',formatter:'{b}<br/>{c} 条 ({d}%)'},legend:{type:'scroll',orient:'vertical',right:4,top:'center',textStyle:{color:'#52637a'}},series:[{type:'pie',radius:['46%','70%'],center:['38%','52%'],avoidLabelOverlap:true,itemStyle:{borderColor:'#fff',borderWidth:3},label:{show:false},emphasis:{label:{show:true,fontSize:15,fontWeight:'bold'}},data:specialStats.value.map(item=>({name:item.specialName,value:item.count}))}],color:['#2563eb','#0f766e','#f59e0b','#dc2626','#64748b','#7c3aed']})}
+async function loadPage(){tableLoading.value=true;errorMessage.value='';try{const response=await pageWarningHistory({pageNum:pageNum.value,pageSize:pageSize.value,warningSpecial:selectedSpecial.value||undefined,keyword:keyword.value.trim()||undefined});rows.value=pageRecords(response);total.value=pageTotal(response)}catch(error){rows.value=[];total.value=0;errorMessage.value=error?.message||'历史预警明细加载失败';ElMessage.error(errorMessage.value)}finally{tableLoading.value=false}}
+async function loadData(){loading.value=true;errorMessage.value='';try{result.value={...result.value,...dashboardData(await getWarningHistoryOverview(),{})};await nextTick();renderChart();await loadPage()}catch(error){errorMessage.value=error?.message||'历史预警概况加载失败,请稍后重试';ElMessage.error(errorMessage.value)}finally{loading.value=false}}
+onMounted(()=>{loadData();window.addEventListener('resize',resize)});onUnmounted(()=>{window.removeEventListener('resize',resize);chart?.dispose()})
+</script>
+
+<style scoped lang="scss">
+.overview-page{min-height:calc(100vh - 84px);padding:22px;background:#f4f7fb;color:#172b4d}.page-head{display:flex;justify-content:space-between;align-items:flex-end;gap:18px;margin-bottom:20px}.eyebrow{color:#0f766e;font-size:11px;font-weight:700;letter-spacing:2px}.page-head h1{margin:6px 0 4px;font-size:26px}.page-head p{margin:0;color:#718096}.error-alert{margin-bottom:16px}.history-layout{display:grid;grid-template-columns:minmax(250px,.65fr) minmax(460px,1.5fr);gap:16px;margin-bottom:16px}.total-card{border-top:3px solid #0f766e}.total-card :deep(.el-card__body){display:flex;min-height:290px;flex-direction:column;justify-content:center;padding:30px}.total-card span{color:#718096}.total-card strong{margin:14px 0 10px;font-size:48px;line-height:1}.total-card small{color:#94a3b8}.total-line{height:5px;margin-top:28px;overflow:hidden;border-radius:4px;background:#e6edf4}.total-line i{display:block;height:100%;background:#0f766e}.chart-card{min-height:320px}.chart-box{height:250px}.card-title{display:flex;justify-content:space-between;align-items:center;gap:16px;font-weight:650}.card-title>div:first-child{display:flex;align-items:baseline;gap:10px}.card-title em{color:#94a3b8;font-size:12px;font-style:normal;font-weight:400}.filters{display:flex;gap:10px}.filters .el-select{width:190px}.filters .el-input{width:240px}.pagination{justify-content:flex-end;margin-top:16px}@media(max-width:900px){.history-layout{grid-template-columns:1fr}.page-head{align-items:flex-start;flex-direction:column}.card-title{align-items:flex-start;flex-direction:column}.filters{width:100%;flex-wrap:wrap}.filters .el-select,.filters .el-input{width:100%}}
+</style>

+ 54 - 7
src/views/subSystem/lifeCompany/lOverviewWarning/Ltoday.vue

@@ -1,11 +1,58 @@
-<script setup>
-
-</script>
-
 <template>
-<div>今日预警概况</div>
+  <div class="overview-page" v-loading="loading">
+    <section class="page-head">
+      <div><span class="eyebrow">TODAY'S WARNING DESK</span><h1>今日预警概况</h1><p>数据更新:{{ stats.generatedAt || '正在加载' }}</p></div>
+      <el-button type="primary" :loading="loading" @click="loadData"><el-icon><Refresh /></el-icon>刷新数据</el-button>
+    </section>
+    <el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon closable class="error-alert" @close="errorMessage = ''" />
+    <div class="metric-grid">
+      <button class="metric-card danger" :class="{ active: activeTab === 'unresolved' }" type="button" @click="selectTab('unresolved')"><span class="metric-label">仍未解除</span><strong>{{ unresolvedCount }}</strong><small>截至当前仍需关注的预警</small></button>
+      <button class="metric-card success" :class="{ active: activeTab === 'resolved' }" type="button" @click="selectTab('resolved')"><span class="metric-label">今日已解除</span><strong>{{ resolvedCount }}</strong><small>今日完成闭环的预警数量</small></button>
+      <button class="metric-card primary" :class="{ active: activeTab === 'all' }" type="button" @click="selectTab('all')"><span class="metric-label">今日预警总量</span><strong>{{ totalCount }}</strong><small>今天发布的全部非草稿预警</small></button>
+    </div>
+    <el-card shadow="never" class="table-card">
+      <template #header><div class="card-title"><div><span>{{ tabTitle }}</span><em>{{ total }} 条</em></div><div class="filters"><el-select v-model="warningType" clearable placeholder="全部类型" @change="resetPage"><el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="warningLevel" clearable placeholder="全部级别" @change="resetPage"><el-option v-for="item in levelOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="keyword" clearable placeholder="搜索名称、专项或位置" class="search-input" @keyup.enter="resetPage" @clear="resetPage"><template #prefix><el-icon><Search /></el-icon></template></el-input><el-button type="primary" @click="resetPage">查询</el-button></div></div></template>
+      <el-table v-loading="tableLoading" :data="rows" stripe highlight-current-row empty-text="暂无符合条件的预警" @row-click="openDetail">
+        <el-table-column label="预警事项" min-width="190" show-overflow-tooltip><template #default="{ row }">{{ row.warningName || row.warning_name || '未命名预警' }}</template></el-table-column>
+        <el-table-column label="预警类型" width="120"><template #default="{ row }">{{ warningTypeText(row) }}</template></el-table-column>
+        <el-table-column label="预警专项" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.warningSpecial || row.warning_special || '未设置' }}</template></el-table-column>
+        <el-table-column label="位置" min-width="170" show-overflow-tooltip><template #default="{ row }">{{ row.location || '未设置' }}</template></el-table-column>
+        <el-table-column label="发布时间" min-width="165"><template #default="{ row }">{{ formatDashboardTime(row.publishTime || row.publish_time || row.createTime) }}</template></el-table-column>
+        <el-table-column label="状态" width="105" align="center"><template #default="{ row }"><el-tag :type="warningStatusTag(row)">{{ warningStatusText(row) }}</el-tag></template></el-table-column>
+        <el-table-column label="操作" width="90" fixed="right" align="center"><template #default="{ row }"><el-button link type="primary" @click.stop="openDetail(row)">查看详情</el-button></template></el-table-column>
+      </el-table>
+      <el-pagination v-if="total" v-model:current-page="pageNum" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" :total="total" class="pagination" @current-change="loadPage" @size-change="handleSizeChange" />
+    </el-card>
+    <WarningDetailDrawer v-model="detailVisible" :row="selectedRow" />
+  </div>
 </template>
 
-<style scoped lang="scss">
+<script setup name="WarningTodayOverview">
+import { computed, onMounted, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Refresh, Search } from '@element-plus/icons-vue'
+import { getWarningTodayOverview, pageWarningTodayOverview } from '@/api/warning/dashboard'
+import { warningTypeLabels } from '../lstatistics/statisticsShared'
+import WarningDetailDrawer from './WarningDetailDrawer.vue'
+import { dashboardData, formatDashboardTime, pageRecords, pageTotal, warningStatusTag, warningStatusText, warningTypeText } from './dashboardShared'
 
-</style>
+const loading = ref(false); const tableLoading = ref(false); const errorMessage = ref(''); const activeTab = ref('unresolved'); const keyword = ref(''); const warningType = ref(''); const warningLevel = ref(''); const pageNum = ref(1); const pageSize = ref(10); const detailVisible = ref(false); const selectedRow = ref(null); const rows = ref([]); const total = ref(0)
+const stats = ref({ todayTotal: 0, unresolvedCount: 0, resolvedCount: 0 })
+const unresolvedCount = computed(() => Number(stats.value.unresolvedCount || 0))
+const resolvedCount = computed(() => Number(stats.value.resolvedCount || 0))
+const totalCount = computed(() => Number(stats.value.todayTotal || 0))
+const typeOptions = Object.entries(warningTypeLabels).filter(([value]) => ['WATER_PIPE', 'SEWER_PIPE', 'GAS_PIPE', 'MANHOLE'].includes(value)).map(([value, label]) => ({ value, label }))
+const levelOptions = [{ value: '1', label: '蓝色(IV级)' }, { value: '2', label: '黄色(III级)' }, { value: '3', label: '橙色(II级)' }, { value: '4', label: '红色(I级)' }]
+const tabTitle = computed(() => ({ unresolved: '仍未解除预警', resolved: '今日已解除预警', all: '今日全部预警' }[activeTab.value]))
+function resetPage() { pageNum.value = 1; loadPage() }
+function handleSizeChange() { pageNum.value = 1; loadPage() }
+function selectTab(tab) { if (activeTab.value !== tab) { activeTab.value = tab; resetPage() } else { resetPage() } }
+function openDetail(row) { selectedRow.value = row; detailVisible.value = true }
+async function loadPage() { tableLoading.value = true; errorMessage.value = ''; try { const response = await pageWarningTodayOverview({ category: activeTab.value, pageNum: pageNum.value, pageSize: pageSize.value, warningType: warningType.value || undefined, warningLevel: warningLevel.value || undefined, keyword: keyword.value.trim() || undefined }); rows.value = pageRecords(response); total.value = pageTotal(response) } catch (error) { rows.value = []; total.value = 0; errorMessage.value = error?.message || '今日预警明细加载失败'; ElMessage.error(errorMessage.value) } finally { tableLoading.value = false } }
+async function loadData() { loading.value = true; errorMessage.value = ''; try { stats.value = { ...stats.value, ...dashboardData(await getWarningTodayOverview(), {}) }; await loadPage() } catch (error) { errorMessage.value = error?.message || '今日预警概况加载失败,请稍后重试'; ElMessage.error(errorMessage.value) } finally { loading.value = false } }
+onMounted(loadData)
+</script>
+
+<style scoped lang="scss">
+.overview-page{min-height:calc(100vh - 84px);padding:22px;background:#f4f7fb;color:#172b4d}.page-head{display:flex;justify-content:space-between;align-items:flex-end;gap:18px;margin-bottom:20px}.eyebrow{color:#dc2626;font-size:11px;font-weight:700;letter-spacing:2px}.page-head h1{margin:6px 0 4px;font-size:26px;line-height:1.25}.page-head p{margin:0;color:#718096}.error-alert{margin-bottom:16px}.metric-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:18px}.metric-card{min-height:138px;padding:20px 22px;border:1px solid #e6ebf2;border-radius:8px;background:#fff;color:#172b4d;text-align:left;cursor:pointer;transition:.2s}.metric-card:hover,.metric-card.active{border-color:#2563eb;box-shadow:0 8px 22px rgba(37,99,235,.12);transform:translateY(-1px)}.metric-card.danger{border-top:3px solid #dc2626}.metric-card.success{border-top:3px solid #059669}.metric-card.primary{border-top:3px solid #2563eb}.metric-label{display:block;color:#718096;font-size:14px}.metric-card strong{display:block;margin:9px 0 7px;font-size:35px;line-height:1}.metric-card small{color:#94a3b8;font-size:12px}.table-card{border-radius:8px}.card-title{display:flex;justify-content:space-between;align-items:center;gap:16px;font-weight:650}.card-title>div:first-child{display:flex;align-items:baseline;gap:10px}.card-title em{color:#94a3b8;font-size:12px;font-style:normal;font-weight:400}.filters{display:flex;align-items:center;gap:8px}.filters .el-select{width:150px}.search-input{width:230px}.pagination{justify-content:flex-end;margin-top:16px}@media(max-width:980px){.card-title{align-items:flex-start;flex-direction:column}.filters{width:100%;flex-wrap:wrap}}@media(max-width:760px){.page-head{align-items:flex-start;flex-direction:column}.metric-grid{grid-template-columns:1fr}.filters .el-select,.search-input{width:100%}}
+</style>

+ 70 - 0
src/views/subSystem/lifeCompany/lOverviewWarning/WarningDetailDrawer.vue

@@ -0,0 +1,70 @@
+<template>
+  <el-drawer v-model="visible" title="预警详情" size="720px" append-to-body>
+    <template v-if="row">
+      <div class="detail-hero">
+        <div class="detail-kicker">WARNING EVENT</div>
+        <div class="detail-title">{{ detail.warningName }}</div>
+        <div class="detail-meta">
+          <el-tag :type="levelTag">{{ detail.warningLevel }}</el-tag>
+          <el-tag :type="statusTag" effect="plain">{{ detail.status }}</el-tag>
+          <span>{{ detail.warningNo || detail.warningId || '暂无编号' }}</span>
+        </div>
+      </div>
+
+      <el-descriptions :column="2" border class="detail-descriptions">
+        <el-descriptions-item label="预警类型">{{ detail.warningType }}</el-descriptions-item>
+        <el-descriptions-item label="预警专项">{{ detail.warningSpecial }}</el-descriptions-item>
+        <el-descriptions-item label="预警位置" :span="2">{{ detail.location }}</el-descriptions-item>
+        <el-descriptions-item label="权属单位">{{ detail.ownershipUnit }}</el-descriptions-item>
+        <el-descriptions-item label="发布人">{{ detail.publisher }}</el-descriptions-item>
+        <el-descriptions-item label="发布时间">{{ detail.publishTime }}</el-descriptions-item>
+        <el-descriptions-item label="创建时间">{{ detail.createTime }}</el-descriptions-item>
+      </el-descriptions>
+
+      <section class="content-block">
+        <div class="block-title">预警描述</div>
+        <p>{{ detail.warningContent }}</p>
+      </section>
+
+      <section v-if="detail.todoInfo" class="content-block todo-block">
+        <div class="block-title">待办信息</div>
+        <el-descriptions :column="2" size="small">
+          <el-descriptions-item label="处置人">{{ detail.todoInfo.userName || detail.todoInfo.user_name || '未分配' }}</el-descriptions-item>
+          <el-descriptions-item label="任务类型">{{ detail.todoInfo.taskName || detail.todoInfo.task_name || detail.todoInfo.todoType || '预警处置' }}</el-descriptions-item>
+          <el-descriptions-item label="待办时间">{{ formatDashboardTime(detail.todoInfo.createTime || detail.todoInfo.create_time, '未设置') }}</el-descriptions-item>
+        </el-descriptions>
+      </section>
+    </template>
+    <el-empty v-else description="暂无预警详情" />
+  </el-drawer>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { detailView, formatDashboardTime, warningLevelTag, warningStatusTag } from './dashboardShared'
+
+const props = defineProps({
+  modelValue: { type: Boolean, default: false },
+  row: { type: Object, default: null }
+})
+const emit = defineEmits(['update:modelValue'])
+const visible = computed({
+  get: () => props.modelValue,
+  set: value => emit('update:modelValue', value)
+})
+const detail = computed(() => detailView(props.row || {}))
+const levelTag = computed(() => warningLevelTag(props.row || {}))
+const statusTag = computed(() => warningStatusTag(props.row || {}))
+</script>
+
+<style scoped lang="scss">
+.detail-hero { padding: 4px 0 20px; border-bottom: 1px solid #e8eef5; margin-bottom: 20px; }
+.detail-kicker { color: #2563eb; font-size: 11px; letter-spacing: 2px; font-weight: 700; }
+.detail-title { margin: 8px 0 12px; font-size: 23px; font-weight: 700; color: #172b4d; line-height: 1.35; }
+.detail-meta { display: flex; align-items: center; gap: 8px; color: #7b8ba5; font-size: 12px; }
+.detail-descriptions { margin-bottom: 22px; }
+.content-block { padding: 16px; margin-top: 16px; border: 1px solid #e8eef5; border-radius: 8px; background: #f8fafc; }
+.block-title { margin-bottom: 8px; font-weight: 650; color: #334155; }
+.content-block p { margin: 0; color: #52637a; line-height: 1.7; white-space: pre-wrap; }
+.todo-block { background: #fff8ed; border-color: #fde4b3; }
+</style>

+ 130 - 0
src/views/subSystem/lifeCompany/lOverviewWarning/dashboardShared.js

@@ -0,0 +1,130 @@
+import { warningTypeLabel } from '../lstatistics/statisticsShared'
+
+export const warningLevelLabels = {
+  // 预警级别编码遵循国家常用颜色规范:1 蓝、2 黄、3 橙、4 红。
+  '1': '蓝色(IV级)',
+  '2': '黄色(III级)',
+  '3': '橙色(II级)',
+  '4': '红色(I级)',
+  I: '红色(I级)',
+  II: '橙色(II级)',
+  III: '黄色(III级)',
+  IV: '蓝色(IV级)',
+  RED: '红色',
+  ORANGE: '橙色',
+  YELLOW: '黄色',
+  BLUE: '蓝色'
+}
+
+export const warningStatusLabels = {
+  DRAFT: '草稿',
+  PENDING: '待处置',
+  PROCESSING: '处置中',
+  RELEASED: '已发布',
+  HANDLED: '已处置',
+  CLOSED: '已解除',
+  RESOLVED: '已解除'
+}
+
+export function dashboardData(response, fallback = {}) {
+  return response?.data ?? fallback
+}
+
+export function pageRecords(response) {
+  const data = dashboardData(response, {})
+  if (Array.isArray(data)) return data
+  return data.records || data.list || []
+}
+
+export function pageTotal(response) {
+  const data = dashboardData(response, {})
+  return Number(data.total ?? data.totalCount ?? 0)
+}
+
+export function listValue(row, ...keys) {
+  for (const key of keys) {
+    if (row?.[key] !== undefined && row?.[key] !== null && row?.[key] !== '') return row[key]
+  }
+  return ''
+}
+
+export function warningTypeText(row) {
+  return warningTypeLabel(listValue(row, 'warningTypeName', 'warning_type_name', 'warningType', 'warning_type'))
+}
+
+export function isSupportedWarningType(row) {
+  return ['供水管网', '排水管网', '燃气管网', '窨井'].includes(warningTypeText(row))
+}
+
+export function warningLevelText(row) {
+  const value = listValue(row, 'warningLevel', 'warning_level')
+  return warningLevelLabels[String(value)] || value || '未设置'
+}
+
+export function warningLevelCode(row) {
+  const value = String(listValue(row, 'warningLevel', 'warning_level')).toUpperCase()
+  if (['4', 'I', 'RED'].includes(value)) return '4'
+  if (['3', 'II', 'ORANGE'].includes(value)) return '3'
+  if (['2', 'III', 'YELLOW'].includes(value)) return '2'
+  if (['1', 'IV', 'BLUE'].includes(value)) return '1'
+  return ''
+}
+
+export function warningStatusText(row) {
+  const value = listValue(row, 'status', 'warningStatus', 'warning_status')
+  const normalizedValue = String(value || '').toUpperCase()
+  return warningStatusLabels[normalizedValue] || value || '未设置'
+}
+
+export function warningLevelTag(row) {
+  const value = warningLevelCode(row)
+  if (value === '4') return 'danger'
+  if (value === '3' || value === '2') return 'warning'
+  if (value === '1') return 'primary'
+  return 'info'
+}
+
+export function warningStatusTag(row) {
+  const value = String(listValue(row, 'status', 'warningStatus', 'warning_status')).toUpperCase()
+  if (['CLOSED', 'RESOLVED', 'HANDLED'].includes(value)) return 'success'
+  if (['PENDING', 'PROCESSING'].includes(value)) return 'warning'
+  if (value === 'DRAFT') return 'info'
+  return 'primary'
+}
+
+export function formatDashboardTime(value, fallback = '-') {
+  if (!value) return fallback
+  const date = value instanceof Date ? value : new Date(value)
+  if (Number.isNaN(date.getTime())) return String(value)
+  const pad = number => String(number).padStart(2, '0')
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
+}
+
+export function detailView(row = {}) {
+  return {
+    warningId: listValue(row, 'warningId', 'warning_id'),
+    warningNo: listValue(row, 'warningNo', 'warning_no'),
+    warningName: listValue(row, 'warningName', 'warning_name') || '未命名预警',
+    warningType: warningTypeText(row),
+    warningLevel: warningLevelText(row),
+    warningSpecial: listValue(row, 'warningSpecial', 'warning_special') || '未设置',
+    location: listValue(row, 'location') || '未设置',
+    ownershipUnit: listValue(row, 'ownershipUnit', 'ownership_unit') || '未设置',
+    publisher: listValue(row, 'publisher') || '未设置',
+    publishTime: formatDashboardTime(listValue(row, 'publishTime', 'publish_time'), '未设置'),
+    createTime: formatDashboardTime(listValue(row, 'createTime', 'create_time'), '未设置'),
+    status: warningStatusText(row),
+    warningContent: listValue(row, 'warningContent', 'warning_content') || '暂无预警描述',
+    todoInfo: row.todoInfo || row.todo_info || null
+  }
+}
+
+export function filterRows(rows, keyword) {
+  const value = String(keyword || '').trim().toLowerCase()
+  if (!value) return rows || []
+  return (rows || []).filter(row => {
+    const detail = detailView(row)
+    return [detail.warningName, detail.warningSpecial, detail.location, detail.ownershipUnit, detail.warningType]
+      .some(item => String(item).toLowerCase().includes(value))
+  })
+}

+ 668 - 609
src/views/subSystem/lifeCompany/lhome/lHome.vue

@@ -1,726 +1,785 @@
 <template>
-  <div class="prototype-container">
-    <!-- 头部区域 -->
-    <div class="app-header">
-      <div class="title-section">
-        <h1>
-          <i class="fas fa-file-alt"></i> 应急预案在线审批机制
-        </h1>
-        <p>领导 · 专家评审修正 | 科学高效 · 快速响应</p>
+  <div class="lifeline-home" v-loading="loading">
+    <header class="home-header">
+      <div class="header-copy">
+        <span class="header-kicker">城市生命线运行态势</span>
+        <h1>预警联动管理首页</h1>
+        <p>汇集供水、排水、燃气与窨井预警,集中掌握风险和处置进度。</p>
       </div>
-      <div class="badge-area">
-        <i class="fas fa-check-circle"></i> 先进性·科学性·高效性
+      <div class="header-tools">
+        <div class="date-block">
+          <strong>{{ currentDateLabel }}</strong>
+          <span>{{ currentTimeLabel }} · 数据更新 {{ lastUpdated || '未更新' }}</span>
+        </div>
+        <el-tooltip content="刷新首页数据" placement="bottom">
+          <el-button circle type="primary" :loading="loading" aria-label="刷新首页数据" @click="loadData">
+            <el-icon v-if="!loading"><Refresh /></el-icon>
+          </el-button>
+        </el-tooltip>
       </div>
-    </div>
-
-    <div class="dashboard-grid">
-      <!-- 左侧面板:审批流程设置 + 预案调整管理 -->
-      <div class="left-panel">
-        <!-- 1. 审批流程设置模块 -->
-        <div class="section-card">
-          <div class="section-header">
-            <h2><i class="fas fa-diagram-project"></i> 审批流程设置</h2>
-            <span class="tag">动态流转</span>
+    </header>
+
+    <el-alert
+      v-if="errorMessage"
+      :title="errorMessage"
+      :type="allRequestsFailed ? 'error' : 'warning'"
+      show-icon
+      closable
+      class="data-alert"
+      @close="errorMessage = ''"
+    >
+      <template #default>
+        <el-button link type="primary" @click="loadData">重新加载</el-button>
+      </template>
+    </el-alert>
+
+    <section class="metric-grid" aria-label="今日预警核心指标">
+      <button
+        v-for="metric in metricCards"
+        :key="metric.key"
+        type="button"
+        class="metric-card"
+        :style="{ '--tone': metric.color, '--tone-soft': metric.softColor }"
+        @click="navigate(metric.routeName)"
+      >
+        <span class="metric-icon"><el-icon><component :is="metric.icon" /></el-icon></span>
+        <span class="metric-content">
+          <span class="metric-label">{{ metric.label }}</span>
+          <strong>{{ formatCount(metric.value) }}</strong>
+          <small>{{ metric.note }}</small>
+        </span>
+        <el-icon class="metric-arrow"><ArrowRight /></el-icon>
+      </button>
+    </section>
+
+    <div class="analysis-grid">
+      <section class="panel trend-panel">
+        <div class="panel-header">
+          <div>
+            <h2>近 7 日预警趋势</h2>
+            <p>四类设施非草稿预警的每日变化</p>
           </div>
-          <div class="section-content">
-            <div class="flow-steps">
-              <div
-                  v-for="(step, idx) in flowSteps"
-                  :key="idx"
-                  class="flow-node"
-                  :class="{ 'active': step.status === 'active', 'pending': step.status === 'pending' }"
-              >
-                <div class="node-icon"><i :class="step.icon"></i></div>
-                <div class="node-info">
-                  <div class="node-title">{{ step.title }}</div>
-                  <div class="node-desc">{{ step.desc }}</div>
-                </div>
-                <div class="node-badge">
-                  <i :class="step.status === 'active' ? 'fas fa-check-circle' : 'fas fa-clock'"></i>
-                  {{ step.statusText }}
-                </div>
-              </div>
-            </div>
-            <div class="add-flow-btn">
-              <i class="fas fa-plus-circle"></i> 自定义审批节点 / 会签设置
-            </div>
-            <div class="helper-text">
-              <i class="fas fa-info-circle"></i> 支持串签、并签、转审,全审批流转记录留痕
-            </div>
+          <el-button link type="primary" @click="navigate('WarningTrend')">
+            趋势分析<el-icon><ArrowRight /></el-icon>
+          </el-button>
+        </div>
+        <div ref="trendChartRef" class="chart-box" />
+        <el-empty v-if="!trendRows.length && !loading" description="近 7 日暂无预警数据" :image-size="74" />
+      </section>
+
+      <section class="panel status-panel">
+        <div class="panel-header">
+          <div>
+            <h2>近 30 日处置状态</h2>
+            <p>各处置阶段预警数量分布</p>
           </div>
+          <el-button link type="primary" @click="navigate('WarningDisposeStatus')">
+            查看分析<el-icon><ArrowRight /></el-icon>
+          </el-button>
         </div>
+        <div ref="statusChartRef" class="chart-box" />
+        <el-empty v-if="!statusRows.length && !loading" description="当前周期暂无状态数据" :image-size="74" />
+      </section>
+    </div>
 
-        <!-- 3. 预案调整管理 -->
-        <div class="section-card">
-          <div class="section-header">
-            <h2><i class="fas fa-sliders-h"></i> 预案调整管理</h2>
-            <i class="fas fa-sync-alt" style="color:#6c86a3;"></i>
-          </div>
-          <div class="section-content">
-            <div
-                v-for="adjust in adjustments"
-                :key="adjust.id"
-                class="adjust-item"
-            >
-              <div class="adjust-title">
-                <span><i class="fas fa-pen-nib"></i> {{ adjust.name }}</span>
-                <span class="status-badge" :class="adjust.statusClass">{{ adjust.statusText }}</span>
-              </div>
-              <div class="adjust-detail">
-                {{ adjust.detail }}
-              </div>
-            </div>
-            <div class="add-flow-btn" style="background:#f1f5f9; margin-top:10px;">
-              <i class="fas fa-edit"></i> 基于审批结果快速调整预案版本
-            </div>
-          </div>
+    <section class="panel facility-panel">
+      <div class="panel-header">
+        <div>
+          <h2>设施预警分布</h2>
+          <p>历史非草稿预警累计 {{ formatCount(facilityTotal) }} 条</p>
         </div>
+        <el-button link type="primary" @click="navigate('WarningHistoryOverview')">
+          历史预警概况<el-icon><ArrowRight /></el-icon>
+        </el-button>
       </div>
-
-      <!-- 右侧面板:预案在线审批 + 预案发布 -->
-      <div class="right-panel">
-        <!-- 2. 预案在线审批模块 -->
-        <div class="section-card">
-          <div class="section-header">
-            <h2><i class="fas fa-check-double"></i> 预案在线审批</h2>
-            <span class="tag">待办{{ pendingCount }}项</span>
+      <div class="facility-grid">
+        <article
+          v-for="facility in facilityStats"
+          :key="facility.value"
+          class="facility-card"
+          :style="{ '--facility-color': facility.color, '--facility-soft': facility.softColor }"
+        >
+          <div class="facility-title">
+            <span class="facility-mark">{{ facility.shortLabel }}</span>
+            <div><strong>{{ facility.label }}</strong><small>历史预警</small></div>
           </div>
-          <div class="section-content">
-            <table class="data-table">
-              <thead>
-              <tr>
-                <th>预案名称</th>
-                <th>审批人员</th>
-                <th>审批记录/意见</th>
-                <th>状态</th>
-                <th>操作</th>
-              </tr>
-              </thead>
-              <tbody>
-              <tr v-for="item in approvalList" :key="item.id">
-                <td>
-                  <strong>{{ item.name }}</strong><br>
-                  <span class="version-text">{{ item.version }}</span>
-                </td>
-                <td>{{ item.approvers }}</td>
-                <td class="comment-text">
-                  <i class="fas fa-comment-dots"></i> {{ item.comment }}
-                </td>
-                <td>
-                  <span class="status-badge" :class="item.statusClass">{{ item.statusText }}</span>
-                </td>
-                <td>
-                  <i class="fas fa-eye btn-icon"></i>
-                  <i class="fas fa-check-circle btn-icon" style="color:#2c7da0;"></i>
-                </td>
-              </tr>
-              </tbody>
-            </table>
-            <div class="helper-text" style="text-align: right; margin-top: 12px;">
-              <i class="fas fa-archive"></i> 完整审批日志可追溯 &nbsp;|&nbsp; 所有意见留痕
-            </div>
+          <div class="facility-value">
+            <strong>{{ formatCount(facility.count) }}</strong>
+            <span>{{ formatPercent(facility.percentage) }}</span>
           </div>
-        </div>
-
-        <!-- 4. 预案发布模块 -->
-        <div class="section-card">
-          <div class="section-header">
-            <h2><i class="fas fa-rocket"></i> 预案发布</h2>
-            <i class="fas fa-globe-asia" style="color:#3083a2;"></i>
-          </div>
-          <div class="section-content">
-            <div class="publish-card">
-              <div class="publish-info">
-                <h4><i class="fas fa-check-circle" style="color:#15803d;"></i> 已发布预案 (应急处置可用)</h4>
-                <p style="font-size:0.8rem; margin-top: 8px;">
-                  {{ publishedPlans.map(p => p.name).join(' · ') || '暂无已发布预案' }}
-                </p>
-              </div>
-              <div class="publish-btn disabled">仅展示已发布</div>
-            </div>
-
-            <div style="margin-top: 20px;">
-              <div class="pending-publish-card">
-                <div>
-                  <i class="fas fa-spinner fa-pulse" style="color:#e6b422;"></i>
-                  <strong>待发布预案(审批完毕)</strong>
-                  <br>
-                  <span class="pending-list">{{ pendingPublish.map(p => p.name).join(' | ') || '无' }}</span>
-                </div>
-                <span class="publish-action" @click="publishPlan">
-                  一键发布 <i class="fas fa-arrow-right"></i>
-                </span>
-              </div>
-            </div>
-
-            <div class="helper-text" style="margin-top: 20px; background:#eef6fb; padding: 12px; border-radius: 18px;">
-              <i class="fas fa-bolt"></i> <strong>快速响应保障:</strong> 已发布的预案将在应急指挥中心实时同步,支持一键启动应急预案、调集资源。
-            </div>
+          <div class="facility-progress"><i :style="{ width: `${facility.percentage}%` }" /></div>
+        </article>
+      </div>
+    </section>
+
+    <div class="operations-grid">
+      <section class="panel warning-panel">
+        <div class="panel-header">
+          <div>
+            <h2>重点未处置预警</h2>
+            <p>按预警级别和等待时长优先展示,共 {{ formatCount(unprocessedTotal) }} 条</p>
           </div>
+          <el-button link type="primary" @click="navigate('WarningTodayUnprocessed')">
+            查看全部<el-icon><ArrowRight /></el-icon>
+          </el-button>
         </div>
-
-        <!-- 评审过程 & 结果记录 -->
-        <div class="section-card" style="margin-bottom:0;">
-          <div class="section-header">
-            <h2><i class="fas fa-clipboard-list"></i> 评审过程 & 结果记录</h2>
-          </div>
-          <div class="section-content">
-            <div style="display: flex; gap: 12px; flex-wrap: wrap;">
-              <div class="record-card" v-for="record in reviewRecords" :key="record.id">
-                <i class="fas fa-calendar-alt"></i> <strong>{{ record.title }}</strong>
-                <div class="record-desc">{{ record.content }}</div>
+        <el-table
+          :data="unprocessedRows"
+          row-key="warningId"
+          class="warning-table"
+          empty-text="当前暂无未处置预警"
+          @row-click="openDetail"
+        >
+          <el-table-column label="级别" width="122">
+            <template #default="{ row }">
+              <el-tag :type="warningLevelTag(row)" effect="light" size="small">
+                {{ warningLevelText(row) }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="预警事项" min-width="190" show-overflow-tooltip>
+            <template #default="{ row }">
+              <div class="warning-name">
+                <i :class="`level-${warningLevelCode(row)}`" />
+                <span>{{ listValue(row, 'warningName', 'warning_name') || '未命名预警' }}</span>
               </div>
-            </div>
-            <div class="helper-text" style="margin-top: 12px;">
-              <i class="fas fa-database"></i> 所有评审节点自动保存至历史版本库,支持追溯、对比、复盘。
-            </div>
+            </template>
+          </el-table-column>
+          <el-table-column label="设施类型" width="110">
+            <template #default="{ row }">{{ warningTypeText(row) }}</template>
+          </el-table-column>
+          <el-table-column label="权属单位" min-width="150" show-overflow-tooltip>
+            <template #default="{ row }">{{ listValue(row, 'ownershipUnit', 'ownership_unit') || '未设置' }}</template>
+          </el-table-column>
+          <el-table-column label="等待时长" width="105">
+            <template #default="{ row }"><span class="waiting-time">{{ waitingText(row) }}</span></template>
+          </el-table-column>
+          <el-table-column label="状态" width="94" align="center">
+            <template #default="{ row }">
+              <el-tag :type="warningStatusTag(row)" effect="plain" size="small">
+                {{ warningStatusText(row) }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="84" fixed="right" align="center">
+            <template #default="{ row }">
+              <el-button link type="primary" @click.stop="openDetail(row)">详情</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </section>
+
+      <aside class="panel quick-panel">
+        <div class="panel-header">
+          <div>
+            <h2>常用功能</h2>
+            <p>快速进入预警查询与处置工作</p>
           </div>
         </div>
-      </div>
+        <div class="quick-list">
+          <button
+            v-for="action in quickActions"
+            :key="action.routeName"
+            type="button"
+            class="quick-action"
+            @click="navigate(action.routeName)"
+          >
+            <span class="quick-icon" :style="{ color: action.color, background: action.softColor }">
+              <el-icon><component :is="action.icon" /></el-icon>
+            </span>
+            <span><strong>{{ action.label }}</strong><small>{{ action.note }}</small></span>
+            <el-icon class="quick-arrow"><ArrowRight /></el-icon>
+          </button>
+        </div>
+      </aside>
     </div>
 
-    <!-- 底部信息 -->
-    <div class="footer-bar">
-      <div style="display: flex; gap: 24px;">
-        <span><i class="fas fa-chart-line" style="color:#1e6f5c;"></i> 先进性 · 动态评审机制</span>
-        <span><i class="fas fa-flask"></i> 科学性 · 专家逐条修正</span>
-        <span><i class="fas fa-tachometer-alt"></i> 高效性 · 快速响应闭环</span>
-      </div>
-      <div style="font-size:0.7rem; color:#66809a;">
-        <i class="far fa-clock"></i> 审批流转引擎就绪 · 提升应急处置敏捷度
-      </div>
-    </div>
+    <WarningDetailDrawer v-model="detailVisible" :row="selectedRow" />
   </div>
 </template>
 
 <script setup>
-import { reactive, computed } from 'vue'
-
-// 审批流程步骤配置
-const flowSteps = reactive([
-  {
-    title: '环节 1:预案起草人提交',
-    desc: '审批人: 安全总监 · 权限: 初审/退回',
-    icon: 'fas fa-user-tie',
-    status: 'active',
-    statusText: '已激活'
-  },
-  {
-    title: '环节 2:专家技术评审',
-    desc: '审批人: 专家组(3人) · 权限: 修正建议/通过',
-    icon: 'fas fa-chalkboard-user',
-    status: 'active',
-    statusText: '进行中'
-  },
-  {
-    title: '环节 3:领导层终审',
-    desc: '审批人: 应急总指挥 · 权限: 批准/驳回',
-    icon: 'fas fa-landmark',
-    status: 'pending',
-    statusText: '待触发'
-  },
-  {
-    title: '环节 4:签发与归档',
-    desc: '办公室自动化发布 · 记录存档',
-    icon: 'fas fa-cloud-upload-alt',
-    status: 'pending',
-    statusText: '流程终点'
-  }
-])
+import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import {
+  ArrowRight,
+  Bell,
+  CircleCheck,
+  Clock,
+  DataAnalysis,
+  Refresh,
+  Setting,
+  TrendCharts,
+  Warning
+} from '@element-plus/icons-vue'
+import * as echarts from 'echarts'
+import {
+  getWarningTodayOverview,
+  pageWarningHistory,
+  pageWarningTodayUnprocessed
+} from '@/api/warning/dashboard'
+import { getDisposeStatusStat, getWarningTrendByDay } from '@/api/warning/statistics'
+import WarningDetailDrawer from '../lOverviewWarning/WarningDetailDrawer.vue'
+import {
+  dashboardData,
+  listValue,
+  pageRecords,
+  pageTotal,
+  warningLevelCode,
+  warningLevelTag,
+  warningLevelText,
+  warningStatusTag,
+  warningStatusText,
+  warningTypeText
+} from '../lOverviewWarning/dashboardShared'
+import { labelOf, responseData, statusLabels } from '../lstatistics/statisticsShared'
+
+const router = useRouter()
+const loading = ref(false)
+const errorMessage = ref('')
+const allRequestsFailed = ref(false)
+const lastUpdated = ref('')
+const now = ref(new Date())
+const todayOverview = ref({})
+const unprocessedRows = ref([])
+const unprocessedTotal = ref(null)
+const trendRows = ref([])
+const statusRows = ref([])
+const detailVisible = ref(false)
+const selectedRow = ref(null)
+const trendChartRef = ref()
+const statusChartRef = ref()
+let trendChart
+let statusChart
+let clockTimer
+let resizeObserver
+
+const facilityDefinitions = [
+  { value: 'WATER_PIPE', label: '供水管网', shortLabel: '供', color: '#2563eb', softColor: '#eff6ff' },
+  { value: 'SEWER_PIPE', label: '排水管网', shortLabel: '排', color: '#0f766e', softColor: '#ecfdf5' },
+  { value: 'GAS_PIPE', label: '燃气管网', shortLabel: '燃', color: '#d97706', softColor: '#fffbeb' },
+  { value: 'MANHOLE', label: '窨井', shortLabel: '井', color: '#64748b', softColor: '#f1f5f9' }
+]
+
+const facilityCounts = ref(Object.fromEntries(facilityDefinitions.map(item => [item.value, null])))
+
+const facilityTotal = computed(() => {
+  const values = Object.values(facilityCounts.value).filter(value => Number.isFinite(value))
+  return values.length ? values.reduce((total, value) => total + value, 0) : null
+})
 
-// 预案调整数据
-const adjustments = reactive([
-  {
-    id: 1,
-    name: '洪涝灾害应急预案 V2.1',
-    statusText: '基于专家意见调整',
-    statusClass: 'secondary',
-    detail: '调整内容: 根据刘宏专家评审意见,优化预警阈值及撤离路线;增加物资调度流程。评审结果: “科学性提升,建议补充应急通讯保障” — 已修正。'
-  },
-  {
-    id: 2,
-    name: '危化品泄漏专项预案',
-    statusText: '修改中',
-    statusClass: 'warning',
-    detail: '根据领导层反馈: 需强化现场指挥架构,新增“紧急疏散图”附件,当前版本迭代至3.0。'
-  },
-  {
-    id: 3,
-    name: '地震应急响应预案',
-    statusText: '审批通过待调整细节',
-    statusClass: '',
-    detail: '最终评审批示: 修正响应分级条件,将震级启动标准细化,已按专家组合议完成修订。'
-  }
-])
+const facilityStats = computed(() => facilityDefinitions.map(item => {
+  const count = facilityCounts.value[item.value]
+  const percentage = Number.isFinite(count) && facilityTotal.value > 0
+    ? Number(((count / facilityTotal.value) * 100).toFixed(1))
+    : 0
+  return { ...item, count, percentage }
+}))
 
-// 审批列表数据
-const approvalList = reactive([
+const metricCards = computed(() => [
   {
-    id: 1,
-    name: '洪涝灾害应急预案',
-    version: 'v2.2',
-    approvers: '专家组: 王宏,李敏,张启\n领导: 陈卫国',
-    comment: '专家李敏: 响应等级描述建议更精确,已采纳。领导陈卫国: 同意专家组意见,补充响应时效。',
-    statusText: '终审中',
-    statusClass: ''
+    key: 'today', label: '今日预警', value: numericValue(todayOverview.value.todayTotal),
+    note: '今日非草稿预警', routeName: 'WarningTodayOverview', icon: Bell,
+    color: '#2563eb', softColor: '#eff6ff'
   },
   {
-    id: 2,
-    name: '危化品泄漏专项预案',
-    version: 'v3.1',
-    approvers: '安全总监: 赵岩\n外部专家: 刘景明',
-    comment: '审批记录: 安全总监初审通过, 专家提出“新增化学洗消流程”修正要求,预案调整中。',
-    statusText: '待修正',
-    statusClass: 'warning'
+    key: 'unresolved', label: '当前未解除', value: numericValue(todayOverview.value.unresolvedCount),
+    note: '截至当前仍在跟进', routeName: 'WarningTodayOverview', icon: Warning,
+    color: '#dc2626', softColor: '#fef2f2'
   },
   {
-    id: 3,
-    name: '公共卫生事件应急预案',
-    version: 'v1.5',
-    approvers: '应急办: 周敏, 疾控专家: 何秀英',
-    comment: '审批意见: 何秀英专家评审通过;领导复核要求更新隔离标准,修正后重新提交审批。',
-    statusText: '二次评审',
-    statusClass: 'secondary'
+    key: 'resolved', label: '今日已解除', value: numericValue(todayOverview.value.resolvedCount),
+    note: '今日完成风险解除', routeName: 'WarningTodayOverview', icon: CircleCheck,
+    color: '#059669', softColor: '#ecfdf5'
   },
   {
-    id: 4,
-    name: '大面积停电应急预案',
-    version: 'v2.0',
-    approvers: '专家组: 电力工程师+总指挥',
-    comment: '审批记录: 全票通过先进性评估,目前进入发布前最终确认环节。',
-    statusText: '审批通过',
-    statusClass: ''
+    key: 'unprocessed', label: '未处置预警', value: unprocessedTotal.value,
+    note: '需要重点督办', routeName: 'WarningTodayUnprocessed', icon: Clock,
+    color: '#d97706', softColor: '#fffbeb'
   }
 ])
 
-// 已发布的预案
-const publishedPlans = reactive([
-  { id: 1, name: '地震应急响应预案 (2025-01-发布)' }
-])
+const quickActions = [
+  { label: '今日预警概况', note: '查看今日警情明细', routeName: 'WarningTodayOverview', icon: Bell, color: '#2563eb', softColor: '#eff6ff' },
+  { label: '今日未处置预警', note: '跟进待处理警情', routeName: 'WarningTodayUnprocessed', icon: Warning, color: '#dc2626', softColor: '#fef2f2' },
+  { label: '历史预警概况', note: '查看设施预警分布', routeName: 'WarningHistoryOverview', icon: DataAnalysis, color: '#0f766e', softColor: '#ecfdf5' },
+  { label: '处置状态分析', note: '分析处置进度', routeName: 'WarningDisposeStatus', icon: TrendCharts, color: '#7c3aed', softColor: '#f5f3ff' },
+  { label: '预警事项管理', note: '维护预警清单配置', routeName: 'WarningItemManage', icon: Setting, color: '#475569', softColor: '#f1f5f9' }
+]
 
-// 待发布预案(审批完毕但尚未发布)
-const pendingPublish = reactive([
-  { id: 4, name: '大面积停电应急预案' },
-  { id: 3, name: '公共卫生事件应急预案' }
-])
+const currentDateLabel = computed(() => new Intl.DateTimeFormat('zh-CN', {
+  year: 'numeric', month: 'long', day: 'numeric', weekday: 'long'
+}).format(now.value))
 
-// 评审记录
-const reviewRecords = reactive([
-  {
-    id: 1,
-    title: '2025-04-10 专家评审会',
-    content: '评审意见:预案结构科学,响应衔接效率高,建议增加辅助决策地图。→ 记录已归档,预案已完成修正。'
-  },
-  {
-    id: 2,
-    title: '领导终审记录',
-    content: '最终结果:同意发布,强调实战演练与预案结合。结果存入档案,提升先进性指标。'
-  }
-])
-
-// 计算待办数量(状态为终审中/待修正/二次评审的数量)
-const pendingCount = computed(() => {
-  return approvalList.filter(item =>
-      item.statusText === '终审中' || item.statusText === '待修正' || item.statusText === '二次评审'
-  ).length
-})
-
-// 发布预案功能:将待发布预案移至已发布
-const publishPlan = () => {
-  if (pendingPublish.length === 0) {
-    alert('当前没有待发布的预案')
-    return
-  }
-  // 将待发布预案添加到已发布列表
-  pendingPublish.forEach(plan => {
-    if (!publishedPlans.some(p => p.name === plan.name)) {
-      publishedPlans.push({ id: plan.id, name: `${plan.name} (刚刚发布)` })
-    }
-  })
-  // 清空待发布列表
-  pendingPublish.splice(0, pendingPublish.length)
-
-  // 同时更新审批列表中的对应预案状态为“已发布”
-  approvalList.forEach(item => {
-    if (item.name === '大面积停电应急预案' || item.name === '公共卫生事件应急预案') {
-      item.statusText = '已发布'
-      item.statusClass = ''
-    }
-  })
+const currentTimeLabel = computed(() => new Intl.DateTimeFormat('zh-CN', {
+  hour: '2-digit', minute: '2-digit', hour12: false
+}).format(now.value))
 
-  // 可选:提示发布成功
-  alert('预案已成功发布,应急处置中可立即启用!')
+function numericValue(value) {
+  if (value === null || value === undefined || value === '') return null
+  const number = Number(value)
+  return Number.isFinite(number) ? number : null
 }
-</script>
 
-<style scoped>
-* {
-  margin: 0;
-  padding: 0;
-  box-sizing: border-box;
+function formatCount(value) {
+  return Number.isFinite(value) ? new Intl.NumberFormat('zh-CN').format(value) : '-'
 }
 
-.prototype-container {
-  max-width: 1440px;
-  margin: 0 auto;
-  background: #ffffff;
-  border-radius: 32px;
-  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
-  overflow: hidden;
-  font-family: 'Inter', 'Nunito', system-ui, -apple-system, sans-serif;
+function formatPercent(value) {
+  return facilityTotal.value > 0 ? `${Number(value || 0).toFixed(1)}%` : '-'
 }
 
-.app-header {
-  background: linear-gradient(135deg, #0f2b3d 0%, #1b4a6e 100%);
-  padding: 24px 32px;
-  color: white;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  flex-wrap: wrap;
-  gap: 16px;
+function pad(value) {
+  return String(value).padStart(2, '0')
 }
 
-.title-section h1 {
-  font-size: 1.8rem;
-  font-weight: 700;
-  display: flex;
-  align-items: center;
-  gap: 12px;
+function formatApiDate(date) {
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
 }
 
-.title-section h1 i {
-  font-size: 1.8rem;
-  color: #ffd966;
+function rangeForDays(days) {
+  const end = new Date()
+  const start = new Date(end)
+  start.setHours(0, 0, 0, 0)
+  start.setDate(start.getDate() - days + 1)
+  return { startTime: formatApiDate(start), endTime: formatApiDate(end) }
 }
 
-.title-section p {
-  font-size: 0.85rem;
-  opacity: 0.85;
-  margin-top: 6px;
+function recentDateKeys(days = 7) {
+  const result = []
+  const cursor = new Date()
+  cursor.setHours(0, 0, 0, 0)
+  cursor.setDate(cursor.getDate() - days + 1)
+  for (let index = 0; index < days; index += 1) {
+    result.push(`${cursor.getFullYear()}-${pad(cursor.getMonth() + 1)}-${pad(cursor.getDate())}`)
+    cursor.setDate(cursor.getDate() + 1)
+  }
+  return result
 }
 
-.badge-area {
-  background: rgba(255, 255, 240, 0.15);
-  backdrop-filter: blur(4px);
-  padding: 8px 20px;
-  border-radius: 40px;
-  font-size: 0.9rem;
-  font-weight: 500;
+function warningCount(row) {
+  return Number(row.warning_count ?? row.warningCount ?? row.status_count ?? row.statusCount ?? 0)
 }
 
-.dashboard-grid {
-  display: grid;
-  grid-template-columns: 1fr 1.4fr;
-  gap: 0;
-  border-top: 1px solid #e9edf2;
+function renderTrendChart() {
+  if (!trendChartRef.value) return
+  trendChart ||= echarts.init(trendChartRef.value)
+  if (!trendRows.value.length) {
+    trendChart.clear()
+    return
+  }
+  const dates = recentDateKeys()
+  const totals = Object.fromEntries(dates.map(date => [date, 0]))
+  trendRows.value.forEach(row => {
+    const date = String(row.stat_date ?? row.statDate ?? '').slice(0, 10)
+    if (date in totals) totals[date] += warningCount(row)
+  })
+  trendChart.setOption({
+    animationDuration: 450,
+    color: ['#2563eb'],
+    tooltip: { trigger: 'axis', valueFormatter: value => `${value} 条` },
+    grid: { left: 48, right: 22, top: 24, bottom: 38 },
+    xAxis: {
+      type: 'category', boundaryGap: false, data: dates.map(date => date.slice(5)),
+      axisLine: { lineStyle: { color: '#d9e1ea' } }, axisLabel: { color: '#64748b' }
+    },
+    yAxis: {
+      type: 'value', minInterval: 1, axisLabel: { color: '#64748b' },
+      splitLine: { lineStyle: { color: '#edf1f5' } }
+    },
+    series: [{
+      name: '预警数量', type: 'line', smooth: true, symbol: 'circle', symbolSize: 7,
+      lineStyle: { width: 3 }, itemStyle: { borderColor: '#ffffff', borderWidth: 2 },
+      areaStyle: { color: 'rgba(37, 99, 235, 0.10)' }, data: dates.map(date => totals[date])
+    }]
+  }, true)
+}
+
+function renderStatusChart() {
+  if (!statusChartRef.value) return
+  statusChart ||= echarts.init(statusChartRef.value)
+  const data = statusRows.value.map(row => ({
+    name: labelOf(statusLabels, row.status), value: warningCount(row), status: String(row.status || '').toUpperCase()
+  })).filter(item => item.value > 0)
+  if (!data.length) {
+    statusChart.clear()
+    return
+  }
+  const colorByStatus = {
+    PENDING: '#dc2626', PROCESSING: '#d97706', RELEASED: '#2563eb',
+    HANDLED: '#0f766e', CLOSED: '#059669', RESOLVED: '#059669'
+  }
+  statusChart.setOption({
+    tooltip: { trigger: 'item', formatter: '{b}<br/>{c} 条({d}%)' },
+    legend: { type: 'scroll', bottom: 0, left: 'center', textStyle: { color: '#64748b' } },
+    series: [{
+      type: 'pie', radius: ['46%', '68%'], center: ['50%', '43%'], avoidLabelOverlap: true,
+      itemStyle: { borderColor: '#ffffff', borderWidth: 3 }, label: { show: false },
+      emphasis: { label: { show: true, fontSize: 14, fontWeight: 600 } },
+      data: data.map(item => ({
+        name: item.name, value: item.value,
+        itemStyle: { color: colorByStatus[item.status] || '#64748b' }
+      }))
+    }]
+  }, true)
+}
+
+function waitingText(row) {
+  const hours = numericValue(listValue(row, 'waitingHours', 'waiting_hours'))
+  if (hours === null) return '-'
+  if (hours < 1) return '< 1 小时'
+  if (hours < 24) return `${hours.toFixed(hours >= 10 ? 0 : 1)} 小时`
+  return `${Math.floor(hours / 24)} 天 ${Math.round(hours % 24)} 小时`
+}
+
+function openDetail(row) {
+  selectedRow.value = row
+  detailVisible.value = true
+}
+
+function navigate(routeName) {
+  const routeCandidates = router.getRoutes().filter(route => route.name === routeName)
+  if (!routeCandidates.length) {
+    ElMessage.warning('当前账号暂无该功能菜单权限')
+    return
+  }
+  // Legacy databases can temporarily expose the same route name at root level
+  // and under the subsystem. Prefer the scoped route when both are available.
+  const preferredRoute = routeCandidates.sort((left, right) => {
+    const leftScoped = left.path.startsWith('/subSystem/') ? 1 : 0
+    const rightScoped = right.path.startsWith('/subSystem/') ? 1 : 0
+    return rightScoped - leftScoped || right.path.length - left.path.length
+  })[0]
+  router.push(preferredRoute.path).catch(() => {})
+}
+
+async function loadData() {
+  if (loading.value) return
+  loading.value = true
+  errorMessage.value = ''
+  const requests = [
+    { key: 'today', label: '今日概况', promise: getWarningTodayOverview() },
+    { key: 'unprocessed', label: '未处置清单', promise: pageWarningTodayUnprocessed({ pageNum: 1, pageSize: 6 }) },
+    { key: 'trend', label: '预警趋势', promise: getWarningTrendByDay(rangeForDays(7)) },
+    { key: 'status', label: '处置状态', promise: getDisposeStatusStat(rangeForDays(30)) },
+    ...facilityDefinitions.map(item => ({
+      key: item.value, label: item.label,
+      promise: pageWarningHistory({ pageNum: 1, pageSize: 1, warningType: item.value })
+    }))
+  ]
+
+  try {
+    const results = await Promise.allSettled(requests.map(item => item.promise))
+    const failedLabels = []
+    let successCount = 0
+    let generatedAt = ''
+
+    results.forEach((result, index) => {
+      const request = requests[index]
+      if (result.status === 'rejected') {
+        failedLabels.push(request.label)
+        return
+      }
+      successCount += 1
+      if (request.key === 'today') {
+        todayOverview.value = dashboardData(result.value, {})
+        generatedAt = todayOverview.value.generatedAt || generatedAt
+      } else if (request.key === 'unprocessed') {
+        const data = dashboardData(result.value, {})
+        unprocessedRows.value = pageRecords(result.value)
+        unprocessedTotal.value = pageTotal(result.value)
+        generatedAt = data.generatedAt || generatedAt
+      } else if (request.key === 'trend') {
+        trendRows.value = responseData(result.value, [])
+      } else if (request.key === 'status') {
+        statusRows.value = responseData(result.value, [])
+      } else {
+        facilityCounts.value = { ...facilityCounts.value, [request.key]: pageTotal(result.value) }
+      }
+    })
+
+    allRequestsFailed.value = successCount === 0
+    if (successCount > 0) lastUpdated.value = generatedAt || formatApiDate(new Date())
+    if (failedLabels.length) errorMessage.value = `${failedLabels.join('、')}加载失败,其他可用数据已正常展示。`
+    await nextTick()
+    renderTrendChart()
+    renderStatusChart()
+  } finally {
+    loading.value = false
+  }
 }
 
-.left-panel {
-  background: #fafcff;
-  border-right: 1px solid #eef2f6;
-  padding: 28px 24px;
+function resizeCharts() {
+  trendChart?.resize()
+  statusChart?.resize()
 }
 
-.right-panel {
-  background: #ffffff;
-  padding: 28px 24px;
-}
+onMounted(() => {
+  clockTimer = window.setInterval(() => { now.value = new Date() }, 30000)
+  resizeObserver = new ResizeObserver(resizeCharts)
+  if (trendChartRef.value) resizeObserver.observe(trendChartRef.value)
+  if (statusChartRef.value) resizeObserver.observe(statusChartRef.value)
+  loadData()
+})
 
-.section-card {
-  background: white;
-  border-radius: 24px;
-  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(0, 0, 0, 0.02);
-  margin-bottom: 32px;
-  overflow: hidden;
-}
+onUnmounted(() => {
+  window.clearInterval(clockTimer)
+  resizeObserver?.disconnect()
+  trendChart?.dispose()
+  statusChart?.dispose()
+})
+</script>
 
-.section-header {
-  padding: 18px 24px 12px 24px;
-  border-bottom: 2px solid #f1f5f9;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  flex-wrap: wrap;
+<style scoped lang="scss">
+.lifeline-home {
+  min-height: calc(100vh - 84px);
+  padding: 20px 22px 28px;
+  background: #f3f6fa;
+  color: #1f2937;
 }
 
-.section-header h2 {
-  font-size: 1.35rem;
-  font-weight: 600;
+.home-header {
   display: flex;
-  align-items: center;
-  gap: 10px;
-  color: #0f2b3d;
-}
-
-.tag {
-  font-size: 12px;
-  background: #eef2ff;
-  padding: 4px 12px;
-  border-radius: 30px;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 24px;
+  padding: 2px 2px 18px;
+  border-bottom: 1px solid #dfe6ee;
 }
 
-.section-content {
-  padding: 20px 24px 24px 24px;
-}
+.header-kicker { color: #2563eb; font-size: 12px; font-weight: 700; }
+.header-copy h1 { margin: 6px 0 5px; font-size: 26px; line-height: 1.25; }
+.header-copy p, .panel-header p { margin: 0; color: #718096; }
+.header-tools { display: flex; align-items: center; gap: 14px; }
 
-.flow-steps {
+.date-block {
   display: flex;
+  min-width: 245px;
   flex-direction: column;
-  gap: 16px;
+  align-items: flex-end;
+  gap: 5px;
 }
 
-.flow-node {
-  display: flex;
-  align-items: center;
-  gap: 16px;
-  background: #f8fafc;
-  padding: 14px 18px;
-  border-radius: 20px;
-  border-left: 5px solid #cbd5e1;
-  transition: all 0.2s;
-}
+.date-block strong { font-size: 14px; }
+.date-block span { color: #8492a6; font-size: 12px; }
+.data-alert { margin-top: 16px; }
 
-.flow-node.active {
-  border-left-color: #2c7da0;
-  background: #f1f9fe;
+.metric-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 14px;
+  margin: 18px 0 16px;
 }
 
-.node-icon {
-  width: 44px;
-  height: 44px;
-  background: #eef2ff;
-  border-radius: 30px;
+.metric-card {
+  position: relative;
   display: flex;
+  min-width: 0;
+  min-height: 122px;
   align-items: center;
-  justify-content: center;
-  font-size: 1.4rem;
-  color: #1e4a6b;
-}
-
-.node-info {
-  flex: 1;
-}
-
-.node-title {
-  font-weight: 700;
-  font-size: 1rem;
-}
-
-.node-desc {
-  font-size: 0.8rem;
-  color: #475569;
-  margin-top: 4px;
-}
-
-.node-badge {
-  background: #e2e8f0;
-  padding: 4px 12px;
-  border-radius: 50px;
-  font-size: 0.75rem;
-  font-weight: 600;
-  color: #1e293b;
-}
-
-.add-flow-btn {
-  margin-top: 12px;
-  background: white;
-  border: 1px dashed #94a3b8;
-  border-radius: 50px;
-  padding: 10px;
-  text-align: center;
-  color: #2c7da0;
-  font-weight: 500;
-  font-size: 0.85rem;
-  cursor: default;
-}
-
-.data-table {
-  width: 100%;
-  border-collapse: collapse;
-}
-
-.data-table th {
+  gap: 15px;
+  padding: 20px;
+  border: 1px solid #e1e7ef;
+  border-top: 3px solid var(--tone);
+  border-radius: 8px;
+  background: #ffffff;
+  color: inherit;
   text-align: left;
-  padding: 12px 8px 12px 0;
-  font-weight: 600;
-  font-size: 0.8rem;
-  color: #5b6e8c;
-  border-bottom: 1px solid #eef2f6;
-}
-
-.data-table td {
-  padding: 14px 8px 14px 0;
-  border-bottom: 1px solid #f1f5f9;
-  font-size: 0.85rem;
-  vertical-align: middle;
+  cursor: pointer;
+  transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
 }
 
-.version-text {
-  font-size: 11px;
-  color: #54708f;
+.metric-card:hover {
+  border-color: var(--tone);
+  box-shadow: 0 8px 20px rgba(31, 41, 55, 0.08);
+  transform: translateY(-1px);
 }
 
-.status-badge {
-  background: #dcfce7;
-  color: #15803d;
-  padding: 4px 10px;
-  border-radius: 30px;
-  font-size: 0.7rem;
-  font-weight: 600;
-  display: inline-block;
+.metric-icon, .quick-icon {
+  display: inline-flex;
+  width: 42px;
+  height: 42px;
+  flex: 0 0 42px;
+  align-items: center;
+  justify-content: center;
+  border-radius: 7px;
 }
 
-.status-badge.warning {
-  background: #fff3e3;
-  color: #b45309;
-}
+.metric-icon { background: var(--tone-soft); color: var(--tone); font-size: 21px; }
+.metric-content { display: flex; min-width: 0; flex: 1; flex-direction: column; }
+.metric-label { color: #64748b; font-size: 13px; }
+.metric-content strong { margin: 4px 0 2px; color: #172033; font-size: 34px; line-height: 1.05; }
 
-.status-badge.secondary {
-  background: #eef2ff;
-  color: #1e40af;
-}
-
-.btn-icon {
-  background: none;
-  border: none;
-  color: #5b6e8c;
-  cursor: default;
-  font-size: 1rem;
-  margin: 0 4px;
+.metric-content small {
+  overflow: hidden;
+  color: #94a3b8;
+  font-size: 12px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.comment-text {
-  font-size: 0.8rem;
-  max-width: 180px;
-  white-space: normal;
-  word-break: break-word;
-  line-height: 1.4;
-}
+.metric-arrow { color: #b6c0cd; }
 
-.adjust-item {
-  background: #fefce8;
-  border-left: 4px solid #facc15;
-  padding: 14px 16px;
-  border-radius: 16px;
-  margin-bottom: 14px;
-}
-
-.adjust-title {
-  font-weight: 700;
-  display: flex;
-  justify-content: space-between;
-  flex-wrap: wrap;
-  gap: 8px;
-  margin-bottom: 6px;
+.analysis-grid, .operations-grid {
+  display: grid;
+  grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.8fr);
+  gap: 16px;
+  margin-bottom: 16px;
 }
 
-.adjust-detail {
-  font-size: 0.8rem;
-  color: #3b3f46;
-  margin-top: 8px;
+.panel {
+  min-width: 0;
+  border: 1px solid #e1e7ef;
+  border-radius: 8px;
+  background: #ffffff;
+  box-shadow: 0 2px 8px rgba(31, 41, 55, 0.035);
 }
 
-.publish-card, .pending-publish-card {
-  background: linear-gradient(115deg, #eef6fc 0%, #ffffff 100%);
-  border-radius: 20px;
-  padding: 20px;
+.panel-header {
   display: flex;
-  justify-content: space-between;
+  min-height: 72px;
   align-items: center;
-  flex-wrap: wrap;
+  justify-content: space-between;
   gap: 16px;
+  padding: 17px 20px 14px;
+  border-bottom: 1px solid #edf1f5;
 }
 
-.publish-info h4 {
-  font-weight: 700;
-}
+.panel-header h2 { margin: 0 0 5px; font-size: 17px; line-height: 1.25; }
+.panel-header p { font-size: 12px; }
+.panel-header .el-button { flex: 0 0 auto; }
+.chart-box { width: 100%; height: 292px; }
+.trend-panel, .status-panel { position: relative; min-height: 365px; }
+.trend-panel .el-empty, .status-panel .el-empty { position: absolute; inset: 84px 0 8px; }
+.facility-panel { margin-bottom: 16px; }
 
-.publish-btn {
-  background: #0f2b3d;
-  border: none;
-  padding: 10px 24px;
-  border-radius: 40px;
-  color: white;
-  font-weight: 600;
-  font-size: 0.85rem;
-  cursor: default;
+.facility-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 14px;
+  padding: 16px 20px 20px;
 }
 
-.publish-btn.disabled {
-  background: #94a3b8;
-  cursor: not-allowed;
+.facility-card {
+  min-width: 0;
+  padding: 16px;
+  border: 1px solid #e7ebf1;
+  border-radius: 7px;
+  background: #ffffff;
 }
 
-.pending-publish-card {
-  background: #ffffff;
-  border: 1px solid #eef2f6;
+.facility-title, .facility-value {
+  display: flex;
+  align-items: center;
   justify-content: space-between;
+  gap: 10px;
 }
 
-.pending-list {
-  font-size: 12px;
-  color: #2c3e66;
-}
+.facility-title { justify-content: flex-start; }
+.facility-title > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
+.facility-title strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
+.facility-title small, .facility-value span { color: #94a3b8; font-size: 12px; }
 
-.publish-action {
-  background: #2c7da0;
-  padding: 6px 18px;
-  border-radius: 40px;
-  color: white;
-  font-size: 0.8rem;
-  font-weight: 600;
-  cursor: pointer;
-  transition: 0.2s;
+.facility-mark {
+  display: inline-flex;
+  width: 36px;
+  height: 36px;
+  flex: 0 0 36px;
+  align-items: center;
+  justify-content: center;
+  border-radius: 7px;
+  background: var(--facility-soft);
+  color: var(--facility-color);
+  font-size: 14px;
+  font-weight: 700;
 }
 
-.publish-action:hover {
-  background: #1f5e7e;
-  transform: scale(0.98);
-}
+.facility-value { margin: 14px 0 10px; align-items: flex-end; }
+.facility-value strong { font-size: 27px; line-height: 1; }
+.facility-progress { height: 5px; overflow: hidden; border-radius: 3px; background: #eef2f6; }
 
-.record-card {
-  flex: 1;
-  background: #f9fafb;
-  border-radius: 20px;
-  padding: 12px;
+.facility-progress i {
+  display: block;
+  height: 100%;
+  border-radius: inherit;
+  background: var(--facility-color);
+  transition: width 0.35s ease;
 }
 
-.record-desc {
-  font-size: 13px;
-  margin-top: 8px;
-  color: #334155;
-}
+.warning-table { width: calc(100% - 32px); margin: 0 16px 16px; cursor: pointer; }
+.warning-name { display: flex; min-width: 0; align-items: center; gap: 8px; }
 
-.helper-text {
-  font-size: 0.7rem;
-  color: #6c86a3;
-  margin-top: 12px;
-  text-align: center;
+.warning-name i {
+  width: 8px;
+  height: 8px;
+  flex: 0 0 8px;
+  border-radius: 50%;
+  background: #94a3b8;
 }
 
-.footer-bar {
-  background: #f9fef9;
-  border-top: 1px solid #e2ecf5;
-  padding: 16px 32px;
+.warning-name i.level-1 { background: #2563eb; }
+.warning-name i.level-2 { background: #eab308; }
+.warning-name i.level-3 { background: #f97316; }
+.warning-name i.level-4 { background: #dc2626; }
+.warning-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.waiting-time { color: #b45309; font-weight: 600; }
+.quick-panel { align-self: stretch; }
+.quick-list { display: flex; flex-direction: column; padding: 8px 14px 14px; }
+
+.quick-action {
   display: flex;
-  justify-content: space-between;
+  width: 100%;
+  min-height: 64px;
   align-items: center;
-  flex-wrap: wrap;
-  gap: 10px;
+  gap: 12px;
+  padding: 10px 8px;
+  border: 0;
+  border-bottom: 1px solid #edf1f5;
+  background: transparent;
+  color: inherit;
+  text-align: left;
+  cursor: pointer;
 }
 
+.quick-action:last-child { border-bottom: 0; }
+.quick-action:hover { background: #f8fafc; }
 
-@media (max-width: 980px) {
-  .dashboard-grid {
-    grid-template-columns: 1fr;
-  }
-  .left-panel {
-    border-right: none;
-    border-bottom: 1px solid #eef2f6;
-  }
-  body {
-    padding: 20px;
-  }
+.quick-action > span:nth-child(2) {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  flex-direction: column;
+  gap: 4px;
 }
-</style>
+
+.quick-action strong, .quick-action small {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.quick-action strong { font-size: 14px; }
+.quick-action small { color: #94a3b8; font-size: 12px; }
+.quick-icon { width: 38px; height: 38px; flex-basis: 38px; font-size: 18px; }
+.quick-arrow { flex: 0 0 auto; color: #b6c0cd; }
+
+@media (max-width: 1180px) {
+  .metric-grid, .facility-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .analysis-grid, .operations-grid { grid-template-columns: 1fr; }
+  .quick-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
+}
+
+@media (max-width: 680px) {
+  .lifeline-home { padding: 14px 12px 22px; }
+  .home-header { align-items: flex-start; flex-direction: column; }
+  .header-tools { width: 100%; justify-content: space-between; }
+  .date-block { min-width: 0; align-items: flex-start; }
+  .metric-grid, .facility-grid, .quick-list { grid-template-columns: 1fr; }
+  .metric-card { min-height: 110px; }
+  .panel-header { align-items: flex-start; }
+  .chart-box { height: 260px; }
+  .trend-panel, .status-panel { min-height: 335px; }
+  .facility-grid { padding: 14px; }
+}
+</style>

+ 25 - 0
src/views/subSystem/lifeCompany/lstatistics/disposalEfficiency/index.vue

@@ -0,0 +1,25 @@
+<template>
+  <div class="statistics-page"><section class="page-head"><div><span class="eyebrow">SERVICE QUALITY</span><h1>处置时效分析</h1><p>聚焦已完成处置的响应速度,识别低效专项。</p></div><el-button type="primary" :loading="loading" @click="loadData"><el-icon><Refresh /></el-icon>刷新统计</el-button></section>
+    <el-card class="filter-card" shadow="never"><el-date-picker v-model="dateRange" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" /><el-input v-model="warningSpecial" clearable placeholder="输入预警专项" class="special-input" /><el-select v-model="sortType" class="sort-select"><el-option label="时效从长到短" value="desc" /><el-option label="时效从短到长" value="asc" /></el-select><el-button type="primary" @click="loadData">查询</el-button></el-card>
+    <el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon closable @close="errorMessage = ''" />
+    <div class="metric-grid"><div v-for="metric in metrics" :key="metric.label" class="metric-card"><span>{{ metric.label }}</span><strong>{{ metric.value }}</strong><small>{{ metric.note }}</small></div></div>
+    <div class="chart-grid"><el-card shadow="never"><template #header><div class="card-title"><span>处置时效区间</span><em>小时</em></div></template><div ref="rangeRef" class="chart-box" /></el-card><el-card shadow="never"><template #header><div class="card-title"><span>专项平均处置时效</span><em>高时效优先督导</em></div></template><div ref="specialRef" class="chart-box" /></el-card></div>
+    <el-card shadow="never" class="table-card"><template #header><div class="card-title"><span>已处置预警明细</span><em>{{ total }} 条</em></div></template><el-table v-loading="loading" :data="rows" stripe><el-table-column prop="warningNo" label="预警编号" min-width="150"/><el-table-column prop="warningName" label="预警事项" min-width="180" show-overflow-tooltip/><el-table-column label="预警专项" min-width="140"><template #default="{row}">{{ row.warning_special || row.warningSpecial || '-' }}</template></el-table-column><el-table-column label="发布时间" min-width="160"><template #default="{row}">{{ row.publish_time || row.publishTime || '-' }}</template></el-table-column><el-table-column label="处置时长" width="120"><template #default="{row}"><b>{{ row.dispose_hours ?? row.disposeHours ?? '-' }}</b> 小时</template></el-table-column><el-table-column label="状态" width="100"><template #default="{row}"><el-tag type="success">{{ row.status === 'CLOSED' ? '已解除' : '已处置' }}</el-tag></template></el-table-column></el-table><pagination v-show="total > 0" v-model:page="query.pageNum" v-model:limit="query.pageSize" :total="total" @pagination="loadPage" /></el-card>
+  </div>
+</template>
+<script setup name="WarningDisposeEfficiency">
+import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Refresh } from '@element-plus/icons-vue'
+import * as echarts from 'echarts'
+import { getDisposeEfficiencyOverview, getDisposeEfficiencyRange, getDisposeEfficiencySpecial, pageDisposeEfficiency } from '@/api/warning/statistics'
+import { defaultRange, rangeParams, responseData, rowsOf, safeNumber, totalOf } from '../statisticsShared'
+const dateRange=ref(defaultRange()); const warningSpecial=ref(''); const sortType=ref('desc'); const loading=ref(false); const errorMessage=ref(''); const overview=ref({}); const rangeRows=ref([]); const specialRows=ref([]); const rows=ref([]); const total=ref(0); const query=ref({pageNum:1,pageSize:10}); const rangeRef=ref(); const specialRef=ref(); let rangeChart; let specialChart
+const value = (key, fallback='-') => overview.value[key] ?? overview.value[key.replace(/_([a-z])/g, (_,c)=>c.toUpperCase())] ?? fallback
+const metrics=computed(()=>[{label:'平均处置时长',value:safeNumber(value('avg_hours'),2),note:'小时'},{label:'最长处置时长',value:safeNumber(value('max_hours'),2),note:'小时'},{label:'最短处置时长',value:safeNumber(value('min_hours'),2),note:'小时'},{label:'完成处置数',value:value('total_count',0),note:'已处置/已解除'}])
+function renderCharts(){if(!rangeRef.value||!specialRef.value)return;rangeChart ||= echarts.init(rangeRef.value);specialChart ||= echarts.init(specialRef.value);rangeChart.setOption({tooltip:{trigger:'axis'},grid:{left:45,right:18,top:22,bottom:38},xAxis:{type:'category',data:rangeRows.value.map(r=>r.time_range||r.timeRange)},yAxis:{type:'value',minInterval:1},series:[{type:'bar',barWidth:34,data:rangeRows.value.map(r=>Number(r.warning_count??r.warningCount??0)),itemStyle:{color:'#2563eb',borderRadius:[6,6,0,0]}}]});specialChart.setOption({tooltip:{trigger:'axis'},grid:{left:55,right:18,top:22,bottom:54},xAxis:{type:'category',data:specialRows.value.map(r=>r.warning_special||r.warningSpecial||'未设置'),axisLabel:{rotate:25}},yAxis:{type:'value',name:'小时'},series:[{type:'bar',barWidth:28,data:specialRows.value.map(r=>Number(r.avg_dispose_hours??r.avgDisposeHours??0)),itemStyle:{color:'#0f766e',borderRadius:[6,6,0,0]}}]})}
+async function loadData(){loading.value=true;errorMessage.value='';try{const params={...rangeParams(dateRange.value),warningSpecial:warningSpecial.value||undefined};const [a,b,c]=await Promise.all([getDisposeEfficiencyOverview(params),getDisposeEfficiencyRange(params),getDisposeEfficiencySpecial(params)]);overview.value=responseData(a,{});rangeRows.value=responseData(b,[]);specialRows.value=responseData(c,[]);await loadPage();await nextTick();renderCharts()}catch(error){errorMessage.value=error?.message||'统计数据加载失败';ElMessage.error(errorMessage.value)}finally{loading.value=false}}
+async function loadPage(){const res=await pageDisposeEfficiency({...rangeParams(dateRange.value),warningSpecial:warningSpecial.value||undefined,sortType:sortType.value,...query.value});rows.value=rowsOf(res).map(row=>({...row,warningNo:row.warningNo??row.warning_no,warningName:row.warningName??row.warning_name}));total.value=totalOf(res)}
+function resize(){rangeChart?.resize();specialChart?.resize()};onMounted(()=>{loadData();window.addEventListener('resize',resize)});onUnmounted(()=>{window.removeEventListener('resize',resize);rangeChart?.dispose();specialChart?.dispose()})
+</script>
+<style scoped lang="scss">.statistics-page{padding:22px;background:#f4f7fb;min-height:calc(100vh - 84px);color:#172b4d}.page-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:18px}.eyebrow{font-size:11px;letter-spacing:2px;color:#0f766e}.page-head h1{margin:6px 0 4px;font-size:26px}.page-head p{margin:0;color:#718096}.filter-card{display:flex;gap:12px;align-items:center;margin-bottom:16px}.special-input{width:190px}.sort-select{width:150px}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}.metric-card{padding:18px 20px;background:#fff;border:1px solid #e6ebf2;border-radius:8px;display:flex;flex-direction:column;gap:8px}.metric-card span{font-size:13px;color:#718096}.metric-card strong{font-size:26px;color:#172b4d}.metric-card small{color:#9aa7b8}.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}.card-title{display:flex;justify-content:space-between;font-weight:600}.card-title em{font-style:normal;font-size:12px;color:#94a3b8;font-weight:400}.chart-box{height:290px}.table-card{margin-bottom:20px}@media(max-width:900px){.metric-grid,.chart-grid{grid-template-columns:1fr 1fr}.filter-card{flex-wrap:wrap}}@media(max-width:600px){.metric-grid,.chart-grid{grid-template-columns:1fr}.filter-card>*{width:100%!important}.page-head{align-items:flex-start;gap:12px;flex-direction:column}}</style>

+ 26 - 0
src/views/subSystem/lifeCompany/lstatistics/disposalStatus/index.vue

@@ -0,0 +1,26 @@
+<template>
+  <div class="statistics-page">
+    <section class="page-head"><div><span class="eyebrow">WARNING OPERATIONS</span><h1>处置状态分析</h1><p>按统计周期掌握预警状态与处置动作分布。</p></div><el-button type="primary" :loading="loading" @click="loadData"><el-icon><Refresh /></el-icon>刷新统计</el-button></section>
+    <el-card class="filter-card" shadow="never"><el-date-picker v-model="dateRange" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" /><el-input v-model="warningSpecial" clearable placeholder="输入预警专项" class="special-input" @keyup.enter="loadData" /><el-button type="primary" @click="loadData">查询</el-button><el-button @click="reset">重置</el-button></el-card>
+    <el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon closable @close="errorMessage = ''" />
+    <div class="metric-grid"><div v-for="metric in metrics" :key="metric.label" class="metric-card"><span>{{ metric.label }}</span><strong>{{ metric.value }}</strong><small>{{ metric.note }}</small></div></div>
+    <div class="chart-grid"><el-card shadow="never"><template #header><div class="card-title"><span>预警状态分布</span><em>排除草稿</em></div></template><div ref="statusChartRef" class="chart-box" /></el-card><el-card shadow="never"><template #header><div class="card-title"><span>处置动作分布</span><em>按动作类型</em></div></template><div ref="typeChartRef" class="chart-box" /></el-card></div>
+  </div>
+</template>
+<script setup name="WarningDisposeStatus">
+import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Refresh } from '@element-plus/icons-vue'
+import * as echarts from 'echarts'
+import { getDisposalTypeStat, getDisposeStatusStat } from '@/api/warning/statistics'
+import { defaultRange, disposalTypeLabels, labelOf, rangeParams, responseData, statusLabels } from '../statisticsShared'
+const dateRange = ref(defaultRange()); const warningSpecial = ref(''); const loading = ref(false); const errorMessage = ref(''); const statusRows = ref([]); const typeRows = ref([]); const statusChartRef = ref(); const typeChartRef = ref(); let statusChart; let typeChart
+const count = rows => rows.reduce((total, row) => total + Number(row.status_count ?? row.statusCount ?? row.disposal_count ?? row.disposalCount ?? 0), 0)
+const metrics = computed(() => [{ label: '统计预警数', value: count(statusRows.value), note: '当前筛选周期' }, { label: '状态类型', value: statusRows.value.length, note: '非草稿状态' }, { label: '处置动作数', value: count(typeRows.value), note: '已记录动作' }, { label: '主要状态', value: statusRows.value[0] ? labelOf(statusLabels, statusRows.value[0].status) : '-', note: '数量最多' }])
+function renderCharts() { if (!statusChartRef.value || !typeChartRef.value) return; statusChart ||= echarts.init(statusChartRef.value); typeChart ||= echarts.init(typeChartRef.value); statusChart.setOption({ color: ['#2563eb', '#0f766e', '#f59e0b', '#ef4444'], tooltip: { trigger: 'item' }, series: [{ type: 'pie', radius: ['45%', '72%'], label: { formatter: '{b}\n{c} 条' }, data: statusRows.value.map(row => ({ name: labelOf(statusLabels, row.status), value: Number(row.status_count ?? row.statusCount ?? 0) })) }] }); typeChart.setOption({ color: ['#2563eb', '#0f766e', '#f59e0b', '#8b5cf6'], tooltip: { trigger: 'axis' }, grid: { left: 44, right: 24, top: 24, bottom: 36 }, xAxis: { type: 'category', data: typeRows.value.map(row => labelOf(disposalTypeLabels, row.disposal_type ?? row.disposalType)), axisLabel: { interval: 0 } }, yAxis: { type: 'value', minInterval: 1 }, series: [{ type: 'bar', barWidth: 32, data: typeRows.value.map(row => Number(row.disposal_count ?? row.disposalCount ?? 0)), itemStyle: { borderRadius: [6, 6, 0, 0] } }] }) }
+async function loadData() { loading.value = true; errorMessage.value = ''; try { const params = { ...rangeParams(dateRange.value), warningSpecial: warningSpecial.value || undefined }; const [statusResponse, typeResponse] = await Promise.all([getDisposeStatusStat(params), getDisposalTypeStat(params)]); statusRows.value = responseData(statusResponse, []); typeRows.value = responseData(typeResponse, []); await nextTick(); renderCharts() } catch (error) { errorMessage.value = error?.message || '统计数据加载失败'; ElMessage.error(errorMessage.value) } finally { loading.value = false } }
+function reset() { dateRange.value = defaultRange(); warningSpecial.value = ''; loadData() }
+function resize() { statusChart?.resize(); typeChart?.resize() }
+onMounted(() => { loadData(); window.addEventListener('resize', resize) }); onUnmounted(() => { window.removeEventListener('resize', resize); statusChart?.dispose(); typeChart?.dispose() })
+</script>
+<style scoped lang="scss">.statistics-page{padding:22px;background:#f4f7fb;min-height:calc(100vh - 84px);color:#172b4d}.page-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:18px}.eyebrow{font-size:11px;letter-spacing:2px;color:#2563eb}.page-head h1{margin:6px 0 4px;font-size:26px}.page-head p{margin:0;color:#718096}.filter-card{display:flex;gap:12px;align-items:center;margin-bottom:16px}.special-input{width:220px}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}.metric-card{padding:18px 20px;background:#fff;border:1px solid #e6ebf2;border-radius:8px;display:flex;flex-direction:column;gap:8px}.metric-card span{font-size:13px;color:#718096}.metric-card strong{font-size:28px;color:#172b4d}.metric-card small{color:#9aa7b8}.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.card-title{display:flex;justify-content:space-between;font-weight:600}.card-title em{font-style:normal;font-size:12px;color:#94a3b8;font-weight:400}.chart-box{height:330px}@media(max-width:900px){.metric-grid,.chart-grid{grid-template-columns:1fr 1fr}.filter-card{flex-wrap:wrap}}@media(max-width:600px){.metric-grid,.chart-grid{grid-template-columns:1fr}.page-head{align-items:flex-start;gap:12px;flex-direction:column}.filter-card>*{width:100%!important}}</style>

+ 24 - 0
src/views/subSystem/lifeCompany/lstatistics/receiveEfficiency/index.vue

@@ -0,0 +1,24 @@
+<template>
+  <div class="statistics-page"><section class="page-head"><div><span class="eyebrow">RESPONSE PERFORMANCE</span><h1>接警效率</h1><p>按预警类型、处置状态和时间分析接警响应效率。</p></div><el-button type="primary" :loading="loading" @click="loadData"><el-icon><Refresh /></el-icon>刷新统计</el-button></section>
+    <el-card class="filter-card" shadow="never"><el-date-picker v-model="dateRange" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" /><el-select v-model="warningType" clearable placeholder="预警类型" class="filter-select"><el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="status" clearable placeholder="处置状态" class="filter-select"><el-option v-for="(label,value) in statusLabels" :key="value" :label="label" :value="value" /></el-select><el-input v-model="warningSpecial" clearable placeholder="输入预警专项" class="special-input"/><el-button type="primary" @click="loadData">查询</el-button></el-card>
+    <el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon closable @close="errorMessage = ''" />
+    <div class="metric-grid"><div v-for="metric in metrics" :key="metric.label" class="metric-card"><span>{{ metric.label }}</span><strong>{{ metric.value }}</strong><small>{{ metric.note }}</small></div></div>
+    <div class="chart-grid"><el-card shadow="never"><template #header><div class="card-title"><span>接警时效区间</span><em>从发布时间至首次接收</em></div></template><div ref="rangeRef" class="chart-box" /></el-card><el-card shadow="never"><template #header><div class="card-title"><span>按类型平均接警时效</span><em>小时</em></div></template><div ref="typeRef" class="chart-box" /></el-card></div>
+    <el-card shadow="never"><template #header><div class="card-title"><span>接警效率明细</span><em>{{ total }} 条</em></div></template><el-table v-loading="loading" :data="rows" stripe><el-table-column prop="warningNo" label="预警编号" min-width="150"/><el-table-column prop="warningName" label="预警事项" min-width="180" show-overflow-tooltip/><el-table-column label="类型" min-width="120"><template #default="{row}">{{ typeLabel(row.warning_type||row.warningType) }}</template></el-table-column><el-table-column label="首次接警" min-width="160"><template #default="{row}">{{ row.receive_time||row.receiveTime||'-' }}</template></el-table-column><el-table-column label="接警时长" width="120"><template #default="{row}"><b>{{ row.receive_hours??row.receiveHours??'-' }}</b> 小时</template></el-table-column><el-table-column label="状态" width="100"><template #default="{row}"><el-tag>{{ statusLabels[row.status]||row.status }}</el-tag></template></el-table-column></el-table><pagination v-show="total > 0" v-model:page="query.pageNum" v-model:limit="query.pageSize" :total="total" @pagination="loadPage" /></el-card>
+  </div>
+</template>
+<script setup name="WarningReceiveEfficiency">
+import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Refresh } from '@element-plus/icons-vue'
+import * as echarts from 'echarts'
+import { getReceiveEfficiencyOverview, getReceiveEfficiencyRange, getReceiveEfficiencyType, pageReceiveEfficiency } from '@/api/warning/statistics'
+import { defaultRange, labelOf, rangeParams, responseData, rowsOf, safeNumber, statusLabels, totalOf, warningTypeLabel, warningTypeOptions, warningTypeLabels } from '../statisticsShared'
+const dateRange=ref(defaultRange());const warningType=ref('');const warningSpecial=ref('');const status=ref('');const loading=ref(false);const errorMessage=ref('');const overview=ref({});const rangeRows=ref([]);const typeRows=ref([]);const rows=ref([]);const total=ref(0);const query=ref({pageNum:1,pageSize:10});const rangeRef=ref();const typeRef=ref();let rangeChart;let typeChart
+const typeOptions=warningTypeOptions;const typeLabel=value=>labelOf(warningTypeLabels,value);const read=(key, fallback='-')=>overview.value[key]??overview.value[key.replace(/_([a-z])/g,(_,c)=>c.toUpperCase())]??fallback;const metrics=computed(()=>[{label:'平均接警时长',value:safeNumber(read('avg_hours'),2),note:'小时'},{label:'最长接警时长',value:safeNumber(read('max_hours'),2),note:'小时'},{label:'最短接警时长',value:safeNumber(read('min_hours'),2),note:'小时'},{label:'有效接警数',value:read('total_count',0),note:'已接收预警'}])
+function render(){if(!rangeRef.value||!typeRef.value)return;rangeChart ||= echarts.init(rangeRef.value);typeChart ||= echarts.init(typeRef.value);rangeChart.setOption({tooltip:{trigger:'axis'},grid:{left:46,right:18,top:22,bottom:38},xAxis:{type:'category',data:rangeRows.value.map(r=>r.time_range||r.timeRange)},yAxis:{type:'value',minInterval:1},series:[{type:'bar',barWidth:32,data:rangeRows.value.map(r=>Number(r.warning_count??r.warningCount??0)),itemStyle:{color:'#2563eb',borderRadius:[6,6,0,0]}}]});typeChart.setOption({tooltip:{trigger:'axis'},grid:{left:55,right:18,top:22,bottom:54},xAxis:{type:'category',data:typeRows.value.map(r=>typeLabel(r.warning_type||r.warningType)),axisLabel:{rotate:25}},yAxis:{type:'value',name:'小时'},series:[{type:'bar',barWidth:28,data:typeRows.value.map(r=>Number(r.avg_receive_hours??r.avgReceiveHours??0)),itemStyle:{color:'#dc2626',borderRadius:[6,6,0,0]}}]})}
+async function loadData(){loading.value=true;errorMessage.value='';try{const params={...rangeParams(dateRange.value),warningType:warningType.value||undefined,warningSpecial:warningSpecial.value||undefined,status:status.value||undefined};const [a,b,c]=await Promise.all([getReceiveEfficiencyOverview(params),getReceiveEfficiencyRange(params),getReceiveEfficiencyType(params)]);overview.value=responseData(a,{});rangeRows.value=responseData(b,[]);typeRows.value=responseData(c,[]);await loadPage();await nextTick();render()}catch(error){errorMessage.value=error?.message||'接警效率加载失败';ElMessage.error(errorMessage.value)}finally{loading.value=false}}
+async function loadPage(){const res=await pageReceiveEfficiency({...rangeParams(dateRange.value),warningType:warningType.value||undefined,warningSpecial:warningSpecial.value||undefined,status:status.value||undefined,sortType:'desc',...query.value});rows.value=rowsOf(res).map(row=>({...row,warningNo:row.warningNo??row.warning_no,warningName:row.warningName??row.warning_name}));total.value=totalOf(res)}
+function resize(){rangeChart?.resize();typeChart?.resize()};onMounted(()=>{loadData();window.addEventListener('resize',resize)});onUnmounted(()=>{window.removeEventListener('resize',resize);rangeChart?.dispose();typeChart?.dispose()})
+</script>
+<style scoped lang="scss">.statistics-page{padding:22px;background:#f4f7fb;min-height:calc(100vh - 84px);color:#172b4d}.page-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:18px}.eyebrow{font-size:11px;letter-spacing:2px;color:#dc2626}.page-head h1{margin:6px 0 4px;font-size:26px}.page-head p{margin:0;color:#718096}.filter-card{display:flex;gap:12px;align-items:center;margin-bottom:16px}.filter-select{width:150px}.special-input{width:180px}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}.metric-card{padding:18px 20px;background:#fff;border:1px solid #e6ebf2;border-radius:8px;display:flex;flex-direction:column;gap:8px}.metric-card span{font-size:13px;color:#718096}.metric-card strong{font-size:26px;color:#172b4d}.metric-card small{color:#9aa7b8}.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}.card-title{display:flex;justify-content:space-between;font-weight:600}.card-title em{font-style:normal;font-size:12px;color:#94a3b8;font-weight:400}.chart-box{height:290px}@media(max-width:1000px){.filter-card{flex-wrap:wrap}.metric-grid,.chart-grid{grid-template-columns:1fr 1fr}}@media(max-width:600px){.metric-grid,.chart-grid{grid-template-columns:1fr}.filter-card>*{width:100%!important}.page-head{align-items:flex-start;gap:12px;flex-direction:column}}</style>

+ 91 - 0
src/views/subSystem/lifeCompany/lstatistics/statisticsShared.js

@@ -0,0 +1,91 @@
+export const warningTypeLabels = {
+  WATER_PIPE: '供水管网',
+  SEWER_PIPE: '排水管网',
+  GAS_PIPE: '燃气管网',
+  MANHOLE: '窨井',
+  '供水': '供水管网',
+  '供水管网': '供水管网',
+  '供水管网预警': '供水管网',
+  '排水': '排水管网',
+  '排水管网': '排水管网',
+  '排水管网预警': '排水管网',
+  '燃气': '燃气管网',
+  '燃气管网': '燃气管网',
+  '燃气管网预警': '燃气管网',
+  '窨井预警': '窨井'
+}
+
+// 业务范围固定为四类城市生命线设施;别名仅用于兼容历史数据,不在筛选器中重复展示。
+export const warningTypeOptions = [
+  { value: 'WATER_PIPE', label: '供水管网' },
+  { value: 'SEWER_PIPE', label: '排水管网' },
+  { value: 'GAS_PIPE', label: '燃气管网' },
+  { value: 'MANHOLE', label: '窨井' }
+]
+
+export const statusLabels = {
+  DRAFT: '草稿',
+  PENDING: '待处置',
+  PROCESSING: '处置中',
+  RELEASED: '已发布',
+  HANDLED: '已处置',
+  CLOSED: '已解除',
+  RESOLVED: '已解除'
+}
+
+export const disposalTypeLabels = {
+  RELEASE: '发布处置',
+  RESOLVE: '解除预警',
+  RETURN: '退回重办',
+  UPGRADE: '升级预警'
+}
+
+export function labelOf(map, value) {
+  const legacyTypeLabels = { '1': '供水管网', '2': '排水管网', '3': '燃气管网', '4': '窨井' }
+  const normalizedValue = value === undefined || value === null ? '' : String(value).toUpperCase()
+  return map[value] || map[normalizedValue] || (map === warningTypeLabels ? legacyTypeLabels[String(value)] : undefined) || value || '未设置'
+}
+
+export function warningTypeLabel(value) {
+  if (value === undefined || value === null || value === '') return '未设置'
+  const legacyLabels = { '1': '供水管网', '2': '排水管网', '3': '燃气管网', '4': '窨井' }
+  return warningTypeLabels[value] || legacyLabels[String(value)] || (Object.values(warningTypeLabels).includes(value) ? value : '未设置')
+}
+
+export function responseData(response, fallback = []) {
+  return response?.data ?? fallback
+}
+
+export function rowsOf(response) {
+  const data = responseData(response, {})
+  return Array.isArray(data) ? data : data.records || []
+}
+
+export function totalOf(response) {
+  const data = responseData(response, {})
+  return Number(data.total || 0)
+}
+
+export function defaultRange(days = 30) {
+  const end = new Date()
+  const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000)
+  return [formatDateTime(start), formatDateTime(end)]
+}
+
+export function formatDateTime(value) {
+  const date = value instanceof Date ? value : new Date(value)
+  const pad = number => String(number).padStart(2, '0')
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
+}
+
+export function rangeParams(range) {
+  return {
+    startTime: range?.[0] || undefined,
+    endTime: range?.[1] || undefined
+  }
+}
+
+export function safeNumber(value, digits = 2) {
+  const number = Number(value)
+  return Number.isFinite(number) ? number.toFixed(digits) : '-'
+}

+ 21 - 0
src/views/subSystem/lifeCompany/lstatistics/today/index.vue

@@ -0,0 +1,21 @@
+<template>
+  <div class="statistics-page"><section class="page-head"><div><span class="eyebrow">LIVE WARNING DESK</span><h1>今日预警统计</h1><p>快速查看今日非草稿预警及处置进展。</p></div><el-button type="primary" :loading="loading" @click="loadData"><el-icon><Refresh /></el-icon>刷新数据</el-button></section>
+    <div class="metric-grid"><button v-for="metric in metricCards" :key="metric.key" class="metric-card" :class="{active: activeStatus === metric.status}" @click="selectStatus(metric.status)"><span>{{ metric.label }}</span><strong>{{ metric.value }}</strong><small>{{ metric.note }}</small></button></div>
+    <el-card shadow="never" class="table-card"><template #header><div class="card-title"><span>{{ activeStatus ? statusLabels[activeStatus] : '今日全部预警' }}</span><div><el-button text @click="selectStatus('')">全部</el-button><el-button type="primary" plain @click="loadData">刷新</el-button></div></div></template><el-table v-loading="loading" :data="rows" stripe empty-text="今日暂无预警"><el-table-column prop="warningNo" label="预警编号" min-width="150"/><el-table-column prop="warningName" label="预警事项" min-width="190" show-overflow-tooltip/><el-table-column prop="warningType" label="预警类型" min-width="120"/><el-table-column prop="warningSpecial" label="预警专项" min-width="140"/><el-table-column prop="publishTime" label="发布时间" min-width="165"/><el-table-column label="处置状态" width="110"><template #default="{row}"><el-tag :type="row.status === 'CLOSED' ? 'success' : row.status === 'HANDLED' ? 'primary' : 'warning'">{{ statusLabels[row.status] || row.status }}</el-tag></template></el-table-column></el-table><pagination v-show="total > 0" v-model:page="query.pageNum" v-model:limit="query.pageSize" :total="total" @pagination="loadPage" /></el-card>
+  </div>
+</template>
+<script setup name="WarningToday">
+import { computed, onMounted, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Refresh } from '@element-plus/icons-vue'
+import { getTodayWarningStats, pageTodayWarnings } from '@/api/warning/statistics'
+import { responseData, rowsOf, statusLabels, totalOf, warningTypeLabel } from '../statisticsShared'
+const loading=ref(false);const errorMessage=ref('');const stats=ref({});const rows=ref([]);const total=ref(0);const activeStatus=ref('');const query=ref({pageNum:1,pageSize:10})
+const read=(...keys)=>{for(const key of keys){if(stats.value[key] !== undefined)return Number(stats.value[key])}return 0}
+const metricCards=computed(()=>[{key:'total',label:'今日预警',value:read('today_total','todayTotal'),note:'非草稿状态',status:''},{key:'handled',label:'已处置',value:read('handled_today','handledToday'),note:'完成处置',status:'HANDLED'},{key:'closed',label:'已解除',value:read('closed_today','closedToday'),note:'解除预警',status:'CLOSED'}])
+async function loadData(){loading.value=true;errorMessage.value='';try{const res=await getTodayWarningStats();stats.value=responseData(res,{});await loadPage()}catch(error){errorMessage.value=error?.message||'今日预警加载失败';ElMessage.error(errorMessage.value)}finally{loading.value=false}}
+async function loadPage(){const res=await pageTodayWarnings({...query.value,status:activeStatus.value||undefined});rows.value=rowsOf(res).map(row=>({...row,warningType:warningTypeLabel(row.warningType??row.warning_type)}));total.value=totalOf(res)}
+function selectStatus(status){activeStatus.value=status;query.value.pageNum=1;loadPage()};onMounted(loadData)
+</script>
+<style scoped lang="scss">.statistics-page{padding:22px;background:#f4f7fb;min-height:calc(100vh - 84px);color:#172b4d}.page-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:22px}.eyebrow{font-size:11px;letter-spacing:2px;color:#dc2626}.page-head h1{margin:6px 0 4px;font-size:26px}.page-head p{margin:0;color:#718096}.metric-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:18px}.metric-card{border:1px solid #e6ebf2;border-radius:8px;background:#fff;text-align:left;padding:22px 24px;display:flex;flex-direction:column;gap:8px;cursor:pointer;transition:.2s}.metric-card:hover,.metric-card.active{border-color:#2563eb;box-shadow:0 8px 22px rgba(37,99,235,.12);transform:translateY(-1px)}.metric-card span{font-size:14px;color:#718096}.metric-card strong{font-size:36px;color:#172b4d}.metric-card small{color:#94a3b8}.card-title{display:flex;justify-content:space-between;align-items:center;font-weight:600}.table-card{margin-bottom:20px}@media(max-width:600px){.metric-grid{grid-template-columns:1fr}.page-head{align-items:flex-start;gap:12px;flex-direction:column}}
+</style>

+ 21 - 0
src/views/subSystem/lifeCompany/lstatistics/trend/index.vue

@@ -0,0 +1,21 @@
+<template>
+  <div class="statistics-page"><section class="page-head"><div><span class="eyebrow">SIGNAL TREND</span><h1>预警发展趋势</h1><p>按日或月份观察各专项预警数量变化。</p></div><el-button type="primary" :loading="loading" @click="loadData"><el-icon><Refresh /></el-icon>刷新趋势</el-button></section>
+    <el-card class="filter-card" shadow="never"><el-radio-group v-model="granularity" @change="loadData"><el-radio-button label="day">按日</el-radio-button><el-radio-button label="month">按月</el-radio-button></el-radio-group><el-date-picker v-model="dateRange" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" /><el-input v-model="warningSpecial" clearable placeholder="输入预警专项" class="special-input" /><el-button type="primary" @click="loadData">查询</el-button></el-card>
+    <el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon closable @close="errorMessage = ''" />
+    <el-card shadow="never"><template #header><div class="card-title"><span>{{ granularity === 'day' ? '日趋势' : '月趋势' }}</span><em>{{ rows.length }} 个数据点</em></div></template><div ref="chartRef" class="trend-chart" /><el-empty v-if="!rows.length && !loading" description="当前周期暂无趋势数据" /></el-card>
+  </div>
+</template>
+<script setup name="WarningTrend">
+import { nextTick, onMounted, onUnmounted, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Refresh } from '@element-plus/icons-vue'
+import * as echarts from 'echarts'
+import { getWarningTrendByDay, getWarningTrendByMonth } from '@/api/warning/statistics'
+import { defaultRange, rangeParams, responseData } from '../statisticsShared'
+const granularity=ref('day');const dateRange=ref(defaultRange(30));const warningSpecial=ref('');const loading=ref(false);const errorMessage=ref('');const rows=ref([]);const chartRef=ref();let chart
+function render(){if(!chartRef.value)return;chart ||= echarts.init(chartRef.value);const dateKey=granularity.value==='day'?'stat_date':'stat_month';const dateCamelKey=granularity.value==='day'?'statDate':'statMonth';const readDate=row=>row[dateKey]??row[dateCamelKey]??'';const dates=[...new Set(rows.value.map(readDate))].filter(Boolean);const specials=[...new Set(rows.value.map(r=>r.warning_special||r.warningSpecial||'未设置'))];chart.setOption({tooltip:{trigger:'axis'},legend:{top:0,data:specials},grid:{left:48,right:22,top:42,bottom:38},xAxis:{type:'category',boundaryGap:false,data:dates},yAxis:{type:'value',minInterval:1,name:'数量'},series:specials.map((special,index)=>({name:special,type:'line',smooth:true,symbol:'circle',symbolSize:7,data:dates.map(date=>{const row=rows.value.find(item=>readDate(item)===date&&(item.warning_special||item.warningSpecial||'未设置')===special);return Number(row?.warning_count??row?.warningCount??0)}),lineStyle:{width:3},itemStyle:{color:['#2563eb','#0f766e','#f59e0b','#ef4444','#8b5cf6'][index%5]}}))})}
+async function loadData(){loading.value=true;errorMessage.value='';try{const params={...rangeParams(dateRange.value),warningSpecial:warningSpecial.value||undefined};const res=granularity.value==='day'?await getWarningTrendByDay(params):await getWarningTrendByMonth(params);rows.value=responseData(res,[]);await nextTick();render()}catch(error){errorMessage.value=error?.message||'趋势数据加载失败';ElMessage.error(errorMessage.value)}finally{loading.value=false}}
+function resize(){chart?.resize()};onMounted(()=>{loadData();window.addEventListener('resize',resize)});onUnmounted(()=>{window.removeEventListener('resize',resize);chart?.dispose()})
+</script>
+<style scoped lang="scss">.statistics-page{padding:22px;background:#f4f7fb;min-height:calc(100vh - 84px);color:#172b4d}.page-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:18px}.eyebrow{font-size:11px;letter-spacing:2px;color:#2563eb}.page-head h1{margin:6px 0 4px;font-size:26px}.page-head p{margin:0;color:#718096}.filter-card{display:flex;gap:14px;align-items:center;margin-bottom:16px}.special-input{width:220px}.card-title{display:flex;justify-content:space-between;font-weight:600}.card-title em{font-style:normal;font-size:12px;color:#94a3b8;font-weight:400}.trend-chart{height:470px}@media(max-width:700px){.filter-card{flex-wrap:wrap}.filter-card>*{width:100%!important}.page-head{align-items:flex-start;gap:12px;flex-direction:column}}
+</style>

+ 14 - 0
src/views/subSystem/lifeCompany/lwarningInfoCont/LwarningDisp.vue

@@ -74,6 +74,9 @@
         <el-form-item label="处置人">
           <el-input v-model="queryParams.handler" placeholder="请输入处置人" clearable style="width: 180px" />
         </el-form-item>
+        <el-form-item label="设备编码">
+          <el-input v-model="queryParams.deviceCode" placeholder="请输入设备编码" clearable style="width: 180px" />
+        </el-form-item>
         <el-form-item label="发布时间">
           <el-date-picker
             v-model="queryParams.dateRange"
@@ -110,6 +113,8 @@
           </template>
         </el-table-column>
         <el-table-column label="处置人" prop="handler" width="120" />
+        <el-table-column label="设备名称" prop="deviceName" min-width="140" show-overflow-tooltip />
+        <el-table-column label="设备编码" prop="deviceCode" width="160" show-overflow-tooltip />
         <el-table-column label="发布人" prop="publisher" width="120" />
         <el-table-column label="报警位置" prop="location" min-width="180" show-overflow-tooltip />
         <el-table-column label="权属单位" prop="ownershipUnit" width="160" show-overflow-tooltip />
@@ -152,6 +157,8 @@
           <el-descriptions-item label="预警级别">{{ detailBasic.warningLevelText || "-" }}</el-descriptions-item>
           <el-descriptions-item label="状态">{{ getStatusText(detailBasic.status) }}</el-descriptions-item>
           <el-descriptions-item label="处置人">{{ detailBasic.handler || "-" }}</el-descriptions-item>
+          <el-descriptions-item label="设备名称">{{ detailBasic.deviceName || "-" }}</el-descriptions-item>
+          <el-descriptions-item label="设备编码">{{ detailBasic.deviceCode || "-" }}</el-descriptions-item>
           <el-descriptions-item label="发布人">{{ detailBasic.publisher || "-" }}</el-descriptions-item>
           <el-descriptions-item label="报警位置" :span="2">{{ detailBasic.location || "-" }}</el-descriptions-item>
           <el-descriptions-item label="权属单位">{{ detailBasic.ownershipUnit || "-" }}</el-descriptions-item>
@@ -316,6 +323,7 @@ const queryParams = reactive({
   warningLevel: "",
   status: "",
   handler: "",
+  deviceCode: "",
   dateRange: []
 });
 
@@ -372,6 +380,8 @@ const detailBasic = computed(() => {
     warningLevelText: warningLevelLabelMap.value[warningLevel] || warningLevel || "-",
     status: basic.status || "",
     handler: basic.handler || "",
+    deviceCode: basic.deviceCode || basic.device_code || "",
+    deviceName: basic.deviceName || basic.device_name || "",
     publisher: basic.publisher || "",
     location: basic.location || "",
     ownershipUnit: basic.ownershipUnit || basic.ownership_unit || "",
@@ -425,6 +435,8 @@ function normalizeWarningRow(item) {
     warningLevel,
     warningLevelText: warningLevelLabelMap.value[warningLevel] || warningLevel || "-",
     handler: item.handler || "",
+    deviceCode: item.deviceCode || item.device_code || "",
+    deviceName: item.deviceName || item.device_name || "",
     publisher: item.publisher || "",
     location: item.location || "",
     ownershipUnit: item.ownershipUnit || item.ownership_unit || "",
@@ -469,6 +481,7 @@ async function loadList() {
       warningLevel: queryParams.warningLevel || undefined,
       status: queryParams.status || undefined,
       handler: queryParams.handler || undefined,
+      deviceCode: queryParams.deviceCode || undefined,
       startTime: queryParams.dateRange?.[0] || undefined,
       endTime: queryParams.dateRange?.[1] || undefined
     });
@@ -493,6 +506,7 @@ function resetQuery() {
   queryParams.warningLevel = "";
   queryParams.status = "";
   queryParams.handler = "";
+  queryParams.deviceCode = "";
   queryParams.dateRange = [];
   loadList();
 }

+ 340 - 136
src/views/subSystem/pipeNetwork/pAlarmMonitor/PEqWarning.vue

@@ -7,39 +7,45 @@
     <!-- ========= 统计卡片区域 ========== -->
     <el-row :gutter="16" class="stat-row">
       <el-col :span="4">
-        <div class="stat-item">
-          <div class="stat-title">总预警</div>
-          <div class="stat-value" :class="stats.total > 0 ? 'primary' : ''">{{ stats.total }}</div>
+        <div class="stat-item stat-primary">
+          <div class="stat-icon"><el-icon><Warning /></el-icon></div>
+          <div class="stat-content"><div class="stat-title">总预警</div><div class="stat-value">{{ stats.total }}</div></div>
+          <div class="stat-caption">累计预警总量</div>
         </div>
       </el-col>
       <el-col :span="4">
-        <div class="stat-item">
-          <div class="stat-title">待确认</div>
-          <div class="stat-value warning" :class="stats.unconfirmed > 0 ? 'highlight' : ''">{{ stats.unconfirmed }}</div>
+        <div class="stat-item stat-warning">
+          <div class="stat-icon"><el-icon><Bell /></el-icon></div>
+          <div class="stat-content"><div class="stat-title">待确认</div><div class="stat-value">{{ stats.unconfirmed }}</div></div>
+          <div class="stat-caption">待核实异常</div>
         </div>
       </el-col>
       <el-col :span="4">
-        <div class="stat-item">
-          <div class="stat-title">处理中</div>
-          <div class="stat-value info" :class="stats.processing > 0 ? 'highlight' : ''">{{ stats.processing }}</div>
+        <div class="stat-item stat-processing">
+          <div class="stat-icon"><el-icon><Tools /></el-icon></div>
+          <div class="stat-content"><div class="stat-title">处理中</div><div class="stat-value">{{ stats.processing }}</div></div>
+          <div class="stat-caption">正在处置</div>
         </div>
       </el-col>
       <el-col :span="4">
-        <div class="stat-item">
-          <div class="stat-title">已处置</div>
-          <div class="stat-value success" :class="stats.handled > 0 ? 'highlight' : ''">{{ stats.handled }}</div>
+        <div class="stat-item stat-handled">
+          <div class="stat-icon"><el-icon><CircleCheck /></el-icon></div>
+          <div class="stat-content"><div class="stat-title">已处置</div><div class="stat-value">{{ stats.handled }}</div></div>
+          <div class="stat-caption">等待复核清除</div>
         </div>
       </el-col>
       <el-col :span="4">
-        <div class="stat-item">
-          <div class="stat-title">已解除</div>
-          <div class="stat-value done" :class="stats.closed > 0 ? 'highlight' : ''">{{ stats.closed }}</div>
+        <div class="stat-item stat-closed">
+          <div class="stat-icon"><el-icon><CircleClose /></el-icon></div>
+          <div class="stat-content"><div class="stat-title">已解除</div><div class="stat-value">{{ stats.closed }}</div></div>
+          <div class="stat-caption">已完成闭环</div>
         </div>
       </el-col>
       <el-col :span="4">
-        <div class="stat-item">
-          <div class="stat-title">今日新增</div>
-          <div class="stat-value today" :class="stats.today > 0 ? 'highlight' : ''">{{ stats.today }}</div>
+        <div class="stat-item stat-today">
+          <div class="stat-icon"><el-icon><TrendCharts /></el-icon></div>
+          <div class="stat-content"><div class="stat-title">今日新增</div><div class="stat-value">{{ stats.today }}</div></div>
+          <div class="stat-caption">今日新增预警</div>
         </div>
       </el-col>
     </el-row>
@@ -48,7 +54,7 @@
     <el-row :gutter="16" class="chart-row">
       <el-col :span="12"><el-card><template #header><span>近 7 天趋势</span></template><div id="trendChart" style="height:240px"/></el-card></el-col>
       <el-col :span="6"><el-card><template #header><span>级别分布</span></template><div id="levelChart" style="height:240px"/></el-card></el-col>
-      <el-col :span="6"><el-card><template #header><span>类型占比</span></template><div id="typeChart" style="height:240px"/></el-card></el-col>
+      <el-col :span="6"><el-card><template #header><span>级别占比</span></template><div id="typeChart" style="height:240px"/></el-card></el-col>
     </el-row>
 
     <!-- ========= 查询表单 ========== -->
@@ -57,6 +63,12 @@
         <el-form-item label="预警名称">
           <el-input v-model="queryParams.warningName" placeholder="请输入预警名称" clearable style="width:200px"/>
         </el-form-item>
+        <el-form-item label="设备编码">
+          <el-input v-model="queryParams.deviceCode" placeholder="请输入设备编码" clearable style="width:180px"/>
+        </el-form-item>
+        <el-form-item label="设备名称">
+          <el-input v-model="queryParams.deviceName" placeholder="请输入设备名称" clearable style="width:180px"/>
+        </el-form-item>
         <el-form-item label="状态">
           <el-select v-model="queryParams.status" placeholder="全部状态" clearable style="width:150px">
             <el-option label="待确认" value="RELEASED"/>
@@ -98,10 +110,11 @@
       <el-table :data="tableData" border stripe v-loading="loading" max-height="600">
         <el-table-column prop="warningNo" label="预警编号" width="140" />
         <el-table-column prop="warningName" label="预警名称" min-width="160" show-overflow-tooltip />
-        <el-table-column label="关联设备" width="160">
+        <el-table-column label="关联设备" width="220">
           <template #default="{ row }">
-            <div v-if="row.deviceCode" class="device-cell">
-              <el-tag :type="row.warningLevel === '4' ? 'danger' : 'info'" size="small" effect="plain">{{ row.deviceCode }}</el-tag>
+            <div v-if="row.deviceCode || row.deviceName" class="device-cell">
+              <span v-if="row.deviceName" class="device-name">{{ row.deviceName }}</span>
+              <el-tag v-if="row.deviceCode" :type="row.warningLevel === '4' ? 'danger' : 'info'" size="small" effect="plain">{{ row.deviceCode }}</el-tag>
             </div>
             <span v-else class="no-device">未关联</span>
           </template>
@@ -124,11 +137,13 @@
             <el-tag :type="getStatusType(row.status)" size="small">{{ getStatusText(row.status) }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="publishTime" label="发布时间" width="170" sortable />
+        <el-table-column prop="publishTime" label="发布时间" width="170" sortable class-name="publish-time-column" :formatter="formatPublishTime" />
         <el-table-column label="操作" width="180" fixed="right">
           <template #default="{ row }">
             <el-button v-if="isUnconfirmed(row.status)" type="primary" size="small" @click="openConfirm(row)">确认异常</el-button>
             <el-button v-if="isUnconfirmed(row.status)" type="info" size="small" @click="confirmMisreport(row)">误报</el-button>
+            <el-button v-if="row.status === 'PROCESSING'" type="warning" size="small" @click="openProcess(row)">提交处理</el-button>
+            <el-button v-if="row.status === 'HANDLED'" type="success" size="small" @click="clearHandled(row)">清除预警</el-button>
             <el-button type="info" size="small" @click="openDetail(row.warningId)">详情</el-button>
           </template>
         </el-table-column>
@@ -143,16 +158,15 @@
         <el-descriptions-item label="预警编号">{{ current.warningNo }}</el-descriptions-item>
         <el-descriptions-item label="预警名称">{{ current.warningName }}</el-descriptions-item>
         <el-descriptions-item label="预警内容">{{ current.warningContent || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="关联设备">{{ formatWarningDevice(current) }}</el-descriptions-item>
         <el-descriptions-item label="设备位置">{{ current.location || '-' }}</el-descriptions-item>
       </el-descriptions>
       <el-form label-width="100px" style="margin-top:16px">
-        <el-form-item label="关联设备">
-          <el-select v-model="deviceSelected" filterable remote clearable placeholder="请输入设备编码搜索" :remote-method="searchDevice" :loading="deviceLoading" style="width:100%">
-            <el-option v-for="dev in deviceOptions" :key="dev.equipmentId" :label="`${dev.equipmentName}(${dev.equipmentCode})`" :value="dev.equipmentId"/>
-          </el-select>
-        </el-form-item>
         <el-form-item label="备注信息">
-          <el-input v-model="confirmRemark" type="textarea" :rows="3" placeholder="填写异常情况描述(将写入工单描述)" maxlength="300" show-word-limit/>
+          <el-input v-model="confirmingRemark" type="textarea" :rows="3" placeholder="填写异常情况描述(将写入工单描述)" maxlength="300" show-word-limit/>
+        </el-form-item>
+        <el-form-item label="处理人员">
+          <el-input v-model="assignedUser" placeholder="可填写处理人账号,不填则使用预警原处理人或当前用户" clearable />
         </el-form-item>
       </el-form>
       <template #footer>
@@ -161,6 +175,29 @@
       </template>
     </el-dialog>
 
+    <el-dialog v-model="processVisible" title="提交预警处理反馈" width="620px" append-to-body destroy-on-close>
+      <el-descriptions :column="1" border>
+        <el-descriptions-item label="预警编号">{{ processRow.warningNo }}</el-descriptions-item>
+        <el-descriptions-item label="预警名称">{{ processRow.warningName }}</el-descriptions-item>
+        <el-descriptions-item label="关联设备">{{ formatWarningDevice(processRow) }}</el-descriptions-item>
+      </el-descriptions>
+      <el-form label-width="100px" style="margin-top:16px">
+        <el-form-item label="处理反馈" required>
+          <el-input v-model="processContent" type="textarea" :rows="5" maxlength="1000" show-word-limit placeholder="请填写处理过程和处理结果" />
+        </el-form-item>
+        <el-form-item label="处理附件">
+          <el-upload v-model:file-list="processFileList" action="#" list-type="text" :auto-upload="false" :limit="9" accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.xls,.xlsx,.doc,.docx" :on-change="handleProcessFileChange" :on-remove="handleProcessFileRemove" :on-exceed="handleProcessFileExceed">
+            <el-icon><Plus /></el-icon>
+          </el-upload>
+          <div class="upload-tip">支持图片、PDF、Excel、Word,最多 9 个文件,单个不超过 20MB</div>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="processVisible=false">取消</el-button>
+        <el-button type="primary" :loading="submitting" @click="submitProcess">提交反馈</el-button>
+      </template>
+    </el-dialog>
+
     <!-- ========== 详情页抽屉 ========== -->
     <el-drawer v-model="detailVisible" title="📋 预警处理详情" size="560px">
       <template v-if="detail">
@@ -170,14 +207,15 @@
           <el-descriptions-item label="预警类型">{{ detail.basicInfo.warning_type || '-' }}</el-descriptions-item>
           <el-descriptions-item label="预警级别">{{ getLevelText(detail.basicInfo.warning_level) }}</el-descriptions-item>
           <el-descriptions-item label="发布人">{{ detail.basicInfo.publisher || '-' }}</el-descriptions-item>
-          <el-descriptions-item label="发布时间">{{ detail.basicInfo.publish_time || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="发布时间">{{ formatWarningTime(detail.basicInfo.publish_time) }}</el-descriptions-item>
           <el-descriptions-item label="状态">{{ getStatusText(detail.basicInfo.status) }}</el-descriptions-item>
           <el-descriptions-item label="预警内容">{{ detail.basicInfo.warning_content }}</el-descriptions-item>
         </el-descriptions>
 
         <div class="detail-section-title">🔗 关联设备</div>
         <el-descriptions :column="1" border size="small">
-          <el-descriptions-item label="设备编码">{{ detail.basicInfo.remark?.startsWith('AUTO|') ? detail.basicInfo.remark.split('|')[1] : '(手工录入,无设备关联)' }}</el-descriptions-item>
+          <el-descriptions-item label="设备名称">{{ detail.basicInfo.device_name || detail.basicInfo.deviceName || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="设备编码">{{ detail.basicInfo.device_code || detail.basicInfo.deviceCode || extractDeviceCodeStr(detail.basicInfo) || '(手工录入,无设备关联)' }}</el-descriptions-item>
           <el-descriptions-item label="设备位置">{{ detail.basicInfo.location || '-' }}</el-descriptions-item>
           <el-descriptions-item label="权属单位">{{ detail.basicInfo.ownership_unit || '-' }}</el-descriptions-item>
         </el-descriptions>
@@ -193,12 +231,38 @@
         <div v-else class="detail-empty">暂无处置记录</div>
 
         <div class="detail-section-title">📎 附件</div>
-        <el-upload v-if="false" action="#" list-type="text"/>
         <div v-if="detail.attachmentList?.length" class="attachment-list">
-          <a v-for="(att,idx) in detail.attachmentList" :key="idx" class="attachment-link" :href="att.attachmentUrl" target="_blank">{{ att.attachmentName }}</a>
+          <div v-for="stage in ['REPORT','PROCESS']" :key="stage" class="attachment-group">
+            <div class="attachment-stage">{{ stage === 'PROCESS' ? '处理反馈附件' : '预警报告附件' }}</div>
+            <div v-if="attachmentsByStage(stage).length" class="attachment-grid">
+              <div v-for="(att,idx) in attachmentsByStage(stage)" :key="att.attachmentId || idx" class="attachment-item">
+                <el-image v-if="isImageAttachment(att) && attachmentPreviewUrl(att)" class="attachment-image" :src="attachmentPreviewUrl(att)" :preview-src-list="[attachmentPreviewUrl(att)]" fit="cover" preview-teleported>
+                  <template #error><div class="attachment-image-error">图片加载失败</div></template>
+                </el-image>
+                <div v-else-if="isImageAttachment(att)" class="attachment-image attachment-image-loading">加载中...</div>
+                <div v-else class="attachment-file-icon"><el-icon><Document /></el-icon></div>
+                <div class="attachment-name" :title="displayAttachmentName(att)">{{ displayAttachmentName(att) }}</div>
+                <div class="attachment-actions">
+                  <el-button v-if="canPreviewAttachment(att)" link type="primary" size="small" @click="previewAttachment(att)"><el-icon><View /></el-icon>预览</el-button>
+                  <el-button link type="primary" size="small" @click="downloadAttachment(att)"><el-icon><Download /></el-icon>下载</el-button>
+                </div>
+              </div>
+            </div>
+            <div v-else class="detail-empty">暂无附件</div>
+          </div>
         </div>
         <div v-else class="detail-empty">暂无附件</div>
 
+        <div class="detail-section-title">预警督办</div>
+        <div v-if="detail.supervisionList?.length" class="supervision-list">
+          <div v-for="(item,idx) in detail.supervisionList" :key="idx" class="supervision-item">
+            <b>{{ item.supervisionUser || '-' }}</b>
+            <span>{{ formatWarningTime(item.createTime) }}</span>
+            <p>{{ item.supervisionContent || '-' }}</p>
+          </div>
+        </div>
+        <div v-else class="detail-empty">暂无督办记录</div>
+
         <div class="detail-section-title">📦 电子归档</div>
         <el-descriptions v-if="detail.electronicArchive" :column="2" border size="small">
           <el-descriptions-item label="归档编号">{{ detail.electronicArchive.archiveNo }}</el-descriptions-item>
@@ -211,12 +275,12 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
+import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
 import * as echarts from 'echarts'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { Search, Refresh } from '@element-plus/icons-vue'
-import { getWarningDashboardStatisticsData, getWarningSearchData, getWarningFullDetailData, publishWarningData, resolveWarningData } from '@/api/pipeNetwork/earlyWarning'
-import { addManholeWorkOrder, getEquipmentFullPage } from '@/api/pipeNetwork/basic'
+import { Search, Refresh, Plus, Document, Download, View, Warning, Bell, Tools, CircleCheck, CircleClose, TrendCharts } from '@element-plus/icons-vue'
+import { getWarningDashboardStatisticsData, getWarningSearchData, getWarningFullDetailData, confirmWarningData, misreportWarningData, submitWarningProcessData, clearWarningData, getWarningAttachmentPreviewData, getWarningAttachmentDownloadData } from '@/api/pipeNetwork/earlyWarning'
+import { addManholeWorkOrder } from '@/api/pipeNetwork/basic'
 
 // ========== 统计数据 ==========
 const stats = ref({ total:0, unconfirmed:0, processing:0, handled:0, closed:0, today:0 })
@@ -225,7 +289,7 @@ const stats = ref({ total:0, unconfirmed:0, processing:0, handled:0, closed:0, t
 const chartData = ref({ trend:[], levels:[], types:[] })
 
 // ========== 查询参数 & 列表 ==========
-const queryParams = ref({ warningName:'', status:'', warningLevel:'', category:'', startDate:'', endDate:'' })
+const queryParams = ref({ warningName:'', deviceCode:'', deviceName:'', status:'', warningLevel:'', category:'', startDate:'', endDate:'' })
 const tableData = ref([])
 const loading = ref(false)
 const pageNum = ref(1)
@@ -238,6 +302,19 @@ async function loadStats() {
     if (!res.data) return
     const b = res.data.basicStats
     const t = res.data.trendStats || []
+    chartData.value.trend = t.map(item => ({
+      date: String(item.date || '').slice(5) || '-',
+      count: Number(item.count || 0)
+    }))
+    chartData.value.levels = (res.data.levelStats || []).map(item => ({
+      levelCode: String(item.levelCode || ''),
+      count: Number(item.count || 0)
+    }))
+    chartData.value.types = (res.data.typeStats || []).map(item => ({
+      typeCode: item.typeCode,
+      typeName: item.typeName || item.typeCode,
+      count: Number(item.count || 0)
+    }))
     // releasedCount + pendingCount = 待确认
     stats.value = {
       total: Number(b.total || 0),
@@ -255,6 +332,8 @@ async function loadList() {
   try {
     const params = { pageNum: pageNum.value, pageSize: pageSize.value }
     if (queryParams.value.warningName) params.warningName = queryParams.value.warningName
+    if (queryParams.value.deviceCode) params.deviceCode = queryParams.value.deviceCode
+    if (queryParams.value.deviceName) params.deviceName = queryParams.value.deviceName
     if (queryParams.value.status) params.status = queryParams.value.status
     if (queryParams.value.warningLevel) params.warningLevel = queryParams.value.warningLevel
     if (queryParams.value.category === 'normal') params.publisher = '系统自动'
@@ -270,17 +349,15 @@ async function loadList() {
   finally { loading.value = false }
 }
 
-// 图表数据填充(模拟)
+// 后端统计接口异常或没有配置数据时,仍显示稳定的默认图表状态
 const fillChartData = () => {
-  if (chartData.value.trend?.length) return
-  const allStats = stats.value
+  if (chartData.value.trend?.length && chartData.value.levels?.length && chartData.value.types?.length) return
   const dates = [], counts = []
   for(let i=6;i>=0;i--) { const d=new Date(); d.setDate(d.getDate()-i); dates.push((d.getMonth()+1)+'-'+d.getDate()) }
-  for(let i=6;i>=0;i--) { counts.push(Math.max(0, Math.floor(allStats.today * (0.5 + Math.random()*0.5)))) }
-  chartData.value.trend = dates.map((date,idx)=>({date,count:counts[idx]}))
-  chartData.value.levels = [1,2,3,4].map(lv=>({levelCode:String(lv), count: Math.max(1, Math.floor(Math.random()*allStats.total/5)+1)}))
-  chartData.value.types = [{typeCode:'drainage',typeName:'排水'}, {typeCode:'gas',typeName:'燃气管网'}, {typeCode:'fire',typeName:'消防水压'}]
-    .map(t=>({...t, count: Math.max(1, Math.floor(Math.random()*allStats.total/3)+2)}))
+  for(let i=6;i>=0;i--) { counts.push(0) }
+  if (!chartData.value.trend?.length) chartData.value.trend = dates.map((date,idx)=>({date,count:counts[idx]}))
+  if (!chartData.value.levels?.length) chartData.value.levels = [1,2,3,4].map(lv=>({levelCode:String(lv), count:0}))
+  if (!chartData.value.types?.length) chartData.value.types = [{typeCode:'empty',typeName:'暂无数据',count:1}]
 }
 
 // 图表渲染时的空数据状态
@@ -306,10 +383,30 @@ function normalizeWarning(row) {
   })
   n.category = n.publisher === '系统自动' ? 'normal' : 'special'
   // 从 remark 字段提取设备编码 (AUTO|deviceCode|warningCode)
-  n.deviceCode = extractDeviceCodeStr(row)
+  n.deviceCode = n.deviceCode || extractDeviceCodeStr(row)
+  n.deviceName = n.deviceName || row.device_name || row.deviceName || ''
   return n
 }
 
+function formatPublishTime(row, column, value) {
+  return formatWarningTime(value)
+}
+
+function formatWarningTime(value) {
+  if (!value) return '-'
+  const text = String(value).trim()
+  const match = text.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})/)
+  if (match) return `${match[1]} ${match[2]}`
+  return text.replace('T', ' ').replace(/\.\d+(Z|[+-]\d{2}:?\d{2})$/, '')
+}
+
+function formatWarningDevice(warning) {
+  const name = warning?.deviceName || ''
+  const code = warning?.deviceCode || ''
+  if (name && code) return `${name}(${code})`
+  return name || code || '未关联'
+}
+
 // 从预警行中提取设备编码
 function extractDeviceCodeStr(row) {
   if (row.remark && typeof row.remark === 'string' && row.remark.startsWith('AUTO|')) {
@@ -337,20 +434,22 @@ const levelChartOpt = {
   xAxis:{ type:'category', data: ['1 级','2 级','3 级','4 级'], axisLabel:{ color:'#9098a6' }, axisLine:{ lineStyle:{ color:'#d0d5dd' } } },
   yAxis:{ type:'value', name:'数量', axisLabel:{ color:'#9098a6' }, splitLine:{ lineStyle:{ color:'#f0f2f6',type:'dashed' } } },
   series:[{
-    type:'bar', barWidth:30,
+    type:'bar', barWidth:34,
+    label:{ show:true, position:'top', color:'#606266', fontSize:12 },
     data:[],
     itemStyle:{
       color:function(params){
-        const colors={1:'#e3f2fd',2:'#fff8e1',3:'#fff3e0',4:'#ffebee'};
+        const colors={1:'#5b8ff9',2:'#f6bd16',3:'#f6903d',4:'#e8684a'};
         return colors[params.dataIndex+1]||'#e3f2fd';
-      }
+      },
+      borderRadius:[4,4,0,0]
     }
   }]
 }
 
 const typeChartOpt = {
   tooltip:{ trigger:'item' },
-  legend:{ top:'10%', orient:'vertical', right:0 },
+  legend:{ top:'center', orient:'vertical', right:4, itemWidth:12, itemHeight:12, textStyle:{ color:'#606266' } },
   series:[{
     type:'pie', radius:['40%','70%'], avoidLabelOverlap:false,
     itemStyle:{ borderRadius:4, borderColor:'#fff', borderWidth:1 },
@@ -364,8 +463,8 @@ const resizeCharts = () => { trendChart?.resize(); levelChart?.resize(); typeCha
 // ========== 图表渲染函数 ==========
 function renderTrendChart() {
   const dom = document.getElementById('trendChart')
-  if (!dom || !trendChart) return
-  trendChart.dispose()
+  if (!dom) return
+  if (trendChart) trendChart.dispose()
   trendChart = echarts.init(dom)
   const data = chartData.value.trend.length ? chartData.value.trend : emptyChartData.trend
   trendChart.setOption({ ...trendChartOpt, xAxis:{ ...trendChartOpt.xAxis, data: data.map(i=>i.date) }, series:[{ ...trendChartOpt.series[0], data: data.map(i=>i.count || 0) }]
@@ -374,8 +473,8 @@ function renderTrendChart() {
 
 function renderLevelChart() {
   const dom = document.getElementById('levelChart')
-  if (!dom || !levelChart) return
-  levelChart.dispose()
+  if (!dom) return
+  if (levelChart) levelChart.dispose()
   levelChart = echarts.init(dom)
   const data = chartData.value.levels.length ? chartData.value.levels : emptyChartData.levels
   levelChart.setOption({ ...levelChartOpt, series:[{ ...levelChartOpt.series[0], data: data.map(l=>l.count || 0) }]
@@ -384,103 +483,107 @@ function renderLevelChart() {
 
 function renderTypeChart() {
   const dom = document.getElementById('typeChart')
-  if (!dom || !typeChart) return
-  typeChart.dispose()
+  if (!dom) return
+  if (typeChart) typeChart.dispose()
   typeChart = echarts.init(dom)
-  const data = chartData.value.types.length ? chartData.value.types : emptyChartData.types
-  typeChart.setOption({ ...typeChartOpt, series:[{ ...typeChartOpt.series[0], data: data.map(t=>({ value:t.count || 0, name:t.typeName || t.typeCode })) }]
+  const levels = chartData.value.levels || []
+  const levelNames = { '1':'1 级(蓝)', '2':'2 级(黄)', '3':'3 级(橙)', '4':'4 级(红)' }
+  const total = levels.reduce((sum, item) => sum + Number(item.count || 0), 0)
+  const data = total > 0
+    ? levels.map(item => ({ value:Number(item.count || 0), name:levelNames[String(item.levelCode)] || `${item.levelCode} 级` }))
+    : [{ value:1, name:'暂无数据', itemStyle:{ color:'#c0c4cc' } }]
+  typeChart.setOption({ ...typeChartOpt, series:[{ ...typeChartOpt.series[0], data }]
   })
 }
 
-onMounted(async () => {
-  await loadStats()
-  await loadList()
-  await nextTick()
-  // 填充图表数据
-  const allStats = stats.value
-  const dates = [], counts = []
-  for(let i=6;i>=0;i--) { const d = new Date(); d.setDate(d.getDate()-i); dates.push((d.getMonth()+1)+'-'+d.getDate()) }
-  for(let i=6;i>=0;i--) { counts.push(Math.max(0, allStats.today - Math.floor(Math.random()*allStats.total*0.3))) }
-  chartData.value.trend = dates.map((date,idx)=>({date,count:counts[idx]}))
-  
-  // 级别图
-  chartData.value.levels = [1,2,3,4].map(lv=>({levelCode:String(lv), count: Math.floor(Math.random()*allStats.total/5)+1}))
-  // 类型图(无 API 时随机)
-  chartData.value.types = [{typeCode:'drainage',typeName:'排水'}, {typeCode:'gas',typeName:'燃气管网'}, {typeCode:'fire',typeName:'消防水压'}]
-    .map(t=>({...t, count: Math.floor(Math.random()*allStats.total/3)+2}))
-
-  initCharts()
-  window.addEventListener('resize', resizeCharts)
-})
-
-onBeforeUnmount(() => {
-  window.removeEventListener('resize', resizeCharts)
-  trendChart?.dispose()
-  levelChart?.dispose()
-  typeChart?.dispose()
-})
-
 // ========== 辅助函数 ==========
 const isUnconfirmed = (status) => ['RELEASED','PENDING'].includes(status)
 const getStatusText = (s) => ({ DRAFT:'草稿', PENDING:'待办', PROCESSING:'处理中', RELEASED:'待确认', HANDLED:'已处置', CLOSED:'已解除' }[s]||s)
 const getStatusType = (s) => ({ DRAFT:'info', PENDING:'danger', PROCESSING:'warning', RELEASED:'warning', HANDLED:'primary', CLOSED:'success' }[s]||'')
 const getLevelText = (lv) => ({ '1':'1 级 (蓝)','2':'2 级 (黄)','3':'3 级 (橙)','4':'4 级 (红)' }[lv]||'-' )
 const getLevelType = (lv) => ({ '1':'','2':'warning','3':'warning','4':'danger' }[lv]||'')
-const disposalTypeText = (t) => ({ RELEASE:'发布预警', UPGRADE:'升级预警', RESOLVE:'解除预警', RETURN:'退回重办', SUPERVISION:'督办', INSTRUCTION:'批示' }[t]||t)
+const disposalTypeText = (t) => ({ RELEASE:'发布预警', CONFIRM:'确认异常', MISREPORT:'误报清除', HANDLE:'提交处理反馈', CLEAR:'清除预警', UPGRADE:'升级预警', RESOLVE:'解除预警', RETURN:'退回重办', SUPERVISION:'督办', INSTRUCTION:'批示' }[t]||t)
 const disposalTimelineType = (t) => ({ RESOLVE:'success', UPGRADE:'warning', RETURN:'danger' }[t]||'primary')
+const attachmentsByStage = (stage) => (detail.value?.attachmentList || []).filter(item => (item.attachmentStage || item.attachment_stage || 'REPORT') === stage)
+const isImageAttachment = (attachment) => String(attachment.attachmentType || '').startsWith('image/') || /\.(png|jpe?g|gif|webp)$/i.test(attachment.attachmentName || '')
+const attachmentPreviewUrls = ref({})
+const attachmentPreviewUrl = (attachment) => attachmentPreviewUrls.value[attachment.attachmentId] || ''
+const displayAttachmentName = (attachment) => String(attachment.attachmentName || '预警附件').replace(/^\[(预警报告|处理反馈)\]\s*/, '')
+const canPreviewAttachment = (attachment) => isImageAttachment(attachment) || /\.pdf$/i.test(displayAttachmentName(attachment)) || String(attachment.attachmentType || '').toLowerCase() === 'application/pdf'
+
+function revokeAttachmentPreviews() {
+  Object.values(attachmentPreviewUrls.value).forEach(url => url && URL.revokeObjectURL(url))
+  attachmentPreviewUrls.value = {}
+}
+
+async function loadAttachmentPreviews(attachments) {
+  revokeAttachmentPreviews()
+  const imageAttachments = (attachments || []).filter(isImageAttachment)
+  await Promise.all(imageAttachments.map(async (attachment) => {
+    try {
+      const blob = await getWarningAttachmentPreviewData(attachment.attachmentId)
+      attachmentPreviewUrls.value[attachment.attachmentId] = URL.createObjectURL(blob)
+    } catch (e) {
+      console.error('预警图片加载失败', attachment.attachmentId, e)
+    }
+  }))
+}
+
+async function previewAttachment(attachment) {
+  try {
+    const blob = isImageAttachment(attachment) && attachmentPreviewUrl(attachment)
+      ? null
+      : await getWarningAttachmentPreviewData(attachment.attachmentId)
+    const url = attachmentPreviewUrl(attachment) || URL.createObjectURL(blob)
+    window.open(url, '_blank', 'noopener,noreferrer')
+    if (blob) setTimeout(() => URL.revokeObjectURL(url), 60000)
+  } catch (e) {
+    ElMessage.error('附件预览失败')
+  }
+}
+
+async function downloadAttachment(attachment) {
+  try {
+    const blob = await getWarningAttachmentDownloadData(attachment.attachmentId)
+    const url = URL.createObjectURL(blob)
+    const link = document.createElement('a')
+    link.href = url
+    link.download = displayAttachmentName(attachment)
+    document.body.appendChild(link)
+    link.click()
+    document.body.removeChild(link)
+    setTimeout(() => URL.revokeObjectURL(url), 1000)
+  } catch (e) {
+    ElMessage.error('附件下载失败')
+  }
+}
 
 // ========== 操作处理 ==========
 function handleSearch() { pageNum.value=1; loadList() }
-function handleReset() { queryParams.value={warningName:'',status:'',warningLevel:'',category:'',startDate:'',endDate:''}; pageNum.value=1; loadList() }
+function handleReset() { queryParams.value={warningName:'',deviceCode:'',deviceName:'',status:'',warningLevel:'',category:'',startDate:'',endDate:''}; pageNum.value=1; loadList() }
 
 // ========== 确认异常 ==========
 const confirmVisible = ref(false)
 const current = ref({})
-const deviceSelected = ref(null)
-const deviceOptions = ref([])
-const deviceLoading = ref(false)
 const confirmingRemark = ref('')
+const assignedUser = ref('')
 const submitting = ref(false)
 
 function openConfirm(row) {
   current.value = row
-  deviceSelected.value = null
-  deviceOptions.value = []
   confirmingRemark.value = ''
-  extractDeviceCode(row)
+  assignedUser.value = row.assignedUser || row.handler || ''
   confirmVisible.value = true
 }
 
-function extractDeviceCode(row) {
-  // remark 格式:"AUTO|equipmentCode|warningCode"
-  if (row.remark && row.remark.startsWith('AUTO|')) {
-    const parts = row.remark.split('|')
-    if (parts.length >= 2) searchDevice(parts[1])
-  } else {
-    const m = (row.warningContent || '').match(/设备 \[([^\]]+)\]/)
-    if (m) searchDevice(m[1])
-  }
-}
-
-async function searchDevice(query) {
-  if (!query) return
-  deviceLoading.value = true
-  try {
-    const res = await getEquipmentFullPage(1, 20, { equipmentCode: query })
-    if (res.data) deviceOptions.value = (res.data.rows || []) || []
-  } catch(e) { console.warn('设备搜索失败', e) }
-  finally { deviceLoading.value = false }
-}
-
 async function submitConfirm() {
-  if (!deviceSelected.value) { ElMessage.warning('请选择关联设备'); return }
   if (!confirmingRemark.value.trim()) { ElMessage.warning('请填写异常描述'); return }
   
   submitting.value = true
   try {
-    await publishWarningData(current.value.warningId)
+    await confirmWarningData({ warningId: current.value.warningId, assignedUser: assignedUser.value, confirmRemark: confirmingRemark.value })
     const orderDesc = `预警「${current.value.warningName}」异常确认;${confirmingRemark.value}`
-    const res = await addManholeWorkOrder({ alarmId: current.value.warningId, deviceId: deviceSelected.value, orderType: 1, orderLevel: getWorkOrderLevel(current.value.warningLevel), orderDesc: orderDesc.slice(0,500) })
+    await addManholeWorkOrder({ alarmId: current.value.warningId, orderType: 1, orderLevel: getWorkOrderLevel(current.value.warningLevel), orderDesc: orderDesc.slice(0,500) })
     ElMessage.success(`✅ 预警已确认 | 工单已生成 (WO-...) | 请前往工单系统继续处理`)
     confirmVisible.value = false
     loadList()
@@ -500,12 +603,86 @@ function confirmMisreport(row) {
     confirmButtonText:'确认误报',
     cancelButtonText:'取消'
   }).then(async ({value}) => {
-    await resolveWarningData({ warningId: row.warningId, disposalContent: '误报:'+value })
+    await misreportWarningData({ warningId: row.warningId, reason: value || '系统确认误报' })
     ElMessage.success('已标记为误报并解除')
     loadList()
   }).catch(()=>{})
 }
 
+const processVisible = ref(false)
+const processRow = ref({})
+const processContent = ref('')
+const processFileList = ref([])
+const processFiles = ref([])
+
+function openProcess(row) {
+  processRow.value = row
+  processContent.value = ''
+  processFiles.value = []
+  processFileList.value = []
+  processVisible.value = true
+}
+
+function handleProcessFileChange(uploadFile, uploadFiles) {
+  const raw = uploadFile.raw
+  const extension = String(raw?.name || '').split('.').pop().toLowerCase()
+  const allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'xls', 'xlsx', 'doc', 'docx']
+  if (!raw || !allowedExtensions.includes(extension)) {
+    processFileList.value = uploadFiles.filter(item => item.uid !== uploadFile.uid)
+    ElMessage.warning('仅支持图片、PDF、Excel、Word文件')
+    return
+  }
+  if (raw.size > 20 * 1024 * 1024) {
+    processFileList.value = uploadFiles.filter(item => item.uid !== uploadFile.uid)
+    ElMessage.warning('单个附件不能超过20MB')
+    return
+  }
+  processFileList.value = uploadFiles
+  processFiles.value = uploadFiles.map(item => item.raw).filter(Boolean)
+}
+
+function handleProcessFileRemove(uploadFile, uploadFiles) {
+  handleProcessFileChange(uploadFile, uploadFiles)
+}
+
+function handleProcessFileExceed() {
+  ElMessage.warning('最多上传9个附件')
+}
+
+async function submitProcess() {
+  if (!processContent.value.trim()) {
+    ElMessage.warning('请填写处理反馈')
+    return
+  }
+  submitting.value = true
+  try {
+    await submitWarningProcessData({ warningId: processRow.value.warningId, processContent: processContent.value, files: processFiles.value })
+    ElMessage.success('处理反馈已提交')
+    processVisible.value = false
+    await loadList()
+  } catch (e) {
+    ElMessage.error('提交处理反馈失败')
+  } finally {
+    submitting.value = false
+  }
+}
+
+function clearHandled(row) {
+  ElMessageBox.prompt('请输入清除说明', '清除预警', {
+    inputPlaceholder: '例如:维修完成,现场复核正常',
+    confirmButtonText: '确认清除',
+    cancelButtonText: '取消'
+  }).then(async ({ value }) => {
+    if (!value || !value.trim()) {
+      ElMessage.warning('请填写清除说明')
+      return
+    }
+    await clearWarningData({ warningId: row.warningId, clearRemark: value })
+    ElMessage.success('预警已清除')
+    loadList()
+  }).catch(() => {})
+}
+
 // ========== 详情抽屉 ==========
 const detailVisible = ref(false)
 const detail = ref(null)
@@ -513,13 +690,18 @@ const detail = ref(null)
 async function openDetail(warningId) {
   detailVisible.value = true
   detail.value = null
+  revokeAttachmentPreviews()
   try {
     const res = await getWarningFullDetailData(warningId)
-    if (res.data) detail.value = res.data
+    if (res.data) {
+      detail.value = res.data
+      await loadAttachmentPreviews(res.data.attachmentList)
+    }
   } catch(e) { ElMessage.error('详情加载中'); console.error(e) }
 }
 
 onBeforeUnmount(() => {
+  revokeAttachmentPreviews()
   window.removeEventListener('resize', resizeCharts)
   trendChart?.dispose()
   levelChart?.dispose()
@@ -539,19 +721,23 @@ onMounted(async () => {
 .warning-container { padding: 20px; min-height: calc(100vh - 84px); }
 .stat-row { margin-bottom: 16px; }
 .stat-item {
-  background: #fff; border-radius: 12px; padding: 12px 10px; text-align: center;
-  box-shadow: 0 2px 8px rgba(0,0,0,0.06); border: 1px solid #eef2f6;
-  transition: transform 0.2s, box-shadow 0.2s;
-}
-.stat-item:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,0.1); }
-.stat-title { font-size: 13px; color: #7a8a9a; margin-bottom: 4px; }
-.stat-value { font-size: 28px; font-weight: 700; color: #3a4a5a; transition: all 0.3s; }
-.stat-value.primary { color: #409eff; }
-.stat-value.warning { color: #e6a23c; }
-.stat-value.info { color: #909399; }
-.stat-value.success { color: #67c23a; }
-.stat-value.done { color: #27ae60; }
-.stat-value.today { color: #9b59b6; }
+  position:relative;min-height:104px;padding:16px 16px 12px 62px;background:#fff;
+  border:1px solid #e9edf3;border-radius:10px;box-shadow:0 3px 12px rgba(31,45,61,.07);
+  overflow:hidden;transition:transform .2s,box-shadow .2s;
+}
+.stat-item::before { content:'';position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--stat-color); }
+.stat-item:hover { transform:translateY(-2px);box-shadow:0 8px 20px rgba(31,45,61,.12); }
+.stat-icon { position:absolute;left:16px;top:22px;width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--stat-color);background:var(--stat-bg);font-size:19px; }
+.stat-content { display:flex;align-items:baseline;justify-content:space-between;gap:8px; }
+.stat-title { font-size:13px;color:#667085;white-space:nowrap; }
+.stat-value { font-size:30px;line-height:34px;font-weight:700;color:var(--stat-color);transition:all .3s; }
+.stat-caption { margin-top:8px;color:#98a2b3;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
+.stat-primary { --stat-color:#409eff;--stat-bg:#ecf5ff; }
+.stat-warning { --stat-color:#e6a23c;--stat-bg:#fdf6ec; }
+.stat-processing { --stat-color:#8a96a3;--stat-bg:#f2f4f7; }
+.stat-handled { --stat-color:#67c23a;--stat-bg:#f0f9eb; }
+.stat-closed { --stat-color:#18a567;--stat-bg:#eafaf3; }
+.stat-today { --stat-color:#8e5cc7;--stat-bg:#f5effb; }
 .stat-value.highlight { animation: pulse 1.5s ease-in-out; }
 @keyframes pulse { 0%{transform:scale(1)}50%{transform:scale(1.12)}100%{transform:scale(1)} }
 
@@ -567,17 +753,35 @@ onMounted(async () => {
 .search-card { margin-bottom: 16px; }
 .table-card { min-height: 600px; }
 .device-cell { display:flex; align-items:center; gap:4px; }
+.device-name { max-width:96px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#606266; font-size:12px; }
 .no-device { color:#c0c4cc; font-size:12px; }
+:deep(.publish-time-column .cell) { white-space: nowrap; }
 
 .detail-section-title { font-weight:600;font-size:14px;color:#2c3e50;margin:16px 0 10px;padding-bottom:8px;border-bottom:1px solid #eef2f6 }
 .detail-sub { font-size:12px;color:#9098a6;margin-top:4px }
 .detail-empty { font-size:13px;color:#9098a6;padding:12px 0 }
 .attachment-list { display:flex;flex-direction:column;gap:6px }
-.attachment-link { font-size:13px;color:#3498db;text-decoration:none }
-.attachment-link:hover { text-decoration:underline }
+.attachment-group { margin-bottom:12px }
+.attachment-stage { color:#606266;font-size:13px;font-weight:600;margin-bottom:6px }
+.attachment-grid { display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px }
+.attachment-item { min-width:0;padding:8px;border:1px solid #ebeef5;border-radius:6px;background:#fff }
+.attachment-image,.attachment-image-loading,.attachment-file-icon { width:100%;height:86px;border-radius:4px;overflow:hidden }
+.attachment-image-loading,.attachment-image-error,.attachment-file-icon { display:flex;align-items:center;justify-content:center;color:#a8abb2;background:#f5f7fa;font-size:12px }
+.attachment-file-icon { color:#409eff;font-size:32px }
+.attachment-name { margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#606266;font-size:12px }
+.attachment-actions { display:flex;justify-content:center;gap:4px;margin-top:3px }
+.attachment-actions .el-button { margin-left:0;padding:2px 3px }
+.supervision-list { display:flex;flex-direction:column;gap:8px }
+.supervision-item { padding:8px 10px;background:#f7f8fa;border-radius:4px;font-size:12px }
+.supervision-item span { color:#909399;margin-left:8px }
+.supervision-item p { margin:6px 0 0;color:#606266 }
+.upload-tip { color:#909399;font-size:12px;line-height:20px }
 
 @media (max-width: 1300px) {
   .stat-row { display:flex; gap:10px; flex-wrap:wrap; }
   .stat-row > * { flex-shrink:0; width: calc(33.33% - 14px) !important; }
 }
+@media (max-width: 900px) {
+  .stat-row > * { width: calc(50% - 10px) !important; }
+}
 </style>

+ 236 - 0
src/views/subSystem/warningList/codeGenerate/index.vue

@@ -0,0 +1,236 @@
+<template>
+  <div class="code-page">
+    <div class="code-hero">
+      <div>
+        <span>IDENTITY GENERATOR</span>
+        <h1>事项编码生成</h1>
+        <p>选择预警类型与等级,系统按统一编码规则生成唯一的预警事项编码。</p>
+      </div>
+      <div class="code-orbit">
+        <div class="orbit-line"></div>
+        <el-icon><Connection /></el-icon>
+      </div>
+    </div>
+
+    <div class="generator-grid">
+      <el-card class="steps-card" shadow="never">
+        <div class="step-heading">
+          <span>编码配置</span>
+          <small>STEP {{ currentStep }}/2</small>
+        </div>
+        <div class="step-track">
+          <div class="track-item active"><span>01</span><div><strong>选择类型</strong><small>确定预警所属领域</small></div></div>
+          <div class="track-line"></div>
+          <div class="track-item" :class="{ active: form.warningLevel }"><span>02</span><div><strong>选择等级</strong><small>确定预警响应级别</small></div></div>
+          <div class="track-line"></div>
+          <div class="track-item" :class="{ active: generatedCode }"><span>03</span><div><strong>生成编码</strong><small>得到唯一事项标识</small></div></div>
+        </div>
+
+        <div class="field-block">
+          <label>预警类型</label>
+          <div class="type-grid">
+            <button v-for="item in warningTypes" :key="item.value" :class="{ selected: form.warningType === item.value }" @click="selectType(item.value)">
+              <span class="type-symbol"><el-icon><Warning /></el-icon></span>
+              <span>{{ item.label.replace('预警', '') }}</span>
+              <el-icon v-if="form.warningType === item.value" class="selected-icon"><Check /></el-icon>
+            </button>
+          </div>
+        </div>
+
+        <div class="field-block">
+          <label>预警级别</label>
+          <div class="level-grid">
+            <button
+              v-for="item in warningLevels"
+              :key="item.value"
+              :class="{ selected: form.warningLevel === item.value }"
+              :style="{ '--level-color': item.color }"
+              @click="selectLevel(item.value)"
+            >
+              <span class="level-dot"></span>
+              <div><strong>{{ item.shortLabel }}预警</strong><small>{{ item.label.match(/(.*)/)?.[0] || '' }}</small></div>
+              <el-icon v-if="form.warningLevel === item.value"><Check /></el-icon>
+            </button>
+          </div>
+        </div>
+
+        <div class="generate-actions">
+          <el-button @click="reset">重新选择</el-button>
+          <el-button type="primary" :disabled="!canGenerate" :loading="loading" :icon="MagicStick" @click="generate">生成事项编码</el-button>
+        </div>
+      </el-card>
+
+      <el-card class="result-card" shadow="never">
+        <div class="result-heading"><span>生成结果</span><el-tag v-if="generatedCode" type="success">已生成</el-tag></div>
+        <div v-if="generatedCode" class="code-result">
+          <div class="result-icon"><el-icon><Key /></el-icon></div>
+          <span class="result-label">预警事项编码</span>
+          <strong>{{ generatedCode }}</strong>
+          <button class="copy-button" @click="copyCode"><el-icon><CopyDocument /></el-icon> 复制编码</button>
+          <div class="code-parts">
+            <div><b>WI</b><span>事项标识</span></div>
+            <div><b>{{ typePrefix }}</b><span>类型缩写</span></div>
+            <div><b>{{ form.warningLevel }}</b><span>预警等级</span></div>
+            <div><b>{{ datePart }}</b><span>生成日期</span></div>
+            <div><b>{{ sequencePart }}</b><span>流水号</span></div>
+          </div>
+        </div>
+        <div v-else class="empty-result">
+          <div class="empty-illustration"><el-icon><MagicStick /></el-icon></div>
+          <strong>等待生成编码</strong>
+          <p>完成左侧类型和等级选择后<br />点击“生成事项编码”获取唯一标识</p>
+        </div>
+        <div class="rule-box">
+          <div class="rule-title"><el-icon><InfoFilled /></el-icon> 编码规则</div>
+          <p><code>WI + 类型缩写 + 等级 + YYYYMMDD + 4位流水号</code></p>
+          <span>同一类型、等级和日期下流水号自动递增,确保编码唯一。</span>
+        </div>
+      </el-card>
+    </div>
+  </div>
+</template>
+
+<script setup name="WarningCodeGenerate">
+import { computed, onMounted, reactive, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Check, Connection, CopyDocument, InfoFilled, Key, MagicStick, Warning } from '@element-plus/icons-vue'
+import { getDicts } from '@/api/system/dict/data'
+import { generateWarningItemCode } from '@/api/warning/list'
+import { fallbackWarningTypes, normalizeWarningTypeDict, warningLevels } from '../shared'
+
+const loading = ref(false)
+const generatedCode = ref('')
+const warningTypes = ref(fallbackWarningTypes)
+const form = reactive({ warningType: '', warningLevel: '' })
+const canGenerate = computed(() => !!form.warningType && !!form.warningLevel)
+const currentStep = computed(() => generatedCode.value ? 3 : form.warningLevel ? 2 : 1)
+const typePrefix = computed(() => (form.warningType || '').slice(0, 3).toUpperCase())
+const datePart = computed(() => generatedCode.value ? generatedCode.value.slice(-12, -4) : 'YYYYMMDD')
+const sequencePart = computed(() => generatedCode.value ? generatedCode.value.slice(-4) : '0001')
+
+async function loadTypes() {
+  try {
+    const res = await getDicts('warning_type')
+    warningTypes.value = normalizeWarningTypeDict(res.data)
+  } catch {
+    warningTypes.value = fallbackWarningTypes
+  }
+}
+function selectType(value) {
+  form.warningType = value
+  generatedCode.value = ''
+}
+function selectLevel(value) {
+  form.warningLevel = value
+  generatedCode.value = ''
+}
+function reset() {
+  form.warningType = ''
+  form.warningLevel = ''
+  generatedCode.value = ''
+}
+async function generate() {
+  if (!canGenerate.value) return
+  loading.value = true
+  try {
+    const res = await generateWarningItemCode(form.warningType, form.warningLevel)
+    generatedCode.value = res.data || ''
+    if (generatedCode.value) ElMessage.success('事项编码生成成功')
+  } finally {
+    loading.value = false
+  }
+}
+async function copyCode() {
+  if (!generatedCode.value) return
+  await navigator.clipboard.writeText(generatedCode.value)
+  ElMessage.success('编码已复制')
+}
+
+onMounted(loadTypes)
+</script>
+
+<style scoped lang="scss">
+.code-page { min-height: calc(100vh - 84px); padding: 20px; background: #f4f7fb; }
+.code-hero {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 28px 34px;
+  color: #fff;
+  border-radius: 18px;
+  background: linear-gradient(120deg, #312e81, #4f46a5 52%, #7c3aed);
+  box-shadow: 0 15px 40px rgba(73, 54, 163, .25);
+  span { color: #d9d4ff; font-size: 11px; letter-spacing: 3px; }
+  h1 { margin: 6px 0 8px; font-size: 28px; }
+  p { margin: 0; color: #ddd9ff; }
+}
+.code-orbit { position: relative; display: grid; width: 90px; height: 90px; font-size: 28px; border: 1px solid rgba(255, 255, 255, .3); border-radius: 50%; background: rgba(255, 255, 255, .1); place-items: center; }
+.orbit-line { position: absolute; width: 112px; height: 38px; border: 1px solid rgba(255, 255, 255, .35); border-radius: 50%; transform: rotate(-32deg); }
+.generator-grid { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(340px, .7fr); gap: 16px; margin-top: 18px; }
+.steps-card, .result-card { border: 1px solid #e4e9f1; border-radius: 16px; }
+.step-heading, .result-heading { display: flex; align-items: center; justify-content: space-between; color: #1e293b; font-weight: 700; }
+.step-heading small { color: #8fa0b3; font: 11px Consolas, monospace; }
+.step-track { display: flex; align-items: center; margin: 26px 0 32px; }
+.track-item { display: flex; align-items: center; gap: 9px; color: #a0adbc; white-space: nowrap; span { display: grid; width: 30px; height: 30px; border: 1px solid #dce4ed; border-radius: 50%; place-items: center; } div { display: flex; flex-direction: column; gap: 3px; } strong { font-size: 12px; } small { font-size: 10px; } &.active { color: #4f46a5; span { color: #fff; border-color: #4f46a5; background: #4f46a5; } } }
+.track-line { flex: 1; height: 1px; margin: 0 12px; background: #e3e8ef; }
+.field-block { margin-bottom: 27px; label { display: block; margin-bottom: 12px; color: #334155; font-size: 14px; font-weight: 700; } }
+.type-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
+.type-grid button {
+  position: relative;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 13px;
+  color: #526477;
+  text-align: left;
+  border: 1px solid #e5eaf1;
+  border-radius: 10px;
+  background: #fff;
+  cursor: pointer;
+  &:hover, &.selected { color: #4f46a5; border-color: #9b94eb; background: #f5f3ff; }
+}
+.type-symbol { display: grid; width: 29px; height: 29px; color: #746ce1; border-radius: 8px; background: #ebe9ff; place-items: center; }
+.selected-icon { position: absolute; top: 8px; right: 8px; color: #4f46a5; }
+.level-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
+.level-grid button {
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  padding: 13px 10px;
+  color: #64748b;
+  text-align: left;
+  border: 1px solid #e6eaf0;
+  border-radius: 10px;
+  background: #fff;
+  cursor: pointer;
+  .level-dot { flex: 0 0 10px; width: 10px; height: 10px; border-radius: 50%; background: var(--level-color); }
+  div { display: flex; flex-direction: column; gap: 3px; }
+  strong { color: #334155; font-size: 12px; }
+  small { color: #94a3b8; font-size: 10px; }
+  .el-icon { margin-left: auto; color: var(--level-color); }
+  &:hover, &.selected { border-color: var(--level-color); background: color-mix(in srgb, var(--level-color) 7%, #fff); }
+}
+.generate-actions { display: flex; justify-content: flex-end; gap: 10px; padding-top: 10px; border-top: 1px solid #edf0f4; }
+.result-card { min-height: 500px; }
+.result-heading { padding-bottom: 16px; border-bottom: 1px solid #edf0f4; small { color: #94a3b8; } }
+.code-result { display: flex; align-items: center; flex-direction: column; padding: 34px 8px 24px; text-align: center; }
+.result-icon { display: grid; width: 58px; height: 58px; margin-bottom: 14px; color: #4f46a5; font-size: 26px; border-radius: 18px; background: #efedff; place-items: center; }
+.result-label { color: #94a3b8; font-size: 12px; }
+.code-result > strong { margin: 9px 0 15px; color: #27205d; font: 25px Consolas, monospace; letter-spacing: 1px; }
+.copy-button { padding: 7px 12px; color: #4f46a5; border: 1px solid #c9c5fa; border-radius: 7px; background: #f7f6ff; cursor: pointer; }
+.code-parts { display: flex; width: 100%; justify-content: center; gap: 8px; margin-top: 28px; }
+.code-parts div { display: flex; min-width: 46px; flex-direction: column; gap: 4px; padding: 8px 5px; border: 1px solid #eceef4; border-radius: 7px; background: #fafbfe; }
+.code-parts b { color: #4f46a5; font: 12px Consolas, monospace; }
+.code-parts span { color: #94a3b8; font-size: 9px; }
+.empty-result { display: flex; align-items: center; flex-direction: column; padding: 70px 0 60px; text-align: center; }
+.empty-illustration { display: grid; width: 72px; height: 72px; margin-bottom: 14px; color: #a39df1; font-size: 34px; border-radius: 22px; background: #f2f1ff; place-items: center; }
+.empty-result strong { color: #48566b; }
+.empty-result p { color: #9aa7b6; font-size: 12px; line-height: 1.8; }
+.rule-box { padding: 14px; border-radius: 10px; background: #f7f7ff; }
+.rule-title { color: #5c55bc; font-size: 12px; font-weight: 700; }
+.rule-box p { margin: 8px 0; color: #5e6581; font-size: 11px; }
+.rule-box code { color: #4f46a5; font: 11px Consolas, monospace; }
+.rule-box > span { color: #9aa0b3; font-size: 10px; }
+@media (max-width: 1100px) { .generator-grid { grid-template-columns: 1fr; } }
+@media (max-width: 700px) { .type-grid, .level-grid { grid-template-columns: repeat(2, 1fr); } .track-item div { display: none; } }
+</style>

+ 224 - 0
src/views/subSystem/warningList/itemDetail/index.vue

@@ -0,0 +1,224 @@
+<template>
+  <div class="detail-page">
+    <section class="detail-hero">
+      <div>
+        <span>WARNING ITEM PROFILE</span>
+        <h1>预警事项详情</h1>
+        <p>通过事项编码查看完整配置、处置环节和可选预警原因。</p>
+      </div>
+      <div class="search-box">
+        <el-input v-model="itemCode" placeholder="输入预警事项编码" clearable @keyup.enter="search">
+          <template #prepend><el-icon><Search /></el-icon></template>
+          <template #append><el-button type="primary" @click="search">查询详情</el-button></template>
+        </el-input>
+      </div>
+    </section>
+
+    <template v-if="hasDetail">
+      <section class="identity-card">
+        <div class="identity-main">
+          <div class="identity-icon" :style="{ '--level-color': levelMeta(detail.warningLevel).color }"><el-icon><Warning /></el-icon></div>
+          <div>
+            <span class="identity-code">{{ detail.itemCode }}</span>
+            <h2>{{ detail.itemName }}</h2>
+            <div class="identity-tags">
+              <el-tag :type="levelMeta(detail.warningLevel).tagType">{{ levelMeta(detail.warningLevel).label }}</el-tag>
+              <el-tag effect="plain">{{ typeLabel(detail.warningType) }}</el-tag>
+              <el-tag :type="statusMeta(detail.status).type">{{ statusMeta(detail.status).label }}</el-tag>
+            </div>
+          </div>
+        </div>
+        <div class="identity-meta">
+          <div><span>所属行业</span><strong>{{ detail.industry || '-' }}</strong></div>
+          <div><span>预警专项</span><strong>{{ detail.warningSpecial || '通用专项' }}</strong></div>
+          <div><span>响应时长</span><strong>{{ detail.duration || '-' }}<small> 小时</small></strong></div>
+        </div>
+      </section>
+
+      <div class="detail-grid">
+        <main>
+          <el-card class="info-card" shadow="never">
+            <template #header><div class="card-heading"><span>环节配置</span><small>{{ detailLinks.length }} 个标准环节</small></div></template>
+            <div v-if="detailLinks.length" class="process-flow">
+              <div v-for="(link, index) in detailLinks" :key="index" class="process-item">
+                <div class="process-node">{{ String(index + 1).padStart(2, '0') }}</div>
+                <div class="process-line" v-if="index < detailLinks.length - 1"></div>
+                <div class="process-content">
+                  <div class="process-title"><strong>{{ link.name }}</strong><span>{{ link.timeLimit || '-' }} 分钟</span></div>
+                  <small>责任方:{{ link.handler || '未配置' }}</small>
+                  <p>{{ link.description || '暂无环节说明' }}</p>
+                </div>
+              </div>
+            </div>
+            <el-empty v-else description="暂未配置环节" />
+          </el-card>
+          <el-card class="info-card" shadow="never">
+            <template #header><div class="card-heading"><span>事项备注</span></div></template>
+            <div class="remark-content">{{ detail.remark || '暂无补充说明' }}</div>
+          </el-card>
+        </main>
+
+        <aside>
+          <el-card class="info-card reason-card" shadow="never">
+            <template #header><div class="card-heading"><span>可选预警原因</span><small>{{ reasons.length }} 条</small></div></template>
+            <div v-loading="reasonLoading">
+              <div v-for="reason in reasons" :key="reason.reasonId" class="reason-row">
+                <span class="reason-index">{{ reason.sortOrder ?? '-' }}</span>
+                <div><strong>{{ reason.reasonName }}</strong><p>{{ reason.description || '暂无描述' }}</p></div>
+              </div>
+              <el-empty v-if="!reasonLoading && !reasons.length" description="暂无启用原因" :image-size="60" />
+            </div>
+          </el-card>
+          <el-card class="info-card meta-card" shadow="never">
+            <template #header><div class="card-heading"><span>记录信息</span></div></template>
+            <el-descriptions :column="1" size="small">
+              <el-descriptions-item label="创建时间">{{ detail.createTime || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="创建人">{{ detail.createBy || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="更新时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="更新人">{{ detail.updateBy || '-' }}</el-descriptions-item>
+            </el-descriptions>
+          </el-card>
+        </aside>
+      </div>
+    </template>
+    <el-card v-else class="empty-detail" shadow="never">
+      <div class="empty-detail-icon"><el-icon><Search /></el-icon></div>
+      <h3>{{ searched ? '未找到对应事项' : '输入编码开始查询' }}</h3>
+      <p>{{ searched ? '请检查事项编码是否正确,或前往事项信息查询浏览完整清单。' : '事项编码是预警清单的唯一标识,支持精确查询。' }}</p>
+    </el-card>
+  </div>
+</template>
+
+<script setup name="WarningItemDetail">
+import { computed, onMounted, reactive, ref } from 'vue'
+import { useRoute } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import { Search, Warning } from '@element-plus/icons-vue'
+import { getDicts } from '@/api/system/dict/data'
+import { getWarningItemByCode, listReasonsByTypeAndLevel } from '@/api/warning/list'
+import {
+  fallbackWarningTypes,
+  levelMeta,
+  normalizeWarningTypeDict,
+  optionLabel,
+  parseLinkConfig,
+  statusMeta
+} from '../shared'
+
+const route = useRoute()
+const itemCode = ref(String(route.query.code || ''))
+const searched = ref(false)
+const reasonLoading = ref(false)
+const warningTypes = ref(fallbackWarningTypes)
+const reasons = ref([])
+const detail = reactive({})
+const hasDetail = computed(() => !!detail.itemId)
+const detailLinks = computed(() => parseLinkConfig(detail.linkConfig))
+
+function typeLabel(value) {
+  return optionLabel(warningTypes.value, value)
+}
+async function loadTypes() {
+  try {
+    const res = await getDicts('warning_type')
+    warningTypes.value = normalizeWarningTypeDict(res.data)
+  } catch {
+    warningTypes.value = fallbackWarningTypes
+  }
+}
+async function search() {
+  if (!itemCode.value.trim()) {
+    ElMessage.warning('请输入预警事项编码')
+    return
+  }
+  searched.value = true
+  Object.keys(detail).forEach(key => delete detail[key])
+  reasons.value = []
+  try {
+    const res = await getWarningItemByCode(itemCode.value.trim())
+    Object.assign(detail, res.data || {})
+    await loadReasons()
+  } catch {
+    // request 统一提示错误,页面保留空状态
+  }
+}
+async function loadReasons() {
+  reasonLoading.value = true
+  try {
+    const res = await listReasonsByTypeAndLevel(detail.warningType, detail.warningLevel)
+    reasons.value = res.data || []
+  } finally {
+    reasonLoading.value = false
+  }
+}
+
+onMounted(async () => {
+  await loadTypes()
+  if (itemCode.value) search()
+})
+</script>
+
+<style scoped lang="scss">
+.detail-page { min-height: calc(100vh - 84px); padding: 20px; background: #f4f7fb; }
+.detail-hero {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 25px;
+  padding: 28px 32px;
+  color: #fff;
+  border-radius: 18px;
+  background: linear-gradient(120deg, #172554, #1e40af 55%, #2563eb);
+  box-shadow: 0 14px 38px rgba(29, 64, 160, .23);
+  > div:first-child { flex: 1; }
+  span { color: #9bc7ff; font-size: 11px; letter-spacing: 3px; }
+  h1 { margin: 6px 0 8px; font-size: 28px; }
+  p { margin: 0; color: #cad9f7; }
+}
+.search-box { width: 390px; :deep(.el-input-group__prepend) { padding: 0 13px; color: #4f6ea8; background: #fff; } :deep(.el-input__wrapper) { box-shadow: none; } }
+.identity-card {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 20px;
+  margin: 16px 0;
+  padding: 22px 26px;
+  border: 1px solid #dfe7f1;
+  border-radius: 16px;
+  background: #fff;
+  box-shadow: 0 8px 25px rgba(39, 61, 91, .06);
+}
+.identity-main { display: flex; align-items: center; gap: 16px; }
+.identity-icon { display: grid; width: 58px; height: 58px; color: var(--level-color); font-size: 27px; border: 1px solid color-mix(in srgb, var(--level-color) 25%, transparent); border-radius: 17px; background: color-mix(in srgb, var(--level-color) 10%, #fff); place-items: center; }
+.identity-code { color: #8392a7; font: 12px Consolas, monospace; }
+.identity-main h2 { margin: 5px 0 10px; color: #20314b; font-size: 22px; }
+.identity-tags { display: flex; gap: 7px; }
+.identity-meta { display: flex; gap: 34px; }
+.identity-meta div { display: flex; flex-direction: column; gap: 6px; }
+.identity-meta span { color: #94a3b8; font-size: 11px; }
+.identity-meta strong { color: #334155; font-size: 15px; }
+.identity-meta small { color: #94a3b8; font-size: 11px; }
+.detail-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .55fr); gap: 16px; }
+.info-card { margin-bottom: 16px; border: 1px solid #e2e8f0; border-radius: 14px; }
+.card-heading { display: flex; align-items: center; justify-content: space-between; color: #1f3149; font-weight: 700; small { color: #94a3b8; font-size: 11px; font-weight: 400; } }
+.process-flow { padding: 8px 8px 12px; }
+.process-item { position: relative; display: flex; gap: 16px; min-height: 115px; }
+.process-node { z-index: 1; display: grid; flex: 0 0 42px; width: 42px; height: 42px; color: #fff; font: 12px Consolas, monospace; border: 4px solid #e9f0ff; border-radius: 50%; background: #3168d8; place-items: center; }
+.process-line { position: absolute; top: 42px; bottom: 0; left: 20px; width: 2px; background: #dbe6f8; }
+.process-content { flex: 1; padding: 3px 0 22px; }
+.process-title { display: flex; align-items: center; justify-content: space-between; strong { color: #273a54; } span { color: #3168d8; font: 12px Consolas, monospace; } }
+.process-content > small { display: block; margin: 7px 0; color: #8795a8; }
+.process-content p { margin: 0; color: #66778d; line-height: 1.65; }
+.remark-content { min-height: 90px; color: #66778d; line-height: 1.8; }
+.reason-row { display: flex; gap: 10px; padding: 13px 0; border-bottom: 1px dashed #e8edf3; &:last-child { border-bottom: 0; } }
+.reason-index { display: grid; flex: 0 0 25px; width: 25px; height: 25px; color: #2563eb; font-size: 11px; border-radius: 7px; background: #edf4ff; place-items: center; }
+.reason-row div { min-width: 0; }
+.reason-row strong { color: #334155; font-size: 13px; }
+.reason-row p { overflow: hidden; margin: 5px 0 0; color: #94a3b8; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.empty-detail { display: grid; min-height: 420px; margin-top: 16px; place-items: center; text-align: center; }
+.empty-detail-icon { display: grid; width: 72px; height: 72px; color: #7191d1; font-size: 35px; border-radius: 23px; background: #edf3ff; place-items: center; }
+.empty-detail h3 { margin: 16px 0 6px; color: #334155; }
+.empty-detail p { margin: 0; color: #94a3b8; font-size: 12px; }
+@media (max-width: 1000px) { .detail-grid { grid-template-columns: 1fr; } .identity-meta { gap: 16px; } }
+@media (max-width: 720px) { .detail-hero, .identity-card { align-items: flex-start; flex-direction: column; } .search-box { width: 100%; } .identity-meta { width: 100%; justify-content: space-between; } }
+</style>

+ 628 - 0
src/views/subSystem/warningList/itemManage/index.vue

@@ -0,0 +1,628 @@
+<template>
+  <div class="manage-page">
+    <header class="manage-header">
+      <div>
+        <span class="header-kicker">CONFIGURATION CENTER</span>
+        <h1>预警事项管理</h1>
+        <p>维护预警编码、类型、等级、时长与处置环节,形成标准化预警清单。</p>
+      </div>
+      <el-button
+        v-hasPermi="['warning:item:add']"
+        type="primary"
+        size="large"
+        :icon="Plus"
+        @click="openCreate"
+      >
+        新增预警事项
+      </el-button>
+    </header>
+
+    <div class="workspace">
+      <aside class="side-panel">
+        <div class="side-title">快速筛选</div>
+        <el-input v-model="query.itemName" :prefix-icon="Search" placeholder="事项名称 / 编码" clearable @keyup.enter="handleQuery" />
+        <div class="filter-label">预警类型</div>
+        <el-select v-model="query.warningType" placeholder="全部类型" clearable @change="handleQuery">
+          <el-option v-for="item in warningTypes" :key="item.value" :label="item.label" :value="item.value" />
+        </el-select>
+        <div class="filter-label">预警级别</div>
+        <div class="level-filter">
+          <button
+            v-for="item in warningLevels"
+            :key="item.value"
+            :class="{ active: query.warningLevel === item.value }"
+            :style="{ '--level-color': item.color }"
+            @click="toggleLevel(item.value)"
+          >
+            <span></span>{{ item.shortLabel }}
+          </button>
+        </div>
+        <div class="filter-label">事项状态</div>
+        <div class="status-filter">
+          <button
+            v-for="item in itemStatuses"
+            :key="item.value"
+            :class="{ active: query.status === item.value }"
+            @click="toggleStatus(item.value)"
+          >
+            {{ item.label }}
+          </button>
+        </div>
+        <el-button class="reset-button" :icon="Refresh" @click="resetQuery">清空筛选</el-button>
+      </aside>
+
+      <main class="main-panel">
+        <el-card class="table-card" shadow="never">
+          <template #header>
+            <div class="table-heading">
+              <div>
+                <strong>事项配置清单</strong>
+                <span>{{ total }} 条配置记录</span>
+              </div>
+              <el-button :icon="RefreshRight" circle @click="loadData" />
+            </div>
+          </template>
+          <el-table v-loading="loading" :data="tableData" row-key="itemId">
+            <el-table-column label="事项信息" min-width="270">
+              <template #default="{ row }">
+                <div class="item-cell">
+                  <div class="item-mark" :style="{ background: levelMeta(row.warningLevel).color }"></div>
+                  <div>
+                    <strong>{{ row.itemName }}</strong>
+                    <span>{{ row.itemCode }}</span>
+                  </div>
+                </div>
+              </template>
+            </el-table-column>
+            <el-table-column label="类型 / 专项" min-width="200">
+              <template #default="{ row }">
+                <div class="stack-cell">
+                  <span>{{ typeLabel(row.warningType) }}</span>
+                  <small>{{ row.warningSpecial || '通用专项' }}</small>
+                </div>
+              </template>
+            </el-table-column>
+            <el-table-column label="级别" width="110">
+              <template #default="{ row }">
+                <el-tag :type="levelMeta(row.warningLevel).tagType">{{ levelMeta(row.warningLevel).shortLabel }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="响应时长" width="110">
+              <template #default="{ row }"><strong>{{ row.duration || '-' }}</strong> 小时</template>
+            </el-table-column>
+            <el-table-column label="环节数" width="90">
+              <template #default="{ row }">{{ parseLinkConfig(row.linkConfig).length }} 个</template>
+            </el-table-column>
+            <el-table-column label="状态" width="100">
+              <template #default="{ row }">
+                <el-tag :type="statusMeta(row.status).type" effect="light">{{ statusMeta(row.status).label }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="250" fixed="right">
+              <template #default="{ row }">
+                <el-button v-hasPermi="['warning:item:query']" link type="primary" :icon="View" @click="openPreview(row)">详情</el-button>
+                <el-button v-hasPermi="['warning:item:edit']" link type="primary" :icon="Edit" @click="openEdit(row)">修改</el-button>
+                <el-dropdown v-hasPermi="['warning:item:edit']" trigger="click" @command="command => changeStatus(row, command)">
+                  <el-button link type="primary">状态<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
+                  <template #dropdown>
+                    <el-dropdown-menu>
+                      <el-dropdown-item command="ENABLED" :disabled="row.status === 'ENABLED'">启用</el-dropdown-item>
+                      <el-dropdown-item command="DISABLED" :disabled="row.status === 'DISABLED'">禁用</el-dropdown-item>
+                      <el-dropdown-item command="INVALID" :disabled="row.status === 'INVALID'" divided>作废</el-dropdown-item>
+                    </el-dropdown-menu>
+                  </template>
+                </el-dropdown>
+              </template>
+            </el-table-column>
+          </el-table>
+          <el-empty v-if="!loading && !tableData.length" description="暂无预警事项配置" />
+          <pagination
+            v-show="total > 0"
+            v-model:page="query.pageNum"
+            v-model:limit="query.pageSize"
+            :total="total"
+            @pagination="loadData"
+          />
+        </el-card>
+      </main>
+    </div>
+
+    <el-drawer
+      v-model="editorVisible"
+      :title="form.itemId ? '修改预警事项' : '新增预警事项'"
+      size="720px"
+      destroy-on-close
+      class="editor-drawer"
+    >
+      <el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="editor-form">
+        <div class="form-section">
+          <div class="form-section-title"><span>01</span> 基本信息</div>
+          <el-row :gutter="16">
+            <el-col :span="16">
+              <el-form-item label="事项名称" prop="itemName">
+                <el-input v-model="form.itemName" maxlength="100" show-word-limit placeholder="请输入清晰、可识别的事项名称" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item label="所属行业" prop="industry">
+                <el-select v-model="form.industry" placeholder="请选择">
+                  <el-option v-for="item in industryOptions" :key="item" :label="item" :value="item" />
+                </el-select>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="16">
+            <el-col :span="12">
+              <el-form-item label="预警类型" prop="warningType">
+                <el-select v-model="form.warningType" placeholder="请选择预警类型" @change="handleCodeDependencyChange">
+                  <el-option v-for="item in warningTypes" :key="item.value" :label="item.label" :value="item.value" />
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="预警级别" prop="warningLevel">
+                <el-select v-model="form.warningLevel" placeholder="请选择预警级别" @change="handleCodeDependencyChange">
+                  <el-option v-for="item in warningLevels" :key="item.value" :label="item.label" :value="item.value" />
+                </el-select>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="16">
+            <el-col :span="14">
+              <el-form-item label="事项编码" prop="itemCode">
+                <el-input v-model="form.itemCode" placeholder="选择类型和等级后自动生成">
+                  <template #append>
+                    <el-button :icon="MagicStick" :loading="codeLoading" @click="generateCode">生成</el-button>
+                  </template>
+                </el-input>
+              </el-form-item>
+            </el-col>
+            <el-col :span="10">
+              <el-form-item label="预警专项" prop="warningSpecial">
+                <el-input v-model="form.warningSpecial" placeholder="如:城市内涝专项" />
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="16">
+            <el-col :span="12">
+              <el-form-item label="预警时长(小时)" prop="duration">
+                <el-input-number v-model="form.duration" :min="1" :max="720" controls-position="right" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="初始状态" prop="status">
+                <el-radio-group v-model="form.status">
+                  <el-radio-button label="ENABLED">启用</el-radio-button>
+                  <el-radio-button label="DISABLED">禁用</el-radio-button>
+                </el-radio-group>
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </div>
+
+        <div class="form-section">
+          <div class="form-section-title">
+            <div><span>02</span> 预警环节配置</div>
+            <el-button link type="primary" :icon="Plus" @click="addLink">增加环节</el-button>
+          </div>
+          <div class="link-builder">
+            <div v-for="(link, index) in form.links" :key="index" class="link-row">
+              <div class="step-number">{{ String(index + 1).padStart(2, '0') }}</div>
+              <div class="link-fields">
+                <el-row :gutter="12">
+                  <el-col :span="9"><el-input v-model="link.name" placeholder="环节名称" /></el-col>
+                  <el-col :span="8"><el-input v-model="link.handler" placeholder="责任角色/部门" /></el-col>
+                  <el-col :span="7">
+                    <el-input-number v-model="link.timeLimit" :min="1" :max="1440" controls-position="right" />
+                    <small>分钟</small>
+                  </el-col>
+                </el-row>
+                <el-input v-model="link.description" type="textarea" :rows="2" placeholder="描述该环节的触发条件和处置要求" />
+              </div>
+              <el-button type="danger" link :icon="Delete" :disabled="form.links.length === 1" @click="removeLink(index)" />
+            </div>
+          </div>
+        </div>
+
+        <div class="form-section">
+          <div class="form-section-title"><span>03</span> 补充说明</div>
+          <el-form-item label="备注">
+            <el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="填写适用范围、口径或其他说明" />
+          </el-form-item>
+        </div>
+      </el-form>
+      <template #footer>
+        <el-button @click="editorVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="submitForm">保存事项配置</el-button>
+      </template>
+    </el-drawer>
+
+    <el-dialog v-model="previewVisible" title="预警事项详情" width="760px">
+      <div class="preview-title">
+        <div>
+          <span>{{ preview.itemCode }}</span>
+          <h3>{{ preview.itemName }}</h3>
+        </div>
+        <el-tag :type="statusMeta(preview.status).type">{{ statusMeta(preview.status).label }}</el-tag>
+      </div>
+      <el-descriptions :column="3" border>
+        <el-descriptions-item label="预警类型">{{ typeLabel(preview.warningType) }}</el-descriptions-item>
+        <el-descriptions-item label="预警级别">{{ levelMeta(preview.warningLevel).label }}</el-descriptions-item>
+        <el-descriptions-item label="时长">{{ preview.duration || '-' }} 小时</el-descriptions-item>
+        <el-descriptions-item label="预警专项">{{ preview.warningSpecial || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="所属行业">{{ preview.industry || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="更新时间">{{ preview.updateTime || preview.createTime || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="备注" :span="3">{{ preview.remark || '-' }}</el-descriptions-item>
+      </el-descriptions>
+      <div class="preview-links">
+        <div v-for="(link, index) in parseLinkConfig(preview.linkConfig)" :key="index" class="preview-link">
+          <span>{{ index + 1 }}</span>
+          <div><strong>{{ link.name }}</strong><small>{{ link.handler }} · {{ link.timeLimit || '-' }} 分钟</small></div>
+          <p>{{ link.description }}</p>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup name="WarningItemManage">
+import { onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { ArrowDown, Delete, Edit, MagicStick, Plus, Refresh, RefreshRight, Search, View } from '@element-plus/icons-vue'
+import { getDicts } from '@/api/system/dict/data'
+import {
+  addWarningItem,
+  generateWarningItemCode,
+  getWarningItemDetail,
+  listWarningItems,
+  updateWarningItem,
+  updateWarningItemStatus
+} from '@/api/warning/list'
+import {
+  cloneDefaultLinks,
+  fallbackWarningTypes,
+  industryOptions,
+  itemStatuses,
+  levelMeta,
+  normalizeWarningTypeDict,
+  optionLabel,
+  parseLinkConfig,
+  statusMeta,
+  warningLevels
+} from '../shared'
+
+const loading = ref(false)
+const saving = ref(false)
+const codeLoading = ref(false)
+const editorVisible = ref(false)
+const previewVisible = ref(false)
+const formRef = ref()
+const tableData = ref([])
+const total = ref(0)
+const warningTypes = ref(fallbackWarningTypes)
+const preview = reactive({})
+const query = reactive({ pageNum: 1, pageSize: 10, itemName: '', warningType: '', warningLevel: '', status: '' })
+const form = reactive(createEmptyForm())
+const rules = {
+  itemName: [{ required: true, message: '请输入事项名称', trigger: 'blur' }],
+  warningType: [{ required: true, message: '请选择预警类型', trigger: 'change' }],
+  warningLevel: [{ required: true, message: '请选择预警级别', trigger: 'change' }],
+  itemCode: [{ required: true, message: '请生成或填写事项编码', trigger: 'blur' }],
+  industry: [{ required: true, message: '请选择所属行业', trigger: 'change' }],
+  duration: [{ required: true, message: '请设置预警时长', trigger: 'change' }]
+}
+
+function createEmptyForm() {
+  return {
+    itemId: '',
+    itemCode: '',
+    itemName: '',
+    warningType: '',
+    warningLevel: '',
+    warningSpecial: '',
+    industry: '',
+    duration: 24,
+    status: 'ENABLED',
+    remark: '',
+    links: cloneDefaultLinks()
+  }
+}
+
+function typeLabel(value) {
+  return optionLabel(warningTypes.value, value)
+}
+
+async function loadTypes() {
+  try {
+    const res = await getDicts('warning_type')
+    warningTypes.value = normalizeWarningTypeDict(res.data)
+  } catch {
+    warningTypes.value = fallbackWarningTypes
+  }
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const params = { ...query }
+    if (query.itemName && /^WI/i.test(query.itemName.trim())) {
+      params.itemCode = query.itemName.trim()
+      params.itemName = ''
+    }
+    const res = await listWarningItems(params)
+    tableData.value = res.data?.records || []
+    total.value = Number(res.data?.total || 0)
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleQuery() {
+  query.pageNum = 1
+  loadData()
+}
+function resetQuery() {
+  Object.assign(query, { pageNum: 1, pageSize: 10, itemName: '', warningType: '', warningLevel: '', status: '' })
+  loadData()
+}
+function toggleLevel(value) {
+  query.warningLevel = query.warningLevel === value ? '' : value
+  handleQuery()
+}
+function toggleStatus(value) {
+  query.status = query.status === value ? '' : value
+  handleQuery()
+}
+function resetForm(data = createEmptyForm()) {
+  Object.keys(form).forEach(key => delete form[key])
+  Object.assign(form, data)
+}
+function openCreate() {
+  resetForm()
+  editorVisible.value = true
+}
+async function openEdit(row) {
+  const res = await getWarningItemDetail(row.itemId)
+  const data = res.data || row
+  resetForm({ ...data, links: parseLinkConfig(data.linkConfig).length ? parseLinkConfig(data.linkConfig) : cloneDefaultLinks() })
+  editorVisible.value = true
+}
+async function openPreview(row) {
+  const res = await getWarningItemDetail(row.itemId)
+  Object.keys(preview).forEach(key => delete preview[key])
+  Object.assign(preview, res.data || row)
+  previewVisible.value = true
+}
+function handleCodeDependencyChange() {
+  if (!form.itemId) form.itemCode = ''
+}
+async function generateCode() {
+  if (!form.warningType || !form.warningLevel) {
+    ElMessage.warning('请先选择预警类型和预警级别')
+    return
+  }
+  codeLoading.value = true
+  try {
+    const res = await generateWarningItemCode(form.warningType, form.warningLevel)
+    form.itemCode = res.data
+  } finally {
+    codeLoading.value = false
+  }
+}
+function addLink() {
+  form.links.push({ name: '', handler: '', timeLimit: 15, description: '' })
+}
+function removeLink(index) {
+  form.links.splice(index, 1)
+}
+async function submitForm() {
+  await formRef.value?.validate()
+  if (form.links.some(item => !item.name || !item.handler || !item.timeLimit)) {
+    ElMessage.warning('请完整填写每个预警环节的名称、责任方和时限')
+    return
+  }
+  saving.value = true
+  try {
+    const { links, ...payload } = form
+    payload.linkConfig = JSON.stringify(links)
+    if (payload.itemId) {
+      await updateWarningItem(payload)
+      ElMessage.success('预警事项修改成功')
+    } else {
+      await addWarningItem(payload)
+      ElMessage.success('预警事项新增成功')
+    }
+    editorVisible.value = false
+    loadData()
+  } finally {
+    saving.value = false
+  }
+}
+async function changeStatus(row, status) {
+  const meta = statusMeta(status)
+  await ElMessageBox.confirm(
+    status === 'INVALID' ? '事项作废后将不再用于新预警,确认作废?' : `确认将该事项设置为“${meta.label}”?`,
+    '状态变更确认',
+    { type: status === 'INVALID' ? 'warning' : 'info' }
+  )
+  await updateWarningItemStatus(row.itemId, status)
+  ElMessage.success('事项状态已更新')
+  loadData()
+}
+
+onMounted(async () => {
+  await loadTypes()
+  loadData()
+})
+</script>
+
+<style scoped lang="scss">
+.manage-page {
+  min-height: calc(100vh - 84px);
+  padding: 20px;
+  background: linear-gradient(180deg, #eef4fb 0, #f7f9fc 260px);
+}
+.manage-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 18px;
+  padding: 26px 30px;
+  color: #fff;
+  border-radius: 18px;
+  background: linear-gradient(120deg, #111d38, #173b72 58%, #2065a8);
+  box-shadow: 0 14px 35px rgba(20, 50, 95, .2);
+  h1 { margin: 5px 0 7px; font-size: 27px; }
+  p { margin: 0; color: #b9cae1; }
+}
+.header-kicker { color: #65d4ff; font-size: 11px; letter-spacing: 2.6px; }
+.workspace { display: grid; grid-template-columns: 230px minmax(0, 1fr); gap: 16px; }
+.side-panel {
+  align-self: start;
+  padding: 20px;
+  border: 1px solid #e1e8f1;
+  border-radius: 16px;
+  background: #fff;
+  box-shadow: 0 8px 24px rgba(42, 67, 96, .06);
+  :deep(.el-select) { width: 100%; }
+}
+.side-title { margin-bottom: 16px; color: #1e293b; font-size: 16px; font-weight: 700; }
+.filter-label { margin: 20px 0 9px; color: #64748b; font-size: 12px; font-weight: 600; }
+.level-filter {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 8px;
+  button {
+    padding: 8px;
+    color: #64748b;
+    border: 1px solid #e5eaf1;
+    border-radius: 8px;
+    background: #fff;
+    cursor: pointer;
+    span { display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 50%; background: var(--level-color); }
+    &.active { color: var(--level-color); border-color: var(--level-color); background: color-mix(in srgb, var(--level-color) 7%, #fff); }
+  }
+}
+.status-filter {
+  display: flex;
+  flex-direction: column;
+  gap: 7px;
+  button {
+    padding: 9px 12px;
+    color: #64748b;
+    text-align: left;
+    border: 0;
+    border-radius: 8px;
+    background: #f6f8fb;
+    cursor: pointer;
+    &.active { color: #1d5fd1; font-weight: 600; background: #eaf2ff; }
+  }
+}
+.reset-button { width: 100%; margin-top: 20px; }
+.table-card { border: 1px solid #e1e8f1; border-radius: 16px; }
+.table-heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  div { display: flex; flex-direction: column; gap: 3px; }
+  strong { color: #1e293b; font-size: 16px; }
+  span { color: #94a3b8; font-size: 12px; }
+}
+.table-card :deep(th.el-table__cell) { color: #52647a; background: #f7f9fc; }
+.item-cell {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  .item-mark { width: 5px; height: 38px; border-radius: 8px; }
+  div:last-child { display: flex; flex-direction: column; gap: 4px; }
+  strong { color: #1f2d3d; }
+  span { color: #8a98aa; font-family: Consolas, monospace; font-size: 12px; }
+}
+.stack-cell { display: flex; flex-direction: column; gap: 4px; color: #334155; small { color: #94a3b8; } }
+.form-section {
+  margin-bottom: 18px;
+  padding: 20px;
+  border: 1px solid #e5eaf1;
+  border-radius: 14px;
+  background: #fff;
+}
+.form-section-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 18px;
+  color: #1e293b;
+  font-weight: 700;
+  > span, > div span {
+    display: inline-grid;
+    width: 28px;
+    height: 28px;
+    margin-right: 8px;
+    color: #2563eb;
+    font-size: 11px;
+    border-radius: 8px;
+    background: #eaf2ff;
+    place-items: center;
+  }
+}
+.editor-form :deep(.el-select), .editor-form :deep(.el-input-number) { width: 100%; }
+.link-builder { display: flex; flex-direction: column; gap: 12px; }
+.link-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  padding: 14px;
+  border: 1px solid #e7ebf1;
+  border-radius: 12px;
+  background: #f8fafc;
+}
+.step-number {
+  display: grid;
+  flex: 0 0 34px;
+  width: 34px;
+  height: 34px;
+  color: #fff;
+  font-size: 12px;
+  border-radius: 10px;
+  background: #255fc1;
+  place-items: center;
+}
+.link-fields {
+  flex: 1;
+  :deep(.el-textarea) { margin-top: 10px; }
+  .el-col:last-child { display: flex; align-items: center; gap: 6px; small { color: #94a3b8; } }
+}
+.preview-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16px;
+  padding: 18px;
+  border-radius: 12px;
+  background: linear-gradient(110deg, #eaf2ff, #f7fbff);
+  span { color: #5f78a0; font: 12px Consolas, monospace; }
+  h3 { margin: 5px 0 0; color: #1d3557; }
+}
+.preview-links {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 10px;
+  margin-top: 18px;
+}
+.preview-link {
+  display: grid;
+  grid-template-columns: 30px 1fr;
+  gap: 8px;
+  padding: 13px;
+  border: 1px solid #e6ebf2;
+  border-radius: 10px;
+  > span { display: grid; width: 28px; height: 28px; color: #2563eb; border-radius: 8px; background: #edf4ff; place-items: center; }
+  div { display: flex; flex-direction: column; gap: 3px; }
+  small { color: #8a98aa; }
+  p { grid-column: 2; margin: 3px 0 0; color: #64748b; line-height: 1.5; }
+}
+@media (max-width: 900px) {
+  .workspace { grid-template-columns: 1fr; }
+  .manage-header { align-items: flex-start; gap: 16px; }
+}
+</style>

+ 464 - 0
src/views/subSystem/warningList/itemQuery/index.vue

@@ -0,0 +1,464 @@
+<template>
+  <div class="warning-page">
+    <section class="hero-panel">
+      <div>
+        <div class="eyebrow">EARLY WARNING DIRECTORY</div>
+        <h1>事项信息查询</h1>
+        <p>面向城市生命线预警清单的组合检索与信息浏览,快速定位专项、等级和状态配置。</p>
+      </div>
+      <div class="hero-decoration">
+        <el-icon><Search /></el-icon>
+      </div>
+    </section>
+
+    <section class="metric-grid">
+      <article v-for="item in metrics" :key="item.key" class="metric-card" :class="`metric-${item.key}`">
+        <div class="metric-icon"><el-icon><component :is="item.icon" /></el-icon></div>
+        <div>
+          <span>{{ item.label }}</span>
+          <strong>{{ item.value }}</strong>
+        </div>
+      </article>
+    </section>
+
+    <el-card class="filter-card" shadow="never">
+      <el-form ref="queryRef" :model="query" :inline="true" label-position="top">
+        <el-form-item label="事项编号">
+          <el-input v-model="query.itemCode" placeholder="输入完整或部分编码" clearable @keyup.enter="handleQuery" />
+        </el-form-item>
+        <el-form-item label="事项名称">
+          <el-input v-model="query.itemName" placeholder="输入事项名称" clearable @keyup.enter="handleQuery" />
+        </el-form-item>
+        <el-form-item label="预警专项">
+          <el-input v-model="query.warningSpecial" placeholder="如:城市内涝" clearable />
+        </el-form-item>
+        <el-form-item label="预警类型">
+          <el-select v-model="query.warningType" placeholder="全部类型" clearable>
+            <el-option v-for="item in warningTypes" :key="item.value" :label="item.label" :value="item.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="预警级别">
+          <el-select v-model="query.warningLevel" placeholder="全部级别" clearable>
+            <el-option v-for="item in warningLevels" :key="item.value" :label="item.label" :value="item.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="事项状态">
+          <el-select v-model="query.status" placeholder="全部状态" clearable>
+            <el-option v-for="item in itemStatuses" :key="item.value" :label="item.label" :value="item.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item class="filter-actions">
+          <el-button type="primary" :icon="Search" @click="handleQuery">组合查询</el-button>
+          <el-button :icon="Refresh" @click="handleReset">重置条件</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card class="content-card" shadow="never">
+      <template #header>
+        <div class="card-title-row">
+          <div>
+            <h2>预警事项清单</h2>
+            <span>共检索到 {{ total }} 条事项信息</span>
+          </div>
+          <el-button :icon="RefreshRight" circle @click="loadData" />
+        </div>
+      </template>
+
+      <el-table v-loading="loading" :data="tableData" class="warning-table" row-key="itemId">
+        <el-table-column label="事项编号" prop="itemCode" min-width="210">
+          <template #default="{ row }">
+            <button class="code-link" @click="openDetail(row)">{{ row.itemCode }}</button>
+          </template>
+        </el-table-column>
+        <el-table-column label="事项名称" prop="itemName" min-width="200" show-overflow-tooltip />
+        <el-table-column label="预警专项" prop="warningSpecial" min-width="140" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.warningSpecial || '通用专项' }}</template>
+        </el-table-column>
+        <el-table-column label="类型" prop="warningType" min-width="150">
+          <template #default="{ row }">{{ typeLabel(row.warningType) }}</template>
+        </el-table-column>
+        <el-table-column label="级别" prop="warningLevel" width="130">
+          <template #default="{ row }">
+            <span class="level-pill" :style="{ '--level-color': levelMeta(row.warningLevel).color }">
+              {{ levelMeta(row.warningLevel).shortLabel }}预警
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column label="所属行业" prop="industry" width="110">
+          <template #default="{ row }">{{ row.industry || '-' }}</template>
+        </el-table-column>
+        <el-table-column label="时长" prop="duration" width="90">
+          <template #default="{ row }">{{ row.duration ? `${row.duration}h` : '-' }}</template>
+        </el-table-column>
+        <el-table-column label="状态" prop="status" width="100">
+          <template #default="{ row }">
+            <el-tag :type="statusMeta(row.status).type" effect="light">{{ statusMeta(row.status).label }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="更新时间" prop="updateTime" min-width="170">
+          <template #default="{ row }">{{ row.updateTime || row.createTime || '-' }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="100" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" :icon="View" @click="openDetail(row)">浏览</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-empty v-if="!loading && !tableData.length" description="没有找到符合条件的预警事项" />
+      <pagination
+        v-show="total > 0"
+        v-model:page="query.pageNum"
+        v-model:limit="query.pageSize"
+        :total="total"
+        @pagination="loadData"
+      />
+    </el-card>
+
+    <el-drawer v-model="detailVisible" size="560px" class="detail-drawer" destroy-on-close>
+      <template #header>
+        <div class="drawer-heading">
+          <span>事项信息浏览</span>
+          <small>{{ detail.itemCode }}</small>
+        </div>
+      </template>
+      <div v-loading="detailLoading" class="detail-body">
+        <div class="detail-banner" :style="{ '--level-color': levelMeta(detail.warningLevel).color }">
+          <div>
+            <span>{{ typeLabel(detail.warningType) }}</span>
+            <h3>{{ detail.itemName }}</h3>
+          </div>
+          <el-tag :type="statusMeta(detail.status).type">{{ statusMeta(detail.status).label }}</el-tag>
+        </div>
+        <el-descriptions :column="2" border>
+          <el-descriptions-item label="事项编码" :span="2">{{ detail.itemCode }}</el-descriptions-item>
+          <el-descriptions-item label="预警专项">{{ detail.warningSpecial || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="所属行业">{{ detail.industry || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="预警级别">{{ levelMeta(detail.warningLevel).label }}</el-descriptions-item>
+          <el-descriptions-item label="预警时长">{{ detail.duration ? detail.duration + ' 小时' : '-' }}</el-descriptions-item>
+          <el-descriptions-item label="备注" :span="2">{{ detail.remark || '-' }}</el-descriptions-item>
+        </el-descriptions>
+        <div class="section-heading">预警环节配置</div>
+        <el-timeline v-if="detailLinks.length">
+          <el-timeline-item
+            v-for="(link, index) in detailLinks"
+            :key="index"
+            :timestamp="`${link.handler || '待配置'} · ${link.timeLimit || '-'} 分钟`"
+            placement="top"
+          >
+            <div class="timeline-card">
+              <strong>{{ link.name }}</strong>
+              <p>{{ link.description || '暂无环节说明' }}</p>
+            </div>
+          </el-timeline-item>
+        </el-timeline>
+        <el-empty v-else description="暂未配置预警环节" :image-size="72" />
+      </div>
+      <template #footer>
+        <el-button @click="detailVisible = false">关闭</el-button>
+        <el-button v-if="router.hasRoute('WarningItemDetail')" type="primary" @click="goFullDetail">进入完整详情</el-button>
+      </template>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup name="WarningItemQuery">
+import { computed, onMounted, reactive, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { CircleCheck, CircleClose, Collection, Refresh, RefreshRight, Search, View, Warning } from '@element-plus/icons-vue'
+import { getDicts } from '@/api/system/dict/data'
+import { getWarningItemDetail, listWarningItems } from '@/api/warning/list'
+import {
+  fallbackWarningTypes,
+  itemStatuses,
+  levelMeta,
+  normalizeWarningTypeDict,
+  optionLabel,
+  parseLinkConfig,
+  statusMeta,
+  warningLevels
+} from '../shared'
+
+const router = useRouter()
+const loading = ref(false)
+const detailLoading = ref(false)
+const detailVisible = ref(false)
+const tableData = ref([])
+const total = ref(0)
+const warningTypes = ref(fallbackWarningTypes)
+const detail = reactive({})
+const counters = reactive({ total: 0, enabled: 0, disabled: 0, invalid: 0 })
+const query = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  itemCode: '',
+  itemName: '',
+  warningType: '',
+  warningLevel: '',
+  warningSpecial: '',
+  status: ''
+})
+
+const metrics = computed(() => [
+  { key: 'total', label: '事项总量', value: counters.total, icon: Collection },
+  { key: 'enabled', label: '启用事项', value: counters.enabled, icon: CircleCheck },
+  { key: 'disabled', label: '禁用事项', value: counters.disabled, icon: CircleClose },
+  { key: 'invalid', label: '作废事项', value: counters.invalid, icon: Warning }
+])
+const detailLinks = computed(() => parseLinkConfig(detail.linkConfig))
+
+function typeLabel(value) {
+  return optionLabel(warningTypes.value, value)
+}
+
+async function loadTypes() {
+  try {
+    const res = await getDicts('warning_type')
+    warningTypes.value = normalizeWarningTypeDict(res.data)
+  } catch {
+    warningTypes.value = fallbackWarningTypes
+  }
+}
+
+async function loadMetrics() {
+  try {
+    const [all, enabled, disabled, invalid] = await Promise.all([
+      listWarningItems({ pageNum: 1, pageSize: 1 }),
+      listWarningItems({ pageNum: 1, pageSize: 1, status: 'ENABLED' }),
+      listWarningItems({ pageNum: 1, pageSize: 1, status: 'DISABLED' }),
+      listWarningItems({ pageNum: 1, pageSize: 1, status: 'INVALID' })
+    ])
+    counters.total = Number(all.data?.total || 0)
+    counters.enabled = Number(enabled.data?.total || 0)
+    counters.disabled = Number(disabled.data?.total || 0)
+    counters.invalid = Number(invalid.data?.total || 0)
+  } catch {
+    Object.assign(counters, { total: 0, enabled: 0, disabled: 0, invalid: 0 })
+  }
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await listWarningItems(query)
+    tableData.value = res.data?.records || []
+    total.value = Number(res.data?.total || 0)
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleQuery() {
+  query.pageNum = 1
+  loadData()
+}
+
+function handleReset() {
+  Object.assign(query, {
+    pageNum: 1,
+    pageSize: 10,
+    itemCode: '',
+    itemName: '',
+    warningType: '',
+    warningLevel: '',
+    warningSpecial: '',
+    status: ''
+  })
+  loadData()
+}
+
+async function openDetail(row) {
+  detailVisible.value = true
+  detailLoading.value = true
+  try {
+    const res = await getWarningItemDetail(row.itemId)
+    Object.keys(detail).forEach(key => delete detail[key])
+    Object.assign(detail, res.data || row)
+  } finally {
+    detailLoading.value = false
+  }
+}
+
+function goFullDetail() {
+  detailVisible.value = false
+  router.push({ name: 'WarningItemDetail', query: { code: detail.itemCode } })
+}
+
+onMounted(async () => {
+  await loadTypes()
+  await Promise.all([loadData(), loadMetrics()])
+})
+</script>
+
+<style scoped lang="scss">
+.warning-page {
+  min-height: calc(100vh - 84px);
+  padding: 20px;
+  background:
+    radial-gradient(circle at 85% 0%, rgba(37, 99, 235, .09), transparent 25%),
+    #f4f7fb;
+}
+
+.hero-panel {
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  overflow: hidden;
+  padding: 28px 32px;
+  color: #fff;
+  border-radius: 18px;
+  background: linear-gradient(120deg, #0f2f64 0%, #1552a1 54%, #1686b8 100%);
+  box-shadow: 0 18px 45px rgba(20, 66, 130, .22);
+
+  &::after {
+    position: absolute;
+    right: -50px;
+    bottom: -100px;
+    width: 260px;
+    height: 260px;
+    content: '';
+    border: 42px solid rgba(255, 255, 255, .07);
+    border-radius: 50%;
+  }
+
+  h1 { margin: 4px 0 8px; font-size: 28px; letter-spacing: 1px; }
+  p { margin: 0; color: rgba(255, 255, 255, .76); }
+}
+
+.eyebrow { font-size: 11px; letter-spacing: 3px; color: #8fe2ff; }
+.hero-decoration {
+  z-index: 1;
+  display: grid;
+  width: 76px;
+  height: 76px;
+  font-size: 34px;
+  border: 1px solid rgba(255, 255, 255, .25);
+  border-radius: 22px;
+  background: rgba(255, 255, 255, .12);
+  place-items: center;
+  backdrop-filter: blur(8px);
+}
+
+.metric-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 14px;
+  margin: 18px 0;
+}
+.metric-card {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  padding: 18px;
+  border: 1px solid #e7edf5;
+  border-radius: 14px;
+  background: rgba(255, 255, 255, .96);
+  box-shadow: 0 8px 24px rgba(31, 56, 88, .06);
+
+  span { display: block; margin-bottom: 5px; color: #718096; font-size: 13px; }
+  strong { color: #172b4d; font-size: 26px; }
+}
+.metric-icon {
+  display: grid;
+  width: 46px;
+  height: 46px;
+  color: #2563eb;
+  font-size: 22px;
+  border-radius: 13px;
+  background: #eaf1ff;
+  place-items: center;
+}
+.metric-enabled .metric-icon { color: #059669; background: #e8f8f1; }
+.metric-disabled .metric-icon { color: #64748b; background: #eef2f6; }
+.metric-invalid .metric-icon { color: #dc2626; background: #fff0f0; }
+
+.filter-card, .content-card {
+  border: 1px solid #e5ebf3;
+  border-radius: 16px;
+}
+.filter-card { margin-bottom: 16px; }
+.filter-card :deep(.el-form) {
+  display: grid;
+  grid-template-columns: repeat(6, minmax(150px, 1fr));
+  gap: 4px 14px;
+}
+.filter-card :deep(.el-form-item) { margin: 0; }
+.filter-card :deep(.el-form-item__content),
+.filter-card :deep(.el-select) { width: 100%; }
+.filter-actions { align-self: end; }
+.filter-actions :deep(.el-form-item__content) { flex-wrap: nowrap; }
+
+.card-title-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  h2 { margin: 0 0 4px; color: #1e293b; font-size: 17px; }
+  span { color: #94a3b8; font-size: 12px; }
+}
+.warning-table :deep(th.el-table__cell) {
+  color: #475569;
+  background: #f7f9fc;
+}
+.code-link {
+  padding: 0;
+  color: #1d5fd1;
+  font: inherit;
+  font-weight: 600;
+  border: 0;
+  background: transparent;
+  cursor: pointer;
+}
+.level-pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 9px;
+  color: color-mix(in srgb, var(--level-color) 78%, #1f2937);
+  font-size: 12px;
+  border: 1px solid color-mix(in srgb, var(--level-color) 30%, transparent);
+  border-radius: 999px;
+  background: color-mix(in srgb, var(--level-color) 10%, #fff);
+  &::before { width: 7px; height: 7px; content: ''; border-radius: 50%; background: var(--level-color); }
+}
+.drawer-heading {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  color: #172b4d;
+  font-weight: 700;
+  small { color: #8a98ac; font-weight: 400; }
+}
+.detail-body { padding: 0 4px 20px; }
+.detail-banner {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 18px;
+  padding: 20px;
+  border-left: 4px solid var(--level-color);
+  border-radius: 12px;
+  background: linear-gradient(110deg, color-mix(in srgb, var(--level-color) 10%, #fff), #f8fafc);
+  span { color: #64748b; font-size: 12px; }
+  h3 { margin: 5px 0 0; color: #1e293b; font-size: 20px; }
+}
+.section-heading { margin: 24px 0 14px; color: #1e293b; font-weight: 700; }
+.timeline-card {
+  padding: 12px 14px;
+  border: 1px solid #e8edf4;
+  border-radius: 10px;
+  background: #fff;
+  p { margin: 6px 0 0; color: #718096; line-height: 1.6; }
+}
+
+@media (max-width: 1200px) {
+  .filter-card :deep(.el-form) { grid-template-columns: repeat(3, 1fr); }
+}
+@media (max-width: 768px) {
+  .warning-page { padding: 12px; }
+  .metric-grid { grid-template-columns: repeat(2, 1fr); }
+  .filter-card :deep(.el-form) { grid-template-columns: 1fr; }
+  .hero-decoration { display: none; }
+}
+</style>

+ 427 - 0
src/views/subSystem/warningList/reasonManage/index.vue

@@ -0,0 +1,427 @@
+<template>
+  <div class="reason-page">
+    <section class="reason-hero">
+      <div class="hero-copy">
+        <span>KNOWLEDGE LIBRARY</span>
+        <h1>预警原因库管理</h1>
+        <p>按“预警类型 + 等级”沉淀标准原因,提升处置人员选择效率和后续分析数据质量。</p>
+      </div>
+      <div class="hero-visual">
+        <div class="visual-ring"><el-icon><CollectionTag /></el-icon></div>
+        <div class="visual-stat"><strong>{{ total }}</strong><span>原因条目</span></div>
+      </div>
+    </section>
+
+    <el-card class="reason-card" shadow="never">
+      <div class="toolbar">
+        <el-form :model="query" :inline="true">
+          <el-form-item>
+            <el-input v-model="query.reasonCode" :prefix-icon="Search" placeholder="原因编码" clearable @keyup.enter="handleQuery" />
+          </el-form-item>
+          <el-form-item>
+            <el-input v-model="query.reasonName" placeholder="原因名称" clearable @keyup.enter="handleQuery" />
+          </el-form-item>
+          <el-form-item>
+            <el-select v-model="query.warningType" placeholder="预警类型" clearable>
+              <el-option v-for="item in warningTypes" :key="item.value" :label="item.label" :value="item.value" />
+            </el-select>
+          </el-form-item>
+          <el-form-item>
+            <el-select v-model="query.warningLevel" placeholder="预警级别" clearable>
+              <el-option v-for="item in warningLevels" :key="item.value" :label="item.label" :value="item.value" />
+            </el-select>
+          </el-form-item>
+          <el-form-item>
+            <el-select v-model="query.status" placeholder="使用状态" clearable>
+              <el-option label="正常使用" value="0" />
+              <el-option label="已停用" value="1" />
+            </el-select>
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
+            <el-button :icon="Refresh" @click="resetQuery">重置</el-button>
+          </el-form-item>
+        </el-form>
+        <el-button v-hasPermi="['warning:reason:add']" type="primary" :icon="Plus" @click="openCreate">新增原因</el-button>
+      </div>
+
+      <div class="library-layout">
+        <aside class="category-panel">
+          <button :class="{ active: !query.warningType }" @click="selectType('')">
+            <span class="category-icon all"><el-icon><Grid /></el-icon></span>
+            <div><strong>全部类型</strong><small>查看完整原因库</small></div>
+          </button>
+          <button
+            v-for="item in warningTypes"
+            :key="item.value"
+            :class="{ active: query.warningType === item.value }"
+            @click="selectType(item.value)"
+          >
+            <span class="category-icon"><el-icon><Warning /></el-icon></span>
+            <div><strong>{{ item.label }}</strong><small>{{ item.value }}</small></div>
+          </button>
+        </aside>
+
+        <main class="reason-list">
+          <div v-loading="loading" class="reason-grid">
+            <article v-for="row in tableData" :key="row.reasonId" class="reason-item" :class="{ disabled: row.status === '1' }">
+              <div class="reason-top">
+                <span class="reason-code">{{ row.reasonCode }}</span>
+                <el-switch
+                  v-hasPermi="['warning:reason:edit']"
+                  :model-value="row.status === '0'"
+                  inline-prompt
+                  active-text="启"
+                  inactive-text="停"
+                  @change="value => changeStatus(row, value)"
+                />
+              </div>
+              <h3>{{ row.reasonName }}</h3>
+              <p>{{ row.description || '暂无原因描述' }}</p>
+              <div class="reason-tags">
+                <el-tag effect="plain">{{ typeLabel(row.warningType) }}</el-tag>
+                <el-tag :type="levelMeta(row.warningLevel).tagType" effect="light">{{ levelMeta(row.warningLevel).shortLabel }}预警</el-tag>
+                <span>排序 {{ row.sortOrder ?? 0 }}</span>
+              </div>
+              <div class="reason-actions">
+                <span>{{ row.updateTime || row.createTime || '-' }}</span>
+                <div>
+                  <el-button v-hasPermi="['warning:reason:query']" link type="primary" @click="openDetail(row)">详情</el-button>
+                  <el-button v-hasPermi="['warning:reason:edit']" link type="primary" @click="openEdit(row)">修改</el-button>
+                </div>
+              </div>
+            </article>
+          </div>
+          <el-empty v-if="!loading && !tableData.length" description="该分类下暂无预警原因" />
+          <pagination
+            v-show="total > 0"
+            v-model:page="query.pageNum"
+            v-model:limit="query.pageSize"
+            :total="total"
+            @pagination="loadData"
+          />
+        </main>
+      </div>
+    </el-card>
+
+    <el-dialog v-model="editorVisible" :title="form.reasonId ? '修改预警原因' : '新增预警原因'" width="620px" destroy-on-close>
+      <el-form ref="formRef" :model="form" :rules="rules" label-position="top">
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="预警类型" prop="warningType">
+              <el-select v-model="form.warningType" placeholder="请选择">
+                <el-option v-for="item in warningTypes" :key="item.value" :label="item.label" :value="item.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="预警级别" prop="warningLevel">
+              <el-select v-model="form.warningLevel" placeholder="请选择">
+                <el-option v-for="item in warningLevels" :key="item.value" :label="item.label" :value="item.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="16">
+          <el-col :span="16">
+            <el-form-item label="原因名称" prop="reasonName">
+              <el-input v-model="form.reasonName" maxlength="100" show-word-limit placeholder="请输入简明、可直接选择的原因名称" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="显示排序" prop="sortOrder">
+              <el-input-number v-model="form.sortOrder" :min="0" :max="999" controls-position="right" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="原因编码">
+          <el-input v-model="form.reasonCode" placeholder="留空时后端自动生成" />
+        </el-form-item>
+        <el-form-item label="原因描述" prop="description">
+          <el-input v-model="form.description" type="textarea" :rows="5" maxlength="500" show-word-limit placeholder="描述判断依据、典型场景或处置提示" />
+        </el-form-item>
+        <el-form-item label="使用状态">
+          <el-radio-group v-model="form.status">
+            <el-radio-button label="0">正常使用</el-radio-button>
+            <el-radio-button label="1">停用</el-radio-button>
+          </el-radio-group>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="editorVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="submitForm">保存原因</el-button>
+      </template>
+    </el-dialog>
+
+    <el-drawer v-model="detailVisible" title="原因详情" size="480px">
+      <div class="reason-detail">
+        <span>{{ detail.reasonCode }}</span>
+        <h2>{{ detail.reasonName }}</h2>
+        <div class="detail-badges">
+          <el-tag>{{ typeLabel(detail.warningType) }}</el-tag>
+          <el-tag :type="levelMeta(detail.warningLevel).tagType">{{ levelMeta(detail.warningLevel).label }}</el-tag>
+        </div>
+        <div class="detail-description">{{ detail.description || '暂无原因描述' }}</div>
+        <el-descriptions :column="1" border>
+          <el-descriptions-item label="使用状态">{{ detail.status === '0' ? '正常使用' : '已停用' }}</el-descriptions-item>
+          <el-descriptions-item label="显示排序">{{ detail.sortOrder ?? 0 }}</el-descriptions-item>
+          <el-descriptions-item label="创建时间">{{ detail.createTime || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="更新时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
+        </el-descriptions>
+      </div>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup name="WarningReasonManage">
+import { onMounted, reactive, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { CollectionTag, Grid, Plus, Refresh, Search, Warning } from '@element-plus/icons-vue'
+import { getDicts } from '@/api/system/dict/data'
+import {
+  addWarningReason,
+  getWarningReasonDetail,
+  listWarningReasons,
+  updateWarningReason,
+  updateWarningReasonStatus
+} from '@/api/warning/list'
+import {
+  fallbackWarningTypes,
+  levelMeta,
+  normalizeWarningTypeDict,
+  optionLabel,
+  warningLevels
+} from '../shared'
+
+const loading = ref(false)
+const saving = ref(false)
+const editorVisible = ref(false)
+const detailVisible = ref(false)
+const formRef = ref()
+const tableData = ref([])
+const total = ref(0)
+const warningTypes = ref(fallbackWarningTypes)
+const detail = reactive({})
+const query = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  reasonCode: '',
+  reasonName: '',
+  warningType: '',
+  warningLevel: '',
+  status: ''
+})
+const form = reactive(emptyForm())
+const rules = {
+  warningType: [{ required: true, message: '请选择预警类型', trigger: 'change' }],
+  warningLevel: [{ required: true, message: '请选择预警级别', trigger: 'change' }],
+  reasonName: [{ required: true, message: '请输入原因名称', trigger: 'blur' }],
+  description: [{ required: true, message: '请输入原因描述', trigger: 'blur' }]
+}
+
+function emptyForm() {
+  return { reasonId: '', reasonCode: '', reasonName: '', warningType: '', warningLevel: '', description: '', sortOrder: 0, status: '0' }
+}
+function typeLabel(value) {
+  return optionLabel(warningTypes.value, value)
+}
+function resetForm(data = emptyForm()) {
+  Object.keys(form).forEach(key => delete form[key])
+  Object.assign(form, data)
+}
+async function loadTypes() {
+  try {
+    const res = await getDicts('warning_type')
+    warningTypes.value = normalizeWarningTypeDict(res.data)
+  } catch {
+    warningTypes.value = fallbackWarningTypes
+  }
+}
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await listWarningReasons(query)
+    tableData.value = res.data?.records || []
+    total.value = Number(res.data?.total || 0)
+  } finally {
+    loading.value = false
+  }
+}
+function handleQuery() {
+  query.pageNum = 1
+  loadData()
+}
+function resetQuery() {
+  Object.assign(query, { pageNum: 1, pageSize: 10, reasonCode: '', reasonName: '', warningType: '', warningLevel: '', status: '' })
+  loadData()
+}
+function selectType(value) {
+  query.warningType = value
+  handleQuery()
+}
+function openCreate() {
+  resetForm({ ...emptyForm(), warningType: query.warningType, warningLevel: query.warningLevel })
+  editorVisible.value = true
+}
+async function openEdit(row) {
+  const res = await getWarningReasonDetail(row.reasonId)
+  resetForm(res.data || row)
+  editorVisible.value = true
+}
+async function openDetail(row) {
+  const res = await getWarningReasonDetail(row.reasonId)
+  Object.keys(detail).forEach(key => delete detail[key])
+  Object.assign(detail, res.data || row)
+  detailVisible.value = true
+}
+async function submitForm() {
+  await formRef.value?.validate()
+  saving.value = true
+  try {
+    if (form.reasonId) {
+      await updateWarningReason(form)
+      ElMessage.success('预警原因修改成功')
+    } else {
+      await addWarningReason(form)
+      ElMessage.success('预警原因新增成功')
+    }
+    editorVisible.value = false
+    loadData()
+  } finally {
+    saving.value = false
+  }
+}
+async function changeStatus(row, enabled) {
+  const status = enabled ? '0' : '1'
+  try {
+    await ElMessageBox.confirm(`确认${enabled ? '启用' : '停用'}原因“${row.reasonName}”?`, '状态变更', { type: 'warning' })
+    await updateWarningReasonStatus(row.reasonId, status)
+    ElMessage.success('原因状态已更新')
+    loadData()
+  } catch {
+    // 取消操作时恢复由数据源控制的开关状态
+  }
+}
+
+onMounted(async () => {
+  await loadTypes()
+  loadData()
+})
+</script>
+
+<style scoped lang="scss">
+.reason-page {
+  min-height: calc(100vh - 84px);
+  padding: 20px;
+  background:
+    radial-gradient(circle at 10% 10%, rgba(14, 165, 233, .08), transparent 25%),
+    #f4f7fb;
+}
+.reason-hero {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16px;
+  padding: 26px 32px;
+  color: #fff;
+  border-radius: 18px;
+  background: linear-gradient(120deg, #12354f, #0c6175 58%, #0e8b91);
+  box-shadow: 0 15px 40px rgba(10, 86, 104, .2);
+}
+.hero-copy {
+  span { color: #7ee7df; font-size: 11px; letter-spacing: 3px; }
+  h1 { margin: 6px 0 8px; font-size: 27px; }
+  p { margin: 0; color: rgba(255, 255, 255, .75); }
+}
+.hero-visual { display: flex; align-items: center; gap: 15px; }
+.visual-ring {
+  display: grid;
+  width: 62px;
+  height: 62px;
+  font-size: 27px;
+  border: 1px solid rgba(255, 255, 255, .2);
+  border-radius: 50%;
+  background: rgba(255, 255, 255, .1);
+  place-items: center;
+}
+.visual-stat { display: flex; flex-direction: column; strong { font-size: 25px; } span { color: #b8dce1; font-size: 12px; } }
+.reason-card { border: 1px solid #e2e8f0; border-radius: 16px; }
+.toolbar {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+  padding-bottom: 16px;
+  border-bottom: 1px solid #edf0f4;
+  :deep(.el-form-item) { margin-bottom: 0; }
+  :deep(.el-select) { width: 145px; }
+}
+.library-layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 18px; margin-top: 18px; }
+.category-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 7px;
+  padding-right: 16px;
+  border-right: 1px solid #edf0f4;
+  button {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 11px;
+    text-align: left;
+    border: 0;
+    border-radius: 10px;
+    background: transparent;
+    cursor: pointer;
+    &:hover { background: #f4f8fb; }
+    &.active { background: #eaf8f8; }
+    div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
+    strong { overflow: hidden; color: #334155; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
+    small { overflow: hidden; color: #94a3b8; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
+  }
+}
+.category-icon {
+  display: grid;
+  flex: 0 0 34px;
+  width: 34px;
+  height: 34px;
+  color: #0f8890;
+  border-radius: 9px;
+  background: #e5f5f5;
+  place-items: center;
+  &.all { color: #2563eb; background: #eaf2ff; }
+}
+.reason-grid { display: grid; min-height: 300px; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
+.reason-item {
+  position: relative;
+  display: flex;
+  min-height: 210px;
+  flex-direction: column;
+  padding: 16px;
+  border: 1px solid #e4eaf0;
+  border-radius: 13px;
+  background: linear-gradient(150deg, #fff, #fbfcfd);
+  transition: .2s ease;
+  &:hover { transform: translateY(-2px); border-color: #9fd2d4; box-shadow: 0 10px 25px rgba(30, 80, 92, .09); }
+  &.disabled { opacity: .65; filter: grayscale(.25); }
+  h3 { margin: 12px 0 7px; color: #23364d; font-size: 16px; }
+  > p { display: -webkit-box; overflow: hidden; margin: 0; color: #718096; line-height: 1.65; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
+}
+.reason-top { display: flex; align-items: center; justify-content: space-between; }
+.reason-code { color: #0c7c83; font: 11px Consolas, monospace; letter-spacing: .4px; }
+.reason-tags { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 12px; > span:last-child { margin-left: auto; color: #94a3b8; font-size: 11px; } }
+.reason-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 13px; padding-top: 10px; border-top: 1px dashed #e5eaf0; > span { color: #a0aaba; font-size: 10px; } }
+.reason-detail {
+  > span { color: #0c7c83; font: 12px Consolas, monospace; }
+  h2 { margin: 8px 0 14px; color: #203047; }
+}
+.detail-badges { display: flex; gap: 8px; }
+.detail-description { margin: 20px 0; padding: 16px; color: #526477; line-height: 1.8; border-left: 4px solid #14a3a8; border-radius: 8px; background: #f1fafa; }
+:deep(.el-dialog .el-select), :deep(.el-dialog .el-input-number) { width: 100%; }
+@media (max-width: 1250px) { .reason-grid { grid-template-columns: repeat(2, 1fr); } }
+@media (max-width: 900px) {
+  .toolbar { flex-direction: column; }
+  .library-layout { grid-template-columns: 1fr; }
+  .category-panel { display: grid; grid-template-columns: repeat(2, 1fr); padding-right: 0; border-right: 0; }
+}
+</style>

+ 67 - 0
src/views/subSystem/warningList/shared.js

@@ -0,0 +1,67 @@
+export const fallbackWarningTypes = [
+  { label: '供水管网预警', value: 'WATER_PIPE' },
+  { label: '排水管网预警', value: 'SEWER_PIPE' },
+  { label: '燃气管网预警', value: 'GAS_PIPE' },
+  { label: '窨井预警', value: 'MANHOLE' }
+]
+
+export const warningLevels = [
+  { label: '蓝色预警(IV级)', shortLabel: '蓝色', value: '1', color: '#3b82f6', tagType: 'primary' },
+  { label: '黄色预警(III级)', shortLabel: '黄色', value: '2', color: '#eab308', tagType: 'warning' },
+  { label: '橙色预警(II级)', shortLabel: '橙色', value: '3', color: '#f97316', tagType: 'warning' },
+  { label: '红色预警(I级)', shortLabel: '红色', value: '4', color: '#ef4444', tagType: 'danger' }
+]
+
+export const itemStatuses = [
+  { label: '已启用', value: 'ENABLED', type: 'success' },
+  { label: '已禁用', value: 'DISABLED', type: 'info' },
+  { label: '已作废', value: 'INVALID', type: 'danger' }
+]
+
+export const industryOptions = ['供水', '排水', '燃气', '窨井']
+
+export const defaultLinks = [
+  { name: '监测触发', handler: '监测中心', timeLimit: 5, description: '监测数据达到预警阈值后自动触发' },
+  { name: '研判确认', handler: '行业主管部门', timeLimit: 15, description: '核验数据并确认预警事项' },
+  { name: '发布处置', handler: '应急处置人员', timeLimit: 30, description: '发布预警并按预案执行处置' },
+  { name: '复核归档', handler: '预警管理人员', timeLimit: 60, description: '复核处置结果并完成归档' }
+]
+
+export function optionLabel(options, value, fallback = '-') {
+  return options.find(item => String(item.value) === String(value))?.label || value || fallback
+}
+
+export function statusMeta(value) {
+  return itemStatuses.find(item => item.value === value) || { label: value || '未知', type: 'info' }
+}
+
+export function levelMeta(value) {
+  return warningLevels.find(item => item.value === String(value)) || {
+    label: value || '未配置',
+    shortLabel: value || '-',
+    color: '#94a3b8',
+    tagType: 'info'
+  }
+}
+
+export function parseLinkConfig(value) {
+  if (!value) return []
+  if (Array.isArray(value)) return value
+  try {
+    const parsed = JSON.parse(value)
+    return Array.isArray(parsed) ? parsed : []
+  } catch {
+    return [{ name: '环节说明', handler: '-', timeLimit: null, description: value }]
+  }
+}
+
+export function cloneDefaultLinks() {
+  return defaultLinks.map(item => ({ ...item }))
+}
+
+export function normalizeWarningTypeDict(data = []) {
+  const enabled = data
+    .filter(item => item.status === undefined || String(item.status) === '0')
+    .map(item => ({ label: item.dictLabel, value: item.dictValue }))
+  return enabled.length ? enabled : fallbackWarningTypes
+}

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.