TotalIntroduction.vue 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383
  1. <script setup>
  2. // 安全区间 (0.0-0.2 mg/kg)
  3. // 预警区间 (0.2-0.3 mg/kg)
  4. // 超标区间 (≥0.3 mg/kg)
  5. import { ref, watch, onMounted, onBeforeUnmount, onUnmounted ,computed,nextTick} from 'vue'
  6. import L from 'leaflet'
  7. import 'leaflet/dist/leaflet.css'
  8. import { api8000 } from '@/utils/request'
  9. const samplePointsData = ref([])
  10. const cdSampleData = ref([]) // 第二个地图的 CD 数据
  11. const mapRef = ref(null)
  12. const mapRef2 = ref(null) // 第二个地图
  13. let map = null
  14. let map2 = null // 第二个地图实例
  15. let wmsLayer = null
  16. let wmsLayer2 = null // 第二个地图的 WMS 图层
  17. let geoJsonLayer = null
  18. // 统计信息
  19. const statistics = ref({
  20. totalBlocks: 0,
  21. avgPH: 0,
  22. strongAcidCount: 0,
  23. mildAcidCount: 0,
  24. normalCount: 0,
  25. maxPH: 0,
  26. minPH: 0,
  27. strongAcidArea: 0,
  28. mildAcidArea: 0,
  29. normalArea: 0,
  30. totalArea: 0,
  31. strongAcidPercent: 0,
  32. mildAcidPercent: 0,
  33. normalPercent: 0
  34. })
  35. // pH 分布个数计算
  36. const phDistribution = ref({
  37. range1: 0,
  38. range2: 0,
  39. range3: 0,
  40. })
  41. // 第二个地图的 CD 含量统计
  42. const cdStatistics = ref({
  43. totalBlocks: 0,
  44. avgCD: 0,
  45. safeCount: 0, // 安全区间 (0.0-0.2 mg/kg)
  46. warningCount: 0, // 预警区间 (0.2-0.3 mg/kg)
  47. exceedCount: 0, // 超标区间 (≥0.3 mg/kg)
  48. maxCD: 0,
  49. minCD: 0
  50. })
  51. // CD 含量分布个数计算
  52. const cdDistribution = ref({
  53. safe: 0, // 安全
  54. warning: 0, // 预警
  55. exceed: 0 // 超标
  56. })
  57. const selectedPoint = ref(null)
  58. // 地图配置
  59. const CONFIG = {
  60. center:[25.222903, 113.25383],
  61. zoom:10, // 调小缩放级别,显示更大范围
  62. // 获取所有点的 API 地址
  63. getPoint:'/api/vector/export/all?table_name=le_data_block_map&format=geojson',
  64. geoserver:{
  65. url:'/geoserver',
  66. workspace:'acidmap',
  67. layerGroup:'leshujukanbanmap',
  68. dataLayer:'le_data_block_map',
  69. wmsUrl:'/geoserver/acidmap/wms',
  70. phField: 'ph_mean'
  71. },
  72. reboundGeoserver:{
  73. url:'/geoserver',
  74. workspace:'acidmap',
  75. layerGroup:'le_reflux_map',
  76. dataLayer:'le_data_reflux_result',
  77. wmsUrl:'/geoserver/acidmap/wms',
  78. phField: 'le_data__4'
  79. }
  80. }
  81. // 图层配置已验证:正常地图使用 le_data_block_map,反酸地图使用 le_data_reflux_result
  82. const currentMapType = ref('normal')
  83. // 获取 pH 等级对应的 CSS 类
  84. function getPHLevelClass(ph) {
  85. // ✅ 先转换为数字
  86. const numericPh = typeof ph === 'string' ? parseFloat(ph) : ph
  87. if (!numericPh || numericPh === 0) return ''
  88. if (numericPh <= 5.2) return 'danger'
  89. if (numericPh < 6.0) return 'warning'
  90. return 'success'
  91. }
  92. // 获取综合评语
  93. function getPHComment(avgPH) {
  94. if (avgPH <= 4.5) return '🔴 土壤严重酸化,需立即治理!'
  95. if (avgPH <= 5.2) return '🟠 土壤酸化明显,建议尽快改良'
  96. if (avgPH < 6.0) return '🟡 土壤微酸性,注意保持'
  97. return '🟢 土壤酸碱度适宜,状态良好'
  98. }
  99. function getPHRangeLabel(range) {
  100. const labels = {
  101. range1: '< 5.2',
  102. range2: '5.2 - 6.0',
  103. range3: ' >6.0 ',
  104. }
  105. return labels[range] || range
  106. }
  107. // 添加分页加载支持
  108. const batchSize = 1000;
  109. let allFeatures = [];
  110. async function initMap(){
  111. await nextTick()
  112. if(!mapRef.value) return
  113. map = L.map(mapRef.value).setView(CONFIG.center,CONFIG.zoom)
  114. wmsLayer = L.tileLayer.wms(CONFIG.geoserver.wmsUrl, {
  115. layers: `${CONFIG.geoserver.workspace}:${CONFIG.geoserver.layerGroup}`,
  116. format: 'image/png',
  117. transparent: true,
  118. version: '1.1.0',
  119. srs:'EPSG:4326',
  120. attribution: '© GeoServer - Acidmap'
  121. }).addTo(map)
  122. }
  123. async function initMap2(){
  124. await nextTick()
  125. if(!mapRef2.value) return
  126. map2 = L.map(mapRef2.value).setView(CONFIG.center,CONFIG.zoom)
  127. // 第二个地图展示 CropCd_block_map_with_boundary 图层
  128. wmsLayer2 = L.tileLayer.wms(CONFIG.geoserver.wmsUrl, {
  129. layers: `${CONFIG.geoserver.workspace}:CropCd_block_map_with_boundary`,
  130. format: 'image/png',
  131. transparent: true,
  132. version: '1.1.0',
  133. srs:'EPSG:4326',
  134. attribution: '© GeoServer - Crop CD'
  135. }).addTo(map2)
  136. }
  137. async function switchMap(mapType) {
  138. if (!map || !wmsLayer) return
  139. currentMapType.value = mapType
  140. const config = mapType === 'rebound' ? CONFIG.reboundGeoserver : CONFIG.geoserver
  141. // 移除旧的 WMS 图层
  142. map.removeLayer(wmsLayer)
  143. // 创建新的 WMS 图层
  144. wmsLayer = L.tileLayer.wms(config.wmsUrl, {
  145. layers: `${config.workspace}:${config.layerGroup}`,
  146. format: 'image/png',
  147. transparent: true,
  148. version: '1.1.0',
  149. srs:'EPSG:4326',
  150. attribution: '© GeoServer - Acidmap'
  151. }).addTo(map)
  152. // console.log(`地图已切换到:${mapType === 'rebound' ? '反酸一周期后' : '实施降酸措施'}`)
  153. // console.log('新图层:', `${config.workspace}:${config.layerGroup}`)
  154. // 重新加载对应地图的数据和统计
  155. await loadMapData()
  156. }
  157. // 加载当前地图类型的数据
  158. async function loadMapData() {
  159. try {
  160. const config = currentMapType.value === 'rebound' ? CONFIG.reboundGeoserver : CONFIG.geoserver
  161. const url = `${config.url}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${config.workspace}:${config.dataLayer}&outputFormat=application/json`
  162. // console.log('🔍 请求 WFS 数据:', url);
  163. const response = await fetch(url);
  164. if (!response.ok) {
  165. const errorText = await response.text();
  166. console.error('❌ WFS 响应错误:', errorText);
  167. throw new Error(`HTTP error! status: ${response.status}`);
  168. }
  169. const text = await response.text();
  170. // console.log('📦 原始响应:', text.substring(0, 200));
  171. // 尝试解析 JSON
  172. let geoJsonData;
  173. try {
  174. geoJsonData = JSON.parse(text);
  175. } catch (parseError) {
  176. console.error('❌ JSON 解析失败,响应内容:', text);
  177. throw new Error('GeoServer 返回的数据格式不正确,不是有效的 JSON');
  178. }
  179. // console.log('✅ 获取到地图数据:', geoJsonData.features?.length || 0, '条记录');
  180. // 保存数据用于统计和交互
  181. allFeatures = geoJsonData.features;
  182. samplePointsData.value = geoJsonData.features;
  183. // 计算统计信息
  184. calculatePHDistribution(allFeatures);
  185. loadStatistics();
  186. } catch (err) {
  187. console.error('❌ 加载数据失败:', err);
  188. }
  189. }
  190. function createPointIcon(feature) {
  191. const ph = feature.properties.ph || feature.properties.value
  192. let color = '#22c55e'
  193. if (ph <= 5.2) color = '#ef4444'
  194. else if (ph < 6.0) color = '#f59e0b'
  195. return L.divIcon({
  196. className: 'custom-marker',
  197. html: `<div style="
  198. background-color: ${color};
  199. width: 12px;
  200. height: 12px;
  201. border-radius: 50%;
  202. border: 2px solid white;
  203. box-shadow: 0 2px 4px rgba(0,0,0,0.3);
  204. "></div>`,
  205. iconSize: [12, 12],
  206. iconAnchor: [6, 6]
  207. })
  208. }
  209. function parsePHValue(phValue) {
  210. if (!phValue && phValue !== 0) return null
  211. const numericPh = typeof phValue === 'string' ? parseFloat(phValue) : phValue
  212. return !isNaN(numericPh) && numericPh > 0 ? numericPh : null
  213. }
  214. async function loadStatistics() {
  215. try {
  216. // 等待数据加载完成
  217. if (samplePointsData.value.length === 0) return;
  218. // 根据当前地图类型选择 pH 字段
  219. const config = currentMapType.value === 'rebound' ? CONFIG.reboundGeoserver : CONFIG.geoserver
  220. let phCount = 0
  221. let avgPH = 0
  222. let strongAcidCount = 0
  223. let mildAcidCount = 0
  224. let normalCount = 0
  225. let maxPH = -Infinity
  226. let minPH = Infinity
  227. let strongAcidArea = 0
  228. let mildAcidArea = 0
  229. let normalArea = 0
  230. let totalArea = 0
  231. samplePointsData.value.forEach(feature => {
  232. const numericPh = parsePHValue(feature.properties[config.phField])
  233. const area = parseFloat(feature.properties.area) || 0
  234. // 累加总面积(所有地块)
  235. if (area > 0) {
  236. totalArea += area
  237. }
  238. // 过滤无效 pH 值,只统计有数据的地块
  239. if (numericPh && numericPh > 0 && !isNaN(numericPh)) {
  240. phCount++
  241. const delta = numericPh - avgPH;
  242. avgPH = avgPH + delta / phCount;
  243. maxPH = Math.max(maxPH, numericPh)
  244. minPH = Math.min(minPH, numericPh)
  245. if (numericPh <= 5.2) {
  246. strongAcidCount++
  247. strongAcidArea += area
  248. }
  249. else if (numericPh < 6.0) {
  250. mildAcidCount++
  251. mildAcidArea += area
  252. }
  253. else {
  254. normalCount++
  255. normalArea += area
  256. }
  257. }
  258. });
  259. // 计算有 pH 数据的地块总面积(用于占比计算)
  260. const totalPHArea = strongAcidArea + mildAcidArea + normalArea;
  261. // 计算面积占比(基于有 pH 数据的地块)
  262. const strongAcidPercent = totalPHArea > 0 ? parseFloat(((strongAcidArea / totalPHArea) * 100).toFixed(2)) : 0
  263. const mildAcidPercent = totalPHArea > 0 ? parseFloat(((mildAcidArea / totalPHArea) * 100).toFixed(2)) : 0
  264. const normalPercent = totalPHArea > 0 ? parseFloat(((normalArea / totalPHArea) * 100).toFixed(2)) : 0
  265. statistics.value = {
  266. totalBlocks: samplePointsData.value.length,
  267. avgPH: phCount > 0 ? parseFloat(avgPH.toFixed(2)) : 0, // ✅ 确保是数字
  268. strongAcidCount,
  269. mildAcidCount,
  270. normalCount,
  271. maxPH: maxPH === -Infinity ? 0 : parseFloat(maxPH.toFixed(2)),
  272. minPH: minPH === Infinity ? 0 : parseFloat(minPH.toFixed(2)),
  273. strongAcidArea: parseFloat(strongAcidArea.toFixed(2)),
  274. mildAcidArea: parseFloat(mildAcidArea.toFixed(2)),
  275. normalArea: parseFloat(normalArea.toFixed(2)),
  276. totalArea: parseFloat(totalArea.toFixed(2)),
  277. strongAcidPercent,
  278. mildAcidPercent,
  279. normalPercent
  280. };
  281. // console.log('✅ 统计数据加载完成:', statistics.value);
  282. } catch (err) {
  283. console.error('加载统计数据失败:', err);
  284. }
  285. }
  286. // 加载第二个地图的 CD 含量统计数据
  287. async function loadCDStatistics() {
  288. try {
  289. // 等待数据加载完成
  290. if (cdSampleData.value.length === 0) return;
  291. let cdCount = 0
  292. let avgCD = 0
  293. let safeCount = 0 // 0.0-0.2 mg/kg
  294. let warningCount = 0 // 0.2-0.3 mg/kg
  295. let exceedCount = 0 // ≥0.3 mg/kg
  296. let maxCD = -Infinity
  297. let minCD = Infinity
  298. cdSampleData.value.forEach(feature => {
  299. // 假设 CD 含量字段为 CropCd_mea
  300. const cdValue = feature.properties.CropCd_mea
  301. const numericCd = typeof cdValue === 'string' ? parseFloat(cdValue) : cdValue
  302. if (numericCd !== null && numericCd !== undefined && !isNaN(numericCd) && numericCd >= 0) {
  303. cdCount++
  304. const delta = numericCd - avgCD;
  305. avgCD = avgCD + delta / cdCount;
  306. maxCD = Math.max(maxCD, numericCd)
  307. minCD = Math.min(minCD, numericCd)
  308. // 根据规则分类
  309. if (numericCd < 0.2) {
  310. safeCount++
  311. }
  312. else if (numericCd < 0.3) {
  313. warningCount++
  314. }
  315. else {
  316. exceedCount++
  317. }
  318. }
  319. });
  320. cdStatistics.value = {
  321. totalBlocks: cdSampleData.value.length,
  322. avgCD: cdCount > 0 ? parseFloat(avgCD.toFixed(3)) : 0,
  323. safeCount,
  324. warningCount,
  325. exceedCount,
  326. maxCD: maxCD === -Infinity ? 0 : parseFloat(maxCD.toFixed(3)),
  327. minCD: minCD === Infinity ? 0 : parseFloat(minCD.toFixed(3))
  328. };
  329. // 计算 CD 含量分布
  330. cdDistribution.value = {
  331. safe: safeCount,
  332. warning: warningCount,
  333. exceed: exceedCount
  334. };
  335. // console.log('✅ CD 统计数据加载完成:', cdStatistics.value);
  336. } catch (err) {
  337. console.error('加载 CD 统计数据失败:', err);
  338. }
  339. }
  340. // 加载第二个地图的 CD 数据
  341. async function loadCDData() {
  342. try {
  343. const url = `${CONFIG.geoserver.url}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${CONFIG.geoserver.workspace}:Crop_cd_block_map&outputFormat=application/json`
  344. const response = await fetch(url);
  345. if (!response.ok) {
  346. const errorText = await response.text();
  347. console.error('❌ WFS 响应错误:', errorText);
  348. throw new Error(`HTTP error! status: ${response.status}`);
  349. }
  350. const text = await response.text();
  351. // 尝试解析 JSON
  352. let geoJsonData;
  353. try {
  354. geoJsonData = JSON.parse(text);
  355. } catch (parseError) {
  356. console.error('❌ JSON 解析失败,响应内容:', text);
  357. throw new Error('GeoServer 返回的数据格式不正确,不是有效的 JSON');
  358. }
  359. // 保存 CD 数据
  360. cdSampleData.value = geoJsonData.features;
  361. // 计算 CD 统计信息
  362. loadCDStatistics();
  363. } catch (err) {
  364. console.error('❌ 加载 CD 数据失败:', err);
  365. }
  366. }
  367. // 修改为只获取数据用于统计,不渲染到地图
  368. async function fetchDataForStatistics() {
  369. try {
  370. const config = currentMapType.value === 'rebound' ? CONFIG.reboundGeoserver : CONFIG.geoserver
  371. const url = `${config.url}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${config.workspace}:${config.dataLayer}&outputFormat=application/json`
  372. // console.log('🔍 请求 WFS 数据:', url);
  373. const response = await fetch(url);
  374. if (!response.ok) {
  375. const errorText = await response.text();
  376. console.error('❌ WFS 响应错误:', errorText);
  377. throw new Error(`HTTP error! status: ${response.status}`);
  378. }
  379. const text = await response.text();
  380. // console.log('📦 原始响应:', text.substring(0, 200));
  381. // 尝试解析 JSON
  382. let geoJsonData;
  383. try {
  384. geoJsonData = JSON.parse(text);
  385. } catch (parseError) {
  386. console.error('❌ JSON 解析失败,响应内容:', text);
  387. throw new Error('GeoServer 返回的数据格式不正确,不是有效的 JSON');
  388. }
  389. // console.log('✅ 获取到数据:', geoJsonData.features?.length || 0, '条记录');
  390. // 调试:打印第一个要素的 properties
  391. // if (geoJsonData.features && geoJsonData.features.length > 0) {
  392. // console.log('📋 第一个要素的 properties:', geoJsonData.features[0].properties);
  393. // console.log('📋 pH 字段值 (ph_mean):', geoJsonData.features[0].properties.ph_mean);
  394. // console.log('📋 pH 字段值 (le_data__4):', geoJsonData.features[0].properties.le_data__4);
  395. // }
  396. // 保存数据用于统计和交互
  397. allFeatures = geoJsonData.features;
  398. samplePointsData.value = geoJsonData.features;
  399. // 计算统计信息
  400. calculatePHDistribution(allFeatures);
  401. loadStatistics();
  402. // 添加点击事件监听
  403. map.on('click', handleMapClick);
  404. } catch (err) {
  405. console.error('❌ 加载统计数据失败:', err);
  406. }
  407. }
  408. // 计算 pH 分布
  409. function calculatePHDistribution(features) {
  410. const config = currentMapType.value === 'rebound' ? CONFIG.reboundGeoserver : CONFIG.geoserver
  411. const distribution = {
  412. range1: 0,
  413. range2: 0,
  414. range3: 0
  415. }
  416. features.forEach(feature => {
  417. const phValue = feature.properties[config.phField]
  418. const numericPh = typeof phValue === 'string' ? parseFloat(phValue) : phValue
  419. if (numericPh && numericPh > 0) {
  420. if (numericPh <= 5.2) distribution.range1++
  421. else if (numericPh <= 6) distribution.range2++
  422. else distribution.range3++
  423. }
  424. })
  425. phDistribution.value = distribution
  426. }
  427. // 处理地图点击,查询地块属性
  428. async function handleMapClick(e) {
  429. const latlng = e.latlng;
  430. // console.log('🔍 地图点击:', latlng);
  431. try {
  432. // 根据当前地图类型选择配置
  433. const config = currentMapType.value === 'rebound' ? CONFIG.reboundGeoserver : CONFIG.geoserver
  434. // console.log('📋 当前地图类型:', currentMapType.value);
  435. // console.log('📋 查询的数据层:', config.dataLayer);
  436. // 构建 WMS GetFeatureInfo 请求
  437. const size = map.getSize();
  438. const point = map.latLngToContainerPoint(latlng);
  439. const params = new URLSearchParams({
  440. SERVICE: 'WMS',
  441. VERSION: '1.1.1',
  442. REQUEST: 'GetFeatureInfo',
  443. LAYERS: `${config.workspace}:${config.dataLayer}`,
  444. QUERY_LAYERS: `${config.workspace}:${config.dataLayer}`,
  445. STYLES: '',
  446. SRS: 'EPSG:4326',
  447. BBOX: map.getBounds().toBBoxString(),
  448. WIDTH: size.x,
  449. HEIGHT: size.y,
  450. X: Math.round(point.x),
  451. Y: Math.round(point.y),
  452. INFO_FORMAT: 'application/json',
  453. FEATURE_COUNT: 1
  454. });
  455. const url = `${config.wmsUrl}?${params.toString()}`;
  456. // console.log('🔍 查询 URL:', url);
  457. const response = await fetch(url);
  458. if (!response.ok) {
  459. throw new Error(`HTTP error! status: ${response.status}`);
  460. }
  461. const data = await response.json();
  462. // console.log('� 查询结果:', data);
  463. if (data.features && data.features.length > 0) {
  464. const feature = data.features[0];
  465. const ph = parsePHValue(feature.properties[config.phField]);
  466. // console.log('📋 pH 字段:', config.phField);
  467. // console.log('📋 pH 值:', ph);
  468. // console.log('📋 完整属性:', feature.properties);
  469. selectedPoint.value = {
  470. ph: ph,
  471. properties: feature.properties
  472. };
  473. // console.log('✅ 选中地块:', selectedPoint.value);
  474. } else {
  475. // console.log('⚠️ 该位置没有地块数据');
  476. selectedPoint.value = null;
  477. }
  478. } catch (err) {
  479. console.error('❌ 查询地块信息失败:', err);
  480. alert('查询地块信息失败,请重试');
  481. }
  482. }
  483. onUnmounted(()=>{
  484. if(map) {
  485. map.off('click', handleMapClick) // 移除点击事件监听
  486. map.remove()
  487. map = null
  488. }
  489. if(wmsLayer) {
  490. wmsLayer = null
  491. }
  492. if(markersLayer) {
  493. markersLayer = null
  494. }
  495. if(geoJsonLayer) { // 原 markersLayer 改为 geoJsonLayer(代码中实际定义的是 geoJsonLayer)
  496. geoJsonLayer = null
  497. }
  498. samplePointsData.value = []
  499. selectedPoint.value = null
  500. })
  501. onMounted(async()=>{
  502. await initMap()
  503. await initMap2() // 初始化第二个地图
  504. await fetchDataForStatistics()
  505. await loadStatistics()
  506. await loadCDData() // 加载 CD 数据
  507. // loadCDStatistics() 会在 loadCDData 完成后自动调用
  508. })
  509. onUnmounted(()=>{
  510. if(map) {
  511. map.remove()
  512. map = null
  513. }
  514. if(map2) {
  515. map2.remove()
  516. map2 = null
  517. }
  518. })
  519. </script>
  520. <template>
  521. <div class="map-container">
  522. <div class="ph-map" ref="mapRef"></div>
  523. <div class="ph-map" ref="mapRef2"></div>
  524. <!-- 计算刷新按钮 -->
  525. <div class="compute">
  526. <button class="combtn" @click="switchMap('normal')">实施降酸措施一周期后</button>
  527. <button class="combtn" @click="switchMap('rebound')">反酸一周期后</button>
  528. </div>
  529. <!-- pH 统计 -->
  530. <div class="statistics-panel">
  531. <h4>📊 韶关土壤 pH 统计</h4>
  532. <div class="stat-row">
  533. <span class="stat-label">地块总数:</span>
  534. <span class="stat-value">{{ statistics.totalBlocks }} 个</span>
  535. </div>
  536. <div class="stat-row highlight">
  537. <span class="stat-label">平均 pH 值:</span>
  538. <span class="stat-value" :class="getPHLevelClass(parseFloat(statistics.avgPH))">
  539. {{ statistics.avgPH || '-' }}
  540. </span>
  541. </div>
  542. <div class="stat-row">
  543. <span class="stat-label">强酸性 (pH≤5.2):</span>
  544. <span class="stat-value danger">{{ statistics.strongAcidCount }} 个</span>
  545. </div>
  546. <div class="stat-row">
  547. <span class="stat-label">强酸性面积占比:</span>
  548. <span class="stat-value danger">{{ statistics.strongAcidPercent }}%</span>
  549. </div>
  550. <div class="stat-row">
  551. <span class="stat-label">弱酸性 (pH 5.2~6.0): </span>
  552. <span class="stat-value warning">{{ statistics.mildAcidCount }} 个</span>
  553. </div>
  554. <div class="stat-row">
  555. <span class="stat-label">弱酸性面积占比:</span>
  556. <span class="stat-value warning">{{ statistics.mildAcidPercent }}%</span>
  557. </div>
  558. <div class="stat-row">
  559. <span class="stat-label">正常 (pH≥6.0):</span>
  560. <span class="stat-value success">{{ statistics.normalCount }} 个</span>
  561. </div>
  562. <div class="stat-row">
  563. <span class="stat-label">正常面积占比:</span>
  564. <span class="stat-value success">{{ statistics.normalPercent }}%</span>
  565. </div>
  566. <div class="stat-divider"></div>
  567. <div class="stat-row small">
  568. <span class="stat-label">总面积:</span>
  569. <span class="stat-value">{{ statistics.totalArea }} 亩</span>
  570. </div>
  571. <div class="stat-row small">
  572. <span class="stat-label">最高 pH:</span>
  573. <span class="stat-value success">{{ statistics.maxPH || '-' }}</span>
  574. </div>
  575. <div class="stat-row small">
  576. <span class="stat-label">最低 pH:</span>
  577. <span class="stat-value danger">{{ statistics.minPH || '-' }}</span>
  578. </div>
  579. </div>
  580. <!-- CD 含量统计 -->
  581. <div class="statistics-panel" style="top: 550px;">
  582. <h4>🌾 作物 Cd 含量统计</h4>
  583. <div class="stat-row">
  584. <span class="stat-label">地块总数:</span>
  585. <span class="stat-value">{{ cdStatistics.totalBlocks }} 个</span>
  586. </div>
  587. <div class="stat-row highlight">
  588. <span class="stat-label">平均 Cd 含量:</span>
  589. <span class="stat-value" :class="cdStatistics.avgCD > 0 ? 'success' : 'danger'">
  590. {{ cdStatistics.avgCD > 0 ? cdStatistics.avgCD + ' mg/kg' : '-' }}
  591. </span>
  592. </div>
  593. <div class="stat-row">
  594. <span class="stat-label">安全 (<0.2 mg/kg):</span>
  595. <span class="stat-value success">{{ cdStatistics.safeCount }} 个</span>
  596. </div>
  597. <div class="stat-row">
  598. <span class="stat-label">预警 (0.2-0.3 mg/kg):</span>
  599. <span class="stat-value warning">{{ cdStatistics.warningCount }} 个</span>
  600. </div>
  601. <div class="stat-row">
  602. <span class="stat-label">超标 (≥0.3 mg/kg):</span>
  603. <span class="stat-value danger">{{ cdStatistics.exceedCount }} 个</span>
  604. </div>
  605. <div class="stat-divider"></div>
  606. <div class="stat-row small">
  607. <span class="stat-label">最高 Cd:</span>
  608. <span class="stat-value danger">{{ cdStatistics.maxCD > 0 ? cdStatistics.maxCD + ' mg/kg' : '-' }}</span>
  609. </div>
  610. <div class="stat-row small">
  611. <span class="stat-label">最低 Cd:</span>
  612. <span class="stat-value success">{{ cdStatistics.minCD > 0 ? cdStatistics.minCD + ' mg/kg' : '-' }}</span>
  613. </div>
  614. </div>
  615. <!-- ph 分布 -->
  616. <div class="distribution-chart">
  617. <h4>📈 pH 分布</h4>
  618. <div class="bar-chart">
  619. <div
  620. v-for="(value, key) in phDistribution"
  621. :key="key"
  622. class="bar-item"
  623. >
  624. <div class="bar-label">{{ getPHRangeLabel(key) }}</div>
  625. <div class="bar-container">
  626. <div
  627. class="bar-fill"
  628. :style="{ width: `${(value / statistics.totalBlocks) * 100}%` }"
  629. :class="
  630. {
  631. danger: key === 'range1',
  632. warning: key === 'range2',
  633. success: key === 'range3'
  634. }
  635. "
  636. ></div>
  637. <span class="bar-value">{{ value }}</span>
  638. </div>
  639. </div>
  640. </div>
  641. </div>
  642. <!-- CD 含量分布 -->
  643. <div class="distribution-chart" style="top: 550px;">
  644. <h4>🌾 作物 Cd 含量分布</h4>
  645. <div class="bar-chart">
  646. <div class="bar-item">
  647. <div class="bar-label">安全 (<0.2)</div>
  648. <div class="bar-container">
  649. <div
  650. class="bar-fill success"
  651. :style="{ width: `${cdStatistics.totalBlocks > 0 ? (cdDistribution.safe / cdStatistics.totalBlocks) * 100 : 0}%` }"
  652. ></div>
  653. <span class="bar-value">{{ cdDistribution.safe }}</span>
  654. </div>
  655. </div>
  656. <div class="bar-item">
  657. <div class="bar-label">预警 (0.2-0.3)</div>
  658. <div class="bar-container">
  659. <div
  660. class="bar-fill warning"
  661. :style="{ width: `${cdStatistics.totalBlocks > 0 ? (cdDistribution.warning / cdStatistics.totalBlocks) * 100 : 0}%` }"
  662. ></div>
  663. <span class="bar-value">{{ cdDistribution.warning }}</span>
  664. </div>
  665. </div>
  666. <div class="bar-item">
  667. <div class="bar-label">超标 (≥0.3)</div>
  668. <div class="bar-container">
  669. <div
  670. class="bar-fill danger"
  671. :style="{ width: `${cdStatistics.totalBlocks > 0 ? (cdDistribution.exceed / cdStatistics.totalBlocks) * 100 : 0}%` }"
  672. ></div>
  673. <span class="bar-value">{{ cdDistribution.exceed }}</span>
  674. </div>
  675. </div>
  676. </div>
  677. </div>
  678. <div class="stat-comment" v-if="statistics.avgPH > 0">
  679. <div class="comment-title">📝 综合评估:</div>
  680. <div class="comment-text" :class="getPHLevelClass(statistics.avgPH)">
  681. {{ getPHComment(statistics.avgPH) }}
  682. </div>
  683. </div>
  684. <!-- 图例 -->
  685. <div class="legend">
  686. <h4>pH 值图例</h4>
  687. <div class="legend-item">
  688. <span class="legend-color" style="background: #ef4444;"></span>
  689. <span>≤ 5.2 (强酸性)</span>
  690. </div>
  691. <div class="legend-item">
  692. <span class="legend-color" style="background: #eab308;"></span>
  693. <span>5.2 - 6.0 (弱酸性)</span>
  694. </div>
  695. <div class="legend-item">
  696. <span class="legend-color" style="background: #22c55e;"></span>
  697. <span>≥ 6.0 (中性/碱性)</span>
  698. </div>
  699. <div class="legend-item">
  700. <span class="legend-color" style="background: #cccccc;"></span>
  701. <span>无数据</span>
  702. </div>
  703. </div>
  704. <!-- CD 含量图例 -->
  705. <div class="legend" style="top: 580px;">
  706. <h4>🌾 Cd 含量图例</h4>
  707. <div class="legend-item">
  708. <span class="legend-color" style="background: #22c55e;"></span>
  709. <span>0.0 - 0.2 mg/kg (安全)</span>
  710. </div>
  711. <div class="legend-item">
  712. <span class="legend-color" style="background: #eab308;"></span>
  713. <span>0.2-0.3 mg/kg (预警)</span>
  714. </div>
  715. <div class="legend-item">
  716. <span class="legend-color" style="background: #ef4444;"></span>
  717. <span>≥ 0.3 mg/kg (超标)</span>
  718. </div>
  719. </div>
  720. <!-- 采样点详情 -->
  721. <div class="point-detail-modal" v-if="selectedPoint">
  722. <div class="detail-content">
  723. <div class="detail-header">
  724. <h4>📍 采样点详情</h4>
  725. <button @click="selectedPoint = null" class="close-btn">×</button>
  726. </div>
  727. <div class="detail-row">
  728. <span class="detail-label">pH 值:</span>
  729. <span class="detail-value" :class="getPHLevelClass(selectedPoint.ph)">
  730. {{ selectedPoint.ph?.toFixed(2) || '-' }}
  731. </span>
  732. </div>
  733. <div class="detail-row">
  734. <span class="detail-label">酸化程度:</span>
  735. <span :class="['detail-value', getPHLevelClass(selectedPoint.ph)]">
  736. {{ !selectedPoint.ph || selectedPoint.ph <= 0 ? '无数据' : selectedPoint.ph <= 5.2 ? '强酸性' : selectedPoint.ph < 6.0 ? '弱酸性' : '正常' }}
  737. </span>
  738. </div>
  739. <div class="detail-row">
  740. <span class="detail-label">建议:</span>
  741. <span class="detail-suggestion">
  742. {{ !selectedPoint.ph || selectedPoint.ph <= 0 ? '无数据' :selectedPoint.ph <= 5.2 ? '立即治理,施用石灰改良' : selectedPoint.ph < 6.0 ? '注意保持,适量施用有机肥' : '继续保持当前管理措施' }}
  743. </span>
  744. </div>
  745. </div>
  746. </div>
  747. </div>
  748. </template>
  749. <style scoped>
  750. .map-container{
  751. width: 100%;
  752. height: 100vh;
  753. position: absolute;
  754. left:0;
  755. top:0;
  756. z-index: 1000;
  757. }
  758. .ph-map{
  759. width: 600px;
  760. height: 500px; /* 固定高度 */
  761. border-radius: 16px;
  762. border: 3px solid #1092d8;
  763. overflow: hidden;
  764. margin-bottom: 20px; /* 两个地图之间的间距 */
  765. }
  766. .ph-map:first-child {
  767. margin-bottom: 50px; /* 第一个地图下方留空 */
  768. }
  769. /* ✅ 统计面板样式 */
  770. .statistics-panel {
  771. position: absolute;
  772. top: 0px;
  773. left: 650px;
  774. background: rgba(255, 255, 255, 0.95);
  775. padding: 20px;
  776. border-radius: 12px;
  777. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
  778. z-index: 1000;
  779. min-width: 350px;
  780. height: 450px;
  781. backdrop-filter: blur(10px);
  782. border: 2px solid rgba(16, 146, 216, 0.2);
  783. }
  784. .compute {
  785. position: absolute;
  786. display: flex;
  787. gap: 15px;
  788. top:5px;
  789. right: 20px;
  790. padding: 20px;
  791. z-index: 1000;
  792. }
  793. .combtn {
  794. padding: 15px 20px;
  795. font-size: 14px;
  796. font-weight: 600;
  797. color: #fff;
  798. background: linear-gradient(135deg, #1092d8 0%, #0d7bb8 100%);
  799. border: none;
  800. border-radius: 8px;
  801. cursor: pointer;
  802. box-shadow: 0 4px 12px rgba(16, 146, 216, 0.3);
  803. transition: all 0.3s ease;
  804. white-space: nowrap;
  805. }
  806. .combtn:hover {
  807. background: linear-gradient(135deg, #0d7bb8 0%, #0a6598 100%);
  808. box-shadow: 0 6px 16px rgba(16, 146, 216, 0.4);
  809. transform: translateY(-2px);
  810. }
  811. .combtn:active {
  812. transform: translateY(0);
  813. box-shadow: 0 2px 8px rgba(16, 146, 216, 0.3);
  814. }
  815. .statistics-panel h4 {
  816. margin: 0 0 15px 0;
  817. font-size: 18px;
  818. color: #1092d8;
  819. border-bottom: 2px solid #1092d8;
  820. padding-bottom: 10px;
  821. font-weight: bold;
  822. }
  823. .stat-row {
  824. display: flex;
  825. justify-content: space-between;
  826. align-items: center;
  827. margin-bottom: 12px;
  828. font-size: 14px;
  829. line-height: 1.4; /* 新增行高,提升可读性 */
  830. }
  831. .stat-row.highlight {
  832. background: linear-gradient(to right, rgba(16, 146, 216, 0.1), transparent);
  833. padding: 8px 12px;
  834. border-radius: 6px;
  835. margin-bottom: 15px;
  836. }
  837. .stat-row.small {
  838. font-size: 12px;
  839. margin-bottom: 6px;
  840. }
  841. .stat-label {
  842. color: #666;
  843. font-weight: 500;
  844. }
  845. .stat-value {
  846. font-weight: 600;
  847. font-size: 16px;
  848. color: #333;
  849. letter-spacing: 0.5px; /* 新增字间距 */
  850. }
  851. .stat-value.danger {
  852. color: #ef4444;
  853. }
  854. .stat-value.warning {
  855. color: #f59e0b;
  856. }
  857. .stat-value.success {
  858. color: #22c55e;
  859. }
  860. .stat-divider {
  861. height: 1px;
  862. background: linear-gradient(to right, transparent, #ddd, transparent);
  863. margin: 15px 0;
  864. }
  865. /* 综合评语 */
  866. .stat-comment {
  867. position: absolute;
  868. top: 300px; /* 放在 pH 分布统计下面 */
  869. right: 10px;
  870. padding: 15px;
  871. width: 350px; /* 和 pH 分布统计差不多的宽度 */
  872. background: rgba(255, 255, 255, 0.95);
  873. border-radius: 12px;
  874. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
  875. z-index: 1000;
  876. backdrop-filter: blur(10px);
  877. border-left: 4px solid #1092d8;
  878. }
  879. .comment-title {
  880. font-size: 15px;
  881. color: #666;
  882. margin-bottom: 6px;
  883. font-weight: bold;
  884. }
  885. .comment-text {
  886. font-size: 14px;
  887. font-weight: bold;
  888. color: #333;
  889. }
  890. .comment-text.danger {
  891. color: #ef4444;
  892. }
  893. .comment-text.warning {
  894. color: #f59e0b;
  895. }
  896. .comment-text.success {
  897. color: #22c55e;
  898. }
  899. .distribution-chart {
  900. position: absolute;
  901. top:100px;
  902. right: 10px;
  903. background: rgba(255, 255, 255, 0.95);
  904. padding: 15px;
  905. border-radius: 12px;
  906. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
  907. z-index: 1000;
  908. min-width: 350px;
  909. backdrop-filter: blur(10px);
  910. }
  911. .distribution-chart h4 {
  912. margin: 0 0 12px 0;
  913. font-size: 16px;
  914. color: #1092d8;
  915. border-bottom: 2px solid #1092d8;
  916. padding-bottom: 8px;
  917. }
  918. .bar-chart {
  919. display: flex;
  920. flex-direction: column;
  921. gap: 8px;
  922. }
  923. .bar-item {
  924. display: flex;
  925. align-items: center;
  926. gap: 8px;
  927. }
  928. .bar-label {
  929. font-size: 11px;
  930. color: #666;
  931. min-width: 60px;
  932. }
  933. .bar-container {
  934. flex: 1;
  935. position: relative;
  936. height: 20px;
  937. background: #f0f0f0;
  938. border-radius: 4px;
  939. overflow: hidden;
  940. border: 1px solid #e5e7eb;
  941. }
  942. .bar-fill {
  943. height: 100%;
  944. transition: width 0.3s ease-in-out; /* 缓动动画更丝滑 */
  945. border-radius: 3px; /* 圆角匹配容器 */
  946. }
  947. .bar-fill.danger {
  948. background: #ef4444;
  949. }
  950. .bar-fill.warning {
  951. background: #f59e0b;
  952. }
  953. .bar-fill.success {
  954. background: #22c55e;
  955. }
  956. .bar-value {
  957. position: absolute;
  958. right: 8px;
  959. top: 50%;
  960. transform: translateY(-50%);
  961. font-size: 11px;
  962. font-weight: bold;
  963. color: black;
  964. text-shadow: 0 1px 2px rgba(0,0,0,0.2); /* 新增文字阴影 */
  965. }
  966. .alert-panel {
  967. position: absolute;
  968. top: 20px;
  969. left: 10px;
  970. background: rgba(255, 255, 255, 0.95);
  971. padding: 15px;
  972. border-radius: 12px;
  973. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
  974. z-index: 1000;
  975. min-width: 250px;
  976. max-height: 300px;
  977. overflow-y: auto;
  978. backdrop-filter: blur(10px);
  979. }
  980. .alert-panel h4 {
  981. margin: 0 0 12px 0;
  982. font-size: 16px;
  983. color: #ef4444;
  984. border-bottom: 2px solid #ef4444;
  985. padding-bottom: 8px;
  986. }
  987. .alert-list {
  988. display: flex;
  989. flex-direction: column;
  990. gap: 6px;
  991. }
  992. .alert-item {
  993. display: flex;
  994. align-items: center;
  995. gap: 8px;
  996. padding: 8px;
  997. background: rgba(239, 68, 68, 0.05);
  998. border-radius: 6px;
  999. cursor: pointer;
  1000. transition: all 0.3s;
  1001. }
  1002. .alert-item:hover {
  1003. background: rgba(239, 68, 68, 0.15);
  1004. transform: translateX(4px);
  1005. }
  1006. .alert-rank {
  1007. background: #ef4444;
  1008. color: white;
  1009. width: 20px;
  1010. height: 20px;
  1011. border-radius: 50%;
  1012. display: flex;
  1013. align-items: center;
  1014. justify-content: center;
  1015. font-size: 12px;
  1016. font-weight: bold;
  1017. flex-shrink: 0;
  1018. }
  1019. .alert-name {
  1020. flex: 1;
  1021. font-size: 13px;
  1022. color: #333;
  1023. }
  1024. .alert-ph {
  1025. font-size: 12px;
  1026. font-weight: bold;
  1027. }
  1028. .alert-ph.danger {
  1029. color: #ef4444;
  1030. }
  1031. /* 图例样式 */
  1032. .legend {
  1033. position: absolute;
  1034. top: 30px;
  1035. left: 30px;
  1036. background: rgba(255, 255, 255, 0.95);
  1037. padding: 15px;
  1038. border-radius: 8px;
  1039. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  1040. min-width: 10px;
  1041. backdrop-filter: blur(4px);
  1042. z-index: 0;
  1043. }
  1044. .legend h4 {
  1045. margin: 0 0 10px 0;
  1046. font-size: 10px;
  1047. color: #333;
  1048. border-bottom: 2px solid #1092d8;
  1049. padding-bottom: 6px;
  1050. }
  1051. .legend-item {
  1052. display: flex;
  1053. align-items: center;
  1054. gap: 8px;
  1055. margin-bottom: 6px;
  1056. font-size: 8px;
  1057. color: #555;
  1058. }
  1059. .legend-item:last-child {
  1060. margin-bottom: 0;
  1061. }
  1062. .legend-color {
  1063. width: 10px;
  1064. height: 10px;
  1065. border-radius: 4px;
  1066. border: 1px solid #ddd;
  1067. flex-shrink: 0;
  1068. }
  1069. .point-detail-modal {
  1070. position: fixed;
  1071. top: 50%;
  1072. left: 50%;
  1073. transform: translate(-50%, -50%);
  1074. background: white;
  1075. padding: 20px;
  1076. border-radius: 12px;
  1077. box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
  1078. z-index: 2000;
  1079. min-width: 300px;
  1080. border: 1px solid rgba(16, 146, 216, 0.2); /* 新增边框 */
  1081. animation: fadeIn 0.3s ease; /* 新增淡入动画 */
  1082. }
  1083. /* 新增弹窗动画 */
  1084. @keyframes fadeIn {
  1085. from {
  1086. opacity: 0;
  1087. transform: translate(-50%, -45%);
  1088. }
  1089. to {
  1090. opacity: 1;
  1091. transform: translate(-50%, -50%);
  1092. }
  1093. }
  1094. .detail-content {
  1095. position: relative;
  1096. }
  1097. .detail-content h4 {
  1098. margin: 0 0 15px 0;
  1099. color: #1092d8;
  1100. border-bottom: 2px solid #1092d8;
  1101. padding-bottom: 10px;
  1102. }
  1103. .detail-row {
  1104. display: flex;
  1105. justify-content: space-between;
  1106. margin-bottom: 12px;
  1107. padding: 8px;
  1108. background: #f8f9fa;
  1109. border-radius: 6px;
  1110. }
  1111. .detail-label {
  1112. font-weight: 500;
  1113. color: #666;
  1114. }
  1115. .detail-value {
  1116. font-weight: bold;
  1117. color: #333;
  1118. }
  1119. .detail-value.danger {
  1120. color: #ef4444;
  1121. }
  1122. .detail-value.warning {
  1123. color: #f59e0b;
  1124. }
  1125. .detail-value.success {
  1126. color: #22c55e;
  1127. }
  1128. .detail-suggestion {
  1129. color: #1092d8;
  1130. font-size: 13px;
  1131. line-height: 1.5;
  1132. }
  1133. /* 自定义 Tooltip 样式 */
  1134. :deep(.custom-tooltip) {
  1135. background: rgba(255, 255, 255, 0.95);
  1136. border: 2px solid #1092d8;
  1137. border-radius: 8px;
  1138. padding: 8px 12px;
  1139. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  1140. font-family: 'Microsoft YaHei', sans-serif;
  1141. backdrop-filter: blur(4px);
  1142. }
  1143. .custom-marker {
  1144. background: transparent !important;
  1145. border: none !important;
  1146. }
  1147. .detail-content {
  1148. position: relative;
  1149. }
  1150. .detail-header {
  1151. display: flex;
  1152. justify-content: space-between;
  1153. align-items: center;
  1154. margin-bottom: 15px;
  1155. }
  1156. .close-btn {
  1157. background: none;
  1158. border: none;
  1159. font-size: 20px;
  1160. cursor: pointer;
  1161. color: #909399;
  1162. padding: 0;
  1163. width: 20px;
  1164. height: 20px;
  1165. display: flex;
  1166. align-items: center;
  1167. justify-content: center;
  1168. border-radius: 50%; /* 新增圆形背景 */
  1169. transition: all 0.2s; /* 新增过渡 */
  1170. }
  1171. .close-btn:hover {
  1172. color: #ef4444; /* 改为红色更醒目 */
  1173. background: #fef2f2; /* 新增背景色 */
  1174. }
  1175. </style>