소스 검색

feat(water): show ledger stats and fix device image urls

Kazerin 10 시간 전
부모
커밋
6329ec7d4c

+ 1 - 0
src/api/pipeNetwork/waterSupply.js

@@ -36,6 +36,7 @@ export const createWaterDevice = (data) => request({ url: '/waterSupply/device/l
 export const getWaterDeviceById = (id) => get(`/EquipmentBase/getById/${id}`)
 export const updateWaterDevice = (data) => request({ url: '/EquipmentBase/updateById', method: 'put', data })
 export const deleteWaterDevices = (ids) => request({ url: '/EquipmentBase/deleteBatchById', method: 'delete', params: { ids: Array.isArray(ids) ? ids.join(',') : ids } })
+export const getDeviceLedgerStatistics = () => get('/waterSupply/device/ledger/statistics')
 export const listDeviceLedger = (params) => get('/waterSupply/device/ledger/page', params)
 export const getDeviceTypeStatistics = () => get('/waterSupply/device/ledger/typeStatistics')
 export const listDeviceGisPoints = (params) => get('/waterSupply/device/ledger/gisPoints', params)

+ 9 - 0
src/utils/waterSupplyUiModel.js

@@ -32,6 +32,14 @@ const onlineStatusLabels = {
   0: '离线'
 }
 
+const deviceImageBaseUrl = import.meta.env?.VITE_APP_BASE_API || ''
+
+export function normalizeDeviceImageUrl(value) {
+  const url = String(value ?? '').trim()
+  if (!url || /^(https?:|blob:|data:)/i.test(url)) return url
+  if (deviceImageBaseUrl && (url === deviceImageBaseUrl || url.startsWith(`${deviceImageBaseUrl}/`))) return url
+  return url.startsWith('/') ? `${deviceImageBaseUrl}${url}` : `${deviceImageBaseUrl}/${url}`
+}
 export function normalizeDeviceRow(row = {}) {
   const alarmStatus = Number(row.alarmStatus)
   const currentStatus = Number(row.currentStatus)
@@ -54,6 +62,7 @@ export function normalizeDeviceRow(row = {}) {
   const workOrderStatus = normalizeWorkOrderStatus(rawOrderStatus)
   return {
     ...row,
+    equipmentImageUrl: normalizeDeviceImageUrl(row.equipmentImage),
     deviceStatusText: currentStatusText,
     currentStatusText,
     onlineStatusText,

+ 1335 - 427
src/views/subSystem/waterSupply/components/WaterDevicePage.vue

@@ -1,442 +1,1350 @@
 <template>
-  <div class="water-page device-page">
-    <div class="page-heading">
-      <div class="page-title">
-        <span class="page-title-icon"><el-icon><component :is="pageIcon" /></el-icon></span>
-        <div><h2>{{ title }}</h2><span class="muted">{{ subtitle }}</span></div>
-      </div>
-      <el-button class="heading-refresh" :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
-    </div>
+    <div class="water-page device-page">
+        <div class="page-heading">
+            <div class="page-title">
+                <span class="page-title-icon"><el-icon><component :is="pageIcon"/></el-icon></span>
+                <div><h2>{{ title }}</h2><span class="muted">{{ subtitle }}</span></div>
+            </div>
+            <el-button class="heading-refresh" :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
+        </div>
 
-    <div v-if="view === 'abnormal'" class="metric-grid">
-      <div v-for="item in abnormalStats" :key="item.label" class="metric">
-        <div class="metric-icon"><el-icon><component :is="item.icon" /></el-icon></div>
-        <div class="metric-body">
-          <span>{{ item.label }}</span>
-          <div class="metric-value"><strong>{{ item.value }}</strong><small>{{ item.unit }}</small></div>
+        <div v-if="view === 'abnormal'" class="metric-grid">
+            <div v-for="item in abnormalStats" :key="item.label" class="metric">
+                <div class="metric-icon">
+                    <el-icon>
+                        <component :is="item.icon"/>
+                    </el-icon>
+                </div>
+                <div class="metric-body">
+                    <span>{{ item.label }}</span>
+                    <div class="metric-value"><strong>{{ item.value }}</strong><small>{{ item.unit }}</small></div>
+                </div>
+            </div>
         </div>
-      </div>
-    </div>
 
-    <el-card shadow="never" class="filter-card">
-      <el-form :inline="true" :model="query">
-        <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 class="filter-select" style="width:180px"><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 class="filter-select" style="width:180px"><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>
-        <div class="panel-actions">
-          <el-button v-if="view === 'ledger'" type="primary" plain size="small" :icon="Plus" @click="openCreate">新增</el-button>
-          <el-button v-if="view === 'ledger'" type="danger" plain size="small" :icon="Delete" :disabled="!selectedDevices.length" @click="removeSelectedDevices">删除</el-button>
-          <el-button v-if="view === 'ledger'" type="info" plain size="small" :icon="Upload" @click="openImport">导入</el-button>
-          <el-button v-if="view !== 'gis'" type="success" plain size="small" :icon="Download" @click="exportData">导出</el-button>
+        <div v-if="view === 'ledger'" class="metric-grid">
+            <div v-for="item in ledgerStatsCards" :key="item.label" class="metric">
+                <div class="metric-icon">
+                    <el-icon>
+                        <component :is="item.icon"/>
+                    </el-icon>
+                </div>
+                <div class="metric-body">
+                    <span>{{ item.label }}</span>
+                    <div class="metric-value"><strong>{{ item.value }}</strong><small>{{ item.unit }}</small></div>
+                </div>
+            </div>
         </div>
-      </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" @selection-change="handleDeviceSelectionChange">
-        <el-table-column v-if="view === 'ledger'" type="selection" width="48" align="center" /><el-table-column type="index" label="#" width="55" /><el-table-column prop="equipmentCode" label="设备编码" min-width="130" /><el-table-column prop="equipmentName" label="设备名称" min-width="150" /><el-table-column prop="equipmentTypeName" label="设备类型" min-width="120" /><el-table-column prop="equipmentLocation" label="安装位置" min-width="180" show-overflow-tooltip />
-        <el-table-column v-if="view === 'ledger'" label="设备图片" width="110" align="center"><template #default="{ row }"><el-image v-if="row.equipmentImage" :src="row.equipmentImage" :preview-src-list="[row.equipmentImage]" preview-teleported hide-on-click-modal fit="cover" class="device-image" /><span v-else class="image-placeholder">暂无图片</span></template></el-table-column>
-        <el-table-column v-if="view !== 'maintain'" label="设备状态" width="100"><template #default="{ row }"><el-tag :type="Number(row.currentStatus) === 3 ? 'danger' : 'success'">{{ row.currentStatusText }}</el-tag></template></el-table-column>
-        <el-table-column v-if="view !== 'maintain'" label="在线状态" width="100"><template #default="{ row }"><el-tag :type="Number(row.onlineStatus) === 1 ? 'success' : 'info'">{{ row.onlineStatusText }}</el-tag></template></el-table-column>
-        <el-table-column v-if="view !== 'maintain'" label="报警状态" width="100"><template #default="{ row }"><el-tag :type="Number(row.alarmStatus) === 1 ? 'danger' : 'success'">{{ row.alarmStatusText }}</el-tag></template></el-table-column>
-        <el-table-column v-if="view !== 'maintain'" label="报警等级" width="110"><template #default="{ row }"><el-tag :type="alarmLevelType(row.alertLevel)">{{ row.alarmLevelText }}</el-tag></template></el-table-column>
-        <el-table-column v-if="view === 'abnormal' || view === 'dispatch'" label="异常原因" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.abnormalReasonText || '-' }}</template></el-table-column>
-        <el-table-column label="关联工单" min-width="190" show-overflow-tooltip><template #default="{ row }">{{ view === 'maintain' ? (row.orderNo || '-') : row.relatedOrderNo }}</template></el-table-column>
-        <el-table-column label="工单状态" min-width="110"><template #default="{ row }"><el-tag v-if="view !== 'maintain' && row.workOrderStatusText !== '暂无工单'" :type="workOrderStatusType(row.workOrderStatusCode)">{{ row.workOrderStatusText }}</el-tag><span v-else>{{ view === 'maintain' ? (row.status || '暂无') : row.workOrderStatusText }}</span></template></el-table-column>
-        <el-table-column v-if="view === 'abnormal' || view === 'dispatch'" label="接单责任人" min-width="110"><template #default="{ row }">{{ view === 'maintain' ? (row.maintainPerson || '-') : (row.relatedWorkOrderPerson || '-') }}</template></el-table-column>
-        <el-table-column label="操作" width="210" fixed="right"><template #default="{ row }"><div class="facility-row-actions">
-          <el-tooltip content="查看详情" placement="top"><el-button class="facility-row-action is-detail" aria-label="查看详情" size="small" type="primary" plain circle :icon="View" @click="selectPoint(row)" /></el-tooltip>
-          <el-tooltip v-if="view === 'ledger'" content="修改信息" placement="top"><el-button class="facility-row-action is-edit" aria-label="修改信息" size="small" type="warning" plain circle :icon="Edit" @click="openEdit(row)" /></el-tooltip>
-          <el-tooltip v-if="canDispatch(row)" content="维修派单" placement="top"><el-button class="facility-row-action is-dispatch" aria-label="维修派单" size="small" type="warning" plain circle :icon="Position" @click="openOrder(row)" /></el-tooltip>
-          <el-tooltip v-if="view === 'ledger'" content="删除设备" placement="top"><el-button class="facility-row-action is-delete" aria-label="删除设备" size="small" type="danger" plain circle :icon="Delete" @click="removeDevice(row)" /></el-tooltip>
-          <el-tooltip v-if="view === 'maintain'" content="删除记录" placement="top"><el-button class="facility-row-action is-delete" aria-label="删除记录" size="small" type="danger" plain circle :icon="Delete" @click="removeRecord(row)" /></el-tooltip>
-        </div></template></el-table-column>
-      </el-table>
-      <el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="page.total" layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" @change="loadData" />
-    </el-card>
-
-    <el-drawer v-model="detailVisible" class="detail-drawer device-detail-drawer" title="设备详情" direction="rtl" size="680px" append-to-body>
-      <div class="detail-summary"><span class="detail-name">{{ detail.equipmentName || '-' }}</span><el-tag :type="Number(detail.currentStatus) === 3 ? 'danger' : 'success'">{{ detail.currentStatusText || '-' }}</el-tag><el-tag :type="Number(detail.alarmStatus) === 1 ? 'danger' : 'success'">{{ detail.alarmStatusText || '-' }}</el-tag><el-tag v-if="detail.workOrderStatusText && detail.workOrderStatusText !== '暂无工单'" :type="workOrderStatusType(detail.workOrderStatusCode)">{{ detail.workOrderStatusText }}</el-tag></div>
-      <div v-if="view === 'ledger'" class="detail-media">
-        <div class="detail-media-label">设备图片</div>
-        <el-image v-if="detail.equipmentImage" :src="detail.equipmentImage" :preview-src-list="[detail.equipmentImage]" preview-teleported hide-on-click-modal fit="cover" class="detail-image" />
-        <div v-else class="detail-image-placeholder">暂无图片</div>
-      </div>
-      <el-descriptions :column="2" border>
-        <el-descriptions-item v-for="item in detailFields" :key="item.label" :label="item.label">{{ item.value }}</el-descriptions-item>
-      </el-descriptions>
-      <template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
-    </el-drawer>
-    <el-drawer v-model="editVisible" class="detail-drawer device-edit-drawer" :title="editMode === 'create' ? '新增设备信息' : '修改设备信息'" direction="rtl" size="680px" append-to-body>
-      <el-form ref="editFormRef" :model="editForm" label-width="110px" :rules="editRules">
-        <el-row :gutter="16">
-          <el-col :span="12"><el-form-item label="设备编码" prop="equipmentCode"><el-input v-model="editForm.equipmentCode" :disabled="editMode === 'edit'" maxlength="50" /></el-form-item></el-col>
-          <el-col :span="12"><el-form-item label="设备类型" prop="equipmentTypeId"><el-select v-if="editMode === 'create'" v-model="editForm.equipmentTypeId" filterable class="full-input"><el-option v-for="item in waterDeviceTypes" :key="item.id" :label="item.typeName" :value="item.id" /></el-select><el-input v-else v-model="editForm.equipmentTypeName" disabled /></el-form-item></el-col>
-          <el-col :span="12"><el-form-item label="设备名称" prop="equipmentName"><el-input v-model="editForm.equipmentName" clearable /></el-form-item></el-col>
-          <el-col :span="12"><el-form-item label="设备型号"><el-input v-model="editForm.equipmentModel" clearable /></el-form-item></el-col>
-          <el-col :span="12"><el-form-item label="所属片区"><el-select v-model="editForm.district" clearable class="full-input"><el-option label="城南" value="城南" /><el-option label="城北" value="城北" /><el-option label="太常" value="太常" /></el-select></el-form-item></el-col>
-          <el-col :span="12"><el-form-item label="制造商"><el-input v-model="editForm.manufacturer" clearable /></el-form-item></el-col>
-          <el-col :span="12"><el-form-item label="经度"><el-input-number v-model="editForm.longitude" :precision="8" :controls="false" class="full-input" /></el-form-item></el-col>
-          <el-col :span="12"><el-form-item label="纬度"><el-input-number v-model="editForm.latitude" :precision="8" :controls="false" class="full-input" /></el-form-item></el-col>
-          <el-col :span="24"><el-form-item label="安装位置"><el-input v-model="editForm.equipmentLocation" clearable /></el-form-item></el-col>
-          <el-col :span="12"><el-form-item label="设备负责人"><el-input v-model="editForm.maintainer" clearable /></el-form-item></el-col>
-          <el-col :span="12"><el-form-item label="联系方式"><el-input v-model="editForm.maintainerPhone" clearable /></el-form-item></el-col>
-          <el-col :span="24"><el-form-item label="权属单位"><el-input v-model="editForm.ownershipUnit" clearable /></el-form-item></el-col>
-          <el-col :span="24"><el-form-item label="设备图片"><ImageUpload v-model="editForm.equipmentImage" :limit="1" :file-size="5" /></el-form-item></el-col>
-          <el-col :span="24"><el-form-item label="备注"><el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-col>
-        </el-row>
-      </el-form>
-      <template #footer><el-button @click="editVisible = false">取消</el-button><el-button type="primary" :loading="editSaving" @click="submitEdit">保存</el-button></template>
-    </el-drawer>
-    <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-card shadow="never" class="filter-card">
+            <el-form :inline="true" :model="query">
+                <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 class="filter-select" style="width:180px">
+                        <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 class="filter-select" style="width:180px">
+                        <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>
+                <div class="panel-actions">
+                    <el-button v-if="view === 'ledger'" type="primary" plain size="small" :icon="Plus"
+                               @click="openCreate">新增
+                    </el-button>
+                    <el-button v-if="view === 'ledger'" type="danger" plain size="small" :icon="Delete"
+                               :disabled="!selectedDevices.length" @click="removeSelectedDevices">删除
+                    </el-button>
+                    <el-button v-if="view === 'ledger'" type="info" plain size="small" :icon="Upload"
+                               @click="openImport">导入
+                    </el-button>
+                    <el-button v-if="view !== 'gis'" type="success" plain size="small" :icon="Download"
+                               @click="exportData">导出
+                    </el-button>
+                </div>
+            </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"
+                      @selection-change="handleDeviceSelectionChange">
+                <el-table-column v-if="view === 'ledger'" type="selection" width="48" align="center"/>
+                <el-table-column type="index" label="#" width="55"/>
+                <el-table-column prop="equipmentCode" label="设备编码" min-width="130"/>
+                <el-table-column prop="equipmentName" label="设备名称" min-width="150"/>
+                <el-table-column prop="equipmentTypeName" label="设备类型" min-width="120"/>
+                <el-table-column prop="equipmentLocation" label="安装位置" min-width="180" show-overflow-tooltip/>
+                <el-table-column v-if="view === 'ledger'" label="设备图片" width="110" align="center">
+                    <template #default="{ row }">
+                        <el-image v-if="row.equipmentImageUrl" :src="row.equipmentImageUrl"
+                                  :preview-src-list="[row.equipmentImageUrl]" preview-teleported hide-on-click-modal
+                                  fit="cover" class="device-image"/>
+                        <span v-else class="image-placeholder">暂无图片</span></template>
+                </el-table-column>
+                <el-table-column v-if="view !== 'maintain'" label="设备状态" width="100">
+                    <template #default="{ row }">
+                        <el-tag :type="Number(row.currentStatus) === 3 ? 'danger' : 'success'">{{ row.currentStatusText
+                            }}
+                        </el-tag>
+                    </template>
+                </el-table-column>
+                <el-table-column v-if="view !== 'maintain'" label="在线状态" width="100">
+                    <template #default="{ row }">
+                        <el-tag :type="Number(row.onlineStatus) === 1 ? 'success' : 'info'">{{ row.onlineStatusText }}
+                        </el-tag>
+                    </template>
+                </el-table-column>
+                <el-table-column v-if="view !== 'maintain'" label="报警状态" width="100">
+                    <template #default="{ row }">
+                        <el-tag :type="Number(row.alarmStatus) === 1 ? 'danger' : 'success'">{{ row.alarmStatusText }}
+                        </el-tag>
+                    </template>
+                </el-table-column>
+                <el-table-column v-if="view !== 'maintain'" label="报警等级" width="110">
+                    <template #default="{ row }">
+                        <el-tag :type="alarmLevelType(row.alertLevel)">{{ row.alarmLevelText }}</el-tag>
+                    </template>
+                </el-table-column>
+                <el-table-column v-if="view === 'abnormal' || view === 'dispatch'" label="异常原因" min-width="150"
+                                 show-overflow-tooltip>
+                    <template #default="{ row }">{{ row.abnormalReasonText || '-' }}</template>
+                </el-table-column>
+                <el-table-column label="关联工单" min-width="190" show-overflow-tooltip>
+                    <template #default="{ row }">{{ view === 'maintain' ? (row.orderNo || '-') : row.relatedOrderNo }}
+                    </template>
+                </el-table-column>
+                <el-table-column label="工单状态" min-width="110">
+                    <template #default="{ row }">
+                        <el-tag v-if="view !== 'maintain' && row.workOrderStatusText !== '暂无工单'"
+                                :type="workOrderStatusType(row.workOrderStatusCode)">{{ row.workOrderStatusText }}
+                        </el-tag>
+                        <span v-else>{{ view === 'maintain' ? (row.status || '暂无') : row.workOrderStatusText }}</span>
+                    </template>
+                </el-table-column>
+                <el-table-column v-if="view === 'abnormal' || view === 'dispatch'" label="接单责任人" min-width="110">
+                    <template #default="{ row }">{{ view === 'maintain' ? (row.maintainPerson || '-') :
+                        (row.relatedWorkOrderPerson || '-') }}
+                    </template>
+                </el-table-column>
+                <el-table-column label="操作" width="210" fixed="right">
+                    <template #default="{ row }">
+                        <div class="facility-row-actions">
+                            <el-tooltip content="查看详情" placement="top">
+                                <el-button class="facility-row-action is-detail" aria-label="查看详情" size="small"
+                                           type="primary" plain circle :icon="View" @click="selectPoint(row)"/>
+                            </el-tooltip>
+                            <el-tooltip v-if="view === 'ledger'" content="修改信息" placement="top">
+                                <el-button class="facility-row-action is-edit" aria-label="修改信息" size="small"
+                                           type="warning" plain circle :icon="Edit" @click="openEdit(row)"/>
+                            </el-tooltip>
+                            <el-tooltip v-if="canDispatch(row)" content="维修派单" placement="top">
+                                <el-button class="facility-row-action is-dispatch" aria-label="维修派单" size="small"
+                                           type="warning" plain circle :icon="Position" @click="openOrder(row)"/>
+                            </el-tooltip>
+                            <el-tooltip v-if="view === 'ledger'" content="删除设备" placement="top">
+                                <el-button class="facility-row-action is-delete" aria-label="删除设备" size="small"
+                                           type="danger" plain circle :icon="Delete" @click="removeDevice(row)"/>
+                            </el-tooltip>
+                            <el-tooltip v-if="view === 'maintain'" content="删除记录" placement="top">
+                                <el-button class="facility-row-action is-delete" aria-label="删除记录" size="small"
+                                           type="danger" plain circle :icon="Delete" @click="removeRecord(row)"/>
+                            </el-tooltip>
+                        </div>
+                    </template>
+                </el-table-column>
             </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="工单类型"><span>1-故障维修</span></el-form-item>
-          <el-form-item label="优先级" required><el-select v-model="dispatchForm.orderLevel" placeholder="请选择优先级"><el-option label="紧急" :value="1" /><el-option label="一般" :value="2" /><el-option label="低" :value="3" /></el-select></el-form-item>
-          <el-form-item label="问题描述" required><el-input v-model="dispatchForm.description" type="textarea" :rows="5" 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>
-
-    <el-dialog v-model="importVisible" title="供水设备批量导入" width="520px" append-to-body @closed="resetImport">
-      <el-upload ref="importUploadRef" drag :limit="1" accept=".xlsx,.xls" :auto-upload="false" :file-list="importFileList" :on-change="handleImportChange" :on-remove="handleImportRemove" :disabled="importUploading">
-        <el-icon class="el-icon--upload"><Upload /></el-icon>
-        <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
-        <template #tip><div class="el-upload__tip import-tip">仅支持 xls、xlsx;设备类型必须属于供水类型树。</div></template>
-      </el-upload>
-      <el-checkbox v-model="importUpdateSupport" class="import-update">更新已存在的设备编码</el-checkbox>
-      <template #footer>
-        <el-button type="primary" :loading="importUploading" @click="submitImport">开始导入</el-button>
-        <el-button @click="importVisible = false">取消</el-button>
-      </template>
-    </el-dialog>
-  </div>
+            <el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="page.total"
+                           layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" @change="loadData"/>
+        </el-card>
+
+        <el-drawer v-model="detailVisible" class="detail-drawer device-detail-drawer" title="设备详情" direction="rtl"
+                   size="680px" append-to-body>
+            <div class="detail-summary"><span class="detail-name">{{ detail.equipmentName || '-' }}</span>
+                <el-tag :type="Number(detail.currentStatus) === 3 ? 'danger' : 'success'">{{ detail.currentStatusText ||
+                    '-' }}
+                </el-tag>
+                <el-tag :type="Number(detail.alarmStatus) === 1 ? 'danger' : 'success'">{{ detail.alarmStatusText || '-'
+                    }}
+                </el-tag>
+                <el-tag v-if="detail.workOrderStatusText && detail.workOrderStatusText !== '暂无工单'"
+                        :type="workOrderStatusType(detail.workOrderStatusCode)">{{ detail.workOrderStatusText }}
+                </el-tag>
+            </div>
+            <div v-if="view === 'ledger'" class="detail-media">
+                <div class="detail-media-label">设备图片</div>
+                <el-image v-if="detail.equipmentImageUrl" :src="detail.equipmentImageUrl"
+                          :preview-src-list="[detail.equipmentImageUrl]" preview-teleported hide-on-click-modal fit="cover"
+                          class="detail-image"/>
+                <div v-else class="detail-image-placeholder">暂无图片</div>
+            </div>
+            <el-descriptions :column="2" border>
+                <el-descriptions-item v-for="item in detailFields" :key="item.label" :label="item.label">{{ item.value
+                    }}
+                </el-descriptions-item>
+            </el-descriptions>
+            <template #footer>
+                <el-button @click="detailVisible = false">关闭</el-button>
+            </template>
+        </el-drawer>
+        <el-drawer v-model="editVisible" class="detail-drawer device-edit-drawer"
+                   :title="editMode === 'create' ? '新增设备信息' : '修改设备信息'" direction="rtl" size="680px"
+                   append-to-body>
+            <el-form ref="editFormRef" :model="editForm" label-width="110px" :rules="editRules">
+                <el-row :gutter="16">
+                    <el-col :span="12">
+                        <el-form-item label="设备编码" prop="equipmentCode">
+                            <el-input v-model="editForm.equipmentCode" :disabled="editMode === 'edit'" maxlength="50"/>
+                        </el-form-item>
+                    </el-col>
+                    <el-col :span="12">
+                        <el-form-item label="设备类型" prop="equipmentTypeId">
+                            <el-select v-if="editMode === 'create'" v-model="editForm.equipmentTypeId" filterable
+                                       class="full-input">
+                                <el-option v-for="item in waterDeviceTypes" :key="item.id" :label="item.typeName"
+                                           :value="item.id"/>
+                            </el-select>
+                            <el-input v-else v-model="editForm.equipmentTypeName" disabled/>
+                        </el-form-item>
+                    </el-col>
+                    <el-col :span="12">
+                        <el-form-item label="设备名称" prop="equipmentName">
+                            <el-input v-model="editForm.equipmentName" clearable/>
+                        </el-form-item>
+                    </el-col>
+                    <el-col :span="12">
+                        <el-form-item label="设备型号">
+                            <el-input v-model="editForm.equipmentModel" clearable/>
+                        </el-form-item>
+                    </el-col>
+                    <el-col :span="12">
+                        <el-form-item label="所属片区">
+                            <el-select v-model="editForm.district" clearable class="full-input">
+                                <el-option label="城南" value="城南"/>
+                                <el-option label="城北" value="城北"/>
+                                <el-option label="太常" value="太常"/>
+                            </el-select>
+                        </el-form-item>
+                    </el-col>
+                    <el-col :span="12">
+                        <el-form-item label="制造商">
+                            <el-input v-model="editForm.manufacturer" clearable/>
+                        </el-form-item>
+                    </el-col>
+                    <el-col :span="12">
+                        <el-form-item label="经度">
+                            <el-input-number v-model="editForm.longitude" :precision="8" :controls="false"
+                                             class="full-input"/>
+                        </el-form-item>
+                    </el-col>
+                    <el-col :span="12">
+                        <el-form-item label="纬度">
+                            <el-input-number v-model="editForm.latitude" :precision="8" :controls="false"
+                                             class="full-input"/>
+                        </el-form-item>
+                    </el-col>
+                    <el-col :span="24">
+                        <el-form-item label="安装位置">
+                            <el-input v-model="editForm.equipmentLocation" clearable/>
+                        </el-form-item>
+                    </el-col>
+                    <el-col :span="12">
+                        <el-form-item label="设备负责人">
+                            <el-input v-model="editForm.maintainer" clearable/>
+                        </el-form-item>
+                    </el-col>
+                    <el-col :span="12">
+                        <el-form-item label="联系方式">
+                            <el-input v-model="editForm.maintainerPhone" clearable/>
+                        </el-form-item>
+                    </el-col>
+                    <el-col :span="24">
+                        <el-form-item label="权属单位">
+                            <el-input v-model="editForm.ownershipUnit" clearable/>
+                        </el-form-item>
+                    </el-col>
+                    <el-col :span="24">
+                        <el-form-item label="设备图片">
+                            <ImageUpload v-model="editForm.equipmentImage" :limit="1" :file-size="5"/>
+                        </el-form-item>
+                    </el-col>
+                    <el-col :span="24">
+                        <el-form-item label="备注">
+                            <el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="500"
+                                      show-word-limit/>
+                        </el-form-item>
+                    </el-col>
+                </el-row>
+            </el-form>
+            <template #footer>
+                <el-button @click="editVisible = false">取消</el-button>
+                <el-button type="primary" :loading="editSaving" @click="submitEdit">保存</el-button>
+            </template>
+        </el-drawer>
+        <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="工单类型"><span>1-故障维修</span></el-form-item>
+                    <el-form-item label="优先级" required>
+                        <el-select v-model="dispatchForm.orderLevel" placeholder="请选择优先级">
+                            <el-option label="紧急" :value="1"/>
+                            <el-option label="一般" :value="2"/>
+                            <el-option label="低" :value="3"/>
+                        </el-select>
+                    </el-form-item>
+                    <el-form-item label="问题描述" required>
+                        <el-input v-model="dispatchForm.description" type="textarea" :rows="5" 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>
+
+        <el-dialog v-model="importVisible" title="供水设备批量导入" width="520px" append-to-body @closed="resetImport">
+            <el-upload ref="importUploadRef" drag :limit="1" accept=".xlsx,.xls" :auto-upload="false"
+                       :file-list="importFileList" :on-change="handleImportChange" :on-remove="handleImportRemove"
+                       :disabled="importUploading">
+                <el-icon class="el-icon--upload">
+                    <Upload/>
+                </el-icon>
+                <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
+                <template #tip>
+                    <div class="el-upload__tip import-tip">仅支持 xls、xlsx;设备类型必须属于供水类型树。</div>
+                </template>
+            </el-upload>
+            <el-checkbox v-model="importUpdateSupport" class="import-update">更新已存在的设备编码</el-checkbox>
+            <template #footer>
+                <el-button type="primary" :loading="importUploading" @click="submitImport">开始导入</el-button>
+                <el-button @click="importVisible = false">取消</el-button>
+            </template>
+        </el-dialog>
+    </div>
 </template>
 
 <script setup>
-import { computed, onMounted, ref, watch } from 'vue'
-import { ElMessage, ElMessageBox } from 'element-plus'
-import { Monitor, Refresh, Warning, Bell, Tools, DataLine, Upload, Download, Search, Plus, Delete, View, Edit, Position } from '@element-plus/icons-vue'
-import { listUser } from '@/api/system/user'
-import { listDept } from '@/api/system/dept'
-import { handleTree } from '@/utils/ruoyi'
-import { createAbnormalOrder, createWaterDevice, deleteWaterDevices, exportAbnormalDevices, exportDeviceLedger, getWaterDeviceById, importDeviceLedger, listAbnormalDevices, listDeviceLedger, listDeviceGisPoints, listMaintainRecords, updateWaterDevice, deleteMaintainRecord } from '@/api/pipeNetwork/waterSupply'
-import { listEquipmentTypeAll } from '@/api/pipeNetwork/pdeviceInform'
-import { normalizePage, readMonitoringExportError } from '@/utils/waterSupplyModel'
-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 importVisible = ref(false); const importUploading = ref(false); const importUpdateSupport = ref(false); const importFileList = ref([]); const importUploadRef = ref(null); const editVisible = ref(false); const editSaving = ref(false); const editFormRef = ref(null); const editForm = ref({})
-const editMode = ref('edit'); const selectedDevices = ref([]); const waterDeviceTypes = ref([])
-const editRules = {
-  equipmentCode: [{ required: true, message: '请输入设备编码', trigger: 'blur' }],
-  equipmentTypeId: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
-  equipmentName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }]
-}
-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: 'warning', 9: 'warning' })[Number(code)] || 'info' }
-const abnormalStats = computed(() => {
-  const rows = page.value.records
-  const alarmCount = rows.filter(row => Number(row.alarmStatus) === 1).length
-  const repairCount = rows.filter(row => Number(row.currentStatus) === 3).length
-  const pendingCount = rows.filter(row => row.workOrderStatusCode && !row.workOrderTerminal).length
-  return [
-    { label: '异常设备', value: page.value.total, unit: '台', icon: Warning },
-    { label: '报警中', value: alarmCount, unit: '台', icon: Bell },
-    { label: '维修中', value: repairCount, unit: '台', icon: Tools },
-    { label: '待闭环', value: pendingCount, unit: '条', icon: DataLine }
-  ]
-})
-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, ...(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 }
-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 || '', orderLevel: 2, 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('删除失败') } }
-function emptyDeviceForm() {
-  return {
-    equipmentId: undefined, equipmentCode: '', equipmentTypeId: undefined, equipmentTypeName: '',
-    equipmentName: '', equipmentModel: '', district: '', manufacturer: '', longitude: undefined,
-    latitude: undefined, equipmentLocation: '', maintainer: '', maintainerPhone: '', ownershipUnit: '',
-    equipmentImage: '', remark: ''
-  }
-}
-function isWaterDeviceType(type, typesById) {
-  if (!type || (type.typeName === '供水' && type.parentTypeId === '0')) return false
-  let cursor = type
-  let guard = 0
-  while (cursor && guard++ <= typesById.size) {
-    if (cursor.typeName === '供水' && cursor.parentTypeId === '0') return true
-    cursor = typesById.get(cursor.parentTypeId)
-  }
-  return false
-}
-async function loadWaterTypes() {
-  if (waterDeviceTypes.value.length) return
-  try {
-    const response = await listEquipmentTypeAll()
-    const rows = response?.data || []
-    const typesById = new Map(rows.map(item => [item.id, item]))
-    waterDeviceTypes.value = rows.filter(item => isWaterDeviceType(item, typesById))
-      .map(({ id, typeName }) => ({ id, typeName }))
-  } catch (error) {
-    ElMessage.warning('供水设备类型加载失败,请重试')
-  }
-}
-async function openCreate() {
-  editMode.value = 'create'
-  editVisible.value = true
-  editSaving.value = false
-  editForm.value = emptyDeviceForm()
-  await loadWaterTypes()
-}
-function handleDeviceSelectionChange(rows) { selectedDevices.value = rows }
-async function confirmDeleteDevices(ids) {
-  await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 台设备吗?删除后不可恢复。`, '删除设备', { type: 'warning' })
-  await deleteWaterDevices(ids)
-  ElMessage.success('删除成功')
-  selectedDevices.value = []
-  query.value.pageNum = 1
-  await loadData()
-}
-async function removeDevice(row) {
-  if (!row?.equipmentId) return
-  await confirmDeleteDevices([row.equipmentId])
-}
-async function removeSelectedDevices() {
-  const ids = selectedDevices.value.map(row => row.equipmentId).filter(Boolean)
-  if (!ids.length) return
-  await confirmDeleteDevices(ids)
-}
-async function openEdit(row) {
-  editMode.value = 'edit'
-  editVisible.value = true
-  editSaving.value = false
-  editForm.value = { ...row }
-  try {
-    const response = await getWaterDeviceById(row.equipmentId)
-    const full = response?.data || response
-    if (full) editForm.value = { ...editForm.value, ...full }
-  } catch (error) {
-    ElMessage.warning('设备详情加载失败,已使用列表数据填充')
-  }
-}
-async function submitEdit() {
-  try { await editFormRef.value?.validate() } catch { return }
-  editSaving.value = true
-  try {
-    const creating = editMode.value === 'create'
-    if (creating) await createWaterDevice(editForm.value)
-    else await updateWaterDevice(editForm.value)
-    ElMessage.success(creating ? '新增成功' : '修改成功')
-    editVisible.value = false
-    if (creating) query.value.pageNum = 1
-    await loadData()
-  } catch (error) {
-    ElMessage.error(error?.response?.data?.msg || error?.message || (editMode.value === 'create' ? '新增失败,请稍后重试' : '修改失败,请稍后重试'))
-  } finally {
-    editSaving.value = false
-  }
-}
-function openImport() { importVisible.value = true }
-function handleImportChange(file, fileList) {
-  if (!/\.(xlsx|xls)$/iu.test(file?.name || '')) {
-    ElMessage.error('仅支持 xls、xlsx 文件')
-    importFileList.value = []
-    return false
-  }
-  importFileList.value = fileList.slice(-1)
-}
-function handleImportRemove(file, fileList) { importFileList.value = fileList }
-function resetImport() {
-  importUploading.value = false
-  importUpdateSupport.value = false
-  importFileList.value = []
-  importUploadRef.value?.clearFiles()
-}
-async function submitImport() {
-  if (!importFileList.value.length) return ElMessage.warning('请选择 xls 或 xlsx 文件')
-  importUploading.value = true
-  try {
-    const response = await importDeviceLedger(importFileList.value[0].raw, importUpdateSupport.value)
-    if (response?.code && response.code !== 200) throw new Error(response.msg || response.message || '导入失败,请检查文件内容')
-    ElMessage.success(response?.msg || response?.data || '导入成功')
-    importVisible.value = false
-    await loadData()
-  } catch (error) {
-    ElMessage.error(error?.response?.data?.msg || error?.response?.data?.message || error?.message || '导入失败,请稍后重试')
-  } finally {
-    importUploading.value = false
-  }
-}
-async function exportData() {
-  try {
-    const response = await (view === 'dispatch' || view === 'abnormal' ? exportAbnormalDevices : exportDeviceLedger)({ ...query.value })
-    const errorMessage = await readMonitoringExportError(response)
-    if (errorMessage) throw new Error(errorMessage)
-    const blob = response instanceof Blob ? response : new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
-    saveAs(blob, `${title}_${new Date().toISOString().slice(0, 10)}.xlsx`)
-    ElMessage.success('导出成功')
-  } catch (error) {
-    const backendMessage = await readMonitoringExportError(error?.response?.data)
-    ElMessage.error(backendMessage || error?.message || '导出失败,请稍后重试')
-  }
-}
-onMounted(loadData)
+    import {computed, onMounted, ref, watch} from 'vue'
+    import {ElMessage, ElMessageBox} from 'element-plus'
+    import {Bell, DataLine, Download, Edit, Monitor, Plus, Position, Refresh, Tools, Upload, View, Warning} from '@element-plus/icons-vue'
+    import {listUser} from '@/api/system/user'
+    import {listDept} from '@/api/system/dept'
+    import {handleTree} from '@/utils/ruoyi'
+    import {
+        createAbnormalOrder,
+        createWaterDevice,
+        deleteMaintainRecord,
+        deleteWaterDevices,
+        exportAbnormalDevices,
+        exportDeviceLedger,
+        getDeviceLedgerStatistics,
+        getWaterDeviceById,
+        importDeviceLedger,
+        listAbnormalDevices,
+        listDeviceGisPoints,
+        listDeviceLedger,
+        listMaintainRecords,
+        updateWaterDevice
+    } from '@/api/pipeNetwork/waterSupply'
+    import {listEquipmentTypeAll} from '@/api/pipeNetwork/pdeviceInform'
+    import {normalizePage, readMonitoringExportError} from '@/utils/waterSupplyModel'
+    import {
+        buildAssigneeQuery,
+        buildMaintainOrderPayload,
+        canDispatchDevice,
+        normalizeAssigneeUser,
+        normalizeDeviceGisPoints,
+        normalizeDeviceRow,
+        normalizeDispatchOrderResponse,
+        preserveDevicePageOnError,
+        selectSingleAssignee
+    } 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 importVisible = ref(false);
+    const importUploading = ref(false);
+    const importUpdateSupport = ref(false);
+    const importFileList = ref([]);
+    const importUploadRef = ref(null);
+    const editVisible = ref(false);
+    const editSaving = ref(false);
+    const editFormRef = ref(null);
+    const editForm = ref({})
+    const editMode = ref('edit');
+    const selectedDevices = ref([]);
+    const waterDeviceTypes = ref([])
+    const editRules = {
+        equipmentCode: [{required: true, message: '请输入设备编码', trigger: 'blur'}],
+        equipmentTypeId: [{required: true, message: '请选择设备类型', trigger: 'change'}],
+        equipmentName: [{required: true, message: '请输入设备名称', trigger: 'blur'}]
+    }
+    const page = ref({records: [], total: 0});
+    const ledgerStats = ref({ total: 0, online: 0, offline: 0, onlineRate: 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: 'warning',
+            9: 'warning'
+        })[Number(code)] || 'info'
+    }
+
+    const abnormalStats = computed(() => {
+        const rows = page.value.records
+        const alarmCount = rows.filter(row => Number(row.alarmStatus) === 1).length
+        const repairCount = rows.filter(row => Number(row.currentStatus) === 3).length
+        const pendingCount = rows.filter(row => row.workOrderStatusCode && !row.workOrderTerminal).length
+        return [
+            {label: '异常设备', value: page.value.total, unit: '台', icon: Warning},
+            {label: '报警中', value: alarmCount, unit: '台', icon: Bell},
+            {label: '维修中', value: repairCount, unit: '台', icon: Tools},
+            {label: '待闭环', value: pendingCount, unit: '条', icon: DataLine}
+        ]
+    })
+    const ledgerStatsCards = computed(() => [
+        {label: '设备数量', value: ledgerStats.value.total, unit: '台', icon: Monitor},
+        {label: '在线设备', value: ledgerStats.value.online, unit: '台', icon: DataLine},
+        {label: '离线设备', value: ledgerStats.value.offline, unit: '台', icon: Warning},
+        {label: '在线率', value: ledgerStats.value.onlineRate, unit: '%', icon: Tools}
+    ])
+
+    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, ...(view === 'dispatch' ? {dispatchableOnly: true} : {})}); else response = await listDeviceLedger({...query.value});
+            const normalized = normalizePage(response);
+            page.value = {...normalized, records: normalized.records.map(normalizeDeviceRow)}
+            if (view === 'ledger') await loadLedgerStats()
+        } 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 loadLedgerStats() {
+        try {
+            const response = await getDeviceLedgerStatistics()
+            const stats = response?.data ?? response ?? {}
+            ledgerStats.value = {
+                total: Number(stats.total ?? 0) || 0,
+                online: Number(stats.online ?? 0) || 0,
+                offline: Number(stats.offline ?? 0) || 0,
+                onlineRate: Number(stats.onlineRate ?? 0) || 0
+            }
+        } catch (error) {
+            console.warn('设备台账统计加载失败', error)
+        }
+    }
+
+    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
+    }
+
+    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 || '',
+            orderLevel: 2,
+            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('删除失败')
+        }
+    }
+
+    function emptyDeviceForm() {
+        return {
+            equipmentId: undefined, equipmentCode: '', equipmentTypeId: undefined, equipmentTypeName: '',
+            equipmentName: '', equipmentModel: '', district: '', manufacturer: '', longitude: undefined,
+            latitude: undefined, equipmentLocation: '', maintainer: '', maintainerPhone: '', ownershipUnit: '',
+            equipmentImage: '', remark: ''
+        }
+    }
+
+    function isWaterDeviceType(type, typesById) {
+        if (!type || (type.typeName === '供水' && type.parentTypeId === '0')) return false
+        let cursor = type
+        let guard = 0
+        while (cursor && guard++ <= typesById.size) {
+            if (cursor.typeName === '供水' && cursor.parentTypeId === '0') return true
+            cursor = typesById.get(cursor.parentTypeId)
+        }
+        return false
+    }
+
+    async function loadWaterTypes() {
+        if (waterDeviceTypes.value.length) return
+        try {
+            const response = await listEquipmentTypeAll()
+            const rows = response?.data || []
+            const typesById = new Map(rows.map(item => [item.id, item]))
+            waterDeviceTypes.value = rows.filter(item => isWaterDeviceType(item, typesById))
+                .map(({id, typeName}) => ({id, typeName}))
+        } catch (error) {
+            ElMessage.warning('供水设备类型加载失败,请重试')
+        }
+    }
+
+    async function openCreate() {
+        editMode.value = 'create'
+        editVisible.value = true
+        editSaving.value = false
+        editForm.value = emptyDeviceForm()
+        await loadWaterTypes()
+    }
+
+    function handleDeviceSelectionChange(rows) {
+        selectedDevices.value = rows
+    }
+
+    async function confirmDeleteDevices(ids) {
+        await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 台设备吗?删除后不可恢复。`, '删除设备', {type: 'warning'})
+        await deleteWaterDevices(ids)
+        ElMessage.success('删除成功')
+        selectedDevices.value = []
+        query.value.pageNum = 1
+        await loadData()
+    }
+
+    async function removeDevice(row) {
+        if (!row?.equipmentId) return
+        await confirmDeleteDevices([row.equipmentId])
+    }
+
+    async function removeSelectedDevices() {
+        const ids = selectedDevices.value.map(row => row.equipmentId).filter(Boolean)
+        if (!ids.length) return
+        await confirmDeleteDevices(ids)
+    }
+
+    async function openEdit(row) {
+        editMode.value = 'edit'
+        editVisible.value = true
+        editSaving.value = false
+        editForm.value = {...row}
+        try {
+            const response = await getWaterDeviceById(row.equipmentId)
+            const full = response?.data || response
+            if (full) editForm.value = {...editForm.value, ...full}
+        } catch (error) {
+            ElMessage.warning('设备详情加载失败,已使用列表数据填充')
+        }
+    }
+
+    async function submitEdit() {
+        try {
+            await editFormRef.value?.validate()
+        } catch {
+            return
+        }
+        editSaving.value = true
+        try {
+            const creating = editMode.value === 'create'
+            if (creating) await createWaterDevice(editForm.value)
+            else await updateWaterDevice(editForm.value)
+            ElMessage.success(creating ? '新增成功' : '修改成功')
+            editVisible.value = false
+            if (creating) query.value.pageNum = 1
+            await loadData()
+        } catch (error) {
+            ElMessage.error(error?.response?.data?.msg || error?.message || (editMode.value === 'create' ? '新增失败,请稍后重试' : '修改失败,请稍后重试'))
+        } finally {
+            editSaving.value = false
+        }
+    }
+
+    function openImport() {
+        importVisible.value = true
+    }
+
+    function handleImportChange(file, fileList) {
+        if (!/\.(xlsx|xls)$/iu.test(file?.name || '')) {
+            ElMessage.error('仅支持 xls、xlsx 文件')
+            importFileList.value = []
+            return false
+        }
+        importFileList.value = fileList.slice(-1)
+    }
+
+    function handleImportRemove(file, fileList) {
+        importFileList.value = fileList
+    }
+
+    function resetImport() {
+        importUploading.value = false
+        importUpdateSupport.value = false
+        importFileList.value = []
+        importUploadRef.value?.clearFiles()
+    }
+
+    async function submitImport() {
+        if (!importFileList.value.length) return ElMessage.warning('请选择 xls 或 xlsx 文件')
+        importUploading.value = true
+        try {
+            const response = await importDeviceLedger(importFileList.value[0].raw, importUpdateSupport.value)
+            if (response?.code && response.code !== 200) throw new Error(response.msg || response.message || '导入失败,请检查文件内容')
+            ElMessage.success(response?.msg || response?.data || '导入成功')
+            importVisible.value = false
+            await loadData()
+        } catch (error) {
+            ElMessage.error(error?.response?.data?.msg || error?.response?.data?.message || error?.message || '导入失败,请稍后重试')
+        } finally {
+            importUploading.value = false
+        }
+    }
+
+    async function exportData() {
+        try {
+            const response = await (view === 'dispatch' || view === 'abnormal' ? exportAbnormalDevices : exportDeviceLedger)({...query.value})
+            const errorMessage = await readMonitoringExportError(response)
+            if (errorMessage) throw new Error(errorMessage)
+            const blob = response instanceof Blob ? response : new Blob([response], {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'})
+            saveAs(blob, `${title}_${new Date().toISOString().slice(0, 10)}.xlsx`)
+            ElMessage.success('导出成功')
+        } catch (error) {
+            const backendMessage = await readMonitoringExportError(error?.response?.data)
+            ElMessage.error(backendMessage || error?.message || '导出失败,请稍后重试')
+        }
+    }
+
+    onMounted(loadData)
 </script>
 
 <style scoped>
-.device-page{padding:20px;background:linear-gradient(135deg,#eff6ff 0%,#f8fafc 45%,#eef2f7 100%);min-height:calc(100vh - 84px)}
-.page-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:16px}
-.page-title{display:flex;align-items:center;gap:12px;min-width:0}
-.page-title-icon{display:grid;place-items:center;width:42px;height:42px;color:#fff;background:linear-gradient(135deg,#2563eb 0%,#06b6d4 100%);border-radius:8px;box-shadow:0 8px 16px rgba(37,99,235,.2)}
-.page-title h2{margin:0 0 4px;font-size:20px;color:#1e293b}
-.muted{font-size:13px;color:#64748b}
-.heading-refresh{border-color:#bfdbfe;color:#2563eb;background:#fff}
-.filter-card,.water-panel{border:1px solid #e2e8f0;border-radius:8px;box-shadow:0 6px 14px rgba(15,23,42,.04)}
-.filter-card{margin-bottom:16px;background:#fff}
-.filter-card :deep(.el-card__header){padding:14px 18px;border-bottom:1px solid #e2e8f0;background:#fbfdff}
-.filter-card :deep(.el-form-item){margin-bottom:0}
-.filter-card :deep(.el-form-item__label){color:#475569}
-.metric-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:16px}
-.metric{display:flex;align-items:center;gap:14px;min-width:0;padding:16px 18px;background:#fff;border:1px solid #e2e8f0;border-radius:8px;box-shadow:0 6px 14px rgba(15,23,42,.04)}
-.metric-icon{position:static;flex:0 0 42px;display:grid;place-items:center;width:42px;height:42px;color:#2563eb;background:linear-gradient(135deg,#dbeafe 0%,#eff6ff 100%);border-radius:8px}
-.metric-icon .el-icon{font-size:22px}
-.metric-body{min-width:0;text-align:left}
-.metric-body span{display:block;overflow:hidden;margin-bottom:3px;color:#64748b;font-size:13px;text-overflow:ellipsis;white-space:nowrap}
-.metric-value{display:flex;align-items:baseline;gap:5px;min-width:0}
-.metric-value strong{font-size:24px;line-height:1.2;color:#1e293b}
-.metric-value small{color:#94a3b8}
-.water-panel{overflow:hidden}
-.water-panel :deep(.el-card__header){padding:14px 18px;border-bottom:1px solid #e2e8f0;background:#fbfdff}
-.card-title{display:flex;align-items:center;gap:8px;color:#1e293b;font-weight:600}
-.card-title .el-icon{color:#2563eb;font-size:18px}
-.table-toolbar{display:flex;justify-content:space-between;align-items:center}
-.panel-actions{display:flex;align-items:center;gap:8px}
-.load-error{margin-bottom:14px}
-.device-page :deep(.el-table){--el-table-border-color:#e2e8f0;--el-table-header-bg-color:#f8fafc;--el-table-header-text-color:#475569;--el-table-row-hover-bg-color:#eff6ff}
-.device-page :deep(.el-table__row){cursor:pointer}
-.device-image{width:66px;height:44px;border-radius:6px;overflow:hidden}
-.image-placeholder{display:inline-grid;place-items:center;width:66px;height:44px;color:#94a3b8;font-size:12px;background:#f1f5f9;border-radius:6px}
-.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),.device-edit-drawer :deep(.el-drawer__body){overflow:auto;padding:20px 24px}
-.device-detail-drawer :deep(.el-descriptions__label){white-space:nowrap}
-.device-detail-drawer :deep(.el-descriptions__content){overflow-wrap:anywhere;word-break:break-word}
-.detail-media{margin-bottom:14px;padding:12px;background:#f7fbff;border:1px solid rgba(37,99,235,.1);border-radius:10px}
-.detail-media-label{margin-bottom:8px;font-weight:600;color:#17324d}
-.detail-image{width:100%;height:180px;border-radius:8px;overflow:hidden}
-.detail-image-placeholder{display:grid;place-items:center;height:120px;background:#f1f5f9;border-radius:8px;color:#909399}
-.full-input{width:100%}
-.import-update{margin-top:10px}
-.import-tip{line-height:1.6}
-.el-pagination{justify-content:flex-end;margin-top:16px}
-.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;justify-content:space-between;align-items:center;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}
-@media(max-width:900px){.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.filter-card :deep(.el-form-item){margin-bottom:12px}}
-@media(max-width:768px){.device-detail-drawer,.device-edit-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}}
+    .device-page {
+        padding: 20px;
+        background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 45%, #eef2f7 100%);
+        min-height: calc(100vh - 84px)
+    }
+
+    .page-heading {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        gap: 16px;
+        margin-bottom: 16px
+    }
+
+    .page-title {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+        min-width: 0
+    }
+
+    .page-title-icon {
+        display: grid;
+        place-items: center;
+        width: 42px;
+        height: 42px;
+        color: #fff;
+        background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);
+        border-radius: 8px;
+        box-shadow: 0 8px 16px rgba(37, 99, 235, .2)
+    }
+
+    .page-title h2 {
+        margin: 0 0 4px;
+        font-size: 20px;
+        color: #1e293b
+    }
+
+    .muted {
+        font-size: 13px;
+        color: #64748b
+    }
+
+    .heading-refresh {
+        border-color: #bfdbfe;
+        color: #2563eb;
+        background: #fff
+    }
+
+    .filter-card, .water-panel {
+        border: 1px solid #e2e8f0;
+        border-radius: 8px;
+        box-shadow: 0 6px 14px rgba(15, 23, 42, .04)
+    }
+
+    .filter-card {
+        margin-bottom: 16px;
+        background: #fff
+    }
+
+    .filter-card :deep(.el-card__header) {
+        padding: 14px 18px;
+        border-bottom: 1px solid #e2e8f0;
+        background: #fbfdff
+    }
+
+    .filter-card :deep(.el-form-item) {
+        margin-top: 8px;
+        margin-right: 16px;
+        margin-bottom: 8px
+    }
+
+    .filter-card :deep(.el-form-item__label) {
+        color: #475569
+    }
+
+    .metric-grid {
+        display: grid;
+        grid-template-columns:repeat(4, minmax(0, 1fr));
+        gap: 14px;
+        margin-bottom: 16px
+    }
+
+    .metric {
+        display: flex;
+        align-items: center;
+        gap: 14px;
+        min-width: 0;
+        padding: 16px 18px;
+        background: #fff;
+        border: 1px solid #e2e8f0;
+        border-radius: 8px;
+        box-shadow: 0 6px 14px rgba(15, 23, 42, .04)
+    }
+
+    .metric-icon {
+        position: static;
+        flex: 0 0 42px;
+        display: grid;
+        place-items: center;
+        width: 42px;
+        height: 42px;
+        color: #2563eb;
+        background: linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%);
+        border-radius: 8px
+    }
+
+    .metric-icon .el-icon {
+        font-size: 22px
+    }
+
+    .metric-body {
+        min-width: 0;
+        text-align: left
+    }
+
+    .metric-body span {
+        display: block;
+        overflow: hidden;
+        margin-bottom: 3px;
+        color: #64748b;
+        font-size: 13px;
+        text-overflow: ellipsis;
+        white-space: nowrap
+    }
+
+    .metric-value {
+        display: flex;
+        align-items: baseline;
+        gap: 5px;
+        min-width: 0
+    }
+
+    .metric-value strong {
+        font-size: 24px;
+        line-height: 1.2;
+        color: #1e293b
+    }
+
+    .metric-value small {
+        color: #94a3b8
+    }
+
+    .water-panel {
+        overflow: hidden
+    }
+
+    .water-panel :deep(.el-card__header) {
+        padding: 14px 18px;
+        border-bottom: 1px solid #e2e8f0;
+        background: #fbfdff
+    }
+
+    .card-title {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        color: #1e293b;
+        font-weight: 600;
+        margin-bottom: 16px
+    }
+
+    .card-title .el-icon {
+        color: #2563eb;
+        font-size: 18px
+    }
+
+    .table-toolbar {
+        display: flex;
+        justify-content: space-between;
+        align-items: center
+    }
+
+    .panel-actions {
+        display: flex;
+        align-items: center;
+        gap: 8px
+    }
+
+    .load-error {
+        margin-bottom: 14px
+    }
+
+    .device-page :deep(.el-table) {
+        --el-table-border-color: #e2e8f0;
+        --el-table-header-bg-color: #f8fafc;
+        --el-table-header-text-color: #475569;
+        --el-table-row-hover-bg-color: #eff6ff
+    }
+
+    .device-page :deep(.el-table__row) {
+        cursor: pointer
+    }
+
+    .device-image {
+        width: 66px;
+        height: 44px;
+        border-radius: 6px;
+        overflow: hidden
+    }
+
+    .image-placeholder {
+        display: inline-grid;
+        place-items: center;
+        width: 66px;
+        height: 44px;
+        color: #94a3b8;
+        font-size: 12px;
+        background: #f1f5f9;
+        border-radius: 6px
+    }
+
+    .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), .device-edit-drawer :deep(.el-drawer__body) {
+        overflow: auto;
+        padding: 20px 24px
+    }
+
+    .device-detail-drawer :deep(.el-descriptions__label) {
+        white-space: nowrap
+    }
+
+    .device-detail-drawer :deep(.el-descriptions__content) {
+        overflow-wrap: anywhere;
+        word-break: break-word
+    }
+
+    .detail-media {
+        margin-bottom: 14px;
+        padding: 12px;
+        background: #f7fbff;
+        border: 1px solid rgba(37, 99, 235, .1);
+        border-radius: 10px
+    }
+
+    .detail-media-label {
+        margin-bottom: 8px;
+        font-weight: 600;
+        color: #17324d
+    }
+
+    .detail-image {
+        width: 100%;
+        height: 180px;
+        border-radius: 8px;
+        overflow: hidden
+    }
+
+    .detail-image-placeholder {
+        display: grid;
+        place-items: center;
+        height: 120px;
+        background: #f1f5f9;
+        border-radius: 8px;
+        color: #909399
+    }
+
+    .full-input {
+        width: 100%
+    }
+
+    .import-update {
+        margin-top: 10px
+    }
+
+    .import-tip {
+        line-height: 1.6
+    }
+
+    .el-pagination {
+        justify-content: flex-end;
+        margin-top: 16px
+    }
+
+    .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;
+        justify-content: space-between;
+        align-items: center;
+        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
+    }
+
+    @media (max-width: 900px) {
+        .metric-grid {
+            grid-template-columns:repeat(2, minmax(0, 1fr))
+        }
+
+        .filter-card :deep(.el-form-item) {
+            margin-bottom: 12px
+        }
+    }
+
+    @media (max-width: 768px) {
+        .device-detail-drawer, .device-edit-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>
 <style lang="scss">
-@use './waterSupplyPageTheme.scss';
-@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}}
+    @use './waterSupplyPageTheme.scss';
+    @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>

+ 9 - 9
tests/waterDeviceAssigneeSelector.test.mjs

@@ -19,7 +19,7 @@ test('assignee loading failure remains in place with a retry action', () => {
 })
 
 test('assignee loading failure preserves existing rows and total', () => {
-  const loadFn = source.match(/async function loadAssigneeUsers\(\)[\s\S]*?\nfunction searchAssigneeUsers/)?.[0] || ''
+  const loadFn = source.match(new RegExp("async function loadAssigneeUsers\\(\\)[\\s\\S]*?\\n\\s*function searchAssigneeUsers"))?.[0] || ""
   assert.doesNotMatch(loadFn, /users\.value = \[\]/)
   assert.doesNotMatch(loadFn, /assigneeTotal\.value = 0/)
   assert.match(loadFn, /userLoadError\.value = '责任人列表加载失败,请点击重试'/)
@@ -27,25 +27,25 @@ test('assignee loading failure preserves existing rows and total', () => {
 
 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\(\) \}/)
+  assert.match(source, /async function retryAssigneeLoading\(\)\s*\{[\s\S]*?await loadDeptTree\(true\)[\s\S]*?await loadAssigneeUsers\(\)[\s\S]*?\}/)
 })
 
 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\(\) \}/)
+  assert.match(source, /function searchAssigneeUsers\(\)\s*\{[\s\S]*?assigneePageNum\.value = 1;?\s*loadAssigneeUsers\(\)[\s\S]*?\}/)
 })
 
 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, /buildAssigneeQuery\(\{\s*deptId:\s*assigneeDeptId\.value,\s*keyword:\s*assigneeKeyword\.value,\s*pageNum:\s*assigneePageNum\.value,\s*pageSize:\s*assigneePageSize\.value\s*\}\)/)
   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, /function chooseAssignee\(row\)\s*\{[\s\S]*?assigneeDraft\.value = selectSingleAssignee\(\[row\]\)[\s\S]*?\}/)
+  assert.match(source, /function confirmAssignee\(\)\s*\{[\s\S]*?selectedAssignee\.value = assigneeDraft\.value;?\s*dispatchForm\.value\.receiveUserId = assigneeDraft\.value\?\.userId[\s\S]*?\}/)
   assert.match(source, /<div class="dispatch-layout">/)
   assert.match(source, /<el-form class="dispatch-form"/)
   assert.match(source, /:current-row-key="assigneeDraft\?\.userId"/)
@@ -60,9 +60,9 @@ test('opens the split dispatch dialog with a fresh assignee query', () => {
 })
 
 test('dispatch form exposes fixed fault type and editable priority', () => {
-  assert.match(source, /<el-form-item label="工单类型"><span>1-故障维修<\/span><\/el-form-item>/)
-  assert.match(source, /<el-form-item label="优先级" required><el-select v-model="dispatchForm\.orderLevel"/)
-  assert.match(source, /<el-option label="紧急" :value="1" \/>/)
+  assert.match(source, /<el-form-item label="工单类型">\s*<span>1-故障维修<\/span>\s*<\/el-form-item>/)
+  assert.match(source, /<el-form-item label="优先级" required>\s*<el-select v-model="dispatchForm\.orderLevel"/)
+  assert.match(source, /<el-option\s+label="紧急"\s+:value="1"\s*\/?>/)
 })
 
 test('dispatch requires a valid server order summary and preserves failure state', () => {

+ 6 - 2
tests/waterDeviceExport.test.mjs

@@ -30,14 +30,18 @@ test('water device ledger import and image display use the public contract', asy
   assert.match(apiSource, /deleteWaterDevices/u)
   assert.match(apiSource, /export const importDeviceLedger = \(file, updateSupport = false\)/u)
   assert.match(apiSource, /url: '\/waterSupply\/device\/ledger\/import'/u)
+  assert.match(apiSource, /export const getDeviceLedgerStatistics = \(\) => get\('\/waterSupply\/device\/ledger\/statistics'\)/u)
   assert.match(pageSource, /importDeviceLedger/u)
   assert.match(pageSource, /供水设备批量导入/u)
   assert.match(pageSource, /importUpdateSupport/u)
   assert.match(pageSource, /openEdit\(row\)/u)
   assert.match(pageSource, /updateWaterDevice\(editForm\.value\)/u)
-  assert.match(pageSource, /row\.equipmentImage/u)
-  assert.match(pageSource, /detail\.equipmentImage/u)
+  assert.match(pageSource, /row\.equipmentImageUrl/u)
+  assert.match(pageSource, /detail\.equipmentImageUrl/u)
   assert.match(pageSource, /openCreate/u)
+  assert.match(pageSource, /ledgerStatsCards/u)
+  assert.match(pageSource, /设备数量/u)
+  assert.match(pageSource, /在线率/u)
   assert.match(pageSource, /removeSelectedDevices/u)
   assert.match(pageSource, /handleDeviceSelectionChange/u)
   assert.match(pageSource, /ImageUpload v-model="editForm.equipmentImage"/u)