|
|
@@ -0,0 +1,1526 @@
|
|
|
+<template>
|
|
|
+ <el-card class="map-card">
|
|
|
+ <div class="title">
|
|
|
+ <div class="section-icon">🗺️</div>
|
|
|
+ <p class="map-title">南雄市细粒度地块级酸化预测</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div id="map-container" class="map-container">
|
|
|
+ <div v-if="mapLoading" class="loading">
|
|
|
+ <div class="loading-spinner"></div>
|
|
|
+ <span>地图加载中...</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="mapError" class="error-tip">
|
|
|
+ <el-alert title="地图加载失败" type="error" show-icon>
|
|
|
+ <template #description>
|
|
|
+ <p>请检查GeoServer服务和配置</p>
|
|
|
+ <el-button @click="reloadMap" type="primary">重试加载</el-button>
|
|
|
+ </template>
|
|
|
+ </el-alert>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 整合后的信息弹窗 -->
|
|
|
+ <div v-if="showPopup"
|
|
|
+ ref="popupElement"
|
|
|
+ class="feature-popup fixed-center"
|
|
|
+ :style="{
|
|
|
+ left: popupPosition.x + '%',
|
|
|
+ top: popupPosition.y + '%',
|
|
|
+ translate:'none'
|
|
|
+ }">
|
|
|
+ <div class="popup-content">
|
|
|
+ <div class="popup-header" @mousedown="startDrag">
|
|
|
+ <!-- 地块信息 -->
|
|
|
+ <div v-if="featureInfo.loading" class="loading-info">
|
|
|
+ <div class="loading-spinner small"></div>
|
|
|
+ <span>加载地块信息中...</span>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="featureInfo.error" class="error-info">获取地块信息失败</div>
|
|
|
+ <div v-else-if="featureInfo.data" class="feature-info">
|
|
|
+ <div class="info-item">
|
|
|
+ <label>所属村:</label>
|
|
|
+ <span>{{ featureInfo.data.village || '未知' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <label>用地类型:</label>
|
|
|
+ <span>{{ featureInfo.data.landType || '未知' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <label>当前pH:</label>
|
|
|
+ <span v-if="phLoading">加载中...</span>
|
|
|
+ <span v-else>{{ currentPH?.toFixed(2) || '未获取' }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else class="no-data">无地块信息</div>
|
|
|
+ <button @click="closePopup" class="close-btn">×</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="popup-body">
|
|
|
+ <!-- 操作按钮(仅在无参数输入、无结果时显示) -->
|
|
|
+ <div class="action-buttons" v-if="!showAcidReductionInput && !showAcidInversionInput && !showPredictionResult">
|
|
|
+ <el-button
|
|
|
+ size="small"
|
|
|
+ type="success"
|
|
|
+ @click="startAcidInversionPrediction"
|
|
|
+ >
|
|
|
+ 反酸预测
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ size="small"
|
|
|
+ type="primary"
|
|
|
+ @click="startAcidReductionPrediction"
|
|
|
+ >
|
|
|
+ 降酸预测
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 降酸参数 + 结果(同区域展示,无下方弹出) -->
|
|
|
+ <div v-if="showAcidReductionInput || (showPredictionResult && currentPredictionType === 'reduction')" class="prediction-card">
|
|
|
+ <!-- 降酸参数输入(未预测时显示) -->
|
|
|
+ <div v-if="showAcidReductionInput && !showPredictionResult">
|
|
|
+ <div class="section-title">降酸预测参数</div>
|
|
|
+ <el-form
|
|
|
+ :model="acidReductionParams"
|
|
|
+ :rules="acidReductionRules"
|
|
|
+ ref="acidReductionFormRef"
|
|
|
+ label-width="80px"
|
|
|
+ size="small"
|
|
|
+ >
|
|
|
+ <div class="params-row">
|
|
|
+ <el-form-item label="当前pH" class="form-item-compact readonly-item">
|
|
|
+ <div class="current-ph-value">
|
|
|
+ <span v-if="phLoading" class="loading-text">加载中...</span>
|
|
|
+ <span v-else-if="currentPH !== null">{{ currentPH.toFixed(2) }}</span>
|
|
|
+ <span v-else class="no-data-text">未获取</span>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="目标pH" prop="target_pH" class="form-item-compact">
|
|
|
+ <el-input
|
|
|
+ v-model.number="acidReductionParams.target_pH"
|
|
|
+ type="number"
|
|
|
+ step="0.01"
|
|
|
+ placeholder="0-14"
|
|
|
+ :disabled="phLoading"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ </div>
|
|
|
+ </el-form>
|
|
|
+ <div class="input-buttons">
|
|
|
+ <el-button size="small" @click="cancelAcidReduction" :disabled="phLoading">取消</el-button>
|
|
|
+ <el-button size="small" type="primary" @click="confirmAcidReduction" :loading="predictionLoading || phLoading">
|
|
|
+ 开始预测
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 降酸结果(预测后替换参数区显示) -->
|
|
|
+ <div v-if="showPredictionResult && currentPredictionType === 'reduction'" class="result-section">
|
|
|
+ <div class="section-title">降酸预测结果</div>
|
|
|
+ <div v-if="predictionLoading" class="loading-info">
|
|
|
+ <div class="loading-spinner small"></div>
|
|
|
+ <span>预测中...</span>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="predictionError" class="error-info">
|
|
|
+ {{ predictionError }}
|
|
|
+ </div>
|
|
|
+ <div v-else-if="predictionResult" class="result-content">
|
|
|
+ <div class="result-item">
|
|
|
+ <span class="prediction-value reduction">每亩地土壤表层20cm撒{{ formatPredictionValue(predictionResult.prediction_reduce)}} 吨</span>
|
|
|
+ </div>
|
|
|
+ <div class="result-buttons">
|
|
|
+ <el-button size="small" @click="resetPrediction">重新预测</el-button>
|
|
|
+ <el-button size="small" type="primary" @click="closePopup">关闭</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 反酸参数 + 结果(和降酸完全同样式,同区域替换,无下方弹出) -->
|
|
|
+ <div v-if="showAcidInversionInput || (showPredictionResult && currentPredictionType === 'inversion')" class="prediction-card">
|
|
|
+ <!-- 反酸参数输入(未预测时显示) -->
|
|
|
+ <div v-if="showAcidInversionInput && !showPredictionResult">
|
|
|
+ <div class="section-title">反酸预测参数</div>
|
|
|
+ <div class="params-row">
|
|
|
+ <el-form-item label="当前pH" class="form-item-compact readonly-item">
|
|
|
+ <div class="current-ph-value">
|
|
|
+ <span v-if="phLoading">加载中...</span>
|
|
|
+ <span v-else>{{ currentPH?.toFixed(2) || '未获取' }}</span>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+ </div>
|
|
|
+ <div class="input-buttons" style="margin-top: 16px;">
|
|
|
+ <el-button size="small" @click="cancelAcidInversion" :disabled="phLoading">取消</el-button>
|
|
|
+ <el-button size="small" type="primary" @click="confirmAcidInversion" :loading="predictionLoading || phLoading">
|
|
|
+ 开始预测
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 反酸结果(预测后替换参数区显示,和降酸样式1:1) -->
|
|
|
+ <div v-if="showPredictionResult && currentPredictionType === 'inversion'" class="result-section">
|
|
|
+ <div class="section-title">反酸预测结果</div>
|
|
|
+ <div v-if="predictionLoading" class="loading-info">
|
|
|
+ <div class="loading-spinner small"></div>
|
|
|
+ <span>预测中...</span>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="predictionError" class="error-info">
|
|
|
+ {{ predictionError }}
|
|
|
+ </div>
|
|
|
+ <div v-else-if="predictionResult" class="result-content">
|
|
|
+ <div class="result-item">
|
|
|
+ <span class="prediction-value inversion">ΔpH{{ formatPredictionValue(predictionResult.prediction_reflux)}} </span>
|
|
|
+ </div>
|
|
|
+ <div class="result-buttons">
|
|
|
+ <el-button size="small" @click="resetPrediction">重新预测</el-button>
|
|
|
+ <el-button size="small" type="primary" @click="closePopup">关闭</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!--连接线容器-->
|
|
|
+ <div v-if="showPopup && showConnectionLine"
|
|
|
+ class="connection-line"
|
|
|
+ :style="connectionLine.style">
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { reactive, ref, nextTick, onMounted, onUnmounted, computed } from "vue";
|
|
|
+import { ElMessage } from "element-plus";
|
|
|
+import type { FormInstance } from "element-plus";
|
|
|
+import { api8000 } from "@/utils/request";
|
|
|
+import { api5000 } from "../../../utils/request";
|
|
|
+
|
|
|
+// 新增:反酸预测相关状态(区分降酸/反酸的显示)
|
|
|
+const phLoading = ref(false); // 控制当前pH的加载状态(点击预测后先加载pH)
|
|
|
+
|
|
|
+// 反酸预测显示状态(补充缺失的变量)
|
|
|
+const showAcidInversionInput = ref(false);
|
|
|
+
|
|
|
+// 地图状态
|
|
|
+const mapLoading = ref(true);
|
|
|
+const mapError = ref(false);
|
|
|
+const map = ref<any>(null);
|
|
|
+const showPopup = ref(false);
|
|
|
+
|
|
|
+// 新增:高亮图层(存储当前选中地块的高亮图层)
|
|
|
+const highlightLayer = ref<any>(null);
|
|
|
+// 新增:控制连接线显示的标志
|
|
|
+const showConnectionLine = ref(false);
|
|
|
+
|
|
|
+// 预测状态
|
|
|
+const predictionLoading = ref(false);
|
|
|
+const predictionError = ref('');
|
|
|
+const predictionResult = ref<any>(null);
|
|
|
+const showPredictionResult = ref(false);
|
|
|
+const showAcidReductionInput = ref(false);
|
|
|
+const currentPH = ref<number | null>(null);
|
|
|
+
|
|
|
+let L: any = null;
|
|
|
+
|
|
|
+// 南雄固定参数(全程使用,无用户输入)
|
|
|
+const nanxiongFixedParams = reactive({
|
|
|
+ NO3: 33.80,
|
|
|
+ NH4: 4.20,
|
|
|
+ Al: 4.92,
|
|
|
+ FeO: 43.12
|
|
|
+});
|
|
|
+
|
|
|
+// 地块信息状态
|
|
|
+const featureInfo = reactive({
|
|
|
+ loading: false,
|
|
|
+ error: false,
|
|
|
+ data: null as any
|
|
|
+});
|
|
|
+
|
|
|
+// 新增:地块中心点坐标(用于连接线起点)
|
|
|
+const featureCenter = reactive({
|
|
|
+ lng: 0,
|
|
|
+ lat: 0
|
|
|
+});
|
|
|
+
|
|
|
+// 当前坐标
|
|
|
+const currentClickCoords = reactive({
|
|
|
+ lng: 0,
|
|
|
+ lat: 0
|
|
|
+});
|
|
|
+
|
|
|
+const currentPredictionType = ref<'reduction' | 'inversion' | null>(null);
|
|
|
+
|
|
|
+// 降酸预测参数输入相关(仅保留target_pH,删除NO3/NH4)
|
|
|
+const acidReductionFormRef = ref<FormInstance | null>(null);
|
|
|
+interface AcidReductionParams {
|
|
|
+ target_pH?: number;
|
|
|
+}
|
|
|
+const acidReductionParams = reactive<AcidReductionParams>({
|
|
|
+ target_pH: undefined
|
|
|
+});
|
|
|
+
|
|
|
+// 用地类型枚举
|
|
|
+const LandTypeEnum = {
|
|
|
+ DRY_LAND: '旱地', // 旱地
|
|
|
+ PADDY_FIELD: '水田' // 水田
|
|
|
+} as const;
|
|
|
+
|
|
|
+
|
|
|
+// 计算属性:判断当前是否为水田
|
|
|
+const isPaddyField = computed(() => {
|
|
|
+ return featureInfo.data?.landType === LandTypeEnum.PADDY_FIELD;
|
|
|
+});
|
|
|
+
|
|
|
+// 降酸预测校验规则(仅保留target_pH)
|
|
|
+const acidReductionRules = reactive({
|
|
|
+ target_pH: [
|
|
|
+ { required: true, message: '请输入目标pH值', trigger: 'blur' },
|
|
|
+ { type: 'number', message: '请输入有效数字', trigger: 'blur' },
|
|
|
+ {
|
|
|
+ validator: (_rule: any, value: number, callback: any) => {
|
|
|
+ if (value < 0 || value > 14) {
|
|
|
+ callback(new Error('值范围在0-14之间'));
|
|
|
+ } else {
|
|
|
+ callback();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ trigger: 'blur'
|
|
|
+ }
|
|
|
+ ]
|
|
|
+});
|
|
|
+
|
|
|
+
|
|
|
+// 格式化预测值
|
|
|
+const formatPredictionValue = (value: any): string => {
|
|
|
+ console.log('预测值原始数据:', value, '类型:', typeof value);
|
|
|
+
|
|
|
+ if (value === null || value === undefined) return '无数据';
|
|
|
+
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ if (value.length === 0) return '无数据';
|
|
|
+ const num = Number(value[0]);
|
|
|
+ return isNaN(num) ? '无效数据' : num.toFixed(4);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof value === 'object') {
|
|
|
+ console.warn('预测值是对象类型:', value);
|
|
|
+ return '数据格式错误';
|
|
|
+ }
|
|
|
+
|
|
|
+ const num = Number(value);
|
|
|
+ return isNaN(num) ? '无效数据' : num.toFixed(4);
|
|
|
+};
|
|
|
+
|
|
|
+// 修改:在原地放大地块
|
|
|
+const drawHighlightFeature = (geoJsonFeature: any) => {
|
|
|
+ if (!map.value || !geoJsonFeature || !geoJsonFeature.geometry) return;
|
|
|
+
|
|
|
+ // 清除旧的高亮图层
|
|
|
+ if (highlightLayer.value) {
|
|
|
+ map.value.removeLayer(highlightLayer.value);
|
|
|
+ highlightLayer.value = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算放大后的坐标(原地放大)
|
|
|
+ const scaleFeatureCoordinates = (feature: any, scale: number) => {
|
|
|
+ const scaledFeature = JSON.parse(JSON.stringify(feature));
|
|
|
+
|
|
|
+ const scaleCoordinates = (coords: any): any => {
|
|
|
+ if (Array.isArray(coords[0]) && Array.isArray(coords[0][0]) && Array.isArray(coords[0][0][0])) {
|
|
|
+ return coords.map((polygon: any) =>
|
|
|
+ polygon.map((ring: any) => scaleCoordinates(ring))
|
|
|
+ );
|
|
|
+ } else if (Array.isArray(coords[0]) && Array.isArray(coords[0][0])) {
|
|
|
+ return coords.map((ring: any) => scaleCoordinates(ring));
|
|
|
+ } else if (Array.isArray(coords[0])) {
|
|
|
+ return coords.map((point: any) => {
|
|
|
+ const lng = point[0];
|
|
|
+ const lat = point[1];
|
|
|
+
|
|
|
+ const centerLng = coords[0][0];
|
|
|
+ const centerLat = coords[0][1];
|
|
|
+
|
|
|
+ const deltaLng = lng - centerLng;
|
|
|
+ const deltaLat = lat - centerLat;
|
|
|
+
|
|
|
+ return [
|
|
|
+ centerLng + deltaLng * scale,
|
|
|
+ centerLat + deltaLat * scale
|
|
|
+ ];
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return coords;
|
|
|
+ };
|
|
|
+
|
|
|
+ scaledFeature.geometry.coordinates = scaleCoordinates(feature.geometry.coordinates);
|
|
|
+ return scaledFeature;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 创建放大1倍的地块
|
|
|
+ const scaledFeature = scaleFeatureCoordinates(geoJsonFeature, 1);
|
|
|
+
|
|
|
+ // 使用放大后的GeoJSON创建高亮图层
|
|
|
+ highlightLayer.value = L.geoJSON(scaledFeature, {
|
|
|
+ style: {
|
|
|
+ color: '#000',
|
|
|
+ weight: 4,
|
|
|
+ fillColor: '#ff4d4f',
|
|
|
+ fillOpacity: 1,
|
|
|
+ opacity: 1
|
|
|
+ }
|
|
|
+ }).addTo(map.value);
|
|
|
+
|
|
|
+ // 计算地块的中心点
|
|
|
+ const bounds = highlightLayer.value.getBounds();
|
|
|
+ const center = bounds.getCenter();
|
|
|
+ featureCenter.lng = center.lng;
|
|
|
+ featureCenter.lat = center.lat;
|
|
|
+
|
|
|
+ // 确保高亮图层在最上层
|
|
|
+ highlightLayer.value.bringToFront();
|
|
|
+ // 先隐藏连接线
|
|
|
+ showConnectionLine.value = false;
|
|
|
+ // 计算放大后地块的边界
|
|
|
+ const scaledBounds = highlightLayer.value.getBounds();
|
|
|
+
|
|
|
+ // 调整地图视图以更好地显示放大的地块
|
|
|
+ map.value.flyToBounds(scaledBounds, {
|
|
|
+ padding: [50, 50], // 上下左右各50像素的内边距
|
|
|
+ duration: 0.3, // 动画时长
|
|
|
+ maxZoom: 16 // 最大缩放级别限制
|
|
|
+ });
|
|
|
+
|
|
|
+ updateConnectionLine();
|
|
|
+};
|
|
|
+
|
|
|
+// 获取地块信息
|
|
|
+const getFeatureInfo = async (_latlng: any, point: any): Promise<boolean> => {
|
|
|
+ if (!map.value) return false;
|
|
|
+
|
|
|
+ featureInfo.loading = true;
|
|
|
+ featureInfo.error = false;
|
|
|
+ featureInfo.data = null;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const GEOSERVER_CONFIG = {
|
|
|
+ url: "/geoserver/wms",
|
|
|
+ workspace: "acidmap",
|
|
|
+ layerGroup: "xiafencun",
|
|
|
+ };
|
|
|
+
|
|
|
+ const bounds = map.value.getBounds();
|
|
|
+ const size = map.value.getSize();
|
|
|
+
|
|
|
+ const params = new URLSearchParams();
|
|
|
+ params.append('service', 'WMS');
|
|
|
+ params.append('version', '1.1.1');
|
|
|
+ params.append('request', 'GetFeatureInfo');
|
|
|
+ params.append('layers', `${GEOSERVER_CONFIG.workspace}:${GEOSERVER_CONFIG.layerGroup}`);
|
|
|
+ params.append('query_layers', `${GEOSERVER_CONFIG.workspace}:${GEOSERVER_CONFIG.layerGroup}`);
|
|
|
+ params.append('info_format', 'application/json');
|
|
|
+ params.append('feature_count', '10');
|
|
|
+ params.append('x', Math.round(point.x).toString());
|
|
|
+ params.append('y', Math.round(point.y).toString());
|
|
|
+ params.append('width', size.x.toString());
|
|
|
+ params.append('height', size.y.toString());
|
|
|
+ params.append('srs', 'EPSG:4326');
|
|
|
+ params.append('bbox', `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`);
|
|
|
+
|
|
|
+ const url = `${GEOSERVER_CONFIG.url}?${params.toString()}`;
|
|
|
+ const response = await fetch(url);
|
|
|
+
|
|
|
+ if (!response.ok) {
|
|
|
+ throw new Error(`HTTP error! status: ${response.status}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = await response.json();
|
|
|
+
|
|
|
+ if (data.features && data.features.length > 0) {
|
|
|
+ const feature = data.features[0];
|
|
|
+ const properties = feature.properties;
|
|
|
+ const hasValidData = properties.QSDWMC || properties.DLMC;
|
|
|
+
|
|
|
+ if (hasValidData) {
|
|
|
+ featureInfo.data = {
|
|
|
+ village: properties.QSDWMC,
|
|
|
+ landType: properties.DLMC,
|
|
|
+ };
|
|
|
+ // 绘制高亮地块
|
|
|
+ drawHighlightFeature(feature);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取地块信息失败:', error);
|
|
|
+ featureInfo.error = true;
|
|
|
+ return false;
|
|
|
+ } finally {
|
|
|
+ featureInfo.loading = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 新增:获取水田地块基础参数(CEC/OM)
|
|
|
+const fetchPaddyFieldBaseParams = async (lng: number, lat: number) => {
|
|
|
+ try {
|
|
|
+ // 调用nearest_point接口(仅传必要参数)
|
|
|
+ const response = await api8000.get('/api/vector/nearest-with-predictions', {
|
|
|
+ params: {
|
|
|
+ target_lon: lng.toString(),
|
|
|
+ target_lat: lat.toString(),
|
|
|
+ city:'nanxiong'
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const nearestPoint = response.data.nearest_point || {};
|
|
|
+ // 修复取值逻辑:存在则转数字,否则用默认值
|
|
|
+ return {
|
|
|
+ CEC: nearestPoint.cec !== undefined ? Number(nearestPoint.cec) : 7.14,
|
|
|
+ OM: nearestPoint.om !== undefined ? Number(nearestPoint.om) : 22.12
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取水田基础参数失败:', error);
|
|
|
+ ElMessage.error('获取地块基础参数失败,使用默认值');
|
|
|
+ // 接口调用失败时返回默认值
|
|
|
+ return { CEC: 7.14, OM: 22.12 };
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 新增:缓存已获取的原始pH值
|
|
|
+const originalPH = ref<number | null>(null);
|
|
|
+
|
|
|
+// 新增:单独获取当前pH的方法
|
|
|
+const fetchCurrentPH = async () => {
|
|
|
+ if (originalPH.value !== null) {
|
|
|
+ currentPH.value = originalPH.value;
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (phLoading.value) return false;
|
|
|
+
|
|
|
+ phLoading.value = true;
|
|
|
+ try {
|
|
|
+ const params = {
|
|
|
+ target_lon: currentClickCoords.lng.toString(),
|
|
|
+ target_lat: currentClickCoords.lat.toString(),
|
|
|
+ city:'nanxiong'
|
|
|
+ };
|
|
|
+
|
|
|
+ const response = await api8000.get('/api/vector/nearest-with-predictions', { params });
|
|
|
+ const phValue = response.data.nearest_point?.ph !== undefined
|
|
|
+ ? Number(response.data.nearest_point.ph)
|
|
|
+ : null;
|
|
|
+
|
|
|
+ originalPH.value = phValue;
|
|
|
+ currentPH.value = phValue;
|
|
|
+ return phValue !== null;
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取当前pH失败:', error);
|
|
|
+ ElMessage.error('获取土壤当前pH值失败,请重试');
|
|
|
+ currentPH.value = null;
|
|
|
+ return false;
|
|
|
+ } finally {
|
|
|
+ phLoading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 降酸预测:点击后先加载pH,再显示输入区
|
|
|
+const startAcidReductionPrediction = async () => {
|
|
|
+ // 先获取当前pH
|
|
|
+ const fetchSuccess = await fetchCurrentPH();
|
|
|
+ if (!fetchSuccess) return;
|
|
|
+
|
|
|
+ // pH获取成功,显示降酸输入区
|
|
|
+ currentPredictionType.value = 'reduction';
|
|
|
+ showAcidReductionInput.value = true;
|
|
|
+ showPredictionResult.value = false;
|
|
|
+};
|
|
|
+
|
|
|
+// 反酸预测:点击后先加载pH,再显示反酸区域
|
|
|
+const startAcidInversionPrediction = async () => {
|
|
|
+ if (!featureInfo.data?.landType) {
|
|
|
+ ElMessage.error('请先选择有效的地块(需包含用地类型)');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 先获取当前pH
|
|
|
+ const fetchSuccess = await fetchCurrentPH();
|
|
|
+ if (!fetchSuccess) return;
|
|
|
+
|
|
|
+ // pH获取成功,显示反酸区域(无输入参数)
|
|
|
+ currentPredictionType.value = 'inversion';
|
|
|
+ showAcidInversionInput.value = true;
|
|
|
+ showAcidReductionInput.value = false;
|
|
|
+ showPredictionResult.value = false;
|
|
|
+};
|
|
|
+
|
|
|
+// 取消降酸预测
|
|
|
+const cancelAcidReduction = () => {
|
|
|
+ showAcidReductionInput.value = false;
|
|
|
+ currentPredictionType.value = null;
|
|
|
+};
|
|
|
+
|
|
|
+// 取消反酸预测
|
|
|
+const cancelAcidInversion = () => {
|
|
|
+ showAcidInversionInput.value = false;
|
|
|
+ currentPredictionType.value = null;
|
|
|
+};
|
|
|
+
|
|
|
+// 确认降酸预测
|
|
|
+const confirmAcidReduction = async () => {
|
|
|
+ if (!acidReductionFormRef.value) return;
|
|
|
+ if (currentPH.value === null) {
|
|
|
+ ElMessage.error('请先获取当前pH值');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ await acidReductionFormRef.value.validate();
|
|
|
+
|
|
|
+ // 调用预测接口(仅传target_pH,NO3/NH4用固定值)
|
|
|
+ await callPredictionAPI(
|
|
|
+ currentClickCoords.lng,
|
|
|
+ currentClickCoords.lat,
|
|
|
+ {
|
|
|
+ target_pH: acidReductionParams.target_pH!
|
|
|
+ }
|
|
|
+ );
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error('输入参数不合法,请检查后重试');
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 确认反酸预测(无表单校验,直接调用)
|
|
|
+const confirmAcidInversion = async () => {
|
|
|
+ if (currentPH.value === null) {
|
|
|
+ ElMessage.error('请先获取当前pH值');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!featureInfo.data?.landType) {
|
|
|
+ ElMessage.error('未获取到地块用地类型,无法进行预测');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 直接调用预测接口(无用户输入参数)
|
|
|
+ await callPredictionAPI(
|
|
|
+ currentClickCoords.lng,
|
|
|
+ currentClickCoords.lat
|
|
|
+ );
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error('预测请求失败,请稍后重试');
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 调用预测接口
|
|
|
+const callPredictionAPI = async (
|
|
|
+ lng: number,
|
|
|
+ lat: number,
|
|
|
+ params?: {
|
|
|
+ target_pH?: number;
|
|
|
+ }
|
|
|
+) => {
|
|
|
+ predictionLoading.value = true;
|
|
|
+ predictionError.value = '';
|
|
|
+ predictionResult.value = null;
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 区分预测类型处理
|
|
|
+ if (currentPredictionType.value === 'inversion' && isPaddyField.value) {
|
|
|
+ // ========== 水田反酸预测逻辑 ==========
|
|
|
+ // 1. 先获取地块基础参数(CEC/OM)
|
|
|
+ const baseParams = await fetchPaddyFieldBaseParams(lng, lat);
|
|
|
+
|
|
|
+ // 2. 合并:基础参数 + 南雄固定参数
|
|
|
+ const requestData = {
|
|
|
+ model_id: 36, // 水田反酸接口固定model_id
|
|
|
+ parameters: {
|
|
|
+ CEC: baseParams.CEC,
|
|
|
+ NH4: nanxiongFixedParams.NH4,
|
|
|
+ NO3: nanxiongFixedParams.NO3,
|
|
|
+ Al: nanxiongFixedParams.Al,
|
|
|
+ FeO: nanxiongFixedParams.FeO,
|
|
|
+ OM: baseParams.OM
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 3. 调用水田反酸POST接口
|
|
|
+ const response = await api5000.post('/predict', requestData);
|
|
|
+
|
|
|
+ // 4. 处理返回结果
|
|
|
+ predictionResult.value = {
|
|
|
+ prediction_reflux: response.data.result?.[0],
|
|
|
+ nearest_point: { ph: originalPH.value }
|
|
|
+ };
|
|
|
+ currentPH.value = originalPH.value;
|
|
|
+
|
|
|
+ } else {
|
|
|
+ // ========== 其他预测类型(降酸/非水田反酸) ==========
|
|
|
+ const requestParams: Record<string, string | number> = {
|
|
|
+ target_lon: lng,
|
|
|
+ target_lat: lat,
|
|
|
+ city:'nanxiong',
|
|
|
+ };
|
|
|
+
|
|
|
+ if (currentPredictionType.value === 'reduction') {
|
|
|
+ requestParams.prediction_type = 'reduce';
|
|
|
+ if (params?.target_pH) requestParams.target_pH = params.target_pH;
|
|
|
+ } else if (currentPredictionType.value === 'inversion') {
|
|
|
+ requestParams.prediction_type = 'reflux';
|
|
|
+ }
|
|
|
+
|
|
|
+ const apiUrl = '/api/vector/nearest-with-predictions';
|
|
|
+ const response = await api8000.get(apiUrl, { params: requestParams });
|
|
|
+ predictionResult.value = response.data;
|
|
|
+
|
|
|
+ // 同步缓存pH值
|
|
|
+ const newPH = predictionResult.value.nearest_point?.ph !== undefined
|
|
|
+ ? Number(predictionResult.value.nearest_point.ph)
|
|
|
+ : originalPH.value;
|
|
|
+ currentPH.value = newPH;
|
|
|
+ originalPH.value = newPH;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 通用结果处理
|
|
|
+ showPredictionResult.value = true;
|
|
|
+ showAcidReductionInput.value = false;
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ currentPH.value = null;
|
|
|
+ console.error('调用预测接口失败:', error);
|
|
|
+ // 友好的错误提示
|
|
|
+ predictionError.value = currentPredictionType.value === 'inversion' && isPaddyField.value
|
|
|
+ ? '水田反酸预测失败,请检查参数或重试'
|
|
|
+ : '预测请求失败,请稍后重试';
|
|
|
+ } finally {
|
|
|
+ predictionLoading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 重置预测
|
|
|
+const resetPrediction = () => {
|
|
|
+ showPredictionResult.value = false;
|
|
|
+ predictionResult.value = null;
|
|
|
+ currentPredictionType.value = null;
|
|
|
+ showAcidReductionInput.value = false;
|
|
|
+ currentPH.value = originalPH.value;
|
|
|
+ // 重置降酸参数
|
|
|
+ acidReductionParams.target_pH = undefined;
|
|
|
+};
|
|
|
+
|
|
|
+// 地图点击事件处理
|
|
|
+const handleMapClick = async (e: any) => {
|
|
|
+ const lng = e.latlng.lng;
|
|
|
+ const lat = e.latlng.lat;
|
|
|
+
|
|
|
+ // 更新当前坐标
|
|
|
+ currentClickCoords.lng = lng;
|
|
|
+ currentClickCoords.lat = lat;
|
|
|
+
|
|
|
+ // 重置地块中心点
|
|
|
+ featureCenter.lng = 0;
|
|
|
+ featureCenter.lat = 0;
|
|
|
+
|
|
|
+ // 计算点击点的屏幕坐标
|
|
|
+ const containerPoint = map.value.latLngToContainerPoint(e.latlng);
|
|
|
+ const mapRect = document.getElementById('map-container')!.getBoundingClientRect();
|
|
|
+
|
|
|
+ clickPoint.x = mapRect.left + containerPoint.x;
|
|
|
+ clickPoint.y = mapRect.top + containerPoint.y;
|
|
|
+
|
|
|
+ // 重置预测状态
|
|
|
+ resetPrediction();
|
|
|
+ originalPH.value = null;
|
|
|
+ currentPH.value = null;
|
|
|
+ featureInfo.data = null;
|
|
|
+
|
|
|
+ // 先清除旧的高亮图层
|
|
|
+ if (highlightLayer.value) {
|
|
|
+ map.value.removeLayer(highlightLayer.value);
|
|
|
+ highlightLayer.value = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取地块信息
|
|
|
+ try {
|
|
|
+ const hasFeature = await getFeatureInfo(e.latlng, containerPoint);
|
|
|
+
|
|
|
+ if (hasFeature) {
|
|
|
+ // 设置弹窗初始位置
|
|
|
+ popupPosition.x = 35;
|
|
|
+ popupPosition.y = 35;
|
|
|
+
|
|
|
+ showPopup.value = true;
|
|
|
+ showConnectionLine.value = false;
|
|
|
+
|
|
|
+ await fetchCurrentPH();
|
|
|
+ // 使用点击点作为连接线起点
|
|
|
+ featureCenter.lng = lng;
|
|
|
+ featureCenter.lat = lat;
|
|
|
+ // 直接显示连接线
|
|
|
+ showConnectionLine.value = true;
|
|
|
+ nextTick(() => {
|
|
|
+ updateConnectionLine();
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ showPopup.value = false;
|
|
|
+ showConnectionLine.value = false;
|
|
|
+
|
|
|
+ ElMessage({
|
|
|
+ message: '请点击有效地块',
|
|
|
+ type: 'info',
|
|
|
+ offset: 250,
|
|
|
+ duration: 2000,
|
|
|
+ customClass: 'custom-land-message',
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('处理地块信息失败:', error);
|
|
|
+ // 清除高亮
|
|
|
+ if (highlightLayer.value) {
|
|
|
+ map.value.removeLayer(highlightLayer.value);
|
|
|
+ highlightLayer.value = null;
|
|
|
+ }
|
|
|
+ showPopup.value = false;
|
|
|
+ showConnectionLine.value = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 使用点击点作为连接线起点
|
|
|
+ featureCenter.lng = lng;
|
|
|
+ featureCenter.lat = lat;
|
|
|
+ nextTick(() => {
|
|
|
+ updateConnectionLine();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 关闭弹窗(清除高亮图层)
|
|
|
+const closePopup = () => {
|
|
|
+ showPopup.value = false;
|
|
|
+ showConnectionLine.value = false;
|
|
|
+ resetPrediction();
|
|
|
+ featureInfo.data = null;
|
|
|
+ // 清空pH缓存
|
|
|
+ originalPH.value = null;
|
|
|
+ currentPH.value = null;
|
|
|
+ // 清除高亮图层
|
|
|
+ if (highlightLayer.value && map.value) {
|
|
|
+ map.value.removeLayer(highlightLayer.value);
|
|
|
+ highlightLayer.value = null;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 新增:弹窗拖动相关状态
|
|
|
+const popupElement = ref<HTMLElement>();
|
|
|
+const popupPosition = reactive({
|
|
|
+ x: 35,
|
|
|
+ y: 35
|
|
|
+});
|
|
|
+
|
|
|
+// 新增:连接线相关状态
|
|
|
+const connectionLine = reactive({
|
|
|
+ show: false,
|
|
|
+ style: {}
|
|
|
+});
|
|
|
+
|
|
|
+// 新增:点击坐标(用于连接线起点)
|
|
|
+const clickPoint = reactive({
|
|
|
+ x: 0,
|
|
|
+ y: 0
|
|
|
+});
|
|
|
+
|
|
|
+// 手动拖拽功能
|
|
|
+const isDragging = ref(false);
|
|
|
+const dragStartPos = reactive({ x: 0, y: 0 });
|
|
|
+
|
|
|
+const startDrag = (event: MouseEvent) => {
|
|
|
+ if (!popupElement.value) return;
|
|
|
+
|
|
|
+ isDragging.value = true;
|
|
|
+ // 初始值:百分比转像素
|
|
|
+ const viewportWidth = window.innerWidth;
|
|
|
+ const viewportHeight = window.innerHeight;
|
|
|
+ const popupLeft = (popupPosition.x / 100) * viewportWidth;
|
|
|
+ const popupTop = (popupPosition.y / 100) * viewportHeight;
|
|
|
+
|
|
|
+ dragStartPos.x = event.clientX - popupLeft;
|
|
|
+ dragStartPos.y = event.clientY - popupTop;
|
|
|
+
|
|
|
+ document.addEventListener('mousemove', onDrag);
|
|
|
+ document.addEventListener('mouseup', stopDrag);
|
|
|
+ event.preventDefault();
|
|
|
+};
|
|
|
+
|
|
|
+const onDrag = (event: MouseEvent) => {
|
|
|
+ if (!isDragging.value) return;
|
|
|
+
|
|
|
+ // 拖拽后转成百分比
|
|
|
+ const viewportWidth = window.innerWidth;
|
|
|
+ const viewportHeight = window.innerHeight;
|
|
|
+ // 计算拖拽后的像素位置
|
|
|
+ const newX = event.clientX - dragStartPos.x;
|
|
|
+ const newY = event.clientY - dragStartPos.y;
|
|
|
+ // 转成百分比(限制0-95,避免弹窗超出可视区)
|
|
|
+ popupPosition.x = Math.max(0, Math.min(95, (newX / viewportWidth) * 100));
|
|
|
+ popupPosition.y = Math.max(0, Math.min(95, (newY / viewportHeight) * 100));
|
|
|
+ updateConnectionLine();
|
|
|
+};
|
|
|
+
|
|
|
+const stopDrag = () => {
|
|
|
+ isDragging.value = false;
|
|
|
+ document.removeEventListener('mousemove', onDrag);
|
|
|
+ document.removeEventListener('mouseup', stopDrag);
|
|
|
+ nextTick(() => {
|
|
|
+ updateConnectionLine();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 修改更新连接线方法,使用经纬度坐标
|
|
|
+const updateConnectionLine = () => {
|
|
|
+ if (!map.value || !popupElement.value || !featureCenter.lng || !featureCenter.lat) return;
|
|
|
+
|
|
|
+ // 将地块中心点的经纬度转换为屏幕坐标
|
|
|
+ const centerLatLng = L.latLng(featureCenter.lat, featureCenter.lng);
|
|
|
+ const centerPoint = map.value.latLngToContainerPoint(centerLatLng);
|
|
|
+
|
|
|
+ const mapRect = document.getElementById('map-container')!.getBoundingClientRect();
|
|
|
+ const startX = mapRect.left + centerPoint.x;
|
|
|
+ const startY = mapRect.top + centerPoint.y;
|
|
|
+
|
|
|
+ // 获取弹窗中心点
|
|
|
+ const popupRect = popupElement.value.getBoundingClientRect();
|
|
|
+ // 视口总宽度/高度
|
|
|
+ const viewportWidth = window.innerWidth;
|
|
|
+ const viewportHeight = window.innerHeight;
|
|
|
+ // 弹窗左上角像素坐标(百分比转像素)
|
|
|
+ const popupLeft = (popupPosition.x / 100) * viewportWidth;
|
|
|
+ const popupTop = (popupPosition.y / 100) * viewportHeight;
|
|
|
+ // 弹窗中心点像素坐标
|
|
|
+ const endX = popupLeft + popupRect.width / 2;
|
|
|
+ const endY = popupTop + popupRect.height / 2;
|
|
|
+
|
|
|
+ // 计算线的长度和角度
|
|
|
+ const length = Math.sqrt(Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2));
|
|
|
+ const angle = Math.atan2(endY - startY, endX - startX) * 180 / Math.PI;
|
|
|
+
|
|
|
+ connectionLine.style = {
|
|
|
+ left: startX + 'px',
|
|
|
+ top: startY + 'px',
|
|
|
+ width: length + 'px',
|
|
|
+ transform: `rotate(${angle}deg)`,
|
|
|
+ transformOrigin: '0 0',
|
|
|
+ '--arrow-angle': `${angle}deg`
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+const initMap = async () => {
|
|
|
+ mapLoading.value = true;
|
|
|
+ mapError.value = false;
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 动态导入Leaflet
|
|
|
+ if (!L) {
|
|
|
+ L = await import('leaflet');
|
|
|
+ await import('leaflet/dist/leaflet.css');
|
|
|
+
|
|
|
+ delete (L.Icon.Default.prototype as any)._getIconUrl;
|
|
|
+ L.Icon.Default.mergeOptions({
|
|
|
+ iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
|
|
+ iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
|
|
+ shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清除现有地图
|
|
|
+ if (map.value) {
|
|
|
+ map.value.remove();
|
|
|
+ map.value = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建地图实例
|
|
|
+ map.value = L.map('map-container', {
|
|
|
+ zoomControl: true,
|
|
|
+ attributionControl: false,
|
|
|
+ center: [25.24266, 114.39792],
|
|
|
+ zoom: 16
|
|
|
+ });
|
|
|
+
|
|
|
+ // WMS配置
|
|
|
+ const GEOSERVER_CONFIG = {
|
|
|
+ url: "/geoserver/wms",
|
|
|
+ workspace: "acidmap",
|
|
|
+ layerGroup: "xiafencun",
|
|
|
+ };
|
|
|
+
|
|
|
+ // WMS图层配置
|
|
|
+ const wmsLayer = L.tileLayer.wms(GEOSERVER_CONFIG.url, {
|
|
|
+ layers: `${GEOSERVER_CONFIG.workspace}:${GEOSERVER_CONFIG.layerGroup}`,
|
|
|
+ format: "image/png",
|
|
|
+ transparent: true,
|
|
|
+ version: "1.1.1",
|
|
|
+ crs: L.CRS.EPSG4326,
|
|
|
+ attribution: "Data from GeoServer"
|
|
|
+ });
|
|
|
+
|
|
|
+ // 添加图层到地图
|
|
|
+ wmsLayer.addTo(map.value);
|
|
|
+
|
|
|
+ // 绑定点击事件
|
|
|
+ map.value.on('click', handleMapClick);
|
|
|
+
|
|
|
+ // 添加地图移动和缩放事件监听,实时更新连接线
|
|
|
+ map.value.on('moveend zoomend', () => {
|
|
|
+ if (showPopup.value && showConnectionLine.value) {
|
|
|
+ updateConnectionLine();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 监听地图飞行动画完成事件
|
|
|
+ map.value.on('moveend', () => {
|
|
|
+ // 地图动画完成后显示连接线
|
|
|
+ if (showPopup.value && !showConnectionLine.value && featureCenter.lng && featureCenter.lat) {
|
|
|
+ showConnectionLine.value = true;
|
|
|
+ nextTick(() => {
|
|
|
+ updateConnectionLine();
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ mapLoading.value = false;
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('地图初始化失败:', error);
|
|
|
+ mapError.value = true;
|
|
|
+ mapLoading.value = false;
|
|
|
+
|
|
|
+ let errorMessage = '地图初始化失败';
|
|
|
+ if (error instanceof Error) {
|
|
|
+ errorMessage += ': ' + error.message;
|
|
|
+ }
|
|
|
+ ElMessage.error(errorMessage);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const reloadMap = () => {
|
|
|
+ initMap();
|
|
|
+};
|
|
|
+
|
|
|
+// 组件卸载时清理
|
|
|
+onUnmounted(() => {
|
|
|
+ if (map.value) {
|
|
|
+ map.value.remove();
|
|
|
+ }
|
|
|
+ // 清除高亮图层
|
|
|
+ if (highlightLayer.value && map.value) {
|
|
|
+ map.value.removeLayer(highlightLayer.value);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 移除拖拽事件监听
|
|
|
+ document.removeEventListener('mousemove', onDrag);
|
|
|
+ document.removeEventListener('mouseup', stopDrag);
|
|
|
+
|
|
|
+ window.removeEventListener('resize', handleWindowResize);
|
|
|
+});
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ nextTick(() => {
|
|
|
+ initMap();
|
|
|
+ });
|
|
|
+ // 监听窗口缩放
|
|
|
+ window.addEventListener('resize', handleWindowResize);
|
|
|
+});
|
|
|
+
|
|
|
+// 窗口缩放时重新计算连接线
|
|
|
+const handleWindowResize = () => {
|
|
|
+ if (showPopup.value && showConnectionLine.value) {
|
|
|
+ nextTick(() => {
|
|
|
+ updateConnectionLine();
|
|
|
+ });
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.feature-popup {
|
|
|
+ position: fixed;
|
|
|
+ z-index: 1000;
|
|
|
+ background: white;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
|
+ width: 280px; /* 增大最小宽度 */
|
|
|
+ min-width: 280px; /* 强制最小宽度 */
|
|
|
+ min-height: 150px; /* 防止弹窗塌陷 */
|
|
|
+}
|
|
|
+
|
|
|
+.feature-popup:active {
|
|
|
+ cursor: grabbing;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-content {
|
|
|
+ padding: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 10px 15px 0 15px;
|
|
|
+ border-bottom: 1px solid #ebeef5;
|
|
|
+ background: #f5f7fa;
|
|
|
+ border-radius: 8px 8px 0 0;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-header h4 {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+
|
|
|
+.close-btn {
|
|
|
+ background: none;
|
|
|
+ border: none;
|
|
|
+ font-size: 18px;
|
|
|
+ cursor: pointer;
|
|
|
+ color: #909399;
|
|
|
+ padding: 0;
|
|
|
+ width: 20px;
|
|
|
+ height: 20px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.close-btn:hover {
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-body {
|
|
|
+ padding: 10px;
|
|
|
+ max-height: 500px;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.loading-info, .error-info, .no-data {
|
|
|
+ text-align: center;
|
|
|
+ color: #909399;
|
|
|
+ font-size: 14px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin: 10px 0;
|
|
|
+}
|
|
|
+
|
|
|
+.custom-land-message .el-icon-info {
|
|
|
+ color: red !important;
|
|
|
+}
|
|
|
+
|
|
|
+.inversion-form {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(2, 1fr); /* 默认2列 */
|
|
|
+ gap: 12px 8px; /* 行间距12px,列间距8px */
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+/* 网格项通用样式 */
|
|
|
+.form-item-grid {
|
|
|
+ margin-bottom: 0 !important;
|
|
|
+}
|
|
|
+
|
|
|
+/* 非水田时:当前pH独占一行 */
|
|
|
+.full-width {
|
|
|
+ grid-column: span 2; /* 跨2列 */
|
|
|
+}
|
|
|
+
|
|
|
+/* 适配小屏幕,防止布局错乱 */
|
|
|
+@media (max-width: 300px) {
|
|
|
+ .inversion-form {
|
|
|
+ grid-template-columns: 1fr; /* 小屏幕改为1列 */
|
|
|
+ }
|
|
|
+ .full-width {
|
|
|
+ grid-column: span 1;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.error-info {
|
|
|
+ color: #f56c6c;
|
|
|
+}
|
|
|
+
|
|
|
+.feature-info {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.info-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.info-item label {
|
|
|
+ font-weight: 600;
|
|
|
+ color: #606266;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.info-item span {
|
|
|
+ color: #303133;
|
|
|
+ font-size: 14px;
|
|
|
+ text-align: right;
|
|
|
+}
|
|
|
+
|
|
|
+.action-buttons {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ justify-content: center;
|
|
|
+ margin: 12px 0;
|
|
|
+ padding-top: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.acid-reduction-input {
|
|
|
+ margin-top: 2px;
|
|
|
+ padding-top: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.input-buttons {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ justify-content: flex-end;
|
|
|
+ margin-top: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.prediction-section {
|
|
|
+ margin-top: 2px;
|
|
|
+ padding-top: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.section-title {
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ font-size: 13px;
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.prediction-result {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.result-item {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.result-item label {
|
|
|
+ font-weight: 600;
|
|
|
+ color: #606266;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.point-info {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #666;
|
|
|
+ background: #f8f9fa;
|
|
|
+ padding: 6px;
|
|
|
+ border-radius: 4px;
|
|
|
+ line-height: 1.4;
|
|
|
+}
|
|
|
+
|
|
|
+.prediction-value {
|
|
|
+ font-weight: bold;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+.prediction-value.reduction {
|
|
|
+ color: #e6a23c;
|
|
|
+}
|
|
|
+
|
|
|
+.warnings {
|
|
|
+ margin-top: 8px;
|
|
|
+ padding: 6px;
|
|
|
+ background: #fff6f6;
|
|
|
+ border: 1px solid #fbc4c4;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.warning-item {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #f56c6c;
|
|
|
+ line-height: 1.3;
|
|
|
+}
|
|
|
+
|
|
|
+.result-buttons {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ justify-content: flex-end;
|
|
|
+ margin-top: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes spin {
|
|
|
+ 0% { transform: rotate(0deg); }
|
|
|
+ 100% { transform: rotate(360deg); }
|
|
|
+}
|
|
|
+
|
|
|
+.map-card {
|
|
|
+ width: 850px;
|
|
|
+ flex: 1;
|
|
|
+ min-height: 600px;
|
|
|
+ margin: 0 auto;
|
|
|
+}
|
|
|
+
|
|
|
+.map-container {
|
|
|
+ height: 550px;
|
|
|
+ width: 100%;
|
|
|
+ position: relative;
|
|
|
+ border-radius: 4px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #f0f2f5;
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
+}
|
|
|
+
|
|
|
+.loading {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ text-align: center;
|
|
|
+ z-index: 1000;
|
|
|
+ background: rgba(255, 255, 255, 0.95);
|
|
|
+ padding: 20px;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+.loading span {
|
|
|
+ margin-left: 8px;
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+
|
|
|
+.error-tip {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ width: 80%;
|
|
|
+ z-index: 1000;
|
|
|
+}
|
|
|
+
|
|
|
+.title {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ margin-bottom: 15px;
|
|
|
+}
|
|
|
+
|
|
|
+.map-title {
|
|
|
+ color: #1a365d;
|
|
|
+ font-size: 1.6rem;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.section-icon {
|
|
|
+ font-size: 2.2rem;
|
|
|
+ color: #3a9fd3;
|
|
|
+}
|
|
|
+
|
|
|
+/* 表单样式调整 */
|
|
|
+:deep(.el-form-item) {
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-form-item__label) {
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 28px;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-input) {
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+.popup-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+/* 参数行布局 */
|
|
|
+.params-row {
|
|
|
+ display: flex;
|
|
|
+ gap: 0px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ align-items: center;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+/* 只读参数项样式 */
|
|
|
+.param-item {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ padding: 0 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.param-item label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #606266;
|
|
|
+ margin-bottom: 4px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.param-item span {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #303133;
|
|
|
+ padding: 6px 12px;
|
|
|
+ background: #f5f7fa;
|
|
|
+ border-radius: 4px;
|
|
|
+ min-height: 28px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.current-ph-value {
|
|
|
+ width: 100%;
|
|
|
+ height: 32px;
|
|
|
+ line-height: 32px;
|
|
|
+ padding: 0 12px;
|
|
|
+ background: #f5f7fa;
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
+ border-radius: 4px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #606266;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+/* 只读项的特殊样式 */
|
|
|
+.readonly-item :deep(.el-form-item__label) {
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.readonly-param span {
|
|
|
+ background: #f0f2f5;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.loading-text, .no-data-text {
|
|
|
+ color: #c0c4cc !important;
|
|
|
+ font-style: italic;
|
|
|
+}
|
|
|
+
|
|
|
+/* 紧凑的表单项样式 */
|
|
|
+.form-item-compact {
|
|
|
+ flex: 1;
|
|
|
+ margin-bottom: 0 !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.form-item-compact .el-form-item__content) {
|
|
|
+ margin-left: 0 !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.form-item-compact .el-form-item__label) {
|
|
|
+ width: 60px !important; /* 调整标签宽度 */
|
|
|
+ text-align: right;
|
|
|
+ padding-right: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.form-item-compact .el-input) {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+/* 确保 Tooltip 能正常显示 */
|
|
|
+:deep(.el-tooltip__trigger) {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+/* 确保 Tooltip 有足够的 z-index */
|
|
|
+:deep(.el-popper) {
|
|
|
+ z-index: 10000 !important;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-input-number .el-input__inner)::-webkit-outer-spin-button,
|
|
|
+:deep(.el-input-number .el-input__inner)::-webkit-inner-spin-button {
|
|
|
+ -webkit-appearance: none;
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-input .el-input__inner[type="number"])::-webkit-outer-spin-button,
|
|
|
+:deep(.el-input .el-input__inner[type="number"])::-webkit-inner-spin-button {
|
|
|
+ -webkit-appearance: none;
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.prediction-value.reduction {
|
|
|
+ color: #e6a23c; /* 降酸结果用橙色 */
|
|
|
+}
|
|
|
+
|
|
|
+.prediction-value.inversion {
|
|
|
+ color: #48bb78; /* 反酸结果用绿色 */
|
|
|
+}
|
|
|
+
|
|
|
+/* 新增:高亮图层的z-index确保在最上层 */
|
|
|
+:deep(.leaflet-geojson) {
|
|
|
+ z-index: 999 !important;
|
|
|
+}
|
|
|
+
|
|
|
+/* 连接线样式 */
|
|
|
+.connection-line {
|
|
|
+ position: fixed;
|
|
|
+ height: 1px;
|
|
|
+ background: transparent;
|
|
|
+ background-image: linear-gradient(to right, #409eff 50%, transparent 50%);
|
|
|
+ background-size: 10px 1px;
|
|
|
+ border: none;
|
|
|
+ z-index: 999;
|
|
|
+ pointer-events: none;
|
|
|
+ --arrow-angle: 0deg;
|
|
|
+}
|
|
|
+
|
|
|
+.connection-line::before {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ left: -5px; /* 箭头在连接线起点左侧,对准地块 */
|
|
|
+ top: 50%;
|
|
|
+ /* 跟随连接线角度旋转,保持垂直居中 */
|
|
|
+ transform: translateY(-50%) rotate(var(--arrow-angle));
|
|
|
+ /* 三角形箭头:右向箭头(指向地块) */
|
|
|
+ width: 0;
|
|
|
+ height: 0;
|
|
|
+ border-style: solid;
|
|
|
+ border-width: 4px 8px 4px 0; /* 箭头尺寸:高4px*2,长8px */
|
|
|
+ border-color: transparent #409eff transparent transparent; /* 箭头颜色与连接线一致 */
|
|
|
+ transform-origin: center center;
|
|
|
+ z-index: 1;
|
|
|
+}
|
|
|
+/* 新增FeO输入项样式(和其他参数统一) */
|
|
|
+:deep(.form-item-compact.feo-item .el-input) {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+.feo-placeholder {
|
|
|
+ color: #c0c4cc;
|
|
|
+ font-size: 11px;
|
|
|
+}
|
|
|
+</style>
|
|
|
+
|
|
|
+<style>
|
|
|
+/* 强制覆盖“请点击有效地块”提示的图标颜色 */
|
|
|
+.custom-land-message.el-message--info .el-icon-info {
|
|
|
+ color: red !important;
|
|
|
+}
|
|
|
+
|
|
|
+/* 可选:同时修改提示文字颜色 */
|
|
|
+.custom-land-message .el-message__content {
|
|
|
+ color: #ff0000 !important;
|
|
|
+ font-size: 14px !important;
|
|
|
+}
|
|
|
+
|
|
|
+/* 可选:修改提示框整体样式 */
|
|
|
+.custom-land-message {
|
|
|
+ top: 250px !important; /* 和你设置的 offset 一致 */
|
|
|
+ background: #fef0f0 !important;
|
|
|
+ border: 1px solid #fbc4c4 !important;
|
|
|
+ border-radius: 8px !important;
|
|
|
+}
|
|
|
+</style>
|