effcdStatistics.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. <template>
  2. <div class="soil-dashboard p-4 bg-white min-h-screen">
  3. <div class="flex justify-between items-center mb-6">
  4. <h1 class="text-xl font-bold text-gray-800">有效态 Cd 数据统计分析</h1>
  5. <div class="flex items-center">
  6. <div class="stat-card inline-block px-3 py-2">
  7. <div class="stat-value text-lg">样本数量{{ stats.samples }}</div>
  8. </div>
  9. </div>
  10. </div>
  11. <!-- 加载状态 -->
  12. <div v-if="isLoading" class="loading-overlay">
  13. <span class="spinner"></span>
  14. <span class="ml-3 text-gray-700">数据加载中...</span>
  15. </div>
  16. <!-- 错误提示 -->
  17. <div v-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
  18. <p>数据加载失败: {{ error.message }}</p>
  19. <button class="mt-2 px-3 py-1 bg-red-500 text-white rounded" @click="initCharts">重试</button>
  20. </div>
  21. <!-- 1️⃣ 总Cd & 有效态Cd -->
  22. <section class="mb-4 chart-container">
  23. <h3 class="section-title text-base font-semibold">污染指标</h3>
  24. <div ref="cdBarChart" style="width: 100%; height: 415px;"></div>
  25. </section>
  26. <!-- 2️⃣ 养分元素 -->
  27. <section class="mb-4 chart-container">
  28. <h3 class="section-title text-base font-semibold">主要养分元素</h3>
  29. <div ref="nutrientBoxChart" style="width: 100%; height: 400px;"></div>
  30. </section>
  31. <!-- 3️⃣ 其他理化性质 -->
  32. <section class="chart-container">
  33. <div class="flex justify-between items-center mb-3">
  34. <h3 class="section-title text-base font-semibold">其他理化性质</h3>
  35. </div>
  36. <div ref="extraBoxChart" style="height: 400px; width: 100%;"></div>
  37. </section>
  38. </div>
  39. </template>
  40. <script setup>
  41. import { ref, onMounted, nextTick } from 'vue';
  42. import * as echarts from 'echarts';
  43. import { api8000 } from '@/utils/request'; // 导入 api8000 实例
  44. // 图表实例引用
  45. const cdBarChart = ref(null);
  46. const nutrientBoxChart = ref(null);
  47. const extraBoxChart = ref(null);
  48. // 图表实例变量
  49. let chartInstanceCd = null;
  50. let chartInstanceNutrient = null;
  51. let chartInstanceExtra = null;
  52. // 响应式状态
  53. const isLoading = ref(true);
  54. const error = ref(null);
  55. const stats = ref({
  56. totalCdAvg: 0,
  57. effCdAvg: 0,
  58. samples: 0
  59. });
  60. // 参考灌溉水代码:按图表类型缓存统计数据(与x轴顺序严格对应)
  61. const pollutionStats = ref([]); // 污染指标统计(总镉/有效态镉)
  62. const nutrientStats = ref([]); // 养分元素统计
  63. const extraStats = ref([]); // 其他理化性质统计
  64. // 字段配置(参考灌溉水的重金属配置方式)
  65. const fieldConfig = {
  66. pollution: [
  67. { key: 'TCd_IDW', name: '总镉', color: '#5470c6' ,unit:'mg/kg' , convert: false },
  68. { key: 'AvaK_IDW', name: '速效钾', color: '#fac858',unit:'mg/kg', convert: false },
  69. { key: 'AvaP_IDW', name: '有效磷', color: '#fac858' ,unit:'mg/kg' , convert: false},
  70. { key: 'TMn_IDW', name: '全锰', color: '#73c0de' ,unit:'mg/kg' , convert: false},
  71. { key: 'TN_IDW', name: '全氮', color: '#fac858' ,unit:'mg/kg' , convert: true, conversionFactor: 1000},
  72. { key: 'TP_IDW', name: '全磷', color: '#fac858' ,unit:'mg/kg', convert: true, conversionFactor: 1000},
  73. { key: 'TK_IDW', name: '全钾', color: '#fac858' ,unit:'mg/kg', convert: true, conversionFactor: 1000},
  74. ],
  75. nutrient: [
  76. { key: 'AvaK_IDW', name: '速效钾', color: '#fac858',unit:'mg/kg' , convert: false},
  77. { key: 'AvaP_IDW', name: '有效磷', color: '#fac858' ,unit:'mg/kg', convert: false },
  78. { key: 'TMn_IDW', name: '全锰', color: '#73c0de' ,unit:'mg/kg', convert: false },
  79. { key: 'TN_IDW', name: '全氮', color: '#fac858' ,unit:'mg/kg' , convert: true, conversionFactor: 1000},
  80. { key: 'TP_IDW', name: '全磷', color: '#fac858' ,unit:'mg/kg' , convert: true, conversionFactor: 1000},
  81. { key: 'TK_IDW', name: '全钾', color: '#fac858' ,unit:'mg/kg', convert: true, conversionFactor: 1000},
  82. { key: 'TS_IDW', name: '全硫', unit:'mg/kg',convert:true,conversionFactor:1000}
  83. ],
  84. extra: [
  85. { key: 'TFe_IDW', name: '全铁', color: '#73c0de' ,unit:'%', convert: false},
  86. { key: 'TCa_IDW', name: '全钙', color: '#73c0de' ,unit:'%', convert: false},
  87. { key: 'TMg_IDW', name: '全镁', color: '#73c0de' ,unit:'%', convert: false},
  88. { key: 'TAl_IDW', name: '全铝', color: '#73c0de' ,unit:'%', convert: false}
  89. ]
  90. };
  91. const fetchData = async () => {
  92. try {
  93. isLoading.value = true;
  94. const apiUrl = '/api/vector/stats/EffCd_input_data';
  95. console.log('正在请求数据:', apiUrl);
  96. const response = await api8000.get(apiUrl);
  97. console.log('API响应:', response);
  98. // 调试:输出响应结构
  99. console.log('响应数据:', response.data);
  100. // 处理不同的响应格式
  101. let processedData;
  102. processedData = response.data.data;
  103. if (!processedData) {
  104. throw new Error('无法解析API返回的数据结构');
  105. }
  106. console.log('处理后的数据:', processedData);
  107. return processedData;
  108. } catch (err) {
  109. console.error('数据请求失败:', err);
  110. throw new Error(`数据加载失败: ${err.message || '网络或服务器错误'}`);
  111. }
  112. };
  113. // 计算单个字段的箱线图统计量(带顺序校正)
  114. const calculateFieldStats = (statsData, fieldKey, fieldName, fieldConfigItem) => {
  115. const fieldStats = statsData[fieldKey]; // 从接口数据中取当前字段的统计结果
  116. if (!fieldStats) {
  117. return {
  118. key: fieldKey,
  119. name: fieldName,
  120. min: null,
  121. q1: null,
  122. median: null,
  123. q3: null,
  124. max: null,
  125. mean: null
  126. };
  127. }
  128. // 提取原始统计值
  129. let min = fieldStats.min;
  130. let q1 = fieldStats.q1;
  131. let median = fieldStats.median;
  132. let q3 = fieldStats.q3;
  133. let max = fieldStats.max;
  134. let mean = fieldStats.mean;
  135. // 处理「单位转换」(与原代码逻辑一致,若配置了convert则乘以系数)
  136. if (fieldConfigItem?.convert && fieldConfigItem.conversionFactor) {
  137. min *= fieldConfigItem.conversionFactor;
  138. q1 *= fieldConfigItem.conversionFactor;
  139. median *= fieldConfigItem.conversionFactor;
  140. q3 *= fieldConfigItem.conversionFactor;
  141. max *= fieldConfigItem.conversionFactor;
  142. mean *= fieldConfigItem.conversionFactor;
  143. }
  144. // 强制校正统计量顺序(确保 min ≤ q1 ≤ median ≤ q3 ≤ max)
  145. const sortedStats = [min, q1, median, q3, max].sort((a, b) => a - b);
  146. return {
  147. key: fieldKey,
  148. name: fieldName,
  149. min: sortedStats[0],
  150. q1: sortedStats[1],
  151. median: sortedStats[2],
  152. q3: sortedStats[3],
  153. max: sortedStats[4],
  154. mean: mean
  155. };
  156. };
  157. // 批量计算所有字段的统计量(按图表类型缓存)
  158. const calculateAllStats = (statsData) => {
  159. // 1. 污染指标统计(与x轴顺序一致)
  160. pollutionStats.value = fieldConfig.pollution.map(field =>
  161. calculateFieldStats(statsData, field.key, field.name, field)
  162. );
  163. // 2. 养分元素统计(与x轴顺序一致)
  164. nutrientStats.value = fieldConfig.nutrient.map(field =>
  165. calculateFieldStats(statsData, field.key, field.name, field)
  166. );
  167. // 3. 其他理化性质统计(与x轴顺序一致)
  168. extraStats.value = fieldConfig.extra.map(field =>
  169. calculateFieldStats(statsData, field.key, field.name, field)
  170. );
  171. // 更新「样本数量、平均值」统计(从接口数据中取mean更准确)
  172. const totalCdStats = pollutionStats.value.find(s => s.key === 'TCd_IDW'); // 替换为实际「总Cd」字段名
  173. const effCdStats = pollutionStats.value.find(s => s.key === 'Cdsolution'); // 替换为实际「有效态Cd」字段名
  174. stats.value = {
  175. totalCdAvg: totalCdStats ? totalCdStats.mean : 0,
  176. effCdAvg: effCdStats ? effCdStats.mean : 0,
  177. samples: statsData[Object.keys(statsData)[0]]?.count || 0 // 取第一个字段的count作为样本数
  178. };
  179. };
  180. // 构建箱线图数据(将统计量转换为ECharts所需格式)
  181. const buildBoxplotData = (statsArray) => {
  182. return statsArray.map(stat => {
  183. if (!stat.min) return [null, null, null, null, null];
  184. return [stat.min, stat.q1, stat.median, stat.q3, stat.max];
  185. });
  186. };
  187. // 初始化污染指标图表(柱状图)
  188. const initPollutionChart = () => {
  189. if (chartInstanceCd) chartInstanceCd.dispose();
  190. chartInstanceCd = echarts.init(cdBarChart.value);
  191. // 提取x轴标签和数据
  192. const xAxisData = fieldConfig.pollution.map(f => f.name);
  193. const barData = pollutionStats.value.map(stat => stat.mean);
  194. chartInstanceCd.setOption({
  195. title: { text: '主要指标含量对比', left: 'center', textStyle: { fontSize: 14 } },
  196. tooltip: {
  197. trigger: 'axis',
  198. formatter: (params) => `${params[0].name}<br/>平均值: ${params[0].value.toFixed(4)} mg/kg`
  199. },
  200. grid: { top: 40, right: 15, bottom: 30, left: 40 },
  201. xAxis: {
  202. type: "category",
  203. data: xAxisData,
  204. axisLabel: {
  205. fontSize: 12,
  206. rotate: 30,
  207. interval: 0, // 强制显示所有标签
  208. formatter: (value) => value.length > 8 ? value.substring(0, 8) + '...' : value
  209. }
  210. },
  211. yAxis: {
  212. type: "value",
  213. name: '含量 (mg/kg)',
  214. nameTextStyle: { fontSize: 12 },
  215. axisLabel: { fontSize: 11 }
  216. },
  217. series: [{
  218. name: '平均值', type: "bar",
  219. itemStyle: {color: (params) => fieldConfig.pollution[params.dataIndex].color },
  220. data: barData
  221. }]
  222. });
  223. };
  224. // 初始化养分元素图表(箱线图)
  225. const initNutrientChart = () => {
  226. if (chartInstanceNutrient) chartInstanceNutrient.dispose();
  227. chartInstanceNutrient = echarts.init(nutrientBoxChart.value);
  228. const xAxisData = fieldConfig.nutrient.map(f => f.name);
  229. const boxData = buildBoxplotData(nutrientStats.value);
  230. chartInstanceNutrient.setOption({
  231. title: { text: "主要养分元素分布", left: 'center', textStyle: { fontSize: 14 } },
  232. tooltip: {
  233. trigger: "item",
  234. formatter: (params) => formatTooltip(nutrientStats.value[params.dataIndex])
  235. },
  236. grid: { top: 40, right: 15, bottom: 40, left: 40 },
  237. xAxis: {
  238. type: "category",
  239. data: xAxisData,
  240. axisLabel: {
  241. fontSize: 11,
  242. rotate: 30,
  243. interval: 0, // 强制显示所有标签
  244. formatter: (value) => value.length > 8 ? value.substring(0, 8) + '...' : value
  245. }
  246. },
  247. yAxis: {
  248. type: "value",
  249. name: '含量(mg/kg)',
  250. nameTextStyle: { fontSize: 12 },
  251. axisLabel: { fontSize: 11 }
  252. },
  253. series: [{
  254. name: '养分元素', type: "boxplot",
  255. itemStyle: { color: (params) => fieldConfig.nutrient[params.dataIndex].color },
  256. data: boxData
  257. }]
  258. });
  259. };
  260. // 初始化其他理化性质图表(箱线图)
  261. const initExtraChart = () => {
  262. const xAxisData = fieldConfig.extra.map(f => f.name);
  263. const boxData = buildBoxplotData(extraStats.value);
  264. nextTick(() => {
  265. if (chartInstanceExtra) chartInstanceExtra.dispose();
  266. chartInstanceExtra = echarts.init(extraBoxChart.value);
  267. chartInstanceExtra.setOption({
  268. title: { text: "其他理化性质分布", left: 'center', textStyle: { fontSize: 14 } },
  269. tooltip: {
  270. trigger: "item",
  271. formatter: (params) => formatTooltip(extraStats.value[params.dataIndex])
  272. },
  273. grid: { top: 40, right: 15, bottom: 40, left: 40 },
  274. xAxis: { type: "category", data: xAxisData, axisLabel: { fontSize: 11 } },
  275. yAxis: { type: "value", name: '%', nameTextStyle: { fontSize: 12 }, axisLabel: { fontSize: 11 } },
  276. series: [{
  277. name: '理化性质', type: "boxplot",
  278. itemStyle: { color: (params) => fieldConfig.extra[params.dataIndex].color },
  279. data: boxData
  280. }]
  281. });
  282. });
  283. };
  284. // 格式化Tooltip(复用缓存的统计数据)
  285. const formatTooltip = (stat) => {
  286. if (!stat || !stat.min) {
  287. return `<div style="font-weight:bold;color:#f56c6c">${stat?.name || '未知'}</div><div>无有效数据</div>`;
  288. }
  289. return `<div style="font-weight:bold">${stat.name}</div>
  290. <div style="margin-top:8px">
  291. <div>最小值:<span style="color:#5a5;">${stat.min.toFixed(4)}</span></div>
  292. <div>下四分位:<span style="color:#d87a80;">${stat.q1.toFixed(4)}</span></div>
  293. <div>中位数:<span style="color:#f56c6c;font-weight:bold;">${stat.median.toFixed(4)}</span></div>
  294. <div>上四分位:<span style="color:#d87a80;">${stat.q3.toFixed(4)}</span></div>
  295. <div>最大值:<span style="color:#5a5;">${stat.max.toFixed(4)}</span></div>
  296. </div>`;
  297. };
  298. // 初始化图表主流程
  299. const initCharts = async () => {
  300. try {
  301. isLoading.value = true;
  302. error.value = null;
  303. const data = await fetchData();
  304. calculateAllStats(data); // 计算并缓存所有统计数据
  305. // 初始化图表
  306. initPollutionChart();
  307. initNutrientChart();
  308. initExtraChart();
  309. isLoading.value = false;
  310. } catch (err) {
  311. isLoading.value = false;
  312. error.value = err;
  313. console.error('初始化失败:', err);
  314. }
  315. };
  316. // 组件挂载
  317. onMounted(() => {
  318. initCharts();
  319. // 窗口 resize 处理
  320. const handleResize = () => {
  321. [chartInstanceCd, chartInstanceNutrient, chartInstanceExtra]
  322. .forEach(inst => inst && inst.resize());
  323. };
  324. window.addEventListener('resize', handleResize);
  325. // 组件卸载清理
  326. return () => {
  327. window.removeEventListener('resize', handleResize);
  328. [chartInstanceCd, chartInstanceNutrient, chartInstanceExtra]
  329. .forEach(inst => inst && inst.dispose());
  330. };
  331. });
  332. </script>
  333. <style>
  334. .soil-dashboard {
  335. font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
  336. max-width: 1200px;
  337. margin: 0 auto;
  338. font-size: 14px;
  339. }
  340. .chart-container {
  341. background: white;
  342. border-radius: 6px;
  343. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  344. padding: 12px;
  345. margin-bottom: 16px;
  346. }
  347. .section-title {
  348. color: #2c3e50;
  349. border-left: 3px solid #3498db;
  350. padding-left: 10px;
  351. margin-bottom: 12px;
  352. }
  353. .toggle-btn {
  354. background: #f8f9fa;
  355. border: 1px solid #e9ecef;
  356. padding: 6px 12px;
  357. border-radius: 3px;
  358. cursor: pointer;
  359. display: inline-flex;
  360. align-items: center;
  361. transition: all 0.3s;
  362. }
  363. .toggle-btn:hover {
  364. background: #e9ecef;
  365. }
  366. .loading-overlay {
  367. position: absolute;
  368. top: 0;
  369. left: 0;
  370. right: 0;
  371. bottom: 0;
  372. background: rgba(255, 255, 255, 0.8);
  373. display: flex;
  374. align-items: center;
  375. justify-content: center;
  376. z-index: 10;
  377. }
  378. .spinner {
  379. width: 30px;
  380. height: 30px;
  381. border: 3px solid rgba(0, 0, 0, 0.1);
  382. border-radius: 50%;
  383. border-left-color: #3498db;
  384. animation: spin 1s linear infinite;
  385. }
  386. @keyframes spin { to { transform: rotate(360deg); } }
  387. .stat-card {
  388. background: linear-gradient(135deg, #f5f7fa 0%, #e4edf5 100%);
  389. border-radius: 6px;
  390. padding: 8px 12px;
  391. box-shadow: 0 1px 3px rgba(0,0,0,0.05);
  392. }
  393. .stat-value {
  394. font-size: 16px;
  395. font-weight: bold;
  396. color: #2c3e50;
  397. }
  398. .stat-label {
  399. font-size: 12px;
  400. color: #7f8c8d;
  401. }
  402. .legend-item {
  403. display: flex;
  404. align-items: center;
  405. margin-right: 12px;
  406. font-size: 13px;
  407. }
  408. .legend-color {
  409. width: 10px;
  410. height: 10px;
  411. border-radius: 50%;
  412. margin-right: 5px;
  413. }
  414. </style>