RepairRecord.vue 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. <template>
  2. <div class="repair-container">
  3. <!-- 上半部分:天地图(左) + 图表(右) -->
  4. <div class="top-section">
  5. <el-card class="topo-card">
  6. <template #header>
  7. <div class="topo-header">
  8. <span class="card-title">燃气管网维修记录拓扑图</span>
  9. <div class="topo-legend">
  10. <span class="legend-item"><i class="legend-line high"></i> 高压</span>
  11. <span class="legend-item"><i class="legend-line medium"></i> 中压</span>
  12. <span class="legend-item"><i class="legend-line low"></i> 低压</span>
  13. <span class="legend-item"><i class="legend-dot repaired"></i> 有维修</span>
  14. <span class="legend-item"><i class="legend-dot point"></i> 管点</span>
  15. </div>
  16. </div>
  17. </template>
  18. <div class="map-wrapper">
  19. <div id="repairTdtMap" class="tdt-map"></div>
  20. <div v-if="mapError" class="map-error">{{ mapError }}</div>
  21. </div>
  22. </el-card>
  23. <div class="charts-column">
  24. <el-card class="chart-card">
  25. <template #header><span class="card-title">维修统计</span></template>
  26. <div id="riskPieChart" style="height:260px"></div>
  27. </el-card>
  28. <el-card class="chart-card">
  29. <template #header><span class="card-title">维修趋势</span></template>
  30. <div id="repairTrendChart" style="height:260px"></div>
  31. </el-card>
  32. </div>
  33. </div>
  34. <!-- 下半部分:搜索 + 表格 -->
  35. <el-card class="table-card">
  36. <div class="table-toolbar">
  37. <div class="table-toolbar-left">
  38. <el-form :inline="true" :model="searchForm" class="search-form">
  39. <el-form-item label="管网ID">
  40. <el-select v-model="searchForm.networkId" placeholder="请选择" clearable style="width:190px" @change="onFilterChange">
  41. <el-option v-for="n in networkOptions" :key="n.networkId" :label="`${n.networkName}(${n.networkCode})`" :value="n.networkId" />
  42. </el-select>
  43. </el-form-item>
  44. <el-form-item label="维修类型">
  45. <el-select v-model="searchForm.repairType" placeholder="请选择" clearable style="width:160px">
  46. <el-option v-for="d in repairTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
  47. </el-select>
  48. </el-form-item>
  49. <el-form-item>
  50. <el-button type="primary" @click="handleSearch">查询</el-button>
  51. <el-button @click="handleReset">重置</el-button>
  52. </el-form-item>
  53. </el-form>
  54. </div>
  55. <div class="table-toolbar-right">
  56. <el-button type="success" @click="handleAdd">新增维修记录</el-button>
  57. </div>
  58. </div>
  59. <el-table :data="tableData" border stripe v-loading="loading" max-height="380" size="small">
  60. <el-table-column label="管网" width="130">
  61. <template #default="{ row }">{{ getNetworkName(row.networkId) }}</template>
  62. </el-table-column>
  63. <el-table-column prop="repairDate" label="维修日期" width="120" />
  64. <el-table-column prop="repairType" label="维修类型" width="120">
  65. <template #default="{ row }">{{ getDictLabel(repairTypeDict, row.repairType) }}</template>
  66. </el-table-column>
  67. <el-table-column prop="repairContent" label="维修内容" min-width="230" show-overflow-tooltip />
  68. <el-table-column prop="repairCompany" label="维修单位" min-width="160" show-overflow-tooltip />
  69. <el-table-column prop="repairCost" label="费用(元)" width="110" align="right" />
  70. <el-table-column prop="repairPerson" label="负责人" width="100" />
  71. <el-table-column label="图片" width="80">
  72. <template #default="{ row }">
  73. <template v-if="parseImageUrls(row.repairImages).length">
  74. <el-image
  75. :src="getImageUrl(parseImageUrls(row.repairImages)[0])"
  76. fit="cover"
  77. :preview-src-list="parseImageUrls(row.repairImages).map(getImageUrl)"
  78. preview-teleported
  79. class="table-image"
  80. />
  81. </template>
  82. <el-tag v-else type="info" size="small">无</el-tag>
  83. </template>
  84. </el-table-column>
  85. <el-table-column prop="createTime" label="创建时间" width="170" />
  86. <el-table-column label="操作" width="150" fixed="right">
  87. <template #default="{ row }">
  88. <el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
  89. <el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
  90. </template>
  91. </el-table-column>
  92. </el-table>
  93. <el-pagination
  94. v-model:current-page="pageNum" v-model:page-size="pageSize" :total="total"
  95. layout="total, prev, pager, next, sizes" :page-sizes="[10, 20, 50]"
  96. @size-change="() => { pageNum = 1; loadData() }" @current-change="() => loadData()"
  97. style="margin-top:16px;justify-content:flex-end"
  98. />
  99. </el-card>
  100. <!-- 详情对话框 -->
  101. <el-dialog :title="dialogTitle" v-model="dialogVisible" width="600px" destroy-on-close>
  102. <el-form :model="formData" ref="formRef" label-width="100px">
  103. <el-form-item label="管网ID" prop="networkId">
  104. <el-select v-model="formData.networkId" style="width:100%">
  105. <el-option v-for="n in networkOptions" :key="n.networkId" :label="`${n.networkName}(${n.networkCode})`" :value="n.networkId" />
  106. </el-select>
  107. </el-form-item>
  108. <el-form-item label="维修日期" prop="repairDate">
  109. <el-date-picker v-model="formData.repairDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width:100%" />
  110. </el-form-item>
  111. <el-form-item label="维修类型" prop="repairType">
  112. <el-select v-model="formData.repairType" style="width:100%">
  113. <el-option v-for="d in repairTypeDict" :key="d.dictValue" :label="d.dictLabel" :value="d.dictValue" />
  114. </el-select>
  115. </el-form-item>
  116. <el-form-item label="维修内容"><el-input v-model="formData.repairContent" type="textarea" :rows="3" /></el-form-item>
  117. <el-form-item label="维修单位"><el-input v-model="formData.repairCompany" /></el-form-item>
  118. <el-form-item label="费用(元)"><el-input-number v-model="formData.repairCost" :precision="2" :step="100" style="width:100%" /></el-form-item>
  119. <el-form-item label="负责人"><el-input v-model="formData.repairPerson" /></el-form-item>
  120. <el-form-item label="备注"><el-input v-model="formData.remark" type="textarea" :rows="2" /></el-form-item>
  121. <el-form-item label="维修图片">
  122. <el-upload
  123. action="#"
  124. :auto-upload="false"
  125. :show-file-list="false"
  126. :http-request="() => {}"
  127. :on-change="handleImageChange"
  128. accept="image/jpeg,image/png,image/jpg"
  129. multiple
  130. >
  131. <el-button type="primary" plain>选择图片</el-button>
  132. <template #tip>
  133. <div class="el-upload__tip">支持 JPG/PNG 格式,单张不超过 5MB</div>
  134. </template>
  135. </el-upload>
  136. <div v-if="imageList.length" class="image-list">
  137. <div v-for="(img, index) in imageList" :key="index" class="image-item">
  138. <img :src="img" class="image-thumb" @click="previewImage(index)" />
  139. <el-icon class="image-remove" @click="removeImage(index)"><Close /></el-icon>
  140. </div>
  141. </div>
  142. </el-form-item>
  143. </el-form>
  144. <template #footer>
  145. <el-button @click="dialogVisible = false">取消</el-button>
  146. <el-button type="primary" @click="handleSubmit">确定</el-button>
  147. </template>
  148. </el-dialog>
  149. <!-- 图片预览 -->
  150. <el-image-viewer
  151. v-if="previewVisible"
  152. :url-list="previewList"
  153. :initial-index="previewIndex"
  154. hide-on-click-modal
  155. @close="previewVisible = false"
  156. />
  157. </div>
  158. </template>
  159. <script setup>
  160. import { whenMapReady } from '@/utils/mapApi'
  161. import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
  162. import * as echarts from 'echarts'
  163. import { ElMessage, ElMessageBox } from 'element-plus'
  164. import { Close } from '@element-plus/icons-vue'
  165. import request from '@/utils/request'
  166. import { convertCoord } from '@/utils/coordTransform'
  167. import { getPipeNetworkOptions, getRepairRecordPage, getRepairStats, addRepairRecord, updateRepairRecord, deleteRepairRecord } from '@/api/pipeNetwork/basic'
  168. const baseApi = import.meta.env.VITE_APP_BASE_API
  169. // ==================== 数据 ====================
  170. const loading = ref(false)
  171. const pageNum = ref(1)
  172. const pageSize = ref(10)
  173. const total = ref(0)
  174. const searchForm = reactive({ networkId: '', repairType: '' })
  175. const dialogVisible = ref(false)
  176. const dialogTitle = ref('新增')
  177. const formRef = ref(null)
  178. const formData = reactive({ repairId: '', networkId: '', repairDate: '', repairType: '', repairContent: '', repairCompany: '', repairCost: null, repairPerson: '', remark: '', repairImages: '' })
  179. const repairTypeDict = ref([
  180. { dictValue: '1', dictLabel: '抢修' },
  181. { dictValue: '2', dictLabel: '计划维修' },
  182. { dictValue: '3', dictLabel: '防腐层修复' },
  183. { dictValue: '4', dictLabel: '管件更换' },
  184. { dictValue: '5', dictLabel: '焊缝修补' },
  185. { dictValue: '6', dictLabel: '阀门维修' }
  186. ])
  187. function getDictLabel(dict, value) {
  188. const item = dict.find(d => d.dictValue === String(value))
  189. return item ? item.dictLabel : value
  190. }
  191. // ==================== 天地图配置 ====================
  192. const centerPoint = { lng: 110.386, lat: 28.445 }
  193. const MAP_BOUNDS = { southWest: { lng: 110.355, lat: 28.428 }, northEast: { lng: 110.410, lat: 28.460 } }
  194. const MAP_MIN_ZOOM = 13
  195. const MAP_MAX_ZOOM = 19
  196. const MAP_DEFAULT_ZOOM = 15
  197. const mapError = ref('')
  198. const networkOptions = ref([])
  199. let mapInstance = null
  200. let activeInfoWindow = null
  201. let overlayClicked = false
  202. let mapOverlays = []
  203. // ==================== 管网数据 (沅陵县真实坐标) ====================
  204. const pipelineData = [
  205. { id: 'net001', name: '燃气1', pressure: 'high',
  206. points: [{lng:110.404947,lat:28.433419},{lng:110.404836,lat:28.433429},{lng:110.403275,lat:28.433571},{lng:110.401084,lat:28.433876},{lng:110.399601,lat:28.433876},{lng:110.398112,lat:28.433979},{lng:110.396717,lat:28.434314},{lng:110.394299,lat:28.434538},{lng:110.394352,lat:28.434887},{lng:110.391969,lat:28.434985},{lng:110.384374,lat:28.435695},{lng:110.381418,lat:28.43559},{lng:110.377993,lat:28.436193},{lng:110.376062,lat:28.436385},{lng:110.374231,lat:28.43655},{lng:110.372584,lat:28.437107},{lng:110.371292,lat:28.4377},{lng:110.370581,lat:28.438198},{lng:110.369831,lat:28.438821}] },
  207. { id: 'net002', name: '燃气2', pressure: 'medium',
  208. points: [{lng:110.361433,lat:28.454979},{lng:110.362363,lat:28.453169},{lng:110.362851,lat:28.452213},{lng:110.364377,lat:28.452443},{lng:110.365138,lat:28.45246},{lng:110.366462,lat:28.452157},{lng:110.369549,lat:28.451441},{lng:110.37219,lat:28.450897},{lng:110.372753,lat:28.450901},{lng:110.373875,lat:28.451194},{lng:110.373913,lat:28.451107},{lng:110.374597,lat:28.451476}] },
  209. ]
  210. // 管点数据 (管网节点位置)
  211. const pipePointData = [
  212. { name: 'A点', lng: 110.362363, lat: 28.453169, pipelineId: 'net001' },
  213. { name: 'B点', lng: 110.364377, lat: 28.452443, pipelineId: 'net001' },
  214. { name: 'C点', lng: 110.372753, lat: 28.450901, pipelineId: 'net001' },
  215. { name: 'D点', lng: 110.401084, lat: 28.433876, pipelineId: 'net001' },
  216. { name: 'E点', lng: 110.391969, lat: 28.434985, pipelineId: 'net001' },
  217. { name: 'F点', lng: 110.376062, lat: 28.436385, pipelineId: 'net001' },
  218. { name: 'G点', lng: 110.370581, lat: 28.438198, pipelineId: 'net001' },
  219. ]
  220. // ==================== 维修记录数据 ====================
  221. const tableData = ref([])
  222. // 图片相关
  223. const imageList = ref([]) // 展示用预览列表(URL)
  224. const imageUrls = ref([]) // 已上传的图片URL列表
  225. const imageUploading = ref(false) // 上传中状态
  226. const previewVisible = ref(false)
  227. const previewList = ref([])
  228. const previewIndex = ref(0)
  229. /** 获取图片展示 URL(直接使用上传接口返回的完整 URL) */
  230. function getImageUrl(url) {
  231. if (!url) return ''
  232. return url.trim()
  233. }
  234. /** 将存储的 repairImages 字符串转为有效 URL 数组 */
  235. function parseImageUrls(repairImages) {
  236. if (!repairImages) return []
  237. return repairImages.split(',').map(s => s.trim()).filter(Boolean)
  238. }
  239. /** 图片选择后立即上传到 MinIO */
  240. async function handleImageChange(file) {
  241. if (!file.raw) return
  242. const isImage = file.raw.type === 'image/jpeg' || file.raw.type === 'image/png' || file.raw.type === 'image/jpg'
  243. if (!isImage) { ElMessage.error('只能上传图片文件 (JPG/PNG)!'); return }
  244. if (file.raw.size / 1024 / 1024 >= 5) { ElMessage.error('图片大小不能超过 5MB!'); return }
  245. imageUploading.value = true
  246. try {
  247. const uploadFormData = new FormData()
  248. uploadFormData.append('file', file.raw)
  249. uploadFormData.append('dir', 'upload') // 指定存储目录(可选)
  250. const uploadResponse = await request({
  251. url: '/minio/upload',
  252. method: 'post',
  253. data: uploadFormData
  254. })
  255. const url = uploadResponse.url || uploadResponse.fileName
  256. imageUrls.value.push(url)
  257. imageList.value.push(getImageUrl(url))
  258. } catch (e) {
  259. ElMessage.error('图片上传失败')
  260. } finally {
  261. imageUploading.value = false
  262. }
  263. }
  264. /** 删除图片 */
  265. function removeImage(index) {
  266. imageUrls.value.splice(index, 1)
  267. imageList.value.splice(index, 1)
  268. }
  269. /** 预览大图 */
  270. function previewImage(index) {
  271. previewList.value = [...imageList.value]
  272. previewIndex.value = index
  273. previewVisible.value = true
  274. }
  275. const pressureColors = { high: '#e63946', medium: '#f77f00', low: '#2a9d8f' }
  276. const repairedNetworkIds = computed(() => { const s = new Set(); tableData.value.forEach(r => s.add(r.networkId)); return s })
  277. async function loadNetworkOptions() {
  278. if (networkOptions.value.length > 0) return
  279. try { const res = await getPipeNetworkOptions(); networkOptions.value = res.data || [] } catch (e) { console.error(e) }
  280. }
  281. function getNetworkName(id) {
  282. const found = networkOptions.value.find(n => n.networkId === id)
  283. return found ? found.networkName : id
  284. }
  285. function getPipelineName(id) {
  286. const name = getNetworkName(id)
  287. return name !== id ? name : (pipelineData.find(p => p.id === id) || {}).name || id
  288. }
  289. // ==================== 数据加载 ====================
  290. async function loadData() {
  291. loading.value = true
  292. try {
  293. const res = await getRepairRecordPage(pageNum.value, pageSize.value, searchForm)
  294. tableData.value = (res.data && res.data.records) ? res.data.records : []
  295. total.value = (res.data && res.data.total) ? res.data.total : 0
  296. } catch (e) { console.error(e) } finally { loading.value = false }
  297. }
  298. // ==================== 天地图 ====================
  299. function closeInfoWindow() {
  300. if (activeInfoWindow) { try { activeInfoWindow.close(); mapInstance.removeOverlay(activeInfoWindow) } catch (_) {}; activeInfoWindow = null }
  301. }
  302. function getMidPoint(points) {
  303. let lng = 0, lat = 0
  304. points.forEach(p => { lng += p.lng; lat += p.lat })
  305. return { lng: lng / points.length, lat: lat / points.length }
  306. }
  307. function initMap() {
  308. const el = document.getElementById('repairTdtMap')
  309. if (!el) { mapError.value = '地图容器未找到。'; return }
  310. try { mapInstance = new TdtMap.Map('repairTdtMap', { enableMapClick: true }) } catch (e) {
  311. console.error('创建地图实例失败:', e); mapError.value = '天地图初始化失败,请检查 VITE_TIANDITU_TK 与网络连接。'; return
  312. }
  313. const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat); try { mapInstance.centerAndZoom(new TdtMap.Point(clng, clat), MAP_DEFAULT_ZOOM); mapInstance.enableScrollWheelZoom(true) } catch (e) { console.error('设置中心失败:', e) }
  314. try { if (typeof mapInstance.setMinZoom === 'function') mapInstance.setMinZoom(MAP_MIN_ZOOM); if (typeof mapInstance.setMaxZoom === 'function') mapInstance.setMaxZoom(MAP_MAX_ZOOM) } catch (e) {}
  315. try {
  316. if (typeof mapInstance.setMaxBounds === 'function') {
  317. const sw = new TdtMap.Point(MAP_BOUNDS.southWest.lng, MAP_BOUNDS.southWest.lat)
  318. const ne = new TdtMap.Point(MAP_BOUNDS.northEast.lng, MAP_BOUNDS.northEast.lat)
  319. mapInstance.setMaxBounds(new TdtMap.Bounds(sw, ne))
  320. }
  321. } catch (e) {}
  322. mapInstance.addEventListener('click', () => {
  323. if (overlayClicked) { overlayClicked = false; return }
  324. closeInfoWindow()
  325. })
  326. try { renderMapScene() } catch (e) { console.error('渲染地图失败:', e); mapError.value = '地图要素渲染失败。' }
  327. }
  328. function renderMapScene() {
  329. if (!mapInstance || typeof TdtMap === 'undefined') return
  330. closeInfoWindow()
  331. try { mapInstance.clearOverlays() } catch (_) {}
  332. mapOverlays = []
  333. // 绘制管线
  334. pipelineData.forEach(pipe => {
  335. try {
  336. const hasRepair = repairedNetworkIds.value.has(pipe.id)
  337. const pts = pipe.points.map(p => { const [lng, lat] = convertCoord(p.lng, p.lat); return new TdtMap.Point(lng, lat) })
  338. const polyline = new TdtMap.Polyline(pts, {
  339. strokeColor: hasRepair ? '#f59e0b' : pressureColors[pipe.pressure],
  340. strokeWeight: hasRepair ? 5 : 3.5,
  341. strokeOpacity: 0.9
  342. })
  343. mapInstance.addOverlay(polyline)
  344. mapOverlays.push({ overlay: polyline, pipe })
  345. polyline.addEventListener('click', (e) => {
  346. overlayClicked = true
  347. if (e && e.domEvent) e.domEvent.stopPropagation()
  348. closeInfoWindow()
  349. const rawMid = getMidPoint(pipe.points)
  350. const mid = { lng: convertCoord(rawMid.lng, rawMid.lat)[0], lat: convertCoord(rawMid.lng, rawMid.lat)[1] }
  351. const count = tableData.value.filter(r => r.networkId === pipe.id).length
  352. const repairList = tableData.value.filter(r => r.networkId === pipe.id).slice(0, 3).map(r => `${r.repairDate} ${getDictLabel(repairTypeDict, r.repairType)}`).join('<br>')
  353. const html = `<div style="font-size:13px;max-width:300px;"><b>${pipe.name}</b>(${getNetworkName(pipe.id)})<br>
  354. <span style="color:${pressureColors[pipe.pressure]};font-weight:bold;">${pipe.pressure === 'high' ? '高压' : pipe.pressure === 'medium' ? '中压' : '低压'}</span>
  355. &nbsp;累计维修 <b style="color:#e63946;">${count}</b> 次
  356. ${count > 0 ? '<br><span style="color:#909399;font-size:11px;">' + repairList + '</span>' : ''}</div>`
  357. activeInfoWindow = new TdtMap.InfoWindow(html, { width: 260 })
  358. mapInstance.openInfoWindow(activeInfoWindow, new TdtMap.Point(mid.lng, mid.lat))
  359. // 筛选表格
  360. searchForm.networkId = pipe.id
  361. pageNum.value = 1
  362. })
  363. } catch (_) {}
  364. })
  365. // 绘制管点
  366. pipePointData.forEach(point => {
  367. try {
  368. const hasRepair = repairedNetworkIds.value.has(point.pipelineId)
  369. const [plng, plat] = convertCoord(point.lng, point.lat); const pt = new TdtMap.Point(plng, plat)
  370. const color = hasRepair ? '#f59e0b' : '#3b82f6'
  371. const size = hasRepair ? 10 : 7
  372. const label = new TdtMap.Label(point.name, {
  373. position: pt,
  374. offset: new TdtMap.Size(-22, -22)
  375. })
  376. label.setStyle({
  377. color: '#fff', fontSize: '10px', padding: '2px 6px',
  378. backgroundColor: color, borderRadius: '3px', whiteSpace: 'nowrap',
  379. border: hasRepair ? '2px solid #fff' : 'none', boxShadow: hasRepair ? '0 0 6px rgba(245,158,11,0.6)' : 'none'
  380. })
  381. mapInstance.addOverlay(label)
  382. mapOverlays.push({ overlay: label, point })
  383. label.addEventListener('click', (e) => {
  384. overlayClicked = true
  385. if (e && e.domEvent) e.domEvent.stopPropagation()
  386. closeInfoWindow()
  387. const pipeName = getPipelineName(point.pipelineId)
  388. const count = tableData.value.filter(r => r.networkId === point.pipelineId).length
  389. const html = `<div style="font-size:13px;"><b>${point.name}</b><br>所属管线: ${point.pipelineId} ${pipeName}<br>维修记录: <b style="color:#e63946;">${count}</b> 次</div>`
  390. activeInfoWindow = new TdtMap.InfoWindow(html, { width: 240 })
  391. mapInstance.openInfoWindow(activeInfoWindow, pt)
  392. searchForm.networkId = point.pipelineId
  393. pageNum.value = 1
  394. })
  395. } catch (_) {}
  396. })
  397. // 维修标记点 (有维修记录的管线中点位置)
  398. pipelineData.forEach(pipe => {
  399. if (!repairedNetworkIds.value.has(pipe.id)) return
  400. try {
  401. const rawMid2 = getMidPoint(pipe.points)
  402. const mid2 = { lng: convertCoord(rawMid2.lng, rawMid2.lat)[0], lat: convertCoord(rawMid2.lng, rawMid2.lat)[1] }
  403. const pt = new TdtMap.Point(mid2.lng, mid2.lat)
  404. const count = tableData.value.filter(r => r.networkId === pipe.id).length
  405. const marker = new TdtMap.Label(String(count), {
  406. position: pt,
  407. offset: new TdtMap.Size(-12, -12)
  408. })
  409. marker.setStyle({
  410. color: '#fff', fontSize: '11px', fontWeight: 'bold',
  411. backgroundColor: '#e63946', borderRadius: '12px',
  412. padding: '2px 7px', border: '2px solid #fff',
  413. boxShadow: '0 0 8px rgba(230,57,70,0.5)'
  414. })
  415. mapInstance.addOverlay(marker)
  416. mapOverlays.push({ overlay: marker, pipe })
  417. } catch (_) {}
  418. })
  419. }
  420. // ==================== ECharts ====================
  421. let riskPieChart = null
  422. let repairTrendChart = null
  423. function initRiskPie(data) {
  424. const dom = document.getElementById('riskPieChart')
  425. if (!dom) return
  426. if (riskPieChart) riskPieChart.dispose()
  427. riskPieChart = echarts.init(dom)
  428. riskPieChart.setOption({
  429. tooltip: { trigger: 'item', formatter: '{b}: {c} 次 ({d}%)' },
  430. legend: { orient: 'vertical', right: 6, top: 'center', itemWidth: 10, itemHeight: 10, textStyle: { color: '#606a78', fontSize: 11 }, itemGap: 14 },
  431. series: [{
  432. type: 'pie', radius: ['50%', '76%'], center: ['38%', '50%'],
  433. avoidLabelOverlap: false, itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 3 },
  434. label: { show: false }, emphasis: { scale: true, scaleSize: 6 },
  435. data: (data || []).map(d => ({ value: d.value, name: d.name, itemStyle: { color: d.color } }))
  436. }]
  437. })
  438. }
  439. function initRepairTrend(data) {
  440. const dom = document.getElementById('repairTrendChart')
  441. if (!dom) return
  442. if (repairTrendChart) repairTrendChart.dispose()
  443. repairTrendChart = echarts.init(dom)
  444. const t = data || {}
  445. const months = t.months || ['1月','2月','3月','4月','5月','6月']
  446. repairTrendChart.setOption({
  447. tooltip: { trigger: 'axis' },
  448. legend: { top: 0, textStyle: { color: '#606a78', fontSize: 11 } },
  449. grid: { left: 44, right: 20, top: 36, bottom: 32 },
  450. xAxis: { type: 'category', data: months, boundaryGap: false, axisLabel: { color: '#9098a6', fontSize: 11 }, axisLine: { lineStyle: { color: '#d0d5dd' } } },
  451. yAxis: { type: 'value', name: '次数', minInterval: 1, axisLabel: { color: '#9098a6', fontSize: 11 }, splitLine: { lineStyle: { color: '#f0f2f6', type: 'dashed' } } },
  452. series: [
  453. { name: '高压', type: 'line', data: t.high || [], smooth: true, symbol: 'circle', symbolSize: 7, lineStyle: { width: 3, color: '#e63946' }, itemStyle: { color: '#e63946', borderColor: '#fff', borderWidth: 2 }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(230,57,70,0.12)' }, { offset: 1, color: 'rgba(230,57,70,0)' }]) } },
  454. { name: '中压', type: 'line', data: t.medium || [], smooth: true, symbol: 'circle', symbolSize: 7, lineStyle: { width: 3, color: '#f77f00' }, itemStyle: { color: '#f77f00', borderColor: '#fff', borderWidth: 2 }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(247,127,0,0.12)' }, { offset: 1, color: 'rgba(247,127,0,0)' }]) } },
  455. { name: '低压', type: 'line', data: t.low || [], smooth: true, symbol: 'circle', symbolSize: 7, lineStyle: { width: 3, color: '#2a9d8f' }, itemStyle: { color: '#2a9d8f', borderColor: '#fff', borderWidth: 2 }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(42,157,143,0.12)' }, { offset: 1, color: 'rgba(42,157,143,0)' }]) } }
  456. ]
  457. })
  458. }
  459. async function loadStats() {
  460. try {
  461. const res = await getRepairStats()
  462. const d = res.data || {}
  463. initRiskPie(d.typeStats)
  464. initRepairTrend(d.trend)
  465. } catch (e) { console.error(e) }
  466. }
  467. function resizeCharts() { riskPieChart?.resize(); repairTrendChart?.resize() }
  468. // ==================== 操作 ====================
  469. function onFilterChange() { pageNum.value = 1; loadData() }
  470. function handleSearch() { pageNum.value = 1; loadData() }
  471. function handleReset() {
  472. searchForm.networkId = ''; searchForm.repairType = ''; pageNum.value = 1
  473. loadData()
  474. }
  475. function handleAdd() {
  476. dialogTitle.value = '新增'; dialogVisible.value = true
  477. Object.assign(formData, { repairId: '', networkId: '', repairDate: '', repairType: '', repairContent: '', repairCompany: '', repairCost: null, repairPerson: '', remark: '', repairImages: '' })
  478. imageList.value = []
  479. imageUrls.value = []
  480. loadNetworkOptions()
  481. }
  482. function handleEdit(row) {
  483. dialogTitle.value = '编辑'; dialogVisible.value = true
  484. Object.assign(formData, { ...row })
  485. // 回显已有图片
  486. const urls = parseImageUrls(row.repairImages)
  487. imageUrls.value = [...urls]
  488. imageList.value = urls.map(url => getImageUrl(url))
  489. loadNetworkOptions()
  490. }
  491. async function handleSubmit() {
  492. try {
  493. if (imageUploading.value) { ElMessage.warning('图片正在上传,请稍候'); return }
  494. // 将图片URL列表拼接为逗号分隔字符串
  495. formData.repairImages = imageUrls.value.join(',')
  496. if (formData.repairId) { await updateRepairRecord(formData); ElMessage.success('修改成功') }
  497. else { await addRepairRecord(formData); ElMessage.success('新增成功') }
  498. dialogVisible.value = false; loadData(); nextTick(() => renderMapScene())
  499. } catch (e) { const msg = e?.response?.data?.msg || e?.message || '操作失败'; ElMessage.error(msg) }
  500. }
  501. function handleDelete(row) {
  502. ElMessageBox.confirm('确认删除该维修记录?', '提示', { type: 'warning' }).then(async () => {
  503. try { await deleteRepairRecord(row.repairId); ElMessage.success('删除成功'); loadData(); nextTick(() => renderMapScene()) } catch (e) { ElMessage.error('删除失败') }
  504. }).catch(() => {})
  505. }
  506. // ==================== 生命周期 ====================
  507. let resizeTimer = null
  508. onMounted(async () => {
  509. await nextTick()
  510. await loadNetworkOptions()
  511. await loadData()
  512. whenMapReady(initMap)
  513. loadStats()
  514. window.addEventListener('resize', () => {
  515. clearTimeout(resizeTimer)
  516. resizeTimer = setTimeout(() => {
  517. resizeCharts()
  518. if (mapInstance) {
  519. try { mapInstance.resize() } catch (_) {}
  520. try { mapInstance.setViewport([new TdtMap.Point(centerPoint.lng, centerPoint.lat)], { zoom: mapInstance.getZoom() }) } catch (_) {}
  521. }
  522. }, 200)
  523. })
  524. })
  525. onBeforeUnmount(() => {
  526. window.removeEventListener('resize', resizeCharts)
  527. riskPieChart?.dispose(); repairTrendChart?.dispose()
  528. if (mapInstance) { try { mapInstance.clearOverlays() } catch (_) {}; mapInstance = null }
  529. })
  530. </script>
  531. <style scoped>
  532. .repair-container { padding: 16px; display: flex; flex-direction: column; gap: 16px; background: #f0f2f6; min-height: calc(100vh - 84px); }
  533. /* 多图上传样式 */
  534. .table-image {
  535. width: 40px;
  536. height: 40px;
  537. border-radius: 4px;
  538. cursor: pointer;
  539. }
  540. .image-list {
  541. display: flex;
  542. flex-wrap: wrap;
  543. gap: 8px;
  544. margin-top: 8px;
  545. }
  546. .image-item {
  547. position: relative;
  548. width: 80px;
  549. height: 80px;
  550. border-radius: 4px;
  551. overflow: visible;
  552. }
  553. .image-thumb {
  554. width: 80px;
  555. height: 80px;
  556. object-fit: cover;
  557. border-radius: 4px;
  558. border: 1px solid #dcdfe6;
  559. cursor: pointer;
  560. }
  561. .image-thumb:hover {
  562. border-color: #409eff;
  563. }
  564. .image-remove {
  565. position: absolute;
  566. top: -6px;
  567. right: -6px;
  568. width: 18px;
  569. height: 18px;
  570. background: #f56c6c;
  571. color: #fff;
  572. border-radius: 50%;
  573. cursor: pointer;
  574. display: flex;
  575. align-items: center;
  576. justify-content: center;
  577. font-size: 12px;
  578. z-index: 1;
  579. }
  580. .image-remove:hover {
  581. background: #e64a4a;
  582. }
  583. /* ========== 上半部分 ========== */
  584. .top-section { display: flex; gap: 16px; align-items: stretch; }
  585. .topo-card { flex: 1; min-width: 0; display: flex; flex-direction: column; }
  586. .charts-column { width: 340px; flex-shrink: 0; display: flex; flex-direction: column; gap: 16px; }
  587. .chart-card { flex: 1; }
  588. .card-title { font-size: 15px; font-weight: 700; color: #1f2d3d; }
  589. .topo-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
  590. .topo-legend { display: flex; gap: 14px; flex-wrap: wrap; }
  591. .legend-item { font-size: 11px; color: #606a78; display: flex; align-items: center; gap: 5px; }
  592. .legend-line { display: inline-block; width: 24px; height: 3px; border-radius: 2px; }
  593. .legend-line.high { background: #e63946; }
  594. .legend-line.medium { background: #f77f00; }
  595. .legend-line.low { background: #2a9d8f; }
  596. .legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
  597. .legend-dot.repaired { background: #f59e0b; border: 2px solid #fff; box-shadow: 0 0 4px rgba(245,158,11,0.5); }
  598. .legend-dot.point { background: #3b82f6; border: 2px solid #fff; }
  599. .map-wrapper { position: relative; width: 100%; height: 100%; min-height: 460px; border-radius: 6px; overflow: hidden; }
  600. .tdt-map { width: 100%; height: 100%; min-height: 460px; }
  601. .map-error { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: #fafbfc; color: #e63946; font-size: 14px; font-weight: 600; }
  602. .topo-card { display: flex; flex-direction: column; }
  603. :deep(.topo-card .el-card__body) { flex: 1; display: flex; padding: 0; overflow: hidden; }
  604. /* ========== 表格 ========== */
  605. .table-toolbar { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; }
  606. .search-form { margin-bottom: 0; }
  607. :deep(.search-form .el-form-item) { margin-bottom: 0; }
  608. :deep(.el-card__header) { padding: 12px 18px; border-bottom: 1px solid #f0f2f6; flex-shrink: 0; }
  609. :deep(.topo-card .el-card__body) { flex: 1; display: flex; padding: 0; overflow: hidden; }
  610. :deep(.chart-card .el-card__body) { padding: 12px; }
  611. :deep(.table-card .el-card__body) { padding: 12px; }
  612. @media (max-width: 1400px) {
  613. .top-section { flex-direction: column; }
  614. .map-wrapper { height: 400px; }
  615. .charts-column { width: 100%; flex-direction: row; }
  616. .charts-column .chart-card { flex: 1; min-width: 300px; }
  617. }
  618. </style>