circulation.vue 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733
  1. <!--案件流转管理:以报警(alarm_data)为源头,关联工单(work_order)完成核查/派单/办结闭环-->
  2. <template>
  3. <div class="page">
  4. <!-- 筛选 -->
  5. <div class="panel filter">
  6. <div class="fl">
  7. <label>关键字</label>
  8. <el-input v-model="keyword" placeholder="报警单号 / 设备编号" clearable style="width:210px" @keyup.enter="loadAlarms" />
  9. </div>
  10. <div class="fl">
  11. <label>报警等级</label>
  12. <el-select v-model="levelFilter" placeholder="全部" clearable style="width:120px" @change="loadAlarms">
  13. <el-option v-for="lv in alarmLevels" :key="lv.value" :label="lv.label" :value="lv.value" />
  14. </el-select>
  15. </div>
  16. <div class="fl">
  17. <label>报警时间</label>
  18. <el-date-picker
  19. v-model="timeRange"
  20. type="datetimerange"
  21. value-format="YYYY-MM-DD HH:mm:ss"
  22. range-separator="至"
  23. start-placeholder="开始时间"
  24. end-placeholder="结束时间"
  25. style="width:320px"
  26. />
  27. </div>
  28. <button class="btn-pri" @click="loadAlarms">查询</button>
  29. <button class="btn-mini" @click="resetFilter">重置</button>
  30. </div>
  31. <!-- 状态页签 + 视图切换 -->
  32. <div class="panel statustabs">
  33. <div
  34. v-for="kpi in kpis"
  35. :key="kpi.key"
  36. class="st"
  37. :class="{ on: tabStatus === kpi.key }"
  38. @click="switchTab(kpi.key)"
  39. >
  40. {{ kpi.label }}<i>{{ kpi.count }}</i>
  41. </div>
  42. <div class="viewswitch">
  43. <button :class="{ on: viewMode === 'list' }" @click="viewMode = 'list'">表格视图</button>
  44. <button :class="{ on: viewMode === 'map' }" @click="viewMode = 'map'">地图视图</button>
  45. </div>
  46. </div>
  47. <!-- 工作区 -->
  48. <div class="workarea">
  49. <!-- 左:报警列表 -->
  50. <div class="panel listpane" v-loading="loading">
  51. <div class="hd"><b>报警列表</b><span class="cnt">共 {{ filteredAlarms.length }} 条</span></div>
  52. <div class="listbody">
  53. <div
  54. v-for="alarm in paginatedAlarms"
  55. :key="alarm.id"
  56. class="card"
  57. :class="{ on: selectedAlarm?.id === alarm.id }"
  58. @click="selectAlarm(alarm)"
  59. >
  60. <div class="r1">
  61. <span class="no">{{ alarm.alarmNo }}</span>
  62. <el-tag :type="levelTagType(alarm.alarmLevel)" size="small">{{ levelText(alarm.alarmLevel) }}</el-tag>
  63. <span class="tag sm" :class="statusMeta(alarm).cls" style="margin-left:auto">{{ statusMeta(alarm).text }}</span>
  64. </div>
  65. <div class="desc">{{ alarm.alarmContent || '暂无报警描述' }}</div>
  66. <div class="r3">
  67. <span>{{ alarm.deviceName || alarm.deviceCode || '—' }}</span>
  68. <span>{{ formatDateTime(alarm.createTime) }}</span>
  69. </div>
  70. <div class="wo" v-if="woMap[alarm.id] != null">
  71. <span>关联工单</span><b>{{ ORDER_STATUS[woMap[alarm.id]] }}</b>
  72. </div>
  73. </div>
  74. <div v-if="!paginatedAlarms.length" class="empty">暂无报警数据</div>
  75. </div>
  76. <div class="listfoot">
  77. <el-pagination small layout="prev, pager, next" :total="filteredAlarms.length" :page-size="pageSize" v-model:current-page="currentPage" />
  78. </div>
  79. </div>
  80. <!-- 右:地图 / 详情 -->
  81. <div v-if="viewMode === 'map'" class="panel detailpane" v-loading="mapLoading">
  82. <GisMapCanvas :points="mapPoints" @point-click="onPointClick" />
  83. <div class="map-legend">
  84. <div><i style="background:#E6A23C"></i>待审核</div>
  85. <div><i style="background:#409EFF"></i>处置中</div>
  86. <div><i style="background:#67C23A"></i>已办结</div>
  87. <div><i style="background:#909399"></i>误报</div>
  88. </div>
  89. </div>
  90. <div v-else-if="selectedAlarm" class="panel detailpane" v-loading="detailLoading">
  91. <!-- 头部 -->
  92. <div class="dhead">
  93. <div class="dstat">
  94. <div class="statbox">
  95. <span class="lb">报警状态</span>
  96. <span class="vl" :class="statusMeta(selectedAlarm).cls">{{ statusMeta(selectedAlarm).text }}</span>
  97. </div>
  98. <span class="arw">→</span>
  99. <div class="statbox derived">
  100. <span class="lb">关联工单</span>
  101. <span class="vl">{{ workOrder ? (ORDER_STATUS[workOrder.orderStatus] || '—') : '未生成工单' }}</span>
  102. </div>
  103. <div class="statbox derived" v-if="workOrder">
  104. <span class="lb">工单号</span>
  105. <span class="vl mono">{{ workOrder.orderNo }}</span>
  106. </div>
  107. </div>
  108. </div>
  109. <!-- 步骤条 -->
  110. <div class="steps">
  111. <div
  112. v-for="(s, i) in stepNames"
  113. :key="s"
  114. class="step"
  115. :class="{ done: i < activeStep || activeStep === stepNames.length, cur: i === activeStep && activeStep < stepNames.length }"
  116. >
  117. <span class="idx"><template v-if="i < activeStep || activeStep === stepNames.length">✓</template><template v-else>{{ i + 1 }}</template></span>
  118. <span class="nm">{{ s }}</span>
  119. <span class="bar" v-if="i < stepNames.length - 1"></span>
  120. </div>
  121. </div>
  122. <!-- 详情页签 -->
  123. <div class="dtabs">
  124. <div v-for="t in detailTabs" :key="t.key" class="dtab" :class="{ on: activeTab === t.key }" @click="activeTab = t.key">
  125. {{ t.label }}
  126. </div>
  127. </div>
  128. <div class="dbody">
  129. <!-- 报警信息 -->
  130. <template v-if="activeTab === 'alarm'">
  131. <div class="dl">
  132. <div class="it"><span class="k">报警单号</span><span class="v mono">{{ detail?.alarmNo || '—' }}</span></div>
  133. <div class="it"><span class="k">报警等级</span><span class="v"><el-tag :type="levelTagType(detail?.alarmLevel)">{{ levelText(detail?.alarmLevel) }}</el-tag></span></div>
  134. <div class="it"><span class="k">报警审核</span><span class="v">{{ auditText(detail?.auditStatus) }}</span></div>
  135. <div class="it"><span class="k">实际值</span><span class="v">{{ detail?.actualValue ?? '—' }}</span></div>
  136. <div class="it"><span class="k">阈值</span><span class="v">{{ detail?.minValue ?? '—' }} / {{ detail?.maxValue ?? '—' }}</span></div>
  137. <div class="it"><span class="k">报警时间</span><span class="v">{{ formatDateTime(detail?.createTime ?? selectedAlarm.createTime, true) }}</span></div>
  138. <div class="it one"><span class="k">报警描述</span><span class="v">{{ detail?.alarmContent || '—' }}</span></div>
  139. </div>
  140. <div class="sec-t mt14">报警附件</div>
  141. <div class="media" v-if="alarmImages.length">
  142. <el-image
  143. v-for="(img, idx) in alarmImages"
  144. :key="idx"
  145. :src="img"
  146. fit="cover"
  147. class="mi-img"
  148. :preview-src-list="alarmImages"
  149. preview-teleported
  150. />
  151. </div>
  152. <div v-else class="empty" style="padding:20px 0">暂无报警附件</div>
  153. </template>
  154. <!-- 关联工单 -->
  155. <template v-else-if="activeTab === 'workorder'">
  156. <template v-if="workOrder">
  157. <div class="wo-card">
  158. <div class="hd3">
  159. <span class="wn">{{ workOrder.orderNo }}</span>
  160. <span class="tag" :class="orderTagClass(workOrder.orderStatus)">{{ ORDER_STATUS[workOrder.orderStatus] || '—' }}</span>
  161. <span class="spacer"></span>
  162. <span class="hint">处置人:{{ workOrder.receiveUserName || '—' }} · 核验人:{{ workOrder.verifyUserName || '—' }}</span>
  163. </div>
  164. <div class="bd3">
  165. <div class="dl">
  166. <div class="it"><span class="k">设备编码</span><span class="v mono">{{ workOrder.deviceCode || '—' }}</span></div>
  167. <div class="it"><span class="k">设备名称</span><span class="v">{{ workOrder.deviceName || '—' }}</span></div>
  168. <div class="it"><span class="k">故障发生时间</span><span class="v">{{ formatDateTime(workOrder.faultTime, true) }}</span></div>
  169. <div class="it"><span class="k">计划完成时间</span><span class="v">{{ formatDateTime(workOrder.planFinishTime, true) }}</span></div>
  170. <div class="it"><span class="k">实际完成时间</span><span class="v">{{ formatDateTime(workOrder.realFinishTime, true) }}</span></div>
  171. <div class="it"><span class="k">验收时间</span><span class="v">{{ formatDateTime(workOrder.verifyTime, true) }}</span></div>
  172. <div class="it one"><span class="k">问题描述</span><span class="v">{{ workOrder.orderDesc || '—' }}</span></div>
  173. <div class="it one"><span class="k">维修处置结果</span><span class="v">{{ workOrder.repairResult || '—' }}</span></div>
  174. </div>
  175. <div class="media mt14" v-if="woImages.length">
  176. <el-image v-for="(img, idx) in woImages" :key="idx" :src="img" fit="cover" class="mi-img" :preview-src-list="woImages" preview-teleported />
  177. </div>
  178. </div>
  179. </div>
  180. </template>
  181. <div v-else class="empty">暂无关联工单,请先核查报警</div>
  182. </template>
  183. <!-- 案件经过:整合报警日志与工单日志 -->
  184. <template v-else-if="activeTab === 'flow'">
  185. <el-timeline v-if="flowList.length" class="flow-tl">
  186. <el-timeline-item
  187. v-for="(item, idx) in flowList"
  188. :key="idx"
  189. :timestamp="flowTimestamp(item.time)"
  190. placement="top"
  191. :type="item.source === 'alarm' ? 'primary' : 'warning'"
  192. :color="item.source === 'alarm' ? '' : '#e6a23c'"
  193. >
  194. <div class="flow-card">
  195. <div class="flow-hd">
  196. <el-tag size="small" :type="item.source === 'alarm' ? 'primary' : 'warning'" effect="plain">
  197. {{ item.source === 'alarm' ? '报警' : '工单' }}
  198. </el-tag>
  199. <b class="flow-type">{{ item.typeLabel }}</b>
  200. <span class="flow-op">{{ item.operator }}</span>
  201. </div>
  202. <div v-if="item.content" class="flow-desc">{{ item.content }}</div>
  203. <div v-if="attachmentsOf(item.attachments).length" class="media mt14">
  204. <el-image
  205. v-for="(a, i) in attachmentsOf(item.attachments)"
  206. :key="i"
  207. :src="a.url"
  208. fit="cover"
  209. class="mi-img"
  210. :preview-src-list="attachmentsOf(item.attachments).map(x => x.url)"
  211. preview-teleported
  212. />
  213. </div>
  214. </div>
  215. </el-timeline-item>
  216. </el-timeline>
  217. <div v-else class="empty">暂无案件经过</div>
  218. </template>
  219. <!-- 设备信息 -->
  220. <template v-else>
  221. <div class="dl">
  222. <div class="it"><span class="k">设备编码</span><span class="v mono">{{ detail?.deviceCode || '—' }}</span></div>
  223. <div class="it"><span class="k">设备名称</span><span class="v">{{ detail?.deviceName || '—' }}</span></div>
  224. <div class="it"><span class="k">设备类型</span><span class="v">{{ detail?.equipmentTypeName || '—' }}</span></div>
  225. <div class="it"><span class="k">安装位置</span><span class="v">{{ detail?.equipmentLocation || '—' }}</span></div>
  226. <div class="it"><span class="k">经度</span><span class="v">{{ detail?.longitude || '—' }}</span></div>
  227. <div class="it"><span class="k">纬度</span><span class="v">{{ detail?.latitude || '—' }}</span></div>
  228. </div>
  229. </template>
  230. </div>
  231. <!-- 底部操作:本页只做总览,所有写操作跳转既有页面 -->
  232. <div class="dfoot">
  233. <el-button v-if="canReview" type="primary" @click="goAudit">前往核查</el-button>
  234. <el-button v-if="showWorkOrderBtn" type="warning" @click="goWorkOrder">工单处置</el-button>
  235. </div>
  236. </div>
  237. <div v-else class="panel detailpane center">
  238. <div class="empty">请从左侧选择报警查看流转详情</div>
  239. </div>
  240. </div>
  241. </div>
  242. </template>
  243. <script setup>
  244. import { computed, onActivated, onMounted, ref } from 'vue'
  245. import { useRouter } from 'vue-router'
  246. import GisMapCanvas from '@/components/GisMapCanvas.vue'
  247. import {
  248. getAlarmDetail,
  249. getAlarmList,
  250. getAlarmMap,
  251. getAlarmTimeline
  252. } from '@/api/pipeNetwork/basic'
  253. import { useDict } from '@/utils/dict'
  254. import { imageAttachments } from '@/views/subSystem/alarm/common/js/alarmReviewModel'
  255. import { orderLogText } from '@/views/subSystem/pipeNetwork/ops/workOrder/js/workOrderDict.js'
  256. const router = useRouter()
  257. const { alarm_level: alarmLevels } = useDict('alarm_level')
  258. // 窨井设备根类型 ID(报警模块按 rootTypeId 隔离子系统;如与实际不符请调整)
  259. const ROOT_TYPE_MANHOLE = 4
  260. const ORDER_STATUS = {
  261. 0: '待派单', 1: '待接单', 2: '已接单', 3: '处理中', 4: '待验收', 5: '已结案', 6: '延期中', 7: '已取消'
  262. }
  263. const stepNames = ['报警生成', '核实', '派单', '处置', '核查', '办结']
  264. const detailTabs = computed(() => [
  265. { key: 'alarm', label: '报警信息' },
  266. { key: 'workorder', label: '关联工单' },
  267. { key: 'flow', label: '案件经过' },
  268. { key: 'device', label: '设备信息' }
  269. ])
  270. const alarms = ref([])
  271. const loading = ref(false)
  272. const detailLoading = ref(false)
  273. const mapLoading = ref(false)
  274. const keyword = ref('')
  275. const levelFilter = ref('')
  276. const timeRange = ref([])
  277. const tabStatus = ref('')
  278. const currentPage = ref(1)
  279. const pageSize = 8
  280. const selectedAlarm = ref(null)
  281. const detail = ref(null)
  282. const workOrder = ref(null)
  283. const timeline = ref([])
  284. const workOrderLogs = ref([])
  285. const viewMode = ref('list')
  286. const activeTab = ref('alarm')
  287. const mapPoints = ref([])
  288. const woMap = ref({})
  289. function formatDateTime(value, withSeconds = false) {
  290. if (!value) return '—'
  291. return String(value).replace('T', ' ').slice(0, withSeconds ? 19 : 16)
  292. }
  293. function localDay(date) {
  294. const pad = n => String(n).padStart(2, '0')
  295. return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
  296. }
  297. // 时间轴时间戳:日期按工单 dayLabel 的“今天/昨天”相对词,时刻按工单 logTime 只取 HH:mm
  298. function flowTimestamp(value) {
  299. const raw = String(value || '').replace('T', ' ').trim()
  300. if (!raw) return '—'
  301. const day = raw.slice(0, 10)
  302. const hm = raw.slice(11, 16) || '--:--'
  303. if (day === localDay(new Date())) return `今天 ${hm}`
  304. if (day === localDay(new Date(Date.now() - 86400000))) return `昨天 ${hm}`
  305. return `${(+day.slice(5, 7))}月${(+day.slice(8, 10))}日 ${hm}`
  306. }
  307. function orderTagClass(s) {
  308. if (s === 5) return 'tg-s'
  309. if (s === 7) return 'tg-g'
  310. if (s === 0) return 'tg-w'
  311. return 'tg-b'
  312. }
  313. // 报警等级:参考系统其它子系统,按级别映射 el-tag 类型区分颜色(字典未配置 listClass,故硬编码)
  314. function levelText(l) {
  315. const item = alarmLevels.value.find(i => String(i.value) === String(l))
  316. return item ? item.label : '—'
  317. }
  318. function levelTagType(l) {
  319. return { '1': 'danger', '2': 'warning', '3': 'primary', '4': 'success' }[String(l)] || 'info'
  320. }
  321. function auditText(s) {
  322. return { 0: '未审核', 1: '确认报警', 2: '误报警' }[Number(s)] || '—'
  323. }
  324. // 与报警日志(AlarmAttachments)一致:仅取图片附件并拼接 minio 完整 URL
  325. function toImageUrls(value) {
  326. return imageAttachments(value).map(item => item.url)
  327. }
  328. // 报警展示状态:误报 / 待审核 / 处置中 / 已办结
  329. function statusMeta(alarm) {
  330. if (alarm.auditStatus === 2) return { key: 'false', text: '误报', cls: 'tg-g' }
  331. if (alarm.auditStatus === 0) return { key: 'pending', text: '待审核', cls: 'tg-w' }
  332. const wo = woMap.value[alarm.id]
  333. if (wo === 5) return { key: 'completed', text: '已办结', cls: 'tg-s' }
  334. return { key: 'processing', text: '处置中', cls: 'tg-b' }
  335. }
  336. const filteredAlarms = computed(() => {
  337. if (!tabStatus.value) return alarms.value
  338. return alarms.value.filter(a => statusMeta(a).key === tabStatus.value)
  339. })
  340. const paginatedAlarms = computed(() => {
  341. const start = (currentPage.value - 1) * pageSize
  342. return filteredAlarms.value.slice(start, start + pageSize)
  343. })
  344. const kpis = computed(() => [
  345. { key: '', label: '全部', count: alarms.value.length },
  346. { key: 'pending', label: '待审核', count: alarms.value.filter(a => statusMeta(a).key === 'pending').length },
  347. { key: 'processing', label: '处置中', count: alarms.value.filter(a => statusMeta(a).key === 'processing').length },
  348. { key: 'completed', label: '已办结', count: alarms.value.filter(a => statusMeta(a).key === 'completed').length },
  349. { key: 'false', label: '误报', count: alarms.value.filter(a => statusMeta(a).key === 'false').length }
  350. ])
  351. // 步骤进度
  352. const stepStates = computed(() => {
  353. const a = detail.value
  354. const wo = workOrder.value
  355. return [
  356. true,
  357. !!a && a.auditStatus !== 0,
  358. !!wo && wo.orderStatus >= 1,
  359. !!wo && wo.orderStatus >= 3,
  360. !!wo && wo.orderStatus >= 4,
  361. !!wo && wo.orderStatus === 5
  362. ]
  363. })
  364. const activeStep = computed(() => {
  365. const s = stepStates.value
  366. const i = s.findIndex(x => !x)
  367. return i === -1 ? s.length : i
  368. })
  369. // 操作按钮可见性(严格对应现有接口)
  370. // 前往核查:仅当报警未审核(audit_status=0)时显示
  371. const canReview = computed(() => !!detail.value && detail.value.auditStatus === 0)
  372. // 工单处置:仅当报警已关联工单(alarm_data.work_order_id 不为空)且工单未结案(order_status!=5)时显示
  373. const showWorkOrderBtn = computed(() => {
  374. const wo = workOrder.value
  375. return !!detail.value?.workOrderId && !!wo && Number(wo.orderStatus) !== 5
  376. })
  377. const alarmImages = computed(() => {
  378. const imgs = []
  379. timeline.value.forEach(t => {
  380. if (t.attachments) imgs.push(...toImageUrls(t.attachments))
  381. if (t.attachUrl) imgs.push(...toImageUrls(t.attachUrl))
  382. })
  383. if (detail.value?.attachUrl) imgs.push(...toImageUrls(detail.value.attachUrl))
  384. return [...new Set(imgs)]
  385. })
  386. const woImages = computed(() => [
  387. ...toImageUrls(workOrder.value?.maintenanceBeforeImg),
  388. ...toImageUrls(workOrder.value?.maintenanceAfterImg)
  389. ])
  390. // 案件经过:整合 alarm_log 与 work_order_log,归一化后按时间倒序
  391. function normalizeAlarmLog(l) {
  392. return {
  393. source: 'alarm',
  394. time: l.operTime,
  395. operator: l.operUserName || '系统',
  396. typeLabel: l.operTypeName || '报警操作',
  397. content: l.operDesc || '',
  398. attachments: l.attachUrl
  399. }
  400. }
  401. function normalizeWorkOrderLog(l) {
  402. return {
  403. source: 'workOrder',
  404. time: l.operTime,
  405. operator: l.operUserName || '系统',
  406. typeLabel: orderLogText(l.operType),
  407. content: l.operDesc || '',
  408. attachments: l.attachUrl
  409. }
  410. }
  411. function attachmentsOf(value) {
  412. return imageAttachments(value).map(i => ({ url: i.url, name: i.name }))
  413. }
  414. const flowList = computed(() => {
  415. const items = [
  416. ...timeline.value.map(normalizeAlarmLog),
  417. ...workOrderLogs.value.map(normalizeWorkOrderLog)
  418. ]
  419. // 按操作时间数值降序(新→旧),兼容带 T 的 ISO 与空格分隔格式
  420. const toTs = t => {
  421. const s = String(t || '').trim()
  422. if (!s) return 0
  423. let v = new Date(s).getTime()
  424. if (!Number.isFinite(v)) v = new Date(s.replace('T', ' ').replace(/-/g, '/')).getTime()
  425. return Number.isFinite(v) ? v : 0
  426. }
  427. // 同一时刻:工单日志在上,报警日志在下(先报警后派单,工单动作更新)
  428. const rank = x => x.source === 'workOrder' ? 0 : 1
  429. items.sort((a, b) => (toTs(b.time) - toTs(a.time)) || (rank(a) - rank(b)))
  430. return items
  431. })
  432. async function loadAlarms() {
  433. loading.value = true
  434. try {
  435. const params = {
  436. pageNum: 1,
  437. pageSize: 100,
  438. rootTypeId: ROOT_TYPE_MANHOLE,
  439. keyword: keyword.value.trim() || undefined,
  440. alarmLevel: levelFilter.value || undefined
  441. }
  442. if (timeRange.value && timeRange.value.length === 2) {
  443. params.startTime = timeRange.value[0]
  444. params.endTime = timeRange.value[1]
  445. }
  446. const res = await getAlarmList(params)
  447. const records = Array.isArray(res?.data?.records) ? res.data.records : []
  448. alarms.value = records
  449. currentPage.value = 1
  450. const first = filteredAlarms.value[0]
  451. if (first) await selectAlarm(first)
  452. else {
  453. selectedAlarm.value = null
  454. detail.value = null
  455. workOrder.value = null
  456. }
  457. } catch (e) {
  458. alarms.value = []
  459. selectedAlarm.value = null
  460. } finally {
  461. loading.value = false
  462. }
  463. }
  464. async function selectAlarm(alarm) {
  465. selectedAlarm.value = alarm
  466. activeTab.value = 'alarm'
  467. detailLoading.value = true
  468. workOrder.value = null
  469. try {
  470. const [dRes, tRes] = await Promise.all([
  471. getAlarmDetail(alarm.id),
  472. getAlarmTimeline(alarm.id)
  473. ])
  474. detail.value = dRes?.data || alarm
  475. const t = tRes?.data
  476. timeline.value = Array.isArray(t) ? t : (t?.records || [])
  477. workOrderLogs.value = []
  478. // 报警详情接口已按 alarm_data.work_order_id 关联出工单(workOrderDetail = { order, logs })
  479. const woDetail = detail.value.workOrderDetail
  480. workOrder.value = woDetail?.order || null
  481. workOrderLogs.value = Array.isArray(woDetail?.logs) ? woDetail.logs : []
  482. if (workOrder.value) woMap.value[alarm.id] = workOrder.value.orderStatus
  483. } catch (e) {
  484. detail.value = alarm
  485. workOrder.value = null
  486. workOrderLogs.value = []
  487. } finally {
  488. detailLoading.value = false
  489. }
  490. }
  491. function switchTab(key) {
  492. tabStatus.value = key
  493. currentPage.value = 1
  494. const first = filteredAlarms.value[0]
  495. if (first) selectAlarm(first)
  496. else {
  497. selectedAlarm.value = null
  498. detail.value = null
  499. workOrder.value = null
  500. }
  501. }
  502. function resetFilter() {
  503. keyword.value = ''
  504. levelFilter.value = ''
  505. timeRange.value = []
  506. loadAlarms()
  507. }
  508. const onPointClick = (p) => {
  509. const a = alarms.value.find(x => x.id === p.id)
  510. if (a) selectAlarm(a)
  511. }
  512. async function loadMap() {
  513. mapLoading.value = true
  514. try {
  515. const res = await getAlarmMap({ rootTypeId: ROOT_TYPE_MANHOLE })
  516. const list = res?.data || []
  517. mapPoints.value = list
  518. .map(p => ({
  519. id: p.id,
  520. name: p.deviceName || p.deviceCode || '设备',
  521. lng: Number(p.longitude),
  522. lat: Number(p.latitude),
  523. status: statusMeta(p).key,
  524. code: p.deviceCode,
  525. type: p.warningCode
  526. }))
  527. .filter(p => p.lng && p.lat)
  528. } finally {
  529. mapLoading.value = false
  530. }
  531. }
  532. // 核查:跳转窨井报警审核页,带报警单号作为关键字,审核页会自动填入并定位到该报警
  533. function goAudit() {
  534. if (!selectedAlarm.value) return
  535. router.push({
  536. path: '/subSystem/manholeCover/alarm/alarmAudit',
  537. query: { rootTypeId: '4', keyword: selectedAlarm.value.alarmNo }
  538. })
  539. }
  540. // 工单操作:跳转工单处置页,带工单号作为关键字,处置页会自动填入并定位到该工单
  541. function goWorkOrder() {
  542. if (!workOrder.value) return
  543. router.push({
  544. path: '/subSystem/manholeCover/caseHandling/workOrderDispose',
  545. query: { keyword: workOrder.value.orderNo || '', rootTypeId: '4' }
  546. })
  547. }
  548. onMounted(() => {
  549. loadAlarms()
  550. loadMap()
  551. })
  552. // 从其他页面返回时刷新状态(菜单多为 keep-alive)
  553. onActivated(() => {
  554. loadAlarms()
  555. loadMap()
  556. })
  557. </script>
  558. <style scoped>
  559. .page {
  560. height: calc(100vh - 110px);
  561. display: flex;
  562. flex-direction: column;
  563. gap: 10px;
  564. padding: 10px;
  565. background: #f0f2f5;
  566. font-size: 13px;
  567. color: #303133;
  568. overflow: hidden;
  569. }
  570. .panel { background: #fff; border: 1px solid #ebeef5; border-radius: 4px; }
  571. /* ===== 筛选 ===== */
  572. .filter { padding: 12px 14px; display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; }
  573. .fl { display: flex; align-items: center; gap: 8px; }
  574. .fl label { color: #606266; white-space: nowrap; }
  575. .btn-pri { background: #409eff; color: #fff; padding: 0 16px; height: 32px; border-radius: 4px; cursor: pointer; border: none; font-size: 13px; }
  576. .btn-pri:hover { background: #66b1ff; }
  577. .btn-mini { height: 32px; padding: 0 14px; border: 1px solid #dcdfe6; background: #fff; color: #606266; border-radius: 4px; cursor: pointer; font-size: 13px; }
  578. .btn-mini:hover { color: #409eff; border-color: #66b1ff; }
  579. /* ===== 状态页签 ===== */
  580. .statustabs { display: flex; align-items: center; gap: 2px; padding: 0 14px; height: 44px; flex: none; }
  581. .st { padding: 0 14px; color: #606266; height: 44px; line-height: 44px; cursor: pointer; position: relative; white-space: nowrap; }
  582. .st i { font-style: normal; color: #909399; margin-left: 3px; }
  583. .st:hover { color: #409eff; }
  584. .st.on { color: #409eff; }
  585. .st.on::after { content: ''; position: absolute; left: 12px; right: 12px; bottom: 0; height: 2px; background: #409eff; }
  586. .viewswitch { margin-left: auto; display: flex; border: 1px solid #dcdfe6; border-radius: 4px; overflow: hidden; }
  587. .viewswitch button { padding: 0 12px; height: 26px; font-size: 12px; color: #606266; background: #fff; border: none; cursor: pointer; }
  588. .viewswitch button.on { background: #409eff; color: #fff; }
  589. /* ===== 工作区 ===== */
  590. .workarea { flex: 1; display: flex; gap: 10px; min-height: 0; }
  591. /* 左列表 */
  592. .listpane { width: 330px; flex: none; display: flex; flex-direction: column; min-height: 0; }
  593. .listpane .hd { padding: 10px 14px; border-bottom: 1px solid #ebeef5; display: flex; align-items: center; justify-content: space-between; }
  594. .listpane .hd b { font-weight: 500; }
  595. .listpane .hd .cnt { color: #909399; }
  596. .listbody { flex: 1; overflow-y: auto; padding: 8px; }
  597. .listfoot { border-top: 1px solid #ebeef5; padding: 6px; text-align: center; }
  598. .card { border: 1px solid #e4e7ed; border-radius: 4px; padding: 10px 12px; margin-bottom: 8px; cursor: pointer; transition: 0.15s; background: #fff; }
  599. .card:hover { border-color: #66b1ff; }
  600. .card.on { border-color: #409eff; background: #f5f9ff; }
  601. .card .r1 { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; }
  602. .card .no { font-size: 12px; color: #909399; font-family: Menlo, Consolas, monospace; }
  603. .card .desc { font-size: 13px; color: #303133; line-height: 1.5; margin-bottom: 6px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
  604. .card .r3 { display: flex; justify-content: space-between; color: #909399; font-size: 12px; gap: 8px; }
  605. .card .wo { margin-top: 6px; padding-top: 6px; border-top: 1px dashed #ebeef5; font-size: 11px; color: #909399; display: flex; justify-content: space-between; }
  606. .card .wo b { font-weight: 400; color: #606266; }
  607. /* 标签 */
  608. .tag { display: inline-flex; align-items: center; height: 20px; padding: 0 7px; border-radius: 3px; font-size: 12px; line-height: 1; white-space: nowrap; }
  609. .tag.sm { height: 18px; font-size: 11px; padding: 0 5px; }
  610. .tg-w { color: #b88230; background: #fdf6ec; border: 1px solid #faecd8; }
  611. .tg-b { color: #337ecc; background: #ecf5ff; border: 1px solid #d9ecff; }
  612. .tg-s { color: #529b2e; background: #f0f9eb; border: 1px solid #e1f3d8; }
  613. .tg-d { color: #c45656; background: #fef0f0; border: 1px solid #fde2e2; }
  614. .tg-g { color: #73767a; background: #f4f4f5; border: 1px solid #e9e9eb; }
  615. .tg-p { color: #5a52c4; background: #f0effe; border: 1px solid #e0defa; }
  616. /* 右详情 */
  617. .detailpane { flex: 1; display: flex; flex-direction: column; min-height: 0; position: relative; overflow: hidden; }
  618. .detailpane.center { align-items: center; justify-content: center; }
  619. .dhead { padding: 13px 18px 12px; border-bottom: 1px solid #ebeef5; flex: none; }
  620. .dstat { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
  621. .statbox { border: 1px solid #e4e7ed; border-radius: 4px; padding: 6px 12px; display: flex; align-items: center; gap: 9px; background: #fafcff; }
  622. .statbox .lb { font-size: 12px; color: #909399; }
  623. .statbox .vl { font-size: 13px; color: #303133; font-weight: 500; }
  624. .statbox.derived { background: #fff; }
  625. .statbox .mono { font-family: Menlo, Consolas, monospace; color: #409eff; }
  626. .dstat .arw { color: #dcdfe6; font-size: 12px; }
  627. /* 步骤条 */
  628. .steps { display: flex; align-items: center; padding: 13px 18px; background: #fafcff; border-bottom: 1px solid #ebeef5; flex: none; }
  629. .step { display: flex; align-items: center; gap: 8px; flex: 1; position: relative; }
  630. .step .idx { width: 24px; height: 24px; border-radius: 50%; border: 1px solid #dcdfe6; background: #fff; color: #909399; display: flex; align-items: center; justify-content: center; font-size: 12px; flex: none; }
  631. .step .nm { font-size: 13px; color: #909399; white-space: nowrap; }
  632. .step.done .idx { background: #67c23a; border-color: #67c23a; color: #fff; }
  633. .step.done .nm { color: #606266; }
  634. .step.cur .idx { background: #409eff; border-color: #409eff; color: #fff; box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.18); }
  635. .step.cur .nm { color: #409eff; font-weight: 500; }
  636. .step .bar { height: 1px; background: #dcdfe6; flex: 1; margin: 0 10px; }
  637. .step.done .bar { background: #67c23a; }
  638. .step:last-child { flex: none; }
  639. .step:last-child .bar { display: none; }
  640. /* 详情页签 */
  641. .dtabs { display: flex; gap: 2px; padding: 0 18px; border-bottom: 1px solid #ebeef5; flex: none; }
  642. .dtab { padding: 0 16px; height: 40px; line-height: 40px; font-size: 13px; color: #606266; cursor: pointer; position: relative; }
  643. .dtab:hover { color: #409eff; }
  644. .dtab.on { color: #409eff; }
  645. .dtab.on::after { content: ''; position: absolute; left: 16px; right: 16px; bottom: -1px; height: 2px; background: #409eff; }
  646. .dtab .cnt { font-size: 11px; color: #909399; margin-left: 4px; }
  647. .dbody { flex: 1; overflow-y: auto; padding: 16px 18px; }
  648. .dfoot { border-top: 1px solid #ebeef5; padding: 10px 18px; display: flex; justify-content: flex-end; gap: 10px; flex: none; }
  649. /* dl 网格(键值对) */
  650. .dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 26px; }
  651. .dl .it { display: flex; gap: 10px; padding: 9px 0; border-bottom: 1px dashed #ebeef5; align-items: flex-start; }
  652. .dl .it.one { grid-column: 1 / -1; }
  653. .dl .it .k { color: #909399; width: 100px; flex: none; }
  654. .dl .it .v { color: #303133; flex: 1; word-break: break-all; line-height: 1.6; }
  655. .mono { font-family: Menlo, Consolas, monospace; font-size: 12px; }
  656. .sec-t { font-size: 13px; font-weight: 500; color: #303133; padding: 2px 0 10px; display: flex; align-items: center; gap: 8px; }
  657. .sec-t::before { content: ''; width: 3px; height: 13px; background: #409eff; border-radius: 2px; }
  658. .mt14 { margin-top: 14px; }
  659. /* 多媒体网格 */
  660. .media { display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); gap: 10px; }
  661. .mi-img { width: 100%; height: 90px; border-radius: 4px; border: 1px solid #e4e7ed; cursor: pointer; }
  662. /* 工单卡片 */
  663. .wo-card { border: 1px solid #e4e7ed; border-radius: 4px; overflow: hidden; }
  664. .wo-card .hd3 { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #fafcff; border-bottom: 1px solid #ebeef5; flex-wrap: wrap; }
  665. .wo-card .hd3 .wn { font-family: Menlo, Consolas, monospace; font-size: 13px; font-weight: 500; }
  666. .wo-card .hd3 .spacer { flex: 1; }
  667. .wo-card .hd3 .hint { font-size: 12px; color: #909399; }
  668. .wo-card .bd3 { padding: 4px 12px 12px; }
  669. /* 案件经过时间轴(参考工单处置记录) */
  670. .flow-tl { padding: 4px 0; }
  671. /* 竖线:与工单处置记录一致,蓝色 1px 短划线(4px 实 / 4px 空),末项不画线 */
  672. .flow-tl :deep(.el-timeline-item__tail) {
  673. border-left: none;
  674. left: 5px;
  675. width: 1px;
  676. background: repeating-linear-gradient(to bottom, #3b5bdb 0 4px, transparent 4px 8px);
  677. }
  678. .flow-tl :deep(.el-timeline-item:last-child .el-timeline-item__tail) { display: none; }
  679. .flow-card { background: #fafcff; border: 1px solid #ebeef5; border-radius: 6px; padding: 10px 12px; }
  680. .flow-hd { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; }
  681. .flow-hd .flow-type { font-weight: 500; color: #303133; }
  682. .flow-hd .flow-op { margin-left: auto; color: #909399; font-size: 12px; }
  683. .flow-desc { font-size: 13px; color: #606266; line-height: 1.7; margin-top: 6px; white-space: pre-wrap; }
  684. /* 地图图例 */
  685. .map-legend { position: absolute; left: 10px; bottom: 10px; background: rgba(255, 255, 255, 0.94); border: 1px solid #dcdfe6; border-radius: 4px; padding: 7px 9px; font-size: 12px; color: #606266; display: flex; flex-direction: column; gap: 5px; z-index: 900; }
  686. .map-legend i { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
  687. .empty { padding: 44px 0; text-align: center; color: #909399; font-size: 13px; }
  688. </style>