123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458 |
- <template>
- <div class="container mx-auto px-4 py-8">
- <!-- 错误提示(带原始响应预览) -->
- <div v-if="error" class="status error mb-4">
- <i class="fa fa-exclamation-circle"></i> {{ error }}
- <div class="raw-response" v-if="rawResponse">
- <button @click="showRaw = !showRaw" class="mt-2">
- {{ showRaw ? '收起原始响应' : '查看原始响应(前1000字符)' }}
- </button>
- <pre v-if="showRaw" class="mt-2 bg-gray-50 p-2 text-sm">{{ truncatedRawResponse }}</pre>
- </div>
- </div>
- <div class="bg-white rounded-xl shadow-lg overflow-hidden">
- <!-- 加载状态 -->
- <div v-if="loading" class="py-20 flex justify-center items-center">
- <div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
- </div>
-
- <!-- 数据展示区:表格 + 地图 + 柱状图 -->
- <div v-else-if="filteredData.length > 0" class="flex flex-col md:flex-row">
- <!-- 表格 -->
- <div class="w-full md:w-1/2 overflow-x-auto">
- <table class="min-w-full divide-y divide-gray-200">
- <thead class="bg-white">
- <tr>
- <th
- v-for="(col, index) in displayColumns"
- :key="index"
- :style="{ width: col.width }"
- class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
- @click="sortData(col.label)"
- >
- <div class="flex items-center justify-between">
- {{ col.label }}
- <span v-if="sortKey === col.label" class="ml-1 text-gray-400">
- {{ sortOrder === 'asc' ? '↑' : '↓' }}
- </span>
- </div>
- </th>
- </tr>
- </thead>
- <tbody class="bg-white divide-y divide-gray-200">
- <tr v-for="(item, rowIndex) in sortedData" :key="rowIndex"
- class="hover:bg-white transition-colors duration-150">
- <td
- v-for="(col, colIndex) in displayColumns"
- :key="colIndex"
- :style="{ width: col.width }"
- class="px-6 py-4 whitespace-nowrap text-sm"
- >
- <div class="flex items-center">
- <div class="text-gray-900 font-medium">
- {{ formatValue(item, col) }}
- </div>
- </div>
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- <!-- 地图 + 柱状图(右侧区域) -->
- <div class="w-full md:w-1/2 p-4 flex flex-col">
- <!-- 地图(依赖:@vue-leaflet/vue-leaflet) -->
- <div class="h-64 mb-4">
- <LMap
- :center="mapCenter"
- :zoom="12"
- style="width: 100%; height: 100%"
- >
- <LTileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
- <LMarker
- v-for="(item, idx) in filteredData"
- :key="idx"
- :lat-lng="[item.latitude, item.longitude]"
- >
- <LPopup>{{ item.sampling_location }}</LPopup>
- </LMarker>
- </LMap>
- </div>
- <!-- 柱状图(依赖:echarts) -->
- <div class="h-64">
- <div ref="chart" class="w-full h-full"></div>
- </div>
- </div>
- </div>
-
- <!-- 空数据状态 -->
- <div v-else class="p-8 text-center">
- <div class="flex flex-col items-center justify-center">
- <div class="text-gray-400 mb-4">
- <i class="fa fa-database text-5xl"></i>
- </div>
- <h3 class="text-lg font-medium text-gray-900 mb-1">暂无有效数据</h3>
- <p class="text-gray-500">已过滤全空行</p>
- </div>
- </div>
-
- <!-- 数据统计 -->
- <div class="p-4 bg-gray-50 border-t border-gray-200">
- <div class="flex flex-col md:flex-row justify-between items-center">
- <div class="text-sm text-gray-500 mb-2 md:mb-0">
- 共 <span class="font-medium text-gray-900">{{ filteredData.length }}</span> 条数据
- </div>
- </div>
- </div>
- </div>
- </div>
- </template>
- <script setup>
- import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
- import { LMap, LTileLayer, LMarker, LPopup } from '@vue-leaflet/vue-leaflet'; // 地图组件
- import * as echarts from 'echarts'; // 柱状图
- // ========== 接口配置 ==========
- const apiUrl = 'http://localhost:8000/api/vector/export/all?table_name=Atmo_sample_data';
- // ========== 响应式数据 ==========
- const props = defineProps({
- calculationMethod: {
- type: String,
- required: true,
- default: 'weight'
- }
- });
- const waterData = ref([]);
- const loading = ref(true);
- const error = ref('');
- const rawResponse = ref('');
- const showRaw = ref(false);
- const sortKey = ref('');
- const sortOrder = ref('asc');
- const mapCenter = ref([23.5, 116.5]); // 地图初始中心(根据实际数据调整)
- const chartInstance = ref(null); // ECharts实例
- // 换算函数(重量→体积)
- function calculateConcentration(heavyMetalWeight, volume) {
- if (heavyMetalWeight === undefined || volume === undefined || isNaN(heavyMetalWeight) || isNaN(volume) || volume === 0) {
- return '未知';
- }
- const ug = heavyMetalWeight * 1000;
- const concentration = ug / volume;
- return concentration.toFixed(2); // 保留2位小数
- }
- function calculateParticleConcentration(particleWeight, volume) {
- if (particleWeight === undefined || volume === undefined || isNaN(particleWeight) || isNaN(volume) || volume === 0) {
- return '未知';
- }
- const ug = particleWeight * 1000;
- const concentration = ug / volume;
- return concentration.toFixed(2); // 保留2位小数
- }
- // 列配置(补充 width,根据内容合理分配宽度)
- const commonColumns = [
- { key: 'sampling_location', label: '采样位置', type: 'string', width: '180px' },
- { key: 'sample_name', label: '样品名称', type: 'string', width: '70px' },
- { key: 'latitude', label: '纬度', type: 'number', width: '60px' }, // 地图依赖字段
- { key: 'longitude', label: '经度', type: 'number', width: '60px' },// 地图依赖字段
- ];
- const weightColumns = [
- { key: 'Cr_particulate', label: 'Cr mg/kg', type: 'number', width: '80px' },
- { key: 'As_particulate', label: 'As mg/kg', type: 'number', width: '80px' },
- { key: 'Cd_particulate', label: 'Cd mg/kg', type: 'number', width: '80px' },
- { key: 'Hg_particulate', label: 'Hg mg/kg', type: 'number', width: '80px' },
- { key: 'Pb_particulate', label: 'Pb mg/kg', type: 'number', width: '80px' },
- { key: 'particle_weight', label: '颗粒物重量 mg', type: 'number', width: '140px' },
- ];
- const volumeColumns = [
- { key: 'standard_volume', label: '标准体积 m³', type: 'number', width: '120px' },
- { label: 'Cr ug/m³', getValue: (item) => calculateConcentration(item.Cr_particulate, item.standard_volume), type: 'number', width: '80px' },
- { label: 'As ug/m³', getValue: (item) => calculateConcentration(item.As_particulate, item.standard_volume), type: 'number', width: '80px' },
- { label: 'Cd ug/m³', getValue: (item) => calculateConcentration(item.Cd_particulate, item.standard_volume), type: 'number', width: '80px' },
- { label: 'Hg ug/m³', getValue: (item) => calculateConcentration(item.Hg_particulate, item.standard_volume), type: 'number', width: '80px' },
- { label: 'Pb ug/m³', getValue: (item) => calculateConcentration(item.Pb_particulate, item.standard_volume), type: 'number', width: '80px' },
- { label: '颗粒物浓度 ug/m³', getValue: (item) => calculateParticleConcentration(item.particle_weight, item.standard_volume), type: 'number', width: '140px' },
- ];
- // 动态生成显示列
- const displayColumns = computed(() => {
- return props.calculationMethod === 'volume'
- ? [...commonColumns, ...volumeColumns]
- : [...commonColumns, ...weightColumns];
- });
- // 数值格式化
- const formatValue = (item, col) => {
- if (col.getValue) {
- const val = col.getValue(item);
- return val === '未知' ? '-' : val;
- } else {
- const value = item[col.key];
- if (value === null || value === undefined || value === '') return '-';
- if (col.type === 'number') {
- const num = parseFloat(value);
- return isNaN(num) ? '-' : num.toFixed(2); // 统一保留2位小数
- } else {
- return value;
- }
- }
- };
- // 过滤全空行(允许0值)
- const filteredData = computed(() => {
- return waterData.value.filter(item => {
- return displayColumns.value.some(col => {
- let val = col.getValue ? col.getValue(item) : item[col.key];
- if (col.type === 'string') {
- return val !== null && val !== '' && val !== '-';
- } else {
- const num = parseFloat(val);
- return !isNaN(num); // 允许0值,仅排除非数字
- }
- });
- });
- });
- // 排序功能
- const sortedData = computed(() => {
- if (!sortKey.value) return filteredData.value;
-
- const sortCol = displayColumns.value.find(col => col.label === sortKey.value);
- if (!sortCol) return filteredData.value;
-
- return [...filteredData.value].sort((a, b) => {
- let valA = sortCol.getValue ? sortCol.getValue(a) : a[sortCol.key];
- let valB = sortCol.getValue ? sortCol.getValue(b) : b[sortCol.key];
-
- if (sortCol.type === 'string') {
- const strA = valA.toString().trim();
- const strB = valB.toString().trim();
- return sortOrder.value === 'asc'
- ? strA.localeCompare(strB)
- : strB.localeCompare(strA);
- }
-
- const numA = parseFloat(valA) || -Infinity;
- const numB = parseFloat(valB) || -Infinity;
- if (numA < numB) return sortOrder.value === 'asc' ? -1 : 1;
- if (numA > numB) return sortOrder.value === 'asc' ? 1 : -1;
- return 0;
- });
- });
- // 切换排序
- const sortData = (label) => {
- const targetCol = displayColumns.value.find(col => col.label === label);
- if (!targetCol) return;
-
- if (sortKey.value === label) {
- sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
- } else {
- sortKey.value = label;
- sortOrder.value = 'asc';
- }
- };
- // 截断原始响应(前1000字符)
- const truncatedRawResponse = computed(() => {
- return rawResponse.value.length > 1000
- ? rawResponse.value.slice(0, 1000) + '...'
- : rawResponse.value;
- });
- // 柱状图数据(动态生成)
- const chartData = computed(() => {
- const xData = filteredData.value.map(item => item.sample_name);
- const yData = filteredData.value.map(item => {
- if (props.calculationMethod === 'volume') {
- const val = item['Cr ug/m³'];
- return val !== '-' ? parseFloat(val) : 0;
- } else {
- const val = item.Cr_particulate;
- return val !== '-' ? parseFloat(val) : 0;
- }
- });
- return {
- xAxis: { type: 'category', data: xData },
- yAxis: { type: 'value' },
- series: [{ name: 'Cr 浓度', type: 'bar', data: yData }],
- };
- });
- // 初始化柱状图
- const initChart = () => {
- nextTick(() => {
- if (chartInstance.value) chartInstance.value.dispose();
- chartInstance.value = echarts.init($refs.chart);
- chartInstance.value.setOption(chartData.value);
- });
- };
- // 监听数据变化,更新图表
- watch(filteredData, () => {
- if (chartInstance.value) {
- chartInstance.value.setOption(chartData.value);
- }
- });
- // 数据请求 & 修复逻辑(含超时控制)
- const fetchData = async () => {
- try {
- loading.value = true;
- error.value = '';
- rawResponse.value = '';
- // 超时控制(5秒)
- const TIMEOUT = 5000;
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
- const response = await fetch(apiUrl, { signal: controller.signal });
- clearTimeout(timeoutId);
- if (!response.ok) throw new Error(`HTTP 错误:${response.status}`);
- let rawText = await response.text();
- rawResponse.value = rawText;
- // 修复 JSON 语法(替换 NaN/Infinity)
- rawText = rawText
- .replace(/:\s*NaN/g, ': null')
- .replace(/:\s*Infinity/g, ': null');
- let data;
- try {
- data = JSON.parse(rawText);
- } catch (parseErr) {
- error.value = `数据解析失败:${parseErr.message}\n原始响应:${rawText.slice(0, 200)}...`;
- console.error(parseErr, rawText);
- loading.value = false;
- return;
- }
- // 兼容接口格式
- let features = [];
- if (data.type === 'FeatureCollection' && Array.isArray(data.features)) {
- features = data.features;
- } else if (Array.isArray(data)) {
- features = data;
- } else {
- throw new Error('接口格式异常,需为 FeatureCollection 或数组');
- }
- // 提取数据(含经纬度)
- waterData.value = features.map(feature =>
- feature.properties ? feature.properties : feature
- );
- // 更新地图中心(取第一个点的经纬度,无数据则保持默认)
- if (waterData.value.length > 0) {
- mapCenter.value = [
- waterData.value[0].latitude,
- waterData.value[0].longitude
- ];
- }
- console.log('✅ 数据加载完成,记录数:', waterData.value.length);
- initChart(); // 初始化图表
- } catch (err) {
- if (err.name === 'AbortError') {
- error.value = '请求超时!请检查网络或接口响应速度';
- } else {
- error.value = `数据加载失败:${err.message}`;
- }
- console.error(err);
- } finally {
- loading.value = false;
- }
- };
- // 组件挂载时加载数据
- onMounted(() => {
- fetchData();
- });
- // 卸载时销毁图表
- onUnmounted(() => {
- if (chartInstance.value) {
- chartInstance.value.dispose();
- }
- });
- </script>
- <style scoped>
- /* 错误提示样式 */
- .status.error {
- color: #dc2626;
- background: #fee2e2;
- padding: 12px 16px;
- border-radius: 6px;
- }
- .status.error button {
- cursor: pointer;
- background: #ff4d4f;
- color: #fff;
- border: none;
- border-radius: 4px;
- padding: 4px 8px;
- }
- .raw-response pre {
- white-space: pre-wrap;
- word-break: break-all;
- background: #f9fafb;
- padding: 8px;
- border-radius: 4px;
- font-size: 12px;
- }
- /* 表格核心样式:固定布局 + 列宽生效 */
- table {
- border-collapse: collapse;
- table-layout: fixed;
- width: 100%;
- background-color: white;
- }
- th, td {
- border: 1px solid #d1d5db;
- text-align: center;
- padding: 10px 6px;
- min-width: 60px;
- white-space: normal;
- overflow: hidden;
- text-overflow: ellipsis;
- background-color: white;
- }
- /* 地图 & 图表容器 */
- .leaflet-container {
- width: 100%;
- height: 100%;
- }
- .echarts-container {
- width: 100%;
- height: 100%;
- }
- /* 响应式优化 */
- @media (max-width: 768px) {
- th, td {
- padding: 8px 4px;
- font-size: 14px;
- }
- .flex-col md:flex-row {
- flex-direction: column;
- }
- }
- </style>
|