waterassaydata2.vue 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. <template>
  2. <div class="region-average-chart">
  3. <div ref="chartRef" class="chart-box"></div>
  4. <div v-if="loading" class="status">数据加载中...</div>
  5. <div v-else-if="error" class="status error">{{ error }}</div>
  6. </div>
  7. </template>
  8. <!--各地区的重金属平均值得柱状图-->
  9. <script setup>
  10. import { ref, onMounted, onUnmounted } from 'vue';
  11. import * as echarts from 'echarts';
  12. import { api8000 } from '@/utils/request'; // 导入 api8000 实例
  13. // ========== 接口配置(使用 api8000 实例) ==========
  14. const TABLE_NAME = 'water_sampling_data';
  15. // ========== 配置项(调整字段适配新接口) ==========
  16. // 排除的非重金属字段(适配新接口字段名)
  17. const EXCLUDE_FIELDS = [
  18. 'id', 'sample_code', 'assayer_id', 'assay_time',
  19. 'assay_instrument_model', 'sample_number', 'ph_value',
  20. 'latitude', 'longitude', 'sampling_location', 'sampling_time' // 新增采样相关非检测字段
  21. ];
  22. const COLORS = ['#ff4d4f99', '#1890ff', '#ffd700', '#52c41a88', '#722ed199' ];
  23. // 韶关市下属行政区划白名单(保持不变)
  24. const SG_REGIONS = [
  25. '浈江区', '武江区', '曲江区', '乐昌市',
  26. '南雄市', '始兴县', '仁化县', '翁源县',
  27. '新丰县', '乳源瑶族自治县'
  28. ];
  29. // ========== 响应式数据 ==========
  30. const chartRef = ref(null);
  31. const loading = ref(true);
  32. const error = ref('');
  33. let myChart = null;
  34. // ========== 地区提取函数(保持不变) ==========
  35. const extractRegion = (location) => {
  36. if (!location || typeof location !== 'string') return null;
  37. // 1. 精确匹配官方区县名称
  38. const officialMatch = SG_REGIONS.find(region =>
  39. location.includes(region)
  40. );
  41. if (officialMatch) return officialMatch;
  42. // 2. 处理嵌套格式(如"韶关市-浈江区")
  43. const nestedMatch = location.match(/(韶关市)([^市]+?[区市县])/);
  44. if (nestedMatch && nestedMatch[2]) {
  45. const region = nestedMatch[2].replace("韶关市", "").trim();
  46. const validRegion = SG_REGIONS.find(r => r.includes(region));
  47. if (validRegion) return validRegion;
  48. }
  49. // 3. 特殊格式处理(如"韶关市浈江区")
  50. const shortMatch = location.match(/韶关市([区市县][^市]{2,5})/);
  51. if (shortMatch && shortMatch[1]) return shortMatch[1];
  52. // 4. 修正常见拼写错误
  53. if (location.includes('乐昌')) return '乐昌市';
  54. if (location.includes('乳源')) return '乳源瑶族自治县';
  55. console.warn(`⚠️ 未识别地区: ${location}`);
  56. return '未知区县';
  57. };
  58. // ========== 数据处理流程(适配新接口单数据源) ==========
  59. const processData = (allData) => {
  60. // 1. 构建采样点ID到区县的映射(sample_number对应新接口的水样ID)
  61. const regionMap = new Map();
  62. allData.forEach(item => {
  63. const region = extractRegion(item.sampling_location || '');
  64. if (region && region !== '未知区县' && item.sample_number) {
  65. regionMap.set(item.sample_number, region);
  66. }
  67. });
  68. // 2. 关联重金属数据与区县(单条数据已包含所有信息)
  69. const mergedData = allData.map(item => ({
  70. ...item,
  71. // 通过sample_number关联区县
  72. region: regionMap.get(item.sample_number) || '未知区县'
  73. }));
  74. // 3. 识别重金属字段(新接口字段如cr_concentration、as_concentration等)
  75. const metals = Object.keys(mergedData[0] || {})
  76. .filter(key =>
  77. !EXCLUDE_FIELDS.includes(key) && // 排除非重金属字段
  78. !isNaN(parseFloat(mergedData[0][key])) && // 确保是数值
  79. key.includes('concentration') // 新接口重金属字段含concentration
  80. );
  81. // 4. 按区县分组统计
  82. const regionGroups = {};
  83. const cityWideAverages = {}; // 全市平均值
  84. const uniqueSampleIds = new Set();
  85. // 初始化统计计数器
  86. metals.forEach(metal => {
  87. cityWideAverages[metal] = { sum: 0, count: 0 };
  88. });
  89. mergedData.forEach(item => {
  90. const region = item.region;
  91. if (item.sample_number) {
  92. uniqueSampleIds.add(item.sample_number);
  93. }
  94. // 初始化区县分组
  95. if (!regionGroups[region]) {
  96. regionGroups[region] = {};
  97. metals.forEach(metal => {
  98. regionGroups[region][metal] = { sum: 0, count: 0 };
  99. });
  100. }
  101. // 统计各重金属含量
  102. metals.forEach(metal => {
  103. const val = parseFloat(item[metal]);
  104. if (!isNaN(val)) {
  105. // 区县统计
  106. regionGroups[region][metal].sum += val;
  107. regionGroups[region][metal].count++;
  108. // 全市统计
  109. cityWideAverages[metal].sum += val;
  110. cityWideAverages[metal].count++;
  111. }
  112. });
  113. });
  114. const totalSamples = uniqueSampleIds.size;
  115. // 5. 按官方顺序排序区县
  116. const regions = SG_REGIONS.filter(region => regionGroups[region]);
  117. // 6. 添加"全市平均"作为最后一个类别
  118. regions.push("全市平均");
  119. // 7. 构建ECharts数据(处理重金属字段名显示)
  120. const series = metals.map((metal, idx) => {
  121. // 格式化重金属名称(如cr_concentration → Cr)
  122. const prefix = metal.split('_')[0]; // 先获取前缀
  123. const metalName = prefix
  124. ? prefix[0].toUpperCase() + prefix.slice(1) // 首字母大写 + 剩余字符
  125. : ''; // 处理空字符串情况
  126. // 计算全市平均值
  127. const cityWideAvg = cityWideAverages[metal].count
  128. ? (cityWideAverages[metal].sum / cityWideAverages[metal].count).toFixed(2)
  129. : 0;
  130. return {
  131. name: metalName, // 显示简化名称(如Cr、As)
  132. type: 'bar',
  133. data: regions.map(region => {
  134. if (region === "全市平均") {
  135. return cityWideAvg;
  136. }
  137. const group = regionGroups[region][metal];
  138. return group.count ? (group.sum / group.count).toFixed(2) : 0;
  139. }),
  140. itemStyle: {
  141. color: COLORS[idx % COLORS.length],
  142. },
  143. label: {
  144. show: true,
  145. position: 'top',
  146. fontSize: 15,
  147. color: '#333',
  148. }
  149. };
  150. });
  151. return { regions, series, totalSamples };
  152. };
  153. // ========== ECharts 初始化(保持不变) ==========
  154. const initChart = ({ regions, series, totalSamples }) => {
  155. if (!chartRef.value) return;
  156. if (myChart) myChart.dispose();
  157. myChart = echarts.init(chartRef.value);
  158. const option = {
  159. title: {
  160. text: '各地区重金属含量平均值',
  161. left: 'center',
  162. subtext: `数据来源: ${totalSamples}个有效检测样本`,
  163. subtextStyle: {
  164. fontSize: 15
  165. }
  166. },
  167. tooltip: {
  168. trigger: 'axis',
  169. formatter: params => {
  170. const regionName = params[0].name;
  171. const isCityWide = regionName === "全市平均";
  172. let content = `${isCityWide ? "全市平均值" : regionName}:`;
  173. if (isCityWide) {
  174. content += `<br><span style="color: #666;">(基于${totalSamples}个样本计算)</span>`;
  175. }
  176. return content + params.map(p => `<br>${p.seriesName}: ${p.value} ug/L`).join('');
  177. },
  178. textStyle: {
  179. fontSize: 15
  180. }
  181. },
  182. xAxis: {
  183. type: 'category',
  184. data: regions,
  185. axisLabel: {
  186. rotate: 45,
  187. formatter: val => val.replace('韶关市', ''),
  188. fontSize: 15
  189. }
  190. },
  191. yAxis: {
  192. type: 'value',
  193. name: '浓度(ug/L)',
  194. nameTextStyle: {
  195. fontSize: 15,
  196. },
  197. axisLabel: {
  198. fontSize: 15,
  199. }
  200. },
  201. dataZoom: [{
  202. type: 'inside',
  203. start: 0,
  204. end: 100
  205. }],
  206. series,
  207. legend: { //图例
  208. data: series.map(s => s.name),
  209. top:'10%',
  210. right:'5%',
  211. textStyle: {
  212. fontSize: 15
  213. }
  214. },
  215. grid: { //整个图的位置
  216. left: '3%',
  217. right: '3%',
  218. bottom: '5%',
  219. containLabel: true
  220. },
  221. };
  222. myChart.setOption(option);
  223. };
  224. // ========== 生命周期钩子(使用 api8000 实例) ==========
  225. onMounted(async () => {
  226. try {
  227. loading.value = true;
  228. error.value = '';
  229. // 使用 api8000 实例获取数据
  230. const response = await api8000.get(`/api/vector/export/all?table_name=${TABLE_NAME}`);
  231. // 处理可能的字符串响应
  232. let data = response.data;
  233. if (typeof data === 'string') {
  234. try {
  235. // 替换 NaN 为 null
  236. const cleanedData = data.replace(/\bNaN\b/g, 'null');
  237. data = JSON.parse(cleanedData);
  238. } catch (parseErr) {
  239. throw new Error('接口返回的是字符串,但 JSON 解析失败');
  240. }
  241. }
  242. // 处理对象中的 NaN 值
  243. if (typeof data === 'object' && data !== null) {
  244. const replaceNaN = (obj) => {
  245. for (const key in obj) {
  246. if (typeof obj[key] === 'object' && obj[key] !== null) {
  247. replaceNaN(obj[key]);
  248. } else if (typeof obj[key] === 'number' && isNaN(obj[key])) {
  249. obj[key] = null;
  250. } else if (obj[key] === 'NaN') {
  251. obj[key] = null;
  252. }
  253. }
  254. };
  255. replaceNaN(data);
  256. }
  257. // 接口返回格式判断(GeoJSON或直接数组)
  258. const allData = data.features
  259. ? data.features.map(f => f.properties)
  260. : data;
  261. // 处理数据并初始化图表
  262. initChart(processData(allData));
  263. } catch (err) {
  264. error.value = '数据加载失败: ' + (err.message || '未知错误');
  265. console.error('接口错误:', err);
  266. } finally {
  267. loading.value = false;
  268. }
  269. });
  270. // 响应式布局(保持不变)
  271. const resizeHandler = () => myChart && myChart.resize();
  272. onMounted(() => window.addEventListener('resize', resizeHandler));
  273. onUnmounted(() => {
  274. window.removeEventListener('resize', resizeHandler);
  275. if (myChart) myChart.dispose();
  276. });
  277. </script>
  278. <style scoped>
  279. .region-average-chart {
  280. width: 100%;
  281. height: 500px;
  282. max-width: 1200px;
  283. margin: 0 auto;
  284. position: relative;
  285. }
  286. .chart-box {
  287. width: 100%;
  288. height: 500px;
  289. min-height: 400px;
  290. background-color: white;
  291. border-radius: 8px;
  292. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  293. }
  294. .status {
  295. position: absolute;
  296. top: 50%;
  297. left: 50%;
  298. transform: translate(-50%, -50%);
  299. padding: 15px;
  300. background: rgba(255,255,255,0.8);
  301. border-radius: 4px;
  302. font-size: 16px;
  303. z-index: 10;
  304. }
  305. .error {
  306. color: #ff4d4f;
  307. font-weight: bold;
  308. }
  309. </style>