airsampleLine.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. <template>
  2. <div class="container mx-auto px-4 py-8">
  3. <!-- 错误提示(带原始响应预览) -->
  4. <div v-if="error" class="status error mb-4">
  5. <i class="fa fa-exclamation-circle"></i> {{ error }}
  6. <div class="raw-response" v-if="rawResponse">
  7. <button @click="showRaw = !showRaw" class="mt-2">
  8. {{ showRaw ? '收起原始响应' : '查看原始响应(前1000字符)' }}
  9. </button>
  10. <pre v-if="showRaw" class="mt-2 bg-gray-50 p-2 text-sm">{{ truncatedRawResponse }}</pre>
  11. </div>
  12. </div>
  13. <div class="bg-white rounded-xl shadow-lg overflow-hidden">
  14. <!-- 加载状态 -->
  15. <div v-if="loading" class="py-20 flex justify-center items-center">
  16. <div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
  17. </div>
  18. <!-- 数据展示区:表格 + 地图 + 柱状图 -->
  19. <div v-else-if="filteredData.length > 0" class="flex flex-col md:flex-row">
  20. <!-- 表格 -->
  21. <div class="w-full md:w-1/2 overflow-x-auto">
  22. <table class="min-w-full divide-y divide-gray-200">
  23. <thead class="bg-white">
  24. <tr>
  25. <th
  26. v-for="(col, index) in displayColumns"
  27. :key="index"
  28. :style="{ width: col.width }"
  29. 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"
  30. @click="sortData(col.label)"
  31. >
  32. <div class="flex items-center justify-between">
  33. {{ col.label }}
  34. <span v-if="sortKey === col.label" class="ml-1 text-gray-400">
  35. {{ sortOrder === 'asc' ? '↑' : '↓' }}
  36. </span>
  37. </div>
  38. </th>
  39. </tr>
  40. </thead>
  41. <tbody class="bg-white divide-y divide-gray-200">
  42. <tr v-for="(item, rowIndex) in sortedData" :key="rowIndex"
  43. class="hover:bg-white transition-colors duration-150">
  44. <td
  45. v-for="(col, colIndex) in displayColumns"
  46. :key="colIndex"
  47. :style="{ width: col.width }"
  48. class="px-6 py-4 whitespace-nowrap text-sm"
  49. >
  50. <div class="flex items-center">
  51. <div class="text-gray-900 font-medium">
  52. {{ formatValue(item, col) }}
  53. </div>
  54. </div>
  55. </td>
  56. </tr>
  57. </tbody>
  58. </table>
  59. </div>
  60. <!-- 地图 + 柱状图(右侧区域) -->
  61. <div class="w-full md:w-1/2 p-4 flex flex-col">
  62. <!-- 地图(依赖:@vue-leaflet/vue-leaflet) -->
  63. <div class="h-64 mb-4">
  64. <LMap
  65. :center="mapCenter"
  66. :zoom="12"
  67. style="width: 100%; height: 100%"
  68. >
  69. <LTileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
  70. <LMarker
  71. v-for="(item, idx) in filteredData"
  72. :key="idx"
  73. :lat-lng="[item.latitude, item.longitude]"
  74. >
  75. <LPopup>{{ item.sampling_location }}</LPopup>
  76. </LMarker>
  77. </LMap>
  78. </div>
  79. <!-- 柱状图(依赖:echarts) -->
  80. <div class="h-64">
  81. <div ref="chart" class="w-full h-full"></div>
  82. </div>
  83. </div>
  84. </div>
  85. <!-- 空数据状态 -->
  86. <div v-else class="p-8 text-center">
  87. <div class="flex flex-col items-center justify-center">
  88. <div class="text-gray-400 mb-4">
  89. <i class="fa fa-database text-5xl"></i>
  90. </div>
  91. <h3 class="text-lg font-medium text-gray-900 mb-1">暂无有效数据</h3>
  92. <p class="text-gray-500">已过滤全空行</p>
  93. </div>
  94. </div>
  95. <!-- 数据统计 -->
  96. <div class="p-4 bg-gray-50 border-t border-gray-200">
  97. <div class="flex flex-col md:flex-row justify-between items-center">
  98. <div class="text-sm text-gray-500 mb-2 md:mb-0">
  99. 共 <span class="font-medium text-gray-900">{{ filteredData.length }}</span> 条数据
  100. </div>
  101. </div>
  102. </div>
  103. </div>
  104. </div>
  105. </template>
  106. <script setup>
  107. import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
  108. import { LMap, LTileLayer, LMarker, LPopup } from '@vue-leaflet/vue-leaflet'; // 地图组件
  109. import * as echarts from 'echarts'; // 柱状图
  110. // ========== 接口配置 ==========
  111. const apiUrl = 'http://localhost:8000/api/vector/export/all?table_name=Atmo_sample_data';
  112. // ========== 响应式数据 ==========
  113. const props = defineProps({
  114. calculationMethod: {
  115. type: String,
  116. required: true,
  117. default: 'weight'
  118. }
  119. });
  120. const waterData = ref([]);
  121. const loading = ref(true);
  122. const error = ref('');
  123. const rawResponse = ref('');
  124. const showRaw = ref(false);
  125. const sortKey = ref('');
  126. const sortOrder = ref('asc');
  127. const mapCenter = ref([23.5, 116.5]); // 地图初始中心(根据实际数据调整)
  128. const chartInstance = ref(null); // ECharts实例
  129. // 换算函数(重量→体积)
  130. function calculateConcentration(heavyMetalWeight, volume) {
  131. if (heavyMetalWeight === undefined || volume === undefined || isNaN(heavyMetalWeight) || isNaN(volume) || volume === 0) {
  132. return '未知';
  133. }
  134. const ug = heavyMetalWeight * 1000;
  135. const concentration = ug / volume;
  136. return concentration.toFixed(2); // 保留2位小数
  137. }
  138. function calculateParticleConcentration(particleWeight, volume) {
  139. if (particleWeight === undefined || volume === undefined || isNaN(particleWeight) || isNaN(volume) || volume === 0) {
  140. return '未知';
  141. }
  142. const ug = particleWeight * 1000;
  143. const concentration = ug / volume;
  144. return concentration.toFixed(2); // 保留2位小数
  145. }
  146. // 列配置(补充 width,根据内容合理分配宽度)
  147. const commonColumns = [
  148. { key: 'sampling_location', label: '采样位置', type: 'string', width: '180px' },
  149. { key: 'sample_name', label: '样品名称', type: 'string', width: '70px' },
  150. { key: 'latitude', label: '纬度', type: 'number', width: '60px' }, // 地图依赖字段
  151. { key: 'longitude', label: '经度', type: 'number', width: '60px' },// 地图依赖字段
  152. ];
  153. const weightColumns = [
  154. { key: 'Cr_particulate', label: 'Cr mg/kg', type: 'number', width: '80px' },
  155. { key: 'As_particulate', label: 'As mg/kg', type: 'number', width: '80px' },
  156. { key: 'Cd_particulate', label: 'Cd mg/kg', type: 'number', width: '80px' },
  157. { key: 'Hg_particulate', label: 'Hg mg/kg', type: 'number', width: '80px' },
  158. { key: 'Pb_particulate', label: 'Pb mg/kg', type: 'number', width: '80px' },
  159. { key: 'particle_weight', label: '颗粒物重量 mg', type: 'number', width: '140px' },
  160. ];
  161. const volumeColumns = [
  162. { key: 'standard_volume', label: '标准体积 m³', type: 'number', width: '120px' },
  163. { label: 'Cr ug/m³', getValue: (item) => calculateConcentration(item.Cr_particulate, item.standard_volume), type: 'number', width: '80px' },
  164. { label: 'As ug/m³', getValue: (item) => calculateConcentration(item.As_particulate, item.standard_volume), type: 'number', width: '80px' },
  165. { label: 'Cd ug/m³', getValue: (item) => calculateConcentration(item.Cd_particulate, item.standard_volume), type: 'number', width: '80px' },
  166. { label: 'Hg ug/m³', getValue: (item) => calculateConcentration(item.Hg_particulate, item.standard_volume), type: 'number', width: '80px' },
  167. { label: 'Pb ug/m³', getValue: (item) => calculateConcentration(item.Pb_particulate, item.standard_volume), type: 'number', width: '80px' },
  168. { label: '颗粒物浓度 ug/m³', getValue: (item) => calculateParticleConcentration(item.particle_weight, item.standard_volume), type: 'number', width: '140px' },
  169. ];
  170. // 动态生成显示列
  171. const displayColumns = computed(() => {
  172. return props.calculationMethod === 'volume'
  173. ? [...commonColumns, ...volumeColumns]
  174. : [...commonColumns, ...weightColumns];
  175. });
  176. // 数值格式化
  177. const formatValue = (item, col) => {
  178. if (col.getValue) {
  179. const val = col.getValue(item);
  180. return val === '未知' ? '-' : val;
  181. } else {
  182. const value = item[col.key];
  183. if (value === null || value === undefined || value === '') return '-';
  184. if (col.type === 'number') {
  185. const num = parseFloat(value);
  186. return isNaN(num) ? '-' : num.toFixed(2); // 统一保留2位小数
  187. } else {
  188. return value;
  189. }
  190. }
  191. };
  192. // 过滤全空行(允许0值)
  193. const filteredData = computed(() => {
  194. return waterData.value.filter(item => {
  195. return displayColumns.value.some(col => {
  196. let val = col.getValue ? col.getValue(item) : item[col.key];
  197. if (col.type === 'string') {
  198. return val !== null && val !== '' && val !== '-';
  199. } else {
  200. const num = parseFloat(val);
  201. return !isNaN(num); // 允许0值,仅排除非数字
  202. }
  203. });
  204. });
  205. });
  206. // 排序功能
  207. const sortedData = computed(() => {
  208. if (!sortKey.value) return filteredData.value;
  209. const sortCol = displayColumns.value.find(col => col.label === sortKey.value);
  210. if (!sortCol) return filteredData.value;
  211. return [...filteredData.value].sort((a, b) => {
  212. let valA = sortCol.getValue ? sortCol.getValue(a) : a[sortCol.key];
  213. let valB = sortCol.getValue ? sortCol.getValue(b) : b[sortCol.key];
  214. if (sortCol.type === 'string') {
  215. const strA = valA.toString().trim();
  216. const strB = valB.toString().trim();
  217. return sortOrder.value === 'asc'
  218. ? strA.localeCompare(strB)
  219. : strB.localeCompare(strA);
  220. }
  221. const numA = parseFloat(valA) || -Infinity;
  222. const numB = parseFloat(valB) || -Infinity;
  223. if (numA < numB) return sortOrder.value === 'asc' ? -1 : 1;
  224. if (numA > numB) return sortOrder.value === 'asc' ? 1 : -1;
  225. return 0;
  226. });
  227. });
  228. // 切换排序
  229. const sortData = (label) => {
  230. const targetCol = displayColumns.value.find(col => col.label === label);
  231. if (!targetCol) return;
  232. if (sortKey.value === label) {
  233. sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
  234. } else {
  235. sortKey.value = label;
  236. sortOrder.value = 'asc';
  237. }
  238. };
  239. // 截断原始响应(前1000字符)
  240. const truncatedRawResponse = computed(() => {
  241. return rawResponse.value.length > 1000
  242. ? rawResponse.value.slice(0, 1000) + '...'
  243. : rawResponse.value;
  244. });
  245. // 柱状图数据(动态生成)
  246. const chartData = computed(() => {
  247. const xData = filteredData.value.map(item => item.sample_name);
  248. const yData = filteredData.value.map(item => {
  249. if (props.calculationMethod === 'volume') {
  250. const val = item['Cr ug/m³'];
  251. return val !== '-' ? parseFloat(val) : 0;
  252. } else {
  253. const val = item.Cr_particulate;
  254. return val !== '-' ? parseFloat(val) : 0;
  255. }
  256. });
  257. return {
  258. xAxis: { type: 'category', data: xData },
  259. yAxis: { type: 'value' },
  260. series: [{ name: 'Cr 浓度', type: 'bar', data: yData }],
  261. };
  262. });
  263. // 初始化柱状图
  264. const initChart = () => {
  265. nextTick(() => {
  266. if (chartInstance.value) chartInstance.value.dispose();
  267. chartInstance.value = echarts.init($refs.chart);
  268. chartInstance.value.setOption(chartData.value);
  269. });
  270. };
  271. // 监听数据变化,更新图表
  272. watch(filteredData, () => {
  273. if (chartInstance.value) {
  274. chartInstance.value.setOption(chartData.value);
  275. }
  276. });
  277. // 数据请求 & 修复逻辑(含超时控制)
  278. const fetchData = async () => {
  279. try {
  280. loading.value = true;
  281. error.value = '';
  282. rawResponse.value = '';
  283. // 超时控制(5秒)
  284. const TIMEOUT = 5000;
  285. const controller = new AbortController();
  286. const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
  287. const response = await fetch(apiUrl, { signal: controller.signal });
  288. clearTimeout(timeoutId);
  289. if (!response.ok) throw new Error(`HTTP 错误:${response.status}`);
  290. let rawText = await response.text();
  291. rawResponse.value = rawText;
  292. // 修复 JSON 语法(替换 NaN/Infinity)
  293. rawText = rawText
  294. .replace(/:\s*NaN/g, ': null')
  295. .replace(/:\s*Infinity/g, ': null');
  296. let data;
  297. try {
  298. data = JSON.parse(rawText);
  299. } catch (parseErr) {
  300. error.value = `数据解析失败:${parseErr.message}\n原始响应:${rawText.slice(0, 200)}...`;
  301. console.error(parseErr, rawText);
  302. loading.value = false;
  303. return;
  304. }
  305. // 兼容接口格式
  306. let features = [];
  307. if (data.type === 'FeatureCollection' && Array.isArray(data.features)) {
  308. features = data.features;
  309. } else if (Array.isArray(data)) {
  310. features = data;
  311. } else {
  312. throw new Error('接口格式异常,需为 FeatureCollection 或数组');
  313. }
  314. // 提取数据(含经纬度)
  315. waterData.value = features.map(feature =>
  316. feature.properties ? feature.properties : feature
  317. );
  318. // 更新地图中心(取第一个点的经纬度,无数据则保持默认)
  319. if (waterData.value.length > 0) {
  320. mapCenter.value = [
  321. waterData.value[0].latitude,
  322. waterData.value[0].longitude
  323. ];
  324. }
  325. console.log('✅ 数据加载完成,记录数:', waterData.value.length);
  326. initChart(); // 初始化图表
  327. } catch (err) {
  328. if (err.name === 'AbortError') {
  329. error.value = '请求超时!请检查网络或接口响应速度';
  330. } else {
  331. error.value = `数据加载失败:${err.message}`;
  332. }
  333. console.error(err);
  334. } finally {
  335. loading.value = false;
  336. }
  337. };
  338. // 组件挂载时加载数据
  339. onMounted(() => {
  340. fetchData();
  341. });
  342. // 卸载时销毁图表
  343. onUnmounted(() => {
  344. if (chartInstance.value) {
  345. chartInstance.value.dispose();
  346. }
  347. });
  348. </script>
  349. <style scoped>
  350. /* 错误提示样式 */
  351. .status.error {
  352. color: #dc2626;
  353. background: #fee2e2;
  354. padding: 12px 16px;
  355. border-radius: 6px;
  356. }
  357. .status.error button {
  358. cursor: pointer;
  359. background: #ff4d4f;
  360. color: #fff;
  361. border: none;
  362. border-radius: 4px;
  363. padding: 4px 8px;
  364. }
  365. .raw-response pre {
  366. white-space: pre-wrap;
  367. word-break: break-all;
  368. background: #f9fafb;
  369. padding: 8px;
  370. border-radius: 4px;
  371. font-size: 12px;
  372. }
  373. /* 表格核心样式:固定布局 + 列宽生效 */
  374. table {
  375. border-collapse: collapse;
  376. table-layout: fixed;
  377. width: 100%;
  378. background-color: white;
  379. }
  380. th, td {
  381. border: 1px solid #d1d5db;
  382. text-align: center;
  383. padding: 10px 6px;
  384. min-width: 60px;
  385. white-space: normal;
  386. overflow: hidden;
  387. text-overflow: ellipsis;
  388. background-color: white;
  389. }
  390. /* 地图 & 图表容器 */
  391. .leaflet-container {
  392. width: 100%;
  393. height: 100%;
  394. }
  395. .echarts-container {
  396. width: 100%;
  397. height: 100%;
  398. }
  399. /* 响应式优化 */
  400. @media (max-width: 768px) {
  401. th, td {
  402. padding: 8px 4px;
  403. font-size: 14px;
  404. }
  405. .flex-col md:flex-row {
  406. flex-direction: column;
  407. }
  408. }
  409. </style>