// ==UserScript== // @name 学习通题目提取助手 // @namespace https://docs.ocsjs.com/cx-extractor // @version 6.1.0 // @description 专为超星学习通打造:右上角原地展开直接点击抓取、顶部2×2对齐滑块、零卡顿上下拖拽(背景下沉+卡片浮起)、跨页实时同步、纯黑微软雅黑零延迟导出标准 .docx。 // @author OCS-Extractor-Engineer // @license MIT // @match *://*.chaoxing.com/* // @match *://*.edu.cn/* // @match *://*.org.cn/* // @match *://*.xueyinonline.com/* // @match *://*.hnsyu.net/* // @match *://*.qutjxjy.cn/* // @match *://*.ynny.cn/* // @match *://*.hnvist.cn/* // @match *://*.fjlecb.cn/* // @match *://*.gdhkmooc.com/* // @match *://*.cugbonline.cn/* // @match *://*.zjelib.cn/* // @match *://*.cqrspx.cn/* // @match *://*.neauce.com/* // @match *://*.zhihui-yun.com/* // @match *://*.cqie.cn/* // @match *://*.ccqmxx.com/* // @match *://*.jxgmxy.com/* // @match *://*.jnzyjsxy.cn/* // @match *://*.smjyxy.cn/* // @match *://*.sslibrary.com/* // @match *://*.xuexi365.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addValueChangeListener // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect * // @run-at document-idle // ==/UserScript== (function () { 'use strict'; if (window.top !== window.self) { return; } /* ========================================================================== * 顶部配置常量 (CONFIG) —— 已锁定定制液态玻璃参数 * ========================================================================== */ const CONFIG = { STORAGE_KEY_STATE: 'ocs_cx_qe_state_v5', STORAGE_KEY_POS: 'ocs_cx_qe_ball_pos_v5', // 你调校锁定的 12 项 Apple 液态玻璃光学参数 GLASS_THEME: { shellAlpha: 0.42, shellGray: 231, blur: 5, brightness: 88, saturate: 150, borderAlpha: 0.2, shadowAlpha: 0.4, innerGlow: 0.34, cardAlpha: 0.86, cardBorder: 0.25, grooveAlpha: 0.09, badgeOffset: 11 }, PERF: { IMG_CONCURRENCY: 6, IMG_TIMEOUT_MS: 3500, WAIT_FONT_DECRYPT_MS: 800, LONG_PRESS_MS: 220 }, UI: { BALL_SIZE: 48, DEFAULT_RIGHT: 24, DEFAULT_TOP: 96, PANEL_WIDTH: 336, LIST_MAX_HEIGHT: 330, COLLAPSE_DELAY_MS: 300, SCROLL_IDLE_MS: 250, Z_INDEX: 2147483647, EMPTY_TEXT: '轻点右上图标或「抓取」收录本页题目' }, SELECTORS: { courseTitle: [ '.course_name', '.portal_course_name', '#courseName', '.nav_side .title', '.header .courseName', '.backOld .course-name' ].join(', '), workTitle: [ '.mark_title', '#workTitle', '.ceyan_name h3', '.Cy_TItle1 h2', '.posCatalog_active .posCatalog_name', '.top-back-box .title' ].join(', '), questionRoot: '.questionLi, .TiMu, .question-item, [id*="sigleQuestionDiv"]', title: [ '.mark_name', '.splitS-left .mark_name', '.qtContent', '.Zy_TItle .clearfix', '.question-name .html-content-box', ':scope > h3' ].join(', '), titleExclude: [ '.mark_letter', '.stem_answer', '.mark_answer', '.answerBg', '.Py_answer', '.Py_tk', '.Zy_ulTop', '.Zy_ulBottom', '.option-result', '.analysisInfo', '.qtAnalysis' ].join(', '), options: [ '.mark_letter li', '.stem_answer .answer_p', '.answerBg .answer_p', '.Zy_ulTop li', 'ul li .after', '.option-result' ].join(', '), typeInput: 'input[name^="type"], input[id^="answertype"]', typeAndScoreText: '.mark_name .colorShallow, .Zy_TItle .clearfix, .qtTitle', answerBlock: '.mark_answer, .Py_answer, .Py_tk, .analysisInfo, .qtAnalysis, .answer_info', teacherAnswerNodes: [ '.mark_answer .teacher_answer', '.mark_answer .mark_teacher_answer', '.mark_answer .revise_answer', '.Py_answer .teacher_answer', '[class*="teacherAnswer"]', '[class*="teacher_answer"]' ].join(', '), standardAnswerNodes: '.mark_key .colorGreen, .mark_fill .colorGreen, .Py_answer .colorGreen, .rightAnswer', myAnswerNodes: '.mark_key .colorDeep, .mark_fill .colorDeep, .Py_answer .colorDeep, .myAnswer', teacherCommentNodes: '.mark_Protext, .teacher_remark, .Py_tk .teacher_remark, .teacherComment, [class*="teacherRemark"]', rightIcon: '.marking_dui, .Py_answer .ri, .mark_judge_name .marking_dui', wrongIcon: '.marking_cuo, .Py_answer .wr, .mark_judge_name .marking_cuo', halfRightIcon: '.marking_bandui', scoreNodes: '.mark_score .totalScore, .mark_score, .Py_answer .score', analysisNodes: '.qtAnalysis, .analysisInfo, .mark_answer .analysis' }, IFRAME_SKIP_RE: /\/ananas\/modules\/(video|audio|pdf|ppt|innerbook|zt)\/|timing=/i, PATTERNS: { TEACHER_ANS_LABEL: /(?:老师批改(?:后的)?答案|教师批改(?:后的)?答案|老师修改(?:后的)?答案|教师修改答案|老师给出的答案|教师给出的答案|老师答案|教师答案|批改答案|订正答案)\s*[::]\s*([\s\S]*?)(?=(?:我的答案|学生答案|标准答案|参考答案|正确答案|老师评语|教师评语|批改结果|得分|答案解析|解析|$))/i, STD_ANS_LABEL: /(?:正确答案|参考答案|标准答案|系统答案)\s*[::]\s*([\s\S]*?)(?=(?:我的答案|学生答案|老师批改|教师批改|老师答案|教师答案|老师评语|教师评语|批改结果|得分|答案解析|解析|$))/i, MY_ANS_LABEL: /(?:我的答案|学生答案|你的答案|作答)\s*[::]\s*([\s\S]*?)(?=(?:正确答案|参考答案|标准答案|老师批改|教师批改|老师答案|教师答案|老师评语|教师评语|批改结果|得分|答案解析|解析|$))/i, COMMENT_LABEL: /(?:老师评语|教师评语|批改评语|教师批注|老师批注|评语)\s*[::]\s*([\s\S]*?)(?=(?:答案解析|解析|得分|正确答案|标准答案|$))/i, ANALYSIS_LABEL: /(?:答案解析|试题解析|题目解析|解析)\s*[::]\s*([\s\S]*?)(?=(?:老师评语|教师评语|得分|$))/i, TEACHER_GRADE_TRACE: /(?:老师批改|教师批改|教师评语|老师评语|批改评语|教师已批|老师已批|人工批改|批阅)/i } }; const CX_TYPE_MAP = { 0: '单选题', 1: '多选题', 2: '填空题', 3: '判断题', 4: '简答题', 5: '名词解释', 6: '论述题', 7: '计算题', 8: '其它题', 9: '分录题', 10: '资料题', 11: '连线题', 14: '完形填空', 15: '阅读理解' }; const TYPE_ORDER_WEIGHT = { '单选题': 1, '多选题': 2, '判断题': 3, '填空题': 4, '简答题': 5, '问答题': 6, '名词解释': 7, '计算题': 8, '论述题': 9, '完形填空': 10, '阅读理解': 11, '连线题': 12, '分录题': 13, '资料题': 14, '其它题': 99 }; const CN_NUMS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二', '十三', '十四', '十五']; /* ========================================================================== * 极细去色矢量图标集 (1.6px stroke) * ========================================================================== */ const ICONS = { brand: ` `, grab: ` `, export: ` `, folder: ` `, edit: ` ` }; /* ========================================================================== * 样式注入 (GM_addStyle) —— 固化用户调校的 12 项液态玻璃参数 * ========================================================================== */ const T = CONFIG.GLASS_THEME; GM_addStyle(` .ocs-qe-root, .lg-tuner-panel { display: none !important; } .lg-root { --cfg-shell-alpha: ${T.shellAlpha}; --cfg-shell-gray: ${T.shellGray}; --cfg-blur: ${T.blur}px; --cfg-brightness: ${T.brightness}%; --cfg-saturate: ${T.saturate}%; --cfg-border-alpha: ${T.borderAlpha}; --cfg-shadow-alpha: ${T.shadowAlpha}; --cfg-inner-glow: ${T.innerGlow}; --cfg-card-alpha: ${T.cardAlpha}; --cfg-card-border: ${T.cardBorder}; --cfg-groove-alpha: ${T.grooveAlpha}; --cfg-badge-offset: ${T.badgeOffset}px; position: fixed; top: ${CONFIG.UI.DEFAULT_TOP}px; right: ${CONFIG.UI.DEFAULT_RIGHT}px; width: ${CONFIG.UI.BALL_SIZE}px; height: ${CONFIG.UI.BALL_SIZE}px; z-index: ${CONFIG.UI.Z_INDEX}; font-family: "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; font-size: 13px; line-height: 1.45; color: #18181B; text-shadow: 0 1px 1px rgba(255, 255, 255, 0.85); user-select: none; pointer-events: none; box-sizing: border-box; } .lg-root *, .lg-root *::before, .lg-root *::after { box-sizing: border-box; } /* 主液态玻璃外壳(v0.3.0 架构 + 独立背景模糊层防卡顿) */ .lg-shell { position: absolute; top: 0; right: 0; width: ${CONFIG.UI.BALL_SIZE}px; max-height: ${CONFIG.UI.BALL_SIZE}px; border-radius: 24px; box-shadow: inset 0 2px 1.5px 0 rgba(255, 255, 255, var(--cfg-inner-glow)), inset 1.5px 0 1.5px 0 rgba(255, 255, 255, calc(var(--cfg-inner-glow) * 0.88)), inset -1.5px -2px 3px 0 rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(0, 0, 0, var(--cfg-border-alpha)), 0 14px 32px -6px rgba(0, 0, 0, var(--cfg-shadow-alpha)), 0 4px 12px -2px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 0.45)); overflow: hidden; pointer-events: auto; transform: translateZ(0); transition: width 400ms cubic-bezier(0.32, 1.2, 0.54, 1), max-height 420ms cubic-bezier(0.32, 1.2, 0.54, 1), border-radius 400ms cubic-bezier(0.32, 1.2, 0.54, 1), transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1); } /* 独立静态 GPU 模糊层(拖拽与列表滚动时零重绘开销) */ .lg-glass-bg { --mx: 160px; --my: 40px; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: 0; contain: strict; background: radial-gradient(220px circle at var(--mx) var(--my), rgba(255, 255, 255, 0.65) 0%, rgba(255, 255, 255, 0) 65%), linear-gradient( 145deg, rgba(255, 255, 255, calc(var(--cfg-shell-alpha) + 0.10)) 0%, rgba(var(--cfg-shell-gray), var(--cfg-shell-gray), calc(var(--cfg-shell-gray) + 2), var(--cfg-shell-alpha)) 55%, rgba(calc(var(--cfg-shell-gray) - 6), calc(var(--cfg-shell-gray) - 4), var(--cfg-shell-gray), calc(var(--cfg-shell-alpha) + 0.06)) 100% ); backdrop-filter: blur(var(--cfg-blur)) saturate(var(--cfg-saturate)) brightness(var(--cfg-brightness)); -webkit-backdrop-filter: blur(var(--cfg-blur)) saturate(var(--cfg-saturate)) brightness(var(--cfg-brightness)); } /* 表面斜向纯白光泽带 */ .lg-shell::after { content: ""; position: absolute; top: -40%; left: -40%; width: 180%; height: 180%; background: linear-gradient( 115deg, rgba(255, 255, 255, 0) 32%, rgba(255, 255, 255, calc(var(--cfg-inner-glow) * 0.35)) 47%, rgba(255, 255, 255, 0.04) 53%, rgba(255, 255, 255, 0) 65% ); pointer-events: none; z-index: 1; } .lg-root.lg-align-left .lg-shell { right: auto; left: 0; } .lg-root.lg-expanded .lg-shell { width: ${CONFIG.UI.PANEL_WIDTH}px; max-height: 600px; border-radius: 26px; box-shadow: inset 0 2.5px 2px 0 rgba(255, 255, 255, var(--cfg-inner-glow)), inset 1.5px 0 1.5px 0 rgba(255, 255, 255, calc(var(--cfg-inner-glow) * 0.9)), inset -1.5px -2.5px 4px 0 rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(0, 0, 0, var(--cfg-border-alpha)), 0 26px 54px -12px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 1.25)), 0 8px 20px -4px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 0.55)); } .lg-shell.lg-pressing { transform: scale(0.90, 0.93) translateZ(0) !important; } .lg-shell.lg-dragging { cursor: grabbing !important; transform: scale(1.06) translateZ(0) !important; } /* 顶部栏 & 右上角原地水滴图标 */ .lg-top-bar { position: relative; height: ${CONFIG.UI.BALL_SIZE}px; width: 100%; display: flex; align-items: center; z-index: 4; } .lg-orb-trigger { position: absolute; top: 0; right: 0; width: ${CONFIG.UI.BALL_SIZE}px; height: ${CONFIG.UI.BALL_SIZE}px; display: flex; align-items: center; justify-content: center; cursor: pointer; color: #18181B; border-radius: 24px; z-index: 6; transition: transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1), background 200ms ease, box-shadow 200ms ease, border-radius 220ms ease; } .lg-root.lg-align-left .lg-orb-trigger { right: auto; left: 0; } .lg-root.lg-expanded .lg-orb-trigger { top: 7px; right: 10px; width: 34px; height: 34px; border-radius: 17px; background: linear-gradient(145deg, rgba(255, 255, 255, var(--cfg-card-alpha)) 0%, rgba(244, 245, 247, calc(var(--cfg-card-alpha) * 0.65)) 100%); box-shadow: inset 0 1.5px 1px rgba(255, 255, 255, var(--cfg-inner-glow)), 0 1.5px 3px rgba(0, 0, 0, 0.07), 0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border)); } .lg-root.lg-align-left.lg-expanded .lg-orb-trigger { right: auto; left: 10px; } .lg-root.lg-expanded .lg-orb-trigger:hover { transform: scale(1.06); background: #FFFFFF; } .lg-orb-trigger:active { transform: scale(0.88) !important; } .lg-orb-trigger svg { width: 20px; height: 20px; pointer-events: none; transition: transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1); } .lg-root.lg-expanded .lg-orb-trigger svg { width: 16.5px; height: 16.5px; } /* 右上角无圈数字角标(向内收敛 11px,0 不显示) */ .lg-count-num { position: absolute; top: var(--cfg-badge-offset); right: calc(var(--cfg-badge-offset) + 1px); font-size: 10px; font-weight: 800; line-height: 1; color: #18181B; text-shadow: 0 0 3px #FFFFFF; pointer-events: none; font-variant-numeric: tabular-nums; transition: top 200ms ease, right 200ms ease, font-size 200ms ease; } .lg-root.lg-expanded .lg-count-num { top: calc(var(--cfg-badge-offset) * 0.5); right: calc(var(--cfg-badge-offset) * 0.65); font-size: 9px; } .lg-count-num.lg-hidden { display: none !important; } /* 课程总标题水滴条 */ .lg-course-wrap { flex: 1; min-width: 0; padding: 6px 52px 0 12px; display: flex; align-items: center; opacity: 0; transform: translateX(8px); pointer-events: none; transition: opacity 200ms ease 40ms, transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1) 40ms; } .lg-root.lg-align-left .lg-course-wrap { padding: 6px 12px 0 52px; } .lg-root.lg-expanded .lg-course-wrap { opacity: 1; transform: translateX(0); pointer-events: auto; } .lg-course-title { flex: 1; min-width: 0; font-size: 13.5px; font-weight: 700; color: #18181B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; padding: 5px 10px; border-radius: 12px; background: linear-gradient(180deg, rgba(255, 255, 255, var(--cfg-card-alpha)) 0%, rgba(255, 255, 255, calc(var(--cfg-card-alpha) * 0.55)) 100%); box-shadow: inset 0 1.5px 1px rgba(255, 255, 255, var(--cfg-inner-glow)), 0 1px 2px rgba(0, 0, 0, 0.04), 0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border)); transition: background 150ms ease; } .lg-course-title:hover { background: #FFFFFF; } .lg-course-input { flex: 1; min-width: 0; height: 28px; padding: 0 10px; font-size: 12.5px; font-weight: 600; color: #18181B; background: rgba(255, 255, 255, 0.92); border: 1px solid rgba(0, 0, 0, 0.18); border-radius: 10px; outline: none; } /* 面板主体:2×2 严丝合缝对齐矩阵 */ .lg-body { position: relative; z-index: 4; padding: 8px 12px 10px 12px; opacity: 0; transform: translateY(-6px); pointer-events: none; transition: opacity 200ms ease, transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1); } .lg-root.lg-expanded .lg-body { opacity: 1; transform: translateY(0); pointer-events: auto; transition-delay: 40ms; } .lg-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .lg-btn { height: 36px; border-radius: 14px; font-size: 12.5px; font-weight: 700; color: #18181B; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px; outline: none; border: none; transition: transform 160ms cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 160ms ease, background 160ms ease, opacity 140ms ease; } .lg-btn:disabled { opacity: 0.55; cursor: not-allowed; } .lg-btn svg { width: 15px; height: 15px; } .lg-btn-primary { background: linear-gradient(165deg, rgba(255, 255, 255, calc(var(--cfg-card-alpha) + 0.12)) 0%, rgba(242, 244, 247, calc(var(--cfg-card-alpha) * 0.82)) 100%); box-shadow: inset 0 2px 1px rgba(255, 255, 255, var(--cfg-inner-glow)), inset 0 -1.5px 3px rgba(0, 0, 0, 0.05), 0 4px 10px -2px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 0.6)), 0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border)); } .lg-btn-primary:hover:not(:disabled) { transform: translateY(-1px); background: #FFFFFF; } .lg-btn-secondary { background: linear-gradient(165deg, rgba(255, 255, 255, var(--cfg-card-alpha)) 0%, rgba(240, 242, 245, calc(var(--cfg-card-alpha) * 0.58)) 100%); box-shadow: inset 0 2px 1px rgba(255, 255, 255, var(--cfg-inner-glow)), inset 0 -1.5px 2.5px rgba(0, 0, 0, 0.04), 0 3px 8px -2px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 0.45)), 0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border)); } .lg-btn-secondary:hover:not(:disabled) { transform: translateY(-1px); background: #FFFFFF; } .lg-btn:active:not(:disabled) { transform: scale(0.95) translateY(1px) !important; box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.12) !important; } /* 第二行:顺序/类型 | 默认/纯净(与抓取、导出 1:1 严丝合缝对齐) */ .lg-sub-controls { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; padding-bottom: 10px; border-bottom: 1px solid rgba(0, 0, 0, calc(var(--cfg-border-alpha) * 0.75)); } .lg-seg-switch { position: relative; display: grid; grid-template-columns: 1fr 1fr; background: rgba(0, 0, 0, var(--cfg-groove-alpha)); box-shadow: inset 0 1.5px 3px rgba(0, 0, 0, calc(var(--cfg-groove-alpha) * 1.3)), 0 1px 0 rgba(255, 255, 255, 0.8); border-radius: 11px; padding: 2.5px; width: 100%; height: 30px; } .lg-seg-glider { position: absolute; top: 2.5px; left: 2.5px; width: calc(50% - 2.5px); height: calc(100% - 5px); background: linear-gradient(165deg, #FFFFFF 0%, rgba(246, 247, 249, 0.88) 100%); border-radius: 8.5px; box-shadow: inset 0 1.5px 1px #FFFFFF, 0 1.5px 4px rgba(0, 0, 0, 0.10), 0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border)); transition: transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1); pointer-events: none; z-index: 1; } .lg-seg-switch[data-second="true"] .lg-seg-glider { transform: translateX(100%); } .lg-seg-opt { position: relative; z-index: 2; border: none; background: transparent; color: #52525B; font-size: 11.5px; font-weight: 600; padding: 0; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: color 180ms ease; } .lg-seg-opt.active { color: #18181B; font-weight: 700; } /* 轻提示条 */ .lg-toast { margin-top: 8px; padding: 6px 11px; border-radius: 12px; font-size: 11.5px; font-weight: 600; display: none; align-items: center; justify-content: space-between; word-break: break-all; background: rgba(255, 255, 255, 0.88); color: #18181B; box-shadow: inset 0 1.5px 1px #FFFFFF, 0 3px 8px rgba(0, 0, 0, 0.06), 0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border)); } .lg-toast.lg-show { display: flex; } .lg-toast-warn { background: rgba(254, 252, 232, 0.92); color: #854D0E; } .lg-toast-error { background: rgba(254, 242, 242, 0.92); color: #991B1B; } /* 列表玻璃凹槽 & 水滴作业卡 */ .lg-list-wrap { margin-top: 10px; background: rgba(0, 0, 0, var(--cfg-groove-alpha)); box-shadow: inset 0 2px 5px rgba(0, 0, 0, calc(var(--cfg-groove-alpha) * 1.2)), 0 1px 0 rgba(255, 255, 255, 0.75); border-radius: 18px; padding: 6px; transition: transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1), background-color 200ms ease, box-shadow 200ms ease; } .lg-list-wrap.lg-recessed { background: rgba(0, 0, 0, calc(var(--cfg-groove-alpha) + 0.045)); transform: scale(0.98); box-shadow: inset 0 3px 10px rgba(0, 0, 0, 0.12); } .lg-list { display: block !important; max-height: ${CONFIG.UI.LIST_MAX_HEIGHT}px; overflow-y: auto; overflow-x: hidden; margin: 0; padding: 2px; contain: content; } .lg-list::-webkit-scrollbar { width: 4px; } .lg-list::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.18); border-radius: 2px; } .lg-empty { padding: 26px 10px; text-align: center; color: #52525B; font-weight: 500; font-size: 12px; } .lg-group { display: block !important; margin-bottom: 6px; border-radius: 15px; transition: opacity 160ms ease; } .lg-group:last-child { margin-bottom: 0; } .lg-list-wrap.lg-recessed .lg-group:not(.lg-drag-placeholder) { opacity: 0.62; } .lg-group-header { display: flex !important; align-items: center; gap: 6px; padding: 8px 10px; border-radius: 13px; color: #18181B; background: linear-gradient(145deg, rgba(255, 255, 255, var(--cfg-card-alpha)) 0%, rgba(246, 247, 249, calc(var(--cfg-card-alpha) * 0.65)) 100%); box-shadow: inset 0 1.5px 1px rgba(255, 255, 255, var(--cfg-inner-glow)), 0 2px 6px -1px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 0.4)), 0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border)); transition: background 160ms ease, box-shadow 160ms ease; } .lg-group-header:hover { background: linear-gradient(145deg, rgba(255, 255, 255, calc(var(--cfg-card-alpha) + 0.12)) 0%, rgba(250, 250, 252, calc(var(--cfg-card-alpha) * 0.8)) 100%); } .lg-group.lg-group-open .lg-group-header { background: linear-gradient(145deg, #FFFFFF 0%, rgba(242, 244, 247, 0.90) 100%); box-shadow: inset 0 2px 1.5px #FFFFFF, 0 5px 14px -3px rgba(0, 0, 0, calc(var(--cfg-shadow-alpha) * 0.7)), 0 0 0 1px rgba(0, 0, 0, calc(var(--cfg-card-border) * 1.25)); } /* 题数以左区域:删去六个点,按住即可上下拖动,轻点展开 */ .lg-group-drag-zone { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; cursor: grab; } .lg-group-drag-zone:active { cursor: grabbing; } .lg-folder-icon { width: 15px; height: 15px; display: inline-flex; align-items: center; justify-content: center; opacity: 0.78; flex-shrink: 0; } .lg-folder-icon svg { width: 15px; height: 15px; } .lg-group-name { flex: 1; min-width: 0; font-weight: 700; font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .lg-group-input { flex: 1; min-width: 0; height: 24px; padding: 0 6px; font-size: 12px; font-weight: 600; color: #18181B; border: 1px solid rgba(0, 0, 0, 0.2); border-radius: 7px; outline: none; background: #FFFFFF; } .lg-group-count { font-size: 11px; font-weight: 700; color: #3F3F46; background: rgba(0, 0, 0, var(--cfg-groove-alpha)); box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.04), 0 1px 0 #FFFFFF; padding: 1px 7px; border-radius: 8px; font-variant-numeric: tabular-nums; flex-shrink: 0; } .lg-icon-btn { width: 20px; height: 20px; border: none; background: transparent; color: #3F3F46; opacity: 0.65; font-size: 13px; border-radius: 7px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; padding: 0; flex-shrink: 0; transition: opacity 120ms ease, background-color 120ms ease; } .lg-icon-btn svg { width: 12.5px; height: 12.5px; } .lg-icon-btn:hover { opacity: 1; background: rgba(0, 0, 0, 0.06); } .lg-sublist { display: none; margin: 6px 4px 4px 16px; padding: 2px 4px 4px 12px; border-left: 1.5px solid rgba(0, 0, 0, 0.14); flex-wrap: wrap; gap: 6px; } .lg-group.lg-group-open .lg-sublist { display: flex !important; } .lg-type-badge-row { width: 100%; font-size: 11px; font-weight: 700; color: #52525B; padding: 3px 2px 1px 2px; } .lg-qchip { display: inline-flex !important; align-items: center; gap: 4px; padding: 4px 9px; background: linear-gradient(155deg, rgba(255, 255, 255, var(--cfg-card-alpha)) 0%, rgba(244, 245, 247, calc(var(--cfg-card-alpha) * 0.7)) 100%); border-radius: 10px; font-size: 12px; color: #18181B; cursor: grab; box-shadow: inset 0 1.5px 1px #FFFFFF, 0 2px 4px rgba(0, 0, 0, 0.05), 0 0 0 0.5px rgba(0, 0, 0, var(--cfg-card-border)); transition: background 140ms ease, opacity 140ms ease; } .lg-qchip:hover { background: #FFFFFF; } .lg-list-wrap.lg-recessed .lg-qchip:not(.lg-drag-placeholder) { opacity: 0.58; } .lg-qchip-label { font-weight: 600; cursor: pointer; } .lg-qchip-del { border: none; background: transparent; color: #71717A; font-size: 13px; line-height: 1; padding: 0 1px; cursor: pointer; border-radius: 4px; } .lg-qchip-del:hover { color: #18181B; background: rgba(0, 0, 0, 0.07); } .lg-teacher-dot { width: 5px; height: 5px; border-radius: 50%; background: #18181B; display: inline-block; flex-shrink: 0; } .lg-teacher-warn-dot { width: 5px; height: 5px; border-radius: 50%; background: #D97706; display: inline-block; flex-shrink: 0; } /* 零延迟 GPU 悬浮拖拽卡片 */ .lg-drag-placeholder { background: rgba(0, 0, 0, 0.03) !important; border: 1.5px dashed rgba(0, 0, 0, 0.25) !important; box-shadow: none !important; } .lg-drag-placeholder * { visibility: hidden !important; } .lg-floating-card { position: fixed; z-index: 2147483647; pointer-events: none; background: #FFFFFF !important; color: #18181B !important; border-radius: 13px !important; box-shadow: inset 0 2px 1px #FFFFFF, 0 0 0 1px rgba(0, 0, 0, 0.12), 0 16px 32px -6px rgba(0, 0, 0, 0.22), 0 4px 10px -2px rgba(0, 0, 0, 0.10) !important; will-change: transform; transition: none !important; } .lg-qeditor { width: 100%; margin-top: 5px; padding: 9px; background: rgba(255, 255, 255, 0.92); border-radius: 12px; font-size: 12px; box-shadow: inset 0 1.5px 1px #FFFFFF, 0 4px 10px rgba(0, 0, 0, 0.06), 0 0 0 0.5px rgba(0, 0, 0, 0.10); } .lg-qeditor-row { margin-bottom: 6px; } .lg-qeditor-row label { display: block; font-size: 11px; font-weight: 700; color: #52525B; margin-bottom: 2px; } .lg-qeditor-row textarea, .lg-qeditor-row input { width: 100%; padding: 5px 7px; font-size: 12px; border: 1px solid rgba(0, 0, 0, 0.15); border-radius: 8px; color: #18181B; background: #FFFFFF; font-family: inherit; outline: none; } .lg-qeditor-actions { display: flex; justify-content: flex-end; gap: 6px; } .lg-qeditor-btn { padding: 3px 10px; font-size: 11px; font-weight: 700; border-radius: 8px; cursor: pointer; border: 1px solid rgba(0, 0, 0, 0.08); background: #F4F4F5; color: #18181B; } .lg-qeditor-btn.primary { background: #18181B; color: #FFFFFF; border-color: #18181B; } .lg-footer { margin-top: 6px; display: flex; align-items: center; justify-content: flex-end; gap: 6px; font-size: 11.5px; padding: 0 2px; } .lg-text-btn { border: none; background: transparent; color: #52525B; font-size: 11.5px; font-weight: 600; cursor: pointer; padding: 3px 8px; border-radius: 8px; transition: background-color 140ms ease, transform 80ms ease; } .lg-text-btn:active { transform: scale(0.92); } .lg-text-btn.lg-undo-btn { color: #18181B; background: rgba(255, 255, 255, 0.8); box-shadow: inset 0 1px 0.5px #FFFFFF, 0 1px 3px rgba(0, 0, 0, 0.06), 0 0 0 0.5px rgba(0, 0, 0, 0.08); font-weight: 700; display: none; } .lg-text-btn.lg-undo-btn.lg-show { display: inline-block; } .lg-text-btn.lg-clear-btn:hover { color: #18181B; background: rgba(0, 0, 0, 0.05); } `); /* ========================================================================== * 状态与跨标签页/返回列表页实时同步管理 * ========================================================================== */ const imageCache = new Map(); const state = { courseTitle: '', grabCount: 0, groups: [], sortMode: 'order', // 'order' | 'type' docStyle: 'default', // 'default' | 'pure' typeGroupExpanded: {}, undoBackup: null, ballPos: null, isExpanded: false, isBallDragging: false, isListDragging: false, isScrolling: false, isEditingInput: false, editingCourseTitle: false, editingGroupId: null, editingQuestionLoc: null, collapseTimer: null, scrollTimer: null }; let suppressNextHeaderClick = false; function cleanText(str) { return (str || '') .replace(/\u00A0/g, ' ') .replace(/[ \t]+/g, ' ') .replace(/\n\s*\n/g, '\n') .trim(); } function cleanSingleLine(str) { return (str || '').replace(/\s+/g, ' ').trim(); } function deduplicateAnswerString(ansStr) { if (!ansStr) return ''; const parts = String(ansStr) .split(/[;;]/) .map((s) => cleanSingleLine(s).replace(/^([A-Z])\s+(?=[.\u4e00-\u9fa5A-Za-z0-9])/i, '$1. ')) .filter(Boolean); return [...new Set(parts)].join(';'); } function computeFingerprint(q) { if (q._fp) return q._fp; const stemNorm = cleanText(q.stem).replace(/^\d+[\.、.\s]*/, '').replace(/\s+/g, ''); const optNorm = (q.options || []).map((o) => cleanText(o.text).replace(/\s+/g, '')).join('|'); q._fp = `${q.type || ''}::${stemNorm}::${optNorm}`; return q._fp; } function getTotalQuestionCount() { return state.groups.reduce((sum, g) => sum + (Array.isArray(g.questions) ? g.questions.length : 0), 0); } function getTypeWeight(typeStr) { const t = cleanText(typeStr); if (TYPE_ORDER_WEIGHT[t] !== undefined) return TYPE_ORDER_WEIGHT[t]; for (const k of Object.keys(TYPE_ORDER_WEIGHT)) { if (t.includes(k.replace('题', ''))) return TYPE_ORDER_WEIGHT[k]; } return 50; } function normalizeGroupQuestionsByType(questions) { return [...(questions || [])].sort((a, b) => getTypeWeight(a.type) - getTypeWeight(b.type)); } function sanitizeStoredGroups(groups) { if (!Array.isArray(groups)) return []; groups.forEach((grp) => { (grp.questions || []).forEach((q) => { if (q.myAnswer) q.myAnswer = deduplicateAnswerString(q.myAnswer); if (q.teacherAnswer) q.teacherAnswer = cleanSingleLine(q.teacherAnswer); if (q.standardAnswer) q.standardAnswer = cleanSingleLine(q.standardAnswer); }); }); return groups; } function syncStateFromStorage() { if (state.isEditingInput || state.isListDragging) return; try { const raw = GM_getValue(CONFIG.STORAGE_KEY_STATE, '') || GM_getValue('ocs_cx_qe_state_v4', ''); if (raw) { const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; if (parsed && typeof parsed === 'object') { const prevExpandedMap = new Map(state.groups.map((g) => [g.id, g.expanded])); const loadedGroups = sanitizeStoredGroups(parsed.groups); loadedGroups.forEach((g) => { if (prevExpandedMap.has(g.id)) { g.expanded = prevExpandedMap.get(g.id); } }); state.groups = loadedGroups; state.grabCount = typeof parsed.grabCount === 'number' ? parsed.grabCount : state.groups.length; if (parsed.courseTitle) state.courseTitle = parsed.courseTitle; state.sortMode = parsed.sortMode === 'type' ? 'type' : 'order'; state.docStyle = parsed.docStyle === 'pure' || parsed.docStyle === 'quiz' ? 'pure' : 'default'; } } } catch (_) {} try { const rawPos = GM_getValue(CONFIG.STORAGE_KEY_POS, '') || GM_getValue('ocs_cx_qe_ball_pos_v4', ''); if (rawPos) { const pos = typeof rawPos === 'string' ? JSON.parse(rawPos) : rawPos; if (pos && typeof pos.left === 'number' && typeof pos.top === 'number') { state.ballPos = pos; } } } catch (_) {} } function saveStateNow() { try { GM_setValue( CONFIG.STORAGE_KEY_STATE, JSON.stringify({ courseTitle: state.courseTitle, grabCount: state.grabCount, groups: state.groups, sortMode: state.sortMode, docStyle: state.docStyle }) ); } catch (e) { showToast('存储空间不足,请先导出并清空旧记录', 'error'); } } function saveBallPosition(left, top) { state.ballPos = { left, top }; try { GM_setValue(CONFIG.STORAGE_KEY_POS, JSON.stringify(state.ballPos)); } catch (_) {} } /* ========================================================================== * 超星页面扫描、课程名/作业名提取与题目解析 * ========================================================================== */ function getChaoxingQuizDocuments(rootDoc = document) { const docs = [rootDoc]; const queue = Array.from(rootDoc.querySelectorAll('iframe')); while (queue.length > 0) { const frame = queue.shift(); const src = frame.getAttribute('src') || ''; if (src && CONFIG.IFRAME_SKIP_RE.test(src)) continue; try { const subDoc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document); if (subDoc) { docs.push(subDoc); const childFrames = subDoc.querySelectorAll('iframe'); if (childFrames.length > 0) queue.push(...Array.from(childFrames)); } } catch (_) {} } return docs; } function detectCourseTitle(docs) { for (const doc of docs) { const el = doc.querySelector(CONFIG.SELECTORS.courseTitle); if (el) { const t = cleanSingleLine(el.textContent); if (t && t.length > 1) return t; } } return state.courseTitle || '学习通课程题库汇总'; } function detectCurrentWorkTitle(docs, fallbackIndex) { for (const doc of docs) { const titleEl = doc.querySelector(CONFIG.SELECTORS.workTitle); if (titleEl) { const t = cleanSingleLine(titleEl.textContent) .replace(/^(?:\d{4,}[-\s_]*)+/, '') .trim(); if (t) return t; } } const docTitle = cleanSingleLine(document.title) .replace(/[-_—]\s*(超星学习通|学习通|泛雅|作业详情|整卷预览).*$/i, '') .replace(/^(?:\d{4,}[-\s_]*)+/, '') .trim(); if (docTitle && docTitle !== '作业详情' && docTitle !== '学生端' && docTitle !== '章节学习') { return docTitle; } return `作业${fallbackIndex}`; } async function waitForCxFontDecryptedIfNeeded(docs) { const hasSecretFont = () => docs.some((d) => d.querySelector('.font-cxsecret') !== null); if (!hasSecretFont()) return true; const start = Date.now(); while (Date.now() - start < CONFIG.PERF.WAIT_FONT_DECRYPT_MS) { await new Promise((r) => setTimeout(r, 150)); if (!hasSecretFont()) return true; } return !hasSecretFont(); } async function runWithConcurrency(tasks, limit = CONFIG.PERF.IMG_CONCURRENCY) { const results = new Array(tasks.length); let idx = 0; const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => { while (idx < tasks.length) { const cur = idx++; results[cur] = await tasks[cur](); } }); await Promise.all(workers); return results; } async function imageToBase64Cached(imgEl) { if (!imgEl) return null; const rawSrc = imgEl.getAttribute('data-src') || imgEl.getAttribute('data-original') || imgEl.src; if (!rawSrc) return null; let absUrl = rawSrc; try { absUrl = new URL(rawSrc, (imgEl.ownerDocument || document).baseURI || location.href).href; } catch (_) {} if (imageCache.has(absUrl)) return imageCache.get(absUrl); const naturalW = imgEl.naturalWidth || imgEl.width || 160; const naturalH = imgEl.naturalHeight || imgEl.height || 90; if (absUrl.startsWith('data:image/')) { const info = { base64: absUrl, width: naturalW, height: naturalH }; imageCache.set(absUrl, info); return info; } const fetchPromise = new Promise((resolve) => { if (typeof GM_xmlhttpRequest !== 'function') { resolve(null); return; } try { GM_xmlhttpRequest({ method: 'GET', url: absUrl, responseType: 'blob', timeout: CONFIG.PERF.IMG_TIMEOUT_MS, onload: (res) => { if (res.status >= 200 && res.status < 300 && res.response) { const reader = new FileReader(); reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : null); reader.onerror = () => resolve(null); reader.readAsDataURL(res.response); } else { resolve(null); } }, onerror: () => resolve(null), ontimeout: () => resolve(null) }); } catch (_) { resolve(null); } }); const timeoutGuard = new Promise((resolve) => setTimeout(() => resolve(null), CONFIG.PERF.IMG_TIMEOUT_MS + 200)); const gmBase64 = await Promise.race([fetchPromise, timeoutGuard]); if (gmBase64 && gmBase64.startsWith('data:image/')) { const info = { base64: gmBase64, width: naturalW, height: naturalH }; imageCache.set(absUrl, info); return info; } try { const canvas = document.createElement('canvas'); canvas.width = naturalW; canvas.height = naturalH; const ctx = canvas.getContext('2d'); ctx.drawImage(imgEl, 0, 0, naturalW, naturalH); const info = { base64: canvas.toDataURL('image/png'), width: naturalW, height: naturalH }; imageCache.set(absUrl, info); return info; } catch (_) { return null; } } function extractNodeTextAndImageEls(containerEl, isTitleNode = false) { if (!containerEl) return { text: '', imgEls: [] }; const clone = containerEl.cloneNode(true); if (isTitleNode) { clone.querySelectorAll('.colorShallow').forEach((n) => n.remove()); clone.querySelectorAll(CONFIG.SELECTORS.titleExclude).forEach((n) => n.remove()); } clone.querySelectorAll('script, style, input[type="hidden"]').forEach((n) => n.remove()); clone.querySelectorAll('img.kfformula, img[data-latex]').forEach((img) => { const latex = img.getAttribute('data-latex') || img.getAttribute('data-slatex') || ''; if (latex.trim()) { img.replaceWith(` $${latex.replace(/\\\\/g, '\\')}$ `); } }); if (!isTitleNode) { clone.querySelectorAll('.ri').forEach((el) => el.replaceWith('对')); clone.querySelectorAll('.wr').forEach((el) => el.replaceWith('错')); } const imgEls = Array.from(containerEl.querySelectorAll('img')).filter((img) => { if (isTitleNode && img.closest(CONFIG.SELECTORS.titleExclude)) return false; const src = img.getAttribute('data-src') || img.src || ''; if (!src || /marking_(dui|cuo|bandui)|space\.gif|blank\.gif|icon/i.test(src)) return false; return true; }); const text = isTitleNode ? cleanText(clone.innerText || clone.textContent || '') : cleanSingleLine(clone.innerText || clone.textContent || ''); return { text, imgEls }; } function parseCxTypeAndScore(rootEl) { let qType = ''; let qScore = ''; const typeInput = rootEl.querySelector(CONFIG.SELECTORS.typeInput); if (typeInput && typeInput.value !== '') { const code = parseInt(typeInput.value, 10); if (!Number.isNaN(code) && CX_TYPE_MAP[code]) qType = CX_TYPE_MAP[code]; } if (!qType && rootEl.classList.contains('question-item')) { if (rootEl.classList.contains('single-choice')) qType = '单选题'; else if (rootEl.classList.contains('multiple-choice')) qType = '多选题'; } const metaEl = rootEl.querySelector(CONFIG.SELECTORS.typeAndScoreText); const metaText = metaEl ? cleanSingleLine(metaEl.textContent) : ''; if (!qType && metaText) { const tm = metaText.match(/(单选题|多选题|判断题|填空题|简答题|问答题|论述题|计算题|名词解释|完形填空|阅读理解|连线题|单选|多选|判断|填空)/); if (tm) qType = tm[1].endsWith('题') || tm[1].length > 2 ? tm[1] : `${tm[1]}题`; } const sm = metaText.match(/(\d+(?:\.\d+)?)\s*分/); if (sm) qScore = sm[1]; return { qType, qScore }; } function parseCxGradingAndAnswers(rootEl, totalScoreHint) { const answerBlocks = Array.from(rootEl.querySelectorAll(CONFIG.SELECTORS.answerBlock)); const fullBlockText = (answerBlocks.length > 0 ? answerBlocks : [rootEl]) .map((n) => cleanText(n.innerText || n.textContent)) .join('\n'); let teacherAnswer = ''; let standardAnswer = ''; let myAnswer = ''; let judgeResult = ''; let obtainedScore = ''; let scoreDisplay = ''; let teacherComment = ''; let analysis = ''; let hasTeacherGrade = false; const teacherAnsNode = rootEl.querySelector(CONFIG.SELECTORS.teacherAnswerNodes); if (teacherAnsNode) { hasTeacherGrade = true; teacherAnswer = cleanSingleLine(teacherAnsNode.innerText || teacherAnsNode.textContent) .replace(/^(?:老师批改(?:后的)?答案|教师批改(?:后的)?答案|老师答案|教师答案|批改答案|订正答案)\s*[::]\s*/i, ''); } if (!teacherAnswer) { const tMatch = fullBlockText.match(CONFIG.PATTERNS.TEACHER_ANS_LABEL); if (tMatch && tMatch[1]) { hasTeacherGrade = true; teacherAnswer = cleanSingleLine(tMatch[1]); } } const stdAnsNode = rootEl.querySelector(CONFIG.SELECTORS.standardAnswerNodes); if (stdAnsNode) { standardAnswer = cleanSingleLine(stdAnsNode.innerText || stdAnsNode.textContent) .replace(/^(?:正确答案|参考答案|标准答案|系统答案|答案)\s*[::]\s*/i, ''); } if (!standardAnswer) { const sMatch = fullBlockText.match(CONFIG.PATTERNS.STD_ANS_LABEL); if (sMatch && sMatch[1]) standardAnswer = cleanSingleLine(sMatch[1]); } const myAnsNode = rootEl.querySelector(CONFIG.SELECTORS.myAnswerNodes); if (myAnsNode) { myAnswer = cleanSingleLine(myAnsNode.innerText || myAnsNode.textContent) .replace(/^(?:我的答案|学生答案|你的答案|作答)\s*[::]\s*/i, ''); } if (!myAnswer) { const mMatch = fullBlockText.match(CONFIG.PATTERNS.MY_ANS_LABEL); if (mMatch && mMatch[1]) myAnswer = cleanSingleLine(mMatch[1]); } if (!myAnswer) { const rawCheckedNodes = Array.from( rootEl.querySelectorAll('[class*="check_answer"], .option-result.active, label input[checked="checked"], [aria-checked="true"], input[type="radio"]:checked, input[type="checkbox"]:checked') ); if (rawCheckedNodes.length > 0) { const uniqueRowSet = new Set(); const extractedAnswers = []; rawCheckedNodes.forEach((el) => { const rowContainer = el.closest('.answerBg, li, .option-result, label') || el; if (uniqueRowSet.has(rowContainer)) return; uniqueRowSet.add(rowContainer); const numSpan = rowContainer.querySelector('.num_option, .num_option_dx'); const pSpan = rowContainer.querySelector('.answer_p, .after'); if (numSpan && pSpan) { const letter = cleanSingleLine(numSpan.textContent); const content = cleanSingleLine(pSpan.textContent); extractedAnswers.push(content ? `${letter}. ${content}` : letter); } else if (el.tagName === 'INPUT' && el.value) { extractedAnswers.push(cleanSingleLine(el.value)); } else { const rowText = cleanSingleLine(rowContainer.textContent).replace(/^([A-Z])\s+/i, '$1. '); if (rowText) extractedAnswers.push(rowText); } }); myAnswer = [...new Set(extractedAnswers)].join(';'); } else { const textVals = []; rootEl.querySelectorAll('textarea, .line_answer input[name^="answer"]').forEach((inp) => { const val = cleanSingleLine(inp.value); if (val) textVals.push(val); }); rootEl.querySelectorAll('iframe[id^="ueditor_"]').forEach((frame) => { try { const uText = cleanSingleLine(frame.contentDocument.body.innerText); if (uText) textVals.push(uText); } catch (_) {} }); if (textVals.length > 0) myAnswer = [...new Set(textVals)].join(';'); } } myAnswer = deduplicateAnswerString(myAnswer); const commentNode = rootEl.querySelector(CONFIG.SELECTORS.teacherCommentNodes); if (commentNode) { const cText = cleanSingleLine(commentNode.innerText || commentNode.textContent) .replace(/^(?:老师评语|教师评语|批改评语|评语)\s*[::]\s*/i, ''); if (cText) { teacherComment = cText; hasTeacherGrade = true; } } if (!teacherComment) { const cMatch = fullBlockText.match(CONFIG.PATTERNS.COMMENT_LABEL); if (cMatch && cMatch[1]) { teacherComment = cleanSingleLine(cMatch[1]); if (teacherComment) hasTeacherGrade = true; } } if (rootEl.querySelector(CONFIG.SELECTORS.rightIcon)) { judgeResult = '✔'; } else if (rootEl.querySelector(CONFIG.SELECTORS.wrongIcon)) { judgeResult = '✘'; } else if (rootEl.querySelector(CONFIG.SELECTORS.halfRightIcon)) { judgeResult = '✔/✘ (半对)'; } else { const jm = fullBlockText.match(/(?:批改结果|状态)\s*[::]?\s*(正确|错误|对|错|√|×|✔|✘)/); if (jm) judgeResult = /(正确|对|√|✔)/.test(jm[1]) ? '✔' : '✘'; } const scoreNodes = Array.from(rootEl.querySelectorAll(CONFIG.SELECTORS.scoreNodes)); const scoreRaw = scoreNodes.map((n) => cleanSingleLine(n.textContent)).join(' ') + '\n' + fullBlockText; const ratioMatch = scoreRaw.match(/(\d+(?:\.\d+)?)\s*(?:分)?\s*(?:\/|/)\s*(\d+(?:\.\d+)?)/); if (ratioMatch) { obtainedScore = ratioMatch[1]; scoreDisplay = `${ratioMatch[1]}/${ratioMatch[2]}`; } else { const totalScoreItalic = rootEl.querySelector('.mark_score .totalScore i'); const italicScoreText = totalScoreItalic ? cleanSingleLine(totalScoreItalic.textContent) : ''; const singleScoreMatch = (italicScoreText !== '' && /^\d+(?:\.\d+)?$/.test(italicScoreText) ? [null, italicScoreText] : null) || scoreRaw.match(/(?:得分|实际得分|获得分数)\s*[::]\s*(\d+(?:\.\d+)?)\s*分?/); if (singleScoreMatch && singleScoreMatch[1] !== undefined) { obtainedScore = singleScoreMatch[1]; scoreDisplay = totalScoreHint ? `${obtainedScore}/${totalScoreHint}` : `${obtainedScore}分`; } } if ( teacherAnswer || teacherComment || CONFIG.PATTERNS.TEACHER_GRADE_TRACE.test(fullBlockText) || rootEl.querySelector('.mark_Protext, .teacher_remark') ) { hasTeacherGrade = true; } const analysisNode = rootEl.querySelector(CONFIG.SELECTORS.analysisNodes); if (analysisNode) { analysis = cleanText(analysisNode.innerText || analysisNode.textContent) .replace(/^(?:答案解析|试题解析|题目解析|解析)\s*[::]\s*/i, ''); } if (!analysis) { const aMatch = fullBlockText.match(CONFIG.PATTERNS.ANALYSIS_LABEL); if (aMatch && aMatch[1]) analysis = cleanText(aMatch[1]); } const finalAnswer = teacherAnswer || standardAnswer || ''; const missingTeacherAnswerWarn = Boolean(hasTeacherGrade && !teacherAnswer && !standardAnswer); const isGraded = Boolean(judgeResult !== '' || obtainedScore !== ''); return { myAnswer, teacherAnswer, standardAnswer, finalAnswer, hasTeacherGrade, missingTeacherAnswerWarn, isGraded, judgeResult, obtainedScore, score: scoreDisplay, teacherComment, analysis }; } function parseCxQuestionSync(rootEl) { const titleNodes = Array.from(rootEl.querySelectorAll(CONFIG.SELECTORS.title)).filter( (el) => !el.closest(CONFIG.SELECTORS.titleExclude) ); const primaryTitleEl = titleNodes[0] || rootEl; const { text: rawStemText, imgEls: stemImgEls } = extractNodeTextAndImageEls(primaryTitleEl, true); const cleanedStem = rawStemText .replace(/^\d+[\.、.\s]+/, '') .replace(/^[\((【\[][^\))】\]]*(?:..题|名词解释|完形填空|阅读理解|\d+(?:\.\d+)?\s*分)[^\))】\]]*[\))】\]]\s*/, '') .replace(/\s+[A-D][\.\s、]+对\s+[A-D][\.\s、]+错[\s\S]*$/, '') .trim(); if (!cleanedStem && stemImgEls.length === 0) return null; const { qType, qScore } = parseCxTypeAndScore(rootEl); const rawOptNodes = Array.from(rootEl.querySelectorAll(CONFIG.SELECTORS.options)); const optNodes = rawOptNodes.filter( (node) => !rawOptNodes.some((other) => other !== node && other.contains(node)) ); const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; const options = []; for (let i = 0; i < optNodes.length; i++) { const { text, imgEls } = extractNodeTextAndImageEls(optNodes[i], false); if (!text && imgEls.length === 0) continue; const prefixLetter = letters[i] || String(i + 1); const parentOptionSpan = optNodes[i].parentElement && optNodes[i].parentElement.querySelector('.num_option, .num_option_dx'); const detectedLetter = parentOptionSpan ? cleanSingleLine(parentOptionSpan.textContent) : ''; const finalLetter = /^[A-Z]$/i.test(detectedLetter) ? detectedLetter.toUpperCase() : prefixLetter; const hasPrefix = /^[A-Z][\s\.、.::)\)]/i.test(text); const normalizedText = hasPrefix ? text.replace(/^([A-Z])[\s\.、.::)\)]+/i, (_, l) => `${l.toUpperCase()}. `) : `${finalLetter}. ${text}`; options.push({ prefix: finalLetter, text: normalizedText, _imgEls: imgEls, images: [] }); } const grading = parseCxGradingAndAnswers(rootEl, qScore); const qObj = { id: `cx_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, stem: cleanedStem || '[图片题干]', _stemImgEls: stemImgEls, stemImages: [], type: qType || (options.length === 2 ? '判断题' : options.length > 2 ? '单选题' : '简答题'), totalScore: qScore, options, myAnswer: grading.myAnswer, teacherAnswer: grading.teacherAnswer, standardAnswer: grading.standardAnswer, finalAnswer: grading.finalAnswer, hasTeacherGrade: grading.hasTeacherGrade, missingTeacherAnswerWarn: grading.missingTeacherAnswerWarn, isGraded: grading.isGraded, judgeResult: grading.judgeResult, obtainedScore: grading.obtainedScore, score: grading.score, teacherComment: grading.teacherComment, analysis: grading.analysis }; computeFingerprint(qObj); return qObj; } /* ========================================================================== * 抓取主流程 * ========================================================================== */ let isGrabbing = false; async function grabCurrentPageQuestions() { if (isGrabbing) return; isGrabbing = true; syncStateFromStorage(); if (grabBtnEl) { grabBtnEl.disabled = true; grabBtnEl.innerHTML = `${ICONS.grab}抓取中...`; } if (shellEl) { shellEl.classList.add('lg-pressing'); setTimeout(() => shellEl.classList.remove('lg-pressing'), 180); } try { const docs = getChaoxingQuizDocuments(document); await waitForCxFontDecryptedIfNeeded(docs); if (!state.courseTitle) { state.courseTitle = detectCourseTitle(docs); } const candidateRoots = []; for (const doc of docs) { const matched = Array.from(doc.querySelectorAll(CONFIG.SELECTORS.questionRoot)); const leafRoots = matched.filter( (el) => !matched.some((other) => other !== el && el.contains(other)) ); candidateRoots.push(...leafRoots); } if (candidateRoots.length === 0) { expandPanel(); showToast('未识别到题目,请确认已进入学习通作业/测验页', 'error'); return; } const detectedTitle = detectCurrentWorkTitle(docs, state.groups.length + 1); const firstQPreview = parseCxQuestionSync(candidateRoots[0]); const firstFp = firstQPreview ? firstQPreview._fp : ''; let targetGroup = firstFp ? state.groups.find((g) => (g.questions || []).some((q) => computeFingerprint(q) === firstFp)) : null; const isNewGroup = !targetGroup; if (!targetGroup) { let uniqueName = detectedTitle; if (state.groups.some((g) => g.name === uniqueName)) { uniqueName = `${detectedTitle} (${state.groups.length + 1})`; } targetGroup = { id: `grp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, name: uniqueName, expanded: false, questions: [] }; } const existingFpSet = new Set(targetGroup.questions.map(computeFingerprint)); let addedCount = 0; let skippedCount = 0; const newQuestions = []; const imageTasks = []; for (const rootEl of candidateRoots) { const qData = parseCxQuestionSync(rootEl); if (!qData) continue; if (existingFpSet.has(qData._fp)) { skippedCount++; continue; } existingFpSet.add(qData._fp); newQuestions.push(qData); addedCount++; qData._stemImgEls.forEach((imgEl) => { imageTasks.push(async () => { const info = await imageToBase64Cached(imgEl); if (info && info.base64) qData.stemImages.push(info); }); }); qData.options.forEach((opt) => { opt._imgEls.forEach((imgEl) => { imageTasks.push(async () => { const info = await imageToBase64Cached(imgEl); if (info && info.base64) opt.images.push(info); }); }); }); } if (addedCount === 0 && skippedCount === 0) { expandPanel(); showToast('未解析出有效题干,请检查页面是否加载完成', 'error'); return; } if (imageTasks.length > 0 && grabBtnEl) { grabBtnEl.innerHTML = `${ICONS.grab}存图(${imageTasks.length})...`; await runWithConcurrency(imageTasks, CONFIG.PERF.IMG_CONCURRENCY); } newQuestions.forEach((q) => { delete q._stemImgEls; q.options.forEach((opt) => delete opt._imgEls); targetGroup.questions.push(q); }); targetGroup.questions = normalizeGroupQuestionsByType(targetGroup.questions); if (isNewGroup && targetGroup.questions.length > 0) { state.groups.push(targetGroup); } if (addedCount > 0) { state.grabCount = Math.max(state.groups.length, state.grabCount + 1); saveStateNow(); renderListAndBadge(); } const msgParts = [`「${targetGroup.name}」+${addedCount}题`]; if (skippedCount > 0) msgParts.push(`跳过重复${skippedCount}题`); showToast(msgParts.join(','), 'info'); } catch (err) { console.error('[OCS-CX-QE] 抓取异常:', err); showToast(`抓取失败:${err.message || String(err)}`, 'error'); } finally { isGrabbing = false; if (grabBtnEl) { grabBtnEl.disabled = false; grabBtnEl.innerHTML = `${ICONS.grab}抓取`; } } } /* ========================================================================== * 纯本地零延迟标准 .docx 导出引擎(纯黑 #000000 微软雅黑) * ========================================================================== */ function base64ToUint8Array(dataUrl) { try { const base64Str = dataUrl.includes(',') ? dataUrl.split(',')[1] : dataUrl; const binaryStr = atob(base64Str); const len = binaryStr.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryStr.charCodeAt(i); } return bytes; } catch (_) { return null; } } function detectImageExt(dataUrl) { const m = /^data:image\/(png|jpe?g|gif|bmp|webp)/i.exec(dataUrl || ''); if (!m) return 'png'; const sub = m[1].toLowerCase(); return sub === 'jpg' ? 'jpeg' : sub; } function buildStandardDocxBlob() { const enc = new TextEncoder(); const escXml = (s) => String(s || '') .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]/g, '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); const mediaFiles = []; const relsItems = []; const addImageRel = (imgInfo, maxPx = 360) => { if (!imgInfo || !imgInfo.base64) return ''; const bytes = base64ToUint8Array(imgInfo.base64); if (!bytes || bytes.length === 0) return ''; const ext = detectImageExt(imgInfo.base64); const idx = mediaFiles.length + 1; const name = `image${idx}.${ext}`; const rId = `rIdImg${idx}`; mediaFiles.push({ name, data: bytes, rId, ext }); relsItems.push( `` ); let w = imgInfo.width || 180; let h = imgInfo.height || 100; if (w > maxPx) { const r = maxPx / w; w = Math.round(w * r); h = Math.round(h * r); } const cx = Math.max(9525, w * 9525); const cy = Math.max(9525, h * 9525); return ``; }; const mkRun = (text, bold = false, sz = 21) => `${ bold ? '' : '' }${escXml(text)}`; const mkPara = (runsXml, align = 'left', indentLeft = 0, before = 60, after = 60) => `${ indentLeft ? `` : '' }${runsXml}`; const bodyParts = []; const isPureMode = state.docStyle === 'pure'; const docCourseTitle = cleanSingleLine(state.courseTitle) || '学习通课程题库汇总'; bodyParts.push(mkPara(mkRun(docCourseTitle, true, 32), 'center', 0, 120, 240)); const appendQuestionToBody = (q, seqNum) => { const cleanStemText = String(q.stem || '') .replace(/^\[[^\]]+\]\s*(?:(\d+(?:\.\d+)?\s*分)|\(\d+(?:\.\d+)?\s*分\))?\s*/, '') .trim(); const stemLines = cleanStemText.split('\n'); stemLines.forEach((line, lIdx) => { const prefix = lIdx === 0 ? `${seqNum}. ` : ''; bodyParts.push(mkPara(mkRun(prefix + line, true, 22), 'left', 0, lIdx === 0 ? 140 : 20, 40)); }); if (Array.isArray(q.stemImages) && q.stemImages.length > 0) { const ir = q.stemImages.map((img) => addImageRel(img, 360)).join(''); if (ir) bodyParts.push(mkPara(ir, 'left', 0, 40, 60)); } (q.options || []).forEach((opt) => { bodyParts.push(mkPara(mkRun(cleanSingleLine(opt.text), false, 21), 'left', 360, 20, 20)); if (Array.isArray(opt.images) && opt.images.length > 0) { const oir = opt.images.map((img) => addImageRel(img, 260)).join(''); if (oir) bodyParts.push(mkPara(oir, 'left', 360, 20, 40)); } }); if (isPureMode) { return; } // 默认模式:有标准答案则仅直接输出标准答案;无标准答案则输出我的答案与得分 const officialAns = cleanSingleLine(q.teacherAnswer || q.standardAnswer); if (officialAns) { bodyParts.push(mkPara(mkRun('标准答案:', true, 21) + mkRun(officialAns, false, 21), 'left', 0, 40, 40)); return; } const cleanMyAns = deduplicateAnswerString(q.myAnswer); if (cleanMyAns) { bodyParts.push(mkPara(mkRun('我的答案:', true, 21) + mkRun(cleanMyAns, false, 21), 'left', 0, 40, 25)); } const hasScore = Boolean(q.obtainedScore !== undefined && q.obtainedScore !== ''); const hasJudge = Boolean(q.judgeResult); if (hasScore || hasJudge) { let scoreRuns = ''; if (hasScore) { scoreRuns += mkRun('得分:', true, 21) + mkRun(q.score || `${q.obtainedScore}分`, false, 21); } if (hasJudge) { scoreRuns += mkRun(`${hasScore ? ' ' : ''}批改结果:`, true, 21) + mkRun(q.judgeResult, false, 21); } bodyParts.push(mkPara(scoreRuns, 'left', 0, 25, 40)); } }; const appendQuestionsGroupedByType = (questions) => { const typeBuckets = new Map(); questions.forEach((q) => { const tName = q.type || '其它题'; if (!typeBuckets.has(tName)) typeBuckets.set(tName, []); typeBuckets.get(tName).push(q); }); const sortedTypes = Array.from(typeBuckets.keys()).sort((a, b) => getTypeWeight(a) - getTypeWeight(b)); let seq = 1; sortedTypes.forEach((tName, tIdx) => { const list = typeBuckets.get(tName); const cnNum = CN_NUMS[tIdx] || String(tIdx + 1); bodyParts.push(mkPara(mkRun(`${cnNum}、${tName}`, true, 24), 'left', 0, 180, 70)); list.forEach((q) => { appendQuestionToBody(q, seq++); }); }); }; if (state.sortMode === 'order') { state.groups.forEach((grp, gIdx) => { if (!grp.questions || grp.questions.length === 0) return; const workHeading = grp.name || `作业${gIdx + 1}`; bodyParts.push(mkPara(mkRun(workHeading, true, 26), 'left', 0, 240, 90)); appendQuestionsGroupedByType(grp.questions); }); } else { const allUniqueQuestions = []; const seenFp = new Set(); state.groups.forEach((grp) => { (grp.questions || []).forEach((q) => { const fp = computeFingerprint(q); if (seenFp.has(fp)) return; seenFp.add(fp); allUniqueQuestions.push(q); }); }); appendQuestionsGroupedByType(allUniqueQuestions); } const contentTypesXml = ` `; const rootRelsXml = ` `; const docRelsXml = ` ${relsItems.join('\n ')} `; const documentXml = ` ${bodyParts.join('\n ')} `; const zipEntries = [ { path: '[Content_Types].xml', data: enc.encode(contentTypesXml) }, { path: '_rels/.rels', data: enc.encode(rootRelsXml) }, { path: 'word/_rels/document.xml.rels', data: enc.encode(docRelsXml) }, { path: 'word/document.xml', data: enc.encode(documentXml) }, ...mediaFiles.map((m) => ({ path: `word/media/${m.name}`, data: m.data })) ]; const crcTable = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; crcTable[n] = c >>> 0; } const crc32 = (bytes) => { let crc = 0xffffffff; for (let i = 0; i < bytes.length; i++) { crc = crcTable[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); } return (crc ^ 0xffffffff) >>> 0; }; const localParts = []; const centralParts = []; let offset = 0; for (const entry of zipEntries) { const nameBytes = enc.encode(entry.path); const dataBytes = entry.data; const crc = crc32(dataBytes); const localHeader = new Uint8Array(30 + nameBytes.length); const lv = new DataView(localHeader.buffer); lv.setUint32(0, 0x04034b50, true); lv.setUint16(4, 20, true); lv.setUint16(6, 0x0800, true); lv.setUint16(8, 0, true); lv.setUint16(10, 0, true); lv.setUint16(12, 0, true); lv.setUint32(14, crc, true); lv.setUint32(18, dataBytes.length, true); lv.setUint32(22, dataBytes.length, true); lv.setUint16(26, nameBytes.length, true); lv.setUint16(28, 0, true); localHeader.set(nameBytes, 30); const centralHeader = new Uint8Array(46 + nameBytes.length); const cv = new DataView(centralHeader.buffer); cv.setUint32(0, 0x02014b50, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true); cv.setUint16(8, 0x0800, true); cv.setUint16(10, 0, true); cv.setUint16(12, 0, true); cv.setUint16(14, 0, true); cv.setUint32(16, crc, true); cv.setUint32(20, dataBytes.length, true); cv.setUint32(24, dataBytes.length, true); cv.setUint16(28, nameBytes.length, true); cv.setUint16(30, 0, true); cv.setUint16(32, 0, true); cv.setUint16(34, 0, true); cv.setUint16(36, 0, true); cv.setUint32(38, 0, true); cv.setUint32(42, offset, true); centralHeader.set(nameBytes, 46); localParts.push(localHeader, dataBytes); centralParts.push(centralHeader); offset += localHeader.length + dataBytes.length; } const centralSize = centralParts.reduce((s, p) => s + p.length, 0); const eocd = new Uint8Array(22); const ev = new DataView(eocd.buffer); ev.setUint32(0, 0x06054b50, true); ev.setUint16(4, 0, true); ev.setUint16(6, 0, true); ev.setUint16(8, zipEntries.length, true); ev.setUint16(10, zipEntries.length, true); ev.setUint32(12, centralSize, true); ev.setUint32(16, offset, true); ev.setUint16(20, 0, true); return new Blob([...localParts, ...centralParts, eocd], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); } function exportToDocx(exportBtn) { syncStateFromStorage(); const totalCount = getTotalQuestionCount(); if (totalCount === 0) { showToast('列表为空,请先抓取题目', 'warn'); return; } exportBtn.disabled = true; try { const blob = buildStandardDocxBlob(); const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}`; const titleClean = (cleanSingleLine(state.courseTitle) || '学习通课程题库汇总').replace(/[\\/:*?"<>|]/g, '_'); const pureTag = state.docStyle === 'pure' ? '_纯净' : ''; const fileName = `${titleClean}${pureTag}_${timestamp}.docx`; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 3000); showToast(`已导出「${fileName}」(共 ${totalCount} 题)`, 'info'); } catch (err) { console.error('[OCS-CX-QE] 导出异常:', err); showToast(`导出失败:${err.message || String(err)}`, 'error'); } finally { exportBtn.disabled = false; } } /* ========================================================================== * UI 构建、零卡顿上下拖拽引擎与事件绑定 * ========================================================================== */ let rootEl, shellEl, glassBgEl, orbTriggerEl, countNumEl, courseWrapEl, grabBtnEl, listWrapEl, listEl, toastEl; let sortSwitchEl, sortOrderBtn, sortTypeBtn, docStyleSwitchEl, styleDefaultBtn, stylePureBtn, undoBtnEl, clearBtnEl; function showToast(msg, type = 'info') { if (!toastEl) return; toastEl.className = `lg-toast lg-show ${type !== 'info' ? 'lg-toast-' + type : ''}`; toastEl.textContent = msg; clearTimeout(toastEl._timer); toastEl._timer = setTimeout(() => { toastEl.classList.remove('lg-show'); }, 3600); } function applyBallPosition(left, top) { const maxW = window.innerWidth - CONFIG.UI.BALL_SIZE - 8; const maxH = window.innerHeight - CONFIG.UI.BALL_SIZE - 8; const clampedLeft = Math.max(8, Math.min(left, maxW)); const clampedTop = Math.max(8, Math.min(top, maxH)); rootEl.style.left = `${clampedLeft}px`; rootEl.style.top = `${clampedTop}px`; rootEl.style.right = 'auto'; if (clampedLeft < CONFIG.UI.PANEL_WIDTH) { rootEl.classList.add('lg-align-left'); } else { rootEl.classList.remove('lg-align-left'); } return { left: clampedLeft, top: clampedTop }; } function bindOrbClickAndLongPressDrag() { let startX = 0, startY = 0, origLeft = 0, origTop = 0; let longPressTimer = null, isLongPress = false, moved = false, rafId = null; const resetOrb = () => { clearTimeout(longPressTimer); if (rafId) { cancelAnimationFrame(rafId); rafId = null; } window.removeEventListener('pointermove', onMove, true); window.removeEventListener('pointerup', onUp, true); window.removeEventListener('pointercancel', resetOrb, true); shellEl.classList.remove('lg-pressing', 'lg-dragging'); setTimeout(() => { state.isBallDragging = false; }, 50); }; const onDown = (e) => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const rect = rootEl.getBoundingClientRect(); startX = e.clientX; startY = e.clientY; origLeft = rect.left; origTop = rect.top; moved = false; isLongPress = false; shellEl.classList.add('lg-pressing'); longPressTimer = setTimeout(() => { isLongPress = true; state.isBallDragging = true; shellEl.classList.remove('lg-pressing'); shellEl.classList.add('lg-dragging'); }, CONFIG.PERF.LONG_PRESS_MS); window.addEventListener('pointermove', onMove, true); window.addEventListener('pointerup', onUp, true); window.addEventListener('pointercancel', resetOrb, true); }; const onMove = (e) => { const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 5 || Math.abs(dy) > 5) { moved = true; if (!isLongPress) { clearTimeout(longPressTimer); isLongPress = true; state.isBallDragging = true; shellEl.classList.remove('lg-pressing'); shellEl.classList.add('lg-dragging'); } } if (isLongPress && !rafId) { const cx = e.clientX; const cy = e.clientY; rafId = requestAnimationFrame(() => { rafId = null; applyBallPosition(origLeft + (cx - startX), origTop + (cy - startY)); }); } }; const onUp = (e) => { const wasLongMoved = isLongPress && moved; const dx = e.clientX - startX; const dy = e.clientY - startY; resetOrb(); if (wasLongMoved) { const finalPos = applyBallPosition(origLeft + dx, origTop + dy); saveBallPosition(finalPos.left, finalPos.top); } else { grabCurrentPageQuestions(); } }; orbTriggerEl.addEventListener('pointerdown', onDown); window.addEventListener('resize', () => { if (state.ballPos) applyBallPosition(state.ballPos.left, state.ballPos.top); }); } function bindLiquidGlareTracking() { let rafId = null; shellEl.addEventListener('pointermove', (e) => { if (!state.isExpanded || state.isListDragging) return; if (rafId) return; const cx = e.clientX; const cy = e.clientY; rafId = requestAnimationFrame(() => { rafId = null; const rect = shellEl.getBoundingClientRect(); glassBgEl.style.setProperty('--mx', `${cx - rect.left}px`); glassBgEl.style.setProperty('--my', `${cy - rect.top}px`); }); }, { passive: true }); } function expandPanel() { if (state.isBallDragging) return; clearTimeout(state.collapseTimer); syncStateFromStorage(); if (!state.courseTitle) { state.courseTitle = detectCourseTitle(getChaoxingQuizDocuments(document)); } renderListAndBadge(); state.isExpanded = true; rootEl.classList.add('lg-expanded'); } function scheduleCollapsePanel() { clearTimeout(state.collapseTimer); state.collapseTimer = setTimeout(() => { if (state.isBallDragging || state.isListDragging || state.isScrolling || state.isEditingInput) return; state.isExpanded = false; rootEl.classList.remove('lg-expanded'); }, CONFIG.UI.COLLAPSE_DELAY_MS); } function renderCourseBar() { if (!courseWrapEl) return; courseWrapEl.replaceChildren(); if (state.editingCourseTitle) { const input = document.createElement('input'); input.type = 'text'; input.className = 'lg-course-input'; input.value = state.courseTitle || '学习通课程题库汇总'; const commit = () => { const v = cleanSingleLine(input.value); if (v) state.courseTitle = v; state.editingCourseTitle = false; state.isEditingInput = false; saveStateNow(); renderCourseBar(); }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') commit(); else if (e.key === 'Escape') { state.editingCourseTitle = false; state.isEditingInput = false; renderCourseBar(); } }); input.addEventListener('blur', commit); courseWrapEl.appendChild(input); setTimeout(() => { input.focus(); input.select(); }, 10); } else { const titleSpan = document.createElement('span'); titleSpan.className = 'lg-course-title'; titleSpan.textContent = state.courseTitle || '学习通课程题库汇总'; titleSpan.addEventListener('click', () => { state.editingCourseTitle = true; state.isEditingInput = true; renderCourseBar(); }); courseWrapEl.appendChild(titleSpan); } } function updateSwitchesAndUndoUI() { if (sortSwitchEl && sortOrderBtn && sortTypeBtn) { sortSwitchEl.setAttribute('data-second', state.sortMode === 'type' ? 'true' : 'false'); sortOrderBtn.classList.toggle('active', state.sortMode === 'order'); sortTypeBtn.classList.toggle('active', state.sortMode === 'type'); } if (docStyleSwitchEl && styleDefaultBtn && stylePureBtn) { docStyleSwitchEl.setAttribute('data-second', state.docStyle === 'pure' ? 'true' : 'false'); styleDefaultBtn.classList.toggle('active', state.docStyle === 'default'); stylePureBtn.classList.toggle('active', state.docStyle === 'pure'); } if (undoBtnEl) { undoBtnEl.classList.toggle('lg-show', Boolean(state.undoBackup && state.undoBackup.groups && state.undoBackup.groups.length > 0)); } } function createQuestionChip(q, gIdx, origQIdx, displayNum, grp) { const chip = document.createElement('div'); chip.className = 'lg-qchip'; chip.dataset.gidx = String(gIdx); chip.dataset.qidx = String(origQIdx); if (q.hasTeacherGrade) { const dot = document.createElement('span'); dot.className = q.missingTeacherAnswerWarn ? 'lg-teacher-warn-dot' : 'lg-teacher-dot'; chip.appendChild(dot); } const labelSpan = document.createElement('span'); labelSpan.className = 'lg-qchip-label'; labelSpan.textContent = q.customLabel || `题${displayNum}`; const officialAns = cleanSingleLine(q.teacherAnswer || q.standardAnswer); const previewAns = officialAns ? `标准答案:${officialAns}` : q.myAnswer ? `我的答案:${deduplicateAnswerString(q.myAnswer)}${q.score ? ' | 得分:' + q.score : ''}` : ''; labelSpan.title = `${q.stem}${previewAns ? '\n' + previewAns : ''}`; labelSpan.addEventListener('click', (e) => { if (state.isListDragging) return; e.stopPropagation(); if ( state.editingQuestionLoc && state.editingQuestionLoc.gIdx === gIdx && state.editingQuestionLoc.qIdx === origQIdx ) { state.editingQuestionLoc = null; state.isEditingInput = false; } else { state.editingQuestionLoc = { gIdx, qIdx: origQIdx }; state.isEditingInput = true; } renderListAndBadge(); }); const delQBtn = document.createElement('button'); delQBtn.type = 'button'; delQBtn.className = 'lg-qchip-del'; delQBtn.textContent = '×'; delQBtn.addEventListener('click', (e) => { e.stopPropagation(); grp.questions.splice(origQIdx, 1); if (grp.questions.length === 0) { state.groups.splice(gIdx, 1); state.grabCount = Math.max(0, state.groups.length); } state.editingQuestionLoc = null; state.isEditingInput = false; saveStateNow(); renderListAndBadge(); }); chip.appendChild(labelSpan); chip.appendChild(delQBtn); return chip; } function appendInlineEditorIfActive(container, grp, gIdx) { if ( !state.editingQuestionLoc || state.editingQuestionLoc.gIdx !== gIdx || !grp.questions[state.editingQuestionLoc.qIdx] ) { return; } const qIdx = state.editingQuestionLoc.qIdx; const targetQ = grp.questions[qIdx]; const editorDiv = document.createElement('div'); editorDiv.className = 'lg-qeditor'; editorDiv.innerHTML = `
`; const stemInput = editorDiv.querySelector('.lg-edit-stem'); const ansInput = editorDiv.querySelector('.lg-edit-ans'); stemInput.value = targetQ.stem; ansInput.value = targetQ.teacherAnswer || targetQ.standardAnswer || ''; editorDiv.addEventListener('click', (e) => e.stopPropagation()); editorDiv.querySelector('.cancel').addEventListener('click', () => { state.editingQuestionLoc = null; state.isEditingInput = false; renderListAndBadge(); }); editorDiv.querySelector('.save').addEventListener('click', () => { targetQ.stem = cleanText(stemInput.value) || targetQ.stem; const newAns = cleanSingleLine(ansInput.value); if (targetQ.teacherAnswer) targetQ.teacherAnswer = newAns; else targetQ.standardAnswer = newAns; targetQ.finalAnswer = newAns; state.editingQuestionLoc = null; state.isEditingInput = false; saveStateNow(); renderListAndBadge(); showToast(`已保存题${qIdx + 1}`, 'info'); }); container.appendChild(editorDiv); } function renderListAndBadge() { if (!countNumEl || !listEl) return; renderCourseBar(); updateSwitchesAndUndoUI(); const countVal = state.grabCount || state.groups.length || 0; if (countVal <= 0) { countNumEl.textContent = ''; countNumEl.classList.add('lg-hidden'); } else { countNumEl.textContent = String(countVal); countNumEl.classList.remove('lg-hidden'); } const totalCount = getTotalQuestionCount(); const frag = document.createDocumentFragment(); if (state.groups.length === 0 || totalCount === 0) { const emptyDiv = document.createElement('div'); emptyDiv.className = 'lg-empty'; emptyDiv.textContent = CONFIG.UI.EMPTY_TEXT; frag.appendChild(emptyDiv); listEl.replaceChildren(frag); return; } if (state.sortMode === 'order') { state.groups.forEach((grp, gIdx) => { const groupDiv = document.createElement('div'); groupDiv.className = `lg-group${grp.expanded ? ' lg-group-open' : ''}`; groupDiv.dataset.gidx = String(gIdx); const headerDiv = document.createElement('div'); headerDiv.className = 'lg-group-header'; const dragZone = document.createElement('div'); dragZone.className = 'lg-group-drag-zone'; const folderIcon = document.createElement('span'); folderIcon.className = 'lg-folder-icon'; folderIcon.innerHTML = ICONS.folder; dragZone.appendChild(folderIcon); if (state.editingGroupId === grp.id) { const input = document.createElement('input'); input.type = 'text'; input.className = 'lg-group-input'; input.value = grp.name; const commitRename = () => { const val = cleanSingleLine(input.value); if (val) grp.name = val; state.editingGroupId = null; state.isEditingInput = false; saveStateNow(); renderListAndBadge(); }; input.addEventListener('click', (e) => e.stopPropagation()); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') commitRename(); else if (e.key === 'Escape') { state.editingGroupId = null; state.isEditingInput = false; renderListAndBadge(); } }); input.addEventListener('blur', commitRename); dragZone.appendChild(input); setTimeout(() => { input.focus(); input.select(); }, 10); } else { const nameSpan = document.createElement('span'); nameSpan.className = 'lg-group-name'; nameSpan.textContent = grp.name; nameSpan.addEventListener('dblclick', (e) => { e.stopPropagation(); state.editingGroupId = grp.id; state.isEditingInput = true; renderListAndBadge(); }); dragZone.appendChild(nameSpan); } headerDiv.appendChild(dragZone); const countSpan = document.createElement('span'); countSpan.className = 'lg-group-count'; countSpan.textContent = `${(grp.questions || []).length}`; headerDiv.appendChild(countSpan); const editBtn = document.createElement('button'); editBtn.type = 'button'; editBtn.className = 'lg-icon-btn'; editBtn.innerHTML = ICONS.edit; editBtn.addEventListener('click', (e) => { e.stopPropagation(); state.editingGroupId = grp.id; state.isEditingInput = true; renderListAndBadge(); }); headerDiv.appendChild(editBtn); const delGrpBtn = document.createElement('button'); delGrpBtn.type = 'button'; delGrpBtn.className = 'lg-icon-btn'; delGrpBtn.textContent = '×'; delGrpBtn.addEventListener('click', (e) => { e.stopPropagation(); state.groups.splice(gIdx, 1); state.grabCount = Math.max(0, state.groups.length); saveStateNow(); renderListAndBadge(); }); headerDiv.appendChild(delGrpBtn); headerDiv.addEventListener('click', () => { if (suppressNextHeaderClick || state.editingGroupId === grp.id || state.isListDragging) return; grp.expanded = !grp.expanded; state.editingQuestionLoc = null; saveStateNow(); renderListAndBadge(); }); groupDiv.appendChild(headerDiv); const subListDiv = document.createElement('div'); subListDiv.className = 'lg-sublist'; let lastType = ''; (grp.questions || []).forEach((q, qIdx) => { const curType = q.type || '题目'; if (curType !== lastType) { lastType = curType; const typeRow = document.createElement('div'); typeRow.className = 'lg-type-badge-row'; typeRow.textContent = curType; subListDiv.appendChild(typeRow); } subListDiv.appendChild(createQuestionChip(q, gIdx, qIdx, qIdx + 1, grp)); }); appendInlineEditorIfActive(subListDiv, grp, gIdx); groupDiv.appendChild(subListDiv); frag.appendChild(groupDiv); }); } else { const typeMap = new Map(); const seenFp = new Set(); state.groups.forEach((grp, gIdx) => { (grp.questions || []).forEach((q, qIdx) => { const fp = computeFingerprint(q); if (seenFp.has(fp)) return; seenFp.add(fp); const tName = q.type || '其它题'; if (!typeMap.has(tName)) typeMap.set(tName, []); typeMap.get(tName).push({ q, gIdx, qIdx, grp }); }); }); const sortedTypes = Array.from(typeMap.keys()).sort((a, b) => getTypeWeight(a) - getTypeWeight(b)); sortedTypes.forEach((tName, tIdx) => { const items = typeMap.get(tName); const isOpen = Boolean(state.typeGroupExpanded[tName]); const groupDiv = document.createElement('div'); groupDiv.className = `lg-group${isOpen ? ' lg-group-open' : ''}`; const headerDiv = document.createElement('div'); headerDiv.className = 'lg-group-header'; headerDiv.innerHTML = `
${ICONS.folder} ${CN_NUMS[tIdx] || tIdx + 1}、${tName}
${items.length} `; headerDiv.addEventListener('click', () => { state.typeGroupExpanded[tName] = !state.typeGroupExpanded[tName]; state.editingQuestionLoc = null; renderListAndBadge(); }); groupDiv.appendChild(headerDiv); const subListDiv = document.createElement('div'); subListDiv.className = 'lg-sublist'; items.forEach(({ q, gIdx, qIdx, grp }, idxInType) => { subListDiv.appendChild(createQuestionChip(q, gIdx, qIdx, idxInType + 1, grp)); if ( state.editingQuestionLoc && state.editingQuestionLoc.gIdx === gIdx && state.editingQuestionLoc.qIdx === qIdx ) { appendInlineEditorIfActive(subListDiv, grp, gIdx); } }); groupDiv.appendChild(subListDiv); frag.appendChild(groupDiv); }); } listEl.replaceChildren(frag); } /* ========================================================================== * 零卡顿 GPU 垂直拖拽排序(锁定左右 X 轴,仅允许上下丝滑移动) * ========================================================================== */ function bindPhysicalCardDragSort() { let pressTimer = null, dragMode = null, draggedEl = null, floatingCard = null; let pendingTargetEl = null, pendingMode = null; let startY = 0, srcGIdx = -1; let rafId = null, latestClientY = 0; const cleanup = () => { clearTimeout(pressTimer); if (rafId) { cancelAnimationFrame(rafId); rafId = null; } window.removeEventListener('pointermove', onMove, true); window.removeEventListener('pointerup', onUp, true); window.removeEventListener('pointercancel', cleanup, true); if (floatingCard) { floatingCard.remove(); floatingCard = null; } if (draggedEl) draggedEl.classList.remove('lg-drag-placeholder'); listWrapEl.classList.remove('lg-recessed'); if (state.isListDragging) { suppressNextHeaderClick = true; setTimeout(() => { suppressNextHeaderClick = false; }, 120); if (dragMode === 'group') { const nextGroups = []; listEl.querySelectorAll('.lg-group').forEach((el) => { const idx = parseInt(el.dataset.gidx, 10); if (!Number.isNaN(idx) && state.groups[idx]) nextGroups.push(state.groups[idx]); }); if (nextGroups.length === state.groups.length) state.groups = nextGroups; } else if (dragMode === 'chip' && srcGIdx >= 0 && state.groups[srcGIdx]) { const grp = state.groups[srcGIdx]; const groupEl = listEl.querySelector(`.lg-group[data-gidx="${srcGIdx}"]`); if (groupEl) { const nextQs = []; groupEl.querySelectorAll('.lg-qchip').forEach((el) => { const qIdx = parseInt(el.dataset.qidx, 10); if (!Number.isNaN(qIdx) && grp.questions[qIdx]) nextQs.push(grp.questions[qIdx]); }); if (nextQs.length === grp.questions.length) grp.questions = nextQs; } } saveStateNow(); renderListAndBadge(); } setTimeout(() => { state.isListDragging = false; dragMode = null; draggedEl = null; pendingTargetEl = null; pendingMode = null; srcGIdx = -1; }, 40); }; const startDrag = (targetEl, mode) => { state.isListDragging = true; dragMode = mode; draggedEl = targetEl; const cloneSrc = mode === 'group' ? targetEl.querySelector('.lg-group-header') || targetEl : targetEl; const cRect = cloneSrc.getBoundingClientRect(); listWrapEl.classList.add('lg-recessed'); floatingCard = cloneSrc.cloneNode(true); floatingCard.className = `${cloneSrc.className} lg-floating-card`; floatingCard.style.width = `${cRect.width}px`; floatingCard.style.height = `${cRect.height}px`; floatingCard.style.left = `${cRect.left}px`; floatingCard.style.top = `${cRect.top}px`; floatingCard.style.transform = `translate3d(0, 0px, 0) scale(1.03)`; document.body.appendChild(floatingCard); targetEl.classList.add('lg-drag-placeholder'); }; const updateDragFrame = () => { rafId = null; if (!state.isListDragging || !floatingCard || !draggedEl) return; const dy = latestClientY - startY; floatingCard.style.transform = `translate3d(0, ${dy}px, 0) scale(1.03)`; if (dragMode === 'group') { const prev = draggedEl.previousElementSibling; const next = draggedEl.nextElementSibling; if (prev && prev.classList.contains('lg-group')) { const r = prev.getBoundingClientRect(); if (latestClientY < r.top + r.height / 2) { prev.before(draggedEl); return; } } if (next && next.classList.contains('lg-group')) { const r = next.getBoundingClientRect(); if (latestClientY > r.top + r.height / 2) { next.after(draggedEl); } } } else if (dragMode === 'chip') { const prev = draggedEl.previousElementSibling; const next = draggedEl.nextElementSibling; if (prev && prev.classList.contains('lg-qchip')) { const r = prev.getBoundingClientRect(); if (latestClientY < r.top + r.height / 2) { prev.before(draggedEl); return; } } if (next && next.classList.contains('lg-qchip')) { const r = next.getBoundingClientRect(); if (latestClientY > r.top + r.height / 2) { next.after(draggedEl); } } } }; const onMove = (e) => { const dy = e.clientY - startY; latestClientY = e.clientY; if (!state.isListDragging) { if (pendingTargetEl && Math.abs(dy) > 4) { clearTimeout(pressTimer); startDrag(pendingTargetEl, pendingMode); } return; } e.preventDefault(); if (!rafId) { rafId = requestAnimationFrame(updateDragFrame); } }; const onUp = () => cleanup(); listEl.addEventListener('pointerdown', (e) => { if (e.button !== 0 || state.isEditingInput || state.sortMode === 'type') return; if (e.target.closest('button, input, textarea, .lg-group-count')) return; const chipEl = e.target.closest('.lg-qchip'); const dragZoneEl = e.target.closest('.lg-group-drag-zone'); const groupEl = e.target.closest('.lg-group'); if (!chipEl && !dragZoneEl) return; startY = e.clientY; latestClientY = e.clientY; window.addEventListener('pointermove', onMove, true); window.addEventListener('pointerup', onUp, true); window.addEventListener('pointercancel', cleanup, true); if (dragZoneEl && groupEl) { pendingTargetEl = groupEl; pendingMode = 'group'; pressTimer = setTimeout(() => startDrag(groupEl, 'group'), 140); } else if (chipEl) { srcGIdx = parseInt(chipEl.dataset.gidx, 10); pendingTargetEl = chipEl; pendingMode = 'chip'; pressTimer = setTimeout(() => startDrag(chipEl, 'chip'), 140); } }); } function mountUI() { syncStateFromStorage(); rootEl = document.createElement('div'); rootEl.className = 'lg-root'; if (state.ballPos) { applyBallPosition(state.ballPos.left, state.ballPos.top); } shellEl = document.createElement('div'); shellEl.className = 'lg-shell'; glassBgEl = document.createElement('div'); glassBgEl.className = 'lg-glass-bg'; shellEl.appendChild(glassBgEl); const topBar = document.createElement('div'); topBar.className = 'lg-top-bar'; courseWrapEl = document.createElement('div'); courseWrapEl.className = 'lg-course-wrap'; orbTriggerEl = document.createElement('div'); orbTriggerEl.className = 'lg-orb-trigger'; orbTriggerEl.innerHTML = ICONS.brand; countNumEl = document.createElement('span'); countNumEl.className = 'lg-count-num lg-hidden'; orbTriggerEl.appendChild(countNumEl); topBar.appendChild(courseWrapEl); topBar.appendChild(orbTriggerEl); const bodyDiv = document.createElement('div'); bodyDiv.className = 'lg-body'; const actionsDiv = document.createElement('div'); actionsDiv.className = 'lg-actions'; grabBtnEl = document.createElement('button'); grabBtnEl.type = 'button'; grabBtnEl.className = 'lg-btn lg-btn-primary'; grabBtnEl.innerHTML = `${ICONS.grab}抓取`; grabBtnEl.addEventListener('click', () => grabCurrentPageQuestions()); const exportBtn = document.createElement('button'); exportBtn.type = 'button'; exportBtn.className = 'lg-btn lg-btn-secondary'; exportBtn.innerHTML = `${ICONS.export}导出`; exportBtn.addEventListener('click', () => exportToDocx(exportBtn)); actionsDiv.appendChild(grabBtnEl); actionsDiv.appendChild(exportBtn); const subControls = document.createElement('div'); subControls.className = 'lg-sub-controls'; sortSwitchEl = document.createElement('div'); sortSwitchEl.className = 'lg-seg-switch'; sortOrderBtn = document.createElement('button'); sortOrderBtn.type = 'button'; sortOrderBtn.className = 'lg-seg-opt'; sortOrderBtn.textContent = '顺序'; sortOrderBtn.addEventListener('click', (e) => { e.stopPropagation(); if (state.sortMode !== 'order') { state.sortMode = 'order'; saveStateNow(); renderListAndBadge(); } }); sortTypeBtn = document.createElement('button'); sortTypeBtn.type = 'button'; sortTypeBtn.className = 'lg-seg-opt'; sortTypeBtn.textContent = '类型'; sortTypeBtn.addEventListener('click', (e) => { e.stopPropagation(); if (state.sortMode !== 'type') { state.sortMode = 'type'; saveStateNow(); renderListAndBadge(); } }); sortSwitchEl.innerHTML = '
'; sortSwitchEl.appendChild(sortOrderBtn); sortSwitchEl.appendChild(sortTypeBtn); docStyleSwitchEl = document.createElement('div'); docStyleSwitchEl.className = 'lg-seg-switch'; styleDefaultBtn = document.createElement('button'); styleDefaultBtn.type = 'button'; styleDefaultBtn.className = 'lg-seg-opt'; styleDefaultBtn.textContent = '默认'; styleDefaultBtn.addEventListener('click', (e) => { e.stopPropagation(); if (state.docStyle !== 'default') { state.docStyle = 'default'; saveStateNow(); updateSwitchesAndUndoUI(); } }); stylePureBtn = document.createElement('button'); stylePureBtn.type = 'button'; stylePureBtn.className = 'lg-seg-opt'; stylePureBtn.textContent = '纯净'; stylePureBtn.addEventListener('click', (e) => { e.stopPropagation(); if (state.docStyle !== 'pure') { state.docStyle = 'pure'; saveStateNow(); updateSwitchesAndUndoUI(); } }); docStyleSwitchEl.innerHTML = '
'; docStyleSwitchEl.appendChild(styleDefaultBtn); docStyleSwitchEl.appendChild(stylePureBtn); subControls.appendChild(sortSwitchEl); subControls.appendChild(docStyleSwitchEl); toastEl = document.createElement('div'); toastEl.className = 'lg-toast'; listWrapEl = document.createElement('div'); listWrapEl.className = 'lg-list-wrap'; listEl = document.createElement('div'); listEl.className = 'lg-list'; listEl.addEventListener( 'scroll', () => { state.isScrolling = true; clearTimeout(state.scrollTimer); state.scrollTimer = setTimeout(() => { state.isScrolling = false; }, CONFIG.UI.SCROLL_IDLE_MS); }, { passive: true } ); bindPhysicalCardDragSort(); listWrapEl.appendChild(listEl); const footerDiv = document.createElement('div'); footerDiv.className = 'lg-footer'; undoBtnEl = document.createElement('button'); undoBtnEl.type = 'button'; undoBtnEl.className = 'lg-text-btn lg-undo-btn'; undoBtnEl.textContent = '恢复'; undoBtnEl.addEventListener('click', (e) => { e.stopPropagation(); if (state.undoBackup) { state.groups = state.undoBackup.groups || []; state.grabCount = state.undoBackup.grabCount || state.groups.length; if (state.undoBackup.courseTitle) state.courseTitle = state.undoBackup.courseTitle; state.undoBackup = null; saveStateNow(); renderListAndBadge(); showToast('已恢复清空前的记录', 'info'); } }); clearBtnEl = document.createElement('button'); clearBtnEl.type = 'button'; clearBtnEl.className = 'lg-text-btn lg-clear-btn'; clearBtnEl.textContent = '清空'; clearBtnEl.addEventListener('click', (e) => { e.stopPropagation(); if (getTotalQuestionCount() === 0) { showToast('当前列表已为空', 'info'); return; } state.undoBackup = { groups: JSON.parse(JSON.stringify(state.groups)), grabCount: state.grabCount, courseTitle: state.courseTitle }; state.groups = []; state.grabCount = 0; state.editingGroupId = null; state.editingQuestionLoc = null; state.isEditingInput = false; saveStateNow(); renderListAndBadge(); showToast('已清空,点击「恢复」可撤回', 'info'); }); footerDiv.appendChild(undoBtnEl); footerDiv.appendChild(clearBtnEl); bodyDiv.appendChild(actionsDiv); bodyDiv.appendChild(subControls); bodyDiv.appendChild(toastEl); bodyDiv.appendChild(listWrapEl); bodyDiv.appendChild(footerDiv); shellEl.appendChild(topBar); shellEl.appendChild(bodyDiv); rootEl.appendChild(shellEl); bindOrbClickAndLongPressDrag(); bindLiquidGlareTracking(); shellEl.addEventListener('mouseenter', expandPanel); shellEl.addEventListener('mouseleave', scheduleCollapsePanel); window.addEventListener('focus', () => { syncStateFromStorage(); renderListAndBadge(); }); window.addEventListener('pageshow', () => { syncStateFromStorage(); renderListAndBadge(); }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { syncStateFromStorage(); renderListAndBadge(); } }); if (typeof GM_addValueChangeListener === 'function') { GM_addValueChangeListener(CONFIG.STORAGE_KEY_STATE, (_, __, ___, remote) => { if (remote) { syncStateFromStorage(); renderListAndBadge(); } }); } document.body.appendChild(rootEl); renderListAndBadge(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mountUI); } else { mountUI(); } })();