cropcdStatictics.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  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">{{ $t('SoilCdStatistics.cropCdAnalysis') }}</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">{{ $t('SoilCdStatistics.sampleCount') }}: {{ 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">{{ $t('DetectionStatistics.dataLoading') }}</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>{{ $t('SoilCdStatistics.dataLoadFailed') }}: {{ error.message }}</p>
  19. <button class="mt-2 px-3 py-1 bg-red-500 text-white rounded" @click="initCharts">{{ $t('SoilCdStatistics.retry') }}</button>
  20. </div>
  21. <!-- 1️⃣ 作物态 Cd 指标 -->
  22. <section class="mb-4 chart-container">
  23. <h3 class="section-title text-base font-semibold">{{ $t('SoilCdStatistics.cropCdMainIndicators') }}</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">{{ $t('SoilCdStatistics.mainNutrients') }}</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">{{ $t('SoilCdStatistics.otherProperties') }}</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, nextTick } from 'vue';
  42. import * as echarts from 'echarts';
  43. import { api8000 } from '@/utils/request'; // 导入 api8000 实例import {api8000} from '@/utils/request'
  44. import { useI18n } from 'vue-i18n';
  45. const { t } = useI18n();
  46. const {locale} = useI18n()
  47. // 图表实例引用
  48. const cdBarChart = ref(null);
  49. const nutrientBoxChart = ref(null);
  50. const extraBoxChart = ref(null);
  51. // 图表实例变量
  52. let chartInstanceCd = null;
  53. let chartInstanceNutrient = null;
  54. let chartInstanceExtra = null;
  55. // 响应式状态
  56. const isLoading = ref(true);
  57. const error = ref(null);
  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. // 字段配置 - 改为函数形式
  69. const getFieldConfig = () => ({
  70. pollution: [
  71. {
  72. key: '002_0002IDW',
  73. name: t('SoilCdStatistics.siltContent'),
  74. color: '#5470c6',
  75. unit: t('SoilCdStatistics.unitPercent'),
  76. convert: false
  77. },
  78. {
  79. key: '02_002IDW',
  80. name: t('SoilCdStatistics.sandContent'),
  81. color: '#91cc75',
  82. unit: t('SoilCdStatistics.unitPercent'),
  83. convert: false
  84. },
  85. {
  86. key: '2_02IDW',
  87. name: t('SoilCdStatistics.gravelContent'),
  88. color: '#fac858',
  89. unit: t('SoilCdStatistics.unitPercent'),
  90. convert: false
  91. }
  92. ],
  93. nutrient: [
  94. { key: 'AvaK_IDW', name: t('SoilCdStatistics.availablePotassium'), color: '#ee6666', unit: t('SoilCdStatistics.unitMgKg'), convert: false },
  95. { key: 'SAvaK_IDW', name: t('SoilCdStatistics.exchangeablePotassium'), color: '#ee6666', unit: t('SoilCdStatistics.unitMgKg'), convert: false },
  96. { key: 'AvaP_IDW', name: t('SoilCdStatistics.availablePhosphorus'), color: '#ee6666', unit: t('SoilCdStatistics.unitMgKg'), convert: false },
  97. { key: 'TMn_IDW', name: t('SoilCdStatistics.totalManganese'), color: '#73c0de', unit: t('SoilCdStatistics.unitMgKg'), convert: false },
  98. { key: 'TN_IDW', name: t('SoilCdStatistics.totalNitrogen'), color: '#ee6666', unit: t('SoilCdStatistics.unitMgKg'), convert: true, conversionFactor: 1000 },
  99. { key: 'TS_IDW', name: t('SoilCdStatistics.totalSulfur'), color: '#ee6666', unit: t('SoilCdStatistics.unitMgKg'), convert: true, conversionFactor: 1000 }
  100. ],
  101. extra: [
  102. { key: 'TAl_IDW', name: t('SoilCdStatistics.totalAluminum'), color: '#73c0de', unit: t('SoilCdStatistics.unitPercent'), convert: false },
  103. { key: 'TCa_IDW', name: t('SoilCdStatistics.totalCalcium'), color: '#73c0de', unit: t('SoilCdStatistics.unitPercent'), convert: false },
  104. { key: 'TFe_IDW', name: t('SoilCdStatistics.totalIron'), color: '#73c0de', unit: t('SoilCdStatistics.unitPercent'), convert: false },
  105. { key: 'TMg_IDW', name: t('SoilCdStatistics.totalMagnesium'), color: '#73c0de', unit: t('SoilCdStatistics.unitPercent'), convert: false },
  106. ]
  107. });
  108. // 数据请求 - 增强错误处理和调试
  109. const fetchData = async () => {
  110. try {
  111. isLoading.value = true;
  112. const apiUrl = '/api/vector/stats/CropCd_input_data';
  113. // console.log('正在请求数据:', apiUrl);
  114. const response = await api8000.get(apiUrl);
  115. // console.log('API响应:', response);
  116. // 调试:输出响应结构
  117. // console.log('响应数据:', response.data);
  118. // 处理不同的响应格式
  119. let processedData;
  120. processedData = response.data.data;
  121. if (!processedData) {
  122. throw new Error('无法解析API返回的数据结构');
  123. }
  124. // console.log('处理后的数据:', processedData);
  125. return processedData;
  126. } catch (err) {
  127. console.error('数据请求失败:', err);
  128. throw new Error(`数据加载失败: ${err.message || '网络或服务器错误'}`);
  129. }
  130. };
  131. // 构建箱线图数据
  132. const buildBoxplotData = (statsArray) => {
  133. return statsArray.map(stat => {
  134. if (!stat.min) return [null, null, null, null, null];
  135. return [stat.min, stat.q1, stat.median, stat.q3, stat.max];
  136. });
  137. };
  138. // 初始化作物态 Cd 指标图表
  139. const initPollutionChart = () => {
  140. nextTick(() => {
  141. if (chartInstanceCd) chartInstanceCd.dispose();
  142. if (!cdBarChart.value) return;
  143. chartInstanceCd = echarts.init(cdBarChart.value);
  144. const currentFieldConfig = getFieldConfig();
  145. const xAxisData = currentFieldConfig.pollution.map(f => f.name);
  146. const barData = pollutionStats.value.map(stat => stat.avg || 0);
  147. chartInstanceCd.setOption({
  148. title: {},
  149. tooltip: {
  150. trigger: 'axis',
  151. formatter: (params) => `${params[0].name}<br/>${t('SoilCdStatistics.averageValue')}: ${params[0].value.toFixed(4)}`
  152. },
  153. grid: { top: 40, right: 15, bottom: '18%', left: '10%' },
  154. xAxis: { type: "category", data: xAxisData, axisLabel: { fontSize: 12, rotate: 30 } },
  155. yAxis: { type: "value", name: '%', nameTextStyle: { fontSize: 12 }, axisLabel: { fontSize: 11 } },
  156. series: [{
  157. name: t('SoilCdStatistics.averageValue'), type: "bar",
  158. itemStyle: { color: (p) => currentFieldConfig.pollution[p.dataIndex].color },
  159. data: barData
  160. }]
  161. });
  162. });
  163. };
  164. // 初始化养分元素图表
  165. const initNutrientChart = () => {
  166. nextTick(() => {
  167. if (chartInstanceNutrient) chartInstanceNutrient.dispose();
  168. if (!nutrientBoxChart.value) return;
  169. chartInstanceNutrient = echarts.init(nutrientBoxChart.value);
  170. const currentFieldConfig = getFieldConfig();
  171. const xAxisData = currentFieldConfig.nutrient.map(f => f.name);
  172. const boxData = buildBoxplotData(nutrientStats.value);
  173. chartInstanceNutrient.setOption({
  174. title: { text: t('SoilCdStatistics.nutrientDistribution'), left: 'center', textStyle: { fontSize: 14 } },
  175. tooltip: {
  176. trigger: "item",
  177. formatter: (params) => {
  178. const stat = nutrientStats.value[params.dataIndex];
  179. const fieldConfigItem = currentFieldConfig.nutrient.find(f => f.key === stat.key);
  180. return formatTooltip(stat, fieldConfigItem?.unit);
  181. }
  182. },
  183. grid: { top: 40, right: 15, bottom: 45, left: 40 },
  184. xAxis: {
  185. type: "category",
  186. data: xAxisData,
  187. axisLabel: { fontSize: 11, rotate: 30 }
  188. },
  189. yAxis: {
  190. type: "value",
  191. name: 'mg/kg',
  192. nameTextStyle: { fontSize: 12 },
  193. axisLabel: { fontSize: 11 }
  194. },
  195. series: [{
  196. name: t('SoilCdStatistics.mainNutrients'),
  197. type: "boxplot",
  198. itemStyle: { color: '#ee6666', borderColor: '#fac858' },
  199. data: boxData
  200. }]
  201. });
  202. });
  203. };
  204. // 初始化其他理化性质图表
  205. const initExtraChart = () => {
  206. const currentFieldConfig = getFieldConfig();
  207. const xAxisData = currentFieldConfig.extra.map(f => f.name);
  208. const boxData = buildBoxplotData(extraStats.value);
  209. nextTick(() => {
  210. if (chartInstanceExtra) chartInstanceExtra.dispose();
  211. chartInstanceExtra = echarts.init(extraBoxChart.value);
  212. chartInstanceExtra.setOption({
  213. title: { text: t('SoilCdStatistics.propertiesDistribution'), left: 'center', textStyle: { fontSize: 14 } },
  214. tooltip: {
  215. trigger: "item",
  216. formatter: (params) => formatTooltip(extraStats.value[params.dataIndex])
  217. },
  218. grid: { top: 40, right: 15, bottom: 40, left: 40 },
  219. xAxis: { type: "category", data: xAxisData, axisLabel: { fontSize: 11 } },
  220. yAxis: { type: "value", name: '%', nameTextStyle: { fontSize: 12 }, axisLabel: { fontSize: 11 } },
  221. series: [{
  222. name: t('SoilCdStatistics.otherProperties'), type: "boxplot",
  223. itemStyle: { color: '#73c0de', borderColor: '#5470c6' },
  224. data: boxData
  225. }]
  226. });
  227. });
  228. };
  229. // 格式化 Tooltip
  230. const formatTooltip = (stat, unit = '') => {
  231. if (!stat || !stat.min) {
  232. return `<div style="font-weight:bold;color:#f56c6c">${stat?.name || t('DetectionStatistics.noValidData')}</div><div>${t('DetectionStatistics.noValidData')}</div>`;
  233. }
  234. return `<div style="font-weight:bold">${stat.name}</div>
  235. <div style="margin-top:8px">
  236. <div>${t('DetectionStatistics.minValue')}:<span style="color:#5a5;">${stat.min.toFixed(4)} ${unit}</span></div>
  237. <div>${t('DetectionStatistics.q1Value')}:<span style="color:#d87a80;">${stat.q1.toFixed(4)} ${unit}</span></div>
  238. <div>${t('DetectionStatistics.medianValue')}:<span style="color:#f56c6c;font-weight:bold;">${stat.median.toFixed(4)} ${unit}</span></div>
  239. <div>${t('DetectionStatistics.q3Value')}:<span style="color:#d87a80;">${stat.q3.toFixed(4)} ${unit}</span></div>
  240. <div>${t('DetectionStatistics.maxValue')}:<span style="color:#5a5;">${stat.max.toFixed(4)} ${unit}</span></div>
  241. </div>`;
  242. };
  243. // 初始化图表主流程
  244. const initCharts = async () => {
  245. try {
  246. isLoading.value = true;
  247. error.value = null;
  248. const statsData = await fetchData();
  249. const currentFieldConfig = getFieldConfig();
  250. // -------- 1. 处理「作物态 Cd 指标」统计 --------
  251. pollutionStats.value = currentFieldConfig.pollution.map(field => {
  252. const fieldStats = statsData[field.key];
  253. if (!fieldStats) {
  254. return {
  255. key: field.key,
  256. name: field.name,
  257. min: null,
  258. q1: null,
  259. median: null,
  260. q3: null,
  261. max: null,
  262. avg: null
  263. };
  264. }
  265. let min = fieldStats.min;
  266. let q1 = fieldStats.q1;
  267. let median = fieldStats.median;
  268. let q3 = fieldStats.q3;
  269. let max = fieldStats.max;
  270. let avg = fieldStats.mean;
  271. if (field.convert && field.conversionFactor) {
  272. min *= field.conversionFactor;
  273. q1 *= field.conversionFactor;
  274. median *= field.conversionFactor;
  275. q3 *= field.conversionFactor;
  276. max *= field.conversionFactor;
  277. avg *= field.conversionFactor;
  278. }
  279. return {
  280. key: field.key,
  281. name: field.name,
  282. min,
  283. q1,
  284. median,
  285. q3,
  286. max,
  287. avg
  288. };
  289. });
  290. // -------- 2. 处理「主要养分元素」统计 --------
  291. nutrientStats.value = currentFieldConfig.nutrient.map(field => {
  292. const fieldStats = statsData[field.key];
  293. if (!fieldStats) {
  294. return {
  295. key: field.key,
  296. name: field.name,
  297. min: null,
  298. q1: null,
  299. median: null,
  300. q3: null,
  301. max: null,
  302. avg: null
  303. };
  304. }
  305. let min = fieldStats.min;
  306. let q1 = fieldStats.q1;
  307. let median = fieldStats.median;
  308. let q3 = fieldStats.q3;
  309. let max = fieldStats.max;
  310. let avg = fieldStats.mean;
  311. if (field.convert && field.conversionFactor) {
  312. min *= field.conversionFactor;
  313. q1 *= field.conversionFactor;
  314. median *= field.conversionFactor;
  315. q3 *= field.conversionFactor;
  316. max *= field.conversionFactor;
  317. avg *= field.conversionFactor;
  318. }
  319. return {
  320. key: field.key,
  321. name: field.name,
  322. min,
  323. q1,
  324. median,
  325. q3,
  326. max,
  327. avg
  328. };
  329. });
  330. // -------- 3. 处理「其他理化性质」统计 --------
  331. extraStats.value = currentFieldConfig.extra.map(field => {
  332. const fieldStats = statsData[field.key];
  333. if (!fieldStats) {
  334. return {
  335. key: field.key,
  336. name: field.name,
  337. min: null,
  338. q1: null,
  339. median: null,
  340. q3: null,
  341. max: null,
  342. avg: null
  343. };
  344. }
  345. let min = fieldStats.min;
  346. let q1 = fieldStats.q1;
  347. let median = fieldStats.median;
  348. let q3 = fieldStats.q3;
  349. let max = fieldStats.max;
  350. let avg = fieldStats.mean;
  351. if (field.convert && field.conversionFactor) {
  352. min *= field.conversionFactor;
  353. q1 *= field.conversionFactor;
  354. median *= field.conversionFactor;
  355. q3 *= field.conversionFactor;
  356. max *= field.conversionFactor;
  357. avg *= field.conversionFactor;
  358. }
  359. return {
  360. key: field.key,
  361. name: field.name,
  362. min,
  363. q1,
  364. median,
  365. q3,
  366. max,
  367. avg
  368. };
  369. });
  370. // -------- 更新汇总统计 --------
  371. const firstFieldKey = currentFieldConfig.pollution[0]?.key;
  372. stats.value = {
  373. cd002Avg: pollutionStats.value.find(s => s.key === '002_0002IDW')?.avg || 0,
  374. cd02Avg: pollutionStats.value.find(s => s.key === '02_002IDW')?.avg || 0,
  375. cd2Avg: pollutionStats.value.find(s => s.key === '2_02IDW')?.avg || 0,
  376. samples: statsData[firstFieldKey]?.count || 0
  377. };
  378. // 初始化图表
  379. initPollutionChart();
  380. initNutrientChart();
  381. initExtraChart();
  382. isLoading.value = false;
  383. } catch (err) {
  384. isLoading.value = false;
  385. error.value = err;
  386. console.error('初始化失败:', err);
  387. }
  388. };
  389. // 组件挂载
  390. onMounted(() => {
  391. initCharts();
  392. // 窗口resize处理
  393. const handleResize = () => {
  394. [chartInstanceCd, chartInstanceNutrient, chartInstanceExtra]
  395. .forEach(inst => inst && inst.resize());
  396. };
  397. window.addEventListener('resize', handleResize);
  398. // 组件卸载清理
  399. return () => {
  400. window.removeEventListener('resize', handleResize);
  401. [chartInstanceCd, chartInstanceNutrient, chartInstanceExtra]
  402. .forEach(inst => inst && inst.dispose());
  403. };
  404. });
  405. // 监听语言变化
  406. watch(locale, () => {
  407. console.log('语言切换,重新初始化图表');
  408. initCharts();
  409. });
  410. </script>
  411. <style>
  412. .crop-cd-dashboard {
  413. font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
  414. max-width: 1200px;
  415. margin: 0 auto;
  416. font-size: 14px;
  417. }
  418. .chart-container {
  419. background: white;
  420. border-radius: 6px;
  421. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  422. padding: 12px;
  423. margin-bottom: 16px;
  424. }
  425. .section-title {
  426. color: #2c3e50;
  427. border-left: 3px solid #3498db;
  428. padding-left: 10px;
  429. margin-bottom: 12px;
  430. }
  431. .toggle-btn {
  432. background: #f8f9fa;
  433. border: 1px solid #e9ecef;
  434. padding: 6px 12px;
  435. border-radius: 3px;
  436. cursor: pointer;
  437. display: inline-flex;
  438. align-items: center;
  439. transition: all 0.3s;
  440. }
  441. .toggle-btn:hover {
  442. background: #e9ecef;
  443. }
  444. .loading-overlay {
  445. position: absolute;
  446. top: 0;
  447. left: 0;
  448. right: 0;
  449. bottom: 0;
  450. background: rgba(255, 255, 255, 0.8);
  451. display: flex;
  452. align-items: center;
  453. justify-content: center;
  454. z-index: 10;
  455. }
  456. .spinner {
  457. width: 30px;
  458. height: 30px;
  459. border: 3px solid rgba(0, 0, 0, 0.1);
  460. border-radius: 50%;
  461. border-left-color: #3498db;
  462. animation: spin 1s linear infinite;
  463. }
  464. @keyframes spin { to { transform: rotate(360deg); } }
  465. .stat-card {
  466. background: linear-gradient(135deg, #f5f7fa 0%, #e4edf5 100%);
  467. border-radius: 6px;
  468. padding: 8px 12px;
  469. box-shadow: 0 1px 3px rgba(0,0,0,0.05);
  470. }
  471. .stat-value {
  472. font-size: 16px;
  473. font-weight: bold;
  474. color: #2c3e50;
  475. }
  476. .stat-label {
  477. font-size: 12px;
  478. color: #7f8c8d;
  479. }
  480. .legend-item {
  481. display: flex;
  482. align-items: center;
  483. margin-right: 12px;
  484. font-size: 13px;
  485. margin-bottom: 8px;
  486. }
  487. .legend-color {
  488. width: 10px;
  489. height: 10px;
  490. border-radius: 50%;
  491. margin-right: 5px;
  492. }
  493. </style>