Explorar o código

feat(router): 统一首页路由名称并新增预警概览功能

- 将路由名称从 Index 更新为 MainAdminHome 以避免后端菜单路径冲突
- 在面包屑组件中同时支持 Index 和 MainAdminHome 路由名称
- 修改 portal 页面中的 enterBackend 方法默认跳转到 /index
- 更新 index 页面的 script setup 名称为 MainAdminHome
- 在侧边栏 Logo 组件中为收缩和展开状态都添加首页链接
- 新增历史预警概览页面,包含统计卡片、专项分布图表和预警明细表格
- 新增今日预警概况页面,显示未解除、已解除和总量统计数据
- 新增今日未处置预警页面,展示待关注预警清单
- 在预警处置页面添加设备编码筛选和显示功能
- 新增预警仪表板相关 API 接口文件
- 创建预警概览共享工具函数文件
林仔 hai 1 semana
pai
achega
d6d731dbb4
Modificáronse 26 ficheiros con 2762 adicións e 30 borrados
  1. 29 0
      src/api/warning/dashboard.js
  2. 102 0
      src/api/warning/list.js
  3. 22 0
      src/api/warning/statistics.js
  4. 2 2
      src/components/Breadcrumb/index.vue
  5. 3 3
      src/layout/components/Sidebar/Logo.vue
  6. 4 2
      src/router/index.js
  7. 1 1
      src/views/index.vue
  8. 4 4
      src/views/portal/index.vue
  9. 42 6
      src/views/subSystem/lifeCompany/lOverviewWarning/LTodayNot.vue
  10. 43 6
      src/views/subSystem/lifeCompany/lOverviewWarning/Lhistory.vue
  11. 53 6
      src/views/subSystem/lifeCompany/lOverviewWarning/Ltoday.vue
  12. 70 0
      src/views/subSystem/lifeCompany/lOverviewWarning/WarningDetailDrawer.vue
  13. 129 0
      src/views/subSystem/lifeCompany/lOverviewWarning/dashboardShared.js
  14. 25 0
      src/views/subSystem/lifeCompany/lstatistics/disposalEfficiency/index.vue
  15. 26 0
      src/views/subSystem/lifeCompany/lstatistics/disposalStatus/index.vue
  16. 24 0
      src/views/subSystem/lifeCompany/lstatistics/receiveEfficiency/index.vue
  17. 81 0
      src/views/subSystem/lifeCompany/lstatistics/statisticsShared.js
  18. 21 0
      src/views/subSystem/lifeCompany/lstatistics/today/index.vue
  19. 21 0
      src/views/subSystem/lifeCompany/lstatistics/trend/index.vue
  20. 14 0
      src/views/subSystem/lifeCompany/lwarningInfoCont/LwarningDisp.vue
  21. 236 0
      src/views/subSystem/warningList/codeGenerate/index.vue
  22. 224 0
      src/views/subSystem/warningList/itemDetail/index.vue
  23. 628 0
      src/views/subSystem/warningList/itemManage/index.vue
  24. 464 0
      src/views/subSystem/warningList/itemQuery/index.vue
  25. 427 0
      src/views/subSystem/warningList/reasonManage/index.vue
  26. 67 0
      src/views/subSystem/warningList/shared.js

+ 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 - 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 - 2
src/router/index.js

@@ -82,7 +82,9 @@ export const constantRoutes = [
       {
         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 }
       }
     ]
@@ -200,4 +202,4 @@ const router = createRouter({
   },
 });
 
-export default router;
+export default router;

+ 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>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 42 - 6
src/views/subSystem/lifeCompany/lOverviewWarning/LTodayNot.vue


+ 43 - 6
src/views/subSystem/lifeCompany/lOverviewWarning/Lhistory.vue

@@ -1,11 +1,48 @@
+<template>
+  <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>
+
 <script setup>
+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'
 
+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>
 
-<template>
-<div>历史预警概览</div>
-</template>
-
 <style scoped lang="scss">
-
-</style>
+.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>

+ 53 - 6
src/views/subSystem/lifeCompany/lOverviewWarning/Ltoday.vue

@@ -1,11 +1,58 @@
+<template>
+  <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>
+
 <script setup>
+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'
 
+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>
 
-<template>
-<div>今日预警概况</div>
-</template>
-
 <style scoped lang="scss">
-
-</style>
+.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>

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

@@ -0,0 +1,129 @@
+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')
+  return warningStatusLabels[String(value)] || 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))
+  })
+}

+ 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>
+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>
+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>
+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, 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=Object.entries(warningTypeLabels).map(([value,label])=>({value,label}));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>

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

@@ -0,0 +1,81 @@
+export const warningTypeLabels = {
+  WATER_PIPE: '供水管网',
+  SEWER_PIPE: '排水管网',
+  GAS_PIPE: '燃气管网',
+  MANHOLE: '窨井',
+  '供水': '供水管网',
+  '供水管网': '供水管网',
+  '供水管网预警': '供水管网',
+  '排水': '排水管网',
+  '排水管网': '排水管网',
+  '排水管网预警': '排水管网',
+  '燃气': '燃气管网',
+  '燃气管网': '燃气管网',
+  '燃气管网预警': '燃气管网',
+  '窨井预警': '窨井'
+}
+
+export const statusLabels = {
+  DRAFT: '草稿',
+  PENDING: '待处置',
+  PROCESSING: '处置中',
+  RELEASED: '已发布',
+  HANDLED: '已处置',
+  CLOSED: '已解除'
+}
+
+export const disposalTypeLabels = {
+  RELEASE: '发布处置',
+  RESOLVE: '解除预警',
+  RETURN: '退回重办',
+  UPGRADE: '升级预警'
+}
+
+export function labelOf(map, value) {
+  const legacyTypeLabels = { '1': '供水管网', '2': '排水管网', '3': '燃气管网', '4': '窨井' }
+  return map[value] || (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>
+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>
+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();
 }

+ 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>
+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>
+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>
+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 type="primary" @click="goFullDetail">进入完整详情</el-button>
+      </template>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup>
+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>
+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: 9,
+  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: 9, 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
+}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio