cropcdStatictics.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. <template>
  2. <div class="crop-cd-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指标 -->
  22. <section class="mb-4 chart-container">
  23. <h3 class="section-title text-base font-semibold">作物态Cd主要指标</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="width: 100%; height: 400px;"></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. // 响应式状态
  53. const showDialog = ref(false);
  54. const dialogTitle = ref("");
  55. const isLoading = ref(true);
  56. const error = ref(null);
  57. const lastUpdate = ref("");
  58. const stats = ref({
  59. cd002Avg: 0,
  60. cd02Avg: 0,
  61. cd2Avg: 0,
  62. samples: 0
  63. });
  64. // 按类别缓存统计数据(与x轴顺序对应)
  65. const pollutionStats = ref([]);
  66. const nutrientStats = ref([]);
  67. const extraStats = ref([]);
  68. // 字段配置(根据接口返回的作物态Cd数据结构定义)
  69. const fieldConfig = {
  70. pollution: [
  71. {
  72. key: '002_0002IDW',
  73. name: '粉粒组分质量占比', // 横坐标保持原标识
  74. color: '#5470c6' ,
  75. unit:"%",
  76. convert: false
  77. },
  78. {
  79. key: '02_002IDW',
  80. name: '砂粒组分质量占比', // 横坐标保持原标识
  81. color: '#91cc75' ,
  82. unit:"%",
  83. convert: false
  84. },
  85. {
  86. key: '2_02IDW',
  87. name: '石砾组分质量占比', // 横坐标保持原标识
  88. color: '#fac858' ,
  89. unit:"%",
  90. convert: false
  91. }
  92. ],
  93. nutrient: [
  94. { key: 'AvaK_IDW', name: '速效钾', color: '#ee6666',unit:'mg/kg' ,convert: false},
  95. { key: 'SAvaK_IDW', name: '交换性速效钾', color: '#ee6666' ,unit:'mg/kg' ,convert: false},
  96. { key: 'AvaP_IDW', name: '速效磷', color: '#ee6666' ,unit:'mg/kg' ,convert: false},
  97. { key: 'TMn_IDW', name: '全锰', color: '#73c0de' ,unit:'mg/kg' ,convert: false},
  98. { key: 'TN_IDW', name: '全氮', color: '#ee6666' ,unit:'mg/kg' ,convert: true,conversionFactor:1000},
  99. { key: 'TS_IDW', name: '全硫', color: '#ee6666',unit:'mg/kg' ,convert: true,conversionFactor:1000}
  100. ],
  101. extra: [
  102. { key: 'TAl_IDW', name: '全铝', color: '#73c0de' ,unit:'%',convert: false },
  103. { key: 'TCa_IDW', name: '全钙', color: '#73c0de' ,unit:'%',convert: false},
  104. { key: 'TFe_IDW', name: '全铁', color: '#73c0de' ,unit:'%',convert: false},
  105. { key: 'TMg_IDW', name: '全镁', color: '#73c0de' ,unit:'%',convert: false},
  106. ]
  107. };
  108. // 数据请求(作物态Cd接口)
  109. const fetchData = async () => {
  110. try {
  111. const apiUrl = 'http://localhost:8000/api/vector/stats/CropCd_input_data';
  112. const response = await axios.get(apiUrl);
  113. console.log("接口原始返回:", response.data);
  114. return response.data.data;
  115. } catch (err) {
  116. throw new Error('数据加载失败: ' + err.message);
  117. }
  118. };
  119. // 构建箱线图数据
  120. const buildBoxplotData = (statsArray) => {
  121. return statsArray.map(stat => {
  122. if (!stat.min) return [null, null, null, null, null];
  123. return [stat.min, stat.q1, stat.median, stat.q3, stat.max];
  124. });
  125. };
  126. // 初始化作物态Cd指标图表
  127. const initPollutionChart = () => {
  128. nextTick(() => {
  129. // 若图表实例已存在,先销毁避免内存泄漏
  130. if (chartInstanceCd) chartInstanceCd.dispose();
  131. // 校验 DOM 存在性(防止 ref 未关联到有效 DOM)
  132. if (!cdBarChart.value) return;
  133. // 初始化 ECharts 实例
  134. chartInstanceCd = echarts.init(cdBarChart.value);
  135. const xAxisData = fieldConfig.pollution.map(f => f.name);
  136. const barData = pollutionStats.value.map(stat => stat.avg || 0);
  137. chartInstanceCd.setOption({
  138. title: {},
  139. tooltip: {
  140. trigger: 'axis',
  141. formatter: (params) => `${params[0].name}<br/>平均值: ${params[0].value.toFixed(4)}`
  142. },
  143. grid: { top: 40, right: 15, bottom: '18%', left: '10%' },
  144. xAxis: { type: "category", data: xAxisData, axisLabel: { fontSize: 12, rotate: 30 } },
  145. yAxis: { type: "value", name: '%', nameTextStyle: { fontSize: 12 }, axisLabel: { fontSize: 11 } },
  146. series: [{
  147. name: '平均值', type: "bar",
  148. itemStyle: { color: (p) => fieldConfig.pollution[p.dataIndex].color },
  149. data: barData
  150. }]
  151. });
  152. });
  153. };
  154. // 初始化养分元素图表
  155. const initNutrientChart = () => {
  156. nextTick(() => {
  157. if (chartInstanceNutrient) chartInstanceNutrient.dispose();
  158. if (!nutrientBoxChart.value) return;
  159. chartInstanceNutrient = echarts.init(nutrientBoxChart.value);
  160. const xAxisData = fieldConfig.nutrient.map(f => f.name);
  161. const boxData = buildBoxplotData(nutrientStats.value);
  162. chartInstanceNutrient.setOption({
  163. title: { text: "主要养分元素分布", left: 'center', textStyle: { fontSize: 14 } },
  164. tooltip: {
  165. trigger: "item",
  166. formatter: (params) => {
  167. const stat = nutrientStats.value[params.dataIndex];
  168. const fieldConfigItem = fieldConfig.nutrient.find(f => f.key === stat.key);
  169. return formatTooltip(stat, fieldConfigItem?.unit);
  170. }
  171. },
  172. grid: { top: 40, right: 15, bottom: 45, left: 40 },
  173. xAxis: {
  174. type: "category",
  175. data: xAxisData,
  176. axisLabel: { fontSize: 11, rotate: 30 }
  177. },
  178. yAxis: {
  179. type: "value",
  180. name: 'mg/kg',
  181. nameTextStyle: { fontSize: 12 },
  182. axisLabel: { fontSize: 11 }
  183. },
  184. series: [{
  185. name: '养分元素',
  186. type: "boxplot",
  187. itemStyle: { color: '#ee6666', borderColor: '#fac858' },
  188. data: boxData
  189. }]
  190. });
  191. });
  192. };
  193. // 初始化其他理化性质图表
  194. const initExtraChart = () => {
  195. const xAxisData = fieldConfig.extra.map(f => f.name);
  196. const boxData = buildBoxplotData(extraStats.value);
  197. nextTick(() => {
  198. if (chartInstanceExtra) chartInstanceExtra.dispose();
  199. chartInstanceExtra = echarts.init(extraBoxChart.value);
  200. chartInstanceExtra.setOption({
  201. title: { text: "其他理化性质分布", left: 'center', textStyle: { fontSize: 14 } },
  202. tooltip: {
  203. trigger: "item",
  204. formatter: (params) => formatTooltip(extraStats.value[params.dataIndex])
  205. },
  206. grid: { top: 40, right: 15, bottom: 40, left: 40 },
  207. xAxis: { type: "category", data: xAxisData, axisLabel: { fontSize: 11} },
  208. yAxis: { type: "value", name: '%', nameTextStyle: { fontSize: 12 }, axisLabel: { fontSize: 11 } },
  209. series: [{
  210. name: '理化性质', type: "boxplot",
  211. itemStyle: { color: '#73c0de', borderColor: '#5470c6' },
  212. data: boxData
  213. }]
  214. });
  215. });
  216. };
  217. // 格式化Tooltip
  218. const formatTooltip = (stat , unit ='') => {
  219. if (!stat || !stat.min) {
  220. return `<div style="font-weight:bold;color:#f56c6c">${stat?.name || '未知'}</div><div>无有效数据</div>`;
  221. }
  222. return `<div style="font-weight:bold">${stat.name}</div>
  223. <div style="margin-top:8px">
  224. <div>最小值:<span style="color:#5a5;">${stat.min.toFixed(4)}</span></div>
  225. <div>下四分位:<span style="color:#d87a80;">${stat.q1.toFixed(4)}</span></div>
  226. <div>中位数:<span style="color:#f56c6c;font-weight:bold;">${stat.median.toFixed(4)}</span></div>
  227. <div>上四分位:<span style="color:#d87a80;">${stat.q3.toFixed(4)}</span></div>
  228. <div>最大值:<span style="color:#5a5;">${stat.max.toFixed(4)}</span></div>
  229. </div>`;
  230. };
  231. // 初始化图表主流程
  232. const initCharts = async () => {
  233. try {
  234. isLoading.value = true;
  235. error.value = null;
  236. const statsData = await fetchData(); // 新接口返回的统计数据
  237. // -------- 1. 处理「作物态Cd指标」统计 --------
  238. pollutionStats.value = fieldConfig.pollution.map(field => {
  239. const fieldStats = statsData[field.key]; // 从接口数据中取对应字段的统计
  240. if (!fieldStats) {
  241. return {
  242. key: field.key,
  243. name: field.name,
  244. min: null,
  245. q1: null,
  246. median: null,
  247. q3: null,
  248. max: null,
  249. avg: null
  250. };
  251. }
  252. // (可选)单位转换:若接口返回原始值,需按fieldConfig的convert规则转换
  253. let min = fieldStats.min;
  254. let q1 = fieldStats.q1;
  255. let median = fieldStats.median;
  256. let q3 = fieldStats.q3;
  257. let max = fieldStats.max;
  258. let avg = fieldStats.mean;
  259. if (field.convert && field.conversionFactor) {
  260. min *= field.conversionFactor;
  261. q1 *= field.conversionFactor;
  262. median *= field.conversionFactor;
  263. q3 *= field.conversionFactor;
  264. max *= field.conversionFactor;
  265. avg *= field.conversionFactor;
  266. }
  267. return {
  268. key: field.key,
  269. name: field.name,
  270. min,
  271. q1,
  272. median,
  273. q3,
  274. max,
  275. avg
  276. };
  277. });
  278. // -------- 2. 处理「主要养分元素」统计 --------
  279. nutrientStats.value = fieldConfig.nutrient.map(field => {
  280. const fieldStats = statsData[field.key];
  281. if (!fieldStats) {
  282. return {
  283. key: field.key,
  284. name: field.name,
  285. min: null,
  286. q1: null,
  287. median: null,
  288. q3: null,
  289. max: null,
  290. avg: null
  291. };
  292. }
  293. // (可选)单位转换
  294. let min = fieldStats.min;
  295. let q1 = fieldStats.q1;
  296. let median = fieldStats.median;
  297. let q3 = fieldStats.q3;
  298. let max = fieldStats.max;
  299. let avg = fieldStats.mean;
  300. if (field.convert && field.conversionFactor) {
  301. min *= field.conversionFactor;
  302. q1 *= field.conversionFactor;
  303. median *= field.conversionFactor;
  304. q3 *= field.conversionFactor;
  305. max *= field.conversionFactor;
  306. avg *= field.conversionFactor;
  307. }
  308. return {
  309. key: field.key,
  310. name: field.name,
  311. min,
  312. q1,
  313. median,
  314. q3,
  315. max,
  316. avg
  317. };
  318. });
  319. // -------- 3. 处理「其他理化性质」统计 --------
  320. extraStats.value = fieldConfig.extra.map(field => {
  321. const fieldStats = statsData[field.key];
  322. if (!fieldStats) {
  323. return {
  324. key: field.key,
  325. name: field.name,
  326. min: null,
  327. q1: null,
  328. median: null,
  329. q3: null,
  330. max: null,
  331. avg: null
  332. };
  333. }
  334. // (可选)单位转换
  335. let min = fieldStats.min;
  336. let q1 = fieldStats.q1;
  337. let median = fieldStats.median;
  338. let q3 = fieldStats.q3;
  339. let max = fieldStats.max;
  340. let avg = fieldStats.mean;
  341. if (field.convert && field.conversionFactor) {
  342. min *= field.conversionFactor;
  343. q1 *= field.conversionFactor;
  344. median *= field.conversionFactor;
  345. q3 *= field.conversionFactor;
  346. max *= field.conversionFactor;
  347. avg *= field.conversionFactor;
  348. }
  349. return {
  350. key: field.key,
  351. name: field.name,
  352. min,
  353. q1,
  354. median,
  355. q3,
  356. max,
  357. avg
  358. };
  359. });
  360. // -------- 更新「样本数量」等汇总统计 --------
  361. const firstFieldKey = fieldConfig.pollution[0]?.key;
  362. stats.value = {
  363. cd002Avg: pollutionStats.value.find(s => s.key === '002_0002IDW')?.avg || 0,
  364. cd02Avg: pollutionStats.value.find(s => s.key === '02_002IDW')?.avg || 0,
  365. cd2Avg: pollutionStats.value.find(s => s.key === '2_02IDW')?.avg || 0,
  366. samples: statsData[firstFieldKey]?.count || 0 // 从接口的count字段取样本数
  367. };
  368. // 初始化图表
  369. initPollutionChart();
  370. initNutrientChart();
  371. initExtraChart();
  372. isLoading.value = false;
  373. } catch (err) {
  374. isLoading.value = false;
  375. error.value = err;
  376. console.error('初始化失败:', err);
  377. }
  378. };
  379. // 组件挂载
  380. onMounted(() => {
  381. initCharts();
  382. // 窗口resize处理
  383. const handleResize = () => {
  384. [chartInstanceCd, chartInstanceNutrient, chartInstanceExtra]
  385. .forEach(inst => inst && inst.resize());
  386. };
  387. window.addEventListener('resize', handleResize);
  388. // 组件卸载清理
  389. return () => {
  390. window.removeEventListener('resize', handleResize);
  391. [chartInstanceCd, chartInstanceNutrient, chartInstanceExtra]
  392. .forEach(inst => inst && inst.dispose());
  393. };
  394. });
  395. </script>
  396. <style>
  397. .crop-cd-dashboard {
  398. font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
  399. max-width: 1200px;
  400. margin: 0 auto;
  401. font-size: 14px;
  402. }
  403. .chart-container {
  404. background: white;
  405. border-radius: 6px;
  406. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  407. padding: 12px;
  408. margin-bottom: 16px;
  409. }
  410. .section-title {
  411. color: #2c3e50;
  412. border-left: 3px solid #3498db;
  413. padding-left: 10px;
  414. margin-bottom: 12px;
  415. }
  416. .toggle-btn {
  417. background: #f8f9fa;
  418. border: 1px solid #e9ecef;
  419. padding: 6px 12px;
  420. border-radius: 3px;
  421. cursor: pointer;
  422. display: inline-flex;
  423. align-items: center;
  424. transition: all 0.3s;
  425. }
  426. .toggle-btn:hover {
  427. background: #e9ecef;
  428. }
  429. .loading-overlay {
  430. position: absolute;
  431. top: 0;
  432. left: 0;
  433. right: 0;
  434. bottom: 0;
  435. background: rgba(255, 255, 255, 0.8);
  436. display: flex;
  437. align-items: center;
  438. justify-content: center;
  439. z-index: 10;
  440. }
  441. .spinner {
  442. width: 30px;
  443. height: 30px;
  444. border: 3px solid rgba(0, 0, 0, 0.1);
  445. border-radius: 50%;
  446. border-left-color: #3498db;
  447. animation: spin 1s linear infinite;
  448. }
  449. @keyframes spin { to { transform: rotate(360deg); } }
  450. .stat-card {
  451. background: linear-gradient(135deg, #f5f7fa 0%, #e4edf5 100%);
  452. border-radius: 6px;
  453. padding: 8px 12px;
  454. box-shadow: 0 1px 3px rgba(0,0,0,0.05);
  455. }
  456. .stat-value {
  457. font-size: 16px;
  458. font-weight: bold;
  459. color: #2c3e50;
  460. }
  461. .stat-label {
  462. font-size: 12px;
  463. color: #7f8c8d;
  464. }
  465. .legend-item {
  466. display: flex;
  467. align-items: center;
  468. margin-right: 12px;
  469. font-size: 13px;
  470. margin-bottom: 8px;
  471. }
  472. .legend-color {
  473. width: 10px;
  474. height: 10px;
  475. border-radius: 50%;
  476. margin-right: 5px;
  477. }
  478. </style>