| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156 |
- <template>
- <div class="water-page">
- <div class="page-heading">
- <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>
- <div class="heading-actions">
- <el-checkbox-group v-if="view === 'gis'" v-model="selectedTypes" @change="loadData">
- <el-checkbox-button v-for="item in facilityTypes" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox-button>
- </el-checkbox-group>
- <el-button v-if="view === 'stats'" type="success" plain icon="Download" @click="exportData">导出</el-button>
- <el-button :icon="Refresh" @click="loadData">刷新</el-button>
- </div>
- </div>
- <div class="metric-grid">
- <div v-for="(item, index) in overview.slice(0, 5)" :key="item.label" class="metric">
- <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>
- </div>
- </div>
- <template v-if="view === 'gis'">
- <el-card shadow="never" class="map-card" v-loading="loading">
- <div class="map-legend"><span v-for="item in facilityTypes" :key="item.value"><i :style="{ background: item.color }"></i>{{ item.label }}</span></div>
- <div class="facility-map">
- <svg class="pipe-layer" viewBox="0 0 100 100" preserveAspectRatio="none">
- <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" />
- </svg>
- <button v-for="point in mapPoints" :key="point.id" class="facility-point" :style="pointStyle(point)" :title="point.name" @click="openFeature(point)">
- <span></span><b>{{ point.name || point.code || point.typeName }}</b>
- </button>
- <el-empty v-if="!mapPoints.length && !mapLines.length" description="暂无设施空间数据" />
- </div>
- </el-card>
- </template>
- <template v-else>
- <div class="chart-grid">
- <el-card shadow="never"><div class="card-title">设施数量与状态</div><div ref="barChartRef" class="chart"></div></el-card>
- <el-card shadow="never"><div class="card-title">设施类型占比</div><div ref="pieChartRef" class="chart"></div></el-card>
- </div>
- <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>
- </template>
- <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>
- </div>
- </template>
- <script setup>
- import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
- import { useRoute } from 'vue-router'
- import { DataAnalysis, Location, MapLocation, Refresh, Setting } from '@element-plus/icons-vue'
- import { saveAs } from 'file-saver'
- import * as echarts from 'echarts'
- import { exportPipeNetworkStatistics, getPipeNetworkDashboard, getWaterFacilityDetail, getWaterFacilityFeatures, getWaterFacilityOverview } from '@/api/pipeNetwork/waterSupply'
- import { normalizeMapPoints, unwrapResponse } from '@/utils/waterSupplyModel'
- const props = defineProps({ title: { type: String, required: true }, view: { type: String, default: 'stats' } })
- const pageIcon = computed(() => props.view === 'gis' ? MapLocation : DataAnalysis)
- const metricIcons = [Location, Setting, DataAnalysis, MapLocation]
- const route = useRoute()
- const facilityTypes = [
- { label: '水源地', value: 'source', color: '#2563eb' },
- { label: '水厂', value: 'plant', color: '#16a34a' },
- { label: '泵站', value: 'pumpStation', color: '#d97706' },
- { label: '管网', value: 'pipe', color: '#64748b' },
- { label: '用水户', value: 'user', color: '#dc2626' }
- ]
- const overview = ref([])
- const rows = ref([])
- const mapPoints = ref([])
- const mapLines = ref([])
- const routeTypes = String(route.query.type || '').split(',').map(item => item.trim()).filter(Boolean)
- const selectedTypes = ref(routeTypes.length ? facilityTypes.map(item => item.value).filter(item => routeTypes.includes(item)) : facilityTypes.map(item => item.value))
- const loading = ref(false)
- const detailVisible = ref(false)
- const detail = ref({})
- const barChartRef = ref()
- const pieChartRef = ref()
- let barChart
- let pieChart
- const typeColor = type => facilityTypes.find(item => item.value === type)?.color || '#2563eb'
- const flattenMetrics = (section, value) => {
- if (Array.isArray(value)) return value.map(item => ({ ...item, section }))
- if (!value || typeof value !== 'object') return [{ name: section, value, unit: '项' }]
- return Object.entries(value).flatMap(([name, metric]) => {
- if (Array.isArray(metric)) return metric.map(item => ({ ...item, section: `${section}/${name}` }))
- if (metric && typeof metric === 'object') return flattenMetrics(`${section}/${name}`, metric)
- return [{ name: `${section}/${name}`, value: metric, unit: '项' }]
- })
- }
- const renderCharts = async facilityOverview => {
- await nextTick()
- barChart?.dispose(); pieChart?.dispose()
- if (!barChartRef.value || !pieChartRef.value) return
- const labels = facilityOverview.map(item => item.facilityTypeName || item.name || item.facilityType)
- barChart = echarts.init(barChartRef.value)
- 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' } }] })
- pieChart = echarts.init(pieChartRef.value)
- 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}%' } }] })
- }
- const buildMap = features => {
- const rawPoints = []
- const rawLines = []
- features.forEach(feature => {
- const coordinates = feature.geometry?.coordinates || []
- const properties = feature.properties || {}
- if (feature.geometry?.type === 'Point') rawPoints.push({ ...properties, id: feature.id, longitude: coordinates[0], latitude: coordinates[1], type: properties.facilityType, typeName: properties.facilityTypeName })
- if (feature.geometry?.type === 'LineString' && coordinates.length > 1) rawLines.push({ id: feature.id, properties, coordinates })
- })
- const coordinatePoints = [...rawPoints, ...rawLines.flatMap(line => line.coordinates.map((coordinate, index) => ({ id: `${line.id}-${index}`, longitude: coordinate[0], latitude: coordinate[1] })))]
- const positions = new Map(normalizeMapPoints(coordinatePoints).map(item => [item.id, item]))
- mapPoints.value = rawPoints.map(point => ({ ...point, ...positions.get(point.id) })).filter(point => Number.isFinite(point.mapX))
- 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)
- }
- const loadData = async () => {
- loading.value = true
- try {
- if (props.view === 'gis') {
- const [overviewResponse, featureResponse] = await Promise.all([getWaterFacilityOverview(), getWaterFacilityFeatures({ types: selectedTypes.value.join(',') })])
- const summary = unwrapResponse(overviewResponse) || []
- overview.value = summary.filter(item => item.facilityType !== 'total').map(item => ({ label: item.facilityTypeName, value: item.geoCompleteCount ?? 0, unit: '个点位' }))
- buildMap(unwrapResponse(featureResponse)?.features || [])
- } else {
- const data = unwrapResponse(await getPipeNetworkDashboard()) || {}
- const facilityOverview = Array.isArray(data.facilityOverview) ? data.facilityOverview : []
- overview.value = facilityOverview.map(item => ({ label: item.facilityTypeName || item.facilityType, value: item.totalCount ?? 0, unit: '项' }))
- 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 || '' }))
- await renderCharts(facilityOverview)
- }
- } catch (error) { overview.value = []; rows.value = []; mapPoints.value = []; mapLines.value = []; console.warn(`[${props.title}] 数据加载失败`, error) } finally { loading.value = false }
- }
- const exportData = async () => {
- try {
- const blob = await exportPipeNetworkStatistics()
- saveAs(new Blob([blob]), `管网数据统计分析_${new Date().toISOString().slice(0, 10)}.xlsx`)
- } catch (error) {
- console.warn('管网统计导出失败', error)
- }
- }
- const pointStyle = point => ({ left: `${point.mapX}%`, top: `${point.mapY}%`, color: typeColor(point.type) })
- const openFeature = async point => { try { const response = await getWaterFacilityDetail(point.type, point.id?.split(':').pop()); detail.value = unwrapResponse(response) || point; detailVisible.value = true } catch {} }
- const resizeCharts = () => { barChart?.resize(); pieChart?.resize() }
- onMounted(() => { loadData(); window.addEventListener('resize', resizeCharts) })
- onBeforeUnmount(() => { window.removeEventListener('resize', resizeCharts); barChart?.dispose(); pieChart?.dispose() })
- </script>
- <style lang="scss">
- @use './waterSupplyPageTheme.scss';
- </style>
- <style scoped>
- .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}}
- </style>
|