Преглед изворни кода

班级对比导出、五率综合

liurongli пре 1 месец
родитељ
комит
087075b5a0

+ 9 - 0
src/api/analysis.ts

@@ -86,6 +86,15 @@ export const classContrastSubjectTable = (data: any): Promise<ApiResponse> => {
     data,
   });
 };
+// 导出
+export const publicExportFive = (data: any): Promise<ApiResponse> => {
+  return request({
+    url: "/api/v1/ai_analysis/excel/publicExport_five",
+    method: "post",
+    data,
+    responseType: 'blob'
+  });
+};
 // ==========================================小题分析============================================
 export const questionAnalysis = (data: any): Promise<ApiResponse> => {
   return request({

+ 27 - 2
src/components/ReportModule.vue

@@ -17,7 +17,11 @@
       <div class="title_right">
         <slot name="title_right" />
         <template v-if="showPrintBtn">
-          <el-button class="default_button" :loading="state.printLoading" @click="PrintPdf">
+          <el-button
+            class="default_button"
+            :loading="state.printLoading"
+            @click="PrintPdf"
+          >
             <img
               v-if="!state.printLoading"
               src="@/assets/icon/print_icon.webp"
@@ -272,6 +276,27 @@ defineExpose({
           color: #666;
         }
       }
+      :deep(.right_item) {
+        font-size: 14px;
+        color: #999;
+        font-weight: 400;
+        cursor: pointer;
+        height: 30px;
+        line-height: 32px;
+        margin-right: 5px;
+        border-bottom: 2px solid #ffffff;
+        &.no_border {
+          border-bottom: 0 !important;
+        }
+
+        &.item_active {
+          font-size: 14px;
+          color: #2e64fa;
+          font-weight: 500;
+          cursor: pointer;
+          border-bottom: 2px solid #2e64fa;
+        }
+      }
     }
   }
 
@@ -396,7 +421,7 @@ defineExpose({
       padding-left: 20px;
       box-sizing: border-box;
     }
-    :deep(.content_right) { 
+    :deep(.content_right) {
       padding-right: 20px;
       box-sizing: border-box;
     }

+ 112 - 86
src/components/StudentQuestionImg.vue

@@ -1,26 +1,26 @@
 <template>
-  <div 
-    class="canvas_image" 
-    v-loading="isLoading" 
-    element-loading-text="加载中……" 
-    element-loading-spinner="el-icon-loading" 
+  <div
+    class="canvas_image"
+    v-loading="isLoading"
+    element-loading-text="加载中……"
+    element-loading-spinner="el-icon-loading"
     element-loading-background="#ffffff"
   >
     <!-- PointCanvas 组件需要确保也是 Vue 3 版本 -->
-    <PointCanvas 
-      ref="pointCanvasRef" 
-      :usedCardType="usedCardType" 
-      :drawData="currentDrawData" 
-      :paperImage="paperImage" 
+    <PointCanvas
+      ref="pointCanvasRef"
+      :usedCardType="usedCardType"
+      :drawData="currentDrawData"
+      :paperImage="paperImage"
       type="question"
     ></PointCanvas>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, watch, onMounted, nextTick } from 'vue';
+import { ref, watch, onMounted, nextTick } from "vue";
 import PointCanvas from "@/components/QuestionPoint.vue"; // 小题的画布版本
-import { getStudentPaperCardInfo } from '@/api/analysis';
+import { getStudentPaperCardInfo } from "@/api/analysis";
 
 // --- 类型定义 ---
 
@@ -67,7 +67,7 @@ interface Props {
 const props = withDefaults(defineProps<Props>(), {
   paperInfo: () => ({}),
   paperData: () => ({}),
-  isBatch: false
+  isBatch: false,
 });
 
 // --- 响应式数据 ---
@@ -76,10 +76,10 @@ const pointCanvasRef = ref<InstanceType<typeof PointCanvas> | null>(null);
 
 const paperImageList = ref<PageVO[]>([]); // 学生试卷图片列表
 const currentIndex = ref<number>(0); // 当前学生试卷图片索引
-const currentPaperUrl = ref<string>(''); // 当前学生试卷图片地址
+const currentPaperUrl = ref<string>(""); // 当前学生试卷图片地址
 
 const paperImage = ref<{ url: string; page: number }[]>([]); // 学生试卷图片列表(用于显示)
-const currentDownLoadName = ref<string>(''); // 当前学生试卷图片下载名称
+const currentDownLoadName = ref<string>(""); // 当前学生试卷图片下载名称
 const currentDrawData = ref<any[]>([]); // 当前学生试卷答题标记数据
 const questionList = ref<QuestionVO[]>([]); // 学生试卷题目列表
 const usedCardType = ref<number | null>(null); // 1系统卡 2 三方卡
@@ -88,38 +88,46 @@ const isLoading = ref<boolean>(false); // 是否正在加载中
 // --- 方法 ---
 
 // 获取图片信息 (宽高)
-const GetImageInfo = async (imageUrl: string): Promise<{ width: number; height: number }> => {
+const GetImageInfo = async (
+  imageUrl: string,
+): Promise<{ width: number; height: number }> => {
   try {
     // 注意:fetch 可能在某些环境下需要配置代理或处理 CORS
-    const response = await fetch(imageUrl + '?x-oss-process=image/info');
+    const response = await fetch(imageUrl + "?x-oss-process=image/info");
     if (!response.ok) {
-      throw new Error('Network response was not ok');
+      throw new Error("Network response was not ok");
     }
     const data = await response.json();
-    
+
     const imageWidth = Number(data.ImageWidth?.value || 0);
     const imageHeight = Number(data.ImageHeight?.value || 0);
 
     return {
       width: imageWidth,
-      height: imageHeight
+      height: imageHeight,
     };
   } catch (error) {
-    console.error('获取图片信息失败:', error);
+    console.error("获取图片信息失败:", error);
     // 返回默认值或抛出错误,视业务需求而定
     return { width: 0, height: 0 };
   }
 };
 
 // 获取切块图片的地址 (OSS Crop)
-const GetQuestionImgUrl = (url: string, x: number, y: number, w: number, h: number): string => {
-  const ossProcessParam = 'x-oss-process=image';
+const GetQuestionImgUrl = (
+  url: string,
+  x: number,
+  y: number,
+  w: number,
+  h: number,
+): string => {
+  const ossProcessParam = "x-oss-process=image";
   const cropParams = `/crop,x_${Math.round(x)},y_${Math.round(y)},w_${Math.round(w)},h_${Math.round(h)}`;
-  
+
   if (url.includes(ossProcessParam)) {
     return url + cropParams;
   } else {
-    const separator = url.includes('?') ? '&' : '?';
+    const separator = url.includes("?") ? "&" : "?";
     return url + `${separator}${ossProcessParam}${cropParams}`;
   }
 };
@@ -134,33 +142,38 @@ const UpdateCurrentPaperData = async () => {
   // 获取第一张图片的尺寸用于坐标转换
   // 注意:如果 paperImageList 为空,这里需要保护
   if (paperImageList.value.length === 0) return;
-  
+
   const firstPageUrl = paperImageList.value[0].picUrl;
   let imageInfo = { width: 0, height: 0 };
-  
+
   if (firstPageUrl) {
     imageInfo = await GetImageInfo(firstPageUrl);
   }
-
+  //兼容旧的数据
+  if (paperImageList.value[currentIndex.value].useType) {
+    usedCardType.value = paperImageList.value[currentIndex.value].useType; //卡类型需要调整为从此页获取
+  }
   // 1. 处理显示的图片 (paperImage)
   if (currentQuestionItem.titleType == 1) {
     // 如果是客观题,显示整张试卷
     currentPaperUrl.value = paperImageList.value[currentIndex.value].picUrl;
-    paperImage.value = [{
-      url: currentPaperUrl.value,
-      page: paperImageList.value[currentIndex.value].page,
-    }];
+    paperImage.value = [
+      {
+        url: currentPaperUrl.value,
+        page: paperImageList.value[currentIndex.value].page,
+      },
+    ];
   } else {
     // 否则显示对应的切块图片
     const list = currentQuestionItem.pagePaintingVOS || [];
     paperImage.value = [];
-    
+
     for (const item of list) {
-      const pageItem = paperImageList.value.find(p => p.page == item.page);
+      const pageItem = paperImageList.value.find((p) => p.page == item.page);
       if (!pageItem) continue;
 
       let obj: { url: string; page: number } = {
-        url: '',
+        url: "",
         page: pageItem.page,
       };
 
@@ -168,14 +181,14 @@ const UpdateCurrentPaperData = async () => {
         // 系统卡:计算相对坐标
         let templateInfo = {
           width: 794 - 30 * 2, // A4 减去边距
-          height: 1123 - 25 * 2
+          height: 1123 - 25 * 2,
         };
 
         // 如果长大于宽 就是A3
         if (imageInfo.width > imageInfo.height) {
           templateInfo = {
             width: 1588 - 30 * 2, // A3 减去边距
-            height: 1123 - 25 * 2
+            height: 1123 - 25 * 2,
           };
         }
 
@@ -187,12 +200,24 @@ const UpdateCurrentPaperData = async () => {
           page: item.page,
         };
 
-        obj.url = GetQuestionImgUrl(pageItem.picUrl, newBlockPoint.x, newBlockPoint.y, newBlockPoint.w, newBlockPoint.h);
+        obj.url = GetQuestionImgUrl(
+          pageItem.picUrl,
+          newBlockPoint.x,
+          newBlockPoint.y,
+          newBlockPoint.w,
+          newBlockPoint.h,
+        );
       } else {
         // 三方卡
-        obj.url = GetQuestionImgUrl(pageItem.picUrl, item.x, item.y, item.w, item.h);
+        obj.url = GetQuestionImgUrl(
+          pageItem.picUrl,
+          item.x,
+          item.y,
+          item.w,
+          item.h,
+        );
       }
-      
+
       paperImage.value.push(obj);
     }
   }
@@ -200,9 +225,9 @@ const UpdateCurrentPaperData = async () => {
   // 2. 处理采分点坐标 (currentDrawData)
   let positionX = 0;
   let positionY = 0;
-  
+
   try {
-    const point = JSON.parse(currentQuestionItem.samplingPosition || '{}');
+    const point = JSON.parse(currentQuestionItem.samplingPosition || "{}");
     positionX = point.x || 0;
     positionY = point.y || 0;
   } catch (e) {
@@ -222,16 +247,16 @@ const UpdateCurrentPaperData = async () => {
         height: 1123,
       };
     }
-    
+
     // 计算试卷相对于模板的倍率
     const offsetScale = imageInfo.width / templateInfo.width;
-    
+
     positionX = parseFloat((offsetScale * positionX).toFixed(2));
     positionY = parseFloat((offsetScale * positionY).toFixed(2));
   }
 
   const drawDataItem = {
-    id: '',
+    id: "",
     name: currentQuestionItem.questionName,
     fullScore: currentQuestionItem.fullScore, // 满分
     score: currentQuestionItem.score, // 学生得分
@@ -245,7 +270,7 @@ const UpdateCurrentPaperData = async () => {
   };
 
   currentDrawData.value = [drawDataItem];
-  currentDownLoadName.value = '答题卡';
+  currentDownLoadName.value = "答题卡";
 };
 
 // 处理批量查看的数据
@@ -255,19 +280,19 @@ const StudentPaperData = (res: PaperDataResult | any) => {
 
   // 合并所有试卷图片中的题目列表
   let allQuestions: QuestionVO[] = [];
-  paperImageList.value.forEach(item => {
+  paperImageList.value.forEach((item) => {
     if (item.questionVOS && item.questionVOS.length > 0) {
       allQuestions = allQuestions.concat(item.questionVOS);
     }
   });
-  
+
   questionList.value = allQuestions;
   currentIndex.value = 0;
 
   if (questionList.value.length > 0) {
     UpdateCurrentPaperData();
   }
-  
+
   nextTick(() => {
     isLoading.value = false;
   });
@@ -276,47 +301,49 @@ const StudentPaperData = (res: PaperDataResult | any) => {
 // 获取学生试卷详情信息
 const GetStudentPaperInfo = () => {
   console.log("加载学生小题试卷信息参数", props.paperInfo);
-  
+
   if (props.paperInfo?.examPaperId && props.paperInfo?.platformNumber != null) {
     isLoading.value = true;
-    
-    getStudentPaperCardInfo(props.paperInfo).then(res => {
-      console.log("打印学生试卷详情信息", res);
-      
-      if (res.code == 200 && res.data) {
-        const data = res.data as PaperDataResult;
-        paperImageList.value = data.pageVOS || [];
-        usedCardType.value = data.usedCardType || 2;
-
-        // 合并所有试卷图片中的题目列表
-        let allQuestions: QuestionVO[] = [];
-        paperImageList.value.forEach(item => {
-          if (item.questionVOS && item.questionVOS.length > 0) {
-            allQuestions = allQuestions.concat(item.questionVOS);
+
+    getStudentPaperCardInfo(props.paperInfo)
+      .then((res) => {
+        console.log("打印学生试卷详情信息", res);
+
+        if (res.code == 200 && res.data) {
+          const data = res.data as PaperDataResult;
+          paperImageList.value = data.pageVOS || [];
+          usedCardType.value = data.usedCardType || 2;
+
+          // 合并所有试卷图片中的题目列表
+          let allQuestions: QuestionVO[] = [];
+          paperImageList.value.forEach((item) => {
+            if (item.questionVOS && item.questionVOS.length > 0) {
+              allQuestions = allQuestions.concat(item.questionVOS);
+            }
+          });
+          questionList.value = allQuestions;
+
+          // 重置索引并更新当前试卷数据
+          currentIndex.value = 0;
+          if (questionList.value.length > 0) {
+            UpdateCurrentPaperData();
           }
-        });
-        questionList.value = allQuestions;
-        
-        // 重置索引并更新当前试卷数据
-        currentIndex.value = 0;
-        if (questionList.value.length > 0) {
-          UpdateCurrentPaperData();
+
+          nextTick(() => {
+            isLoading.value = false;
+          });
+        } else {
+          nextTick(() => {
+            isLoading.value = false;
+          });
         }
-        
+      })
+      .catch((err) => {
+        console.error(err);
         nextTick(() => {
           isLoading.value = false;
         });
-      } else {
-        nextTick(() => {
-          isLoading.value = false;
-        });
-      }
-    }).catch(err => {
-      console.error(err);
-      nextTick(() => {
-        isLoading.value = false;
       });
-    });
   } else {
     if (props.isBatch) {
       StudentPaperData(props.paperData as PaperDataResult);
@@ -340,7 +367,7 @@ watch(
       GetStudentPaperInfo();
     }
   },
-  { deep: true }
+  { deep: true },
 );
 
 watch(
@@ -353,7 +380,7 @@ watch(
       StudentPaperData(newVal as PaperDataResult);
     }
   },
-  { deep: true }
+  { deep: true },
 );
 
 // --- 生命周期 ---
@@ -362,7 +389,6 @@ onMounted(() => {
   // 初始加载
   GetStudentPaperInfo();
 });
-
 </script>
 
 <style lang="scss" scoped>
@@ -378,4 +404,4 @@ onMounted(() => {
     position: relative;
   }
 }
-</style>
+</style>

+ 380 - 0
src/components/echarts/barStackChart_horzontal.vue

@@ -0,0 +1,380 @@
+<template>
+  <div class="echart_content">
+    <div class="is_show_all" v-if="props.showCheckBox">
+      <el-checkbox
+        v-model="isShowAll"
+        :indeterminate="isIndeterminate"
+        @change="ToggleChangeAll"
+      >
+        显示全部
+      </el-checkbox>
+    </div>
+    <!-- 横向堆叠条形图 -->
+    <div
+      ref="barStackChart"
+      class="chart_bar_stack"
+      v-if="props.data.length < 15"
+    ></div>
+    <div ref="barStackChart" class="chart_bar_stacks" v-else></div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import * as echarts from 'echarts';
+import throttle from 'lodash/throttle';
+
+// ================= Props & Emits =================
+interface Props {
+  showCheckBox?: boolean;
+  data?: any[][];
+  color?: string[];
+  isClick?: boolean;
+  tooltipData?: any[];
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  showCheckBox: true,
+  data: () => [],
+  color: () => [],
+  isClick: false,
+  tooltipData: () => [],
+});
+
+const emit = defineEmits<{
+  (e: 'HandleChartClick', index: number, xName: string): void;
+}>();
+
+// ================= Refs & State =================
+const barStackChart = ref<HTMLElement | null>(null);
+const myChart = ref<echarts.ECharts | null>(null);
+
+const isShowAll = ref(true);
+const isIndeterminate = ref(false);
+const legendSelected = ref<Record<string, boolean>>({});
+const legenSelectList = ref<string[]>(props.data?.[0]?.slice(1) as string[] || []);
+const legenAllList = ref<string[]>(props.data?.[0]?.slice(1) as string[] || []);
+const chartHeight = ref(400);
+
+// ================= Core Methods =================
+const LoadEchart = () => {
+  if (myChart.value) {
+    myChart.value.dispose();
+  }
+  if (!barStackChart.value) return;
+
+  myChart.value = echarts.init(barStackChart.value, null, {
+    devicePixelRatio: 2,
+  });
+
+  //定义数据集
+  const dataset = {
+    source: props.data,
+  };
+
+  legenAllList.value = (props.data?.[0]?.slice(1) as string[]) || [];
+
+  // 初始化 legendSelected 为所有图例项显示
+  legendSelected.value = (dataset.source[0] as string[]).slice(1).reduce((acc: Record<string, boolean>, dim) => {
+    acc[dim] = false; // 默认不显示
+    return acc;
+  }, {});
+
+  legenSelectList.value.forEach((item) => {
+    legendSelected.value[item] = true;
+  });
+
+  isShowAll.value = legenSelectList.value.length === legenAllList.value.length;
+  isIndeterminate.value =
+    legenSelectList.value.length > 0 &&
+    legenSelectList.value.length < legenAllList.value.length;
+
+  let colors = props.color; //按顺序显示的20个颜色值
+  if (props.data && props.data.length > 10) {
+    chartHeight.value = 600;
+  }
+
+  //计算每个y轴的高度
+  let totalHeight = barStackChart.value.clientHeight;
+  //计算每个柱的宽度 (图表宽度-左右边距)/ x轴数据长度
+  let singleSeriesWidth = Math.ceil((totalHeight - 97) / (props.data?.length || 1));
+
+  const barMinWidth = 20; // 最小柱子宽度 加上两边间距的宽度
+  const dataZoomNum = Math.floor((totalHeight - 97) / (barMinWidth * 1)); // 当前区域内可显示的柱子的数量
+  const dataZoomEnd = Math.floor((100 / (props.data?.length || 1)) * dataZoomNum); // 当前区域内可显示柱子的占比
+
+  const dataZoom = {
+    start: 0,
+    end: dataZoomEnd,
+    type: 'slider',
+    show: true,
+    borderColor: 'transparent',
+    borderCap: 'round',
+    yAxisIndex: [0],
+    width: 8,
+    right: 20,
+    top: 20,
+    bottom: 20,
+    fillerColor: 'transparent',
+    zoomLock: true,
+    handleSize: '0',
+    handleStyle: {
+      color: '#b8b8b8',
+      borderWidth: 2,
+    },
+    backgroundColor: 'transparent',
+    showDataShadow: false,
+    showDetail: false,
+    filterMode: 'filter',
+  };
+
+  const option: any = {
+    dataset: dataset,
+    tooltip: {
+      triggerOn: 'mousemove',
+      confine: true,
+      extraCssText:
+        'border-radius: 4px;padding:5px 0px 5px 5px;white-space:normal;word-warp:break-word;max-width: 400px;',
+      enterable: true,
+      formatter: (params: any) => {
+        const seriesName = params.name;
+        let tooltip = `<div class='tooltip_content'>`;
+        let title = seriesName;
+        tooltip += `<div class='tooltip_title'>${title}</div>`;
+        let rateLabel = (dataset.source[0] as string[]).slice(1);
+
+        for (let i = 0; i < rateLabel.length; i++) {
+          let labelName = rateLabel[i];
+          let rate = params?.value?.[i + 1] || '-';
+          let list: any[] = [],
+            rateNum = '',
+            isShowTip = props.tooltipData?.length > 0;
+
+          if (props.tooltipData?.length > 0) {
+            const dataIndex = params.dataIndex;
+            const itemTooltip = props.tooltipData?.[dataIndex]?.[i] ?? '';
+            list = itemTooltip?.list ?? [];
+            rateNum = itemTooltip?.rateNum ?? '-';
+          }
+
+          if (isShowTip) {
+            tooltip += `<div class='tooltip_student'><span class='tooltip_rect_icon' style='background:${colors[i]}'></span>${labelName}:${rate == '-' ? '-' : rate + '%'},${rateNum}人</div>`;
+            if (i == rateLabel.length - 1) {
+              for (let j = 0; j < list.length; j++) {
+                tooltip += `<div class='tooltip_student'><span class='tooltip_rect_icon' style='background:#CCCCCC'></span>${list[j].label}:${list[j].value}</div>`;
+              }
+            }
+          } else {
+            tooltip += `<div class='tooltip_student'><span class='tooltip_rect_icon' style='background:${colors[i]}'></span>${labelName}:${rate == '-' ? '-' : rate + '%'}</div>`;
+          }
+        }
+
+        tooltip += `</div>`;
+        return tooltip;
+      },
+    },
+    legend: {
+      show: true,
+      top: '6px',
+      left: '110px',
+      itemGap: 20,
+      itemHeight: 10,
+      itemWidth: 20,
+      textStyle: { fontSize: 12, color: '#333' },
+      selectedMode: true,
+      selected: legendSelected.value,
+      formatter: (name: string) => {
+        return name;
+      },
+    },
+    grid: {
+      top: 40,
+      left: 0,
+      right: 40,
+      bottom: 0,
+      containLabel: true,
+    },
+    dataZoom: singleSeriesWidth < barMinWidth ? dataZoom : null,
+    xAxis: {
+      type: 'value',
+      axisLabel: {
+        formatter: '{value}%',
+        interval: 0,
+        textStyle: {
+          fontSize: 14,
+          color: '#666',
+          fontWeight: 400,
+        },
+      },
+      axisTick: {
+        lineStyle: {
+          color: 'red',
+          type: 'dashed',
+          width: 1,
+        },
+      },
+    },
+    yAxis: {
+      type: 'category',
+      axisLabel: {
+        interval: 0,
+        formatter: '{value}',
+        textStyle: {
+          fontSize: 14,
+          color: '#666',
+          fontWeight: 400,
+        },
+      },
+      inverse: true,
+    },
+    series: (dataset.source[0] as string[]).slice(1).map(function (dim, index) {
+      const color = colors[index];
+      return {
+        type: 'bar',
+        stack: 'total',
+        barMaxWidth: 50,
+        barMinWidth: 14,
+        label: {
+          show: true,
+          position: 'inside',
+          color: '#fff',
+          fontSize: dataset.source.length > 10 ? 12 : 14,
+          formatter: function (params: any) {
+            if (params.value[params.seriesIndex + 1] == 0) {
+              return '';
+            } else {
+              return params.value[params.seriesIndex + 1] + '%';
+            }
+          },
+        },
+        itemStyle: {
+          color: color,
+        },
+        emphasis: {
+          focus: 'series',
+        },
+      };
+    }),
+  };
+
+  myChart.value.setOption(option);
+  myChart.value.on('legendselectchanged', HandleLegendSelectChanged);
+
+  if (props.isClick) {
+    const yAxisDataLength = (props.data?.length || 1) - 1;
+    const gridRect = myChart.value
+      .getModel()
+      .getComponent('grid').coordinateSystem.getRect();
+
+    const singleLabelHeight = gridRect.height / yAxisDataLength;
+
+    myChart.value.on('click', (params: any) => {
+      if (params.seriesType === 'bar') {
+        let pixelPosition = myChart.value!.convertToPixel(
+          { yAxisIndex: 0 },
+          params.name,
+        );
+        myChart.value!.setOption({
+          graphic: {
+            id: 'highlight-box',
+            type: 'rect',
+            shape: {
+              y: pixelPosition - singleLabelHeight / 2,
+              x: gridRect.x,
+              width: gridRect.width,
+              height: singleLabelHeight,
+            },
+            style: {
+              fill: params.color + '30',
+            },
+          },
+        });
+        let index = params.dataIndex;
+        let xName = params.name;
+        emit('HandleChartClick', index, xName);
+      }
+    });
+
+    let defaultPixelPosition = myChart.value.convertToPixel(
+      { yAxisIndex: 0 },
+      props.data![1][0],
+    );
+    myChart.value.setOption({
+      graphic: {
+        id: 'highlight-box',
+        type: 'rect',
+        shape: {
+          y: defaultPixelPosition - singleLabelHeight / 2,
+          x: gridRect.x,
+          height: singleLabelHeight,
+          width: gridRect.width,
+        },
+        style: {
+          fill: 'rgba(84,112,198,0.1)',
+        },
+      },
+    });
+  }
+};
+
+// ================= Event Handlers =================
+const HandleLegendSelectChanged = (params: any) => {
+  const selected = params.selected;
+  legenSelectList.value = [];
+  for (let i = 0; i < legenAllList.value.length; i++) {
+    if (selected[legenAllList.value[i]] == true) {
+      legenSelectList.value.push(legenAllList.value[i]);
+    }
+  }
+  isShowAll.value = legenSelectList.value.length === legenAllList.value.length;
+  isIndeterminate.value =
+    legenSelectList.value.length > 0 &&
+    legenSelectList.value.length < legenAllList.value.length;
+};
+
+const handleResize = throttle(() => {
+  nextTick(() => {
+    myChart.value?.resize();
+  });
+}, 500);
+
+const ToggleChangeAll = () => {
+  if (isShowAll.value) {
+    legenSelectList.value = (props.data?.[0]?.slice(1) as string[]) || [];
+  } else {
+    legenSelectList.value = [];
+  }
+  LoadEchart();
+};
+
+// ================= Watch =================
+watch(
+  () => props.data,
+  () => {
+    LoadEchart();
+  },
+  { deep: true },
+);
+
+// ================= Lifecycle =================
+onMounted(() => {
+  window.addEventListener('resize', handleResize);
+  LoadEchart();
+});
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize);
+  if (myChart.value) {
+    myChart.value.dispose();
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.chart_bar_stack {
+  min-height: 400px;
+}
+.chart_bar_stacks {
+  height: 600px;
+}
+</style>

+ 293 - 379
src/components/echarts/barStackChart_vertical.vue

@@ -1,468 +1,382 @@
 <template>
   <div class="echart_content">
-    <div class="is_show_all" v-if="showCheckBox">
+    <div class="is_show_all" v-if="props.showCheckBox">
       <el-checkbox
         v-model="isShowAll"
         :indeterminate="isIndeterminate"
-        @change="toggleChangeAll"
+        @change="ToggleChangeAll"
       >
         显示全部
       </el-checkbox>
     </div>
     <!-- 竖向堆叠条形图 -->
-    <div ref="barStackChartRef" class="chart_box"></div>
+    <div ref="barStackChart" class="chart_box"></div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, watch, onMounted, onCreated, onUnmounted, nextTick, withDefaults } from 'vue'
-import _throttle from 'lodash/throttle'
-import * as echarts from 'echarts'
-import { getScorePerformanceAnalysis } from '@/utils/common'
-
-// ===================== 类型定义 =====================
-/** 提示框额外数据项 */
-interface TooltipExtraItem {
-  label: string
-  value: string | number
+import { ref, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
+import * as echarts from "echarts";
+import throttle from "lodash/throttle";
+import { getScorePerformanceAnalysis } from "@/utils/common";
+
+// ================= Props & Emits =================
+interface Props {
+  showCheckBox?: boolean;
+  data?: (string | number)[][];
+  color?: string[];
+  isClick?: boolean;
+  legendList?: string[];
+  tooltipData?: any[];
 }
 
-/** 单条提示数据 */
-interface TooltipItem {
-  list: TooltipExtraItem[]
-  rateNum: string | number
-}
-
-/** Props 类型 */
-interface BarStackChartProps {
-  showCheckBox: boolean
-  data: (string | number)[][]
-  color: string[]
-  isClick: boolean
-  legendList: string[]
-  tooltipData: TooltipItem[][]
-}
-
-// ===================== 常量配置 =====================
-const CHART_DPR = 2
-const GRID_LEFT = 40
-const GRID_RIGHT = 60
-const GRID_HORIZONTAL_SUM = GRID_LEFT + GRID_RIGHT
-const BAR_MIN_WIDTH = 30
-const BAR_MAX_WIDTH = 50
-const DATA_ZOOM_HEIGHT = 8
-const THROTTLE_DELAY = 500
-const HIGHLIGHT_OPACITY = '30'
-const DEFAULT_CHART_HEIGHT = 400
-
-// ===================== Props 修复:纯TS类型 + withDefaults 设置默认值 =====================
-const props = withDefaults(defineProps<BarStackChartProps>(), {
+const props = withDefaults(defineProps<Props>(), {
   showCheckBox: true,
   data: () => [],
   color: () => [],
   isClick: false,
   legendList: () => [],
-  tooltipData: () => []
-})
+  tooltipData: () => [],
+});
 
-// ===================== 事件 =====================
 const emit = defineEmits<{
-  HandleChartClick: [index: number, name: string]
-}>()
-
-// ===================== 响应式变量 =====================
-const barStackChartRef = ref<HTMLDivElement | null>(null)
-let myChart: echarts.ECharts | null = null
-
-// 多选框状态
-const isShowAll = ref(true)
-const isIndeterminate = ref(false)
-const legendSelected = ref<Record<string, boolean>>({})
-const legenSelectList = ref<string[]>([])
-const legenAllList = ref<string[]>([])
-
-// 全局颜色池
-const colorsPool = ref<string[]>(getScorePerformanceAnalysis())
-
-// 窗口缩放节流
-const handleResize = _throttle(async () => {
-  await nextTick()
-  myChart?.resize()
-}, THROTTLE_DELAY)
-
-// ===================== 工具函数 =====================
-/**
- * 生成 DataZoom 滚动条配置
- */
-function getDataZoom(totalWidth: number, dataCount: number): echarts.DataZoomSliderOption | null {
-  const contentWidth = totalWidth - GRID_HORIZONTAL_SUM
-  const singleSeriesWidth = Math.ceil(contentWidth / dataCount)
-
-  if (singleSeriesWidth >= BAR_MIN_WIDTH) return null
-
-  const dataZoomNum = Math.floor(contentWidth / BAR_MIN_WIDTH)
-  const dataZoomEnd = Math.floor((100 / dataCount) * dataZoomNum)
-
-  return {
-    type: 'slider',
-    xAxisIndex: [0],
-    start: 0,
-    end: dataZoomEnd,
-    height: DATA_ZOOM_HEIGHT,
-    left: 20,
-    right: 20,
-    bottom: 0,
-    borderColor: 'transparent',
-    borderCap: 'round',
-    fillerColor: 'transparent',
-    zoomLock: true,
-    handleSize: '0',
-    handleStyle: {
-      color: '#b8b8b8',
-      borderWidth: 2
-    },
-    backgroundColor: 'transparent',
-    showDataShadow: false,
-    showDetail: false,
-    filterMode: 'filter'
-  }
-}
+  (e: "HandleChartClick", index: number, xName: string): void;
+}>();
 
-/**
- * X轴文字超长截断处理
- */
-function formatXLabel(value: string, singleSeriesWidth: number): string {
-  const charUnit = 14
-  const textWidth = value.length * charUnit
+// ================= Refs & State =================
+const barStackChart = ref<HTMLElement | null>(null);
+const myChart = ref<echarts.ECharts | null>(null);
 
-  if (textWidth <= singleSeriesWidth) return value
+const isShowAll = ref(true);
+const isIndeterminate = ref(false);
+const legendSelected = ref<Record<string, boolean>>({});
+const legenSelectList = ref<string[]>([]);
+const legenAllList = ref<string[]>([]);
 
-  if (singleSeriesWidth < 100) {
-    return textWidth > 80 ? `${value.slice(0, 2)}...${value.slice(-3)}` : value
-  }
-
-  const maxLen = Math.floor(singleSeriesWidth / charUnit) - 1
-  return value.slice(0, maxLen) + '...'
-}
+const defaultColors = getScorePerformanceAnalysis();
 
-/**
- * 图例选中状态更新
- */
-function updateLegendStatus() {
-  isShowAll.value = legenSelectList.value.length === legenAllList.value.length
-  isIndeterminate.value = legenSelectList.value.length > 0 && legenSelectList.value.length < legenAllList.value.length
-}
+// ================= Helper Methods =================
+const initLegenSelectList = () => {
+  if (props.legendList.length > 0) {
+    legenSelectList.value = props.legendList;
+  } else {
+    legenSelectList.value = (props.data[0]?.slice(1) as string[]) || [];
+  }
+};
 
-/**
- * 绑定柱子点击与高亮效果
- */
-function bindBarClick(totalWidth: number, dataCount: number) {
-  if (!myChart) return
-
-  const gridModel = myChart.getModel().getComponent('grid')
-  const gridRect = gridModel.coordinateSystem.getRect()
-  const xAxisDataLength = dataCount - 1
-  const singleLabelWidth = gridRect.width / xAxisDataLength
-
-  // 默认高亮第一项
-  const firstItem = props.data[1]?.[0] ?? ''
-  const defaultPixelPos = myChart.convertToPixel({ xAxisIndex: 0 }, firstItem)
-  myChart.setOption({
-    graphic: {
-      id: 'highlight-box',
-      type: 'rect',
-      shape: {
-        x: defaultPixelPos - singleLabelWidth / 2,
-        y: gridRect.y,
-        width: singleLabelWidth,
-        height: gridRect.height
-      },
-      style: {
-        fill: 'rgba(84,112,198,0.1)'
-      }
-    }
-  })
+// ================= Core Methods =================
+const LoadEchart = () => {
+  if (myChart.value) {
+    myChart.value.dispose();
+  }
+  if (!barStackChart.value) return;
 
-  // 点击事件
-  myChart.on('click', (params) => {
-    if (params.seriesType !== 'bar') return
+  myChart.value = echarts.init(barStackChart.value, null, {
+    devicePixelRatio: 2,
+  });
 
-    const pixelPos = myChart.convertToPixel({ xAxisIndex: 0 }, params.name)
-    myChart.setOption({
-      graphic: {
-        id: 'highlight-box',
-        type: 'rect',
-        shape: {
-          x: pixelPos - singleLabelWidth / 2,
-          y: gridRect.y,
-          width: singleLabelWidth,
-          height: gridRect.height
-        },
-        style: {
-          fill: (params.color as string) + HIGHLIGHT_OPACITY
-        }
-      }
-    })
+  if (props.data.length === 0) {
+    return;
+  }
 
-    emit('HandleChartClick', params.dataIndex, params.name as string)
-  })
-}
+  const dataset = {
+    source: props.data,
+  };
+  const dataCount = props.data.length;
 
-// ===================== 核心:初始化图表 =====================
-function loadEchart() {
-  const dom = barStackChartRef.value
-  if (!dom) return
+  legenAllList.value = (props.data[0]?.slice(1) as string[]) || [];
 
-  // 销毁旧实例,防止多实例叠加
-  if (myChart) {
-    myChart.dispose()
-    myChart = null
-  }
+  legendSelected.value = (dataset.source[0] as string[])
+    .slice(1)
+    .reduce((acc: Record<string, boolean>, dim) => {
+      acc[dim] = false;
+      return acc;
+    }, {});
 
-  myChart = echarts.init(dom, null, { devicePixelRatio: CHART_DPR })
-  const sourceData = props.data
-  if (sourceData.length === 0) return
+  legenSelectList.value.forEach((item) => {
+    legendSelected.value[item] = true;
+  });
 
-  const dataCount = sourceData.length
-  const headerRow = sourceData[0]
-  const allLegend = headerRow.slice(1)
-  legenAllList.value = allLegend
+  isShowAll.value = legenSelectList.value.length === legenAllList.value.length;
+  isIndeterminate.value =
+    legenSelectList.value.length > 0 &&
+    legenSelectList.value.length < legenAllList.value.length;
 
-  // 初始化图例选中状态
-  legendSelected.value = allLegend.reduce((acc, dim) => {
-    acc[dim as string] = false
-    return acc
-  }, {} as Record<string, boolean>)
+  let totalWidth = barStackChart.value.clientWidth;
+  let singleSeriesWidth = Math.ceil((totalWidth - 140) / dataCount);
+  const barMinWidth = 30;
+  const dataZoomNum = Math.floor((totalWidth - 140) / (barMinWidth * 1));
+  const dataZoomEnd = Math.floor((100 / dataCount) * dataZoomNum);
 
-  // 优先使用外部传入图例
-  if (props.legendList.length > 0) {
-    legenSelectList.value = [...props.legendList]
-  } else {
-    legenSelectList.value = [...allLegend]
-  }
+  const dataZoom = {
+    start: 0,
+    end: dataZoomEnd,
+    type: "slider",
+    show: true,
+    borderColor: "transparent",
+    borderCap: "round",
+    xAxisIndex: [0],
+    height: 8,
+    left: 20,
+    right: 20,
+    bottom: 0,
+    fillerColor: "transparent",
+    zoomLock: true,
+    handleSize: "0",
+    handleStyle: { color: "#b8b8b8", borderWidth: 2 },
+    backgroundColor: "transparent",
+    showDataShadow: false,
+    showDetail: false,
+    filterMode: "filter",
+  };
 
-  // 同步选中状态
-  legenSelectList.value.forEach(item => {
-    legendSelected.value[item] = true
-  })
-  updateLegendStatus()
-
-  // 计算柱子宽度 & 滚动条
-  const totalWidth = dom.clientWidth
-  const contentWidth = totalWidth - GRID_HORIZONTAL_SUM
-  const singleSeriesWidth = Math.ceil(contentWidth / dataCount)
-  const dataZoomOpt = getDataZoom(totalWidth, dataCount)
-
-  // 构建 series
-  const seriesList: echarts.SeriesBarOption[] = allLegend.map((dim, index) => {
-    const color = props.color[index] || colorsPool.value[index]
-    return {
-      type: 'bar',
-      stack: 'total',
-      barMaxWidth: BAR_MAX_WIDTH,
-      label: {
-        show: singleSeriesWidth > 22,
-        position: 'inside',
-        color: '#fff',
-        fontSize: 12,
-        formatter: (params) => {
-          const val = params.value?.[params.seriesIndex + 1]
-          return val === 0 ? '' : `${val}%`
-        }
-      },
-      itemStyle: {
-        color
-      }
-    }
-  })
+  const chartColors = props.color?.length > 0 ? props.color : defaultColors;
 
-  // ECharts 配置项
   const option: echarts.EChartsOption = {
-    dataset: {
-      source: sourceData
-    },
+    dataset: dataset,
     tooltip: {
+      triggerOn: "mousemove",
       confine: true,
+      extraCssText:
+        "border-radius: 4px;padding:5px 0px 5px 5px;white-space:normal;word-warp:break-word;max-width: 400px;",
       enterable: true,
-      extraCssText: 'border-radius: 4px;padding:5px 0 5px 5px;white-space:normal;word-wrap:break-word;max-width: 400px;',
-      triggerOn: 'mousemove',
-      formatter: (params) => {
-        const firstParams = Array.isArray(params) ? params[0] : params
-        const title = firstParams.name ?? ''
-        const rateLabelList = headerRow.slice(1)
-        const dataIndex = firstParams.dataIndex
-
-        let tooltip = `<div class="tooltip_content"><div class="tooltip_title">${title}</div>`
-
-        rateLabelList.forEach((label, i) => {
-          const rate = firstParams.value?.[i + 1]
-          const curTip = props.tooltipData[dataIndex]?.[i]
-          const rateNum = curTip?.rateNum ?? '-'
-          const tipList = curTip?.list ?? []
-          const showTip = props.tooltipData.length > 0
-
-          const rateText = rate === '-' ? '-' : `${rate}%`
-          const color = props.color[i] || colorsPool.value[i]
-
-          if (showTip) {
-            tooltip += `<div class="tooltip_student">
-              <span class="tooltip_rect_icon" style="background:${color}"></span>
-              ${label}:${rateText},${rateNum}人
-            </div>`
-            // 最后一项追加额外提示
-            if (i === rateLabelList.length - 1) {
-              tipList.forEach(item => {
-                tooltip += `<div class="tooltip_student">
-                  <span class="tooltip_rect_icon" style="background:#CCCCCC"></span>
-                  ${item.label}:${item.value}
-                </div>`
-              })
+      formatter: (params: any) => {
+        const seriesName = params.name;
+        let tooltip = `<div class='tooltip_content'>`;
+        let title = seriesName;
+        tooltip += `<div class='tooltip_title'>${title}</div>`;
+
+        let rateLabel = (dataset.source[0] as string[]).slice(1);
+
+        for (let i = 0; i < rateLabel.length; i++) {
+          let labelName = rateLabel[i];
+          let rate = params.value[i + 1];
+          let list: any[] = [],
+            rateNum = "",
+            isShowTip = props.tooltipData?.length > 0;
+
+          if (props.tooltipData?.length > 0) {
+            const dataIndex = params.dataIndex;
+            const itemTooltip = props.tooltipData?.[dataIndex]?.[i] ?? "";
+            list = itemTooltip?.list ?? [];
+            rateNum = itemTooltip?.rateNum ?? "-";
+          }
+
+          if (isShowTip) {
+            tooltip += `<div class='tooltip_student'><span class='tooltip_rect_icon' style='background:${chartColors[i]}'></span>${labelName}:${rate == "-" ? "-" : rate + "%"},${rateNum}人</div>`;
+            if (i == rateLabel.length - 1) {
+              for (let j = 0; j < list.length; j++) {
+                tooltip += `<div class='tooltip_student'><span class='tooltip_rect_icon' style='background:#CCCCCC'></span>${list[j].label}:${list[j].value}</div>`;
+              }
             }
           } else {
-            tooltip += `<div class="tooltip_student">
-              <span class="tooltip_rect_icon" style="background:${color}"></span>
-              ${label}:${rateText}
-            </div>`
+            tooltip += `<div class='tooltip_student'><span class='tooltip_rect_icon' style='background:${chartColors[i]}'></span>${labelName}:${rate == "-" ? "-" : rate + "%"}</div>`;
           }
-        })
-
-        tooltip += '</div>'
-        return tooltip
-      }
+        }
+        tooltip += `</div>`;
+        return tooltip;
+      },
     },
     legend: {
       show: true,
-      left: props.showCheckBox ? '110px' : '0px',
+      top: '6px',
+      left: props.showCheckBox ? "110px" : "0px",
       itemGap: 20,
       itemHeight: 10,
       itemWidth: 20,
-      textStyle: { fontSize: 12, color: '#333' },
+      textStyle: { fontSize: 12, color: "#333" },
       selectedMode: true,
-      selected: legendSelected.value
+      selected: legendSelected.value,
+      formatter: (name: string) => name,
     },
     grid: {
       top: 50,
-      left: GRID_LEFT,
-      right: GRID_RIGHT,
+      left: 40,
+      right: 60,
       bottom: 0,
-      containLabel: true
+      containLabel: true,
     },
-    dataZoom: dataZoomOpt,
+    dataZoom: singleSeriesWidth < barMinWidth ? dataZoom : null,
     yAxis: {
-      type: 'value',
+      type: "value",
       axisLabel: {
-        formatter: '{value}%',
+        formatter: "{value}%",
         fontSize: 14,
-        color: '#666',
-        fontWeight: 400
+        color: "#666",
+        fontWeight: 400,
       },
       axisTick: {
-        lineStyle: {
-          color: 'red',
-          type: 'dashed',
-          width: 1
-        }
-      }
+        lineStyle: { color: "red", type: "dashed", width: 1 },
+      },
     },
     xAxis: {
-      type: 'category',
+      type: "category",
       axisLabel: {
         fontSize: 14,
-        color: '#666',
+        color: "#666",
         fontWeight: 400,
         interval: 0,
         rotate: singleSeriesWidth < 80 ? 45 : 0,
-        formatter: (val) => formatXLabel(val, singleSeriesWidth)
-      }
+        formatter: function (value: string) {
+          const valueWidth = value.length * 14;
+          if (valueWidth > singleSeriesWidth) {
+            if (singleSeriesWidth < 100) {
+              if (valueWidth > 80) {
+                return value.slice(0, 2) + "..." + value.slice(-3);
+              } else {
+                return value;
+              }
+            } else {
+              let maxLength = Math.floor(singleSeriesWidth / 14) - 1;
+              return value.slice(0, maxLength) + "...";
+            }
+          } else {
+            return value;
+          }
+        },
+      },
+      tooltip: {
+        show: true,
+        formatter: function (params: any) {
+          return params.value;
+        },
+      },
     },
-    series: seriesList
-  }
-
-  myChart.setOption(option)
+    series: (dataset.source[0] as string[]).slice(1).map(function (dim, index) {
+      const color = chartColors[index];
+      return {
+        type: "bar",
+        stack: "total",
+        barMaxWidth: 50,
+        label: {
+          show: singleSeriesWidth > 22,
+          position: "inside",
+          color: "#fff",
+          fontSize: 12,
+          formatter: function (params: any) {
+            if (params.value[params.seriesIndex + 1] == 0) {
+              return "";
+            } else {
+              return params.value[params.seriesIndex + 1] + "%";
+            }
+          },
+        },
+        itemStyle: { color: color },
+      };
+    }),
+  };
 
-  // 图例切换监听
-  myChart.on('legendselectchanged', handleLegendSelectChanged)
+  myChart.value.setOption(option);
+  myChart.value.on("legendselectchanged", HandleLegendSelectChanged);
 
-  // 开启柱子点击事件
   if (props.isClick) {
-    bindBarClick(totalWidth, dataCount)
-  }
-}
+    // const containerWidth = barStackChart.value.offsetWidth;
+    const xAxisDataLength = props.data.length - 1;
+    const gridRect = myChart.value
+      .getModel()
+      .getComponent("grid")
+      .coordinateSystem.getRect();
+    const singleLabelWidth = gridRect.width / xAxisDataLength;
+
+    myChart.value.on("click", (params: any) => {
+      if (params.seriesType === "bar") {
+        let pixelPosition = myChart.value!.convertToPixel(
+          { xAxisIndex: 0 },
+          params.name,
+        );
+        myChart.value!.setOption({
+          graphic: {
+            id: "highlight-box",
+            type: "rect",
+            shape: {
+              x: pixelPosition - singleLabelWidth / 2,
+              y: gridRect.y,
+              width: singleLabelWidth,
+              height: gridRect.height,
+            },
+            style: { fill: params.color + "30" },
+          },
+        });
+        emit("HandleChartClick", params.dataIndex, params.name);
+      }
+    });
 
-// ===================== 事件处理 =====================
-/**
- * 图例切换事件
- */
-function handleLegendSelectChanged(params: echarts.LegendSelectChangedParams) {
-  const selected = params.selected
-  legenSelectList.value = []
-
-  legenAllList.value.forEach(item => {
-    if (selected[item]) {
-      legenSelectList.value.push(item)
+    let defaultPixelPosition = myChart.value.convertToPixel(
+      { xAxisIndex: 0 },
+      props.data[1][0],
+    );
+    myChart.value.setOption({
+      graphic: {
+        id: "highlight-box",
+        type: "rect",
+        shape: {
+          x: defaultPixelPosition - singleLabelWidth / 2,
+          y: gridRect.y,
+          width: singleLabelWidth,
+          height: gridRect.height,
+        },
+        style: { fill: "rgba(84,112,198,0.1)" },
+      },
+    });
+  }
+};
+
+const HandleLegendSelectChanged = (params: any) => {
+  const selected = params.selected;
+  legenSelectList.value = [];
+  for (let i = 0; i < legenAllList.value.length; i++) {
+    if (selected[legenAllList.value[i]] === true) {
+      legenSelectList.value.push(legenAllList.value[i]);
     }
-  })
-
-  updateLegendStatus()
-}
-
-/**
- * 显示全部 复选框切换
- */
-function toggleChangeAll(val: boolean) {
-  if (val) {
-    legenSelectList.value = [...legenAllList.value]
+  }
+  isShowAll.value = legenSelectList.value.length === legenAllList.value.length;
+  isIndeterminate.value =
+    legenSelectList.value.length > 0 &&
+    legenSelectList.value.length < legenAllList.value.length;
+};
+
+const handleResize = throttle(() => {
+  nextTick(() => {
+    myChart.value?.resize();
+  });
+}, 500);
+
+const ToggleChangeAll = (value: boolean | string | number) => {
+  const isChecked = value === true;
+  if (isChecked) {
+    legenSelectList.value = [...legenAllList.value];
+    isIndeterminate.value = false;
   } else {
-    legenSelectList.value = []
+    legenSelectList.value = [];
+    isIndeterminate.value = false;
   }
-  isIndeterminate.value = false
-  loadEchart()
-}
+  LoadEchart();
+};
 
-// ===================== 监听 & 生命周期 =====================
-// 数据源变化重绘图表
+// ================= Watch =================
 watch(
   () => props.data,
   () => {
-    loadEchart()
+    initLegenSelectList();
+    LoadEchart();
   },
-  { deep: true }
-)
+  { deep: true },
+);
 
+// ================= Lifecycle =================
 onMounted(() => {
-  window.addEventListener('resize', handleResize)
-  loadEchart()
-})
-
-onUnmounted(() => {
-  // 清除监听 & 销毁实例,防止内存泄漏
-  window.removeEventListener('resize', handleResize)
-  if (myChart) {
-    myChart.dispose()
-    myChart = null
+  window.addEventListener("resize", handleResize);
+  initLegenSelectList();
+  LoadEchart();
+});
+
+onBeforeUnmount(() => {
+  window.removeEventListener("resize", handleResize);
+  if (myChart.value) {
+    myChart.value.dispose();
   }
-})
+});
 </script>
 
 <style lang="scss" scoped>
-.echart_content {
-  width: 100%;
-}
-
-.is_show_all {
-  margin-bottom: 8px;
-}
-
-.chart_box {
-  width: 100%;
-  height: v-bind(DEFAULT_CHART_HEIGHT + 'px');
-}
-
-:deep(.tooltip_rect_icon) {
-  display: inline-block;
-  width: 8px;
-  height: 8px;
-  border-radius: 2px;
-  margin-right: 6px;
-}
-</style>
+</style>

Разлика између датотеке није приказан због своје велике величине
+ 580 - 518
src/components/echarts/barsCharts.vue


Разлика између датотеке није приказан због своје велике величине
+ 547 - 626
src/styles/common.scss


+ 85 - 52
src/utils/common.ts

@@ -2,18 +2,18 @@
  * 科目背景色映射表
  */
 export const COURSE_COLOR_MAP: Record<string, string> = {
-  '语文': '#91CC75', // 浅绿
-  '数学': '#FAC858', // 黄色
-  '英语': '#EE6666', // 红色
-  '物理': '#817BE2', // 紫色
-  '化学': '#73C0DE', // 天蓝
-  '生物': '#3BA272', // 绿色
-  '政治': '#63A1FF', // 
-  '历史': '#FC8452', // 土黄
-  '地理': '#9A60B4', // 天蓝
-  '道德与法治': '#FF869D',
-  'default': '#C495FA' // 默认颜色
-}
+  语文: "#91CC75", // 浅绿
+  数学: "#FAC858", // 黄色
+  英语: "#EE6666", // 红色
+  物理: "#817BE2", // 紫色
+  化学: "#73C0DE", // 天蓝
+  生物: "#3BA272", // 绿色
+  政治: "#63A1FF", //
+  历史: "#FC8452", // 土黄
+  地理: "#9A60B4", // 天蓝
+  道德与法治: "#FF869D",
+  default: "#C495FA", // 默认颜色
+};
 
 /**
  * 根据科目名称获取背景色
@@ -22,10 +22,10 @@ export const COURSE_COLOR_MAP: Record<string, string> = {
  */
 export const getCourseBgColor = (courseName: string): string => {
   if (!courseName) {
-    return COURSE_COLOR_MAP['default']
+    return COURSE_COLOR_MAP["default"];
   }
-  return COURSE_COLOR_MAP[courseName] || COURSE_COLOR_MAP['default']
-}
+  return COURSE_COLOR_MAP[courseName] || COURSE_COLOR_MAP["default"];
+};
 
 /**
  * 将时间戳转换为指定格式的时间字符串
@@ -33,57 +33,64 @@ export const getCourseBgColor = (courseName: string): string => {
  * @param fmt 格式化字符串,默认为 'YYYY-MM-DD HH:mm:ss'
  * @returns 格式化后的时间字符串
  */
-export const formatTimestamp = (timestamp: number | string | null | undefined, fmt: string = 'YYYY-MM-DD HH:mm:ss'): string => {
-  if (!timestamp) return '-';
-  
+export const formatTimestamp = (
+  timestamp: number | string | null | undefined,
+  fmt: string = "YYYY-MM-DD HH:mm:ss",
+): string => {
+  if (!timestamp) return "-";
+
   // 统一转换为数字类型
   let ts = Number(timestamp);
-  
+
   // 如果时间戳是10位(秒级),转换为13位(毫秒级)
   if (ts.toString().length === 10) {
     ts = ts * 1000;
   }
 
   const date = new Date(ts);
-  
+
   // 如果日期无效,返回原值或横杠
-  if (isNaN(date.getTime())) return '-';
+  if (isNaN(date.getTime())) return "-";
 
   const o: Record<string, number> = {
-    'M+': date.getMonth() + 1,                 // 月份
-    'D+': date.getDate(),                      // 日
-    'H+': date.getHours(),                     // 小时
-    'm+': date.getMinutes(),                   // 分
-    's+': date.getSeconds(),                   // 秒
-    'q+': Math.floor((date.getMonth() + 3) / 3), // 季度
-    'S': date.getMilliseconds()                // 毫秒
+    "M+": date.getMonth() + 1, // 月份
+    "D+": date.getDate(), // 日
+    "H+": date.getHours(), // 小时
+    "m+": date.getMinutes(), // 分
+    "s+": date.getSeconds(), // 秒
+    "q+": Math.floor((date.getMonth() + 3) / 3), // 季度
+    S: date.getMilliseconds(), // 毫秒
   };
 
   if (/(Y+)/.test(fmt)) {
-    fmt = fmt.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length));
+    fmt = fmt.replace(
+      RegExp.$1,
+      (date.getFullYear() + "").substr(4 - RegExp.$1.length),
+    );
   }
 
   for (const k in o) {
-    if (new RegExp('(' + k + ')').test(fmt)) {
-      fmt = fmt.replace(RegExp.$1, (RegExp.$1.length === 1) ? (o[k] + '') : (('00' + o[k]).substr(('' + o[k]).length)));
+    if (new RegExp("(" + k + ")").test(fmt)) {
+      fmt = fmt.replace(
+        RegExp.$1,
+        RegExp.$1.length === 1
+          ? o[k] + ""
+          : ("00" + o[k]).substr(("" + o[k]).length),
+      );
     }
   }
   return fmt;
-}
-
+};
 
 // 将毫米转换成px 保留整数 小数 四舍五入
-export const mmToPx=(num:number) :number=>{
-    if(num)
-    {
-      let scale=1754/297;
-      return  parseFloat((scale*num).toFixed(4));
-    }
-    else
-    {
-      console.log("num",num);
-      return 0;
-    }
+export const mmToPx = (num: number): number => {
+  if (num) {
+    let scale = 1754 / 297;
+    return parseFloat((scale * num).toFixed(4));
+  } else {
+    console.log("num", num);
+    return 0;
+  }
 };
 // 获取分析报告成绩分析中默认的20个颜色
 export const getCompareAnalysis = () => {
@@ -130,7 +137,7 @@ export const getCompareAnalysis = () => {
     "#D6AF83",
     "#84313D",
   ];
-}
+};
 // 获取分析报告成绩分析中默认的20个颜色
 export const getScorePerformanceAnalysis = () => {
   return [
@@ -197,9 +204,9 @@ export const getScorePerformanceAnalysis = () => {
     "#D6AF83",
     "#84313D",
   ];
-}
+};
 //获取G组 G10-G1对应的颜色值
-export const getGGroupColor= () => {
+export const getGGroupColor = () => {
   return [
     {
       name: "G1",
@@ -242,7 +249,33 @@ export const getGGroupColor= () => {
       color: "#FAC858",
     },
   ];
-}
+};
+//获取五率对应的颜色值
+export const getSchoolFiveRateColor = (type: string) => {
+  let list = [
+    {
+      name: "high",
+      color: "#5470C6",
+    },
+    {
+      name: "good",
+      color: "#3BA272",
+    },
+    {
+      name: "fine",
+      color: "#FAC858",
+    },
+    {
+      name: "pass",
+      color: "#995FB3",
+    },
+    {
+      name: "low",
+      color: "#EE6666",
+    },
+  ]; //五率颜色配置
+  return list.find((item) => item.name === type)?.color || "#5470C6"; //默认5470C6颜色
+};
 // 去掉小数点后面为0的 如果不为0 最多只保留2位小数
 /**
  * 数字格式化:整数直接返回、小数最多保留2位并去除末尾多余0
@@ -267,12 +300,12 @@ export const getFormatNumber = (numberStr: string | number): string => {
   let formatted = num.toFixed(2);
 
   // .00 结尾直接返回整数部分
-  if (formatted.endsWith('.00')) {
-    return formatted.split('.')[0];
+  if (formatted.endsWith(".00")) {
+    return formatted.split(".")[0];
   }
 
   // 去除末尾多余的 0
-  formatted = formatted.replace(/\.?0+$/, '');
+  formatted = formatted.replace(/\.?0+$/, "");
 
   return formatted;
-}
+};

Разлика између датотеке није приказан због своје велике величине
+ 599 - 344
src/views/analysis/classComparison.vue


+ 90 - 62
src/views/analysis/index.vue

@@ -21,7 +21,10 @@
 <script lang="ts" setup>
 import FiltersItem from "@/components/FiltersItem.vue";
 // 重命名导入的 API 函数以避免冲突
-import { findCommonSelectList, getAnalysisExamInfo as fetchAnalysisExamInfo } from "@/api/analysis";
+import {
+  findCommonSelectList,
+  getAnalysisExamInfo as fetchAnalysisExamInfo,
+} from "@/api/analysis";
 import { onMounted, ref, computed } from "vue";
 import { useRoute } from "vue-router";
 import { useAnalysisStore } from "@/store/analysis";
@@ -46,7 +49,13 @@ interface FilterOption {
 
 interface FilterItem {
   label: string;
-  type: "subjectName" | "schoolName" | "registrationType" | "scoreType" | "classType" | "className";
+  type:
+    | "subjectName"
+    | "schoolName"
+    | "registrationType"
+    | "scoreType"
+    | "classType"
+    | "className";
   list: FilterOption[];
   value: string;
 }
@@ -110,9 +119,9 @@ interface FilterParams {
   isTotal: number | string;
   subjectGroupCodes: string;
   schoolId: string;
-  schoolLevel: string | number;
+  schoolLevel: number;
   schoolGroupId: string;
-  schoolGroupName: string | null;
+  schoolGroupName: string | null | undefined;
   schoolName: string | null;
   schoolGroupNames: string;
   registrationType: string;
@@ -141,16 +150,18 @@ const filtersData = ref<FilterItem[]>([
 // --- 工具函数 ---
 const updateBySchool = (school: FilterOption) => {
   if (!school || !school.selectStatusVoList) return;
-  
-  const registrationTypeList: FilterOption[] = school.selectStatusVoList.map((item: StatusItem) => ({
-    label: item.statusName,
-    value: `${item.statusGroupType || ""}${item.statusGroupId || ""}${item.statusName || ""}`,
-    statusGroupType: item.statusGroupType,
-    statusName: item.statusName,
-    statusGroupId: item.statusGroupId,
-    examCommonSelectScoreList: item.examCommonSelectScoreList,
-    statusGroupNames: item.statusGroupNames,
-  }));
+
+  const registrationTypeList: FilterOption[] = school.selectStatusVoList.map(
+    (item: StatusItem) => ({
+      label: item.statusName,
+      value: `${item.statusGroupType || ""}${item.statusGroupId || ""}${item.statusName || ""}`,
+      statusGroupType: item.statusGroupType,
+      statusName: item.statusName,
+      statusGroupId: item.statusGroupId,
+      examCommonSelectScoreList: item.examCommonSelectScoreList,
+      statusGroupNames: item.statusGroupNames,
+    }),
+  );
 
   filtersData.value[2].list = registrationTypeList;
   filtersData.value[2].value = registrationTypeList[0]?.value || "";
@@ -160,11 +171,13 @@ const updateBySchool = (school: FilterOption) => {
 const updateByStatus = (status: FilterOption) => {
   if (!status || !status.examCommonSelectScoreList) return;
 
-  const scoreTypeList: FilterOption[] = status.examCommonSelectScoreList.map((item: ScoreTypeItem) => ({
-    label: item.typeName,
-    value: item.scoreType,
-    selectClassVoList: item.selectClassVoList,
-  }));
+  const scoreTypeList: FilterOption[] = status.examCommonSelectScoreList.map(
+    (item: ScoreTypeItem) => ({
+      label: item.typeName,
+      value: item.scoreType,
+      selectClassVoList: item.selectClassVoList,
+    }),
+  );
 
   filtersData.value[3].list = scoreTypeList;
   filtersData.value[3].value = scoreTypeList[0]?.value || "";
@@ -174,11 +187,13 @@ const updateByStatus = (status: FilterOption) => {
 const updateByScoreType = (type: FilterOption) => {
   if (!type || !type.selectClassVoList) return;
 
-  const classTypeList: FilterOption[] = type.selectClassVoList.map((item: ClassTypeItem) => ({
-    label: item.typeName,
-    value: item.classType,
-    selectInfoVoList: item.selectInfoVoList,
-  }));
+  const classTypeList: FilterOption[] = type.selectClassVoList.map(
+    (item: ClassTypeItem) => ({
+      label: item.typeName,
+      value: item.classType,
+      selectInfoVoList: item.selectInfoVoList,
+    }),
+  );
 
   filtersData.value[4].list = classTypeList;
   filtersData.value[4].value = classTypeList[0]?.value || "";
@@ -188,16 +203,18 @@ const updateByScoreType = (type: FilterOption) => {
 const updateByClassType = (classType: FilterOption) => {
   if (!classType || !classType.selectInfoVoList) return;
 
-  const classList: FilterOption[] = (classType.selectInfoVoList || []).map((item: ClassItem) => ({
-    label: item.className,
-    value: `${item.classLevel || ""}${item.classGroupId || ""}${item.classIdCode || ""}`,
-    classLevel: item.classLevel,
-    className: item.className,
-    classCode: item.classCode,
-    classIdCode: item.classIdCode,
-    classGroupId: item.classGroupId,
-    classGroupNames: item.classGroupNames,
-  }));
+  const classList: FilterOption[] = (classType.selectInfoVoList || []).map(
+    (item: ClassItem) => ({
+      label: item.className,
+      value: `${item.classLevel || ""}${item.classGroupId || ""}${item.classIdCode || ""}`,
+      classLevel: item.classLevel,
+      className: item.className,
+      classCode: item.classCode,
+      classIdCode: item.classIdCode,
+      classGroupId: item.classGroupId,
+      classGroupNames: item.classGroupNames,
+    }),
+  );
 
   filtersData.value[5].list = classList;
   filtersData.value[5].value = classList[0]?.value || "";
@@ -284,31 +301,33 @@ const getCommonSelectList = async () => {
       }));
 
     if (!subjectList.length) return;
-    
+
     filtersData.value[0].list = subjectList;
     filtersData.value[0].value = subjectList[0].value;
 
     const firstSubject = subjectList[0];
     if (!firstSubject.selectSchoolVoList) return;
 
-    const schoolList: FilterOption[] = firstSubject.selectSchoolVoList.map((item: SchoolItem) => ({
-      label: item.schoolName,
-      value: `${item.schoolLevel || ""}${item.schoolGroupId || ""}${item.schoolId || ""}`,
-      schoolId: item.schoolId,
-      schoolName: item.schoolName,
-      schoolLevel: item.schoolLevel,
-      schoolGroupId: item.schoolGroupId,
-      selectStatusVoList: item.selectStatusVoList,
-      schoolGroupNames: item.schoolGroupNames,
-    }));
+    const schoolList: FilterOption[] = firstSubject.selectSchoolVoList.map(
+      (item: SchoolItem) => ({
+        label: item.schoolName,
+        value: `${item.schoolLevel || ""}${item.schoolGroupId || ""}${item.schoolId || ""}`,
+        schoolId: item.schoolId,
+        schoolName: item.schoolName,
+        schoolLevel: item.schoolLevel,
+        schoolGroupId: item.schoolGroupId,
+        selectStatusVoList: item.selectStatusVoList,
+        schoolGroupNames: item.schoolGroupNames,
+      }),
+    );
 
     filtersData.value[1].list = schoolList;
     filtersData.value[1].value = schoolList[0]?.value || "";
-    
+
     if (schoolList[0]) {
       updateBySchool(schoolList[0]);
     }
-    
+
     buildAndSaveFilterParams();
   } catch (err) {
     console.error("获取筛选数据失败:", err);
@@ -319,10 +338,10 @@ const getCommonSelectList = async () => {
 const loadAnalysisExamInfo = async () => {
   try {
     // 如果 subjectCode 可能为空,可能需要处理
-    const currentSubjectCode = subjectCode.value; 
+    const currentSubjectCode = subjectCode.value;
     if (!currentSubjectCode) {
-        console.warn("Subject code is not available");
-        return;
+      console.warn("Subject code is not available");
+      return;
     }
 
     const res = await fetchAnalysisExamInfo({
@@ -331,7 +350,14 @@ const loadAnalysisExamInfo = async () => {
     });
 
     if (res.code === 200 && res.data) {
-      analysisStore.setAnalysisExamInfo(res.data);
+      const resData = res.data;
+      const fiveRate = {
+        3: "三率",
+        4: "四率",
+        5: "五率",
+      };
+      resData.fiveRateName = fiveRate[resData.fiveRateType];
+      analysisStore.setAnalysisExamInfo(resData);
     }
   } catch (error) {
     console.error("获取分析考试信息失败:", error);
@@ -350,16 +376,18 @@ const handleSelectChange = (index: number, value: string) => {
 
   if (type === "subjectName") {
     if (!selectedItem.selectSchoolVoList) return;
-    const schoolList: FilterOption[] = selectedItem.selectSchoolVoList.map((item: SchoolItem) => ({
-      label: item.schoolName,
-      value: `${item.schoolLevel || ""}${item.schoolGroupId || ""}${item.schoolId || ""}`,
-      schoolId: item.schoolId,
-      schoolName: item.schoolName,
-      schoolLevel: item.schoolLevel,
-      schoolGroupId: item.schoolGroupId,
-      selectStatusVoList: item.selectStatusVoList,
-      schoolGroupNames: item.schoolGroupNames,
-    }));
+    const schoolList: FilterOption[] = selectedItem.selectSchoolVoList.map(
+      (item: SchoolItem) => ({
+        label: item.schoolName,
+        value: `${item.schoolLevel || ""}${item.schoolGroupId || ""}${item.schoolId || ""}`,
+        schoolId: item.schoolId,
+        schoolName: item.schoolName,
+        schoolLevel: item.schoolLevel,
+        schoolGroupId: item.schoolGroupId,
+        selectStatusVoList: item.selectStatusVoList,
+        schoolGroupNames: item.schoolGroupNames,
+      }),
+    );
     filtersData.value[1].list = schoolList;
     filtersData.value[1].value = schoolList[0]?.value || "";
     if (schoolList[0]) {
@@ -406,4 +434,4 @@ onMounted(() => {
     cursor: pointer;
   }
 }
-</style>
+</style>

+ 1 - 1
src/views/analysis/optionAnalysis.vue

@@ -471,7 +471,7 @@ const objectiveTable = computed(() => {
   const end = start + state.objectiveAnalysisData.pageSize;
   return state.objectiveAnalysisData.tableData.slice(start, end);
 });
-const PageInit = (isRefresh) => {
+const PageInit = (isRefresh: boolean) => {
   state.objectiveAnalysisData.currentPage = 1;
   if (isRefresh) {
     state.pieChart.refresh = true;

+ 50 - 2
src/views/analysis/optionDetail.vue

@@ -62,7 +62,24 @@
           :label="item.label"
           min-width="80"
           fixed="left"
-        />
+        >
+          <template #default="scope">
+            <span
+              v-if="
+                scope.row.score != '缺考' &&
+                scope.row.score != '违纪' &&
+                item.prop === 'studentUserName'
+              "
+              @click="OpenStudentPaper(scope.row)"
+              style="cursor: pointer; color: #2e64fa"
+            >
+              {{ scope.row[item.prop] }}
+            </span>
+            <template v-else>
+              {{ scope.row?.[item.prop] || "-" }}
+            </template>
+          </template>
+        </el-table-column>
         <el-table-column
           v-for="(item, index) in state.answerDataTitle"
           :key="item.prop"
@@ -77,10 +94,17 @@
       </el-table>
     </template>
   </ReportModule>
+  <StudentPaper
+    :modelValue="state.showStudentPaperDialog"
+    :paperInfo="state.paperInfo"
+    :pageTitle="state.paperTitle"
+    @updateModelValue="UpdateModelValue"
+  ></StudentPaper>
 </template>
 
 <script lang="ts" setup>
 import ReportModule from "@/components/ReportModule.vue";
+import StudentPaper from "@/components/StudentPaper.vue"; //学生答题卡组件
 import {
   studentTranscriptTitle,
   queryJointStudentStatistics,
@@ -90,7 +114,7 @@ import {
 import { useAnalysisStore } from "@/store/analysis";
 import { Search } from "@element-plus/icons-vue";
 import { downloadExcel } from "@/utils/exportExcel";
-import { onMounted, reactive, ref, watch } from "vue";
+import { onMounted, reactive, ref, computed, watch } from "vue";
 
 interface TableColumn {
   prop: string;
@@ -123,6 +147,9 @@ interface State {
 }
 
 const analysisStore = useAnalysisStore();
+const getExamName = computed(() => {
+  return analysisStore.analysisExamInfo.examName || "";
+});
 const reportModuleRef = ref<any>(null);
 const state = reactive<State>({
   keyWord: "",
@@ -141,6 +168,13 @@ const state = reactive<State>({
   },
   tableLoading: true,
   loadingText: "加载中……",
+  paperInfo: {
+    examPaperId: "", //考试科目id
+    platformNumber: "", //学籍号平台号
+    questionId: "", //题目id
+  }, //学生试卷信息
+  paperTitle: "", //学生试卷标题
+  showStudentPaperDialog: false, //是否显示学生答题卡弹窗
 });
 
 /** 获取表头 */
@@ -245,6 +279,20 @@ const ExportExcel = () => {
     reportModuleRef.value?.SetExportLoading?.(false);
   });
 };
+//打开答题卡弹窗
+const OpenStudentPaper = (row, prop) => {
+  state.paperInfo = {
+    examPaperId: analysisStore.filterObject.subjectId, //考试科目id
+    platformNumber: row.studentRegistrationCode, //学籍号平台号
+    questionId: "", //题目id
+  };
+  state.paperTitle = `${getExamName.value}-${analysisStore.filterObject.subjectName}-${row.className}-${row.studentUserName}`; //学生姓名
+  state.showStudentPaperDialog = true;
+};
+//更新弹窗状态
+const UpdateModelValue = (val: boolean) => {
+  state.showStudentPaperDialog = val;
+};
 // 搜索事件
 const HandleSearch = () => {
   state.pageInfo.pageNum = 1;

+ 2 - 2
vite.config.ts

@@ -16,8 +16,8 @@ export default defineConfig({
     proxy:{
      
       '/api':{
-        // target:'https://dev3.k12100.net/teaching/api/',//测试环境
-        target:'http://192.168.1.48:47001/api/',//测试环境
+        target:'https://dev3.k12100.net/teaching/api/',//测试环境
+        // target:'http://192.168.1.48:47001/api/',//测试环境
         // target:'https://www.k12100.com/teaching/api/',//正式环境
         changeOrigin:true,
         rewrite:path => path.replace(/^\/api/, '')

Неке датотеке нису приказане због велике количине промена