瀏覽代碼

feat(应急调度): 首页看板/流转五页优化/预案模板库与版本闭环/SSE 通知客户端

- 调度首页看板:统计卡 + GIS 实时态势 + 处置中事件 + 类型/等级分布图表 + SSE 铃铛消息面板
- 流转五页优化:详情统一两列布局;报送附件(pdf/图片/Excel/Word,拖拽+粘贴);列表/详情附件可下载;
  催办弹窗补状态/责任人电话、默认勾选系统消息;解除弹窗支持处置前后佐证(图片预览/视频下载)
- 修复:弹窗校验状态残留飘红;GIS 点位丢失(geocode 串行化 + 相近点位散开)
- 预案管理:模板库接入(新建可选模板预填名称/章节正文)、版本快照补正文/附件、
  审批页 UI 与信息重做、导出升级 xlsx(附件列超链接)、下载模板落地、编辑/修订按钮文案区分
- 新增 dispatchSse.js:登录自动连接 SSE,催办提醒右下角弹窗(标来源/10s 消失/点击跳转)
null 5 天之前
父節點
當前提交
32e3a00892

+ 1 - 0
.qoderignore

@@ -0,0 +1 @@
+# 指定在索引时要忽略的文件或文件夹。与.gitignore匹配规则一致,支持 my-security/ 和 *.log 等通配符模式。

+ 2 - 1
package.json

@@ -35,7 +35,8 @@
     "pinia": "2.1.7",
     "vue": "3.4.31",
     "vue-cropper": "1.1.1",
-    "vue-router": "4.4.0"
+    "vue-router": "4.4.0",
+    "xlsx": "^0.18.5"
   },
   "devDependencies": {
     "@unocss/preset-attributify": "^66.11.0",

+ 3 - 0
src/App.vue

@@ -6,9 +6,12 @@
 import useSettingsStore from '@/store/modules/settings'
 import { handleThemeStyle } from '@/utils/theme'
 import { startWorkOrderReminder } from '@/utils/deviceStatusWebSocket'
+import { connectSse } from '@/utils/dispatchSse'
 
 onMounted(() => {
   startWorkOrderReminder()
+  // 应急调度:SSE 实时消息(催办提醒等),登录后自动建立连接
+  connectSse()
   nextTick(() => {
     // 初始化主题样式
     handleThemeStyle(useSettingsStore().theme)

+ 87 - 211
src/api/dispatch.js

@@ -1,133 +1,95 @@
 import request from '@/utils/request'
 
-// ============ 接报管理 ============
-export function getEventReportPage(pageNum, pageSize, params) {
-  return request({ url: '/api/dispatch/report/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
-}
-export function getEventReportById(id) {
-  return request({ url: '/api/dispatch/report/getById/' + id, method: 'get' })
-}
-export function addEventReport(data) {
-  return request({ url: '/api/dispatch/report/save', method: 'post', data })
+// ============ 应急事件统一档案(全生命周期闭环) ============
+// 接报登记(0 待核实) -> 初判核实(1 已核实 / 4 不实事件) -> 开始处置(2 处置中) -> 事件解除(3 已解除)
+// 转接 / 报送 / 处置跟踪 / 工作催办只写操作日志,不改变事件状态
+export function getEventPage(pageNum, pageSize, params) {
+  return request({ url: '/api/dispatch/event/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
 }
-export function updateEventReport(data) {
-  return request({ url: '/api/dispatch/report/updateById', method: 'put', data })
+export function getEventDetail(id) {
+  return request({ url: '/api/dispatch/event/getDetail/' + id, method: 'get' })
 }
-export function deleteEventReport(id) {
-  return request({ url: '/api/dispatch/report/deleteById', method: 'delete', params: { id } })
+export function getEventStats(params) {
+  return request({ url: '/api/dispatch/event/stats', method: 'get', params })
 }
-export function acceptEventReport(id) {
-  return request({ url: '/api/dispatch/report/accept/' + id, method: 'put' })
+// 事件状态下拉项(后端返回 EventStatusEnum 的 编码 + 中文)
+export function getEventStatusOptions() {
+  return request({ url: '/api/dispatch/event/statusOptions', method: 'get' })
 }
-export function rejectEventReport(id, remark) {
-  return request({ url: '/api/dispatch/report/reject/' + id, method: 'put', params: { remark } })
+// 接报登记
+export function saveEventReport(data) {
+  return request({ url: '/api/dispatch/event/saveReport', method: 'post', data })
 }
-
-// ============ 初判核实 ============
-export function getEventVerifyPage(pageNum, pageSize, params) {
-  return request({ url: '/api/dispatch/verify/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
+export function updateEventInfo(data) {
+  return request({ url: '/api/dispatch/event/updateById', method: 'put', data })
 }
-export function getEventVerifyById(id) {
-  return request({ url: '/api/dispatch/verify/getById/' + id, method: 'get' })
+export function deleteEventInfo(id) {
+  return request({ url: '/api/dispatch/event/deleteById', method: 'delete', params: { id } })
 }
-export function addEventVerify(data) {
-  return request({ url: '/api/dispatch/verify/save', method: 'post', data })
+// 初判核实:verifyResult 0-真实 1-不实
+export function verifyEventInfo(id, data) {
+  return request({ url: '/api/dispatch/event/verify/' + id, method: 'put', data })
 }
-export function updateEventVerify(data) {
-  return request({ url: '/api/dispatch/verify/updateById', method: 'put', data })
+// 责任处置人下拉项(系统用户:昵称 + 联系电话 + 所属部门),选中后自动带出联系电话
+export function getUserOptions() {
+  return request({ url: '/api/dispatch/event/userOptions', method: 'get' })
 }
-export function deleteEventVerify(id) {
-  return request({ url: '/api/dispatch/verify/deleteById', method: 'delete', params: { id } })
+// 责任单位候选(系统部门,前端据 parentId 建树)
+export function getDeptOptions() {
+  return request({ url: '/api/dispatch/event/deptOptions', method: 'get' })
 }
-export function verifyEvent(data) {
-  return request({ url: '/api/dispatch/verify/verify', method: 'put', data })
+// 调度首页看板(统计卡 + GIS 点位 + 处置中事件 + 类型/等级分布)
+export function getHomeBoard() {
+  return request({ url: '/api/dispatch/event/home', method: 'get' })
 }
-
-// ============ 转接报送 ============
-export function getEventTransferPage(pageNum, pageSize, params) {
-  return request({ url: '/api/dispatch/transfer/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
+// 我的催办消息(未读在前)+ 未读数
+export function getMyNotifies(limit = 20) {
+  return request({ url: '/api/dispatch/notify/my', method: 'get', params: { limit } })
 }
-export function getEventTransferById(id) {
-  return request({ url: '/api/dispatch/transfer/getById/' + id, method: 'get' })
+// 标记单条催办消息已读
+export function markNotifyRead(id) {
+  return request({ url: '/api/dispatch/notify/read/' + id, method: 'post' })
 }
-export function addEventTransfer(data) {
-  return request({ url: '/api/dispatch/transfer/save', method: 'post', data })
+// 全部标记已读
+export function markAllNotifiesRead() {
+  return request({ url: '/api/dispatch/notify/readAll', method: 'post' })
 }
-export function updateEventTransfer(data) {
-  return request({ url: '/api/dispatch/transfer/updateById', method: 'put', data })
+// 转接 / 报送(不改状态)
+export function transferEvent(data) {
+  return request({ url: '/api/dispatch/event/transfer', method: 'post', data })
 }
-export function deleteEventTransfer(id) {
-  return request({ url: '/api/dispatch/transfer/deleteById', method: 'delete', params: { id } })
+export function reportEvent(data) {
+  return request({ url: '/api/dispatch/event/report', method: 'post', data })
 }
-export function doTransfer(data) {
-  return request({ url: '/api/dispatch/transfer/doTransfer', method: 'put', data })
+// 处置:开始处置 / 跟踪 / 解除
+export function startDisposeEvent(data) {
+  return request({ url: '/api/dispatch/event/startDispose', method: 'post', data })
 }
-export function doEventReport(data) {
-  return request({ url: '/api/dispatch/transfer/doReport', method: 'put', data })
+export function trackEvent(data) {
+  return request({ url: '/api/dispatch/event/track', method: 'post', data })
 }
-export function getTransferLogs(transferId) {
-  return request({ url: '/api/dispatch/transfer/getTransferLogs/' + transferId, method: 'get' })
+export function releaseEventInfo(id, data) {
+  return request({ url: '/api/dispatch/event/release/' + id, method: 'post', data })
 }
-
-// ============ 工作催办 ============
+// 工作催办(不改状态)
 export function getEventUrgePage(pageNum, pageSize, params) {
-  return request({ url: '/api/dispatch/urge/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
-}
-export function getEventUrgeById(id) {
-  return request({ url: '/api/dispatch/urge/getById/' + id, method: 'get' })
-}
-export function addEventUrge(data) {
-  return request({ url: '/api/dispatch/urge/save', method: 'post', data })
-}
-export function updateEventUrge(data) {
-  return request({ url: '/api/dispatch/urge/updateById', method: 'put', data })
-}
-export function deleteEventUrge(id) {
-  return request({ url: '/api/dispatch/urge/deleteById', method: 'delete', params: { id } })
-}
-export function doUrge(data) {
-  return request({ url: '/api/dispatch/urge/doUrge', method: 'post', data })
+  return request({ url: '/api/dispatch/event/urge/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
 }
-export function closeUrge(id) {
-  return request({ url: '/api/dispatch/urge/closeUrge/' + id, method: 'put' })
+export function urgeEvent(data) {
+  return request({ url: '/api/dispatch/event/urge', method: 'post', data })
 }
-export function getUrgeDetails(urgeId) {
-  return request({ url: '/api/dispatch/urge/getUrgeDetails/' + urgeId, method: 'get' })
+// 事件操作日志(时间线);operType 可选:8 为工作催办记录
+export function getEventLogs(eventId, operType) {
+  return request({ url: '/api/dispatch/event/logs/' + eventId, method: 'get', params: { operType } })
 }
 
-// ============ 跟踪解除 ============
-export function getEventTrackPage(pageNum, pageSize, params) {
-  return request({ url: '/api/dispatch/track/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
-}
-export function getEventTrackById(id) {
-  return request({ url: '/api/dispatch/track/getById/' + id, method: 'get' })
-}
-export function addEventTrack(data) {
-  return request({ url: '/api/dispatch/track/save', method: 'post', data })
-}
-export function updateEventTrack(data) {
-  return request({ url: '/api/dispatch/track/updateById', method: 'put', data })
-}
-export function deleteEventTrack(id) {
-  return request({ url: '/api/dispatch/track/deleteById', method: 'delete', params: { id } })
-}
-export function addTrackLog(data) {
-  return request({ url: '/api/dispatch/track/addTrackLog', method: 'post', data })
-}
-export function releaseEvent(id) {
-  return request({ url: '/api/dispatch/track/release/' + id, method: 'put' })
-}
-export function closeEvent(id) {
-  return request({ url: '/api/dispatch/track/close/' + id, method: 'put' })
-}
-export function getTrackLogs(trackId) {
-  return request({ url: '/api/dispatch/track/getTrackLogs/' + trackId, method: 'get' })
-}
-
-// ============ 应急事件分级 ============
+// ============ 应急事件分级标准 ============
 export function getEventGradePage(pageNum, pageSize, params) {
   return request({ url: '/api/dispatch/grade/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
 }
+export function getEventGradeStatusOptions() {
+  return request({ url: '/api/dispatch/grade/statusOptions', method: 'get' })
+}
 export function addEventGrade(data) {
   return request({ url: '/api/dispatch/grade/save', method: 'post', data })
 }
@@ -137,59 +99,45 @@ export function updateEventGrade(data) {
 export function deleteEventGrade(id) {
   return request({ url: '/api/dispatch/grade/deleteById', method: 'delete', params: { id } })
 }
-export function getGradesByCategory(category) {
-  return request({ url: '/api/dispatch/grade/listByCategory', method: 'get', params: { category } })
-}
-
-// ============ 应急预案分类 ============
-export function getPlanCategoryTree() {
-  return request({ url: '/api/dispatch/plan-category/getCategoryTree', method: 'get' })
-}
-export function addPlanCategory(data) {
-  return request({ url: '/api/dispatch/plan-category/save', method: 'post', data })
-}
-export function updatePlanCategory(data) {
-  return request({ url: '/api/dispatch/plan-category/updateById', method: 'put', data })
-}
-export function deletePlanCategory(id) {
-  return request({ url: '/api/dispatch/plan-category/deleteById', method: 'delete', params: { id } })
-}
 
-// ============ 应急预案 ============
+// ============ 应急预案(编辑 + 审批共用一套数据) ============
+// 0 草稿 -> 1 待审核 -> 2 已通过 / 3 已驳回;已通过的预案再保存视为「修订」,生成审核中的候选版本
 export function getEventPlanPage(pageNum, pageSize, params) {
   return request({ url: '/api/dispatch/plan/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
 }
 export function getEventPlanById(id) {
-  return request({ url: '/api/dispatch/plan/getById/' + id, method: 'get' })
+  return request({ url: '/api/dispatch/plan/getDetail/' + id, method: 'get' })
 }
-export function addEventPlan(data) {
-  return request({ url: '/api/dispatch/plan/save', method: 'post', data })
+export function getPlanStatusOptions() {
+  return request({ url: '/api/dispatch/plan/statusOptions', method: 'get' })
+}
+export function getPlanTemplates(pageNum = 1, pageSize = 50, params = {}) {
+  return request({ url: '/api/dispatch/plan/templates', method: 'get', params: { pageNum, pageSize, ...params } })
 }
-export function updateEventPlan(data) {
-  return request({ url: '/api/dispatch/plan/updateById', method: 'put', data })
+// 新增草稿 / 修改预案 / 修订已通过预案
+export function saveEventPlan(data) {
+  return request({ url: '/api/dispatch/plan/save', method: 'post', data })
 }
 export function deleteEventPlan(id) {
   return request({ url: '/api/dispatch/plan/deleteById', method: 'delete', params: { id } })
 }
-
-// ============ 应急事件数据查询与统计 ============
-export function getEventRecordPage(pageNum, pageSize, params) {
-  return request({ url: '/api/dispatch/event-record/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
+export function submitEventPlan(id) {
+  return request({ url: '/api/dispatch/plan/submit/' + id, method: 'post' })
 }
-export function getEventRecordStats(params) {
-  return request({ url: '/api/dispatch/event-record/stats', method: 'get', params })
+export function approvePlan(id, data) {
+  return request({ url: '/api/dispatch/plan/approve/' + id, method: 'post', data })
 }
-export function getEventRecordById(id) {
-  return request({ url: '/api/dispatch/event-record/getById/' + id, method: 'get' })
+export function rejectPlan(id, data) {
+  return request({ url: '/api/dispatch/plan/reject/' + id, method: 'post', data })
 }
-export function addEventRecord(data) {
-  return request({ url: '/api/dispatch/event-record/save', method: 'post', data })
+export function getPlanVersions(planId) {
+  return request({ url: '/api/dispatch/plan/versions/' + planId, method: 'get' })
 }
-export function updateEventRecord(data) {
-  return request({ url: '/api/dispatch/event-record/updateById', method: 'put', data })
+export function getPlanVersion(versionId) {
+  return request({ url: '/api/dispatch/plan/version/' + versionId, method: 'get' })
 }
-export function deleteEventRecord(id) {
-  return request({ url: '/api/dispatch/event-record/deleteById', method: 'delete', params: { id } })
+export function getPlanLogs(planId) {
+  return request({ url: '/api/dispatch/plan/logs/' + planId, method: 'get' })
 }
 
 // ============ 数字预案库匹配(应急预案选择) ============
@@ -209,89 +157,17 @@ export function applyPlan(data) {
   return request({ url: '/api/dispatch/plan-library/apply', method: 'post', data })
 }
 
-// ============ 应急预案审批 ============
-export function getPlanApprovalPage(pageNum, pageSize, params) {
-  return request({ url: '/api/dispatch/plan-approval/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
-}
-export function getPlanApprovalById(id) {
-  return request({ url: '/api/dispatch/plan-approval/getById/' + id, method: 'get' })
-}
-export function submitPlanForApproval(id) {
-  return request({ url: '/api/dispatch/plan-approval/submitForApproval/' + id, method: 'put' })
-}
-export function createPlanApproval(planId) {
-  return request({ url: '/api/dispatch/plan-approval/submit/' + planId, method: 'post' })
-}
-export function approvePlan(id) {
-  return request({ url: '/api/dispatch/plan-approval/approve/' + id, method: 'put' })
-}
-export function rejectPlan(id) {
-  return request({ url: '/api/dispatch/plan-approval/reject/' + id, method: 'put' })
-}
-export function publishPlan(id) {
-  return request({ url: '/api/dispatch/plan-approval/publish/' + id, method: 'put' })
-}
-export function getApprovalSteps(approvalId) {
-  return request({ url: '/api/dispatch/plan-approval-step/listByApprovalId', method: 'get', params: { approvalId } })
-}
-
-// ============ 应急事件统一档案(全生命周期闭环) ============
-// 接报 -> 受理 -> 核实 -> 转接/报送 -> 跟踪/催办 -> 解除 -> 关闭
-export function getEventPage(pageNum, pageSize, params) {
-  return request({ url: '/api/dispatch/event/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
-}
-export function getEventDetail(id) {
-  return request({ url: '/api/dispatch/event/getDetail/' + id, method: 'get' })
-}
-export function getEventStats(params) {
-  return request({ url: '/api/dispatch/event/stats', method: 'get', params })
-}
-export function saveEventReport(data) {
-  return request({ url: '/api/dispatch/event/saveReport', method: 'post', data })
-}
-export function acceptEvent(id) {
-  return request({ url: '/api/dispatch/event/accept', method: 'post', data: { id } })
-}
-export function verifyEventInfo(data) {
-  return request({ url: '/api/dispatch/event/verify', method: 'post', data })
-}
-export function transferEvent(data) {
-  return request({ url: '/api/dispatch/event/transfer', method: 'post', data })
-}
-export function reportEvent(data) {
-  return request({ url: '/api/dispatch/event/report', method: 'post', data })
-}
-export function trackEvent(data) {
-  return request({ url: '/api/dispatch/event/track', method: 'post', data })
-}
-export function releaseEventInfo(id) {
-  return request({ url: '/api/dispatch/event/release', method: 'post', data: { id } })
-}
-export function closeEventInfo(data) {
-  return request({ url: '/api/dispatch/event/close', method: 'post', data })
-}
-export function updateEventInfo(data) {
-  return request({ url: '/api/dispatch/event/updateById', method: 'put', data })
-}
-export function deleteEventInfo(id) {
-  return request({ url: '/api/dispatch/event/deleteById', method: 'delete', params: { id } })
-}
-
 // ============ 应急事件事后评估(总结评估) ============
-// 事件(已解除/已关闭) -> 四维评分 -> 生成报告/录入案例库
-// 查询单事件评估(无则返回 null)
+// 事件(已解除) -> 四维评分 -> 生成报告/录入案例库
 export function getEventAssess(eventId) {
   return request({ url: '/api/dispatch/assess/findByEventId', method: 'get', params: { eventId } })
 }
-// 批量查询评估(列表标记已评估/待评估)
 export function listEventAssess(eventIds) {
   return request({ url: '/api/dispatch/assess/listByEventIds', method: 'get', params: { eventIds: eventIds.join(',') } })
 }
-// 保存/更新评估(自动计算综合分与等级;仅传 eventId+caseFlag 表示录入案例库)
 export function saveEventAssess(data) {
   return request({ url: '/api/dispatch/assess/save', method: 'post', data })
 }
-// 删除评估(重新评估)
 export function deleteEventAssess(eventId) {
   return request({ url: '/api/dispatch/assess/delete', method: 'delete', params: { eventId } })
 }

+ 109 - 0
src/utils/dispatchSse.js

@@ -0,0 +1,109 @@
+/**
+ * 应急与指挥调度:SSE 实时消息客户端(轻量版,参照 RuoYi-Vue-Plus 5.x sse.js 设计)
+ *
+ * 登录后在 App.vue 全局建立连接;服务端推送(如催办提醒)到达时:
+ * 1. 右下角 ElNotification 弹窗(标明来源、10 秒自动消失、点击跳转「工作催办」);
+ * 2. 广播 window 事件 dispatch-notify-arrived,供首页铃铛等页面刷新未读数。
+ *
+ * EventSource 不支持自定义请求头,token 按约定拼在 URL 上,
+ * 后端 SseController 自行校验并放行该端点。
+ */
+import { ElNotification } from 'element-plus'
+import router from '@/router'
+import { getToken } from '@/utils/auth'
+
+let eventSource = null
+let reconnectTimer = null
+let stopped = true
+let reconnectAttempts = 0
+
+const MAX_RETRY_DELAY = 5 * 60 * 1000
+
+function scheduleReconnect() {
+  if (stopped || reconnectTimer) return
+  const delay = Math.min(MAX_RETRY_DELAY, 10000 * Math.pow(2, reconnectAttempts++))
+  reconnectTimer = setTimeout(() => {
+    reconnectTimer = null
+    connectSse()
+  }, delay)
+}
+
+// 动态菜单路由 path 以 sys_menu 配置为准,按标识查找「工作催办」页
+function goUrgingPage() {
+  const hit = router.getRoutes().find(r => /gzcb/i.test(String(r.path || '')) || r.name === 'Gzcb')
+  if (hit) {
+    router.push(hit.path)
+  } else {
+    router.push('/index')
+  }
+}
+
+function escapeHtml(value) {
+  return String(value ?? '')
+    .replace(/&/g, '&')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;')
+}
+
+function handleMessage(event) {
+  try {
+    const msg = JSON.parse(event.data || '{}')
+    const source = msg.type === 'URGING' ? '应急与指挥调度 · 工作催办' : '应急与指挥调度 · 系统消息'
+    const meta = msg.urgingUser ? `${source} · ${msg.urgingUser}` : source
+    const notification = ElNotification({
+      title: msg.title || '系统消息',
+      // 来源标注 + 点击跳转提示;10 秒自动消失
+      message:
+        `<div style="color:#606266;line-height:1.6">${escapeHtml(msg.content || '')}</div>` +
+        `<div style="margin-top:8px;color:#909399;font-size:12px">来自:${escapeHtml(meta)}</div>` +
+        `<div style="margin-top:4px;color:#409eff;font-size:12px">点击本通知跳转「工作催办」</div>`,
+      type: 'warning',
+      duration: 10000,
+      position: 'bottom-right',
+      dangerouslyUseHTMLString: true,
+      onClick: () => {
+        notification.close()
+        goUrgingPage()
+      }
+    })
+    // 广播给关注未读数的页面(如调度首页铃铛)
+    window.dispatchEvent(new CustomEvent('dispatch-notify-arrived', { detail: msg }))
+  } catch (e) {
+    // 非 JSON 负载忽略
+  }
+}
+
+export function connectSse() {
+  if (typeof (EventSource) === 'undefined') return
+  if (eventSource) return
+  const token = getToken()
+  if (!token) {
+    scheduleReconnect()
+    return
+  }
+  stopped = false
+  eventSource = new EventSource(import.meta.env.VITE_APP_BASE_API + '/api/dispatch/sse/connect?Authorization=' + encodeURIComponent('Bearer ' + token))
+  eventSource.onmessage = handleMessage
+  eventSource.onerror = () => {
+    // 断开后由本地退避重连(token 过期时重连也会失败,退避封顶 5 分钟)
+    if (eventSource) {
+      eventSource.close()
+      eventSource = null
+    }
+    scheduleReconnect()
+  }
+}
+
+export function closeSse() {
+  stopped = true
+  if (reconnectTimer) {
+    clearTimeout(reconnectTimer)
+    reconnectTimer = null
+  }
+  if (eventSource) {
+    eventSource.close()
+    eventSource = null
+  }
+}

+ 116 - 86
src/views/subSystem/dispatch/disAssess/DisInquiry.vue

@@ -11,7 +11,7 @@
           <el-form :model="queryForm" label-width="80px" label-position="top" size="default">
             <el-form-item label="事件类型">
               <el-select v-model="queryForm.eventType" placeholder="请选择" clearable style="width: 100%">
-                <el-option v-for="item in eventTypeOptions" :key="item" :label="item" :value="item" />
+                <el-option v-for="d in dis_event_type" :key="d.value" :label="d.label" :value="d.value" />
               </el-select>
             </el-form-item>
             <el-form-item label="发生时间">
@@ -20,16 +20,17 @@
             </el-form-item>
             <el-form-item label="事件等级">
               <el-select v-model="queryForm.eventLevel" placeholder="请选择" clearable style="width: 100%">
-                <el-option v-for="item in eventLevelOptions" :key="item" :label="item" :value="item" />
+                <el-option v-for="d in dis_event_level" :key="d.value" :label="d.label" :value="d.value" />
               </el-select>
             </el-form-item>
             <el-form-item label="地域">
-              <el-select v-model="queryForm.region" placeholder="请选择" clearable style="width: 100%">
-                <el-option v-for="item in regionOptions" :key="item" :label="item" :value="item" />
+              <el-select v-model="queryForm.eventRegion" placeholder="请选择" clearable style="width: 100%">
+                <el-option v-for="d in dis_event_region" :key="d.value" :label="d.label" :value="d.value" />
               </el-select>
             </el-form-item>
             <el-form-item label="责任部门">
-              <el-select v-model="queryForm.department" placeholder="请选择" clearable style="width: 100%">
+              <el-select v-model="queryForm.department" placeholder="请选择" clearable filterable allow-create
+                default-first-option style="width: 100%">
                 <el-option v-for="item in departmentOptions" :key="item" :label="item" :value="item" />
               </el-select>
             </el-form-item>
@@ -78,19 +79,24 @@
             </div>
           </template>
           <el-table :data="tableData" border stripe style="width: 100%" size="default">
-            <el-table-column prop="eventName" label="事件名称" min-width="180" show-overflow-tooltip />
-            <el-table-column prop="eventType" label="类型" width="100" />
+            <el-table-column prop="eventNo" label="事件编号" min-width="180" show-overflow-tooltip />
+            <el-table-column prop="eventType" label="类型" width="100">
+              <template #default="{ row }">{{ dictLabel(dis_event_type, row.eventType) }}</template>
+            </el-table-column>
             <el-table-column prop="happenTime" label="发生时间" width="160" />
-            <el-table-column prop="level" label="等级" width="100">
+            <el-table-column prop="eventLevel" label="等级" width="100">
               <template #default="{ row }">
-                <el-tag :type="levelTagType(row.level)" size="small" effect="dark">{{ row.level }}</el-tag>
+                <el-tag v-if="row.eventLevel" :type="levelTagType(row.eventLevel)" size="small" effect="dark">{{ dictLabel(dis_event_level, row.eventLevel) }}</el-tag>
+                <span v-else>-</span>
               </template>
             </el-table-column>
-            <el-table-column prop="region" label="地域" width="90" />
+            <el-table-column prop="eventRegion" label="地域" width="90">
+              <template #default="{ row }">{{ dictLabel(dis_event_region, row.eventRegion) }}</template>
+            </el-table-column>
             <el-table-column prop="department" label="责任部门" width="110" />
             <el-table-column prop="status" label="状态" width="90">
               <template #default="{ row }">
-                <el-tag :type="statusTagType(row.status)" size="small">{{ row.status }}</el-tag>
+                <el-tag :type="statusTagType(row.status)" size="small">{{ row.statusName }}</el-tag>
               </template>
             </el-table-column>
             <el-table-column label="损失(万元)" width="110" align="right">
@@ -100,8 +106,8 @@
             </el-table-column>
             <el-table-column label="操作" width="140" align="center">
               <template #default="{ row }">
-                <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
-                <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
+                <el-button v-if="row.editable" link type="primary" @click="openEdit(row)">编辑</el-button>
+                <el-button v-if="row.editable" link type="danger" @click="handleDelete(row)">删除</el-button>
               </template>
             </el-table-column>
           </el-table>
@@ -122,34 +128,35 @@
     <!-- 新增/编辑弹窗 -->
     <el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" destroy-on-close>
       <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
-        <el-form-item label="事件名称" prop="eventName">
-          <el-input v-model="form.eventName" placeholder="请输入事件名称" />
+        <el-form-item label="事件来源" prop="eventSource">
+          <el-select v-model="form.eventSource" placeholder="请选择" style="width:100%">
+            <el-option v-for="d in dis_event_source" :key="d.value" :label="d.label" :value="d.value" />
+          </el-select>
         </el-form-item>
-        <el-form-item label="事件类型" prop="eventType">
-          <el-select v-model="form.eventType" placeholder="请选择" style="width:100%">
-            <el-option v-for="item in eventTypeOptions.filter(o => o !== '全部')" :key="item" :label="item" :value="item" />
+        <el-form-item label="管网类型" prop="eventType">
+          <el-select v-model="form.eventType" placeholder="请选择管网类型" style="width:100%">
+            <el-option v-for="d in dis_event_type" :key="d.value" :label="d.label" :value="d.value" />
           </el-select>
         </el-form-item>
-        <el-form-item label="发生时间" prop="eventTime">
-          <el-date-picker v-model="form.eventTime" type="date" value-format="YYYY-MM-DD" style="width:100%" />
+        <el-form-item label="发生地点" prop="location">
+          <el-input v-model="form.location" placeholder="请输入发生地点" />
         </el-form-item>
-        <el-form-item label="事件等级" prop="eventLevel">
-          <el-select v-model="form.eventLevel" placeholder="请选择" style="width:100%">
-            <el-option v-for="item in eventLevelOptions.filter(o => o !== '全部')" :key="item" :label="item" :value="item" />
-          </el-select>
+        <el-form-item label="发生时间" prop="happenTime">
+          <el-date-picker v-model="form.happenTime" type="datetime" placeholder="选择日期时间" style="width:100%" value-format="YYYY-MM-DD HH:mm:ss" />
         </el-form-item>
-        <el-form-item label="地域" prop="region">
-          <el-select v-model="form.region" placeholder="请选择" style="width:100%">
-            <el-option v-for="item in regionOptions.filter(o => o !== '全部')" :key="item" :label="item" :value="item" />
-          </el-select>
+        <el-form-item label="事件描述" prop="description">
+          <el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入事件描述" />
         </el-form-item>
-        <el-form-item label="责任部门" prop="department">
-          <el-select v-model="form.department" placeholder="请选择" style="width:100%">
-            <el-option v-for="item in departmentOptions.filter(o => o !== '全部')" :key="item" :label="item" :value="item" />
-          </el-select>
+        <el-form-item label="接报人" prop="reporter">
+          <el-input v-model="form.reporter" readonly placeholder="自动取当前录入人员" />
         </el-form-item>
-        <el-form-item label="损失(万元)" prop="lossAmount">
-          <el-input-number v-model="form.lossAmount" :min="0" :precision="2" style="width:100%" />
+        <el-form-item label="接报时间" prop="reportTime">
+          <el-date-picker v-model="form.reportTime" type="datetime" placeholder="选择日期时间" style="width:100%" value-format="YYYY-MM-DD HH:mm:ss" />
+        </el-form-item>
+        <el-form-item label="来源渠道" prop="channel">
+          <el-select v-model="form.channel" placeholder="请选择来源渠道" style="width:100%">
+            <el-option v-for="d in dis_event_channel" :key="d.value" :label="d.label" :value="d.value" />
+          </el-select>
         </el-form-item>
       </el-form>
       <template #footer>
@@ -161,23 +168,52 @@
 </template>
 
 <script setup>
-import { ref, reactive } from 'vue'
+import { ref, reactive, computed } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
+import { useDict } from '@/utils/dict'
+import useUserStore from '@/store/modules/user'
 import { getEventPage, getEventStats, saveEventReport, updateEventInfo, deleteEventInfo } from '@/api/dispatch'
 
-// 查询条件选项(与应急预案选择/统一事件口径一致)
-const eventTypeOptions = ['全部', '排水', '供水', '燃气', '其他']
-const eventLevelOptions = ['全部', '一般', '较大', '重大', '特别重大']
-const regionOptions = ['全部', '城南', '城北', '太常']
-const departmentOptions = ['全部', '供水局', '燃气公司', '排水公司']
-const lossRangeOptions = ['全部', '100万以下', '100-500万', '500-1000万', '1000万以上']
+// 接报人固定取当前录入人员,不手填(后端落库时同样以当前登录用户为准)
+const userStore = useUserStore()
+function currentOperatorName() {
+  return userStore.nickName || userStore.name || ''
+}
+
+// ==================== 字典 ====================
+const {
+  dis_event_type, dis_event_level, dis_event_region, dis_event_source, dis_event_channel, dis_transfer_dept
+} = useDict(
+  'dis_event_type', 'dis_event_level', 'dis_event_region',
+  'dis_event_source', 'dis_event_channel', 'dis_transfer_dept'
+)
+
+function dictLabel(list, value) {
+  if (value === null || value === undefined || value === '') return '-'
+  const hit = (list || []).find(item => String(item.value) === String(value))
+  return hit ? hit.label : value
+}
+
+// 等级字典值 -> 标签样式(1 Ⅰ级紧急 2 Ⅱ级较大 3 Ⅲ级一般)
+const LEVEL_TAG = { 1: 'danger', 2: 'warning', 3: 'info' }
+const levelTagType = (level) => LEVEL_TAG[level] || 'info'
+
+// 状态码 -> 标签样式(0 待核实 1 已核实 2 处置中 3 已解除 4 不实事件)
+const STATUS_TAG = { 0: 'warning', 1: 'success', 2: 'primary', 3: 'warning', 4: 'info' }
+const statusTagType = (status) => STATUS_TAG[status] || 'info'
+
+// 责任部门存的是初判核实录入的责任单位名称,下拉只作候选,允许直接输入
+const departmentOptions = computed(() => (dis_transfer_dept.value || []).map(d => d.label))
+
+// 损失区间取值需与后端 applyLossRange 严格一致
+const lossRangeOptions = ['0-1万', '1-10万', '10-100万', '100万以上']
 
 // 查询表单
 const queryForm = reactive({
   eventType: '',
   eventTime: '',
   eventLevel: '',
-  region: '',
+  eventRegion: '',
   department: '',
   lossRange: ''
 })
@@ -195,16 +231,15 @@ const stats = ref({ totalEvents: 0, totalLoss: 0, avgLoss: 0, majorEvents: 0 })
 const dialogVisible = ref(false)
 const dialogTitle = ref('')
 const formRef = ref(null)
-const form = reactive({ id: null, eventName: '', eventType: '', eventTime: '', eventLevel: '', region: '', department: '', lossAmount: undefined })
+const form = reactive({ id: null, eventSource: '', eventType: '', channel: '', location: '', happenTime: '', reportTime: '', reporter: currentOperatorName(), description: '' })
 
 const formRules = {
-  eventName: [{ required: true, message: '请输入事件名称', trigger: 'blur' }],
-  eventType: [{ required: true, message: '请选择事件类型', trigger: 'change' }],
-  eventTime: [{ required: true, message: '请选择发生时间', trigger: 'change' }],
-  eventLevel: [{ required: true, message: '请选择事件等级', trigger: 'change' }],
-  region: [{ required: true, message: '请选择地域', trigger: 'change' }],
-  department: [{ required: true, message: '请选择责任部门', trigger: 'change' }],
-  lossAmount: [{ required: true, message: '请输入损失金额', trigger: 'blur' }]
+  eventSource: [{ required: true, message: '请选择事件来源', trigger: 'change' }],
+  location: [{ required: true, message: '请输入发生地点', trigger: 'blur' }],
+  happenTime: [{ required: true, message: '请选择发生时间', trigger: 'change' }],
+  description: [{ required: true, message: '请输入事件描述', trigger: 'blur' }],
+  reporter: [{ required: true, message: '请输入接报人', trigger: 'blur' }],
+  channel: [{ required: true, message: '请选择来源渠道', trigger: 'change' }]
 }
 
 // 损失金额格式化(保留2位小数)
@@ -213,24 +248,30 @@ const formatLoss = (val) => {
   return isNaN(num) ? '-' : num.toFixed(2)
 }
 
+const resetForm = () => {
+  Object.assign(form, { id: null, eventSource: '', eventType: '', channel: '', location: '', happenTime: '', reportTime: '', reporter: currentOperatorName(), description: '' })
+}
+
 // 打开新增
 const openAdd = () => {
-  Object.assign(form, { id: null, eventName: '', eventType: '', eventTime: '', eventLevel: '', region: '', department: '', lossAmount: undefined })
+  resetForm()
   dialogTitle.value = '新增事件记录'
   dialogVisible.value = true
 }
 
-// 打开编辑
+// 打开编辑(仅待核实事件可编辑,由 row.editable 控制)
 const openEdit = (row) => {
   Object.assign(form, {
     id: row.id,
-    eventName: row.eventName,
-    eventType: row.eventType,
-    eventTime: row.happenTime ? String(row.happenTime).slice(0, 10) : '',
-    eventLevel: row.level,
-    region: row.region,
-    department: row.department,
-    lossAmount: Number(row.lossAmount)
+    eventSource: row.eventSource || '',
+    eventType: row.eventType || '',
+    channel: row.channel || '',
+    location: row.location || '',
+    happenTime: row.happenTime || '',
+    reportTime: row.reportTime || '',
+    // 接报人登记后不可改,编辑时只读回显原值
+    reporter: row.reporter || '',
+    description: row.description || ''
   })
   dialogTitle.value = '编辑事件记录'
   dialogVisible.value = true
@@ -241,13 +282,14 @@ const submitForm = () => {
   formRef.value.validate(async (valid) => {
     if (!valid) return
     const payload = {
-      eventName: form.eventName,
+      eventSource: form.eventSource,
       eventType: form.eventType,
-      happenTime: form.eventTime ? form.eventTime + ' 00:00:00' : null,
-      level: form.eventLevel,
-      region: form.region,
-      department: form.department,
-      lossAmount: form.lossAmount
+      channel: form.channel,
+      location: form.location,
+      happenTime: form.happenTime || null,
+      reportTime: form.reportTime || null,
+      reporter: form.reporter,
+      description: form.description
     }
     try {
       if (form.id) {
@@ -267,7 +309,7 @@ const submitForm = () => {
 
 // 删除
 const handleDelete = (row) => {
-  ElMessageBox.confirm(`确定删除事件「${row.eventName}」吗?`, '删除确认', {
+  ElMessageBox.confirm(`确定删除事件「${row.eventNo}」吗?`, '删除确认', {
     confirmButtonText: '确定',
     cancelButtonText: '取消',
     type: 'warning'
@@ -282,18 +324,18 @@ const handleDelete = (row) => {
   }).catch(() => {})
 }
 
-// 构建查询参数(空值与'全部'不传)
+// 构建查询参数(空值不传)
 const buildParams = () => {
   const params = {}
-  if (queryForm.eventType && queryForm.eventType !== '全部') params.eventType = queryForm.eventType
+  if (queryForm.eventType) params.eventType = queryForm.eventType
   if (queryForm.eventTime) {
     params.startDate = queryForm.eventTime
     params.endDate = queryForm.eventTime
   }
-  if (queryForm.eventLevel && queryForm.eventLevel !== '全部') params.level = queryForm.eventLevel
-  if (queryForm.region && queryForm.region !== '全部') params.region = queryForm.region
-  if (queryForm.department && queryForm.department !== '全部') params.department = queryForm.department
-  if (queryForm.lossRange && queryForm.lossRange !== '全部') params.lossRange = queryForm.lossRange
+  if (queryForm.eventLevel) params.eventLevel = queryForm.eventLevel
+  if (queryForm.eventRegion) params.eventRegion = queryForm.eventRegion
+  if (queryForm.department) params.department = queryForm.department
+  if (queryForm.lossRange) params.lossRange = queryForm.lossRange
   return params
 }
 
@@ -305,7 +347,7 @@ const loadData = async () => {
       getEventPage(pageNum.value, pageSize.value, params),
       getEventStats(params)
     ])
-    tableData.value = pageRes.data?.records || []
+    tableData.value = pageRes.data?.rows || pageRes.data?.records || []
     total.value = pageRes.data?.total || 0
     stats.value = statsRes.data || { totalEvents: 0, totalLoss: 0, avgLoss: 0, majorEvents: 0 }
   } catch (e) {
@@ -313,18 +355,6 @@ const loadData = async () => {
   }
 }
 
-// 等级tag颜色
-const levelTagType = (level) => {
-  const map = { '一般': 'info', '较大': 'warning', '重大': 'danger', '特别重大': 'danger' }
-  return map[level] || 'info'
-}
-
-// 状态tag颜色
-const statusTagType = (status) => {
-  const map = { '待受理': 'warning', '待核实': 'warning', '已核实': 'primary', '处置中': 'primary', '已解除': 'warning', '已关闭': 'success', '不实事件': 'info' }
-  return map[status] || 'info'
-}
-
 // 查询
 const handleQuery = () => {
   pageNum.value = 1
@@ -340,7 +370,7 @@ const handleSizeChange = () => {
 // 重置
 const handleReset = () => {
   Object.assign(queryForm, {
-    eventType: '', eventTime: '', eventLevel: '', region: '', department: '', lossRange: ''
+    eventType: '', eventTime: '', eventLevel: '', eventRegion: '', department: '', lossRange: ''
   })
   pageNum.value = 1
   loadData()

+ 31 - 13
src/views/subSystem/dispatch/disAssess/DisSummary.vue

@@ -27,7 +27,7 @@
               </div>
             </div>
           </div>
-          <el-empty v-else description="暂无已解除/已关闭事件" :image-size="80" />
+          <el-empty v-else description="暂无已解除事件" :image-size="80" />
           <el-button type="primary" plain :loading="loading" style="width: 100%; margin-top: 12px" @click="handleRefresh">刷新事件列表</el-button>
         </el-card>
 
@@ -112,7 +112,7 @@
             <el-button @click="resetEvaluation">重新评估</el-button>
           </div>
         </el-card>
-        <el-empty v-else description="暂无已解除/已关闭事件,请先完成事件处置后进行评估" />
+        <el-empty v-else description="暂无已解除事件,请先完成事件处置后进行评估" />
       </div>
     </div>
   </div>
@@ -121,9 +121,28 @@
 <script setup>
 import { ref, reactive, computed, watch, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
+import { useDict } from '@/utils/dict'
 import { getEventPage, getEventAssess, listEventAssess, saveEventAssess, deleteEventAssess } from '@/api/dispatch'
 
-// 已解除/已关闭事件列表(可评估)
+// ==================== 字典 ====================
+const { dis_event_type, dis_event_level } = useDict('dis_event_type', 'dis_event_level')
+
+function dictLabel(list, value) {
+  if (value === null || value === undefined || value === '') return '-'
+  const hit = (list || []).find(item => String(item.value) === String(value))
+  return hit ? hit.label : value
+}
+
+// 等级字典值 → 标签样式(1 Ⅰ级紧急 2 Ⅱ级较大 3 Ⅲ级一般),以中文名为键便于模板直接取用
+const levelTagType = (label) => {
+  const hit = (dis_event_level.value || []).find(item => item.label === label)
+  return (hit && hit.elTagType) || 'info'
+}
+
+// 可评估事件:事件状态 3 已解除(EventStatusEnum)
+const RELEASED_STATUS = 3
+
+// 已解除事件列表(可评估)
 const events = ref([])
 const loading = ref(false)
 const currentEventId = ref(null)
@@ -175,15 +194,20 @@ const loadAssessmentsToForm = async () => {
   }
 }
 
-// 加载事件列表(已解除/已关闭)并批量标记已评估
+// 加载事件列表(已解除)并批量标记已评估
 const loadEvents = async () => {
   loading.value = true
   try {
-    const res = await getEventPage(1, 200, { statusList: ['已解除', '已关闭'] })
+    const res = await getEventPage(1, 200, { statusList: [RELEASED_STATUS] })
     const rows = res.data?.records || res.data?.rows || res.records || res.rows || []
     events.value = (rows || []).map(e => ({
-      id: e.id, name: e.eventName, time: e.happenTime, location: e.location,
-      level: e.level, type: e.eventType, desc: e.description
+      id: e.id,
+      name: e.eventNo,
+      time: e.happenTime,
+      location: e.location,
+      level: dictLabel(dis_event_level.value, e.eventLevel),
+      type: dictLabel(dis_event_type.value, e.eventType),
+      desc: e.description
     }))
     evaluatedIds.value = new Set()
     if (events.value.length) {
@@ -205,12 +229,6 @@ const selectEvent = (id) => {
   loadAssessmentsToForm()
 }
 
-// 等级tag颜色
-const levelTagType = (level) => {
-  const map = { '一般': 'info', '较大': 'warning', '重大': 'danger', '特别重大': 'danger' }
-  return map[level] || 'info'
-}
-
 // 生成评估报告(保存评估到后端 + 下载报告)
 const generateReport = async () => {
   const event = currentEvent.value

+ 414 - 97
src/views/subSystem/dispatch/disOnDuty/cphs.vue

@@ -3,22 +3,14 @@
     <!-- 搜索区 -->
     <div class="filter-panel">
       <div class="filter-form">
-        <el-select v-model="searchForm.category" placeholder="事件类别" clearable style="width:160px">
-          <el-option label="管道泄漏" value="管道泄漏" />
-          <el-option label="塌陷" value="塌陷" />
-          <el-option label="积水" value="积水" />
-          <el-option label="设备故障" value="设备故障" />
+        <el-select v-model="searchForm.eventCategory" placeholder="事件类别" clearable style="width:160px">
+          <el-option v-for="d in dis_event_category" :key="d.value" :label="d.label" :value="d.value" />
         </el-select>
-        <el-select v-model="searchForm.level" placeholder="事件等级" clearable style="width:140px">
-          <el-option label="一般" value="一般" />
-          <el-option label="较大" value="较大" />
-          <el-option label="重大" value="重大" />
-          <el-option label="特别重大" value="特别重大" />
+        <el-select v-model="searchForm.eventLevel" placeholder="事件等级" clearable style="width:140px">
+          <el-option v-for="d in dis_event_level" :key="d.value" :label="d.label" :value="d.value" />
         </el-select>
-        <el-select v-model="searchForm.verifyStatus" placeholder="核实状态" clearable style="width:140px">
-          <el-option label="待核实" value="待核实" />
-          <el-option label="已核实" value="已核实" />
-          <el-option label="不实事件" value="不实事件" />
+        <el-select v-model="searchForm.status" placeholder="核实状态" clearable style="width:140px">
+          <el-option v-for="d in verifyStatusOptions" :key="d.value" :label="d.label" :value="d.value" />
         </el-select>
         <el-button type="primary" @click="handleSearch">搜索</el-button>
         <el-button @click="handleReset">重置</el-button>
@@ -29,28 +21,31 @@
     <div class="table-panel">
       <el-table :data="tableData" border stripe style="width:100%" v-loading="loading">
         <el-table-column prop="eventNo" label="事件编号" width="140" />
-        <el-table-column prop="category" label="事件类别" width="110">
-          <template #default="{ row }">{{ row.category || '-' }}</template>
+        <el-table-column prop="eventType" label="管网类型" width="100">
+          <template #default="{ row }">{{ dictLabel(dis_event_type, row.eventType) }}</template>
         </el-table-column>
-        <el-table-column prop="level" label="事件等级" width="100">
+        <el-table-column prop="eventCategory" label="事件类别" width="110">
+          <template #default="{ row }">{{ dictLabel(dis_event_category, row.eventCategory) }}</template>
+        </el-table-column>
+        <el-table-column prop="eventLevel" label="事件等级" width="100">
           <template #default="{ row }">
-            <el-tag v-if="row.level" :type="levelTagType(row.level)" size="small">{{ row.level }}</el-tag>
+            <el-tag v-if="row.eventLevel" :type="levelTagType(row.eventLevel)" size="small">{{ dictLabel(dis_event_level, row.eventLevel) }}</el-tag>
             <span v-else>-</span>
           </template>
         </el-table-column>
         <el-table-column prop="location" label="发生地点" show-overflow-tooltip />
-        <el-table-column prop="judgeTime" label="初判时间" width="150" />
+        <el-table-column prop="verifyTime" label="初判时间" width="150" />
         <el-table-column prop="verifier" label="核实人" width="100" />
-        <el-table-column prop="status" label="状态" width="100">
+        <el-table-column prop="statusName" label="状态" width="100">
           <template #default="{ row }">
-            <el-tag :type="statusTagType(row.status)" size="small">{{ row.status }}</el-tag>
+            <el-tag :type="statusTagType(row.status)" size="small">{{ row.statusName }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column label="操作" width="200" fixed="right">
           <template #default="{ row }">
-            <el-button v-if="row.status === '待核实'" type="primary" link size="small" @click="handleVerify(row)">核实</el-button>
+            <el-button v-if="row.status === 0" type="primary" link size="small" @click="handleVerify(row)">核实</el-button>
             <el-button type="primary" link size="small" @click="handleDetail(row)">详情</el-button>
-            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
+            <el-button v-if="row.editable" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -67,9 +62,14 @@
       </div>
     </div>
 
-    <!-- GIS 占位区 -->
+    <!-- GIS 展示区:当前列表事件坐标打点,固定区域为沅陵县 -->
     <div class="gis-panel">
-      <div class="gis-placeholder">事件坐标 GIS 展示区</div>
+      <GisMapCanvas :points="gisPoints" :zoom="12" :enable-clustering="false" @point-click="handleMapPointClick" @error="gisError = $event" />
+      <div style="margin-top:8px;color:#909399;font-size:12px">
+        展示区域:怀化市沅陵县 · <span style="color:#e74c3c">●</span> 事件坐标点 · 点击点位查看事件详情
+        <span v-if="geoFailed > 0" style="color:#e6a23c;margin-left:8px">({{ geoFailed }} 个事件的地址解析失败,未上图)</span>
+      </div>
+      <div v-if="gisError" style="margin-top:4px;color:#e6a23c;font-size:12px">{{ gisError }}</div>
     </div>
 
     <!-- 核实弹窗 -->
@@ -84,33 +84,62 @@
             <el-form-item label="发生地点">{{ verifyForm.location }}</el-form-item>
           </el-col>
         </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="事件坐标">{{ formatGeo(verifyForm.longitude, verifyForm.latitude) }}</el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="接报人">{{ verifyForm.reporter || '-' }}</el-form-item>
+          </el-col>
+        </el-row>
         <el-form-item label="事件描述">
           <div class="desc-text">{{ verifyForm.description }}</div>
         </el-form-item>
         <div class="section-title">核实信息</div>
-        <el-form-item label="事件类别" prop="category">
-          <el-select v-model="verifyForm.category" placeholder="请选择" style="width:100%">
-            <el-option label="管道泄漏" value="管道泄漏" />
-            <el-option label="塌陷" value="塌陷" />
-            <el-option label="积水" value="积水" />
-            <el-option label="设备故障" value="设备故障" />
+        <el-form-item label="事件类别" prop="eventCategory">
+          <el-select v-model="verifyForm.eventCategory" placeholder="请选择" style="width:100%">
+            <el-option v-for="d in dis_event_category" :key="d.value" :label="d.label" :value="d.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="事件等级" prop="eventLevel">
+          <el-select v-model="verifyForm.eventLevel" placeholder="请选择" style="width:100%">
+            <el-option v-for="d in dis_event_level" :key="d.value" :label="d.label" :value="d.value" />
           </el-select>
         </el-form-item>
-        <el-form-item label="事件等级" prop="level">
-          <el-select v-model="verifyForm.level" placeholder="请选择" style="width:100%">
-            <el-option label="一般" value="一般" />
-            <el-option label="较大" value="较大" />
-            <el-option label="重大" value="重大" />
-            <el-option label="特别重大" value="特别重大" />
+        <el-form-item label="责任单位" prop="department">
+          <el-tree-select
+            v-model="verifyForm.deptId"
+            :data="deptTree"
+            :props="{ label: 'deptName', children: 'children' }"
+            node-key="deptId"
+            check-strictly
+            filterable
+            placeholder="请选择责任单位(系统部门)"
+            style="width:100%"
+            @change="handleDeptChange"
+          />
+        </el-form-item>
+        <el-form-item label="责任处置人" prop="handler">
+          <el-select
+            v-model="verifyForm.handler"
+            :placeholder="verifyForm.deptId ? '请选择责任处置人(该部门人员)' : '请先选择责任单位'"
+            filterable
+            style="width:100%"
+            @change="handleHandlerChange"
+          >
+            <el-option v-for="u in filteredHandlerOptions" :key="u.userId" :label="u.nickName" :value="u.nickName" />
           </el-select>
         </el-form-item>
-        <el-form-item label="核实意见" prop="opinion">
-          <el-input v-model="verifyForm.opinion" type="textarea" :rows="3" placeholder="请输入核实意见" />
+        <el-form-item label="联系电话" prop="handlerPhone">
+          <el-input v-model="verifyForm.handlerPhone" placeholder="选人自动带出;未维护时可手动填写" />
+        </el-form-item>
+        <el-form-item label="核实意见" prop="verifyOpinion">
+          <el-input v-model="verifyForm.verifyOpinion" type="textarea" :rows="3" placeholder="请输入核实意见" />
         </el-form-item>
-        <el-form-item label="是否真实" prop="isReal">
-          <el-radio-group v-model="verifyForm.isReal">
-            <el-radio label="真实">真实</el-radio>
-            <el-radio label="不实">不实</el-radio>
+        <el-form-item label="是否真实" prop="verifyResult">
+          <el-radio-group v-model="verifyForm.verifyResult">
+            <el-radio :label="0">真实</el-radio>
+            <el-radio :label="1">不实</el-radio>
           </el-radio-group>
         </el-form-item>
       </el-form>
@@ -125,34 +154,39 @@
       <div v-if="currentRow" class="detail-content">
         <el-form label-width="100px" size="default">
           <el-row :gutter="20">
-            <el-col :span="8">
-              <el-form-item label="接报编号">{{ currentRow.eventNo }}</el-form-item>
+            <el-col :span="12">
+              <el-form-item label="事件编号">{{ currentRow.eventNo }}</el-form-item>
             </el-col>
-            <el-col :span="8">
-              <el-form-item label="事件标识">{{ currentRow.eventNo }}</el-form-item>
+            <el-col :span="12">
+              <el-form-item label="事件来源">{{ dictLabel(dis_event_source, currentRow.eventSource) }}</el-form-item>
             </el-col>
-            <el-col :span="8">
-              <el-form-item label="事件来源">{{ currentRow.source || '-' }}</el-form-item>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="管网类型">{{ dictLabel(dis_event_type, currentRow.eventType) }}</el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="事件类别">{{ dictLabel(dis_event_category, currentRow.eventCategory) }}</el-form-item>
             </el-col>
           </el-row>
           <el-row :gutter="20">
             <el-col :span="12">
-              <el-form-item label="事件类别">{{ currentRow.category }}</el-form-item>
+              <el-form-item label="事件等级">{{ dictLabel(dis_event_level, currentRow.eventLevel) }}</el-form-item>
             </el-col>
             <el-col :span="12">
-              <el-form-item label="事件等级">{{ currentRow.level }}</el-form-item>
+              <el-form-item label="发生地点">{{ currentRow.location || '-' }}</el-form-item>
             </el-col>
           </el-row>
           <el-row :gutter="20">
             <el-col :span="12">
-              <el-form-item label="发生地点">{{ currentRow.location }}</el-form-item>
+              <el-form-item label="发生时间">{{ currentRow.happenTime || '-' }}</el-form-item>
             </el-col>
             <el-col :span="12">
-              <el-form-item label="初判时间">{{ currentRow.judgeTime }}</el-form-item>
+              <el-form-item label="事件坐标">{{ formatGeo(currentRow.longitude, currentRow.latitude) }}</el-form-item>
             </el-col>
           </el-row>
           <el-form-item label="事件描述">
-            <div class="desc-text">{{ currentRow.description }}</div>
+            <div class="desc-text">{{ currentRow.description || '-' }}</div>
           </el-form-item>
           <el-row :gutter="20">
             <el-col :span="12">
@@ -165,9 +199,33 @@
           <el-form-item label="核实意见">
             <div class="desc-text">{{ currentRow.verifyOpinion || '-' }}</div>
           </el-form-item>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="责任单位">{{ currentRow.department || '-' }}</el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="责任处置人">
+                <template v-if="currentRow.handler">{{ currentRow.handler }}<span v-if="currentRow.handlerPhone">({{ currentRow.handlerPhone }})</span></template>
+                <template v-else>-</template>
+              </el-form-item>
+            </el-col>
+          </el-row>
           <el-form-item label="状态">
-            <el-tag :type="statusTagType(currentRow.status)" size="small">{{ currentRow.status }}</el-tag>
+            <el-tag :type="statusTagType(currentRow.status)" size="small">{{ currentRow.statusName }}</el-tag>
           </el-form-item>
+          <div class="section-title">事件记录(操作日志)</div>
+          <el-timeline v-if="currentRow.timeline && currentRow.timeline.length">
+            <el-timeline-item
+              v-for="(item, index) in currentRow.timeline"
+              :key="index"
+              :type="item.type"
+              :timestamp="item.time"
+            >
+              <div style="font-weight:600">{{ item.title }}</div>
+              <div style="color:#606266;font-size:13px">{{ item.content }}</div>
+            </el-timeline-item>
+          </el-timeline>
+          <div v-else class="desc-text">暂无记录</div>
         </el-form>
       </div>
       <template #footer>
@@ -178,28 +236,151 @@
 </template>
 
 <script setup name="Cphs">
-import { ref, onMounted } from 'vue'
+import { ref, computed, nextTick, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { getEventPage, deleteEventInfo, verifyEventInfo } from '@/api/dispatch'
+import { useDict } from '@/utils/dict'
+import { handleTree } from '@/utils/ruoyi'
+import { bd09ToWgs84 } from '@/utils/coordTransform'
+import { gisPointStatusIcons } from '@/utils/gisMapColors'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import { getEventPage, deleteEventInfo, verifyEventInfo, getEventStatusOptions, getEventLogs, getUserOptions, getDeptOptions } from '@/api/dispatch'
+
+// ==================== 字典 ====================
+const { dis_event_source, dis_event_type, dis_event_category, dis_event_level } = useDict(
+  'dis_event_source', 'dis_event_type', 'dis_event_category', 'dis_event_level'
+)
+
+// 核实环节可选状态:待核实(0) / 已核实(1) / 不实事件(4),其余状态不属于本页
+const verifyStatusOptions = ref([])
+
+async function loadStatusOptions() {
+  try {
+    const res = await getEventStatusOptions()
+    verifyStatusOptions.value = (res.data || []).filter(item => [0, 1, 4].includes(item.value))
+  } catch {
+    verifyStatusOptions.value = []
+  }
+}
+
+function dictLabel(list, value) {
+  if (value === null || value === undefined || value === '') return '-'
+  const hit = (list || []).find(item => String(item.value) === String(value))
+  return hit ? hit.label : value
+}
+
+// 事件坐标展示口径:经度, 纬度(不带 °N / °E 后缀;库内统一存 WGS84)
+function formatGeo(lng, lat) {
+  if (lng === null || lng === undefined || lng === '' || lat === null || lat === undefined || lat === '') return '-'
+  return `${Number(lng).toFixed(6)}, ${Number(lat).toFixed(6)}`
+}
+
+// ==================== 责任单位(系统部门)+ 责任处置人(该部门人员) ====================
+// 责任单位由手填改为选择系统部门(el-tree-select 树形);选中后责任处置人候选
+// 限定为该部门所属人员,选人自动带出联系电话(用户未维护电话时可手填)。
+// 提交时仍按 dis_event.department 的既有口径写「部门中文名」。
+const userOptions = ref([])
+const deptList = ref([])
+const deptTree = computed(() => {
+  if (!deptList.value.length) return []
+  return handleTree(JSON.parse(JSON.stringify(deptList.value)), 'deptId')
+})
+
+async function loadUserOptions() {
+  try {
+    const res = await getUserOptions()
+    userOptions.value = res.data || []
+  } catch {
+    userOptions.value = []
+  }
+}
+
+async function loadDeptOptions() {
+  try {
+    const res = await getDeptOptions()
+    deptList.value = res.data || []
+  } catch {
+    deptList.value = []
+  }
+}
+
+// 责任处置人候选:所选责任单位(部门)所属人员
+const filteredHandlerOptions = computed(() => {
+  if (!verifyForm.value.deptId) return []
+  return userOptions.value.filter(u => String(u.deptId) === String(verifyForm.value.deptId))
+})
+
+function handleDeptChange() {
+  // 切换责任单位后,原处置人可能不属于新部门,一并清空重选
+  verifyForm.value.handler = ''
+  verifyForm.value.handlerId = null
+  verifyForm.value.handlerPhone = ''
+}
+
+function handleHandlerChange(nickName) {
+  const user = filteredHandlerOptions.value.find(item => item.nickName === nickName)
+  if (user) {
+    // 选中系统用户后:记下用户ID(随核实请求落库),并自动带出联系电话
+    verifyForm.value.handlerId = user.userId
+    if (user.phonenumber) {
+      verifyForm.value.handlerPhone = user.phonenumber
+    } else {
+      // 用户信息里没维护电话:清空旧值,提示手动填写
+      verifyForm.value.handlerPhone = ''
+      ElMessage.info('该用户未维护联系电话,请手动填写')
+    }
+  }
+}
+
+// 按部门名称反查部门ID(核实回显用:department 存的是部门中文名)
+function deptIdByName(name) {
+  if (!name) return null
+  const hit = deptList.value.find(d => d.deptName === name)
+  return hit ? hit.deptId : null
+}
+
+// 状态码 -> 标签样式(0 待核实 1 已核实 2 处置中 3 已解除 4 不实事件)
+const STATUS_TAG = { 0: 'warning', 1: 'success', 2: 'primary', 3: 'warning', 4: 'info' }
+function statusTagType(status) {
+  return STATUS_TAG[status] || 'info'
+}
+
+// 等级字典值 -> 标签样式(1 Ⅰ级紧急 2 Ⅱ级较大 3 Ⅲ级一般)
+const LEVEL_TAG = { 1: 'danger', 2: 'warning', 3: 'info' }
+function levelTagType(level) {
+  return LEVEL_TAG[level] || 'info'
+}
+
+// 操作日志 -> 时间线节点(operType 见后端 EventLogOperTypeEnum:0 接报登记 ... 9 信息修改)
+const LOG_TYPE = { 0: 'primary', 1: 'success', 2: 'info', 3: 'primary', 4: 'primary', 5: 'warning', 6: '', 7: 'success', 8: 'warning', 9: 'info' }
+function toTimeline(logs) {
+  return (logs || []).map(log => ({
+    type: LOG_TYPE[log.operType] ?? '',
+    time: log.operTime,
+    title: log.operTypeName || '操作',
+    content: log.operDesc || log.targets || '-'
+  }))
+}
 
 // ==================== 数据状态 ====================
 const tableData = ref([])
 const total = ref(0)
 const loading = ref(false)
-const searchForm = ref({ category: '', level: '', verifyStatus: '' })
+const searchForm = ref({ eventCategory: '', eventLevel: '', status: 0 })
 const pagination = ref({ page: 1, pageSize: 10 })
 
 // ==================== 加载数据 ====================
 async function loadData() {
   loading.value = true
   try {
-    const params = { status: '待核实' }
-    if (searchForm.value.category) params.category = searchForm.value.category
-    if (searchForm.value.level) params.level = searchForm.value.level
-    if (searchForm.value.verifyStatus) params.status = searchForm.value.verifyStatus
+    // 初判核实页默认只看待核实(0)
+    const params = { status: 0 }
+    if (searchForm.value.eventCategory) params.eventCategory = searchForm.value.eventCategory
+    if (searchForm.value.eventLevel) params.eventLevel = searchForm.value.eventLevel
+    if (searchForm.value.status !== '' && searchForm.value.status !== null) params.status = searchForm.value.status
     const res = await getEventPage(pagination.value.page, pagination.value.pageSize, params)
     tableData.value = res.data?.rows || res.data?.records || res.data || []
     total.value = res.data?.total || 0
+    resolveMissingCoords()
   } catch (error) {
     ElMessage.error('加载数据失败:' + (error.message || '未知错误'))
   } finally {
@@ -207,7 +388,127 @@ async function loadData() {
   }
 }
 
+// ==================== 事件坐标 GIS 展示区 ====================
+// 固定区域为沅陵县(GisMapCanvas 默认中心即沅陵):库内坐标为 WGS84,组件内自动转百度坐标打点。
+// 没录坐标的事件按「怀化市沅陵县 + 发生地点」用百度 Geocoder 解析后仅作展示定位,不回写数据库。
+const gisError = ref('')
+const geocodedPoints = ref([])
+const geoFailed = ref(0)
+const hasCoord = (val) => val !== null && val !== undefined && val !== ''
+const geocodeCache = new Map()
+
+function truncateName(text) {
+  const str = String(text || '')
+  return str.length > 8 ? str.slice(0, 8) + '…' : str
+}
+
+function buildPoint(row, lng, lat) {
+  return {
+    id: row.id,
+    lng: Number(lng),
+    lat: Number(lat),
+    name: truncateName(row.location) || row.eventNo,
+    // 事件点位统一红点(与报警点位同图标);iconUrl 优先于组件内置状态推断
+    iconUrl: gisPointStatusIcons.alarm,
+    eventNo: row.eventNo,
+    location: row.location,
+    statusName: row.statusName
+  }
+}
+
+const gisPoints = computed(() => {
+  const fromDb = tableData.value
+    .filter(row => hasCoord(row.longitude) && hasCoord(row.latitude))
+    .map(row => buildPoint(row, row.longitude, row.latitude))
+  return spreadPoints([...fromDb, ...geocodedPoints.value])
+})
+
+// ---------- 相近点位散开 ----------
+// 县城内多个事件解析出的坐标常只差数百米,缩放级别 12 下 marker 会相互遮挡,
+// 看起来像"丢了点"。这里只在**展示层**把过近的点位按序错开(不回写坐标)。
+const OVERLAP_EPS = 0.0015   // 约 150m:两点近于此距离视为重叠
+const OVERLAP_STEP = 0.004   // 每次错开约 400m
+
+function spreadPoints(points) {
+  const placed = []
+  return points.map(p => {
+    let lng = p.lng
+    let lat = p.lat
+    let k = 0
+    while (k < 12 && placed.some(q => Math.abs(q.lng - lng) < OVERLAP_EPS && Math.abs(q.lat - lat) < OVERLAP_EPS)) {
+      k++
+      const ring = Math.ceil(k / 4)
+      const dir = (k - 1) % 4
+      const d = OVERLAP_STEP * ring
+      lng = p.lng + (dir === 0 || dir === 3 ? d : -d)
+      lat = p.lat + (dir === 0 || dir === 1 ? d : -d)
+    }
+    const moved = { ...p, lng, lat }
+    placed.push(moved)
+    return moved
+  })
+}
+
+let geoSeq = 0
+
+function resolveMissingCoords() {
+  geocodedPoints.value = []
+  const seq = ++geoSeq
+  const BMapGL = window.BMapGL
+  if (!BMapGL || typeof BMapGL.Geocoder !== 'function') return
+  const pending = tableData.value.filter(row => !hasCoord(row.longitude) || !hasCoord(row.latitude))
+  if (!pending.length) {
+    geoFailed.value = 0
+    return
+  }
+  const geocoder = new BMapGL.Geocoder()
+  let idx = 0
+  let failed = 0
+
+  const applyGeocode = (row, lng, lat) => {
+    // 列表可能已翻页/重查,回包时确认该事件仍在当前列表中再上点
+    if (seq === geoSeq && tableData.value.some(item => item.id === row.id)) {
+      geocodedPoints.value.push(buildPoint(row, lng, lat))
+    }
+  }
+
+  // 串行 + 间隔解析:并发请求会被百度 Geocoder 限流,部分结果会静默丢失
+  const next = () => {
+    if (seq !== geoSeq || idx >= pending.length) {
+      geoFailed.value = failed
+      return
+    }
+    const row = pending[idx++]
+    const address = '怀化市沅陵县' + (row.location || '')
+    const cached = geocodeCache.get(address)
+    if (cached) {
+      applyGeocode(row, cached[0], cached[1])
+      next()
+      return
+    }
+    geocoder.getPoint(address, (point) => {
+      if (point) {
+        const [lng, lat] = bd09ToWgs84(point.lng, point.lat)
+        geocodeCache.set(address, [lng, lat])
+        applyGeocode(row, lng, lat)
+      } else {
+        failed++
+      }
+      setTimeout(next, 300)
+    })
+  }
+  next()
+}
+
+function handleMapPointClick(point) {
+  const row = tableData.value.find(item => item.id === point.id)
+  if (row) handleDetail(row)
+}
+
 onMounted(() => {
+  loadStatusOptions()
+  loadUserOptions()
+  loadDeptOptions()
   loadData()
 })
 
@@ -218,7 +519,7 @@ function handleSearch() {
 }
 
 function handleReset() {
-  searchForm.value = { category: '', level: '', verifyStatus: '' }
+  searchForm.value = { eventCategory: '', eventLevel: '', status: 0 }
   pagination.value.page = 1
   loadData()
 }
@@ -235,45 +536,50 @@ function handleCurrentChange(val) {
   loadData()
 }
 
-// ==================== 标签样式 ====================
-function statusTagType(status) {
-  if (status === '待核实') return 'warning'
-  if (status === '已核实') return 'success'
-  if (status === '不实事件') return 'info'
-  return 'info'
-}
-
-function levelTagType(level) {
-  if (level === '特别重大') return 'danger'
-  if (level === '重大') return 'danger'
-  if (level === '较大') return 'warning'
-  return 'info'
-}
-
 // ==================== 核实 ====================
 const verifyVisible = ref(false)
 const verifyFormRef = ref(null)
 const currentVerifyId = ref(null)
-const verifyForm = ref({ eventNo: '', location: '', description: '', category: '', level: '', opinion: '', isReal: '真实' })
-const verifyRules = {
-  category: [{ required: true, message: '请选择事件类别', trigger: 'change' }],
-  level: [{ required: true, message: '请选择事件等级', trigger: 'change' }],
-  opinion: [{ required: true, message: '请输入核实意见', trigger: 'blur' }],
-  isReal: [{ required: true, message: '请选择是否真实', trigger: 'change' }]
-}
+const verifyForm = ref({
+  eventNo: '', location: '', longitude: null, latitude: null, reporter: '', description: '',
+  eventCategory: '', eventLevel: '',
+  deptId: null, department: '', handler: '', handlerId: null, handlerPhone: '', verifyOpinion: '', verifyResult: 0
+})
+// 类别/等级/责任处置人只在「真实」时必填;「不实」只需核销原因
+const verifyRules = computed(() => {
+  const isReal = verifyForm.value.verifyResult === 0
+  return {
+    eventCategory: isReal ? [{ required: true, message: '请选择事件类别', trigger: 'change' }] : [],
+    eventLevel: isReal ? [{ required: true, message: '请选择事件等级', trigger: 'change' }] : [],
+    handler: isReal ? [{ required: true, message: '请输入责任处置人', trigger: 'blur' }] : [],
+    verifyOpinion: [{ required: true, message: '请输入核实意见', trigger: 'blur' }],
+    verifyResult: [{ required: true, message: '请选择是否真实', trigger: 'change' }]
+  }
+})
 
 function handleVerify(row) {
   currentVerifyId.value = row.id
   verifyForm.value = {
     eventNo: row.eventNo,
     location: row.location,
+    longitude: row.longitude,
+    latitude: row.latitude,
+    reporter: row.reporter,
     description: row.description,
-    category: row.category,
-    level: row.level,
-    opinion: row.verifyOpinion || '',
-    isReal: '真实'
+    eventCategory: row.eventCategory || '',
+    eventLevel: row.eventLevel || '',
+    // department 存的是部门中文名,回显时反查部门ID以恢复树形选择
+    deptId: deptIdByName(row.department),
+    department: row.department || '',
+    handler: row.handler || '',
+    handlerId: row.handlerId || null,
+    handlerPhone: row.handlerPhone || '',
+    verifyOpinion: row.verifyOpinion || '',
+    verifyResult: 0
   }
   verifyVisible.value = true
+  // 上次校验失败的红框会残留在表单上,打开时清一次校验状态
+  nextTick(() => verifyFormRef.value?.clearValidate())
 }
 
 async function confirmVerify() {
@@ -283,12 +589,17 @@ async function confirmVerify() {
     return
   }
   try {
-    await verifyEventInfo({
-      id: currentVerifyId.value,
-      category: verifyForm.value.category,
-      level: verifyForm.value.level,
-      opinion: verifyForm.value.opinion,
-      isReal: verifyForm.value.isReal
+    // 提交口径不变:department 写部门中文名(工作催办、多条件筛选均按名称匹配)
+    const selectedDept = deptList.value.find(d => String(d.deptId) === String(verifyForm.value.deptId))
+    await verifyEventInfo(currentVerifyId.value, {
+      verifyResult: verifyForm.value.verifyResult,
+      eventCategory: verifyForm.value.eventCategory,
+      eventLevel: verifyForm.value.eventLevel,
+      department: selectedDept ? selectedDept.deptName : verifyForm.value.department,
+      handler: verifyForm.value.handler,
+      handlerId: verifyForm.value.handlerId,
+      handlerPhone: verifyForm.value.handlerPhone,
+      verifyOpinion: verifyForm.value.verifyOpinion
     })
     ElMessage.success('核实成功')
     verifyVisible.value = false
@@ -316,9 +627,15 @@ async function handleDelete(row) {
 const detailVisible = ref(false)
 const currentRow = ref(null)
 
-function handleDetail(row) {
-  currentRow.value = row
+async function handleDetail(row) {
+  currentRow.value = { ...row, timeline: [] }
   detailVisible.value = true
+  try {
+    const res = await getEventLogs(row.id)
+    currentRow.value = { ...currentRow.value, timeline: toTimeline(res.data) }
+  } catch {
+    // 加载失败时保留列表行数据
+  }
 }
 </script>
 

+ 148 - 75
src/views/subSystem/dispatch/disOnDuty/gzcb.vue

@@ -3,12 +3,10 @@
     <!-- 搜索区 -->
     <div class="filter-panel">
       <div class="filter-form">
-        <el-select v-model="searchForm.urgeStatus" placeholder="催办状态" clearable style="width:140px">
-          <el-option label="待催办" value="待催办" />
-          <el-option label="催办中" value="催办中" />
-          <el-option label="已完成" value="已完成" />
+        <el-select v-model="searchForm.status" placeholder="事件状态" clearable style="width:140px">
+          <el-option v-for="d in urgeStatusOptions" :key="d.value" :label="d.label" :value="d.value" />
         </el-select>
-        <el-input v-model="searchForm.receiver" placeholder="目标接收人" clearable style="width:140px" />
+        <el-input v-model="searchForm.handler" placeholder="责任人" clearable style="width:140px" />
         <el-input v-model="searchForm.eventNo" placeholder="事件编号" clearable style="width:160px" />
         <el-button type="primary" @click="handleSearch">搜索</el-button>
         <el-button @click="handleReset">重置</el-button>
@@ -18,22 +16,36 @@
     <!-- 数据表格 -->
     <div class="table-panel">
       <el-table :data="tableData" border stripe style="width:100%" v-loading="loading">
-        <el-table-column prop="eventNo" label="事件编号" width="140" />
-        <el-table-column prop="summary" label="事件摘要" show-overflow-tooltip />
-        <el-table-column prop="receiver" label="目标接收人" width="110" />
-        <el-table-column prop="receiverDept" label="接收部门" width="120" />
-        <el-table-column prop="urgeCount" label="催办次数" width="90" />
-        <el-table-column prop="lastUrgeTime" label="最近催办时间" width="150" />
-        <el-table-column prop="status" label="状态" width="100">
+        <el-table-column prop="eventNo" label="事件编号" width="150" />
+        <el-table-column prop="eventType" label="管网类型" width="100">
+          <template #default="{ row }">{{ dictLabel(dis_event_type, row.eventType) }}</template>
+        </el-table-column>
+        <el-table-column prop="summary" label="事件摘要" min-width="160" show-overflow-tooltip />
+        <el-table-column prop="department" label="责任单位" width="110">
+          <template #default="{ row }">{{ row.department || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="receiver" label="目标接收人" width="100">
+          <template #default="{ row }">{{ row.receiver || row.handler || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="receiverDept" label="接收部门" width="110">
+          <template #default="{ row }">{{ row.receiverDept || row.department || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="elapsed" label="接报后已过" width="110">
+          <template #default="{ row }">{{ row.elapsed || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="urgeCount" label="催办次数" width="90" align="center" />
+        <el-table-column prop="lastUrgeTime" label="最近催办时间" width="150">
+          <template #default="{ row }">{{ row.lastUrgeTime || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="statusName" label="状态" width="90">
           <template #default="{ row }">
-            <el-tag :type="statusTagType(row.status)" size="small">{{ row.status }}</el-tag>
+            <el-tag :type="statusTagType(row.status)" size="small">{{ row.statusName }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="220" fixed="right">
+        <el-table-column label="操作" width="160" fixed="right">
           <template #default="{ row }">
-            <el-button v-if="row.status !== '已完成'" type="primary" link size="small" @click="handleUrge(row)">催办</el-button>
+            <el-button v-if="canUrge(row)" type="primary" link size="small" @click="handleUrge(row)">催办</el-button>
             <el-button type="primary" link size="small" @click="handleViewRecords(row)">查看记录</el-button>
-            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -59,27 +71,39 @@
             <el-form-item label="事件编号">{{ urgeForm.eventNo }}</el-form-item>
           </el-col>
           <el-col :span="12">
-            <el-form-item label="事件摘要">{{ urgeForm.summary }}</el-form-item>
+            <el-form-item label="管网类型">{{ dictLabel(dis_event_type, urgeForm.eventType) }}</el-form-item>
           </el-col>
         </el-row>
-        <div class="section-title">接收人信息</div>
         <el-row :gutter="20">
-          <el-col :span="8">
-            <el-form-item label="姓名">{{ urgeForm.receiver }}</el-form-item>
+          <el-col :span="12">
+            <el-form-item label="当前状态">
+              <el-tag :type="statusTagType(urgeForm.status)" size="small">{{ urgeForm.statusName || '-' }}</el-tag>
+            </el-form-item>
           </el-col>
-          <el-col :span="8">
-            <el-form-item label="部门">{{ urgeForm.receiverDept }}</el-form-item>
+          <el-col :span="12">
+            <el-form-item label="接报后已过">{{ urgeForm.elapsed || '-' }}</el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="事件摘要">
+          <div class="desc-text">{{ urgeForm.summary || '-' }}</div>
+        </el-form-item>
+        <div class="section-title">接收人信息</div>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="姓名">{{ urgeForm.receiver || '-' }}</el-form-item>
           </el-col>
-          <el-col :span="8">
-            <el-form-item label="电话">{{ urgeForm.receiverPhone }}</el-form-item>
+          <el-col :span="12">
+            <el-form-item label="部门">{{ urgeForm.receiverDept || '-' }}</el-form-item>
           </el-col>
         </el-row>
+        <el-form-item label="责任人电话">
+          <span v-if="urgeForm.receiverPhone" style="color:#2f6fe4;font-weight:600;font-size:14px">{{ urgeForm.receiverPhone }}</span>
+          <span v-else style="color:#c0c4cc;font-size:13px">责任人未维护联系电话,请改用电话/短信以外的催办方式</span>
+        </el-form-item>
         <div class="section-title">催办内容</div>
         <el-form-item label="催办方式" prop="methods">
           <el-checkbox-group v-model="urgeForm.methods">
-            <el-checkbox label="电话" />
-            <el-checkbox label="短信" />
-            <el-checkbox label="系统消息" />
+            <el-checkbox v-for="d in dis_event_urge_method" :key="d.value" :label="d.label" />
           </el-checkbox-group>
         </el-form-item>
         <el-form-item label="催办内容" prop="content">
@@ -95,12 +119,27 @@
     <!-- 查看记录弹窗 -->
     <el-drawer v-model="recordsVisible" title="催办记录" size="700px" :close-on-click-modal="false" class="detail-drawer" direction="rtl">
       <div v-if="currentRow">
-        <p style="margin-bottom:12px;font-weight:600">事件编号:{{ currentRow.eventNo }} | 事件摘要:{{ currentRow.summary }}</p>
+        <el-form label-width="100px" size="default" style="margin-bottom:12px">
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="事件编号">{{ currentRow.eventNo }}</el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="责任单位">{{ currentRow.department || '-' }}</el-form-item>
+            </el-col>
+          </el-row>
+          <el-form-item label="事件摘要">
+            <div class="desc-text">{{ currentRow.summary || '-' }}</div>
+          </el-form-item>
+        </el-form>
         <el-table :data="currentRow.records" border stripe style="width:100%">
-          <el-table-column prop="urgeTime" label="催办时间" width="160" />
-          <el-table-column prop="method" label="催办方式" width="120" />
+          <el-table-column prop="urgeTime" label="催办时间" width="150" />
+          <el-table-column prop="method" label="催办方式" width="100" />
+          <el-table-column prop="receiver" label="目标接收人" width="90">
+            <template #default="{ row }">{{ row.receiver || '-' }}</template>
+          </el-table-column>
           <el-table-column prop="content" label="催办内容" show-overflow-tooltip />
-          <el-table-column prop="result" label="结果" width="100">
+          <el-table-column prop="result" label="结果" width="90">
             <template #default="{ row }">
               <el-tag :type="row.result === '已反馈' ? 'success' : 'info'" size="small">{{ row.result }}</el-tag>
             </template>
@@ -115,15 +154,53 @@
 </template>
 
 <script setup name="Gzcb">
-import { ref, onMounted } from 'vue'
-import { ElMessage, ElMessageBox } from 'element-plus'
-import { getEventUrgePage, deleteEventUrge, doUrge, getUrgeDetails } from '@/api/dispatch'
+import { ref, nextTick, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import { useDict } from '@/utils/dict'
+import { getEventUrgePage, urgeEvent, getEventLogs, getEventStatusOptions } from '@/api/dispatch'
+
+// ==================== 字典 ====================
+const { dis_event_urge_method, dis_event_urge_result, dis_event_type } = useDict('dis_event_urge_method', 'dis_event_urge_result', 'dis_event_type')
+
+function dictLabel(list, value) {
+  if (value === null || value === undefined || value === '') return '-'
+  const hit = (list || []).find(item => String(item.value) === String(value) || item.label === value)
+  return hit ? hit.label : value
+}
+
+// 多选方式以「、」连接存储,回显时逐个翻译
+function methodLabel(value) {
+  if (!value) return '-'
+  return String(value).split('、').map(item => dictLabel(dis_event_urge_method.value, item)).join('、')
+}
+
+// 未办结状态均可催办(0 待核实 1 已核实 2 处置中)
+const urgeStatusOptions = ref([])
+
+async function loadStatusOptions() {
+  try {
+    const res = await getEventStatusOptions()
+    urgeStatusOptions.value = (res.data || []).filter(item => [0, 1, 2].includes(item.value))
+  } catch {
+    urgeStatusOptions.value = []
+  }
+}
+
+// 状态码 -> 标签样式(0 待核实 1 已核实 2 处置中 3 已解除 4 不实事件)
+const STATUS_TAG = { 0: 'warning', 1: 'success', 2: 'primary', 3: 'warning', 4: 'info' }
+function statusTagType(status) {
+  return STATUS_TAG[status] || 'info'
+}
+
+function canUrge(row) {
+  return [0, 1, 2].includes(row.status)
+}
 
 // ==================== 数据状态 ====================
 const tableData = ref([])
 const total = ref(0)
 const loading = ref(false)
-const searchForm = ref({ urgeStatus: '', receiver: '', eventNo: '' })
+const searchForm = ref({ status: '', handler: '', eventNo: '' })
 const pagination = ref({ page: 1, pageSize: 10 })
 
 // ==================== 加载数据 ====================
@@ -131,8 +208,8 @@ async function loadData() {
   loading.value = true
   try {
     const params = {}
-    if (searchForm.value.urgeStatus) params.status = searchForm.value.urgeStatus
-    if (searchForm.value.receiver) params.receiver = searchForm.value.receiver
+    if (searchForm.value.status !== '' && searchForm.value.status !== null) params.status = searchForm.value.status
+    if (searchForm.value.handler) params.handler = searchForm.value.handler
     if (searchForm.value.eventNo) params.eventNo = searchForm.value.eventNo
     const res = await getEventUrgePage(pagination.value.page, pagination.value.pageSize, params)
     tableData.value = res.data?.rows || res.data?.records || res.data || []
@@ -145,6 +222,7 @@ async function loadData() {
 }
 
 onMounted(() => {
+  loadStatusOptions()
   loadData()
 })
 
@@ -155,7 +233,7 @@ function handleSearch() {
 }
 
 function handleReset() {
-  searchForm.value = { urgeStatus: '', receiver: '', eventNo: '' }
+  searchForm.value = { status: '', handler: '', eventNo: '' }
   pagination.value.page = 1
   loadData()
 }
@@ -172,36 +250,35 @@ function handleCurrentChange(val) {
   loadData()
 }
 
-// ==================== 标签样式 ====================
-function statusTagType(status) {
-  if (status === '待催办') return 'warning'
-  if (status === '催办中') return 'primary'
-  if (status === '已完成') return 'success'
-  return 'info'
-}
-
 // ==================== 催办 ====================
 const urgeVisible = ref(false)
 const urgeFormRef = ref(null)
 const currentUrgeId = ref(null)
-const urgeForm = ref({ eventNo: '', summary: '', receiver: '', receiverDept: '', receiverPhone: '', methods: [], content: '' })
+const urgeForm = ref({ eventNo: '', eventType: '', status: null, statusName: '', elapsed: '', summary: '', receiver: '', receiverDept: '', receiverPhone: '', methods: [], content: '' })
 const urgeRules = {
   methods: [{ required: true, message: '请选择催办方式', trigger: 'change', type: 'array' }],
   content: [{ required: true, message: '请输入催办内容', trigger: 'blur' }]
 }
 
 function handleUrge(row) {
-  currentUrgeId.value = row.id
+  currentUrgeId.value = row.eventId
   urgeForm.value = {
     eventNo: row.eventNo,
+    eventType: row.eventType,
+    status: row.status,
+    statusName: row.statusName || '',
+    elapsed: row.elapsed || '',
     summary: row.summary || '',
-    receiver: row.receiver,
-    receiverDept: row.receiverDept,
-    receiverPhone: row.receiverPhone,
-    methods: [],
+    receiver: row.receiver || row.handler || '',
+    receiverDept: row.receiverDept || row.department || '',
+    // 电话取事件责任处置人的电话(初判核实录入),无则回退最近催办记录里的号码
+    receiverPhone: row.handlerPhone || row.receiverPhone || '',
+    // 默认勾选「系统消息」:发送后给处置人推一条站内催办消息
+    methods: ['系统消息'],
     content: '关于【' + (row.summary || '') + '】的处置进度,请尽快反馈处理情况。'
   }
   urgeVisible.value = true
+  nextTick(() => urgeFormRef.value?.clearValidate())
 }
 
 async function confirmUrge() {
@@ -211,10 +288,13 @@ async function confirmUrge() {
     return
   }
   try {
-    await doUrge({
-      urgeId: currentUrgeId.value,
+    await urgeEvent({
+      eventId: currentUrgeId.value,
+      receiver: urgeForm.value.receiver,
+      receiverDept: urgeForm.value.receiverDept,
+      receiverPhone: urgeForm.value.receiverPhone,
       method: urgeForm.value.methods.join('、'),
-      content: urgeForm.value.content
+      operDesc: urgeForm.value.content
     })
     ElMessage.success('催办已发送')
     urgeVisible.value = false
@@ -224,35 +304,28 @@ async function confirmUrge() {
   }
 }
 
-// ==================== 删除 ====================
-async function handleDelete(row) {
-  try {
-    await ElMessageBox.confirm('确定要删除该催办记录吗?', '删除确认', { type: 'warning' })
-    await deleteEventUrge(row.id)
-    ElMessage.success('删除成功')
-    loadData()
-  } catch (error) {
-    if (error !== 'cancel' && error !== 'close') {
-      ElMessage.error('删除失败:' + (error.message || '未知错误'))
-    }
-  }
-}
-
 // ==================== 查看记录 ====================
 const recordsVisible = ref(false)
 const currentRow = ref(null)
 
 async function handleViewRecords(row) {
-  currentRow.value = row
+  currentRow.value = { ...row, records: [] }
   recordsVisible.value = true
-  // 尝试从服务器加载催办详情
+  // 催办记录就是该事件 operType=8 的操作日志
   try {
-    const res = await getUrgeDetails(row.id)
-    if (res.data) {
-      currentRow.value = { ...row, records: res.data.records || res.data || [] }
+    const res = await getEventLogs(row.eventId, 8)
+    currentRow.value = {
+      ...row,
+      records: (res.data || []).map(log => ({
+        urgeTime: log.operTime,
+        method: methodLabel(log.method),
+        receiver: log.receiver || '',
+        content: log.operDesc,
+        result: dictLabel(dis_event_urge_result.value, log.urgeResult)
+      }))
     }
   } catch {
-    // 如果加载失败,使用行内的 records 数据
+    // 加载失败时保留空记录列表
   }
 }
 </script>

+ 404 - 139
src/views/subSystem/dispatch/disOnDuty/gzjc.vue

@@ -3,16 +3,11 @@
     <!-- 搜索区 -->
     <div class="filter-panel">
       <div class="filter-form">
-        <el-select v-model="searchForm.eventStatus" placeholder="事件状态" clearable style="width:140px">
-          <el-option label="处置中" value="处置中" />
-          <el-option label="已解除" value="已解除" />
-          <el-option label="已关闭" value="已关闭" />
+        <el-select v-model="searchForm.status" placeholder="事件状态" clearable style="width:140px">
+          <el-option v-for="d in trackStatusOptions" :key="d.value" :label="d.label" :value="d.value" />
         </el-select>
-        <el-select v-model="searchForm.category" placeholder="事件类别" clearable style="width:140px">
-          <el-option label="管道泄漏" value="管道泄漏" />
-          <el-option label="塌陷" value="塌陷" />
-          <el-option label="积水" value="积水" />
-          <el-option label="设备故障" value="设备故障" />
+        <el-select v-model="searchForm.eventCategory" placeholder="事件类别" clearable style="width:140px">
+          <el-option v-for="d in dis_event_category" :key="d.value" :label="d.label" :value="d.value" />
         </el-select>
         <el-date-picker
           v-model="searchForm.dateRange"
@@ -32,27 +27,42 @@
     <div class="table-panel">
       <el-table :data="tableData" border stripe style="width:100%" v-loading="loading">
         <el-table-column prop="eventNo" label="事件编号" width="140" />
-        <el-table-column prop="category" label="事件类别" width="110" />
-        <el-table-column prop="level" label="事件等级" width="100">
+        <el-table-column prop="eventType" label="管网类型" width="100">
+          <template #default="{ row }">{{ dictLabel(dis_event_type, row.eventType) }}</template>
+        </el-table-column>
+        <el-table-column prop="eventCategory" label="事件类别" width="110">
+          <template #default="{ row }">{{ dictLabel(dis_event_category, row.eventCategory) }}</template>
+        </el-table-column>
+        <el-table-column prop="eventLevel" label="事件等级" width="100">
           <template #default="{ row }">
-            <el-tag :type="levelTagType(row.level)" size="small">{{ row.level }}</el-tag>
+            <el-tag v-if="row.eventLevel" :type="levelTagType(row.eventLevel)" size="small">{{ dictLabel(dis_event_level, row.eventLevel) }}</el-tag>
+            <span v-else>-</span>
           </template>
         </el-table-column>
-        <el-table-column prop="status" label="当前状态" width="100">
+        <el-table-column prop="location" label="发生地点" min-width="150" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.location || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="statusName" label="当前状态" width="100">
           <template #default="{ row }">
-            <el-tag :type="statusTagType(row.status)" size="small">{{ row.status }}</el-tag>
+            <el-tag :type="statusTagType(row.status)" size="small">{{ row.statusName }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="lastVisitTime" label="最近回访时间" min-width="150" />
-        <el-table-column prop="nextVisitTime" label="下次回访时间" min-width="150" />
-        <el-table-column prop="handler" label="负责人" min-width="100" />
+        <el-table-column prop="handler" label="负责人" width="100">
+          <template #default="{ row }">{{ row.handler || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="lastVisitTime" label="最近回访" width="150">
+          <template #default="{ row }">{{ row.lastVisitTime || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="nextVisitTime" label="下次回访" min-width="150">
+          <template #default="{ row }">{{ row.nextVisitTime || '-' }}</template>
+        </el-table-column>
         <el-table-column label="操作" width="260" fixed="right">
           <template #default="{ row }">
-            <el-button v-if="row.status === '处置中'" type="primary" link size="small" @click="handleTrack(row)">跟踪</el-button>
-            <el-button v-if="row.status === '处置中'" type="primary" link size="small" @click="handleClose(row)">解除</el-button>
-            <el-button v-if="row.status === '已解除'" type="primary" link size="small" @click="handleClose(row)">关闭</el-button>
+            <el-button v-if="row.status === 1" type="primary" link size="small" @click="handleStart(row)">开始处置</el-button>
+            <el-button v-if="row.status === 2" type="primary" link size="small" @click="handleTrack(row)">跟踪</el-button>
+            <el-button v-if="row.status === 2" type="primary" link size="small" @click="handleRelease(row)">解除</el-button>
             <el-button type="primary" link size="small" @click="handleDetail(row)">详情</el-button>
-            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
+            <el-button v-if="row.editable" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -72,6 +82,31 @@
     <!-- 跟踪弹窗 -->
     <el-dialog v-model="trackVisible" title="事件跟踪" width="700px" :close-on-click-modal="false">
       <div v-if="currentRow">
+        <div class="section-title">事件信息</div>
+        <el-form label-width="100px" size="default" style="margin-bottom:12px">
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="事件编号">{{ currentRow.eventNo }}</el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="管网类型">{{ dictLabel(dis_event_type, currentRow.eventType) }}</el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="当前状态">
+                <el-tag :type="statusTagType(currentRow.status)" size="small">{{ currentRow.statusName }}</el-tag>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="负责人">{{ currentRow.handler || '-' }}</el-form-item>
+            </el-col>
+          </el-row>
+          <el-form-item label="发生地点">
+            <div class="desc-text">{{ currentRow.location || '-' }}</div>
+          </el-form-item>
+        </el-form>
+
         <div class="section-title">事件进展时间线</div>
         <el-timeline>
           <el-timeline-item
@@ -87,20 +122,17 @@
 
         <div class="section-title">新增跟踪记录</div>
         <el-form :model="trackForm" :rules="trackRules" ref="trackFormRef" label-width="120px">
-          <el-form-item label="跟踪内容" prop="content">
-            <el-input v-model="trackForm.content" type="textarea" :rows="3" placeholder="请输入跟踪内容" />
+          <el-form-item label="跟踪内容" prop="operDesc">
+            <el-input v-model="trackForm.operDesc" type="textarea" :rows="3" placeholder="请输入跟踪内容" />
           </el-form-item>
-          <el-form-item label="当前态势评估" prop="assessment">
-            <el-radio-group v-model="trackForm.assessment">
-              <el-radio label="趋于平稳">趋于平稳</el-radio>
-              <el-radio label="仍在发展">仍在发展</el-radio>
-              <el-radio label="明显恶化">明显恶化</el-radio>
+          <el-form-item label="当前态势评估" prop="trend">
+            <el-radio-group v-model="trackForm.trend">
+              <el-radio v-for="d in dis_event_trend" :key="d.value" :label="d.value">{{ d.label }}</el-radio>
             </el-radio-group>
           </el-form-item>
           <el-form-item label="是否需要升级" prop="needUpgrade">
             <el-radio-group v-model="trackForm.needUpgrade">
-              <el-radio label="否">否</el-radio>
-              <el-radio label="是">是</el-radio>
+              <el-radio v-for="d in dis_yes_no" :key="d.value" :label="Number(d.value)">{{ d.label }}</el-radio>
             </el-radio-group>
           </el-form-item>
           <el-form-item label="下次回访时间" prop="nextVisitTime">
@@ -114,28 +146,68 @@
       </template>
     </el-dialog>
 
-    <!-- 解除/关闭弹窗 -->
-    <el-dialog v-model="closeVisible" :title="closeTitle" width="500px" :close-on-click-modal="false">
-      <el-form :model="closeForm" :rules="closeRules" ref="closeFormRef" label-width="100px">
-        <el-form-item label="事件编号">{{ closeForm.eventNo }}</el-form-item>
-        <el-form-item label="事件摘要">{{ closeForm.summary }}</el-form-item>
-        <el-form-item label="解除原因" prop="reason">
-          <el-select v-model="closeForm.reason" placeholder="请选择" style="width:100%">
-            <el-option label="事件不存在" value="事件不存在" />
-            <el-option label="处置完成" value="处置完成" />
-            <el-option label="误报" value="误报" />
+    <!-- 解除弹窗 -->
+    <el-dialog v-model="releaseVisible" title="事件解除" width="600px" :close-on-click-modal="false">
+      <el-form :model="releaseForm" :rules="releaseRules" ref="releaseFormRef" label-width="100px">
+        <el-form-item label="事件编号">{{ releaseForm.eventNo }}</el-form-item>
+        <el-form-item label="事件摘要">
+          <div class="desc-text">{{ releaseForm.summary || '-' }}</div>
+        </el-form-item>
+        <el-form-item label="解除原因" prop="releaseReason">
+          <el-select v-model="releaseForm.releaseReason" placeholder="请选择" style="width:100%">
+            <el-option v-for="d in dis_event_release_reason" :key="d.value" :label="d.label" :value="d.value" />
           </el-select>
         </el-form-item>
-        <el-form-item label="解除说明" prop="remark">
-          <el-input v-model="closeForm.remark" type="textarea" :rows="4" placeholder="请输入解除说明" />
+        <el-form-item label="解除说明" prop="releaseNote">
+          <el-input v-model="releaseForm.releaseNote" type="textarea" :rows="4" placeholder="请输入解除说明" />
         </el-form-item>
         <el-form-item label="损失(万元)">
-          <el-input-number v-model="closeForm.lossAmount" :min="0" :precision="2" style="width:100%" />
+          <el-input-number v-model="releaseForm.lossAmount" :min="0" :precision="2" style="width:100%" />
         </el-form-item>
+        <template v-if="isHandledRelease">
+          <el-form-item label="处置前佐证">
+            <el-upload
+              ref="beforeUploadRef"
+              v-model:file-list="releaseForm.beforeFiles"
+              :action="uploadUrl"
+              :headers="uploadHeaders"
+              :limit="5"
+              multiple
+              accept=".jpg,.jpeg,.png,.gif,.bmp,.webp,.mp4,.mov,.m4v,.webm"
+              :on-success="(res) => handleAttachSuccess(res, 'before')"
+              :on-error="handleAttachError"
+              :on-exceed="() => ElMessage.warning('处置前佐证最多 5 个')"
+              :before-upload="handleAttachBefore"
+              style="width:100%"
+            >
+              <el-button type="primary" size="small">上传处置前照片/视频</el-button>
+              <template #tip><div class="el-upload__tip">图片或视频,单个不超过 50MB,最多 5 个</div></template>
+            </el-upload>
+          </el-form-item>
+          <el-form-item label="处置后佐证">
+            <el-upload
+              ref="afterUploadRef"
+              v-model:file-list="releaseForm.afterFiles"
+              :action="uploadUrl"
+              :headers="uploadHeaders"
+              :limit="5"
+              multiple
+              accept=".jpg,.jpeg,.png,.gif,.bmp,.webp,.mp4,.mov,.m4v,.webm"
+              :on-success="(res) => handleAttachSuccess(res, 'after')"
+              :on-error="handleAttachError"
+              :on-exceed="() => ElMessage.warning('处置后佐证最多 5 个')"
+              :before-upload="handleAttachBefore"
+              style="width:100%"
+            >
+              <el-button type="primary" size="small">上传处置后照片/视频</el-button>
+              <template #tip><div class="el-upload__tip">图片或视频,单个不超过 50MB,最多 5 个</div></template>
+            </el-upload>
+          </el-form-item>
+        </template>
       </el-form>
       <template #footer>
-        <el-button @click="closeVisible = false">取消</el-button>
-        <el-button type="primary" @click="confirmClose">确认</el-button>
+        <el-button @click="releaseVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmRelease">确认</el-button>
       </template>
     </el-dialog>
 
@@ -144,39 +216,96 @@
       <div v-if="currentRow" class="detail-content">
         <el-form label-width="100px" size="default">
           <el-row :gutter="20">
-            <el-col :span="8">
+            <el-col :span="12">
               <el-form-item label="事件编号">{{ currentRow.eventNo }}</el-form-item>
             </el-col>
-            <el-col :span="8">
-              <el-form-item label="事件类别">{{ currentRow.category }}</el-form-item>
+            <el-col :span="12">
+              <el-form-item label="事件来源">{{ dictLabel(dis_event_source, currentRow.eventSource) }}</el-form-item>
             </el-col>
-            <el-col :span="8">
-              <el-form-item label="事件等级">
-                <el-tag v-if="currentRow.level" :type="levelTagType(currentRow.level)" size="small">{{ currentRow.level }}</el-tag>
-              </el-form-item>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="管网类型">{{ dictLabel(dis_event_type, currentRow.eventType) }}</el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="事件类别">{{ dictLabel(dis_event_category, currentRow.eventCategory) }}</el-form-item>
             </el-col>
           </el-row>
           <el-row :gutter="20">
-            <el-col :span="8">
+            <el-col :span="12">
+              <el-form-item label="事件等级">
+                <el-tag v-if="currentRow.eventLevel" :type="levelTagType(currentRow.eventLevel)" size="small">{{ dictLabel(dis_event_level, currentRow.eventLevel) }}</el-tag>
+                <span v-else>-</span>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
               <el-form-item label="当前状态">
-                <el-tag :type="statusTagType(currentRow.status)" size="small">{{ currentRow.status }}</el-tag>
+                <el-tag :type="statusTagType(currentRow.status)" size="small">{{ currentRow.statusName }}</el-tag>
               </el-form-item>
             </el-col>
-            <el-col :span="8">
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
               <el-form-item label="负责人">{{ currentRow.handler || '-' }}</el-form-item>
             </el-col>
-            <el-col :span="8">
+            <el-col :span="12">
               <el-form-item label="发生地点">{{ currentRow.location || '-' }}</el-form-item>
             </el-col>
           </el-row>
           <el-row :gutter="20">
-            <el-col :span="8">
+            <el-col :span="12">
               <el-form-item label="最近回访">{{ currentRow.lastVisitTime || '-' }}</el-form-item>
             </el-col>
-            <el-col :span="8">
+            <el-col :span="12">
               <el-form-item label="下次回访">{{ currentRow.nextVisitTime || '-' }}</el-form-item>
             </el-col>
           </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="解除时间">{{ currentRow.releaseTime || '-' }}</el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="损失(万元)">{{ formatLoss(currentRow.lossAmount) }}</el-form-item>
+            </el-col>
+          </el-row>
+          <el-form-item v-if="currentRow.beforeUrl" label="处置前佐证">
+            <template v-for="(item, i) in attachItems(currentRow.beforeUrl)" :key="'b' + i">
+              <el-image
+                v-if="isImage(item.name)"
+                :src="item.url"
+                :preview-src-list="[item.url]"
+                preview-teleported
+                fit="cover"
+                :initial-index="0"
+                style="width:64px;height:48px;border-radius:4px;border:1px solid #ebeef5;margin:0 8px 6px 0;cursor:zoom-in"
+              />
+              <el-link
+                v-else
+                type="primary"
+                style="margin-right:16px;font-size:13px"
+                @click="downloadAttach(item.url, item.name)"
+              >{{ item.name }}(点击下载)</el-link>
+            </template>
+          </el-form-item>
+          <el-form-item v-if="currentRow.afterUrl" label="处置后佐证">
+            <template v-for="(item, i) in attachItems(currentRow.afterUrl)" :key="'a' + i">
+              <el-image
+                v-if="isImage(item.name)"
+                :src="item.url"
+                :preview-src-list="[item.url]"
+                preview-teleported
+                fit="cover"
+                :initial-index="0"
+                style="width:64px;height:48px;border-radius:4px;border:1px solid #ebeef5;margin:0 8px 6px 0;cursor:zoom-in"
+              />
+              <el-link
+                v-else
+                type="primary"
+                style="margin-right:16px;font-size:13px"
+                @click="downloadAttach(item.url, item.name)"
+              >{{ item.name }}(点击下载)</el-link>
+            </template>
+          </el-form-item>
           <el-form-item label="事件描述">
             <div class="desc-text">{{ currentRow.description || '-' }}</div>
           </el-form-item>
@@ -202,24 +331,116 @@
 </template>
 
 <script setup name="Gzjc">
-import { ref, onMounted } from 'vue'
+import { ref, nextTick, computed, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { getEventPage, deleteEventInfo, trackEvent, releaseEventInfo, closeEventInfo, getTrackLogs } from '@/api/dispatch'
+import { useDict } from '@/utils/dict'
+import { getToken } from '@/utils/auth'
+import {
+  getEventPage, deleteEventInfo, startDisposeEvent, trackEvent,
+  releaseEventInfo, getEventLogs, getEventStatusOptions
+} from '@/api/dispatch'
+
+// ==================== 字典 ====================
+const { dis_event_source, dis_event_type, dis_event_category, dis_event_level, dis_event_trend, dis_event_release_reason, dis_yes_no } = useDict(
+  'dis_event_source', 'dis_event_type', 'dis_event_category', 'dis_event_level', 'dis_event_trend', 'dis_event_release_reason', 'dis_yes_no'
+)
+
+// 损失金额展示:空显示 -,有值保留 2 位小数
+const formatLoss = (val) => (val === null || val === undefined || val === '') ? '-' : Number(val).toFixed(2)
+
+// ---------- 处置佐证附件(URL 逗号分隔 → {url,name} 列表;图片预览 / 视频下载) ----------
+const IMAGE_EXT = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']
+
+function attachUrls(attachUrl) {
+  return String(attachUrl || '').split(',').map(s => s.trim()).filter(Boolean)
+}
+
+function attachItems(attachUrl) {
+  return attachUrls(attachUrl).map(url => {
+    let name = url.substring(url.lastIndexOf('/') + 1)
+    try { name = decodeURIComponent(name) } catch { /* 保留原值 */ }
+    const underscore = name.indexOf('_')
+    return { url, name: underscore > 0 ? name.substring(underscore + 1) : name }
+  })
+}
+
+const isImage = (name) => IMAGE_EXT.includes(String(name).split('.').pop().toLowerCase())
+
+// 视频强制下载:fetch 转 blob 后用 a[download] 触发;CORS 拦截时兜底新窗口打开
+async function downloadAttach(url, name) {
+  if (!url) return
+  try {
+    const res = await fetch(url)
+    if (!res.ok) throw new Error('HTTP ' + res.status)
+    const blob = await res.blob()
+    const link = document.createElement('a')
+    link.href = URL.createObjectURL(blob)
+    link.download = name || '视频'
+    link.click()
+    URL.revokeObjectURL(link.href)
+  } catch {
+    window.open(url, '_blank')
+  }
+}
+
+function dictLabel(list, value) {
+  if (value === null || value === undefined || value === '') return '-'
+  const hit = (list || []).find(item => String(item.value) === String(value))
+  return hit ? hit.label : value
+}
+
+// 本页覆盖「已核实(1) 待开始处置 / 处置中(2) 待跟踪解除 / 已解除(3) 终态查看」
+const trackStatusOptions = ref([])
+
+async function loadStatusOptions() {
+  try {
+    const res = await getEventStatusOptions()
+    trackStatusOptions.value = (res.data || []).filter(item => [1, 2, 3].includes(item.value))
+  } catch {
+    trackStatusOptions.value = []
+  }
+}
+
+// 状态码 -> 标签样式(0 待核实 1 已核实 2 处置中 3 已解除 4 不实事件)
+const STATUS_TAG = { 0: 'warning', 1: 'success', 2: 'primary', 3: 'warning', 4: 'info' }
+function statusTagType(status) {
+  return STATUS_TAG[status] || 'info'
+}
+
+// 等级字典值 -> 标签样式(1 Ⅰ级紧急 2 Ⅱ级较大 3 Ⅲ级一般)
+const LEVEL_TAG = { 1: 'danger', 2: 'warning', 3: 'info' }
+function levelTagType(level) {
+  return LEVEL_TAG[level] || 'info'
+}
+
+// 操作日志 -> 时间线节点
+const LOG_TYPE = { 0: 'primary', 1: 'success', 2: 'info', 3: 'primary', 4: 'primary', 5: 'warning', 6: '', 7: 'success', 8: 'warning', 9: 'info' }
+function toTimeline(logs) {
+  return (logs || []).map(log => ({
+    type: LOG_TYPE[log.operType] ?? '',
+    time: log.operTime,
+    title: log.operTypeName || '操作',
+    content: log.operDesc || log.targets || '-'
+  }))
+}
 
 // ==================== 数据状态 ====================
 const tableData = ref([])
 const total = ref(0)
 const loading = ref(false)
-const searchForm = ref({ eventStatus: '', category: '', dateRange: null })
+const searchForm = ref({ status: '', eventCategory: '', dateRange: null })
 const pagination = ref({ page: 1, pageSize: 10 })
 
+// 下拉清空后可能是 '' / null / undefined,统一判定,避免拼出空查询条件
+const hasValue = (val) => val !== '' && val !== null && val !== undefined
+
 // ==================== 加载数据 ====================
 async function loadData() {
   loading.value = true
   try {
-    const params = { statusList: ['处置中', '已解除', '已关闭'] }
-    if (searchForm.value.eventStatus) params.statusList = [searchForm.value.eventStatus]
-    if (searchForm.value.category) params.category = searchForm.value.category
+    const params = { statusList: [1, 2, 3] }
+    if (hasValue(searchForm.value.status)) params.statusList = [searchForm.value.status]
+    if (searchForm.value.eventCategory) params.eventCategory = searchForm.value.eventCategory
     if (searchForm.value.dateRange && searchForm.value.dateRange.length === 2) {
       params.startDate = searchForm.value.dateRange[0]
       params.endDate = searchForm.value.dateRange[1]
@@ -235,6 +456,7 @@ async function loadData() {
 }
 
 onMounted(() => {
+  loadStatusOptions()
   loadData()
 })
 
@@ -245,7 +467,7 @@ function handleSearch() {
 }
 
 function handleReset() {
-  searchForm.value = { eventStatus: '', category: '', dateRange: null }
+  searchForm.value = { status: '', eventCategory: '', dateRange: null }
   pagination.value.page = 1
   loadData()
 }
@@ -262,52 +484,50 @@ function handleCurrentChange(val) {
   loadData()
 }
 
-// ==================== 标签样式 ====================
-function statusTagType(status) {
-  if (status === '处置中') return 'primary'
-  if (status === '已解除') return 'warning'
-  if (status === '已关闭') return 'success'
-  return 'info'
-}
-
-function levelTagType(level) {
-  if (level === '特别重大') return 'danger'
-  if (level === '重大') return 'danger'
-  if (level === '较大') return 'warning'
-  return 'info'
+// ==================== 开始处置 ====================
+async function handleStart(row) {
+  try {
+    await ElMessageBox.confirm('确认开始处置【' + (row.eventNo || '') + '】吗?', '开始处置', { type: 'warning' })
+  } catch {
+    return
+  }
+  try {
+    await startDisposeEvent({ eventId: row.id, operDesc: '开始处置' })
+    ElMessage.success('已开始处置')
+    loadData()
+  } catch (error) {
+    ElMessage.error('操作失败:' + (error.message || '未知错误'))
+  }
 }
 
 // ==================== 跟踪 ====================
 const trackVisible = ref(false)
 const trackFormRef = ref(null)
+const currentRow = ref(null)
 const currentTrackId = ref(null)
-const trackForm = ref({ eventNo: '', content: '', assessment: '趋于平稳', needUpgrade: '否', nextVisitTime: '' })
+const trackForm = ref({ operDesc: '', trend: '', needUpgrade: 0, nextVisitTime: '' })
 const trackRules = {
-  content: [{ required: true, message: '请输入跟踪内容', trigger: 'blur' }],
-  assessment: [{ required: true, message: '请选择态势评估', trigger: 'change' }],
+  operDesc: [{ required: true, message: '请输入跟踪内容', trigger: 'blur' }],
+  trend: [{ required: true, message: '请选择态势评估', trigger: 'change' }],
   needUpgrade: [{ required: true, message: '请选择是否升级', trigger: 'change' }],
   nextVisitTime: [{ required: true, message: '请选择下次回访时间', trigger: 'change' }]
 }
 
-function handleTrack(row) {
-  currentRow.value = row
+async function handleTrack(row) {
+  currentRow.value = { ...row, timeline: [] }
   currentTrackId.value = row.id
-  trackForm.value = { eventNo: row.eventNo, content: '', assessment: '趋于平稳', needUpgrade: '否', nextVisitTime: '' }
-  // 加载跟踪日志(按最近跟踪记录ID)
-  if (row.trackId) {
-    loadTrackLogs(row.trackId)
-  }
+  trackForm.value = { operDesc: '', trend: '1', needUpgrade: 0, nextVisitTime: '' }
   trackVisible.value = true
+  nextTick(() => trackFormRef.value?.clearValidate())
+  loadTimeline(row.id)
 }
 
-async function loadTrackLogs(trackId) {
+async function loadTimeline(eventId) {
   try {
-    const res = await getTrackLogs(trackId)
-    if (res.data && currentRow.value) {
-      currentRow.value = { ...currentRow.value, timeline: res.data || [] }
-    }
+    const res = await getEventLogs(eventId)
+    currentRow.value = { ...currentRow.value, timeline: toTimeline(res.data) }
   } catch {
-    // 使用行内已有数据
+    // 时间线加载失败时不影响跟踪录入
   }
 }
 
@@ -319,9 +539,9 @@ async function confirmTrack() {
   }
   try {
     await trackEvent({
-      relEventId: currentTrackId.value,
-      description: trackForm.value.content,
-      assessment: trackForm.value.assessment,
+      eventId: currentTrackId.value,
+      operDesc: trackForm.value.operDesc,
+      trend: trackForm.value.trend,
       needUpgrade: trackForm.value.needUpgrade,
       nextVisitTime: trackForm.value.nextVisitTime
     })
@@ -333,44 +553,95 @@ async function confirmTrack() {
   }
 }
 
-// ==================== 解除/关闭 ====================
-const closeVisible = ref(false)
-const closeFormRef = ref(null)
-const currentCloseRow = ref(null)
-const closeForm = ref({ eventNo: '', summary: '', reason: '', remark: '', lossAmount: undefined })
-const closeRules = {
-  reason: [{ required: true, message: '请选择解除原因', trigger: 'change' }],
-  remark: [{ required: true, message: '请输入解除说明', trigger: 'blur' }]
+// ==================== 解除 ====================
+const releaseVisible = ref(false)
+const releaseFormRef = ref(null)
+const currentReleaseRow = ref(null)
+const releaseForm = ref({ eventNo: '', summary: '', releaseReason: '', releaseNote: '', lossAmount: undefined, beforeFiles: [], afterFiles: [], beforeUrls: [], afterUrls: [] })
+const releaseRules = {
+  releaseReason: [{ required: true, message: '请选择解除原因', trigger: 'change' }],
+  releaseNote: [{ required: true, message: '请输入解除说明', trigger: 'blur' }]
+}
+
+// ==================== 解除佐证附件(处置前 / 处置后) ====================
+// 解除原因为「事件已处置完成」(字典值 '1') 时展示上传控件;图片/视频各最多 5 个、单个 ≤50MB
+// 佐证附件统一存 MinIO(图片/视频直链可预览)
+const uploadUrl = import.meta.env.VITE_APP_BASE_API + '/minio/upload?dir=dispatch_release'
+const uploadHeaders = { Authorization: 'Bearer ' + getToken() }
+const beforeUploadRef = ref(null)
+const afterUploadRef = ref(null)
+const ALLOWED_ATTACH_EXT = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'mp4', 'mov', 'm4v', 'webm']
+
+const isHandledRelease = computed(() => dictLabel(dis_event_release_reason.value, releaseForm.value.releaseReason) === '事件已处置完成')
+
+function handleAttachBefore(file) {
+  const ext = String(file.name).split('.').pop().toLowerCase()
+  if (!ALLOWED_ATTACH_EXT.includes(ext)) {
+    ElMessage.error('仅支持图片(jpg/png/gif 等)或视频(mp4/mov 等)格式')
+    return false
+  }
+  if (file.size / 1024 / 1024 >= 50) {
+    ElMessage.error('文件大小不能超过 50MB')
+    return false
+  }
+  return true
 }
-const closeTitle = ref('事件解除')
 
-function handleClose(row) {
-  currentCloseRow.value = row
-  closeTitle.value = row.status === '处置中' ? '事件解除' : '事件关闭'
-  closeForm.value = { eventNo: row.eventNo, summary: row.description || '', reason: '', remark: '', lossAmount: row.lossAmount ? Number(row.lossAmount) : undefined }
-  closeVisible.value = true
+function handleAttachSuccess(res, slot) {
+  if (res.code === 200 && res.url) {
+    releaseForm.value[slot + 'Urls'].push(res.url)
+  } else {
+    ElMessage.error(res.msg || '上传失败')
+  }
 }
 
-async function confirmClose() {
+function handleAttachError() {
+  ElMessage.error('文件上传失败')
+}
+
+function handleRelease(row) {
+  currentReleaseRow.value = row
+  releaseForm.value = {
+    eventNo: row.eventNo,
+    summary: row.description || '',
+    releaseReason: '',
+    releaseNote: '',
+    lossAmount: row.lossAmount !== null && row.lossAmount !== undefined ? Number(row.lossAmount) : undefined,
+    beforeFiles: [],
+    afterFiles: [],
+    beforeUrls: [],
+    afterUrls: []
+  }
+  releaseVisible.value = true
+  // 清掉上次打开残留的上传文件列表
+  nextTick(() => {
+    beforeUploadRef.value?.clearFiles()
+    afterUploadRef.value?.clearFiles()
+  })
+}
+
+async function confirmRelease() {
   try {
-    await closeFormRef.value.validate()
+    await releaseFormRef.value.validate()
   } catch {
     return
   }
   try {
-    if (currentCloseRow.value.status === '处置中') {
-      await releaseEventInfo(currentCloseRow.value.id)
-      ElMessage.success('事件已解除')
-    } else if (currentCloseRow.value.status === '已解除') {
-      await closeEventInfo({
-        id: currentCloseRow.value.id,
-        closeReason: closeForm.value.reason,
-        closeRemark: closeForm.value.remark,
-        lossAmount: closeForm.value.lossAmount
-      })
-      ElMessage.success('事件已关闭')
-    }
-    closeVisible.value = false
+    await releaseEventInfo(currentReleaseRow.value.id, {
+      eventId: currentReleaseRow.value.id,
+      releaseReason: releaseForm.value.releaseReason,
+      releaseNote: releaseForm.value.releaseNote,
+      lossAmount: releaseForm.value.lossAmount,
+      // 处置前/后佐证(多文件英文逗号分隔),仅「事件已处置完成」时采集
+      beforeUrl: releaseForm.value.beforeUrls.join(','),
+      afterUrl: releaseForm.value.afterUrls.join(',')
+    })
+    ElMessage.success('事件已解除')
+    releaseVisible.value = false
+    nextTick(() => {
+      beforeUploadRef.value?.clearFiles()
+      afterUploadRef.value?.clearFiles()
+    })
     loadData()
   } catch (error) {
     ElMessage.error('操作失败:' + (error.message || '未知错误'))
@@ -380,7 +651,7 @@ async function confirmClose() {
 // ==================== 删除 ====================
 async function handleDelete(row) {
   try {
-    await ElMessageBox.confirm('确定要删除该跟踪记录吗?', '删除确认', { type: 'warning' })
+    await ElMessageBox.confirm('确定要删除该事件吗?', '删除确认', { type: 'warning' })
     await deleteEventInfo(row.id)
     ElMessage.success('删除成功')
     loadData()
@@ -393,21 +664,15 @@ async function handleDelete(row) {
 
 // ==================== 详情 ====================
 const detailVisible = ref(false)
-const currentRow = ref(null)
 
 async function handleDetail(row) {
-  currentRow.value = row
+  currentRow.value = { ...row, timeline: [] }
   detailVisible.value = true
-  // 加载最新的跟踪日志(按最近跟踪记录ID)
-  if (row.trackId) {
-    try {
-      const res = await getTrackLogs(row.trackId)
-      if (res.data && res.data.length) {
-        currentRow.value = { ...row, timeline: res.data }
-      }
-    } catch {
-      // 使用行内已有数据
-    }
+  try {
+    const res = await getEventLogs(row.id)
+    currentRow.value = { ...currentRow.value, timeline: toTimeline(res.data) }
+  } catch {
+    // 加载失败时保留列表行数据
   }
 }
 </script>

+ 201 - 93
src/views/subSystem/dispatch/disOnDuty/jbgl.vue

@@ -3,11 +3,8 @@
     <!-- 搜索区 -->
     <div class="filter-panel">
       <div class="filter-form">
-        <el-select v-model="searchForm.source" placeholder="事件来源" clearable style="width:160px">
-          <el-option label="巡查上报" value="巡查上报" />
-          <el-option label="呼叫中心" value="呼叫中心" />
-          <el-option label="视频监控" value="视频监控" />
-          <el-option label="上级下达" value="上级下达" />
+        <el-select v-model="searchForm.eventSource" placeholder="事件来源" clearable style="width:160px">
+          <el-option v-for="d in dis_event_source" :key="d.value" :label="d.label" :value="d.value" />
         </el-select>
         <el-date-picker
           v-model="searchForm.dateRange"
@@ -19,13 +16,7 @@
           value-format="YYYY-MM-DD"
         />
         <el-select v-model="searchForm.status" placeholder="事件状态" clearable style="width:140px">
-          <el-option label="待受理" value="待受理" />
-          <el-option label="待核实" value="待核实" />
-          <el-option label="已核实" value="已核实" />
-          <el-option label="处置中" value="处置中" />
-          <el-option label="已解除" value="已解除" />
-          <el-option label="已关闭" value="已关闭" />
-          <el-option label="不实事件" value="不实事件" />
+          <el-option v-for="d in eventStatusOptions" :key="d.value" :label="d.label" :value="d.value" />
         </el-select>
         <el-button type="primary" @click="handleSearch">搜索</el-button>
         <el-button @click="handleReset">重置</el-button>
@@ -39,22 +30,26 @@
     <div class="table-panel">
       <el-table :data="tableData" border stripe style="width:100%" v-loading="loading">
         <el-table-column prop="eventNo" label="事件编号" width="140" />
-        <el-table-column prop="source" label="事件来源" width="110" />
+        <el-table-column prop="eventSource" label="事件来源" width="110">
+          <template #default="{ row }">{{ dictLabel(dis_event_source, row.eventSource) }}</template>
+        </el-table-column>
+        <el-table-column prop="eventType" label="管网类型" width="110">
+          <template #default="{ row }">{{ dictLabel(dis_event_type, row.eventType) }}</template>
+        </el-table-column>
         <el-table-column prop="location" label="发生地点" show-overflow-tooltip />
         <el-table-column prop="happenTime" label="发生时间" width="150" />
         <el-table-column prop="description" label="事件描述" show-overflow-tooltip />
         <el-table-column prop="reporter" label="接报人" width="100" />
-        <el-table-column prop="status" label="状态" width="100">
+        <el-table-column prop="statusName" label="状态" width="100">
           <template #default="{ row }">
-            <el-tag :type="statusTagType(row.status)" size="small">{{ row.status }}</el-tag>
+            <el-tag :type="statusTagType(row.status)" size="small">{{ row.statusName }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column label="操作" width="220" fixed="right">
           <template #default="{ row }">
-            <el-button v-if="row.status === '待受理'" type="primary" link size="small" @click="handleAccept(row)">受理</el-button>
-            <el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
+            <el-button v-if="row.editable" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
             <el-button type="primary" link size="small" @click="handleDetail(row)">详情</el-button>
-            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
+            <el-button v-if="row.editable" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -71,20 +66,6 @@
       </div>
     </div>
 
-    <!-- 受理确认弹窗 -->
-    <el-dialog v-model="acceptVisible" title="受理确认" width="400px" :close-on-click-modal="false">
-      <div v-if="currentRow">
-        <p>事件编号:{{ currentRow.eventNo }}</p>
-        <p>事件来源:{{ currentRow.source }}</p>
-        <p>发生地点:{{ currentRow.location }}</p>
-        <p>事件描述:{{ currentRow.description }}</p>
-      </div>
-      <template #footer>
-        <el-button @click="acceptVisible = false">取消</el-button>
-        <el-button type="primary" @click="confirmAccept">确认受理</el-button>
-      </template>
-    </el-dialog>
-
     <!-- 详情弹窗 -->
     <el-drawer v-model="detailVisible" title="事件详情" size="600px" :close-on-click-modal="false" class="detail-drawer" direction="rtl">
       <div v-if="currentRow" class="detail-content">
@@ -94,7 +75,7 @@
               <el-form-item label="事件编号">{{ currentRow.eventNo }}</el-form-item>
             </el-col>
             <el-col :span="12">
-              <el-form-item label="事件来源">{{ currentRow.source }}</el-form-item>
+              <el-form-item label="事件来源">{{ dictLabel(dis_event_source, currentRow.eventSource) }}</el-form-item>
             </el-col>
           </el-row>
           <el-row :gutter="20">
@@ -116,10 +97,38 @@
               <el-form-item label="接报时间">{{ currentRow.reportTime }}</el-form-item>
             </el-col>
           </el-row>
-          <el-form-item label="来源渠道">{{ currentRow.channel }}</el-form-item>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="管网类型">{{ dictLabel(dis_event_type, currentRow.eventType) }}</el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="来源渠道">{{ dictLabel(dis_event_channel, currentRow.channel) }}</el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="事件坐标">{{ formatGeo(currentRow.longitude, currentRow.latitude) }}</el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="联系电话">{{ currentRow.reporterPhone || '-' }}</el-form-item>
+            </el-col>
+          </el-row>
           <el-form-item label="状态">
-            <el-tag :type="statusTagType(currentRow.status)" size="small">{{ currentRow.status }}</el-tag>
+            <el-tag :type="statusTagType(currentRow.status)" size="small">{{ currentRow.statusName }}</el-tag>
           </el-form-item>
+          <div class="section-title">事件记录(操作日志)</div>
+          <el-timeline v-if="currentRow.timeline && currentRow.timeline.length">
+            <el-timeline-item
+              v-for="(item, index) in currentRow.timeline"
+              :key="index"
+              :type="item.type"
+              :timestamp="item.time"
+            >
+              <div style="font-weight:600">{{ item.title }}</div>
+              <div style="color:#606266;font-size:13px">{{ item.content }}</div>
+            </el-timeline-item>
+          </el-timeline>
+          <div v-else class="desc-text">暂无记录</div>
         </el-form>
       </div>
       <template #footer>
@@ -130,17 +139,25 @@
     <!-- 新增/编辑接报弹窗 -->
     <el-dialog v-model="addVisible" :title="editingId ? '编辑接报' : '新增接报'" width="600px" :close-on-click-modal="false">
       <el-form :model="addForm" :rules="addRules" ref="addFormRef" label-width="100px">
-        <el-form-item label="事件来源" prop="source">
-          <el-select v-model="addForm.source" placeholder="请选择" style="width:100%">
-            <el-option label="巡查上报" value="巡查上报" />
-            <el-option label="呼叫中心" value="呼叫中心" />
-            <el-option label="视频监控" value="视频监控" />
-            <el-option label="上级下达" value="上级下达" />
+        <el-form-item label="事件来源" prop="eventSource">
+          <el-select v-model="addForm.eventSource" placeholder="请选择" style="width:100%">
+            <el-option v-for="d in dis_event_source" :key="d.value" :label="d.label" :value="d.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="管网类型" prop="eventType">
+          <el-select v-model="addForm.eventType" placeholder="请选择管网类型" style="width:100%">
+            <el-option v-for="d in dis_event_type" :key="d.value" :label="d.label" :value="d.value" />
           </el-select>
         </el-form-item>
         <el-form-item label="发生地点" prop="location">
           <el-input v-model="addForm.location" placeholder="请输入发生地点" />
         </el-form-item>
+        <el-form-item label="事件坐标" prop="longitude">
+          <div style="display:flex;gap:8px;width:100%">
+            <el-input :model-value="geoText" readonly placeholder="填写发生地点后点击「解析坐标」" />
+            <el-button :loading="geoLoading" @click="handleGeocode">解析坐标</el-button>
+          </div>
+        </el-form-item>
         <el-form-item label="发生时间" prop="happenTime">
           <el-date-picker v-model="addForm.happenTime" type="datetime" placeholder="选择日期时间" style="width:100%" value-format="YYYY-MM-DD HH:mm:ss" />
         </el-form-item>
@@ -148,18 +165,17 @@
           <el-input v-model="addForm.description" type="textarea" :rows="3" placeholder="请输入事件描述" />
         </el-form-item>
         <el-form-item label="接报人" prop="reporter">
-          <el-input v-model="addForm.reporter" placeholder="请输入接报人" />
+          <el-input v-model="addForm.reporter" readonly placeholder="自动取当前录入人员" />
         </el-form-item>
         <el-form-item label="接报时间" prop="reportTime">
           <el-date-picker v-model="addForm.reportTime" type="datetime" placeholder="选择日期时间" style="width:100%" value-format="YYYY-MM-DD HH:mm:ss" />
         </el-form-item>
+        <el-form-item label="联系电话" prop="reporterPhone">
+          <el-input v-model="addForm.reporterPhone" placeholder="请输入上报人联系电话" />
+        </el-form-item>
         <el-form-item label="来源渠道" prop="channel">
           <el-select v-model="addForm.channel" placeholder="请选择来源渠道" style="width:100%">
-            <el-option label="12345市民热线" value="12345市民热线" />
-            <el-option label="移动终端巡查系统" value="移动终端巡查系统" />
-            <el-option label="省应急管理厅平台" value="省应急管理厅平台" />
-            <el-option label="交警监控共享平台" value="交警监控共享平台" />
-            <el-option label="其他" value="其他" />
+            <el-option v-for="d in dis_event_channel" :key="d.value" :label="d.label" :value="d.value" />
           </el-select>
         </el-form-item>
       </el-form>
@@ -172,15 +188,52 @@
 </template>
 
 <script setup name="Jbgl">
-import { ref, onMounted } from 'vue'
+import { ref, computed, nextTick, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { getEventPage, saveEventReport, updateEventInfo, deleteEventInfo, acceptEvent } from '@/api/dispatch'
+import { useDict } from '@/utils/dict'
+import useUserStore from '@/store/modules/user'
+import { bd09ToWgs84 } from '@/utils/coordTransform'
+import { getEventPage, saveEventReport, updateEventInfo, deleteEventInfo, getEventStatusOptions, getEventLogs } from '@/api/dispatch'
+
+// ==================== 字典 ====================
+const { dis_event_source, dis_event_channel, dis_event_type } = useDict('dis_event_source', 'dis_event_channel', 'dis_event_type')
+const eventStatusOptions = ref([])
+
+// ==================== 当前录入人员 ====================
+// 接报人固定取当前录入人员,不手填(后端落库时同样以当前登录用户为准)
+const userStore = useUserStore()
+function currentOperatorName() {
+  return userStore.nickName || userStore.name || ''
+}
+
+function dictLabel(list, value) {
+  if (value === null || value === undefined || value === '') return '-'
+  const hit = (list || []).find(item => String(item.value) === String(value))
+  return hit ? hit.label : value
+}
+
+// 状态码 -> 标签样式(0 待核实 1 已核实 2 处置中 3 已解除 4 不实事件)
+const STATUS_TAG = { 0: 'warning', 1: 'success', 2: 'primary', 3: 'warning', 4: 'info' }
+function statusTagType(status) {
+  return STATUS_TAG[status] || 'info'
+}
+
+// 操作日志 -> 时间线节点(operType 见后端 EventLogOperTypeEnum:0 接报登记 ... 9 信息修改)
+const LOG_TYPE = { 0: 'primary', 1: 'success', 2: 'info', 3: 'primary', 4: 'primary', 5: 'warning', 6: '', 7: 'success', 8: 'warning', 9: 'info' }
+function toTimeline(logs) {
+  return (logs || []).map(log => ({
+    type: LOG_TYPE[log.operType] ?? '',
+    time: log.operTime,
+    title: log.operTypeName || '操作',
+    content: log.operDesc || log.targets || '-'
+  }))
+}
 
 // ==================== 数据状态 ====================
 const tableData = ref([])
 const total = ref(0)
 const loading = ref(false)
-const searchForm = ref({ source: '', dateRange: null, status: '' })
+const searchForm = ref({ eventSource: '', dateRange: null, status: '' })
 const pagination = ref({ page: 1, pageSize: 10 })
 
 // ==================== 加载数据 ====================
@@ -188,8 +241,8 @@ async function loadData() {
   loading.value = true
   try {
     const params = {}
-    if (searchForm.value.source) params.source = searchForm.value.source
-    if (searchForm.value.status) params.status = searchForm.value.status
+    if (searchForm.value.eventSource) params.eventSource = searchForm.value.eventSource
+    if (searchForm.value.status !== '' && searchForm.value.status !== null) params.status = searchForm.value.status
     if (searchForm.value.dateRange && searchForm.value.dateRange.length === 2) {
       params.startDate = searchForm.value.dateRange[0]
       params.endDate = searchForm.value.dateRange[1]
@@ -204,7 +257,17 @@ async function loadData() {
   }
 }
 
+async function loadStatusOptions() {
+  try {
+    const res = await getEventStatusOptions()
+    eventStatusOptions.value = res.data || []
+  } catch {
+    eventStatusOptions.value = []
+  }
+}
+
 onMounted(() => {
+  loadStatusOptions()
   loadData()
 })
 
@@ -215,7 +278,7 @@ function handleSearch() {
 }
 
 function handleReset() {
-  searchForm.value = { source: '', dateRange: null, status: '' }
+  searchForm.value = { eventSource: '', dateRange: null, status: '' }
   pagination.value.page = 1
   loadData()
 }
@@ -232,85 +295,130 @@ function handleCurrentChange(val) {
   loadData()
 }
 
-// ==================== 状态标签样式 ====================
-function statusTagType(status) {
-  if (status === '待受理') return 'warning'
-  if (status === '待核实') return 'warning'
-  if (status === '已核实') return 'success'
-  if (status === '处置中') return 'primary'
-  if (status === '已解除') return 'warning'
-  if (status === '已关闭') return 'success'
-  if (status === '不实事件') return 'info'
-  return 'info'
-}
-
-// ==================== 受理 ====================
-const acceptVisible = ref(false)
-const currentRow = ref(null)
-
-function handleAccept(row) {
-  currentRow.value = row
-  acceptVisible.value = true
-}
-
-async function confirmAccept() {
-  if (!currentRow.value) return
-  try {
-    await acceptEvent(currentRow.value.id)
-    ElMessage.success('受理成功')
-    acceptVisible.value = false
-    loadData()
-  } catch (error) {
-    ElMessage.error('受理失败:' + (error.message || '未知错误'))
-  }
-}
-
 // ==================== 详情 ====================
 const detailVisible = ref(false)
+const currentRow = ref(null)
 
-function handleDetail(row) {
-  currentRow.value = row
+async function handleDetail(row) {
+  currentRow.value = { ...row, timeline: [] }
   detailVisible.value = true
+  try {
+    const res = await getEventLogs(row.id)
+    currentRow.value = { ...currentRow.value, timeline: toTimeline(res.data) }
+  } catch {
+    // 加载失败时保留列表行数据
+  }
 }
 
 // ==================== 新增/编辑接报 ====================
 const addVisible = ref(false)
 const addFormRef = ref(null)
 const editingId = ref(null)
-const addForm = ref({ source: '', location: '', happenTime: '', description: '', reporter: '', reportTime: '', channel: '' })
+
+// 接报表单初值(含原型要求的「事件坐标」与「联系电话」;接报人自动取当前录入人员)
+function blankForm() {
+  return {
+    eventSource: '', eventType: '', location: '', longitude: null, latitude: null,
+    happenTime: '', description: '', reporter: currentOperatorName(), reportTime: '',
+    reporterPhone: '', channel: ''
+  }
+}
+
+const addForm = ref(blankForm())
 const addRules = {
-  source: [{ required: true, message: '请选择事件来源', trigger: 'change' }],
+  eventSource: [{ required: true, message: '请选择事件来源', trigger: 'change' }],
   location: [{ required: true, message: '请输入发生地点', trigger: 'blur' }],
   happenTime: [{ required: true, message: '请选择发生时间', trigger: 'change' }],
   description: [{ required: true, message: '请输入事件描述', trigger: 'blur' }],
   reporter: [{ required: true, message: '请输入接报人', trigger: 'blur' }],
   reportTime: [{ required: true, message: '请选择接报时间', trigger: 'change' }],
-  channel: [{ required: true, message: '请输入来源渠道', trigger: 'blur' }]
+  channel: [{ required: true, message: '请选择来源渠道', trigger: 'change' }]
+}
+
+// ==================== 事件坐标 ====================
+const geoLoading = ref(false)
+const hasCoord = (val) => val !== null && val !== undefined && val !== ''
+
+// 展示口径:经度, 纬度(不带 °N / °E 后缀;库内统一存 WGS84,与管网 / 设备模块同口径)
+function formatGeo(lng, lat) {
+  if (!hasCoord(lng) || !hasCoord(lat)) return '-'
+  return `${Number(lng).toFixed(6)}, ${Number(lat).toFixed(6)}`
+}
+const geoText = computed(() => {
+  const text = formatGeo(addForm.value.longitude, addForm.value.latitude)
+  return text === '-' ? '' : text
+})
+
+// 发生地点 → 坐标:百度 Geocoder 返回 BD-09,落库前转 WGS84
+function handleGeocode() {
+  const address = (addForm.value.location || '').trim()
+  if (!address) {
+    ElMessage.warning('请先填写发生地点,再解析坐标')
+    return
+  }
+  const BMapGL = window.BMapGL
+  if (!BMapGL || typeof BMapGL.Geocoder !== 'function') {
+    ElMessage.warning('地图服务未就绪,请稍后重试')
+    return
+  }
+  geoLoading.value = true
+  try {
+    const geocoder = new BMapGL.Geocoder()
+    geocoder.getPoint(address, (point) => {
+      geoLoading.value = false
+      if (!point) {
+        ElMessage.warning('地址解析失败,请把发生地点写得更具体后重试')
+        return
+      }
+      const [lng, lat] = bd09ToWgs84(point.lng, point.lat)
+      addForm.value.longitude = Number(lng.toFixed(6))
+      addForm.value.latitude = Number(lat.toFixed(6))
+      ElMessage.success('坐标解析成功:' + formatGeo(addForm.value.longitude, addForm.value.latitude))
+    })
+  } catch (error) {
+    geoLoading.value = false
+    ElMessage.warning('地址解析失败,请稍后重试')
+  }
 }
 
 function handleAdd() {
   editingId.value = null
-  addForm.value = { source: '', location: '', happenTime: '', description: '', reporter: '', reportTime: '', channel: '' }
+  addForm.value = blankForm()
   addVisible.value = true
+  nextTick(() => addFormRef.value?.clearValidate())
 }
 
 function handleEdit(row) {
   editingId.value = row.id
   addForm.value = {
-    source: row.source,
+    eventSource: row.eventSource,
+    eventType: row.eventType,
     location: row.location,
+    longitude: row.longitude,
+    latitude: row.latitude,
     happenTime: row.happenTime,
     description: row.description,
+    // 接报人在接报登记时已按「当前录入人员」确定,编辑环节只读回显、不可改
     reporter: row.reporter,
     reportTime: row.reportTime,
+    reporterPhone: row.reporterPhone,
     channel: row.channel
   }
   addVisible.value = true
+  nextTick(() => addFormRef.value?.clearValidate())
 }
 
 function handleSubmit() {
   addFormRef.value.validate(async (valid) => {
     if (!valid) return
+    // 事件坐标要落库:保存前若还没解析,明确确认一次,避免静默存进空坐标
+    if (!hasCoord(addForm.value.longitude) || !hasCoord(addForm.value.latitude)) {
+      try {
+        await ElMessageBox.confirm('尚未解析事件坐标,保存后该事件将没有坐标信息,是否继续?', '提示', { type: 'warning' })
+      } catch {
+        return
+      }
+    }
     try {
       if (editingId.value) {
         await updateEventInfo({ id: editingId.value, ...addForm.value })

+ 259 - 124
src/views/subSystem/dispatch/disOnDuty/zjbs.vue

@@ -4,17 +4,11 @@
     <div class="filter-panel">
       <div class="filter-form">
         <el-input v-model="searchForm.eventNo" placeholder="事件编号" clearable style="width:160px" />
-        <el-select v-model="searchForm.transferStatus" placeholder="转接状态" clearable style="width:140px">
-          <el-option label="已核实" value="已核实" />
-          <el-option label="处置中" value="处置中" />
+        <el-select v-model="searchForm.status" placeholder="事件状态" clearable style="width:140px">
+          <el-option v-for="d in transferStatusOptions" :key="d.value" :label="d.label" :value="d.value" />
         </el-select>
-        <el-select v-model="searchForm.receiveDept" placeholder="接收部门" clearable style="width:160px">
-          <el-option label="市政部门" value="市政部门" />
-          <el-option label="消防部门" value="消防部门" />
-          <el-option label="排水管理部" value="排水管理部" />
-          <el-option label="管网维护部" value="管网维护部" />
-          <el-option label="设备运维部" value="设备运维部" />
-          <el-option label="安全管理部" value="安全管理部" />
+        <el-select v-model="searchForm.department" placeholder="责任单位" clearable style="width:160px">
+          <el-option v-for="d in dis_transfer_dept" :key="d.value" :label="d.label" :value="d.label" />
         </el-select>
         <el-button type="primary" @click="handleSearch">搜索</el-button>
         <el-button @click="handleReset">重置</el-button>
@@ -25,26 +19,48 @@
     <div class="table-panel">
       <el-table :data="tableData" border stripe style="width:100%" v-loading="loading">
         <el-table-column prop="eventNo" label="事件编号" width="150" />
-        <el-table-column prop="category" label="事件类别" width="110" />
-        <el-table-column prop="level" label="事件等级" width="100">
+        <el-table-column prop="eventType" label="管网类型" width="100">
+          <template #default="{ row }">{{ dictLabel(dis_event_type, row.eventType) }}</template>
+        </el-table-column>
+        <el-table-column prop="eventCategory" label="事件类别" width="110">
+          <template #default="{ row }">{{ dictLabel(dis_event_category, row.eventCategory) }}</template>
+        </el-table-column>
+        <el-table-column prop="eventLevel" label="事件等级" width="100">
           <template #default="{ row }">
-            <el-tag :type="levelTagType(row.level)" size="small">{{ row.level }}</el-tag>
+            <el-tag v-if="row.eventLevel" :type="levelTagType(row.eventLevel)" size="small">{{ dictLabel(dis_event_level, row.eventLevel) }}</el-tag>
+            <span v-else>-</span>
           </template>
         </el-table-column>
-        <el-table-column prop="transferDept" label="转接部门" min-width="120" />
-        <el-table-column prop="reportTargets" label="报送层级" min-width="120" />
-        <el-table-column prop="transferTime" label="转接时间" min-width="150" />
-        <el-table-column prop="status" label="状态" width="100">
+        <el-table-column prop="lastTransferTargets" label="转接部门" min-width="120">
+          <template #default="{ row }">{{ row.lastTransferTargets || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="lastReportTargets" label="报送层级" min-width="130">
           <template #default="{ row }">
-            <el-tag :type="statusTagType(row.status)" size="small">{{ row.status }}</el-tag>
+            <span>{{ row.lastReportTargets || '-' }}</span>
+            <el-popover v-if="row.lastReportAttachUrl" placement="top" trigger="hover" width="280">
+              <template #reference>
+                <el-button type="primary" link size="small" style="margin-left:4px">附件({{ attachUrls(row.lastReportAttachUrl).length }})</el-button>
+              </template>
+              <div v-for="(name, i) in attachNames(row.lastReportAttachUrl)" :key="i" style="margin:4px 0">
+                <el-link type="primary" @click="downloadAttach(attachUrls(row.lastReportAttachUrl)[i])">{{ name }}</el-link>
+              </div>
+            </el-popover>
+          </template>
+        </el-table-column>
+        <el-table-column prop="lastTransferTime" label="转接时间" min-width="150">
+          <template #default="{ row }">{{ row.lastTransferTime || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="statusName" label="状态" width="100">
+          <template #default="{ row }">
+            <el-tag :type="statusTagType(row.status)" size="small">{{ row.statusName }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column label="操作" width="240" fixed="right">
           <template #default="{ row }">
-            <el-button v-if="row.status === '已核实'" type="primary" link size="small" @click="handleTransfer(row)">转接</el-button>
-            <el-button v-if="row.status === '处置中' && row.transferId" type="primary" link size="small" @click="handleReport(row)">报送</el-button>
+            <el-button v-if="row.status === 1" type="primary" link size="small" @click="handleTransfer(row)">转接</el-button>
+            <el-button v-if="canReport(row)" type="primary" link size="small" @click="handleReport(row)">报送</el-button>
             <el-button type="primary" link size="small" @click="handleDetail(row)">详情</el-button>
-            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
+            <el-button v-if="row.editable" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -65,19 +81,16 @@
     <el-dialog v-model="transferVisible" title="事件转接" width="600px" :close-on-click-modal="false">
       <el-form :model="transferForm" :rules="transferRules" ref="transferFormRef" label-width="120px">
         <el-form-item label="事件摘要">
-          <div class="desc-text">{{ transferForm.summary }}</div>
-        </el-form-item>
-        <el-form-item label="推荐转接部门">
-          <el-tag type="success" size="small">{{ transferForm.recommendDept }}</el-tag>
+          <div class="desc-text" style="width:100%;min-height:90px">
+            <div>事件编号:{{ transferForm.eventNo || '-' }}</div>
+            <div>发生时间:{{ transferForm.happenTime || '-' }}</div>
+            <div>发生地点:{{ transferForm.location || '-' }}</div>
+            <div style="margin-top:4px">事件描述:{{ transferForm.summary || '-' }}</div>
+          </div>
         </el-form-item>
         <el-form-item label="选择转接部门" prop="depts">
           <el-select v-model="transferForm.depts" multiple placeholder="请选择" style="width:100%">
-            <el-option label="市政部门" value="市政部门" />
-            <el-option label="消防部门" value="消防部门" />
-            <el-option label="排水管理部" value="排水管理部" />
-            <el-option label="管网维护部" value="管网维护部" />
-            <el-option label="设备运维部" value="设备运维部" />
-            <el-option label="安全管理部" value="安全管理部" />
+            <el-option v-for="d in dis_transfer_dept" :key="d.value" :label="d.label" :value="d.label" />
           </el-select>
         </el-form-item>
         <el-form-item label="备注">
@@ -92,37 +105,45 @@
 
     <!-- 报送弹窗 -->
     <el-dialog v-model="reportVisible" title="事件报送" width="600px" :close-on-click-modal="false">
-      <el-form :model="reportForm" :rules="reportRules" ref="reportFormRef" label-width="120px">
-        <el-form-item label="报送层级" prop="reportTargets">
-          <el-checkbox-group v-model="reportForm.reportTargets">
-            <el-checkbox label="区级应急办" />
-            <el-checkbox label="市级应急办" />
-            <el-checkbox label="省级应急厅" />
-            <el-checkbox label="国务院应急管理部" />
-          </el-checkbox-group>
-        </el-form-item>
-        <el-form-item label="报送内容摘要">
-          <div class="desc-text">{{ reportForm.summary }}</div>
-        </el-form-item>
-        <el-form-item label="附件上传">
-          <el-upload
-            ref="uploadRef"
-            :action="uploadUrl"
-            :headers="uploadHeaders"
-            :limit="5"
-            :on-success="handleUploadSuccess"
-            :on-error="handleUploadError"
-            :on-exceed="handleUploadExceed"
-            :file-list="reportForm.fileList"
-            :before-upload="handleBeforeUpload"
-          >
-            <el-button type="primary">选择文件</el-button>
-            <template #tip>
-              <div class="el-upload__tip">单个文件不超过 50MB,最多上传 5 个文件</div>
-            </template>
-          </el-upload>
-        </el-form-item>
-      </el-form>
+      <div @paste="handleDialogPaste">
+        <el-form :model="reportForm" :rules="reportRules" ref="reportFormRef" label-width="120px">
+          <el-form-item label="报送层级" prop="reportTargets">
+            <el-checkbox-group v-model="reportForm.reportTargets">
+              <el-checkbox v-for="d in dis_report_target" :key="d.value" :label="d.label" />
+            </el-checkbox-group>
+          </el-form-item>
+          <el-form-item label="报送内容摘要">
+            <div class="desc-text" style="width:100%;min-height:90px">
+              <div>事件编号:{{ reportForm.eventNo || '-' }}</div>
+              <div>发生时间:{{ reportForm.happenTime || '-' }}</div>
+              <div>发生地点:{{ reportForm.location || '-' }}</div>
+              <div style="margin-top:4px">事件描述:{{ reportForm.summary || '-' }}</div>
+            </div>
+          </el-form-item>
+          <el-form-item label="附件上传" prop="fileList">
+            <el-upload
+              ref="uploadRef"
+              v-model:file-list="reportForm.fileList"
+              drag
+              multiple
+              :action="uploadUrl"
+              :headers="uploadHeaders"
+              :limit="5"
+              :on-success="handleUploadSuccess"
+              :on-error="handleUploadError"
+              :on-exceed="handleUploadExceed"
+              :before-upload="handleBeforeUpload"
+              style="width:100%"
+            >
+              <el-icon class="el-icon--upload"><UploadFilled /></el-icon>
+              <div class="el-upload__text">将文件拖到此处,或<em>点击选择</em>,也可直接 Ctrl+V 粘贴</div>
+              <template #tip>
+                <div class="el-upload__tip">支持 PDF / 图片 / Excel / Word 格式,单个文件不超过 50MB,最多上传 5 个文件</div>
+              </template>
+            </el-upload>
+          </el-form-item>
+        </el-form>
+      </div>
       <template #footer>
         <el-button @click="reportVisible = false">取消</el-button>
         <el-button type="primary" @click="confirmReport" :loading="submitting">确认报送</el-button>
@@ -134,38 +155,45 @@
       <div v-if="currentRow" class="detail-content">
         <el-form label-width="100px" size="default">
           <el-row :gutter="20">
-            <el-col :span="6">
+            <el-col :span="12">
               <el-form-item label="事件编号">{{ currentRow.eventNo }}</el-form-item>
             </el-col>
-            <el-col :span="6">
-              <el-form-item label="事件类别">{{ currentRow.category }}</el-form-item>
+            <el-col :span="12">
+              <el-form-item label="事件来源">{{ dictLabel(dis_event_source, currentRow.eventSource) }}</el-form-item>
             </el-col>
-            <el-col :span="6">
-              <el-form-item label="事件等级">{{ currentRow.level }}</el-form-item>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="管网类型">{{ dictLabel(dis_event_type, currentRow.eventType) }}</el-form-item>
             </el-col>
-            <el-col :span="6">
-              <el-form-item label="事件来源">{{ currentRow.source || '-' }}</el-form-item>
+            <el-col :span="12">
+              <el-form-item label="事件类别">{{ dictLabel(dis_event_category, currentRow.eventCategory) }}</el-form-item>
             </el-col>
           </el-row>
           <el-row :gutter="20">
-            <el-col :span="8">
-              <el-form-item label="事件等级">{{ currentRow.level }}</el-form-item>
+            <el-col :span="12">
+              <el-form-item label="事件等级">{{ dictLabel(dis_event_level, currentRow.eventLevel) }}</el-form-item>
             </el-col>
-            <el-col :span="8">
-              <el-form-item label="转接部门">{{ currentRow.transferDept }}</el-form-item>
+            <el-col :span="12">
+              <el-form-item label="责任单位">{{ currentRow.department || '-' }}</el-form-item>
             </el-col>
-            <el-col :span="8">
-              <el-form-item label="当前状态">
-                <el-tag :type="statusTagType(currentRow.status)" size="small">{{ currentRow.status }}</el-tag>
-              </el-form-item>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="转接部门">{{ currentRow.lastTransferTargets || '-' }}</el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="报送层级">{{ currentRow.lastReportTargets || '-' }}</el-form-item>
             </el-col>
           </el-row>
           <el-row :gutter="20">
             <el-col :span="12">
-              <el-form-item label="转接时间">{{ currentRow.transferTime || '-' }}</el-form-item>
+              <el-form-item label="转接时间">{{ currentRow.lastTransferTime || '-' }}</el-form-item>
             </el-col>
             <el-col :span="12">
-              <el-form-item label="报送层级">{{ currentRow.reportTargets || '-' }}</el-form-item>
+              <el-form-item label="当前状态">
+                <el-tag :type="statusTagType(currentRow.status)" size="small">{{ currentRow.statusName }}</el-tag>
+              </el-form-item>
             </el-col>
           </el-row>
           <el-form-item label="事件描述">
@@ -180,7 +208,16 @@
             :type="item.type"
             :timestamp="item.actionTime"
           >
-            {{ item.content }}
+            <div>{{ item.content }}</div>
+            <div v-if="item.attachUrls && item.attachUrls.length" style="margin-top:4px">
+              <el-link
+                v-for="(name, i) in item.attachNames"
+                :key="i"
+                type="primary"
+                style="margin-right:16px;font-size:13px"
+                @click="downloadAttach(item.attachUrls[i])"
+              >附件:{{ name }}</el-link>
+            </div>
           </el-timeline-item>
         </el-timeline>
       </div>
@@ -192,34 +229,70 @@
 </template>
 
 <script setup name="Zjbs">
-import { ref, onMounted } from 'vue'
+import { ref, nextTick, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
+import { UploadFilled } from '@element-plus/icons-vue'
 import { getToken } from '@/utils/auth'
-import { getEventPage, deleteEventInfo, transferEvent, reportEvent } from '@/api/dispatch'
+import { useDict } from '@/utils/dict'
+import { getEventPage, getEventDetail, getEventStatusOptions, deleteEventInfo, transferEvent, reportEvent } from '@/api/dispatch'
+
+// ==================== 字典 ====================
+const { dis_event_source, dis_event_type, dis_event_category, dis_event_level, dis_transfer_dept, dis_report_target } = useDict(
+  'dis_event_source', 'dis_event_type', 'dis_event_category', 'dis_event_level', 'dis_transfer_dept', 'dis_report_target'
+)
+
+function dictLabel(list, value) {
+  if (value === null || value === undefined || value === '') return '-'
+  const hit = (list || []).find(item => String(item.value) === String(value) || item.label === value)
+  return hit ? hit.label : value
+}
 
-// ==================== 推荐部门映射 ====================
-const recommendMap = {
-  '管道泄漏': '市政部门',
-  '塌陷': '管网维护部',
-  '积水': '排水管理部',
-  '设备故障': '设备运维部'
+// 设备类型 -> 推荐转接部门(字典 dis_transfer_dept 的编码):供人工参考,页面上不再展示
+
+// 本页只处理「已核实 / 处置中」两个状态
+const transferStatusOptions = ref([])
+
+async function loadStatusOptions() {
+  try {
+    const res = await getEventStatusOptions()
+    transferStatusOptions.value = (res.data || []).filter(item => [1, 2].includes(item.value))
+  } catch {
+    transferStatusOptions.value = []
+  }
+}
+
+// 状态码 -> 标签样式(0 待核实 1 已核实 2 处置中 3 已解除 4 不实事件)
+const STATUS_TAG = { 0: 'warning', 1: 'success', 2: 'primary', 3: 'warning', 4: 'info' }
+function statusTagType(status) {
+  return STATUS_TAG[status] || 'info'
+}
+
+// 等级字典值 -> 标签样式(1 Ⅰ级紧急 2 Ⅱ级较大 3 Ⅲ级一般)
+const LEVEL_TAG = { 1: 'danger', 2: 'warning', 3: 'info' }
+function levelTagType(level) {
+  return LEVEL_TAG[level] || 'info'
 }
 
 // ==================== 数据状态 ====================
 const tableData = ref([])
 const total = ref(0)
 const loading = ref(false)
-const searchForm = ref({ eventNo: '', transferStatus: '', receiveDept: '' })
+const searchForm = ref({ eventNo: '', status: '', department: '' })
 const pagination = ref({ page: 1, pageSize: 10 })
 
+// 下拉清空后可能是 '' / null / undefined,统一判定,避免拼出空查询条件
+const hasValue = (val) => val !== '' && val !== null && val !== undefined
+
 // ==================== 加载数据 ====================
 async function loadData() {
   loading.value = true
   try {
-    const params = { statusList: ['已核实', '处置中'] }
+    const params = { statusList: [1, 2] }
     if (searchForm.value.eventNo) params.eventNo = searchForm.value.eventNo
-    if (searchForm.value.transferStatus) params.statusList = [searchForm.value.transferStatus]
-    if (searchForm.value.receiveDept) params.department = searchForm.value.receiveDept
+    if (hasValue(searchForm.value.status)) {
+      params.statusList = [searchForm.value.status]
+    }
+    if (searchForm.value.department) params.department = searchForm.value.department
     const res = await getEventPage(pagination.value.page, pagination.value.pageSize, params)
     tableData.value = res.data?.rows || res.data?.records || res.data || []
     total.value = res.data?.total || 0
@@ -231,6 +304,7 @@ async function loadData() {
 }
 
 onMounted(() => {
+  loadStatusOptions()
   loadData()
 })
 
@@ -241,7 +315,7 @@ function handleSearch() {
 }
 
 function handleReset() {
-  searchForm.value = { eventNo: '', transferStatus: '', receiveDept: '' }
+  searchForm.value = { eventNo: '', status: '', department: '' }
   pagination.value.page = 1
   loadData()
 }
@@ -258,26 +332,16 @@ function handleCurrentChange(val) {
   loadData()
 }
 
-// ==================== 标签样式 ====================
-function statusTagType(status) {
-  if (status === '已核实') return 'primary'
-  if (status === '处置中') return 'warning'
-  if (status === '已报送') return 'success'
-  return 'info'
-}
-
-function levelTagType(level) {
-  if (level === '特别重大') return 'danger'
-  if (level === '重大') return 'danger'
-  if (level === '较大') return 'warning'
-  return 'info'
+// 已核实或处置中、且已完成过一次转接后可报送
+function canReport(row) {
+  return row.lastTransferTargets && (row.status === 1 || row.status === 2)
 }
 
 // ==================== 转接 ====================
 const transferVisible = ref(false)
 const transferFormRef = ref(null)
 const currentTransferId = ref(null)
-const transferForm = ref({ eventNo: '', summary: '', recommendDept: '', depts: [], remark: '' })
+const transferForm = ref({ eventNo: '', summary: '', happenTime: '', location: '', depts: [], remark: '' })
 const transferRules = {
   depts: [{ required: true, message: '请选择转接部门', trigger: 'change', type: 'array' }]
 }
@@ -286,12 +350,14 @@ function handleTransfer(row) {
   currentTransferId.value = row.id
   transferForm.value = {
     eventNo: row.eventNo,
-    summary: row.summary || row.description || '',
-    recommendDept: recommendMap[row.category] || '相关部门',
+    summary: row.description || '',
+    happenTime: row.happenTime || '',
+    location: row.location || '',
     depts: [],
     remark: ''
   }
   transferVisible.value = true
+  nextTick(() => transferFormRef.value?.clearValidate())
 }
 
 async function confirmTransfer() {
@@ -302,9 +368,9 @@ async function confirmTransfer() {
   }
   try {
     await transferEvent({
-      relEventId: currentTransferId.value,
-      transferDept: transferForm.value.depts.join('、'),
-      transferRemark: transferForm.value.remark
+      eventId: currentTransferId.value,
+      depts: transferForm.value.depts,
+      operDesc: transferForm.value.remark
     })
     ElMessage.success('转接成功')
     transferVisible.value = false
@@ -314,15 +380,38 @@ async function confirmTransfer() {
   }
 }
 
+// ==================== 附件 ====================
+// 报送附件:pdf / 图片 / Excel / Word,单个 ≤50MB,最多 5 个;多个附件 URL 逗号拼接存日志 attach_url
+const ALLOWED_UPLOAD_EXT = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'xls', 'xlsx', 'doc', 'docx']
+
+function attachUrls(attachUrl) {
+  return String(attachUrl || '').split(',').map(s => s.trim()).filter(Boolean)
+}
+
+// 从 URL 末段取文件名展示;RuoYi 存储名带「uuid_」前缀,截掉更可读
+function attachNames(attachUrl) {
+  return attachUrls(attachUrl).map(url => {
+    let name = url.substring(url.lastIndexOf('/') + 1)
+    try { name = decodeURIComponent(name) } catch { /* 保留原值 */ }
+    const underscore = name.indexOf('_')
+    return underscore > 0 ? name.substring(underscore + 1) : name
+  })
+}
+
+function downloadAttach(url) {
+  if (url) window.open(url, '_blank')
+}
+
 // ==================== 报送 ====================
 const reportVisible = ref(false)
 const reportFormRef = ref(null)
 const uploadRef = ref(null)
 const currentReportId = ref(null)
 const submitting = ref(false)
-const uploadUrl = import.meta.env.VITE_APP_BASE_API + '/common/upload'
+// 附件统一存 MinIO(响应结构与 /common/upload 兼容:code/url/originalFilename)
+const uploadUrl = import.meta.env.VITE_APP_BASE_API + '/minio/upload?dir=dispatch_report'
 const uploadHeaders = { Authorization: 'Bearer ' + getToken() }
-const reportForm = ref({ eventNo: '', summary: '', reportTargets: [], fileList: [], attachments: [] })
+const reportForm = ref({ eventNo: '', summary: '', happenTime: '', location: '', reportTargets: [], fileList: [], attachments: [] })
 const reportRules = {
   reportTargets: [{ required: true, message: '请选择报送层级', trigger: 'change', type: 'array' }]
 }
@@ -331,17 +420,24 @@ function handleReport(row) {
   currentReportId.value = row.id
   reportForm.value = {
     eventNo: row.eventNo,
-    summary: row.summary || row.description || '',
+    summary: row.description || '',
+    happenTime: row.happenTime || '',
+    location: row.location || '',
     reportTargets: [],
     fileList: [],
     attachments: []
   }
   reportVisible.value = true
+  nextTick(() => reportFormRef.value?.clearValidate())
 }
 
 function handleBeforeUpload(file) {
-  const isLt50M = file.size / 1024 / 1024 < 50
-  if (!isLt50M) {
+  const ext = String(file.name).split('.').pop().toLowerCase()
+  if (!ALLOWED_UPLOAD_EXT.includes(ext)) {
+    ElMessage.error('仅支持 PDF、图片、Excel、Word 格式文件')
+    return false
+  }
+  if (file.size / 1024 / 1024 >= 50) {
     ElMessage.error('文件大小不能超过 50MB')
     return false
   }
@@ -349,7 +445,7 @@ function handleBeforeUpload(file) {
 }
 
 function handleUploadSuccess(res) {
-  if (res.code === 200) {
+  if (res.code === 200 && res.url) {
     reportForm.value.attachments.push({
       fileName: res.originalFilename,
       url: res.url
@@ -367,6 +463,22 @@ function handleUploadExceed() {
   ElMessage.warning('附件数量不能超过 5 个')
 }
 
+// 弹窗内 Ctrl+V 粘贴文件(截图 / 复制的文件):塞给 el-upload 走同一套校验与上传流程
+function handleDialogPaste(event) {
+  const files = Array.from(event.clipboardData?.files || [])
+  if (!files.length || !uploadRef.value) return
+  const remain = 5 - (reportForm.value.fileList?.length || 0)
+  if (remain <= 0) {
+    ElMessage.warning('附件数量不能超过 5 个')
+    return
+  }
+  if (files.length > remain) {
+    ElMessage.warning('附件数量不能超过 5 个,本次只取前 ' + remain + ' 个')
+  }
+  files.slice(0, remain).forEach(file => uploadRef.value.handleStart(file))
+  uploadRef.value.submit()
+}
+
 async function confirmReport() {
   try {
     await reportFormRef.value.validate()
@@ -376,12 +488,14 @@ async function confirmReport() {
   submitting.value = true
   try {
     await reportEvent({
-      id: currentReportId.value,
-      reportTargets: reportForm.value.reportTargets,
-      attachPaths: reportForm.value.attachments.map(a => a.url).join(',')
+      eventId: currentReportId.value,
+      depts: reportForm.value.reportTargets,
+      attachUrl: reportForm.value.attachments.map(a => a.url).join(',')
     })
     ElMessage.success('报送成功')
     reportVisible.value = false
+    // 清掉 el-upload 的残留文件列表,避免下次打开还显示上一次的文件
+    uploadRef.value?.clearFiles()
     loadData()
   } catch (error) {
     if (error !== 'cancel' && error !== 'close') {
@@ -395,7 +509,7 @@ async function confirmReport() {
 // ==================== 删除 ====================
 async function handleDelete(row) {
   try {
-    await ElMessageBox.confirm('确定要删除该转接记录吗?', '删除确认', { type: 'warning' })
+    await ElMessageBox.confirm('确定要删除该事件吗?', '删除确认', { type: 'warning' })
     await deleteEventInfo(row.id)
     ElMessage.success('删除成功')
     loadData()
@@ -410,9 +524,30 @@ async function handleDelete(row) {
 const detailVisible = ref(false)
 const currentRow = ref(null)
 
-function handleDetail(row) {
-  currentRow.value = row
+const LOG_TYPE = { 3: 'primary', 4: 'success', 5: 'warning', 6: '', 7: 'danger', 8: 'warning' }
+
+async function handleDetail(row) {
+  currentRow.value = { ...row, history: [] }
   detailVisible.value = true
+  try {
+    const res = await getEventDetail(row.id)
+    const detail = res.data || {}
+    currentRow.value = {
+      ...detail,
+      history: (detail.logs || []).map(log => {
+        const urls = attachUrls(log.attachUrl)
+        return {
+          type: LOG_TYPE[log.operType] ?? '',
+          actionTime: log.operTime,
+          content: (log.operTypeName || '操作') + ':' + (log.operDesc || log.targets || '-'),
+          attachUrls: urls,
+          attachNames: urls.length ? attachNames(log.attachUrl) : []
+        }
+      })
+    }
+  } catch {
+    // 加载失败时保留列表行数据
+  }
 }
 </script>
 

+ 76 - 39
src/views/subSystem/dispatch/disPlanManage/DisEventGrade.vue

@@ -4,18 +4,18 @@
     <!-- 搜索区 -->
     <el-form :model="queryParams" :inline="true" class="search-form">
       <el-form-item label="事件类别">
-        <el-select v-model="queryParams.category" placeholder="请选择" clearable style="width: 160px">
-          <el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
+        <el-select v-model="queryParams.eventCategory" placeholder="请选择" clearable style="width: 160px">
+          <el-option v-for="d in dis_event_category" :key="d.value" :label="d.label" :value="d.value" />
         </el-select>
       </el-form-item>
       <el-form-item label="事件等级">
-        <el-select v-model="queryParams.level" placeholder="请选择" clearable style="width: 140px">
-          <el-option v-for="item in levelOptions" :key="item" :label="item" :value="item" />
+        <el-select v-model="queryParams.eventLevel" placeholder="请选择" clearable style="width: 140px">
+          <el-option v-for="d in dis_event_level" :key="d.value" :label="d.label" :value="d.value" />
         </el-select>
       </el-form-item>
       <el-form-item label="事件状态">
         <el-select v-model="queryParams.status" placeholder="请选择" clearable style="width: 120px">
-          <el-option v-for="item in statusOptions" :key="item" :label="item" :value="item" />
+          <el-option v-for="d in gradeStatusOptions" :key="d.value" :label="d.label" :value="d.value" />
         </el-select>
       </el-form-item>
       <el-form-item>
@@ -33,17 +33,21 @@
     <el-table :data="pageList" border stripe @selection-change="handleSelectionChange" style="width: 100%">
       <el-table-column type="selection" width="55" align="center" />
       <el-table-column label="序号" type="index" width="60" align="center" />
-      <el-table-column prop="category" label="事件类别" min-width="120" />
-      <el-table-column prop="level" label="事件等级" width="100" align="center">
+      <el-table-column prop="gradeNo" label="标准编号" width="140" />
+      <el-table-column prop="eventCategory" label="事件类别" min-width="120">
+        <template #default="{ row }">{{ dictLabel(dis_event_category, row.eventCategory) }}</template>
+      </el-table-column>
+      <el-table-column prop="eventLevel" label="事件等级" width="100" align="center">
         <template #default="{ row }">
-          <el-tag :type="levelTagType(row.level)" size="small">{{ row.level }}</el-tag>
+          <el-tag :type="levelTagType(row.eventLevel)" size="small">{{ dictLabel(dis_event_level, row.eventLevel) }}</el-tag>
         </template>
       </el-table-column>
       <el-table-column prop="scenario" label="适用场景" min-width="140" />
       <el-table-column prop="criteria" label="判断标准" min-width="200" show-overflow-tooltip />
+      <el-table-column prop="response" label="响应要求" min-width="160" show-overflow-tooltip />
       <el-table-column prop="status" label="状态" width="90" align="center">
         <template #default="{ row }">
-          <el-tag :type="row.status === '启用' ? 'success' : 'info'">{{ row.status }}</el-tag>
+          <el-tag :type="row.status === 0 ? 'success' : 'info'">{{ row.statusName }}</el-tag>
         </template>
       </el-table-column>
       <el-table-column label="操作" width="140" align="center">
@@ -69,14 +73,14 @@
     <!-- 新增/修改弹窗 -->
     <el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" destroy-on-close>
       <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px">
-        <el-form-item label="事件类别" prop="category">
-          <el-select v-model="form.category" placeholder="请选择事件类别" style="width: 100%">
-            <el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
+        <el-form-item label="事件类别" prop="eventCategory">
+          <el-select v-model="form.eventCategory" placeholder="请选择事件类别" style="width: 100%">
+            <el-option v-for="d in dis_event_category" :key="d.value" :label="d.label" :value="d.value" />
           </el-select>
         </el-form-item>
-        <el-form-item label="事件等级" prop="level">
-          <el-select v-model="form.level" placeholder="请选择" style="width: 100%">
-            <el-option v-for="item in levelOptions" :key="item" :label="item" :value="item" />
+        <el-form-item label="事件等级" prop="eventLevel">
+          <el-select v-model="form.eventLevel" placeholder="请选择" style="width: 100%">
+            <el-option v-for="d in dis_event_level" :key="d.value" :label="d.label" :value="d.value" />
           </el-select>
         </el-form-item>
         <el-form-item label="类别细分" prop="subCategory">
@@ -93,10 +97,12 @@
             placeholder="填写具体判断规则、适用条件、划分依据"
           />
         </el-form-item>
+        <el-form-item label="响应要求" prop="response">
+          <el-input v-model="form.response" placeholder="如:30 分钟内启动预案" />
+        </el-form-item>
         <el-form-item label="标准生效状态" prop="status">
           <el-radio-group v-model="form.status">
-            <el-radio label="启用">启用</el-radio>
-            <el-radio label="停用">停用</el-radio>
+            <el-radio v-for="d in gradeStatusOptions" :key="d.value" :label="d.value">{{ d.label }}</el-radio>
           </el-radio-group>
         </el-form-item>
         <el-form-item label="备注说明">
@@ -114,29 +120,57 @@
 <script setup>
 import { ref, computed, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { getEventGradePage, addEventGrade, updateEventGrade, deleteEventGrade } from '@/api/dispatch'
+import { useDict } from '@/utils/dict'
+import { getEventGradePage, addEventGrade, updateEventGrade, deleteEventGrade, getEventGradeStatusOptions } from '@/api/dispatch'
+
+// ---------- 字典 ----------
+const { dis_event_category, dis_event_level } = useDict('dis_event_category', 'dis_event_level')
+
+function dictLabel(list, value) {
+  if (value === null || value === undefined || value === '') return '-'
+  const hit = (list || []).find(item => String(item.value) === String(value))
+  return hit ? hit.label : value
+}
 
 // ---------- 下拉选项 ----------
-const categoryOptions = ['管网泄漏', '路面积水', '设施损坏', '管道堵塞']
-const levelOptions = ['一级', '二级', '三级', '四级']
-const statusOptions = ['全部', '启用', '停用']
+// 标准生效状态:GradeStatusEnum 0启用 1停用
+const gradeStatusOptions = ref([])
+
+async function loadStatusOptions() {
+  try {
+    const res = await getEventGradeStatusOptions()
+    gradeStatusOptions.value = res.data || []
+  } catch {
+    gradeStatusOptions.value = []
+  }
+}
+
+// 等级字典值 -> 标签样式(1 Ⅰ级紧急 2 Ⅱ级较大 3 Ⅲ级一般)
+const LEVEL_TAG = { 1: 'danger', 2: 'warning', 3: 'info' }
+function levelTagType(level) {
+  return LEVEL_TAG[level] || 'info'
+}
 
 // ---------- 查询参数 ----------
-const queryParams = ref({ category: '', level: '', status: '' })
+const queryParams = ref({ eventCategory: '', eventLevel: '', status: '' })
 
 // ---------- 数据 ----------
 const tableData = ref([])
 
 async function loadData() {
   try {
-    const res = await getEventGradePage(1, 999, queryParams.value)
+    const params = {}
+    if (queryParams.value.eventCategory) params.eventCategory = queryParams.value.eventCategory
+    if (queryParams.value.eventLevel) params.eventLevel = queryParams.value.eventLevel
+    if (queryParams.value.status !== '' && queryParams.value.status !== null) params.status = queryParams.value.status
+    const res = await getEventGradePage(1, 999, params)
     tableData.value = res?.data?.records ?? res?.data ?? res ?? []
   } catch {
     ElMessage.error('加载数据失败')
   }
 }
 
-onMounted(() => { loadData() })
+onMounted(() => { loadStatusOptions(); loadData() })
 
 // ---------- 分页 ----------
 const pagination = ref({ page: 1, size: 10 })
@@ -152,7 +186,7 @@ const onPageChange = () => {}
 // ---------- 搜索 / 重置 ----------
 const handleSearch = () => { pagination.value.page = 1; loadData() }
 const handleReset = () => {
-  queryParams.value = { category: '', level: '', status: '' }
+  queryParams.value = { eventCategory: '', eventLevel: '', status: '' }
   pagination.value.page = 1
   loadData()
 }
@@ -166,13 +200,13 @@ const dialogVisible = ref(false)
 const dialogTitle = ref('新增')
 const formRef = ref(null)
 const defaultForm = () => ({
-  id: null, category: '', level: '', subCategory: '', scenario: '', criteria: '', status: '启用', remark: ''
+  id: null, eventCategory: '', eventLevel: '', subCategory: '', scenario: '', criteria: '', response: '', status: 0, remark: ''
 })
 const form = ref(defaultForm())
 
 const formRules = {
-  category: [{ required: true, message: '请输入事件类别', trigger: 'blur' }],
-  level: [{ required: true, message: '请选择事件等级', trigger: 'change' }],
+  eventCategory: [{ required: true, message: '请选择事件类别', trigger: 'change' }],
+  eventLevel: [{ required: true, message: '请选择事件等级', trigger: 'change' }],
   scenario: [{ required: true, message: '请输入适用场景', trigger: 'blur' }],
   criteria: [{ required: true, message: '请填写判断标准', trigger: 'blur' }],
   status: [{ required: true, message: '请选择状态', trigger: 'change' }],
@@ -185,7 +219,7 @@ const handleAdd = () => {
 }
 
 const handleEdit = (row) => {
-  form.value = { ...row }
+  form.value = { ...defaultForm(), ...row }
   dialogTitle.value = '修改'
   dialogVisible.value = true
 }
@@ -234,21 +268,24 @@ const handleBatchDelete = () => {
   }).catch(() => {})
 }
 
-function levelTagType(level) {
-  if (level === '一级') return 'danger'
-  if (level === '二级') return 'warning'
-  if (level === '三级') return 'primary'
-  return 'info'
-}
-
 // ---------- 导出 ----------
 const handleExport = () => {
   if (!tableData.value.length) return ElMessage.warning('暂无数据可导出')
-  const headers = ['事件类别', '事件等级', '类别细分', '适用场景', '判断标准', '状态', '备注']
-  const keys = ['category', 'level', 'subCategory', 'scenario', 'criteria', 'status', 'remark']
+  const headers = ['标准编号', '事件类别', '事件等级', '类别细分', '适用场景', '判断标准', '响应要求', '状态', '备注']
+  const rows = tableData.value.map(row => [
+    row.gradeNo,
+    dictLabel(dis_event_category.value, row.eventCategory),
+    dictLabel(dis_event_level.value, row.eventLevel),
+    row.subCategory,
+    row.scenario,
+    row.criteria,
+    row.response,
+    row.statusName,
+    row.remark
+  ])
   const csvContent = [
     '' + headers.join(','),
-    ...tableData.value.map(row => keys.map(k => '"' + (row[k] || '') + '"').join(','))
+    ...rows.map(cells => cells.map(c => '"' + (c || '') + '"').join(','))
   ].join('\n')
   const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
   const link = document.createElement('a')

+ 185 - 180
src/views/subSystem/dispatch/disPlanManage/DisPlanApproval.vue

@@ -6,6 +6,11 @@
       <el-form-item label="预案名称">
         <el-input v-model="queryParams.planName" placeholder="请输入预案名称" clearable style="width: 220px" />
       </el-form-item>
+      <el-form-item label="状态">
+        <el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 140px">
+          <el-option v-for="d in approveStatusOptions" :key="d.value" :label="d.label" :value="d.value" />
+        </el-select>
+      </el-form-item>
       <el-form-item>
         <el-button type="primary" @click="handleSearch">搜索</el-button>
         <el-button @click="handleReset">重置</el-button>
@@ -15,20 +20,29 @@
     <!-- 数据表格 -->
     <el-table :data="pageList" border stripe style="width: 100%">
       <el-table-column label="序号" type="index" width="60" align="center" />
+      <el-table-column prop="planNo" label="预案编号" width="140" />
       <el-table-column prop="planName" label="预案名称" min-width="180" show-overflow-tooltip />
-      <el-table-column prop="planType" label="预案类型" width="130" />
-      <el-table-column prop="planLevel" label="预案级别" width="100" align="center" />
-      <el-table-column prop="reviewers" label="审批人员" min-width="180" show-overflow-tooltip />
-      <el-table-column prop="auditRecord" label="审批记录/意见" min-width="240" show-overflow-tooltip />
+      <el-table-column prop="planType" label="预案类型" width="130">
+        <template #default="{ row }">{{ dictLabel(dis_plan_type, row.planType) }}</template>
+      </el-table-column>
+      <el-table-column prop="planLevel" label="预案级别" width="100" align="center">
+        <template #default="{ row }">
+          <el-tag v-if="row.planLevel" :type="levelTagType(row.planLevel)" size="small">{{ dictLabel(dis_plan_level, row.planLevel) }}</el-tag>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="auditRecord" label="审批记录/意见" min-width="240" show-overflow-tooltip>
+        <template #default="{ row }">{{ auditText(row) }}</template>
+      </el-table-column>
       <el-table-column prop="status" label="状态" width="100" align="center">
         <template #default="{ row }">
-          <el-tag :type="statusTagType(row.status)">{{ row.status }}</el-tag>
+          <el-tag :type="statusTagType(row.status)">{{ row.statusName }}</el-tag>
         </template>
       </el-table-column>
       <el-table-column label="操作" width="130" align="center">
         <template #default="{ row }">
           <el-button link type="primary" @click="handleView(row)">查看</el-button>
-          <el-button link type="primary" @click="handleApprove(row)">审批</el-button>
+          <el-button v-if="row.status === 1" link type="primary" @click="handleApprove(row)">审批</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -50,45 +64,48 @@
       <div class="detail-section">
         <div class="section-title">预案基本信息</div>
         <el-descriptions :column="2" border>
+          <el-descriptions-item label="预案编号">{{ currentRow.planNo || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="预案版本">{{ currentRow.version || '—' }}</el-descriptions-item>
           <el-descriptions-item label="预案名称">{{ currentRow.planName }}</el-descriptions-item>
-          <el-descriptions-item label="预案类型">{{ currentRow.planType }}</el-descriptions-item>
-          <el-descriptions-item label="预案等级">{{ currentRow.planLevel }}</el-descriptions-item>
-          <el-descriptions-item label="审批人员">{{ currentRow.reviewers }}</el-descriptions-item>
+          <el-descriptions-item label="预案类型">{{ dictLabel(dis_plan_type, currentRow.planType) }}</el-descriptions-item>
+          <el-descriptions-item label="预案等级">
+            <el-tag v-if="currentRow.planLevel" :type="levelTagType(currentRow.planLevel)" size="small">{{ dictLabel(dis_plan_level, currentRow.planLevel) }}</el-tag>
+            <span v-else>-</span>
+          </el-descriptions-item>
+          <el-descriptions-item label="编制单位">{{ currentRow.compileUnit || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="当前状态">
+            <el-tag :type="statusTagType(currentRow.status)" size="small">{{ currentRow.statusName }}</el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="附件">
+            <el-link v-if="currentRow.attachPath" type="primary" :underline="false" @click="downloadAttach(currentRow)">{{ currentRow.attachName || '查看附件' }}</el-link>
+            <span v-else>—</span>
+          </el-descriptions-item>
         </el-descriptions>
       </div>
 
-      <!-- 审批流程 2x2 卡片 -->
+      <!-- 审批流程时间线 -->
       <div class="detail-section">
         <div class="section-title">审批流程</div>
-        <div class="flow-grid">
-          <div class="flow-card blue">
-            <div class="flow-step">环节一:预案起草人提交</div>
-            <div class="flow-info">审批人:安全总监 · 权限:初审/退回</div>
-          </div>
-          <div class="flow-card green">
-            <div class="flow-step">环节二:专家技术评审</div>
-            <div class="flow-info">审批人:专家组(3人) · 权限:修正建议/通过</div>
-          </div>
-          <div class="flow-card blue">
-            <div class="flow-step">环节三:领导层终审</div>
-            <div class="flow-info">审批人:应急总指挥 · 权限:批准/驳回</div>
-          </div>
-          <div class="flow-card yellow">
-            <div class="flow-step">环节四:签发与归档</div>
-            <div class="flow-info">状态:完成</div>
-          </div>
-        </div>
+        <el-timeline v-if="flowSteps.length">
+          <el-timeline-item
+            v-for="(step, idx) in flowSteps"
+            :key="idx"
+            :type="step.tlType"
+            :timestamp="step.time"
+          >
+            <div style="font-weight:600">{{ step.title }}</div>
+            <div style="color:#606266;font-size:13px">操作人:{{ step.operator }}</div>
+          </el-timeline-item>
+        </el-timeline>
+        <div v-else style="color:#c0c4cc;font-size:13px;padding:12px 0">暂无审批记录</div>
       </div>
 
       <!-- 预案调整 -->
       <div class="detail-section">
         <div class="section-title">预案调整</div>
         <div class="adjust-box">
-          <div class="adjust-title">{{ currentRow.planName }} V{{ currentRow.version || '2.1' }}</div>
-          <div class="adjust-content">
-            根据刘宏专家评审意见,优化预警阈值及撤离路线;增加物资调度流程。
-            评审结果:"科学性提升,建议补充应急通讯保障" — 已修正。
-          </div>
+          <div class="adjust-title">{{ currentRow.planName }} {{ currentRow.version || '' }}</div>
+          <div class="adjust-content">{{ currentRow.changeNoteText || '暂无变更说明' }}</div>
         </div>
       </div>
 
@@ -96,46 +113,53 @@
       <div class="detail-section">
         <div class="section-title">预案发布</div>
         <div class="publish-box">
-          <template v-if="currentRow.status === '已发布'">
-            <el-tag type="success" size="large">已发布</el-tag>
+          <template v-if="currentRow.status === 2">
+            <el-tag type="success" size="large">已发布(应急处置可用)</el-tag>
           </template>
           <template v-else>
             <div class="unpublished-tip">未发布预案(应急处置不可用)</div>
-            <el-button type="success" @click="handlePublish">发布</el-button>
+            <el-button v-if="currentRow.status === 1" type="success" @click="handleApprove(currentRow)">去审批</el-button>
           </template>
         </div>
       </div>
     </el-drawer>
 
     <!-- 审批弹窗 -->
-    <el-dialog v-model="approveVisible" title="审批流程" width="600px" destroy-on-close>
-      <!-- 案件基本信息 -->
+    <el-dialog v-model="approveVisible" title="预案审批" width="620px" destroy-on-close>
+      <!-- 预案信息 -->
       <div class="approve-header">
-        <el-descriptions :column="1" border>
-          <el-descriptions-item label="案件名称">{{ currentRow.planName }}</el-descriptions-item>
-          <el-descriptions-item label="案件类型">{{ currentRow.planType }}</el-descriptions-item>
-          <el-descriptions-item label="案件等级">{{ currentRow.planLevel }}</el-descriptions-item>
+        <el-descriptions :column="2" border>
+          <el-descriptions-item label="预案名称">{{ currentRow.planName }}</el-descriptions-item>
+          <el-descriptions-item label="预案类型">{{ dictLabel(dis_plan_type, currentRow.planType) }}</el-descriptions-item>
+          <el-descriptions-item label="预案等级">{{ dictLabel(dis_plan_level, currentRow.planLevel) }}</el-descriptions-item>
+          <el-descriptions-item label="编制单位">{{ currentRow.compileUnit || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="附件">
+            <el-link v-if="currentRow.attachPath" type="primary" :underline="false" @click="downloadAttach(currentRow)">{{ currentRow.attachName || '查看附件' }}</el-link>
+            <span v-else>—</span>
+          </el-descriptions-item>
+          <el-descriptions-item label="修订说明">{{ currentRow.changeNote || '—' }}</el-descriptions-item>
         </el-descriptions>
       </div>
 
-      <!-- 审批环节列表 -->
-      <div class="approve-steps">
-        <div v-for="(step, idx) in approveSteps" :key="idx" :class="['approve-step-card', step.colorClass]">
-          <div class="step-header">
-            <span class="step-dot"></span>
-            <span class="step-title">{{ step.title }}</span>
-          </div>
-          <div class="step-info">审批人:{{ step.approver }} · 权限:{{ step.permission }}</div>
-          <div class="step-opinion">
-            <span class="opinion-label">审批意见:</span>
-            <el-input v-model="step.opinion" type="textarea" :rows="2" placeholder="请输入审批意见" />
-          </div>
+      <!-- 审批意见 -->
+      <div class="approve-card">
+        <div class="approve-card-title">
+          <span class="step-dot"></span>审批意见
+          <span class="approve-meta">审批人:当前用户 · 权限:通过 / 不通过</span>
         </div>
+        <el-input
+          v-model="approveOpinion"
+          type="textarea"
+          :rows="3"
+          maxlength="200"
+          show-word-limit
+          placeholder="通过可不填;驳回时必须填写驳回原因"
+        />
       </div>
 
       <template #footer>
-        <el-button type="danger" @click="handleReject">驳回</el-button>
-        <el-button type="primary" @click="handleComplete">完成</el-button>
+        <el-button type="danger" plain @click="handleReject">驳 回</el-button>
+        <el-button type="primary" :loading="approving" @click="handleComplete">审批通过</el-button>
       </template>
     </el-dialog>
   </div>
@@ -144,24 +168,69 @@
 <script setup>
 import { ref, computed, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { getPlanApprovalPage, getPlanApprovalById, approvePlan, rejectPlan, publishPlan, getApprovalSteps } from '@/api/dispatch'
+import { useDict } from '@/utils/dict'
+import { getEventPlanPage, approvePlan, rejectPlan, getPlanLogs, getPlanStatusOptions } from '@/api/dispatch'
+
+// ---------- 字典 ----------
+const { dis_plan_type, dis_plan_level } = useDict('dis_plan_type', 'dis_plan_level')
+
+function dictLabel(list, value) {
+  if (value === null || value === undefined || value === '') return '-'
+  const hit = (list || []).find(item => String(item.value) === String(value))
+  return hit ? hit.label : value
+}
+
+// 预案等级字典值 -> 标签样式(1 Ⅰ级 2 Ⅱ级 3 Ⅲ级)
+const LEVEL_TAG = { 1: 'danger', 2: 'warning', 3: 'info' }
+const levelTagType = (level) => LEVEL_TAG[level] || 'info'
+
+// 预案状态 -> 标签样式(0草稿 1待审核 2已通过 3已驳回)
+const STATUS_TAG = { 0: 'info', 1: 'warning', 2: 'success', 3: 'danger' }
+const statusTagType = (status) => STATUS_TAG[status] || 'info'
+
+// 审批结果文本:结果(时间):意见
+function auditText(row) {
+  if (!row.approveResult) return '尚未审批'
+  let text = row.approveResult
+  if (row.approveTime) text += '(' + row.approveTime + ')'
+  if (row.approveNote) text += ':' + row.approveNote
+  return text
+}
+
+// 本页只处理「待审核 / 已通过 / 已驳回」,草稿需先在编辑页提交审核
+const approveStatusOptions = ref([])
+
+async function loadStatusOptions() {
+  try {
+    const res = await getPlanStatusOptions()
+    approveStatusOptions.value = (res.data || []).filter(item => [1, 2, 3].includes(item.value))
+  } catch {
+    approveStatusOptions.value = []
+  }
+}
+
+// 操作日志 -> 审批流程时间线(operType 见后端 PlanLogOperTypeEnum)
+const LOG_TL_TYPE = { 0: 'primary', 1: 'primary', 2: 'success', 3: 'warning', 4: 'primary' }
 
 // ---------- 查询 ----------
-const queryParams = ref({ planName: '' })
+const queryParams = ref({ planName: '', status: 1 })
 
 // ---------- 数据 ----------
 const tableData = ref([])
 
 async function loadData() {
   try {
-    const res = await getPlanApprovalPage(1, 999, queryParams.value)
+    const params = {}
+    if (queryParams.value.planName) params.planName = queryParams.value.planName
+    if (queryParams.value.status !== '' && queryParams.value.status !== null) params.status = queryParams.value.status
+    const res = await getEventPlanPage(1, 999, params)
     tableData.value = res?.data?.records ?? res?.data ?? res ?? []
   } catch {
     ElMessage.error('加载数据失败')
   }
 }
 
-onMounted(() => { loadData() })
+onMounted(() => { loadStatusOptions(); loadData() })
 
 // ---------- 分页 ----------
 const pagination = ref({ page: 1, size: 10 })
@@ -175,80 +244,64 @@ const onPageChange = () => {}
 // ---------- 搜索 / 重置 ----------
 const handleSearch = () => { pagination.value.page = 1; loadData() }
 const handleReset = () => {
-  queryParams.value.planName = ''
+  queryParams.value = { planName: '', status: 1 }
   pagination.value.page = 1
   loadData()
 }
 
-// ---------- 状态 Tag 类型 ----------
-const statusTagType = (status) => {
-  if (status === '终审中') return 'warning'
-  if (status === '待修正') return 'danger'
-  if (status === '已发布') return 'success'
-  return 'info'
-}
-
 // ---------- 当前操作行 ----------
 const currentRow = ref({})
+const flowSteps = ref([])
 
 // ---------- 查看详情弹窗 ----------
 const detailVisible = ref(false)
 const handleView = async (row) => {
-  currentRow.value = { ...row }
-  try {
-    const res = await getApprovalSteps(row.id)
-    currentRow.value.approvalSteps = res?.data?.records ?? res?.data ?? res ?? []
-  } catch { /* ignore */ }
+  currentRow.value = { ...row, changeNoteText: '' }
+  flowSteps.value = []
   detailVisible.value = true
+  try {
+    const res = await getPlanLogs(row.id)
+    const logs = res?.data ?? []
+    flowSteps.value = logs.map(log => ({
+      title: (log.operTypeName || '操作') + (log.operDesc ? ':' + log.operDesc : ''),
+      operator: log.operUserName || '—',
+      time: log.operTime || '—',
+      tlType: LOG_TL_TYPE[log.operType] || 'primary'
+    }))
+    const last = logs.filter(log => log.operDesc).pop()
+    currentRow.value = {
+      ...currentRow.value,
+      changeNoteText: last ? (last.operTypeName + ':' + last.operDesc + '(' + (last.operTime || '—') + ')') : ''
+    }
+  } catch {
+    // 日志加载失败时保留行数据
+  }
 }
 
-// ---------- 发布 ----------
-const handlePublish = () => {
-  ElMessageBox.confirm('确认发布该预案?发布后将在应急处置中启用。', '提示', { type: 'warning' }).then(async () => {
-    try {
-      await publishPlan(currentRow.value.id)
-      currentRow.value.status = '已发布'
-      ElMessage.success('发布成功')
-      loadData()
-    } catch {
-      ElMessage.error('发布失败')
-    }
-  }).catch(() => {})
+// 附件下载(MinIO 直链)
+const downloadAttach = (row) => {
+  if (row?.attachPath) window.open(row.attachPath, '_blank')
 }
 
 // ---------- 审批弹窗 ----------
 const approveVisible = ref(false)
-const approveSteps = ref([])
+const approveOpinion = ref('')
+const approving = ref(false)
 
-const handleApprove = async (row) => {
+const handleApprove = (row) => {
   currentRow.value = { ...row }
-  try {
-    const res = await getApprovalSteps(row.id)
-    const steps = res?.data?.records ?? res?.data ?? res ?? []
-    if (steps.length > 0) {
-      approveSteps.value = steps
-    } else {
-      approveSteps.value = [
-        { title: '环节一:预案起草人提交', approver: '安全总监', permission: '初审/退回', colorClass: 'blue', opinion: '' },
-        { title: '环节二:专家技术评审', approver: '专家组(3人)', permission: '修正建议/通过', colorClass: 'green', opinion: '' },
-        { title: '环节三:领导层终审', approver: '应急总指挥', permission: '批准/驳回', colorClass: 'blue', opinion: '' },
-      ]
-    }
-  } catch {
-    approveSteps.value = [
-      { title: '环节一:预案起草人提交', approver: '安全总监', permission: '初审/退回', colorClass: 'blue', opinion: '' },
-      { title: '环节二:专家技术评审', approver: '专家组(3人)', permission: '修正建议/通过', colorClass: 'green', opinion: '' },
-      { title: '环节三:领导层终审', approver: '应急总指挥', permission: '批准/驳回', colorClass: 'blue', opinion: '' },
-    ]
-  }
+  approveOpinion.value = ''
+  detailVisible.value = false
   approveVisible.value = true
 }
 
 const handleReject = async () => {
+  const note = (approveOpinion.value || '').trim()
+  if (!note) return ElMessage.warning('驳回时必须填写审批意见(驳回原因)')
   try {
-    await rejectPlan(currentRow.value.id)
+    await rejectPlan(currentRow.value.id, { approveNote: note })
     approveVisible.value = false
-    ElMessage.success('已驳回,状态变更为待修正')
+    ElMessage.success('已驳回')
     loadData()
   } catch {
     ElMessage.error('驳回失败')
@@ -256,13 +309,17 @@ const handleReject = async () => {
 }
 
 const handleComplete = async () => {
+  const note = (approveOpinion.value || '').trim()
+  approving.value = true
   try {
-    await approvePlan(currentRow.value.id)
+    await approvePlan(currentRow.value.id, { approveNote: note })
     approveVisible.value = false
-    ElMessage.success('审批完成')
+    ElMessage.success('审批通过,预案已发布')
     loadData()
   } catch {
     ElMessage.error('审批失败')
+  } finally {
+    approving.value = false
   }
 }
 </script>
@@ -288,33 +345,6 @@ const handleComplete = async () => {
   }
 }
 
-.flow-grid {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 12px;
-}
-
-.flow-card {
-  border-radius: 6px;
-  padding: 14px 16px;
-  color: #fff;
-
-  .flow-step {
-    font-weight: bold;
-    font-size: 14px;
-    margin-bottom: 6px;
-  }
-
-  .flow-info {
-    font-size: 13px;
-    opacity: 0.9;
-  }
-
-  &.blue { background: #409eff; }
-  &.green { background: #67c23a; }
-  &.yellow { background: #e6a23c; }
-}
-
 .adjust-box {
   background: #fafafa;
   border: 1px solid #ebeef5;
@@ -354,61 +384,36 @@ const handleComplete = async () => {
   margin-bottom: 16px;
 }
 
-.approve-steps {
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-}
-
-.approve-step-card {
+.approve-card {
+  border: 1px solid #d9ecff;
+  border-left: 4px solid #409eff;
   border-radius: 6px;
   padding: 14px 16px;
+  background: #f5faff;
 
-  .step-header {
+  .approve-card-title {
     display: flex;
     align-items: center;
-    margin-bottom: 6px;
+    font-weight: 600;
+    font-size: 14px;
+    color: #303133;
+    margin-bottom: 10px;
 
     .step-dot {
       display: inline-block;
-      width: 10px;
-      height: 10px;
+      width: 8px;
+      height: 8px;
       border-radius: 50%;
       margin-right: 8px;
-      background: #fff;
-      border: 2px solid #fff;
-    }
-
-    .step-title {
-      font-weight: bold;
-      font-size: 14px;
-      color: #fff;
+      background: #409eff;
     }
-  }
 
-  .step-info {
-    font-size: 13px;
-    color: rgba(255, 255, 255, 0.9);
-    margin-bottom: 8px;
-  }
-
-  .step-opinion {
-    .opinion-label {
-      font-size: 13px;
-      color: #fff;
-      display: block;
-      margin-bottom: 4px;
+    .approve-meta {
+      margin-left: auto;
+      font-size: 12px;
+      font-weight: 400;
+      color: #909399;
     }
   }
-
-  &.blue {
-    background: #409eff;
-    .step-dot { border-color: #fff; }
-  }
-
-  &.green {
-    background: #67c23a;
-    .step-dot { border-color: #fff; }
-  }
 }
 </style>

+ 361 - 106
src/views/subSystem/dispatch/disPlanManage/DisPlanEditing.vue

@@ -39,9 +39,18 @@
         <el-table :data="pageList" border stripe @selection-change="handleSelectionChange" style="width: 100%">
           <el-table-column type="selection" width="55" align="center" />
           <el-table-column label="序号" type="index" width="60" align="center" />
+          <el-table-column prop="planNo" label="预案编号" width="140" />
           <el-table-column prop="planName" label="预案名称" min-width="180" show-overflow-tooltip />
-          <el-table-column prop="planType" label="预案类型" width="130" />
-          <el-table-column prop="planLevel" label="预案级别" width="100" align="center" />
+          <el-table-column prop="planType" label="预案类型" width="130">
+            <template #default="{ row }">{{ dictLabel(dis_plan_type, row.planType) }}</template>
+          </el-table-column>
+          <el-table-column prop="planLevel" label="预案级别" width="100" align="center">
+            <template #default="{ row }">
+              <el-tag v-if="row.planLevel" :type="levelTagType(row.planLevel)" size="small">{{ dictLabel(dis_plan_level, row.planLevel) }}</el-tag>
+              <span v-else>-</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="version" label="版本" width="90" />
           <el-table-column prop="attachName" label="附件名称" min-width="200" show-overflow-tooltip />
           <el-table-column label="附件下载" width="90" align="center">
             <template #default="{ row }">
@@ -52,11 +61,17 @@
           </el-table-column>
           <el-table-column prop="publishDate" label="发布时间" width="120" align="center" />
           <el-table-column prop="compileUnit" label="编制单位" width="100" />
+          <el-table-column prop="statusName" label="状态" width="100" align="center">
+            <template #default="{ row }">
+              <el-tag :type="statusTagType(row.status)">{{ row.statusName }}</el-tag>
+            </template>
+          </el-table-column>
           <el-table-column label="操作" width="200" align="center">
             <template #default="{ row }">
-              <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
+              <el-button link type="primary" @click="handleEdit(row)">{{ isReviseRow(row) ? '修订' : '编辑' }}</el-button>
               <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
-              <el-button v-if="row.status !== '已发布'" link type="success" @click="handleSubmitApproval(row)">提交审批</el-button>
+              <el-button v-if="row.submittable" link type="success" @click="handleSubmitApproval(row)">提交审核</el-button>
+              <el-button link type="primary" @click="handleVersions(row)">版本</el-button>
             </template>
           </el-table-column>
         </el-table>
@@ -75,60 +90,141 @@
       </div>
     </div>
 
+    <!-- 新建预案:空白 or 选择模板 -->
+    <el-dialog v-model="createPickVisible" title="新建预案" width="560px">
+      <div
+        class="pick-card"
+        style="border:1px dashed #409eff;background:#f5faff;padding:14px 16px;border-radius:6px;cursor:pointer;margin-bottom:12px"
+        @click="pickCreate(null)"
+      >
+        <div style="font-weight:600;color:#409eff">+ 直接新建(空白预案)</div>
+        <div style="font-size:12px;color:#909399;margin-top:4px">不套用模板,从空白正文开始编写</div>
+      </div>
+      <template v-if="libraryList.length">
+        <div style="margin:4px 0 8px;font-size:13px;color:#606266">或选择模板:</div>
+        <div
+          v-for="tpl in libraryList"
+          :key="tpl.id"
+          class="pick-card"
+          style="border:1px solid #ebeef5;padding:12px 16px;border-radius:6px;cursor:pointer;margin-bottom:8px"
+          @click="pickCreate(tpl)"
+        >
+          <div style="font-weight:600;font-size:14px">{{ tpl.planName }}</div>
+          <div style="font-size:12px;color:#909399;margin-top:4px">{{ tpl.planSummary || '暂无概要' }}</div>
+        </div>
+      </template>
+      <template #footer>
+        <el-button @click="createPickVisible = false">取消</el-button>
+      </template>
+    </el-dialog>
+
     <!-- 新增/修改预案弹窗 -->
     <el-dialog v-model="dialogVisible" :title="dialogTitle" width="580px" destroy-on-close>
-      <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
-        <el-form-item label="预案分类" prop="categoryId">
-          <el-tree-select
-            v-model="form.categoryId"
-            :data="treeData"
-            :props="treeProps"
-            node-key="id"
-            placeholder="请选择预案分类"
-            check-strictly
-            :render-after-expand="false"
-            style="width: 100%"
-          />
-        </el-form-item>
-        <el-form-item label="预案名称" prop="planName">
-          <el-input v-model="form.planName" placeholder="请输入预案名称" />
-        </el-form-item>
-        <el-form-item label="预案级别" prop="planLevel">
-          <el-select v-model="form.planLevel" placeholder="请选择" style="width: 100%">
-            <el-option v-for="item in levelOptions" :key="item" :label="item" :value="item" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="预案类型" prop="planType">
-          <el-input v-model="form.planType" placeholder="请输入预案类型" />
-        </el-form-item>
-        <el-form-item label="预案版本" prop="version">
-          <el-input v-model="form.version" placeholder="请输入版本号" />
-        </el-form-item>
-        <el-form-item label="编制单位" prop="compileUnit">
-          <el-input v-model="form.compileUnit" placeholder="请输入编制单位" />
-        </el-form-item>
-        <el-form-item label="附件上传">
-          <el-upload
-            ref="uploadRef"
-            :action="uploadUrl"
-            :headers="uploadHeaders"
-            :limit="1"
-            :on-success="handleUploadSuccess"
-            :on-error="handleUploadError"
-            :on-exceed="() => ElMessage.warning('只能上传一个文件')"
-            :file-list="form.fileList"
-            :before-upload="handleBeforeUpload"
-          >
-            <el-button type="primary">添加文件</el-button>
-          </el-upload>
-          <div class="upload-tip">支持上传任意格式文件,单个文件不超过 50MB</div>
-        </el-form-item>
-      </el-form>
+      <div @paste="handleDialogPaste">
+        <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
+          <el-form-item label="预案名称" prop="planName">
+            <el-input v-model="form.planName" placeholder="请输入预案名称" />
+          </el-form-item>
+          <el-form-item label="预案级别" prop="planLevel">
+            <el-select v-model="form.planLevel" placeholder="请选择" style="width: 100%">
+              <el-option v-for="d in dis_plan_level" :key="d.value" :label="d.label" :value="d.value" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="预案类型" prop="planType">
+            <el-select v-model="form.planType" placeholder="请选择预案类型" style="width: 100%">
+              <el-option v-for="d in dis_plan_type" :key="d.value" :label="d.label" :value="d.value" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="预案版本">
+            <el-input v-model="form.version" disabled placeholder="由系统在审批通过后生成" />
+          </el-form-item>
+          <el-form-item label="编制单位" prop="compileUnit">
+            <el-input v-model="form.compileUnit" placeholder="请输入编制单位" />
+          </el-form-item>
+          <el-form-item v-if="isRevising" label="修订说明" prop="changeNote">
+            <el-input v-model="form.changeNote" type="textarea" :rows="3" placeholder="请填写本次修订说明(修订已通过预案必填)" />
+          </el-form-item>
+          <el-form-item label="预案正文">
+            <el-input
+              v-model="form.content"
+              type="textarea"
+              :rows="5"
+              maxlength="2000"
+              show-word-limit
+              placeholder="请输入预案正文要点(将随版本快照保存,可在版本管理中查看历史正文)"
+            />
+          </el-form-item>
+          <el-form-item label="附件上传">
+            <el-upload
+              ref="uploadRef"
+              v-model:file-list="form.fileList"
+              :action="uploadUrl"
+              :headers="uploadHeaders"
+              :limit="1"
+              accept=".pdf,.doc,.docx"
+              :on-success="handleUploadSuccess"
+              :on-error="handleUploadError"
+              :on-remove="handleUploadRemove"
+              :on-exceed="() => ElMessage.warning('只能上传一个文件')"
+              :before-upload="handleBeforeUpload"
+              style="width:100%"
+            >
+              <el-button type="primary">添加文件</el-button>
+              <template #tip>
+                <div class="upload-tip">仅支持 PDF / Word 格式文件,单个文件不超过 50MB,支持 Ctrl+V 粘贴</div>
+              </template>
+            </el-upload>
+          </el-form-item>
+        </el-form>
+      </div>
       <template #footer>
         <el-button @click="dialogVisible = false">取消</el-button>
         <el-button type="primary" @click="handleSubmit">保存提交</el-button>
       </template>
     </el-dialog>
+
+    <!-- 版本管理弹窗 -->
+    <el-dialog v-model="versionVisible" title="版本管理" width="760px" destroy-on-close>
+      <div style="margin-bottom:12px;color:#606266;font-size:13px">
+        当前预案:{{ currentVersionRow.planName }} · 生效版本 {{ currentVersionRow.version || '—' }} · {{ currentVersionRow.statusName || '—' }}
+      </div>
+      <el-table :data="versionList" border stripe style="width: 100%">
+        <el-table-column prop="version" label="版本号" width="90" />
+        <el-table-column prop="attachName" label="附件" min-width="160" show-overflow-tooltip>
+          <template #default="{ row }">
+            <el-link v-if="row.attachPath" type="primary" :underline="false" @click="downloadVersionAttach(row)">{{ row.attachName || '下载附件' }}</el-link>
+            <span v-else>—</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="editor" label="修订人" width="110" />
+        <el-table-column prop="editTime" label="修订时间" width="160" />
+        <el-table-column prop="changeNote" label="变更说明" min-width="160" show-overflow-tooltip />
+        <el-table-column prop="verStatusName" label="状态" width="100" align="center">
+          <template #default="{ row }">
+            <el-tag :type="verTagType(row.verStatus)">{{ row.verStatusName }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="100" align="center">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="handlePreviewVersion(row)">查看正文</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <div style="margin-top:12px;color:#909399;font-size:12px;line-height:1.6">
+        版本在「审核通过」后落地:修订已通过预案并保存会生成一条审核中的候选版本(旧版本仍为当前版本);审核通过后候选版本转为当前版本、旧版本降为历史;审核不通过则候选版本标记为已驳回,旧版本保持生效。
+      </div>
+      <template #footer>
+        <el-button @click="versionVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 版本正文快照弹窗 -->
+    <el-dialog v-model="contentVisible" :title="'正文快照 · ' + (currentVersion.version || '')" width="720px" destroy-on-close>
+      <div style="line-height:1.7;font-size:13px;color:#303133" v-html="versionContentHtml"></div>
+      <template #footer>
+        <el-button @click="contentVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -136,25 +232,39 @@
 import { ref, computed, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Download } from '@element-plus/icons-vue'
+import * as XLSX from 'xlsx'
 import { getToken } from '@/utils/auth'
-import { getEventPlanPage, getEventPlanById, addEventPlan, updateEventPlan, deleteEventPlan, getPlanCategoryTree, createPlanApproval } from '@/api/dispatch'
+import { useDict } from '@/utils/dict'
+import {
+  getEventPlanPage, getEventPlanById, saveEventPlan, deleteEventPlan,
+  submitEventPlan, getPlanVersions, getPlanVersion, getPlanLibraryList
+} from '@/api/dispatch'
+
+// ---------- 字典 ----------
+const { dis_plan_type, dis_plan_level } = useDict('dis_plan_type', 'dis_plan_level')
+
+function dictLabel(list, value) {
+  if (value === null || value === undefined || value === '') return '-'
+  const hit = (list || []).find(item => String(item.value) === String(value))
+  return hit ? hit.label : value
+}
+
+// 预案状态 -> 标签样式(0草稿 1待审核 2已通过 3已驳回)
+const STATUS_TAG = { 0: 'info', 1: 'warning', 2: 'success', 3: 'danger' }
+const statusTagType = (status) => STATUS_TAG[status] || 'info'
+
+// 预案等级字典值 -> 标签样式(1 Ⅰ级 2 Ⅱ级 3 Ⅲ级)
+const LEVEL_TAG = { 1: 'danger', 2: 'warning', 3: 'info' }
+const levelTagType = (level) => LEVEL_TAG[level] || 'info'
 
-// ---------- 常量 ----------
-const levelOptions = ['一级', '二级', '三级', '四级']
+// 版本状态 -> 标签样式(0历史 1当前版本 2审核中 3已驳回)
+const VER_TAG = { 0: 'info', 1: 'success', 2: 'warning', 3: 'danger' }
+const verTagType = (verStatus) => VER_TAG[verStatus] || 'info'
 
-// ---------- 分类树 ----------
+// ---------- 分类树(复用预案类型字典,后端不再单独维护分类表) ----------
 const treeRef = ref(null)
 const treeProps = { children: 'children', label: 'label' }
-const treeData = ref([])
-
-async function loadCategoryTree() {
-  try {
-    const res = await getPlanCategoryTree()
-    treeData.value = res?.data ?? res ?? []
-  } catch {
-    ElMessage.error('加载分类树失败')
-  }
-}
+const treeData = computed(() => (dis_plan_type.value || []).map(d => ({ id: d.value, label: d.label })))
 
 const selectedTreeNode = ref(null)
 const handleNodeClick = (node) => {
@@ -173,7 +283,7 @@ async function loadData() {
   try {
     const params = { ...queryParams.value }
     if (selectedTreeNode.value) {
-      params.categoryId = selectedTreeNode.value.id
+      params.planType = selectedTreeNode.value.id
     }
     const res = await getEventPlanPage(1, 999, params)
     tableData.value = res?.data?.records ?? res?.data ?? res ?? []
@@ -182,7 +292,7 @@ async function loadData() {
   }
 }
 
-onMounted(() => { loadData(); loadCategoryTree() })
+onMounted(() => { loadData() })
 
 // ---------- 分页 ----------
 const pagination = ref({ page: 1, size: 10 })
@@ -198,6 +308,7 @@ const handleSearch = () => { pagination.value.page = 1; loadData() }
 const handleReset = () => {
   queryParams.value.planName = ''
   selectedTreeNode.value = null
+  treeRef.value?.setCurrentKey(null)
   pagination.value.page = 1
   loadData()
 }
@@ -211,37 +322,81 @@ const dialogVisible = ref(false)
 const dialogTitle = ref('新增预案')
 const formRef = ref(null)
 const defaultForm = () => ({
-  id: null, planName: '', planLevel: '', planType: '', version: '1.0', compileUnit: '', categoryId: selectedTreeNode.value?.id || null, attachName: '', attachPath: '', fileList: []
+  id: null, planName: '', planType: '', planLevel: '', version: '', compileUnit: '',
+  status: null, changeNote: '', content: '', attachName: '', attachPath: '', fileList: []
 })
 const form = ref(defaultForm())
 
-const formRules = {
-  planName: [{ required: true, message: '请输入预案名称', trigger: 'blur' }],
-  planLevel: [{ required: true, message: '请选择预案级别', trigger: 'change' }],
-  planType: [{ required: true, message: '请输入预案类型', trigger: 'blur' }],
-}
-
-const uploadUrl = import.meta.env.VITE_APP_BASE_API + '/common/upload'
-const uploadHeaders = { Authorization: 'Bearer ' + getToken() }
+// ---------- 新建预案:空白 or 选择模板 ----------
+const createPickVisible = ref(false)
+const libraryList = ref([])
 
 const handleAdd = () => {
+  createPickVisible.value = true
+  if (!libraryList.value.length) {
+    getPlanLibraryList()
+      .then(res => { libraryList.value = res?.data ?? [] })
+      .catch(() => { libraryList.value = [] })
+  }
+}
+
+// 空白 → 空表单;选模板 → 预填名称(去掉“模板”后缀)与模板章节正文,类型/级别仍由用户选择
+function pickCreate(tpl) {
+  createPickVisible.value = false
   form.value = defaultForm()
+  if (tpl) {
+    form.value.planName = String(tpl.planName || '').replace(/模板$/, '')
+    form.value.content = tpl.planContent || ''
+  }
   dialogTitle.value = '新增预案'
   dialogVisible.value = true
 }
 
-const handleEdit = (row) => {
+// 已通过的预案走「修订」:保存后生成审核中的候选版本,必须填写修订说明
+const isRevising = computed(() => !!form.value.id && form.value.status === 2)
+
+// 列表按钮文案:已通过且版本不是第一版(v1.0)→「修订」,其余 →「编辑」
+const isReviseRow = (row) =>
+  row.status === 2 && !!row.version && !['v1.0', '1.0'].includes(String(row.version).toLowerCase())
+
+const formRules = {
+  planName: [{ required: true, message: '请输入预案名称', trigger: 'blur' }],
+  planLevel: [{ required: true, message: '请选择预案级别', trigger: 'change' }],
+  planType: [{ required: true, message: '请选择预案类型', trigger: 'change' }],
+  changeNote: [{ required: true, message: '请填写修订说明', trigger: 'blur' }],
+}
+
+// 预案附件统一存 MinIO
+const uploadUrl = import.meta.env.VITE_APP_BASE_API + '/minio/upload?dir=dispatch_plan'
+const uploadHeaders = { Authorization: 'Bearer ' + getToken() }
+
+const handleEdit = async (row) => {
+  let detail = { ...row }
+  try {
+    const res = await getEventPlanById(row.id)
+    detail = res?.data ?? row
+  } catch {
+    // 详情接口失败时退回列表行数据
+  }
   form.value = {
-    ...row,
-    fileList: row.attachName ? [{ name: row.attachName, url: row.attachPath }] : []
+    ...detail,
+    changeNote: '',
+    fileList: detail.attachName ? [{ name: detail.attachName, url: detail.attachPath }] : []
   }
-  dialogTitle.value = '修改预案'
+  dialogTitle.value = detail.status === 2 ? '修订预案' : '修改预案'
   dialogVisible.value = true
 }
 
+// 附件仅支持 PDF / Word(预案正文文档),单个 ≤50MB;弹窗内支持 Ctrl+V 粘贴
+const ALLOWED_PLAN_EXT = ['pdf', 'doc', 'docx']
+
 function handleBeforeUpload(file) {
-  const isLt50M = file.size / 1024 / 1024 < 50
-  if (!isLt50M) {
+  const ext = String(file.name).split('.').pop().toLowerCase()
+  if (!ALLOWED_PLAN_EXT.includes(ext)) {
+    ElMessage.error('仅支持 PDF / Word 格式文件')
+    return false
+  }
+  if (file.size / 1024 / 1024 >= 50) {
     ElMessage.error('文件大小不能超过 50MB')
     return false
   }
@@ -257,21 +412,45 @@ function handleUploadSuccess(res) {
   }
 }
 
+// 移除已上传文件时同步清空附件字段,避免列表残留旧附件
+function handleUploadRemove() {
+  form.value.attachName = ''
+  form.value.attachPath = ''
+}
+
 function handleUploadError() {
   ElMessage.error('文件上传失败')
 }
 
+// 弹窗内 Ctrl+V 粘贴文件:塞给 el-upload 走同一套校验与上传流程(预案只允许 1 个附件)
+function handleDialogPaste(event) {
+  const files = Array.from(event.clipboardData?.files || [])
+  if (!files.length || !uploadRef.value) return
+  if ((form.value.fileList?.length || 0) >= 1) {
+    ElMessage.warning('只能上传一个文件,请先移除已上传的文件')
+    return
+  }
+  files.slice(0, 1).forEach(file => uploadRef.value.handleStart(file))
+  uploadRef.value.submit()
+}
+
 const handleSubmit = () => {
   formRef.value?.validate(async (valid) => {
     if (!valid) return
     try {
-      if (form.value.id) {
-        await updateEventPlan(form.value)
-      } else {
-        await addEventPlan(form.value)
-      }
+      await saveEventPlan({
+        id: form.value.id || null,
+        planName: form.value.planName,
+        planType: form.value.planType,
+        planLevel: form.value.planLevel,
+        compileUnit: form.value.compileUnit,
+        content: form.value.content,
+        attachName: form.value.attachName,
+        attachPath: form.value.attachPath,
+        changeNote: form.value.changeNote
+      })
       dialogVisible.value = false
-      ElMessage.success('保存成功')
+      ElMessage.success(isRevising.value ? '修订已保存,候选版本待审核' : '保存成功')
       loadData()
     } catch {
       ElMessage.error('保存失败')
@@ -311,36 +490,112 @@ const handleDownload = (row) => {
   window.open(row.attachPath, '_blank')
 }
 
+// Excel 公式参数转义(URL/文件名一般不含引号,兜底处理)
+const excelQuote = (value) => String(value ?? '').replace(/"/g, '""')
+
+// 附件列单元格:有附件写 HYPERLINK 公式(Excel/WPS 中点击打开附件文件),无附件留空
+function attachCell(row) {
+  if (!row.attachPath) return ''
+  const name = row.attachName || '附件'
+  return `=HYPERLINK("${excelQuote(row.attachPath)}","${excelQuote(name)}")`
+}
+
+// 下载模板:真实 xlsx 模板(表头 + 示例行),供后续批量导入参照
+const handleDownloadTemplate = () => {
+  const headers = ['预案名称', '预案类型', '预案级别', '编制单位', '附件名称', '预案正文']
+  const sampleType = dis_plan_type.value?.[0]?.label || '综合'
+  const sampleLevel = dis_plan_level.value?.[0]?.label || 'Ⅲ级'
+  const aoa = [
+    headers,
+    ['示例:沅陵县供水管网爆管专项预案', sampleType, sampleLevel, '沅陵县供水公司', '示例_预案文档.docx', '预案正文要点:适用场景、响应分级、处置流程…(导入前请删除本示例行)']
+  ]
+  const ws = XLSX.utils.aoa_to_sheet(aoa)
+  ws['!cols'] = [{ wch: 32 }, { wch: 12 }, { wch: 10 }, { wch: 18 }, { wch: 24 }, { wch: 50 }]
+  const wb = XLSX.utils.book_new()
+  XLSX.utils.book_append_sheet(wb, ws, '预案导入模板')
+  XLSX.writeFile(wb, '应急预案导入模板.xlsx')
+}
+
 const handleExport = () => {
   if (!tableData.value.length) return ElMessage.warning('暂无数据可导出')
-  const headers = ['预案名称', '预案类型', '预案级别', '附件名称', '发布时间', '编制单位']
-  const keys = ['planName', 'planType', 'planLevel', 'attachName', 'publishDate', 'compileUnit']
-  const csvContent = [
-    '' + headers.join(','),
-    ...tableData.value.map(row => keys.map(k => '"' + (row[k] || '') + '"').join(','))
-  ].join('\n')
-  const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
-  const link = document.createElement('a')
-  link.href = URL.createObjectURL(blob)
-  link.download = '应急预案_' + new Date().toISOString().slice(0, 10) + '.csv'
-  link.click()
-  URL.revokeObjectURL(link.href)
-  ElMessage.success('导出成功')
+  const headers = ['预案编号', '预案名称', '预案类型', '预案级别', '版本', '附件名称', '发布时间', '编制单位', '状态']
+  const aoa = [headers]
+  tableData.value.forEach(row => {
+    aoa.push([
+      row.planNo,
+      row.planName,
+      dictLabel(dis_plan_type.value, row.planType),
+      dictLabel(dis_plan_level.value, row.planLevel),
+      row.version,
+      // 附件列:HYPERLINK 公式,Excel/WPS 中点击即打开 MinIO 附件文件
+      attachCell(row),
+      row.publishDate,
+      row.compileUnit,
+      row.statusName
+    ])
+  })
+  const ws = XLSX.utils.aoa_to_sheet(aoa)
+  ws['!cols'] = [{ wch: 18 }, { wch: 24 }, { wch: 12 }, { wch: 10 }, { wch: 8 }, { wch: 28 }, { wch: 14 }, { wch: 16 }, { wch: 10 }]
+  const wb = XLSX.utils.book_new()
+  XLSX.utils.book_append_sheet(wb, ws, '应急预案')
+  XLSX.writeFile(wb, '应急预案_' + new Date().toISOString().slice(0, 10) + '.xlsx')
+  ElMessage.success('导出成功(附件列可点击打开附件文件)')
 }
 
 const handleSubmitApproval = (row) => {
-  ElMessageBox.confirm('确认将该预案提交审批?', '提示', { type: 'warning' }).then(async () => {
+  ElMessageBox.confirm('确认将该预案提交审核?', '提示', { type: 'warning' }).then(async () => {
     try {
-      await createPlanApproval(row.id)
-      ElMessage.success('提交审批成功')
+      await submitEventPlan(row.id)
+      ElMessage.success('提交审核成功')
       loadData()
     } catch {
-      ElMessage.error('提交审批失败')
+      ElMessage.error('提交审核失败')
     }
   }).catch(() => {})
 }
 
-const handleDownloadTemplate = () => { ElMessage.success('模版下载成功(Mock)') }
+// ---------- 版本 ----------
+const versionVisible = ref(false)
+const versionList = ref([])
+const currentVersionRow = ref({})
+
+async function handleVersions(row) {
+  currentVersionRow.value = { ...row }
+  versionList.value = []
+  versionVisible.value = true
+  try {
+    const res = await getPlanVersions(row.id)
+    versionList.value = res.data || []
+  } catch {
+    ElMessage.error('加载版本失败')
+  }
+}
+
+const contentVisible = ref(false)
+const currentVersion = ref({})
+
+// 版本快照附件下载(MinIO 直链)
+const downloadVersionAttach = (row) => {
+  if (row?.attachPath) window.open(row.attachPath, '_blank')
+}
+
+// 正文为空时给出占位提示,避免 v-html 里再嵌引号
+const versionContentHtml = computed(
+  () => currentVersion.value.content || '<p style="color:#909399">暂无正文</p>'
+)
+
+async function handlePreviewVersion(row) {
+  currentVersion.value = { ...row }
+  if (!row.content) {
+    try {
+      const res = await getPlanVersion(row.id)
+      currentVersion.value = res.data || row
+    } catch {
+      // 正文快照加载失败时保留版本行数据
+    }
+  }
+  contentVisible.value = true
+}
 </script>
 
 <style scoped lang="scss">

+ 748 - 7
src/views/subSystem/dispatch/dishome/disHome.vue

@@ -1,14 +1,755 @@
-<!--首页-->
-<script setup>
+<template>
+  <div class="app-container home-page">
+    <!-- 顶部横幅 -->
+    <div class="hero-banner">
+      <div>
+        <div class="hero-title">应急与指挥调度工作台</div>
+        <div class="hero-sub">沅陵县 · 全域事件实时态势总览</div>
+      </div>
+      <div class="hero-right">
+        <div class="hero-date">{{ todayText }}</div>
+        <div class="hero-tip">数据实时更新 · 接报即流转</div>
+      </div>
+      <el-popover placement="bottom-end" width="380" trigger="click" @show="loadNotifies">
+        <template #reference>
+          <div class="hero-bell">
+            <el-badge :value="unreadCount" :hidden="!unreadCount" :max="99">
+              <el-icon :size="22"><Bell /></el-icon>
+            </el-badge>
+          </div>
+        </template>
+        <div class="notify-head">
+          <span style="font-weight:600">催办消息</span>
+          <el-link type="primary" :underline="false" @click="readAllNotifies">全部已读</el-link>
+        </div>
+        <div class="notify-list">
+          <div
+            v-for="item in notifies"
+            :key="item.id"
+            :class="['notify-item', { 'is-unread': !item.readFlag }]"
+            @click="readNotify(item)"
+          >
+            <div class="notify-title">
+              <span class="unread-dot" v-if="!item.readFlag"></span>{{ item.title }}
+            </div>
+            <div class="notify-content">{{ item.content || '-' }}</div>
+            <div class="notify-meta">{{ item.createTime }}<span v-if="item.urgingUser"> · {{ item.urgingUser }}</span></div>
+          </div>
+          <div v-if="!notifies.length" class="empty-tip" style="padding:24px 0">暂无催办消息</div>
+        </div>
+      </el-popover>
+    </div>
 
-</script>
+    <!-- 顶部统计卡 -->
+    <div class="stat-cards">
+      <div class="stat-card stat-blue">
+        <div class="stat-icon icon-blue"><el-icon><Bell /></el-icon></div>
+        <div class="stat-body">
+          <div class="stat-title">今日接报事件</div>
+          <div class="stat-value">{{ board.todayCount ?? 0 }}<span class="stat-unit">起</span></div>
+          <div class="stat-foot">
+            <span :class="['delta', (board.todayDelta || 0) >= 0 ? 'delta-up' : 'delta-down']">
+              {{ (board.todayDelta || 0) >= 0 ? '▲' : '▼' }} 较昨日 {{ (board.todayDelta || 0) >= 0 ? '+' : '' }}{{ board.todayDelta ?? 0 }}
+            </span>
+          </div>
+        </div>
+      </div>
+      <div class="stat-card stat-orange">
+        <div class="stat-icon icon-orange"><el-icon><Odometer /></el-icon></div>
+        <div class="stat-body">
+          <div class="stat-title">处置中</div>
+          <div class="stat-value">{{ board.disposingCount ?? 0 }}<span class="stat-unit">起</span></div>
+          <div class="stat-foot"><span class="foot-note">其中紧急 {{ board.urgentDisposingCount ?? 0 }} 起</span></div>
+        </div>
+      </div>
+      <div class="stat-card stat-green">
+        <div class="stat-icon icon-green"><el-icon><CircleCheck /></el-icon></div>
+        <div class="stat-body">
+          <div class="stat-title">已解除</div>
+          <div class="stat-value">{{ board.releasedCount ?? 0 }}<span class="stat-unit">起</span></div>
+          <div class="stat-foot"><span class="foot-note">处置率 {{ board.handledRate ?? 0 }}%</span></div>
+        </div>
+      </div>
+      <div class="stat-card stat-yellow">
+        <div class="stat-icon icon-yellow"><el-icon><View /></el-icon></div>
+        <div class="stat-body">
+          <div class="stat-title">待核实</div>
+          <div class="stat-value">{{ board.pendingCount ?? 0 }}<span class="stat-unit">起</span></div>
+          <div class="stat-foot"><span class="foot-note">请及时安排初判核实</span></div>
+        </div>
+      </div>
+      <div class="stat-card stat-purple">
+        <div class="stat-icon icon-purple"><el-icon><Files /></el-icon></div>
+        <div class="stat-body">
+          <div class="stat-title">可用预案</div>
+          <div class="stat-value">{{ board.planTotal ?? 0 }}<span class="stat-unit">个</span></div>
+          <div class="stat-foot"><span class="foot-note">已发布 {{ board.planPublished ?? 0 }} 个</span></div>
+        </div>
+      </div>
+    </div>
 
-<template>
-  <div class="app-container">
-    <h1>应急与指挥调度系统</h1>
+    <!-- 中部:GIS 态势图 + 处置中事件 -->
+    <div class="mid-row">
+      <div class="panel gis-panel">
+        <div class="panel-head">
+          <span class="panel-title">GIS 实时态势图</span>
+          <el-button type="primary" size="small" @click="goDispatchPage">进入调度</el-button>
+        </div>
+        <div class="gis-map-wrap">
+          <GisMapCanvas :points="mapPoints" :zoom="12" :enable-clustering="false" @error="gisError = $event" />
+          <div class="gis-legend">
+            <span><i class="legend-dot" style="background:#e74c3c"></i> 紧急事件</span>
+            <span><i class="legend-dot" style="background:#f1c40f"></i> 较大事件</span>
+            <span><i class="legend-dot" style="background:#2ecc71"></i> 一般事件</span>
+          </div>
+        </div>
+        <div v-if="gisError" class="gis-error">{{ gisError }}</div>
+      </div>
+
+      <div class="panel dispose-panel">
+        <div class="panel-head">
+          <span class="panel-title">处置中事件</span>
+        </div>
+        <div class="dispose-list">
+          <div v-for="item in disposingEvents" :key="item.id" class="dispose-item" @click="goDispatchPage">
+            <div class="dispose-line1">
+              <span class="dispose-no">{{ item.eventNo }}</span>
+              <el-tag :type="levelTagType(item.eventLevel)" size="small" effect="dark">{{ levelLabel(item.eventLevel) }}</el-tag>
+            </div>
+            <div class="dispose-line2">
+              <span class="dispose-title">{{ typeLabel(item.eventType) }}-{{ categoryLabel(item.eventCategory) }}</span>
+            </div>
+            <div class="dispose-line3">
+              <span class="dispose-desc">{{ item.description || '暂无描述' }}</span>
+              <span class="dispose-status">{{ item.statusName }}</span>
+            </div>
+          </div>
+          <div v-if="!disposingEvents.length" class="empty-tip">当前暂无处置中的事件</div>
+        </div>
+        <div class="dispose-foot">点击任一事件可进入「转接报送」调度</div>
+      </div>
+    </div>
+
+    <!-- 底部:类型分布 + 等级占比 -->
+    <div class="bottom-row">
+      <div class="panel chart-panel">
+        <div class="panel-head">
+          <span class="panel-title">近 7 日事件类型分布</span>
+        </div>
+        <div ref="typeChartRef" class="chart-box"></div>
+        <div class="chart-note">数据口径:按接报事件类型统计(单位:起),统计范围近 7 日</div>
+      </div>
+      <div class="panel chart-panel">
+        <div class="panel-head">
+          <span class="panel-title">事件等级占比</span>
+        </div>
+        <div ref="levelChartRef" class="chart-box"></div>
+      </div>
+    </div>
   </div>
 </template>
 
+<script setup name="DisHome">
+import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import * as echarts from 'echarts'
+import { ElMessage } from 'element-plus'
+import { Bell, CircleCheck, Files, Odometer, View } from '@element-plus/icons-vue'
+import { useDict } from '@/utils/dict'
+import { gisPointStatusIcons } from '@/utils/gisMapColors'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import { getHomeBoard, getMyNotifies, markNotifyRead, markAllNotifiesRead } from '@/api/dispatch'
+
+const router = useRouter()
+
+// 横幅日期(打开页面时即可,无需秒级刷新)
+const WEEK = ['日', '一', '二', '三', '四', '五', '六']
+const todayText = computed(() => {
+  const d = new Date()
+  const pad = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 星期${WEEK[d.getDay()]}`
+})
+
+// ==================== 字典 ====================
+const { dis_event_type, dis_event_category, dis_event_level } = useDict('dis_event_type', 'dis_event_category', 'dis_event_level')
+
+function dictLabel(list, value) {
+  if (value === null || value === undefined || value === '') return '-'
+  const hit = (list || []).find(item => String(item.value) === String(value))
+  return hit ? hit.label : value
+}
+const typeLabel = (v) => dictLabel(dis_event_type.value, v)
+const categoryLabel = (v) => dictLabel(dis_event_category.value, v)
+// 等级:1 Ⅰ级(紧急) 2 Ⅱ级(较大) 3 Ⅲ级(一般)
+const LEVEL_NAME = { 1: '紧急', 2: '较大', 3: '一般' }
+const LEVEL_TAG = { 1: 'danger', 2: 'warning', 3: 'info' }
+const levelLabel = (v) => LEVEL_NAME[v] || '一般'
+const levelTagType = (v) => LEVEL_TAG[v] || 'info'
+
+// ==================== 数据 ====================
+const board = ref({})
+const gisError = ref('')
+
+const hasCoord = (v) => v !== null && v !== undefined && v !== ''
+
+// GIS 点位:紧急红 / 较大黄 / 一般绿(库内 WGS84,组件内自动转百度坐标)
+const mapPoints = computed(() => {
+  const list = board.value.mapEvents || []
+  return list
+    .filter(e => hasCoord(e.longitude) && hasCoord(e.latitude))
+    .map(e => {
+      const icon = e.eventLevel === '1' ? gisPointStatusIcons.alarm
+        : e.eventLevel === '2' ? gisPointStatusIcons.warning : gisPointStatusIcons.normal
+      return {
+        id: e.id,
+        lng: Number(e.longitude),
+        lat: Number(e.latitude),
+        name: e.location ? String(e.location).slice(0, 8) : e.eventNo,
+        iconUrl: icon
+      }
+    })
+})
+
+const disposingEvents = computed(() => board.value.disposingEvents || [])
+
+// ==================== 催办消息(铃铛) ====================
+const notifies = ref([])
+const unreadCount = ref(0)
+
+async function loadNotifies() {
+  try {
+    const res = await getMyNotifies(20)
+    notifies.value = res.data?.list || []
+    unreadCount.value = res.data?.unreadCount || 0
+  } catch {
+    // 静默失败,不影响看板
+  }
+}
+
+async function readNotify(item) {
+  if (item.readFlag) return
+  try {
+    await markNotifyRead(item.id)
+    item.readFlag = 1
+    unreadCount.value = Math.max(0, unreadCount.value - 1)
+  } catch {
+    // 忽略
+  }
+}
+
+async function readAllNotifies() {
+  try {
+    await markAllNotifiesRead()
+    notifies.value.forEach(n => { n.readFlag = 1 })
+    unreadCount.value = 0
+  } catch {
+    // 忽略
+  }
+}
+
+// SSE 推送到达(utils/dispatchSse.js 广播)时刷新未读数与列表
+function onNotifyArrived() {
+  loadNotifies()
+}
+
+async function loadBoard() {
+  try {
+    const res = await getHomeBoard()
+    board.value = res.data || {}
+    renderCharts()
+  } catch (e) {
+    ElMessage.error('首页看板加载失败:' + (e.message || '未知错误'))
+  }
+}
+
+// ==================== 跳转调度 ====================
+// 动态菜单路由 path 以 sys_menu 配置为准,这里按标识查找,找不到则提示走菜单
+function goDispatchPage() {
+  const hit = router.getRoutes().find(r => /zjbs/i.test(String(r.path || '')) || r.name === 'Zjbs')
+  if (hit) {
+    router.push(hit.path)
+  } else {
+    ElMessage.info('未找到「转接报送」菜单,请从左侧菜单进入')
+  }
+}
+
+// ==================== 图表 ====================
+const typeChartRef = ref(null)
+const levelChartRef = ref(null)
+let typeChart = null
+let levelChart = null
+
+function renderCharts() {
+  nextTick(() => {
+    renderTypeChart()
+    renderLevelChart()
+  })
+}
+
+function renderTypeChart() {
+  if (!typeChartRef.value) return
+  if (!typeChart) typeChart = echarts.init(typeChartRef.value)
+  const stats = board.value.typeStats || []
+  const labels = stats.map(s => typeLabel(s.type))
+  const values = stats.map(s => Number(s.count) || 0)
+  typeChart.setOption({
+    grid: { left: 40, right: 24, top: 30, bottom: 30 },
+    tooltip: { trigger: 'axis', formatter: '{b}:{c} 起' },
+    xAxis: { type: 'category', data: labels, axisTick: { show: false }, axisLine: { lineStyle: { color: '#dcdfe6' } }, axisLabel: { color: '#606266' } },
+    yAxis: { type: 'value', minInterval: 1, axisLabel: { color: '#909399' }, splitLine: { lineStyle: { color: '#f0f2f5' } } },
+    series: [{
+      type: 'bar',
+      barWidth: '45%',
+      data: values,
+      itemStyle: {
+        borderRadius: [6, 6, 0, 0],
+        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+          { offset: 0, color: '#79bbff' },
+          { offset: 1, color: '#409eff' }
+        ])
+      },
+      label: { show: true, position: 'top', color: '#606266' }
+    }]
+  })
+}
+
+function renderLevelChart() {
+  if (!levelChartRef.value) return
+  if (!levelChart) levelChart = echarts.init(levelChartRef.value)
+  const stats = board.value.levelStats || []
+  const NAME = { 1: '紧急', 2: '较大', 3: '一般', released: '已解除' }
+  const COLOR = { 1: '#f56c6c', 2: '#e6a23c', 3: '#e6c34a', released: '#67c23a' }
+  const data = stats
+    .map(s => ({ name: NAME[s.level] || s.level, value: Number(s.count) || 0, itemStyle: { color: COLOR[s.level] || '#909399' } }))
+    .filter(d => d.value > 0)
+  levelChart.setOption({
+    tooltip: { trigger: 'item', formatter: '{b}:{c} 起({d}%)' },
+    legend: { orient: 'vertical', right: 10, top: 'center', itemWidth: 10, itemHeight: 10, textStyle: { color: '#606266' } },
+    series: [{
+      type: 'pie',
+      radius: ['52%', '76%'],
+      center: ['38%', '50%'],
+      avoidLabelOverlap: true,
+      label: { show: false },
+      data
+    }]
+  })
+}
+
+function handleResize() {
+  typeChart?.resize()
+  levelChart?.resize()
+}
+
+onMounted(() => {
+  loadBoard()
+  loadNotifies()
+  window.addEventListener('resize', handleResize)
+  window.addEventListener('dispatch-notify-arrived', onNotifyArrived)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  window.removeEventListener('dispatch-notify-arrived', onNotifyArrived)
+  typeChart?.dispose()
+  levelChart?.dispose()
+  typeChart = null
+  levelChart = null
+})
+</script>
+
 <style scoped lang="scss">
+.home-page {
+  padding: 12px;
+  background: linear-gradient(180deg, #e8eefb 0%, #f2f4f9 260px, #f4f5f9 100%);
+  min-height: calc(100vh - 84px);
+}
+
+// ---------- 顶部横幅 ----------
+.hero-banner {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+  padding: 18px 26px;
+  color: #fff;
+  border-radius: 10px;
+  background:
+    radial-gradient(circle at 88% 20%, rgba(255, 255, 255, 0.14) 0, transparent 42%),
+    linear-gradient(120deg, #1d3f9e 0%, #2f6fe4 55%, #4aa0f5 100%);
+  box-shadow: 0 6px 18px rgba(31, 63, 158, 0.25);
+}
+
+.hero-title {
+  font-size: 20px;
+  font-weight: 700;
+  letter-spacing: 2px;
+}
+
+.hero-sub {
+  margin-top: 6px;
+  font-size: 13px;
+  opacity: 0.85;
+}
+
+.hero-right {
+  text-align: right;
+  margin-left: auto;
+  margin-right: 24px;
+}
+
+.hero-date {
+  font-size: 15px;
+  font-weight: 600;
+}
+
+.hero-tip {
+  margin-top: 6px;
+  font-size: 12px;
+  opacity: 0.8;
+}
+
+// 铃铛入口
+.hero-bell {
+  width: 42px;
+  height: 42px;
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+  cursor: pointer;
+  background: rgba(255, 255, 255, 0.16);
+  transition: background 0.2s;
+
+  &:hover {
+    background: rgba(255, 255, 255, 0.28);
+  }
+}
+
+.notify-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8px;
+}
+
+.notify-list {
+  max-height: 320px;
+  overflow-y: auto;
+}
+
+.notify-item {
+  padding: 8px 4px;
+  border-bottom: 1px solid #f0f2f5;
+  cursor: pointer;
+  border-radius: 4px;
+
+  &:hover {
+    background: #f5f7fa;
+  }
+
+  &.is-unread .notify-title {
+    color: #303133;
+    font-weight: 600;
+  }
+}
+
+.notify-title {
+  font-size: 13px;
+  color: #606266;
+}
+
+.unread-dot {
+  display: inline-block;
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: #f56c6c;
+  margin-right: 6px;
+  vertical-align: middle;
+}
+
+.notify-content {
+  margin-top: 2px;
+  font-size: 12px;
+  color: #909399;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+
+.notify-meta {
+  margin-top: 2px;
+  font-size: 12px;
+  color: #c0c4cc;
+}
+
+// ---------- 统计卡 ----------
+.stat-cards {
+  display: grid;
+  grid-template-columns: repeat(5, 1fr);
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.stat-card {
+  display: flex;
+  gap: 12px;
+  align-items: flex-start;
+  padding: 16px;
+  border: none;
+  border-radius: 10px;
+  transition: transform 0.2s, box-shadow 0.2s;
+
+  &:hover {
+    transform: translateY(-3px);
+    box-shadow: 0 8px 20px rgba(31, 63, 158, 0.12);
+  }
+}
+
+.stat-blue { background: linear-gradient(135deg, #eaf2ff 0%, #ffffff 65%); }
+.stat-orange { background: linear-gradient(135deg, #fff4e8 0%, #ffffff 65%); }
+.stat-green { background: linear-gradient(135deg, #eafaf0 0%, #ffffff 65%); }
+.stat-yellow { background: linear-gradient(135deg, #fdf9e7 0%, #ffffff 65%); }
+.stat-purple { background: linear-gradient(135deg, #f3edff 0%, #ffffff 65%); }
+
+.stat-icon {
+  width: 42px;
+  height: 42px;
+  border-radius: 10px;
+  color: #fff;
+  font-size: 20px;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.icon-blue { background: linear-gradient(135deg, #5a9cff, #2f6fe4); box-shadow: 0 4px 10px rgba(47, 111, 228, 0.35); }
+.icon-orange { background: linear-gradient(135deg, #ffc069, #e6822a); box-shadow: 0 4px 10px rgba(230, 130, 42, 0.35); }
+.icon-green { background: linear-gradient(135deg, #7ed99a, #34a853); box-shadow: 0 4px 10px rgba(52, 168, 83, 0.35); }
+.icon-yellow { background: linear-gradient(135deg, #ffe58f, #d9b514); box-shadow: 0 4px 10px rgba(217, 181, 20, 0.3); }
+.icon-purple { background: linear-gradient(135deg, #b795ff, #7c4de0); box-shadow: 0 4px 10px rgba(124, 77, 224, 0.35); }
+
+.stat-body {
+  min-width: 0;
+}
+
+.stat-title {
+  font-size: 13px;
+  color: #8a94a6;
+}
+
+.stat-value {
+  margin-top: 6px;
+  font-size: 28px;
+  font-weight: 700;
+  color: #1f2d3d;
+  line-height: 1.1;
+  font-family: 'DIN Alternate', 'Segoe UI', sans-serif;
+}
+
+.stat-unit {
+  margin-left: 4px;
+  font-size: 13px;
+  font-weight: 400;
+  color: #909399;
+}
+
+.stat-foot {
+  margin-top: 6px;
+  font-size: 12px;
+}
+
+.delta {
+  font-weight: 600;
+}
+
+.delta-up { color: #f56c6c; }
+.delta-down { color: #67c23a; }
+
+.foot-note { color: #909399; }
+
+// ---------- 中部 / 底部面板 ----------
+.mid-row {
+  display: grid;
+  grid-template-columns: 2fr 1fr;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.bottom-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px;
+}
+
+.panel {
+  background: #fff;
+  border: 1px solid #ebeef5;
+  border-radius: 10px;
+  padding: 14px 16px;
+  box-shadow: 0 2px 8px rgba(31, 63, 158, 0.05);
+  transition: box-shadow 0.2s;
+
+  &:hover {
+    box-shadow: 0 6px 16px rgba(31, 63, 158, 0.1);
+  }
+}
+
+.panel-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+
+.panel-title {
+  position: relative;
+  padding-left: 12px;
+  font-size: 15px;
+  font-weight: 600;
+  color: #303133;
+
+  &::before {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 3px;
+    bottom: 3px;
+    width: 4px;
+    border-radius: 2px;
+    background: linear-gradient(180deg, #4aa0f5, #2f6fe4);
+  }
+}
+
+.gis-map-wrap {
+  position: relative;
+  height: 420px;
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.gis-legend {
+  position: absolute;
+  left: 12px;
+  bottom: 12px;
+  display: flex;
+  gap: 14px;
+  padding: 6px 12px;
+  background: rgba(255, 255, 255, 0.92);
+  border-radius: 4px;
+  font-size: 12px;
+  color: #606266;
+  z-index: 5;
+}
+
+.legend-dot {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  margin-right: 4px;
+}
+
+.gis-error {
+  margin-top: 8px;
+  color: #e6a23c;
+  font-size: 12px;
+}
+
+.dispose-list {
+  height: 420px;
+  overflow-y: auto;
+}
+
+.dispose-item {
+  padding: 10px 8px;
+  border-bottom: 1px solid #f0f2f5;
+  cursor: pointer;
+  border-radius: 6px;
+  transition: background 0.15s;
+
+  &:hover {
+    background: #f0f6ff;
+  }
+}
+
+.dispose-line1 {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.dispose-no {
+  font-weight: 600;
+  color: #303133;
+  font-size: 13px;
+}
+
+.dispose-line2 {
+  margin-top: 4px;
+}
+
+.dispose-title {
+  font-size: 13px;
+  color: #303133;
+  font-weight: 600;
+}
+
+.dispose-line3 {
+  margin-top: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.dispose-desc {
+  flex: 1;
+  font-size: 12px;
+  color: #909399;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.dispose-status {
+  font-size: 12px;
+  color: #2f6fe4;
+  flex-shrink: 0;
+}
+
+.dispose-foot {
+  margin-top: 10px;
+  font-size: 12px;
+  color: #c0c4cc;
+  text-align: center;
+}
+
+.empty-tip {
+  padding: 60px 0;
+  text-align: center;
+  color: #c0c4cc;
+  font-size: 13px;
+}
+
+.chart-box {
+  height: 300px;
+}
+
+.chart-note {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #c0c4cc;
+}
 
-</style>
+@media (max-width: 1200px) {
+  .stat-cards { grid-template-columns: repeat(3, 1fr); }
+  .mid-row { grid-template-columns: 1fr; }
+  .bottom-row { grid-template-columns: 1fr; }
+}
+</style>

+ 117 - 29
src/views/subSystem/manholeCover/device/deviceApicture.vue

@@ -83,6 +83,7 @@
           </div>
           <div class="legend">
             <span><i class="legend-dot online"></i> 在线</span>
+            <span><i class="legend-dot alert"></i> 告警</span>
             <span><i class="legend-dot offline"></i> 离线</span>
             <span><i class="legend-dot selected"></i> 选中设备</span>
           </div>
@@ -153,19 +154,26 @@ async function loadDevices() {
       isQueryManholeData: true
     })
     const rows = res.data?.records || []
-    devices.value = rows.map(d => ({
-      id: d.equipmentId,
-      code: d.equipmentCode,
-      name: d.equipmentName,
-      equipmentTypeId: d.equipmentTypeId,
-      typeName: d.equipmentTypeName,
-      location: d.equipmentLocation,
-      status: Number(d.equipmentStatus?.onlineStatus) === 1 ? 'online' : 'offline',
-      lng: d.longitude != null ? Number(d.longitude) : null,
-      lat: d.latitude != null ? Number(d.latitude) : null,
-      battery: d.manholeData?.batteryLevel ?? null,
-      lastTime: d.manholeData?.uploadTime || d.manholeData?.createTime || '-'
-    }))
+    devices.value = rows.map(d => {
+      const manholeData = d.manholeData || {}
+      const isAlert = Number(d.equipmentStatus?.alarmStatus) === 1 ||
+        Number(manholeData.alarmStatus) === 1 ||
+        Number(manholeData.waterInfiltrationAlarmStatus) === 1 ||
+        Number(manholeData.waterLevelAlarmStatus) === 1
+      return {
+        id: d.equipmentId,
+        code: d.equipmentCode,
+        name: d.equipmentName,
+        equipmentTypeId: d.equipmentTypeId,
+        typeName: d.equipmentTypeName,
+        location: d.equipmentLocation,
+        status: isAlert ? 'alert' : Number(d.equipmentStatus?.onlineStatus) === 1 ? 'online' : 'offline',
+        lng: d.longitude != null ? Number(d.longitude) : null,
+        lat: d.latitude != null ? Number(d.latitude) : null,
+        battery: d.manholeData?.batteryLevel ?? null,
+        lastTime: d.manholeData?.uploadTime || d.manholeData?.createTime || '-'
+      }
+    })
     total.value = res.data?.total || rows.length
     refreshMarkers()
   } catch (e) {
@@ -236,8 +244,9 @@ function initMap() {
 }
 
 function getStatusColor(status) {
+  if (status === 'alert') return '#E64545'
   if (status === 'online') return '#67C23A'
-  if (status === 'offline') return '#909399'
+  if (status === 'offline') return '#E67E22'
   return '#E6A23C'
 }
 
@@ -246,23 +255,33 @@ function addMapMarker(device) {
   if (!mapInstance || device.lng == null || device.lat == null) return
   const color = getStatusColor(device.status)
   const bPoint = new BMapGL.Point(device.lng, device.lat)
+  const isAttention = device.status === 'alert' || device.status === 'offline'
+  const attentionText = device.status === 'alert' ? '告警' : '离线'
 
-  const html = `<div style="text-align:center;cursor:pointer;">
-    <div style="color:#fff;background:${color};border-radius:4px;padding:2px 8px;font-size:11px;white-space:nowrap;display:inline-block;margin-bottom:2px;box-shadow:0 1px 4px rgba(0,0,0,0.3);">
-      ${device.name}
-    </div>
-    <div><img src="${locationIcon}" style="width:28px;height:28px;display:block;margin:0 auto;"/></div>
-  </div>`
+  const html = isAttention
+    ? `<div class="device-map-attention-marker device-map-${device.status}" title="${attentionText}设备:${device.name}">
+        <div class="device-map-attention-badge">${attentionText} - ${device.name}</div>
+        <div class="device-map-attention-pin">
+          <i class="device-map-attention-ring"></i>
+          <img src="${locationIcon}" alt="" />
+        </div>
+      </div>`
+    : `<div style="text-align:center;cursor:pointer;">
+        <div style="color:#fff;background:${color};border-radius:4px;padding:2px 8px;font-size:11px;white-space:nowrap;display:inline-block;margin-bottom:2px;box-shadow:0 1px 4px rgba(0,0,0,0.3);">
+          ${device.name}
+        </div>
+        <div><img src="${locationIcon}" alt="" style="width:28px;height:28px;display:block;margin:0 auto;"/></div>
+      </div>`
 
   const label = new BMapGL.Label(html, {
     position: bPoint,
-    offset: new BMapGL.Size(-30, -50)
+    offset: new BMapGL.Size(isAttention ? -62 : -30, isAttention ? -68 : -50)
   })
   label.setStyle({
     border: 'none',
     background: 'transparent',
     padding: '0',
-    zIndex: '10'
+    zIndex: isAttention ? '20' : '10'
   })
   label.addEventListener('click', function () {
     selectDevice(device)
@@ -285,8 +304,9 @@ function highlightMarker(id) {
     const dom = label.getContentContainer ? label.getContentContainer() : null
     if (dom) {
       const isSelected = key === id
-      dom.style.zIndex = isSelected ? '999' : '10'
-      const title = dom.querySelector('div')
+      const attentionMarker = dom.querySelector('.device-map-attention-marker')
+      dom.style.zIndex = isSelected ? '999' : attentionMarker ? '20' : '10'
+      const title = attentionMarker || dom.querySelector('div')
       if (title) {
         title.style.transform = isSelected ? 'scale(1.15)' : 'scale(1)'
         title.style.transition = 'transform 0.2s'
@@ -300,8 +320,9 @@ function clearMarkerHighlight() {
   markerMap.forEach((label, key) => {
     const dom = label.getContentContainer ? label.getContentContainer() : null
     if (dom) {
-      dom.style.zIndex = '10'
-      const title = dom.querySelector('div')
+      const attentionMarker = dom.querySelector('.device-map-attention-marker')
+      dom.style.zIndex = attentionMarker ? '20' : '10'
+      const title = attentionMarker || dom.querySelector('div')
       if (title) {
         title.style.transform = 'scale(1)'
         title.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'
@@ -317,11 +338,11 @@ const handleResize = () => {
 
 // ==================== 状态文案 ====================
 function getStatusText(status) {
-  const map = { online: '在线', offline: '离线' }
+  const map = { online: '在线', alert: '告警', offline: '离线' }
   return map[status] || status
 }
 function getStatusTag(status) {
-  const map = { online: 'success', offline: 'info' }
+  const map = { online: 'success', alert: 'danger', offline: 'warning' }
   return map[status] || ''
 }
 
@@ -494,7 +515,8 @@ onBeforeUnmount(() => {
   margin-right: 4px;
 }
 .legend-dot.online { background-color: #67C23A; }
-.legend-dot.offline { background-color: #909399; }
+.legend-dot.alert { background-color: #E64545; }
+.legend-dot.offline { background-color: #E67E22; }
 .legend-dot.selected { background-color: #409eff; box-shadow: 0 0 0 2px rgba(64,158,255,0.4); }
 .map-area {
   flex: 1;
@@ -548,3 +570,69 @@ onBeforeUnmount(() => {
   justify-content: center;
 }
 </style>
+
+<style>
+/* 百度 Label 注入的 DOM 不带 scoped 属性,因此动画样式需要保持全局 */
+.device-map-attention-marker {
+  position: relative;
+  width: 124px;
+  height: 70px;
+  cursor: pointer;
+  text-align: center;
+}
+.device-map-attention-badge {
+  position: relative;
+  z-index: 2;
+  display: inline-block;
+  max-width: 118px;
+  overflow: hidden;
+  padding: 2px 7px;
+  border: 1px solid rgba(255, 255, 255, 0.72);
+  border-radius: 4px;
+  color: #fff;
+  box-shadow: 0 2px 8px rgba(152, 23, 23, 0.55);
+  font-size: 10px;
+  line-height: 16px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.device-map-attention-pin {
+  position: absolute;
+  left: 50%;
+  top: 27px;
+  width: 28px;
+  height: 34px;
+  transform: translateX(-50%);
+  animation: deviceMapAttentionBounce 1.1s ease-in-out infinite;
+}
+.device-map-attention-pin img {
+  position: relative;
+  z-index: 2;
+  display: block;
+  width: 28px;
+  height: 28px;
+}
+.device-map-attention-ring {
+  position: absolute;
+  z-index: 1;
+  left: 50%;
+  top: 12px;
+  width: 18px;
+  height: 18px;
+  border-radius: 50%;
+  transform: translate(-50%, -50%);
+  animation: deviceMapAttentionPulse 1.5s ease-out infinite;
+}
+.device-map-alert .device-map-attention-badge { background: #E64545; }
+.device-map-alert .device-map-attention-ring { border: 2px solid rgba(255, 75, 75, 0.95); }
+.device-map-offline .device-map-attention-badge { background: #E67E22; }
+.device-map-offline .device-map-attention-ring { border: 2px solid rgba(255, 166, 77, 0.95); }
+@keyframes deviceMapAttentionBounce {
+  0%, 100% { transform: translate(-50%, 0); }
+  50% { transform: translate(-50%, -8px); }
+}
+@keyframes deviceMapAttentionPulse {
+  0% { opacity: 0.9; transform: translate(-50%, -50%) scale(0.7); }
+  70%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(2.2); }
+}
+</style>

+ 80 - 6
src/views/subSystem/manholeCover/home/index.vue

@@ -347,15 +347,25 @@ function refreshMarkers() {
   allDevices.value.forEach(d => {
     if (d.lng == null || d.lat == null) return
     const color = getStatusColor(d.status)
-    const html = `<div style="text-align:center;cursor:pointer;">
-      <div style="color:#fff;background:${color};border-radius:3px;padding:1px 6px;font-size:10px;white-space:nowrap;display:inline-block;margin-bottom:2px;box-shadow:0 1px 3px rgba(0,0,0,0.3);">${d.name}</div>
-      <div><img src="${locationIcon}" style="width:22px;height:22px;display:block;margin:0 auto;"/></div>
-    </div>`
+    const isAttention = d.status === 'alert' || d.status === 'offline'
+    const attentionText = d.status === 'alert' ? '告警' : '离线'
+    const html = isAttention
+      ? `<div class="home-map-attention-marker home-map-${d.status}" title="${attentionText}设备:${d.name}">
+          <div class="home-map-attention-badge">${attentionText} - ${d.name}</div>
+          <div class="home-map-attention-pin">
+            <i class="home-map-attention-ring"></i>
+            <img src="${locationIcon}" alt="" />
+          </div>
+        </div>`
+      : `<div style="text-align:center;cursor:pointer;">
+          <div style="color:#fff;background:${color};border-radius:3px;padding:1px 6px;font-size:10px;white-space:nowrap;display:inline-block;margin-bottom:2px;box-shadow:0 1px 3px rgba(0,0,0,0.3);">${d.name}</div>
+          <div><img src="${locationIcon}" alt="" style="width:22px;height:22px;display:block;margin:0 auto;"/></div>
+        </div>`
     const label = new BMapGL.Label(html, {
       position: new BMapGL.Point(d.lng, d.lat),
-      offset: new BMapGL.Size(-25, -40)
+      offset: new BMapGL.Size(isAttention ? -62 : -25, isAttention ? -68 : -40)
     })
-    label.setStyle({ border: 'none', background: 'transparent', padding: '0', zIndex: '10' })
+    label.setStyle({ border: 'none', background: 'transparent', padding: '0', zIndex: isAttention ? '20' : '10' })
     mapInstance.addOverlay(label)
     markerMap.set(d.id, label)
   })
@@ -483,6 +493,70 @@ onBeforeUnmount(() => {
   flex: 1;
   min-height: 0;
 }
+
+/* 告警和离线设备地图标记:通过扩散圈和上下位移动画强化定位提示 */
+.home-map-attention-marker {
+  position: relative;
+  width: 124px;
+  height: 70px;
+  cursor: pointer;
+  text-align: center;
+}
+.home-map-attention-badge {
+  position: relative;
+  z-index: 2;
+  display: inline-block;
+  max-width: 118px;
+  overflow: hidden;
+  padding: 2px 7px;
+  border: 1px solid rgba(255, 255, 255, 0.72);
+  border-radius: 4px;
+  color: #fff;
+  box-shadow: 0 2px 8px rgba(152, 23, 23, 0.55);
+  font-size: 10px;
+  line-height: 16px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.home-map-attention-pin {
+  position: absolute;
+  left: 50%;
+  top: 27px;
+  width: 28px;
+  height: 34px;
+  transform: translateX(-50%);
+  animation: homeMapAttentionBounce 1.1s ease-in-out infinite;
+}
+.home-map-attention-pin img {
+  position: relative;
+  z-index: 2;
+  display: block;
+  width: 28px;
+  height: 28px;
+}
+.home-map-attention-ring {
+  position: absolute;
+  z-index: 1;
+  left: 50%;
+  top: 12px;
+  width: 18px;
+  height: 18px;
+  border-radius: 50%;
+  transform: translate(-50%, -50%);
+  animation: homeMapAttentionPulse 1.5s ease-out infinite;
+}
+.home-map-alert .home-map-attention-badge { background: #e64545; }
+.home-map-alert .home-map-attention-ring { border: 2px solid rgba(255, 75, 75, 0.95); }
+.home-map-offline .home-map-attention-badge { background: #e67e22; }
+.home-map-offline .home-map-attention-ring { border: 2px solid rgba(255, 166, 77, 0.95); }
+@keyframes homeMapAttentionBounce {
+  0%, 100% { transform: translate(-50%, 0); }
+  50% { transform: translate(-50%, -8px); }
+}
+@keyframes homeMapAttentionPulse {
+  0% { opacity: 0.9; transform: translate(-50%, -50%) scale(0.7); }
+  70%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(2.2); }
+}
 </style>
 
 <!-- ==================== 页面布局样式(scoped) ==================== -->