Explorar o código

feat(water): add device CRUD entry and upload actions

Kazerin hai 11 horas
pai
achega
8e1eb0b714

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

@@ -32,8 +32,10 @@ export const saveThresholds = (data) => request({ url: '/waterSupply/alarm/thres
 export const updateThreshold = (data) => request({ url: '/waterSupply/alarm/threshold/update', method: 'put', data })
 export const deleteThresholds = (ids) => request({ url: '/waterSupply/alarm/threshold/delete', method: 'delete', params: { ids } })
 
+export const createWaterDevice = (data) => request({ url: '/waterSupply/device/ledger/create', method: 'post', data })
 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 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)

+ 93 - 15
src/views/subSystem/waterSupply/components/WaterDevicePage.vue

@@ -29,11 +29,18 @@
     </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'" link type="primary" :icon="Upload" @click="openImport">导入</el-button><el-button v-if="view !== 'gis'" link type="primary" :icon="Download" @click="exportData">导出</el-button></div></div>
+      <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">
-        <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 :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>
@@ -43,7 +50,13 @@
         <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="240" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="selectPoint(row)">详情</el-button><el-button v-if="view === 'ledger'" link type="primary" @click="openEdit(row)">修改</el-button><el-button v-if="canDispatch(row)" link type="warning" @click="openOrder(row)">维修派单</el-button><el-button v-if="view === 'maintain'" link type="danger" @click="removeRecord(row)">删除</el-button></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>
@@ -60,11 +73,11 @@
       </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="修改设备信息" direction="rtl" size="680px" append-to-body>
+    <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="设备编码"><el-input v-model="editForm.equipmentCode" disabled /></el-form-item></el-col>
-          <el-col :span="12"><el-form-item label="设备类型"><el-input v-model="editForm.equipmentTypeName" disabled /></el-form-item></el-col>
+          <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>
@@ -75,7 +88,7 @@
           <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="设备图片URL"><el-input v-model="editForm.equipmentImage" 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>
@@ -135,12 +148,13 @@
 
 <script setup>
 import { computed, onMounted, ref, watch } from 'vue'
-import { ElMessage } from 'element-plus'
-import { Monitor, Refresh, Warning, Bell, Tools, DataLine, Upload, Download, Search } from '@element-plus/icons-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, exportAbnormalDevices, exportDeviceLedger, getWaterDeviceById, importDeviceLedger, listAbnormalDevices, listDeviceLedger, listDeviceGisPoints, listMaintainRecords, updateWaterDevice, deleteMaintainRecord } from '@/api/pipeNetwork/waterSupply'
+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'
@@ -149,7 +163,12 @@ const props = defineProps({ title: { type: String, required: true }, subtitle: {
 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 editRules = { equipmentName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }] }
+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) {
@@ -221,7 +240,63 @@ async function submitDispatch() {
   }
 }
 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 }
@@ -237,12 +312,15 @@ async function submitEdit() {
   try { await editFormRef.value?.validate() } catch { return }
   editSaving.value = true
   try {
-    await updateWaterDevice(editForm.value)
-    ElMessage.success('修改成功')
+    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 || '修改失败,请稍后重试')
+    ElMessage.error(error?.response?.data?.msg || error?.message || (editMode.value === 'create' ? '新增失败,请稍后重试' : '修改失败,请稍后重试'))
   } finally {
     editSaving.value = false
   }

+ 9 - 0
tests/waterDeviceExport.test.mjs

@@ -27,6 +27,7 @@ test('water device ledger import and image display use the public contract', asy
 
   assert.match(apiSource, /export const getWaterDeviceById = \(id\) => get\(`\/EquipmentBase\/getById\/\$\{id\}`\)/u)
   assert.match(apiSource, /export const updateWaterDevice = \(data\) => request\(\{ url: '\/EquipmentBase\/updateById', method: 'put', data \}/u)
+  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(pageSource, /importDeviceLedger/u)
@@ -36,6 +37,14 @@ test('water device ledger import and image display use the public contract', asy
   assert.match(pageSource, /updateWaterDevice\(editForm\.value\)/u)
   assert.match(pageSource, /row\.equipmentImage/u)
   assert.match(pageSource, /detail\.equipmentImage/u)
+  assert.match(pageSource, /openCreate/u)
+  assert.match(pageSource, /removeSelectedDevices/u)
+  assert.match(pageSource, /handleDeviceSelectionChange/u)
+  assert.match(pageSource, /ImageUpload v-model="editForm.equipmentImage"/u)
+  assert.match(pageSource, /facility-row-action is-detail/u)
+  assert.match(pageSource, /facility-row-action is-edit/u)
+  assert.match(pageSource, /type="primary" plain size="small" :icon="Plus"/u)
+  assert.match(pageSource, /type="danger" plain size="small" :icon="Delete"/u)
 })
 
 test('recognizes JSON export errors when the MIME includes a charset', async () => {