|
|
@@ -0,0 +1,2347 @@
|
|
|
+<template>
|
|
|
+ <div :key="dialogKey">
|
|
|
+ <el-dialog title="导出个性化错题手册" class="page_full_dialog" :visible.sync="showDialog" fullscreen height="100%" >
|
|
|
+ <div class="score_slip">
|
|
|
+ <div class="page_jg_20"></div>
|
|
|
+ <div class="print_slip" ref="printSlip" >
|
|
|
+ <div class="slip_content" v-loading="loading" element-loading-text="正在努力加载中,请稍候……" element-loading-spinner="el-icon-loading" element-loading-background="#ffffff">
|
|
|
+ <div class="area_page" v-for="(page,pageIndex) in pageList" :key="'slip_'+pageIndex" :style="{width:pageInfo.width+'px',height:pageInfo.height+'px'}">
|
|
|
+ <!-- 页眉 每页都有 -->
|
|
|
+ <div class="page_header_info">
|
|
|
+ <span>{{studentName}} {{className}}</span>
|
|
|
+ <span>{{schoolName}}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 固定内容区域 -->
|
|
|
+ <div class="page_area_content">
|
|
|
+ <!-- 一栏布局 -->
|
|
|
+ <template v-if="columnRadio==1">
|
|
|
+ <template v-for="(questionItem,index) in page.list">
|
|
|
+ <!-- 切片包裹层:超高块切片时按切片高度裁剪 正常块不受影响 -->
|
|
|
+ <div :key="'w_'+index" :style="questionItem.isSlice ? {height: questionItem.sliceHeight + 'px', overflow: 'hidden'} : {}">
|
|
|
+ <div :style="questionItem.isSlice ? {'margin-top': -questionItem.offsetTop + 'px'} : {}">
|
|
|
+ <!-- 错题本顶部标题 区域 -->
|
|
|
+ <div class="page_title_info" v-if="questionItem.areaType=='pageTitle'">
|
|
|
+ {{ questionItem.title }}
|
|
|
+
|
|
|
+ </div>
|
|
|
+ <!-- 标题下方间距:用实体间隔块(高度计入测量与切片) 不用margin-bottom(PDF截图/切片包裹层会丢失) -->
|
|
|
+ <div class="page_jg_10" v-if="questionItem.areaType=='pageTitle'"></div>
|
|
|
+ <!-- 知识点标题区 -->
|
|
|
+ <div class="page_knowledge_info" v-if="questionItem.areaType=='knowledge'">
|
|
|
+ <span class="knowledge_name">{{questionItem.knowledgeName}}</span>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10" v-if="questionItem.areaType=='knowledge'"></div>
|
|
|
+ <!-- 题目内容区 -->
|
|
|
+ <div class="question_item" v-if="questionItem.areaType=='question'" >
|
|
|
+ <div class="item_header_one">
|
|
|
+ <div class="header_left">
|
|
|
+ <span class="left_question_num">错题{{questionItem.questionNum}}</span>
|
|
|
+ <span class="left_question_source">来源:{{questionItem.sourceName}}</span>
|
|
|
+ </div>
|
|
|
+ <div class="header_right">
|
|
|
+ <span v-if="questionItem.answerScore">
|
|
|
+ 满分:{{questionItem.answerScore}}
|
|
|
+ </span>
|
|
|
+ <span v-if="questionItem.score">
|
|
|
+ 得分:{{questionItem.score}}
|
|
|
+ </span>
|
|
|
+ <span>
|
|
|
+ 得分率:{{questionItem.scoreRate}}%
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10">
|
|
|
+ </div>
|
|
|
+ <div class="item_question_title" v-html="questionItem.title">
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10">
|
|
|
+ </div>
|
|
|
+ <div class="item_question_knowledge">
|
|
|
+ <span class="knowledge_title">考查知识点:</span><span class="knowledge_list">{{(questionItem.knowledgeList || []).map(k => k.knowName).filter(Boolean).join('、')}}</span>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10">
|
|
|
+ </div>
|
|
|
+
|
|
|
+
|
|
|
+ </div>
|
|
|
+ <!-- 学生作答区 -->
|
|
|
+ <div v-if="questionItem.areaType=='studentAnswer'">
|
|
|
+ <div class="item_question_answer" >
|
|
|
+ <div class="answer_title">我的作答</div>
|
|
|
+ <!-- 客观题 直接显示学生选择的答案 -->
|
|
|
+ <div class="answer_content" v-html="questionItem.studentAnswer" v-if="questionItem.isObjective==1">
|
|
|
+
|
|
|
+ </div>
|
|
|
+ <!-- 主观题 显示学生作答的图片 -->
|
|
|
+ <!-- <div class="answer_images" v-if="questionItem.isObjective==0">
|
|
|
+ <img v-for="(image, imageIndex) in questionItem.studentAnswer.split(';')" :key="'image_'+imageIndex" :src="image" alt="">
|
|
|
+ </div> -->
|
|
|
+ <!-- 主观题:只有真正的图片地址才渲染成 img;文本作答(如 isObjective 未标对但内容是"C")按文本显示,避免塞进 img src 产生 404 -->
|
|
|
+ <div class="answer_images" v-if="questionItem.isObjective==0">
|
|
|
+ <img v-for="(image, imageIndex) in AnswerImages(questionItem.studentAnswer)" :key="'image_'+imageIndex" :src="image" alt="">
|
|
|
+ <div class="answer_content" v-if="AnswerText(questionItem.studentAnswer)" v-html="AnswerText(questionItem.studentAnswer)"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 答案解析区 -->
|
|
|
+ <div v-if="questionItem.areaType=='answerAnalysis'">
|
|
|
+ <div class="item_question_answer" v-if="questionItem.parse">
|
|
|
+ <div class="answer_title">答案解析</div>
|
|
|
+ <div class="answer_content" >
|
|
|
+ <span>【答案】:</span>
|
|
|
+ <span v-html="questionItem.answer"></span>
|
|
|
+ </div>
|
|
|
+ <div class="answer_content" v-html="questionItem.parse">
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
+ <!-- 两栏布局:一页分两半 先填左栏再填右栏 -->
|
|
|
+ <template v-else>
|
|
|
+ <!-- 通栏标题区(错题本顶部标题 / 卷底答案解析标题) -->
|
|
|
+ <template v-for="(fullItem,fullIdx) in page.fullList">
|
|
|
+ <div class="page_title_info" v-if="fullItem.areaType=='pageTitle'" :key="'full_'+fullIdx">
|
|
|
+ {{ fullItem.title }}
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10" v-if="fullItem.areaType=='pageTitle'" :key="'full_gap_'+fullIdx"></div>
|
|
|
+ </template>
|
|
|
+ <div class="page_columns_wrap">
|
|
|
+ <div class="page_column" v-for="(colList,colIdx) in page.columns" :key="'col_'+colIdx">
|
|
|
+ <template v-for="(questionItem,index) in colList">
|
|
|
+ <!-- 切片包裹层:超高块切片时按切片高度裁剪 正常块不受影响 -->
|
|
|
+ <div :key="'w_'+index" :style="questionItem.isSlice ? {height: questionItem.sliceHeight + 'px', overflow: 'hidden'} : {}">
|
|
|
+ <div :style="questionItem.isSlice ? {'margin-top': -questionItem.offsetTop + 'px'} : {}">
|
|
|
+ <!-- 知识点标题区 -->
|
|
|
+ <div class="page_knowledge_info" v-if="questionItem.areaType=='knowledge'" :key="'k_'+index">
|
|
|
+ <span class="knowledge_name">{{questionItem.knowledgeName}}</span>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10" v-if="questionItem.areaType=='knowledge'" :key="'kg_'+index"></div>
|
|
|
+ <!-- 题目内容区 -->
|
|
|
+ <div class="question_item" v-if="questionItem.areaType=='question'" :key="'q_'+index">
|
|
|
+ <div class="item_header_two">
|
|
|
+ <div class="header_left">
|
|
|
+ <span class="left_question_num">错题{{questionItem.questionNum}}</span>
|
|
|
+ <span class="left_question_source">
|
|
|
+ <span v-if="questionItem.answerScore">
|
|
|
+ 满分:{{questionItem.answerScore}}
|
|
|
+ </span>
|
|
|
+ <span v-if="questionItem.score">
|
|
|
+ 得分:{{questionItem.score}}
|
|
|
+ </span>
|
|
|
+ <span>
|
|
|
+ 得分率:{{questionItem.scoreRate}}%
|
|
|
+ </span>
|
|
|
+ </span>
|
|
|
+
|
|
|
+ </div>
|
|
|
+ <div class="header_right">
|
|
|
+ <span class="left_question_source">来源:{{questionItem.sourceName}}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10">
|
|
|
+ </div>
|
|
|
+ <div class="item_question_title" v-html="questionItem.title">
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10">
|
|
|
+ </div>
|
|
|
+ <div class="item_question_knowledge">
|
|
|
+ <span class="knowledge_title">考查知识点:</span><span class="knowledge_list">{{(questionItem.knowledgeList || []).map(k => k.knowName).filter(Boolean).join('、')}}</span>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 学生作答区:作答为空也要显示"我的作答"空框(与单栏/测量容器一致 不加空值判断,否则实测高度比测量少一个空框高导致页高失真) -->
|
|
|
+ <div v-if="questionItem.areaType=='studentAnswer'" :key="'a_'+index">
|
|
|
+ <div class="item_question_answer">
|
|
|
+ <div class="answer_title">我的作答</div>
|
|
|
+ <!-- 客观题 直接显示学生选择的答案 -->
|
|
|
+ <div class="answer_content" v-html="questionItem.studentAnswer" v-if="questionItem.isObjective==1">
|
|
|
+
|
|
|
+ </div>
|
|
|
+ <!-- 主观题 显示学生作答的图片 -->
|
|
|
+ <!-- <div class="answer_images" v-if="questionItem.isObjective==0">
|
|
|
+ <img v-for="(image, imageIndex) in questionItem.studentAnswer.split(';')" :key="'image_'+imageIndex" :src="image" alt="">
|
|
|
+ </div> -->
|
|
|
+ <!-- 主观题:只有真正的图片地址才渲染成 img;文本作答按文本显示,避免塞进 img src 产生 404 -->
|
|
|
+ <div class="answer_images" v-if="questionItem.isObjective==0">
|
|
|
+ <img v-for="(image, imageIndex) in AnswerImages(questionItem.studentAnswer)" :key="'image_'+imageIndex" :src="image" alt="">
|
|
|
+ <div class="answer_content" v-if="AnswerText(questionItem.studentAnswer)" v-html="AnswerText(questionItem.studentAnswer)"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 答案解析区 -->
|
|
|
+ <div v-if="questionItem.areaType=='answerAnalysis'" :key="'p_'+index">
|
|
|
+ <div class="item_question_answer" v-if="questionItem.parse">
|
|
|
+ <div class="answer_title">答案解析</div>
|
|
|
+ <div class="answer_content" >
|
|
|
+ <span>【答案】:</span>
|
|
|
+ <span v-html="questionItem.answer"></span>
|
|
|
+ </div>
|
|
|
+ <div class="answer_content" v-html="questionItem.parse">
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 页脚 每页都有 -->
|
|
|
+ <div class="page_footer_info">
|
|
|
+ <span>第 {{pageIndex+1}} 页</span>
|
|
|
+ <span>共 {{pageList.length}} 页</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="slip_set_box">
|
|
|
+ <div class="set slip_set">
|
|
|
+ <el-radio-group v-model="answerRadio" @change="HandleAnswerCheckChange">
|
|
|
+ <el-radio :label="0">答案解析在卷底</el-radio>
|
|
|
+ <el-radio :label="1">答案解析在每题后</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </div>
|
|
|
+ <div class="set slip_sets">
|
|
|
+ <el-radio-group v-model="columnRadio" @change="HandleAnswerCheckChange">
|
|
|
+ <el-radio :label="1">一栏</el-radio>
|
|
|
+ <el-radio :label="2">二栏</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </div>
|
|
|
+ <div class="set slip_set_rank">
|
|
|
+ <el-checkbox-group v-model="answerCheck" @change="HandleAnswerCheckChange">
|
|
|
+ <el-checkbox :label="1">我的作答</el-checkbox>
|
|
|
+ <el-checkbox :label="2">答案解析</el-checkbox>
|
|
|
+
|
|
|
+ </el-checkbox-group>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="slip_close" @click="showDialog=false">
|
|
|
+ 关闭
|
|
|
+ </div>
|
|
|
+ <div class="slip_print" @click="DownloadPdfs()">
|
|
|
+ 下载PDF (共{{pageList.length}}页)
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 离线测量容器:与正式页等宽同样式,但不可见;两栏时按单栏宽度测量 -->
|
|
|
+ <div ref="measureBox" class="measure_box" :style="{width: measureBoxWidth+'px'}">
|
|
|
+ <template v-for="(mItem, mIdx) in measureList">
|
|
|
+ <!-- 错题本顶部标题 区域 外包一层:标题+间距块整体作为测量容器的一个子元素 保持与 measureList 一一对应 -->
|
|
|
+ <div v-if="mItem.areaType=='pageTitle'" :key="'mt_'+mIdx">
|
|
|
+ <div class="page_title_info">
|
|
|
+ {{ mItem.title }}
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10"></div>
|
|
|
+ </div>
|
|
|
+ <div v-if="mItem.areaType=='knowledge'" :key="'mk_'+mIdx">
|
|
|
+ <div class="page_knowledge_info">
|
|
|
+ <span>{{mItem.knowledgeName}}</span>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10"></div>
|
|
|
+ </div>
|
|
|
+ <div class="question_item" v-if="mItem.areaType=='question'" :key="'mq_'+mIdx">
|
|
|
+ <!-- 测量容器必须与正式渲染用同一套标题结构与样式(单栏/两栏连 DOM 结构都不同),否则测量高度与实际渲染不一致导致页高失真 -->
|
|
|
+ <!-- 单栏结构:左侧错题号+来源 右侧满分/得分/得分率 -->
|
|
|
+ <div class="item_header_one" v-if="columnRadio != 2">
|
|
|
+ <div class="header_left">
|
|
|
+ <span class="left_question_num">错题{{mItem.questionNum}}</span>
|
|
|
+ <span class="left_question_source">来源:{{mItem.sourceName}}</span>
|
|
|
+ </div>
|
|
|
+ <div class="header_right">
|
|
|
+ <span v-if="mItem.answerScore">满分:{{mItem.answerScore}}</span>
|
|
|
+ <span v-if="mItem.score">得分:{{mItem.score}}</span>
|
|
|
+ <span>得分率:{{mItem.scoreRate}}%</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 两栏结构:第一行错题号+满分/得分/得分率 第二行来源(与两栏正式渲染一致) -->
|
|
|
+ <div class="item_header_two" v-else>
|
|
|
+ <div class="header_left">
|
|
|
+ <span class="left_question_num">错题{{mItem.questionNum}}</span>
|
|
|
+ <span class="left_question_source">
|
|
|
+ <span v-if="mItem.answerScore">满分:{{mItem.answerScore}}</span>
|
|
|
+ <span v-if="mItem.score">得分:{{mItem.score}}</span>
|
|
|
+ <span>得分率:{{mItem.scoreRate}}%</span>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="header_right">
|
|
|
+ <span class="left_question_source">来源:{{mItem.sourceName}}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10"></div>
|
|
|
+ <div class="item_question_title" v-html="mItem.title"></div>
|
|
|
+ <div class="page_jg_10"></div>
|
|
|
+ <div class="item_question_knowledge">
|
|
|
+ <span class="knowledge_title">考查知识点:</span>
|
|
|
+ <span class="knowledge_list">{{(mItem.knowledgeList || []).map(k => k.knowName).filter(Boolean).join('、')}}</span>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10"></div>
|
|
|
+ </div>
|
|
|
+ <!-- 学生作答区 -->
|
|
|
+ <div v-if="mItem.areaType=='studentAnswer'">
|
|
|
+ <div class="item_question_answer" >
|
|
|
+ <div class="answer_title">我的作答</div>
|
|
|
+ <!-- 客观题 直接显示学生选择的答案 -->
|
|
|
+ <div class="answer_content" v-html="mItem.studentAnswer" v-if="mItem.isObjective==1">
|
|
|
+
|
|
|
+ </div>
|
|
|
+ <!-- 主观题 显示学生作答的图片 -->
|
|
|
+ <!-- <div class="answer_images" v-if="mItem.isObjective==0">
|
|
|
+ <img v-for="(image, imageIndex) in mItem.studentAnswer.split(';')" :key="'image_'+imageIndex" :src="image" alt="">
|
|
|
+ </div> -->
|
|
|
+ <!-- 主观题:只有真正的图片地址才渲染成 img;文本作答按文本显示,避免塞进 img src 产生 404 -->
|
|
|
+ <div class="answer_images" v-if="mItem.isObjective==0">
|
|
|
+ <img v-for="(image, imageIndex) in AnswerImages(mItem.studentAnswer)" :key="'image_'+imageIndex" :src="image" alt="">
|
|
|
+ <div class="answer_content" v-if="AnswerText(mItem.studentAnswer)" v-html="AnswerText(mItem.studentAnswer)"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 答案解析区 -->
|
|
|
+ <div v-if="mItem.areaType=='answerAnalysis'">
|
|
|
+ <div class="item_question_answer">
|
|
|
+ <div class="answer_title">答案解析</div>
|
|
|
+ <div class="answer_content" >
|
|
|
+ <span>【答案】:</span>
|
|
|
+ <span v-html="mItem.answer"></span>
|
|
|
+ </div>
|
|
|
+ <div class="answer_content" v-html="mItem.parse">
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+ <div class="page_dialog">
|
|
|
+ <el-dialog title="下载PDF" class="page_dialog" :visible.sync="showReportLoading" width="500px" top="15%" append-to-body>
|
|
|
+ <div class="report_loading">
|
|
|
+ <div class="report_loading_icon">
|
|
|
+ <img src="@/assets/report/report_loading.png">
|
|
|
+ </div>
|
|
|
+ <div class="report_loading_title">
|
|
|
+
|
|
|
+ 正在努力生成PDF中,请稍等...
|
|
|
+
|
|
|
+ </div>
|
|
|
+ <div class="report_loading_progress">
|
|
|
+ <el-progress :text-inside="true" color="#2E64FA" text-color="#ffffff" :stroke-width="16" :percentage="targetProgress"></el-progress>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { jsPDF } from "jspdf";
|
|
|
+import domtoimage from 'dom-to-image';
|
|
|
+import { mapGetters } from "vuex";
|
|
|
+export default {
|
|
|
+ props: {
|
|
|
+ value: {
|
|
|
+ type: Boolean,
|
|
|
+ required: true,
|
|
|
+ },//是否显示弹窗
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ },
|
|
|
+ components: {
|
|
|
+
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ answerRadio:1,//设置 0答案解析在卷底 1答案解析在每题题后
|
|
|
+ answerCheck:[1,2],//1 我的作答 2 答案解析
|
|
|
+ columnRadio:1,//设置列数 1 栏 2 栏
|
|
|
+
|
|
|
+ checkAverage:[],//均分
|
|
|
+ checkFullScore:[0],//显示满分
|
|
|
+ pageList:[],//导出pdf的页数据
|
|
|
+ questionList:[],//错题数据
|
|
|
+ pageInfo:{
|
|
|
+ width:794,
|
|
|
+ height:1123
|
|
|
+ },//导出pdf页面的比例配置
|
|
|
+
|
|
|
+ showReportLoading:false,//是否显示生成pdf进度弹窗
|
|
|
+
|
|
|
+ targetProgress:0,//目标进度值
|
|
|
+ progressInterval:null,//用于存储定时器
|
|
|
+ listData:[],//成绩小条数据list
|
|
|
+ rowCount: 3,//默认3行
|
|
|
+ examName:'',//考试名称
|
|
|
+ subjectName:'',//科目名称
|
|
|
+
|
|
|
+ dialogKey:1,//弹窗key值
|
|
|
+ tagActiveName:'',//试题组合名称
|
|
|
+ tagActive:100,//试题组合id
|
|
|
+
|
|
|
+ pageNum:1,//当前页数
|
|
|
+ pageSize:10,//每页显示数量
|
|
|
+ total:0,//总页数
|
|
|
+
|
|
|
+ measureList:[],//离线测量用的临时列表
|
|
|
+
|
|
|
+ allData:[],//所有数据 缓存
|
|
|
+ loading:false,//加载中
|
|
|
+
|
|
|
+ };
|
|
|
+ },
|
|
|
+
|
|
|
+ computed:{//分析报告公共参数变量
|
|
|
+
|
|
|
+ ...mapGetters(["userInfo"]),
|
|
|
+ showDialog: {
|
|
|
+ get() {
|
|
|
+ return this.value;
|
|
|
+ },
|
|
|
+ set(val) {
|
|
|
+ // 当外层弹窗关闭时,确保内层弹窗也关闭
|
|
|
+ if (!val) {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.showReportLoading = false;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ this.$emit("input", val);
|
|
|
+ },
|
|
|
+ },
|
|
|
+
|
|
|
+ showAnswer()
|
|
|
+ {
|
|
|
+
|
|
|
+ },//是否显示我的作答
|
|
|
+
|
|
|
+
|
|
|
+ studentName(){
|
|
|
+ return this.userInfo.userName; // 学生名称
|
|
|
+ },
|
|
|
+
|
|
|
+ className()
|
|
|
+ {
|
|
|
+ return this.userInfo.className || "" ; // 班级名称
|
|
|
+ },
|
|
|
+
|
|
|
+ schoolName(){
|
|
|
+ return this.userInfo.schoolName; // 学校名称
|
|
|
+ },
|
|
|
+
|
|
|
+ //测量容器宽度:必须与页面里块的实际可用宽度完全一致,否则图片按 max-width:100% 等比缩放、
|
|
|
+ //文字按不同宽度换行,测出的高度与正式渲染不一致(此前单栏用 794 测量而页面实际只有 754,块高被高估约5%);
|
|
|
+ //全局样式 .print_slip .area_page 有 padding:20px + box-sizing:border-box,页面内容实际宽 = 794 - 40 = 754;
|
|
|
+ //两栏时按单栏宽度测量:单栏宽 = (内容宽754 - 两栏间距20) / 2 = 367(与 .page_column 固定宽一致)
|
|
|
+ measureBoxWidth()
|
|
|
+ {
|
|
|
+ if(this.columnRadio == 2)
|
|
|
+ {
|
|
|
+ return Math.floor((this.pageInfo.width - 40 - 20) / 2);
|
|
|
+ }
|
|
|
+ return this.pageInfo.width - 40;
|
|
|
+ },
|
|
|
+
|
|
|
+ //每页内容区域可用高度 = 页高 - 页眉区 - 页脚保留区(与模板实际布局严格对应):
|
|
|
+ pageContentHeight()
|
|
|
+ {
|
|
|
+ return 1023;//固定值 已经减去页眉区和页脚保留区
|
|
|
+ },
|
|
|
+
|
|
|
+
|
|
|
+ },
|
|
|
+ created()
|
|
|
+ {
|
|
|
+ },
|
|
|
+ beforeDestroy()
|
|
|
+ {
|
|
|
+ // 组件销毁前清除定时器
|
|
|
+ if (this.progressInterval) {
|
|
|
+ clearInterval(this.progressInterval);
|
|
|
+ this.progressInterval = null;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ methods: {
|
|
|
+
|
|
|
+ //解析学生作答内容 分离图片地址与文本:
|
|
|
+ //部分后端数据 isObjective 不为1 但作答内容是纯文本(如单选答案"C"),旧逻辑直接 split 后全塞给 img src,
|
|
|
+ //文本被当相对路径请求(GET /C 404);现在只有真正的图片地址才渲染成 img,其余按文本显示;
|
|
|
+ //data:base64 内联图自身含 ; 和 , 先整体提取 避免被普通分隔符拆碎;返回 { images: [...], text: '...' }
|
|
|
+ ParseAnswerParts(studentAnswer)
|
|
|
+ {
|
|
|
+ const images = [];
|
|
|
+ const texts = [];
|
|
|
+ let rest = String(studentAnswer || '');
|
|
|
+ rest = rest.replace(/data:image\/[a-zA-Z.+-]+;base64,[A-Za-z0-9+/=\s]+/g, (m) => {
|
|
|
+ images.push(m.replace(/\s/g, ''));
|
|
|
+ return '\u0001';//占位符 防止被后续分隔符拆开
|
|
|
+ });
|
|
|
+ rest.split(/[;;,,]/).forEach(part => {
|
|
|
+ part = part.trim();
|
|
|
+ if(!part || part === '\u0001') return;
|
|
|
+ // http(s)/blob 开头 或 以常见图片扩展名结尾 才认定为图片地址
|
|
|
+ if(/^(https?:|blob:)/i.test(part) || /\.(png|jpe?g|gif|bmp|webp|svg)(\?|#|$)/i.test(part))
|
|
|
+ {
|
|
|
+ images.push(part);
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ texts.push(part);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return { images: images, text: texts.join(',') };
|
|
|
+ },
|
|
|
+ //学生作答中的图片地址列表(模板 v-for 用)
|
|
|
+ AnswerImages(studentAnswer)
|
|
|
+ {
|
|
|
+ return this.ParseAnswerParts(studentAnswer).images;
|
|
|
+ },
|
|
|
+ //学生作答中的文本部分(非图片地址的内容 按文本显示 避免塞进 img src 产生 404)
|
|
|
+ AnswerText(studentAnswer)
|
|
|
+ {
|
|
|
+ return this.ParseAnswerParts(studentAnswer).text;
|
|
|
+ },
|
|
|
+
|
|
|
+ //重新处理数据
|
|
|
+ HandleAnswerCheckChange()
|
|
|
+ {
|
|
|
+ this.loading=true;
|
|
|
+ //滚动条回到顶部
|
|
|
+ this.$nextTick(() => {
|
|
|
+ if (this.$refs.printSlip) {
|
|
|
+ this.$refs.printSlip.scrollTop = 0;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ this.SetListData(this.allData).then(() => {
|
|
|
+
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ //获取错题数据
|
|
|
+ GetListData(params)
|
|
|
+ {
|
|
|
+ this.dialogKey += 1;
|
|
|
+ this.checkFullScore = [0];
|
|
|
+
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ let param = {
|
|
|
+ ...params
|
|
|
+ };
|
|
|
+ this.loading=true;
|
|
|
+ this.$api.target.getTargetErrorExportData(param).then(res=>{
|
|
|
+ console.log("获取错题列表",res);
|
|
|
+
|
|
|
+ if(res.code==200)
|
|
|
+ {
|
|
|
+ this.allData = res.data;//缓存所有数据
|
|
|
+ this.SetListData(res.data).then(() => {
|
|
|
+ resolve(res);
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+ resolve(res);
|
|
|
+ })
|
|
|
+
|
|
|
+ }).catch((err) => {
|
|
|
+ reject(err);
|
|
|
+ });
|
|
|
+
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ //根据原始数据构造 list 并测量每个区域高度,回写到 list[i].height
|
|
|
+ SetListData(rawData)
|
|
|
+ {
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ let list=[];
|
|
|
+ let pageTitleObj={
|
|
|
+ areaType:'pageTitle',//标题区域
|
|
|
+ title:this.studentName+' 个性化错题',//标题
|
|
|
+ height:40,//高度
|
|
|
+ };//页面标题
|
|
|
+ list.push(pageTitleObj);
|
|
|
+ let questionNum=1;
|
|
|
+ (rawData || []).forEach(item=>{
|
|
|
+ let obj={
|
|
|
+ areaType:'knowledge',//知识点名称区域
|
|
|
+ knowledgeName:item.knowledgeName,
|
|
|
+ height:0,//高度
|
|
|
+ };
|
|
|
+ list.push(obj);
|
|
|
+ (item.questionList || []).forEach(questionItem=>{
|
|
|
+ let questionObj={
|
|
|
+ questionNum:questionNum,//题号 按顺序
|
|
|
+ title:questionItem.title,//题目内容
|
|
|
+ knowledgeList:questionItem.knowledgeList,//知识点列表
|
|
|
+ sourceName:questionItem.sourceName,//题目来源
|
|
|
+ answerScore:questionItem.answerScore,//满分
|
|
|
+ score:questionItem.score,//得分
|
|
|
+ scoreRate:questionItem.scoreRate,//得分率
|
|
|
+ areaType:'question',//区域类型 题目区域
|
|
|
+ height:0,//高度
|
|
|
+ };
|
|
|
+ questionNum++;
|
|
|
+ list.push(questionObj);//插入题目
|
|
|
+
|
|
|
+ //判断是否显示我的作答区域
|
|
|
+ if(this.answerCheck.includes(1))
|
|
|
+ {
|
|
|
+ console.log("插入我的作答区域");
|
|
|
+ let answerObj={
|
|
|
+ areaType:'studentAnswer',//区域类型 答案区域
|
|
|
+ isObjective:questionItem.isObjective,//是客观题
|
|
|
+ studentAnswer:questionItem.studentAnswer,//我的作答
|
|
|
+ height:0,//高度
|
|
|
+ };
|
|
|
+ list.push(answerObj);//插入我的作答
|
|
|
+ }
|
|
|
+
|
|
|
+ //判断是否显示答案解析区域
|
|
|
+ if(this.answerCheck.includes(2))
|
|
|
+ {
|
|
|
+
|
|
|
+ //判断答案在卷底 还是在每题后
|
|
|
+ if(this.answerRadio==1)
|
|
|
+ {
|
|
|
+ let analysisObj={
|
|
|
+ areaType:'answerAnalysis',//区域类型 答案区域
|
|
|
+ answer:questionItem.answer,//答案
|
|
|
+ parse:questionItem.parse,//解析区域
|
|
|
+ height:0,//高度
|
|
|
+ };
|
|
|
+ list.push(analysisObj);//插入我的答案解析
|
|
|
+ console.log("插入答案解析区域");
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ //判断是否显示答案解析区域
|
|
|
+ if(this.answerCheck.includes(2))
|
|
|
+ {
|
|
|
+
|
|
|
+ //判断答案在卷底
|
|
|
+ if(this.answerRadio==0)
|
|
|
+ {
|
|
|
+ let pageTitleObj={
|
|
|
+ areaType:'pageTitle',//标题区域
|
|
|
+ title:'答案解析',//标题
|
|
|
+ height:40,//高度
|
|
|
+ };//页面标题
|
|
|
+ list.push(pageTitleObj);
|
|
|
+ let questionNum=1;
|
|
|
+ (rawData || []).forEach(item=>{
|
|
|
+ let obj={
|
|
|
+ areaType:'knowledge',//知识点名称区域
|
|
|
+ knowledgeName:item.knowledgeName,
|
|
|
+ height:0,//高度
|
|
|
+ };
|
|
|
+ list.push(obj);
|
|
|
+ (item.questionList || []).forEach(questionItem=>{
|
|
|
+ let questionObj={
|
|
|
+
|
|
|
+ areaType:'knowledge',//知识点名称区域
|
|
|
+ knowledgeName:'错题'+questionNum,
|
|
|
+ height:0,//高度
|
|
|
+ };
|
|
|
+ questionNum++;
|
|
|
+ list.push(questionObj);//插入题目
|
|
|
+ let analysisObj={
|
|
|
+ areaType:'answerAnalysis',//区域类型 答案区域
|
|
|
+ answer:questionItem.answer,//答案
|
|
|
+ parse:questionItem.parse,//解析区域
|
|
|
+ height:0,//高度
|
|
|
+ };
|
|
|
+ list.push(analysisObj);//插入我的答案解析
|
|
|
+ console.log("插入答案解析区域");
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+ console.log("打印所有需要加载到页面的list数据", list);
|
|
|
+
|
|
|
+ //先把所有数据丢进测量容器 等dom渲染完再读高度
|
|
|
+ this.measureList = list;
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ // 双重 nextTick,等 v-html 完成二次渲染;再轮询等测量容器内图片全部加载完成才测量。
|
|
|
+ // (img 设了 height:auto 后未加载完高度为0,若提前测量会测小 → 切片窗口与正式渲染错位,出现图片被裁/空白)
|
|
|
+ this.WaitForImagesLoaded().then(() => {
|
|
|
+ const heights = this.MeasureItemHeights();
|
|
|
+ console.log("打印每个子元素的高度", heights);
|
|
|
+ // 测量容器里的元素(与 list 一一对应)切片时用它找文字行边界,避免切断文字行/产生孤行标点
|
|
|
+ const measureEls = this.$refs.measureBox ? Array.from(this.$refs.measureBox.children) : [];
|
|
|
+ // 把测量出的高度(取整)回写到每个 list item 上
|
|
|
+ for(let i = 0; i < list.length; i++)
|
|
|
+ {
|
|
|
+ list[i].height = Math.round(heights[i] || 0);
|
|
|
+ list[i].srcIndex = i;//记录在测量容器中的下标,块级诊断用它回查测量容器当前高度
|
|
|
+ }
|
|
|
+ console.log("打印填充高度后的list", list);
|
|
|
+
|
|
|
+ // 每页内容区域可用高度 固定为1023 已经减去页眉区和页脚保留区
|
|
|
+ const LATER_PAGE_HEIGHT = this.pageContentHeight; // 内容区可用高度
|
|
|
+ if(this.columnRadio == 2)
|
|
|
+ {
|
|
|
+ this.BuildPageListTwoColumns(list, heights, LATER_PAGE_HEIGHT, measureEls);
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ this.BuildPageList(list, heights, LATER_PAGE_HEIGHT, measureEls);
|
|
|
+ }
|
|
|
+ setTimeout(() => {
|
|
|
+ this.loading=false;
|
|
|
+ }, 2000);
|
|
|
+ resolve(list);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ });
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ //等待测量容器内所有图片加载完成后再测量(轮询检测 + 最少等待 + 超时兜底)
|
|
|
+ //避免图片未加载完成时测得的高度与正式渲染不一致,导致切片错位/图片被裁/空白;
|
|
|
+ //img.complete=true 但 naturalWidth=0 表示加载失败,也视为就绪(不再等待)
|
|
|
+ WaitForImagesLoaded()
|
|
|
+ {
|
|
|
+ return new Promise(resolve => {
|
|
|
+ const startTime = Date.now();
|
|
|
+ const MIN_WAIT = 300; // 最少等待 保证 v-html/CSS 布局稳定
|
|
|
+ const MAX_WAIT = 8000; // 超时兜底 防止坏图导致无限等待
|
|
|
+ const timer = setInterval(() => {
|
|
|
+ const box = this.$refs.measureBox;
|
|
|
+ const imgs = box ? box.querySelectorAll('img') : [];
|
|
|
+ let pending = 0;
|
|
|
+ for(let k = 0; k < imgs.length; k++)
|
|
|
+ {
|
|
|
+ if(!imgs[k].complete) pending++;
|
|
|
+ }
|
|
|
+ const elapsed = Date.now() - startTime;
|
|
|
+ if((pending === 0 && elapsed >= MIN_WAIT) || elapsed >= MAX_WAIT)
|
|
|
+ {
|
|
|
+ clearInterval(timer);
|
|
|
+ console.log('[测量等待] 未就绪图片数=' + pending + ' 耗时=' + elapsed + 'ms' + (elapsed >= MAX_WAIT ? '(超时兜底)' : ''));
|
|
|
+ // 双 rAF 确保浏览器完成重排后再测量
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ requestAnimationFrame(() => resolve());
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }, 100);
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ //读取测量容器里每个子元素的高度(返回整数像素值)
|
|
|
+ MeasureItemHeights()
|
|
|
+ {
|
|
|
+ const box = this.$refs.measureBox;
|
|
|
+ if(!box) return [];
|
|
|
+ const children = box.children;
|
|
|
+ const heights = [];
|
|
|
+ for(let i=0; i<children.length; i++)
|
|
|
+ {
|
|
|
+ heights.push(Math.round(children[i].getBoundingClientRect().height));
|
|
|
+ }
|
|
|
+ console.log("打印每个子元素的高度", heights);
|
|
|
+ return heights;
|
|
|
+ },
|
|
|
+
|
|
|
+ //根据测量高度按页容量分页
|
|
|
+ //pageHeight 后续页可用高度(px);firstPageHeight 第一页可用高度(要减去标题块占用)
|
|
|
+ BuildPageList(list, heights, pageHeight = 1023, measureEls = [])
|
|
|
+ {
|
|
|
+ this.pageList = [];
|
|
|
+ let currentPage = {
|
|
|
+ pageIndex: 1,
|
|
|
+ knowledgeName: '',
|
|
|
+ list: [],
|
|
|
+ };
|
|
|
+ let used = 0;
|
|
|
+
|
|
|
+ // 知识点标题下方至少保留的最小高度(避免标题孤零零贴页底)
|
|
|
+ const KNOWLEDGE_RESERVE = 80;
|
|
|
+ // 剩余空间低于该值时不接首片 直接换新页开始切(避免页底只剩一条碎内容)
|
|
|
+ const MIN_FIRST_SLICE = 100;
|
|
|
+ // 切片边界重叠高度:切点未落在安全边界(如强切图片)时,仅前一片向下延伸多显示一段内容,
|
|
|
+ // 避免切线正好穿过一行内容导致上页底部显示半行看不清;下一页从切点正常开始不向上重叠(避免两页重复看着像重复内容)
|
|
|
+ const SLICE_OVERLAP = 10;
|
|
|
+
|
|
|
+ // 创建新页的工厂方法
|
|
|
+ const nextPage = (pageIndex) => ({
|
|
|
+ pageIndex: pageIndex,
|
|
|
+ knowledgeName: '',
|
|
|
+ list: [],
|
|
|
+ });
|
|
|
+ // 当前页存档并开启新页(仅在已有内容时调用)
|
|
|
+ const startNewPage = () => {
|
|
|
+ this.pageList.push(currentPage);
|
|
|
+ currentPage = nextPage(currentPage.pageIndex + 1);
|
|
|
+ used = 0;
|
|
|
+ };
|
|
|
+
|
|
|
+ for(let i = 0; i < list.length; i++)
|
|
|
+ {
|
|
|
+ const item = list[i];
|
|
|
+ // ★ 优先使用 list 上回写的整数高度,避免 heights 与 list[i].height 不一致
|
|
|
+ const h = item.height || Math.round(heights[i] || 0);
|
|
|
+ const areaType = item.areaType;
|
|
|
+ const remainSpace = pageHeight - used;
|
|
|
+ // 可拆分模块:题目、学生作答、答案解析(切片走安全边界:文字不切半行、图片不裁切);知识点标题/页面标题按剩余空间整块判断(放得下就插 放不下换新页)
|
|
|
+ const splittable = (areaType === 'question' || areaType === 'studentAnswer' || areaType === 'answerAnalysis');
|
|
|
+
|
|
|
+ // 1. 可拆分块(题目/学生作答/解析):当前页剩余空间放不下就切片,能塞多少塞多少(首片填满当前页剩余 其余换新页继续)
|
|
|
+ if(splittable && h > remainSpace)
|
|
|
+ {
|
|
|
+ const el = (measureEls && measureEls[i]) ? measureEls[i] : null;
|
|
|
+ // 收集块内图片位置(相对 el 顶部),切片时保证不裁剪/不切穿图片
|
|
|
+ const imgRects = el ? this.GetBlockImageRects(el) : [];
|
|
|
+ const isImageBlock = this.IsImageBlock(el, h, imgRects);
|
|
|
+ // 剩余空间太少不接首片 换新页从头切(避免页底只剩一条碎内容)
|
|
|
+ if(currentPage.list.length > 0 && remainSpace < MIN_FIRST_SLICE)
|
|
|
+ {
|
|
|
+ startNewPage();
|
|
|
+ }
|
|
|
+ console.warn('[分页] 可拆分块按剩余空间切片:', areaType, '高度', h, '剩余空间', pageHeight - used);
|
|
|
+ let offset = 0;
|
|
|
+ let left = h;
|
|
|
+ let isFirst = true;
|
|
|
+ let forcedTimes = 0;//连续强切兜底次数(防重叠挤占空间导致 -1 反复换页死循环)
|
|
|
+ while(left > 0)
|
|
|
+ {
|
|
|
+ // ★ 第一片填满当前页剩余空白 后续片用整页高度(能放多少放多少);
|
|
|
+ // 非尾片预留底部重叠量(不安全切点时前一片向下延伸多显示一段 下一页从切点正常开始不向上重叠),
|
|
|
+ // 避免"内容+重叠"总高超出页高(预留后剩不下则换新页重试)
|
|
|
+ const sliceMax = isFirst ? (pageHeight - used) : pageHeight;
|
|
|
+ const isLikelyLast = left <= sliceMax;
|
|
|
+ const contentMax = sliceMax - (isLikelyLast ? 0 : SLICE_OVERLAP);
|
|
|
+ const cut = this.CalcSafeSliceHeight(el, imgRects, isImageBlock, offset, contentMax, left);
|
|
|
+ let sliceH = cut.h;
|
|
|
+ if(sliceH === -1)
|
|
|
+ {
|
|
|
+ // 当前剩余连一张完整图片都放不下:当前页已有内容就换新页重试;
|
|
|
+ // 空页仍 -1(重叠预留挤占所致)或单图超页高 → 强切兜底(计为不安全切点 后续靠重叠兼容)
|
|
|
+ if(currentPage.list.length > 0 && forcedTimes === 0)
|
|
|
+ {
|
|
|
+ startNewPage();
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ console.warn('[分页] 强切兜底(可用高度不足/单图超页高):', areaType, 'offset', offset);
|
|
|
+ sliceH = Math.min(left, Math.max(contentMax, 1));
|
|
|
+ cut.cutSafe = false;
|
|
|
+ forcedTimes++;
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ forcedTimes = 0;
|
|
|
+ }
|
|
|
+ // 尾片无需底部重叠;切点落在安全边界(文字行顶/图片底边)也无需重叠(不会出现半行字)
|
|
|
+ const isLastSlice = (left - sliceH) <= 0;
|
|
|
+ const bottomOverlap = (isLastSlice || cut.cutSafe) ? 0 : SLICE_OVERLAP;
|
|
|
+ const finalH = sliceH + bottomOverlap;//实际渲染高度(含底部重叠区)
|
|
|
+ // 切片项:模板按 sliceHeight 裁剪、向上偏移 offsetTop 显示原块的对应区间(blockHeight 保留原块测量总高供诊断);
|
|
|
+ // 底部重叠仅通过窗口加高向下延伸实现,下一页切片仍从切点正常开始(不向上重叠 避免两页都显示重叠区看着像重复)
|
|
|
+ currentPage.list.push({ ...item, isSlice: true, sliceHeight: finalH, offsetTop: offset, height: finalH, blockHeight: h });
|
|
|
+ used += finalH;
|
|
|
+ offset += sliceH;
|
|
|
+ left = h - offset;
|
|
|
+ isFirst = false;
|
|
|
+ // 内容没切完 → 换新页继续放剩余切片(新页能放多少放多少)
|
|
|
+ if(left > 0) startNewPage();
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 不可拆分块:按剩余空间判断 放得下就插入 放不下换新页插入(换页后仍超页高的整块放入 超出由内容区 overflow:hidden 裁掉)
|
|
|
+ if(areaType === 'knowledge' && currentPage.list.length > 0 && (pageHeight - used) - h < KNOWLEDGE_RESERVE)
|
|
|
+ {
|
|
|
+ // 知识点标题:放入后下方保留不足 KNOWLEDGE_RESERVE 就换页(避免标题贴页底)
|
|
|
+ startNewPage();
|
|
|
+ }
|
|
|
+ else if(currentPage.list.length > 0 && h > remainSpace)
|
|
|
+ {
|
|
|
+ if(h > pageHeight)
|
|
|
+ {
|
|
|
+ console.warn('[分页] 单块超页高且不可拆分:', areaType, '高度', h, ',整块换新页(超出部分裁掉)');
|
|
|
+ }
|
|
|
+ startNewPage();
|
|
|
+ }
|
|
|
+
|
|
|
+ currentPage.list.push(item);
|
|
|
+ used += h;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 最后一页收尾
|
|
|
+ if(currentPage.list.length > 0)
|
|
|
+ {
|
|
|
+ this.pageList.push(currentPage);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 末页防孤儿:内容不足 1/3 页时把上一页尾部整块移入末页平衡(不拆切片)
|
|
|
+ this.RebalanceLastPage(pageHeight);
|
|
|
+
|
|
|
+ console.log('[分页结果] 共', this.pageList.length, '页', this.pageList.map(p => ({
|
|
|
+ pageIndex: p.pageIndex,
|
|
|
+ itemCount: p.list.length,
|
|
|
+ //每页内容总高度(切片按 sliceHeight 计)不含页眉,用于与实际渲染高度对比
|
|
|
+ contentHeight: (p.list || []).reduce((s, it) => s + (it.isSlice ? it.sliceHeight : (it.height || 0)), 0),
|
|
|
+ //每页块构成:定位"哪页为什么空"(看尾部是什么块、剩余空间是多少)
|
|
|
+ blocks: (p.list || []).map(it => it.areaType + (it.isSlice ? '(切片' + it.sliceHeight + ')' : '(' + (it.height || 0) + ')')).join(' + '),
|
|
|
+ })));
|
|
|
+ // 渲染完成后对比 计算高度 vs 实际DOM高度(页级 + 块级)
|
|
|
+ this.DebugComparePageHeights();
|
|
|
+ this.DebugCompareBlockHeights();
|
|
|
+ },
|
|
|
+
|
|
|
+ //计算安全切片高度(图片与HTML文字统一逻辑 能放多少放多少):
|
|
|
+ //el 测量容器原块;imgRects 块内图片位置;offset 已切到的位置;sliceMax 本次内容可用高度;remain 剩余未切内容高度;
|
|
|
+ //返回值:{ h: 本次切片内容高度, cutSafe: 切点是否落在安全边界 };h=-1 表示可用高度较小且连一张完整图片都放不下 由调用方换新页重试;
|
|
|
+ //cutSafe=true(文字行边界/图片底边/最后一片)上下页边界内容完整无需重叠;false(裁图强切/兜底强切)切点穿过内容中间,
|
|
|
+ //由调用方给前一片底部延伸重叠多显示一段,避免上页底部显示半行看不清;
|
|
|
+ //切点规则:图片区优先切在图片底边(不裁图),但安全切点会留下过多空白(> IMG_GAP_TOLERANCE)时
|
|
|
+ //允许裁切图片填满剩余空间(填满优先 避免页底大片空白);文字区切在行边界(不切半行字/标点孤行推下页)
|
|
|
+ CalcSafeSliceHeight(el, imgRects, isImageBlock, offset, sliceMax, remain)
|
|
|
+ {
|
|
|
+ let sliceH = Math.min(remain, sliceMax);
|
|
|
+ // 最后一片不需要切 直接返回(不产生边界空白)
|
|
|
+ if(sliceH >= remain) return { h: sliceH, cutSafe: true };
|
|
|
+ // 当前切片区间内是否含图片(尾部纯文字区间走文字行边界逻辑)
|
|
|
+ const hasImgInSlice = isImageBlock && imgRects.some(r => r.bottom > offset + 1 && r.top < offset + sliceMax);
|
|
|
+ if(hasImgInSlice)
|
|
|
+ {
|
|
|
+ // 图片内容:可用高度内能完整放下的图片尽量塞满 切点落在图片底边
|
|
|
+ const imgCut = this.FindImageCut(imgRects, offset, offset + sliceMax);
|
|
|
+ const safeH = imgCut > offset ? (imgCut - offset) : 0;
|
|
|
+ // 安全切点留下的空白超过容忍值(或一张完整图都放不下但可用高度不小)→ 裁切图片填满剩余空间 避免页底大片空白(切点穿过图片 不安全)
|
|
|
+ const IMG_GAP_TOLERANCE = 150;
|
|
|
+ if(sliceMax - safeH > IMG_GAP_TOLERANCE)
|
|
|
+ {
|
|
|
+ console.warn('[切片] 图片安全切点仅放下', safeH, 'px,会留', sliceMax - safeH, 'px 空白 → 裁切图片填满剩余', sliceMax, 'px');
|
|
|
+ return { h: sliceH, cutSafe: false };
|
|
|
+ }
|
|
|
+ if(safeH > 0) return { h: safeH, cutSafe: true };
|
|
|
+ // 一张完整图都放不下且可用高度较小 → 换页(不产生裁图小碎条 页底留少量空白可接受)
|
|
|
+ return { h: -1, cutSafe: false };
|
|
|
+ }
|
|
|
+ if(el)
|
|
|
+ {
|
|
|
+ // 文字内容:把切点调整到文字行边界 标点孤行推到下一页(页底会留一点空白属正常)
|
|
|
+ const safe = this.AdjustSliceBoundary(el, offset + sliceH);
|
|
|
+ if(safe - offset > 60)
|
|
|
+ {
|
|
|
+ sliceH = safe - offset;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 兜底:边界调整后切片高度异常 强制按最大高度推进 防止死循环(切点位置不可控 标记不安全)
|
|
|
+ if(sliceH <= 0)
|
|
|
+ {
|
|
|
+ return { h: Math.min(remain, sliceMax), cutSafe: false };
|
|
|
+ }
|
|
|
+ return { h: sliceH, cutSafe: true };
|
|
|
+ },
|
|
|
+
|
|
|
+ //末页防孤儿处理:最后一页内容太少(不足页高 1/3)时,从上一页尾部把整块依次移到末页,
|
|
|
+ //让最后两页内容均衡,避免出现只剩几十像素的"空页";切片不搬动(会破坏顺序);
|
|
|
+ //搬动后若上一页被搬空则移除该页;同时保证上一页不被搬得过空(至少保留页高 1/4)
|
|
|
+ RebalanceLastPage(pageHeight)
|
|
|
+ {
|
|
|
+ if(this.pageList.length < 2) return;
|
|
|
+ const sumH = p => (p.list || []).reduce((s, it) => s + (it.isSlice ? it.sliceHeight : (it.height || 0)), 0);
|
|
|
+ const last = this.pageList[this.pageList.length - 1];
|
|
|
+ let lastH = sumH(last);
|
|
|
+ // 末页内容已足够 → 不处理;末页含切片时无法搬动(会破坏切片顺序)→ 跳过并告警原因,交给切片时的尾片保护处理;
|
|
|
+ // 诊断日志:输出末页每个块的构成,方便定位孤儿页里到底是什么内容(切片/知识点标题/普通块)
|
|
|
+ console.log('[末页构成] 高度', Math.round(lastH), 'px,包含:', (last.list || []).map(it => ({
|
|
|
+ areaType: it.areaType,
|
|
|
+ isSlice: !!it.isSlice,
|
|
|
+ height: it.isSlice ? it.sliceHeight : (it.height || 0),
|
|
|
+ })));
|
|
|
+ if(lastH >= pageHeight / 3) return;
|
|
|
+ if((last.list || []).some(it => it.isSlice))
|
|
|
+ {
|
|
|
+ console.log('[防孤儿页] 末页含切片,无法通过搬运平衡(不改变切片顺序),保持现状');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const prev = this.pageList[this.pageList.length - 2];
|
|
|
+ const targetH = Math.min(pageHeight / 2, 400); // 末页至少填到的高度目标(两者取小 避免超页)
|
|
|
+ console.log('[防孤儿页] 末页仅', Math.round(lastH), 'px,从前一页搬运整块平衡');
|
|
|
+ while(prev.list.length > 0 && lastH < targetH)
|
|
|
+ {
|
|
|
+ const item = prev.list[prev.list.length - 1];
|
|
|
+ if(item.isSlice) break; // 遇到切片停止 避免破坏切片顺序
|
|
|
+ const itemH = item.height || 0;
|
|
|
+ if(lastH + itemH > pageHeight) break; // 末页放不下也不搬(保持顺序 不能跳过它搬更前面的)
|
|
|
+ prev.list.pop();
|
|
|
+ last.list.unshift(item);
|
|
|
+ lastH += itemH;
|
|
|
+ if(sumH(prev) < pageHeight / 4) break; // 上一页不能被搬得过空 否则孤儿页只是转移了
|
|
|
+ }
|
|
|
+ // 上一页被搬空 → 移除该页;最后统一重排页码(页脚/日志里的页码保持一致)
|
|
|
+ if(prev.list.length === 0)
|
|
|
+ {
|
|
|
+ this.pageList.splice(this.pageList.length - 2, 1);
|
|
|
+ }
|
|
|
+ for(let i = 0; i < this.pageList.length; i++)
|
|
|
+ {
|
|
|
+ this.pageList[i].pageIndex = i + 1;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ //两栏分页:一页分左/右两栏,先填左栏再填右栏,两栏都放不下才换页
|
|
|
+ //columnHeight 栏可用高度(px);通栏标题(pageTitle)强制换页并占据页顶,栏可用高度需减去它占的高度
|
|
|
+ BuildPageListTwoColumns(list, heights, columnHeight = 1023, measureEls = [])
|
|
|
+ {
|
|
|
+ this.pageList = [];
|
|
|
+
|
|
|
+ // 知识点标题下方至少保留的最小高度
|
|
|
+ const KNOWLEDGE_RESERVE = 80;
|
|
|
+
|
|
|
+ // 创建新页的工厂方法(通栏标题区 + 左右两栏)
|
|
|
+ const nextPage = (pageIndex) => ({
|
|
|
+ pageIndex: pageIndex,
|
|
|
+ fullList: [],//通栏区域(pageTitle)
|
|
|
+ topHeight: 0,//通栏标题已占用高度(栏可用高度要减去它)
|
|
|
+ columns: [[], []],//左栏 右栏
|
|
|
+ });
|
|
|
+
|
|
|
+ let page = nextPage(1);
|
|
|
+ let used = [0, 0];//左/右栏已用高度
|
|
|
+ let cur = 0;//当前正在填的栏 0左 1右
|
|
|
+
|
|
|
+ const pushPage = () => {
|
|
|
+ this.pageList.push(page);
|
|
|
+ page = nextPage(page.pageIndex + 1);
|
|
|
+ used = [0, 0];
|
|
|
+ cur = 0;
|
|
|
+ };
|
|
|
+
|
|
|
+ for(let i = 0; i < list.length; i++)
|
|
|
+ {
|
|
|
+ const item = list[i];
|
|
|
+ // ★ 优先使用 list 上写的整数高度,避免 heights 与 list[i].height 不一致
|
|
|
+ const h = item.height || Math.round(heights[i] || 0);
|
|
|
+ const areaType = item.areaType;
|
|
|
+
|
|
|
+ // 1. 通栏标题:强制换页并独占页顶(如卷底的"答案解析"标题)
|
|
|
+ if(areaType === 'pageTitle')
|
|
|
+ {
|
|
|
+ if(page.fullList.length > 0 || page.columns[0].length > 0 || page.columns[1].length > 0)
|
|
|
+ {
|
|
|
+ pushPage();
|
|
|
+ }
|
|
|
+ page.fullList.push(item);
|
|
|
+ page.topHeight += h + 10;//标题高度 + 底部外边距10px
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 当前页栏可用高度(减去通栏标题占用)
|
|
|
+ const maxH = columnHeight - page.topHeight;
|
|
|
+
|
|
|
+ // 超高块处理:避免被页容器 overflow:hidden 裁掉丢失内容;题目/学生作答/解析可拆分 其他模块整块换新页(放不下就放入 超出裁掉)
|
|
|
+ const splittable = (areaType === 'question' || areaType === 'studentAnswer' || areaType === 'answerAnalysis');
|
|
|
+ if(h > maxH && !splittable)
|
|
|
+ {
|
|
|
+ console.warn('[两栏分页] 单块超栏高且不可拆分:', areaType, '高度', h, ',整块换新页(超出部分裁掉)');
|
|
|
+ pushPage();
|
|
|
+ page.columns[0].push(item);
|
|
|
+ used[0] += h;
|
|
|
+ cur = 0;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ // 可拆分块(题目/学生作答/解析):当前栏剩余空间放不下就切片 能塞多少塞多少(首片填当前栏 续片优先填同页右栏 右栏切完才换新页 避免右栏空白)
|
|
|
+ const colRemain = maxH - used[cur];
|
|
|
+ if(splittable && h > colRemain)
|
|
|
+ {
|
|
|
+ const el = (measureEls && measureEls[i]) ? measureEls[i] : null;
|
|
|
+ const imgRects = el ? this.GetBlockImageRects(el) : [];
|
|
|
+ const isImageBlock = this.IsImageBlock(el, h, imgRects);
|
|
|
+ // 当前栏剩余空白不足这么多时 就不在当前栏底塞碎片 优先整块挪到右栏/换新页处理;
|
|
|
+ // 注意 cur==1 时不能回退左栏(左栏内容已排在前面 会破坏阅读顺序)
|
|
|
+ const MIN_FIRST_SLICE = 100;
|
|
|
+ let sliceCol = cur;
|
|
|
+ let sliceRemain = colRemain;
|
|
|
+ if(sliceRemain < MIN_FIRST_SLICE)
|
|
|
+ {
|
|
|
+ if(cur === 0 && h <= maxH - used[1])
|
|
|
+ {
|
|
|
+ // 右栏空且整块放得下 → 不切片直接整块放右栏(旧逻辑这里会换页 导致左栏底部剩余空间留白)
|
|
|
+ page.columns[1].push(item);
|
|
|
+ used[1] += h;
|
|
|
+ cur = 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if(h <= columnHeight)
|
|
|
+ {
|
|
|
+ // 新页能放下:直接换页放入左栏(当前页放不下只是因为通栏标题占了空间)
|
|
|
+ pushPage();
|
|
|
+ page.columns[0].push(item);
|
|
|
+ used[0] += h;
|
|
|
+ cur = 0;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ pushPage();
|
|
|
+ sliceCol = 0;
|
|
|
+ sliceRemain = columnHeight;
|
|
|
+ }
|
|
|
+ // 切成多个切片:首片填当前栏剩余 续片优先填同页右栏(阅读顺序仍先左后右)右栏切完才换新页左栏;
|
|
|
+ // 旧逻辑续片一律独占新页左栏,导致切片期间每页右栏全空白且总页数翻倍;
|
|
|
+ // 每片实际占栏高度都要计入容量(含底部重叠),否则栏内容总高超栏高会被 overflow:hidden 裁掉底部内容(右栏空白诱因之一)
|
|
|
+ console.warn('[两栏分页] 可拆分块按剩余空间切片:', areaType, '高度', h, '栏剩余', sliceRemain);
|
|
|
+ // 切片边界重叠:切点不安全(强切图片)时,仅前一片向下延伸多显示一段,避免切线穿过内容中间导致上页/上栏底部半行看不清;下一页从切点正常开始不向上重叠(与单栏 BuildPageList 同一策略)
|
|
|
+ const SLICE_OVERLAP = 10;
|
|
|
+ let offset = 0;
|
|
|
+ let left = h;
|
|
|
+ let advanceTimes = 0;//因 -1 向更大容量位置推进的次数(容量单调递增 最多两次:左栏→右栏→新页 防死循环)
|
|
|
+ while(left > 0)
|
|
|
+ {
|
|
|
+ // 非尾片预留底部重叠量(前一片向下延伸),避免"内容+重叠"总高超出栏高
|
|
|
+ const isLikelyLast = left <= sliceRemain;
|
|
|
+ const contentMax = sliceRemain - (isLikelyLast ? 0 : SLICE_OVERLAP);
|
|
|
+ const cut = this.CalcSafeSliceHeight(el, imgRects, isImageBlock, offset, contentMax, left);
|
|
|
+ let sliceH = cut.h;
|
|
|
+ if(sliceH === -1)
|
|
|
+ {
|
|
|
+ // 连一张完整图片都放不下:当前位置容量小于整栏高就推进到更大容量位置重试;否则强切兜底(防止死循环)
|
|
|
+ if(sliceRemain < columnHeight && advanceTimes < 2)
|
|
|
+ {
|
|
|
+ advanceTimes++;
|
|
|
+ if(sliceCol === 0)
|
|
|
+ {
|
|
|
+ sliceCol = 1;
|
|
|
+ sliceRemain = maxH - used[1];
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ pushPage();
|
|
|
+ sliceCol = 0;
|
|
|
+ sliceRemain = columnHeight;
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ console.warn('[两栏分页] 强切兜底(可用高度不足/单图超栏高):', areaType, 'offset', offset);
|
|
|
+ sliceH = Math.min(left, Math.max(contentMax, 1));
|
|
|
+ cut.cutSafe = false;
|
|
|
+ }
|
|
|
+ // 尾片无需底部重叠;安全边界切点也无需重叠(不会出现半行内容)
|
|
|
+ const isLastSlice = (left - sliceH) <= 0;
|
|
|
+ const bottomOverlap = (isLastSlice || cut.cutSafe) ? 0 : SLICE_OVERLAP;
|
|
|
+ const finalH = sliceH + bottomOverlap;//实际渲染高度(含底部重叠区)
|
|
|
+ page.columns[sliceCol].push({ ...item, isSlice: true, sliceHeight: finalH, offsetTop: offset, height: finalH, blockHeight: h });
|
|
|
+ used[sliceCol] += finalH;
|
|
|
+ offset += sliceH;
|
|
|
+ left = h - offset;
|
|
|
+ // 内容没切完 → 推进下一位置:左栏切的优先进同页右栏(填满右栏避免空白),右栏切的换新页左栏继续;
|
|
|
+ // 尾片落在哪一栏,后续块就从哪一栏继续往下填(另一栏剩余空间留给后续块均衡)
|
|
|
+ if(left > 0)
|
|
|
+ {
|
|
|
+ if(sliceCol === 0)
|
|
|
+ {
|
|
|
+ sliceCol = 1;
|
|
|
+ sliceRemain = maxH - used[1];
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ pushPage();
|
|
|
+ sliceCol = 0;
|
|
|
+ sliceRemain = columnHeight;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ cur = sliceCol;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 普通块放置:优先两栏中剩余空间较小的一栏(先填浅的 让两栏趋于均衡 避免一侧过早填满被迫换页另一侧留白);
|
|
|
+ // 阅读顺序先左栏后右栏:右栏已有内容后不能再放回左栏(会破坏顺序 只能继续填右栏)
|
|
|
+ const knowledge = (areaType === 'knowledge');
|
|
|
+ const limitOf = c => knowledge ? Math.max(maxH - KNOWLEDGE_RESERVE, 0) : maxH;
|
|
|
+ const fits = (c) => page.columns[c].length === 0 || used[c] + h <= limitOf(c);
|
|
|
+ const order = page.columns[1].length > 0 ? [1] : ((maxH - used[0]) <= (maxH - used[1]) ? [0, 1] : [1, 0]);
|
|
|
+ let placed = false;
|
|
|
+ for(let k = 0; k < order.length && !placed; k++)
|
|
|
+ {
|
|
|
+ const c = order[k];
|
|
|
+ if(fits(c))
|
|
|
+ {
|
|
|
+ page.columns[c].push(item);
|
|
|
+ used[c] += h;
|
|
|
+ cur = c;
|
|
|
+ placed = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 当前页两栏都放不下 → 换页放入新页左栏(进入此分支的块高必然 <= 栏高 能完整放下,
|
|
|
+ // 避免强塞当前页剩余较大栏导致尾部被栏容器裁掉丢失内容)
|
|
|
+ if(!placed)
|
|
|
+ {
|
|
|
+ pushPage();
|
|
|
+ page.columns[0].push(item);
|
|
|
+ used[0] += h;
|
|
|
+ cur = 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 最后一页收尾
|
|
|
+ if(page.fullList.length > 0 || page.columns[0].length > 0 || page.columns[1].length > 0)
|
|
|
+ {
|
|
|
+ this.pageList.push(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('[两栏分页结果] 共', this.pageList.length, '页', this.pageList.map(p => ({
|
|
|
+ pageIndex: p.pageIndex,
|
|
|
+ leftCount: p.columns[0].length,
|
|
|
+ rightCount: p.columns[1].length,
|
|
|
+ //每页内容总高度 = 通栏区 + 较高的一栏(不含页眉),用于与实际渲染高度对比
|
|
|
+ contentHeight: (p.fullList || []).reduce((s, it) => s + (it.height || 0), 0)
|
|
|
+ + Math.max(...p.columns.map(col => col.reduce((s, it) => s + (it.isSlice ? it.sliceHeight : (it.height || 0)), 0))),
|
|
|
+ //每页栏内块构成:定位"哪页为什么空"
|
|
|
+ left: (p.columns[0] || []).map(it => it.areaType + (it.isSlice ? '(切片' + it.sliceHeight + ')' : '(' + (it.height || 0) + ')')).join(' + '),
|
|
|
+ right: (p.columns[1] || []).map(it => it.areaType + (it.isSlice ? '(切片' + it.sliceHeight + ')' : '(' + (it.height || 0) + ')')).join(' + '),
|
|
|
+ })));
|
|
|
+ // 渲染完成后对比 计算高度 vs 实际DOM高度(页级 + 块级)
|
|
|
+ this.DebugComparePageHeights();
|
|
|
+ this.DebugCompareBlockHeights();
|
|
|
+ },
|
|
|
+
|
|
|
+ //汇总每页的“计算内容总高度”(不含页眉):单栏 = 块高之和;两栏 = 通栏区 + 较高一栏之和;切片按 sliceHeight 计
|
|
|
+ CalcPageExpectedHeights()
|
|
|
+ {
|
|
|
+ return this.pageList.map(p => {
|
|
|
+ if(p.columns)
|
|
|
+ {
|
|
|
+ const fullH = (p.fullList || []).reduce((s, it) => s + (it.height || 0), 0);
|
|
|
+ const colH = Math.max(...p.columns.map(col => col.reduce((s, it) => s + (it.isSlice ? it.sliceHeight : (it.height || 0)), 0)));
|
|
|
+ return fullH + colH;
|
|
|
+ }
|
|
|
+ return (p.list || []).reduce((s, it) => s + (it.isSlice ? it.sliceHeight : (it.height || 0)), 0);
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ //诊断:对比每页“分页计算的内容总高度”与“页面实际渲染高度”,定位高度失真的页/块;
|
|
|
+ //两者均不含页眉(页眉高度固定 直接扣除);差值 = 实际 - 计算,正数表示实际比算的多(内容被低估)
|
|
|
+ DebugComparePageHeights()
|
|
|
+ {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ setTimeout(() => {
|
|
|
+ const expected = this.CalcPageExpectedHeights();
|
|
|
+ const pages = document.querySelectorAll('.area_page');
|
|
|
+ const rows = [];
|
|
|
+ for(let i = 0; i < pages.length; i++)
|
|
|
+ {
|
|
|
+ const pageEl = pages[i];
|
|
|
+ // 内容区是固定高度包裹层(.page_area_content height:1023 overflow:hidden):
|
|
|
+ // ① 基准线取包裹层顶部(紧随页眉),不能再像旧逻辑那样把页眉的下一个兄弟元素底边当基准(那个兄弟现在就是包裹层自己,会把基准拉到内容区底部,实际高度恒算成0)
|
|
|
+ // ② 包裹层自身高度固定,测它没意义,必须钻进包裹层内测子元素的最大底边(子元素 offsetTop 相对 .area_page,position:relative,与基准线可直接比较)
|
|
|
+ const contentBox = pageEl.querySelector('.page_area_content');
|
|
|
+ const scopeEl = contentBox || pageEl;
|
|
|
+ let baseTop = 0;
|
|
|
+ if(contentBox)
|
|
|
+ {
|
|
|
+ baseTop = contentBox.offsetTop;
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ const header = pageEl.querySelector('.page_header_info');
|
|
|
+ if(header) baseTop = header.offsetTop + header.offsetHeight;
|
|
|
+ }
|
|
|
+ // 实际内容底部 = 包裹层内子元素的最大底边(两栏时 page_columns_wrap 的高度即较高一栏);
|
|
|
+ // 跳过绝对/固定定位元素(如页脚 .page_footer_info 是 absolute 钉在页底,会把底部恒拉到整页高)
|
|
|
+ let maxBottom = 0;
|
|
|
+ for(let c = 0; c < scopeEl.children.length; c++)
|
|
|
+ {
|
|
|
+ const child = scopeEl.children[c];
|
|
|
+ const pos = window.getComputedStyle(child).position;
|
|
|
+ if(pos === 'absolute' || pos === 'fixed') continue;
|
|
|
+ maxBottom = Math.max(maxBottom, child.offsetTop + child.offsetHeight);
|
|
|
+ }
|
|
|
+ const actual = Math.round(Math.max(0, maxBottom - baseTop));
|
|
|
+ const exp = Math.round(expected[i] || 0);
|
|
|
+ rows.push({ 页码: i + 1, 计算高度: exp, 实际高度: actual, 差值: actual - exp });
|
|
|
+ // 差值异常时输出该页内容区每个子元素的高度明细,定位是哪个块失真(跳过定位元素)
|
|
|
+ if(Math.abs(actual - exp) > 20)
|
|
|
+ {
|
|
|
+ const details = [];
|
|
|
+ for(let c = 0; c < scopeEl.children.length; c++)
|
|
|
+ {
|
|
|
+ const child = scopeEl.children[c];
|
|
|
+ const pos = window.getComputedStyle(child).position;
|
|
|
+ if(pos === 'absolute' || pos === 'fixed') continue;
|
|
|
+ details.push({
|
|
|
+ 序号: c,
|
|
|
+ 类名: (child.className || '').toString().slice(0, 40),
|
|
|
+ offsetTop: Math.round(child.offsetTop),
|
|
|
+ 高度: Math.round(child.offsetHeight),
|
|
|
+ 底边: Math.round(child.offsetTop + child.offsetHeight),
|
|
|
+ });
|
|
|
+ }
|
|
|
+ console.log('[页高对比-明细] 第' + (i + 1) + '页 内容区子元素高度明细(基准线=' + Math.round(baseTop) + '):');
|
|
|
+ console.table(details);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ console.log('[页高对比] 计算高度=分页时累加的块高;实际高度=渲染后DOM实测;差值≠0 的页就是高度失真点');
|
|
|
+ console.table(rows);
|
|
|
+ }, 800); // 等渲染与图片加载稳定后再测
|
|
|
+ });
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ //块级诊断:逐块比对"测量容器测得的高度"与"页面实际渲染的高度",定位哪些块被高估/低估;
|
|
|
+ //切片块额外检查切片窗口尾部空白(测量高度 > 实际块高时,尾片底部就会有一段空白);
|
|
|
+ //页高对比只能看到包裹层外壳高度(切片外壳恒等于 sliceHeight),看不到内部失真,所以要钻进块内比对;
|
|
|
+ //注意:该诊断在渲染后 1500ms 执行,若页面图片仍未加载完会出现假阳性(实际高度偏小),以稳定后的结果为准;
|
|
|
+ //只适用于一栏布局(两栏结构不同 跳过)
|
|
|
+ DebugCompareBlockHeights()
|
|
|
+ {
|
|
|
+ if(this.columnRadio == 2) return;
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ setTimeout(() => {
|
|
|
+ const rows = [];
|
|
|
+ const subRows = [];
|
|
|
+ // 下钻一层:把页面块与测量容器块的顶层子元素逐一对比宽/高,定位到底是哪类元素(图片/表格/文字段)两边渲染尺寸不同;
|
|
|
+ // 宽不同 → 可用宽度不一致(图片按比例缩放高度也跟着变);宽相同高不同 → 行高/字体/内部边距差异;
|
|
|
+ // 只递归首层差异子元素一次(通常是 .item_question_title 这类 v-html 容器),避免输出爆炸;
|
|
|
+ // 注意:页面侧被切片包裹的块与测量容器块的模板结构一致 子元素可按序对齐;若两边子元素数不同会在备注里标出(按序对齐可能错位)
|
|
|
+ const diffChildren = (pageBlock, mBlock, pageIdx, depth) => {
|
|
|
+ if(!pageBlock || !mBlock || depth > 1) return;
|
|
|
+ const pk = pageBlock.children;
|
|
|
+ const mk = mBlock.children;
|
|
|
+ if(pk.length === 0) return;
|
|
|
+ const note = pk.length !== mk.length ? '(子元素数不同' + pk.length + '/' + mk.length + ' 对齐可能错位)' : '';
|
|
|
+ const n = Math.min(pk.length, mk.length);
|
|
|
+ for(let c2 = 0; c2 < n; c2++)
|
|
|
+ {
|
|
|
+ const pr = pk[c2].getBoundingClientRect();
|
|
|
+ const mr = mk[c2].getBoundingClientRect();
|
|
|
+ if(Math.abs(pr.height - mr.height) <= 2 && Math.abs(pr.width - mr.width) <= 2) continue;
|
|
|
+ const name = ((pk[c2].className && pk[c2].className.toString) ? pk[c2].className.toString() : pk[c2].tagName).slice(0, 40);
|
|
|
+ subRows.push({
|
|
|
+ 页: pageIdx,
|
|
|
+ 层级: depth,
|
|
|
+ 子元素: name + note,
|
|
|
+ 测量宽: Math.round(mr.width),
|
|
|
+ 页面宽: Math.round(pr.width),
|
|
|
+ 测量高: Math.round(mr.height),
|
|
|
+ 页面高: Math.round(pr.height),
|
|
|
+ 高差: Math.round(pr.height - mr.height),
|
|
|
+ });
|
|
|
+ // 只对首个有差异的子元素下钻一层(v-html 容器内部)
|
|
|
+ diffChildren(pk[c2], mk[c2], pageIdx, depth + 1);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const pages = document.querySelectorAll('.area_page');
|
|
|
+ for(let i = 0; i < pages.length && i < this.pageList.length; i++)
|
|
|
+ {
|
|
|
+ const contentBox = pages[i].querySelector('.page_area_content');
|
|
|
+ const list = this.pageList[i].list || [];
|
|
|
+ if(!contentBox || contentBox.children.length !== list.length) continue;
|
|
|
+ for(let k = 0; k < list.length; k++)
|
|
|
+ {
|
|
|
+ const it = list[k];
|
|
|
+ const wrap = contentBox.children[k];
|
|
|
+ const inner = wrap.firstElementChild;
|
|
|
+ if(!inner || !inner.firstElementChild) continue;
|
|
|
+ const block = inner.firstElementChild;//真正的块元素(题目/解析/作答/知识点...)
|
|
|
+ // 此刻测量容器里同一个块的高度:用于判决失真来源——
|
|
|
+ // 测量现在也变矮了 ≈ 实际 → 时序问题(测量完成后两边都变了,如字体延迟加载);
|
|
|
+ // 测量现在仍是高的 → 环境差异(测量容器与正式页渲染不一致)
|
|
|
+ const mBox = this.$refs.measureBox;
|
|
|
+ const mEl = (mBox && it.srcIndex != null && mBox.children[it.srcIndex]) ? mBox.children[it.srcIndex] : null;
|
|
|
+ const measureNowH = mEl ? Math.round(mEl.getBoundingClientRect().height) : -1;
|
|
|
+ if(it.isSlice)
|
|
|
+ {
|
|
|
+ // 块实际渲染高度(切片把块整体上移 offsetTop,负 margin 不影响 offsetHeight 的取值)
|
|
|
+ const renderedH = Math.round(block.offsetHeight);
|
|
|
+ const storedTotal = it.blockHeight || it.height || 0;//原块测量总高(height 字段已被切片高度覆盖 优先用 blockHeight)
|
|
|
+ // 尾片:切片窗口覆盖到块末尾(offsetTop + sliceHeight ≈ 块测量总高);
|
|
|
+ // 若块实际高度 < 测量总高,窗口底部就会多出一段空白(即用户看到的"考查知识点下面空白")
|
|
|
+ const isLastSlice = (it.offsetTop + it.sliceHeight) >= storedTotal - 1;
|
|
|
+ const tailBlank = isLastSlice ? (it.offsetTop + it.sliceHeight) - renderedH : 0;
|
|
|
+ if(Math.abs(renderedH - storedTotal) > 2 || tailBlank > 2)
|
|
|
+ {
|
|
|
+ rows.push({
|
|
|
+ 页: i + 1,
|
|
|
+ 类型: it.areaType + '(切片)',
|
|
|
+ 测量块总高: storedTotal,
|
|
|
+ 实际块总高: renderedH,
|
|
|
+ 差值: renderedH - storedTotal,
|
|
|
+ 测量容器当前高: measureNowH,
|
|
|
+ 切片区间: it.offsetTop + '~' + (it.offsetTop + it.sliceHeight),
|
|
|
+ 窗口尾部空白: Math.round(tailBlank),
|
|
|
+ 失真来源: Math.abs(measureNowH - renderedH) <= 2 ? '时序(测量后又变了)' : (Math.abs(measureNowH - storedTotal) <= 2 ? '环境差异(两容器渲染不同)' : '两者兼有'),
|
|
|
+ });
|
|
|
+ diffChildren(block, mEl, i + 1, 0);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ const renderedH = Math.round(wrap.offsetHeight);
|
|
|
+ const storedH = it.height || 0;
|
|
|
+ if(Math.abs(renderedH - storedH) > 2)
|
|
|
+ {
|
|
|
+ rows.push({
|
|
|
+ 页: i + 1,
|
|
|
+ 类型: it.areaType,
|
|
|
+ 测量块总高: storedH,
|
|
|
+ 实际块总高: renderedH,
|
|
|
+ 差值: renderedH - storedH,
|
|
|
+ 测量容器当前高: measureNowH,
|
|
|
+ 失真来源: Math.abs(measureNowH - renderedH) <= 2 ? '时序(测量后又变了)' : (Math.abs(measureNowH - storedH) <= 2 ? '环境差异(两容器渲染不同)' : '两者兼有'),
|
|
|
+ });
|
|
|
+ diffChildren(block, mEl, i + 1, 0);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if(rows.length === 0)
|
|
|
+ {
|
|
|
+ console.log('[块级对比] 所有块的测量高度与实际渲染一致,无失真块');
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ console.log('[块级对比] 差值=实际-测量:负数=测量高估(切片窗口尾部会多出这段空白)');
|
|
|
+ console.table(rows);
|
|
|
+ }
|
|
|
+ if(subRows.length > 0)
|
|
|
+ {
|
|
|
+ console.log('[块级对比-子元素明细] 定位两个容器里哪类元素尺寸不同:宽不同=可用宽度不一致;宽相同高不同=行高/字体/内部边距差异');
|
|
|
+ console.table(subRows);
|
|
|
+ // 祖先链宽度追踪:从失真的子元素逐级向上看每一层宽度,找到宽度变窄的那一层(即谁吃了 40px);
|
|
|
+ // 同时输出该层的 computed padding/margin/box-sizing,直接看到是哪条样式造成的;只追踪第一个失真块避免刷屏;
|
|
|
+ // 测量容器侧也同步输出对应链路的宽度作为对照;若两边在某一层宽度开始分岁,问题就在该层或其直接父级;
|
|
|
+ const firstSub = subRows[0];
|
|
|
+ const firstPage = document.querySelector('.area_page .page_area_content');
|
|
|
+ const firstBlock = firstPage && firstPage.children[0] && firstPage.children[0].firstElementChild
|
|
|
+ ? firstPage.children[0].firstElementChild.firstElementChild : null;
|
|
|
+ const walkUp = (el, label) => {
|
|
|
+ const chain = [];
|
|
|
+ let cur = el;
|
|
|
+ let hop = 0;
|
|
|
+ while(cur && hop < 8)
|
|
|
+ {
|
|
|
+ const cs = window.getComputedStyle(cur);
|
|
|
+ chain.push({
|
|
|
+ 来源: label,
|
|
|
+ 层级: hop,
|
|
|
+ 元素: (cur.className && cur.className.toString ? cur.className.toString().slice(0, 30) : cur.tagName),
|
|
|
+ 宽度: Math.round(cur.getBoundingClientRect().width),
|
|
|
+ paddingLeft: cs.paddingLeft,
|
|
|
+ paddingRight: cs.paddingRight,
|
|
|
+ marginLeft: cs.marginLeft,
|
|
|
+ marginRight: cs.marginRight,
|
|
|
+ boxSizing: cs.boxSizing,
|
|
|
+ });
|
|
|
+ cur = cur.parentElement;
|
|
|
+ hop++;
|
|
|
+ }
|
|
|
+ return chain;
|
|
|
+ };
|
|
|
+ if(firstBlock)
|
|
|
+ {
|
|
|
+ console.log('[祖先链宽度-页面侧] 从块往上逐层看宽度,找到变窄的那一层就是 40px 的去向:');
|
|
|
+ console.table(walkUp(firstBlock, '页面'));
|
|
|
+ }
|
|
|
+ const mBoxEl = this.$refs.measureBox;
|
|
|
+ const mFirstBlock = mBoxEl && mBoxEl.children[0] ? mBoxEl.children[0] : null;
|
|
|
+ if(mFirstBlock)
|
|
|
+ {
|
|
|
+ console.log('[祖先链宽度-测量侧] 对照:');
|
|
|
+ console.table(walkUp(mFirstBlock, '测量'));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, 1500);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ //收集块内所有图片的垂直位置(相对 el 顶部 按 top 升序),切片时用于按图片边界切分、避免裁剪图片
|
|
|
+ GetBlockImageRects(el)
|
|
|
+ {
|
|
|
+ const rects = [];
|
|
|
+ try
|
|
|
+ {
|
|
|
+ const elRect = el.getBoundingClientRect();
|
|
|
+ const imgs = el.querySelectorAll('img');
|
|
|
+ for(let k = 0; k < imgs.length; k++)
|
|
|
+ {
|
|
|
+ const r = imgs[k].getBoundingClientRect();
|
|
|
+ if(r.height <= 0) continue;
|
|
|
+ rects.push({ top: r.top - elRect.top, bottom: r.bottom - elRect.top });
|
|
|
+ }
|
|
|
+ rects.sort((a, b) => a.top - b.top);
|
|
|
+ }
|
|
|
+ catch(e)
|
|
|
+ {
|
|
|
+ console.warn('[图片位置收集失败]', e);
|
|
|
+ }
|
|
|
+ return rects;
|
|
|
+ },
|
|
|
+
|
|
|
+ //判断块是否以图片内容为主(图片总高度占比过半 → 按图片边界切分 否则按文字行边界切分)
|
|
|
+ IsImageBlock(el, blockHeight, imgRects)
|
|
|
+ {
|
|
|
+ if(!el || !imgRects || imgRects.length === 0 || blockHeight <= 0) return false;
|
|
|
+ let total = 0;
|
|
|
+ for(const r of imgRects) total += (r.bottom - r.top);
|
|
|
+ return total >= blockHeight * 0.5;
|
|
|
+ },
|
|
|
+
|
|
|
+ //为图片内容找安全切点:在 (offset, maxCut] 内返回最大可用切点,要求:
|
|
|
+ //① 切在图片底边(能完整放下的图片尽量塞满 如能放下2张就切在第2张底边)
|
|
|
+ //② 不得切穿任何图片中间(包括并排图片 只要某图 top < 切点 < bottom 即视为切穿)
|
|
|
+ //第一张图片都无法完整放下时返回 -1 由调用方决定换页 不返回部分高度 避免裁图;
|
|
|
+ //offset、maxCut、返回值均为相对 el 顶部的坐标
|
|
|
+ FindImageCut(imgRects, offset, maxCut)
|
|
|
+ {
|
|
|
+ const rects = imgRects || [];
|
|
|
+ // 候选切点:区间内能完整放下的图片底边(去重) 从大到小尝试(优先塞满)
|
|
|
+ const candidates = [];
|
|
|
+ for(const r of rects)
|
|
|
+ {
|
|
|
+ if(r.bottom > offset + 1 && r.bottom <= maxCut + 1 && candidates.indexOf(r.bottom) < 0)
|
|
|
+ {
|
|
|
+ candidates.push(r.bottom);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ candidates.sort((a, b) => b - a);
|
|
|
+ for(const c of candidates)
|
|
|
+ {
|
|
|
+ // 检查该切点是否会切穿某张图片(并排图片常见)
|
|
|
+ let crossed = false;
|
|
|
+ for(const r of rects)
|
|
|
+ {
|
|
|
+ if(r.top < c && c < r.bottom)
|
|
|
+ {
|
|
|
+ crossed = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if(!crossed) return c;
|
|
|
+ }
|
|
|
+ // 区间内没有能完整放下的图片 → 返回 -1 交由调用方换页处理(不裁图)
|
|
|
+ return -1;
|
|
|
+ },
|
|
|
+
|
|
|
+ //安全化切片边界:不切在文字行中间;若切点上方最后一行只剩标点(孤行)则把该行推到下一页
|
|
|
+ //el 测量容器里的原块元素;boundary 期望切点(相对 el 顶部 px);返回调整后的切点(只会上移不会下移)
|
|
|
+ AdjustSliceBoundary(el, boundary)
|
|
|
+ {
|
|
|
+ try
|
|
|
+ {
|
|
|
+ const elRect = el.getBoundingClientRect();
|
|
|
+ const range = document.createRange();
|
|
|
+ // 收集块内所有文字行位置(只统计文本节点 忽略图片等替换元素)
|
|
|
+ const rects = [];
|
|
|
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
|
|
|
+ let node;
|
|
|
+ while((node = walker.nextNode()))
|
|
|
+ {
|
|
|
+ if(!(node.nodeValue || '').trim()) continue;
|
|
|
+ range.selectNodeContents(node);
|
|
|
+ const rs = range.getClientRects();
|
|
|
+ for(let r = 0; r < rs.length; r++)
|
|
|
+ {
|
|
|
+ if(rs[r].width > 0) rects.push(rs[r]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if(rects.length === 0) return boundary;
|
|
|
+ // 按行聚合(top 容差3px)转成相对 el 顶部的坐标
|
|
|
+ const lines = [];
|
|
|
+ rects.sort((a, b) => a.top - b.top);
|
|
|
+ for(const r of rects)
|
|
|
+ {
|
|
|
+ const relTop = r.top - elRect.top;
|
|
|
+ const relBottom = r.bottom - elRect.top;
|
|
|
+ const last = lines[lines.length - 1];
|
|
|
+ if(last && Math.abs(last.top - relTop) <= 3)
|
|
|
+ {
|
|
|
+ last.bottom = Math.max(last.bottom, relBottom);
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ lines.push({ top: relTop, bottom: relBottom });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let cut = boundary;
|
|
|
+ // 0) 切点落在某张图片中间 → 上移到该图片顶部(不切断图片)
|
|
|
+ const imgList = el.querySelectorAll('img');
|
|
|
+ for(let g = 0; g < imgList.length; g++)
|
|
|
+ {
|
|
|
+ const ir = imgList[g].getBoundingClientRect();
|
|
|
+ if(ir.height <= 0) continue;
|
|
|
+ const iTop = ir.top - elRect.top;
|
|
|
+ const iBottom = ir.bottom - elRect.top;
|
|
|
+ if(iTop < cut && cut < iBottom)
|
|
|
+ {
|
|
|
+ cut = iTop;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 1) 切点落在某一行中间 → 上移到该行顶部(不切断文字行)
|
|
|
+ for(const ln of lines)
|
|
|
+ {
|
|
|
+ if(ln.top < cut && cut < ln.bottom)
|
|
|
+ {
|
|
|
+ cut = ln.top;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 2) 切点上方最后一行若只剩标点 → 再上移一行(最多回退3行 避免极端数据死循环)
|
|
|
+ for(let guard = 0; guard < 3; guard++)
|
|
|
+ {
|
|
|
+ let lastLine = null;
|
|
|
+ for(const ln of lines)
|
|
|
+ {
|
|
|
+ if(ln.bottom <= cut + 1) lastLine = ln;
|
|
|
+ else break;
|
|
|
+ }
|
|
|
+ if(!lastLine) break;
|
|
|
+ const text = this.GetLineText(el, elRect, lastLine);
|
|
|
+ // 非空且全是标点/空白 → 孤行标点 推到下一页
|
|
|
+ if(text !== '' && /^[\s,。、;:!?..,;:!?…·—-~~"'“”‘’()()《》【】〔〕%%]+$/.test(text))
|
|
|
+ {
|
|
|
+ cut = lastLine.top;
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return Math.max(cut, 0);
|
|
|
+ }
|
|
|
+ catch(e)
|
|
|
+ {
|
|
|
+ console.warn('[切片边界调整失败]', e);
|
|
|
+ return boundary;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ //读取某一行(垂直区间)内的文字内容 用于判断是否为标点孤行(逐字测量所属行)
|
|
|
+ GetLineText(el, elRect, line)
|
|
|
+ {
|
|
|
+ let text = '';
|
|
|
+ const range = document.createRange();
|
|
|
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
|
|
|
+ let node;
|
|
|
+ while((node = walker.nextNode()))
|
|
|
+ {
|
|
|
+ const value = node.nodeValue || '';
|
|
|
+ if(!value.trim()) continue;
|
|
|
+ range.selectNodeContents(node);
|
|
|
+ const nr = range.getBoundingClientRect();
|
|
|
+ // 与目标行垂直区间无交集则跳过 减少逐字测量次数
|
|
|
+ if(nr.bottom - elRect.top < line.top - 3 || nr.top - elRect.top > line.bottom + 3) continue;
|
|
|
+ for(let c = 0; c < value.length; c++)
|
|
|
+ {
|
|
|
+ range.setStart(node, c);
|
|
|
+ range.setEnd(node, c + 1);
|
|
|
+ const cr = range.getBoundingClientRect();
|
|
|
+ if(cr.width <= 0 && cr.height <= 0) continue;
|
|
|
+ const cTop = cr.top - elRect.top;
|
|
|
+ if(cTop >= line.top - 3 && cTop < line.bottom)
|
|
|
+ {
|
|
|
+ text += value.charAt(c);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return text;
|
|
|
+ },
|
|
|
+
|
|
|
+ //导出所有pdf domtoimage的方式
|
|
|
+ async DownloadPdfs()
|
|
|
+ {
|
|
|
+
|
|
|
+
|
|
|
+ this.showReportLoading = true;
|
|
|
+
|
|
|
+ // 创建PDF对象,使用自定义纸张尺寸
|
|
|
+ const pdf = new jsPDF("p", "pt", 'a4');//p 为纵向布局
|
|
|
+ const pdfWidth = pdf.internal.pageSize.getWidth();
|
|
|
+ const pdfHeight = pdf.internal.pageSize.getHeight();
|
|
|
+
|
|
|
+ //获取所有的area_page 元素
|
|
|
+ const elements = document.querySelectorAll(".area_page");
|
|
|
+
|
|
|
+ let fillCount=0;//完成数量
|
|
|
+ let pageCount=elements.length;//总数量
|
|
|
+ this.targetProgress=0;
|
|
|
+ let oneProgress=Math.round(1 / pageCount * 100);
|
|
|
+ console.log("打印每页的目标进度", oneProgress);
|
|
|
+ this.StartProgress(0,oneProgress);
|
|
|
+
|
|
|
+ console.log("开始导出",this.targetProgress);
|
|
|
+
|
|
|
+ // 遍历每个页面数据
|
|
|
+ for (const [pageIndex, element] of Array.from(elements).entries()) {
|
|
|
+ // 确保页面元素存在并可见
|
|
|
+ await this.$nextTick();
|
|
|
+ if (element) {
|
|
|
+ // 确保元素可见并已渲染
|
|
|
+ // 确保元素可见并已渲染,并设置为横向排列样式
|
|
|
+ element.style.visibility = 'visible';
|
|
|
+ element.offsetHeight; // 触发重排
|
|
|
+
|
|
|
+ // 等待元素完全渲染
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
+
|
|
|
+ // 使用 dom-to-image 生成图片
|
|
|
+ const dataUrl = await domtoimage.toJpeg(element, {
|
|
|
+ quality: 0.8,// 降低质量以减小文件大小
|
|
|
+ // 获取完整尺寸
|
|
|
+ width: element.scrollWidth*2,
|
|
|
+ height: element.scrollHeight*2,
|
|
|
+ // 提高画布分辨率以获得更清晰的图像
|
|
|
+ canvasWidth: element.scrollWidth * 2,
|
|
|
+ canvasHeight: element.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;
|
|
|
+
|
|
|
+ // console.log("打印pdf图片尺寸", imgWidth, imgHeight);
|
|
|
+ // console.log("打印pdf页面尺寸", pdfWidth, pdfHeight);
|
|
|
+ // console.log("打印pdf缩放比例", ratio);
|
|
|
+ // console.log("打印pdf缩放后尺寸",adjustWidth, adjustHeight);
|
|
|
+ // console.log("打印pdf图片位置", xPosition, yPosition);
|
|
|
+ // 如果不是第一页,添加新页面
|
|
|
+ if (pageIndex > 0)
|
|
|
+ {
|
|
|
+ pdf.addPage();
|
|
|
+ }
|
|
|
+ pdf.addImage(dataUrl, "JPEG", xPosition, yPosition, adjustWidth, adjustHeight);
|
|
|
+ fillCount++;
|
|
|
+ // console.log("打印当前完成数量", fillCount);
|
|
|
+
|
|
|
+ // 更新进度
|
|
|
+ const progress = Math.floor(fillCount / pageCount * 100);
|
|
|
+ const lastProgressprogress=progress-oneProgress;
|
|
|
+ // if(this.targetProgress<progress)
|
|
|
+ // {
|
|
|
+ // this.targetProgress=progress;
|
|
|
+ // }
|
|
|
+
|
|
|
+ this.StartProgress(lastProgressprogress,progress);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 页面间短暂延迟
|
|
|
+ // await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 保存PDF文件
|
|
|
+ pdf.save(this.studentName+'-'+this.className+"-个性化错题.pdf");
|
|
|
+
|
|
|
+ this.showReportLoading = false; //关闭加载loading
|
|
|
+ },
|
|
|
+
|
|
|
+
|
|
|
+ // 开始进度条数值变化 最大增加进度值 maxIncrement
|
|
|
+ StartProgress(currentValue,targetValue)
|
|
|
+ {
|
|
|
+ console.log("开始进度动画,目标值:", targetValue);
|
|
|
+
|
|
|
+ // 清除已存在的定时器
|
|
|
+ if (this.progressInterval) {
|
|
|
+ clearInterval(this.progressInterval);
|
|
|
+ this.progressInterval = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取当前进度值
|
|
|
+ if(this.targetProgress<currentValue)
|
|
|
+ {
|
|
|
+ this.targetProgress=currentValue;
|
|
|
+ }
|
|
|
+ const targetProgress = targetValue;
|
|
|
+ // 如果当前值大于等于目标值,则不需要动画(进度不能递减)
|
|
|
+ if (this.targetProgress >= targetProgress) {
|
|
|
+ console.log("当前进度已达到或超过目标进度,无需动画");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算差值
|
|
|
+ const difference = targetProgress - currentValue;
|
|
|
+ console.log("差值:", difference);
|
|
|
+
|
|
|
+ // 根据差值计算时间间隔(差值越大,时间间隔越小)
|
|
|
+ // 这里设置时间间隔在50ms到100ms之间变化
|
|
|
+ const minInterval = 50;
|
|
|
+ const maxInterval = 500;
|
|
|
+ // 限制差值在1-10范围内
|
|
|
+ // 限制差值在1-10范围内
|
|
|
+ const clampedDifference = Math.max(1, Math.min(difference, 10));
|
|
|
+
|
|
|
+ // 计算时间间隔:差值1->500ms,差值10->50ms
|
|
|
+ let interval = maxInterval - (clampedDifference - 1) * ((maxInterval - minInterval) / 9);
|
|
|
+
|
|
|
+ // 将时间间隔调整为10的倍数
|
|
|
+ interval = Math.round(interval / 10) * 10;
|
|
|
+
|
|
|
+ // 确保时间间隔在有效范围内
|
|
|
+ interval = Math.max(minInterval, Math.min(interval, maxInterval));
|
|
|
+ // 计算每次增加的步长(差值越大,步长越大)
|
|
|
+ const step = Math.max(1, Math.floor(difference / 5)) || 1;
|
|
|
+
|
|
|
+ console.log("动画参数:当前进度=", currentValue, "目标进度=", targetProgress,
|
|
|
+ "差值=", difference, "时间间隔=", interval, "步长=", step);
|
|
|
+
|
|
|
+ // 创建新的定时器
|
|
|
+ this.progressInterval = setInterval(() => {
|
|
|
+ // 只能增加,不能减少
|
|
|
+ this.targetProgress = Math.min(this.targetProgress + step, targetProgress);
|
|
|
+ console.log("进度更新:", this.targetProgress);
|
|
|
+
|
|
|
+ // 如果达到目标值,清除定时器
|
|
|
+ if (this.targetProgress >= targetProgress) {
|
|
|
+ this.targetProgress = targetProgress;
|
|
|
+ console.log("进度动画完成,最终值:", this.targetProgress);
|
|
|
+ clearInterval(this.progressInterval);
|
|
|
+ this.progressInterval = null;
|
|
|
+ }
|
|
|
+ }, interval);
|
|
|
+ },
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+
|
|
|
+ },
|
|
|
+};
|
|
|
+</script>
|
|
|
+<style scoped lang="scss">
|
|
|
+
|
|
|
+.slip_content
|
|
|
+{
|
|
|
+ min-width: 794px;
|
|
|
+}
|
|
|
+.score_slip
|
|
|
+{
|
|
|
+ width: 100%;
|
|
|
+ height: 100vh;
|
|
|
+ background: #F0F4FB;
|
|
|
+ position: relative;
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ .slip_set_box{
|
|
|
+ min-width: 170px;
|
|
|
+ position: absolute;
|
|
|
+ top: 20px;
|
|
|
+ left: 20px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ .set{
|
|
|
+ min-width: 170px;
|
|
|
+ background-color: #ffffff;
|
|
|
+ border-radius: 4px;
|
|
|
+ padding: 12px 12px 0;
|
|
|
+ box-sizing: border-box;
|
|
|
+ :deep(.el-radio-group){
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ }
|
|
|
+ :deep(.el-checkbox-group){
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ }
|
|
|
+ &.slip_sets{
|
|
|
+ margin-top: 16px;
|
|
|
+ }
|
|
|
+ &.slip_set_rank{
|
|
|
+ margin-top: 16px;
|
|
|
+ }
|
|
|
+ &.slip_set_average{
|
|
|
+ margin-top: 16px;
|
|
|
+ }
|
|
|
+ &.slip_set_fullScore{
|
|
|
+ margin-top: 16px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .el-radio
|
|
|
+ {
|
|
|
+ line-height: 22px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ }
|
|
|
+ .el-checkbox
|
|
|
+ {
|
|
|
+ line-height: 22px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ }
|
|
|
+ .slip_close
|
|
|
+ {
|
|
|
+ position: absolute;
|
|
|
+ right:20px;
|
|
|
+ top:20px;
|
|
|
+ width: 160px;
|
|
|
+ height: 42px;
|
|
|
+ background: rgba(245,108,108,0.1);
|
|
|
+ border-radius: 4px 4px 4px 4px;
|
|
|
+ border: 1px solid #F56C6C;
|
|
|
+ line-height: 42px;
|
|
|
+ text-align: center;
|
|
|
+ font-weight: 500;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #F56C6C;
|
|
|
+ cursor: pointer;
|
|
|
+ }
|
|
|
+
|
|
|
+ .slip_print
|
|
|
+ {
|
|
|
+ position: absolute;
|
|
|
+ right:20px;
|
|
|
+ top:82px;
|
|
|
+ width: 160px;
|
|
|
+ height: 44px;
|
|
|
+ background: #2E64FA;
|
|
|
+ border-radius: 4px 4px 4px 4px;
|
|
|
+ line-height:42px;
|
|
|
+ font-weight: 500;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #ffffff;
|
|
|
+ text-align: center;
|
|
|
+ cursor: pointer;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ .slip_down_pdf
|
|
|
+ {
|
|
|
+ position: absolute;
|
|
|
+ right:20px;
|
|
|
+ top:144px;
|
|
|
+ width: 160px;
|
|
|
+ height: 44px;
|
|
|
+ background: #2E64FA;
|
|
|
+ border-radius: 4px 4px 4px 4px;
|
|
|
+
|
|
|
+ line-height:42px;
|
|
|
+
|
|
|
+ font-weight: 500;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #ffffff;
|
|
|
+ text-align: center;
|
|
|
+ cursor: pointer;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ .score_icon
|
|
|
+ {
|
|
|
+ img
|
|
|
+ {
|
|
|
+ width: 12.5px;
|
|
|
+ height: 12px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.area_page
|
|
|
+{
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+// 页面内容区域 固定高度
|
|
|
+.page_area_content
|
|
|
+{
|
|
|
+ width: 100%;
|
|
|
+ height: 1023px;
|
|
|
+ overflow: hidden; /* 内容超出部分隐藏 */
|
|
|
+}
|
|
|
+
|
|
|
+// 页面页眉信息
|
|
|
+.page_header_info
|
|
|
+{
|
|
|
+ width: 100%;
|
|
|
+ height: 30px;
|
|
|
+ border-bottom: 1px solid #DDDDDD;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+// 页面页脚信息
|
|
|
+.page_footer_info
|
|
|
+{
|
|
|
+ position: absolute;
|
|
|
+ bottom: 0px;
|
|
|
+ left: 0px;
|
|
|
+ width: 100%;
|
|
|
+ display: flex;
|
|
|
+ justify-content:center;
|
|
|
+ align-items: center;
|
|
|
+ box-sizing: border-box;
|
|
|
+ line-height: 25px;
|
|
|
+ font-size: 10px;
|
|
|
+ color:#000000;
|
|
|
+ gap: 10px;
|
|
|
+ background-color: #fff;
|
|
|
+ padding-bottom: 10px;
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+.page_title_info
|
|
|
+{
|
|
|
+ width: 100%;
|
|
|
+ height: 40px;
|
|
|
+ line-height: 40px;
|
|
|
+ // font-family: Alibaba PuHuiTi 2.0, Alibaba PuHuiTi 20;
|
|
|
+ font-weight: 550;
|
|
|
+ font-size: 18px;
|
|
|
+ color: #000000;
|
|
|
+ text-align: center;
|
|
|
+
|
|
|
+ background-image: url('../../../assets/bg/target_pdf_header.png');
|
|
|
+ background-size: cover;
|
|
|
+ background-repeat: no-repeat;
|
|
|
+ background-position: center;
|
|
|
+}
|
|
|
+
|
|
|
+.page_knowledge_info
|
|
|
+{
|
|
|
+ width: 100%;
|
|
|
+ height: 30px;
|
|
|
+
|
|
|
+ .knowledge_name
|
|
|
+ {
|
|
|
+ width:auto;
|
|
|
+ font-weight: 550;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #000000;
|
|
|
+ background: #E2E2E2;
|
|
|
+ border-radius: 4px 4px 4px 4px;
|
|
|
+ line-height: 30px;
|
|
|
+ padding: 8px 8px;
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+.question_item
|
|
|
+{
|
|
|
+ width:100%;
|
|
|
+}
|
|
|
+
|
|
|
+//两栏布局:栏宽必须与测量容器宽度一致(367 = (794-40-20)/2),否则测量高度会失真
|
|
|
+.page_columns_wrap
|
|
|
+{
|
|
|
+ width: 100%;
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: flex-start;
|
|
|
+
|
|
|
+ .page_column
|
|
|
+ {
|
|
|
+ width: 367px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ }
|
|
|
+}
|
|
|
+ .item_header
|
|
|
+ {
|
|
|
+ width: 100%;
|
|
|
+ height: auto;
|
|
|
+ min-height: 30px;
|
|
|
+ background: #F3F3F3;
|
|
|
+ border-radius: 4px 4px 4px 4px;
|
|
|
+ border: 1px solid #CCCCCC;
|
|
|
+ padding: 4px 8px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ .header_left
|
|
|
+ {
|
|
|
+
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content:flex-start;
|
|
|
+ gap: 8px;
|
|
|
+
|
|
|
+ .left_question_num
|
|
|
+ {
|
|
|
+ background: #E2E2E2;
|
|
|
+ border-radius: 4px 4px 4px 4px;
|
|
|
+ padding: 4px 10px;
|
|
|
+
|
|
|
+ font-weight: 550;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #000000;
|
|
|
+ }
|
|
|
+
|
|
|
+ .left_question_source
|
|
|
+ {
|
|
|
+ font-weight: 400;
|
|
|
+ font-size: 10px;
|
|
|
+ color: #999999;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .header_right
|
|
|
+ {
|
|
|
+ display:flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ font-size: 10px;
|
|
|
+ color: #000000;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ //一栏的题目内容标题区域
|
|
|
+ .item_header_one
|
|
|
+ {
|
|
|
+ width: 100%;
|
|
|
+ height: 30px;
|
|
|
+
|
|
|
+ background: #F3F3F3;
|
|
|
+ border-radius: 4px 4px 4px 4px;
|
|
|
+ border: 1px solid #CCCCCC;
|
|
|
+ padding: 4px 8px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ .header_left
|
|
|
+ {
|
|
|
+
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content:flex-start;
|
|
|
+ gap: 8px;
|
|
|
+
|
|
|
+ .left_question_num
|
|
|
+ {
|
|
|
+ background: #E2E2E2;
|
|
|
+ border-radius: 4px 4px 4px 4px;
|
|
|
+ padding: 4px 10px;
|
|
|
+
|
|
|
+ font-weight: 550;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #000000;
|
|
|
+ }
|
|
|
+
|
|
|
+ .left_question_source
|
|
|
+ {
|
|
|
+ font-weight: 400;
|
|
|
+ font-size: 10px;
|
|
|
+ color: #999999;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .header_right
|
|
|
+ {
|
|
|
+ display:flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ font-size: 10px;
|
|
|
+ color: #000000;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ //两栏的题目内容标题区域
|
|
|
+ .item_header_two
|
|
|
+ {
|
|
|
+ width: 100%;
|
|
|
+ height: auto;
|
|
|
+
|
|
|
+ background: #F3F3F3;
|
|
|
+ border-radius: 4px 4px 4px 4px;
|
|
|
+ border: 1px solid #CCCCCC;
|
|
|
+ padding: 4px 8px;
|
|
|
+ box-sizing: border-box;
|
|
|
+
|
|
|
+
|
|
|
+ .header_left
|
|
|
+ {
|
|
|
+
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content:space-between;
|
|
|
+ gap: 8px;
|
|
|
+
|
|
|
+ .left_question_num
|
|
|
+ {
|
|
|
+ background: #E2E2E2;
|
|
|
+ border-radius: 4px 4px 4px 4px;
|
|
|
+ padding: 4px 10px;
|
|
|
+
|
|
|
+ font-weight: 550;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #000000;
|
|
|
+ }
|
|
|
+
|
|
|
+ .left_question_source
|
|
|
+ {
|
|
|
+ font-weight: 400;
|
|
|
+ font-size: 10px;
|
|
|
+ color: #000000;
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+ gap: 8px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .header_right
|
|
|
+ {
|
|
|
+ display:flex;
|
|
|
+ justify-content:flex-start;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ font-size: 10px;
|
|
|
+ color: #999999;
|
|
|
+ padding-top: 8px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ //题目内容区域
|
|
|
+ .item_question_title
|
|
|
+ {
|
|
|
+ line-height: 20px;
|
|
|
+ color: #000000;
|
|
|
+ font-size: 10px;
|
|
|
+ overflow-x:hidden;
|
|
|
+
|
|
|
+ ::v-deep table
|
|
|
+ {
|
|
|
+ max-width: 100% !important;
|
|
|
+ }
|
|
|
+ ::v-deep img
|
|
|
+ {
|
|
|
+ max-width: 100% !important;
|
|
|
+ height: auto !important;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .item_question_knowledge
|
|
|
+ {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-start;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ border-radius: 4px 4px 4px 4px;
|
|
|
+ border: 1px solid #CCCCCC;
|
|
|
+ padding: 8px 10px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ .knowledge_title
|
|
|
+ {
|
|
|
+ font-weight: 550;
|
|
|
+ font-size: 10px;
|
|
|
+ color: #000000;
|
|
|
+ }
|
|
|
+
|
|
|
+ .knowledge_list
|
|
|
+ {
|
|
|
+ font-weight: 400;
|
|
|
+ font-size: 10px;
|
|
|
+ color: #000000;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ //答案解析区域
|
|
|
+ .item_question_answer
|
|
|
+ {
|
|
|
+ border-radius: 4px 4px 4px 4px;
|
|
|
+ border: 1px solid #CCCCCC;
|
|
|
+ padding: 5px 10px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ overflow-x:hidden;
|
|
|
+ .answer_title
|
|
|
+ {
|
|
|
+ font-weight: 550;
|
|
|
+ font-size: 10px;
|
|
|
+ color: #000000;
|
|
|
+ line-height: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .answer_content
|
|
|
+ {
|
|
|
+ font-weight: 400;
|
|
|
+ font-size: 10px;
|
|
|
+ color: #000000;
|
|
|
+ line-height: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ //图片不可超过容器的宽度 高度自适应
|
|
|
+ .answer_images
|
|
|
+ {
|
|
|
+ width: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 6px;
|
|
|
+ align-items: flex-start;
|
|
|
+
|
|
|
+ img
|
|
|
+ {
|
|
|
+ display: block;
|
|
|
+ max-width: 100%;
|
|
|
+ height: auto;
|
|
|
+ object-fit: contain;
|
|
|
+ border-radius: 2px;
|
|
|
+ border: 1px solid #e0e0e0;
|
|
|
+ background-color: #ffffff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+// 离线测量容器:宽度与正式页一致,定位到屏外不可见
|
|
|
+.measure_box
|
|
|
+{
|
|
|
+ position: fixed;
|
|
|
+ left: -9999px;
|
|
|
+ top: 0;
|
|
|
+ width: 794px; // 与正式 PDF 页宽保持一致(A4 96dpi 约 794px)
|
|
|
+ visibility: hidden;
|
|
|
+ pointer-events: none;
|
|
|
+ z-index: -1;
|
|
|
+ // ★ 强制浏览器参与布局计算(部分浏览器 visibility:hidden 元素布局会懒加载)
|
|
|
+ contain: layout;
|
|
|
+}
|
|
|
+</style>
|