gis.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. <template>
  2. <div class="water-page gis-page">
  3. <div class="page-heading">
  4. <div class="page-title">
  5. <span class="page-title-icon"><el-icon><MapLocation/></el-icon></span>
  6. <div><h2>供水设施 GIS 一张图</h2><span class="muted">设施点位分布与查询定位</span></div>
  7. </div>
  8. <div class="heading-actions">
  9. <el-button :icon="Refresh" :loading="loading" @click="refreshScene">刷新</el-button>
  10. </div>
  11. </div>
  12. <el-card shadow="never" class="water-panel filter-card">
  13. <template #header>
  14. <div class="card-title">
  15. <span class="card-heading"><el-icon><Search/></el-icon><span>设施筛选</span></span>
  16. </div>
  17. </template>
  18. <el-form :inline="true" class="filter-form">
  19. <el-form-item label="关键字">
  20. <el-input v-model="searchKeyword" class="keyword-input" clearable placeholder="设施名称 / 编号 / 地址"
  21. :prefix-icon="Search" @keyup.enter="applyFilters"/>
  22. </el-form-item>
  23. <el-form-item label="设施类型">
  24. <el-select v-model="selectedFacilityType" class="status-select" clearable placeholder="全部设施类型">
  25. <el-option v-for="item in facilityTypes" :key="item.value" :label="item.label" :value="item.value"/>
  26. </el-select>
  27. </el-form-item>
  28. <el-form-item>
  29. <el-button type="primary" @click="applyFilters">查询</el-button>
  30. <el-button @click="resetFilters">重置</el-button>
  31. </el-form-item>
  32. </el-form>
  33. </el-card>
  34. <el-card shadow="never" class="water-panel map-card">
  35. <WaterGisPageShell
  36. embedded
  37. eyebrow="WATER SUPPLY FACILITY GIS"
  38. title="供水设施 GIS 一张图"
  39. :loading="loading"
  40. >
  41. <template #actions>
  42. <el-input
  43. v-model="searchKeyword"
  44. class="search-input"
  45. placeholder="搜索设施名称/编号/地址"
  46. clearable
  47. :prefix-icon="Search"
  48. @keyup.enter="handleSearch"
  49. />
  50. <el-select v-model="selectedFacilityType" class="type-select" placeholder="全部设施类型" clearable>
  51. <el-option v-for="item in facilityTypes" :key="item.value" :label="item.label" :value="item.value" />
  52. </el-select>
  53. <el-button type="primary" :icon="Refresh" @click="refreshScene">刷新</el-button>
  54. <el-button :icon="Aim" @click="fitBounds">全图</el-button>
  55. </template>
  56. <template #metrics>
  57. <div class="metric">
  58. <div class="metric-icon"><el-icon><Odometer/></el-icon></div>
  59. <div class="metric-body">
  60. <span>设施总数</span>
  61. <div class="metric-value"><strong>{{ summaryStats.total }}</strong><small>处</small></div>
  62. </div>
  63. </div>
  64. <div class="metric">
  65. <div class="metric-icon"><el-icon><Connection/></el-icon></div>
  66. <div class="metric-body">
  67. <span>供水管网</span>
  68. <div class="metric-value"><strong>{{ summaryStats.pipeCount }}</strong><small>条</small></div>
  69. </div>
  70. </div>
  71. <div class="metric">
  72. <div class="metric-icon"><el-icon><OfficeBuilding/></el-icon></div>
  73. <div class="metric-body">
  74. <span>水厂</span>
  75. <div class="metric-value"><strong>{{ summaryStats.plantCount }}</strong><small>座</small></div>
  76. </div>
  77. </div>
  78. <div class="metric">
  79. <div class="metric-icon"><el-icon><SetUp/></el-icon></div>
  80. <div class="metric-body">
  81. <span>泵站</span>
  82. <div class="metric-value"><strong>{{ summaryStats.pumpStationCount }}</strong><small>座</small></div>
  83. </div>
  84. </div>
  85. </template>
  86. <template #controls>
  87. <div class="panel-section">
  88. <h3>图层控制</h3>
  89. <div class="layer-list">
  90. <label v-for="item in facilityTypes" :key="item.value" class="layer-row">
  91. <el-checkbox v-model="layerState[item.value]">{{ item.label }}</el-checkbox>
  92. </label>
  93. </div>
  94. </div>
  95. </template>
  96. <template #legend>
  97. <div class="panel-section legend-panel">
  98. <h3>图例</h3>
  99. <div class="legend-row" v-for="item in facilityTypes" :key="item.value">
  100. <i class="legend-dot" :style="{ background: item.color }"></i>{{ item.label }}
  101. </div>
  102. </div>
  103. </template>
  104. <template #results>
  105. <div class="panel-section result-section">
  106. <h3>搜索设施</h3>
  107. <el-alert v-if="loadError" type="error" :closable="false" show-icon :title="loadError">
  108. <el-button link type="primary" @click="loadGisData">重试</el-button>
  109. </el-alert>
  110. <el-empty
  111. v-else-if="!filteredFeatures.length"
  112. description="暂无可显示的供水设施"
  113. :image-size="80"
  114. />
  115. <div v-else class="device-result-list">
  116. <button v-for="feature in filteredFeatures" :key="feature.id || feature.name" type="button" @click="locateFeature(feature)">
  117. <span class="device-name">{{ feature.name }}</span>
  118. <span class="device-meta">{{ labelMap[feature.type] || '供水设施' }} · {{ feature.code || '-' }}</span>
  119. <el-tag size="small" type="primary">{{ labelMap[feature.type] || '供水设施' }}</el-tag>
  120. </button>
  121. </div>
  122. </div>
  123. </template>
  124. <template #map>
  125. <GisMapCanvas
  126. ref="gisMapRef"
  127. :points="mapPoints"
  128. :lines="mapLines"
  129. cluster-label="设施集合"
  130. :center="centerPoint"
  131. :zoom="15"
  132. @point-click="locateFeature"
  133. @line-click="locateFeature"
  134. @map-click="detailOpen = false"
  135. @ready="handleMapReady"
  136. @error="mapError = $event"
  137. />
  138. <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError">
  139. <el-button link type="primary" @click="loadGisData">重试</el-button>
  140. </el-alert>
  141. <el-alert v-else-if="loadError" class="map-error" type="error" show-icon :closable="false" :title="loadError">
  142. <el-button link type="primary" @click="loadGisData">重试</el-button>
  143. </el-alert>
  144. <el-empty
  145. v-if="dataLoaded && !mapError && !loadError && !visibleFeatures.length"
  146. class="map-empty"
  147. description="当前筛选下没有可显示的供水设施"
  148. />
  149. </template>
  150. <template #drawer>
  151. <el-drawer v-model="detailOpen" title="设施详情" size="420px" direction="rtl" class="detail-drawer" append-to-body>
  152. <div v-loading="detailLoading" class="device-detail">
  153. <el-alert v-if="detailError" type="error" show-icon :closable="false" title="设施详情加载失败,请重试" />
  154. <template v-if="detailRecord">
  155. <section class="detail-hero">
  156. <h3>{{ detailTarget?.name || '供水设施' }}</h3>
  157. <p>{{ labelMap[detailTarget?.type] || '供水设施' }} · {{ detailTarget?.code || '-' }}</p>
  158. <div class="status-tags">
  159. <el-tag type="primary">{{ labelMap[detailTarget?.type] || '供水设施' }}</el-tag>
  160. </div>
  161. </section>
  162. <section class="detail-section">
  163. <h4>基础信息</h4>
  164. <dl>
  165. <template v-for="item in detailEntries" :key="item.label">
  166. <dt>{{ item.label }}</dt>
  167. <dd>{{ item.value }}</dd>
  168. </template>
  169. </dl>
  170. </section>
  171. </template>
  172. <el-empty v-else-if="!detailLoading && !detailError" description="暂无设施详情" />
  173. </div>
  174. </el-drawer>
  175. </template>
  176. </WaterGisPageShell>
  177. </el-card>
  178. </div>
  179. </template>
  180. <script setup>
  181. import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
  182. import { Aim, Connection, MapLocation, Odometer, OfficeBuilding, Refresh, Search, SetUp } from '@element-plus/icons-vue'
  183. import { ElMessage } from 'element-plus'
  184. import GisMapCanvas from '@/components/GisMapCanvas.vue'
  185. import WaterGisPageShell from '@/components/WaterGisPageShell.vue'
  186. import { getWaterFacilityOverview, getWaterFacilityFeatures, getWaterFacilityDetail } from '@/api/pipeNetwork/waterSupply'
  187. import { normalizeGeoJson, unwrapResponse } from '@/utils/waterSupplyModel'
  188. import { getRequestErrorMessage } from '@/utils/waterFacilityQuery'
  189. import { buildFacilityMapModel, filterFacilityMapModel, findFacilityFeature, formatFacilityDetail } from '@/utils/waterFacilityGisModel'
  190. import { getFacilityPointIcon } from '@/utils/gisMapColors'
  191. const centerPoint = { lng: 110.3965, lat: 28.4638 }
  192. const defaultLayerState = () => ({
  193. source: true,
  194. plant: true,
  195. pumpStation: true,
  196. pipe: true,
  197. user: true
  198. })
  199. const facilityTypes = [
  200. { label: '水源地', value: 'source', color: '#1f77d0' },
  201. { label: '水厂', value: 'plant', color: '#2ecc71' },
  202. { label: '泵站', value: 'pumpStation', color: '#e74c3c' },
  203. { label: '管网', value: 'pipe', color: '#f1c40f' },
  204. { label: '用水户', value: 'user', color: '#333333' }
  205. ]
  206. const colorMap = {
  207. source: '#1f77d0',
  208. plant: '#2ecc71',
  209. pumpStation: '#e74c3c',
  210. pipe: '#f1c40f',
  211. user: '#333333'
  212. }
  213. const labelMap = {
  214. source: '水源地',
  215. plant: '水厂',
  216. pumpStation: '泵站',
  217. pipe: '供水管网',
  218. user: '用水户'
  219. }
  220. const gisMapRef = ref(null)
  221. const searchKeyword = ref('')
  222. const selectedFacilityType = ref('')
  223. const mapError = ref('')
  224. const loadError = ref('')
  225. const dataLoaded = ref(false)
  226. const loading = ref(false)
  227. const mapReady = ref(false)
  228. const layerState = reactive(defaultLayerState())
  229. const facilityOverview = ref([])
  230. const pipelineData = ref([])
  231. const pointData = ref([])
  232. const detailOpen = ref(false)
  233. const detailLoading = ref(false)
  234. const detailError = ref('')
  235. const detailRecord = ref(null)
  236. const detailTarget = ref(null)
  237. const typeCount = type => {
  238. const row = facilityOverview.value.find(item => item.facilityType === type)
  239. return Number(row?.totalCount ?? row?.count ?? 0)
  240. }
  241. const summaryStats = computed(() => ({
  242. total: facilityTypes.reduce((sum, item) => sum + typeCount(item.value), 0),
  243. sourceCount: typeCount('source'),
  244. plantCount: typeCount('plant'),
  245. pumpStationCount: typeCount('pumpStation'),
  246. pipeCount: typeCount('pipe'),
  247. userCount: typeCount('user')
  248. }))
  249. const fullMapModel = computed(() => ({ points: pointData.value, lines: pipelineData.value }))
  250. const visibleMapModel = computed(() => filterFacilityMapModel(fullMapModel.value, layerState))
  251. const visibleFeatures = computed(() => [
  252. ...visibleMapModel.value.points.map(item => ({ ...item, kind: 'point' })),
  253. ...visibleMapModel.value.lines.map(item => ({ ...item, kind: 'pipeline' }))
  254. ])
  255. const filteredFeatures = computed(() => {
  256. const keyword = searchKeyword.value.trim().toLocaleLowerCase()
  257. return visibleFeatures.value.filter((feature) => {
  258. const matchedType = !selectedFacilityType.value || feature.type === selectedFacilityType.value
  259. const matchedKeyword = !keyword || [feature.name, feature.code, feature.road]
  260. .some(value => String(value || '').toLocaleLowerCase().includes(keyword))
  261. return matchedType && matchedKeyword
  262. })
  263. })
  264. const mapPoints = computed(() => visibleMapModel.value.points.map(point => ({
  265. ...point,
  266. color: colorMap[point.type] || '#457b9d',
  267. iconUrl: getFacilityPointIcon(point.type),
  268. name: point.name || point.code || '供水设施'
  269. })))
  270. const mapLines = computed(() => visibleMapModel.value.lines.map(line => ({
  271. ...line,
  272. color: colorMap[line.type] || '#fcbf49',
  273. iconUrl: getFacilityPointIcon(line.type),
  274. name: line.name || '供水管网'
  275. })))
  276. const detailEntries = computed(() => formatFacilityDetail(detailRecord.value, detailTarget.value?.type))
  277. watch(layerState, () => {
  278. if (mapReady.value) nextTick(() => gisMapRef.value?.fitBounds())
  279. }, { deep: true })
  280. onMounted(async () => {
  281. await nextTick()
  282. await loadGisData()
  283. })
  284. function handleMapReady() {
  285. mapReady.value = true
  286. if (dataLoaded.value) nextTick(() => gisMapRef.value?.fitBounds())
  287. }
  288. async function loadGisData() {
  289. loading.value = true
  290. mapError.value = ''
  291. loadError.value = ''
  292. dataLoaded.value = false
  293. try {
  294. const [overviewRes, featureRes] = await Promise.all([
  295. getWaterFacilityOverview(),
  296. getWaterFacilityFeatures()
  297. ])
  298. const overviewData = unwrapResponse(overviewRes)
  299. facilityOverview.value = Array.isArray(overviewData) ? overviewData : (overviewData?.facilityOverview || [])
  300. const geoJson = normalizeGeoJson(featureRes)
  301. const model = buildFacilityMapModel(geoJson)
  302. pipelineData.value = model.lines
  303. pointData.value = model.points
  304. dataLoaded.value = true
  305. await nextTick()
  306. if (mapReady.value) gisMapRef.value?.fitBounds()
  307. } catch (e) {
  308. console.error('加载供水GIS数据失败', e)
  309. facilityOverview.value = []
  310. pipelineData.value = []
  311. pointData.value = []
  312. dataLoaded.value = true
  313. loadError.value = getRequestErrorMessage(e, '供水GIS数据加载失败,请重试。')
  314. } finally {
  315. loading.value = false
  316. }
  317. }
  318. function fitBounds() {
  319. gisMapRef.value?.fitBounds()
  320. }
  321. function handleSearch() {
  322. const keyword = searchKeyword.value.trim()
  323. if (!keyword) {
  324. ElMessage.warning('请输入搜索关键字')
  325. return
  326. }
  327. const matched = findFacilityFeature(visibleMapModel.value, keyword)
  328. if (!matched) {
  329. ElMessage.info('未找到匹配要素')
  330. return
  331. }
  332. gisMapRef.value?.locatePoint(matched)
  333. openFacilityDetail(matched)
  334. ElMessage.success(`已定位到 ${matched.name}`)
  335. }
  336. function featureFocus(feature) {
  337. if (Array.isArray(feature.points) && feature.points.length) {
  338. const first = feature.points[0]
  339. const last = feature.points[feature.points.length - 1]
  340. return {
  341. lng: (first.lng + last.lng) / 2,
  342. lat: (first.lat + last.lat) / 2
  343. }
  344. }
  345. return feature
  346. }
  347. function locateFeature(feature) {
  348. gisMapRef.value?.locatePoint(featureFocus(feature))
  349. openFacilityDetail(feature)
  350. }
  351. async function openFacilityDetail(feature) {
  352. detailTarget.value = feature
  353. detailOpen.value = true
  354. detailRecord.value = null
  355. detailError.value = ''
  356. await loadFacilityDetail()
  357. }
  358. async function loadFacilityDetail() {
  359. const feature = detailTarget.value
  360. if (!feature?.type || feature.id === null || feature.id === undefined) return
  361. detailLoading.value = true
  362. detailError.value = ''
  363. try {
  364. detailRecord.value = unwrapResponse(await getWaterFacilityDetail(feature.type, feature.id))
  365. } catch (error) {
  366. detailRecord.value = null
  367. detailError.value = getRequestErrorMessage(error, '设施详情查询失败,请重试。')
  368. } finally {
  369. detailLoading.value = false
  370. }
  371. }
  372. function applyFilters() {
  373. if (searchKeyword.value.trim()) {
  374. handleSearch()
  375. return
  376. }
  377. loadGisData()
  378. }
  379. function resetFilters() {
  380. searchKeyword.value = ''
  381. selectedFacilityType.value = ''
  382. detailOpen.value = false
  383. loadGisData()
  384. }
  385. async function refreshScene() {
  386. layerState.source = true
  387. layerState.plant = true
  388. layerState.pumpStation = true
  389. layerState.pipe = true
  390. layerState.user = true
  391. searchKeyword.value = ''
  392. selectedFacilityType.value = ''
  393. detailOpen.value = false
  394. await loadGisData()
  395. fitBounds()
  396. }
  397. </script>
  398. <style scoped>
  399. .header-actions {
  400. display: flex;
  401. align-items: center;
  402. gap: 10px;
  403. }
  404. .search-input,
  405. .type-select {
  406. width: 240px;
  407. }
  408. .panel-section h3 {
  409. margin: 0 0 8px;
  410. font-size: 14px;
  411. color: #29567e;
  412. }
  413. .layer-list {
  414. display: flex;
  415. flex-direction: column;
  416. gap: 6px;
  417. }
  418. .layer-row {
  419. display: flex;
  420. align-items: center;
  421. }
  422. .legend-panel {
  423. border-top: 1px solid #e4eef8;
  424. padding-top: 12px;
  425. }
  426. .legend-row {
  427. display: flex;
  428. align-items: center;
  429. gap: 8px;
  430. margin-bottom: 6px;
  431. font-size: 13px;
  432. color: #4d667d;
  433. }
  434. .legend-dot {
  435. width: 10px;
  436. height: 10px;
  437. border-radius: 50%;
  438. flex-shrink: 0;
  439. }
  440. .result-section {
  441. flex: 1;
  442. min-height: 0;
  443. display: flex;
  444. flex-direction: column;
  445. }
  446. .device-result-list {
  447. display: flex;
  448. flex-direction: column;
  449. gap: 6px;
  450. }
  451. .device-result-list button {
  452. width: 100%;
  453. display: grid;
  454. grid-template-columns: minmax(0, 1fr) auto;
  455. gap: 3px 8px;
  456. align-items: center;
  457. border: 1px solid #dbe8f5;
  458. background: #fff;
  459. border-radius: 8px;
  460. padding: 8px 10px;
  461. text-align: left;
  462. cursor: pointer;
  463. }
  464. .device-name {
  465. overflow: hidden;
  466. text-overflow: ellipsis;
  467. white-space: nowrap;
  468. font-weight: 600;
  469. color: #274b6d;
  470. }
  471. .device-meta {
  472. grid-column: 1 / -1;
  473. font-size: 12px;
  474. color: #7793aa;
  475. }
  476. .device-detail {
  477. min-height: 240px;
  478. }
  479. .detail-hero h3,
  480. .detail-section h4 {
  481. margin: 0 0 8px;
  482. color: #1c4b78;
  483. }
  484. .detail-hero p {
  485. margin: 0 0 10px;
  486. color: #678197;
  487. font-size: 13px;
  488. }
  489. .status-tags {
  490. display: flex;
  491. flex-wrap: wrap;
  492. gap: 6px;
  493. }
  494. .detail-section {
  495. margin-top: 18px;
  496. padding-top: 12px;
  497. border-top: 1px solid #e5eef7;
  498. }
  499. .detail-section dl {
  500. display: grid;
  501. grid-template-columns: 88px minmax(0, 1fr);
  502. gap: 7px 10px;
  503. margin: 0;
  504. font-size: 13px;
  505. }
  506. .detail-section dt {
  507. color: #71889c;
  508. }
  509. .detail-section dd {
  510. margin: 0;
  511. color: #2b4c67;
  512. overflow-wrap: anywhere;
  513. }
  514. .muted {
  515. margin: 0;
  516. color: #7d93a6;
  517. font-size: 13px;
  518. }
  519. @media (max-width: 1200px) {
  520. .search-input,
  521. .type-select {
  522. width: 180px;
  523. }
  524. }
  525. </style>
  526. <style lang="scss">
  527. @use '../components/waterGisPageTheme.scss';
  528. </style>