Quellcode durchsuchen

feat(water): complete device ledger and repair dispatch UI

Kazerin vor 2 Tagen
Ursprung
Commit
59a5919b10

+ 2 - 1
src/utils/waterSupplyModel.js

@@ -40,7 +40,8 @@ export function normalizeMonitoringRecord(record = {}, metric = 'value', monitor
 }
 
 export async function readMonitoringExportError(blob) {
-  if (!blob || blob.type !== 'application/json') return null
+  const mimeType = String(blob?.type || '').split(';', 1)[0].trim().toLowerCase()
+  if (mimeType !== 'application/json') return null
   try {
     const payload = JSON.parse(await blob.text())
     return payload?.msg || payload?.message || '导出失败,请稍后重试'

+ 55 - 4
src/utils/waterSupplyUiModel.js

@@ -13,9 +13,12 @@ const workOrderStatusLabels = {
   5: '待验收',
   6: '已结案',
   7: '已驳回',
-  8: '已取消'
+  8: '延期审核',
+  9: '已延期'
 }
 
+const terminalWorkOrderStatuses = new Set([6, 7])
+
 const deviceCurrentStatusLabels = {
   1: '在用',
   2: '闲置',
@@ -37,7 +40,6 @@ export function normalizeDeviceRow(row = {}) {
   const relatedOrderNo = row.relatedWorkOrderNo
     ?? row.latestOrderNo
     ?? row.orderNo
-    ?? row.latestMaintainId
     ?? '-'
   const rawOrderStatus = row.relatedWorkOrderStatus
     ?? row.latestMaintainStatus
@@ -77,12 +79,12 @@ export function normalizeWorkOrderStatus(value) {
     return {
       code: Number.isFinite(code) ? code : null,
       text: workOrderStatusLabels[code] ?? '未知',
-      terminal: code >= 6
+      terminal: terminalWorkOrderStatuses.has(code)
     }
   }
   const text = String(value)
   const code = Object.entries(workOrderStatusLabels).find(([, label]) => label === text)?.[0]
-  return { code: code ? Number(code) : null, text: text || '暂无工单', terminal: code ? Number(code) >= 6 : false }
+  return { code: code ? Number(code) : null, text: text || '暂无工单', terminal: code ? terminalWorkOrderStatuses.has(Number(code)) : false }
 }
 
 export function canDispatchDevice(row = {}) {
@@ -105,6 +107,55 @@ export function normalizeDeviceGisPoints(response) {
   })).filter(point => Number.isFinite(point._longitude) && Number.isFinite(point._latitude))
 }
 
+export function normalizeAssigneeUser(user = {}) {
+  const status = String(user.status ?? '0')
+  const delFlag = String(user.delFlag ?? '0')
+  const deptName = user.dept?.deptName ?? user.deptName ?? '-'
+  const phone = user.phonenumber ?? user.phoneNumber ?? user.phone
+  return {
+    userId: user.userId ?? user.id,
+    userName: user.userName ?? '',
+    nickName: user.nickName ?? '',
+    deptName,
+    phone: phone || '未登记手机号',
+    statusText: status === '0' && delFlag === '0' ? '正常' : '停用',
+    eligible: status === '0' && delFlag === '0'
+  }
+}
+
+export function buildAssigneeQuery({ deptId, keyword = '', pageNum = 1, pageSize = 10 } = {}) {
+  const normalizedKeyword = String(keyword ?? '').trim()
+  const searchField = normalizedKeyword
+    ? (/^\+?[\d\s-]+$/.test(normalizedKeyword)
+        ? 'phonenumber'
+        : /[\u3400-\u9fff]/u.test(normalizedKeyword) ? 'nickName' : 'userName')
+    : null
+  return {
+    pageNum: Number(pageNum) > 0 ? Number(pageNum) : 1,
+    pageSize: Number(pageSize) > 0 ? Number(pageSize) : 10,
+    ...(deptId == null || deptId === '' ? {} : { deptId }),
+    status: '0',
+    ...(searchField ? { [searchField]: normalizedKeyword } : {})
+  }
+}
+
+export function selectSingleAssignee(selection = []) {
+  return Array.isArray(selection) && selection.length ? selection[selection.length - 1] : null
+}
+
+export function normalizeDispatchOrderResponse(response) {
+  const candidates = [response?.data?.data, response?.data, response]
+  const summary = candidates.find(value => value && typeof value === 'object' && !Array.isArray(value)
+    && value.orderId != null && String(value.orderNo ?? '').trim())
+  if (!summary || Number(summary.orderStatus) !== 2) return null
+  return {
+    ...summary,
+    orderId: summary.orderId,
+    orderNo: String(summary.orderNo).trim(),
+    orderStatus: Number(summary.orderStatus)
+  }
+}
+
 export function buildMaintainOrderPayload(device = {}, form = {}) {
   const payload = {
     equipmentId: device.equipmentId ?? device.id,

+ 96 - 16
src/views/subSystem/waterSupply/components/WaterDevicePage.vue

@@ -10,16 +10,16 @@
 
     <el-card shadow="never" class="filter-card">
       <el-form :inline="true" :model="query">
-        <el-form-item label="设备名称"><el-input v-model="query.equipmentName" clearable /></el-form-item>
-        <el-form-item label="设备编码"><el-input v-model="query.equipmentCode" clearable /></el-form-item>
-        <el-form-item v-if="view !== 'maintain'" label="报警状态"><el-select v-model="query.alarmStatus" clearable><el-option label="正常" :value="0" /><el-option label="报警" :value="1" /></el-select></el-form-item>
-        <el-form-item v-if="view !== 'maintain'" label="报警等级"><el-select v-model="query.alertLevel" clearable><el-option v-for="item in levels" :key="item.value" v-bind="item" /></el-select></el-form-item>
+        <el-form-item v-if="view !== 'gis'" label="设备名称"><el-input v-model="query.equipmentName" clearable /></el-form-item>
+        <el-form-item v-if="view !== 'gis'" label="设备编码"><el-input v-model="query.equipmentCode" clearable /></el-form-item>
+        <el-form-item v-if="view === 'ledger'" label="报警状态"><el-select v-model="query.alarmStatus" clearable><el-option label="正常" :value="0" /><el-option label="报警" :value="1" /></el-select></el-form-item>
+        <el-form-item v-if="view === 'ledger' || view === 'dispatch' || view === 'abnormal'" label="报警等级"><el-select v-model="query.alertLevel" clearable><el-option v-for="item in levels" :key="item.value" v-bind="item" /></el-select></el-form-item>
         <el-form-item><el-button type="primary" @click="loadData">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
       </el-form>
     </el-card>
 
     <el-card shadow="never" class="water-panel">
-      <div class="card-title"><span><el-icon><Monitor /></el-icon> {{ title }}清单</span><el-button link type="primary" @click="exportData">导出</el-button></div>
+      <div class="card-title"><span><el-icon><Monitor /></el-icon> {{ title }}清单</span><el-button v-if="view !== 'gis'" link type="primary" @click="exportData">导出</el-button></div>
       <el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="load-error"><el-button size="small" type="danger" plain @click="loadData">重试</el-button></el-alert>
       <el-empty v-if="view === 'gis' && !loading && !loadError && !page.records.length" description="暂无设备 GIS 点位数据" />
       <el-table :data="page.records" border stripe v-loading="loading" row-key="equipmentId">
@@ -44,38 +44,116 @@
       </el-descriptions>
       <template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
     </el-drawer>
-    <el-dialog v-model="dispatchVisible" title="设备维修派单" width="600px"><el-form :model="dispatchForm" label-width="100px"><el-form-item label="设备"><span>{{ dispatchDevice.equipmentName }}({{ dispatchDevice.equipmentCode }})</span></el-form-item><el-form-item label="问题描述" required><el-input v-model="dispatchForm.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入故障或异常描述" /></el-form-item><el-form-item label="接单责任人" required><el-select v-model="dispatchForm.receiveUserId" filterable clearable placeholder="请选择责任人" :loading="userLoading" style="width:100%"><el-option v-for="user in users" :key="user.userId" :label="user.nickName || user.userName" :value="user.userId" /></el-select></el-form-item></el-form><template #footer><el-button @click="dispatchVisible = false">取消</el-button><el-button type="primary" :loading="dispatching" @click="submitDispatch">确认派单</el-button></template></el-dialog>
+    <el-dialog v-model="dispatchVisible" class="dispatch-dialog" title="设备维修派单" width="860px">
+      <div class="dispatch-layout">
+        <div class="assignee-picker">
+          <aside class="assignee-depts">
+            <el-input v-model="deptFilterText" clearable placeholder="筛选部门" size="small" />
+            <el-tree ref="deptTreeRef" :data="deptTreeData" node-key="deptId" highlight-current :props="{ label: 'deptName', children: 'children' }" :filter-node-method="filterDeptNode" class="assignee-tree" @node-click="handleDeptClick" />
+          </aside>
+          <section class="assignee-users">
+            <div class="assignee-search"><el-input v-model="assigneeKeyword" clearable placeholder="搜索用户名、昵称或手机号" @keyup.enter="searchAssigneeUsers" /><el-button type="primary" :loading="userLoading" @click="searchAssigneeUsers">查询</el-button></div>
+            <el-alert v-if="userLoadError" :title="userLoadError" type="error" show-icon :closable="false"><el-button size="small" type="danger" plain @click="retryAssigneeLoading">重试</el-button></el-alert>
+            <el-table :data="users" border row-key="userId" highlight-current-row :current-row-key="assigneeDraft?.userId" @row-click="chooseAssignee">
+              <el-table-column label="选择" width="58" align="center"><template #default="{ row }"><el-radio :model-value="assigneeDraft?.userId" :label="row.userId" @click.stop="chooseAssignee(row)">&nbsp;</el-radio></template></el-table-column>
+              <el-table-column prop="nickName" label="昵称" min-width="100" />
+              <el-table-column prop="userName" label="用户名" min-width="110" />
+              <el-table-column prop="deptName" label="部门" min-width="120" show-overflow-tooltip />
+              <el-table-column prop="phone" label="手机号" min-width="130" />
+              <el-table-column prop="statusText" label="账号状态" width="90" />
+            </el-table>
+            <el-empty v-if="!userLoading && !userLoadError && !users.length" description="暂无符合条件的责任人" :image-size="80" />
+            <el-pagination v-model:current-page="assigneePageNum" v-model:page-size="assigneePageSize" :total="assigneeTotal" layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" @current-change="loadAssigneeUsers" @size-change="handleAssigneeSizeChange" />
+            <div class="assignee-picked">已选责任人:<strong v-if="assigneeDraft">{{ assigneeDraft.nickName || assigneeDraft.userName }}</strong><span v-else>未选择</span><el-button size="small" type="primary" plain :disabled="!assigneeDraft" @click="confirmAssignee">确认选择</el-button></div>
+          </section>
+        </div>
+        <el-form class="dispatch-form" :model="dispatchForm" label-width="100px">
+          <el-form-item label="设备"><span>{{ dispatchDevice.equipmentName }}({{ dispatchDevice.equipmentCode }})</span></el-form-item>
+          <el-form-item label="问题描述" required><el-input v-model="dispatchForm.description" type="textarea" :rows="7" maxlength="500" show-word-limit placeholder="请输入故障或异常描述" /></el-form-item>
+          <el-form-item label="接单责任人" required>
+            <span v-if="selectedAssignee" class="assignee-summary">{{ selectedAssignee.nickName || selectedAssignee.userName }}({{ selectedAssignee.deptName || '-' }})</span>
+            <span v-else class="assignee-placeholder">请在左侧选择责任人</span>
+          </el-form-item>
+        </el-form>
+      </div>
+      <template #footer><el-button @click="dispatchVisible = false">取消</el-button><el-button type="primary" :loading="dispatching" @click="submitDispatch">确认派单</el-button></template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup>
-import { computed, onMounted, ref } from 'vue'
+import { computed, onMounted, ref, watch } from 'vue'
 import { ElMessage } from 'element-plus'
 import { Monitor, Refresh } from '@element-plus/icons-vue'
 import { listUser } from '@/api/system/user'
+import { listDept } from '@/api/system/dept'
+import { handleTree } from '@/utils/ruoyi'
 import { createAbnormalOrder, exportAbnormalDevices, exportDeviceLedger, listAbnormalDevices, listDeviceLedger, listDeviceGisPoints, listMaintainRecords, deleteMaintainRecord } from '@/api/pipeNetwork/waterSupply'
 import { normalizePage, readMonitoringExportError } from '@/utils/waterSupplyModel'
-import { buildMaintainOrderPayload, normalizeDeviceRow, canDispatchDevice, normalizeDeviceGisPoints, preserveDevicePageOnError } from '@/utils/waterSupplyUiModel'
+import { buildMaintainOrderPayload, normalizeDeviceRow, canDispatchDevice, normalizeDeviceGisPoints, preserveDevicePageOnError, normalizeAssigneeUser, buildAssigneeQuery, selectSingleAssignee, normalizeDispatchOrderResponse } from '@/utils/waterSupplyUiModel'
 import { saveAs } from 'file-saver'
 
 const props = defineProps({ title: { type: String, required: true }, subtitle: { type: String, default: '设备信息、运行状态与维护过程管理' }, view: { type: String, default: 'ledger' } })
 const { title, subtitle, view } = props
 const query = ref({ pageNum: 1, pageSize: 10, equipmentName: '', equipmentCode: '', alarmStatus: undefined, alertLevel: undefined })
-const page = ref({ records: [], total: 0 }); const loading = ref(false); const loadError = ref(''); const detailVisible = ref(false); const detail = ref({}); const dispatchVisible = ref(false); const dispatching = ref(false); const dispatchDevice = ref({}); const dispatchForm = ref({ description: '', receiveUserId: undefined }); const users = ref([]); const userLoading = ref(false)
+const page = ref({ records: [], total: 0 }); const loading = ref(false); const loadError = ref(''); const detailVisible = ref(false); const detail = ref({}); const dispatchVisible = ref(false); const dispatching = ref(false); const dispatchDevice = ref({}); const dispatchForm = ref({ description: '', receiveUserId: undefined }); const users = ref([]); const userLoading = ref(false); const userLoadError = ref(''); const deptLoadError = ref(''); const selectedAssignee = ref(null); const assigneeDraft = ref(null); const deptTreeRef = ref(null); const deptTreeData = ref([]); const deptFilterText = ref(''); const assigneeKeyword = ref(''); const assigneeDeptId = ref(undefined); const assigneePageNum = ref(1); const assigneePageSize = ref(10); const assigneeTotal = ref(0)
 const levels = [{ label: '一级', value: 1 }, { label: '二级', value: 2 }, { label: '三级', value: 3 }, { label: '四级', value: 4 }]; const pageIcon = Monitor; const alarmLevelType = level => ({ 1: 'danger', 2: 'warning', 3: 'primary', 4: 'success' }[Number(level)] || 'info')
 function canDispatch(row) {
   if (view !== 'dispatch' && view !== 'abnormal') return false
   return canDispatchDevice(row)
 }
-function workOrderStatusType(code) { return ({ 1: 'info', 2: 'warning', 3: 'primary', 4: 'warning', 5: 'danger', 6: 'success', 7: 'info', 8: 'info' })[Number(code)] || 'info' }
+function workOrderStatusType(code) { return ({ 1: 'info', 2: 'warning', 3: 'primary', 4: 'warning', 5: 'danger', 6: 'success', 7: 'info', 8: 'warning', 9: 'warning' })[Number(code)] || 'info' }
 const detailFields = computed(() => [['设备编码', detail.value.equipmentCode], ['设备类型', detail.value.equipmentTypeName || detail.value.typeName], ['安装位置', detail.value.equipmentLocation], ['设备状态', detail.value.currentStatusText], ['在线状态', detail.value.onlineStatusText], ['报警状态', detail.value.alarmStatusText], ['报警等级', detail.value.alarmLevelText], ['异常原因', detail.value.abnormalReasonText], ['关联工单', detail.value.relatedOrderNo || detail.value.orderNo], ['工单状态', detail.value.workOrderStatusText || detail.value.status], ['接单责任人', detail.value.relatedWorkOrderPerson || detail.value.maintainPerson], ['状态更新时间', detail.value.statusUpdateTime]].map(([label, value]) => ({ label, value: value ?? '-' })))
 
-async function loadData() { loading.value = true; loadError.value = ''; try { if (view === 'gis') { const response = await listDeviceGisPoints({ ...query.value }); const records = normalizeDeviceGisPoints(response).map(normalizeDeviceRow); page.value = { records, total: records.length, current: 1, size: records.length || 10 }; return } let response; if (view === 'maintain') response = await listMaintainRecords({ ...query.value }); else if (view === 'dispatch' || view === 'abnormal') response = await listAbnormalDevices({ ...query.value }); else response = await listDeviceLedger({ ...query.value }); const normalized = normalizePage(response); page.value = { ...normalized, records: normalized.records.map(normalizeDeviceRow) } } catch (error) { const retained = preserveDevicePageOnError(page.value); page.value = retained.page; loadError.value = retained.error; console.warn(`[${title}] 数据加载失败`, error) } finally { loading.value = false } }
+async function loadData() { loading.value = true; loadError.value = ''; try { if (view === 'gis') { const response = await listDeviceGisPoints({ ...query.value }); const records = normalizeDeviceGisPoints(response).map(normalizeDeviceRow); page.value = { records, total: records.length, current: 1, size: records.length || 10 }; return } let response; if (view === 'maintain') response = await listMaintainRecords({ ...query.value }); else if (view === 'dispatch' || view === 'abnormal') response = await listAbnormalDevices({ ...query.value, ...(view === 'dispatch' ? { dispatchableOnly: true } : {}) }); else response = await listDeviceLedger({ ...query.value }); const normalized = normalizePage(response); page.value = { ...normalized, records: normalized.records.map(normalizeDeviceRow) } } catch (error) { const retained = preserveDevicePageOnError(page.value); page.value = retained.page; loadError.value = retained.error; console.warn(`[${title}] 数据加载失败`, error) } finally { loading.value = false } }
 function reset() { query.value = { pageNum: 1, pageSize: 10, equipmentName: '', equipmentCode: '', alarmStatus: undefined, alertLevel: undefined }; loadData() }
 function selectPoint(row) { detail.value = normalizeDeviceRow(row); detailVisible.value = true }
-async function loadUsers() { if (users.value.length) return; userLoading.value = true; try { const response = await listUser({ pageNum: 1, pageSize: 200 }); users.value = response.rows || response.data?.rows || [] } catch (error) { ElMessage.error('责任人列表加载失败') } finally { userLoading.value = false } }
-async function openOrder(row) { dispatchDevice.value = row; dispatchForm.value = { description: row.abnormalReason || row.abnormalReasonText || row.description || '', receiveUserId: undefined }; await loadUsers(); dispatchVisible.value = true }
-async function submitDispatch() { if (!dispatchForm.value.description?.trim()) return ElMessage.warning('请填写问题描述'); if (!dispatchForm.value.receiveUserId) return ElMessage.warning('请选择接单责任人'); dispatching.value = true; try { const response = await createAbnormalOrder(buildMaintainOrderPayload(dispatchDevice.value, dispatchForm.value)); const summary = response?.data?.data || response?.data || response || {}; const orderNo = summary.orderNo || summary.orderId; ElMessage.success(orderNo ? `已派单,等待接单(${orderNo})` : '已派单,等待接单'); dispatchVisible.value = false; await loadData() } catch (error) { ElMessage.error(error?.response?.data?.msg || '派单失败,请检查权限或服务状态') } finally { dispatching.value = false } }
+watch(deptFilterText, value => deptTreeRef.value?.filter(value))
+function filterDeptNode(value, data) { return !value || String(data.deptName || '').includes(value) }
+async function loadDeptTree(force = false) { if (!force && deptTreeData.value.length) return true; deptLoadError.value = ''; try { const response = await listDept(); deptTreeData.value = handleTree(response.data || [], 'deptId'); return true } catch (error) { deptLoadError.value = '部门列表加载失败,请点击重试'; userLoadError.value = deptLoadError.value; return false } }
+async function loadAssigneeUsers() { userLoading.value = true; userLoadError.value = ''; try { const response = await listUser(buildAssigneeQuery({ deptId: assigneeDeptId.value, keyword: assigneeKeyword.value, pageNum: assigneePageNum.value, pageSize: assigneePageSize.value })); const rows = response.rows || response.data?.rows || []; users.value = rows.map(normalizeAssigneeUser).filter(user => user.eligible); assigneeTotal.value = Number(response.total ?? response.data?.total ?? users.value.length) || 0 } catch (error) { userLoadError.value = '责任人列表加载失败,请点击重试' } finally { userLoading.value = false } }
+function searchAssigneeUsers() { assigneePageNum.value = 1; loadAssigneeUsers() }
+function handleAssigneeSizeChange() { assigneePageNum.value = 1; loadAssigneeUsers() }
+async function retryAssigneeLoading() { const deptLoaded = await loadDeptTree(true); if (!deptLoaded) return; await loadAssigneeUsers() }
+async function openOrder(row) {
+  dispatchDevice.value = row
+  dispatchForm.value = { description: row.abnormalReason || row.abnormalReasonText || row.description || '', receiveUserId: undefined }
+  selectedAssignee.value = null
+  assigneeDraft.value = null
+  assigneeDeptId.value = undefined
+  assigneeKeyword.value = ''
+  assigneePageNum.value = 1
+  dispatchVisible.value = true
+  await retryAssigneeLoading()
+}
+function handleDeptClick(node) { assigneeDeptId.value = node.deptId; assigneePageNum.value = 1; loadAssigneeUsers() }
+function chooseAssignee(row) { assigneeDraft.value = selectSingleAssignee([row]) }
+function confirmAssignee() { selectedAssignee.value = assigneeDraft.value; dispatchForm.value.receiveUserId = assigneeDraft.value?.userId }
+async function submitDispatch() {
+  if (!dispatchForm.value.description?.trim()) return ElMessage.warning('请填写问题描述')
+  if (!dispatchForm.value.receiveUserId) return ElMessage.warning('请选择接单责任人')
+  dispatching.value = true
+  try {
+    const response = await createAbnormalOrder(buildMaintainOrderPayload(dispatchDevice.value, dispatchForm.value))
+    const summary = normalizeDispatchOrderResponse(response)
+    if (!summary) throw new Error('派单失败:服务端未返回有效的待接单工单')
+    ElMessage.success(`已派单,等待接单(${summary.orderNo})`)
+    dispatchVisible.value = false
+    await loadData()
+  } catch (error) {
+    const responseData = error?.response?.data
+    const message = responseData?.msg || responseData?.message || error?.message || '派单失败,请检查权限或服务状态'
+    // A conflict means another order now owns the device; refresh the list so its order number is visible.
+    if (/已有|冲突|重复|处理中的工单|关联工单/.test(String(message))) {
+      await loadData()
+      ElMessage.warning(`该设备已有相关处置工单:${message}`)
+    } else {
+      ElMessage.error(message)
+    }
+  } finally {
+    dispatching.value = false
+  }
+}
 async function removeRecord(row) { try { await deleteMaintainRecord(row.maintainId || row.id); ElMessage.success('记录已删除'); await loadData() } catch (error) { ElMessage.error('删除失败') } }
 async function exportData() {
   try {
@@ -86,7 +164,8 @@ async function exportData() {
     saveAs(blob, `${title}_${new Date().toISOString().slice(0, 10)}.xlsx`)
     ElMessage.success('导出成功')
   } catch (error) {
-    ElMessage.error(error?.message || '导出失败,请稍后重试')
+    const backendMessage = await readMonitoringExportError(error?.response?.data)
+    ElMessage.error(backendMessage || error?.message || '导出失败,请稍后重试')
   }
 }
 onMounted(loadData)
@@ -94,8 +173,9 @@ onMounted(loadData)
 
 <style scoped>
 .device-page{padding:20px;background:#f5f7fa;min-height:calc(100vh - 84px)}.page-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.page-title{display:flex;gap:12px;align-items:center}.page-title h2{margin:0 0 5px;font-size:20px}.page-title-icon{display:grid;place-items:center;width:38px;height:38px;color:#2563eb;background:#eaf2ff;border-radius:8px}.muted{font-size:13px;color:#909399}.filter-card{margin-bottom:16px}.filter-card :deep(.el-form-item){margin-bottom:0}.card-title{display:flex;justify-content:space-between;align-items:center;font-weight:600;margin-bottom:14px}.el-pagination{justify-content:flex-end;margin-top:16px}.detail-summary{display:flex;align-items:center;gap:12px;margin-bottom:16px}.detail-name{font-size:18px;font-weight:700;color:#17324d}.device-detail-drawer :deep(.el-drawer__body){overflow:auto}.device-detail-drawer :deep(.el-descriptions__label){white-space:nowrap}.device-detail-drawer :deep(.el-descriptions__content){word-break:break-word;overflow-wrap:anywhere}
+.dispatch-layout{display:flex;gap:20px;min-height:420px}.dispatch-form{width:300px;min-width:280px;padding-left:20px;border-left:1px solid #e4e7ed}.assignee-field{display:flex;align-items:center;gap:12px;width:100%;min-height:32px}.assignee-summary{color:#17324d}.assignee-placeholder{color:#909399}.assignee-picker{display:flex;gap:16px;flex:1;min-width:0;min-height:390px}.assignee-depts{width:190px;min-width:190px;padding-right:16px;border-right:1px solid #e4e7ed}.assignee-tree{margin-top:10px;max-height:340px;overflow:auto}.assignee-users{flex:1;min-width:0}.assignee-search{display:flex;gap:8px;margin-bottom:12px}.assignee-search .el-input{flex:1}.assignee-users .el-pagination{margin-top:12px}.assignee-picked{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:14px;padding:10px 12px;background:#f5f7fa;border:1px solid #ebeef5;color:#606266}.assignee-picked strong{color:#2563eb}.assignee-users .el-alert{margin-bottom:12px}
 </style>
 <style lang="scss">
 @use './waterSupplyPageTheme.scss';
-@media(max-width:768px){.device-detail-drawer{width:100%!important}}
+@media(max-width:768px){.device-detail-drawer{width:100%!important}.dispatch-dialog{width:calc(100% - 24px)!important;margin-left:auto;margin-right:auto}.dispatch-layout{flex-direction:column}.assignee-picker{flex-direction:column}.assignee-depts{width:auto;min-width:0;padding-right:0;padding-bottom:12px;border-right:0;border-bottom:1px solid #e4e7ed}.assignee-tree{max-height:180px}.dispatch-form{width:auto;min-width:0;padding-left:0;padding-top:16px;border-left:0;border-top:1px solid #e4e7ed}}
 </style>

+ 72 - 0
tests/waterDeviceAssigneeSelector.test.mjs

@@ -0,0 +1,72 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+
+const source = fs.readFileSync(new URL('../src/views/subSystem/waterSupply/components/WaterDevicePage.vue', import.meta.url), 'utf8')
+
+test('device dispatch uses a department tree and paged single-assignee table', () => {
+  assert.match(source, /<el-tree[\s\S]*@node-click="handleDeptClick"/)
+  assert.match(source, /搜索用户名、昵称或手机号/)
+  assert.match(source, /<el-pagination[\s\S]*assigneeTotal/)
+  assert.match(source, /selectSingleAssignee\(\[row\]\)/)
+  assert.match(source, /:disabled="!assigneeDraft"/)
+})
+
+test('assignee loading failure remains in place with a retry action', () => {
+  assert.match(source, /userLoadError/)
+  assert.match(source, /责任人列表加载失败,请点击重试/)
+  assert.match(source, /@click="retryAssigneeLoading"/)
+})
+
+test('assignee loading failure preserves existing rows and total', () => {
+  const loadFn = source.match(/async function loadAssigneeUsers\(\)[\s\S]*?\nfunction searchAssigneeUsers/)?.[0] || ''
+  assert.doesNotMatch(loadFn, /users\.value = \[\]/)
+  assert.doesNotMatch(loadFn, /assigneeTotal\.value = 0/)
+  assert.match(loadFn, /userLoadError\.value = '责任人列表加载失败,请点击重试'/)
+})
+
+test('retry path force reloads the department tree before users', () => {
+  assert.match(source, /async function loadDeptTree\(force = false\)/)
+  assert.match(source, /async function retryAssigneeLoading\(\) \{ const deptLoaded = await loadDeptTree\(true\); if \(!deptLoaded\) return; await loadAssigneeUsers\(\) \}/)
+})
+
+test('assignee keyword search resets to the first result page', () => {
+  assert.match(source, /@keyup\.enter="searchAssigneeUsers"/)
+  assert.match(source, /@click="searchAssigneeUsers"/)
+  assert.match(source, /function searchAssigneeUsers\(\) \{ assigneePageNum\.value = 1; loadAssigneeUsers\(\) \}/)
+})
+
+test('assignee pagination is server-backed', () => {
+  assert.match(source, /buildAssigneeQuery\(\{ deptId: assigneeDeptId\.value, keyword: assigneeKeyword\.value, pageNum: assigneePageNum\.value, pageSize: assigneePageSize\.value \}\)/)
+  assert.match(source, /@current-change="loadAssigneeUsers"/)
+  assert.match(source, /@size-change="handleAssigneeSizeChange"/)
+})
+
+test('uses draft assignee state until confirmation in the split dispatch dialog', () => {
+  assert.match(source, /const assigneeDraft = ref\(null\)/)
+  assert.match(source, /function chooseAssignee\(row\) \{ assigneeDraft\.value = selectSingleAssignee\(\[row\]\) \}/)
+  assert.match(source, /function confirmAssignee\(\) \{ selectedAssignee\.value = assigneeDraft\.value; dispatchForm\.value\.receiveUserId = assigneeDraft\.value\?\.userId \}/)
+  assert.match(source, /<div class="dispatch-layout">/)
+  assert.match(source, /<el-form class="dispatch-form"/)
+  assert.match(source, /:current-row-key="assigneeDraft\?\.userId"/)
+})
+
+test('opens the split dispatch dialog with a fresh assignee query', () => {
+  assert.match(source, /async function openOrder\(row\)/)
+  assert.match(source, /assigneeDeptId\.value = undefined/)
+  assert.match(source, /assigneeKeyword\.value = ''/)
+  assert.match(source, /dispatchVisible\.value = true/)
+  assert.match(source, /await retryAssigneeLoading\(\)/)
+})
+
+test('dispatch requires a valid server order summary and preserves failure state', () => {
+  assert.match(source, /const summary = normalizeDispatchOrderResponse\(response\)/)
+  assert.match(source, /服务端未返回有效的待接单工单/)
+  assert.match(source, /responseData\?\.msg \|\| responseData\?\.message \|\| error\?\.message/)
+})
+
+test('does not expose filters unsupported by the device GIS endpoint', () => {
+  assert.match(source, /<el-form-item v-if="view !== 'gis'" label="设备名称"/)
+  assert.match(source, /<el-form-item v-if="view !== 'gis'" label="设备编码"/)
+  assert.match(source, /<el-form-item v-if="view === 'ledger'" label="报警状态"/)
+})

+ 34 - 0
tests/waterDeviceExport.test.mjs

@@ -0,0 +1,34 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import test from 'node:test'
+import { readMonitoringExportError } from '../src/utils/waterSupplyModel.js'
+
+test('water device exports download Excel blobs and surface backend JSON errors', async () => {
+  const apiSource = await readFile('src/api/pipeNetwork/waterSupply.js', 'utf8')
+  const pageSource = await readFile(
+    'src/views/subSystem/waterSupply/components/WaterDevicePage.vue',
+    'utf8'
+  )
+
+  const ledgerExport = apiSource.match(/export const exportDeviceLedger = .*$/mu)?.[0] || ''
+  const abnormalExport = apiSource.match(/export const exportAbnormalDevices = .*$/mu)?.[0] || ''
+  assert.match(ledgerExport, /responseType: 'blob'/u)
+  assert.match(abnormalExport, /responseType: 'blob'/u)
+  assert.match(pageSource, /readMonitoringExportError\(response\)/u)
+  assert.match(pageSource, /saveAs\(blob/u)
+})
+
+test('recognizes JSON export errors when the MIME includes a charset', async () => {
+  const message = await readMonitoringExportError(
+    new Blob(['{"msg":"导出失败"}'], { type: 'application/json;charset=UTF-8' })
+  )
+  assert.equal(message, '导出失败')
+})
+
+test('recognizes JSON export errors returned through an HTTP error response', async () => {
+  const message = await readMonitoringExportError({
+    type: 'application/json;charset=UTF-8',
+    text: async () => '{"message":"没有导出权限"}'
+  })
+  assert.equal(message, '没有导出权限')
+})

+ 65 - 1
tests/waterSupplyUiModel.test.mjs

@@ -7,7 +7,11 @@ import {
   normalizeWorkOrderStatus,
   canDispatchDevice,
   preserveDevicePageOnError,
-  normalizeDeviceGisPoints
+  normalizeDeviceGisPoints,
+  normalizeAssigneeUser,
+  buildAssigneeQuery,
+  selectSingleAssignee,
+  normalizeDispatchOrderResponse
 } from '../src/utils/waterSupplyUiModel.js'
 
 test('normalizes alarm and related work-order fields for device pages', () => {
@@ -74,6 +78,8 @@ test('uses the abnormal-device order endpoint for device repair dispatch', () =>
 
 test('maps numeric and numeric-string work-order statuses and marks terminal states', () => {
   assert.deepEqual(normalizeWorkOrderStatus(4), { code: 4, text: '处理中', terminal: false })
+  assert.deepEqual(normalizeWorkOrderStatus(8), { code: 8, text: '延期审核', terminal: false })
+  assert.deepEqual(normalizeWorkOrderStatus('9'), { code: 9, text: '已延期', terminal: false })
   assert.deepEqual(normalizeWorkOrderStatus('6'), { code: 6, text: '已结案', terminal: true })
   assert.deepEqual(normalizeWorkOrderStatus('7'), { code: 7, text: '已驳回', terminal: true })
   assert.deepEqual(normalizeWorkOrderStatus('暂无工单'), { code: null, text: '暂无工单', terminal: false })
@@ -83,9 +89,23 @@ test('allows dispatch when the latest order is terminal, including numeric-strin
   assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-6', relatedWorkOrderStatus: '6' }), true)
   assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-7', relatedWorkOrderStatus: 7 }), true)
   assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-4', relatedWorkOrderStatus: '4' }), false)
+  assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-8', relatedWorkOrderStatus: '8' }), false)
+  assert.equal(canDispatchDevice({ relatedOrderNo: 'WO-9', relatedWorkOrderStatus: 9 }), false)
   assert.equal(canDispatchDevice({ relatedOrderNo: '-' }), true)
 })
 
+test('does not treat a maintenance record id as a unified work-order number', () => {
+  const row = normalizeDeviceRow({
+    equipmentId: 'EQ-RECENT-MAINTENANCE',
+    latestMaintainId: 42,
+    latestMaintainStatus: '已完成'
+  })
+
+  assert.equal(row.relatedOrderNo, '-')
+  assert.equal(row.workOrderStatusText, '已完成')
+  assert.equal(canDispatchDevice(row), true)
+})
+
 test('retains existing page rows and total when a reload fails', () => {
   const previous = { records: [{ equipmentId: 'EQ-1' }], total: 1 }
   assert.deepEqual(preserveDevicePageOnError(previous), {
@@ -100,3 +120,47 @@ test('normalizes GIS responses to point records without inventing coordinates',
   ])
   assert.deepEqual(normalizeDeviceGisPoints({ data: [{ equipmentId: 'EQ-2' }] }), [])
 })
+
+test('normalizes eligible assignee display fields with safe fallbacks', () => {
+  assert.deepEqual(normalizeAssigneeUser({
+    userId: 18,
+    userName: 'zhangsan',
+    nickName: '张三',
+    phonenumber: '',
+    status: '0',
+    delFlag: '0',
+    dept: { deptName: '供水运行部' }
+  }), {
+    userId: 18,
+    userName: 'zhangsan',
+    nickName: '张三',
+    deptName: '供水运行部',
+    phone: '未登记手机号',
+    statusText: '正常',
+    eligible: true
+  })
+  assert.equal(normalizeAssigneeUser({ userId: 19, status: '1', delFlag: '0' }).eligible, false)
+})
+
+test('builds server-paged assignee queries using the matching user field', () => {
+  assert.deepEqual(buildAssigneeQuery({ keyword: 'zhang', deptId: 7, pageNum: 2, pageSize: 20 }), {
+    pageNum: 2,
+    pageSize: 20,
+    deptId: 7,
+    status: '0',
+    userName: 'zhang'
+  })
+  assert.equal(buildAssigneeQuery({ keyword: '张', pageNum: 1, pageSize: 10 }).nickName, '张')
+  assert.equal(buildAssigneeQuery({ keyword: '1380013' }).phonenumber, '1380013')
+})
+
+test('keeps only the last clicked assignee for single selection', () => {
+  assert.deepEqual(selectSingleAssignee([{ userId: 1 }, { userId: 2 }]), { userId: 2 })
+  assert.equal(selectSingleAssignee([]), null)
+})
+
+test('accepts only a real dispatched order summary', () => {
+  assert.equal(normalizeDispatchOrderResponse({ data: { orderId: 12, orderNo: 'WO-12', orderStatus: '2' } }).orderNo, 'WO-12')
+  assert.equal(normalizeDispatchOrderResponse({ data: { orderId: 12, orderNo: 'WO-12', orderStatus: 1 } }), null)
+  assert.equal(normalizeDispatchOrderResponse({ data: { orderId: 12, orderStatus: 2 } }), null)
+})