GisDashboard.vue 31 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090
  1. <template>
  2. <div class="gis-dashboard">
  3. <div class="dashboard-header">
  4. <div class="header-title">
  5. <div class="title-main">
  6. <el-icon><MapLocation /></el-icon>
  7. <span>燃气管网GIS一张图</span>
  8. </div>
  9. <div class="title-metrics">
  10. <span class="metric metric-danger">管线 {{ summaryStats.pipelineLength }}km</span>
  11. <span class="metric metric-primary">燃气管点 {{ summaryStats.pipePointCount }}个</span>
  12. <span class="metric metric-warning">窨井 {{ summaryStats.manholeCount }}个</span>
  13. </div>
  14. </div>
  15. <div class="header-actions">
  16. <el-input
  17. v-model="searchKeyword"
  18. class="search-input"
  19. placeholder="搜索管点/井盖编号/位置"
  20. clearable
  21. @keyup.enter="handleSearch"
  22. >
  23. <template #prefix>
  24. <el-icon><Search /></el-icon>
  25. </template>
  26. </el-input>
  27. <el-button type="primary" @click="handleSearch">
  28. <el-icon><Search /></el-icon>
  29. 搜索
  30. </el-button>
  31. <el-button @click="refreshScene">
  32. <el-icon><RefreshRight /></el-icon>
  33. 刷新
  34. </el-button>
  35. <el-button @click="toggleFullscreen">
  36. <el-icon><FullScreen /></el-icon>
  37. 全图
  38. </el-button>
  39. </div>
  40. </div>
  41. <div ref="screenRef" class="dashboard-content">
  42. <aside class="dashboard-sidebar">
  43. <el-card shadow="never" class="panel-card">
  44. <template #header>
  45. <span>图层控制</span>
  46. </template>
  47. <div class="layer-group">
  48. <div class="layer-title">燃气管线</div>
  49. <el-checkbox v-model="layerState.pipelines.high">高压管线</el-checkbox>
  50. <el-checkbox v-model="layerState.pipelines.subHigh">次高压管线</el-checkbox>
  51. <el-checkbox v-model="layerState.pipelines.medium">中压管线</el-checkbox>
  52. <el-checkbox v-model="layerState.pipelines.low">低压管线</el-checkbox>
  53. </div>
  54. <div class="layer-group">
  55. <div class="layer-title">燃气管点</div>
  56. <el-checkbox v-model="layerState.pipePoints.valve">阀门</el-checkbox>
  57. <el-checkbox v-model="layerState.pipePoints.regulator">调压器</el-checkbox>
  58. <el-checkbox v-model="layerState.pipePoints.flowmeter">流量计</el-checkbox>
  59. <el-checkbox v-model="layerState.pipePoints.terminal">终端节点</el-checkbox>
  60. </div>
  61. <div class="layer-group layer-group-last">
  62. <div class="layer-title">窨井设施</div>
  63. <el-checkbox v-model="layerState.manholes.cover">窨井盖</el-checkbox>
  64. <el-checkbox v-model="layerState.manholes.inspection">检查井</el-checkbox>
  65. </div>
  66. </el-card>
  67. <el-card shadow="never" class="panel-card">
  68. <template #header>
  69. <span>图例说明</span>
  70. </template>
  71. <div class="legend-list">
  72. <div class="legend-item"><span class="legend-line legend-high"></span><span>高压管线</span></div>
  73. <div class="legend-item"><span class="legend-line legend-sub-high"></span><span>次高压管线</span></div>
  74. <div class="legend-item"><span class="legend-line legend-medium"></span><span>中压管线</span></div>
  75. <div class="legend-item"><span class="legend-line legend-low"></span><span>低压管线</span></div>
  76. <div class="legend-item"><span class="legend-dot legend-valve"></span><span>阀门</span></div>
  77. <div class="legend-item"><span class="legend-dot legend-regulator"></span><span>调压器</span></div>
  78. <div class="legend-item"><span class="legend-dot legend-flowmeter"></span><span>流量计</span></div>
  79. <div class="legend-item"><span class="legend-dot legend-terminal"></span><span>终端节点</span></div>
  80. <div class="legend-item"><span class="legend-square legend-cover"></span><span>窨井盖</span></div>
  81. <div class="legend-item"><span class="legend-square legend-inspection"></span><span>检查井</span></div>
  82. </div>
  83. </el-card>
  84. <el-card shadow="never" class="panel-card stats-card">
  85. <template #header>
  86. <span>测绘数据统计</span>
  87. </template>
  88. <div class="stats-list">
  89. <div class="stats-row"><span>管线总长</span><strong>{{ summaryStats.pipelineLength }} km</strong></div>
  90. <div class="stats-row"><span>管点总数</span><strong>{{ summaryStats.pipePointCount }}</strong></div>
  91. <div class="stats-row"><span>窨井总数</span><strong>{{ summaryStats.manholeCount }}</strong></div>
  92. <div class="stats-row"><span>覆盖面积</span><strong>{{ summaryStats.coverageArea }} km²</strong></div>
  93. </div>
  94. </el-card>
  95. <el-card shadow="never" class="panel-card detail-card">
  96. <template #header>
  97. <span>要素详情</span>
  98. </template>
  99. <div v-if="selectedFeature" class="detail-body">
  100. <div class="detail-name">{{ selectedFeature.name }}</div>
  101. <div class="detail-tag">{{ selectedFeature.category }}</div>
  102. <el-descriptions :column="1" border size="small">
  103. <el-descriptions-item
  104. v-for="item in selectedFeatureDetails"
  105. :key="item.label"
  106. :label="item.label"
  107. >
  108. {{ item.value }}
  109. </el-descriptions-item>
  110. </el-descriptions>
  111. </div>
  112. <el-empty v-else description="点击地图要素查看详情" :image-size="72" />
  113. </el-card>
  114. </aside>
  115. <section class="dashboard-map-panel">
  116. <div class="map-toolbar">
  117. <div class="toolbar-left">
  118. <el-button @click="zoomIn">
  119. <el-icon><Plus /></el-icon>
  120. 放大
  121. </el-button>
  122. <el-button @click="zoomOut">
  123. <el-icon><Minus /></el-icon>
  124. 缩小
  125. </el-button>
  126. <el-button @click="locateCenter">
  127. <el-icon><Location /></el-icon>
  128. 定位
  129. </el-button>
  130. </div>
  131. <div class="toolbar-right">当前坐标: {{ currentCoordinate }}</div>
  132. </div>
  133. <div id="gisMap" ref="mapRef" class="gis-map"></div>
  134. <div v-if="mapError" class="map-error">{{ mapError }}</div>
  135. </section>
  136. </div>
  137. </div>
  138. </template>
  139. <script setup>
  140. import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
  141. import { FullScreen, Location, MapLocation, Minus, Plus, RefreshRight, Search } from '@element-plus/icons-vue'
  142. import { ElMessage } from 'element-plus'
  143. import { convertCoord } from '@/utils/coordTransform'
  144. import { getPipeNetworkOptions, getGasPipePointPage, getManholeList } from '@/api/pipeNetwork/basic'
  145. const centerPoint = { lng: 110.386, lat: 28.445 }
  146. const MAP_BOUNDS = {
  147. southWest: { lng: 110.355, lat: 28.428 },
  148. northEast: { lng: 110.410, lat: 28.460 }
  149. }
  150. const MAP_MIN_ZOOM = 13
  151. const MAP_MAX_ZOOM = 19
  152. const MAP_DEFAULT_ZOOM = 15
  153. const defaultLayerState = () => ({
  154. pipelines: { high: true, subHigh: true, medium: true, low: true },
  155. pipePoints: { valve: true, regulator: true, flowmeter: true, terminal: true },
  156. manholes: { cover: true, inspection: true }
  157. })
  158. const boundaryPoints = [
  159. { lng: 110.358, lat: 28.456 },
  160. { lng: 110.375, lat: 28.458 },
  161. { lng: 110.392, lat: 28.458 },
  162. { lng: 110.408, lat: 28.455 },
  163. { lng: 110.410, lat: 28.448 },
  164. { lng: 110.407, lat: 28.440 },
  165. { lng: 110.400, lat: 28.436 },
  166. { lng: 110.390, lat: 28.434 },
  167. { lng: 110.378, lat: 28.433 },
  168. { lng: 110.368, lat: 28.436 },
  169. { lng: 110.360, lat: 28.445 }
  170. ]
  171. const pipelineData = [
  172. {
  173. id: 'pipe-high-01', level: 'high', name: '燃气1', code: 'GX-HP-001', length: 4.1,
  174. material: 'L290N 无缝钢管', diameter: 'DN300', pressure: '高压A (4.0MPa)', road: '燃气1主干线',
  175. status: '运行正常', layingMethod: '直埋敷设', depth: '1.8m', commissioningDate: '2026-06',
  176. 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}]
  177. },
  178. {
  179. id: 'pipe-subhigh-01', level: 'subHigh', name: '燃气2', code: 'GX-SHP-002', length: 3.8,
  180. material: 'L245N 无缝钢管', diameter: 'DN250', pressure: '次高压A (1.6MPa)', road: '燃气2支线',
  181. status: '运行正常', layingMethod: '直埋敷设', depth: '1.6m', commissioningDate: '2026-03',
  182. 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}]
  183. }
  184. ]
  185. const pipePointData = ref([])
  186. const manholeData = ref([])
  187. async function loadGisData() {
  188. try {
  189. const [pointRes, manholeRes] = await Promise.all([
  190. getGasPipePointPage(1, 500, {}),
  191. getManholeList()
  192. ])
  193. const points = (pointRes.data && pointRes.data.records) ? pointRes.data.records : []
  194. pipePointData.value = points.filter(p => p.longitude && p.latitude).map(p => ({
  195. id: 'point-' + p.pointId, type: mapPointType(p.pointType), pipelineId: p.networkId,
  196. name: p.pointName, code: p.pointCode, road: p.networkName || '', status: '在线',
  197. deviceModel: p.material || '', caliber: p.diameter ? p.diameter + 'mm' : '',
  198. lng: Number(p.longitude), lat: Number(p.latitude)
  199. }))
  200. const manholes = manholeRes.data || []
  201. manholeData.value = manholes.filter(m => m.longitude && m.latitude).map(m => ({
  202. id: 'manhole-' + m.manholeId, type: 'cover', pipelineId: m.networkId || '',
  203. name: m.manholeName, code: m.manholeCode, road: '', status: m.status === '0' ? '完好' : '异常',
  204. material: m.material || '', size: m.manholeShape || '', lng: Number(m.longitude), lat: Number(m.latitude)
  205. }))
  206. if (mapInstance && typeof BMapGL !== 'undefined') renderMapScene()
  207. } catch (e) { console.error(e) }
  208. }
  209. function mapPointType(type) {
  210. if (!type) return 'terminal'
  211. if (type.includes('阀门') || type === 'valve') return 'valve'
  212. if (type.includes('调压') || type === 'regulator') return 'regulator'
  213. if (type.includes('计量') || type.includes('流量') || type === 'flowmeter') return 'flowmeter'
  214. return 'terminal'
  215. }
  216. const colorMap = {
  217. high: '#e63946',
  218. subHigh: '#f77f00',
  219. medium: '#fcbf49',
  220. low: '#2a9d8f',
  221. valve: '#457b9d',
  222. regulator: '#e76f51',
  223. flowmeter: '#2d6a4f',
  224. terminal: '#6c757d',
  225. cover: '#7f4f24',
  226. inspection: '#b08968'
  227. }
  228. const labelMap = {
  229. high: '高压管线',
  230. subHigh: '次高压管线',
  231. medium: '中压管线',
  232. low: '低压管线',
  233. valve: '阀门',
  234. regulator: '调压器',
  235. flowmeter: '流量计',
  236. terminal: '终端节点',
  237. cover: '窨井盖',
  238. inspection: '检查井'
  239. }
  240. const mapRef = ref(null)
  241. const screenRef = ref(null)
  242. const searchKeyword = ref('')
  243. const currentCoordinate = ref(`${centerPoint.lng}, ${centerPoint.lat}`)
  244. const selectedFeature = ref(null)
  245. const mapError = ref('')
  246. const layerState = reactive(defaultLayerState())
  247. let mapInstance = null
  248. let activeInfoWindow = null
  249. let overlayClicked = false
  250. const summaryStats = computed(() => ({
  251. pipelineLength: pipelineData.reduce((sum, item) => sum + item.length, 0).toFixed(1),
  252. pipePointCount: pipePointData.value.length,
  253. manholeCount: manholeData.value.length,
  254. coverageArea: '18.6'
  255. }))
  256. const selectedFeatureDetails = computed(() => {
  257. if (!selectedFeature.value) return []
  258. return Object.entries(selectedFeature.value.details).map(([label, value]) => ({ label, value }))
  259. })
  260. const searchableFeatures = computed(() => {
  261. const pipelines = pipelineData.map(item => ({
  262. kind: 'pipeline',
  263. type: item.level,
  264. name: item.name,
  265. code: item.code,
  266. road: item.road,
  267. lng: getMidPoint(item.points).lng,
  268. lat: getMidPoint(item.points).lat,
  269. raw: item
  270. }))
  271. const pipePoints = pipePointData.value.map(item => ({
  272. kind: 'pipePoint',
  273. type: item.type,
  274. name: item.name,
  275. code: item.code,
  276. road: item.road,
  277. lng: item.lng,
  278. lat: item.lat,
  279. raw: item
  280. }))
  281. const manholes = manholeData.value.map(item => ({
  282. kind: 'manhole',
  283. type: item.type,
  284. name: item.name,
  285. code: item.code,
  286. road: item.road,
  287. lng: item.lng,
  288. lat: item.lat,
  289. raw: item
  290. }))
  291. return [...pipelines, ...pipePoints, ...manholes]
  292. })
  293. watch(layerState, () => {
  294. renderMapScene()
  295. }, { deep: true })
  296. onMounted(async () => {
  297. await nextTick()
  298. await loadGisData()
  299. waitForBMapGL()
  300. document.addEventListener('fullscreenchange', handleFullscreenChange)
  301. })
  302. onBeforeUnmount(() => {
  303. document.removeEventListener('fullscreenchange', handleFullscreenChange)
  304. if (mapInstance) {
  305. mapInstance.clearOverlays()
  306. mapInstance = null
  307. }
  308. })
  309. function waitForBMapGL() {
  310. if (typeof BMapGL !== 'undefined') {
  311. initMap()
  312. return
  313. }
  314. let attempts = 0
  315. const maxAttempts = 50
  316. const timer = setInterval(() => {
  317. attempts++
  318. if (typeof BMapGL !== 'undefined') {
  319. clearInterval(timer)
  320. initMap()
  321. return
  322. }
  323. if (attempts >= maxAttempts) {
  324. clearInterval(timer)
  325. mapError.value = '百度地图脚本加载超时,请刷新页面重试。'
  326. }
  327. }, 200)
  328. }
  329. function initMap() {
  330. const el = document.getElementById('gisMap')
  331. if (!el) {
  332. mapError.value = '地图容器未找到。'
  333. return
  334. }
  335. try {
  336. mapInstance = new BMapGL.Map('gisMap', { enableMapClick: true })
  337. } catch (e) {
  338. console.error('创建地图实例失败:', e)
  339. mapError.value = '百度地图初始化失败,请检查AK和网络连接。'
  340. return
  341. }
  342. try {
  343. const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
  344. const centerBMapPoint = new BMapGL.Point(clng, clat)
  345. mapInstance.centerAndZoom(centerBMapPoint, MAP_DEFAULT_ZOOM)
  346. mapInstance.enableScrollWheelZoom(true)
  347. } catch (e) {
  348. console.error('设置地图中心失败:', e)
  349. }
  350. try {
  351. if (typeof mapInstance.setMinZoom === 'function') {
  352. mapInstance.setMinZoom(MAP_MIN_ZOOM)
  353. }
  354. if (typeof mapInstance.setMaxZoom === 'function') {
  355. mapInstance.setMaxZoom(MAP_MAX_ZOOM)
  356. }
  357. } catch (e) {
  358. console.warn('设置缩放限制失败:', e)
  359. }
  360. try {
  361. if (typeof mapInstance.setMaxBounds === 'function') {
  362. const sw = new BMapGL.Point(MAP_BOUNDS.southWest.lng, MAP_BOUNDS.southWest.lat)
  363. const ne = new BMapGL.Point(MAP_BOUNDS.northEast.lng, MAP_BOUNDS.northEast.lat)
  364. mapInstance.setMaxBounds(new BMapGL.Bounds(sw, ne))
  365. }
  366. } catch (e) {
  367. console.warn('设置地图范围限制失败:', e)
  368. }
  369. mapInstance.addEventListener('click', () => {
  370. if (overlayClicked) {
  371. overlayClicked = false
  372. return
  373. }
  374. closeInfoWindow()
  375. selectedFeature.value = null
  376. })
  377. mapInstance.addEventListener('mousemove', (event) => {
  378. try {
  379. if (event && event.latlng) {
  380. currentCoordinate.value = `${event.latlng.lng.toFixed(4)}, ${event.latlng.lat.toFixed(4)}`
  381. }
  382. } catch (_) { /* ignore mousemove errors */ }
  383. })
  384. try {
  385. renderMapScene()
  386. } catch (e) {
  387. console.error('渲染地图要素失败:', e)
  388. mapError.value = '地图要素渲染失败。'
  389. }
  390. }
  391. function renderMapScene() {
  392. if (!mapInstance || typeof BMapGL === 'undefined') return
  393. closeInfoWindow()
  394. try { mapInstance.clearOverlays() } catch (_) { /* ignore */ }
  395. try { drawPipelines() } catch (e) { console.warn('绘制管线失败:', e) }
  396. try { drawPipePoints() } catch (e) { console.warn('绘制管点失败:', e) }
  397. try { drawManholes() } catch (e) { console.warn('绘制窨井失败:', e) }
  398. }
  399. function drawBoundary() {
  400. const pts = boundaryPoints.map(item => { const [lng, lat] = convertCoord(item.lng, item.lat); return new BMapGL.Point(lng, lat) })
  401. const polygon = new BMapGL.Polygon(pts, {
  402. strokeColor: '#52b788',
  403. strokeWeight: 2.5,
  404. strokeOpacity: 0.85,
  405. strokeStyle: 'dashed',
  406. fillColor: '#d8f3dc',
  407. fillOpacity: 0.22
  408. })
  409. mapInstance.addOverlay(polygon)
  410. }
  411. function drawPipelines() {
  412. pipelineData.forEach(item => {
  413. try {
  414. if (!layerState.pipelines[item.level]) return
  415. const pts = item.points.map(p => { const [lng, lat] = convertCoord(p.lng, p.lat); return new BMapGL.Point(lng, lat) })
  416. const polyline = new BMapGL.Polyline(pts, {
  417. strokeColor: colorMap[item.level],
  418. strokeWeight: 6,
  419. strokeOpacity: 0.92
  420. })
  421. const firstPt = { lng: convertCoord(item.points[0].lng, item.points[0].lat)[0], lat: convertCoord(item.points[0].lng, item.points[0].lat)[1] }
  422. try {
  423. polyline.addEventListener('click', (e) => {
  424. overlayClicked = true
  425. e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
  426. showPopup(firstPt.lng, firstPt.lat, buildPopupHTML(buildPipelineDetail(item)))
  427. selectedFeature.value = buildPipelineDetail(item)
  428. })
  429. } catch (_) { /* polyline click listener failed */ }
  430. mapInstance.addOverlay(polyline)
  431. const rawMid = getMidPoint(item.points)
  432. const mid = { lng: convertCoord(rawMid.lng, rawMid.lat)[0], lat: convertCoord(rawMid.lng, rawMid.lat)[1] }
  433. addPipeLabel(mid.lng, mid.lat, item.name, colorMap[item.level], item)
  434. } catch (e) {
  435. console.warn(`绘制管线 ${item.name} 失败:`, e)
  436. }
  437. })
  438. }
  439. function drawPipePoints() {
  440. pipePointData.value.forEach(item => {
  441. try {
  442. if (!layerState.pipePoints[item.type]) return
  443. const [plng, plat] = convertCoord(item.lng, item.lat)
  444. addPointMarker(plng, plat, colorMap[item.type], item.name, 'dot', () => {
  445. showPopup(plng, plat, buildPopupHTML(buildPipePointDetail(item)))
  446. selectedFeature.value = buildPipePointDetail(item)
  447. })
  448. } catch (e) {
  449. console.warn(`绘制管点 ${item.name} 失败:`, e)
  450. }
  451. })
  452. }
  453. function drawManholes() {
  454. manholeData.value.forEach(item => {
  455. try {
  456. if (!layerState.manholes[item.type]) return
  457. const [mlng, mlat] = convertCoord(item.lng, item.lat)
  458. addPointMarker(mlng, mlat, colorMap[item.type], item.name, 'square', () => {
  459. showPopup(mlng, mlat, buildPopupHTML(buildManholeDetail(item)))
  460. selectedFeature.value = buildManholeDetail(item)
  461. })
  462. } catch (e) {
  463. console.warn(`绘制窨井 ${item.name} 失败:`, e)
  464. }
  465. })
  466. }
  467. function addPipeLabel(lng, lat, text, color, rawItem) {
  468. const point = new BMapGL.Point(lng, lat)
  469. const html = `<div class="pipe-label" style="color:${color}">${text}</div>`
  470. const label = new BMapGL.Label(html, {
  471. position: point,
  472. offset: new BMapGL.Size(-28, -8)
  473. })
  474. label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
  475. try {
  476. label.addEventListener('click', (e) => {
  477. overlayClicked = true
  478. e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
  479. showPopup(lng, lat, buildPopupHTML(buildPipelineDetail(rawItem)))
  480. selectedFeature.value = buildPipelineDetail(rawItem)
  481. })
  482. } catch (_) { /* label click listener failed */ }
  483. mapInstance.addOverlay(label)
  484. }
  485. function addPointMarker(lng, lat, color, name, shape, onClick) {
  486. const point = new BMapGL.Point(lng, lat)
  487. const size = 22
  488. const borderRadius = shape === 'square' ? '3px' : '50%'
  489. const html = `<div class="marker-wrap" style="cursor:pointer">
  490. <div style="width:${size}px;height:${size}px;background:${color};border-radius:${borderRadius};border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,0.25);margin:0 auto;"></div>
  491. <div class="marker-text">${name}</div>
  492. </div>`
  493. const label = new BMapGL.Label(html, {
  494. position: point,
  495. offset: new BMapGL.Size(-size / 2 - 3, -size - 8)
  496. })
  497. label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
  498. try {
  499. label.addEventListener('click', (e) => {
  500. overlayClicked = true
  501. e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
  502. onClick && onClick()
  503. })
  504. } catch (_) { /* marker click listener failed */ }
  505. mapInstance.addOverlay(label)
  506. }
  507. function showPopup(lng, lat, html) {
  508. try {
  509. closeInfoWindow()
  510. const point = new BMapGL.Point(lng, lat)
  511. const infoWindow = new BMapGL.InfoWindow(html, {
  512. width: 310,
  513. title: '',
  514. enableMessage: false
  515. })
  516. activeInfoWindow = infoWindow
  517. mapInstance.openInfoWindow(infoWindow, point)
  518. } catch (e) {
  519. console.warn('弹出信息窗口失败:', e)
  520. }
  521. }
  522. function closeInfoWindow() {
  523. if (activeInfoWindow && mapInstance) {
  524. mapInstance.closeInfoWindow()
  525. activeInfoWindow = null
  526. }
  527. }
  528. function buildPopupHTML(detail) {
  529. const rows = Object.entries(detail.details)
  530. .map(([k, v]) => `<div class="popup-row"><span class="popup-label">${k}</span><span class="popup-value">${v}</span></div>`)
  531. .join('')
  532. return `<div class="info-popup">
  533. <div class="popup-header">${detail.name}</div>
  534. <div class="popup-tag">${detail.category}</div>
  535. <div class="popup-divider"></div>
  536. ${rows}
  537. </div>`
  538. }
  539. function buildPipelineDetail(item) {
  540. return {
  541. name: item.name,
  542. category: labelMap[item.level],
  543. details: {
  544. '管线编号': item.code,
  545. '所属道路': item.road,
  546. '管线长度': `${item.length} km`,
  547. '管径': item.diameter,
  548. '材质': item.material,
  549. '压力等级': item.pressure,
  550. '敷设方式': item.layingMethod,
  551. '埋深': item.depth,
  552. '投产日期': item.commissioningDate,
  553. '运行状态': item.status
  554. }
  555. }
  556. }
  557. function getPipelineName(pipelineId) {
  558. const found = pipelineData.find(p => p.id === pipelineId)
  559. return found ? found.name : '—'
  560. }
  561. function buildPipePointDetail(item) {
  562. return {
  563. name: item.name,
  564. category: labelMap[item.type],
  565. details: {
  566. '设备编号': item.code,
  567. '所属管线': getPipelineName(item.pipelineId),
  568. '所属道路': item.road,
  569. '设备型号': item.deviceModel,
  570. '口径': item.caliber,
  571. '运行状态': item.status,
  572. '经度': item.lng.toFixed(5),
  573. '纬度': item.lat.toFixed(5)
  574. }
  575. }
  576. }
  577. function buildManholeDetail(item) {
  578. return {
  579. name: item.name,
  580. category: labelMap[item.type],
  581. details: {
  582. '井盖编号': item.code,
  583. '所属管线': getPipelineName(item.pipelineId),
  584. '所属道路': item.road,
  585. '材质': item.material,
  586. '规格': item.size,
  587. '巡检状态': item.status,
  588. '经度': item.lng.toFixed(5),
  589. '纬度': item.lat.toFixed(5)
  590. }
  591. }
  592. }
  593. function getMidPoint(points) {
  594. return points[Math.floor(points.length / 2)]
  595. }
  596. function handleSearch() {
  597. const keyword = searchKeyword.value.trim()
  598. if (!keyword) {
  599. ElMessage.warning('请输入搜索关键字')
  600. return
  601. }
  602. const matched = searchableFeatures.value.find(item =>
  603. [item.name, item.code, item.road].some(text => text?.includes(keyword))
  604. )
  605. if (!matched) {
  606. ElMessage.info('未找到匹配要素')
  607. return
  608. }
  609. const [mlng, mlat] = convertCoord(matched.lng, matched.lat)
  610. mapInstance.centerAndZoom(new BMapGL.Point(mlng, mlat), 18)
  611. if (matched.kind === 'pipeline') {
  612. selectedFeature.value = buildPipelineDetail(matched.raw)
  613. showPopup(mlng, mlat, buildPopupHTML(selectedFeature.value))
  614. } else if (matched.kind === 'pipePoint') {
  615. selectedFeature.value = buildPipePointDetail(matched.raw)
  616. showPopup(mlng, mlat, buildPopupHTML(selectedFeature.value))
  617. } else {
  618. selectedFeature.value = buildManholeDetail(matched.raw)
  619. showPopup(mlng, mlat, buildPopupHTML(selectedFeature.value))
  620. }
  621. ElMessage.success(`已定位到 ${matched.name}`)
  622. }
  623. async function refreshScene() {
  624. layerState.pipelines = { high: true, subHigh: true, medium: true, low: true }
  625. layerState.pipePoints = { valve: true, regulator: true, flowmeter: true, terminal: true }
  626. layerState.manholes = { cover: true, inspection: true }
  627. searchKeyword.value = ''
  628. selectedFeature.value = null
  629. closeInfoWindow()
  630. await loadGisData()
  631. locateCenter()
  632. }
  633. function locateCenter() {
  634. if (!mapInstance) return
  635. const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
  636. mapInstance.centerAndZoom(new BMapGL.Point(clng, clat), MAP_DEFAULT_ZOOM)
  637. }
  638. function zoomIn() {
  639. if (!mapInstance) return
  640. const z = mapInstance.getZoom()
  641. if (z < MAP_MAX_ZOOM) mapInstance.zoomIn()
  642. else ElMessage.info('已放大至最大级别')
  643. }
  644. function zoomOut() {
  645. if (!mapInstance) return
  646. const z = mapInstance.getZoom()
  647. if (z > MAP_MIN_ZOOM) mapInstance.zoomOut()
  648. else ElMessage.info('已缩小至最小级别')
  649. }
  650. function toggleFullscreen() {
  651. const target = screenRef.value
  652. if (!target) return
  653. if (!document.fullscreenElement) {
  654. target.requestFullscreen?.()
  655. return
  656. }
  657. document.exitFullscreen?.()
  658. }
  659. function handleFullscreenChange() {
  660. if (!mapInstance) return
  661. const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
  662. setTimeout(() => {
  663. mapInstance.centerAndZoom(new BMapGL.Point(clng, clat), mapInstance.getZoom())
  664. }, 200)
  665. }
  666. </script>
  667. <style scoped>
  668. .gis-dashboard {
  669. height: calc(100vh - 84px);
  670. padding: 16px;
  671. background: #f2f5f9;
  672. display: flex;
  673. flex-direction: column;
  674. gap: 0;
  675. }
  676. .dashboard-header {
  677. display: flex;
  678. align-items: center;
  679. justify-content: space-between;
  680. gap: 16px;
  681. padding: 10px 16px;
  682. margin-bottom: 14px;
  683. background: #fff;
  684. border-radius: 10px;
  685. box-shadow: 0 4px 16px rgba(15, 35, 95, 0.05);
  686. flex-shrink: 0;
  687. }
  688. .header-title {
  689. display: flex;
  690. align-items: center;
  691. gap: 20px;
  692. flex-wrap: wrap;
  693. }
  694. .title-main {
  695. display: flex;
  696. align-items: center;
  697. gap: 8px;
  698. color: #1f2d3d;
  699. font-size: 17px;
  700. font-weight: 700;
  701. }
  702. .title-metrics {
  703. display: flex;
  704. align-items: center;
  705. gap: 16px;
  706. flex-wrap: wrap;
  707. }
  708. .metric {
  709. position: relative;
  710. padding-left: 14px;
  711. color: #44546a;
  712. font-size: 13px;
  713. }
  714. .metric::before {
  715. content: '';
  716. position: absolute;
  717. left: 0;
  718. top: 50%;
  719. width: 9px;
  720. height: 9px;
  721. border-radius: 50%;
  722. transform: translateY(-50%);
  723. }
  724. .metric-danger::before { background: #e63946; }
  725. .metric-primary::before { background: #457b9d; }
  726. .metric-warning::before { background: #7f4f24; }
  727. .header-actions {
  728. display: flex;
  729. align-items: center;
  730. gap: 10px;
  731. }
  732. .search-input {
  733. width: 300px;
  734. }
  735. .dashboard-content {
  736. flex: 1;
  737. display: flex;
  738. gap: 14px;
  739. min-height: 0;
  740. }
  741. .dashboard-sidebar {
  742. width: 320px;
  743. display: flex;
  744. flex-direction: column;
  745. gap: 14px;
  746. flex-shrink: 0;
  747. overflow-y: auto;
  748. }
  749. .panel-card {
  750. border: none;
  751. border-radius: 14px;
  752. box-shadow: 0 4px 18px rgba(15, 35, 95, 0.05);
  753. flex-shrink: 0;
  754. }
  755. .layer-group {
  756. display: flex;
  757. flex-direction: column;
  758. gap: 9px;
  759. padding-bottom: 16px;
  760. margin-bottom: 16px;
  761. border-bottom: 1px solid #eef2f8;
  762. }
  763. .layer-group-last {
  764. padding-bottom: 0;
  765. margin-bottom: 0;
  766. border-bottom: none;
  767. }
  768. .layer-title {
  769. color: #303133;
  770. font-size: 14px;
  771. font-weight: 600;
  772. }
  773. .legend-list {
  774. display: flex;
  775. flex-direction: column;
  776. gap: 10px;
  777. }
  778. .legend-item {
  779. display: flex;
  780. align-items: center;
  781. gap: 10px;
  782. color: #4e5969;
  783. font-size: 13px;
  784. }
  785. .legend-line {
  786. width: 34px;
  787. height: 0;
  788. border-top-width: 3px;
  789. border-top-style: solid;
  790. border-radius: 999px;
  791. flex-shrink: 0;
  792. }
  793. .legend-high { border-top-color: #e63946; }
  794. .legend-sub-high { border-top-color: #f77f00; }
  795. .legend-medium { border-top-color: #fcbf49; }
  796. .legend-low { border-top-color: #2a9d8f; }
  797. .legend-dot,
  798. .legend-square {
  799. width: 12px;
  800. height: 12px;
  801. flex-shrink: 0;
  802. }
  803. .legend-dot { border-radius: 50%; }
  804. .legend-square { border-radius: 2px; }
  805. .legend-valve { background: #457b9d; }
  806. .legend-regulator { background: #e76f51; }
  807. .legend-flowmeter { background: #2d6a4f; }
  808. .legend-terminal { background: #6c757d; }
  809. .legend-cover { background: #7f4f24; }
  810. .legend-inspection { background: #b08968; }
  811. .stats-card {
  812. background: linear-gradient(180deg, #f8fbff 0%, #f3f7fb 100%);
  813. }
  814. .stats-list {
  815. display: flex;
  816. flex-direction: column;
  817. gap: 11px;
  818. }
  819. .stats-row {
  820. display: flex;
  821. align-items: center;
  822. justify-content: space-between;
  823. color: #4e5969;
  824. font-size: 13px;
  825. }
  826. .stats-row strong {
  827. color: #1f2d3d;
  828. font-size: 14px;
  829. }
  830. .detail-card {
  831. flex: 1;
  832. min-height: 280px;
  833. }
  834. .detail-body {
  835. display: flex;
  836. flex-direction: column;
  837. gap: 10px;
  838. }
  839. .detail-name {
  840. color: #1f2d3d;
  841. font-size: 16px;
  842. font-weight: 700;
  843. }
  844. .detail-tag {
  845. display: inline-flex;
  846. align-items: center;
  847. width: fit-content;
  848. padding: 3px 10px;
  849. color: #2d6cdf;
  850. background: #edf4ff;
  851. border-radius: 999px;
  852. font-size: 12px;
  853. }
  854. .dashboard-map-panel {
  855. position: relative;
  856. flex: 1;
  857. display: flex;
  858. flex-direction: column;
  859. background: #fff;
  860. border-radius: 14px;
  861. overflow: hidden;
  862. box-shadow: 0 4px 18px rgba(15, 35, 95, 0.05);
  863. min-width: 0;
  864. }
  865. .map-toolbar {
  866. display: flex;
  867. align-items: center;
  868. justify-content: space-between;
  869. gap: 12px;
  870. padding: 10px 16px;
  871. border-bottom: 1px solid #edf2f7;
  872. flex-shrink: 0;
  873. }
  874. .toolbar-left {
  875. display: flex;
  876. align-items: center;
  877. gap: 8px;
  878. }
  879. .toolbar-right {
  880. color: #7a8699;
  881. font-size: 13px;
  882. }
  883. .gis-map {
  884. flex: 1;
  885. min-height: 0;
  886. background: #e9f0e5;
  887. }
  888. .map-error {
  889. position: absolute;
  890. inset: 52px 0 0;
  891. display: flex;
  892. align-items: center;
  893. justify-content: center;
  894. color: #909399;
  895. background: rgba(255, 255, 255, 0.85);
  896. font-size: 15px;
  897. z-index: 10;
  898. }
  899. </style>
  900. <style>
  901. .pipe-label {
  902. padding: 2px 8px;
  903. font-size: 12px;
  904. font-weight: 600;
  905. line-height: 1.3;
  906. white-space: nowrap;
  907. text-shadow: 0 0 4px #fff, 0 0 4px #fff;
  908. background: rgba(255, 255, 255, 0.7);
  909. border-radius: 10px;
  910. }
  911. .marker-wrap {
  912. display: flex;
  913. flex-direction: column;
  914. align-items: center;
  915. }
  916. .marker-text {
  917. margin-top: 3px;
  918. padding: 1px 7px;
  919. color: #2f3a4f;
  920. font-size: 11px;
  921. line-height: 1.3;
  922. white-space: nowrap;
  923. background: rgba(255, 255, 255, 0.78);
  924. border-radius: 10px;
  925. text-shadow: 0 0 3px #fff;
  926. }
  927. .info-popup {
  928. padding: 4px 2px;
  929. font-size: 13px;
  930. color: #333;
  931. }
  932. .popup-header {
  933. font-size: 15px;
  934. font-weight: 700;
  935. color: #1a1a2e;
  936. margin-bottom: 6px;
  937. }
  938. .popup-tag {
  939. display: inline-block;
  940. padding: 2px 10px;
  941. font-size: 11px;
  942. color: #1d4ed8;
  943. background: #eff6ff;
  944. border-radius: 999px;
  945. margin-bottom: 10px;
  946. }
  947. .popup-divider {
  948. height: 1px;
  949. background: #e5e7eb;
  950. margin-bottom: 10px;
  951. }
  952. .popup-row {
  953. display: flex;
  954. justify-content: space-between;
  955. align-items: center;
  956. padding: 5px 0;
  957. border-bottom: 1px dashed #f0f0f0;
  958. font-size: 12px;
  959. line-height: 1.5;
  960. }
  961. .popup-row:last-child {
  962. border-bottom: none;
  963. }
  964. .popup-label {
  965. color: #6b7280;
  966. flex-shrink: 0;
  967. margin-right: 12px;
  968. }
  969. .popup-value {
  970. color: #1f2937;
  971. font-weight: 500;
  972. text-align: right;
  973. word-break: break-all;
  974. }
  975. .BMap_cpyCtrl,
  976. .anchorBL {
  977. display: none;
  978. }
  979. </style>