yangtaodemon 15 цаг өмнө
parent
commit
7d45d0304c

+ 5 - 0
src/components/layout/menuItems.ts

@@ -44,6 +44,11 @@ export const tabMenuMap: Record<string, MenuItem[]> = {
       label: "土壤酸化地图展示",
       icon: Location,
     },
+    {
+      index: "/pHPrediction",
+      label: "土壤pH预测",
+      icon: Location
+    }
   ],
   Calculation: [
     {

+ 9 - 0
src/router/index.ts

@@ -96,6 +96,15 @@ const routes = [
             "@/views/User/acidModel/acidmodelmap.vue"
           ),
         meta: { title: "土壤酸化地图" }
+      },
+       {
+        path: "pHPrediction",
+        name: "pHPrediction",
+        component: () =>
+          import(
+            "@/views/User/acidModel/pHPrediction.vue"
+          ),
+        meta: { title: "土壤pH预测" }
       },
       {
         path: "SoilAcidificationData",

+ 191 - 29
src/views/User/acidModel/ModelIterationVisualization.vue

@@ -1,5 +1,5 @@
 <script setup lang='ts'>
-import { ref, onMounted, nextTick, onUnmounted, defineProps } from 'vue';
+import { ref, onMounted, nextTick, onUnmounted, watch } from 'vue';
 import VueEcharts from 'vue-echarts';
 import 'echarts';
 import { api5000 } from '../../../utils/request';
@@ -76,6 +76,18 @@ const ecInitScatterOptionRef = ref<InstanceType<typeof VueEcharts>>();
 const ecMidScatterOptionRef = ref<InstanceType<typeof VueEcharts>>();
 const ecFinalScatterOptionRef = ref<InstanceType<typeof VueEcharts>>();
 
+// 新增:图片URL和模型类型选择
+const learningCurveImageUrl = ref('');
+const dataIncreaseCurveImageUrl = ref('');
+const selectedModelType = ref('rf'); // 默认选择随机森林
+
+// 模型类型选项
+const modelTypeOptions = [
+  { label: '随机森林', value: 'rf' },
+  { label: 'XGBoost', value: 'xgbr' },
+  { label: '梯度提升', value: 'gbst' },
+];
+
 // 计算数据范围的函数
 const calculateDataRange = (data: [number, number][]) => {
   const xValues = data.map(item => item[0]);
@@ -91,8 +103,6 @@ const calculateDataRange = (data: [number, number][]) => {
 // 获取折线图数据
 const fetchLineData = async () => {
   try {
-
-    // 修改后
     const response = await api5000.get<HistoryDataResponse>(`/get-model-history/${props.lineChartPathParam}`);    
     const data = response.data;
 
@@ -219,6 +229,47 @@ const fetchScatterData = async (modelId: number, optionRef: any) => {
   }
 };
 
+// 新增:获取学习曲线图片
+const fetchLearningCurveImage = async () => {
+  try {
+    const response = await api5000.get('/latest-learning-curve', {
+      params: {
+        data_type: 'reduce',
+        model_type: selectedModelType.value
+      },
+      responseType: 'blob'
+    });
+    
+    const blob = new Blob([response.data], { type: 'image/png' });
+    learningCurveImageUrl.value = URL.createObjectURL(blob);
+  } catch (error) {
+    console.error('获取学习曲线图片失败:', error);
+  }
+};
+
+// 新增:获取数据增长曲线图片
+const fetchDataIncreaseCurveImage = async () => {
+  try {
+    const response = await api5000.get('/latest-data-increase-curve', {
+      params: {
+        data_type: 'reduce',
+      },
+      responseType: 'blob'
+    });
+    
+    const blob = new Blob([response.data], { type: 'image/png' });
+    dataIncreaseCurveImageUrl.value = URL.createObjectURL(blob);
+  } catch (error) {
+    console.error('获取数据增长曲线图片失败:', error);
+  }
+};
+
+// 监听模型类型变化,重新获取图片
+watch(selectedModelType, () => {
+  fetchLearningCurveImage();
+  fetchDataIncreaseCurveImage();
+});
+
 // 定义调整图表大小的函数
 const resizeCharts = () => {
   nextTick(() => {
@@ -235,6 +286,10 @@ onMounted(async () => {
   if (props.showMidScatterChart) await fetchScatterData(props.midScatterModelId, ecMidScatterOption);
   if (props.showFinalScatterChart) await fetchScatterData(props.finalScatterModelId, ecFinalScatterOption);
 
+  // 新增:获取图片
+  await fetchLearningCurveImage();
+  await fetchDataIncreaseCurveImage();
+
   // 页面加载完成后调整图表大小
   resizeCharts();
 
@@ -245,38 +300,58 @@ onMounted(async () => {
 // 组件卸载时移除事件监听器
 onUnmounted(() => {
   window.removeEventListener('resize', resizeCharts);
+  
+  // 清理Blob URL
+  if (learningCurveImageUrl.value) {
+    URL.revokeObjectURL(learningCurveImageUrl.value);
+  }
+  if (dataIncreaseCurveImageUrl.value) {
+    URL.revokeObjectURL(dataIncreaseCurveImageUrl.value);
+  }
 });
 </script>
 
 <template>
   <div class="container">
-    <!-- <template v-if="showLineChart">
-       折线图表头
-      <div class="chart-container">
-        <VueEcharts :option="ecLineOption" ref="ecLineOptionRef" />
-      </div>
-    </template> -->
     <template v-if="showInitScatterChart">
-      <!-- 初代散点图表头 -->
-      <h2 class="chart-header">初代散点图</h2>
+      <!-- 散点图 -->
+      <h2 class="chart-header">散点图</h2>
       <div class="chart-container">
         <VueEcharts :option="ecInitScatterOption" ref="ecInitScatterOptionRef" />
       </div>
     </template>
-    <template v-if="showMidScatterChart">
-      <!-- 中间代散点图表头 -->
-      <h2 class="chart-header">中间代散点图</h2>
-      <div class="chart-container">
-        <VueEcharts :option="ecMidScatterOption" ref="ecMid极简白OptionRef" />
+
+    <h2 class="chart-header">模型性能分析</h2>
+    <!-- 模型性能分析部分 -->
+    <div class="analysis-section">
+       <!-- 数据增长曲线模块 -->
+      <div class="chart-module">
+        <h2 class="chart-header">数据增长曲线</h2>
+        <div class="image-chart-item">
+          <div class="image-container">
+            <img v-if="dataIncreaseCurveImageUrl" :src="dataIncreaseCurveImageUrl" alt="数据增长曲线" >
+            <div v-else class="image-placeholder">加载中...</div>
+          </div>
+        </div>
       </div>
-    </template>
-    <template v-if="showFinalScatterChart">
-      <!-- 最终代散点图表头 -->
-      <h2 class="chart-header">最终代散点图</h2>
-      <div class="chart-container">
-        <VueEcharts :option="ecFinalScatterOption" ref="ecFinalScatterOptionRef" />
+      <!-- 学习曲线模块 -->
+      <div class="chart-module">
+        <h2 class="chart-header">学习曲线</h2>
+        <div class="model-selector-wrapper">
+          <select v-model="selectedModelType" class="model-select">
+            <option v-for="option in modelTypeOptions" :key="option.value" :value="option.value">
+              {{ option.label }}
+            </option>
+          </select>
+        </div>
+        <div class="image-chart-item">
+          <div class="image-container">
+            <img v-if="learningCurveImageUrl" :src="learningCurveImageUrl" alt="学习曲线" >
+            <div v-else class="image-placeholder">加载中...</div>
+          </div>
+        </div>
       </div>
-    </template>
+    </div>
   </div>
 </template>
 
@@ -290,7 +365,7 @@ onUnmounted(() => {
   height: 100%;
   gap: 20px;
   color: #000;
-  background-color: white; /* 添加白色背景 */
+  background-color: white;
 }
 
 .chart-header {
@@ -310,16 +385,89 @@ onUnmounted(() => {
   height: 450px;
   margin: 0 auto;
   margin-bottom: 20px;
-  background-color: white; /* 图表容器背景为白色 */
+  background-color: white;
   border-radius: 8px;
-  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); /* 添加轻微阴影增强层次感 */
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
 }
 
 .VueEcharts {
   width: 100%;
   height: 100%;
   margin: 0 10px;
-  background-color: white; /* ECharts图表背景为白色 */
+  background-color: white;
+}
+
+/* 新增:图片图表样式 */
+.image-charts-section {
+  width: 90%;
+  margin: 30px auto;
+  padding: 20px;
+  background-color: #f8f9fa;
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.model-type-selector {
+  margin: 20px 0;
+  text-align: center;
+}
+
+.model-type-selector label {
+  margin-right: 10px;
+  font-weight: bold;
+  color: #2c3e50;
+}
+
+.model-select {
+  padding: 8px 12px;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  background-color: white;
+  font-size: 14px;
+}
+
+.image-charts-container {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 30px;
+  margin-top: 20px;
+}
+
+.image-chart-item {
+  background-color: white;
+  padding: 20px;
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.image-chart-title {
+  font-size: 16px;
+  font-weight: bold;
+  margin-bottom: 15px;
+  color: #2c3e50;
+  text-align: center;
+}
+
+.image-container {
+  width: 100%;
+  height: 400px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: #f8f9fa;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.image-container img {
+  max-width: 100%;
+  max-height: 100%;
+  object-fit: contain;
+}
+
+.image-placeholder {
+  color: #6c757d;
+  font-style: italic;
 }
 
 /* 确保图表内部也是白色背景 */
@@ -342,6 +490,20 @@ onUnmounted(() => {
 :deep(.echarts-legend) {
   color: #333;
 }
-</style>
-
 
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .image-charts-container {
+    grid-template-columns: 1fr;
+  }
+  
+  .image-container {
+    height: 300px;
+  }
+  
+  .chart-container {
+    width: 95%;
+    height: 400px;
+  }
+}
+</style>

+ 157 - 144
src/views/User/acidModel/acidmodelmap.vue

@@ -1,8 +1,8 @@
 <template>
-  <div class="container">
   <el-card class="map-card">
     <div class="title">
-      <p class="map-title">乐昌市土地酸化预测</p>
+      <div class="section-icon">🗺️</div>
+      <p class="map-title">细粒度地块级酸化预测</p>
     </div>
 
     <div id="map-container" class="map-container">
@@ -25,8 +25,9 @@
          ref="popupElement"  
          class="feature-popup fixed-center"
          :style="{
-          left: popupPosition.x + 'px',
-          top: popupPosition.y + 'px'
+          left: popupPosition.x + '%',
+          top: popupPosition.y + '%',
+          translate:'none'
          }">
       <div class="popup-content">
         <div class="popup-header" @mousedown="startDrag">
@@ -102,7 +103,7 @@
               </div>
 
     <div class="params-row">
-      <el-form-item label="NO3" prop="NO3" class="form-item-compact">
+      <el-form-item label="硝酸盐" prop="NO3" class="form-item-compact">
         
         <el-input
           v-model.number="acidReductionParams.NO3"
@@ -113,7 +114,7 @@
           :disabled="phLoading"
         />
       </el-form-item>
-      <el-form-item label="NH4" prop="NH4" class="form-item-compact">
+      <el-form-item label="铵盐" prop="NH4" class="form-item-compact">
         
         <el-input
           v-model.number="acidReductionParams.NH4"
@@ -158,7 +159,7 @@
 
     <!-- NO3和NH4在同一行 -->
     <div class="params-row">
-      <el-form-item label="NO3" prop="NO3" class="form-item-compact">
+      <el-form-item label="硝酸盐" prop="NO3" class="form-item-compact">
         
         <el-input
           v-model.number="acidInversionParams.NO3"
@@ -169,7 +170,7 @@
           :disabled="phLoading"
         />
       </el-form-item>
-      <el-form-item label="NH4" prop="NH4" class="form-item-compact">
+      <el-form-item label="铵盐" prop="NH4" class="form-item-compact">
         
         <el-input
           v-model.number="acidInversionParams.NH4"
@@ -204,7 +205,7 @@
             <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/10)}} 吨</span>
+                <span class="prediction-value reduction">每亩地土壤表层20cm撒{{ formatPredictionValue(predictionResult.prediction_reduce)}} 吨</span>
               </div>
               <!-- 反酸预测结果 -->
               <div class="result-item" v-if="currentPredictionType === 'inversion' && predictionResult.prediction_reflux !== undefined">
@@ -225,14 +226,12 @@
          :style="connectionLine.style">
     </div>
   </el-card>
-  </div>
 </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 { api5000, api8000, api8080 } from '@/utils/request'; // 导入封装好的API实例
 
 // 新增:反酸预测相关状态(区分降酸/反酸的显示)
 const showInversionPrediction = ref(false); // 控制反酸预测区域显示
@@ -404,20 +403,20 @@ const acidReductionRules = reactive({
 // 格式化预测值
 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);
 };
@@ -472,13 +471,13 @@ const drawHighlightFeature = (geoJsonFeature: any) => {
     return scaledFeature;
   };
 
-  // 创建放大1.3倍的地块
-  const scaledFeature = scaleFeatureCoordinates(geoJsonFeature, 1.3);
+  // 创建放大1倍的地块
+  const scaledFeature = scaleFeatureCoordinates(geoJsonFeature, 1);
 
   // 使用放大后的GeoJSON创建高亮图层
   highlightLayer.value = L.geoJSON(scaledFeature, {
     style: {
-      color: '#ff4d4f',
+      color: '#000',
       weight: 4,
       fillColor: '#ff4d4f',
       fillOpacity: 1,
@@ -513,7 +512,7 @@ const drawHighlightFeature = (geoJsonFeature: any) => {
 // 获取地块信息
 const getFeatureInfo = async (latlng: any, point: any): Promise<boolean> => {
   if (!map.value) return false;
-
+  
   featureInfo.loading = true;
   featureInfo.error = false;
   featureInfo.data = null;
@@ -525,7 +524,7 @@ const getFeatureInfo = async (latlng: any, point: any): Promise<boolean> => {
       layerGroup: "mapwithboundary", 
     };
 
-   const bounds = map.value.getBounds();
+    const bounds = map.value.getBounds();
     const size = map.value.getSize();
     
     const params = new URLSearchParams();
@@ -544,30 +543,24 @@ const getFeatureInfo = async (latlng: any, point: any): Promise<boolean> => {
     params.append('bbox', `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`);
 
     const url = `${GEOSERVER_CONFIG.url}?${params.toString()}`;
-    console.log('GetFeatureInfo URL:', url); // 添加日志
     
-    // 修改:使用 apiMain 代替 fetch
-    const response = await api8080.get(url);
+    const response = await fetch(url);
     
-    if (response.status !== 200) {
+    if (!response.ok) {
       throw new Error(`HTTP error! status: ${response.status}`);
     }
     
-    const data = response.data;
-    console.log('GeoServer返回数据:', data); // 添加日志
+    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;
-      const village = properties.QSDWMC || properties.village || properties.VILLAGE || properties.name || '未知';
-      const landType = properties.DLMC || properties.landType || properties.LANDTYPE || properties.type || '未知';
       
       if (hasValidData) {
         featureInfo.data = {
-          village: village,
-          landType: landType,
+          village: properties.QSDWMC,
+          landType: properties.DLMC,
         };
         // 绘制高亮地块
         drawHighlightFeature(feature);
@@ -575,21 +568,11 @@ const getFeatureInfo = async (latlng: any, point: any): Promise<boolean> => {
       }
     }
     
-    // 如果没有数据,显示默认信息
-    featureInfo.data = {
-      village: '无数据',
-      landType: '无数据',
-    };
     return false;
     
   } catch (error) {
     console.error('获取地块信息失败:', error);
     featureInfo.error = true;
-    // 错误时也显示默认信息
-    featureInfo.data = {
-      village: '获取失败',
-      landType: '获取失败',
-    };
     return false;
   } finally {
     featureInfo.loading = false;
@@ -603,18 +586,22 @@ const fetchCurrentPH = async () => {
 
   phLoading.value = true;
   try {
-    const params = {
-      target_lon: currentClickCoords.lng.toString(),
-      target_lat: currentClickCoords.lat.toString(),
-      NO3: defaultParams.NO3.toString(),
-      NH4: defaultParams.NH4.toString()
-    };
-
-   // 修改:使用 api8000 代替 fetch
-   const response = await api8000.get('/api/vector/nearest-with-predictions', { params });
+    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()}`
+    );
 
-  // 从接口提取当前pH(根据你的接口结构:data.nearest_point.ph)
-   currentPH.value = response.data.nearest_point?.ph !== undefined ? Number(response.data.nearest_point.ph) : null;
+    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);
@@ -730,37 +717,42 @@ const callPredictionAPI = async (
   predictionResult.value = null;
 
   try {
-    const requestParams: any = {
-      target_lon: lng.toString(),
-      target_lat: lat.toString()
-    };
+    const urlParams = new URLSearchParams();
+    urlParams.append('target_lon', lng.toString());
+    urlParams.append('target_lat', lat.toString());
 
     // 明确传递 prediction_type
     if (currentPredictionType.value === 'reduction') {
-      requestParams.prediction_type = 'reduce';
+      urlParams.append('prediction_type', 'reduce');
     } else if (currentPredictionType.value === 'inversion') {
-      requestParams.prediction_type = 'reflux';
+      urlParams.append('prediction_type', 'reflux');
     }
 
     if (params) {
       Object.entries(params).forEach(([key, value]) => {
         if (value !== undefined && value !== null) {
-          requestParams[key] = value.toString();
+          urlParams.append(key, value.toString());
         }
       });
     }
 
-    console.log('调用预测接口,参数:', requestParams);
+    console.log('调用预测接口,参数:', urlParams.toString());
 
-    // 修改:使用 api8000 代替 fetch
-    const response = await api8000.get('/api/vector/nearest-with-predictions', { 
-      params: requestParams 
-    });
+    const response = await fetch(
+      `http://localhost:8000/api/vector/nearest-with-predictions?${urlParams.toString()}`
+    );
 
-    predictionResult.value = response.data;
-    console.log('预测结果:', response.data);
+    if (!response.ok) {
+      const errorData = await response.json();
+      console.error('预测接口返回错误:', errorData);
+      throw new Error(`HTTP error! status: ${response.status}`);
+    }
 
-    currentPH.value = response.data.nearest_point?.ph !== undefined ? Number(response.data.nearest_point.ph) : null;
+    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;
@@ -791,9 +783,7 @@ const resetPrediction = () => {
 const handleMapClick = async (e: any) => {
   const lng = e.latlng.lng;
   const lat = e.latlng.lat;
-
-  console.log('地图点击坐标:', { lng, lat }); // 添加日志
-
+  
   // 更新当前坐标
   currentClickCoords.lng = lng;
   currentClickCoords.lat = lat;
@@ -811,8 +801,8 @@ const handleMapClick = async (e: any) => {
   clickPoint.y = mapRect.top + containerPoint.y;
   
   // 设置弹窗初始位置(在点击点右侧)
-  popupPosition.x = clickPoint.x + 20;
-  popupPosition.y = clickPoint.y - 100; // 居中显示
+  popupPosition.x = 35;
+  popupPosition.y = 35; // 居中显示
   
   showPopup.value = true;
   showConnectionLine.value=false;
@@ -879,8 +869,8 @@ const closePopup = () => {
 // 新增:弹窗拖动相关状态
 const popupElement = ref<HTMLElement>();
 const popupPosition = reactive({
-  x: 0,
-  y: 0
+  x: 35,
+  y: 35
 });
 
 // 新增:连接线相关状态
@@ -903,8 +893,14 @@ const startDrag = (event: MouseEvent) => {
   if (!popupElement.value) return;
   
   isDragging.value = true;
-  dragStartPos.x = event.clientX - popupPosition.x;
-  dragStartPos.y = event.clientY - popupPosition.y;
+  // 初始值:百分比转像素
+  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);
@@ -914,8 +910,15 @@ const startDrag = (event: MouseEvent) => {
 const onDrag = (event: MouseEvent) => {
   if (!isDragging.value) return;
   
-  popupPosition.x = event.clientX - dragStartPos.x;
-  popupPosition.y = event.clientY - dragStartPos.y;
+  // 拖拽后转成百分比
+  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();
 };
 
@@ -923,6 +926,9 @@ const stopDrag = () => {
   isDragging.value = false;
   document.removeEventListener('mousemove', onDrag);
   document.removeEventListener('mouseup', stopDrag);
+  nextTick(() => {
+    updateConnectionLine();
+  });
 };
 
 // 修改更新连接线方法,使用经纬度坐标
@@ -939,8 +945,15 @@ const updateConnectionLine = () => {
   
   // 获取弹窗中心点
   const popupRect = popupElement.value.getBoundingClientRect();
-  const endX = popupPosition.x + popupRect.width / 2;
-  const endY = popupPosition.y + popupRect.height / 2;
+  // 视口总宽度/高度
+  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));
@@ -951,7 +964,8 @@ const updateConnectionLine = () => {
     top: startY + 'px',
     width: length + 'px',
     transform: `rotate(${angle}deg)`,
-    transformOrigin: '0 0'
+    transformOrigin: '0 0',
+    '--arrow-angle':`${angle}deg`
   };
 };
 
@@ -960,21 +974,11 @@ const initMap = async () => {
   mapError.value = false;
 
   try {
-    console.log('开始初始化地图...');
-    
-    // 检查地图容器是否存在
-    const mapContainer = document.getElementById('map-container');
-    if (!mapContainer) {
-      throw new Error('地图容器未找到');
-    }
-    console.log('地图容器尺寸:', mapContainer.offsetWidth, 'x', mapContainer.offsetHeight);
-
     // 动态导入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',
@@ -986,6 +990,7 @@ const initMap = async () => {
     // 清除现有地图
     if (map.value) {
       map.value.remove();
+      map.value = null;
     }
 
     // 创建地图实例
@@ -996,34 +1001,48 @@ const initMap = async () => {
       zoom: 10
     });
 
-    console.log('地图实例创建成功');
-
-    // 然后尝试添加WMS图层
+    // WMS配置
     const GEOSERVER_CONFIG = {
       url: "/geoserver/wms",
       workspace: "acidmap",
       layerGroup: "mapwithboundary", 
     };
 
-    try {
-      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"
-      }).addTo(map.value);
-      console.log('WMS图层添加成功');
-    } catch (wmsError) {
-      console.warn('WMS图层加载失败,使用OSM底图:', wmsError);
-    }
+    // 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;
-    console.log('地图初始化完成');
 
   } catch (error) {
     console.error('地图初始化失败:', error);
@@ -1055,21 +1074,31 @@ onUnmounted(() => {
   // 移除拖拽事件监听
   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;
-  top: 45%;
-  left: 40%;
-  transform: translate(-50%,-50%);
   z-index: 1000;
   background: white;
   border-radius: 8px;
@@ -1260,13 +1289,10 @@ onMounted(() => {
 }
 
 .map-card {
-  background-color: rgba(255, 255, 255, 0.8);
-  border-radius: 8px;
-  padding: 15px;
-  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
-  position: relative;
-  backdrop-filter: blur(5px);
-  overflow: visible !important;
+  width: 850px;
+  flex: 1;
+  min-height: 600px;
+  margin: 0 auto;
 }
 
 .map-container {
@@ -1457,15 +1483,6 @@ onMounted(() => {
   color: #48bb78; /* 反酸结果用绿色 */
 }
 
-.container {
-  padding: 20px;
-  background: linear-gradient(
-    135deg, 
-    rgba(230, 247, 255, 0.7) 0%, 
-    rgba(240, 248, 255, 0.7) 100%
-  );
-  box-sizing: border-box;
-}
 /* 新增:高亮图层的z-index确保在最上层 */
 :deep(.leaflet-geojson) {
   z-index: 999 !important;
@@ -1481,28 +1498,24 @@ onMounted(() => {
   border: none;
   z-index: 999;
   pointer-events: none;
+  --arrow-angle: 0deg;
 }
 
 .connection-line::before {
   content: '';
   position: absolute;
-  width: 6px;
-  height: 6px;
-  background: #409eff;
-  border-radius: 50%;
-  top: -2.5px;
-  left: -3px;
-}
-
-.connection-line::after {
-  content: '';
-  position: absolute;
-  width: 6px;
-  height: 6px;
-  background: #409eff;
-  border-radius: 50%;
-  top: -2.5px;
-  right: -3px;
+  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>

+ 377 - 0
src/views/User/acidModel/pHPrediction.vue

@@ -0,0 +1,377 @@
+<template>
+  <el-card class="box-card">
+    <template #header>
+      <div class="card-header">
+        <span>pH预测模型</span>
+      </div>
+    </template>
+
+    <el-form
+      :model="form"
+      ref="predictForm"
+      label-width="240px"
+      label-position="left"
+    >
+      <el-form-item label="有机质(g/kg)" prop="OM" :error="errorMessages.OM" required>
+        <el-input
+          v-model="form.OM"
+          size="large"
+          placeholder="请输入有机质0~35(g/kg)"
+          @input="handleInput('OM', $event, 0, 35)"
+        ></el-input>
+      </el-form-item>
+      
+      <el-form-item label="氯离子(g/kg)" prop="CL" :error="errorMessages.CL" required>
+        <el-input
+          v-model="form.CL"
+          size="large"
+          placeholder="请输入氯离子0~10(g/kg)"
+          @input="handleInput('CL', $event, 0, 10)"
+        ></el-input>
+      </el-form-item>
+
+      <el-form-item label="阳离子交换量(cmol/kg)" prop="CEC" :error="errorMessages.CEC" required>
+        <el-input
+          v-model="form.CEC"
+          size="large"
+          placeholder="请输入阳离子交换量0~20(cmol/kg)"
+          @input="handleInput('CEC', $event, 0, 20)"
+        ></el-input>
+      </el-form-item>
+
+      <el-form-item label="H+浓度(cmol/kg)" prop="H" :error="errorMessages.H" required>
+        <el-input
+          v-model="form.H"
+          size="large"
+          placeholder="请输入H+浓度0~1(cmol/kg)"
+          @input="handleInput('H', $event, 0, 1)"
+        ></el-input>
+      </el-form-item>
+
+      <el-form-item label="铵态氮(mg/kg)" prop="HN" :error="errorMessages.HN" required>
+        <el-input
+          v-model="form.HN"
+          size="large"
+          placeholder="请输入铵态氮0~30(mg/kg)"
+          @input="handleInput('HN', $event, 0, 30)"
+        ></el-input>
+      </el-form-item>
+
+      <el-form-item label="游离氧化铝(g/kg)" prop="Al3" :error="errorMessages.Al3" required>
+        <el-input
+          v-model="form.Al3"
+          size="large"
+          placeholder="请输入游离氧化铝0~2(g/kg)"
+          @input="handleInput('Al3', $event, 0, 2)"
+        ></el-input>
+      </el-form-item>
+
+       <el-form-item label="氧化铝(g/kg)" prop="AlOx" :error="errorMessages.AlOx" required>
+        <el-input
+          v-model="form.AlOx"
+          size="large"
+          placeholder="请输入氧化铝0~2(g/kg)"
+          @input="handleInput('AlOx', $event, 0, 2)"
+        ></el-input>
+      </el-form-item>
+
+      <el-form-item label="游离铁氧化物(g/kg)" prop="FeOx" :error="errorMessages.FeOx" required>
+        <el-input
+          v-model="form.FeOx"
+          size="large"
+          placeholder="请输入游离铁氧化物0~3(g/kg)"
+          @input="handleInput('FeOx', $event, 0, 3)"
+        ></el-input>
+      </el-form-item>
+
+      <el-form-item label="无定形铁(g/Kg)" prop="AmFe" :error="errorMessages.AmFe" required>
+        <el-input
+          v-model="form.AmFe"
+          size="large"
+          placeholder="请输入无定形铁0~1(g/Kg)"
+          @input="handleInput('AmFe', $event, 0, 1)"
+        ></el-input>
+      </el-form-item>
+
+      <el-form-item label="初始pH值" prop="initpH" :error="errorMessages.initpH" required>
+        <el-input
+          v-model="form.initpH"
+          size="large"
+          placeholder="请输入初始pH值0~14"
+          @input="handleInput('initpH', $event, 0, 14)"
+        ></el-input>
+      </el-form-item>
+
+      <el-button type="primary" @click="onSubmit" class="onSubmit">预测pH曲线</el-button>
+      <el-dialog v-model="dialogVisible" @close="onDialogClose" width="800px" align-center title="pH预测结果">
+        <div class="image-container">
+            <img v-if="imageSrc" :src="imageSrc" alt="预测图片" class="full-image"/>
+        </div>
+        <template #footer>
+          <el-button @click="dialogVisible = false">关闭</el-button>
+        </template>
+      </el-dialog>
+    </el-form>
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref, nextTick } from "vue";
+import { ElMessage } from "element-plus";
+import { api5000 } from "../../../utils/request";
+
+interface Form {
+  OM: number | null;
+  CL: number | null;
+  CEC: number | null;
+  H: number | null;
+  HN: number | null;
+  Al3: number | null;
+  AlOx: number | null;
+  FeOx: number | null;
+  AmFe: number | null;
+  initpH: number | null;
+}
+
+const form = reactive<Form>({
+  OM: null,
+  CL: null,
+  CEC: null,
+  H: null,
+  HN: null,
+  Al3: null,
+  AlOx: null,
+  FeOx: null,
+  AmFe: null,
+  initpH: null,
+});
+
+const imageSrc = ref<string | null>(null);
+const dialogVisible = ref(false);
+const predictForm = ref<any>(null);
+const errorMessages = reactive<Record<string, string>>({
+  OM: "",
+  CL: "",
+  CEC: "",
+  H: "",
+  HN: "",
+  Al3: "",
+  AlOx: "",
+  FeOx: "",
+  AmFe: "",
+  initpH: "",
+});
+
+const handleInput = (field: keyof Form, event: Event, min: number, max: number) => {
+  const target = event.target as HTMLInputElement;
+  let value = target.value.replace(/[^0-9.]/g, "");
+  (form as any)[field] = value ? parseFloat(value) : null;
+
+  if (!value) {
+    errorMessages[field] = "";
+    return;
+  }
+
+  const numValue = parseFloat(value);
+  errorMessages[field] = (isNaN(numValue) || numValue < min || numValue > max)
+    ? `输入值应在 ${min} 到 ${max} 之间`
+    : "";
+};
+
+const validateInput = (value: string, min: number, max: number): boolean => {
+  const numValue = parseFloat(value);
+  return !isNaN(numValue) && numValue >= min && numValue <= max;
+};
+
+const onSubmit = async () => {
+  const inputConfigs = [
+    { field: "OM" as keyof Form, min: 0, max: 35 },
+    { field: "CL" as keyof Form, min: 0, max: 10 },
+    { field: "CEC" as keyof Form, min: 0, max: 20 },
+    { field: "H" as keyof Form, min: 0, max: 1 },
+    { field: "HN" as keyof Form, min: 0, max: 30 },
+    { field: "Al3" as keyof Form, min: 0, max: 2 },
+    { field: "AlOx" as keyof Form, min: 0, max: 2},
+    { field: "FeOx" as keyof Form, min: 0, max: 3 },
+    { field: "AmFe" as keyof Form, min: 0, max: 1 },
+    { field: "initpH" as keyof Form, min: 0, max: 14 },
+  ];
+
+  let isValid = true;
+  for (const config of inputConfigs) {
+    const { field, min, max } = config;
+    const value = form[field];
+    if (value === null || !validateInput(value.toString(), min, max)) {
+      isValid = false;
+      errorMessages[field] = `输入值应在 ${min} 到 ${max} 之间`;
+    } else {
+      errorMessages[field] = "";
+    }
+  }
+
+  if (!isValid) {
+    ElMessage.error("输入值不符合要求,请检查输入");
+    return;
+  }
+
+  const features = {
+    "OM g/kg": form.OM,
+    "CL g/kg": form.CL,
+    "CEC cmol/kg": form.CEC,
+    "H+ cmol/kg": form.H,
+    "HN mg/kg": form.HN,
+    "Al3+ cmol/kg": form.Al3,
+    "Free alumina g/kg": form.AlOx,                                                              
+    "Free iron oxides g/kg": form.FeOx,
+    "Amorphous iron g/Kg": form.AmFe,
+    "0 day": form.initpH
+  };
+
+  const curve =  {                                                                               
+      "start_day": 0,                                                                        
+      "end_day": 200,                                                                        
+      "num_points": 80,                                                                      
+      "return_binary": true                                                     
+    };           
+
+  try {
+    const response = await api5000.post('/api/ph/predict', {
+      day: 50,
+      features,
+      curve
+    }, {
+      responseType: 'arraybuffer'
+    });
+
+    const blob = new Blob([response.data], { type: 'image/png' });
+    imageSrc.value = URL.createObjectURL(blob);
+    dialogVisible.value = true;
+  } catch (error) {
+    ElMessage.error(`请求失败: ${error}`);
+  }
+};
+
+const onDialogClose = () => {
+  dialogVisible.value = false;
+  imageSrc.value = null;
+  Object.keys(form).forEach(key => form[key as keyof Form] = null);
+  Object.keys(errorMessages).forEach(key => errorMessages[key] = "");
+};
+
+
+</script>
+
+<style scoped>
+.box-card {
+  max-width: 850px;
+  margin: 0 auto;
+  padding: 20px;
+  background-color: #f0f5ff;
+  border-radius: 10px;
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+}
+
+.card-header {
+  font-size: 25px;
+  text-align: center;
+  color: #333;
+  margin-bottom: 30px;
+}
+
+.el-form-item {
+  margin-bottom: 20px;
+}
+
+:deep(.el-form-item__label) {
+  font-size: 18px;
+  color: #666;
+}
+
+.model-info {
+  text-align: center;
+  margin-bottom: 20px;
+  font-size: 16px;
+  color: #555;
+}
+
+.el-input {
+  width: 80%;
+}
+
+.onSubmit {
+  display: block;
+  margin: 0 auto;
+  background-color: #007bff;
+  color: white;
+  padding: 10px 20px;
+  border-radius: 5px;
+  font-size: 16px;
+  transition: background-color 0.3s ease;
+}
+
+.onSubmit:hover {
+  background-color: #0056b3;
+}
+
+.dialog-class {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 100%; /* 确保容器占满对话框内容区域 */
+  font-size: 22px;
+}
+
+@media (max-width: 576px) {
+  .el-form {
+    --el-form-label-width: 100px;
+  }
+}
+
+.full-image {
+  max-width: 100%;
+  max-height: 100vh; /* 最大高度不超过视口 */
+  width: auto;
+  height: auto;
+  object-fit: contain; /* 保持比例 */
+  border: 2px solid #409eff;
+  box-shadow: 0 0 20px rgba(0,0,0,0.3);
+}
+
+.image-container {
+  width: 100%;
+  height: 100vh; /* 占满视口高度 */
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  overflow: auto; /* 添加滚动条防止极端情况 */
+}
+
+.el-dialog {
+  margin-top: 0 !important;
+  margin-bottom: 0 !important;
+}
+
+.el-dialog__header {
+  padding: 15px 20px;
+  background-color: #f5f7fa;
+  border-bottom: 1px solid #e4e7ed;
+}
+
+.el-dialog__body {
+  padding: 0 !important;
+  overflow: hidden;
+}
+
+/* 移动端适配 */
+@media (max-width: 768px) {
+  .image-container {
+    height: auto;
+    padding: 10px;
+  }
+  
+  .full-image {
+    max-width: calc(100vw - 20px);
+    max-height: calc(100vh - 40px);
+  }
+}
+</style>

+ 193 - 28
src/views/User/neutralizationModel/ModelIterationVisualization.vue

@@ -1,5 +1,5 @@
 <script setup lang='ts'>
-import { ref, onMounted, nextTick, onUnmounted, defineProps } from 'vue';
+import { ref, onMounted, nextTick, onUnmounted, watch } from 'vue';
 import VueEcharts from 'vue-echarts';
 import 'echarts';
 import { api5000 } from '../../../utils/request';
@@ -48,7 +48,7 @@ const props = defineProps({
   },
   lineChartPathParam: {
     type: String,
-    default: 'reduce'
+    default: 'reflux'
   },
   initScatterModelId: {
     type: Number,
@@ -76,6 +76,19 @@ const ecInitScatterOptionRef = ref<InstanceType<typeof VueEcharts>>();
 const ecMidScatterOptionRef = ref<InstanceType<typeof VueEcharts>>();
 const ecFinalScatterOptionRef = ref<InstanceType<typeof VueEcharts>>();
 
+// 新增:图片URL和模型类型选择
+const learningCurveImageUrl = ref('');
+const dataIncreaseCurveImageUrl = ref('');
+const selectedModelType = ref('rf'); // 默认选择随机森林
+
+// 模型类型选项
+const modelTypeOptions = [
+  { label: '随机森林', value: 'rf' },
+  { label: 'XGBoost', value: 'xgbr' },
+  { label: '梯度提升', value: 'gbst' },
+];
+
+
 // 计算数据范围的函数
 const calculateDataRange = (data: [number, number][]) => {
   const xValues = data.map(item => item[0]);
@@ -217,6 +230,46 @@ const fetchScatterData = async (modelId: number, optionRef: any) => {
   }
 };
 
+// 新增:获取学习曲线图片
+const fetchLearningCurveImage = async () => {
+  try {
+    const response = await api5000.get('/latest-learning-curve', {
+      params: {
+        data_type: 'reflux',
+        model_type: selectedModelType.value
+      },
+      responseType: 'blob'
+    });
+    
+    const blob = new Blob([response.data], { type: 'image/png' });
+    learningCurveImageUrl.value = URL.createObjectURL(blob);
+  } catch (error) {
+    console.error('获取学习曲线图片失败:', error);
+  }
+};
+
+// 新增:获取数据增长曲线图片
+const fetchDataIncreaseCurveImage = async () => {
+  try {
+    const response = await api5000.get('/latest-data-increase-curve', {
+      params: {
+        data_type: 'reflux',
+      },
+      responseType: 'blob'
+    });
+    
+    const blob = new Blob([response.data], { type: 'image/png' });
+    dataIncreaseCurveImageUrl.value = URL.createObjectURL(blob);
+  } catch (error) {
+    console.error('获取数据增长曲线图片失败:', error);
+  }
+};
+
+// 监听模型类型变化,重新获取图片
+watch(selectedModelType, () => {
+  fetchLearningCurveImage();
+  fetchDataIncreaseCurveImage();
+});
 // 定义调整图表大小的函数
 const resizeCharts = () => {
   nextTick(() => {
@@ -233,6 +286,10 @@ onMounted(async () => {
   if (props.showMidScatterChart) await fetchScatterData(props.midScatterModelId, ecMidScatterOption);
   if (props.showFinalScatterChart) await fetchScatterData(props.finalScatterModelId, ecFinalScatterOption);
 
+  // 新增:获取图片
+  await fetchLearningCurveImage();
+  await fetchDataIncreaseCurveImage();
+
   // 页面加载完成后调整图表大小
   resizeCharts();
 
@@ -243,37 +300,58 @@ onMounted(async () => {
 // 组件卸载时移除事件监听器
 onUnmounted(() => {
   window.removeEventListener('resize', resizeCharts);
+
+  // 清理Blob URL
+  if (learningCurveImageUrl.value) {
+    URL.revokeObjectURL(learningCurveImageUrl.value);
+  }
+  if (dataIncreaseCurveImageUrl.value) {
+    URL.revokeObjectURL(dataIncreaseCurveImageUrl.value);
+  }
 });
 </script>
+
 <template>
   <div class="container">
-    <!-- <template v-if="showLineChart">
-      折线图表头 
-      <div class="chart-container">
-        <VueEcharts :option="ecLineOption" ref="ecLineOptionRef" />
-      </div>
-    </template> -->
     <template v-if="showInitScatterChart">
-      <!-- 初代散点图表头 -->
-      <h2 class="chart-header">初代散点图</h2>
+      <!-- 散点图 -->
+      <h2 class="chart-header">散点图</h2>
       <div class="chart-container">
         <VueEcharts :option="ecInitScatterOption" ref="ecInitScatterOptionRef" />
       </div>
     </template>
-    <template v-if="showMidScatterChart">
-      <!-- 中间代散点图表头 -->
-      <h2 class="chart-header">中间代散点图</h2>
-      <div class="chart-container">
-        <VueEcharts :option="ecMidScatterOption" ref="ecMid极简白OptionRef" />
+
+   <h2 class="chart-header">模型性能分析</h2>
+    <!-- 模型性能分析部分 -->
+    <div class="analysis-section">
+       <!-- 数据增长曲线模块 -->
+      <div class="chart-module">
+        <h2 class="chart-header">数据增长曲线</h2>
+        <div class="image-chart-item">
+          <div class="image-container">
+            <img v-if="dataIncreaseCurveImageUrl" :src="dataIncreaseCurveImageUrl" alt="数据增长曲线" >
+            <div v-else class="image-placeholder">加载中...</div>
+          </div>
+        </div>
       </div>
-    </template>
-    <template v-if="showFinalScatterChart">
-      <!-- 最终代散点图表头 -->
-      <h2 class="chart-header">最终代散点图</h2>
-      <div class="chart-container">
-        <VueEcharts :option="ecFinalScatterOption" ref="ecFinalScatterOptionRef" />
+      <!-- 学习曲线模块 -->
+      <div class="chart-module">
+        <h2 class="chart-header">学习曲线</h2>
+        <div class="model-selector-wrapper">
+          <select v-model="selectedModelType" class="model-select">
+            <option v-for="option in modelTypeOptions" :key="option.value" :value="option.value">
+              {{ option.label }}
+            </option>
+          </select>
+        </div>
+        <div class="image-chart-item">
+          <div class="image-container">
+            <img v-if="learningCurveImageUrl" :src="learningCurveImageUrl" alt="学习曲线" >
+            <div v-else class="image-placeholder">加载中...</div>
+          </div>
+        </div>
       </div>
-    </template>
+    </div>
   </div>
 </template>
 
@@ -287,7 +365,7 @@ onUnmounted(() => {
   height: 100%;
   gap: 20px;
   color: #000;
-  background-color: white; /* 添加白色背景 */
+  background-color: white;
 }
 
 .chart-header {
@@ -307,16 +385,89 @@ onUnmounted(() => {
   height: 450px;
   margin: 0 auto;
   margin-bottom: 20px;
-  background-color: white; /* 图表容器背景为白色 */
+  background-color: white;
   border-radius: 8px;
-  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); /* 添加轻微阴影增强层次感 */
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
 }
 
 .VueEcharts {
   width: 100%;
   height: 100%;
   margin: 0 10px;
-  background-color: white; /* ECharts图表背景为白色 */
+  background-color: white;
+}
+
+/* 新增:图片图表样式 */
+.image-charts-section {
+  width: 90%;
+  margin: 30px auto;
+  padding: 20px;
+  background-color: #f8f9fa;
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.model-type-selector {
+  margin: 20px 0;
+  text-align: center;
+}
+
+.model-type-selector label {
+  margin-right: 10px;
+  font-weight: bold;
+  color: #2c3e50;
+}
+
+.model-select {
+  padding: 8px 12px;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  background-color: white;
+  font-size: 14px;
+}
+
+.image-charts-container {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 30px;
+  margin-top: 20px;
+}
+
+.image-chart-item {
+  background-color: white;
+  padding: 20px;
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.image-chart-title {
+  font-size: 16px;
+  font-weight: bold;
+  margin-bottom: 15px;
+  color: #2c3e50;
+  text-align: center;
+}
+
+.image-container {
+  width: 100%;
+  height: 400px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: #f8f9fa;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.image-container img {
+  max-width: 100%;
+  max-height: 100%;
+  object-fit: contain;
+}
+
+.image-placeholder {
+  color: #6c757d;
+  font-style: italic;
 }
 
 /* 确保图表内部也是白色背景 */
@@ -339,6 +490,20 @@ onUnmounted(() => {
 :deep(.echarts-legend) {
   color: #333;
 }
-</style>
-
 
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .image-charts-container {
+    grid-template-columns: 1fr;
+  }
+  
+  .image-container {
+    height: 300px;
+  }
+  
+  .chart-container {
+    width: 95%;
+    height: 400px;
+  }
+}
+</style>