|
|
@@ -0,0 +1,869 @@
|
|
|
+<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" class="feature-popup fixed-center">
|
|
|
+ <div class="popup-content">
|
|
|
+ <div class="popup-header">
|
|
|
+ <h4>地块信息</h4>
|
|
|
+ <button @click="closePopup" class="close-btn">×</button>
|
|
|
+ </div>
|
|
|
+ <div class="popup-body">
|
|
|
+ <!-- 坐标信息 -->
|
|
|
+ <div class="coordinate-info">
|
|
|
+ <div class="info-item">
|
|
|
+ <label>经度:</label>
|
|
|
+ <span>{{ currentClickCoords.lng.toFixed(6) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <label>纬度:</label>
|
|
|
+ <span>{{ currentClickCoords.lat.toFixed(6) }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 地块信息 -->
|
|
|
+ <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>
|
|
|
+
|
|
|
+ <!-- 操作按钮 -->
|
|
|
+ <div class="action-buttons" v-if="!showPredictionResult && !showAcidReductionInput">
|
|
|
+ <el-button
|
|
|
+ size="small"
|
|
|
+ type="primary"
|
|
|
+ @click="startAcidReductionPrediction"
|
|
|
+ >
|
|
|
+ 降酸预测
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ size="small"
|
|
|
+ type="success"
|
|
|
+ @click="handleAcidInversionPrediction"
|
|
|
+ >
|
|
|
+ 反酸预测
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 降酸预测参数输入 -->
|
|
|
+ <div v-if="showAcidReductionInput" class="acid-reduction-input">
|
|
|
+ <div class="section-title">降酸预测参数</div>
|
|
|
+ <el-form :model="acidReductionParams" :rules="acidReductionRules" ref="acidReductionFormRef" label-width="80px" size="small">
|
|
|
+ <el-form-item label="目标pH" prop="targetPH">
|
|
|
+ <el-input
|
|
|
+ v-model.number="acidReductionParams.targetPH"
|
|
|
+ type="number"
|
|
|
+ step="0.01"
|
|
|
+ placeholder="0-14"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="NO3" prop="no3">
|
|
|
+ <el-input
|
|
|
+ v-model.number="acidReductionParams.no3"
|
|
|
+ type="number"
|
|
|
+ step="0.01"
|
|
|
+ min="0"
|
|
|
+ placeholder="非负数"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="CEC" prop="cec">
|
|
|
+ <el-input
|
|
|
+ v-model.number="acidReductionParams.cec"
|
|
|
+ type="number"
|
|
|
+ step="0.01"
|
|
|
+ min="0"
|
|
|
+ placeholder="非负数"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <div class="input-buttons">
|
|
|
+ <el-button size="small" @click="cancelAcidReduction">取消</el-button>
|
|
|
+ <el-button size="small" type="primary" @click="confirmAcidReduction" :loading="predictionLoading">
|
|
|
+ 开始预测
|
|
|
+ </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">
|
|
|
+ <label>最近点位信息:</label>
|
|
|
+ <div class="point-info">
|
|
|
+ <div>经度: {{ predictionResult.nearest_point?.lon }}</div>
|
|
|
+ <div>纬度: {{ predictionResult.nearest_point?.lan }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="result-item" v-if="currentPredictionType === 'reduction' && predictionResult.prediction_model33 !== undefined">
|
|
|
+ <label>降酸预测结果:</label>
|
|
|
+ <span class="prediction-value reduction">每亩地土壤表层20cm撒{{ formatPredictionValue(predictionResult.prediction_model33)}} 吨</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>
|
|
|
+ </el-card>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import {reactive, ref, nextTick, onMounted ,onUnmounted, computed} from "vue";
|
|
|
+import { ElMessage } from "element-plus";
|
|
|
+import type { LeafletMouseEvent } from "leaflet";
|
|
|
+import type { FormInstance } from "element-plus";
|
|
|
+
|
|
|
+// 地图状态
|
|
|
+const mapLoading = ref(true);
|
|
|
+const mapError = ref(false);
|
|
|
+const map = ref<any>(null);
|
|
|
+const showPopup = ref(false);
|
|
|
+
|
|
|
+
|
|
|
+// 预测状态
|
|
|
+const predictionLoading = ref(false);
|
|
|
+const predictionError = ref('');
|
|
|
+const predictionResult = ref<any>(null);
|
|
|
+const showPredictionResult = ref(false);
|
|
|
+const showAcidReductionInput = ref(false);
|
|
|
+
|
|
|
+let L:any = null;
|
|
|
+
|
|
|
+// 地块信息状态
|
|
|
+const featureInfo = reactive({
|
|
|
+ loading: false,
|
|
|
+ error: false,
|
|
|
+ data: null as any
|
|
|
+});
|
|
|
+
|
|
|
+// 当前坐标
|
|
|
+const currentClickCoords = reactive({
|
|
|
+ lng: 0,
|
|
|
+ lat: 0
|
|
|
+});
|
|
|
+
|
|
|
+const currentPredictionType = ref<'reduction' | 'inversion' | null>(null);
|
|
|
+
|
|
|
+// 降酸预测参数输入相关
|
|
|
+const acidReductionFormRef = ref<FormInstance | null>(null);
|
|
|
+const acidReductionParams = reactive({
|
|
|
+ targetPH: 7.0, // 设置一个合理的默认值
|
|
|
+ no3: 34.05, // 设置一个合理的默认值
|
|
|
+ cec: 7.87 // 设置一个合理的默认值
|
|
|
+});
|
|
|
+
|
|
|
+// 过滤警告信息
|
|
|
+const filteredWarnings = computed(() => {
|
|
|
+ if (!predictionResult.value?.warnings) return [];
|
|
|
+ return predictionResult.value.warnings.filter((warning: string) => {
|
|
|
+ return !warning.includes('模型24');
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// 预测标题
|
|
|
+const predictionTitle = computed(() => {
|
|
|
+ if (currentPredictionType.value === 'reduction') return '降酸预测结果';
|
|
|
+ if (currentPredictionType.value === 'inversion') return '反酸预测结果';
|
|
|
+ return '预测结果';
|
|
|
+});
|
|
|
+
|
|
|
+// 输入校验规则
|
|
|
+const acidReductionRules = reactive({
|
|
|
+ targetPH: [
|
|
|
+ { 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'
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ cec: [
|
|
|
+ { required: true, message: '请输入CEC', 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 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 properties = data.features[0].properties;
|
|
|
+ const hasValidData = properties.QSDWMC || properties.DLMC;
|
|
|
+
|
|
|
+ if (hasValidData) {
|
|
|
+ featureInfo.data = {
|
|
|
+ village: properties.QSDWMC,
|
|
|
+ landType: properties.DLMC,
|
|
|
+ };
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取地块信息失败:', error);
|
|
|
+ featureInfo.error = true;
|
|
|
+ return false;
|
|
|
+ } finally {
|
|
|
+ featureInfo.loading = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 调用预测接口
|
|
|
+const callPredictionAPI = async (lng: number, lat: number, acidReductionParams?: {
|
|
|
+ targetPH: number;
|
|
|
+ no3: number;
|
|
|
+ cec: number;
|
|
|
+ }) => {
|
|
|
+ predictionLoading.value = true;
|
|
|
+ predictionError.value = '';
|
|
|
+ predictionResult.value = null;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const urlParams = new URLSearchParams();
|
|
|
+ urlParams.append('target_lon', lng.toString());
|
|
|
+ urlParams.append('target_lan', lat.toString());
|
|
|
+
|
|
|
+ // 如果是降酸预测,添加参数
|
|
|
+ if (acidReductionParams) {
|
|
|
+ urlParams.append('target_pH', acidReductionParams.targetPH.toString());
|
|
|
+ urlParams.append('NO3', acidReductionParams.no3.toString());
|
|
|
+ urlParams.append('CEC', acidReductionParams.cec.toString());
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('调用预测接口,参数:', urlParams.toString());
|
|
|
+
|
|
|
+ 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();
|
|
|
+ predictionResult.value = data;
|
|
|
+ console.log('预测结果:', data);
|
|
|
+
|
|
|
+ // 显示预测结果
|
|
|
+ showPredictionResult.value = true;
|
|
|
+ showAcidReductionInput.value = false;
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('调用预测接口失败:', error);
|
|
|
+ predictionError.value = `预测失败: ${error instanceof Error ? error.message : '未知错误'}`;
|
|
|
+ ElMessage.error('预测请求失败,请检查后端服务是否启动');
|
|
|
+ } finally {
|
|
|
+ predictionLoading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 开始降酸预测(显示参数输入)
|
|
|
+const startAcidReductionPrediction = () => {
|
|
|
+ currentPredictionType.value = 'reduction';
|
|
|
+ showAcidReductionInput.value = true;
|
|
|
+};
|
|
|
+
|
|
|
+// 取消降酸预测
|
|
|
+const cancelAcidReduction = () => {
|
|
|
+ showAcidReductionInput.value = false;
|
|
|
+ currentPredictionType.value = null;
|
|
|
+};
|
|
|
+
|
|
|
+// 确认降酸预测
|
|
|
+const confirmAcidReduction = async () => {
|
|
|
+ if (!acidReductionFormRef.value) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ await acidReductionFormRef.value.validate();
|
|
|
+
|
|
|
+ await callPredictionAPI(
|
|
|
+ currentClickCoords.lng,
|
|
|
+ currentClickCoords.lat,
|
|
|
+ {
|
|
|
+ targetPH: acidReductionParams.targetPH,
|
|
|
+ no3: acidReductionParams.no3,
|
|
|
+ cec: acidReductionParams.cec
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error('输入参数不合法,请检查后重试');
|
|
|
+ console.error('表单校验失败:', error);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 反酸预测按钮点击
|
|
|
+const handleAcidInversionPrediction = () => {
|
|
|
+ currentPredictionType.value = 'inversion';
|
|
|
+ callPredictionAPI(currentClickCoords.lng, currentClickCoords.lat);
|
|
|
+};
|
|
|
+
|
|
|
+// 重置预测
|
|
|
+const resetPrediction = () => {
|
|
|
+ showPredictionResult.value = false;
|
|
|
+ predictionResult.value = null;
|
|
|
+ currentPredictionType.value = null;
|
|
|
+ showAcidReductionInput.value = false;
|
|
|
+};
|
|
|
+
|
|
|
+// 地图点击事件处理
|
|
|
+const handleMapClick = async (e: any) => {
|
|
|
+ const lng = e.latlng.lng;
|
|
|
+ const lat = e.latlng.lat;
|
|
|
+
|
|
|
+ // 更新当前坐标
|
|
|
+ currentClickCoords.lng = lng;
|
|
|
+ currentClickCoords.lat = lat;
|
|
|
+
|
|
|
+ showPopup.value = true;
|
|
|
+
|
|
|
+ // 重置预测状态
|
|
|
+ resetPrediction();
|
|
|
+
|
|
|
+ // 获取地块信息
|
|
|
+ try {
|
|
|
+ const containerPoint = map.value.latLngToContainerPoint(e.latlng);
|
|
|
+ await getFeatureInfo(e.latlng, containerPoint);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('处理地块信息失败:', error);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 关闭弹窗
|
|
|
+const closePopup = () => {
|
|
|
+ showPopup.value = false;
|
|
|
+ resetPrediction();
|
|
|
+};
|
|
|
+
|
|
|
+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);
|
|
|
+
|
|
|
+ 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();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ nextTick(() => {
|
|
|
+ initMap();
|
|
|
+ });
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.feature-popup {
|
|
|
+ position: fixed;
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%,-50%);
|
|
|
+ z-index: 1000;
|
|
|
+ background: white;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
|
+ min-width: 320px;
|
|
|
+ max-width: 380px;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-content {
|
|
|
+ padding: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 10px 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: 15px;
|
|
|
+ max-height: 500px;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.coordinate-info {
|
|
|
+ margin-bottom: 12px;
|
|
|
+ padding-bottom: 12px;
|
|
|
+ border-bottom: 1px solid #f0f0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.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;
|
|
|
+ border-top: 1px solid #f0f0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.acid-reduction-input {
|
|
|
+ margin-top: 12px;
|
|
|
+ padding-top: 12px;
|
|
|
+ border-top: 1px solid #f0f0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.input-buttons {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ justify-content: flex-end;
|
|
|
+ margin-top: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.prediction-section {
|
|
|
+ margin-top: 12px;
|
|
|
+ padding-top: 12px;
|
|
|
+ border-top: 1px solid #f0f0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.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;
|
|
|
+}
|
|
|
+
|
|
|
+.loading-spinner {
|
|
|
+ width: 20px;
|
|
|
+ height: 20px;
|
|
|
+ border: 2px solid #f3f3f3;
|
|
|
+ border-top: 2px solid #409eff;
|
|
|
+ border-radius: 50%;
|
|
|
+ animation: spin 1s linear infinite;
|
|
|
+}
|
|
|
+
|
|
|
+.loading-spinner.small {
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ border-width: 1.5px;
|
|
|
+}
|
|
|
+
|
|
|
+@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;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.el-input__inner) {
|
|
|
+ height: 28px;
|
|
|
+ line-height: 28px;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-body {
|
|
|
+ padding: 15px;
|
|
|
+ overflow-y: auto;
|
|
|
+ max-height: calc(80vh - 60px); /* 减去头部高度 */
|
|
|
+}
|
|
|
+
|
|
|
+.popup-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+</style>
|