airsampleChart.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. <template>
  2. <div class="atmosphere-summary">
  3. <!-- 图表容器 -->
  4. <div ref="chartRef" class="chart-box"></div>
  5. <!-- 状态提示 -->
  6. <div v-if="loading" class="status">
  7. <div class="spinner"></div>
  8. <p>数据加载中...</p>
  9. </div>
  10. <div v-else-if="error" class="status error">
  11. <i class="fa fa-exclamation-circle"></i> {{ error }}
  12. <div v-if="errorDetails" class="error-details">
  13. <p>错误详情:</p>
  14. <pre>{{ errorDetails }}</pre>
  15. </div>
  16. </div>
  17. </div>
  18. </template>
  19. <script setup>
  20. import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
  21. import * as echarts from 'echarts'
  22. import { api8000 } from '@/utils/request'; // 导入 api8000 实例
  23. // 接收计算方式(重量/体积)
  24. const props = defineProps({
  25. calculationMethod: {
  26. type: String,
  27. required: true,
  28. default: 'weight'
  29. }
  30. })
  31. // --------------------------
  32. // 配置区
  33. // --------------------------
  34. const API_URL = `/api/vector/export/all?table_name=Atmo_sample_data`; // 使用相对路径
  35. // 重量指标字段
  36. const WEIGHT_FIELDS = [
  37. 'Cr_particulate',
  38. 'As_particulate',
  39. 'Cd_particulate',
  40. 'Hg_particulate',
  41. 'Pb_particulate'
  42. ];
  43. // 体积字段名
  44. const VOLUME_FIELD = 'volume';
  45. // 自定义颜色(用户指定)
  46. const COLORS = ['#ff4d4f99', '#1890ff', '#ffd700', '#52c41a88', '#722ed199'];
  47. // --------------------------
  48. // 响应式数据
  49. const chartRef = ref(null);
  50. const loading = ref(true);
  51. const error = ref('');
  52. const errorDetails = ref(''); // 存储错误详情
  53. const showLog = ref(false); // 默认隐藏日志
  54. const fullLog = ref('');
  55. let myChart = null;
  56. // 记录日志
  57. const log = (message) => {
  58. const time = new Date().toLocaleTimeString();
  59. fullLog.value += `[${time}] ${message}\n`;
  60. // console.log(`[日志] ${message}`);
  61. };
  62. const fixInvalidJsonValues = (rawData) => {
  63. if (typeof rawData !== 'string') {
  64. rawData = JSON.stringify(rawData);
  65. }
  66. const fixedData = rawData
  67. .replace(/:\s*NaN\b/g, ': null')
  68. .replace(/:\s*"N"\b/g, ': null')
  69. .replace(/:\s*"NaN"\b/g, ': null')
  70. .replace(/:\s*Infinity\b/g, ': null')
  71. .replace(/:\s*-\s*Infinity\b/g, ': null')
  72. .replace(/:\s+/g, ': ')
  73. .replace(/,\s+/g, ', ');
  74. return fixedData;
  75. };
  76. function weightToVolume(weight, volume) {
  77. if (weight === undefined || weight === null) {
  78. log(`重量值无效: ${weight}`);
  79. return 0;
  80. }
  81. if (volume === undefined || volume === null || volume === 0 || isNaN(volume)) {
  82. log(`体积值无效: ${volume}(已自动替换为1)`);
  83. volume = 1;
  84. }
  85. const weightNum = parseFloat(weight);
  86. const volumeNum = parseFloat(volume);
  87. if (isNaN(weightNum)) {
  88. log(`重量无法转换为数字: ${weight}`);
  89. return 0;
  90. }
  91. const ug = weightNum * 1000;
  92. return parseFloat((ug / volumeNum).toFixed(2));
  93. }
  94. function getRegion(location) {
  95. if (!location || typeof location !== 'string') {
  96. return '未知区县';
  97. }
  98. const regions = [
  99. '浈江区', '武江区', '曲江区', '乐昌市',
  100. '南雄市', '始兴县', '仁化县', '翁源县',
  101. '新丰县', '乳源瑶族自治县'
  102. ];
  103. // 精确匹配
  104. for (const region of regions) {
  105. if (location.includes(region)) {
  106. return region;
  107. }
  108. }
  109. // 模糊匹配
  110. const aliasMap = {
  111. '浈江': '浈江区', '武江': '武江区', '曲江': '曲江区',
  112. '乐昌': '乐昌市', '南雄': '南雄市', '始兴': '始兴县',
  113. '仁化': '仁化县', '翁源': '翁源县', '新丰': '新丰县',
  114. '乳源': '乳源瑶族自治县'
  115. };
  116. for (const [alias, region] of Object.entries(aliasMap)) {
  117. if (location.includes(alias)) {
  118. return region;
  119. }
  120. }
  121. return '未知区县';
  122. }
  123. async function processData() {
  124. try {
  125. log('开始数据处理');
  126. // 使用 api8000 实例发起请求
  127. const response = await api8000.get(API_URL, {
  128. responseType: 'text', // 确保获取原始文本
  129. timeout: 15000
  130. });
  131. const fixedJson = fixInvalidJsonValues(response.data);
  132. const geoData = JSON.parse(fixedJson);
  133. if (!geoData || !geoData.features || !Array.isArray(geoData.features)) {
  134. throw new Error('数据结构错误,缺少features数组');
  135. }
  136. log(`解析到${geoData.features.length}条数据`);
  137. // 处理数据
  138. const processedItems = geoData.features.map((feature, index) => {
  139. const props = feature.properties || {};
  140. return {
  141. id: index,
  142. location: props.sampling_location || '',
  143. region: getRegion(props.sampling_location || ''),
  144. volume: props[VOLUME_FIELD],
  145. weights: WEIGHT_FIELDS.reduce((acc, field) => {
  146. acc[field] = props[field];
  147. return acc;
  148. }, {})
  149. };
  150. });
  151. // 统计数据
  152. const regionStats = {};
  153. const totalStats = {};
  154. WEIGHT_FIELDS.forEach(field => {
  155. totalStats[field] = { sum: 0, count: 0 };
  156. totalStats[`${field}_volume`] = { sum: 0, count: 0 };
  157. });
  158. processedItems.forEach(item => {
  159. const { region, volume, weights } = item;
  160. if (!regionStats[region]) {
  161. regionStats[region] = {};
  162. WEIGHT_FIELDS.forEach(field => {
  163. regionStats[region][field] = { sum: 0, count: 0 };
  164. regionStats[region][`${field}_volume`] = { sum: 0, count: 0 };
  165. });
  166. }
  167. WEIGHT_FIELDS.forEach(field => {
  168. const weightValue = weights[field];
  169. const weightNum = parseFloat(weightValue);
  170. if (!isNaN(weightNum)) {
  171. regionStats[region][field].sum += weightNum;
  172. regionStats[region][field].count += 1;
  173. totalStats[field].sum += weightNum;
  174. totalStats[field].count += 1;
  175. }
  176. const volumeValue = weightToVolume(weightValue, volume);
  177. regionStats[region][`${field}_volume`].sum += volumeValue;
  178. regionStats[region][`${field}_volume`].count += 1;
  179. totalStats[`${field}_volume`].sum += volumeValue;
  180. totalStats[`${field}_volume`].count += 1;
  181. });
  182. });
  183. // 准备图表数据
  184. const chartRegions = Object.keys(regionStats).filter(r => r !== '未知区县');
  185. if (chartRegions.length === 0) chartRegions.push('未知区县');
  186. chartRegions.push('全市平均');
  187. // 生成系列数据
  188. const series = WEIGHT_FIELDS.map((field, index) => {
  189. const metricType = props.calculationMethod === 'volume'
  190. ? `${field}_volume`
  191. : field;
  192. const data = chartRegions.map(region => {
  193. if (region === '全市平均') {
  194. return totalStats[metricType].count > 0
  195. ? (totalStats[metricType].sum / totalStats[metricType].count).toFixed(2)
  196. : '0.00';
  197. }
  198. const stats = regionStats[region][metricType];
  199. return stats.count > 0
  200. ? (stats.sum / stats.count).toFixed(2)
  201. : '0.00';
  202. });
  203. return {
  204. name: field.replace('_particulate', ''), // 图例名称(不带后缀)
  205. type: 'bar',
  206. data,
  207. itemStyle: {
  208. color: COLORS[index % COLORS.length] // 使用用户指定的颜色
  209. },
  210. label: {
  211. show: true,
  212. position: 'top',
  213. fontSize: 12
  214. }
  215. };
  216. });
  217. return { regions: chartRegions, series };
  218. } catch (err) {
  219. error.value = '数据处理失败';
  220. errorDetails.value = err.message;
  221. return null;
  222. }
  223. }
  224. // /​**​
  225. // * 初始化图表(带单位显示)
  226. // */
  227. async function initChart() {
  228. loading.value = true;
  229. error.value = '';
  230. errorDetails.value = '';
  231. try {
  232. await nextTick();
  233. if (!chartRef.value) {
  234. throw new Error('图表容器未挂载');
  235. }
  236. const chartData = await processData();
  237. if (!chartData) return;
  238. // 确定单位(核心修改:添加单位逻辑)
  239. const { unit, titleText } = props.calculationMethod === 'weight'
  240. ? {
  241. unit: 'mg/kg',
  242. titleText: '各区域重金属含量平均值' ,
  243. }
  244. : {
  245. unit: 'ug/m³', // 体积单位为ug/m³,可根据实际需求修改
  246. titleText: '各区域重金属含量平均值' ,
  247. };
  248. // 销毁旧图表
  249. if (myChart) myChart.dispose();
  250. myChart = echarts.init(chartRef.value);
  251. // 设置图表配置(带单位显示)
  252. myChart.setOption({
  253. title: {
  254. text: titleText,
  255. subtext: `单位: ${unit}`, // 标题显示单位
  256. left: 'center',
  257. textStyle:{fontSize:20},
  258. subtextStyle:{fontSize:18}
  259. },
  260. tooltip: { //提示框
  261. trigger: 'axis',
  262. formatter: function(params) {
  263. // Tooltip显示单位
  264. let res = `${params[0].name}<br/>`;
  265. params.forEach(item => {
  266. res += `${item.marker} ${item.seriesName}: ${item.value} ${unit}<br/>`;
  267. });
  268. return res;
  269. },
  270. textStyle:{fontSize:15}
  271. },
  272. xAxis: {
  273. type: 'category',
  274. data: chartData.regions,
  275. axisLabel: { fontSize:16 }
  276. },
  277. yAxis: {
  278. type: 'value',
  279. axisLabel: {
  280. formatter: `{value} ${unit}` // Y轴显示单位
  281. ,fontSize:15
  282. }
  283. },
  284. series: chartData.series.map(series => ({ // 遍历每个系列,添加 label 配置
  285. ...series, // 保留原有配置
  286. label: {
  287. show: true, // 显示数值标签
  288. position: 'top', // 标签位置(顶部)
  289. fontSize: 15, // 这里才是柱状图数值的字体大小!
  290. color: '#333' // 可选:设置文字颜色
  291. }
  292. })),
  293. legend: { //图例
  294. data: chartData.series.map(s => s.name),
  295. top:'10%',
  296. right:'5%',
  297. textStyle:{fontSize:18}
  298. },
  299. grid: {
  300. left: '1%', right: '2%', bottom: '2%', top: '20%',
  301. containLabel: true,
  302. axisLabel:{fontSize:18}
  303. }
  304. }, true);
  305. // 监听窗口大小
  306. const handleResize = () => myChart.resize();
  307. window.addEventListener('resize', handleResize);
  308. onUnmounted(() => window.removeEventListener('resize', handleResize));
  309. } catch (err) {
  310. error.value = '图表加载失败';
  311. errorDetails.value = err.message;
  312. } finally {
  313. loading.value = false;
  314. }
  315. }
  316. // 监听计算方式变化
  317. watch(() => props.calculationMethod, initChart);
  318. // 组件挂载后初始化
  319. onMounted(() => {
  320. initChart();
  321. });
  322. // 组件卸载时清理
  323. onUnmounted(() => {
  324. if (myChart) myChart.dispose();
  325. });
  326. </script>
  327. <style scoped>
  328. .atmosphere-summary {
  329. width: 100%;
  330. max-width: 1400px;
  331. margin: 0 auto;
  332. box-sizing: border-box;
  333. position: relative;
  334. }
  335. .chart-box {
  336. width: 100%;
  337. height: 500px;
  338. min-height: 400px;
  339. background: #fff;
  340. border: 1px solid #e9ecef;
  341. border-radius: 8px;
  342. }
  343. .status {
  344. position: absolute;
  345. top: 50%;
  346. left: 50%;
  347. transform: translate(-50%, -50%);
  348. padding: 20px;
  349. background: rgba(255, 255, 255, 0.9);
  350. border-radius: 6px;
  351. text-align: center;
  352. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  353. max-width: 80%;
  354. }
  355. .error {
  356. color: #dc3545;
  357. border: 1px solid #f5c6cb;
  358. background: #f8d7da;
  359. }
  360. .error-details {
  361. margin-top: 15px;
  362. text-align: left;
  363. font-size: 14px;
  364. }
  365. .error-details pre {
  366. background: rgba(255, 255, 255, 0.8);
  367. padding: 10px;
  368. border-radius: 4px;
  369. overflow: auto;
  370. max-height: 200px;
  371. white-space: pre-wrap;
  372. }
  373. .spinner {
  374. width: 40px;
  375. height: 40px;
  376. margin: 0 auto 15px;
  377. border: 4px solid #e9ecef;
  378. border-top: 4px solid #007bff;
  379. border-radius: 50%;
  380. animation: spin 1s linear infinite;
  381. }
  382. @keyframes spin {
  383. 0% { transform: rotate(0deg); }
  384. 100% { transform: rotate(360deg); }
  385. }
  386. .debug-panel {
  387. margin-top: 20px;
  388. padding: 15px;
  389. background: #f8f9fa;
  390. border-radius: 6px;
  391. font-size: 14px;
  392. }
  393. .log-toggle {
  394. background: #007bff;
  395. color: white;
  396. border: none;
  397. padding: 6px 12px;
  398. border-radius: 4px;
  399. cursor: pointer;
  400. margin-bottom: 10px;
  401. }
  402. .log-content {
  403. max-height: 300px;
  404. overflow: auto;
  405. background: #fff;
  406. padding: 10px;
  407. border-radius: 4px;
  408. border: 1px solid #e9ecef;
  409. }
  410. .log-content pre {
  411. margin: 0;
  412. white-space: pre-wrap;
  413. font-family: monospace;
  414. font-size: 12px;
  415. }
  416. </style>