HazardList.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. <template>
  2. <div class="app-container">
  3. <el-form ref="queryRef" :inline="true" :model="searchForm" v-show="showSearch" label-width="80px">
  4. <el-form-item label="隐患类型" prop="hazardType">
  5. <el-select v-model="searchForm.hazardType" placeholder="请选择隐患类型" clearable style="width: 160px">
  6. <el-option v-for="d in hazardTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
  7. </el-select>
  8. </el-form-item>
  9. <el-form-item label="隐患等级" prop="hazardLevel">
  10. <el-select v-model="searchForm.hazardLevel" placeholder="请选择隐患等级" clearable style="width: 160px">
  11. <el-option v-for="d in hazardLevelDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
  12. </el-select>
  13. </el-form-item>
  14. <el-form-item label="状态" prop="hazardStatus">
  15. <el-select v-model="searchForm.hazardStatus" placeholder="请选择状态" clearable style="width: 160px">
  16. <el-option v-for="d in hazardStatusDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
  17. </el-select>
  18. </el-form-item>
  19. <el-form-item label="报送状态" prop="isSubmitted">
  20. <el-select v-model="searchForm.isSubmitted" placeholder="请选择报送状态" clearable style="width: 160px">
  21. <el-option label="未报送" :value="0" />
  22. <el-option label="已报送" :value="1" />
  23. </el-select>
  24. </el-form-item>
  25. <el-form-item label="关联管网" prop="networkId">
  26. <el-select-v2 v-model="searchForm.networkId" placeholder="请选择关联管网" clearable filterable style="width: 200px" :options="networkSelectOptions" @focus="loadNetworkOptions" />
  27. </el-form-item>
  28. <el-form-item label="关联设备" prop="equipmentId">
  29. <el-select-v2 v-model="searchForm.equipmentId" placeholder="请选择关联设备" clearable filterable style="width: 200px" :options="equipmentSelectOptions" @focus="loadEquipmentOptions" />
  30. </el-form-item>
  31. <el-form-item>
  32. <el-button type="primary" icon="Search" @click="handleSearch">搜索</el-button>
  33. <el-button icon="Refresh" @click="handleReset">重置</el-button>
  34. </el-form-item>
  35. </el-form>
  36. <el-row :gutter="10" class="mb8">
  37. <el-col :span="1.5">
  38. <el-button type="primary" plain icon="Plus" @click="handleAdd">新增</el-button>
  39. </el-col>
  40. <right-toolbar v-model:showSearch="showSearch" @queryTable="loadData" />
  41. </el-row>
  42. <el-table :data="tableData" v-loading="loading">
  43. <el-table-column prop="hazardNo" label="隐患编号" width="130" show-overflow-tooltip />
  44. <el-table-column prop="hazardType" label="隐患类型" width="100" align="center">
  45. <template #default="{ row }">{{ getDictLabel(hazardTypeDict, row.hazardType) }}</template>
  46. </el-table-column>
  47. <el-table-column prop="hazardLevel" label="隐患等级" width="100" align="center">
  48. <template #default="{ row }">
  49. <el-tag :type="levelTag(row.hazardLevel)">{{ getDictLabel(hazardLevelDict, row.hazardLevel) }}</el-tag>
  50. </template>
  51. </el-table-column>
  52. <el-table-column prop="hazardDesc" label="隐患描述" min-width="180" show-overflow-tooltip />
  53. <el-table-column prop="hazardSource" label="隐患来源" width="110" align="center">
  54. <template #default="{ row }">{{ getDictLabel(hazardSourceDict, row.hazardSource) }}</template>
  55. </el-table-column>
  56. <el-table-column prop="hazardStatus" label="状态" width="100" align="center">
  57. <template #default="{ row }">
  58. <el-tag :type="statusTag(row.hazardStatus)">{{ getDictLabel(hazardStatusDict, row.hazardStatus) }}</el-tag>
  59. </template>
  60. </el-table-column>
  61. <el-table-column label="隐患图片" width="90" align="center">
  62. <template #default="{ row }">
  63. <template v-if="getRowImageUrls(row).length">
  64. <el-image
  65. :src="getRowImageUrls(row)[0]"
  66. fit="contain"
  67. lazy
  68. :preview-src-list="getRowImageUrls(row)"
  69. preview-teleported
  70. hide-on-click-modal
  71. class="table-image"
  72. />
  73. </template>
  74. <el-tag v-else type="info" size="small">无</el-tag>
  75. </template>
  76. </el-table-column>
  77. <el-table-column label="关联管网" width="140" show-overflow-tooltip>
  78. <template #default="{ row }">{{ getNetworkName(row.networkId) }}</template>
  79. </el-table-column>
  80. <el-table-column label="关联设备" width="140" show-overflow-tooltip>
  81. <template #default="{ row }">{{ getEquipmentName(row.equipmentId) }}</template>
  82. </el-table-column>
  83. <el-table-column prop="reportUnit" label="上报单位" width="130" show-overflow-tooltip />
  84. <el-table-column prop="reportTime" label="上报时间" width="170" align="center" />
  85. <el-table-column label="操作" width="200" fixed="right" align="center" class-name="small-padding fixed-width">
  86. <template #default="{ row }">
  87. <el-button link type="primary" icon="Edit" @click="handleEdit(row)">修改</el-button>
  88. <el-button link type="primary" icon="View" @click="handleDetail(row)">详情</el-button>
  89. <el-button link type="danger" icon="Delete" @click="handleDelete(row)">删除</el-button>
  90. </template>
  91. </el-table-column>
  92. </el-table>
  93. <pagination
  94. v-show="total > 0"
  95. :total="total"
  96. v-model:page="pageNum"
  97. v-model:limit="pageSize"
  98. :page-sizes="[10, 20, 50]"
  99. @pagination="loadData"
  100. />
  101. <!-- 新增/编辑对话框 -->
  102. <el-dialog :title="dialogTitle" v-model="dialogVisible" width="750px" append-to-body destroy-on-close>
  103. <el-form :model="formData" ref="formRef" label-width="100px" :rules="rules">
  104. <el-row :gutter="16">
  105. <el-col :span="12">
  106. <el-form-item label="隐患编号" prop="hazardNo">
  107. <el-input v-model="formData.hazardNo" placeholder="保存时自动生成" readonly />
  108. </el-form-item>
  109. </el-col>
  110. <el-col :span="12">
  111. <el-form-item label="隐患类型" prop="hazardType">
  112. <el-select v-model="formData.hazardType" style="width: 100%">
  113. <el-option v-for="d in hazardTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
  114. </el-select>
  115. </el-form-item>
  116. </el-col>
  117. <el-col :span="12">
  118. <el-form-item label="隐患等级" prop="hazardLevel">
  119. <el-select v-model="formData.hazardLevel" style="width: 100%">
  120. <el-option v-for="d in hazardLevelDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
  121. </el-select>
  122. </el-form-item>
  123. </el-col>
  124. <el-col :span="12">
  125. <el-form-item label="隐患来源" prop="hazardSource">
  126. <el-select v-model="formData.hazardSource" style="width: 100%" placeholder="请选择隐患来源">
  127. <el-option v-for="d in hazardSourceDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
  128. </el-select>
  129. </el-form-item>
  130. </el-col>
  131. <el-col :span="12">
  132. <el-form-item label="关联管网">
  133. <el-select-v2 v-model="formData.networkId" placeholder="请选择" clearable filterable style="width: 100%" :options="networkSelectOptions" />
  134. </el-form-item>
  135. </el-col>
  136. <el-col :span="12">
  137. <el-form-item label="关联设备" prop="equipmentId">
  138. <el-select-v2 v-model="formData.equipmentId" placeholder="请选择" clearable filterable style="width: 100%" :options="equipmentSelectOptions" />
  139. </el-form-item>
  140. </el-col>
  141. <el-col :span="12">
  142. <el-form-item label="上报单位">
  143. <el-input v-model="formData.reportUnit" />
  144. </el-form-item>
  145. </el-col>
  146. <el-col :span="12">
  147. <el-form-item label="上报时间" prop="reportTime">
  148. <el-date-picker v-model="formData.reportTime" type="datetime" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width: 100%" placeholder="请选择上报时间" />
  149. </el-form-item>
  150. </el-col>
  151. <el-col :span="24">
  152. <el-form-item label="隐患描述" prop="hazardDesc">
  153. <el-input v-model="formData.hazardDesc" type="textarea" :rows="3" placeholder="请输入隐患描述" />
  154. </el-form-item>
  155. </el-col>
  156. <el-col :span="24">
  157. <el-form-item label="整改方案">
  158. <el-input v-model="formData.rectifyPlan" type="textarea" :rows="3" />
  159. </el-form-item>
  160. </el-col>
  161. <el-col :span="12">
  162. <el-form-item label="整改期限">
  163. <el-date-picker v-model="formData.rectifyDeadline" type="date" format="YYYY-MM-DD" value-format="YYYY-MM-DD" style="width: 100%" />
  164. </el-form-item>
  165. </el-col>
  166. <el-col :span="12">
  167. <el-form-item label="整改责任人">
  168. <el-input v-model="formData.rectifyPerson" />
  169. </el-form-item>
  170. </el-col>
  171. <el-col :span="12">
  172. <el-form-item label="经度">
  173. <el-input-number v-model="formData.longitude" :precision="10" style="width: 100%" />
  174. </el-form-item>
  175. </el-col>
  176. <el-col :span="12">
  177. <el-form-item label="纬度">
  178. <el-input-number v-model="formData.latitude" :precision="10" style="width: 100%" />
  179. </el-form-item>
  180. </el-col>
  181. <el-col :span="24">
  182. <el-form-item label="隐患图片" class="image-form-item">
  183. <div class="image-upload-content">
  184. <el-upload
  185. action="#"
  186. :auto-upload="false"
  187. :show-file-list="false"
  188. :http-request="() => {}"
  189. :on-change="handleImageChange"
  190. accept="image/jpeg,image/png,image/jpg"
  191. multiple
  192. >
  193. <el-button type="primary" plain :loading="imageUploading">
  194. {{ imageUploading ? '上传中...' : '选择图片' }}
  195. </el-button>
  196. <template #tip>
  197. <div class="el-upload__tip">支持 JPG/PNG 格式,单张不超过 5MB,可多选</div>
  198. </template>
  199. </el-upload>
  200. <div v-if="imageList.length" class="image-list">
  201. <div v-for="(img, index) in imageList" :key="index" class="image-item">
  202. <img :src="img" class="image-thumb" @click="previewImage(index)" />
  203. <el-icon class="image-remove" @click="removeImage(index)"><Close /></el-icon>
  204. </div>
  205. </div>
  206. </div>
  207. </el-form-item>
  208. </el-col>
  209. </el-row>
  210. </el-form>
  211. <template #footer>
  212. <div class="dialog-footer">
  213. <el-button type="primary" @click="handleSave">确 定</el-button>
  214. <el-button @click="dialogVisible = false">取 消</el-button>
  215. </div>
  216. </template>
  217. </el-dialog>
  218. <!-- 详情对话框 -->
  219. <el-drawer title="隐患详情" v-model="detailVisible" size="900px" class="detail-drawer" direction="rtl">
  220. <el-descriptions v-if="detailData" :column="2" border>
  221. <el-descriptions-item label="隐患编号">{{ detailData.hazardNo }}</el-descriptions-item>
  222. <el-descriptions-item label="隐患类型">{{ getDictLabel(hazardTypeDict, detailData.hazardType) }}</el-descriptions-item>
  223. <el-descriptions-item label="隐患等级">
  224. <el-tag :type="levelTag(detailData.hazardLevel)">{{ getDictLabel(hazardLevelDict, detailData.hazardLevel) }}</el-tag>
  225. </el-descriptions-item>
  226. <el-descriptions-item label="隐患来源">{{ getDictLabel(hazardSourceDict, detailData.hazardSource) }}</el-descriptions-item>
  227. <el-descriptions-item label="状态">
  228. <el-tag :type="statusTag(detailData.hazardStatus)">{{ getDictLabel(hazardStatusDict, detailData.hazardStatus) }}</el-tag>
  229. </el-descriptions-item>
  230. <el-descriptions-item label="报送状态">
  231. <el-tag :type="detailData.isSubmitted === 1 ? 'success' : 'info'">{{ detailData.isSubmitted === 1 ? '已报送' : '未报送' }}</el-tag>
  232. </el-descriptions-item>
  233. <el-descriptions-item label="关联管网">{{ getNetworkName(detailData.networkId) }}</el-descriptions-item>
  234. <el-descriptions-item label="关联设备">{{ getEquipmentName(detailData.equipmentId) }}</el-descriptions-item>
  235. <el-descriptions-item label="上报单位">{{ detailData.reportUnit || '-' }}</el-descriptions-item>
  236. <el-descriptions-item label="上报时间">{{ detailData.reportTime || '-' }}</el-descriptions-item>
  237. <el-descriptions-item label="隐患描述" :span="2">{{ detailData.hazardDesc || '-' }}</el-descriptions-item>
  238. <el-descriptions-item label="整改方案" :span="2">{{ detailData.rectifyPlan || '-' }}</el-descriptions-item>
  239. <el-descriptions-item label="整改期限">{{ detailData.rectifyDeadline || '-' }}</el-descriptions-item>
  240. <el-descriptions-item label="整改责任人">{{ detailData.rectifyPerson || '-' }}</el-descriptions-item>
  241. <el-descriptions-item label="经度">{{ detailData.longitude || '-' }}</el-descriptions-item>
  242. <el-descriptions-item label="纬度">{{ detailData.latitude || '-' }}</el-descriptions-item>
  243. <el-descriptions-item label="隐患图片" :span="2">
  244. <div v-if="detailImageUrls.length" class="detail-image-list">
  245. <el-image
  246. v-for="(img, index) in detailImageUrls"
  247. :key="index"
  248. :src="img"
  249. fit="contain"
  250. lazy
  251. :preview-src-list="detailImageUrls"
  252. :initial-index="index"
  253. preview-teleported
  254. hide-on-click-modal
  255. class="detail-image"
  256. />
  257. </div>
  258. <span v-else>-</span>
  259. </el-descriptions-item>
  260. <el-descriptions-item label="备注" :span="2">{{ detailData.remark || '-' }}</el-descriptions-item>
  261. </el-descriptions>
  262. </el-drawer>
  263. <!-- 图片预览 -->
  264. <el-image-viewer
  265. v-if="previewVisible"
  266. :url-list="previewList"
  267. :initial-index="previewIndex"
  268. hide-on-click-modal
  269. @close="previewVisible = false"
  270. />
  271. </div>
  272. </template>
  273. <script setup>
  274. import { computed, ref, reactive, shallowRef, onMounted } from 'vue'
  275. import { ElMessage, ElMessageBox } from 'element-plus'
  276. import { Close } from '@element-plus/icons-vue'
  277. import request from '@/utils/request'
  278. import { createMinioBatchUploader } from '@/utils/minioBatchUpload'
  279. import { getHazardPage, getHazardById, addHazard, updateHazard, deleteHazard } from '@/api/pipeNetwork/hazard'
  280. import { getDicts } from '@/api/system/dict/data'
  281. import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
  282. import { getPipeNetworkOptions, getEquipmentByTopLevelType } from '@/api/pipeNetwork/basic'
  283. const loading = ref(false)
  284. const showSearch = ref(true)
  285. const queryRef = ref(null)
  286. const tableData = ref([])
  287. const pageNum = ref(1)
  288. const pageSize = ref(10)
  289. const total = ref(0)
  290. const searchForm = reactive({ hazardType: '', hazardLevel: '', hazardStatus: '', isSubmitted: null, networkId: '', equipmentId: '' })
  291. const dialogVisible = ref(false)
  292. const dialogTitle = ref('新增')
  293. const formRef = ref(null)
  294. // 表单校验规则
  295. const rules = {
  296. hazardType: [{ required: true, message: '请选择隐患类型', trigger: 'change' }],
  297. hazardLevel: [{ required: true, message: '请选择隐患等级', trigger: 'change' }],
  298. hazardSource: [{ required: true, message: '请选择隐患来源', trigger: 'change' }],
  299. equipmentId: [{ required: true, message: '请选择关联设备', trigger: 'change' }],
  300. reportTime: [{ required: true, message: '请选择上报时间', trigger: 'change' }],
  301. hazardDesc: [{ required: true, message: '请输入隐患描述', trigger: 'blur' }]
  302. }
  303. const formData = reactive({
  304. hazardId: '', hazardNo: '', hazardType: '', hazardLevel: '', hazardDesc: '',
  305. hazardSource: '', hazardStatus: '待核查', reportUnit: '', reportTime: '',
  306. rectifyPlan: '', rectifyDeadline: '', rectifyPerson: '',
  307. networkId: '', equipmentId: '', longitude: null, latitude: null,
  308. hazardImages: ''
  309. })
  310. const hazardTypeDict = ref([])
  311. const hazardLevelDict = ref([])
  312. const hazardStatusDict = ref([])
  313. const hazardSourceDict = ref([])
  314. // 选项列表可能较大,使用浅层响应式并缓存请求,避免每次编辑都触发整棵数组深度更新。
  315. const networkOptions = shallowRef([])
  316. const equipmentOptions = shallowRef([])
  317. const networkNameCache = shallowRef(new Map())
  318. const equipmentNameCache = shallowRef(new Map())
  319. const rowImageCache = new WeakMap()
  320. const networkSelectOptions = computed(() => networkOptions.value.map(item => ({ value: item.networkId, label: item.networkName })))
  321. const equipmentSelectOptions = computed(() => equipmentOptions.value.map(item => ({ value: item.equipmentId, label: item.equipmentName })))
  322. let networkOptionsRequest = null
  323. let equipmentOptionsRequest = null
  324. // 详情相关
  325. const detailVisible = ref(false)
  326. const detailData = ref(null)
  327. const detailImageUrls = computed(() => parseImageUrls(detailData.value?.hazardImages))
  328. // 图片相关
  329. const imageList = ref([])
  330. const imageUrls = ref([])
  331. const imageUploading = ref(false)
  332. const previewVisible = ref(false)
  333. const previewList = ref([])
  334. const previewIndex = ref(0)
  335. function getDictLabel(dict, value) {
  336. const item = dict.find(d => d.dictValue === value)
  337. return item ? item.dictLabel : value
  338. }
  339. function levelTag(value) {
  340. const map = { '0': 'danger', '1': 'warning', '2': 'info', '3': '' }
  341. return map[value] || 'info'
  342. }
  343. function statusTag(value) {
  344. const map = { '待核查': 'info', '整改中': 'warning', '已整改': 'success', '已闭环': '' }
  345. return map[value] || 'info'
  346. }
  347. /** 将存储的 hazardImages 字符串转为 URL 数组 */
  348. function parseImageUrls(hazardImages) {
  349. if (!hazardImages) return []
  350. let values = hazardImages
  351. if (typeof values === 'string') {
  352. const text = values.trim()
  353. if (text.startsWith('[') || text.startsWith('{')) {
  354. try {
  355. values = JSON.parse(text)
  356. } catch (e) {
  357. values = text.split(',')
  358. }
  359. } else {
  360. values = text.split(/[,;\n]/)
  361. }
  362. }
  363. if (!Array.isArray(values)) values = [values]
  364. return values
  365. .map(item => {
  366. const source = typeof item === 'object' ? (item.url || item.fileName || item.name) : item
  367. return normalizeImageUrl(source)
  368. })
  369. .filter(Boolean)
  370. }
  371. function getRowImageUrls(row) {
  372. if (!row || typeof row !== 'object') return []
  373. if (!rowImageCache.has(row)) rowImageCache.set(row, parseImageUrls(row.hazardImages))
  374. return rowImageCache.get(row)
  375. }
  376. /** 兼容 MinIO 完整地址、upload 相对路径和历史仅文件名数据 */
  377. function normalizeImageUrl(value) {
  378. const url = String(value || '').trim()
  379. if (!url || /^(blob:|data:)/i.test(url)) return url
  380. const minioBase = String(import.meta.env.VITE_APP_MINIO_BASE_URL || '').trim().replace(/\/+$/, '')
  381. if (/^https?:\/\//i.test(url)) return buildMinioAttachmentUrl(url)
  382. if (minioBase) {
  383. if (url.startsWith('/')) return buildMinioAttachmentUrl(url)
  384. if (url.startsWith('upload/')) return `${minioBase}/${url}`
  385. if (!url.includes('/')) return `${minioBase}/upload/${url}`
  386. }
  387. const baseApi = String(import.meta.env.VITE_APP_BASE_API || '').trim().replace(/\/+$/, '')
  388. return url.startsWith('/') ? `${baseApi}${url}` : `${baseApi}/${url}`
  389. }
  390. /** 图片选择后立即上传到 MinIO(同一批次多张合并为一次 /minio/uploads 请求) */
  391. const hazardImageUploader = createMinioBatchUploader('hazard')
  392. hazardImageUploader.onBatchSettled(() => { imageUploading.value = false })
  393. async function handleImageChange(file) {
  394. if (!file.raw) return
  395. const isImage = file.raw.type === 'image/jpeg' || file.raw.type === 'image/png' || file.raw.type === 'image/jpg'
  396. if (!isImage) { ElMessage.error('只能上传图片文件 (JPG/PNG)!'); return }
  397. if (file.raw.size / 1024 / 1024 >= 5) { ElMessage.error('图片大小不能超过 5MB!'); return }
  398. imageUploading.value = true
  399. try {
  400. const info = await hazardImageUploader(file)
  401. const url = normalizeImageUrl(info.relativePath || info.url || info.fileName)
  402. if (!url) throw new Error('上传接口未返回图片地址')
  403. imageUrls.value.push(url)
  404. imageList.value.push(url)
  405. } catch (e) {
  406. ElMessage.error('图片上传失败')
  407. }
  408. }
  409. /** 删除图片 */
  410. function removeImage(index) {
  411. imageUrls.value.splice(index, 1)
  412. imageList.value.splice(index, 1)
  413. }
  414. /** 预览大图 */
  415. function previewImage(index) {
  416. previewList.value = [...imageList.value]
  417. previewIndex.value = index
  418. previewVisible.value = true
  419. }
  420. async function loadDicts() {
  421. const [typeRes, levelRes, statusRes, sourceRes] = await Promise.all([
  422. getDicts('hazard_type'),
  423. getDicts('hazard_level'),
  424. getDicts('hazard_status'),
  425. getDicts('hazard_source')
  426. ])
  427. hazardTypeDict.value = typeRes.data
  428. hazardLevelDict.value = levelRes.data
  429. hazardStatusDict.value = statusRes.data
  430. hazardSourceDict.value = sourceRes.data
  431. }
  432. async function loadData() {
  433. loading.value = true
  434. try {
  435. const res = await getHazardPage(pageNum.value, pageSize.value, searchForm)
  436. tableData.value = res.data.records || []
  437. total.value = res.data.total || 0
  438. } catch (e) { console.error(e) } finally { loading.value = false }
  439. }
  440. function handleSearch() { pageNum.value = 1; loadData() }
  441. function handleReset() {
  442. queryRef.value?.resetFields()
  443. Object.assign(searchForm, { hazardType: '', hazardLevel: '', hazardStatus: '', isSubmitted: null, networkId: '', equipmentId: '' })
  444. handleSearch()
  445. }
  446. async function loadNetworkOptions() {
  447. if (Array.isArray(networkOptions.value) && networkOptions.value.length > 0) return
  448. if (networkOptionsRequest) return networkOptionsRequest
  449. networkOptionsRequest = getPipeNetworkOptions()
  450. .then(res => {
  451. networkOptions.value = Array.isArray(res.data) ? res.data : []
  452. networkNameCache.value = new Map(networkOptions.value.map(item => [String(item.networkId), item.networkName]))
  453. return networkOptions.value
  454. })
  455. .catch(e => { console.error(e); return [] })
  456. .finally(() => { networkOptionsRequest = null })
  457. return networkOptionsRequest
  458. }
  459. async function loadEquipmentOptions() {
  460. if (Array.isArray(equipmentOptions.value) && equipmentOptions.value.length > 0) return
  461. if (equipmentOptionsRequest) return equipmentOptionsRequest
  462. equipmentOptionsRequest = getEquipmentByTopLevelType('燃气')
  463. .then(res => {
  464. equipmentOptions.value = Array.isArray(res.data) ? res.data : []
  465. equipmentNameCache.value = new Map(equipmentOptions.value.map(item => [String(item.equipmentId), item.equipmentName]))
  466. return equipmentOptions.value
  467. })
  468. .catch(e => { console.error(e); return [] })
  469. .finally(() => { equipmentOptionsRequest = null })
  470. return equipmentOptionsRequest
  471. }
  472. function getNetworkName(id) {
  473. return networkNameCache.value.get(String(id)) || id || '-'
  474. }
  475. function getEquipmentName(id) {
  476. return equipmentNameCache.value.get(String(id)) || id || '-'
  477. }
  478. /** 自动生成隐患编号:YH-YYYYMMDDHHmmss */
  479. function generateHazardNo() {
  480. const now = new Date()
  481. const pad = (n) => String(n).padStart(2, '0')
  482. const dateStr = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
  483. const timeStr = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
  484. return `YH-${dateStr}${timeStr}`
  485. }
  486. function handleAdd() {
  487. dialogTitle.value = '新增'
  488. Object.assign(formData, { hazardId: '', hazardNo: generateHazardNo(), hazardType: '', hazardLevel: '', hazardDesc: '', hazardSource: '', hazardStatus: '待核查', reportUnit: '', reportTime: '', rectifyPlan: '', rectifyDeadline: '', rectifyPerson: '', networkId: '', equipmentId: '', longitude: null, latitude: null, hazardImages: '', isSubmitted: null, submitTime: null, verifyPerson: null, verifyTime: null })
  489. imageList.value = []
  490. imageUrls.value = []
  491. dialogVisible.value = true
  492. loadNetworkOptions(); loadEquipmentOptions()
  493. }
  494. function handleEdit(row) {
  495. dialogTitle.value = '编辑'
  496. // 只回填表单字段,避免把接口返回的分页/审计字段直接写入响应式表单。
  497. // 直接 Object.assign(row) 在部分数据返回结构下会导致弹窗渲染持续触发更新。
  498. const editableFields = [
  499. 'hazardId', 'hazardNo', 'hazardType', 'hazardLevel', 'hazardDesc',
  500. 'hazardSource', 'hazardStatus', 'reportUnit', 'reportTime',
  501. 'rectifyPlan', 'rectifyDeadline', 'rectifyPerson', 'networkId',
  502. 'equipmentId', 'longitude', 'latitude', 'hazardImages'
  503. ]
  504. editableFields.forEach(key => {
  505. const value = row?.[key]
  506. if (key === 'longitude' || key === 'latitude') {
  507. formData[key] = value === null || value === undefined || value === '' ? null : Number(value)
  508. } else if (key === 'hazardImages') {
  509. formData[key] = parseImageUrls(value).join(',')
  510. } else {
  511. formData[key] = value ?? ''
  512. }
  513. })
  514. // 回显已有图片
  515. const urls = getRowImageUrls(row)
  516. imageUrls.value = [...urls]
  517. imageList.value = [...urls]
  518. dialogVisible.value = true
  519. // 选项加载不阻塞弹窗打开,且函数内部会避免重复请求。
  520. void Promise.allSettled([loadNetworkOptions(), loadEquipmentOptions()])
  521. }
  522. async function handleSave() {
  523. if (!formRef.value) return
  524. try {
  525. await formRef.value.validate()
  526. if (imageUploading.value) { ElMessage.warning('图片正在上传,请稍候'); return }
  527. // 将图片URL列表拼接为逗号分隔字符串
  528. formData.hazardImages = imageUrls.value.join(',')
  529. if (formData.hazardId) { await updateHazard(formData); ElMessage.success('修改成功') }
  530. else { await addHazard(formData); ElMessage.success('新增成功') }
  531. dialogVisible.value = false; loadData()
  532. } catch (e) {
  533. const msg = e?.response?.data?.msg || e?.message || '操作失败'
  534. ElMessage.error(msg)
  535. }
  536. }
  537. function handleDelete(row) {
  538. ElMessageBox.confirm('确认删除该隐患记录?', '提示', { type: 'warning' }).then(async () => {
  539. await deleteHazard(row.hazardId); ElMessage.success('删除成功'); loadData()
  540. }).catch(() => {})
  541. }
  542. async function handleDetail(row) {
  543. try {
  544. const res = await getHazardById(row.hazardId)
  545. detailData.value = res.data || row
  546. detailVisible.value = true
  547. } catch (e) {
  548. // 接口失败时降级使用表格行数据
  549. detailData.value = row
  550. detailVisible.value = true
  551. }
  552. }
  553. onMounted(async () => {
  554. await loadDicts()
  555. void loadData()
  556. })
  557. </script>
  558. <style scoped>
  559. /* 表格图片样式 */
  560. .table-image {
  561. width: 40px;
  562. height: 40px;
  563. border-radius: 4px;
  564. cursor: pointer;
  565. }
  566. /* 多图上传列表样式 */
  567. .image-list {
  568. display: flex;
  569. flex-wrap: wrap;
  570. gap: 8px;
  571. margin-top: 8px;
  572. }
  573. .image-form-item :deep(.el-form-item__content) {
  574. display: block;
  575. }
  576. .image-upload-content {
  577. width: 100%;
  578. }
  579. .image-item {
  580. position: relative;
  581. width: 80px;
  582. height: 80px;
  583. border-radius: 4px;
  584. overflow: visible;
  585. }
  586. .image-thumb {
  587. width: 80px;
  588. height: 80px;
  589. object-fit: contain;
  590. background: #f5f7fa;
  591. border-radius: 4px;
  592. border: 1px solid #dcdfe6;
  593. cursor: pointer;
  594. }
  595. .image-thumb:hover {
  596. border-color: #409eff;
  597. }
  598. .image-remove {
  599. position: absolute;
  600. top: -6px;
  601. right: -6px;
  602. width: 18px;
  603. height: 18px;
  604. background: #f56c6c;
  605. color: #fff;
  606. border-radius: 50%;
  607. cursor: pointer;
  608. display: flex;
  609. align-items: center;
  610. justify-content: center;
  611. font-size: 12px;
  612. z-index: 1;
  613. }
  614. .image-remove:hover {
  615. background: #e64a4a;
  616. }
  617. /* 详情图片样式 */
  618. .detail-image-list {
  619. display: flex;
  620. flex-wrap: wrap;
  621. gap: 10px;
  622. }
  623. .detail-image {
  624. width: 120px;
  625. height: 120px;
  626. border-radius: 6px;
  627. cursor: pointer;
  628. object-fit: contain;
  629. background: #f5f7fa;
  630. }
  631. </style>