irrigationstatistics.vue 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. <template>
  2. <div class="boxplot-container">
  3. <div class="chart-container">
  4. <div class="header">
  5. <div class="chart-title">灌溉水重金属浓度统计箱线图</div>
  6. <p>展示各重金属浓度的分布特征(最小值、四分位数、中位数、最大值)</p>
  7. <p class="sample-subtitle">样本来源:{{ totalPoints }}个数据</p>
  8. </div>
  9. <div v-if="isLoading" class="loading-state">
  10. <span class="spinner"></span> 数据加载中...
  11. </div>
  12. <div v-else-if="error" class="error-state">
  13. ❌ 加载失败:{{ error.message }}
  14. </div>
  15. <div v-else>
  16. <div class="chart-wrapper">
  17. <v-chart :option="chartOption" autoresize />
  18. </div>
  19. </div>
  20. </div>
  21. </div>
  22. </template>
  23. <script>
  24. import * as echarts from 'echarts'
  25. import VChart from 'vue-echarts'
  26. import { api8000 } from '@/utils/request' // 导入 api8000 实例
  27. import { ref, onMounted ,computed} from 'vue'
  28. export default {
  29. components: { VChart },
  30. setup() {
  31. // -------- 基本状态 --------
  32. const apiUrl = ref('/api/vector/export/all?table_name=water_sampling_data') // 使用相对路径
  33. const apiTimestamp = ref(null)
  34. const sampleCount = ref(0)
  35. const statsData = ref({});
  36. const chartOption = ref({})
  37. const isLoading = ref(true)
  38. const error = ref(null)
  39. const stats = null;
  40. const totalPoints = computed(() => {
  41. const firstMetalKey = heavyMetals[0]?.key;
  42. return statsData.value[firstMetalKey]?.count || 0;
  43. })
  44. // 关键:缓存每个品类的统计量(与 x 轴顺序一致)
  45. const statsByIndex = ref([])
  46. // -------- 配置:金属字段(保持你原样) --------
  47. const heavyMetals = [
  48. { key: 'cr_concentration', name: '铬 (Cr)', color: '#FF9800' },
  49. { key: 'as_concentration', name: '砷 (As)', color: '#4CAF50' },
  50. { key: 'cd_concentration', name: '镉 (Cd)', color: '#9C27B0' },
  51. { key: 'hg_concentration', name: '汞 (Hg)', color: '#2196F3' },
  52. { key: 'pb_concentration', name: '铅 (Pb)', color: '#F44336' }
  53. ]
  54. // -------- 构建箱线数据(保留你自己的顺序) --------
  55. const buildBoxplotData = () => {
  56. const xAxisData = heavyMetals.map(m => m.name); // 保持x轴顺序与配置一致
  57. // 缓存每个重金属的统计量(用于tooltip)
  58. statsByIndex.value = heavyMetals.map(metal => {
  59. const stat = statsData.value[metal.key] || {}; // 从接口数据中取当前金属的统计
  60. return {
  61. key: metal.key,
  62. name: metal.name,
  63. min: stat.min,
  64. q1: stat.q1,
  65. median: stat.median,
  66. q3: stat.q3,
  67. max: stat.max,
  68. color: metal.color
  69. };
  70. });
  71. // 构建ECharts箱线图所需的二维数组格式 [[min, q1, median, q3, max], ...]
  72. const data = statsByIndex.value.map(s => {
  73. if (s.min === undefined || s.min === null) {
  74. return [null, null, null, null, null]; // 无数据时返回空数组
  75. }
  76. return [s.min, s.q1, s.median, s.q3, s.max];
  77. });
  78. return { xAxisData, data };
  79. };
  80. // -------- 初始化图表 --------
  81. const initChart = () => {
  82. const { xAxisData, data } = buildBoxplotData(stats)
  83. chartOption.value = {
  84. tooltip: {
  85. trigger: 'item',
  86. // 关键:不从 params.data 取,直接读我们缓存的原始统计值,彻底避免被内部处理影响
  87. formatter: (params) => {
  88. const s = statsByIndex.value[params.dataIndex]
  89. if (!s || s.min === null) {
  90. return `<div style="font-weight:bold;color:#f56c6c">${xAxisData[params.dataIndex]}</div><div>无有效数据</div>`
  91. }
  92. return `<div style="font-weight:bold">${xAxisData[params.dataIndex]}</div>
  93. <div style="margin-top:8px">
  94. <div>最小值:<span style="color:#5a5;">${s.min.toFixed(4)}</span></div>
  95. <div>下四分位:<span style="color:#d87a80;">${s.q1.toFixed(4)}</span></div>
  96. <div>中位数:<span style="color:#f56c6c;font-weight:bold;">${s.median.toFixed(4)}</span></div>
  97. <div>上四分位:<span style="color:#d87a80;">${s.q3.toFixed(4)}</span></div>
  98. <div>最大值:<span style="color:#5a5;">${s.max.toFixed(4)}</span></div>
  99. </div>`
  100. },
  101. },
  102. xAxis: {
  103. type: 'category',
  104. data: xAxisData,
  105. name: '重金属类型',
  106. nameLocation: 'middle',
  107. nameGap: 30,
  108. axisLabel: { color: '#555', rotate: 0, fontWeight: 'bold' ,fontSize :11}
  109. },
  110. yAxis: {
  111. type: 'value',
  112. name: 'ug/L',
  113. nameTextStyle: { fontSize: 12 },
  114. nameLocation: 'end',
  115. nameGap: 8,
  116. axisLabel: { color: '#555', fontWeight: 'bold',fontSize:11 },
  117. splitLine: { lineStyle: { color: '#f0f0f0' } }
  118. },
  119. series: [{
  120. name: '重金属浓度分布',
  121. type: 'boxplot',
  122. data,
  123. itemStyle: {
  124. color: (p) => (heavyMetals[p.dataIndex]?.color || '#1890ff'),
  125. borderWidth: 2
  126. },
  127. emphasis: {
  128. itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.2)', borderWidth: 3 }
  129. }
  130. }],
  131. grid: { top: '10%', right: '3%', left: '6%', bottom: '10%' }
  132. }
  133. isLoading.value = false
  134. }
  135. // -------- 拉取接口并绘图 --------
  136. onMounted(async () => {
  137. try {
  138. //log('发起API请求...')
  139. const response = await axios.get(apiUrl.value);
  140. statsData.value = response.data;
  141. apiTimestamp.value = new Date().toLocaleString()
  142. initChart()
  143. } catch (err) {
  144. error.value = err
  145. isLoading.value = false
  146. console.error('接口请求失败:', err)
  147. }
  148. })
  149. return {
  150. apiUrl,
  151. apiTimestamp,
  152. sampleCount,
  153. chartOption,
  154. isLoading,
  155. error,
  156. totalPoints
  157. }
  158. }
  159. }
  160. </script>
  161. <style scoped>
  162. .boxplot-container {
  163. width: 100%;
  164. height: 300px;
  165. max-width: 1000px;
  166. margin: 0 auto;
  167. }
  168. .header {
  169. text-align: left;
  170. margin-bottom: 10px;
  171. }
  172. .header h2 {
  173. font-size: 0.6rem;
  174. color: #333;
  175. margin-bottom: 4px;
  176. }
  177. .header p {
  178. font-size: 0.6rem;
  179. color: #666;
  180. margin: 0;
  181. }
  182. .loading-state {
  183. text-align: center;
  184. padding: 40px 0;
  185. color: #666;
  186. }
  187. .loading-state .spinner {
  188. display: inline-block; width: 24px; height: 24px; margin-right: 8px;
  189. border: 3px solid #ccc; border-top-color: #1890ff; border-radius: 50%;
  190. animation: spin 1s linear infinite;
  191. }
  192. @keyframes spin { to { transform: rotate(360deg); } }
  193. .error-state { text-align: center; padding: 40px 0; color: #f56c6c; }
  194. .chart-wrapper { width: 100%; height: 220px; }
  195. .chart-container {
  196. background: white;
  197. border-radius: 12px;
  198. box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
  199. margin-bottom: 25px;
  200. height: 100%;
  201. box-sizing: border-box;
  202. }
  203. .chart-header {
  204. display: flex;
  205. justify-content: space-between;
  206. align-items: flex-start; /**与顶部对齐 */
  207. margin-bottom: 15px;
  208. }
  209. .chart-title {
  210. font-size: 14px;
  211. color: #2980b9;
  212. font-weight: 600;
  213. }
  214. .title-group {
  215. display: flex;
  216. align-items: left;
  217. }
  218. .sample-subtitle {
  219. font-size: 0.85rem;
  220. color: #888;
  221. margin-top: 4px;
  222. }
  223. </style>