| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181 |
- <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 :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
- </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><el-option label="正常" :value="0" /><el-option label="报警" :value="1" /></el-select></el-form-item>
- <el-form-item v-if="view === 'ledger' || view === 'dispatch' || view === 'abnormal'" label="报警等级"><el-select v-model="query.alertLevel" clearable><el-option v-for="item in levels" :key="item.value" v-bind="item" /></el-select></el-form-item>
- <el-form-item><el-button type="primary" @click="loadData">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
- </el-form>
- </el-card>
- <el-card shadow="never" class="water-panel">
- <div class="card-title"><span><el-icon><Monitor /></el-icon> {{ title }}清单</span><el-button v-if="view !== 'gis'" link type="primary" @click="exportData">导出</el-button></div>
- <el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="load-error"><el-button size="small" type="danger" plain @click="loadData">重试</el-button></el-alert>
- <el-empty v-if="view === 'gis' && !loading && !loadError && !page.records.length" description="暂无设备 GIS 点位数据" />
- <el-table :data="page.records" border stripe v-loading="loading" row-key="equipmentId">
- <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 !== '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="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="selectPoint(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>
- <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>
- <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-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)"> </el-radio></template></el-table-column>
- <el-table-column prop="nickName" label="昵称" min-width="100" />
- <el-table-column prop="userName" label="用户名" min-width="110" />
- <el-table-column prop="deptName" label="部门" min-width="120" show-overflow-tooltip />
- <el-table-column prop="phone" label="手机号" min-width="130" />
- <el-table-column prop="statusText" label="账号状态" width="90" />
- </el-table>
- <el-empty v-if="!userLoading && !userLoadError && !users.length" description="暂无符合条件的责任人" :image-size="80" />
- <el-pagination v-model:current-page="assigneePageNum" v-model:page-size="assigneePageSize" :total="assigneeTotal" layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" @current-change="loadAssigneeUsers" @size-change="handleAssigneeSizeChange" />
- <div class="assignee-picked">已选责任人:<strong v-if="assigneeDraft">{{ assigneeDraft.nickName || assigneeDraft.userName }}</strong><span v-else>未选择</span><el-button size="small" type="primary" plain :disabled="!assigneeDraft" @click="confirmAssignee">确认选择</el-button></div>
- </section>
- </div>
- <el-form class="dispatch-form" :model="dispatchForm" label-width="100px">
- <el-form-item label="设备"><span>{{ dispatchDevice.equipmentName }}({{ dispatchDevice.equipmentCode }})</span></el-form-item>
- <el-form-item label="问题描述" required><el-input v-model="dispatchForm.description" type="textarea" :rows="7" maxlength="500" show-word-limit placeholder="请输入故障或异常描述" /></el-form-item>
- <el-form-item label="接单责任人" required>
- <span v-if="selectedAssignee" class="assignee-summary">{{ selectedAssignee.nickName || selectedAssignee.userName }}({{ selectedAssignee.deptName || '-' }})</span>
- <span v-else class="assignee-placeholder">请在左侧选择责任人</span>
- </el-form-item>
- </el-form>
- </div>
- <template #footer><el-button @click="dispatchVisible = false">取消</el-button><el-button type="primary" :loading="dispatching" @click="submitDispatch">确认派单</el-button></template>
- </el-dialog>
- </div>
- </template>
- <script setup>
- import { computed, onMounted, ref, watch } from 'vue'
- import { ElMessage } from 'element-plus'
- import { Monitor, Refresh } from '@element-plus/icons-vue'
- import { listUser } from '@/api/system/user'
- import { listDept } from '@/api/system/dept'
- import { handleTree } from '@/utils/ruoyi'
- import { createAbnormalOrder, exportAbnormalDevices, exportDeviceLedger, listAbnormalDevices, listDeviceLedger, listDeviceGisPoints, listMaintainRecords, deleteMaintainRecord } from '@/api/pipeNetwork/waterSupply'
- import { normalizePage, readMonitoringExportError } from '@/utils/waterSupplyModel'
- import { buildMaintainOrderPayload, normalizeDeviceRow, canDispatchDevice, normalizeDeviceGisPoints, preserveDevicePageOnError, normalizeAssigneeUser, buildAssigneeQuery, selectSingleAssignee, normalizeDispatchOrderResponse } from '@/utils/waterSupplyUiModel'
- import { saveAs } from 'file-saver'
- const props = defineProps({ title: { type: String, required: true }, subtitle: { type: String, default: '设备信息、运行状态与维护过程管理' }, view: { type: String, default: 'ledger' } })
- const { title, subtitle, view } = props
- const query = ref({ pageNum: 1, pageSize: 10, equipmentName: '', equipmentCode: '', alarmStatus: undefined, alertLevel: undefined })
- const page = ref({ records: [], total: 0 }); const loading = ref(false); const loadError = ref(''); const detailVisible = ref(false); const detail = ref({}); const dispatchVisible = ref(false); const dispatching = ref(false); const dispatchDevice = ref({}); const dispatchForm = ref({ description: '', receiveUserId: undefined }); const users = ref([]); const userLoading = ref(false); const 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 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 || '', receiveUserId: undefined }
- selectedAssignee.value = null
- assigneeDraft.value = null
- assigneeDeptId.value = undefined
- assigneeKeyword.value = ''
- assigneePageNum.value = 1
- dispatchVisible.value = true
- await retryAssigneeLoading()
- }
- function handleDeptClick(node) { assigneeDeptId.value = node.deptId; assigneePageNum.value = 1; loadAssigneeUsers() }
- function chooseAssignee(row) { assigneeDraft.value = selectSingleAssignee([row]) }
- function confirmAssignee() { selectedAssignee.value = assigneeDraft.value; dispatchForm.value.receiveUserId = assigneeDraft.value?.userId }
- async function submitDispatch() {
- if (!dispatchForm.value.description?.trim()) return ElMessage.warning('请填写问题描述')
- if (!dispatchForm.value.receiveUserId) return ElMessage.warning('请选择接单责任人')
- dispatching.value = true
- try {
- const response = await createAbnormalOrder(buildMaintainOrderPayload(dispatchDevice.value, dispatchForm.value))
- const summary = normalizeDispatchOrderResponse(response)
- if (!summary) throw new Error('派单失败:服务端未返回有效的待接单工单')
- ElMessage.success(`已派单,等待接单(${summary.orderNo})`)
- dispatchVisible.value = false
- await loadData()
- } catch (error) {
- const responseData = error?.response?.data
- const message = responseData?.msg || responseData?.message || error?.message || '派单失败,请检查权限或服务状态'
- // A conflict means another order now owns the device; refresh the list so its order number is visible.
- if (/已有|冲突|重复|处理中的工单|关联工单/.test(String(message))) {
- await loadData()
- ElMessage.warning(`该设备已有相关处置工单:${message}`)
- } else {
- ElMessage.error(message)
- }
- } finally {
- dispatching.value = false
- }
- }
- async function removeRecord(row) { try { await deleteMaintainRecord(row.maintainId || row.id); ElMessage.success('记录已删除'); await loadData() } catch (error) { ElMessage.error('删除失败') } }
- async function exportData() {
- try {
- 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:#f5f7fa;min-height:calc(100vh - 84px)}.page-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.page-title{display:flex;gap:12px;align-items:center}.page-title h2{margin:0 0 5px;font-size:20px}.page-title-icon{display:grid;place-items:center;width:38px;height:38px;color:#2563eb;background:#eaf2ff;border-radius:8px}.muted{font-size:13px;color:#909399}.filter-card{margin-bottom:16px}.filter-card :deep(.el-form-item){margin-bottom:0}.card-title{display:flex;justify-content:space-between;align-items:center;font-weight:600;margin-bottom:14px}.el-pagination{justify-content:flex-end;margin-top:16px}.detail-summary{display:flex;align-items:center;gap:12px;margin-bottom:16px}.detail-name{font-size:18px;font-weight:700;color:#17324d}.device-detail-drawer :deep(.el-drawer__body){overflow:auto}.device-detail-drawer :deep(.el-descriptions__label){white-space:nowrap}.device-detail-drawer :deep(.el-descriptions__content){word-break:break-word;overflow-wrap:anywhere}
- .dispatch-layout{display:flex;gap:20px;min-height:420px}.dispatch-form{width:300px;min-width:280px;padding-left:20px;border-left:1px solid #e4e7ed}.assignee-field{display:flex;align-items:center;gap:12px;width:100%;min-height:32px}.assignee-summary{color:#17324d}.assignee-placeholder{color:#909399}.assignee-picker{display:flex;gap:16px;flex:1;min-width:0;min-height:390px}.assignee-depts{width:190px;min-width:190px;padding-right:16px;border-right:1px solid #e4e7ed}.assignee-tree{margin-top:10px;max-height:340px;overflow:auto}.assignee-users{flex:1;min-width:0}.assignee-search{display:flex;gap:8px;margin-bottom:12px}.assignee-search .el-input{flex:1}.assignee-users .el-pagination{margin-top:12px}.assignee-picked{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:14px;padding:10px 12px;background:#f5f7fa;border:1px solid #ebeef5;color:#606266}.assignee-picked strong{color:#2563eb}.assignee-users .el-alert{margin-bottom:12px}
- </style>
- <style lang="scss">
- @use './waterSupplyPageTheme.scss';
- @media(max-width:768px){.device-detail-drawer{width:100%!important}.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>
|