Bladeren bron

成绩单打印pdf

liurongli 3 weken geleden
bovenliggende
commit
70ed89de4b

+ 8 - 0
src/api/analysis.ts

@@ -69,6 +69,14 @@ export const studentTranscriptExcel = (data: any): Promise<ApiResponse> => {
     responseType: "blob",
   });
 };
+//查询班级学生成绩单导出PDF数据
+export const queryStudentTranscript = (data: any): Promise<ApiResponse> => {
+  return request({
+    url: "/api/v1/ai_analysis/queryStudentTranscript",
+    method: "post",
+    data,
+  });
+};
 // ==========================================错题分析============================================
 // 错题分析
 export const errorQuestionAnalysis = (data: any): Promise<ApiResponse> => {

BIN
src/assets/icon/download_excel_icon.webp


BIN
src/assets/icon/download_icon.webp


BIN
src/assets/icon/export_icon_white.webp


+ 436 - 0
src/components/ScorePdf.vue

@@ -0,0 +1,436 @@
+<template>
+  <!-- 成绩单导出pdf -->
+  <div class="report_module score_pdf">
+    <template v-for="(item, page) in state.tableData">
+      <div class="area_page print_page">
+        <div class="area_title" v-if="item.isFirstPage">
+          <div class="page_num">第{{ page + 1 }}页(共{{ state.pageTotal }}页)</div>
+          <div class="title_text">【{{ `${examName}成绩单(${item?.className || ''})` }}】</div>
+          <div class="print_time">打印时间:{{ getNowTime() }}</div>
+        </div>
+        <div class="area_count" v-if="item.isFirstPage">
+          应考:{{ item?.fiveRateResult?.examAllNum || 0 }}人&nbsp;&nbsp;
+          实考:{{ item?.fiveRateResult?.effectiveNum || 0 }}人&nbsp;&nbsp;
+          缺考:{{ Number(item?.fiveRateResult?.examAllNum || 0) - Number(item?.fiveRateResult?.effectiveNum || 0)
+          }}人&nbsp;&nbsp;
+          满分:{{ item?.fullScore || 0 }}分&nbsp;&nbsp;
+          最高分:{{ item?.fiveRateResult?.maxScore || 0 }}分&nbsp;&nbsp;
+          最低分:{{ item?.fiveRateResult?.minScore || 0 }}分&nbsp;&nbsp;
+          年级均分:{{ item?.fiveRateResult?.avgSchoolScore || 0 }}分&nbsp;&nbsp;
+          班级均分:{{ item?.fiveRateResult?.avgScore || 0 }}分
+        </div>
+        <div class="module_table table_42">
+          <el-table :data="item?.listData || []" border stripe :show-header="item.isFirstPage">
+            <el-table-column v-for="item in state.staticHeaderData" :key="item.prop" :prop="item.prop"
+              :label="item.label" :width="state.groupDataTitleData.length ? '70px' : 'auto'">
+              <template #default="scope">
+                <span v-if="scope.row.score != '缺考' && scope.row.score != '违纪' && item.prop === 'studentUserName'">
+                  {{ 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.groupDataTitleData" :key="item.prop" :prop="item.prop"
+              :label="item.label">
+              <template #default="scope">{{ scope.row?.dynamicsData?.groupData?.[index] || "-" }}</template>
+            </el-table-column>
+          </el-table>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import {
+  studentTranscriptTitle,
+  queryStudentTranscript,
+} from "@/api/analysis";
+import { useAnalysisStore } from "@/store/analysis";
+import { getNowTime } from "@/utils/common";
+import { reactive, computed, nextTick } from "vue";
+import { ElMessage } from "element-plus";
+import { jsPDF } from "jspdf";
+import domtoimage from "dom-to-image";
+
+interface TableColumn {
+  prop: string;
+  label: string;
+  display?: boolean;
+  [key: string]: any;
+}
+
+interface TableCount {
+  examStudentCount: number | string;
+  normalCount: number | string;
+  missExamCount: number | string;
+}
+
+
+interface State {
+  keyWord: string;
+  selectClassList: string[];
+  tableData: any[];
+  pageTotal: number;
+  staticHeaderData: TableColumn[];
+  groupDataTitleData: TableColumn[];
+  tableCount: TableCount;
+  tableLoading: Boolean;
+}
+
+const analysisStore = useAnalysisStore();
+const examName = computed(() => {
+  return analysisStore.analysisExamInfo.examName || "";
+});
+const state = reactive<State>({
+  keyWord: '',
+  selectClassList: [],
+  tableData: [],
+  pageTotal: 0,
+  staticHeaderData: [],
+  groupDataTitleData: [],
+  tableCount: {
+    examStudentCount: 0,
+    normalCount: 0,
+    missExamCount: 0,
+  },
+  tableLoading: true
+});
+/** 获取表头 */
+const GetStudentTranscriptTitle = async () => {
+  if (!analysisStore.filterObject) return;
+  try {
+    const res = await studentTranscriptTitle({
+      ...analysisStore.filterObject,
+      topicControl: 0,
+    });
+    if (res.code === 200) {
+      const staticHeaderData = res.data?.title?.staticHeaderData || [];
+      state.staticHeaderData = staticHeaderData.filter(
+        (item: { display: any }) => item.display,
+      );
+      state.groupDataTitleData =
+        res.data?.title?.dynamicsHeaderData?.groupDataTitle || [];
+    }
+  } catch (err) {
+    console.error("获取表头失败:", err);
+  }
+};
+
+/** 获取表格数据 */
+const GetTableData = async () => {
+  if (!analysisStore.filterObject) return;
+  try {
+    state.tableLoading = true;
+    const res = await queryStudentTranscript({
+      ...analysisStore.filterObject,
+      topicControl: 0,
+      queryStr: state.keyWord,
+      classIdCodes: state.selectClassList,
+    });
+    if (res.code === 200) {
+      const tableData = res?.data || [];
+      tablePageData(tableData);//分页处理(内部会设置 state.tableData 和 state.pageTotal)
+    }
+  } catch (err) {
+    console.error("获取表格数据失败:", err);
+    state.tableData = [];
+    state.pageTotal = 0;
+  } finally {
+    state.tableLoading = false;
+  }
+};
+//分页:超过一页自动换页,后续页不包含标题行和应考/实考/缺考统计行
+const tablePageData = (list: any[]) => {
+  const trLineHeight = 21;//表格表头、tr行高
+  const printPageHeight = 1285;//打印页面高度
+
+  const rowNum = Math.floor(printPageHeight / trLineHeight);//每页可容纳行数 向下取整
+  const firstPageRows = rowNum - 3;//第一页数据行数:标题行+统计行+表头行占3行
+  const otherPageRows = rowNum;//后续页数据行数:无标题、无统计、无表头,数据行放满  原来是 rowNum - 1,后续页没有表头占位了,数据行放满
+
+  const pageList: any[] = [];
+  list.forEach((item) => {
+    const dataList = item?.listData || [];
+    //无数据也保留一页(标题+统计+空表)
+    if (!dataList.length) {
+      pageList.push({ ...item, listData: [], isFirstPage: true });
+      return;
+    }
+    //总页数:第一页放 firstPageRows 行,其余每页 otherPageRows 行
+    const pageNum =
+      Math.ceil((dataList.length - firstPageRows) / otherPageRows) + 1;
+    for (let page = 1; page <= pageNum; page++) {
+      const start =
+        page === 1 ? 0 : firstPageRows + (page - 2) * otherPageRows;
+      const rows =
+        page === 1
+          ? Math.min(firstPageRows, dataList.length)
+          : Math.min(otherPageRows, dataList.length - start);
+      //第二页及后续页的数据放入拆出来的 listData,页面上不渲染标题和统计行
+      pageList.push({
+        ...item,
+        listData: dataList.slice(start, start + rows),
+        isFirstPage: page === 1,
+      });
+    }
+  });
+  state.tableData = pageList;
+  state.pageTotal = pageList.length;
+}
+// 初始化
+const PageInit = async (keyWord: string, selectClassList: string[]) => {
+  state.keyWord = keyWord;
+  state.selectClassList = selectClassList;
+  await Promise.all([GetStudentTranscriptTitle(), GetTableData()]);
+};
+
+// 导出PDF并在浏览器新窗口打开
+const ExportPdf = async () => {
+  const elements = document.querySelectorAll(".score_pdf .print_page");
+  if (!elements.length) {
+    ElMessage.warning("未找到可导出的页面内容,请先加载数据!");
+    return;
+  }
+  // 在用户手势内同步打开空白窗口,避免被浏览器拦截
+  const win = window.open("", "_blank");
+  if (!win) {
+    ElMessage.warning("浏览器拦截了弹窗,请允许后重试");
+    return;
+  }
+  win.document.write(
+    "<p style='font-family:sans-serif;text-align:center;padding-top:40px'>成绩单 PDF 生成中…</p>",
+  );
+
+  const pdf = new jsPDF("p", "pt", "a4");
+  const pdfWidth = pdf.internal.pageSize.getWidth();
+  const pdfHeight = pdf.internal.pageSize.getHeight();
+
+  for (const [pageIndex, element] of Array.from(elements).entries()) {
+    await nextTick();
+    const htmlElement = element as HTMLElement;
+    htmlElement.style.visibility = "visible";
+    htmlElement.offsetHeight;
+
+    // 等待该页渲染完成
+    await new Promise((resolve) => setTimeout(resolve, 150));
+
+    const dataUrl = await domtoimage.toJpeg(htmlElement, {
+      quality: 0.9,
+      width: htmlElement.scrollWidth * 2,
+      height: htmlElement.scrollHeight * 2,
+      canvasWidth: htmlElement.scrollWidth * 2,
+      canvasHeight: htmlElement.scrollHeight * 2,
+      style: {
+        transform: "scale(2)",
+        "transform-origin": "top left",
+        "background-color": "#ffffff",
+        overflow: "visible",
+        "white-space": "normal",
+        position: "relative",
+        visibility: "visible",
+      },
+      bgcolor: "#ffffff",
+    });
+
+    const imgProps = pdf.getImageProperties(dataUrl);
+    const imgWidth = imgProps.width;
+    const imgHeight = imgProps.height;
+    const ratio = Math.min(pdfWidth / imgWidth, pdfHeight / imgHeight);
+    const adjustWidth = imgWidth * ratio;
+    const adjustHeight = imgHeight * ratio;
+    const xPosition = (pdfWidth - adjustWidth) / 2;
+    const yPosition = (pdfHeight - adjustHeight) / 2;
+
+    if (pageIndex > 0) {
+      pdf.addPage();
+    }
+    pdf.addImage(
+      dataUrl,
+      "JPEG",
+      xPosition,
+      yPosition,
+      adjustWidth,
+      adjustHeight,
+    );
+  }
+  // // 直接打开生成的PDF,保留浏览器自带的下载/打印工具栏
+  // const filename = `${examName.value}成绩单.pdf`;
+  // // 通过 PDF 文档属性设置标题,浏览器查看器的标签页标题即显示该文件名
+  // pdf.setProperties({ title: filename });
+  // const blob = pdf.output("blob");
+  // const url = URL.createObjectURL(
+  //   new File([blob], filename, { type: "application/pdf" }),
+  // );
+  // win.location.replace(url);
+  // 在浏览器新窗口打开生成的PDF(中转页方案:窗口标题、下载文件名均使用指定名称)
+  const filename = `${examName.value}成绩单.pdf`;
+  const blob = pdf.output("blob");
+  const url = URL.createObjectURL(
+    new File([blob], filename, { type: "application/pdf" }),
+  );
+  // 转义文件名中的特殊字符,防止破坏 HTML
+  const safeName = filename
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;");
+  // 清空"生成中"占位页,写入中转页
+  win.document.open();
+  win.document.write(
+    `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<title>${safeName}</title>
+<style>
+  body{margin:0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
+  .bar{height:48px;display:flex;align-items:center;gap:12px;padding:0 16px;background:#f5f6f7;border-bottom:1px solid #dcdfe6;box-sizing:border-box;}
+  .bar .fname{flex:1;font-size:14px;color:#333;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+  .bar button{color:#fff;background:#2e64fa;border:0;padding:6px 16px;border-radius:4px;font-size:13px;cursor:pointer;flex-shrink:0;}
+  iframe{width:100%;height:calc(100vh - 48px);border:0;display:block;}
+  @media print{
+    .bar{display:none;}
+    iframe{height:100vh;}
+  }
+</style>
+</head>
+<body>
+  <div class="bar">
+    <span class="fname">${safeName}</span>
+    <button onclick="window.print()">打印PDF</button>
+  </div>
+  <iframe src="${url}"></iframe>
+</body>
+</html>`,
+  );
+  win.document.close();
+};
+
+// 加载数据并导出PDF(供父组件一步调用)
+const LoadAndExportPdf = async (keyWord: string, selectClassList: string[]) => {
+  await PageInit(keyWord, selectClassList);
+  await nextTick();
+  await ExportPdf();
+};
+
+// 暴露方法供父组件调用
+defineExpose({
+  PageInit,
+  ExportPdf,
+  LoadAndExportPdf
+});
+</script>
+
+<style lang="scss" scoped>
+.score_pdf {
+  width: 908px;
+  margin: 0 auto;
+
+  .area_page {
+    width: 908px;
+    margin-top: 20px;
+    padding: 12px 5px 0 !important;
+    background-color: #fff;
+    box-sizing: border-box;
+    overflow: hidden;
+
+    &.print_page {
+      height: 1285px;
+    }
+
+    .area_title {
+      width: 100%;
+      height: 21px;
+      line-height: 21px;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+
+      .page_num {
+        font-size: 12px;
+        color: #000000;
+        flex-shrink: 0;
+      }
+
+      .print_time {
+        font-size: 12px;
+        color: #000000;
+        flex-shrink: 0;
+      }
+
+      .title_text {
+        font-weight: bold;
+        font-size: 16px;
+        color: #000000;
+        flex: 1;
+        text-align: center;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+    }
+
+    .area_count {
+      height: 21px;
+      width: 100%;
+      font-size: 12px;
+      color: #000000;
+      text-align: center;
+      line-height: 21px;
+      overflow: hidden;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    .module_table {
+      width: 100%;
+
+      :deep(.el-table) {
+        border-radius: 6px;
+        border-left: 0px;
+        border-right: 0px;
+
+        .el-table__header {
+          height: 20px !important;
+
+          thead {
+            height: 20px !important;
+            font-size: 12px;
+          }
+
+          .el-table__cell {
+            height: 20px !important;
+          }
+
+          .cell {
+            font-size: 12px;
+          }
+        }
+
+        .el-table__body {
+          tr {
+            height: 20px !important;
+            font-size: 12px;
+
+            .el-table__cell {
+              height: 20px !important;
+            }
+          }
+        }
+
+        .cell {
+          line-height: 20px;
+          display: block;
+          text-align: center;
+          white-space: nowrap;
+          padding: 0 0 0 4px !important;
+          color: #000000;
+        }
+      }
+    }
+  }
+}
+</style>

+ 5 - 0
src/router/index.ts

@@ -130,6 +130,11 @@ const routes: Array<RouteRecordRaw> = [
             name: 'propositionAnalysis',
             component: () => import('@/views/analysis/propositionAnalysis.vue')
           },//命题分析
+          {
+            path: 'reportDownload',
+            name: 'reportDownload',
+            component: () => import('@/views/analysis/reportDownload.vue')
+          }//报表下载
         ]
       },
     ]

+ 33 - 5
src/styles/common.scss

@@ -1832,6 +1832,11 @@ body {
       color: #666666;
     }
   }
+  &.padding_0{
+    .el-dialog__body {
+      padding: 0;
+    }
+  }
 
   .max_height_540 {
     margin: 20px 0;
@@ -2272,27 +2277,43 @@ body {
       margin: auto;
       height: auto;
       // align-items: center;
+      &.score_class_popup {
+        width: 100%;
+      }
 
       .el-checkbox-group {
         display: flex;
         flex-wrap: wrap;
         height: auto;
       }
+
       .el-checkbox {
-        width: 20%;
-        box-sizing: border-box; /* 确保 padding 和 border 不影响宽度 */
+        width: 25%;
+        min-width: 20%;
+        box-sizing: border-box;
+        /* 确保 padding 和 border 不影响宽度 */
         // padding: 5px; /* 可选:添加一些内边距 */
         margin-bottom: 23px;
         margin-right: 0px;
-
         font-size: 16px;
         color: #333333;
+        display: flex;
+        align-items: center;
+        height: 20px;
       }
 
       .el-checkbox__label {
         font-weight: 400;
         font-size: 16px;
         color: #333;
+        white-space: nowrap;
+        /* 禁止换行 */
+        overflow: hidden;
+        /* 隐藏超出容器的内容 */
+        text-overflow: ellipsis;
+        /* 隐藏超出宽度的内容 */
+        width: 80%;
+        // padding-left: 10px;
       }
     }
 
@@ -2742,23 +2763,26 @@ body {
     }
   }
   //饿了么底部按钮样式覆盖
-  .el-dialog__footer {
+  .dialog__footer {
     padding: 0 20px;
     text-align: right;
     border-top: 1px solid #e6e6e6;
     height: 64px;
     line-height: 64px;
     border-radius: 0 0 8px 8px;
-    .dialog_footer {
+    &.dialog_footer {
       padding: 0 !important;
+      width: calc(100% - 40px);
       margin: auto;
       height: 64px;
       display: flex;
       justify-content: space-between;
+      align-items: center;
       .footer_left {
         width: auto;
         height: 64px;
       }
+
       .footer_right {
         width: auto;
         height: 64px;
@@ -2767,6 +2791,10 @@ body {
         align-items: center;
       }
     }
+    &.dialog-footer {
+      width: calc(100% - 40px);
+      margin: auto;
+    }
     .el-button {
       width: 68px;
       height: 36px;

+ 25 - 1
src/utils/common.ts

@@ -312,4 +312,28 @@ export const getFormatNumber = (numberStr: string | number): string => {
 // 加载中svg图标
 export const loadingSvgIcon = () => { 
   return `<svg  xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="currentColor" d="M512 64a32 32 0 0 1 32 32v192a32 32 0 0 1-64 0V96a32 32 0 0 1 32-32m0 640a32 32 0 0 1 32 32v192a32 32 0 1 1-64 0V736a32 32 0 0 1 32-32m448-192a32 32 0 0 1-32 32H736a32 32 0 1 1 0-64h192a32 32 0 0 1 32 32m-640 0a32 32 0 0 1-32 32H96a32 32 0 0 1 0-64h192a32 32 0 0 1 32 32M195.2 195.2a32 32 0 0 1 45.248 0L376.32 331.008a32 32 0 0 1-45.248 45.248L195.2 240.448a32 32 0 0 1 0-45.248m452.544 452.544a32 32 0 0 1 45.248 0L828.8 783.552a32 32 0 0 1-45.248 45.248L647.744 692.992a32 32 0 0 1 0-45.248M828.8 195.264a32 32 0 0 1 0 45.184L692.992 376.32a32 32 0 0 1-45.248-45.248l135.808-135.808a32 32 0 0 1 45.248 0m-452.544 452.48a32 32 0 0 1 0 45.248L240.448 828.8a32 32 0 0 1-45.248-45.248l135.808-135.808a32 32 0 0 1 45.248 0"></path></svg>`
-};
+};
+// 获取当前时间 格式:yyyy-MM-dd HH:mm:ss
+export const getNowTime = () => { 
+  const date = new Date();
+  const year = date.getFullYear();
+  // 月份从0开始,+1
+  const month = String(date.getMonth() + 1).padStart(2, '0');
+  const day = String(date.getDate()).padStart(2, '0');
+  const hour = String(date.getHours()).padStart(2, '0');
+  const minute = String(date.getMinutes()).padStart(2, '0');
+  const second = String(date.getSeconds()).padStart(2, '0');
+  return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
+}
+ //中间截断显示文本,保留前后各n位字符
+export const truncateMiddleText = (text: string,num:number,startStr:number,endStr:number) => {
+  //num:不超过多少字符,全部展示 startStr:开头保留多少字符 endStr:结尾保留多少字符
+  if (text.length <= num) {
+    return text;
+  }
+  // 保留开头和结尾各5位字符
+  const start = text.substring(0, startStr);
+  const end = text.substring(text.length - endStr);
+  
+  return `${start}...${end}`;
+}

+ 1030 - 0
src/views/analysis/reportDownload.vue

@@ -0,0 +1,1030 @@
+<template>
+  <ReportModule
+    :showTitle="false"
+    :showDescribe="false"
+    :showPrintBtn="false"
+    :showExportBtn="false"
+    :showTablePage="false"
+    tableOrChart="qita"
+  >
+    <template #title_left>
+      <span>重点页面表格批量下载中心</span>
+      <span>请勾选需要下载的分析表格,选择批量导出</span>
+    </template>
+    <template #title_right>
+      <el-button class="button_editor" @click="showDownloadDialog">
+        下载记录
+      </el-button>
+      <el-button
+        class="button_task_select"
+        :loading="exportLoading"
+        @click="batchExport('报表下载')"
+      >
+        <img
+          v-if="!exportLoading"
+          src="@/assets/icon/export_icon_white.webp"
+        />批量导出
+      </el-button>
+    </template>
+    <template #module_table_chart>
+      <div
+        class="module_content"
+        v-loading="menuLoading"
+        element-loading-text="加载中,请稍后……"
+        element-loading-spinner="el-icon-loading"
+        element-loading-background="#ffffff"
+      >
+        <div
+          class="item_content"
+          v-for="module in excelModuleList"
+          :key="module.menuInfo"
+        >
+          <div class="item_content_title">
+            <div class="content_title_left">
+              <img src="@/assets/icon/download_icon.webp" />
+              <span>{{ module.menuValue }}</span>
+            </div>
+            <div class="content_title_right" @click="singleExport(module)">
+              下载
+            </div>
+          </div>
+          <div class="item_content_list">
+            <el-checkbox
+              :indeterminate="module.isIndeterminate"
+              v-model="module.checkAll"
+              @change="(val) => handleCheckAllChange(val, module)"
+              >全选</el-checkbox
+            >
+            <el-checkbox-group
+              v-model="module.excelModuleList"
+              @change="(val) => handleexcelModuleListChange(val, module)"
+            >
+              <el-checkbox
+                v-for="item in module.excelsList"
+                :label="item.value"
+                :key="item.value"
+                >{{ item.label }}</el-checkbox
+              >
+            </el-checkbox-group>
+          </div>
+        </div>
+      </div>
+    </template>
+  </ReportModule>
+  <div class="page_dialog">
+    <el-dialog
+      :title="segmentData.title"
+      center
+      v-model="segmentData.see"
+      width="600px"
+      @close="CloseDownloadDialog"
+    >
+      <div class="dialog_center">
+        <div class="dialog_description">{{ segmentData.description }}</div>
+        <div
+          class="dialog_content"
+          v-loading="loading"
+          :element-loading-text="loadingText"
+          element-loading-spinner="el-icon-loading"
+          element-loading-background="#ffffff"
+        >
+          <div
+            class="list_item"
+            v-for="(item, index) in examDownloadList"
+            :key="`${item.createTime}_${index}`"
+          >
+            <div class="list_item_left">
+              <img src="@/assets/icon/download_excel_icon.webp" />
+              <div class="excel_info">
+                <span class="excel_title">{{ item.taskName }}</span>
+                <el-progress
+                  :percentage="item.progress || 0"
+                  :stroke-width="7"
+                  color="#2E64FA"
+                  define-back-color="#E2E6F0"
+                  text-color="#333333"
+                ></el-progress>
+              </div>
+            </div>
+            <div class="list_item_right">
+              <span class="download_time">{{ item.createTime }}</span>
+              <span
+                :class="[
+                  'download_button',
+                  { failed: item.generationStatus == 2 },
+                ]"
+                @click="
+                  handleDownloadClick(
+                    item.generationStatus,
+                    item.id,
+                    item.filePath,
+                    item.fileName,
+                  )
+                "
+              >
+                <template v-if="item.generationStatus == 0">取消下载</template>
+                <template v-else-if="item.generationStatus == 2"
+                  >下载失败</template
+                >
+                <template v-else>下载</template>
+              </span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import {
+  ref,
+  reactive,
+  computed,
+  watch,
+  onBeforeUnmount,
+  getCurrentInstance,
+} from "vue";
+import { useStore } from "vuex";
+import { useAnalysisStore } from "@/store/analysis";
+import { ElMessage, ElMessageBox } from "element-plus";
+import ReportModule from "@/components/ReportModule.vue";
+
+const { proxy } = getCurrentInstance();
+const api = proxy.$api; // 全局挂载的 API 对象
+const store = useStore();
+const analysisStore = useAnalysisStore();
+// 分析报告公共参数变量
+const filterObject = computed(() => analysisStore.filterObject);
+console.log(filterObject.value,98999923)
+
+// 批量导出loading
+const exportLoading = ref(false);
+// 所有模块
+const excelModuleList = ref([]);
+// 弹框显示/隐藏
+const segmentData = reactive({
+  see: false,
+  title: "",
+  description: "",
+  isLoading: false,
+});
+// 下载loading
+const loading = ref(false);
+const loadingText = ref("下载中...");
+// 用于存储轮询定时器
+let downloadTimer = null;
+// 下载记录列表
+const examDownloadList = ref([]);
+// 请求标识符,用于防止快速切换导致的数据累积
+let currentRequestId = 0;
+// 模块列表loading
+const menuLoading = ref(false);
+// 对标考试id
+const lastExamId = computed(() => store.state.report.lastExamRadioId);
+// 多次考试任务对比ID,不包含当前任务ID
+const contrastExamIds = computed(() => store.state.report.lastExamSelectIds);
+
+watch(
+  () => filterObject.value,
+  () => {
+    // 初始化模块列表
+    excelModuleList.value = [];
+    // getExcelModuleList();
+  },
+  { immediate: true, deep: true }
+);
+
+// 获取Excel模块列表
+async function getExcelModuleList() {
+  // 1. 每次调用生成一个新的 requestId
+  const requestId = ++currentRequestId;
+  // 辅助函数:检查当前请求是否已过期(被新的请求覆盖)
+  const isExpired = () => requestId !== currentRequestId;
+
+  menuLoading.value = true;
+  try {
+    const transcriptData = await transcriptModule();
+    if (isExpired()) return; // 如果已过期,直接终止
+    const levelData = levelDistributionModule();
+    excelModuleList.value.push(transcriptData, levelData);
+    if (filterObject.value.examLevel == 1) {
+      if (filterObject.value.schoolLevel != 2) {
+        const schoolContrastData = await schoolContrastModule();
+        if (isExpired()) return; // 如果已过期,直接终止
+        excelModuleList.value.push(schoolContrastData);
+      }
+      if (filterObject.value.subjectGroupType == 1) {
+        const classContrastData = await classContrastModule();
+        if (isExpired()) return; // 如果已过期,直接终止
+        const groupGAnalysisData = await groupGAnalysisModule();
+        if (isExpired()) return; // 如果已过期,直接终止
+        excelModuleList.value.push(
+          subjectContrastModule(),
+          classContrastData,
+          subjectQualityModule(),
+          groupGAnalysisData,
+          quartileModule(),
+        );
+      } else {
+        const classContrastData = await classContrastModule();
+        if (isExpired()) return; // 如果已过期,直接终止
+        const groupGAnalysisData = await groupGAnalysisModule();
+        if (isExpired()) return; // 如果已过期,直接终止
+        const paperAnalysisData = await paperAnalysisModule();
+        if (isExpired()) return; // 如果已过期,直接终止
+        excelModuleList.value.push(
+          classContrastData,
+          paperAnalysisData,
+          groupGAnalysisData,
+        );
+        excelModuleList.value.push(quartileModule());
+      }
+    } else {
+      if (filterObject.value.subjectGroupType == 1) {
+        const classContrastData = await classContrastModule();
+        if (isExpired()) return; // 如果已过期,直接终止
+        const groupGAnalysisData = await groupGAnalysisModule();
+        if (isExpired()) return; // 如果已过期,直接终止
+        excelModuleList.value.push(
+          subjectContrastModule(),
+          classContrastData,
+          subjectQualityModule(),
+          groupGAnalysisData,
+          quartileModule(),
+        );
+      } else {
+        const classContrastData = await classContrastModule();
+        if (isExpired()) return; // 如果已过期,直接终止
+        const groupGAnalysisData = await groupGAnalysisModule();
+        if (isExpired()) return; // 如果已过期,直接终止
+        const paperAnalysisData = await paperAnalysisModule();
+        if (isExpired()) return; // 如果已过期,直接终止
+        excelModuleList.value.push(
+          classContrastData,
+          paperAnalysisData,
+          groupGAnalysisData,
+        );
+        excelModuleList.value.push(quartileModule());
+      }
+    }
+  } catch (error) {
+    console.error("获取模块列表异常:", error);
+  } finally {
+    // 2. 只有当前请求是最新请求时,才关闭 loading
+    // 如果请求已过期,说明有更新的请求正在进行,loading 应继续保持
+    if (!isExpired()) {
+      menuLoading.value = false;
+    }
+  }
+}
+// 成绩单模块
+async function transcriptModule() {
+  let excelModuleList = [],
+    excelsList = [];
+  if (filterObject.value.subjectGroupType === 1) {
+    //总分
+    excelsList.push({
+      label: "成绩单",
+      value: "studentTranscript100",
+    });
+    excelModuleList.push("studentTranscript100");
+  } else {
+    const res = await api.reportSchool.checkQuestionExist(
+      filterObject.value,
+    );
+    if (res.code === 200) {
+      const list = res.data || [];
+      list.forEach((item) => {
+        if (item.value == 100) {
+          excelModuleList.push("studentTranscript100");
+        }
+        if (item.value == 0) {
+          excelModuleList.push("studentTranscript0");
+        }
+        excelsList.push({
+          label: `${item.label}${
+            item.value == 100 || item.value == 0 ? "" : "得分"
+          }`,
+          value: `studentTranscript${item.value}`,
+        });
+      });
+    }
+  }
+  return {
+    menuInfo: "transcript",
+    menuValue: "成绩单",
+    checkAll: excelModuleList.length == excelsList.length, //全选
+    excelModuleList: excelModuleList, //选中的模块
+    excelsList: excelsList, //所有模块List
+    isIndeterminate:
+      excelModuleList.length < excelsList.length &&
+      excelModuleList.length > 0, //半选
+  };
+}
+// 水平分布模块
+function levelDistributionModule() {
+  //判断是否有赋分数据
+  let excelsList = [
+    { label: "分数段", value: "scoreSegment" },
+    { label: "一分段", value: "oneScoreOneSegment" },
+    { label: "名次段", value: "rankSegment" },
+    { label: "N%名", value: "nRankSegment" },
+    { label: "等级分布", value: "academicLevel" },
+  ];
+  if (
+    store.state.report.filterData[3].list.length > 1 &&
+    !filterObject.value.subjectGroupType
+  ) {
+    excelsList.push({ label: "赋分分布", value: "rateScore" });
+  }
+  return {
+    menuInfo: "levelDistribution",
+    menuValue: "水平分布",
+    checkAll: false, //全选
+    excelModuleList: ["scoreSegment"], //选中的模块
+    excelsList: excelsList, //所有模块List
+    isIndeterminate: true, //半选
+  };
+}
+// 学校对比模块
+async function schoolContrastModule() {
+  let excelsList = [];
+  let obj = {
+    label: "学校平均分+各率分析表",
+    value: "schoolContrast0",
+  };
+  if (filterObject.value.subjectGroupType === 1) {
+    excelsList.push(obj);
+  } else {
+    if (filterObject.value.scoreType == 1) {
+      const res = await api.reportSchool.getsubjectTiteType({
+        examId: filterObject.value.examId,
+        subjectId: filterObject.value.subjectId,
+      });
+      if (res.code === 200) {
+        const list = res.data || [];
+        list.forEach((item) => {
+          excelsList.push({
+            label: `学校${item.titleName}平均分+各率分析表`,
+            value: `schoolContrast${item.titleType}${
+              item.titleType == 5 ? "paperTypeName" + item.titleName : ""
+            }`,
+          });
+        });
+      } else {
+        excelsList.push(obj);
+      }
+    } else {
+      excelsList.push(obj);
+    }
+  }
+  const excelModuleList =
+    excelsList.length > 0 ? [excelsList[0].value] : [];
+  return {
+    menuInfo: "schoolContrast",
+    menuValue: "学校对比",
+    checkAll: excelModuleList.length == excelsList.length, //全选
+    excelModuleList: excelModuleList, //选中的模块
+    excelsList: excelsList, //所有模块List
+    isIndeterminate:
+      excelModuleList.length < excelsList.length &&
+      excelModuleList.length > 0, //半选
+  };
+}
+// 学科对比模块
+function subjectContrastModule() {
+  return {
+    menuInfo: "subjectContrast",
+    menuValue: "学科对比",
+    checkAll: true, //全选
+    excelModuleList: ["subjectContrast", "groupSubjectContrast"], //选中的模块
+    excelsList: [
+      //所有模块List
+      {
+        label: "学科平均分+各率分析表",
+        value: "subjectContrast",
+      },
+      {
+        label: "总分大表",
+        value: "groupSubjectContrast",
+      },
+    ],
+    isIndeterminate: false, //半选
+  };
+}
+// 班级对比模块
+async function classContrastModule() {
+  let excelsList = [];
+  let obj = {
+    label: "班级平均分+各率分析表",
+    value: "classContrast0",
+  };
+  if (filterObject.value.subjectGroupType === 1) {
+    excelsList.push(obj);
+  } else {
+    if (filterObject.value.scoreType == 1) {
+      const res = await api.reportSchool.getsubjectTiteType({
+        examId: filterObject.value.examId,
+        subjectId: filterObject.value.subjectId,
+      });
+      if (res.code === 200) {
+        const list = res.data || [];
+        list.forEach((item) => {
+          excelsList.push({
+            label: `班级${item.titleName}平均分+各率分析表`,
+            value: `classContrast${item.titleType}${
+              item.titleType == 5 ? "paperTypeName" + item.titleName : ""
+            }`,
+          });
+        });
+      } else {
+        excelsList.push(obj);
+      }
+    } else {
+      excelsList.push(obj);
+    }
+  }
+  const excelModuleList =
+    excelsList.length > 0 ? [excelsList[0].value] : [];
+  return {
+    menuInfo: "classContrast",
+    menuValue: "班级对比",
+    checkAll: excelModuleList.length == excelsList.length, //全选
+    excelModuleList: excelModuleList, //选中的模块
+    excelsList: excelsList, //所有模块List
+    isIndeterminate:
+      excelModuleList.length < excelsList.length &&
+      excelModuleList.length > 0, //半选
+  };
+}
+// 学科质量模块
+function subjectQualityModule() {
+  return {
+    menuInfo: "subjectQuality",
+    menuValue: "学科质量",
+    checkAll: false, //全选
+    excelModuleList: [], //选中的模块
+    excelsList: [
+      //所有模块List
+      {
+        label: "学科质量分析表",
+        value: "subjectQuality",
+      },
+    ],
+    isIndeterminate: false, //半选
+  };
+}
+// G组分析模块
+async function groupGAnalysisModule() {
+  let excelsList = [];
+  if (filterObject.value.subjectGroupType === 1) {
+    excelsList.push(
+      {
+        label: "G组科目分析表",
+        value: "gGroupSubject",
+      },
+      {
+        label: "G组科目学生分布分析表",
+        value: "gGroupSubjectStudent",
+      },
+      {
+        label: `G组${
+          filterObject.value.schoolLevel == 2 ? "班级" : "学校"
+        }分析表`,
+        value:
+          filterObject.value.schoolLevel == 2
+            ? "gGroupClass"
+            : "gGroupSchool",
+      },
+    );
+  } else {
+    excelsList.push(
+      {
+        label: `G组${
+          filterObject.value.schoolLevel == 2 ? "班级" : "学校"
+        }分析表`,
+        value:
+          filterObject.value.schoolLevel == 2
+            ? "gGroupClass"
+            : "gGroupSchool",
+      },
+      {
+        label: "G组小题分析表",
+        value: "gGroupQuestionAnalysis",
+      },
+    );
+    const res = await api.reportSchool.queryQuestionGroup({
+      subjectId: filterObject.value.subjectId,
+    });
+    if (res.code == 200) {
+      const resData = res.data || [];
+      const data = resData.map((item) => {
+        return {
+          label: `G组${item.name}表`,
+          value: `questionGroupAnalysis${item.code}`,
+        };
+      });
+      excelsList.push(...data);
+    }
+  }
+  return {
+    menuInfo: "groupGAnalysis",
+    menuValue: "G组分析",
+    checkAll: false, //全选
+    excelModuleList: [], //选中的模块
+    excelsList: excelsList, //所有模块List
+    isIndeterminate: false, //半选
+  };
+}
+// 四分位数模块
+function quartileModule() {
+  return {
+    menuInfo: "quartile",
+    menuValue: "四分位数",
+    checkAll: false, //全选
+    excelModuleList: [], //选中的模块
+    excelsList: [
+      //所有模块List
+      {
+        label: "四分位分析表",
+        value: "queryQuartile",
+      },
+    ],
+    isIndeterminate: false, //半选
+  };
+}
+// 试卷分析模块
+async function paperAnalysisModule() {
+  let excelsList = [
+    {
+      label: "小题分析表",
+      value: "questionAnalysis",
+    },
+    {
+      label: "题型分析表",
+      value: "questionGroupAnalysis11",
+    },
+    {
+      label: "错题分析表",
+      value: "errorQuestionAnalysis",
+    },
+    {
+      label: "客观题分析表",
+      value: "selectQuestionAnalysis",
+    },
+    {
+      label: "命题分析表",
+      value: "propositionAnalysis",
+    },
+  ];
+  let excelModuleList = [
+    "questionAnalysis",
+    "errorQuestionAnalysis",
+    "selectQuestionAnalysis",
+  ];
+  const res = await api.reportSchool.queryQuestionGroup({
+    subjectId: filterObject.value.subjectId,
+  });
+  if (res.code == 200) {
+    const resData = res.data || [];
+    let data = [];
+    resData.forEach((item) => {
+      data.push({
+        label: `${item.name}表`,
+        value: `questionGroupAnalysis${item.code}`,
+      });
+      excelModuleList.push(`questionGroupAnalysis${item.code}`);
+    });
+    excelsList.splice(1, 0, ...data);
+  }
+  if (filterObject.value.schoolLevel == 2) {
+    excelsList.splice(excelsList.length - 2, 0, {
+      label: "学生作答明细表",
+      value: "studentAnsweringDetail",
+    });
+    excelModuleList.push("studentAnsweringDetail");
+  }
+  return {
+    menuInfo: "paperAnalysis",
+    menuValue: "试卷分析",
+    checkAll: excelModuleList.length == excelsList.length, //全选
+    excelModuleList: excelModuleList, //选中的模块
+    excelsList: excelsList, //所有模块List
+    isIndeterminate:
+      excelModuleList.length < excelsList.length &&
+      excelModuleList.length > 0, //半选
+  };
+}
+function handleCheckAllChange(val, module) {
+  module.excelModuleList = val
+    ? module.excelsList.map((item) => item.value)
+    : [];
+  module.isIndeterminate = false;
+}
+function handleexcelModuleListChange(value, module) {
+  let checkedCount = value.length;
+  module.checkAll = checkedCount === module.excelsList.length;
+  module.isIndeterminate =
+    checkedCount > 0 && checkedCount < module.excelsList.length;
+}
+// 批量导出
+function batchExport() {
+  // 判断是否所有模块的 excelModuleList 都为空
+  const hasSelected = excelModuleList.value.some(
+    (item) => item.excelModuleList && item.excelModuleList.length > 0,
+  );
+
+  if (!hasSelected) {
+    ElMessage.warning("请至少勾选一个需要下载的表格!");
+    return;
+  }
+  segmentData.title = "批量下载";
+  segmentData.description = "关闭后可在下载记录中查询";
+  exportLoading.value = true;
+  segmentData.see = true;
+  loading.value = true;
+  const exportList = excelModuleList.value.map((item) => ({
+    menuInfo: item.menuInfo,
+    menuValue: item.menuValue,
+    excelModuleList: item.excelModuleList,
+  }));
+  downloadExamFileList(exportList);
+}
+// 单个下载
+function singleExport(module) {
+  if (module.excelModuleList.length == 0) {
+    ElMessage.warning("请至少勾选一个需要下载的表格!");
+    return;
+  }
+  segmentData.title = "下载";
+  segmentData.description = "关闭后可在下载记录中查询";
+  segmentData.see = true;
+  loading.value = true;
+  const exportList = [
+    {
+      menuInfo: module.menuInfo,
+      menuValue: module.menuValue,
+      excelModuleList: module.excelModuleList,
+    },
+  ];
+  downloadExamFileList(exportList);
+}
+// 导出选择的excel模块,生成打包任务
+function downloadExamFileList(excelModuleList) {
+  let schoolName = null;
+  if (filterObject.value.schoolLevel == 0) {
+    schoolName = "联校";
+  } else if (filterObject.value.schoolLevel == 1) {
+    schoolName = filterObject.value.schoolGroupName;
+  } else {
+    schoolName = filterObject.value.schoolName;
+  }
+  api.reportSchool
+    .downloadExamFileList({
+      excelModuleList: excelModuleList,
+      examCommonSelectDTO: {
+        ...filterObject.value,
+        schoolName: schoolName,
+        lastExamId: lastExamId.value,
+        contrastExamIds: contrastExamIds.value,
+        pageParam: {
+          pageSize: 10,
+          pageNum: 1,
+        },
+      },
+    })
+    .then((res) => {
+      if (res.code == 200) {
+        ElMessage.success("下载成功");
+        // 任务提交成功后,开始每10秒轮询一次
+        startPolling();
+      } else {
+        ElMessage.error(res.msg || "下载失败");
+      }
+    })
+    .finally(() => {
+      exportLoading.value = false;
+      loading.value = false;
+    });
+}
+// 启动轮询
+function startPolling() {
+  // 防止重复创建定时器
+  if (downloadTimer) return;
+  // 立即执行一次,然后每10秒执行一次
+  findByExamIdAndSchoolId();
+  downloadTimer = setInterval(() => {
+    findByExamIdAndSchoolId();
+  }, 5000);
+}
+// 停止轮询
+function stopPolling() {
+  if (downloadTimer) {
+    clearInterval(downloadTimer);
+    downloadTimer = null;
+  }
+}
+// 根据考试ID和学校ID查询下载任务列表
+function findByExamIdAndSchoolId() {
+  api.reportSchool
+    .findByExamIdAndSchoolId(filterObject.value.examId)
+    .then((res) => {
+      if (res.code == 200) {
+        examDownloadList.value = res.data || [];
+        // 当列表不为空时,检查是否所有任务都已完成
+        if (examDownloadList.value.length > 0) {
+          const isAllCompleted = examDownloadList.value.every((item) => {
+            // 进度达到100,或者任务状态为失败(2),均视为任务结束
+            return item.progress >= 100 || item.generationStatus == 2;
+          });
+
+          // 如果所有任务都结束,则停止轮询
+          if (isAllCompleted) {
+            stopPolling();
+          }
+        }
+      } else {
+        examDownloadList.value = [];
+      }
+    });
+}
+// 显示下载记录弹框
+function showDownloadDialog() {
+  segmentData.title = "下载记录";
+  segmentData.description =
+    "以下为最近7天下载的数据,根据数据量大小,某些需要生成10分钟左右,请您耐心等待";
+  segmentData.see = true;
+  // 任务提交成功后,开始每10秒轮询一次
+  startPolling();
+}
+// 关闭下载记录弹框
+function CloseDownloadDialog() {
+  segmentData.see = false;
+  stopPolling();
+}
+// 处理下载点击事件
+function handleDownloadClick(generationStatus, id, filePath, fileName) {
+  if (generationStatus == 2) return;
+  if (generationStatus == 0) {
+    // 取消下载
+    ElMessageBox.confirm("此操作将永久删除该文件, 是否继续?", "提示", {
+      confirmButtonText: "确定",
+      cancelButtonText: "取消",
+      type: "warning",
+    }).then(() => {
+      api.reportSchool.downloadDeleteById(id).then((res) => {
+        if (res.code == 200) {
+          ElMessage.success("取消下载成功");
+          // 刷新下载任务列表
+          findByExamIdAndSchoolId();
+        } else {
+          ElMessage.error(res.msg || "取消下载失败");
+        }
+      });
+    });
+  } else {
+    // 下载成功,执行文件下载
+    if (!filePath) {
+      ElMessage.warning("文件地址不存在,请稍后再试!");
+      return;
+    }
+    const newFileName = fileName.replace(/\s/g, ""); //清除字符串所有空格
+    const finalFileName =
+      newFileName && newFileName.includes(".")
+        ? newFileName
+        : `${newFileName || "报表下载"}.zip`;
+
+    // 使用 fetch 获取文件流,解决跨域导致 download 属性失效的问题
+    fetch(filePath)
+      .then((res) => {
+        if (!res.ok) throw new Error("网络请求失败");
+        return res.blob();
+      })
+      .then((blob) => {
+        // 创建 Blob URL
+        const url = window.URL.createObjectURL(blob);
+        const link = document.createElement("a");
+        link.href = url;
+        link.download = finalFileName; // 此时 download 属性对 Blob URL 必定生效
+        link.style.display = "none";
+
+        document.body.appendChild(link);
+        link.click();
+        document.body.removeChild(link);
+
+        // 释放 Blob 对象,释放内存
+        window.URL.revokeObjectURL(url);
+      })
+      .catch((err) => {
+        // console.error("下载失败:", err);
+        ElMessage.error("文件下载失败!");
+      });
+  }
+}
+
+onBeforeUnmount(() => {
+  stopPolling();
+});
+</script>
+<style lang="scss" scoped>
+:deep(.module_title) {
+  padding: 20px 0;
+
+  .title_left {
+    flex-direction: column;
+    justify-content: center;
+    align-items: flex-start !important;
+
+    span {
+      &:nth-child(1) {
+        font-weight: 600;
+        font-size: 16px;
+        color: #333333;
+        line-height: 22px;
+      }
+
+      &:nth-child(2) {
+        margin-top: 8px;
+        font-weight: 400;
+        font-size: 12px;
+        color: #999999;
+        line-height: 17px;
+      }
+    }
+  }
+
+  .title_right {
+    .button_task_select {
+      height: 36px;
+      background-color: #2e64fa;
+      color: #ffffff;
+
+      :deep(.el-icon-loading) {
+        color: #ffffff;
+      }
+    }
+  }
+}
+
+.module_content {
+  justify-content: flex-start !important;
+  padding-bottom: 20px;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20px;
+  min-height: 400px;
+
+  .item_content {
+    width: calc((100% - 80px) / 5);
+    padding: 16px;
+    box-sizing: border-box;
+    background: linear-gradient(170deg, #ebf2ff 10%, #ffffff 50%, #ffffff 100%);
+    border-radius: 10px;
+    border: 1px solid #e1e2e7;
+
+    .item_content_title {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding-bottom: 12px;
+      border-bottom: 1px solid #e1e2e7;
+
+      .content_title_left {
+        display: flex;
+        align-items: center;
+        flex: 1;
+        overflow: hidden;
+
+        img {
+          width: 20px;
+          height: 20px;
+          margin-right: 4px;
+          flex-shrink: 0;
+        }
+
+        span {
+          font-weight: 600;
+          font-size: 16px;
+          color: #333333;
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+      }
+
+      .content_title_right {
+        font-weight: 500;
+        font-size: 14px;
+        color: #2e64fa;
+        cursor: pointer;
+        flex-shrink: 0;
+      }
+    }
+    .item_content_list {
+      margin-top: 12px;
+      display: flex;
+      flex-direction: column;
+      :deep(.el-checkbox-group) {
+        display: flex;
+        flex-direction: column;
+        .el-checkbox {
+          margin-top: 12px;
+          margin-right: 0;
+          white-space: normal;
+          display: flex;
+          align-items: flex-start; /* 关键:让复选框和文本顶部对齐 */
+          .el-checkbox__label {
+            display: block; /* 允许文本换行 */
+            word-break: break-word; /* 长文本自动换行 */
+            line-height: 1.2; /* 调整行高,避免拥挤 */
+            color: #333333;
+            font-weight: 400;
+          }
+        }
+      }
+    }
+  }
+}
+// 下载记录弹框
+.dialog_description {
+  font-weight: 400;
+  font-size: 14px;
+  color: #000000;
+}
+.dialog_content {
+  width: 100%;
+  height: 390px;
+  overflow-y: auto;
+  margin: 16px 0 20px;
+  display: flex;
+  flex-direction: column;
+  .list_item {
+    display: flex;
+    width: 100%;
+    padding: 16px;
+    margin-top: 20px;
+    border-radius: 10px;
+    border: 1px solid #ebeef5;
+    box-sizing: border-box;
+    justify-content: space-between;
+    &:first-child {
+      margin-top: 0;
+    }
+    .list_item_left {
+      display: flex;
+      align-items: center;
+      flex: 1;
+      margin-right: 38px;
+      img {
+        width: 48px;
+        height: 48px;
+        margin-right: 10px;
+        flex-shrink: 0;
+      }
+      .excel_info {
+        flex: 1;
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
+        .excel_title {
+          font-weight: 500;
+          font-size: 14px;
+          color: #333333;
+          line-height: 20px;
+        }
+        :deep(.el-progress) {
+          margin-top: 8px;
+          .el-progress__text {
+            font-weight: 500;
+            font-size: 14px;
+          }
+        }
+      }
+    }
+    .list_item_right {
+      display: flex;
+      flex-direction: column;
+      align-items: flex-end;
+      justify-content: center;
+      .download_time {
+        font-weight: 400;
+        font-size: 12px;
+        color: #999999;
+        line-height: 17px;
+      }
+      .download_button {
+        font-weight: 400;
+        font-size: 14px;
+        color: #2e64fa;
+        cursor: pointer;
+        margin-top: 11px;
+        &.failed {
+          color: #f56c6c;
+          cursor: default;
+        }
+      }
+    }
+  }
+}
+</style>

+ 137 - 10
src/views/analysis/score.vue

@@ -4,7 +4,7 @@
     ref="reportModuleRef"
     :showTitle="false"
     :showDescribe="false"
-    :showPrintBtn="false"
+    :showPrintBtn="true"
     tableOrChart="table"
     :currentPage="state.pageInfo.pageNum"
     :pageSize="state.pageInfo.pageSize"
@@ -13,6 +13,7 @@
     @ChangePageSize="ChangePageSize"
     @ChangeCurrentPage="ChangeCurrentPage"
     @ExportExcel="ExportExcel"
+    @PrintPdf="PrintPdf"
   >
     <template #title_left>
       <el-input
@@ -139,11 +140,53 @@
     :pageTitle="state.paperTitle"
     @updateModelValue="UpdateModelValue"
   ></StudentPaper>
+  <ScorePdf ref="scorePdfRef"></ScorePdf>
+  <div class="page_dialog padding_0">
+    <el-dialog
+      title="选择班级"
+      center
+      v-model="showClassPop"
+      width="480px"
+    >
+      <div class="dialog_center padding_20 page_search">
+        <div class="select_check_content score_class_popup">
+          <el-checkbox
+            :indeterminate="isIndeterminate"
+            v-model="checkAll"
+            @change="HandleCheckAllChange"
+            >全部</el-checkbox
+          >
+          <el-checkbox-group
+            v-model="selectClassList"
+            @change="HandleCheckedClassChange"
+          >
+            <el-checkbox
+              :value="item.classIdCode"
+              v-for="item in classList"
+              :title="item.className"
+              >{{ TruncateMiddleText(item.className, 5, 3, 1) }}</el-checkbox
+            >
+          </el-checkbox-group>
+        </div>
+      </div>
+      <div slot="footer" class="dialog__footer dialog-footer">
+        <el-button size="small" @click="showClassPop = false">取 消</el-button>
+        <el-button
+          size="small"
+          type="primary"
+          @click="ConfirmEnter()"
+          :loading="buttonLoading"
+          >确 定</el-button
+        >
+      </div>
+    </el-dialog>
+  </div>
 </template>
 
 <script lang="ts" setup>
 import ReportModule from "@/components/ReportModule.vue";
 import StudentPaper from "@/components/StudentPaper.vue"; //学生答题卡组件
+import ScorePdf from "@/components/ScorePdf.vue";
 import {
   studentTranscriptTitle,
   queryJointStudentStatistics,
@@ -153,7 +196,8 @@ import {
 import { useAnalysisStore } from "@/store/analysis";
 import { downloadExcel } from "@/utils/exportExcel";
 import { Search } from "@element-plus/icons-vue";
-import {loadingSvgIcon} from "@/utils/common";
+import { loadingSvgIcon, truncateMiddleText } from "@/utils/common";
+import { ElMessage } from "element-plus";
 import {
   nextTick,
   ref,
@@ -240,6 +284,16 @@ const state = reactive<State>({
 });
 const tableRef = ref<any>(null);
 const reportModuleRef = ref<any>(null);
+const scorePdfRef = ref<any>(null);//Pdf组件  
+const showClassPop = ref<boolean>(false); //选择班级弹框
+const isIndeterminate = ref<boolean>(false); //是否班级全选半选
+const checkAll = ref<boolean>(false); //班级全选
+const selectClassList = ref<string[]>([]); //选中的班级
+const buttonLoading = ref<boolean>(false); //按钮loading
+const classList = computed(() => {//班级列表
+  const classNameList = analysisStore.filtersData.find((item: any) => item.type === "className")?.list || [];
+  return classNameList.filter((item: any) => item.classLevel == 2)
+});
 /** 获取表头 */
 const GetStudentTranscriptTitle = async () => {
   if (!analysisStore.filterObject) return;
@@ -257,14 +311,14 @@ const GetStudentTranscriptTitle = async () => {
         res.data?.title?.dynamicsHeaderData?.groupDataTitle || [];
       state.scoreDataTitleData =
         res.data?.title?.dynamicsHeaderData?.scoreDataTitle || [];
-      let checkList = [];  
-      if(state.groupDataTitleData.length){
-        checkList.push('group');
+      let checkList = [];
+      if (state.groupDataTitleData.length) {
+        checkList.push("group");
       }
-      if(state.scoreDataTitleData.length){
-        checkList.push('question');
+      if (state.scoreDataTitleData.length) {
+        checkList.push("question");
       }
-      state.checkList = checkList.length?[checkList[0]]:[];
+      state.checkList = checkList.length ? [checkList[0]] : [];
     }
   } catch (err) {
     console.error("获取表头失败:", err);
@@ -312,8 +366,8 @@ const GetTableData = async (isInit: boolean = false) => {
     state.tableLoading = false;
     // 数据加载完成后重置滚动
     nextTick(() => {
-      TableScrollTop()
-      if(isInit){
+      TableScrollTop();
+      if (isInit) {
         SetTableHeight();
       }
     });
@@ -364,6 +418,79 @@ const ExportExcel = () => {
     reportModuleRef.value?.SetExportLoading?.(false);
   });
 };
+// 打印PDF
+const PrintPdf = () => {
+  selectClassList.value = [];//清空选择的数据
+  isIndeterminate.value = false;//默认不半选
+  buttonLoading.value = false;
+  if(analysisStore?.filterObject?.classLevel===0){//年级
+    selectClassList.value = classList.value.map((item: any) => item.classIdCode);
+    checkAll.value = true;//默认全选
+    showClassPop.value = true;//显示弹框
+  }else{
+    checkAll.value = false;//默认不全选
+    showClassPop.value = false;//不显示弹框
+    reportModuleRef.value?.SetPrintLoading?.(true);
+    const classIdCode = analysisStore?.filterObject?.classIdCode;
+    selectClassList.value = classIdCode? [classIdCode] : [];
+    scorePdfRef.value?.LoadAndExportPdf(state.keyWord, selectClassList.value)
+      .finally(() => {
+        reportModuleRef.value?.SetPrintLoading?.(false);
+      });//加载并导出PDF
+  }
+};
+//班级全选
+const HandleCheckAllChange = () => {
+  if (checkAll.value) {
+    selectClassList.value = classList.value.map((item: any) => item.classIdCode);
+  } else {
+    selectClassList.value = [];
+  }
+};
+//班级选择改变事件
+const HandleCheckedClassChange = () => {
+  if (selectClassList.value.length > 0) {
+    checkAll.value = true;
+    if (selectClassList.value.length == classList.value.length) {
+      isIndeterminate.value = false;
+    } else {
+      isIndeterminate.value = true;
+    }
+  } else {
+    checkAll.value = false;
+    isIndeterminate.value = false;
+  }
+};
+const TruncateMiddleText = (
+  text: string,
+  num: number,
+  startStr: number,
+  endStr: number,
+) => {
+  return truncateMiddleText(text, num, startStr, endStr);
+};
+//确定选择班级
+const ConfirmEnter = () => {
+  if (selectClassList.value.length == 0) {
+    ElMessage.warning("请至少选择一个班级!");
+    return;
+  }
+  buttonLoading.value = true;
+  setTimeout(() => {
+    showClassPop.value = false;//先关闭弹框,不等PDF生成完成
+  }, 500);
+  scorePdfRef.value?.LoadAndExportPdf(state.keyWord, selectClassList.value)//加载并导出PDF
+    .then(() => {
+      ElMessage.success("成绩单已在新页面打开,请去下载或打印");
+    })
+    .catch((err) => {
+      console.error("导出成绩单PDF失败:", err);
+      ElMessage.error("导出PDF失败,请重试");
+    })
+    .finally(() => {
+      buttonLoading.value = false;
+    });
+};
 //打开答题卡弹窗
 const OpenStudentPaper = (row, prop) => {
   state.paperInfo = {

+ 2 - 0
src/views/exam/components/stepItem.vue

@@ -86,6 +86,7 @@ const menuList = computed(() => {
         { name: '分组分析',number: '10', path: 'groupAnalysis' },
         { name: '选项分析',number: '11', path: 'optionAnalysis' },
         { name: '命题分析',number: '12', path: 'propositionAnalysis' },
+        { name: '报表下载',number: '13', path: 'reportDownload' }
       ];
     }else{
       analysisMenu = [
@@ -97,6 +98,7 @@ const menuList = computed(() => {
         { name: '小题分析',number: '9', path: 'questionAnalysis' },
         { name: '选项分析',number: '11', path: 'optionAnalysis' },
         { name: '命题分析',number: '12', path: 'propositionAnalysis' },
+        { name: '报表下载',number: '13', path: 'reportDownload' }
       ];
     }
     list = [

+ 1 - 1
vite.config.ts

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