Pworkdisposition.vue 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  1. <!--全部工单处置(参考排水/燃气工单:搜索卡 + 左列表 + 右详情 tabs + 按钮操作 + 部门树派单)-->
  2. <!--数据源:/api/work-order/*(equipmentType 为空 = 查询所有设备工单)-->
  3. <template>
  4. <div class="order-container">
  5. <!-- 顶部搜索区(参考排水/燃气工单) -->
  6. <el-card class="search-card" shadow="never">
  7. <div class="search-form">
  8. <el-form :model="queryParams" ref="queryFormRef" inline>
  9. <el-form-item label="类型">
  10. <el-select v-model="queryParams.orderType" placeholder="全部类型" clearable style="width: 150px">
  11. <el-option v-for="(v, k) in ORDER_TYPE_MAP" :key="k" :label="v.text" :value="Number(k)"/>
  12. </el-select>
  13. </el-form-item>
  14. <el-form-item label="优先级">
  15. <el-select v-model="queryParams.orderLevel" placeholder="全部优先级" clearable style="width: 120px">
  16. <el-option v-for="(v, k) in ORDER_LEVEL_MAP" :key="k" :label="v.text" :value="Number(k)"/>
  17. </el-select>
  18. </el-form-item>
  19. <el-form-item label="状态">
  20. <el-select v-model="queryParams.orderStatus" placeholder="全部状态" clearable style="width: 120px">
  21. <el-option v-for="(v, k) in ORDER_STATUS_MAP" :key="k" :label="v.text" :value="Number(k)"/>
  22. </el-select>
  23. </el-form-item>
  24. <el-form-item label="关键词">
  25. <el-input v-model="queryParams.keyword" placeholder="工单编号/设备编码" clearable style="width: 200px" @keyup.enter="handleSearch"/>
  26. </el-form-item>
  27. <el-form-item>
  28. <el-button type="primary" @click="handleSearch">查询</el-button>
  29. <el-button @click="handleReset">重置</el-button>
  30. </el-form-item>
  31. </el-form>
  32. </div>
  33. </el-card>
  34. <!-- 主容器:左列表 + 右详情(参考排水/燃气工单) -->
  35. <el-row :gutter="16" class="main-row">
  36. <!-- 左侧:工单列表 -->
  37. <el-col :span="8" class="col-fill">
  38. <el-card class="box-card" shadow="always">
  39. <template #header>
  40. <div class="card-header">
  41. <span>工单列表</span>
  42. </div>
  43. </template>
  44. <el-scrollbar class="list-scroll">
  45. <div class="order-list">
  46. <div
  47. v-for="order in tableData"
  48. :key="order.orderId"
  49. class="order-item"
  50. :class="{ 'active': selectedOrderId === order.orderId }"
  51. @click="handleSelectOrder(order)"
  52. >
  53. <div class="order-header">
  54. <span class="order-no">{{ order.orderNo }}</span>
  55. <el-tag :type="orderStatusType(order.orderStatus)" size="small" effect="light" class="status-badge">{{ orderStatusText(order.orderStatus) }}</el-tag>
  56. </div>
  57. <div class="order-body">
  58. <div class="order-tags">
  59. <el-tag :type="orderLevelType(order.orderLevel)" size="small" effect="plain">{{ orderLevelText(order.orderLevel) }}</el-tag>
  60. <el-tag :type="orderTypeTagType(order.orderType)" size="small" effect="plain">{{ orderTypeText(order.orderType) }}</el-tag>
  61. </div>
  62. <div class="order-desc" v-if="order.orderDesc">{{ order.orderDesc }}</div>
  63. <div class="device-code">设备编号:{{ order.deviceCode || '-' }}</div>
  64. <div class="device-code" v-if="order.hazardNo">关联隐患:{{ order.hazardNo }}</div>
  65. <div class="create-time">创建:{{ timeText(order.createTime) }}</div>
  66. </div>
  67. </div>
  68. <el-empty v-if="tableData.length === 0" description="暂无数据" :image-size="100"/>
  69. </div>
  70. </el-scrollbar>
  71. <div class="pagination-wrapper">
  72. <el-pagination
  73. v-model:current-page="pageNum"
  74. v-model:page-size="pageSize"
  75. :total="total"
  76. layout="total, prev, pager, next"
  77. :page-sizes="[10, 20, 50, 100]"
  78. @size-change="handleSizeChange"
  79. @current-change="handlePageChange"
  80. class="pagination"
  81. />
  82. </div>
  83. </el-card>
  84. </el-col>
  85. <!-- 右侧:工单详情 -->
  86. <el-col :span="16" class="col-fill">
  87. <el-card v-loading="detailLoading" class="box-card" shadow="always">
  88. <template #header>
  89. <div class="card-header">
  90. <span v-if="selectedOrder && selectedOrder.orderId">工单详情 - {{ selectedOrder.orderNo }}</span>
  91. <span v-else>请选择工单查看详情</span>
  92. </div>
  93. </template>
  94. <div v-if="selectedOrder && selectedOrder.orderId" class="detail-body">
  95. <!-- 标签页(参考排水/燃气工单) -->
  96. <el-tabs v-model="activeTab" class="detail-tabs">
  97. <!-- 工单信息 -->
  98. <el-tab-pane label="工单信息" name="info">
  99. <el-descriptions :column="2" border size="small">
  100. <el-descriptions-item label="工单编号">{{ selectedOrder.orderNo }}</el-descriptions-item>
  101. <el-descriptions-item label="工单状态">
  102. <el-tag :type="orderStatusType(selectedOrder.orderStatus)" size="small">{{ orderStatusText(selectedOrder.orderStatus) }}</el-tag>
  103. </el-descriptions-item>
  104. <el-descriptions-item label="关联案件编号">{{ selectedOrder.caseNo || '-' }}</el-descriptions-item>
  105. <el-descriptions-item label="关联隐患编号">{{ selectedOrder.hazardNo || '-' }}</el-descriptions-item>
  106. <el-descriptions-item label="设备编号">{{ selectedOrder.deviceCode || '-' }}</el-descriptions-item>
  107. <el-descriptions-item label="工单类型">{{ orderTypeText(selectedOrder.orderType) }}</el-descriptions-item>
  108. <el-descriptions-item label="优先级">
  109. <el-tag :type="orderLevelType(selectedOrder.orderLevel)" size="small">{{ orderLevelText(selectedOrder.orderLevel) }}</el-tag>
  110. </el-descriptions-item>
  111. <el-descriptions-item label="接单人">{{ userNames(selectedOrder.receiveUser) || '-' }}</el-descriptions-item>
  112. <el-descriptions-item label="问题描述" :span="2">{{ selectedOrder.orderDesc || '-' }}</el-descriptions-item>
  113. <el-descriptions-item label="派单时间">{{ timeText(selectedOrder.dispatchTime) }}</el-descriptions-item>
  114. <el-descriptions-item label="计划完成">{{ timeText(selectedOrder.planFinishTime) }}</el-descriptions-item>
  115. <el-descriptions-item label="实际完成">{{ timeText(selectedOrder.realFinishTime) }}</el-descriptions-item>
  116. <el-descriptions-item label="验收时间">{{ timeText(selectedOrder.verifyTime) }}</el-descriptions-item>
  117. <el-descriptions-item label="创建时间">{{ timeText(selectedOrder.createTime) }}</el-descriptions-item>
  118. <el-descriptions-item label="更新时间">{{ timeText(selectedOrder.updateTime) }}</el-descriptions-item>
  119. <el-descriptions-item label="处置结果" :span="2">{{ selectedOrder.repairResult || '-' }}</el-descriptions-item>
  120. </el-descriptions>
  121. <!-- 关联设备信息(需求 5.2.4.4.1 设备维度:类型/位置/厂家/维护人员/联系方式) -->
  122. <div v-if="detailDevice" class="device-section">
  123. <h4 class="section-title">🔗 关联设备</h4>
  124. <el-descriptions :column="2" border size="small">
  125. <el-descriptions-item label="设备类型">{{ detailDevice.equipmentTypeName || '-' }}</el-descriptions-item>
  126. <el-descriptions-item label="设备编码">{{ detailDevice.equipmentCode || '-' }}</el-descriptions-item>
  127. <el-descriptions-item label="安装位置">{{ detailDevice.equipmentLocation || '-' }}</el-descriptions-item>
  128. <el-descriptions-item label="管点地址">{{ detailDevice.pointAddress || '-' }}</el-descriptions-item>
  129. <el-descriptions-item label="设备厂家">{{ detailDevice.manufacturer || '-' }}</el-descriptions-item>
  130. <el-descriptions-item label="所属管线">{{ detailDevice.networkName || '-' }}</el-descriptions-item>
  131. <el-descriptions-item label="维护人员">{{ detailDevice.maintainer || '-' }}</el-descriptions-item>
  132. <el-descriptions-item label="联系方式">{{ detailDevice.maintainerPhone || '-' }}</el-descriptions-item>
  133. </el-descriptions>
  134. </div>
  135. <RepairImagePanel :before-images="selectedOrder.maintenanceBeforeImg" :after-images="selectedOrder.maintenanceAfterImg" />
  136. <!-- 工单操作(参考排水/燃气工单按钮组) -->
  137. <div class="action-section">
  138. <h4 class="section-title">工单操作</h4>
  139. <div class="action-buttons">
  140. <el-button v-if="canOperate.dispatch(selectedOrder)" type="primary" @click="handleDispatch">下派任务</el-button>
  141. <el-button v-if="canOperate.verify(selectedOrder)" type="warning" @click="handleVerify">核查</el-button>
  142. <el-button v-if="canOperate.process(selectedOrder)" type="success" @click="handleProcess">处理</el-button>
  143. <el-button v-if="canOperate.complete(selectedOrder)" type="danger" @click="handleComplete">提交验收</el-button>
  144. <el-button v-if="canOperate.close(selectedOrder)" type="success" @click="handleClose">验收通过</el-button>
  145. <el-button v-if="canOperate.cancel(selectedOrder)" type="info" plain @click="handleCancel">取消工单</el-button>
  146. </div>
  147. </div>
  148. </el-tab-pane>
  149. <!-- 处理历史 -->
  150. <el-tab-pane label="处理历史" name="history">
  151. <div class="timeline-scroll">
  152. <el-timeline v-if="currentOrderLogs && currentOrderLogs.length > 0">
  153. <el-timeline-item
  154. v-for="log in currentOrderLogs"
  155. :key="log.logId || log.id"
  156. :timestamp="timeText(log.operTime)"
  157. placement="top"
  158. :type="orderLogType(log.operType)"
  159. size="large"
  160. >
  161. <el-card shadow="hover">
  162. <div class="timeline-content">
  163. <div class="timestamp">{{ timeText(log.operTime) }}</div>
  164. <div class="operator-info">
  165. <span class="operator-name">{{ log.operUserName || '-' }}</span>
  166. <span class="oper-type">· {{ orderLogText(log.operType) }}</span>
  167. </div>
  168. <div class="oper-desc">{{ log.operDesc || '-' }}</div>
  169. </div>
  170. </el-card>
  171. </el-timeline-item>
  172. </el-timeline>
  173. <el-empty v-else description="暂无处理记录" :image-size="100"/>
  174. </div>
  175. </el-tab-pane>
  176. </el-tabs>
  177. </div>
  178. <el-empty v-else description="请从左侧选择工单" :image-size="150"/>
  179. </el-card>
  180. </el-col>
  181. </el-row>
  182. <!-- 派单弹窗(部门树 + 用户多选,参考排水/燃气工单) -->
  183. <el-dialog v-model="dispatchDialogVisible" title="下派任务 - 选择接单人" width="750px" append-to-body>
  184. <div style="display: flex; gap: 16px; min-height: 350px;">
  185. <!-- 左侧:部门树 -->
  186. <div style="width: 280px; border-right: 1px solid #e4e7ed; padding-right: 12px;">
  187. <el-input v-model="deptFilterText" placeholder="筛选部门" clearable size="small" style="margin-bottom: 8px"/>
  188. <el-tree
  189. ref="deptTreeRef"
  190. :data="deptTreeData"
  191. :props="{ label: 'deptName', children: 'children' }"
  192. :filter-node-method="filterDeptNode"
  193. node-key="deptId"
  194. highlight-current
  195. @node-click="handleDeptClick"
  196. style="font-size: 14px"
  197. />
  198. </div>
  199. <!-- 右侧:用户列表 -->
  200. <div style="flex: 1; min-width: 0;">
  201. <el-table
  202. ref="userTableRef"
  203. :data="userList"
  204. border size="small"
  205. max-height="300"
  206. row-key="userId"
  207. @selection-change="handleSelectionChange"
  208. @row-click="handleRowClick"
  209. >
  210. <el-table-column type="selection" width="40" align="center" :reserve-selection="true"/>
  211. <el-table-column prop="userName" label="用户名" width="100"/>
  212. <el-table-column prop="nickName" label="昵称" width="100"/>
  213. <el-table-column prop="dept.deptName" label="部门" show-overflow-tooltip/>
  214. </el-table>
  215. <div v-if="userList.length === 0" style="text-align: center; color: #909399; padding: 40px 0;">请先在左侧选择部门</div>
  216. </div>
  217. </div>
  218. <el-form :model="dispatchForm" label-width="80px" style="margin-top: 16px">
  219. <el-form-item label="备注"><el-input v-model="dispatchForm.remarks" placeholder="请输入派单备注"/></el-form-item>
  220. </el-form>
  221. <template #footer>
  222. <span style="float: left; line-height: 32px; color: #606266;">
  223. 已选人员:
  224. <span v-if="selectedUserNames.length > 0" style="color: #409eff; font-weight: bold;">{{ selectedUserNames.join('、') }}</span>
  225. <span v-else style="color: #909399">未选择</span>
  226. ({{ dispatchForm.handlerIds.length }})
  227. </span>
  228. <el-button @click="dispatchDialogVisible = false">取消</el-button>
  229. <el-button type="primary" :loading="submitting" @click="submitDispatch">确认</el-button>
  230. </template>
  231. </el-dialog>
  232. <!-- 核查/处理弹窗(参考排水/燃气工单) -->
  233. <el-dialog v-model="actionDialogVisible" :title="actionTitle" width="600px" append-to-body>
  234. <el-form label-width="90px">
  235. <el-form-item v-if="actionTitle === '处理工单'" label="处理前照片">
  236. <ImageUpload v-model="processBeforeImages" :limit="9" :isShowTip="true" :upload-url="imageUploadUrl" />
  237. </el-form-item>
  238. <el-form-item label="结果说明" required>
  239. <el-input v-model="actionFormData.solution" type="textarea" :rows="5" :placeholder="actionTitle === '核查工单' ? '请输入核查结论(属实/不属实、现场情况)' : '请输入故障原因、处理结果及处理措施'" maxlength="500" show-word-limit/>
  240. </el-form-item>
  241. </el-form>
  242. <template #footer>
  243. <el-button @click="actionDialogVisible = false">取消</el-button>
  244. <el-button type="primary" :loading="submitting" @click="submitAction">确定</el-button>
  245. </template>
  246. </el-dialog>
  247. <!-- 提交验收弹窗 -->
  248. <el-dialog v-model="completeDialogVisible" title="提交验收" width="600px" append-to-body>
  249. <el-form label-width="80px">
  250. <el-form-item label="维修后照片">
  251. <ImageUpload v-model="maintenanceAfterImages" :limit="9" :isShowTip="true" :upload-url="imageUploadUrl" />
  252. </el-form-item>
  253. <el-form-item label="验收意见">
  254. <el-input v-model="completeForm.result" type="textarea" :rows="4" placeholder="请填写维修处置结果说明" maxlength="500" show-word-limit/>
  255. </el-form-item>
  256. </el-form>
  257. <template #footer>
  258. <el-button @click="completeDialogVisible = false">取消</el-button>
  259. <el-button type="primary" :loading="submitting" @click="submitComplete">提交</el-button>
  260. </template>
  261. </el-dialog>
  262. <!-- 验收通过/结案弹窗 -->
  263. <el-dialog v-model="closeDialogVisible" title="验收通过" width="500px" append-to-body>
  264. <el-form label-width="80px">
  265. <el-form-item label="验收结果">
  266. <el-input v-model="closeForm.result" type="textarea" :rows="4" placeholder="请填写验收结果(结案后关联隐患同步为已整改、设备报警状态重置)" maxlength="500" show-word-limit/>
  267. </el-form-item>
  268. </el-form>
  269. <template #footer>
  270. <el-button @click="closeDialogVisible = false">取消</el-button>
  271. <el-button type="success" :loading="submitting" @click="submitClose">确认验收</el-button>
  272. </template>
  273. </el-dialog>
  274. </div>
  275. </template>
  276. <script setup>
  277. import { ref, reactive, computed, watch, onMounted } from 'vue'
  278. import { ElMessage, ElMessageBox } from 'element-plus'
  279. import RepairImagePanel from '@/components/RepairImagePanel'
  280. import ImagePreview from '@/components/ImagePreview'
  281. import { listWorkOrder, getWorkOrderDetail, dispatchWorkOrder, verifyWorkOrder, processWorkOrder, completeWorkOrder, closeWorkOrder, cancelWorkOrder } from '@/api/work/order'
  282. import { getEquipmentFullPage } from '@/api/pipeNetwork/basic'
  283. import { listDept } from '@/api/system/dept'
  284. import { listUser } from '@/api/system/user'
  285. import { handleTree } from '@/utils/ruoyi'
  286. import { ORDER_STATUS_MAP, ORDER_TYPE_MAP, ORDER_LEVEL_MAP, orderStatusText, orderStatusType, orderTypeText, orderTypeTagType, orderLevelText, orderLevelType, orderLogText, orderLogType, canOperate, timeText } from './workOrderDict'
  287. // ========== 查询 & 列表(equipmentType 为空 = 查询所有设备工单) ==========
  288. const listLoading = ref(false)
  289. const tableData = ref([])
  290. const pageNum = ref(1)
  291. const pageSize = ref(10)
  292. const total = ref(0)
  293. const queryFormRef = ref(null)
  294. const queryParams = reactive({ orderType: null, orderLevel: null, orderStatus: null, keyword: '' })
  295. async function handleSearch() {
  296. listLoading.value = true
  297. try {
  298. const params = {}
  299. if (queryParams.orderType) params.orderType = queryParams.orderType
  300. if (queryParams.orderLevel) params.orderLevel = queryParams.orderLevel
  301. if (queryParams.orderStatus) params.orderStatus = queryParams.orderStatus
  302. if (queryParams.keyword) params.keyword = queryParams.keyword
  303. const res = await listWorkOrder(params, pageNum.value, pageSize.value)
  304. if (res.data) {
  305. tableData.value = res.data.records || []
  306. total.value = Number(res.data.total || 0)
  307. }
  308. } catch(e) { ElMessage.error('查询失败'); console.error(e) }
  309. finally { listLoading.value = false }
  310. }
  311. function handleReset() {
  312. queryFormRef.value && queryFormRef.value.resetFields()
  313. queryParams.orderType = null
  314. queryParams.orderLevel = null
  315. queryParams.orderStatus = null
  316. queryParams.keyword = ''
  317. pageNum.value = 1
  318. selectedOrderId.value = null
  319. selectedOrder.value = {}
  320. handleSearch()
  321. }
  322. // ========== 选中工单 & 详情(合并 res.data.order + logs,参考排水/燃气工单) ==========
  323. const selectedOrderId = ref(null)
  324. const selectedOrder = ref({})
  325. const activeTab = ref('info')
  326. const currentOrderLogs = ref([])
  327. const detailLoading = ref(false)
  328. const detailDevice = ref(null)
  329. // 工单图片列表(多个用逗号分隔)
  330. async function handleSelectOrder(order) {
  331. selectedOrderId.value = order.orderId
  332. selectedOrder.value = { ...order }
  333. activeTab.value = 'info'
  334. currentOrderLogs.value = []
  335. detailDevice.value = null
  336. await loadOrderDetail(order.orderId)
  337. }
  338. async function loadOrderDetail(orderId) {
  339. detailLoading.value = true
  340. try {
  341. const res = await getWorkOrderDetail(orderId)
  342. if (res.code === 200 && res.data) {
  343. selectedOrder.value = { ...selectedOrder.value, ...res.data.order }
  344. currentOrderLogs.value = res.data.logs || []
  345. // 关联设备全信息(需求字段:类型/位置/厂家/维护人员/联系方式)
  346. if (res.data.order?.deviceCode) {
  347. loadDeviceInfo(res.data.order.deviceCode)
  348. }
  349. }
  350. } catch(e) { ElMessage.error('加载工单详情失败'); console.error(e) }
  351. finally { detailLoading.value = false }
  352. }
  353. async function loadDeviceInfo(codeOrId) {
  354. try {
  355. const res = await getEquipmentFullPage(1, 1, { equipmentCode: codeOrId })
  356. const rows = res.data?.records || res.data?.rows || []
  357. if (rows.length) detailDevice.value = rows[0]
  358. } catch(e) { console.warn('加载设备信息失败', e) }
  359. }
  360. function handleSizeChange() {
  361. pageNum.value = 1
  362. selectedOrderId.value = null
  363. selectedOrder.value = {}
  364. handleSearch()
  365. }
  366. function handlePageChange() {
  367. selectedOrderId.value = null
  368. selectedOrder.value = {}
  369. handleSearch()
  370. }
  371. // ========== 用户映射(接单人 ID → 姓名) ==========
  372. const userMap = ref({})
  373. function userNames(ids) {
  374. if (!ids) return ''
  375. return String(ids).split(',').map(id => userMap.value[id] || id).join('、')
  376. }
  377. async function loadUsers() {
  378. try {
  379. const res = await listUser({ pageNum: 1, pageSize: 200 })
  380. if (res.rows) {
  381. userMap.value = Object.fromEntries(res.rows.map(u => [u.userId, u.nickName || u.userName]))
  382. }
  383. } catch(e) { console.warn('加载用户列表失败', e) }
  384. }
  385. // ========== 派单(部门树 + 用户多选,参考排水/燃气工单) ==========
  386. const dispatchDialogVisible = ref(false)
  387. const dispatchForm = reactive({ handlerIds: [], remarks: '' })
  388. const deptTreeRef = ref(null)
  389. const userTableRef = ref(null)
  390. const deptTreeData = ref([])
  391. const deptFilterText = ref('')
  392. const userList = ref([])
  393. const selectedUserList = ref([])
  394. const submitting = ref(false)
  395. watch(deptFilterText, (val) => { deptTreeRef.value && deptTreeRef.value.filter(val) })
  396. function filterDeptNode(value, data) {
  397. if (!value) return true
  398. return data.deptName.includes(value)
  399. }
  400. async function loadDeptTree() {
  401. if (deptTreeData.value.length > 0) return
  402. try {
  403. const res = await listDept()
  404. deptTreeData.value = handleTree(res.data || [], 'deptId')
  405. } catch(e) { console.warn('加载部门失败', e) }
  406. }
  407. async function handleDeptClick(node) {
  408. try {
  409. userTableRef.value && userTableRef.value.clearSelection()
  410. selectedUserList.value = []
  411. dispatchForm.handlerIds = []
  412. const res = await listUser({ deptId: node.deptId, pageNum: 1, pageSize: 100 })
  413. userList.value = res.rows || []
  414. } catch(e) { console.warn('加载部门用户失败', e) }
  415. }
  416. function handleSelectionChange(selection) {
  417. selectedUserList.value = selection
  418. dispatchForm.handlerIds = selection.map(item => item.userId)
  419. }
  420. function handleRowClick(row) {
  421. userTableRef.value && userTableRef.value.toggleRowSelection(row)
  422. }
  423. const selectedUserNames = computed(() => selectedUserList.value.map(item => item.nickName || item.userName))
  424. function handleDispatch() {
  425. if (!canOperate.dispatch(selectedOrder.value)) {
  426. ElMessage.warning('仅可对待派单工单进行派单操作')
  427. return
  428. }
  429. Object.assign(dispatchForm, { handlerIds: [], remarks: '' })
  430. selectedUserList.value = []
  431. userList.value = []
  432. loadDeptTree()
  433. dispatchDialogVisible.value = true
  434. }
  435. async function submitDispatch() {
  436. if (!dispatchForm.handlerIds || dispatchForm.handlerIds.length === 0) {
  437. ElMessage.warning('请选择至少一个接单人')
  438. return
  439. }
  440. submitting.value = true
  441. try {
  442. await dispatchWorkOrder({
  443. orderId: selectedOrder.value.orderId,
  444. handlerIds: dispatchForm.handlerIds,
  445. remarks: dispatchForm.remarks
  446. })
  447. ElMessage.success('派单成功!已向 ' + dispatchForm.handlerIds.length + ' 人下发任务')
  448. dispatchDialogVisible.value = false
  449. handleSearch()
  450. if (selectedOrderId.value) handleSelectOrder(tableData.value.find(o => o.orderId === selectedOrderId.value) || {})
  451. } catch(e) { ElMessage.error('派单失败:' + (e.msg || e.message || '请重试')); console.error(e) }
  452. finally { submitting.value = false }
  453. }
  454. // ========== 核查/处理(参考排水/燃气工单) ==========
  455. const actionDialogVisible = ref(false)
  456. const actionTitle = ref('')
  457. const actionFormData = reactive({ solution: '' })
  458. const processBeforeImages = ref('')
  459. const imageUploadUrl = import.meta.env.VITE_APP_BASE_API + '/minio/upload'
  460. function handleVerify() {
  461. if (!canOperate.verify(selectedOrder.value)) {
  462. ElMessage.warning('当前工单不允许核查')
  463. return
  464. }
  465. actionTitle.value = '核查工单'
  466. actionFormData.solution = ''
  467. processBeforeImages.value = ''
  468. actionDialogVisible.value = true
  469. }
  470. function handleProcess() {
  471. if (!canOperate.process(selectedOrder.value)) {
  472. ElMessage.warning('当前工单不允许处理')
  473. return
  474. }
  475. ElMessageBox.alert(
  476. '<div style="padding: 10px; color: #666;">' +
  477. '<strong>维修操作指引:</strong><br/><br/>' +
  478. '• 在下方表格中可以上传处理前的照片(最多9张)<br/>' +
  479. '• 请选择本地图片或拍照上传<br/>' +
  480. '• 填写处理说明后点击"确定"提交<br/>' +
  481. '</div>',
  482. '开始维修',
  483. {
  484. dangerouslyUseHTMLString: true,
  485. confirmButtonText: '我已了解',
  486. center: false
  487. }
  488. ).then(() => {
  489. actionTitle.value = '处理工单'
  490. actionFormData.solution = ''
  491. processBeforeImages.value = ''
  492. actionDialogVisible.value = true
  493. }).catch(() => {})
  494. }
  495. async function submitAction() {
  496. if (!actionFormData.solution.trim()) {
  497. ElMessage.warning('请填写说明')
  498. return
  499. }
  500. submitting.value = true
  501. try {
  502. const data = {
  503. orderId: selectedOrder.value.orderId,
  504. handleResult: 'solved',
  505. solution: actionFormData.solution
  506. }
  507. if (actionTitle.value === '处理工单' && processBeforeImages.value) {
  508. data.maintenanceBeforeImg = processBeforeImages.value
  509. }
  510. if (actionTitle.value === '核查工单') {
  511. await verifyWorkOrder(data)
  512. } else {
  513. await processWorkOrder(data)
  514. }
  515. ElMessage.success(actionTitle.value + '成功')
  516. actionDialogVisible.value = false
  517. handleSearch()
  518. if (selectedOrderId.value) handleSelectOrder(tableData.value.find(o => o.orderId === selectedOrderId.value) || {})
  519. } catch(e) { ElMessage.error('操作失败:' + (e.msg || e.message || '请重试')); console.error(e) }
  520. finally { submitting.value = false }
  521. }
  522. // ========== 提交验收(后端 completeOrder 要求状态 4) ==========
  523. const completeDialogVisible = ref(false)
  524. const completeForm = reactive({ result: '' })
  525. const maintenanceAfterImages = ref('')
  526. function handleComplete() {
  527. if (!canOperate.complete(selectedOrder.value)) {
  528. ElMessage.warning('当前工单不允许提交验收')
  529. return
  530. }
  531. completeForm.result = ''
  532. maintenanceAfterImages.value = ''
  533. completeDialogVisible.value = true
  534. }
  535. async function submitComplete() {
  536. if (!completeForm.result.trim()) {
  537. ElMessage.warning('请填写验收意见')
  538. return
  539. }
  540. submitting.value = true
  541. try {
  542. await completeWorkOrder({
  543. orderId: selectedOrder.value.orderId,
  544. result: completeForm.result,
  545. maintenanceAfterImg: maintenanceAfterImages.value
  546. })
  547. ElMessage.success('提交验收成功')
  548. completeDialogVisible.value = false
  549. handleSearch()
  550. if (selectedOrderId.value) handleSelectOrder(tableData.value.find(o => o.orderId === selectedOrderId.value) || {})
  551. } catch(e) { ElMessage.error('提交失败:' + (e.msg || e.message || '请重试')); console.error(e) }
  552. finally { submitting.value = false }
  553. }
  554. // ========== 验收通过/结案(参考排水/燃气工单) ==========
  555. const closeDialogVisible = ref(false)
  556. const closeForm = reactive({ result: '' })
  557. function handleClose() {
  558. if (!canOperate.close(selectedOrder.value)) {
  559. ElMessage.warning('当前工单不允许验收通过')
  560. return
  561. }
  562. closeForm.result = ''
  563. closeDialogVisible.value = true
  564. }
  565. async function submitClose() {
  566. if (!closeForm.result.trim()) {
  567. ElMessage.warning('请填写验收结果')
  568. return
  569. }
  570. submitting.value = true
  571. try {
  572. await closeWorkOrder({
  573. orderId: selectedOrder.value.orderId,
  574. result: closeForm.result
  575. })
  576. ElMessage.success('验收通过,工单已结案')
  577. closeDialogVisible.value = false
  578. handleSearch()
  579. if (selectedOrderId.value) handleSelectOrder(tableData.value.find(o => o.orderId === selectedOrderId.value) || {})
  580. } catch(e) { ElMessage.error('验收失败:' + (e.msg || e.message || '请重试')); console.error(e) }
  581. finally { submitting.value = false }
  582. }
  583. // ========== 取消工单 ==========
  584. function handleCancel() {
  585. ElMessageBox.confirm(`确定取消工单「${selectedOrder.value.orderNo}」吗?`, '取消工单', { type: 'warning' })
  586. .then(async () => {
  587. try {
  588. await cancelWorkOrder(selectedOrder.value.orderId)
  589. ElMessage.success('工单已取消')
  590. selectedOrderId.value = null
  591. selectedOrder.value = {}
  592. handleSearch()
  593. } catch(e) { ElMessage.error('取消失败:' + (e.msg || e.message || '请重试')); console.error(e) }
  594. }).catch(() => {})
  595. }
  596. onMounted(() => {
  597. loadUsers()
  598. handleSearch()
  599. })
  600. </script>
  601. <style scoped lang="scss">
  602. .order-container {
  603. padding: 0 16px 16px;
  604. .search-card {
  605. margin-bottom: 16px;
  606. :deep(.el-card__body) {
  607. padding: 16px;
  608. }
  609. }
  610. .search-form {
  611. .el-form {
  612. display: flex;
  613. flex-wrap: wrap;
  614. align-items: flex-start;
  615. gap: 12px;
  616. .el-form-item {
  617. margin-bottom: 0;
  618. min-width: 150px;
  619. &:last-child {
  620. margin-left: auto;
  621. }
  622. }
  623. }
  624. }
  625. .main-row {
  626. height: calc(100vh - 200px);
  627. .col-fill {
  628. height: 100%;
  629. }
  630. }
  631. .box-card {
  632. height: 100%;
  633. display: flex;
  634. flex-direction: column;
  635. :deep(.el-card__body) {
  636. flex: 1;
  637. display: flex;
  638. flex-direction: column;
  639. overflow: hidden;
  640. }
  641. }
  642. .card-header {
  643. display: flex;
  644. justify-content: space-between;
  645. align-items: center;
  646. }
  647. .list-scroll {
  648. flex: 1;
  649. overflow: hidden;
  650. }
  651. .order-list {
  652. padding-right: 4px;
  653. }
  654. .order-item {
  655. padding: 16px;
  656. margin-bottom: 8px;
  657. border-radius: 8px;
  658. cursor: pointer;
  659. transition: all 0.2s;
  660. border: 1px solid #e4e7ed;
  661. &:hover {
  662. background-color: #f5f7fa;
  663. }
  664. &.active {
  665. background-color: #ecf5ff;
  666. border-color: #409eff;
  667. }
  668. .order-header {
  669. display: flex;
  670. justify-content: space-between;
  671. align-items: center;
  672. margin-bottom: 8px;
  673. .order-no {
  674. font-weight: bold;
  675. font-size: 14px;
  676. }
  677. .status-badge {
  678. min-width: 60px;
  679. }
  680. }
  681. .order-body {
  682. font-size: 12px;
  683. color: #606266;
  684. .order-tags {
  685. display: flex;
  686. gap: 6px;
  687. margin-bottom: 6px;
  688. }
  689. .order-desc {
  690. margin-top: 4px;
  691. color: #303133;
  692. font-size: 13px;
  693. overflow: hidden;
  694. text-overflow: ellipsis;
  695. white-space: nowrap;
  696. }
  697. .device-code, .create-time {
  698. margin-top: 4px;
  699. }
  700. }
  701. }
  702. .pagination-wrapper {
  703. padding: 16px 0;
  704. border-top: 1px solid #e4e7ed;
  705. margin-top: 8px;
  706. .pagination {
  707. display: flex;
  708. justify-content: flex-end;
  709. }
  710. }
  711. .detail-body {
  712. flex: 1;
  713. overflow-y: auto;
  714. }
  715. .detail-tabs {
  716. margin-top: 0;
  717. }
  718. .timeline-scroll {
  719. max-height: calc(100vh - 340px);
  720. overflow-y: auto;
  721. padding-right: 8px;
  722. }
  723. .device-section, .image-section, .action-section {
  724. margin-top: 24px;
  725. padding-top: 24px;
  726. border-top: 1px solid #e4e7ed;
  727. .section-title {
  728. font-size: 15px;
  729. font-weight: bold;
  730. margin-bottom: 16px;
  731. color: #303133;
  732. }
  733. }
  734. .image-list {
  735. display: flex;
  736. flex-wrap: wrap;
  737. gap: 12px;
  738. }
  739. .image-group + .image-group {
  740. margin-top: 16px;
  741. }
  742. .image-label {
  743. display: block;
  744. margin-bottom: 8px;
  745. color: #606266;
  746. font-size: 13px;
  747. }
  748. .action-buttons {
  749. display: flex;
  750. gap: 12px;
  751. flex-wrap: wrap;
  752. .el-button {
  753. min-width: 100px;
  754. }
  755. }
  756. .timeline-content {
  757. .timestamp {
  758. font-size: 12px;
  759. color: #909399;
  760. margin-bottom: 6px;
  761. }
  762. .operator-info {
  763. font-size: 14px;
  764. margin-bottom: 8px;
  765. .operator-name {
  766. font-weight: bold;
  767. color: #303133;
  768. }
  769. .oper-type {
  770. color: #909399;
  771. }
  772. }
  773. .oper-desc {
  774. font-size: 14px;
  775. color: #606266;
  776. }
  777. }
  778. }
  779. </style>