// ==UserScript==
// @name 粉笔模块化刷题工具免费版
// @namespace http://tampermonkey.net/
// @version 3.0
// @description 支持试卷精准搜索与模糊筛选,可按正确率范围、模块类型快速定位题目,轻松收藏重点考题。提供灵活导出功能,可选择导出全部题目解析或仅导出错题,助力针对性复习。新增试卷单页下载与批量下载,一键保存学习资料。优化收藏管理,支持一键清空所有收藏,操作更便捷。
// @author Mythical Creature
// @match https://*.fenbi.com/*
// @connect tiku.fenbi.com
// @connect ke.fenbi.com
// @connect cdn.jsdelivr.net
// @connect cdnjs.cloudflare.com
// @grant unsafeWindow
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @require https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.1.1/jspdf.umd.min.js
// ==/UserScript==
// 引入外部样式,定义工具的UI样式
GM_addStyle(`
.paper-tool-container {
position: fixed; /* 固定定位,悬浮在页面上 */
top: 20px; /* 距离顶部20px */
right: 20px; /* 距离右侧20px */
z-index: 9999; /* 层级最高,确保不被其他元素遮挡 */
background: white; /* 白色背景 */
border-radius: 8px; /* 圆角边框 */
box-shadow: 0 2px 10px rgba(0,0,0,0.2); /* 阴影效果 */
width: 500px; /* 宽度500px */
max-height: calc(100vh - 40px); /* 最大高度为视口高度减去40px */
display: flex; /* 使用flex布局 */
flex-direction: column; /* 垂直方向排列 */
transition: transform 0.3s ease; /* 变换过渡动画 */
}
/* 隐藏状态样式 */
.paper-tool-container.hidden {
transform: translateX(calc(100% - 40px)); /* 向右移动,只露出40px宽度 */
}
/* 隐藏状态下不显示内容区域 */
.paper-tool-container.hidden .tool-content,
.paper-tool-container.hidden .collect-actions,
.paper-tool-container.hidden .log-area,
.paper-tool-container.hidden .accuracy-filter,
.paper-tool-container.hidden .tool-title,
.paper-tool-container.hidden .search-container,
.paper-tool-container.hidden .batch-download-container {
display: none;
}
/* 隐藏状态下不显示头部边框 */
.paper-tool-container.hidden .tool-header {
border-bottom: none;
}
/* 搜索框样式 */
.search-container {
padding: 10px 15px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
gap: 10px;
}
.search-input {
flex: 1;
padding: 6px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: #1890ff;
}
.search-btn {
padding: 6px 12px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.search-btn:hover {
background-color: #096dd9;
}
.search-btn.reset-btn {
background-color: #f5f5f5;
color: #666;
}
.search-btn.reset-btn:hover {
background-color: #e5e5e5;
}
/* 批量下载选项样式 */
.batch-download-container {
padding: 10px 15px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
gap: 10px;
}
.batch-download-label {
font-size: 13px;
color: #666;
}
.batch-download-pages {
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
width: 60px;
}
.batch-download-btn {
padding: 5px 10px;
background-color: #0284c7;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background-color 0.2s;
}
.batch-download-btn:hover {
background-color: #0369a1;
}
.toggle-btn {
background: none; /* 无背景 */
border: none; /* 无边框 */
font-size: 18px; /* 字体大小18px */
cursor: pointer; /* 鼠标指针为手型 */
color: #666; /* 字体颜色 */
width: 30px; /* 宽度30px */
height: 30px; /* 高度30px */
border-radius: 50%; /* 圆形按钮 */
display: flex; /* flex布局 */
align-items: center; /* 垂直居中 */
justify-content: center; /* 水平居中 */
transition: background-color 0.2s; /* 背景色过渡 */
}
.toggle-btn:hover {
background-color: #f5f5f5; /* 鼠标悬停时背景色 */
}
.tool-title {
margin: 0; /* 无外边距 */
color: #333; /* 字体颜色 */
font-size: 16px; /* 字体大小 */
font-weight: bold; /* 加粗 */
flex-grow: 1; /* 占满剩余空间 */
text-align: center; /* 文字居中 */
}
.tool-content {
display: flex; /* flex布局 */
flex: 1; /* 占满剩余空间 */
overflow: hidden; /* 超出部分隐藏 */
}
.labels-panel {
width: 120px; /* 宽度120px */
border-right: 1px solid #eee; /* 右侧边框 */
overflow-y: auto; /* 垂直方向可滚动 */
}
.label-item {
padding: 10px 15px; /* 内边距 */
cursor: pointer; /* 鼠标指针为手型 */
color: #666; /* 字体颜色 */
font-size: 14px; /* 字体大小 */
transition: all 0.2s; /* 所有属性过渡 */
}
.label-item:hover {
background-color: #f5f5f5; /* 鼠标悬停背景色 */
}
.label-item.active {
background-color: #e6f7ff; /* 选中状态背景色 */
color: #1890ff; /* 选中状态字体色 */
border-left: 3px solid #1890ff; /* 左侧选中边框 */
}
.papers-panel {
flex: 1; /* 占满剩余空间 */
overflow-y: auto; /* 垂直方向可滚动 */
}
.papers-header {
padding: 10px 15px; /* 内边距 */
border-bottom: 1px solid #eee; /* 底部边框 */
display: flex; /* flex布局 */
justify-content: space-between; /* 两端对齐 */
align-items: center; /* 垂直居中 */
}
.papers-count {
color: #666; /* 字体颜色 */
font-size: 13px; /* 字体大小 */
}
.paper-item {
padding: 12px 15px; /* 内边距 */
border-bottom: 1px solid #f5f5f5; /* 底部边框 */
cursor: pointer; /* 鼠标指针为手型 */
transition: background-color 0.2s; /* 背景色过渡 */
}
.paper-item:hover {
background-color: #fafafa; /* 鼠标悬停背景色 */
}
.paper-title {
margin: 0 0 5px 0; /* 外边距 */
font-size: 14px; /* 字体大小 */
color: #333; /* 字体颜色 */
white-space: nowrap; /* 不换行 */
overflow: hidden; /* 超出部分隐藏 */
text-overflow: ellipsis; /* 超出部分显示省略号 */
}
.paper-meta {
display: flex; /* flex布局 */
justify-content: space-between; /* 两端对齐 */
font-size: 12px; /* 字体大小 */
color: #999; /* 字体颜色 */
}
.pagination {
padding: 10px 15px; /* 内边距 */
display: flex; /* flex布局 */
justify-content: center; /* 水平居中 */
gap: 5px; /* 元素间距 */
border-top: 1px solid #eee; /* 顶部边框 */
}
.page-btn {
padding: 3px 10px; /* 内边距 */
border: 1px solid #ddd; /* 边框 */
border-radius: 4px; /* 圆角 */
background: white; /* 背景色 */
cursor: pointer; /* 鼠标指针为手型 */
font-size: 12px; /* 字体大小 */
transition: all 0.2s; /* 所有属性过渡 */
}
.page-btn:disabled {
opacity: 0.5; /* 半透明 */
cursor: not-allowed; /* 禁止指针 */
}
.page-btn.active {
background-color: #1890ff; /* 激活状态背景色 */
color: white; /* 激活状态字体色 */
border-color: #1890ff; /* 激活状态边框色 */
}
.collect-actions {
padding: 10px 15px; /* 内边距 */
border-top: 1px solid #eee; /* 顶部边框 */
display: flex; /* flex布局 */
gap: 8px; /* 元素间距 */
}
.action-btn {
flex: 1; /* 占满剩余空间 */
padding: 6px 0; /* 内边距 */
border: none; /* 无边框 */
border-radius: 4px; /* 圆角 */
cursor: pointer; /* 鼠标指针为手型 */
font-size: 13px; /* 字体大小 */
transition: all 0.2s; /* 所有属性过渡 */
}
.collect-btn {
background-color: #4F46E5; /* 收藏按钮背景色 */
color: white; /* 字体颜色 */
}
.collect-btn:hover {
background-color: #3A32B5; /* 鼠标悬停背景色 */
}
.batch-collect-btn {
background-color: #10B981; /* 批量收藏按钮背景色 */
color: white; /* 字体颜色 */
}
.batch-collect-btn:hover {
background-color: #059669; /* 鼠标悬停背景色 */
}
.solution-btn {
background-color: #4A32B5; /* 清空按钮背景色 */
color: white; /* 字体颜色 */
}
.solution-btn:hover {
background-color: #3A52B5; /* 清空按钮背景色 */
}
.clear-btn {
background-color: #ef4444; /* 清空按钮背景色 */
color: white; /* 字体颜色 */
}
.clear-btn:hover {
background-color: #dc2626; /* 鼠标悬停背景色 */
}
.log-area {
height: 180px; /* 高度180px */
border-top: 1px solid #eee; /* 顶部边框 */
overflow-y: auto; /* 垂直方向可滚动 */
font-size: 12px; /* 字体大小 */
}
.log-item {
padding: 3px 15px; /* 内边距 */
border-bottom: 1px solid #f9f9f9; /* 底部边框 */
}
.log-success {
color: #10B981; /* 成功日志颜色 */
}
.log-error {
color: #ef4444; /* 错误日志颜色 */
}
.log-info {
color: #3B82F6; /* 信息日志颜色 */
}
.log-warning {
color: #F59E0B; /* 警告日志颜色 */
}
.loading {
padding: 20px; /* 内边距 */
text-align: center; /* 文字居中 */
color: #666; /* 字体颜色 */
font-size: 13px; /* 字体大小 */
}
.paper-actions {
display: flex; /* flex布局 */
gap: 5px; /* 元素间距 */
margin-top: 8px; /* 上外边距 */
}
.paper-action-btn {
padding: 3px 8px; /* 内边距 */
font-size: 12px; /* 字体大小 */
border-radius: 3px; /* 圆角 */
border: none; /* 无边框 */
cursor: pointer; /* 鼠标指针为手型 */
transition: all 0.2s; /* 所有属性过渡 */
}
.view-btn {
background-color: #f0f0f0; /* 查看按钮背景色 */
color: #666; /* 字体颜色 */
}
.view-btn:hover {
background-color: #e0e0e0; /* 鼠标悬停背景色 */
}
.download-btn {
background-color: #e6f7ff; /* 下载按钮背景色 */
color: #0284c7; /* 下载按钮字体颜色 */
}
.download-btn:hover {
background-color: #d1eaff; /* 鼠标悬停背景色 */
}
.item-collect-btn {
background-color: #e6f7ff; /* 收藏按钮背景色 */
color: #1890ff; /* 字体颜色 */
}
.item-collect-btn:hover {
background-color: #d1eaff; /* 鼠标悬停背景色 */
}
.item-dis-collect-btn {
background-color: #e6f7ff; /* 取消收藏按钮背景色 */
color: #dd1432ff; /* 字体颜色 */
}
.item-dis-collect-btn:hover {
background-color: #d1eaff; /* 鼠标悬停背景色 */
}
.accuracy-filter {
padding: 10px 15px; /* 内边距 */
border-bottom: 1px solid #eee; /* 底部边框 */
display: flex; /* flex布局 */
align-items: center; /* 垂直居中 */
gap: 10px; /* 元素间距 */
}
.accuracy-filter label {
font-size: 12px; /* 字体大小 */
color: #666; /* 字体颜色 */
}
.accuracy-filter input[type="range"] {
flex: 1; /* 占满剩余空间 */
}
.accuracy-filter span {
font-size: 12px; /* 字体大小 */
color: #666; /* 字体颜色 */
min-width: 50px; /* 最小宽度50px */
}
/* 加载动画样式 */
.spinner {
width: 20px; /* 宽度20px */
height: 20px; /* 高度20px */
border: 2px solid rgba(0, 0, 0, 0.1); /* 边框 */
border-radius: 50%; /* 圆形 */
border-top-color: #1890ff; /* 顶部边框颜色 */
animation: spin 1s ease-in-out infinite; /* 旋转动画 */
display: inline-block; /* 行内块元素 */
vertical-align: middle; /* 垂直居中 */
margin-right: 5px; /* 右外边距 */
}
/* 旋转动画定义 */
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 按钮加载状态 */
.action-btn.loading {
opacity: 0.7; /* 半透明 */
cursor: wait; /* 等待指针 */
}
.action-btn.loading .spinner {
display: inline-block; /* 显示加载动画 */
}
/* 导出选项样式 */
.export-options {
margin-top: 15px;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
}
.export-option-item {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
}
.export-option-item input {
margin-right: 8px;
}
.export-option-label {
cursor: pointer;
}
.no-wrong-questions {
color: #999;
font-size: 13px;
margin-top: 8px;
padding-left: 24px;
font-style: italic;
}
/* 题目类别选择弹窗样式 */
.category-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.category-modal.show {
opacity: 1;
visibility: visible;
}
.category-modal-content {
background-color: white;
border-radius: 8px;
width: 90%;
max-width: 400px;
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.2);
transform: translateY(-20px);
transition: transform 0.3s ease;
}
.category-modal.show .category-modal-content {
transform: translateY(0);
}
.category-modal-header {
padding: 15px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.category-modal-title {
margin: 0;
font-size: 16px;
color: #333;
}
.category-modal-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.category-modal-close:hover {
background-color: #f5f5f5;
color: #333;
}
.category-modal-body {
padding: 20px;
}
.category-item {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.category-item input {
margin-right: 10px;
width: 16px;
height: 16px;
cursor: pointer;
}
.category-item label {
font-size: 14px;
color: #333;
cursor: pointer;
}
.category-modal-footer {
padding: 10px 20px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.category-btn {
padding: 6px 16px;
border-radius: 4px;
border: none;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.category-cancel {
background-color: #f5f5f5;
color: #666;
}
.category-cancel:hover {
background-color: #e5e5e5;
}
.category-confirm {
background-color: #4F46E5;
color: white;
}
.category-confirm:hover {
background-color: #3A32B5;
}
.category-select-all {
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px dashed #eee;
}
/* 批量下载进度条样式 */
.batch-download-progress {
margin-top: 10px;
height: 6px;
background-color: #f0f0f0;
border-radius: 3px;
overflow: hidden;
display: none;
}
.batch-download-progress.active {
display: block;
}
.progress-bar {
height: 100%;
background-color: #0284c7;
width: 0%;
transition: width 0.3s ease;
}
`);
/**
* 主应用类 - FenbiPaperTool
* 封装了所有功能和状态管理
*/
class FenbiPaperTool {
/**
* 构造函数 - 初始化状态和绑定方法
*/
constructor() {
// 定义题目类别
this.questionCategories = [
{ id: 'political', name: '政治理论' },
{ id: 'common-sense', name: '常识判断' },
{ id: 'verbal', name: '言语理解' },
{ id: 'quantity', name: '数量关系' },
{ id: 'logic', name: '判断推理' },
{ id: 'data', name: '资料分析' }
];
// 状态管理 - 集中存储所有应用状态
this.state = {
currentLabelId: null, // 当前选中的标签ID
currentPage: 0, // 当前页码
totalPages: 1, // 总页数
pageSize: 10, // 每页显示数量
allLabels: [], // 所有标签列表
allPapersByLabel: {}, // 按标签ID存储所有试卷
currentPapers: [], // 当前显示的试卷列表
isHidden: true, // 工具是否隐藏
minAccuracy: 0, // 最低正确率筛选值
maxAccuracy: 100, // 最高正确率筛选值
isProcessing: false, // 是否正在处理操作
globalExerciseId: null, // 全局存储的练习ID
globalApiResponseData: null, // 全局存储的API响应数据
globalWrongQuestionIds: null, //全局存储的错误问题ID列表
globalSheetIds: null, //全局存储的练习ID列表
globalSheetQuestionMap: null, //全局存储的练习ID对应问题ID列表
selectedCategories: [], // 当前选中的类别
currentPaper: null, // 当前操作的试卷
currentAction: null, // 当前操作类型: 'collect' 或 'view'
currentChapters: [], // 当前试卷所有章节简介
batchCollection: false, // 是否正在进行批量收藏
searchQuery: '', // 搜索关键词
batchDownloadPages: 1, // 批量下载的页数
isBatchDownloading: false, // 是否正在批量下载
currentStatus: 0,//当前练习的状态
};
// 绑定方法的this上下文,确保在事件回调中this指向当前实例
this.bindMethods();
// 初始化应用
this.init();
}
/**
* 绑定方法的this上下文
* 确保所有类方法在作为回调函数时,this仍然指向类实例
*/
bindMethods() {
this.createContainer = this.createContainer.bind(this);
this.bindEvents = this.bindEvents.bind(this);
this.fetchLabels = this.fetchLabels.bind(this);
this.renderLabels = this.renderLabels.bind(this);
this.fetchPapers = this.fetchPapers.bind(this);
this.renderPapers = this.renderPapers.bind(this);
this.collectPaperQuestions = this.collectPaperQuestions.bind(this);
this.disCollectPaperQuestions = this.disCollectPaperQuestions.bind(this);
this.batchCollectAllPapers = this.batchCollectAllPapers.bind(this);
this.clearAllCollections = this.clearAllCollections.bind(this);
this.getPaperSolution = this.getPaperSolution.bind(this);
this.hookRequest = this.hookRequest.bind(this);
this.pollForValue = this.pollForValue.bind(this);
this.pushLog = this.pushLog.bind(this);
this.showLoading = this.showLoading.bind(this);
this.createCategoryModal = this.createCategoryModal.bind(this);
this.showCategoryModal = this.showCategoryModal.bind(this);
this.hideCategoryModal = this.hideCategoryModal.bind(this);
this.handleCategorySelection = this.handleCategorySelection.bind(this);
this.toggleSelectAllCategories = this.toggleSelectAllCategories.bind(this);
this.performCollectionWithCategories = this.performCollectionWithCategories.bind(this);
this.handleSearch = this.handleSearch.bind(this);
this.resetSearch = this.resetSearch.bind(this);
this.filterPapersBySearch = this.filterPapersBySearch.bind(this);
this.downloadPaperPDF = this.downloadPaperPDF.bind(this);
this.batchDownloadPapers = this.batchDownloadPapers.bind(this); // 批量下载方法绑定
this.updateBatchDownloadProgress = this.updateBatchDownloadProgress.bind(this); // 进度更新方法绑定
}
/**
* 初始化应用
* 设置请求钩子,页面加载完成后初始化UI
*/
init() {
// 设置请求钩子,用于拦截特定请求获取数据
this.hookRequest();
// 页面加载完成后初始化UI
window.addEventListener('load', () => {
// 延迟1秒执行,确保页面完全加载
setTimeout(() => {
// 创建工具容器并添加到页面
this.container = this.createContainer();
document.body.appendChild(this.container);
// 创建类别选择弹窗
this.categoryModal = this.createCategoryModal();
document.body.appendChild(this.categoryModal);
// 加载试卷标签
this.fetchLabels();
// 启动轮询,检查特定页面并获取数据
this.pollForValue();
}, 1000);
});
}
/**
* 创建工具的主界面容器
* @returns {HTMLElement} 工具容器DOM元素
*/
createContainer() {
// 创建容器元素
const container = document.createElement('div');
// 设置初始样式类,默认隐藏
container.className = 'paper-tool-container hidden';
// 设置容器内部HTML结构,新增搜索框和批量下载区域
container.innerHTML = `
${this.state.minAccuracy}%
${this.state.maxAccuracy}%
`;
// 为容器绑定事件处理函数
this.bindEvents(container);
return container;
}
/**
* 创建题目类别选择弹窗
* @returns {HTMLElement} 弹窗DOM元素
*/
createCategoryModal() {
const modal = document.createElement('div');
modal.className = 'category-modal';
// 构建弹窗HTML
modal.innerHTML = `
${this.questionCategories.map(category => `
`).join('')}
`;
// 绑定事件
modal.querySelector('.category-modal-close').addEventListener('click', this.hideCategoryModal);
modal.querySelector('.category-cancel').addEventListener('click', this.hideCategoryModal);
modal.querySelector('.category-confirm').addEventListener('click', this.performCollectionWithCategories);
modal.querySelector('#select-all-categories').addEventListener('change', this.toggleSelectAllCategories);
// 绑定类别选择事件
this.questionCategories.forEach(category => {
modal.querySelector(`#category-${category.id}`).addEventListener('change', this.handleCategorySelection);
});
return modal;
}
/**
* 显示类别选择弹窗
* @param {Object} paper - 当前操作的试卷,批量收藏时可以为null
* @param {string} action - 操作类型: 'collect' 或 'view'
* @param {boolean} isBatch - 是否是批量收藏
*/
showCategoryModal(paper, action = 'collect', isBatch = false) {
// 保存当前操作的试卷和操作类型
this.state.currentPaper = paper;
this.state.currentAction = action;
// 记录是否是批量收藏
this.state.batchCollection = isBatch;
// 显示弹窗
this.categoryModal.classList.add('show');
// 重置为全选状态
const checkboxes = this.categoryModal.querySelectorAll('.category-item input:not(#select-all-categories)');
checkboxes.forEach(checkbox => {
checkbox.checked = true;
});
this.categoryModal.querySelector('#select-all-categories').checked = true;
// 记录当前选中的类别
this.state.selectedCategories = [...this.questionCategories.map(c => c.id)];
// 根据操作类型修改弹窗标题
if (action === 'view') {
this.categoryModal.querySelector('.category-modal-title').textContent =
'选择要查看的题目类别';
} else if (isBatch) {
this.categoryModal.querySelector('.category-modal-title').textContent =
'选择批量收藏的题目类别';
} else {
this.categoryModal.querySelector('.category-modal-title').textContent =
'选择要收藏的题目类别';
}
}
/**
* 隐藏类别选择弹窗
*/
hideCategoryModal() {
this.categoryModal.classList.remove('show');
//this.state.currentPaper = null; // 清除当前操作的试卷
}
/**
* 处理类别选择变化
*/
handleCategorySelection() {
// 获取所有类别复选框
const checkboxes = this.categoryModal.querySelectorAll('.category-item input:not(#select-all-categories)');
const checkedCategories = [];
// 收集选中的类别
checkboxes.forEach(checkbox => {
if (checkbox.checked) {
const categoryId = checkbox.id.replace('category-', '');
checkedCategories.push(categoryId);
}
});
// 更新状态
this.state.selectedCategories = checkedCategories;
// 更新全选复选框状态
const selectAllCheckbox = this.categoryModal.querySelector('#select-all-categories');
selectAllCheckbox.checked = checkedCategories.length === this.questionCategories.length;
}
/**
* 切换全选/取消全选
*/
toggleSelectAllCategories() {
const selectAllCheckbox = this.categoryModal.querySelector('#select-all-categories');
const checkboxes = this.categoryModal.querySelectorAll('.category-item input:not(#select-all-categories)');
// 全选或取消全选
checkboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
});
// 更新状态
this.state.selectedCategories = selectAllCheckbox.checked
? [...this.questionCategories.map(c => c.id)]
: [];
}
/**
* 执行带类别的操作(收藏或查看解析)
*/
async performCollectionWithCategories() {
// 隐藏弹窗
this.hideCategoryModal();
// 记录选中的类别
const selectedCategoryNames = this.state.selectedCategories
.map(id => this.questionCategories.find(c => c.id === id)?.name || id)
.join('、');
this.pushLog(`已选择${this.state.currentAction === 'view' ? '查看' : '收藏'}类别:${selectedCategoryNames}`, 'info');
// 根据操作类型执行不同操作
if (this.state.currentAction === 'view' && this.state.currentPaper) {
// 执行查看解析操作
await this.getPaperSolution(this.state.currentPaper);
} else if (this.state.batchCollection) {
// 执行批量收藏
await this.batchCollectAllPapers();
} else if (this.state.currentPaper) {
// 执行单份试卷收藏
await this.collectPaperQuestions(this.state.currentPaper);
} else {
this.pushLog('未找到试卷信息,操作失败', 'error');
}
}
/**
* 为容器绑定事件处理函数
* @param {HTMLElement} container - 工具容器DOM元素
*/
bindEvents(container) {
// 获取DOM元素
const minAccuracySlider = container.querySelector('#min-accuracy');
const maxAccuracySlider = container.querySelector('#max-accuracy');
const minAccuracyValue = container.querySelector('#min-accuracy-value');
const maxAccuracyValue = container.querySelector('#max-accuracy-value');
const toggleBtn = container.querySelector('.toggle-btn');
const batchCollectBtn = container.querySelector('.batch-collect-btn');
const clearBtn = container.querySelector('.clear-btn');
const solutionBtn = container.querySelector('.solution-btn');
const searchInput = container.querySelector('.search-input');
const searchBtn = container.querySelector('.search-btn:not(.reset-btn)');
const resetBtn = container.querySelector('.reset-btn');
const batchDownloadInput = container.querySelector('.batch-download-pages');
const batchDownloadBtn = container.querySelector('.batch-download-btn');
// 最低正确率滑块事件
minAccuracySlider.addEventListener('input', () => {
// 更新状态中的最低正确率值
this.state.minAccuracy = parseInt(minAccuracySlider.value, 10);
// 更新显示的数值
minAccuracyValue.textContent = `${this.state.minAccuracy}%`;
// 确保最低值不大于最高值
if (this.state.minAccuracy > this.state.maxAccuracy) {
this.state.minAccuracy = this.state.maxAccuracy;
minAccuracySlider.value = this.state.maxAccuracy;
minAccuracyValue.textContent = `${this.state.maxAccuracy}%`;
}
});
// 最高正确率滑块事件
maxAccuracySlider.addEventListener('input', () => {
// 更新状态中的最高正确率值
this.state.maxAccuracy = parseInt(maxAccuracySlider.value, 10);
// 更新显示的数值
maxAccuracyValue.textContent = `${this.state.maxAccuracy}%`;
// 确保最高值不小于最低值
if (this.state.maxAccuracy < this.state.minAccuracy) {
this.state.maxAccuracy = this.state.minAccuracy;
maxAccuracySlider.value = this.state.minAccuracy;
maxAccuracyValue.textContent = `${this.state.minAccuracy}%`;
}
});
// 批量收藏按钮点击事件
batchCollectBtn.addEventListener('click', () => {
// 如果正在处理操作,则不执行
if (this.state.isProcessing) {
this.pushLog('正在处理中,请稍后再试', 'warning');
return;
}
// 显示类别选择弹窗,标记为批量收藏
if (this.state.currentPapers.length > 0) {
this.showCategoryModal(null, 'collect', true);
} else {
this.pushLog('没有可收藏的试卷', 'error');
}
});
// 清空收藏按钮点击事件
clearBtn.addEventListener('click', () => {
// 如果正在处理操作,则不执行
if (this.state.isProcessing) {
this.pushLog('正在处理中,请稍后再试', 'warning');
return;
}
// 执行清空收藏
this.clearAllCollections();
});
// 获取解析按钮点击事件
solutionBtn.addEventListener('click', () => {
// 如果正在处理操作,则不执行
if (this.state.isProcessing) {
this.pushLog('正在处理中,请稍后再试', 'warning');
return;
}
// 执行获取解析
this.getSolutionByExerciseId();
});
// 切换工具显示/隐藏状态
toggleBtn.addEventListener('click', () => {
// 切换状态
this.state.isHidden = !this.state.isHidden;
if (this.state.isHidden) {
// 隐藏状态
container.classList.add('hidden');
toggleBtn.textContent = "+";
} else {
// 显示状态
container.classList.remove('hidden');
toggleBtn.textContent = "-";
}
});
// 搜索相关事件绑定
searchInput.addEventListener('keyup', (e) => {
// 按Enter键触发搜索
if (e.key === 'Enter') {
this.handleSearch();
}
});
searchBtn.addEventListener('click', this.handleSearch);
resetBtn.addEventListener('click', this.resetSearch);
// 批量下载相关事件
batchDownloadInput.addEventListener('change', (e) => {
let value = parseInt(e.target.value, 10) || 1;
value = Math.max(1, value); // 确保最小值为1
this.state.batchDownloadPages = value;
e.target.value = value;
});
batchDownloadBtn.addEventListener('click', this.batchDownloadPapers);
}
/**
* 更新批量下载进度条
* @param {number} current - 当前下载数量
* @param {number} total - 总下载数量
*/
updateBatchDownloadProgress(current, total) {
const progressContainer = this.container.querySelector('.batch-download-progress');
const progressBar = this.container.querySelector('.progress-bar');
if (current === 0) {
progressContainer.classList.add('active');
progressBar.style.width = '0%';
} else {
const percentage = Math.min(100, Math.round((current / total) * 100));
progressBar.style.width = `${percentage}%`;
// 下载完成后隐藏进度条
if (current === total) {
setTimeout(() => {
progressContainer.classList.remove('active');
}, 1000);
}
}
}
/**
* 处理搜索功能
*/
handleSearch() {
const searchInput = this.container.querySelector('.search-input');
const query = searchInput.value.trim().toLowerCase();
this.state.searchQuery = query;
// 如果搜索词为空,直接重置
if (!query) {
this.resetSearch();
return;
}
this.pushLog(`搜索试卷: "${query}"`, 'info');
// 筛选试卷并重新渲染
this.filterPapersBySearch();
}
/**
* 重置搜索
*/
resetSearch() {
const searchInput = this.container.querySelector('.search-input');
searchInput.value = '';
this.state.searchQuery = '';
this.pushLog('搜索已重置', 'info');
// 重新获取当前标签的试卷列表
this.updateCurrentPapers();
}
/**
* 根据搜索词筛选试卷(支持顿号分隔的多关键词,要求全部匹配)
*/
filterPapersBySearch() {
if (!this.state.searchQuery || !this.state.currentLabelId) {
// 如果没有搜索词或没有选中标签,显示所有试卷
this.updateCurrentPapers();
return;
}
// 获取当前标签的所有试卷
const allPapers = this.state.allPapersByLabel[this.state.currentLabelId] || [];
// 将搜索词按顿号分割成数组数组,并,并过滤空字符串
const keywords = this.state.searchQuery
.toLowerCase()
.split('、')
.map(keyword => keyword.trim())
.filter(keyword => keyword);
// 如果分割后没有有效关键词,显示所有试卷
if (keywords.length === 0) {
this.updateCurrentPapers();
return;
}
// 进行多关键词模糊匹配,要求所有关键词都匹配
const filteredPapers = allPapers.filter(paper => {
const paperName = paper.name;
const paperNameLowerCase = paperName.toLowerCase();
// 关键修改:将 some 改为 every
return keywords.every(keyword => paperNameLowerCase.includes(keyword));
});
// 计算总页数
this.state.totalPages = Math.ceil(filteredPapers.length / this.state.pageSize) || 1;
// 确保当前页码有效
if (this.state.currentPage >= this.state.totalPages) {
this.state.currentPage = Math.max(0, this.state.totalPages - 1);
}
// 应用分页
const startIndex = this.state.currentPage * this.state.pageSize;
this.state.currentPapers = filteredPapers.slice(startIndex, startIndex + this.state.pageSize);
this.pushLog(`找到 ${filteredPapers.length} 个同时匹配"${this.state.searchQuery}"所有关键词的试卷`, 'info');
this.renderPapers();
}
/**
* 更新当前显示的试卷列表(应用分页)
*/
updateCurrentPapers() {
if (!this.state.currentLabelId) return;
// 获取当前标签的所有试卷
const allPapers = this.state.allPapersByLabel[this.state.currentLabelId] || [];
// 计算总页数
this.state.totalPages = Math.ceil(allPapers.length / this.state.pageSize) || 1;
// 确保当前页码有效
if (this.state.currentPage >= this.state.totalPages) {
this.state.currentPage = Math.max(0, this.state.totalPages - 1);
}
// 应用分页
const startIndex = this.state.currentPage * this.state.pageSize;
this.state.currentPapers = allPapers.slice(startIndex, startIndex + this.state.pageSize);
this.renderPapers();
}
/**
* 在试卷面板显示加载状态
*/
showLoading() {
const papersPanel = this.container.querySelector('.papers-panel');
papersPanel.innerHTML = '加载中...
';
}
/**
* 向日志区域添加一条日志
* @param {string} message - 日志消息
* @param {string} type - 日志类型:info, success, error, warning
*/
pushLog(message, type = 'info') {
const logArea = this.container.querySelector('.log-area');
// 创建日志项元素
const logItem = document.createElement('div');
logItem.className = `log-item log-${type}`;
// 设置日志内容,包含时间戳
logItem.innerHTML = `[${new Date().toLocaleTimeString()}] ${message}`;
// 添加到日志区域
logArea.appendChild(logItem);
// 滚动到最新日志
logArea.scrollTop = logArea.scrollHeight;
}
/**
* HTTP请求封装
* @param {Object} options - 请求选项
* @returns {Promise} 返回Promise对象
*/
httpRequest(options) {
return new Promise((resolve, reject) => {
// 使用GM_xmlhttpRequest发送请求,支持跨域
GM_xmlhttpRequest({
...options,
// 请求成功回调
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
// 成功状态,返回响应内容
resolve(response.responseText);
} else {
// 错误状态,返回错误信息
reject(new Error(`HTTP错误: ${response.status}`));
}
},
// 请求错误回调
onerror: (error) => reject(error),
// 请求中止回调
onabort: () => reject(new Error('请求被中止')),
// 超时时间15秒
timeout: 15000
});
});
}
/**
* 钩子请求,拦截特定请求获取exerciseId
*/
hookRequest() {
// 保存当前实例的引用
const self = this;
// 保存原始的open和send方法
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
// 重写open方法,保存请求URL
XMLHttpRequest.prototype.open = function (method, url) {
this._requestUrl = url;
return originalOpen.apply(this, arguments);
};
// 重写send方法,添加事件监听
XMLHttpRequest.prototype.send = function (body) {
const url = this._requestUrl;
const that = this;
// 检查是否是目标请求combine/exercise/getExercise
if (url && url.indexOf('/combine/exercise') !== -1) {
console.log('🎯 拦截到目标请求 URL:', url);
// 监听请求状态变化,使用箭头函数保持this指向
this.addEventListener('readystatechange', () => {
if (that.readyState === 4) { // 请求完成
if (that.status >= 200 && that.status < 300) { // 成功状态
try {
const responseText = that.responseText;
const data = JSON.parse(responseText);
// 提取exerciseId,使用self访问类实例
if (data && data.data && data.data.ancientExerciseId && data.data.ancientExerciseId.id) {
self.state.globalExerciseId = data.data.ancientExerciseId.id;
self.state.globalApiResponseData = data;
console.log('✅ 提取到的 exerciseId:', self.state.globalExerciseId);
} else {
console.warn('⚠️ 返回数据结构不符合预期,未找到 exerciseId');
}
} catch (e) {
console.error('❌ 解析返回内容失败:', e);
}
} else {
console.error('❌ 请求失败,状态码:', that.status);
}
}
});
}
// 检查是否是目标请求combine/exercise/
if (url && url.indexOf('/combine/exercise/getSolution') !== -1) {
console.log('🎯 拦截到目标请求 URL:', url);
// 监听请求状态变化,使用箭头函数保持this指向
this.addEventListener('readystatechange', () => {
if (that.readyState === 4) { // 请求完成
if (that.status >= 200 && that.status < 300) { // 成功状态
try {
const responseText = that.responseText;
const data = JSON.parse(responseText);
// 提取status=-1的id,使用self访问类实例
if (data && data.data && data.data.userAnswers) {
// 假设 data 是你获取到的 userAnswers 对象
const userAnswers = data.data.userAnswers;
// 筛选出 status 为 -1 的项,并收集它们的 id
const filteredIds = [];
for (const key in userAnswers) {
if (userAnswers.hasOwnProperty(key)) {
const item = userAnswers[key];
// 检查 status 是否为 -1
if (item.status === -1) {
filteredIds.push(item.id);
}
}
}
self.state.globalWrongQuestionIds = filteredIds;
console.log('✅ 提取到的错误题目ID:', self.state.globalWrongQuestionIds);
} else {
console.warn('⚠️ 返回数据结构不符合预期,未找到 userAnswers');
}
} catch (e) {
console.error('❌ 解析返回内容失败:', e);
}
} else {
console.error('❌ 请求失败,状态码:', that.status);
}
}
});
}
// 调用原始的send方法
return originalSend.apply(this, arguments);
};
}
/**
* 轮询检查特定页面并获取数据
*/
async pollForValue() {
// 目标页面路径前缀
const TARGET_PATH_PREFIX = "/ti/exam/";
// 当前页面路径
const currentPath = window.location.pathname;
// 判断是否是目标页面
const isTargetPage = currentPath.startsWith(TARGET_PATH_PREFIX);
if (!isTargetPage) {
console.log(`❌ 当前页面路径(${currentPath})不是目标页面(需以 ${TARGET_PATH_PREFIX} 开头),跳过请求`);
return;
}
// 如果已获取到WrongQuestionIds,则处理
if (this.state.globalWrongQuestionIds !== null && this.state.globalWrongQuestionIds !== undefined) {
console.log('✅ 目标页面+globalWrongQuestionIds 已存在', this.state.globalWrongQuestionIds);
} else {
console.log('❌ 目标页面,但 globalWrongQuestionIds 还不存在...');
}
// 如果已获取到exerciseId,则处理
if (this.state.globalExerciseId !== null && this.state.globalExerciseId !== undefined) {
console.log('✅ 目标页面+globalExerciseId 已存在', this.state.globalExerciseId);
} else {
console.log('❌ 目标页面,但 globalExerciseId 还不存在,3秒后继续检查...');
// 3秒后再次检查
setTimeout(() => this.pollForValue(), 3000);
}
}
/**
* 延迟函数
* @param {number} ms - 延迟毫秒数
* @returns {Promise} 返回Promise对象
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 渲染标签列表
*/
renderLabels() {
const labelsPanel = this.container.querySelector('.labels-panel');
// 清空现有内容
labelsPanel.innerHTML = '';
// 遍历所有标签,创建标签项
this.state.allLabels.forEach(label => {
const labelItem = document.createElement('div');
// 设置样式类,当前选中的标签添加active类
labelItem.className = `label-item ${this.state.currentLabelId === label.id ? 'active' : ''}`;
labelItem.textContent = label.name;
// 绑定点击事件,切换标签
labelItem.addEventListener('click', () => {
// 切换标签时重置搜索
this.state.searchQuery = '';
const searchInput = this.container.querySelector('.search-input');
if (searchInput) searchInput.value = '';
this.state.currentLabelId = label.id;
this.state.currentPage = 0; // 切换标签时重置到第一页
this.fetchPapers(); // 获取该标签下的试卷
this.renderLabels(); // 重新渲染标签列表,更新选中状态
});
labelsPanel.appendChild(labelItem);
});
}
/**
* 渲染试卷列表
*/
renderPapers() {
const papersPanel = this.container.querySelector('.papers-panel');
// 清空现有内容
papersPanel.innerHTML = '';
// 创建试卷列表头部
const header = document.createElement('div');
header.className = 'papers-header';
let length = this.state.allPapersByLabel[this.state.currentLabelId]?.length || 0;
header.innerHTML = `共 ${length} 份试卷
`;
papersPanel.appendChild(header);
// 创建试卷列表容器
const papersList = document.createElement('div');
papersList.className = 'papers-list';
// 检查是否有试卷数据
if (this.state.currentPapers.length === 0) {
papersList.innerHTML = '没有找到试卷
';
} else {
// 遍历试卷数据,创建试卷项
this.state.currentPapers.forEach(paper => {
const paperItem = document.createElement('div');
paperItem.className = 'paper-item';
const paperName = paper.name;
// 设置试卷项内容,包含下载按钮
paperItem.innerHTML = `
${paperName}
`;
// 查看解析按钮事件 - 点击时显示类别选择弹窗
paperItem.querySelector('.item-view-btn').addEventListener('click', (e) => {
e.stopPropagation(); // 阻止事件冒泡
// 如果正在处理操作,则不执行
if (this.state.isProcessing) {
this.pushLog('正在处理中,请稍后再试', 'warning');
return;
}
const paperId = e.currentTarget.getAttribute('data-id');
const paperName = e.currentTarget.getAttribute('data-name');
// 显示类别选择弹窗,指定操作为查看解析
this.showCategoryModal({ id: paperId, name: paperName }, 'view');
});
// 下载试卷按钮事件
paperItem.querySelector('.download-btn').addEventListener('click', (e) => {
e.stopPropagation(); // 阻止事件冒泡
// 如果正在处理操作,则不执行
if (this.state.isProcessing || this.state.isBatchDownloading) {
this.pushLog('正在处理中,请稍后再试', 'warning');
return;
}
const paperId = e.currentTarget.getAttribute('data-id');
const paperName = e.currentTarget.getAttribute('data-name');
this.downloadPaperPDF({ id: paperId, name: paperName });
});
// 收藏题目按钮事件 - 点击时显示类别选择弹窗
paperItem.querySelector('.item-collect-btn').addEventListener('click', (e) => {
e.stopPropagation(); // 阻止事件冒泡
// 如果正在处理操作,则不执行
if (this.state.isProcessing) {
this.pushLog('正在处理中,请稍后再试', 'warning');
return;
}
const paperId = e.currentTarget.getAttribute('data-id');
const paperName = e.currentTarget.getAttribute('data-name');
// 显示类别选择弹窗
this.showCategoryModal({ id: paperId, name: paperName }, 'collect');
});
// 取消收藏题目按钮事件
paperItem.querySelector('.item-dis-collect-btn').addEventListener('click', (e) => {
e.stopPropagation(); // 阻止事件冒泡
// 如果正在处理操作,则不执行
if (this.state.isProcessing) {
this.pushLog('正在处理中,请稍后再试', 'warning');
return;
}
const paperId = e.currentTarget.getAttribute('data-id');
const paperName = e.currentTarget.getAttribute('data-name');
this.disCollectPaperQuestions({ id: paperId, name: paperName });
});
papersList.appendChild(paperItem);
});
}
papersPanel.appendChild(papersList);
// 创建分页控件
const pagination = document.createElement('div');
pagination.className = 'pagination';
pagination.innerHTML = `
`;
// 上一页按钮事件
pagination.querySelector('.prev-btn').addEventListener('click', () => {
if (this.state.currentPage > 0) {
this.state.currentPage--;
// 应用搜索和分页
if (this.state.searchQuery) {
this.filterPapersBySearch();
} else {
this.updateCurrentPapers();
}
}
});
// 下一页按钮事件
pagination.querySelector('.next-btn').addEventListener('click', () => {
if (this.state.currentPage < this.state.totalPages - 1) {
this.state.currentPage++;
// 应用搜索和分页
if (this.state.searchQuery) {
this.filterPapersBySearch();
} else {
this.updateCurrentPapers();
}
}
});
papersPanel.appendChild(pagination);
}
/**
* 获取标签列表
*/
async fetchLabels() {
try {
// 标签API地址
const apiUrl = 'https://tiku.fenbi.com/api/xingce/subLabels?app=web&kav=100&av=100&hav=100&version=3.0.0.0';
// 发送请求
const response = await this.httpRequest({
method: 'GET',
url: apiUrl,
headers: {
'Accept': 'application/json, text/plain, */*',
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent
}
});
// 解析响应数据
const data = JSON.parse(response);
const allLabels = data || [];
this.state.allLabels = allLabels;
// 渲染标签列表
this.renderLabels();
// 默认选择第一个标签(现在是"国考")
if (this.state.allLabels.length > 0) {
this.state.currentLabelId = this.state.allLabels[0].id;
this.fetchPapers(); // 获取第一个标签下的试卷
this.renderLabels(); // 重新渲染标签列表
}
} catch (error) {
this.pushLog(`获取标签失败: ${error.message}`, 'error');
}
}
/**
* 获取标签下的所有试卷(包括所有分页)
*/
async fetchAllPagesForLabel(labelId) {
if (this.state.allPapersByLabel[labelId] && this.state.allPapersByLabel[labelId][0] == 'isProcessing') {
return;
}
this.state.allPapersByLabel[labelId] = ['isProcessing'];
let allItems = [];
let currentPage = 0;
let totalPages = 1;
try {
const baseUrl = 'https://tiku.fenbi.com/api/xingce/papers';
// 第一页请求
const firstPageUrl = new URL(baseUrl);
firstPageUrl.searchParams.append('toPage', currentPage);
firstPageUrl.searchParams.append('pageSize', 100);
firstPageUrl.searchParams.append('labelId', labelId);
firstPageUrl.searchParams.append('app', 'web');
firstPageUrl.searchParams.append('kav', '100');
firstPageUrl.searchParams.append('av', '100');
firstPageUrl.searchParams.append('hav', '100');
firstPageUrl.searchParams.append('version', '3.0.0.0');
const firstResponse = await this.httpRequest({
method: 'GET',
url: firstPageUrl.toString(),
headers: {
'Accept': 'application/json, text/plain, */*',
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent,
'Cookie': document.cookie
}
});
const firstData = JSON.parse(firstResponse);
allItems = allItems.concat(firstData.list || []);
totalPages = firstData.pageInfo?.totalPage || 1;
this.pushLog(`开始加载标签下的内容,共 ${totalPages} 页`, 'info');
// 加载剩余页面
for (currentPage = 1; currentPage < totalPages; currentPage++) {
this.pushLog(`加载第 ${currentPage + 1}/${totalPages} 页`);
const url = new URL(baseUrl);
url.searchParams.append('toPage', currentPage);
url.searchParams.append('pageSize', 100);
url.searchParams.append('labelId', labelId);
url.searchParams.append('app', 'web');
url.searchParams.append('kav', '100');
url.searchParams.append('av', '100');
url.searchParams.append('hav', '100');
url.searchParams.append('version', '3.0.0.0');
const response = await this.httpRequest({
method: 'GET',
url: url.toString(),
headers: {
'Accept': 'application/json, text/plain, */*',
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent,
'Cookie': document.cookie
}
});
const data = JSON.parse(response);
allItems = allItems.concat(data.list || []);
// 避免请求过于频繁
await this.sleep(500);
}
this.pushLog(`成功加载 ${allItems.length} 张试卷`, 'success');
return allItems;
} catch (error) {
this.pushLog(`获取试卷失败: ${error.message}`, 'error');
return allItems; // 返回已获取的部分
}
}
/**
* 获取当前标签的试卷列表(全量数据)
*/
async fetchPapers() {
// 如果没有选中的标签,直接返回
if (!this.state.currentLabelId) return;
// 检查是否已经加载过该标签的试卷
if (this.state.allPapersByLabel[this.state.currentLabelId] && this.state.allPapersByLabel[this.state.currentLabelId][0] != 'isProcessing') {
this.updateCurrentPapers();
return;
}
// 显示加载状态
this.showLoading();
try {
const labelId = this.state.currentLabelId
// 获取该标签下的所有试卷(所有分页)
const allPapers = await this.fetchAllPagesForLabel(labelId);
if (allPapers) {
// 存储全量数据
this.state.allPapersByLabel[labelId] = allPapers;
}
if (this.state.allPapersByLabel[labelId][0] != 'isProcessing' && labelId == this.state.currentLabelId) {
// 更新当前显示的试卷(应用分页)
this.updateCurrentPapers();
}
} catch (error) {
const papersPanel = this.container.querySelector('.papers-panel');
papersPanel.innerHTML = `加载失败: ${error.message}
`;
this.pushLog(`获取试卷失败: ${error.message}`, 'error');
console.error(error);
}
}
/**
* 下载试卷PDF
* @param {Object} paper - 试卷对象
* @param {boolean} isBatch - 是否是批量下载中的一个
* @returns {boolean} 是否下载成功
*/
async downloadPaperPDF(paper, isBatch = false) {
const paperName = paper.name;
if (!isBatch) {
this.pushLog(`开始下载试卷:《${paperName}》`, 'info');
}
// 步骤1:检查并创建练习
const exerciseCreated = await this.createExercise(paper);
if (!exerciseCreated || !paper.exercise?.id) {
this.pushLog('❌ 无法下载,未获取到练习ID', 'error');
return false;
}
let exerciseId = paper.exercise.id;
// 步骤2:构建下载URL并触发下载
const downloadUrl = `https://tiku.fenbi.com/api/xingce/exercises/${exerciseId}/pdf`;
try {
// 先检查URL是否有效
await this.httpRequest({
method: 'HEAD',
url: downloadUrl,
headers: {
'Cookie': document.cookie,
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent
}
});
// 创建隐藏的a标签来触发下载
const a = document.createElement('a');
a.href = downloadUrl;
// 清理文件名中的特殊字符
const fileName = `${paperName.replace(/[\/:*?"<>|]/g, '-')}.pdf`;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
if (!isBatch) {
this.pushLog(`✅ 已开始下载《${paperName}》`, 'success');
}
return true;
} catch (error) {
if (!isBatch) {
this.pushLog(`❌ 下载失败:${error.message}`, 'error');
}
console.error('下载试卷出错:', error);
return false;
}
}
/**
* 批量下载搜索结果中的试卷
*/
async batchDownloadPapers() {
// 检查是否正在处理或批量下载中
if (this.state.isProcessing || this.state.isBatchDownloading) {
this.pushLog('正在处理中,请稍后再试', 'warning');
return;
}
// 检查是否有搜索结果
if (this.state.currentPapers.length === 0) {
this.pushLog('没有可下载的试卷,请先搜索或选择标签', 'error');
return;
}
// 获取要下载的页数
const pagesToDownload = this.state.batchDownloadPages;
const totalPages = this.state.totalPages;
const actualPages = Math.min(pagesToDownload, totalPages);
// 计算要下载的试卷总数
let totalPapers = 0;
const papersToDownload = [];
// 收集所有要下载的试卷
for (let page = 0; page < actualPages; page++) {
const startIndex = page * this.state.pageSize;
let endIndex = startIndex + this.state.pageSize;
// 获取当前标签的所有试卷,并应用多关键词全匹配筛选
const allPapers = this.state.searchQuery
? (() => {
// 将搜索词按顿号分割成数组,并过滤空字符串
const keywords = this.state.searchQuery
.toLowerCase()
.split('、')
.map(keyword => keyword.trim())
.filter(keyword => keyword);
// 如果没有有效关键词,返回所有试卷
if (keywords.length === 0) {
return this.state.allPapersByLabel[this.state.currentLabelId] || [];
}
// 应用多关键词全匹配筛选
return this.state.allPapersByLabel[this.state.currentLabelId]?.filter(paper => {
const paperName = paper.name.toLowerCase();
return keywords.every(keyword => paperName.includes(keyword));
}) || [];
})()
: this.state.allPapersByLabel[this.state.currentLabelId] || [];
// 确保不超出范围
endIndex = Math.min(endIndex, allPapers.length);
// 添加到下载列表
papersToDownload.push(...allPapers.slice(startIndex, endIndex));
}
totalPapers = papersToDownload.length;
if (totalPapers === 0) {
this.pushLog('没有找到符合条件的试卷', 'error');
return;
}
this.state.isBatchDownloading = true;
this.pushLog(`开始批量下载 ${totalPapers} 份试卷(共 ${actualPages} 页)`, 'info');
this.updateBatchDownloadProgress(0, totalPapers);
let successCount = 0;
let failCount = 0;
try {
// 逐个下载试卷
for (let i = 0; i < totalPapers; i++) {
// 显示当前进度
this.pushLog(`正在下载 ${i + 1}/${totalPapers}:${papersToDownload[i].name ?? papersToDownload[i].sheet.name}`, 'info');
// 下载试卷
const success = await this.downloadPaperPDF(papersToDownload[i], true);
// 更新计数
if (success) {
successCount++;
} else {
failCount++;
this.pushLog(`❌ 下载失败:${papersToDownload[i].name ?? papersToDownload[i].sheet.name}`, 'error');
}
// 更新进度条
this.updateBatchDownloadProgress(i + 1, totalPapers);
// 每下载2个试卷后暂停一下,避免请求过于频繁
if ((i + 1) % 2 === 0) {
await this.sleep(2000);
} else {
await this.sleep(1000);
}
}
this.pushLog(`批量下载完成!成功:${successCount} 份,失败:${failCount} 份`, 'success');
} catch (error) {
this.pushLog(`批量下载过程出错:${error.message}`, 'error');
} finally {
this.state.isBatchDownloading = false;
}
}
/**
* 创建练习
* @param {Object} paper - 试卷对象
* @returns {boolean} 是否创建成功
*/
async createExercise(paper) {
const paperId = paper.id;
const name = paper.name || '未知试卷';
this.pushLog(`🆕 正在创建试卷 "${name}"的相关练习 ...`);
// 练习创建表单数据
const form = {
type: 1,
paperId: paperId,
exerciseTimeMode: 2
};
try {
// 转换表单数据为URLSearchParams格式
const formData = new URLSearchParams();
Object.entries(form).forEach(([k, v]) => formData.append(k, v));
// 发送创建练习请求
const createExerciseResponse = await this.httpRequest({
url: 'https://tiku.fenbi.com/api/xingce/exercises?app=web&kav=100&av=100&hav=100&version=3.0.0.0',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Cookie': document.cookie,
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent
},
data: formData.toString()
});
// 检查响应是否为空
if (!createExerciseResponse) {
this.pushLog(`❌ 创建练习失败,试卷:${name},响应为空`, 'error');
return false;
}
// 解析响应数据
let exerciseResponse = JSON.parse(createExerciseResponse);
let exerciseId = exerciseResponse.id;
if (exerciseId) {
// 保存练习ID到试卷对象
paper.exercise = { id: exerciseId };
this.pushLog(`✅ 练习创建成功,ID: ${exerciseId}`, 'success');
return true;
} else {
this.pushLog(`❌ 创建练习失败,试卷:${name}`, 'error');
return false;
}
} catch (err) {
this.pushLog(`❌ 创建练习出错:${err.message}`, 'error');
return false;
}
}
/**
* 获取练习中的题目
* @param {string} exerciseId - 练习ID
* @returns {Array} 题目ID列表
*/
async getExerciseQuestions(exerciseId) {
try {
// 发送请求获取练习详情
const response = await this.httpRequest({
url: `https://tiku.fenbi.com/api/xingce/exercises/${exerciseId}`,
method: 'GET',
headers: {
'Cookie': document.cookie,
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent
}
});
// 解析响应数据,提取题目ID列表
const data = JSON.parse(response);
this.state.currentStatus = data?.status || 0;
this.state.currentChapters = data?.sheet?.chapters || [];
return data?.sheet?.questionIds || [];
} catch (error) {
this.pushLog(`❌ 获取题目失败:${error.message}`, 'error');
return [];
}
}
/**
* 获取已收藏的题目ID
* @param {Array} questionIds - 题目ID列表
* @returns {Array} 已收藏的题目ID列表
*/
async getCollectedQuestionIds(questionIds) {
try {
// 构建请求URL
let url = 'https://tiku.fenbi.com/api/xingce/collects';
if (questionIds.length > 0) {
url += `?ids=${questionIds.join(',')}`;
}
// 发送请求
const response = await this.httpRequest({
url,
method: 'GET',
headers: {
'Cookie': document.cookie,
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent
}
});
// 解析响应数据
const data = JSON.parse(response);
const collectedIds = Array.isArray(data) ? data : [];
this.pushLog(`🔍 已收藏 ${collectedIds.length} 道题目`, 'info');
return collectedIds;
} catch (error) {
this.pushLog(`❌ 查询收藏状态失败:${error.message}`, 'error');
return [];
}
}
/**
* 获取题目元数据(包括准确率)
* @param {Array} questionIds - 题目ID列表
* @returns {string} 响应数据字符串
*/
async getQuestionMetaByIds(questionIds) {
try {
// 发送请求获取题目元数据
let questions = await this.httpRequest({
url: `https://tiku.fenbi.com/api/xingce/question/meta?ids=${questionIds.join(',')}`,
method: "GET",
headers: {
'Cookie': document.cookie,
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent
}
});
return questions;
} catch (error) {
this.pushLog(`❌ 获取题目准确率失败:${error.message}`, 'error');
return null;
}
}
/**
* 根据选中的类别筛选题目
* @param {Array} questionIds - 题目ID列表
* @returns {Array} 筛选后的题目ID列表(无匹配时返回空数组表示跳过)
*/
async filterQuestionsByCategories(questionIds) {
// 检查是否全选了所有类别
const isSelectAll = this.state.selectedCategories.length === this.questionCategories.length;
if (isSelectAll) {
this.pushLog('已选择全部类别,不进行章节筛选', 'info');
return questionIds; // 全选时直接返回所有题目
}
// 验证章节数据是否存在
if (!this.state.currentChapters || !Array.isArray(this.state.currentChapters)) {
this.pushLog('未获取到章节信息,无法按类别筛选,将跳过该试卷', 'warning');
return []; // 无章节信息时返回空数组表示跳过
}
// 筛选出需要处理的类别
const targetCategories = this.state.selectedCategories;
if (targetCategories.length === 0) {
this.pushLog('没有选择任何类别,返回全部题目', 'info');
return questionIds;
}
// 定义类别关键词映射表,用于匹配章节名称
const categoryKeywords = {
'political': ['政治', '时政', '政策'],
'common-sense': ['常识', '基础知识', '公共基础'],
'verbal': ['言语', '语言', '理解'],
'quantity': ['数量', '数字', '计算'],
'logic': ['逻辑', '推理', '科学'],
'data': ['资料', '数据', '分析']
};
// 收集符合条件的题目ID
let result = [];
let currentPosition = 0; // 记录在总题目列表中的当前位置
// 遍历所有章节,查找与选中类别匹配的章节
this.state.currentChapters.forEach(chapter => {
// 跳过没有题目数量信息的章节
if (!chapter.questionCount || chapter.questionCount <= 0) {
return;
}
this.pushLog(`分析章节: ${chapter.name}, 题目数量: ${chapter.questionCount}`, 'info');
// 检查当前章节是否匹配任何选中的类别
const matchedCategory = targetCategories.find(category => {
// 检查章节名称是否包含该类别的任何关键词
return categoryKeywords[category]?.some(keyword =>
chapter.name.includes(keyword)
);
});
// 如果找到匹配的类别,提取该章节的题目
if (matchedCategory) {
const categoryName = this.questionCategories.find(c => c.id === matchedCategory)?.name;
this.pushLog(`找到匹配章节: ${chapter.name} -> 类别: ${categoryName}`, 'success');
// 计算该章节在总题目列表中的起始和结束索引
const startIndex = currentPosition;
const endIndex = Math.min(currentPosition + chapter.questionCount, questionIds.length);
// 提取对应范围的题目ID
const categoryQuestions = questionIds.slice(startIndex, endIndex);
result = [...result, ...categoryQuestions];
this.pushLog(`从章节提取 ${categoryQuestions.length} 道${categoryName}题目`, 'success');
}
// 更新当前位置指针
currentPosition += chapter.questionCount;
});
if (result.length === 0) {
this.pushLog('没有找到与所选类别匹配的章节,将跳过该试卷', 'warning');
return []; // 没有匹配题目时返回空数组表示跳过
}
return result;
}
/**
* 收藏未收藏的题目
* @param {Array} unCollectedIds - 未收藏的题目ID列表
* @returns {number} 成功收藏的数量
*/
async collectUnCollectedQuestions(unCollectedIds) {
let successCount = 0;
// 遍历未收藏的题目ID
for (let j = 0; j < unCollectedIds.length; j++) {
const qid = unCollectedIds[j];
try {
// 发送收藏请求
await this.httpRequest({
url: `https://tiku.fenbi.com/api/xingce/collects/${qid}`,
method: 'POST',
headers: {
'Cookie': document.cookie,
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent
}
});
successCount++;
this.pushLog(`✅ 收藏成功:第 ${j + 1}/${unCollectedIds.length} 题 (ID: ${qid})`, 'success');
} catch (err) {
this.pushLog(`❌ 收藏失败:第 ${j + 1} 题 (${qid})`, 'error');
await this.sleep(500); // 延迟500ms后重试
// 重试一次
try {
await this.httpRequest({
url: `https://tiku.fenbi.com/api/xingce/collects/${qid}`,
method: 'POST',
headers: {
'Cookie': document.cookie,
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent
}
});
successCount++;
this.pushLog(`✅ 重试成功:第 ${j + 1} 题 (${qid})`, 'success');
} catch (retryErr) {
this.pushLog(`🔁 重试失败:第 ${j + 1} 题 (${qid})`, 'error');
}
}
// 避免请求过于频繁,每个请求间隔300ms
await this.sleep(300);
}
return successCount;
}
/**
* 收藏试卷中的题目
* @param {Object} paper - 试卷对象
*/
async collectPaperQuestions(paper) {
// 设置处理状态为true,防止重复操作
this.state.isProcessing = true;
try {
const paperName = paper.name;
this.pushLog(`开始处理试卷:《${paperName}》`, 'info');
// 步骤1:检查并创建练习
const exerciseCreated = await this.createExercise(paper);
if (!exerciseCreated || !paper.exercise?.id) {
this.pushLog('❌ 无法继续,未获取到练习ID', 'error');
return;
}
let exerciseId = paper.exercise.id;
// 步骤2:获取题目
const questionIds = await this.getExerciseQuestions(exerciseId);
if (questionIds.length === 0) {
this.pushLog('ℹ️ 没有找到题目', 'info');
return;
}
// 步骤3:根据选中的类别筛选题目
const categoryFilteredIds = await this.filterQuestionsByCategories(questionIds);
if (categoryFilteredIds.length === 0) {
this.pushLog('ℹ️ 没有找到符合所选类别的题目', 'info');
return;
}
// 步骤4-1:获取题目元数据并按准确率筛选
const metaIds = await this.getQuestionMetaByIds(categoryFilteredIds);
const parsedMetaIds = JSON.parse(metaIds);
const accuracyFilteredIds = parsedMetaIds
.filter(q => q.correctRatio >= this.state.minAccuracy && q.correctRatio <= this.state.maxAccuracy)
.map(q => q.id);
this.pushLog(`✅ 获取到 ${accuracyFilteredIds.length} 道符合准确率范围的题目`, 'success');
// 步骤4-2:获取已收藏题目
const collectedIds = await this.getCollectedQuestionIds(accuracyFilteredIds);
// 步骤5:收藏未收藏的题目
const unCollectedIds = accuracyFilteredIds.filter(qid => !collectedIds.includes(qid));
if (unCollectedIds.length === 0) {
this.pushLog('✅ 所有题目均已收藏', 'success');
return;
}
this.pushLog(`需要收藏 ${unCollectedIds.length} 道题目`, 'info');
const successCount = await this.collectUnCollectedQuestions(unCollectedIds);
this.pushLog(`🎉 收藏完成,成功 ${successCount}/${unCollectedIds.length} 道`, 'success');
} finally {
// 无论成功失败,都重置处理状态
this.state.isProcessing = false;
}
}
/**
*
* @param {Array} collectedIds - 已经收藏的题目ID列表
*/
async disCollecyByCollectedIds(collectedIds) {
this.pushLog(`🗑️ 开始删除 ${collectedIds.length} 道已收藏题目`, 'info');
let deleteCount = 0;
// 遍历删除每个收藏的题目
for (const qid of collectedIds) {
try {
await this.httpRequest({
url: `https://tiku.fenbi.com/api/xingce/collects/${qid}`,
method: 'DELETE',
headers: {
'Cookie': document.cookie,
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent
}
});
deleteCount++;
this.pushLog(`✅ 已删除:${qid} (${deleteCount}/${collectedIds.length})`, 'success');
} catch (err) {
this.pushLog(`❌ 删除失败:${qid}`, 'error');
}
// 每个删除请求间隔200ms
await this.sleep(200);
}
this.pushLog(`🎉 清理完成,共删除 ${deleteCount} 道收藏题目`, 'success');
return deleteCount;
}
/**
* 取消收藏试卷中的题目
* @param {Object} paper - 试卷对象
*/
async disCollectPaperQuestions(paper) {
// 设置处理状态为true,防止重复操作
this.state.isProcessing = true;
try {
const paperName = paper.name;
this.pushLog(`开始处理试卷:《${paperName}》`, 'info');
// 步骤1:检查并创建练习
const exerciseCreated = await this.createExercise(paper);
if (!exerciseCreated || !paper.exercise?.id) {
this.pushLog('❌ 无法继续,未获取到练习ID', 'error');
return;
}
let exerciseId = paper.exercise.id;
// 步骤2:获取题目
const questionIds = await this.getExerciseQuestions(exerciseId);
if (questionIds.length === 0) {
this.pushLog('ℹ️ 没有找到题目', 'info');
return;
}
// 步骤4:获取已收藏题目
const collectedIds = await this.getCollectedQuestionIds(questionIds);
// 步骤5:取消收藏这些题目
await this.disCollecyByCollectedIds(collectedIds);
} finally {
// 无论成功失败,都重置处理状态
this.state.isProcessing = false;
}
}
/**
* 批量收藏当前标签下的所有试卷
*/
async batchCollectAllPapers() {
// 检查是否有试卷数据
if (this.state.currentPapers.length === 0) {
this.pushLog('❌ 没有可收藏的试卷', 'error');
return;
}
// 如果是批量收藏且没有选择类别,使用默认类别
if (this.state.batchCollection && this.state.selectedCategories.length === 0) {
this.state.selectedCategories = [...this.questionCategories.map(c => c.id)];
}
// 设置处理状态为true
this.state.isProcessing = true;
// 获取批量收藏按钮元素,用于显示加载状态
const batchBtn = this.container.querySelector('.batch-collect-btn');
const originalText = batchBtn.textContent;
batchBtn.innerHTML = '处理中';
batchBtn.disabled = true;
try {
let totalSuccess = 0; // 总成功收藏数量
let totalFailed = 0; // 总失败试卷数量
this.pushLog(`开始批量收藏 ${this.state.currentPapers.length} 份试卷`, 'info');
// 遍历所有试卷
for (let i = 0; i < this.state.currentPapers.length; i++) {
const paper = this.state.currentPapers[i];
const paperName = paper.name;
this.pushLog(`\n处理第 ${i + 1}/${this.state.currentPapers.length} 份:《${paperName}》`, 'info');
try {
// 步骤1:检查并创建练习
const exerciseCreated = await this.createExercise(paper);
if (!exerciseCreated || !paper.exercise?.id) {
this.pushLog('❌ 跳过此试卷', 'error');
totalFailed++;
continue;
}
let exerciseId = paper.exercise.id;
// 获取题目
const questionIds = await this.getExerciseQuestions(exerciseId);
if (questionIds.length === 0) {
this.pushLog('ℹ️ 没有找到题目,跳过', 'warning');
continue;
}
// 步骤3:根据选中的类别筛选题目
const categoryFilteredIds = await this.filterQuestionsByCategories(questionIds);
if (categoryFilteredIds.length === 0) {
this.pushLog('ℹ️ 没有找到符合所选类别的题目,跳过', 'info');
continue;
}
// 按准确率筛选题目
const metaIds = await this.getQuestionMetaByIds(categoryFilteredIds);
const parsedMetaIds = JSON.parse(metaIds);
const filteredIds = parsedMetaIds
.filter(q => q.correctRatio >= this.state.minAccuracy && q.correctRatio <= this.state.maxAccuracy)
.map(q => q.id);
// 获取已收藏题目
const collectedIds = await this.getCollectedQuestionIds(filteredIds);
// 收藏未收藏的题目
const unCollectedIds = filteredIds.filter(qid => !collectedIds.includes(qid));
if (unCollectedIds.length === 0) {
this.pushLog('✅ 所有题目均已收藏', 'success');
continue;
}
// 执行收藏
const successCount = await this.collectUnCollectedQuestions(unCollectedIds);
totalSuccess += successCount;
this.pushLog(`完成收藏,成功 ${successCount}/${unCollectedIds.length} 道`, 'success');
} catch (error) {
this.pushLog(`❌ 处理试卷时出错:${error.message}`, 'error');
totalFailed++;
}
// 每处理完一份试卷休息2秒,避免请求过于频繁
await this.sleep(2000);
}
this.pushLog(`\n🎉 批量收藏完成,共成功收藏 ${totalSuccess} 道题目,${totalFailed} 份试卷处理失败`, 'success');
} finally {
// 重置处理状态和按钮
this.state.isProcessing = false;
this.state.batchCollection = false; // 重置批量收藏状态
batchBtn.innerHTML = originalText;
batchBtn.disabled = false;
}
}
/**
* 清空所有已收藏的题目
*/
async clearAllCollections() {
// 确认用户是否真的要执行清空操作
if (!confirm('确定要清空所有已收藏的题目吗?此操作不可恢复!')) {
return;
}
// 设置处理状态为true
this.state.isProcessing = true;
// 获取清空按钮元素,用于显示加载状态
const clearBtn = this.container.querySelector('.clear-btn');
const originalText = clearBtn.textContent;
clearBtn.innerHTML = '清理中';
clearBtn.disabled = true;
let deleteCounts = 0;
try {
// 构建请求URL
let url = 'https://tiku.fenbi.com/api/xingce/collects/keypoint-tree?timeRange=0&order=desc&app=web&kav=100&av=100&hav=100&version=3.0.0.0';
// 发送请求
const response = await this.httpRequest({
url,
method: 'GET',
headers: {
'Cookie': document.cookie,
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent
}
});
// 解析响应数据
const data = JSON.parse(response);
const collectedIds = [];
data.forEach(item => {
collectedIds.push(...item.questionIds);
});
if (collectedIds.length === 0) {
return;
}
deleteCounts += await this.disCollecyByCollectedIds(collectedIds);
} catch (error) {
this.pushLog(`❌ 批量删除失败:${error.message}`, 'error');
} finally {
// 重置处理状态和按钮
this.state.isProcessing = false;
clearBtn.innerHTML = originalText;
clearBtn.disabled = false;
}
}
/**
* 获取题目解析
* @param {Array} questionIds - 题目ID列表
* @returns {string} 响应数据字符串
*/
async getSolutionsByQuestionIds(questionIds) {
try {
// 发送请求获取题目解析
let solutions = await this.httpRequest({
url: `https://tiku.fenbi.com/api/xingce/solutions?ids=${questionIds.join(',')}`,
method: "GET",
headers: {
'Cookie': document.cookie,
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent
}
});
return solutions;
} catch (error) {
this.pushLog(`❌ 获取题目解析失败:${error.message}`, 'error');
return null;
}
}
/**
* 根据练习获取解析
* @param {Integer} exerciseId
* @param {string} name
* @returns
*/
async getSolutionByExerciseId(exerciseId, name) {
if (!exerciseId && !this.state.globalExerciseId) {
this.pushLog('❌ 无法继续,未获取到练习ID', 'error');
return;
}
if (!exerciseId) {
exerciseId = this.state.globalExerciseId;
}
// 步骤1:获取题号
const questionIds = await this.getExerciseQuestions(exerciseId);
if (questionIds.length === 0) {
this.pushLog('ℹ️ 没有找到题目', 'info');
return;
}
// 步骤2:根据选中的类别筛选题目(如果有选择)
const categoryFilteredIds = await this.filterQuestionsByCategories(questionIds);
if (categoryFilteredIds.length === 0) {
this.pushLog('ℹ️ 没有找到符合所选类别的题目', 'info');
return;
}
// 获取解析
const questionResponse = await this.getSolutionsByQuestionIds(categoryFilteredIds);
const data = JSON.parse(questionResponse);
// 处理解析数据
const filteredData = Object.entries(data).map(([key, value]) => {
const entry = {
options: value.accessories[0]?.options,
key: value.id,
content: value.content,
solution: value.solution
}
// 如果有材料,也包含进去
if (value.material != null) {
entry.material = value.material;
}
return entry;
});
//如果已经完成的还需要收集错题
if (this.state.currentStatus == 1) {
const reportResponse = await this.getReportByExerciseId(exerciseId);
const report = JSON.parse(reportResponse);
const answers = report.answers;
// 筛选出所有 status 为 -1 的项
this.state.globalWrongQuestionIds = answers.filter(item => item.status === -1)
.map(item => item.questionId);
}
this.pushLog(`✅ 成功获取 ${filteredData.length} 道题目的解析`, 'success');
if (!name) {
name = this.state.globalApiResponseData.data.name;
}
// 调用显示解析窗口的方法,传入处理好的解析数据和试卷名称
this.showSolutionWindow(filteredData, name);
}
/**
* 获取练习报告
* @param {Integer} exerciseId
* @returns
*/
async getReportByExerciseId(exerciseId) {
try {
// 发送请求获取题目解析
const response = await this.httpRequest({
url: `https://tiku.fenbi.com/api/xingce/exercises/${exerciseId}/report/v2`,
method: "GET",
headers: {
'Cookie': document.cookie,
'Referer': 'https://tiku.fenbi.com/xingce/',
'User-Agent': navigator.userAgent
}
});
return response;
} catch (error) {
this.pushLog(`❌ 获取练习报告失败:${error.message}`, 'error');
}
}
/**
* 获取试卷解析并在新窗口展示
* @param {Object} paper - 试卷对象
*/
async getPaperSolution(paper) {
try {
const paperName = paper.name;
this.pushLog(`开始获取试卷解析:《${paperName}》`, 'info');
// 步骤1:检查并创建练习
const exerciseCreated = await this.createExercise(paper);
if (!exerciseCreated || !paper.exercise?.id) {
this.pushLog('❌ 无法继续,未获取到练习ID', 'error');
return;
}
let exerciseId = paper.exercise.id;
await this.getSolutionByExerciseId(exerciseId, paperName);
} catch (error) {
this.pushLog(`❌ 获取解析失败:${error.message}`, 'error');
}
}
/**
* 创建并并显示解析窗口,支持按材料分组导出,相同材料材料只显示一次
* 只有当存在材料时,解析才单独成页,解决空白页问题
* @param {Array} solutions - 解析数据数组(所有题目)
* @param {string} paperName - 试卷名称
*/
showSolutionWindow(solutions, paperName) {
// 1. 验证数据有效性
if (!solutions || !Array.isArray(solutions) || solutions.length === 0) {
this.pushLog('❌ 解析数据为空或格式错误', 'error');
return;
}
// 2. 按材料分组 - 相同材料的题目放在一组
const groupedByMaterial = [];
const materialMap = new Map();
solutions.forEach(solution => {
// 使用材料内容作为键,如果没有材料则使用特殊标识
const materialKey = solution.material?.id ? solution.material?.id : `__no_material_${Date.now()}_${Math.random()}`;
if (!materialMap.has(materialKey)) {
// 创建新的材料组
const newGroup = {
materialKey: materialKey,
material: solution.material?.content || null,
hasMaterial: !!solution.material?.content, // 标记是否有材料
questions: []
};
groupedByMaterial.push(newGroup);
materialMap.set(materialKey, newGroup);
}
// 将题目添加到对应的材料组
materialMap.get(materialKey).questions.push(solution);
});
// 3. 创建新弹窗
const solutionWindow = window.open('', '_blank', 'width=827,height=1169,scrollbars=yes');
if (!solutionWindow) {
this.pushLog('❌ 弹窗被浏览器拦截,请请允许弹出窗口后重试', 'error');
return;
}
// 4. 设置弹窗标题
solutionWindow.document.title = `${paperName} - 题目解析`;
// 存储导出相关状态 - 包含分页逻辑
const exportStore = {
isExporting: false,
currentPage: 0,
totalPages: 0, // 后面会计算
exportOptions: {
includeMaterial: true,
includeOptions: true,
includeSolution: true,
quality: 0.95,
pageOrientation: 'portrait',
exportType: 'all'
}
};
// 5. 构建基础DOM结构
const buildBasicDOM = () => {
const doc = solutionWindow.document;
doc.open();
// 创建文档声明
const doctype = doc.implementation.createDocumentType('html', '', '');
doc.appendChild(doctype);
// 创建html元素
const html = doc.createElement('html');
html.lang = 'zh-CN';
doc.appendChild(html);
// 创建head元素
const head = doc.createElement('head');
html.appendChild(head);
// 添加meta标签
const metaCharset = doc.createElement('meta');
metaCharset.charset = 'UTF-8';
head.appendChild(metaCharset);
const metaViewport = doc.createElement('meta');
metaViewport.name = 'viewport';
metaViewport.content = 'width=device-width, initial-scale=1.0';
head.appendChild(metaViewport);
// 添加标题
const title = doc.createElement('title');
title.textContent = `${paperName} - 题目解析`;
head.appendChild(title);
// 添加样式
const style = doc.createElement('style');
style.textContent = `
body { font-family: "Microsoft YaHei", "SimSun", sans-serif; line-height: 1.6; padding: 20px; background-color: #f9f9f9; }
.container { max-width: 794px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 2px solid #4F46E5; padding-bottom: 10px; }
.loading { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; color: white; font-size: 18px; z-index: 1000; }
.spinner { border: 5px solid rgba(255,255,255,0.3); border-radius: 50%; border-top: 5px solid white; width: 50px; height: 50px; animation: spin 1s linear infinite; margin-right: 15px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.export-btn { background-color: #4F46E5; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 8px; }
.export-btn:disabled { background-color: #888; cursor: not-allowed; }
.question { margin-bottom: 30px; padding-bottom: 20px; border-bottom: 1px solid #eee; }
.question-number { font-weight: bold; font-size: 18px; margin-bottom: 10px; color: #333; }
.material, .content, .solution, .options { margin-bottom: 15px; }
.material-title, .content-title, .solution-title, .options-title { font-weight: bold; color: #4F46E5; margin-bottom: 5px; }
.progress-container { position: absolute; bottom: 30px; left: 0; width: 100%; padding: 0 20px; box-sizing: border-box; }
.progress-bar { height: 8px; background-color: rgba(255,255,255,0.3); border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background-color: white; width: 0%; transition: width 0.3s ease; }
.export-preview { padding: 0 !important; border-radius: 0 !important; box-shadow: none !important; }
.resource-progress { margin-top: 10px; font-size: 14px; }
/* 导出选项样式 */
.export-options { margin-top: 15px; padding: 10px; background-color: #f9f9f9; border-radius: 4px; }
.export-option-item { display: flex; align-items: center; margin-bottom: 8px; font-size: 14px; }
.export-option-item input { margin-right: 8px; }
.export-option-label { cursor: pointer; }
.no-wrong-questions { color: #999; font-size: 13px; margin-top: 8px; padding-left: 24px; font-style: italic; }
/* 用于导出时隐藏不需要的部分 */
.hide-section { display: none !important; }
.page-header { margin-bottom: 20px; font-size: 14px; color: #666; }
.material-group { margin-bottom: 40px; padding-bottom: 20px; border-bottom: 2px solid #eee; }
.material-group-title { font-size: 20px; font-weight: bold; margin-bottom: 20px; color: #333; }
`;
head.appendChild(style);
// 创建body元素
const body = doc.createElement('body');
html.appendChild(body);
// 创建内容容器
const container = doc.createElement('div');
container.className = 'container';
container.style.display = 'none';
body.appendChild(container);
doc.close();
return doc;
};
// 6. 初始化页面并加载依赖
const init = () => {
const doc = buildBasicDOM();
const container = doc.querySelector('.container');
const loading = doc.querySelector('.loading');
const resourceProgress = doc.getElementById('resource-progress');
const progressFill = doc.querySelector('.progress-fill');
// 检查浏览器支持
const checkBrowserSupport = () => {
if (!window.Promise) {
throw new Error('您的浏览器不支持Promise,无法使用此功能');
}
if (!window.Blob) {
throw new Error('您的浏览器不支持Blob,无法导出PDF');
}
if (!window.URL) {
throw new Error('您的浏览器不支持URL API,无法导出PDF');
}
};
try {
checkBrowserSupport();
} catch (e) {
alert(e.message);
return;
}
// 依赖加载完成后执行的函数
const doShow = () => {
// 清空容器并填充内容
container.innerHTML = '';
// 1. 创建头部(只保留导出按钮)
const header = doc.createElement('div');
header.className = 'header';
// 创建导出按钮
const exportBtn = doc.createElement('button');
exportBtn.className = 'export-btn';
exportBtn.id = 'export-pdf';
// 创建导出按钮SVG图标
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '16');
svg.setAttribute('height', '16');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
const path1 = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
path1.setAttribute('d', 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z');
svg.appendChild(path1);
const path2 = doc.createElementNS('http://www.w3.org/2000/svg', 'polyline');
path2.setAttribute('points', '14 2 14 8 20 8');
svg.appendChild(path2);
const path3 = doc.createElementNS('http://www.w3.org/2000/svg', 'line');
path3.setAttribute('x1', '16');
path3.setAttribute('y1', '13');
path3.setAttribute('x2', '8');
path3.setAttribute('y2', '13');
svg.appendChild(path3);
const path4 = doc.createElementNS('http://www.w3.org/2000/svg', 'line');
path4.setAttribute('x1', '16');
path4.setAttribute('y1', '17');
path4.setAttribute('x2', '8');
path4.setAttribute('y2', '17');
svg.appendChild(path4);
const path5 = doc.createElementNS('http://www.w3.org/2000/svg', 'polyline');
path5.setAttribute('points', '10 9 9 9 8 9');
svg.appendChild(path5);
exportBtn.appendChild(svg);
exportBtn.appendChild(doc.createTextNode(' 导出为PDF'));
header.appendChild(exportBtn);
container.appendChild(header);
// 2. 添加导出选项
const exportOptions = doc.createElement('div');
exportOptions.className = 'export-options';
// 检查是否有错题数据
const hasWrongQuestions = this.state.globalWrongQuestionIds &&
Array.isArray(this.state.globalWrongQuestionIds) &&
this.state.globalWrongQuestionIds.length > 0;
// 筛选出在当前试卷中的错题
const wrongQuestionIdsInCurrent = hasWrongQuestions
? this.state.globalWrongQuestionIds.filter(id =>
solutions.some(s => s.key === id)
)
: [];
// 构建选项HTML
exportOptions.innerHTML = `
${!hasWrongQuestions ? '没有检测到错题数据
' : ''}
`;
container.appendChild(exportOptions);
// 3. 创建题目容器
const solutionsContainer = doc.createElement('div');
solutionsContainer.id = 'solutions-container';
container.appendChild(solutionsContainer);
// 4. 按材料组渲染题目
let questionIndex = 0; // 全局题目索引,用于显示正确的题号
groupedByMaterial.forEach((materialGroup, groupIndex) => {
const groupDiv = doc.createElement('div');
groupDiv.className = 'material-group';
groupDiv.dataset.groupIndex = groupIndex;
groupDiv.dataset.hasMaterial = materialGroup.hasMaterial;
const materialKey = Array.from(materialMap.entries())
.find(([_, value]) => value === materialGroup)?.[0];
if (materialKey) {
groupDiv.dataset.materialKey = materialKey;
}
// 材料组标题
const groupTitle = doc.createElement('div');
groupTitle.className = 'material-group-title';
if (materialGroup.material) {
groupTitle.textContent = `材料组 ${groupIndex + 1} (包含 ${materialGroup.questions.length} 题)`
}
groupDiv.appendChild(groupTitle);
// 材料部分(如有)
if (exportStore.exportOptions.includeMaterial && materialGroup.material) {
const materialDiv = doc.createElement('div');
materialDiv.className = 'material';
materialDiv.dataset.section = 'material';
materialDiv.dataset.groupIndex = groupIndex;
const materialTitle = doc.createElement('div');
materialTitle.className = 'material-title';
materialTitle.textContent = '材料:';
materialDiv.appendChild(materialTitle);
const materialContent = doc.createElement('div');
materialContent.className = 'material-content';
materialContent.innerHTML = materialGroup.material;
materialDiv.appendChild(materialContent);
groupDiv.appendChild(materialDiv);
}
// 渲染当前材料组下的所有题目
materialGroup.questions.forEach((item) => {
questionIndex++;
const questionDiv = doc.createElement('div');
questionDiv.className = 'question';
questionDiv.dataset.section = 'question';
questionDiv.dataset.questionIndex = questionIndex;
questionDiv.dataset.id = item.key;
questionDiv.dataset.groupIndex = groupIndex;
// 错题标记
const isWrongQuestion = hasWrongQuestions &&
wrongQuestionIdsInCurrent.includes(item.key);
if (isWrongQuestion) {
questionDiv.style.borderLeft = '4px solid #ef4444';
questionDiv.style.paddingLeft = '15px';
}
questionDiv.style.display = 'block';
// 题目编号
const questionNumber = doc.createElement('div');
questionNumber.className = 'question-number';
questionNumber.textContent = `第 ${questionIndex} 题${isWrongQuestion ? ' (错题)' : ''}`;
questionDiv.appendChild(questionNumber);
// 题目内容
const contentDiv = doc.createElement('div');
contentDiv.className = 'content';
contentDiv.dataset.section = 'content';
const contentTitle = doc.createElement('div');
contentTitle.className = 'content-title';
contentTitle.textContent = '题目:';
contentDiv.appendChild(contentTitle);
const contentText = doc.createElement('div');
contentText.className = 'content-text';
contentText.innerHTML = item.content;
contentDiv.appendChild(contentText);
questionDiv.appendChild(contentDiv);
// 选项部分
if (exportStore.exportOptions.includeOptions && item.options && Array.isArray(item.options) && item.options.length > 0) {
const optionsDiv = doc.createElement('div');
optionsDiv.className = 'options';
optionsDiv.dataset.section = 'options';
const optionsTitle = doc.createElement('div');
optionsTitle.className = 'options-title';
optionsTitle.textContent = '选项:';
optionsDiv.appendChild(optionsTitle);
const optionsText = doc.createElement('div');
optionsText.className = 'options-text';
const optionLetters = ['A', 'B', 'C', 'D'];
item.options.forEach((option, index) => {
if (index < 4) {
const optionItem = doc.createElement('div');
optionItem.className = 'option-item';
const letterSpan = doc.createElement('span');
letterSpan.className = 'option-letter';
letterSpan.textContent = `${optionLetters[index]}:`;
const contentContainer = doc.createElement('div');
contentContainer.className = 'option-content';
if (typeof option === 'string') {
let processedOption = option.replace(/^/, '').replace(/<\/p>$/, '');
contentContainer.innerHTML = processedOption;
} else {
contentContainer.textContent = option;
}
optionItem.appendChild(letterSpan);
optionItem.appendChild(contentContainer);
optionsText.appendChild(optionItem);
}
});
optionsDiv.appendChild(optionsText);
questionDiv.appendChild(optionsDiv);
}
// 解析内容
if (exportStore.exportOptions.includeSolution && item.solution) {
const solutionDiv = doc.createElement('div');
solutionDiv.className = 'solution';
solutionDiv.dataset.section = 'solution';
const solutionTitle = doc.createElement('div');
solutionTitle.className = 'solution-title';
solutionTitle.textContent = '解析:';
solutionDiv.appendChild(solutionTitle);
const solutionText = doc.createElement('div');
solutionText.className = 'solution-text';
solutionText.innerHTML = item.solution;
solutionDiv.appendChild(solutionText);
questionDiv.appendChild(solutionDiv);
}
groupDiv.appendChild(questionDiv);
});
solutionsContainer.appendChild(groupDiv);
});
// 计算总页数:精确控制分页逻辑
exportStore.totalPages = groupedByMaterial.reduce((total, group) => {
const materialPages = group.hasMaterial ? 1 : 0;
const questionPages = group.hasMaterial
? group.questions.length * 2 // 有材料:每题2页(题目+解析)
: group.questions.length * 1; // 无材料:每题1页(题目+解析)
return total + materialPages + questionPages;
}, 0);
// 5. 添加交互功能(导出)
addInteraction(solutionWindow, groupedByMaterial, wrongQuestionIdsInCurrent, paperName, exportStore, solutions, materialMap);
// 6. 隐藏加载提示,显示内容
container.style.display = 'block';
};
// 先加载库,再显示内容
doShow();
};
// 7. 添加交互功能(PDF导出)
const addInteraction = (win, materialGroups, wrongQuestionIds, paperName, exportStore, allSolutions, materialMap) => {
const doc = win.document;
const questions = doc.querySelectorAll('.question');
const groups = doc.querySelectorAll('.material-group');
const container = doc.querySelector('.container');
const exportBtn = doc.getElementById('export-pdf');
const exportTypeRadios = doc.querySelectorAll('input[name="export-type"]');
let name = "题目";
// 处理文件名特殊字符
function getPdfFileName() {
return paperName.replace(/[\/:*?"<>|]/g, '-') + '_' + name + '解析.pdf';
}
// 根据导出类型筛选要导出的题目组和题目
function getFilteredGroups() {
if (exportStore.exportOptions.exportType !== 'wrong') {
return [...materialGroups];
}
return materialGroups.map(group => {
const filteredQuestions = group.questions.filter(
q => wrongQuestionIds.includes(Number(q.key))
);
return {
...group,
questions: filteredQuestions
};
}).filter(group => group.questions.length > 0);
}
function updateDisplayByExportType(exportType) {
const isExportWrong = exportType === 'wrong';
// 1. 控制每个题目是否显示
questions.forEach(q => {
const questionId = q.dataset.id;
const isWrong = wrongQuestionIds.includes(Number(questionId));
q.style.display = isExportWrong ? (isWrong ? 'block' : 'none') : 'block';
});
// 2. 控制每个 group 是否显示,并动态更新标题中的错题数量
groups.forEach(group => {
const groupQuestions = group.querySelectorAll('.question');
const visibleQuestions = Array.from(groupQuestions).filter(q => q.style.display !== 'none');
const hasVisibleQuestions = visibleQuestions.length > 0;
group.style.display = hasVisibleQuestions ? 'block' : 'none';
if (hasVisibleQuestions && isExportWrong) {
// 只统计当前显示的错题数量
let visibleWrongCount = 0;
visibleQuestions.forEach(q => {
const questionId = q.dataset.id;
if (wrongQuestionIds.includes(Number(questionId))) {
visibleWrongCount++;
}
});
const materialKey = group.getAttribute('data-material-key');
if (!materialKey.includes('no')) {
// 找到当前 group 的标题并更新
const groupTitleEl = group.querySelector('.material-group-title');
if (groupTitleEl) {
groupTitleEl.textContent = `材料组 (包含 ${visibleWrongCount} 题)`;
}
}
}
});
}
// 监听导出类型变化
exportTypeRadios.forEach(radio => {
radio.addEventListener('change', () => {
if (radio.checked) {
exportStore.exportOptions.exportType = radio.value;
updateDisplayByExportType(radio.value);
}
});
});
// 重置所有部分的显示状态
function resetSectionVisibility() {
const sections = doc.querySelectorAll('[data-section]');
sections.forEach(section => {
section.classList.remove('hide-section');
});
}
// 隐藏指定部分
function hideSections(sectionsToHide) {
resetSectionVisibility();
sectionsToHide.forEach(section => {
const elements = doc.querySelectorAll(`[data-section="${section}"]`);
elements.forEach(el => el.classList.add('hide-section'));
});
}
// 只显示特定组
function showOnlyGroup(groupIndex) {
groups.forEach((group, idx) => {
group.style.display = idx === groupIndex ? 'block' : 'none';
});
}
// 只显示特定题目
function showOnlyQuestion(questionId) {
questions.forEach(q => {
q.style.display = Number(q.dataset.id) === Number(questionId) ? 'block' : 'none';
});
}
function getGroupElementByIndex(filteredGroups, groupIndex) {
try {
const originalGroup = filteredGroups[groupIndex];
if (!originalGroup) {
console.error(`找不到索引为${groupIndex}的组`);
return null;
}
// 重点修复:无材料组的匹配逻辑
if (!originalGroup.hasMaterial) {
// 1. 先找DOM中标记为无材料的组
const candidateGroups = doc.querySelectorAll('.material-group[data-has-material="false"]');
// 2. 通过组内题目ID进行匹配(最可靠的方式)
const groupQuestionIds = originalGroup.questions.map(q => q.key.toString());
for (const candidate of candidateGroups) {
// 获取候选组中的所有题目ID
const candidateQuestionIds = Array.from(candidate.querySelectorAll('.question'))
.map(q => q.dataset.id);
// 检查是否有共同的题目ID(证明是同一个组)
const isMatch = groupQuestionIds.some(id =>
candidateQuestionIds.includes(id)
);
if (isMatch) {
return candidate; // 找到匹配的组
}
}
// 3. 兜底方案:使用组索引查找
return doc.querySelector(`.material-group[data-group-index="${groupIndex}"]`);
}
// 有材料的组保持原逻辑
const entries = Array.from(materialMap.entries());
const foundEntry = entries.find(([key, value]) => key === originalGroup.materialKey);
if (!foundEntry) {
console.error(`找不到与组匹配的 materialMap 条目:`, originalGroup);
return doc.querySelector(`.material-group[data-group-index="${groupIndex}"]`);
}
const [materialKey] = foundEntry;
const groupElement = doc.querySelector(`.material-group[data-material-key="${materialKey}"]`);
if (!groupElement) {
console.warn(`找不到materialKey为${materialKey}的组元素,使用索引查找`);
return doc.querySelector(`.material-group[data-group-index="${groupIndex}"]`);
}
return groupElement;
} catch (error) {
console.error('获取组元素时发生错误:', error);
return doc.querySelector(`.material-group[data-group-index="${groupIndex}"]`);
}
}
// PDF导出功能实现 - 修复空白页和错题导出问题
const exportToPDF = async () => {
if (exportStore.isExporting) return;
exportStore.isExporting = true;
exportBtn.disabled = true;
exportBtn.innerHTML = '
正在导出...';
// 获取过滤后的组
const filteredGroups = getFilteredGroups();
if (filteredGroups.length === 0) {
solutionWindow.alert('没有可导出的题目,请选择其他导出类型');
exportStore.isExporting = false;
exportBtn.disabled = false;
exportBtn.innerHTML = ' 导出为PDF';
return;
}
// 计算过滤后的总页数(关键:与实际生成页数严格一致)
const totalPages = filteredGroups.reduce((total, group) => {
const materialPages = group.hasMaterial ? 1 : 0;
const questionPages = group.hasMaterial ? group.questions.length * 2 : group.questions.length * 1;
return total + materialPages + questionPages;
}, 0);
// 创建导出进度提示
const loading = doc.createElement('div');
loading.className = 'loading';
loading.innerHTML = `
正在生成PDF,第 1/${totalPages} 页
`;
doc.body.appendChild(loading);
const progressFill = doc.getElementById('export-progress');
const currentPageEl = doc.getElementById('current-page');
try {
// 准备导出环境
container.classList.add('export-preview');
win.scrollTo(0, 0);
await new Promise(resolve => setTimeout(resolve, 500));
// 创建PDF实例
const { jsPDF } = jspdf;
const pdf = new jsPDF('p', 'mm', 'a4');
// 设置页面尺寸
const pageWidth = 210;
const pageHeight = 297;
const margin = 15;
const contentWidth = pageWidth - margin * 2;
let currentGlobalPage = 1; // 从1开始计数,与PDF页码一致
// 遍历每个过滤后的材料组
for (let groupIndex = 0; groupIndex < filteredGroups.length; groupIndex++) {
const group = filteredGroups[groupIndex];
const groupElement = getGroupElementByIndex(filteredGroups, groupIndex);
// 检查组元素是否有效
if (!groupElement || group.questions.length === 0) {
console.warn(`跳过无效的组,索引: ${groupIndex}`);
continue;
}
// 只显示当前组
showOnlyGroup(parseInt(groupElement.dataset.groupIndex));
// 1. 导出材料页(如果有材料)
if (group.hasMaterial && group.material) {
// 仅在不是第一页时添加新页面
if (currentGlobalPage > 1) {
pdf.addPage();
}
// 隐藏题目选项和解析
hideSections(['question', 'options', 'solution']);
// 添加页面标题
const pageHeader = doc.createElement('div');
pageHeader.className = 'page-header';
pageHeader.textContent = `材料组 ${groupIndex + 1} - 材料部分`;
groupElement.insertBefore(pageHeader, groupElement.firstChild);
// 等待渲染
await new Promise(resolve => setTimeout(resolve, 300));
// 截图并添加到PDF
const canvas = await html2canvas(container, {
scale: 2,
logging: false,
scrollX: 0,
scrollY: 0,
useCORS: true,
allowTaint: true,
windowWidth: container.offsetWidth,
windowHeight: container.offsetHeight
});
const imgData = canvas.toDataURL('image/jpeg', exportStore.exportOptions.quality);
const imgHeight = canvas.height * contentWidth / canvas.width;
const finalHeight = imgHeight > pageHeight - margin * 2
? pageHeight - margin * 2
: imgHeight;
pdf.addImage(imgData, 'JPEG', margin, margin, contentWidth, finalHeight);
// 添加页码
pdf.setFontSize(10);
pdf.text(`第 ${currentGlobalPage}/${totalPages} 页`,
pageWidth - margin - 40,
pageHeight - margin);
// 移除临时标题
groupElement.removeChild(pageHeader);
currentGlobalPage++;
progressFill.style.width = `${((currentGlobalPage - 1) / totalPages) * 100}%`;
currentPageEl.textContent = currentGlobalPage;
}
// 2. 导出当前组的所有题目
for (let qIndex = 0; qIndex < group.questions.length; qIndex++) {
const question = group.questions[qIndex];
const questionElement = doc.querySelector(`.question[data-id="${question.key}"]`);
if (!questionElement) continue;
// 只显示当前题目
showOnlyQuestion(question.key);
// 有材料的组:题目和解析分开显示;无材料的组:题目和解析在同一页
if (group.hasMaterial) {
// 隐藏材料和解析,只显示题目和选项
hideSections(['material', 'solution']);
// 添加页面标题
const pageHeader = doc.createElement('div');
pageHeader.className = 'page-header';
pageHeader.textContent = `材料组 ${groupIndex + 1} - 第 ${questionElement.dataset.questionIndex} 题(题目与选项)`;
questionElement.insertBefore(pageHeader, questionElement.firstChild);
// 等待渲染
await new Promise(resolve => setTimeout(resolve, 300));
// 截图并添加到PDF
const canvas = await html2canvas(container, {
scale: 2,
logging: false,
scrollX: 0,
scrollY: 0,
useCORS: true,
allowTaint: true
});
const imgData = canvas.toDataURL('image/jpeg', exportStore.exportOptions.quality);
const imgHeight = canvas.height * contentWidth / canvas.width;
const finalHeight = imgHeight > pageHeight - margin * 2
? pageHeight - margin * 2
: imgHeight;
// 添加新页面(关键:只在需要时添加)
if (currentGlobalPage > 1) {
pdf.addPage();
}
pdf.addImage(imgData, 'JPEG', margin, margin, contentWidth, finalHeight);
// 添加页码
pdf.setFontSize(10);
pdf.text(`第 ${currentGlobalPage}/${totalPages} 页`,
pageWidth - margin - 40,
pageHeight - margin);
// 移除临时标题
questionElement.removeChild(pageHeader);
currentGlobalPage++;
progressFill.style.width = `${((currentGlobalPage - 1) / totalPages) * 100}%`;
currentPageEl.textContent = currentGlobalPage;
// 3. 导出当前题目的解析(只有有材料的组才单独导出解析页)
// 隐藏材料和选项,只显示解析
hideSections(['material', 'content', 'options']);
// 添加页面标题
const solutionHeader = doc.createElement('div');
solutionHeader.className = 'page-header';
solutionHeader.textContent = `材料组 ${groupIndex + 1} - 第 ${questionElement.dataset.questionIndex} 题(解析)`;
questionElement.insertBefore(solutionHeader, questionElement.firstChild);
// 等待渲染
await new Promise(resolve => setTimeout(resolve, 500));
// 截图并添加到PDF
const solutionCanvas = await html2canvas(container, {
scale: 2,
logging: false,
scrollX: 0,
scrollY: 0,
useCORS: true,
allowTaint: true
});
const solutionImgData = solutionCanvas.toDataURL('image/jpeg', exportStore.exportOptions.quality);
const solutionImgHeight = solutionCanvas.height * contentWidth / solutionCanvas.width;
const solutionFinalHeight = solutionImgHeight > pageHeight - margin * 2
? pageHeight - margin * 2
: solutionImgHeight;
// 添加新页面
pdf.addPage();
pdf.addImage(solutionImgData, 'JPEG', margin, margin, contentWidth, solutionFinalHeight);
// 添加页码
pdf.setFontSize(10);
pdf.text(`第 ${currentGlobalPage}/${totalPages} 页`,
pageWidth - margin - 40,
pageHeight - margin);
// 移除临时标题
questionElement.removeChild(solutionHeader);
currentGlobalPage++;
progressFill.style.width = `${((currentGlobalPage - 1) / totalPages) * 100}%`;
currentPageEl.textContent = currentGlobalPage;
} else {
// 无材料的组:题目和解析在同一页
hideSections(['material']); // 只隐藏材料,保留其他内容
// 添加页面标题
const pageHeader = doc.createElement('div');
pageHeader.className = 'page-header';
pageHeader.textContent = `第 ${questionElement.dataset.questionIndex} 题(题目、选项与解析)`;
questionElement.insertBefore(pageHeader, questionElement.firstChild);
// 等待渲染
await new Promise(resolve => setTimeout(resolve, 500));
// 截图并添加到PDF
const canvas = await html2canvas(container, {
scale: 2,
logging: false,
scrollX: 0,
scrollY: 0,
useCORS: true,
allowTaint: true
});
const imgData = canvas.toDataURL('image/jpeg', exportStore.exportOptions.quality);
const imgHeight = canvas.height * contentWidth / canvas.width;
const finalHeight = imgHeight > pageHeight - margin * 2
? pageHeight - margin * 2
: imgHeight;
// 仅在不是第一页时添加新页面(避免空白页)
if (currentGlobalPage > 1) {
pdf.addPage();
}
pdf.addImage(imgData, 'JPEG', margin, margin, contentWidth, finalHeight);
// 添加页码
pdf.setFontSize(10);
pdf.text(`${currentGlobalPage}/${totalPages}`,
pageWidth - margin - 40,
pageHeight - margin);
// 移除临时标题
questionElement.removeChild(pageHeader);
currentGlobalPage++;
progressFill.style.width = `${((currentGlobalPage - 1) / totalPages) * 100}%`;
currentPageEl.textContent = currentGlobalPage;
}
}
}
// 保存PDF文件
pdf.save(getPdfFileName());
const totalQuestions = filteredGroups.reduce((sum, group) => sum + group.questions.length, 0);
solutionWindow.alert(`成功导出 ${totalQuestions} 道题目到PDF!总页数: ${totalPages}`);
} catch (error) {
console.error('PDF导出错误:', error);
let errorMsg = 'PDF导出失败:';
if (error.message.includes('html2canvas')) {
errorMsg += '页面截图工具加载失败,请重试或更换浏览器';
} else if (error.message.includes('jsPDF')) {
errorMsg += 'PDF生成工具加载失败,请重试';
} else if (error.message.includes('timeout')) {
errorMsg += '操作超时,请重试';
} else {
errorMsg += error.message;
}
solutionWindow.alert(errorMsg);
} finally {
// 恢复页面状态
container.classList.remove('export-preview');
resetSectionVisibility();
updateDisplayByExportType(exportStore.exportOptions.exportType);
doc.body.removeChild(loading);
exportStore.isExporting = false;
exportBtn.disabled = false;
exportBtn.innerHTML = ' 导出为PDF';
}
};
const checkLib = () => {
// 定义需要加载的脚本及其检查条件
const scripts = [
{
id: 'jspdf',
src: 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.1.1/jspdf.umd.min.js',
check: () => typeof window.jspdf !== 'undefined',
loadedMsg: 'pdf所需js依赖已加载',
loadingMsg: '正在引入pdf所需js依赖',
errorMsg: 'jspdf加载失败'
},
{
id: 'html2canvas',
src: 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js',
check: () => typeof window.html2canvas !== 'undefined',
loadedMsg: 'html2canvas依赖已加载',
loadingMsg: '正在引入pdf所需html2canvas依赖',
errorMsg: 'html2canvas加载失败'
}
];
// 加载单个脚本的函数,返回Promise
const loadScript = (script) => {
return new Promise((resolve, reject) => {
// 检查是否已加载
if (script.check()) {
console.log(script.loadedMsg);
resolve();
return;
}
console.log(script.loadingMsg);
// 检查是否已有相同脚本正在加载
let scriptElement = document.getElementById(script.id);
if (scriptElement) {
// 如果已有该脚本,等待其加载完成
const checkLoaded = () => {
if (script.check()) {
resolve();
} else {
setTimeout(checkLoaded, 100);
}
};
checkLoaded();
return;
}
// 创建新脚本元素
scriptElement = document.createElement('script');
scriptElement.id = script.id;
scriptElement.src = script.src;
scriptElement.type = 'text/javascript';
// 设置超时
const timeout = setTimeout(() => {
reject(new Error(`${script.id}加载超时`));
scriptElement.remove();
}, 10000); // 10秒超时
// 加载成功处理
scriptElement.onload = () => {
clearTimeout(timeout);
console.log(script.loadedMsg);
resolve();
};
// 加载失败处理
scriptElement.onerror = () => {
clearTimeout(timeout);
console.error(script.errorMsg);
reject(new Error(script.errorMsg));
scriptElement.remove();
};
document.head.appendChild(scriptElement);
});
};
// 加载所有必要的脚本
const loadAllScripts = async () => {
try {
// 并行加载所有需要的脚本
await Promise.all(scripts.map(script => loadScript(script)));
// 所有脚本加载完成,调用导出函数
exportToPDF();
} catch (error) {
console.error('脚本加载过程中发生错误:', error.message);
// 可以在这里添加错误处理逻辑,如提示用户
}
};
// 开始加载流程
loadAllScripts();
};
// 绑定导出按钮事件
exportBtn.addEventListener('click', checkLib);
// 初始化时显示所有题目
updateDisplayByExportType('all');
};
// 启动初始化流程
init();
}
}
// 初始化应用
(function () {
'use strict';
// 创建应用实例,启动工具
new FenbiPaperTool();
})();