WaterFacilityDashboard.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. <template>
  2. <div class="water-page">
  3. <div class="page-heading">
  4. <div class="page-title"><span class="page-title-icon"><el-icon><component :is="pageIcon" /></el-icon></span><div><h2>{{ title }}</h2><span class="muted">{{ view === 'gis' ? '设施分类图层、空间分布与详情联动' : '设施规模、状态与分类统计分析' }}</span></div></div>
  5. <div class="heading-actions">
  6. <el-checkbox-group v-if="view === 'gis'" v-model="selectedTypes" @change="loadData">
  7. <el-checkbox-button v-for="item in facilityTypes" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox-button>
  8. </el-checkbox-group>
  9. <el-button v-if="view === 'stats'" type="success" plain icon="Download" @click="exportData">导出</el-button>
  10. <el-button :icon="Refresh" @click="loadData">刷新</el-button>
  11. </div>
  12. </div>
  13. <div class="metric-grid">
  14. <div v-for="(item, index) in overview.slice(0, 5)" :key="item.label" class="metric">
  15. <el-icon class="metric-icon"><component :is="metricIcons[index % metricIcons.length]" /></el-icon><span>{{ item.label }}</span><strong>{{ item.value }}</strong><small>{{ item.unit }}</small>
  16. </div>
  17. </div>
  18. <template v-if="view === 'gis'">
  19. <el-card shadow="never" class="map-card" v-loading="loading">
  20. <div class="map-legend"><span v-for="item in facilityTypes" :key="item.value"><i :style="{ background: item.color }"></i>{{ item.label }}</span></div>
  21. <div class="facility-map">
  22. <svg class="pipe-layer" viewBox="0 0 100 100" preserveAspectRatio="none">
  23. <polyline v-for="line in mapLines" :key="line.id" :points="line.points" fill="none" stroke="#64748b" stroke-width="0.75" vector-effect="non-scaling-stroke" />
  24. </svg>
  25. <button v-for="point in mapPoints" :key="point.id" class="facility-point" :style="pointStyle(point)" :title="point.name" @click="openFeature(point)">
  26. <span></span><b>{{ point.name || point.code || point.typeName }}</b>
  27. </button>
  28. <el-empty v-if="!mapPoints.length && !mapLines.length" description="暂无设施空间数据" />
  29. </div>
  30. </el-card>
  31. </template>
  32. <template v-else>
  33. <div class="chart-grid">
  34. <el-card shadow="never"><div class="card-title">设施数量与状态</div><div ref="barChartRef" class="chart"></div></el-card>
  35. <el-card shadow="never"><div class="card-title">设施类型占比</div><div ref="pieChartRef" class="chart"></div></el-card>
  36. </div>
  37. <el-card shadow="never"><el-table :data="rows" stripe border v-loading="loading"><el-table-column prop="name" label="统计项" min-width="180"/><el-table-column prop="value" label="数量" width="130"/><el-table-column prop="unit" label="单位" width="100"/><el-table-column prop="remark" label="说明" min-width="240"/></el-table><el-empty v-if="!rows.length" description="暂无统计数据"/></el-card>
  38. </template>
  39. <el-drawer v-model="detailVisible" class="detail-drawer" title="供水设施详情" direction="rtl" size="560px" append-to-body><el-descriptions :column="1" border><el-descriptions-item v-for="(value, key) in detail" :key="key" :label="key">{{ value ?? '-' }}</el-descriptions-item></el-descriptions></el-drawer>
  40. </div>
  41. </template>
  42. <script setup>
  43. import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
  44. import { useRoute } from 'vue-router'
  45. import { DataAnalysis, Location, MapLocation, Refresh, Setting } from '@element-plus/icons-vue'
  46. import { saveAs } from 'file-saver'
  47. import * as echarts from 'echarts'
  48. import { exportPipeNetworkStatistics, getPipeNetworkDashboard, getWaterFacilityDetail, getWaterFacilityFeatures, getWaterFacilityOverview } from '@/api/pipeNetwork/waterSupply'
  49. import { normalizeMapPoints, unwrapResponse } from '@/utils/waterSupplyModel'
  50. const props = defineProps({ title: { type: String, required: true }, view: { type: String, default: 'stats' } })
  51. const pageIcon = computed(() => props.view === 'gis' ? MapLocation : DataAnalysis)
  52. const metricIcons = [Location, Setting, DataAnalysis, MapLocation]
  53. const route = useRoute()
  54. const facilityTypes = [
  55. { label: '水源地', value: 'source', color: '#2563eb' },
  56. { label: '水厂', value: 'plant', color: '#16a34a' },
  57. { label: '泵站', value: 'pumpStation', color: '#d97706' },
  58. { label: '管网', value: 'pipe', color: '#64748b' },
  59. { label: '用水户', value: 'user', color: '#dc2626' }
  60. ]
  61. const overview = ref([])
  62. const rows = ref([])
  63. const mapPoints = ref([])
  64. const mapLines = ref([])
  65. const routeTypes = String(route.query.type || '').split(',').map(item => item.trim()).filter(Boolean)
  66. const selectedTypes = ref(routeTypes.length ? facilityTypes.map(item => item.value).filter(item => routeTypes.includes(item)) : facilityTypes.map(item => item.value))
  67. const loading = ref(false)
  68. const detailVisible = ref(false)
  69. const detail = ref({})
  70. const barChartRef = ref()
  71. const pieChartRef = ref()
  72. let barChart
  73. let pieChart
  74. const typeColor = type => facilityTypes.find(item => item.value === type)?.color || '#2563eb'
  75. const flattenMetrics = (section, value) => {
  76. if (Array.isArray(value)) return value.map(item => ({ ...item, section }))
  77. if (!value || typeof value !== 'object') return [{ name: section, value, unit: '项' }]
  78. return Object.entries(value).flatMap(([name, metric]) => {
  79. if (Array.isArray(metric)) return metric.map(item => ({ ...item, section: `${section}/${name}` }))
  80. if (metric && typeof metric === 'object') return flattenMetrics(`${section}/${name}`, metric)
  81. return [{ name: `${section}/${name}`, value: metric, unit: '项' }]
  82. })
  83. }
  84. const renderCharts = async facilityOverview => {
  85. await nextTick()
  86. barChart?.dispose(); pieChart?.dispose()
  87. if (!barChartRef.value || !pieChartRef.value) return
  88. const labels = facilityOverview.map(item => item.facilityTypeName || item.name || item.facilityType)
  89. barChart = echarts.init(barChartRef.value)
  90. barChart.setOption({ tooltip: { trigger: 'axis' }, legend: { top: 0 }, grid: { left: 45, right: 16, top: 42, bottom: 34 }, xAxis: { type: 'category', data: labels }, yAxis: { type: 'value', minInterval: 1 }, series: [{ name: '正常', type: 'bar', stack: 'status', data: facilityOverview.map(item => Number(item.normalCount || 0)), itemStyle: { color: '#16a34a' } }, { name: '异常', type: 'bar', stack: 'status', data: facilityOverview.map(item => Number(item.abnormalCount || 0)), itemStyle: { color: '#dc2626' } }] })
  91. pieChart = echarts.init(pieChartRef.value)
  92. pieChart.setOption({ tooltip: { trigger: 'item' }, legend: { bottom: 0 }, series: [{ type: 'pie', radius: ['42%', '68%'], center: ['50%', '45%'], data: facilityOverview.map(item => ({ name: item.facilityTypeName || item.name || item.facilityType, value: Number(item.totalCount || 0) })), label: { formatter: '{b}\n{d}%' } }] })
  93. }
  94. const buildMap = features => {
  95. const rawPoints = []
  96. const rawLines = []
  97. features.forEach(feature => {
  98. const coordinates = feature.geometry?.coordinates || []
  99. const properties = feature.properties || {}
  100. if (feature.geometry?.type === 'Point') rawPoints.push({ ...properties, id: feature.id, longitude: coordinates[0], latitude: coordinates[1], type: properties.facilityType, typeName: properties.facilityTypeName })
  101. if (feature.geometry?.type === 'LineString' && coordinates.length > 1) rawLines.push({ id: feature.id, properties, coordinates })
  102. })
  103. const coordinatePoints = [...rawPoints, ...rawLines.flatMap(line => line.coordinates.map((coordinate, index) => ({ id: `${line.id}-${index}`, longitude: coordinate[0], latitude: coordinate[1] })))]
  104. const positions = new Map(normalizeMapPoints(coordinatePoints).map(item => [item.id, item]))
  105. mapPoints.value = rawPoints.map(point => ({ ...point, ...positions.get(point.id) })).filter(point => Number.isFinite(point.mapX))
  106. mapLines.value = rawLines.map(line => ({ ...line, points: line.coordinates.map((_, index) => positions.get(`${line.id}-${index}`)).filter(Boolean).map(point => `${point.mapX},${point.mapY}`).join(' ') })).filter(line => line.points)
  107. }
  108. const loadData = async () => {
  109. loading.value = true
  110. try {
  111. if (props.view === 'gis') {
  112. const [overviewResponse, featureResponse] = await Promise.all([getWaterFacilityOverview(), getWaterFacilityFeatures({ types: selectedTypes.value.join(',') })])
  113. const summary = unwrapResponse(overviewResponse) || []
  114. overview.value = summary.filter(item => item.facilityType !== 'total').map(item => ({ label: item.facilityTypeName, value: item.geoCompleteCount ?? 0, unit: '个点位' }))
  115. buildMap(unwrapResponse(featureResponse)?.features || [])
  116. } else {
  117. const data = unwrapResponse(await getPipeNetworkDashboard()) || {}
  118. const facilityOverview = Array.isArray(data.facilityOverview) ? data.facilityOverview : []
  119. overview.value = facilityOverview.map(item => ({ label: item.facilityTypeName || item.facilityType, value: item.totalCount ?? 0, unit: '项' }))
  120. rows.value = Object.entries(data).filter(([section]) => section !== 'facilityOverview').flatMap(([section, value]) => flattenMetrics(section, value)).map(item => ({ name: item.name || item.label || item.section || '统计项', value: item.value ?? item.totalCount ?? 0, unit: item.unit || '项', remark: item.remark || item.section || '' }))
  121. await renderCharts(facilityOverview)
  122. }
  123. } catch (error) { overview.value = []; rows.value = []; mapPoints.value = []; mapLines.value = []; console.warn(`[${props.title}] 数据加载失败`, error) } finally { loading.value = false }
  124. }
  125. const exportData = async () => {
  126. try {
  127. const blob = await exportPipeNetworkStatistics()
  128. saveAs(new Blob([blob]), `管网数据统计分析_${new Date().toISOString().slice(0, 10)}.xlsx`)
  129. } catch (error) {
  130. console.warn('管网统计导出失败', error)
  131. }
  132. }
  133. const pointStyle = point => ({ left: `${point.mapX}%`, top: `${point.mapY}%`, color: typeColor(point.type) })
  134. const openFeature = async point => { try { const response = await getWaterFacilityDetail(point.type, point.id?.split(':').pop()); detail.value = unwrapResponse(response) || point; detailVisible.value = true } catch {} }
  135. const resizeCharts = () => { barChart?.resize(); pieChart?.resize() }
  136. onMounted(() => { loadData(); window.addEventListener('resize', resizeCharts) })
  137. onBeforeUnmount(() => { window.removeEventListener('resize', resizeCharts); barChart?.dispose(); pieChart?.dispose() })
  138. </script>
  139. <style lang="scss">
  140. @use './waterSupplyPageTheme.scss';
  141. </style>
  142. <style scoped>
  143. .water-page{padding:20px;background:#f5f7fa;min-height:calc(100vh - 84px)}.page-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:16px}.page-heading h2{margin:0 0 5px;font-size:20px}.heading-actions{display:flex;align-items:center;gap:12px}.muted{font-size:13px;color:#909399}.metric-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-bottom:16px}.metric{background:#fff;padding:16px 18px;border:1px solid #ebeef5}.metric span{display:block;color:#909399;font-size:13px}.metric strong{font-size:24px;line-height:34px}.metric small{margin-left:5px;color:#909399}.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}.card-title{font-weight:600;margin-bottom:8px}.chart{height:310px}.map-card{min-height:580px}.map-legend{display:flex;gap:18px;margin-bottom:12px;color:#475569;font-size:13px}.map-legend i{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px}.facility-map{position:relative;height:500px;overflow:hidden;border:1px solid #dbe6f2;background-color:#f8fbff;background-image:linear-gradient(#eaf0f6 1px,transparent 1px),linear-gradient(90deg,#eaf0f6 1px,transparent 1px);background-size:40px 40px}.pipe-layer{position:absolute;inset:0;width:100%;height:100%;overflow:visible}.facility-point{position:absolute;transform:translate(-50%,-50%);border:0;background:transparent;cursor:pointer;text-align:center;padding:0;color:#2563eb}.facility-point span{display:block;width:15px;height:15px;margin:auto;border-radius:50%;background:currentColor;border:3px solid #fff;box-shadow:0 0 0 3px currentColor}.facility-point b{display:block;margin-top:4px;color:#334155;font-size:11px;white-space:nowrap}.facility-map :deep(.el-empty){position:absolute;inset:0}@media(max-width:1100px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.heading-actions{align-items:flex-end;flex-direction:column}}@media(max-width:800px){.page-heading{align-items:flex-start;flex-direction:column}.chart-grid{grid-template-columns:1fr}.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.heading-actions{align-items:flex-start}.heading-actions :deep(.el-checkbox-group){display:flex;flex-wrap:wrap}}
  144. </style>