operationTask.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872
  1. <!--运维任务管理 - 窨井设备异常派发中心-->
  2. <template>
  3. <div class="terminal-dispatch">
  4. <!-- 页面头部 -->
  5. <div class="page-header">
  6. <div class="header-left">
  7. <el-icon><Warning /></el-icon>
  8. <span class="title">窨井设备异常派发中心</span>
  9. <el-tag type="danger" effect="dark">未处理异常 {{ abnormalCount }}</el-tag>
  10. </div>
  11. <div class="header-right">
  12. <el-input
  13. v-model="searchKeyword"
  14. placeholder="搜索设备编号/名称/位置"
  15. clearable
  16. :prefix-icon="Search"
  17. style="width: 240px"
  18. @keyup.enter="currentPage = 1"
  19. @clear="currentPage = 1"
  20. />
  21. <el-select v-model="deviceTypeFilter" placeholder="设备类型" clearable style="width: 130px" @change="currentPage = 1">
  22. <el-option label="供水窨井" value="5" />
  23. <el-option label="排水窨井" value="9" />
  24. <el-option label="燃气窨井" value="10" />
  25. </el-select>
  26. <el-button type="primary" :icon="Refresh" @click="loadDevices" :loading="loading">刷新</el-button>
  27. </div>
  28. </div>
  29. <!-- 统计卡片 -->
  30. <div class="stats-cards">
  31. <div class="stat-card critical">
  32. <div class="stat-value">{{ criticalCount }}</div>
  33. <div class="stat-label">严重异常</div>
  34. </div>
  35. <div class="stat-card warning">
  36. <div class="stat-value">{{ warningCount }}</div>
  37. <div class="stat-label">一般异常</div>
  38. </div>
  39. <div class="stat-card">
  40. <div class="stat-value">{{ pendingCount }}</div>
  41. <div class="stat-label">待派发</div>
  42. </div>
  43. <div class="stat-card">
  44. <div class="stat-value">{{ dispatchedCount }}</div>
  45. <div class="stat-label">已派发</div>
  46. </div>
  47. </div>
  48. <!-- 批量操作栏 -->
  49. <div class="batch-bar" v-if="selectedRows.length > 0">
  50. <span>已选择 <strong>{{ selectedRows.length }}</strong> 个异常设备</span>
  51. <el-button type="primary" @click="batchDispatch">批量提交工单</el-button>
  52. <el-button @click="clearSelection">取消选择</el-button>
  53. </div>
  54. <!-- 主内容区:左侧异常设备列表 + 右侧详情 -->
  55. <div class="main-layout">
  56. <!-- 左侧异常设备列表 -->
  57. <div class="terminal-list-panel">
  58. <div class="list-header">
  59. <span>异常设备列表({{ filteredTerminals.length }} 台)</span>
  60. <el-checkbox v-model="selectAll" @change="handleSelectAll">全选</el-checkbox>
  61. </div>
  62. <div class="terminal-list" v-loading="loading">
  63. <div
  64. v-for="terminal in paginatedTerminals"
  65. :key="terminal.id"
  66. class="terminal-item"
  67. :class="{
  68. selected: selectedTerminal?.id === terminal.id,
  69. critical: terminal.abnormalLevel === 'critical',
  70. warning: terminal.abnormalLevel === 'warning'
  71. }"
  72. @click="selectTerminal(terminal)"
  73. >
  74. <el-checkbox
  75. :model-value="selectedRows.includes(terminal.id)"
  76. @change="(val) => toggleSelect(terminal.id, val)"
  77. class="select-checkbox"
  78. @click.stop
  79. />
  80. <div class="terminal-content">
  81. <div class="terminal-header">
  82. <span class="terminal-code">{{ terminal.code }}</span>
  83. <el-tag :type="terminal.abnormalLevel === 'critical' ? 'danger' : 'warning'" size="small">
  84. {{ terminal.abnormalLevel === 'critical' ? '严重' : '一般' }}
  85. </el-tag>
  86. </div>
  87. <div class="terminal-name">{{ terminal.name }}</div>
  88. <div class="terminal-location">
  89. <el-icon><Location /></el-icon> {{ terminal.locationShort }}
  90. </div>
  91. <div class="terminal-desc">{{ terminal.abnormalDesc }}</div>
  92. <div class="terminal-time">异常时间: {{ terminal.abnormalTime || '—' }}</div>
  93. <div class="terminal-footer">
  94. <el-tag v-if="terminal.workOrderSubmitted" type="success" size="small" effect="plain">已派发</el-tag>
  95. <el-tag v-else type="danger" size="small" effect="plain">待派发</el-tag>
  96. <span class="type-tag">{{ terminal.deviceTypeName }}</span>
  97. </div>
  98. </div>
  99. </div>
  100. <el-empty v-if="!loading && filteredTerminals.length === 0" description="暂无异常设备" :image-size="80" />
  101. </div>
  102. <div class="pagination-area" v-if="filteredTerminals.length > pageSize">
  103. <el-pagination
  104. small
  105. layout="prev, pager, next"
  106. :total="filteredTerminals.length"
  107. :page-size="pageSize"
  108. v-model:current-page="currentPage"
  109. />
  110. </div>
  111. </div>
  112. <!-- 右侧详情面板 -->
  113. <div class="detail-panel" v-if="selectedTerminal">
  114. <el-tabs v-model="activeTab" @tab-change="onTabChange">
  115. <!-- 设备信息 -->
  116. <el-tab-pane label="设备信息" name="info">
  117. <div class="info-section">
  118. <div class="section-title">
  119. <span><el-icon><Monitor /></el-icon> 设备基本信息</span>
  120. <el-button
  121. type="primary"
  122. size="small"
  123. @click="openDispatchDialog(selectedTerminal)"
  124. :disabled="selectedTerminal.workOrderSubmitted"
  125. >
  126. {{ selectedTerminal.workOrderSubmitted ? '已派发' : '提交工单' }}
  127. </el-button>
  128. </div>
  129. <el-descriptions :column="2" border>
  130. <el-descriptions-item label="设备编号">{{ selectedTerminal.code }}</el-descriptions-item>
  131. <el-descriptions-item label="设备名称">{{ selectedTerminal.name }}</el-descriptions-item>
  132. <el-descriptions-item label="设备类型">{{ selectedTerminal.deviceTypeName }}</el-descriptions-item>
  133. <el-descriptions-item label="设备位置">{{ selectedTerminal.location }}</el-descriptions-item>
  134. <el-descriptions-item label="异常级别">
  135. <el-tag :type="selectedTerminal.abnormalLevel === 'critical' ? 'danger' : 'warning'">
  136. {{ selectedTerminal.abnormalLevel === 'critical' ? '严重异常' : '一般异常' }}
  137. </el-tag>
  138. </el-descriptions-item>
  139. <el-descriptions-item label="派发状态">
  140. <el-tag :type="selectedTerminal.workOrderSubmitted ? 'success' : 'danger'">
  141. {{ selectedTerminal.workOrderSubmitted ? '已派发' : '待派发' }}
  142. </el-tag>
  143. </el-descriptions-item>
  144. <el-descriptions-item label="异常描述" :span="2">{{ selectedTerminal.abnormalDesc }}</el-descriptions-item>
  145. </el-descriptions>
  146. </div>
  147. <!-- 实时监测数据 -->
  148. <div class="info-section">
  149. <div class="section-title"><el-icon><DataAnalysis /></el-icon> 实时监测数据</div>
  150. <div class="monitor-grid">
  151. <div class="monitor-item" :class="{ alert: selectedTerminal.status.tilt }">
  152. <div class="monitor-label">倾斜角度</div>
  153. <div class="monitor-value">{{ selectedTerminal.tiltAngle }}°</div>
  154. <div class="monitor-extra">阈值: {{ selectedTerminal.threshold }}°</div>
  155. </div>
  156. <div class="monitor-item" :class="{ alert: selectedTerminal.status.waterInfiltration }">
  157. <div class="monitor-label">水浸状态</div>
  158. <div class="monitor-value">{{ selectedTerminal.status.waterInfiltration ? '报警' : '正常' }}</div>
  159. </div>
  160. <div class="monitor-item" :class="{ alert: selectedTerminal.status.waterLevel }">
  161. <div class="monitor-label">水位状态</div>
  162. <div class="monitor-value">{{ selectedTerminal.status.waterLevel ? '超标' : '正常' }}</div>
  163. </div>
  164. <div class="monitor-item" :class="{ alert: selectedTerminal.status.lowBattery }">
  165. <div class="monitor-label">电量</div>
  166. <div class="monitor-value">{{ selectedTerminal.battery }}%</div>
  167. </div>
  168. <div class="monitor-item" :class="{ alert: selectedTerminal.status.lowSignal }">
  169. <div class="monitor-label">信号强度</div>
  170. <div class="monitor-value">{{ selectedTerminal.signal }}%</div>
  171. </div>
  172. <div class="monitor-item">
  173. <div class="monitor-label">温度</div>
  174. <div class="monitor-value">{{ selectedTerminal.temperature || '—' }}°C</div>
  175. </div>
  176. </div>
  177. <div class="last-upload">最后上传时间: {{ selectedTerminal.lastTime || '—' }}</div>
  178. </div>
  179. <!-- 位置信息 -->
  180. <div class="location-section">
  181. <div class="section-title"><el-icon><MapLocation /></el-icon> 设备位置信息</div>
  182. <div class="location-info">
  183. <el-icon :size="20" color="#f56c6c"><Location /></el-icon>
  184. <span>{{ selectedTerminal.location }}</span>
  185. <span v-if="selectedTerminal.lng" class="coords">(经度: {{ selectedTerminal.lng }}, 纬度: {{ selectedTerminal.lat }})</span>
  186. </div>
  187. </div>
  188. </el-tab-pane>
  189. <!-- 处置记录 -->
  190. <el-tab-pane label="处置记录" name="history">
  191. <div class="history-section">
  192. <div class="section-title"><el-icon><Document /></el-icon> 工单处置记录</div>
  193. <div v-loading="loadingOrders">
  194. <el-timeline v-if="workOrders.length">
  195. <el-timeline-item
  196. v-for="order in workOrders"
  197. :key="order.orderId"
  198. :timestamp="order.createTime || ''"
  199. :type="Number(order.orderStatus) >= 6 ? 'success' : 'primary'"
  200. >
  201. <div class="record-content">
  202. <div class="record-row"><strong>工单编号:</strong> {{ order.orderNo || '—' }}</div>
  203. <div class="record-row"><strong>工单类型:</strong> {{ orderTypeText(order.orderType) }}</div>
  204. <div class="record-row"><strong>优先级:</strong>
  205. <el-tag :type="orderLevelTagType(order.orderLevel)" size="small">{{ priorityText(order.orderLevel) }}</el-tag>
  206. </div>
  207. <div class="record-row"><strong>状态:</strong>
  208. <el-tag :type="orderStatusTagType(order.orderStatus)" size="small">{{ orderStatusText(order.orderStatus) }}</el-tag>
  209. </div>
  210. <div class="record-row" v-if="order.orderDesc"><strong>描述:</strong> {{ order.orderDesc }}</div>
  211. </div>
  212. </el-timeline-item>
  213. </el-timeline>
  214. <el-empty v-else description="暂无工单处置记录" :image-size="80" />
  215. </div>
  216. </div>
  217. </el-tab-pane>
  218. </el-tabs>
  219. </div>
  220. <!-- 未选中状态 -->
  221. <div v-else class="empty-panel">
  222. <el-empty description="请从左侧选择异常设备查看详情" :image-size="140" />
  223. </div>
  224. </div>
  225. <!-- 提交工单对话框 -->
  226. <el-dialog v-model="dispatchDialogVisible" title="提交工单" width="520px" @close="dispatchForm = { orderType: 1, orderLevel: 2, orderDesc: '', owner: '' }">
  227. <el-form :model="dispatchForm" label-width="100px">
  228. <el-form-item label="设备名称">
  229. <el-input :model-value="currentDispatchTerminal?.name" disabled />
  230. </el-form-item>
  231. <el-form-item label="异常描述">
  232. <el-input type="textarea" :model-value="currentDispatchTerminal?.abnormalDesc" disabled :rows="2" />
  233. </el-form-item>
  234. <el-form-item label="工单类型" required>
  235. <el-select v-model="dispatchForm.orderType" placeholder="选择工单类型" style="width: 100%">
  236. <el-option label="故障维修" :value="1" />
  237. <el-option label="日常巡检" :value="2" />
  238. <el-option label="设备保养" :value="3" />
  239. </el-select>
  240. </el-form-item>
  241. <el-form-item label="优先级" required>
  242. <el-select v-model="dispatchForm.orderLevel" placeholder="选择优先级" style="width: 100%">
  243. <el-option label="紧急" :value="1" />
  244. <el-option label="一般" :value="2" />
  245. <el-option label="低" :value="3" />
  246. </el-select>
  247. </el-form-item>
  248. <el-form-item label="问题描述" required>
  249. <el-input type="textarea" v-model="dispatchForm.orderDesc" placeholder="填写问题描述、维修要求等" :rows="3" />
  250. </el-form-item>
  251. <el-form-item label="负责人">
  252. <el-input v-model="dispatchForm.owner" placeholder="填写负责人姓名(选填)" />
  253. </el-form-item>
  254. </el-form>
  255. <template #footer>
  256. <el-button @click="dispatchDialogVisible = false">取消</el-button>
  257. <el-button type="primary" @click="confirmDispatch" :loading="submitting">确认提交</el-button>
  258. </template>
  259. </el-dialog>
  260. <!-- 批量提交工单对话框 -->
  261. <el-dialog v-model="batchDispatchDialogVisible" title="批量提交工单" width="450px">
  262. <p>将向以下 {{ batchDispatchIds.length }} 个异常设备提交工单:</p>
  263. <div class="batch-list">
  264. <el-tag v-for="id in batchDispatchIds.slice(0, 5)" :key="id" size="small" style="margin: 4px">
  265. {{ getTerminalCode(id) }}
  266. </el-tag>
  267. <span v-if="batchDispatchIds.length > 5">等{{ batchDispatchIds.length }}个设备</span>
  268. </div>
  269. <el-form style="margin-top: 16px" label-width="80px">
  270. <el-form-item label="工单类型">
  271. <el-select v-model="batchDispatchForm.orderType" style="width: 100%">
  272. <el-option label="故障维修" :value="1" />
  273. <el-option label="日常巡检" :value="2" />
  274. <el-option label="设备保养" :value="3" />
  275. </el-select>
  276. </el-form-item>
  277. <el-form-item label="优先级">
  278. <el-select v-model="batchDispatchForm.orderLevel" style="width: 100%">
  279. <el-option label="紧急" :value="1" />
  280. <el-option label="一般" :value="2" />
  281. <el-option label="低" :value="3" />
  282. </el-select>
  283. </el-form-item>
  284. <el-form-item label="问题描述">
  285. <el-input type="textarea" v-model="batchDispatchForm.orderDesc" :rows="2" placeholder="批量工单描述" />
  286. </el-form-item>
  287. </el-form>
  288. <template #footer>
  289. <el-button @click="batchDispatchDialogVisible = false">取消</el-button>
  290. <el-button type="primary" @click="confirmBatchDispatch" :loading="submitting">确认批量提交</el-button>
  291. </template>
  292. </el-dialog>
  293. </div>
  294. </template>
  295. <script setup>
  296. import { ref, computed, onMounted } from 'vue'
  297. import { Warning, Search, Refresh, Location, Monitor, MapLocation, Document, DataAnalysis } from '@element-plus/icons-vue'
  298. import { ElMessage } from 'element-plus'
  299. import { getManholeAlarmMonitorList, addManholeWorkOrder, getManholeWorkOrderProcessingPage } from '@/api/pipeNetwork/basic'
  300. // ==================== 常量 ====================
  301. const TYPE_MAP = { '5': '供水窨井', '9': '排水窨井', '10': '燃气窨井' }
  302. const LOW_BATTERY_THRESHOLD = 15
  303. const LOW_SIGNAL_THRESHOLD = 20
  304. // ==================== 状态 ====================
  305. const loading = ref(false)
  306. const loadingOrders = ref(false)
  307. const submitting = ref(false)
  308. const devices = ref([])
  309. const searchKeyword = ref('')
  310. const deviceTypeFilter = ref('')
  311. const currentPage = ref(1)
  312. const pageSize = 8
  313. const selectedRows = ref([])
  314. const selectAll = ref(false)
  315. const selectedTerminal = ref(null)
  316. const activeTab = ref('info')
  317. const workOrders = ref([])
  318. const dispatchDialogVisible = ref(false)
  319. const currentDispatchTerminal = ref(null)
  320. const dispatchForm = ref({ orderType: 1, orderLevel: 2, orderDesc: '', owner: '' })
  321. const batchDispatchDialogVisible = ref(false)
  322. const batchDispatchIds = ref([])
  323. const batchDispatchForm = ref({ orderType: 1, orderLevel: 2, orderDesc: '' })
  324. // ==================== 数据加载 ====================
  325. async function loadDevices() {
  326. loading.value = true
  327. try {
  328. const keyword = searchKeyword.value?.trim()
  329. const res = await getManholeAlarmMonitorList({ keyword: keyword || undefined })
  330. const result = res.data || {}
  331. const allDevices = (result.devices || []).map(item => normalizeDevice(item))
  332. // 只保留有异常的设备
  333. devices.value = allDevices.filter(d => d.hasAlert)
  334. // 默认选中第一个
  335. if (devices.value.length && !selectedTerminal.value) {
  336. selectedTerminal.value = devices.value[0]
  337. } else if (devices.value.length === 0) {
  338. selectedTerminal.value = null
  339. }
  340. } catch (error) {
  341. devices.value = []
  342. const msg = error?.message || error?.msg || '未知错误'
  343. ElMessage.error('设备数据加载失败: ' + msg)
  344. } finally {
  345. loading.value = false
  346. }
  347. }
  348. async function loadWorkOrders(deviceId) {
  349. if (!deviceId) return
  350. loadingOrders.value = true
  351. try {
  352. const res = await getManholeWorkOrderProcessingPage({
  353. deviceId,
  354. pageNum: 1,
  355. pageSize: 20
  356. })
  357. workOrders.value = res.data?.records || res.rows || []
  358. } catch (error) {
  359. workOrders.value = []
  360. console.error('[operationTask] 工单记录加载失败:', error)
  361. } finally {
  362. loadingOrders.value = false
  363. }
  364. }
  365. // ==================== 设备数据规范化 ====================
  366. function normalizeDevice(raw) {
  367. const md = raw?.manholeData || {}
  368. const st = raw?.equipmentStatus || {}
  369. const typeId = raw?.equipmentTypeId || ''
  370. // 数值解析
  371. const tiltAngle = parseFloat(md.tiltAngle) || 0
  372. const angleThreshold = parseFloat(md.angleAlarmThreshold) || 15
  373. const battery = parseFloat(md.batteryLevel) || 0
  374. const signal = parseFloat(md.signalStrength) || 0
  375. const temperature = md.temperatureValue ? parseFloat(md.temperatureValue).toFixed(1) : null
  376. const isOnline = Number(st.onlineStatus) === 1
  377. // 异常判定
  378. const tilt = isOnline && tiltAngle > angleThreshold
  379. const waterInfiltration = isOnline && `${md.waterInfiltrationAlarmStatus}` === '1'
  380. const waterLevel = isOnline && `${md.waterLevelAlarmStatus}` === '1'
  381. const lowBattery = battery > 0 && battery <= LOW_BATTERY_THRESHOLD
  382. const lowSignal = signal > 0 && signal <= LOW_SIGNAL_THRESHOLD
  383. // 告警等级
  384. const isCritical = tilt || waterInfiltration || waterLevel
  385. const isWarning = !isCritical && (lowBattery || lowSignal)
  386. const hasAlert = isCritical || isWarning
  387. const alertLevel = isCritical ? 'critical' : (isWarning ? 'warning' : null)
  388. // 异常描述
  389. const parts = []
  390. if (tilt) parts.push(`倾斜角度${tiltAngle}°超过阈值${angleThreshold}°`)
  391. if (waterInfiltration) parts.push('水浸报警')
  392. if (waterLevel) parts.push('水位超标报警')
  393. if (lowBattery) parts.push(`电量仅剩${battery}%`)
  394. if (lowSignal) parts.push(`信号强度${signal}%`)
  395. // 推荐工单类型
  396. let suggestedOrderType = 1
  397. if (lowBattery) suggestedOrderType = 3
  398. else if (lowSignal) suggestedOrderType = 2
  399. return {
  400. id: raw?.equipmentId || raw?.equipmentCode || '',
  401. code: raw?.equipmentCode || raw?.equipmentId || '',
  402. equipmentId: raw?.equipmentId || '',
  403. name: raw?.equipmentName || raw?.equipmentCode || '未命名设备',
  404. typeId,
  405. deviceTypeName: TYPE_MAP[typeId] || raw?.equipmentTypeName || '未知类型',
  406. location: raw?.equipmentLocation || '暂无位置信息',
  407. locationShort: (raw?.equipmentLocation || '暂无位置').slice(0, 20),
  408. lng: raw?.longitude ?? null,
  409. lat: raw?.latitude ?? null,
  410. battery,
  411. signal,
  412. temperature,
  413. tiltAngle,
  414. threshold: angleThreshold,
  415. status: { tilt, waterInfiltration, waterLevel, lowBattery, lowSignal },
  416. hasAlert,
  417. alertLevel,
  418. abnormalLevel: alertLevel,
  419. abnormalDesc: parts.join(';') || '设备状态正常',
  420. abnormalTime: md.uploadTime || md.createTime || '',
  421. lastTime: md.uploadTime || md.createTime || '',
  422. workOrderSubmitted: !!raw?.workOrderSubmitted,
  423. suggestedOrderType
  424. }
  425. }
  426. // ==================== 计算属性 ====================
  427. const filteredTerminals = computed(() => {
  428. let list = devices.value
  429. if (searchKeyword.value) {
  430. const kw = searchKeyword.value.toLowerCase()
  431. list = list.filter(t =>
  432. t.code.toLowerCase().includes(kw) ||
  433. t.name.toLowerCase().includes(kw) ||
  434. t.location.toLowerCase().includes(kw)
  435. )
  436. }
  437. if (deviceTypeFilter.value) {
  438. list = list.filter(t => t.typeId === deviceTypeFilter.value)
  439. }
  440. return list
  441. })
  442. const paginatedTerminals = computed(() => {
  443. const start = (currentPage.value - 1) * pageSize
  444. return filteredTerminals.value.slice(start, start + pageSize)
  445. })
  446. const abnormalCount = computed(() => devices.value.length)
  447. const criticalCount = computed(() => devices.value.filter(t => t.abnormalLevel === 'critical').length)
  448. const warningCount = computed(() => devices.value.filter(t => t.abnormalLevel === 'warning').length)
  449. const pendingCount = computed(() => devices.value.filter(t => !t.workOrderSubmitted).length)
  450. const dispatchedCount = computed(() => devices.value.filter(t => t.workOrderSubmitted).length)
  451. // ==================== 辅助函数 ====================
  452. const orderTypeText = (t) => ({ 1: '故障维修', 2: '日常巡检', 3: '设备保养' }[t] || '—')
  453. const priorityText = (l) => ({ 1: '紧急', 2: '一般', 3: '低' }[l] || '—')
  454. const orderStatusText = (s) => ({
  455. 1: '待派单', 2: '待接单', 3: '已接单', 4: '处理中',
  456. 5: '待验收', 6: '已完成', 7: '已关闭', 8: '延期审核', 9: '已归档'
  457. }[s] || '—')
  458. const orderLevelTagType = (l) => ({ 1: 'danger', 2: 'warning', 3: 'info' }[l] || 'info')
  459. const orderStatusTagType = (s) => {
  460. const n = Number(s)
  461. if (n >= 6) return 'success'
  462. if (n >= 3) return 'primary'
  463. if (n >= 1) return 'warning'
  464. return 'info'
  465. }
  466. // ==================== 选择操作 ====================
  467. const handleSelectAll = (val) => {
  468. if (val) {
  469. selectedRows.value = paginatedTerminals.value.map(t => t.id)
  470. } else {
  471. selectedRows.value = []
  472. }
  473. }
  474. const toggleSelect = (id, val) => {
  475. if (val) {
  476. if (!selectedRows.value.includes(id)) selectedRows.value.push(id)
  477. } else {
  478. selectedRows.value = selectedRows.value.filter(i => i !== id)
  479. }
  480. selectAll.value = paginatedTerminals.value.length > 0 &&
  481. paginatedTerminals.value.every(t => selectedRows.value.includes(t.id))
  482. }
  483. const clearSelection = () => {
  484. selectedRows.value = []
  485. selectAll.value = false
  486. }
  487. const selectTerminal = (terminal) => {
  488. selectedTerminal.value = terminal
  489. if (activeTab.value === 'history') {
  490. loadWorkOrders(terminal.equipmentId)
  491. }
  492. }
  493. const onTabChange = (tabName) => {
  494. if (tabName === 'history' && selectedTerminal.value) {
  495. loadWorkOrders(selectedTerminal.value.equipmentId)
  496. }
  497. }
  498. const getTerminalCode = (id) => {
  499. const terminal = devices.value.find(t => t.id === id)
  500. return terminal?.code || id
  501. }
  502. // ==================== 提交工单 ====================
  503. const openDispatchDialog = (terminal) => {
  504. currentDispatchTerminal.value = terminal
  505. dispatchForm.value = {
  506. orderType: terminal.suggestedOrderType || 1,
  507. orderLevel: terminal.abnormalLevel === 'critical' ? 1 : 2,
  508. orderDesc: terminal.abnormalDesc || '',
  509. owner: ''
  510. }
  511. dispatchDialogVisible.value = true
  512. }
  513. const confirmDispatch = async () => {
  514. if (!dispatchForm.value.orderDesc?.trim()) {
  515. ElMessage.warning('请填写问题描述')
  516. return
  517. }
  518. if (!currentDispatchTerminal.value?.equipmentId) {
  519. ElMessage.warning('设备信息缺失,无法提交工单')
  520. return
  521. }
  522. submitting.value = true
  523. try {
  524. const terminal = currentDispatchTerminal.value
  525. const desc = dispatchForm.value.owner
  526. ? `${dispatchForm.value.orderDesc}(负责人:${dispatchForm.value.owner})`
  527. : dispatchForm.value.orderDesc
  528. await addManholeWorkOrder({
  529. deviceId: terminal.equipmentId,
  530. orderType: dispatchForm.value.orderType,
  531. orderLevel: dispatchForm.value.orderLevel,
  532. orderDesc: desc
  533. })
  534. // 标记已派发
  535. terminal.workOrderSubmitted = true
  536. ElMessage.success('工单已提交,相关部门将即刻处理')
  537. dispatchDialogVisible.value = false
  538. // 如果在处置记录tab,刷新工单列表
  539. if (activeTab.value === 'history') {
  540. loadWorkOrders(terminal.equipmentId)
  541. }
  542. } catch (error) {
  543. const msg = error?.message || error?.msg || '未知错误'
  544. ElMessage.error('工单提交失败: ' + msg)
  545. } finally {
  546. submitting.value = false
  547. }
  548. }
  549. // ==================== 批量提交工单 ====================
  550. const batchDispatch = () => {
  551. if (selectedRows.value.length === 0) {
  552. ElMessage.warning('请先选择异常设备')
  553. return
  554. }
  555. batchDispatchIds.value = [...selectedRows.value]
  556. batchDispatchForm.value = { orderType: 1, orderLevel: 2, orderDesc: '' }
  557. batchDispatchDialogVisible.value = true
  558. }
  559. const confirmBatchDispatch = async () => {
  560. if (!batchDispatchForm.value.orderDesc?.trim()) {
  561. ElMessage.warning('请填写问题描述')
  562. return
  563. }
  564. submitting.value = true
  565. try {
  566. let successCount = 0
  567. let failCount = 0
  568. for (const id of batchDispatchIds.value) {
  569. const terminal = devices.value.find(t => t.id === id)
  570. if (!terminal || terminal.workOrderSubmitted) continue
  571. try {
  572. await addManholeWorkOrder({
  573. deviceId: terminal.equipmentId,
  574. orderType: batchDispatchForm.value.orderType,
  575. orderLevel: batchDispatchForm.value.orderLevel,
  576. orderDesc: batchDispatchForm.value.orderDesc
  577. })
  578. terminal.workOrderSubmitted = true
  579. successCount++
  580. } catch {
  581. failCount++
  582. }
  583. }
  584. if (successCount > 0) {
  585. ElMessage.success(`成功提交 ${successCount} 个工单${failCount > 0 ? `,${failCount}个失败` : ''}`)
  586. } else {
  587. ElMessage.error('批量提交工单全部失败')
  588. }
  589. batchDispatchDialogVisible.value = false
  590. clearSelection()
  591. } catch (error) {
  592. const msg = error?.message || error?.msg || '未知错误'
  593. ElMessage.error('批量提交失败: ' + msg)
  594. } finally {
  595. submitting.value = false
  596. }
  597. }
  598. // ==================== 生命周期 ====================
  599. onMounted(() => {
  600. loadDevices()
  601. })
  602. </script>
  603. <style scoped>
  604. .terminal-dispatch {
  605. background: #f0f2f6;
  606. min-height: 100vh;
  607. padding: 20px;
  608. }
  609. .page-header {
  610. background: white;
  611. border-radius: 16px;
  612. padding: 16px 24px;
  613. margin-bottom: 20px;
  614. display: flex;
  615. justify-content: space-between;
  616. align-items: center;
  617. flex-wrap: wrap;
  618. gap: 12px;
  619. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
  620. }
  621. .header-left {
  622. display: flex;
  623. align-items: center;
  624. gap: 12px;
  625. }
  626. .header-left .title {
  627. font-size: 20px;
  628. font-weight: 600;
  629. color: #1f2d3d;
  630. }
  631. .header-right {
  632. display: flex;
  633. gap: 12px;
  634. align-items: center;
  635. flex-wrap: wrap;
  636. }
  637. .stats-cards {
  638. display: flex;
  639. gap: 16px;
  640. margin-bottom: 20px;
  641. flex-wrap: wrap;
  642. }
  643. .stat-card {
  644. background: white;
  645. border-radius: 12px;
  646. padding: 12px 24px;
  647. text-align: center;
  648. min-width: 110px;
  649. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
  650. }
  651. .stat-card.critical .stat-value { color: #f56c6c; }
  652. .stat-card.warning .stat-value { color: #e6a23c; }
  653. .stat-value { font-size: 28px; font-weight: bold; }
  654. .stat-label { font-size: 13px; color: #606266; margin-top: 4px; }
  655. .batch-bar {
  656. background: #ecf5ff;
  657. border-radius: 12px;
  658. padding: 12px 20px;
  659. margin-bottom: 20px;
  660. display: flex;
  661. align-items: center;
  662. gap: 16px;
  663. }
  664. .main-layout {
  665. display: flex;
  666. gap: 20px;
  667. }
  668. .terminal-list-panel {
  669. width: 420px;
  670. background: white;
  671. border-radius: 16px;
  672. display: flex;
  673. flex-direction: column;
  674. overflow: hidden;
  675. }
  676. .list-header {
  677. padding: 16px;
  678. border-bottom: 1px solid #e9ecef;
  679. display: flex;
  680. justify-content: space-between;
  681. font-weight: 600;
  682. }
  683. .terminal-list {
  684. flex: 1;
  685. overflow-y: auto;
  686. padding: 12px;
  687. max-height: calc(100vh - 280px);
  688. }
  689. .terminal-item {
  690. display: flex;
  691. align-items: flex-start;
  692. gap: 10px;
  693. padding: 12px;
  694. margin-bottom: 10px;
  695. border-radius: 12px;
  696. background: #fafbfc;
  697. border: 1px solid #e9ecef;
  698. cursor: pointer;
  699. transition: all 0.2s;
  700. }
  701. .terminal-item.selected {
  702. background: #ecf5ff;
  703. border-color: #409eff;
  704. }
  705. .terminal-item.critical { border-left: 3px solid #f56c6c; }
  706. .terminal-item.warning { border-left: 3px solid #e6a23c; }
  707. .select-checkbox { margin-top: 2px; }
  708. .terminal-content { flex: 1; }
  709. .terminal-header {
  710. display: flex;
  711. justify-content: space-between;
  712. margin-bottom: 4px;
  713. }
  714. .terminal-code { font-weight: 600; font-size: 14px; }
  715. .terminal-name { font-size: 13px; color: #303133; margin-bottom: 4px; }
  716. .terminal-location { font-size: 12px; color: #6c757d; display: flex; align-items: center; gap: 4px; margin-bottom: 4px; }
  717. .terminal-desc { font-size: 12px; color: #f56c6c; margin-bottom: 4px; }
  718. .terminal-time { font-size: 11px; color: #aaa; }
  719. .terminal-footer {
  720. display: flex;
  721. justify-content: space-between;
  722. align-items: center;
  723. margin-top: 6px;
  724. }
  725. .type-tag {
  726. font-size: 11px;
  727. color: #909399;
  728. }
  729. .detail-panel {
  730. flex: 1;
  731. background: white;
  732. border-radius: 16px;
  733. padding: 20px;
  734. overflow-y: auto;
  735. max-height: calc(100vh - 160px);
  736. }
  737. .empty-panel {
  738. flex: 1;
  739. display: flex;
  740. align-items: center;
  741. justify-content: center;
  742. background: white;
  743. border-radius: 16px;
  744. }
  745. .section-title {
  746. font-weight: 600;
  747. font-size: 16px;
  748. margin: 16px 0 12px 0;
  749. display: flex;
  750. justify-content: space-between;
  751. align-items: center;
  752. }
  753. .info-section { margin-bottom: 20px; }
  754. .monitor-grid {
  755. display: grid;
  756. grid-template-columns: repeat(3, 1fr);
  757. gap: 12px;
  758. }
  759. .monitor-item {
  760. background: #f5f7fa;
  761. border-radius: 8px;
  762. padding: 12px;
  763. text-align: center;
  764. border: 1px solid #e9ecef;
  765. }
  766. .monitor-item.alert {
  767. background: #fef0f0;
  768. border-color: #f56c6c;
  769. }
  770. .monitor-label { font-size: 12px; color: #909399; margin-bottom: 4px; }
  771. .monitor-value { font-size: 18px; font-weight: 600; color: #303133; }
  772. .monitor-item.alert .monitor-value { color: #f56c6c; }
  773. .monitor-extra { font-size: 11px; color: #aaa; margin-top: 2px; }
  774. .last-upload {
  775. margin-top: 10px;
  776. font-size: 12px;
  777. color: #909399;
  778. }
  779. .location-section { margin-bottom: 20px; }
  780. .location-info {
  781. display: flex;
  782. align-items: center;
  783. gap: 8px;
  784. background: #f5f7fa;
  785. border-radius: 8px;
  786. padding: 16px;
  787. font-size: 14px;
  788. color: #303133;
  789. }
  790. .location-info .coords { color: #909399; font-size: 12px; }
  791. .record-content { font-size: 13px; }
  792. .record-row { margin-bottom: 4px; }
  793. .pagination-area { padding: 12px; text-align: center; border-top: 1px solid #eee; }
  794. .batch-list { max-height: 150px; overflow-y: auto; }
  795. </style>