gis.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878
  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-source">水源地 {{ summaryStats.sourceCount }}处</span>
  11. <span class="metric metric-plant">水厂 {{ summaryStats.plantCount }}座</span>
  12. <span class="metric metric-pumpStation">泵站 {{ summaryStats.pumpStationCount }}座</span>
  13. <span class="metric metric-pipe">供水管网 {{ summaryStats.pipeCount }}条</span>
  14. </div>
  15. </div>
  16. <div class="header-actions">
  17. <el-input
  18. v-model="searchKeyword"
  19. class="search-input"
  20. placeholder="搜索设施名称/编号/地址"
  21. clearable
  22. @keyup.enter="handleSearch"
  23. >
  24. <template #prefix>
  25. <el-icon><Search /></el-icon>
  26. </template>
  27. </el-input>
  28. <el-button type="primary" @click="handleSearch">
  29. <el-icon><Search /></el-icon>
  30. 搜索
  31. </el-button>
  32. <el-button @click="refreshScene">
  33. <el-icon><RefreshRight /></el-icon>
  34. 刷新
  35. </el-button>
  36. <el-button @click="toggleFullscreen">
  37. <el-icon><FullScreen /></el-icon>
  38. 全图
  39. </el-button>
  40. </div>
  41. </div>
  42. <div ref="screenRef" class="dashboard-content">
  43. <aside class="dashboard-sidebar">
  44. <el-card shadow="never" class="panel-card">
  45. <template #header>
  46. <span>图层控制</span>
  47. </template>
  48. <div class="layer-group">
  49. <div class="layer-title">供水设施图层</div>
  50. <el-checkbox v-for="item in facilityTypes" :key="item.value" v-model="layerState[item.value]">{{ item.label }}</el-checkbox>
  51. </div>
  52. </el-card>
  53. <el-card shadow="never" class="panel-card">
  54. <template #header>
  55. <span>图例说明</span>
  56. </template>
  57. <div class="legend-list">
  58. <div class="legend-item" v-for="item in facilityTypes" :key="item.value"><span class="legend-dot" :style="{ background: item.color }"></span><span>{{ item.label }}</span></div>
  59. </div>
  60. </el-card>
  61. <el-card shadow="never" class="panel-card stats-card">
  62. <template #header>
  63. <span>供水设施统计</span>
  64. </template>
  65. <div class="stats-list">
  66. <div class="stats-row"><span>水源地</span><strong>{{ summaryStats.sourceCount }}</strong></div>
  67. <div class="stats-row"><span>水厂</span><strong>{{ summaryStats.plantCount }}</strong></div>
  68. <div class="stats-row"><span>泵站</span><strong>{{ summaryStats.pumpStationCount }}</strong></div>
  69. <div class="stats-row"><span>供水管网</span><strong>{{ summaryStats.pipeCount }}</strong></div>
  70. <div class="stats-row"><span>用水户</span><strong>{{ summaryStats.userCount }}</strong></div>
  71. </div>
  72. </el-card>
  73. </aside>
  74. <section class="dashboard-map-panel">
  75. <div class="map-toolbar">
  76. <div class="toolbar-left">
  77. <el-button @click="zoomIn">
  78. <el-icon><Plus /></el-icon>
  79. 放大
  80. </el-button>
  81. <el-button @click="zoomOut">
  82. <el-icon><Minus /></el-icon>
  83. 缩小
  84. </el-button>
  85. <el-button @click="locateCenter">
  86. <el-icon><Location /></el-icon>
  87. 定位
  88. </el-button>
  89. </div>
  90. <div class="toolbar-right">当前坐标: {{ currentCoordinate }}</div>
  91. </div>
  92. <div id="gisMap" ref="mapRef" class="gis-map"></div>
  93. <div v-if="mapError" class="map-error">
  94. <el-result icon="warning" title="供水设施图层加载失败" :sub-title="mapError">
  95. <template #extra><el-button type="primary" @click="loadGisData">重试</el-button></template>
  96. </el-result>
  97. </div>
  98. <div v-else-if="mapEmpty" class="map-error">
  99. <el-empty description="当前筛选下没有可显示的供水设施" />
  100. </div>
  101. </section>
  102. </div>
  103. <el-drawer
  104. v-model="detailOpen"
  105. :title="detailTitle"
  106. direction="rtl"
  107. class="detail-drawer"
  108. size="560px"
  109. append-to-body
  110. >
  111. <el-skeleton v-if="detailLoading" :rows="8" animated />
  112. <el-result v-else-if="detailError" icon="warning" title="设施详情加载失败" :sub-title="detailError">
  113. <template #extra><el-button type="primary" @click="loadFacilityDetail">重试</el-button></template>
  114. </el-result>
  115. <el-descriptions v-else-if="detailRecord" :column="1" border>
  116. <el-descriptions-item v-for="item in detailEntries" :key="item.label" :label="item.label">
  117. {{ item.value }}
  118. </el-descriptions-item>
  119. </el-descriptions>
  120. <el-empty v-else description="暂无设施详情" />
  121. </el-drawer>
  122. </div>
  123. </template>
  124. <script setup>
  125. import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
  126. import { FullScreen, Location, MapLocation, Minus, Plus, RefreshRight, Search } from '@element-plus/icons-vue'
  127. import { ElMessage } from 'element-plus'
  128. import { convertCoord } from '@/utils/coordTransform'
  129. import { getWaterFacilityOverview, getWaterFacilityFeatures, getWaterFacilityDetail } from '@/api/pipeNetwork/waterSupply'
  130. import { normalizeGeoJson, unwrapResponse } from '@/utils/waterSupplyModel'
  131. import { getRequestErrorMessage } from '@/utils/waterFacilityQuery'
  132. import { buildFacilityMapModel, filterFacilityMapModel, findFacilityFeature } from '@/utils/waterFacilityGisModel'
  133. const centerPoint = { lng: 110.386, lat: 28.445 }
  134. const MAP_BOUNDS = {
  135. southWest: { lng: 110.355, lat: 28.428 },
  136. northEast: { lng: 110.410, lat: 28.460 }
  137. }
  138. const MAP_MIN_ZOOM = 13
  139. const MAP_MAX_ZOOM = 19
  140. const MAP_DEFAULT_ZOOM = 15
  141. const defaultLayerState = () => ({
  142. source: true,
  143. plant: true,
  144. pumpStation: true,
  145. pipe: true,
  146. user: true
  147. })
  148. const facilityTypes = [
  149. { label: '水源地', value: 'source', color: '#457b9d' },
  150. { label: '水厂', value: 'plant', color: '#2a9d8f' },
  151. { label: '泵站', value: 'pumpStation', color: '#e76f51' },
  152. { label: '管网', value: 'pipe', color: '#fcbf49' },
  153. { label: '用水户', value: 'user', color: '#7f4f24' }
  154. ]
  155. const colorMap = {
  156. source: '#457b9d',
  157. plant: '#2a9d8f',
  158. pumpStation: '#e76f51',
  159. pipe: '#fcbf49',
  160. user: '#7f4f24'
  161. }
  162. const labelMap = {
  163. source: '水源地',
  164. plant: '水厂',
  165. pumpStation: '泵站',
  166. pipe: '供水管网',
  167. user: '用水户'
  168. }
  169. const mapRef = ref(null)
  170. const screenRef = ref(null)
  171. const searchKeyword = ref('')
  172. const currentCoordinate = ref(`${centerPoint.lng}, ${centerPoint.lat}`)
  173. const mapError = ref('')
  174. const dataLoaded = ref(false)
  175. const layerState = reactive(defaultLayerState())
  176. const facilityOverview = ref([])
  177. const pipelineData = ref([])
  178. const pointData = ref([])
  179. const detailOpen = ref(false)
  180. const detailLoading = ref(false)
  181. const detailError = ref('')
  182. const detailRecord = ref(null)
  183. const detailTarget = ref(null)
  184. let mapInstance = null
  185. let overlayClicked = false
  186. const typeCount = type => {
  187. const row = facilityOverview.value.find(item => item.facilityType === type)
  188. return Number(row?.totalCount ?? row?.count ?? 0)
  189. }
  190. const summaryStats = computed(() => ({
  191. sourceCount: typeCount('source'),
  192. plantCount: typeCount('plant'),
  193. pumpStationCount: typeCount('pumpStation'),
  194. pipeCount: typeCount('pipe'),
  195. userCount: typeCount('user')
  196. }))
  197. const fullMapModel = computed(() => ({ points: pointData.value, lines: pipelineData.value }))
  198. const visibleMapModel = computed(() => filterFacilityMapModel(fullMapModel.value, layerState))
  199. const mapEmpty = computed(() => dataLoaded.value && !mapError.value && visibleMapModel.value.isEmpty)
  200. const detailTitle = computed(() => `${labelMap[detailTarget.value?.type] || '供水设施'}详情`)
  201. const detailEntries = computed(() => Object.entries(detailRecord.value || {})
  202. .filter(([, value]) => value !== null && value !== undefined && typeof value !== 'object')
  203. .map(([label, value]) => ({ label, value: value === '' ? '-' : value })))
  204. watch(layerState, () => {
  205. renderMapScene()
  206. }, { deep: true })
  207. onMounted(async () => {
  208. await nextTick()
  209. await loadGisData()
  210. waitForBMapGL()
  211. document.addEventListener('fullscreenchange', handleFullscreenChange)
  212. })
  213. onBeforeUnmount(() => {
  214. document.removeEventListener('fullscreenchange', handleFullscreenChange)
  215. if (mapInstance) {
  216. mapInstance.clearOverlays()
  217. mapInstance = null
  218. }
  219. })
  220. function waitForBMapGL() {
  221. if (typeof BMapGL !== 'undefined') {
  222. initMap()
  223. return
  224. }
  225. let attempts = 0
  226. const maxAttempts = 50
  227. const timer = setInterval(() => {
  228. attempts++
  229. if (typeof BMapGL !== 'undefined') {
  230. clearInterval(timer)
  231. initMap()
  232. return
  233. }
  234. if (attempts >= maxAttempts) {
  235. clearInterval(timer)
  236. mapError.value = '百度地图脚本加载超时,请刷新页面重试。'
  237. }
  238. }, 200)
  239. }
  240. function initMap() {
  241. const el = document.getElementById('gisMap')
  242. if (!el) {
  243. mapError.value = '地图容器未找到。'
  244. return
  245. }
  246. try {
  247. mapInstance = new BMapGL.Map('gisMap', { enableMapClick: true })
  248. } catch (e) {
  249. console.error('创建地图实例失败:', e)
  250. mapError.value = '百度地图初始化失败,请检查AK和网络连接。'
  251. return
  252. }
  253. try {
  254. const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
  255. const centerBMapPoint = new BMapGL.Point(clng, clat)
  256. mapInstance.centerAndZoom(centerBMapPoint, MAP_DEFAULT_ZOOM)
  257. mapInstance.enableScrollWheelZoom(true)
  258. } catch (e) {
  259. console.error('设置地图中心失败:', e)
  260. }
  261. try {
  262. if (typeof mapInstance.setMinZoom === 'function') {
  263. mapInstance.setMinZoom(MAP_MIN_ZOOM)
  264. }
  265. if (typeof mapInstance.setMaxZoom === 'function') {
  266. mapInstance.setMaxZoom(MAP_MAX_ZOOM)
  267. }
  268. } catch (e) {
  269. console.warn('设置缩放限制失败:', e)
  270. }
  271. try {
  272. if (typeof mapInstance.setMaxBounds === 'function') {
  273. const sw = new BMapGL.Point(MAP_BOUNDS.southWest.lng, MAP_BOUNDS.southWest.lat)
  274. const ne = new BMapGL.Point(MAP_BOUNDS.northEast.lng, MAP_BOUNDS.northEast.lat)
  275. mapInstance.setMaxBounds(new BMapGL.Bounds(sw, ne))
  276. }
  277. } catch (e) {
  278. console.warn('设置地图范围限制失败:', e)
  279. }
  280. mapInstance.addEventListener('click', () => {
  281. if (overlayClicked) {
  282. overlayClicked = false
  283. return
  284. }
  285. detailOpen.value = false
  286. })
  287. mapInstance.addEventListener('mousemove', (event) => {
  288. try {
  289. if (event && event.latlng) {
  290. currentCoordinate.value = `${event.latlng.lng.toFixed(4)}, ${event.latlng.lat.toFixed(4)}`
  291. }
  292. } catch (_) { /* ignore mousemove errors */ }
  293. })
  294. try {
  295. renderMapScene()
  296. } catch (e) {
  297. console.error('渲染地图要素失败:', e)
  298. mapError.value = '地图要素渲染失败。'
  299. }
  300. }
  301. function renderMapScene() {
  302. if (!mapInstance || typeof BMapGL === 'undefined') return
  303. try { mapInstance.clearOverlays() } catch (_) { /* ignore */ }
  304. try { drawPipelines() } catch (e) { console.warn('绘制供水管网失败:', e) }
  305. try { drawPoints() } catch (e) { console.warn('绘制供水设施失败:', e) }
  306. }
  307. function drawPipelines() {
  308. pipelineData.value.forEach(item => {
  309. try {
  310. if (!layerState[item.type]) return
  311. const pts = item.points.map(p => { const [lng, lat] = convertCoord(p.lng, p.lat); return new BMapGL.Point(lng, lat) })
  312. const polyline = new BMapGL.Polyline(pts, {
  313. strokeColor: colorMap[item.type],
  314. strokeWeight: 6,
  315. strokeOpacity: 0.92
  316. })
  317. try {
  318. polyline.addEventListener('click', (e) => {
  319. overlayClicked = true
  320. e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
  321. openFacilityDetail(item)
  322. })
  323. } catch (_) { /* polyline click listener failed */ }
  324. mapInstance.addOverlay(polyline)
  325. const rawMid = getMidPoint(item.points)
  326. const mid = { lng: convertCoord(rawMid.lng, rawMid.lat)[0], lat: convertCoord(rawMid.lng, rawMid.lat)[1] }
  327. addPipeLabel(mid.lng, mid.lat, item.name, colorMap[item.type], item)
  328. } catch (e) {
  329. console.warn(`绘制供水管网 ${item.name} 失败:`, e)
  330. }
  331. })
  332. }
  333. function drawPoints() {
  334. pointData.value.forEach(item => {
  335. try {
  336. if (!layerState[item.type]) return
  337. const [plng, plat] = convertCoord(item.lng, item.lat)
  338. addPointMarker(plng, plat, colorMap[item.type], item.name, 'dot', () => {
  339. openFacilityDetail(item)
  340. })
  341. } catch (e) {
  342. console.warn(`绘制供水设施 ${item.name} 失败:`, e)
  343. }
  344. })
  345. }
  346. function addPipeLabel(lng, lat, text, color, rawItem) {
  347. const point = new BMapGL.Point(lng, lat)
  348. const html = `<div class="pipe-label" style="color:${color}">${text}</div>`
  349. const label = new BMapGL.Label(html, {
  350. position: point,
  351. offset: new BMapGL.Size(-28, -8)
  352. })
  353. label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
  354. try {
  355. label.addEventListener('click', (e) => {
  356. overlayClicked = true
  357. e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
  358. openFacilityDetail(rawItem)
  359. })
  360. } catch (_) { /* label click listener failed */ }
  361. mapInstance.addOverlay(label)
  362. }
  363. function addPointMarker(lng, lat, color, name, shape, onClick) {
  364. const point = new BMapGL.Point(lng, lat)
  365. const size = 22
  366. const borderRadius = shape === 'square' ? '3px' : '50%'
  367. const html = `<div class="marker-wrap" style="cursor:pointer">
  368. <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>
  369. <div class="marker-text">${name}</div>
  370. </div>`
  371. const label = new BMapGL.Label(html, {
  372. position: point,
  373. offset: new BMapGL.Size(-size / 2 - 3, -size - 8)
  374. })
  375. label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
  376. try {
  377. label.addEventListener('click', (e) => {
  378. overlayClicked = true
  379. e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
  380. onClick && onClick()
  381. })
  382. } catch (_) { /* marker click listener failed */ }
  383. mapInstance.addOverlay(label)
  384. }
  385. function getMidPoint(points) {
  386. const first = points[0]
  387. const last = points[points.length - 1]
  388. return {
  389. lng: (first.lng + last.lng) / 2,
  390. lat: (first.lat + last.lat) / 2
  391. }
  392. }
  393. async function loadGisData() {
  394. mapError.value = ''
  395. dataLoaded.value = false
  396. try {
  397. const [overviewRes, featureRes] = await Promise.all([
  398. getWaterFacilityOverview(),
  399. getWaterFacilityFeatures()
  400. ])
  401. const overviewData = unwrapResponse(overviewRes)
  402. facilityOverview.value = Array.isArray(overviewData) ? overviewData : (overviewData?.facilityOverview || [])
  403. const geoJson = normalizeGeoJson(featureRes)
  404. const model = buildFacilityMapModel(geoJson)
  405. pipelineData.value = model.lines
  406. pointData.value = model.points
  407. dataLoaded.value = true
  408. if (mapInstance && typeof BMapGL !== 'undefined') renderMapScene()
  409. } catch (e) {
  410. console.error('加载供水GIS数据失败', e)
  411. facilityOverview.value = []
  412. pipelineData.value = []
  413. pointData.value = []
  414. dataLoaded.value = true
  415. mapError.value = getRequestErrorMessage(e, '供水GIS数据加载失败,请重试。')
  416. }
  417. }
  418. function handleSearch() {
  419. const keyword = searchKeyword.value.trim()
  420. if (!keyword) {
  421. ElMessage.warning('请输入搜索关键字')
  422. return
  423. }
  424. const matched = findFacilityFeature(visibleMapModel.value, keyword)
  425. if (!matched) {
  426. ElMessage.info('未找到匹配要素')
  427. return
  428. }
  429. const [mlng, mlat] = convertCoord(matched.lng, matched.lat)
  430. if (mapInstance && typeof BMapGL !== 'undefined') {
  431. mapInstance.centerAndZoom(new BMapGL.Point(mlng, mlat), 18)
  432. }
  433. openFacilityDetail(matched)
  434. ElMessage.success(`已定位到 ${matched.name}`)
  435. }
  436. async function openFacilityDetail(feature) {
  437. detailTarget.value = feature
  438. detailOpen.value = true
  439. detailRecord.value = null
  440. detailError.value = ''
  441. await loadFacilityDetail()
  442. }
  443. async function loadFacilityDetail() {
  444. const feature = detailTarget.value
  445. if (!feature?.type || feature.id === null || feature.id === undefined) return
  446. detailLoading.value = true
  447. detailError.value = ''
  448. try {
  449. detailRecord.value = unwrapResponse(await getWaterFacilityDetail(feature.type, feature.id))
  450. } catch (error) {
  451. detailRecord.value = null
  452. detailError.value = getRequestErrorMessage(error, '设施详情查询失败,请重试。')
  453. } finally {
  454. detailLoading.value = false
  455. }
  456. }
  457. async function refreshScene() {
  458. layerState.source = true
  459. layerState.plant = true
  460. layerState.pumpStation = true
  461. layerState.pipe = true
  462. layerState.user = true
  463. searchKeyword.value = ''
  464. detailOpen.value = false
  465. await loadGisData()
  466. locateCenter()
  467. }
  468. function locateCenter() {
  469. if (!mapInstance) return
  470. const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
  471. mapInstance.centerAndZoom(new BMapGL.Point(clng, clat), MAP_DEFAULT_ZOOM)
  472. }
  473. function zoomIn() {
  474. if (!mapInstance) return
  475. const z = mapInstance.getZoom()
  476. if (z < MAP_MAX_ZOOM) mapInstance.zoomIn()
  477. else ElMessage.info('已放大至最大级别')
  478. }
  479. function zoomOut() {
  480. if (!mapInstance) return
  481. const z = mapInstance.getZoom()
  482. if (z > MAP_MIN_ZOOM) mapInstance.zoomOut()
  483. else ElMessage.info('已缩小至最小级别')
  484. }
  485. function toggleFullscreen() {
  486. const target = screenRef.value
  487. if (!target) return
  488. if (!document.fullscreenElement) {
  489. target.requestFullscreen?.()
  490. return
  491. }
  492. document.exitFullscreen?.()
  493. }
  494. function handleFullscreenChange() {
  495. if (!mapInstance) return
  496. const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
  497. setTimeout(() => {
  498. mapInstance.centerAndZoom(new BMapGL.Point(clng, clat), mapInstance.getZoom())
  499. }, 200)
  500. }
  501. </script>
  502. <style scoped>
  503. .gis-dashboard {
  504. height: calc(100vh - 84px);
  505. padding: 16px;
  506. background: #f2f5f9;
  507. display: flex;
  508. flex-direction: column;
  509. gap: 0;
  510. }
  511. .dashboard-header {
  512. display: flex;
  513. align-items: center;
  514. justify-content: space-between;
  515. gap: 16px;
  516. padding: 10px 16px;
  517. margin-bottom: 14px;
  518. background: #fff;
  519. border-radius: 10px;
  520. box-shadow: 0 4px 16px rgba(15, 35, 95, 0.05);
  521. flex-shrink: 0;
  522. }
  523. .header-title {
  524. display: flex;
  525. align-items: center;
  526. gap: 20px;
  527. flex-wrap: wrap;
  528. }
  529. .title-main {
  530. display: flex;
  531. align-items: center;
  532. gap: 8px;
  533. color: #1f2d3d;
  534. font-size: 17px;
  535. font-weight: 700;
  536. }
  537. .title-metrics {
  538. display: flex;
  539. flex-wrap: wrap;
  540. gap: 16px;
  541. margin: 8px 0;
  542. align-items: center;
  543. margin-bottom: 8px;
  544. }
  545. .metric {
  546. display: inline-flex;
  547. align-items: center; /* 垂直居中 */
  548. padding: 12px 16px; /* 左右内边距 */
  549. height: auto;
  550. min-height: 48px;
  551. font-size: 16px;
  552. line-height: 1.5;
  553. border-radius: 8px;
  554. min-width: 100px;
  555. white-space: nowrap;
  556. background: rgba(255, 255, 255, 0.06);
  557. border: 1px solid rgba(255, 255, 255, 0.12);
  558. color: #ecf6ff;
  559. box-shadow: 0 1px 8px rgba(0, 0, 0, 0.25);
  560. transition: all 0.25s ease;
  561. cursor: default; /* 如果希望可点击,可改 pointer */
  562. backdrop-filter: blur(4px); /* 毛玻璃效果(可选,需浏览器支持) */
  563. }
  564. .metric::before {
  565. content: '';
  566. position: absolute;
  567. left: 0;
  568. top: 50%;
  569. width: 9px;
  570. height: 9px;
  571. border-radius: 50%;
  572. transform: translateY(-50%);
  573. }
  574. /* 语义颜色强化 */
  575. .metric-source {
  576. background: rgba(69, 123, 157, 0.8);
  577. border-color: rgba(69, 123, 157, 0.6);
  578. color: #F0F4F6;
  579. }
  580. .metric-plant {
  581. background: rgba(42, 157, 143, 0.8);
  582. border-color: rgba(42, 157, 143, 0.6);
  583. color: #F0F4F6;
  584. }
  585. .metric-pumpStation {
  586. background: rgba(231, 111, 81, 0.8);
  587. border-color: rgba(231, 111, 81, 0.6);
  588. color: #F0F4F6;
  589. }
  590. .metric-pipe {
  591. background: rgba(252, 191, 73, 0.8);
  592. border-color: rgba(252, 191, 73, 0.6);
  593. color: #F0F4F6;
  594. }
  595. .metric:hover {
  596. transform: translateY(-2px);
  597. background: rgba(0, 149, 253, 0.5);
  598. border-color: rgba(0, 149, 253, 0.5);
  599. box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
  600. }
  601. .header-actions {
  602. display: flex;
  603. align-items: center;
  604. gap: 10px;
  605. }
  606. .search-input {
  607. width: 300px;
  608. }
  609. .dashboard-content {
  610. flex: 1;
  611. display: flex;
  612. gap: 14px;
  613. min-height: 0;
  614. }
  615. .dashboard-sidebar {
  616. width: 320px;
  617. display: flex;
  618. flex-direction: column;
  619. gap: 14px;
  620. flex-shrink: 0;
  621. overflow-y: auto;
  622. }
  623. .panel-card {
  624. border: none;
  625. border-radius: 14px;
  626. box-shadow: 0 4px 18px rgba(15, 35, 95, 0.05);
  627. flex-shrink: 0;
  628. }
  629. .layer-group {
  630. display: flex;
  631. flex-direction: column;
  632. gap: 9px;
  633. padding-bottom: 16px;
  634. margin-bottom: 16px;
  635. border-bottom: 1px solid #eef2f8;
  636. }
  637. .layer-group-last {
  638. padding-bottom: 0;
  639. margin-bottom: 0;
  640. border-bottom: none;
  641. }
  642. .layer-title {
  643. color: #303133;
  644. font-size: 14px;
  645. font-weight: 600;
  646. }
  647. .legend-list {
  648. display: flex;
  649. flex-direction: column;
  650. gap: 10px;
  651. }
  652. .legend-item {
  653. display: flex;
  654. align-items: center;
  655. gap: 8px;
  656. color: #4e5969;
  657. font-size: 14px;
  658. }
  659. .legend-line {
  660. width: 34px;
  661. height: 0;
  662. border-top-width: 3px;
  663. border-top-style: solid;
  664. border-radius: 999px;
  665. flex-shrink: 0;
  666. }
  667. .legend-high { border-top-color: #e63946; }
  668. .legend-sub-high { border-top-color: #f77f00; }
  669. .legend-medium { border-top-color: #fcbf49; }
  670. .legend-low { border-top-color: #2a9d8f; }
  671. .legend-dot,
  672. .legend-square {
  673. width: 12px;
  674. height: 12px;
  675. flex-shrink: 0;
  676. }
  677. .legend-dot { border-radius: 50%; }
  678. .legend-square { border-radius: 2px; }
  679. .legend-valve { background: #457b9d; }
  680. .legend-regulator { background: #e76f51; }
  681. .legend-flowmeter { background: #2d6a4f; }
  682. .legend-terminal { background: #6c757d; }
  683. .legend-cover { background: #7f4f24; }
  684. .legend-inspection { background: #b08968; }
  685. .stats-card {
  686. background: linear-gradient(180deg, #f8fbff 0%, #f3f7fb 100%);
  687. }
  688. .stats-list {
  689. display: flex;
  690. flex-direction: column;
  691. gap: 11px;
  692. }
  693. .stats-row {
  694. display: flex;
  695. align-items: center;
  696. justify-content: space-between;
  697. color: #4e5969;
  698. font-size: 13px;
  699. }
  700. .stats-row strong {
  701. color: #1f2d3d;
  702. font-size: 14px;
  703. }
  704. .detail-card {
  705. flex: 1;
  706. min-height: 280px;
  707. }
  708. .dashboard-map-panel {
  709. position: relative;
  710. flex: 1;
  711. display: flex;
  712. flex-direction: column;
  713. background: #fff;
  714. border-radius: 14px;
  715. overflow: hidden;
  716. box-shadow: 0 4px 18px rgba(15, 35, 95, 0.05);
  717. min-width: 0;
  718. }
  719. .map-toolbar {
  720. display: flex;
  721. align-items: center;
  722. justify-content: space-between;
  723. gap: 12px;
  724. padding: 10px 16px;
  725. border-bottom: 1px solid #edf2f7;
  726. flex-shrink: 0;
  727. }
  728. .toolbar-left {
  729. display: flex;
  730. align-items: center;
  731. gap: 8px;
  732. }
  733. .toolbar-right {
  734. color: #7a8699;
  735. font-size: 13px;
  736. }
  737. .gis-map {
  738. flex: 1;
  739. min-height: 0;
  740. background: #e9f0e5;
  741. }
  742. .map-error {
  743. position: absolute;
  744. inset: 52px 0 0;
  745. display: flex;
  746. align-items: center;
  747. justify-content: center;
  748. color: #909399;
  749. background: rgba(255, 255, 255, 0.85);
  750. font-size: 15px;
  751. z-index: 10;
  752. }
  753. </style>
  754. <style>
  755. .pipe-label {
  756. padding: 2px 8px;
  757. font-size: 12px;
  758. font-weight: 600;
  759. line-height: 1.3;
  760. white-space: nowrap;
  761. text-shadow: 0 0 4px #fff, 0 0 4px #fff;
  762. background: rgba(255, 255, 255, 0.7);
  763. border-radius: 10px;
  764. }
  765. .marker-wrap {
  766. display: flex;
  767. flex-direction: column;
  768. align-items: center;
  769. }
  770. .marker-text {
  771. margin-top: 3px;
  772. padding: 1px 7px;
  773. color: #2f3a4f;
  774. font-size: 11px;
  775. line-height: 1.3;
  776. white-space: nowrap;
  777. background: rgba(255, 255, 255, 0.78);
  778. border-radius: 10px;
  779. text-shadow: 0 0 3px #fff;
  780. }
  781. .BMap_cpyCtrl,
  782. .anchorBL {
  783. display: none;
  784. }
  785. </style>