effcdStatistics.vue 14 KB

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