acidmodelmap.vue 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521
  1. <template>
  2. <el-card class="map-card">
  3. <div class="title">
  4. <div class="section-icon">🗺️</div>
  5. <p class="map-title">乐昌市土地酸化预测</p>
  6. </div>
  7. <div id="map-container" class="map-container">
  8. <div v-if="mapLoading" class="loading">
  9. <div class="loading-spinner"></div>
  10. <span>地图加载中...</span>
  11. </div>
  12. <div v-if="mapError" class="error-tip">
  13. <el-alert title="地图加载失败" type="error" show-icon>
  14. <template #description>
  15. <p>请检查GeoServer服务和配置</p>
  16. <el-button @click="reloadMap" type="primary">重试加载</el-button>
  17. </template>
  18. </el-alert>
  19. </div>
  20. </div>
  21. <!-- 整合后的信息弹窗 -->
  22. <div v-if="showPopup"
  23. ref="popupElement"
  24. class="feature-popup fixed-center"
  25. :style="{
  26. left: popupPosition.x + '%',
  27. top: popupPosition.y + '%',
  28. translate:'none'
  29. }">
  30. <div class="popup-content">
  31. <div class="popup-header" @mousedown="startDrag">
  32. <!-- 地块信息 -->
  33. <div v-if="featureInfo.loading" class="loading-info">
  34. <div class="loading-spinner small"></div>
  35. <span>加载地块信息中...</span>
  36. </div>
  37. <div v-else-if="featureInfo.error" class="error-info">获取地块信息失败</div>
  38. <div v-else-if="featureInfo.data" class="feature-info">
  39. <div class="info-item">
  40. <label>所属村:</label>
  41. <span>{{ featureInfo.data.village || '未知' }}</span>
  42. </div>
  43. <div class="info-item">
  44. <label>用地类型:</label>
  45. <span>{{ featureInfo.data.landType || '未知' }}</span>
  46. </div>
  47. </div>
  48. <div v-else class="no-data">无地块信息</div>
  49. <button @click="closePopup" class="close-btn">×</button>
  50. </div>
  51. <div class="popup-body">
  52. <!-- 操作按钮 -->
  53. <div class="action-buttons" v-if="!showPredictionResult && !showAcidReductionInput &&!showAcidInversionInput">
  54. <el-button
  55. size="small"
  56. type="success"
  57. @click="startAcidInversionPrediction"
  58. >
  59. 反酸预测
  60. </el-button>
  61. <el-button
  62. size="small"
  63. type="primary"
  64. @click="startAcidReductionPrediction"
  65. >
  66. 降酸预测
  67. </el-button>
  68. </div>
  69. <!-- 降酸预测参数输入 -->
  70. <div v-if="showAcidReductionInput" class="acid-reduction-input">
  71. <div class="section-title">降酸预测参数</div>
  72. <!-- 关键修复:el-form 绑定 rules 和 ref -->
  73. <el-form
  74. :model="acidReductionParams"
  75. :rules="acidReductionRules"
  76. ref="acidReductionFormRef"
  77. label-width="80px"
  78. size="small"
  79. >
  80. <!-- 下面的 params-row 放在 el-form 内部 -->
  81. <div class="params-row">
  82. <el-form-item label="当前pH" class="form-item-compact readonly-item">
  83. <div class="current-ph-value">
  84. <span v-if="phLoading" class="loading-text">加载中...</span>
  85. <span v-else-if="currentPH !== null">{{ currentPH.toFixed(2) }}</span>
  86. <span v-else class="no-data-text">未获取</span>
  87. </div>
  88. </el-form-item>
  89. <el-form-item label="目标pH" prop="target_pH" class="form-item-compact">
  90. <el-input
  91. v-model.number="acidReductionParams.target_pH"
  92. type="number"
  93. step="0.01"
  94. placeholder="0-14"
  95. :disabled="phLoading"
  96. />
  97. </el-form-item>
  98. </div>
  99. <div class="params-row">
  100. <el-form-item label="NO3" prop="NO3" class="form-item-compact">
  101. <el-input
  102. v-model.number="acidReductionParams.NO3"
  103. type="number"
  104. step="0.01"
  105. min="0"
  106. placeholder="9-70"
  107. :disabled="phLoading"
  108. />
  109. </el-form-item>
  110. <el-form-item label="NH4" prop="NH4" class="form-item-compact">
  111. <el-input
  112. v-model.number="acidReductionParams.NH4"
  113. type="number"
  114. step="0.01"
  115. min="0"
  116. placeholder="0-20"
  117. :disabled="phLoading"
  118. />
  119. </el-form-item>
  120. </div>
  121. </el-form>
  122. <div class="input-buttons">
  123. <el-button size="small" @click="cancelAcidReduction" :disabled="phLoading">取消</el-button>
  124. <el-button size="small" type="primary" @click="confirmAcidReduction" :loading="predictionLoading || phLoading">
  125. 开始预测
  126. </el-button>
  127. </div>
  128. </div>
  129. <!-- 反酸预测参数输入 -->
  130. <div v-if="showAcidInversionInput" class="acid-inversion-input">
  131. <div class="section-title">反酸预测参数</div>
  132. <el-form
  133. :model="acidInversionParams"
  134. :rules="acidInversionRules"
  135. ref="acidInversionFormRef"
  136. label-width="80px"
  137. size="small"
  138. >
  139. <!-- 当前pH单独一行 -->
  140. <div class="params-row full-width">
  141. <el-form-item label="当前pH" class="form-item-compact readonly-item">
  142. <div class="current-ph-value">
  143. <span v-if="phLoading" class="loading-text">加载中...</span>
  144. <span v-else-if="currentPH !== null">{{ currentPH.toFixed(2) }}</span>
  145. <span v-else class="no-data-text">未获取</span>
  146. </div>
  147. </el-form-item>
  148. </div>
  149. <!-- NO3和NH4在同一行 -->
  150. <div class="params-row">
  151. <el-form-item label="NO3" prop="NO3" class="form-item-compact">
  152. <el-input
  153. v-model.number="acidInversionParams.NO3"
  154. type="number"
  155. step="0.01"
  156. min="0"
  157. placeholder="9-70"
  158. :disabled="phLoading"
  159. />
  160. </el-form-item>
  161. <el-form-item label="NH4" prop="NH4" class="form-item-compact">
  162. <el-input
  163. v-model.number="acidInversionParams.NH4"
  164. type="number"
  165. step="0.01"
  166. min="0"
  167. placeholder="0-18"
  168. :disabled="phLoading"
  169. />
  170. </el-form-item>
  171. </div>
  172. </el-form>
  173. <div class="input-buttons">
  174. <el-button size="small" @click="cancelAcidInversion" :disabled="phLoading">取消</el-button>
  175. <el-button size="small" type="primary" @click="confirmAcidInversion" :loading="predictionLoading || phLoading">
  176. 开始预测
  177. </el-button>
  178. </div>
  179. </div>
  180. <!-- 预测结果展示 -->
  181. <div v-if="showPredictionResult" class="prediction-section">
  182. <div class="section-title">{{ predictionTitle }}</div>
  183. <div v-if="predictionLoading" class="loading-info">
  184. <div class="loading-spinner small"></div>
  185. <span>预测中...</span>
  186. </div>
  187. <div v-else-if="predictionError" class="error-info">
  188. {{ predictionError }}
  189. </div>
  190. <div v-else-if="predictionResult" class="prediction-result">
  191. <!--降酸结果-->
  192. <div class="result-item" v-if="currentPredictionType === 'reduction' && predictionResult.prediction_reduce !== undefined">
  193. <span class="prediction-value reduction">每亩地土壤表层20cm撒{{ formatPredictionValue(predictionResult.prediction_reduce)}} 吨</span>
  194. </div>
  195. <!-- 反酸预测结果 -->
  196. <div class="result-item" v-if="currentPredictionType === 'inversion' && predictionResult.prediction_reflux !== undefined">
  197. <span class="prediction-value inversion">ΔpH{{ formatPredictionValue(predictionResult.prediction_reflux)}} </span>
  198. </div>
  199. <div class="result-buttons">
  200. <el-button size="small" @click="resetPrediction">重新预测</el-button>
  201. <el-button size="small" type="primary" @click="closePopup">关闭</el-button>
  202. </div>
  203. </div>
  204. </div>
  205. </div>
  206. </div>
  207. </div>
  208. <!--连接线容器-->
  209. <div v-if="showPopup && showConnectionLine"
  210. class="connection-line"
  211. :style="connectionLine.style">
  212. </div>
  213. </el-card>
  214. </template>
  215. <script setup lang="ts">
  216. import { reactive, ref, nextTick, onMounted, onUnmounted, computed } from "vue";
  217. import { ElMessage } from "element-plus";
  218. import type { FormInstance } from "element-plus";
  219. // 新增:反酸预测相关状态(区分降酸/反酸的显示)
  220. const showInversionPrediction = ref(false); // 控制反酸预测区域显示
  221. const phLoading = ref(false); // 控制当前pH的加载状态(点击预测后先加载pH)
  222. // 地图状态
  223. const mapLoading = ref(true);
  224. const mapError = ref(false);
  225. const map = ref<any>(null);
  226. const showPopup = ref(false);
  227. // 新增:高亮图层(存储当前选中地块的高亮图层)
  228. const highlightLayer = ref<any>(null);
  229. // 新增:控制连接线显示的标志
  230. const showConnectionLine = ref(false);
  231. // 预测状态
  232. const predictionLoading = ref(false);
  233. const predictionError = ref('');
  234. const predictionResult = ref<any>(null);
  235. const showPredictionResult = ref(false);
  236. const showAcidReductionInput = ref(false);
  237. const currentPH = ref<number | null>(null);
  238. let L: any = null;
  239. const defaultParams = reactive({
  240. NO3: 34.05, // 随便填一个合理默认值(后端接口要求传,但不影响pH返回)
  241. NH4: 7.87 // 同上,只要是合法数字即可
  242. });
  243. // 地块信息状态
  244. const featureInfo = reactive({
  245. loading: false,
  246. error: false,
  247. data: null as any
  248. });
  249. // 新增:地块中心点坐标(用于连接线起点)
  250. const featureCenter = reactive({
  251. lng: 0,
  252. lat: 0
  253. });
  254. // 当前坐标
  255. const currentClickCoords = reactive({
  256. lng: 0,
  257. lat: 0
  258. });
  259. const currentPredictionType = ref<'reduction' | 'inversion' | null>(null);
  260. // 降酸预测参数输入相关
  261. const acidReductionFormRef = ref<FormInstance | null>(null);
  262. interface AcidReductionParams {
  263. target_pH?: number;
  264. NO3?: number;
  265. NH4?: number;
  266. }
  267. const acidReductionParams = reactive<AcidReductionParams>({
  268. target_pH: undefined,
  269. NO3: undefined,
  270. NH4: undefined
  271. });
  272. // 在 acidReductionParams 附近添加反酸预测参数
  273. interface AcidInversionParams {
  274. NO3?: number;
  275. NH4?: number;
  276. }
  277. // 反酸预测参数(指定类型为 AcidInversionParams)
  278. const acidInversionParams = reactive<AcidInversionParams>({
  279. NO3: undefined,
  280. NH4: undefined
  281. });
  282. // 添加反酸预测的显示状态
  283. const showAcidInversionInput = ref(false);
  284. // 添加反酸预测的表单验证规则
  285. const acidInversionRules = reactive({
  286. NO3: [
  287. { required: true, message: '请输入NO3', trigger: 'change' },
  288. { type: 'number', message: '请输入有效数字', trigger: 'change' },
  289. {
  290. validator: (rule: any, value: number, callback: any) => {
  291. if (value < 0) {
  292. callback(new Error('值不能为负数'));
  293. } else {
  294. callback();
  295. }
  296. },
  297. trigger: 'change'
  298. }
  299. ],
  300. NH4: [
  301. { required: true, message: '请输入NH4', trigger: 'change' },
  302. { type: 'number', message: '请输入有效数字', trigger: 'change' },
  303. {
  304. validator: (rule: any, value: number, callback: any) => {
  305. if (value < 0) {
  306. callback(new Error('值不能为负数'));
  307. } else {
  308. callback();
  309. }
  310. },
  311. trigger: 'change'
  312. }
  313. ]
  314. });
  315. // 添加反酸预测的表单引用
  316. const acidInversionFormRef = ref<FormInstance | null>(null);
  317. // 预测标题
  318. const predictionTitle = computed(() => {
  319. if (currentPredictionType.value === 'reduction') return '降酸预测结果';
  320. if (currentPredictionType.value === 'inversion') return '反酸预测结果';
  321. return '预测结果';
  322. });
  323. // 输入校验规则
  324. const acidReductionRules = reactive({
  325. target_pH: [
  326. { required: true, message: '请输入目标pH值', trigger: 'blur' },
  327. { type: 'number', message: '请输入有效数字', trigger: 'blur' },
  328. {
  329. validator: (rule: any, value: number, callback: any) => {
  330. if (value < 0 || value > 14) {
  331. callback(new Error('值范围在0-14之间'));
  332. } else {
  333. callback();
  334. }
  335. },
  336. trigger: 'blur'
  337. }
  338. ],
  339. NO3: [
  340. { required: true, message: '请输入NO3', trigger: 'blur' },
  341. { type: 'number', message: '请输入有效数字', trigger: 'blur' },
  342. {
  343. validator: (rule: any, value: number, callback: any) => {
  344. if (value < 0) {
  345. callback(new Error('值不能为负数'));
  346. } else {
  347. callback();
  348. }
  349. },
  350. trigger: 'blur'
  351. }
  352. ],
  353. NH4: [ // 修复:之前是nh4,和prop不一致
  354. { required: true, message: '请输入NH4', trigger: 'blur' },
  355. { type: 'number', message: '请输入有效数字', trigger: 'blur' },
  356. {
  357. validator: (rule: any, value: number, callback: any) => {
  358. if (value < 0) {
  359. callback(new Error('值不能为负数'));
  360. } else {
  361. callback();
  362. }
  363. },
  364. trigger: 'blur'
  365. }
  366. ]
  367. });
  368. // 格式化预测值
  369. const formatPredictionValue = (value: any): string => {
  370. console.log('预测值原始数据:', value, '类型:', typeof value);
  371. if (value === null || value === undefined) return '无数据';
  372. if (Array.isArray(value)) {
  373. if (value.length === 0) return '无数据';
  374. const num = Number(value[0]);
  375. return isNaN(num) ? '无效数据' : num.toFixed(4);
  376. }
  377. if (typeof value === 'object') {
  378. console.warn('预测值是对象类型:', value);
  379. return '数据格式错误';
  380. }
  381. const num = Number(value);
  382. return isNaN(num) ? '无效数据' : num.toFixed(4);
  383. };
  384. // 修改:在原地放大地块
  385. const drawHighlightFeature = (geoJsonFeature: any) => {
  386. if (!map.value || !geoJsonFeature || !geoJsonFeature.geometry) return;
  387. // 清除旧的高亮图层
  388. if (highlightLayer.value) {
  389. map.value.removeLayer(highlightLayer.value);
  390. highlightLayer.value = null;
  391. }
  392. // 计算放大后的坐标(原地放大)
  393. const scaleFeatureCoordinates = (feature: any, scale: number) => {
  394. const scaledFeature = JSON.parse(JSON.stringify(feature));
  395. const scaleCoordinates = (coords: any): any => {
  396. if (Array.isArray(coords[0]) && Array.isArray(coords[0][0]) && Array.isArray(coords[0][0][0])) {
  397. return coords.map((polygon: any) =>
  398. polygon.map((ring: any) => scaleCoordinates(ring))
  399. );
  400. } else if (Array.isArray(coords[0]) && Array.isArray(coords[0][0])) {
  401. return coords.map((ring: any) => scaleCoordinates(ring));
  402. } else if (Array.isArray(coords[0])) {
  403. return coords.map((point: any) => {
  404. const lng = point[0];
  405. const lat = point[1];
  406. const centerLng = coords[0][0];
  407. const centerLat = coords[0][1];
  408. const deltaLng = lng - centerLng;
  409. const deltaLat = lat - centerLat;
  410. return [
  411. centerLng + deltaLng * scale,
  412. centerLat + deltaLat * scale
  413. ];
  414. });
  415. }
  416. return coords;
  417. };
  418. scaledFeature.geometry.coordinates = scaleCoordinates(feature.geometry.coordinates);
  419. return scaledFeature;
  420. };
  421. // 创建放大1倍的地块
  422. const scaledFeature = scaleFeatureCoordinates(geoJsonFeature, 1);
  423. // 使用放大后的GeoJSON创建高亮图层
  424. highlightLayer.value = L.geoJSON(scaledFeature, {
  425. style: {
  426. color: '#000',
  427. weight: 4,
  428. fillColor: '#ff4d4f',
  429. fillOpacity: 1,
  430. opacity: 1
  431. }
  432. }).addTo(map.value);
  433. // 计算地块的中心点
  434. const bounds = highlightLayer.value.getBounds();
  435. const center = bounds.getCenter();
  436. featureCenter.lng = center.lng;
  437. featureCenter.lat = center.lat;
  438. // 确保高亮图层在最上层
  439. highlightLayer.value.bringToFront();
  440. // 先隐藏连接线
  441. showConnectionLine.value = false;
  442. // 计算放大后地块的边界
  443. const scaledBounds = highlightLayer.value.getBounds();
  444. // 调整地图视图以更好地显示放大的地块
  445. // 添加一些内边距,让地块不会紧贴地图边缘
  446. map.value.flyToBounds(scaledBounds, {
  447. padding: [50, 50], // 上下左右各50像素的内边距
  448. duration: 0.3, // 1秒动画
  449. maxZoom: 16 // 最大缩放级别限制,避免放得太大
  450. });
  451. updateConnectionLine();
  452. };
  453. // 获取地块信息
  454. const getFeatureInfo = async (latlng: any, point: any): Promise<boolean> => {
  455. if (!map.value) return false;
  456. featureInfo.loading = true;
  457. featureInfo.error = false;
  458. featureInfo.data = null;
  459. try {
  460. const GEOSERVER_CONFIG = {
  461. url: "/geoserver/wms",
  462. workspace: "acidmap",
  463. layerGroup: "mapwithboundary",
  464. };
  465. const bounds = map.value.getBounds();
  466. const size = map.value.getSize();
  467. const params = new URLSearchParams();
  468. params.append('service', 'WMS');
  469. params.append('version', '1.1.1');
  470. params.append('request', 'GetFeatureInfo'); // 固定:请求获取要素信息
  471. params.append('layers', `${GEOSERVER_CONFIG.workspace}:${GEOSERVER_CONFIG.layerGroup}`);
  472. params.append('query_layers', `${GEOSERVER_CONFIG.workspace}:${GEOSERVER_CONFIG.layerGroup}`);
  473. params.append('info_format', 'application/json');
  474. params.append('feature_count', '10');
  475. params.append('x', Math.round(point.x).toString());
  476. params.append('y', Math.round(point.y).toString());
  477. params.append('width', size.x.toString());
  478. params.append('height', size.y.toString());
  479. params.append('srs', 'EPSG:4326');
  480. params.append('bbox', `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`);
  481. const url = `${GEOSERVER_CONFIG.url}?${params.toString()}`;
  482. const response = await fetch(url);
  483. if (!response.ok) {
  484. throw new Error(`HTTP error! status: ${response.status}`);
  485. }
  486. const data = await response.json();
  487. if (data.features && data.features.length > 0) {
  488. const feature = data.features[0]; // 保存完整的要素(包含几何信息)
  489. const properties = feature.properties;
  490. const hasValidData = properties.QSDWMC || properties.DLMC;
  491. if (hasValidData) {
  492. featureInfo.data = {
  493. village: properties.QSDWMC,
  494. landType: properties.DLMC,
  495. };
  496. // 绘制高亮地块
  497. drawHighlightFeature(feature);
  498. return true;
  499. }
  500. }
  501. return false;
  502. } catch (error) {
  503. console.error('获取地块信息失败:', error);
  504. featureInfo.error = true;
  505. return false;
  506. } finally {
  507. featureInfo.loading = false;
  508. }
  509. };
  510. // 新增:单独获取当前pH的方法(点击预测后先加载pH)
  511. const fetchCurrentPH = async () => {
  512. if (currentPH.value !== null) return true; // 已有pH,直接返回
  513. if (phLoading.value) return false; // 正在加载,避免重复请求
  514. phLoading.value = true;
  515. try {
  516. const urlParams = new URLSearchParams();
  517. urlParams.append('target_lon', currentClickCoords.lng.toString());
  518. urlParams.append('target_lat', currentClickCoords.lat.toString());
  519. urlParams.append('NO3', defaultParams.NO3.toString());
  520. urlParams.append('NH4', defaultParams.NH4.toString());
  521. // 调用接口获取当前pH(接口会返回nearest_point.ph)
  522. const response = await fetch(
  523. `http://localhost:8000/api/vector/nearest-with-predictions?${urlParams.toString()}`
  524. );
  525. if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
  526. const data = await response.json();
  527. // 从接口提取当前pH(根据你的接口结构:data.nearest_point.ph)
  528. currentPH.value = data.nearest_point?.ph !== undefined ? Number(data.nearest_point.ph) : null;
  529. return currentPH.value !== null; // 返回是否获取成功
  530. } catch (error) {
  531. console.error('获取当前pH失败:', error);
  532. ElMessage.error('获取土壤当前pH值失败,请重试');
  533. currentPH.value = null;
  534. return false;
  535. } finally {
  536. phLoading.value = false;
  537. }
  538. };
  539. // 降酸预测:点击后先加载pH,再显示输入区
  540. const startAcidReductionPrediction = async () => {
  541. // 先获取当前pH
  542. const fetchSuccess = await fetchCurrentPH();
  543. if (!fetchSuccess) return;
  544. // pH获取成功,显示降酸输入区
  545. currentPredictionType.value = 'reduction';
  546. showAcidReductionInput.value = true;
  547. showInversionPrediction.value = false;
  548. showPredictionResult.value = false;
  549. };
  550. // 反酸预测:点击后先加载pH,再显示反酸区域
  551. const startAcidInversionPrediction = async () => {
  552. // 先获取当前pH
  553. const fetchSuccess = await fetchCurrentPH();
  554. if (!fetchSuccess) return;
  555. // pH获取成功,显示反酸区域(无输入参数)
  556. currentPredictionType.value = 'inversion';
  557. showAcidInversionInput.value = true;
  558. showAcidReductionInput.value = false;
  559. showPredictionResult.value = false;
  560. };
  561. // 取消降酸预测
  562. const cancelAcidReduction = () => {
  563. showAcidReductionInput.value = false;
  564. currentPredictionType.value = null;
  565. };
  566. // 取消反酸预测
  567. const cancelAcidInversion = () => {
  568. showAcidInversionInput.value = false;
  569. currentPredictionType.value = null;
  570. };
  571. // 确认降酸预测(原有逻辑不变,补充pH校验)
  572. const confirmAcidReduction = async () => {
  573. if (!acidReductionFormRef.value) return;
  574. if (currentPH.value === null) {
  575. ElMessage.error('请先获取当前pH值');
  576. return;
  577. }
  578. try {
  579. await acidReductionFormRef.value.validate();
  580. // 调用预测接口(带降酸参数)
  581. await callPredictionAPI(
  582. currentClickCoords.lng,
  583. currentClickCoords.lat,
  584. {
  585. target_pH: acidReductionParams.target_pH!,
  586. NO3: acidReductionParams.NO3!,
  587. NH4: acidReductionParams.NH4!
  588. }
  589. );
  590. } catch (error) {
  591. ElMessage.error('输入参数不合法,请检查后重试');
  592. }
  593. };
  594. // 确认反酸预测
  595. const confirmAcidInversion = async () => {
  596. if (!acidInversionFormRef.value) return;
  597. if (currentPH.value === null) {
  598. ElMessage.error('请先获取当前pH值');
  599. return;
  600. }
  601. try {
  602. await acidInversionFormRef.value.validate();
  603. // 调用预测接口 - 反酸预测需要传递NO3和NH4参数
  604. await callPredictionAPI(
  605. currentClickCoords.lng,
  606. currentClickCoords.lat,
  607. {
  608. NO3: acidInversionParams.NO3!,
  609. NH4: acidInversionParams.NH4!
  610. }
  611. );
  612. } catch (error) {
  613. ElMessage.error('输入参数不合法,请检查后重试');
  614. }
  615. };
  616. // 调用预测接口
  617. const callPredictionAPI = async (
  618. lng: number,
  619. lat: number,
  620. params?: {
  621. target_pH?: number;
  622. NO3: number;
  623. NH4: number;
  624. }
  625. ) => {
  626. predictionLoading.value = true;
  627. predictionError.value = '';
  628. predictionResult.value = null;
  629. try {
  630. const urlParams = new URLSearchParams();
  631. urlParams.append('target_lon', lng.toString());
  632. urlParams.append('target_lat', lat.toString());
  633. // 明确传递 prediction_type
  634. if (currentPredictionType.value === 'reduction') {
  635. urlParams.append('prediction_type', 'reduce');
  636. } else if (currentPredictionType.value === 'inversion') {
  637. urlParams.append('prediction_type', 'reflux');
  638. }
  639. if (params) {
  640. Object.entries(params).forEach(([key, value]) => {
  641. if (value !== undefined && value !== null) {
  642. urlParams.append(key, value.toString());
  643. }
  644. });
  645. }
  646. console.log('调用预测接口,参数:', urlParams.toString());
  647. const response = await fetch(
  648. `http://localhost:8000/api/vector/nearest-with-predictions?${urlParams.toString()}`
  649. );
  650. if (!response.ok) {
  651. const errorData = await response.json();
  652. console.error('预测接口返回错误:', errorData);
  653. throw new Error(`HTTP error! status: ${response.status}`);
  654. }
  655. const data = await response.json();
  656. predictionResult.value = data;
  657. console.log('预测结果:', data);
  658. currentPH.value = data.nearest_point?.ph !== undefined ? Number(data.nearest_point.ph) : null;
  659. // 显示预测结果
  660. showPredictionResult.value = true;
  661. showAcidReductionInput.value = false;
  662. showAcidInversionInput.value = false;
  663. } catch (error) {
  664. currentPH.value = null;
  665. console.error('调用预测接口失败:', error);
  666. predictionError.value = `预测失败: ${error instanceof Error ? error.message : '未知错误'}`;
  667. ElMessage.error('预测请求失败,请检查后端服务是否启动');
  668. } finally {
  669. predictionLoading.value = false;
  670. }
  671. };
  672. // 重置预测
  673. const resetPrediction = () => {
  674. showPredictionResult.value = false;
  675. predictionResult.value = null;
  676. currentPredictionType.value = null;
  677. showAcidReductionInput.value = false;
  678. showAcidInversionInput.value = false;
  679. currentPH.value = null;
  680. };
  681. // 地图点击事件处理
  682. const handleMapClick = async (e: any) => {
  683. const lng = e.latlng.lng;
  684. const lat = e.latlng.lat;
  685. // 更新当前坐标
  686. currentClickCoords.lng = lng;
  687. currentClickCoords.lat = lat;
  688. // 重置地块中心点
  689. featureCenter.lng = 0;
  690. featureCenter.lat = 0;
  691. // 计算点击点的屏幕坐标
  692. const containerPoint = map.value.latLngToContainerPoint(e.latlng);
  693. const mapRect = document.getElementById('map-container')!.getBoundingClientRect();
  694. clickPoint.x = mapRect.left + containerPoint.x;
  695. clickPoint.y = mapRect.top + containerPoint.y;
  696. // 设置弹窗初始位置(在点击点右侧)
  697. popupPosition.x = 35;
  698. popupPosition.y = 35; // 居中显示
  699. showPopup.value = true;
  700. showConnectionLine.value=false;
  701. // 重置预测状态
  702. resetPrediction();
  703. featureInfo.data = null;
  704. // 先清除旧的高亮图层
  705. if (highlightLayer.value) {
  706. map.value.removeLayer(highlightLayer.value);
  707. highlightLayer.value = null;
  708. }
  709. // 获取地块信息
  710. try {
  711. const hasFeature = await getFeatureInfo(e.latlng, containerPoint);
  712. // 如果没有获取到要素,提示
  713. if (!hasFeature) {
  714. featureInfo.data = {
  715. village: '未知',
  716. landType: '未知'
  717. };
  718. ElMessage.info('未查询到该地块的详细信息');
  719. // 如果没有获取到地块,使用点击点作为连接线起点
  720. featureCenter.lng = lng;
  721. featureCenter.lat = lat;
  722. // 直接显示连接线
  723. showConnectionLine.value = true;
  724. nextTick(() => {
  725. updateConnectionLine();
  726. });
  727. }
  728. } catch (error) {
  729. console.error('处理地块信息失败:', error);
  730. // 清除高亮
  731. if (highlightLayer.value) {
  732. map.value.removeLayer(highlightLayer.value);
  733. highlightLayer.value = null;
  734. }
  735. }
  736. // 使用点击点作为连接线起点
  737. featureCenter.lng = lng;
  738. featureCenter.lat = lat;
  739. nextTick(() => {
  740. updateConnectionLine();
  741. });
  742. };
  743. // 关闭弹窗(清除高亮图层)
  744. const closePopup = () => {
  745. showPopup.value = false;
  746. showConnectionLine.value=false;
  747. resetPrediction();
  748. featureInfo.data = null;
  749. // 清除高亮图层
  750. if (highlightLayer.value && map.value) {
  751. map.value.removeLayer(highlightLayer.value);
  752. highlightLayer.value = null;
  753. }
  754. };
  755. // 新增:弹窗拖动相关状态
  756. const popupElement = ref<HTMLElement>();
  757. const popupPosition = reactive({
  758. x: 35,
  759. y: 35
  760. });
  761. // 新增:连接线相关状态
  762. const connectionLine = reactive({
  763. show: false,
  764. style: {}
  765. });
  766. // 新增:点击坐标(用于连接线起点)
  767. const clickPoint = reactive({
  768. x: 0,
  769. y: 0
  770. });
  771. // 手动拖拽功能
  772. const isDragging = ref(false);
  773. const dragStartPos = reactive({ x: 0, y: 0 });
  774. const startDrag = (event: MouseEvent) => {
  775. if (!popupElement.value) return;
  776. isDragging.value = true;
  777. // 初始值:百分比转像素
  778. const viewportWidth = window.innerWidth;
  779. const viewportHeight = window.innerHeight;
  780. const popupLeft = (popupPosition.x / 100) * viewportWidth;
  781. const popupTop = (popupPosition.y / 100) * viewportHeight;
  782. dragStartPos.x = event.clientX - popupLeft;
  783. dragStartPos.y = event.clientY - popupTop;
  784. document.addEventListener('mousemove', onDrag);
  785. document.addEventListener('mouseup', stopDrag);
  786. event.preventDefault();
  787. };
  788. const onDrag = (event: MouseEvent) => {
  789. if (!isDragging.value) return;
  790. // 拖拽后转成百分比
  791. const viewportWidth = window.innerWidth;
  792. const viewportHeight = window.innerHeight;
  793. // 计算拖拽后的像素位置
  794. const newX = event.clientX - dragStartPos.x;
  795. const newY = event.clientY - dragStartPos.y;
  796. // 转成百分比(限制0-95,避免弹窗超出可视区)
  797. popupPosition.x = Math.max(0, Math.min(95, (newX / viewportWidth) * 100));
  798. popupPosition.y = Math.max(0, Math.min(95, (newY / viewportHeight) * 100));
  799. updateConnectionLine();
  800. };
  801. const stopDrag = () => {
  802. isDragging.value = false;
  803. document.removeEventListener('mousemove', onDrag);
  804. document.removeEventListener('mouseup', stopDrag);
  805. nextTick(() => {
  806. updateConnectionLine();
  807. });
  808. };
  809. // 修改更新连接线方法,使用经纬度坐标
  810. const updateConnectionLine = () => {
  811. if (!map.value || !popupElement.value || !featureCenter.lng || !featureCenter.lat) return;
  812. // 将地块中心点的经纬度转换为屏幕坐标
  813. const centerLatLng = L.latLng(featureCenter.lat, featureCenter.lng);
  814. const centerPoint = map.value.latLngToContainerPoint(centerLatLng);
  815. const mapRect = document.getElementById('map-container')!.getBoundingClientRect();
  816. const startX = mapRect.left + centerPoint.x;
  817. const startY = mapRect.top + centerPoint.y;
  818. // 获取弹窗中心点
  819. const popupRect = popupElement.value.getBoundingClientRect();
  820. // 视口总宽度/高度
  821. const viewportWidth = window.innerWidth;
  822. const viewportHeight = window.innerHeight;
  823. // 弹窗左上角像素坐标(百分比转像素)
  824. const popupLeft = (popupPosition.x / 100) * viewportWidth;
  825. const popupTop = (popupPosition.y / 100) * viewportHeight;
  826. // 弹窗中心点像素坐标
  827. const endX = popupLeft + popupRect.width / 2;
  828. const endY = popupTop + popupRect.height / 2;
  829. // 计算线的长度和角度
  830. const length = Math.sqrt(Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2));
  831. const angle = Math.atan2(endY - startY, endX - startX) * 180 / Math.PI;
  832. connectionLine.style = {
  833. left: startX + 'px',
  834. top: startY + 'px',
  835. width: length + 'px',
  836. transform: `rotate(${angle}deg)`,
  837. transformOrigin: '0 0',
  838. '--arrow-angle':`${angle}deg`
  839. };
  840. };
  841. const initMap = async () => {
  842. mapLoading.value = true;
  843. mapError.value = false;
  844. try {
  845. // 动态导入Leaflet
  846. if (!L) {
  847. L = await import('leaflet');
  848. await import('leaflet/dist/leaflet.css');
  849. delete (L.Icon.Default.prototype as any)._getIconUrl;
  850. L.Icon.Default.mergeOptions({
  851. iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
  852. iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
  853. shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
  854. });
  855. }
  856. // 清除现有地图
  857. if (map.value) {
  858. map.value.remove();
  859. map.value = null;
  860. }
  861. // 创建地图实例
  862. map.value = L.map('map-container', {
  863. zoomControl: true,
  864. attributionControl: false,
  865. center: [25.202903, 113.25383],
  866. zoom: 10
  867. });
  868. // WMS配置
  869. const GEOSERVER_CONFIG = {
  870. url: "/geoserver/wms",
  871. workspace: "acidmap",
  872. layerGroup: "mapwithboundary",
  873. };
  874. // WMS图层配置
  875. const wmsLayer = L.tileLayer.wms(GEOSERVER_CONFIG.url, {
  876. layers: `${GEOSERVER_CONFIG.workspace}:${GEOSERVER_CONFIG.layerGroup}`,
  877. format: "image/png",
  878. transparent: true,
  879. version: "1.1.1",
  880. crs: L.CRS.EPSG4326,
  881. attribution: "Data from GeoServer"
  882. });
  883. // 添加图层到地图
  884. wmsLayer.addTo(map.value);
  885. // 绑定点击事件
  886. map.value.on('click', handleMapClick);
  887. // 添加地图移动和缩放事件监听,实时更新连接线
  888. map.value.on('moveend zoomend', () => {
  889. if (showPopup.value && showConnectionLine.value) {
  890. updateConnectionLine();
  891. }
  892. });
  893. // 监听地图飞行动画完成事件
  894. map.value.on('moveend', () => {
  895. // 地图动画完成后显示连接线
  896. if (showPopup.value && !showConnectionLine.value && featureCenter.lng && featureCenter.lat) {
  897. showConnectionLine.value = true;
  898. nextTick(() => {
  899. updateConnectionLine();
  900. });
  901. }
  902. });
  903. mapLoading.value = false;
  904. } catch (error) {
  905. console.error('地图初始化失败:', error);
  906. mapError.value = true;
  907. mapLoading.value = false;
  908. let errorMessage = '地图初始化失败';
  909. if (error instanceof Error) {
  910. errorMessage += ': ' + error.message;
  911. }
  912. ElMessage.error(errorMessage);
  913. }
  914. };
  915. const reloadMap = () => {
  916. initMap();
  917. };
  918. // 组件卸载时清理(新增:清除高亮图层)
  919. onUnmounted(() => {
  920. if (map.value) {
  921. map.value.remove();
  922. }
  923. // 清除高亮图层
  924. if (highlightLayer.value && map.value) {
  925. map.value.removeLayer(highlightLayer.value);
  926. }
  927. // 移除拖拽事件监听
  928. document.removeEventListener('mousemove', onDrag);
  929. document.removeEventListener('mouseup', stopDrag);
  930. window.removeEventListener('resize', handleWindowResize);
  931. });
  932. onMounted(() => {
  933. nextTick(() => {
  934. initMap();
  935. });
  936. // 监听窗口缩放
  937. window.addEventListener('resize', handleWindowResize);
  938. });
  939. // 窗口缩放时重新计算连接线
  940. const handleWindowResize = () => {
  941. if (showPopup.value && showConnectionLine.value) {
  942. nextTick(() => {
  943. updateConnectionLine();
  944. });
  945. }
  946. };
  947. </script>
  948. <style scoped>
  949. .feature-popup {
  950. position: fixed;
  951. z-index: 1000;
  952. background: white;
  953. border-radius: 8px;
  954. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  955. width: 250px;
  956. }
  957. .feature-popup:active {
  958. cursor: grabbing;
  959. }
  960. .popup-content {
  961. padding: 0;
  962. }
  963. .popup-header {
  964. display: flex;
  965. justify-content: space-between;
  966. align-items: center;
  967. padding: 10px 15px 0 15px;
  968. border-bottom: 1px solid #ebeef5;
  969. background: #f5f7fa;
  970. border-radius: 8px 8px 0 0;
  971. }
  972. .popup-header h4 {
  973. margin: 0;
  974. font-size: 14px;
  975. color: #303133;
  976. }
  977. .close-btn {
  978. background: none;
  979. border: none;
  980. font-size: 18px;
  981. cursor: pointer;
  982. color: #909399;
  983. padding: 0;
  984. width: 20px;
  985. height: 20px;
  986. display: flex;
  987. align-items: center;
  988. justify-content: center;
  989. }
  990. .close-btn:hover {
  991. color: #606266;
  992. }
  993. .popup-body {
  994. padding: 10px;
  995. max-height: 500px;
  996. overflow-y: auto;
  997. }
  998. .loading-info, .error-info, .no-data {
  999. text-align: center;
  1000. color: #909399;
  1001. font-size: 14px;
  1002. display: flex;
  1003. align-items: center;
  1004. justify-content: center;
  1005. gap: 8px;
  1006. margin: 10px 0;
  1007. }
  1008. .error-info {
  1009. color: #f56c6c;
  1010. }
  1011. .feature-info {
  1012. display: flex;
  1013. flex-direction: column;
  1014. gap: 8px;
  1015. margin-bottom: 12px;
  1016. }
  1017. .info-item {
  1018. display: flex;
  1019. justify-content: space-between;
  1020. align-items: center;
  1021. }
  1022. .info-item label {
  1023. font-weight: 600;
  1024. color: #606266;
  1025. font-size: 14px;
  1026. }
  1027. .info-item span {
  1028. color: #303133;
  1029. font-size: 14px;
  1030. text-align: right;
  1031. }
  1032. .action-buttons {
  1033. display: flex;
  1034. gap: 8px;
  1035. justify-content: center;
  1036. margin: 12px 0;
  1037. padding-top: 12px;
  1038. }
  1039. .acid-reduction-input {
  1040. margin-top: 2px;
  1041. padding-top: 2px;
  1042. }
  1043. .input-buttons {
  1044. display: flex;
  1045. gap: 8px;
  1046. justify-content: flex-end;
  1047. margin-top: 12px;
  1048. }
  1049. .prediction-section {
  1050. margin-top: 2px;
  1051. padding-top: 2px;
  1052. }
  1053. .section-title {
  1054. font-weight: 600;
  1055. color: #303133;
  1056. font-size: 13px;
  1057. margin-bottom: 8px;
  1058. }
  1059. .prediction-result {
  1060. display: flex;
  1061. flex-direction: column;
  1062. gap: 8px;
  1063. }
  1064. .result-item {
  1065. display: flex;
  1066. flex-direction: column;
  1067. gap: 4px;
  1068. }
  1069. .result-item label {
  1070. font-weight: 600;
  1071. color: #606266;
  1072. font-size: 14px;
  1073. }
  1074. .point-info {
  1075. font-size: 11px;
  1076. color: #666;
  1077. background: #f8f9fa;
  1078. padding: 6px;
  1079. border-radius: 4px;
  1080. line-height: 1.4;
  1081. }
  1082. .prediction-value {
  1083. font-weight: bold;
  1084. font-size: 13px;
  1085. }
  1086. .prediction-value.reduction {
  1087. color: #e6a23c;
  1088. }
  1089. .warnings {
  1090. margin-top: 8px;
  1091. padding: 6px;
  1092. background: #fff6f6;
  1093. border: 1px solid #fbc4c4;
  1094. border-radius: 4px;
  1095. }
  1096. .warning-item {
  1097. font-size: 11px;
  1098. color: #f56c6c;
  1099. line-height: 1.3;
  1100. }
  1101. .result-buttons {
  1102. display: flex;
  1103. gap: 8px;
  1104. justify-content: flex-end;
  1105. margin-top: 12px;
  1106. }
  1107. @keyframes spin {
  1108. 0% { transform: rotate(0deg); }
  1109. 100% { transform: rotate(360deg); }
  1110. }
  1111. .map-card {
  1112. width: 850px;
  1113. flex: 1;
  1114. min-height: 600px;
  1115. margin: 0 auto;
  1116. }
  1117. .map-container {
  1118. height: 550px;
  1119. width: 100%;
  1120. position: relative;
  1121. border-radius: 4px;
  1122. overflow: hidden;
  1123. background: #f0f2f5;
  1124. border: 1px solid #dcdfe6;
  1125. }
  1126. .loading {
  1127. position: absolute;
  1128. top: 50%;
  1129. left: 50%;
  1130. transform: translate(-50%, -50%);
  1131. text-align: center;
  1132. z-index: 1000;
  1133. background: rgba(255, 255, 255, 0.95);
  1134. padding: 20px;
  1135. border-radius: 8px;
  1136. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  1137. }
  1138. .loading span {
  1139. margin-left: 8px;
  1140. color: #606266;
  1141. }
  1142. .error-tip {
  1143. position: absolute;
  1144. top: 50%;
  1145. left: 50%;
  1146. transform: translate(-50%, -50%);
  1147. width: 80%;
  1148. z-index: 1000;
  1149. }
  1150. .title {
  1151. display: flex;
  1152. align-items: center;
  1153. gap: 10px;
  1154. margin-bottom: 15px;
  1155. }
  1156. .map-title {
  1157. color: #1a365d;
  1158. font-size: 1.6rem;
  1159. font-weight: 600;
  1160. }
  1161. .section-icon {
  1162. font-size: 2.2rem;
  1163. color: #3a9fd3;
  1164. }
  1165. /* 表单样式调整 */
  1166. :deep(.el-form-item) {
  1167. margin-bottom: 12px;
  1168. }
  1169. :deep(.el-form-item__label) {
  1170. font-size: 12px;
  1171. line-height: 28px;
  1172. }
  1173. :deep(.el-input) {
  1174. font-size: 12px;
  1175. }
  1176. .popup-content {
  1177. display: flex;
  1178. flex-direction: column;
  1179. height: 100%;
  1180. }
  1181. /* 参数行布局 */
  1182. .params-row {
  1183. display: flex;
  1184. gap: 0px;
  1185. margin-bottom: 12px;
  1186. align-items: flex-start;
  1187. }
  1188. /* 只读参数项样式 */
  1189. .param-item {
  1190. flex: 1;
  1191. display: flex;
  1192. flex-direction: column;
  1193. padding: 0 4px;
  1194. }
  1195. .param-item label {
  1196. font-size: 12px;
  1197. color: #606266;
  1198. margin-bottom: 4px;
  1199. font-weight: 600;
  1200. }
  1201. .param-item span {
  1202. font-size: 12px;
  1203. color: #303133;
  1204. padding: 6px 12px;
  1205. background: #f5f7fa;
  1206. border-radius: 4px;
  1207. min-height: 28px;
  1208. display: flex;
  1209. align-items: center;
  1210. }
  1211. .current-ph-value {
  1212. width: 100%;
  1213. height: 32px;
  1214. line-height: 32px;
  1215. padding: 0 12px;
  1216. background: #f5f7fa;
  1217. border: 1px solid #dcdfe6;
  1218. border-radius: 4px;
  1219. font-size: 12px;
  1220. color: #606266;
  1221. display: flex;
  1222. align-items: center;
  1223. }
  1224. /* 只读项的特殊样式 */
  1225. .readonly-item :deep(.el-form-item__label) {
  1226. color: #909399;
  1227. }
  1228. .readonly-param span {
  1229. background: #f0f2f5;
  1230. color: #909399;
  1231. }
  1232. .loading-text, .no-data-text {
  1233. color: #c0c4cc !important;
  1234. font-style: italic;
  1235. }
  1236. /* 紧凑的表单项样式 */
  1237. .form-item-compact {
  1238. flex: 1;
  1239. margin-bottom: 0 !important;
  1240. }
  1241. :deep(.form-item-compact .el-form-item__content) {
  1242. margin-left: 0 !important;
  1243. }
  1244. :deep(.form-item-compact .el-form-item__label) {
  1245. width: 60px !important; /* 调整标签宽度 */
  1246. text-align: right;
  1247. padding-right: 8px;
  1248. }
  1249. :deep(.form-item-compact .el-input) {
  1250. width: 100%;
  1251. }
  1252. /* 确保 Tooltip 能正常显示 */
  1253. :deep(.el-tooltip__trigger) {
  1254. width: 100%;
  1255. }
  1256. /* 确保 Tooltip 有足够的 z-index */
  1257. :deep(.el-popper) {
  1258. z-index: 10000 !important;
  1259. }
  1260. :deep(.el-input-number .el-input__inner)::-webkit-outer-spin-button,
  1261. :deep(.el-input-number .el-input__inner)::-webkit-inner-spin-button {
  1262. -webkit-appearance: none;
  1263. margin: 0;
  1264. }
  1265. :deep(.el-input .el-input__inner[type="number"])::-webkit-outer-spin-button,
  1266. :deep(.el-input .el-input__inner[type="number"])::-webkit-inner-spin-button {
  1267. -webkit-appearance: none;
  1268. margin: 0;
  1269. }
  1270. .prediction-value.reduction {
  1271. color: #e6a23c; /* 降酸结果用橙色 */
  1272. }
  1273. .prediction-value.inversion {
  1274. color: #48bb78; /* 反酸结果用绿色 */
  1275. }
  1276. /* 新增:高亮图层的z-index确保在最上层 */
  1277. :deep(.leaflet-geojson) {
  1278. z-index: 999 !important;
  1279. }
  1280. /* 连接线样式 */
  1281. .connection-line {
  1282. position: fixed;
  1283. height: 1px;
  1284. background: transparent;
  1285. background-image: linear-gradient(to right, #409eff 50%, transparent 50%);
  1286. background-size: 10px 1px;
  1287. border: none;
  1288. z-index: 999;
  1289. pointer-events: none;
  1290. --arrow-angle: 0deg;
  1291. }
  1292. .connection-line::before {
  1293. content: '';
  1294. position: absolute;
  1295. left: -5px; /* 箭头在连接线起点左侧,对准地块 */
  1296. top: 50%;
  1297. /* 跟随连接线角度旋转,保持垂直居中 */
  1298. transform: translateY(-50%) rotate(var(--arrow-angle));
  1299. /* 三角形箭头:右向箭头(指向地块) */
  1300. width: 0;
  1301. height: 0;
  1302. border-style: solid;
  1303. border-width: 4px 8px 4px 0; /* 箭头尺寸:高4px*2,长8px */
  1304. border-color: transparent #409eff transparent transparent; /* 箭头颜色与连接线一致 */
  1305. transform-origin: center center;
  1306. z-index: 1;
  1307. }
  1308. </style>