| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521 |
- <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>
- <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="!showPredictionResult && !showAcidReductionInput &&!showAcidInversionInput">
- <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" class="acid-reduction-input">
- <div class="section-title">降酸预测参数</div>
-
- <!-- 关键修复:el-form 绑定 rules 和 ref -->
- <el-form
- :model="acidReductionParams"
- :rules="acidReductionRules"
- ref="acidReductionFormRef"
- label-width="80px"
- size="small"
- >
- <!-- 下面的 params-row 放在 el-form 内部 -->
- <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>
- <div class="params-row">
- <el-form-item label="NO3" prop="NO3" class="form-item-compact">
-
- <el-input
- v-model.number="acidReductionParams.NO3"
- type="number"
- step="0.01"
- min="0"
- placeholder="9-70"
- :disabled="phLoading"
- />
- </el-form-item>
- <el-form-item label="NH4" prop="NH4" class="form-item-compact">
-
- <el-input
- v-model.number="acidReductionParams.NH4"
- type="number"
- step="0.01"
- min="0"
- placeholder="0-20"
- :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="showAcidInversionInput" class="acid-inversion-input">
- <div class="section-title">反酸预测参数</div>
- <el-form
- :model="acidInversionParams"
- :rules="acidInversionRules"
- ref="acidInversionFormRef"
- label-width="80px"
- size="small"
- >
- <!-- 当前pH单独一行 -->
- <div class="params-row full-width">
- <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>
- </div>
- <!-- NO3和NH4在同一行 -->
- <div class="params-row">
- <el-form-item label="NO3" prop="NO3" class="form-item-compact">
-
- <el-input
- v-model.number="acidInversionParams.NO3"
- type="number"
- step="0.01"
- min="0"
- placeholder="9-70"
- :disabled="phLoading"
- />
- </el-form-item>
- <el-form-item label="NH4" prop="NH4" class="form-item-compact">
-
- <el-input
- v-model.number="acidInversionParams.NH4"
- type="number"
- step="0.01"
- min="0"
- placeholder="0-18"
- :disabled="phLoading"
- />
- </el-form-item>
- </div>
- </el-form>
- <div class="input-buttons">
- <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>
- <!-- 预测结果展示 -->
- <div v-if="showPredictionResult" class="prediction-section">
- <div class="section-title">{{ predictionTitle }}</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="prediction-result">
- <!--降酸结果-->
- <div class="result-item" v-if="currentPredictionType === 'reduction' && predictionResult.prediction_reduce !== undefined">
- <span class="prediction-value reduction">每亩地土壤表层20cm撒{{ formatPredictionValue(predictionResult.prediction_reduce)}} 吨</span>
- </div>
- <!-- 反酸预测结果 -->
- <div class="result-item" v-if="currentPredictionType === 'inversion' && predictionResult.prediction_reflux !== undefined">
- <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 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";
- // 新增:反酸预测相关状态(区分降酸/反酸的显示)
- const showInversionPrediction = ref(false); // 控制反酸预测区域显示
- const phLoading = ref(false); // 控制当前pH的加载状态(点击预测后先加载pH)
- // 地图状态
- 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 defaultParams = reactive({
- NO3: 34.05, // 随便填一个合理默认值(后端接口要求传,但不影响pH返回)
- NH4: 7.87 // 同上,只要是合法数字即可
- });
- // 地块信息状态
- 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);
- // 降酸预测参数输入相关
- const acidReductionFormRef = ref<FormInstance | null>(null);
- interface AcidReductionParams {
- target_pH?: number;
- NO3?: number;
- NH4?: number;
- }
- const acidReductionParams = reactive<AcidReductionParams>({
- target_pH: undefined,
- NO3: undefined,
- NH4: undefined
- });
- // 在 acidReductionParams 附近添加反酸预测参数
- interface AcidInversionParams {
- NO3?: number;
- NH4?: number;
- }
- // 反酸预测参数(指定类型为 AcidInversionParams)
- const acidInversionParams = reactive<AcidInversionParams>({
- NO3: undefined,
- NH4: undefined
- });
- // 添加反酸预测的显示状态
- const showAcidInversionInput = ref(false);
- // 添加反酸预测的表单验证规则
- const acidInversionRules = reactive({
- NO3: [
- { required: true, message: '请输入NO3', trigger: 'change' },
- { type: 'number', message: '请输入有效数字', trigger: 'change' },
- {
- validator: (rule: any, value: number, callback: any) => {
- if (value < 0) {
- callback(new Error('值不能为负数'));
- } else {
- callback();
- }
- },
- trigger: 'change'
- }
- ],
- NH4: [
- { required: true, message: '请输入NH4', trigger: 'change' },
- { type: 'number', message: '请输入有效数字', trigger: 'change' },
- {
- validator: (rule: any, value: number, callback: any) => {
- if (value < 0) {
- callback(new Error('值不能为负数'));
- } else {
- callback();
- }
- },
- trigger: 'change'
- }
- ]
- });
- // 添加反酸预测的表单引用
- const acidInversionFormRef = ref<FormInstance | null>(null);
- // 预测标题
- const predictionTitle = computed(() => {
- if (currentPredictionType.value === 'reduction') return '降酸预测结果';
- if (currentPredictionType.value === 'inversion') return '反酸预测结果';
- return '预测结果';
- });
- // 输入校验规则
- 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'
- }
- ],
- NO3: [
- { required: true, message: '请输入NO3', trigger: 'blur' },
- { type: 'number', message: '请输入有效数字', trigger: 'blur' },
- {
- validator: (rule: any, value: number, callback: any) => {
- if (value < 0) {
- callback(new Error('值不能为负数'));
- } else {
- callback();
- }
- },
- trigger: 'blur'
- }
- ],
- NH4: [ // 修复:之前是nh4,和prop不一致
- { required: true, message: '请输入NH4', trigger: 'blur' },
- { type: 'number', message: '请输入有效数字', trigger: 'blur' },
- {
- validator: (rule: any, value: number, callback: any) => {
- if (value < 0) {
- callback(new Error('值不能为负数'));
- } 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, // 1秒动画
- 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: "mapwithboundary",
- };
- 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;
- }
- };
- // 新增:单独获取当前pH的方法(点击预测后先加载pH)
- const fetchCurrentPH = async () => {
- if (currentPH.value !== null) return true; // 已有pH,直接返回
- if (phLoading.value) return false; // 正在加载,避免重复请求
- phLoading.value = true;
- try {
- const urlParams = new URLSearchParams();
- urlParams.append('target_lon', currentClickCoords.lng.toString());
- urlParams.append('target_lat', currentClickCoords.lat.toString());
- urlParams.append('NO3', defaultParams.NO3.toString());
- urlParams.append('NH4', defaultParams.NH4.toString());
- // 调用接口获取当前pH(接口会返回nearest_point.ph)
- const response = await fetch(
- `http://localhost:8000/api/vector/nearest-with-predictions?${urlParams.toString()}`
- );
- if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
- const data = await response.json();
-
- // 从接口提取当前pH(根据你的接口结构:data.nearest_point.ph)
- currentPH.value = data.nearest_point?.ph !== undefined ? Number(data.nearest_point.ph) : null;
- return currentPH.value !== 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;
- showInversionPrediction.value = false;
- showPredictionResult.value = false;
- };
- // 反酸预测:点击后先加载pH,再显示反酸区域
- const startAcidInversionPrediction = async () => {
- // 先获取当前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;
- };
- // 确认降酸预测(原有逻辑不变,补充pH校验)
- const confirmAcidReduction = async () => {
- if (!acidReductionFormRef.value) return;
- if (currentPH.value === null) {
- ElMessage.error('请先获取当前pH值');
- return;
- }
- try {
- await acidReductionFormRef.value.validate();
-
- // 调用预测接口(带降酸参数)
- await callPredictionAPI(
- currentClickCoords.lng,
- currentClickCoords.lat,
- {
- target_pH: acidReductionParams.target_pH!,
- NO3: acidReductionParams.NO3!,
- NH4: acidReductionParams.NH4!
- }
- );
- } catch (error) {
- ElMessage.error('输入参数不合法,请检查后重试');
- }
- };
- // 确认反酸预测
- const confirmAcidInversion = async () => {
- if (!acidInversionFormRef.value) return;
- if (currentPH.value === null) {
- ElMessage.error('请先获取当前pH值');
- return;
- }
- try {
- await acidInversionFormRef.value.validate();
-
- // 调用预测接口 - 反酸预测需要传递NO3和NH4参数
- await callPredictionAPI(
- currentClickCoords.lng,
- currentClickCoords.lat,
- {
- NO3: acidInversionParams.NO3!,
- NH4: acidInversionParams.NH4!
- }
- );
- } catch (error) {
- ElMessage.error('输入参数不合法,请检查后重试');
- }
- };
- // 调用预测接口
- const callPredictionAPI = async (
- lng: number,
- lat: number,
- params?: {
- target_pH?: number;
- NO3: number;
- NH4: number;
- }
- ) => {
- predictionLoading.value = true;
- predictionError.value = '';
- predictionResult.value = null;
- try {
- const urlParams = new URLSearchParams();
- urlParams.append('target_lon', lng.toString());
- urlParams.append('target_lat', lat.toString());
- // 明确传递 prediction_type
- if (currentPredictionType.value === 'reduction') {
- urlParams.append('prediction_type', 'reduce');
- } else if (currentPredictionType.value === 'inversion') {
- urlParams.append('prediction_type', 'reflux');
- }
- if (params) {
- Object.entries(params).forEach(([key, value]) => {
- if (value !== undefined && value !== null) {
- urlParams.append(key, value.toString());
- }
- });
- }
- console.log('调用预测接口,参数:', urlParams.toString());
- const response = await fetch(
- `http://localhost:8000/api/vector/nearest-with-predictions?${urlParams.toString()}`
- );
- if (!response.ok) {
- const errorData = await response.json();
- console.error('预测接口返回错误:', errorData);
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const data = await response.json();
- predictionResult.value = data;
- console.log('预测结果:', data);
- currentPH.value = data.nearest_point?.ph !== undefined ? Number(data.nearest_point.ph) : null;
- // 显示预测结果
- showPredictionResult.value = true;
- showAcidReductionInput.value = false;
- showAcidInversionInput.value = false;
- } catch (error) {
- currentPH.value = null;
- console.error('调用预测接口失败:', error);
- predictionError.value = `预测失败: ${error instanceof Error ? error.message : '未知错误'}`;
- ElMessage.error('预测请求失败,请检查后端服务是否启动');
- } finally {
- predictionLoading.value = false;
- }
- };
- // 重置预测
- const resetPrediction = () => {
- showPredictionResult.value = false;
- predictionResult.value = null;
- currentPredictionType.value = null;
- showAcidReductionInput.value = false;
- showAcidInversionInput.value = false;
- currentPH.value = null;
- };
- // 地图点击事件处理
- 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;
-
- // 设置弹窗初始位置(在点击点右侧)
- popupPosition.x = 35;
- popupPosition.y = 35; // 居中显示
-
- showPopup.value = true;
- showConnectionLine.value=false;
-
- // 重置预测状态
- resetPrediction();
- featureInfo.data = null;
-
- // 先清除旧的高亮图层
- if (highlightLayer.value) {
- map.value.removeLayer(highlightLayer.value);
- highlightLayer.value = null;
- }
-
- // 获取地块信息
- try {
- const hasFeature = await getFeatureInfo(e.latlng, containerPoint);
- // 如果没有获取到要素,提示
- if (!hasFeature) {
- featureInfo.data = {
- village: '未知',
- landType: '未知'
- };
- ElMessage.info('未查询到该地块的详细信息');
- // 如果没有获取到地块,使用点击点作为连接线起点
- featureCenter.lng = lng;
- featureCenter.lat = lat;
- // 直接显示连接线
- showConnectionLine.value = true;
- nextTick(() => {
- updateConnectionLine();
- });
- }
- } catch (error) {
- console.error('处理地块信息失败:', error);
- // 清除高亮
- if (highlightLayer.value) {
- map.value.removeLayer(highlightLayer.value);
- highlightLayer.value = null;
- }
- }
- // 使用点击点作为连接线起点
- featureCenter.lng = lng;
- featureCenter.lat = lat;
- nextTick(() => {
- updateConnectionLine();
- });
- };
- // 关闭弹窗(清除高亮图层)
- const closePopup = () => {
- showPopup.value = false;
- showConnectionLine.value=false;
- resetPrediction();
- featureInfo.data = 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.202903, 113.25383],
- zoom: 10
- });
- // WMS配置
- const GEOSERVER_CONFIG = {
- url: "/geoserver/wms",
- workspace: "acidmap",
- layerGroup: "mapwithboundary",
- };
- // 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: 250px;
- }
- .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;
- }
- .error-info {
- color: #f56c6c;
- }
- .feature-info {
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin-bottom: 12px;
- }
- .info-item {
- display: flex;
- justify-content: space-between;
- 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: flex-start;
- }
- /* 只读参数项样式 */
- .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;
- }
- </style>
|