// ==UserScript== // @name 上岸村机考系统错题助手(笔记 / 导出 / 重练) // @namespace http://tampermonkey.net/ // @version 1.9.1 // @description 为上岸村机考系统的错题本补充:解析划线(重点黄 / 易错红,选中即划,Alt+1 / Alt+2 / Alt+3 批注)、划线可像 Word 一样取消与改色、每条划线可挂自己的批注并以批注气球显示在右侧页边距、一键整理为笔记(按题目或按颜色归拢,可预览后复制或下载)、题目右侧批注、增量导出(Markdown/CSV/XLSX)、按模块自定义组卷重练(顺序刷题支持从上次继续、随机组卷按答错次数加权)并保留最近三次组卷历史。答错次数从 1 起算,错满 3 次标记为「顽固错题」。以原生菜单项形态内嵌于错题页,保留站点原生的题目区滚动,UI 使用 shadcn 风格 + lucide 图标。只作用于错题页(index.html#/error)与收藏页(index.html#/shoucang):每道题的操作条(答错次数 / 掌握状态 / 复制题目 / 笔记)置于题目前,掌握状态可手动点选;导出与重练支持「错题 / 收藏 / 两者」三种来源,增量导出基线按来源分开记录。专项练习 / 全真模拟的计时暂停请用配套脚本「上岸村机考系统 · 考试计时暂停」。 // @author 烨笙 // @match https://pub.xdtech.top/mingshi/wxpage/tiku/gongan/index.html // @match https://pub.xdtech.top/*/wxpage/tiku/gongan/index.html // @match https://pub.xdtech.top/*/wxpage/tiku/gongan/index.html* // @include *://*.xdtech.top/*/wxpage/tiku/gongan/index.html* // @connect pub.xdapi.top // @grant GM_xmlhttpRequest // @grant GM_addStyle // @run-at document-idle // @license MIT // ==/UserScript== // 说明:篡改猴对「路径中段单星号」的 @match 解析比 Chrome 严格,故同时给出 // 精确路径 / 单段通配 / 带参数 三种 @match,并补 @include 兜底。 // 只匹配 index.html(错题页 #/error 与收藏页 #/shoucang 是同一个文件的 hash 路由), // 避免和「考试计时暂停」在练习页上重叠。 (function () { 'use strict'; console.log('[错题助手] 脚本已注入:' + location.href); var CTX = location.pathname.split('/')[1] || 'mingshi'; var API_BASE = 'https://pub.xdapi.top/' + CTX + '/api/v1/tiku/gongan/'; var LS_STORE = 'gongan_tiku_helper_' + CTX; var LS_TOKEN = 'token_gongan_' + CTX; var LS_LOGIN = 'login_status_' + CTX; var SUBJ_ZY = 1; var SUBJ_XC = 0; var SUBJECT_NAME = {}; SUBJECT_NAME[SUBJ_ZY] = '公安专业知识'; SUBJECT_NAME[SUBJ_XC] = '行政职业能力测试'; var DAY_RANGES = [['0', '当天'], ['1', '本周'], ['2', '本月'], ['3', '近三月'], ['4', '全部']]; var MASTER_STREAK = 2; var EXPORT_HEADERS = ['序号', '科目', '材料', '题干', '选项', '你的答案', '正确答案', '答错次数', '解析', '笔记', '划线摘录', '掌握状态', '题目ID']; var XLSX_WIDTHS = [6, 16, 30, 60, 50, 10, 10, 10, 60, 40, 40, 14, 12]; function $(sel, root) { return (root || document).querySelector(sel); } function $$(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } // 取 的 src:站点题目区图片带真实 src,个别模板会写成 data-src function imgSrc(tag) { var m = /\s(?:src|data-src)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag); var url = (m && (m[1] || m[2] || m[3])) || ''; if (url && /^\/\//.test(url)) url = location.protocol + url; if (url && !/^[a-z]+:/i.test(url)) { try { url = new URL(url, location.href).href; } catch (e) {} } return url; } // HTML → 纯文本。keepImg 为真时把图片保留成 Markdown 图片语法 ![图片](地址), // 这样「复制题目」粘到别处(搜题 / 问 AI)时图片地址不会跟着标签一起被丢掉 function htmlToText(s, keepImg) { var t = String(s == null ? '' : s) .replace(//gi, '\n') .replace(/<\/p>/gi, '\n'); if (keepImg) { t = t.replace(/]*>/gi, function (tag) { var url = imgSrc(tag); if (!url) return ''; var alt = (/\salt\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(tag) || [])[1] || '图片'; return ' ![' + alt + '](' + url + ') '; }); } return t .replace(/<[^>]+>/g, '') .replace(/ /g, ' ') .replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/&/g, '&') .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); } function stripHtml(s) { return htmlToText(s, false); } function wakeImgs(html) { return String(html == null ? '' : html) .replace(/]*?)data-src=/gi, ']*?)\sloading="lazy"/gi, '', pencil: '', trash: '', save: '', download: '', upload: '', fileText: '', fileSheet: '', fileJson: '', play: '', rotate: '', listChecks: '', clock: '', hash: '', database: '', bookOpen: '', search: '', warn: '', checkCircle: '', xCircle: '', arrowLeft: '', arrowRight: '', eye: '', sparkles: '', copy: '', check: '', highlighter: '', underline: '', quote: '', scissors: '' }; function icon(name) { var d = LUCIDE[name]; if (!d) return ''; return ''; } var store = (function () { var data; try { data = JSON.parse(localStorage.getItem(LS_STORE)) || {}; } catch (e) { data = {}; } if (!data.notes) data.notes = {}; if (!data.mastered) data.mastered = {}; if (!data.wrongCount) data.wrongCount = {}; if (!data.exported) data.exported = {}; // 已导出过的题目 id,用于增量导出 if (!data.history) data.history = []; // 最近三次组卷记录 if (!data.resume) data.resume = {}; // 顺序刷题进度:筛选键 -> 已刷到第几题 if (!data.highlights) data.highlights = {}; // 题目 id -> [{quote,prefix,nth,block,color,at,snap,subject,lost}] migrateExported(data); // 增量导出基线:按来源分桶 return data; })(); // 增量导出基线按「来源」分桶:错题本 / 收藏夹 / 两者合并各记一份。 // 同一道题可能同时出现在错题本和收藏夹,共用一份基线会让另一边漏掉「新增」。 // v1.9 之前是扁平的 id 字典,一律并进「错题本」桶;旧备份文件也要能迁移,所以这段要可重复执行。 function migrateExported(data) { var ex = data.exported; if (!ex || typeof ex !== 'object' || Array.isArray(ex) || ex._v !== 2) { var conv = {}; if (ex && typeof ex === 'object' && !Array.isArray(ex)) { Object.keys(ex).forEach(function (k) { if (k !== '_v') conv[k] = 1; }); } data.exported = { _v: 2, error: conv, favorite: {}, both: {} }; } var at = data.exportAt; if (!at || typeof at !== 'object') { data.exportAt = { error: Number(at) || 0, favorite: 0, both: 0 }; } else { data.exportAt = { error: at.error || 0, favorite: at.favorite || 0, both: at.both || 0 }; } return data; } function saveStore() { try { localStorage.setItem(LS_STORE, JSON.stringify(store)); } catch (e) { alert('本地存储写入失败:' + e.message); } } function setNote(id, text, opts) { opts = opts || {}; if (text) { store.notes[id] = { text: text, snapshot: opts.snapshot || (store.notes[id] && store.notes[id].snapshot) || '', subject: opts.subject != null ? opts.subject : (store.notes[id] && store.notes[id].subject), updated: Date.now() }; } else { delete store.notes[id]; } saveStore(); } function getNote(id) { return (store.notes[id] && store.notes[id].text) || ''; } /* ================= 划线:存锚点,不存 DOM ================= 错题页的题目区由 AngularJS 的 ng-bind-html 渲染,翻页 / 切换 exam_type / 折叠解析 都会整块重绘,直接往 DOM 里塞 会被冲掉。因此每条划线只记录 「原文 + 前置上下文 + 第几次出现」,页面每次重绘后按锚点重新落笔。 */ var HL_COLORS = { yellow: 'yellow', red: 'red' }; var HL_LABEL = { yellow: '重点', red: '易错' }; var HL_PREFIX_LEN = 12; function getHighlights(id) { return store.highlights[id] || []; } function countHighlights(id) { return getHighlights(id).length; } // 划中的文字落在题目的哪一块:靠「哪段源文本包含了这句话」判断,不依赖 DOM 结构 function classifyBlock(item, quote) { if (!item || !quote) return 'other'; var opt = item.opt; if (typeof opt === 'string') { try { opt = JSON.parse(opt); } catch (e) { opt = []; } } if (stripHtml(analysisOf(item)).indexOf(quote) >= 0) return 'analysis'; if (stripHtml(item.material).indexOf(quote) >= 0) return 'material'; if (stripHtml(item.content).indexOf(quote) >= 0) return 'stem'; for (var i = 0; i < (opt || []).length; i++) { if (stripHtml(opt[i] && opt[i].content).indexOf(quote) >= 0) return 'opt'; } return 'other'; } // 题目对象来源有两个:错题页的 AngularJS scope,以及插件组卷的 quiz.list function questionOf(qid) { if (itemsById[qid]) return itemsById[qid]; if (quiz && quiz.list) { for (var i = 0; i < quiz.list.length; i++) { if (String(quiz.list[i].id) === String(qid)) return quiz.list[i]; } } return null; } // 求某个节点 / 偏移在 root 纯文本中的字符下标 function offsetInRoot(root, node, offset) { var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); var acc = 0, n; while ((n = walker.nextNode())) { if (n === node) return acc + offset; acc += n.nodeValue.length; } return -1; } function allIndices(hay, needle) { var out = [], i = hay.indexOf(needle); while (i >= 0) { out.push(i); i = hay.indexOf(needle, i + 1); } return out; } // 归一化:抹掉空白与标点,用于「站点文案微调」后的模糊匹配 var PUNCT_RE = /[\s\u3000,.!?;:'"()()【】《》、,。!?;:""''—\-–·]/g; function norm(s) { return String(s == null ? '' : s).replace(PUNCT_RE, ''); } // 归一化后的下标 -> 原文下标映射 function normMap(s) { var map = [], out = ''; for (var i = 0; i < s.length; i++) { var c = s.charAt(i); if (PUNCT_RE.test(c)) continue; map.push(i); out += c; } return { text: out, map: map }; } /* 在 root 里定位一条划线,返回 {start, end, fuzzy} 或 null。 先精确匹配(按 nth + 前置上下文),失败再模糊匹配。 */ function locate(root, rec) { var full = root.textContent || ''; if (!full || !rec.quote) return null; var idxs = allIndices(full, rec.quote); var pick = -1; if (idxs.length) { if (idxs.length === 1) pick = idxs[0]; else if (rec.nth >= 1 && rec.nth <= idxs.length) pick = idxs[rec.nth - 1]; // 第几次出现对不上时,用前置上下文救一次 if (pick >= 0 && rec.prefix) { var ctx = full.slice(Math.max(0, pick - rec.prefix.length), pick); if (ctx !== rec.prefix) { for (var k = 0; k < idxs.length; k++) { if (full.slice(Math.max(0, idxs[k] - rec.prefix.length), idxs[k]) === rec.prefix) { pick = idxs[k]; break; } } } } if (pick >= 0) return { start: pick, end: pick + rec.quote.length, fuzzy: false }; } var nm = normMap(full), nq = norm(rec.quote); if (!nq) return null; var fi = nm.text.indexOf(nq); if (fi < 0) return null; var s = nm.map[fi], e = nm.map[fi + nq.length - 1] + 1; return { start: s, end: e, fuzzy: true }; } // 把 [start,end) 字符区间内的文本节点包进 。只移动/切分文本节点,不新建内容, // 因此不会破坏 AngularJS 持有的 Text 节点引用(ng-bind-html 区域本身也没有插值节点) function markRange(root, start, end, cls, idx) { var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); var acc = 0, todo = [], n; while ((n = walker.nextNode())) { var len = n.nodeValue.length; var ns = acc, ne = acc + len; acc = ne; if (ne <= start || ns >= end) continue; var a = Math.max(0, start - ns), b = Math.min(len, end - ns); if (b <= a) continue; todo.push({ node: n, a: a, b: b }); } var made = 0; todo.forEach(function (t) { var node = t.node; if (t.b < node.nodeValue.length) node.splitText(t.b); if (t.a > 0) node = node.splitText(t.a); var mk = document.createElement('mark'); mk.className = 'gth-hl ' + cls; mk.dataset.gthHl = '1'; // 反查下标:点击这条划线时能知道它是 store.highlights[qid] 里的第几条 if (idx != null) mk.dataset.gthI = String(idx); node.parentNode.insertBefore(mk, node); mk.appendChild(node); made++; }); return made; } // 落笔前先拆掉已有 mark,保证文本是「干净原文」,重绘才幂等 function unwrapMarks(root) { $$('mark.gth-hl', root).forEach(function (m) { var p = m.parentNode; if (!p) return; while (m.firstChild) p.insertBefore(m.firstChild, m); p.removeChild(m); }); } /* 重画一道题的所有划线。签名机制同时有两个作用: 1) 幂等 —— 画完再被 MutationObserver 唤醒时直接跳过,避免无限重绘; 2) 感知变化 —— 题目文本长度变了(展开解析、换页)就重画。 */ function paintRoot(root) { var qid = root.getAttribute('data-gth-qid'); if (!qid || !root.isConnected) return; var list = store.highlights[qid] || []; var len = (root.textContent || '').length; var sig = qid + '|' + list.length + '|' + len; if (root.getAttribute('data-gth-paint') === sig) return; // 题目内容还没渲染出来(Angular 尚未 ng-bind-html、或正整块重绘)时不要落笔, // 否则会把「还没渲染」误判成「划线失效」,还把这个误判写进本地存储 if (list.length && len < 8) return; unwrapMarks(root); if (!list.length) { root.setAttribute('data-gth-paint', sig); return; } var painted = 0, changed = false, retry = false; list.forEach(function (rec, i) { if (rec.edited) return; // 手改过文本的划线只作笔记素材,不再往页面上画 var pos = locate(root, rec); if (!pos) { // 连续两次定位失败才认定失效:单次失败多半是站点正在重绘,等下一次唤醒再试 rec.miss = (rec.miss || 0) + 1; if (rec.miss >= 2) { if (!rec.lost) { rec.lost = true; changed = true; console.warn('[错题助手] 划线已失效:', qid, rec.quote); } } else { retry = true; } return; } if (rec.miss) { delete rec.miss; changed = true; } if (rec.lost) { delete rec.lost; changed = true; } if (markRange(root, pos.start, pos.end, rec.color === 'red' ? 'red' : 'yellow', i)) painted++; }); if (changed) saveStore(); // 签名只跟「题目文本 + 划线条数」有关。用 list.length 而不是本次实画条数: // 只要有一条 edited / 失效的划线,实画条数就永远对不上,观察器每个周期都会 // 拆了重画(划线闪烁、CPU 空转),这正是「划线看着失效」的来源之一 root.setAttribute('data-gth-paint', retry ? '' : sig); } function repaintHighlights() { $$('[data-gth-qid]').forEach(paintRoot); } // 新增一条划线:由「选区起点在题目纯文本中的下标」反推 nth 与前置上下文 function addHighlight(qid, quote, start, color) { var root = $('[data-gth-qid="' + String(qid).replace(/"/g, '') + '"]'); var full = root ? (root.textContent || '') : ''; var idxs = allIndices(full, quote); var best = -1, nth = 1; for (var i = 0; i < idxs.length; i++) { if (best < 0 || Math.abs(idxs[i] - start) < Math.abs(best - start)) { best = idxs[i]; nth = i + 1; } } if (best < 0) return null; var it = questionOf(qid); var rec = { quote: quote, prefix: full.slice(Math.max(0, best - HL_PREFIX_LEN), best), nth: nth, block: classifyBlock(it, quote), color: color === 'red' ? 'red' : 'yellow', at: Date.now(), snap: it ? stripHtml(it.content || '').slice(0, 240) : '', subject: it ? it.content_type : null }; if (!store.highlights[qid]) store.highlights[qid] = []; store.highlights[qid].push(rec); saveStore(); if (root) { root.removeAttribute('data-gth-paint'); paintRoot(root); } return rec; } function setHighlightColor(qid, idx, color) { var list = store.highlights[qid]; if (!list || !list[idx]) return; list[idx].color = color === 'red' ? 'red' : 'yellow'; saveStore(); var root = $('[data-gth-qid="' + String(qid).replace(/"/g, '') + '"]'); if (root) { root.removeAttribute('data-gth-paint'); paintRoot(root); } } /* ---- 划线的批注:Word 的模型是「批注锚定在文字上」,所以每条划线自带一条批注 ---- */ function hlNoteGet(qid, idx) { var r = (store.highlights[qid] || [])[idx]; return r ? (r.note || '') : ''; } function hlNoteSet(qid, idx, text) { var r = (store.highlights[qid] || [])[idx]; if (!r) return; if (text) { r.note = text; r.noteAt = Date.now(); } else { delete r.note; delete r.noteAt; } saveStore(); } // 取消划线:连同它挂着的批注一起删(Word 里删批注=撤掉高亮,两者同生共死) function hlRemove(qid, idx) { var list = store.highlights[qid]; if (!list || idx < 0 || idx >= list.length) return; list.splice(idx, 1); if (!list.length) delete store.highlights[qid]; saveStore(); var root = $('[data-gth-qid="' + String(qid).replace(/"/g, '') + '"]'); if (root) { root.removeAttribute('data-gth-paint'); paintRoot(root); } } // 选区 [s,e) 覆盖了哪些划线?用于「再点一次同色 = 取消」的 Word 式切换 function hlOverlap(root, start, end, color) { var qid = root.getAttribute('data-gth-qid'); var list = store.highlights[qid] || []; var hit = []; list.forEach(function (rec, i) { if (rec.edited || rec.lost) return; if (color && (rec.color === 'red' ? 'red' : 'yellow') !== color) return; var pos = locate(root, rec); if (!pos) return; if (pos.start < end && pos.end > start) hit.push(i); // 半开区间相交 }); return hit; } function masteredLabel(id) { var m = store.mastered[id]; if (!m || !m.streak) return '未掌握'; if (m.streak >= MASTER_STREAK) return '已掌握'; return '待巩固 ' + m.streak + '/' + MASTER_STREAK; } function isMastered(id) { var m = store.mastered[id]; return !!m && m.streak >= MASTER_STREAK; } /* 答错次数:优先读服务端字段(站点若返回),否则用本地累计 */ var ERR_COUNT_KEYS = ['error_count', 'wrong_count', 'error_num', 'wrong_num', 'error_times', 'wrong_times', 'err_count', 'wrong_cnt', 'error_cnt', 'errorcnt', 'count']; function serverErrCount(q) { for (var i = 0; i < ERR_COUNT_KEYS.length; i++) { var v = q[ERR_COUNT_KEYS[i]]; if (typeof v === 'number' && v > 0) return v; if (typeof v === 'string' && /^\d+$/.test(v.trim()) && Number(v) > 0) return Number(v); } return 0; } // 能进错题本就说明至少已经错过一次,所以真实次数 = 本地累计 + 1 var WRONG_BASE = 1; var STUBBORN_MIN = 3; // 达到这个次数标为红色「顽固错题」(含 3 次) function errCountOf(q) { var s = serverErrCount(q); if (s) return s; return ((store.wrongCount && store.wrongCount[q.id]) || 0) + WRONG_BASE; } // 答错次数标记:达到 STUBBORN_MIN 用红色醒目的「顽固错题」 function errTag(n) { var hard = n >= STUBBORN_MIN; return { cls: 'gth-err' + (hard ? ' stubborn' : ''), html: (hard ? icon('warn') : icon('xCircle')) + (hard ? '顽固错题 · ' + n + ' 次' : '答错 ' + n + ' 次') }; } // 交卷时累计本地答错次数(答错 +1) function bumpWrongCount(q, ok) { if (!store.wrongCount) store.wrongCount = {}; if (!ok) store.wrongCount[q.id] = (store.wrongCount[q.id] || 0) + 1; } // 首次注入列表 UI 时打印一次题目字段清单,便于确认服务端是否带错误次数字段 var probed = false; function probeFields(list) { if (!list || !list.length) return; var keys = Object.keys(list[0]); var hit = keys.filter(function (k) { return ERR_COUNT_KEYS.indexOf(k) >= 0; }); console.log('[错题助手] 题目字段:' + keys.join(', ')); console.log('[错题助手] 错误次数字段:' + (hit.length ? hit.join(', ') : '未发现,当前使用本地累计(站点模板与控制器均无该字段)')); console.log('[错题助手] 解析字段:analysis=' + (list[0].analysis ? '有内容' : '空') + ' / analysis_shadow=' + (list[0].analysis_shadow ? '有内容' : '空')); } function getToken() { var raw = localStorage.getItem(LS_TOKEN) || localStorage.getItem('token_' + CTX) || ''; try { var v = JSON.parse(raw); if (typeof v === 'string') raw = v; } catch (e) {} return raw; } function apiGet(path, params) { return new Promise(function (resolve, reject) { var p = { token: getToken(), clienttype: 4, login_status: localStorage.getItem(LS_LOGIN) || '' }; Object.keys(params || {}).forEach(function (k) { if (params[k] !== '' && params[k] != null) p[k] = params[k]; }); var qs = Object.keys(p).map(function (k) { return encodeURIComponent(k) + '=' + encodeURIComponent(p[k]); }).join('&'); GM_xmlhttpRequest({ method: 'GET', url: API_BASE + path + '?' + qs, timeout: 30000, onload: function (r) { var j; try { j = JSON.parse(r.responseText); } catch (e) { reject(new Error('响应解析失败:' + String(r.responseText).slice(0, 120))); return; } if (j && j.code === 0) resolve(j.data || {}); else reject(new Error('接口 code=' + (j && j.code) + ' ' + ((j && j.msg) || ''))); }, onerror: function () { reject(new Error('网络请求失败')); }, ontimeout: function () { reject(new Error('请求超时')); } }); }); } var commodityPromise = null; function getCommodity() { if (!commodityPromise) { commodityPromise = apiGet('commodity').then(function (d) { var ci = d.commodity_info || {}; if (!ci.content_id) throw new Error('未获取到 content_id,请确认已登录且已开通题库'); return ci; }).catch(function (e) { commodityPromise = null; throw e; }); } return commodityPromise; } function normalize(q) { ['opt', 'correct_answer', 'user_answer'].forEach(function (k) { if (typeof q[k] === 'string') { try { q[k] = JSON.parse(q[k]); } catch (e) { q[k] = k === 'opt' ? [] : ''; } } }); if (!Array.isArray(q.opt)) q.opt = []; if (q.analysis == null) q.analysis = ''; if (q.material == null) q.material = ''; return q; } // 站点把解析做成「展开/收起」开关:加载时 analysis 被清空、内容转入 analysis_shadow, // 点击后再换回来。所以解析始终只在这两个字段之一里,读单个字段必然拿到空串。 function analysisOf(q) { return q.analysis || q.analysis_shadow || ''; } function ansKey(a) { var arr = Array.isArray(a) ? a.slice() : String(a == null ? '' : a).split(''); arr.sort(); return arr.join(''); } function pagedFetch(ci, base, limit) { var url = 'content/' + ci.content_id + '/error/view'; var size = 100, out = []; function nextPage(page) { return apiGet(url, Object.assign({}, base, { page: page, page_size: size })) .then(function (d) { var list = (d.subject_list || []).map(normalize); out = out.concat(list); var total = d.total_items || 0; if (list.length < size) return out; if (limit && out.length >= limit) return out; if (total && out.length >= total) return out; if (page >= 49) return out; return nextPage(page + 1); }); } return nextPage(0); } // 行测的模块(知识点)位于 subcategory_list 的下一级 exampoint_list。 // 站点模板本身也只渲染这一级,其上层「试卷分类」在模板中已被注释掉。 // 同名模块可能同时挂在多个分类下,这里按名称合并为一组请求对。 function buildModuleOptions(list) { var byName = {}, out = []; function add(name, pair) { var k = String(name); if (!byName[k]) { byName[k] = { name: k, pairs: [] }; out.push(byName[k]); } byName[k].pairs.push(pair); } (list || []).forEach(function (c) { var eps = c.exampoint_list || []; if (!eps.length) add(c.name, { subcategory_id: c.id, exampoint_id: '' }); else eps.forEach(function (p) { add(p.name, { subcategory_id: c.id, exampoint_id: p.id }); }); }); return out; } // moduleNames 为空数组表示全部模块 function fetchXingce(moduleNames, onProgress) { return fetchSubcategory().then(function (list) { var pairs = []; buildModuleOptions(list).forEach(function (m) { if (moduleNames && moduleNames.length && moduleNames.indexOf(m.name) < 0) return; m.pairs.forEach(function (p) { pairs.push({ subcategory_id: p.subcategory_id, exampoint_id: p.exampoint_id, name: m.name }); }); }); if (!pairs.length) return []; return getCommodity().then(function (ci) { var seen = new Set(), out = [], i = 0; function next() { if (i >= pairs.length) return out; var pair = pairs[i++]; if (onProgress) onProgress(i, pairs.length, pair.name); return apiGet('content/' + ci.content_id + '/error/view', { view_type: 1, agency_commodity_id: ci.id, is_cal_totalitems: 1, content_type: SUBJ_XC, subcategory_id: pair.subcategory_id, exampoint_id: pair.exampoint_id, page: 0, page_size: 100 }).then(function (d) { (d.subject_list || []).forEach(function (q) { normalize(q); if (!seen.has(q.id)) { seen.add(q.id); out.push(q); } }); return next(); }); } return next(); }); }); } function fetchErrors(f, limit) { if (f.mode === 'date') { return getCommodity().then(function (ci) { return pagedFetch(ci, { view_type: 0, agency_commodity_id: ci.id, is_cal_totalitems: 1, day_range_type: f.dayRange }, limit); }); } if (f.subject === SUBJ_XC) { return fetchXingce(f.module_names || [], function (i, n, name) { setStatus('行测收集中 ' + i + '/' + n + ' · ' + name); }); } return getCommodity().then(function (ci) { var base = { view_type: 1, agency_commodity_id: ci.id, is_cal_totalitems: 1, content_type: f.subject }; return pagedFetch(ci, base, limit); }); } /* ---------- 收藏夹:与错题本同构的第二条题目来源 ---------- 站点两个列表用的是同一个 ng-repeat 表达式(item in subjectList),字段也一致, 接口只差路径:error/view 分页返回,favorite/view 一次性返回且没有分页参数。 */ function listPath(kind, ci) { return 'content/' + ci.content_id + '/' + (kind === 'favorite' ? 'favorite/view' : 'error/view'); } // 收藏与错题的筛选参数一致:view_type 0=日期型、1=科目型 function listParams(ci, f) { var base = { agency_commodity_id: ci.id, is_cal_totalitems: 1 }; if (f.mode === 'date') { base.view_type = 0; base.day_range_type = f.dayRange; } else { base.view_type = 1; base.content_type = f.subject; } return base; } function fetchFavoriteFlat(f) { return getCommodity().then(function (ci) { return apiGet(listPath('favorite', ci), listParams(ci, f)) .then(function (d) { return (d.subject_list || []).map(normalize); }); }); } // 行测的收藏同样要按考点逐个请求:content_type=0 时接口只认 subcategory/exampoint, // 不传考点等于拿不到东西。做法与 fetchXingce 一致——遍历考点再按 id 去重。 function fetchFavoriteXingce(moduleNames, onProgress) { return fetchSubcategory().then(function (list) { var pairs = []; buildModuleOptions(list).forEach(function (m) { if (moduleNames && moduleNames.length && moduleNames.indexOf(m.name) < 0) return; m.pairs.forEach(function (p) { pairs.push({ subcategory_id: p.subcategory_id, exampoint_id: p.exampoint_id, name: m.name }); }); }); if (!pairs.length) return []; return getCommodity().then(function (ci) { var seen = new Set(), out = [], i = 0; function next() { if (i >= pairs.length) return out; var pair = pairs[i++]; if (onProgress) onProgress(i, pairs.length, pair.name); var params = listParams(ci, { mode: 'subject', subject: SUBJ_XC }); params.subcategory_id = pair.subcategory_id; params.exampoint_id = pair.exampoint_id; return apiGet(listPath('favorite', ci), params).then(function (d) { (d.subject_list || []).forEach(function (q) { normalize(q); if (!seen.has(q.id)) { seen.add(q.id); out.push(q); } }); return next(); }); } return next(); }); }); } function fetchFavorites(f) { if (f.mode !== 'date' && f.subject === SUBJ_XC) { return fetchFavoriteXingce(f.module_names || [], function (i, n, name) { setStatus('收藏 · 行测收集中 ' + i + '/' + n + ' · ' + name); }); } return fetchFavoriteFlat(f); } // 面板里的「来源」:error=错题本、favorite=收藏夹、both=两者并集(同一道题只留一份) function fetchByFilter(f, limit) { var src = f.src || 'error'; if (src === 'favorite') return fetchFavorites(f); if (src !== 'both') return fetchErrors(f, limit); return Promise.all([fetchErrors(f, limit), fetchFavorites(f)]).then(function (r) { var seen = new Set(), out = []; r[0].concat(r[1]).forEach(function (q) { if (!q || q.id == null || seen.has(q.id)) return; seen.add(q.id); out.push(q); }); return out; }); } function fetchSubcategory() { return getCommodity() .then(function (ci) { return apiGet('content/' + ci.content_id + '/subcategory'); }) .then(function (d) { return d.subcategory_list || []; }); } var CRC_TABLE = (function () { var t = new Uint32Array(256); for (var n = 0; n < 256; n++) { var c = n; for (var k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c >>> 0; } return t; })(); function crc32(buf) { var crc = 0xFFFFFFFF; for (var i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xFF]; return (crc ^ 0xFFFFFFFF) >>> 0; } function zipStore(entries) { var enc = new TextEncoder(); var local = [], central = []; var offset = 0, cdSize = 0; entries.forEach(function (e) { var name = enc.encode(e.name), data = e.data, crc = crc32(data); var lh = new Uint8Array(30 + name.length); var lv = new DataView(lh.buffer); lv.setUint32(0, 0x04034b50, true); lv.setUint16(4, 20, true); lv.setUint16(8, 0, true); lv.setUint16(12, 0x21, true); lv.setUint32(14, crc, true); lv.setUint32(18, data.length, true); lv.setUint32(22, data.length, true); lv.setUint16(26, name.length, true); lh.set(name, 30); local.push(lh, data); var ch = new Uint8Array(46 + name.length); var cv = new DataView(ch.buffer); cv.setUint32(0, 0x02014b50, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true); cv.setUint16(10, 0, true); cv.setUint16(14, 0x21, true); cv.setUint32(16, crc, true); cv.setUint32(20, data.length, true); cv.setUint32(24, data.length, true); cv.setUint16(28, name.length, true); cv.setUint32(42, offset, true); ch.set(name, 46); central.push(ch); offset += lh.length + data.length; cdSize += ch.length; }); var eocd = new Uint8Array(22); var ev = new DataView(eocd.buffer); ev.setUint32(0, 0x06054b50, true); ev.setUint16(8, entries.length, true); ev.setUint16(10, entries.length, true); ev.setUint32(12, cdSize, true); ev.setUint32(16, offset, true); var all = local.concat(central, [eocd]); var total = 0; all.forEach(function (b) { total += b.length; }); var out = new Uint8Array(total), p = 0; all.forEach(function (b) { out.set(b, p); p += b.length; }); return out; } function xesc(s) { return String(s == null ? '' : s) .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '') .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function colName(n) { var s = ''; while (n > 0) { var m = (n - 1) % 26; s = String.fromCharCode(65 + m) + s; n = Math.floor((n - 1) / 26); } return s; } function buildXlsx(header, rows) { var enc = new TextEncoder(); var xml = '' + ''; header.forEach(function (h, i) { var w = XLSX_WIDTHS[i] || 20; xml += ''; }); xml += ''; var rowsAll = [header].concat(rows); for (var ri = 0; ri < rowsAll.length; ri++) { var row = rowsAll[ri]; xml += ''; for (var ci = 0; ci < row.length; ci++) { xml += '' + '' + xesc(row[ci]) + ''; } xml += ''; } xml += ''; var files = [ { name: '[Content_Types].xml', data: '' + '' + '' + '' + '' + '' + '' }, { name: '_rels/.rels', data: '' + '' + '' + '' }, { name: 'xl/workbook.xml', data: '' + '' + '' }, { name: 'xl/_rels/workbook.xml.rels', data: '' + '' + '' + '' }, { name: 'xl/worksheets/sheet1.xml', data: xml } ]; var encodedFiles = files.map(function (f) { return { name: f.name, data: enc.encode(f.data) }; }); return zipStore(encodedFiles); } function toRows(list) { return list.map(function (q, i) { return [ i + 1, SUBJECT_NAME[q.content_type] || '', stripHtml(q.material), stripHtml(q.content), (q.opt || []).map(function (o) { return o.label + '. ' + stripHtml(o.content); }).join('\n'), ansKey(q.user_answer), ansKey(q.correct_answer), errCountOf(q), stripHtml(analysisOf(q)), getNote(q.id), hlSummary(q.id), masteredLabel(q.id), String(q.id) ]; }); } // 导出用的划线摘要:按「[重点] 原文(批注:…)」逐条拼接 function hlSummary(id) { return getHighlights(id).map(function (h) { return '[' + (HL_LABEL[h.color] || '重点') + '] ' + (h.quote || '') + (h.note ? '(批注:' + h.note.replace(/\s*\n\s*/g, ' ') + ')' : ''); }).join('\n'); } function exportMarkdown(list) { var lines = ['# 上岸村错题本', '', '> 导出时间:' + new Date().toLocaleString() + ' 共 ' + list.length + ' 题', '']; list.forEach(function (q, i) { lines.push('## ' + (i + 1) + '. ' + (stripHtml(q.content) || '(无题干)')); lines.push(''); if (stripHtml(q.material)) { lines.push('**材料**'); lines.push(''); lines.push(stripHtml(q.material)); lines.push(''); } (q.opt || []).forEach(function (o) { lines.push('- ' + o.label + '. ' + stripHtml(o.content)); }); if (q.opt && q.opt.length) lines.push(''); lines.push('**你的答案**:' + (ansKey(q.user_answer) || '—') + ' | **正确答案**:' + ansKey(q.correct_answer) + ' | **状态**:' + masteredLabel(q.id)); lines.push(''); if (stripHtml(analysisOf(q))) { lines.push('**解析**'); lines.push(''); lines.push(stripHtml(analysisOf(q))); lines.push(''); } if (getNote(q.id)) { lines.push('**笔记**'); lines.push(''); lines.push(getNote(q.id)); lines.push(''); } if (hlSummary(q.id)) { lines.push('**划线摘录**'); lines.push(''); getHighlights(q.id).forEach(function (h) { lines.push('- [' + (HL_LABEL[h.color] || '重点') + '] ' + (h.quote || '')); }); lines.push(''); } lines.push('---'); lines.push(''); }); return new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' }); } function exportCsv(list) { var cell = function (v) { return '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"'; }; var body = toRows(list).map(function (r) { return r.map(cell).join(','); }); var csv = '\uFEFF' + [EXPORT_HEADERS.map(cell).join(',')].concat(body).join('\r\n'); return new Blob([csv], { type: 'text/csv;charset=utf-8' }); } function exportXlsx(list) { return new Blob([buildXlsx(EXPORT_HEADERS, toRows(list))], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); } function exportJson(list) { var payload = { _format: 'gongan-tiku-helper-backup', _version: 1, _exportedAt: new Date().toISOString(), questions: list.map(function (q) { return { id: q.id, content_type: q.content_type, material: q.material, content: q.content, opt: q.opt, correct_answer: q.correct_answer, user_answer: q.user_answer, analysis: analysisOf(q) }; }), notes: store.notes, highlights: store.highlights, mastered: store.mastered, wrongCount: store.wrongCount, exported: store.exported, exportAt: store.exportAt || 0, history: store.history || [] }; return new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' }); } function exportNotesMarkdown() { var ids = Object.keys(store.notes); var lines = ['# 我的笔记', '', '> 导出时间:' + new Date().toLocaleString() + ' 共 ' + ids.length + ' 条', '']; ids.forEach(function (id) { var n = store.notes[id]; lines.push('## ' + (SUBJECT_NAME[n.subject] || '题目')); lines.push(''); if (n.snapshot) lines.push('> ' + n.snapshot); lines.push(''); lines.push(n.text); lines.push(''); lines.push('---'); lines.push(''); }); return new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' }); } function restoreJson(text) { var j = JSON.parse(text); if (!j || (j._format !== 'gongan-tiku-helper-backup' && j._format !== 'gongan-tiku-helper-notes')) { throw new Error('文件格式不匹配'); } if (j.notes) store.notes = j.notes; if (j.highlights) store.highlights = j.highlights; if (j.mastered) store.mastered = j.mastered; if (j.wrongCount) store.wrongCount = j.wrongCount; // 备份文件可能来自 v1.9 之前的扁平格式,统一迁移成按来源分桶 if (j.exported) { var bak = migrateExported({ exported: j.exported, exportAt: j.exportAt }); store.exported = bak.exported; store.exportAt = bak.exportAt; } if (j.history) store.history = j.history; saveStore(); refreshBadges(); renderNotesList(); renderHistory(); updateExportHint(); repaintHighlights(); return '已恢复笔记 ' + Object.keys(store.notes).length + ' 条,划线 ' + Object.keys(store.highlights).length + ' 题,掌握记录 ' + Object.keys(store.mastered).length + ' 条,组卷历史 ' + (store.history || []).length + ' 条'; } GM_addStyle([ ':root{', '--gth-bg:#ffffff;--gth-fg:#0f172a;--gth-muted:#64748b;--gth-subtle:#f8fafc;', '--gth-border:#e2e8f0;--gth-border-strong:#cbd5e1;--gth-hover:#f1f5f9;', '--gth-primary:#0f172a;--gth-primary-fg:#f8fafc;--gth-primary-hover:#1e293b;', '--gth-destructive:#dc2626;--gth-destructive-hover:#fef2f2;', '--gth-ring:rgba(148,163,184,0.45);--gth-success:#16a34a;', '}', '.gth-ic{display:inline-flex;align-items:center;justify-content:center;width:1em;height:1em;line-height:1;color:currentColor}', '.gth-ic svg{width:100%;height:100%;display:block}', /* 左侧菜单入口:尺寸/底色/字色完全沿用站点 .left-menu .item,只补激活态 */ '.gth-menu-item{cursor:pointer}', '.gth-menu-item .text{display:inline-flex;align-items:center;gap:6px}', '.gth-menu-item .gth-ic{font-size:14px;opacity:.75}', '.gongan2-container .left-menu .item.gth-menu-item.active{background:#fff;font-weight:700}', // 助手视图打开时压住原生项的激活底色。原生 active 由 AngularJS 的 ng-class 掌管, // 直接摘它的 class 会与 ng-class 打架(表达式值未变就不会重加),因此只做视觉压制、不改状态 'body.gth-view-on .left-menu .item.active:not(.gth-menu-item){background:#f8f8f8}', /* 助手视图:作为 .right-content 的原生同级节点,与站点 .inner-content 互斥显示 */ '.gth-hide{display:none!important}', '.gth-view{padding:0 20px 40px;box-sizing:border-box;', 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;', 'font-size:13px;color:var(--gth-fg)}', '.gth-view[hidden]{display:none}', /* 笔记批注轨道:.content 保持自身滚动(站点原生行为),轨道用 position:fixed 镜像它的视口位置,内部再按 scrollTop 反向平移,从而把批注栏落在容器右侧的空白页边距里。 纯 CSS 做不到——浏览器会把「一轴 clip + 另一轴 scroll」降级为 hidden,overflow-clip-margin 失效。 */ // 用子选择器限定:题目区里也有 .content(题干那层),别给它加定位上下文 'body.gth-error .right-content .inner-content>.content{position:relative}', '.gth-rail{position:fixed;overflow:hidden;pointer-events:none;z-index:50;', 'width:clamp(160px,calc((100vw - 1000px) / 2 - 24px),280px);', 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif}', '.gth-rail[hidden]{display:none}', '.gth-rail-in{position:relative;width:100%}', '.gth-aside{position:absolute;left:0;width:100%;box-sizing:border-box;pointer-events:auto;', 'border:1px solid #fde68a;border-left:3px solid #fcd34d;border-radius:8px;background:#fffbeb;', 'padding:9px 11px;font-size:12px;line-height:1.7;color:#713f12}', '.gth-aside-h{display:flex;align-items:center;gap:5px;font-size:11px;font-weight:600;color:#a16207;margin-bottom:5px}', '.gth-aside-h .gth-ic{font-size:12px}', '.gth-aside-h .sp{flex:1}', '.gth-aside-body{white-space:pre-wrap;word-break:break-word}', '.gth-aside-empty{color:#a16207;opacity:.7;cursor:pointer}', '.gth-aside-empty:hover{opacity:1}', '.gth-aside textarea{width:100%;min-height:72px;box-sizing:border-box;border:1px solid #fde68a;border-radius:6px;', 'padding:6px 8px;font-size:12px;line-height:1.7;font-family:inherit;resize:vertical;', 'background:#fff;color:var(--gth-fg);outline:none}', '.gth-aside-act{margin-top:6px;display:flex;gap:6px}', '.gth-mini{border:1px solid #fde68a;background:#fff;color:#a16207;border-radius:6px;height:24px;padding:0 8px;', 'font-size:11px;cursor:pointer;font-family:inherit;display:inline-flex;align-items:center;gap:4px}', '.gth-mini:hover{background:#fef3c7}', '.gth-mini.primary{background:#f59e0b;border-color:#f59e0b;color:#fff}', '.gth-mini.primary:hover{background:#d97706;border-color:#d97706}', /* 子分类 / 模块 两级多选 chips */ '.gth-chiprow{display:flex;align-items:flex-start;gap:8px;margin-bottom:8px}', '.gth-chiplabel{color:var(--gth-muted);font-size:12px;min-width:28px;padding-top:7px;flex:0 0 auto}', '.gth-chips{display:flex;flex-wrap:wrap;gap:6px;flex:1;min-width:0}', '.gth-chip{display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 11px;', 'border:1px solid var(--gth-border);border-radius:14px;background:var(--gth-bg);color:var(--gth-muted);', 'font-size:12px;cursor:pointer;transition:all .15s;user-select:none}', '.gth-chip:hover{border-color:var(--gth-border-strong);color:var(--gth-fg)}', '.gth-chip.on{background:var(--gth-primary);border-color:var(--gth-primary);color:var(--gth-primary-fg)}', '.gth-chip .gth-ic{font-size:11px}', '.gth-chips-empty{font-size:12px;color:var(--gth-muted);padding:5px 0 10px}', /* 答错次数标记 */ '.gth-err{display:inline-flex;align-items:center;gap:3px;font-size:11px;padding:2px 8px;border-radius:10px;', 'background:var(--gth-subtle);color:var(--gth-muted)}', '.gth-err.stubborn{background:#dc2626;color:#fff;font-weight:600}', '.gth-err .gth-ic{font-size:11px}', '.gth-head{padding:10px 0;border-bottom:1px solid var(--gth-border);margin-bottom:16px}', '.gth-tabs{display:flex;gap:2px}', '.gth-tabs button{border:none;background:transparent;color:var(--gth-muted);padding:8px 12px;', 'font-size:13px;cursor:pointer;border-bottom:2px solid transparent;display:inline-flex;align-items:center;gap:6px;', 'transition:color .15s,border-color .15s}', '.gth-tabs button:hover{color:var(--gth-fg)}', '.gth-tabs button.on{color:#2178db;border-bottom-color:#2178db;font-weight:600}', '.gth-tabs .gth-ic{font-size:14px}', '.gth-body{padding:0}', '.gth-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}', '.gth-row label{color:var(--gth-muted);min-width:44px;font-size:12px}', '.gth-row .sp{flex:1}', '.gth-hint{color:var(--gth-muted);font-size:12px;line-height:1.6;margin:2px 0 12px}', '.gth-hint-sm{color:var(--gth-muted);font-size:11px;line-height:1.5}', '.gth-sep{height:1px;background:var(--gth-border);margin:14px 0}', '.gth-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;', 'height:34px;padding:0 14px;border-radius:8px;', 'border:1px solid var(--gth-border);background:var(--gth-bg);color:var(--gth-fg);', 'font-size:13px;font-weight:500;line-height:1;cursor:pointer;', 'transition:background-color .15s,border-color .15s,color .15s,box-shadow .15s}', '.gth-btn:hover{background:var(--gth-hover);border-color:var(--gth-border-strong)}', '.gth-btn:focus-visible{outline:2px solid var(--gth-ring);outline-offset:1px}', '.gth-btn:disabled{opacity:.5;cursor:not-allowed}', '.gth-btn.primary{background:var(--gth-primary);color:var(--gth-primary-fg);border-color:var(--gth-primary)}', '.gth-btn.primary:hover{background:var(--gth-primary-hover);border-color:var(--gth-primary-hover)}', '.gth-btn.danger{color:var(--gth-destructive);border-color:var(--gth-border)}', '.gth-btn.danger:hover{background:var(--gth-destructive-hover);border-color:#fecaca;color:#b91c1c}', '.gth-btn.ghost{border-color:transparent}', '.gth-btn.ghost:hover{background:var(--gth-hover);border-color:transparent}', '.gth-btn.sm{height:28px;padding:0 10px;font-size:12px;gap:4px}', '.gth-btn .gth-ic{font-size:14px}', '.gth-input,.gth-select{display:inline-flex;align-items:center;height:34px;padding:0 10px;', 'border-radius:8px;border:1px solid var(--gth-border);background:var(--gth-bg);color:var(--gth-fg);', 'font-size:13px;outline:none;font-family:inherit;', 'transition:border-color .15s,box-shadow .15s}', '.gth-input:focus,.gth-select:focus{border-color:var(--gth-border-strong);box-shadow:0 0 0 3px var(--gth-ring)}', '.gth-select{appearance:none;-webkit-appearance:none;background-repeat:no-repeat;background-position:right 10px center;padding-right:30px;', "background-image:url(\"data:image/svg+xml;utf8,\")}", '.gth-select::-ms-expand{display:none}', '#gth-status{margin-top:10px;padding:10px 12px;border-radius:8px;background:var(--gth-subtle);', 'color:var(--gth-muted);font-size:12px;line-height:1.6;white-space:pre-wrap;border:1px solid var(--gth-border)}', '#gth-status.err{background:#fef2f2;color:#b91c1c;border-color:#fecaca}', '#gth-status.ok{background:#f0fdf4;color:#15803d;border-color:#bbf7d0}', // 操作条挂在题目内容之上(ng-repeat 节点的第一个子节点)。左内边距 40px = 站点 // .sequence 的宽度,让操作条与题干左对齐 '.gth-qbar{display:flex;align-items:center;gap:8px;margin:12px 0 0;padding:0 0 8px 40px}', '.gth-qbar [hidden]{display:none!important}', // 操作条上的按钮:挨着答错次数 / 掌握状态徽章,与 shadcn 节奏一致 '.gth-qbar-btn{display:inline-flex;align-items:center;gap:3px;height:24px;padding:0 8px;font-size:11px;', 'border:1px solid var(--gth-border);border-radius:6px;background:var(--gth-bg);color:var(--gth-muted);cursor:pointer}', '.gth-qbar-btn:hover{background:var(--gth-hover);color:var(--gth-fg)}', '.gth-qbar-copy.copied{color:#16a34a;border-color:#bbf7d0;background:#f0fdf4}', // 掌握状态徽章可点:点一下在「未掌握 / 已掌握」之间切 '.gth-badge{font-size:11px;color:var(--gth-muted);display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:10px;', 'background:var(--gth-subtle);cursor:pointer;user-select:none;transition:background .15s,color .15s}', '.gth-badge:hover{background:var(--gth-hover);color:var(--gth-fg)}', '.gth-badge.done{color:#15803d;background:#f0fdf4}', '.gth-badge.ghost{color:#94a3b8;background:transparent;box-shadow:inset 0 0 0 1px var(--gth-border)}', '.gth-badge.has{color:#a16207;background:#fffbeb}', /* 组卷历史 */ '.gth-sub{font-size:12px;font-weight:600;color:var(--gth-fg);margin:0 0 8px}', '.gth-his{display:flex;flex-direction:column;gap:8px}', '.gth-his-item{display:flex;align-items:center;gap:10px;border:1px solid var(--gth-border);border-radius:8px;', 'padding:9px 12px;background:var(--gth-bg)}', '.gth-his-item .desc{flex:1;min-width:0;font-size:12px;color:var(--gth-fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.gth-his-item .meta{font-size:11px;color:var(--gth-muted);white-space:nowrap}', '.gth-notes-search{position:relative;margin-bottom:10px}', '.gth-notes-search .gth-input{width:100%;padding-left:32px}', '.gth-notes-search .ic-l{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--gth-muted);display:inline-flex}', '.gth-notes-meta{color:var(--gth-muted);font-size:12px;display:inline-flex;align-items:center;gap:6px}', '.gth-notes-list{display:flex;flex-direction:column;gap:10px;max-height:48vh;overflow:auto;padding-right:2px}', '.gth-note-item{background:var(--gth-bg);border:1px solid var(--gth-border);border-radius:10px;padding:12px 14px;', 'transition:border-color .15s,box-shadow .15s}', '.gth-note-item:hover{border-color:var(--gth-border-strong);box-shadow:0 2px 6px rgba(15,23,42,.04)}', '.gth-note-h{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--gth-muted);margin-bottom:6px;flex-wrap:wrap}', '.gth-note-h .gth-pill{padding:2px 8px;border-radius:10px;background:var(--gth-subtle);color:var(--gth-muted);font-size:11px;border:1px solid var(--gth-border)}', '.gth-note-h .gth-pill.dyn{color:#a16207;background:#fffbeb;border-color:#fde68a}', '.gth-note-h .gth-pill.hl{color:#a16207;background:#fffbeb;border-color:#fde68a;display:inline-flex;align-items:center;gap:3px}', '.gth-note-h .gth-pill.hl .gth-ic{font-size:11px}', '.gth-note-hl{margin-top:8px;display:flex;flex-direction:column;gap:5px}', '.gth-note-h .sp{flex:1}', '.gth-note-snap{font-size:12px;color:var(--gth-muted);line-height:1.6;margin-bottom:6px;', 'background:var(--gth-subtle);border-radius:6px;padding:6px 10px;border-left:2px solid var(--gth-border)}', '.gth-note-text{font-size:13px;line-height:1.7;white-space:pre-wrap;word-break:break-word;color:var(--gth-fg)}', '.gth-note-text.is-empty{color:var(--gth-muted);font-style:italic}', '.gth-note-edit{display:flex;flex-direction:column;gap:6px}', '.gth-note-edit textarea{width:100%;min-height:80px;border:1px solid var(--gth-border);border-radius:6px;', 'padding:8px;font-size:13px;resize:vertical;box-sizing:border-box;font-family:inherit;margin-bottom:8px}', '.gth-empty{padding:24px;text-align:center;color:var(--gth-muted);font-size:13px;background:var(--gth-subtle);', 'border:1px dashed var(--gth-border);border-radius:10px;display:flex;flex-direction:column;align-items:center;gap:8px}', '#gth-quiz{position:fixed;inset:0;z-index:100000;background:#f8fafc;display:flex;flex-direction:column;', 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif}', '#gth-quiz[hidden]{display:none}', '.gthq-top{display:flex;align-items:center;gap:10px;padding:14px 24px;background:var(--gth-bg);', 'border-bottom:1px solid var(--gth-border);font-size:13px;color:var(--gth-fg);flex-wrap:wrap}', '.gthq-top .sp{flex:1}', '.gthq-top .pill{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:14px;', 'background:var(--gth-subtle);border:1px solid var(--gth-border);color:var(--gth-muted);font-size:12px}', '.gthq-top .pill b{color:var(--gth-fg)}', '.gthq-main{flex:1;overflow:auto;padding:24px}', '.gthq-card{background:var(--gth-bg);border:1px solid var(--gth-border);border-radius:12px;padding:24px;', 'max-width:880px;margin:0 auto;box-shadow:0 1px 2px rgba(15,23,42,.04)}', '.gthq-meta{color:var(--gth-muted);font-size:12px;margin-bottom:10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}', '.gthq-material{background:var(--gth-subtle);border:1px solid var(--gth-border);border-left:3px solid #cbd5e1;', 'padding:12px 14px;margin-bottom:16px;border-radius:8px;font-size:14px;line-height:1.8;color:var(--gth-fg)}', '.gthq-stem{font-size:16px;line-height:1.9;margin-bottom:16px;color:var(--gth-fg)}', '.gthq-opt{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;margin-bottom:8px;', 'border:1px solid var(--gth-border);border-radius:10px;cursor:pointer;font-size:15px;line-height:1.7;', 'background:var(--gth-bg);transition:all .15s}', '.gthq-opt:hover{border-color:var(--gth-border-strong);background:var(--gth-subtle)}', '.gthq-opt.sel{border-color:var(--gth-primary);background:var(--gth-primary);color:var(--gth-primary-fg)}', '.gthq-opt .lb{font-weight:600;min-width:22px}', // 题目区图片默认行内(display:inline-block),让「如图 所示」这类图文混排中的插图 // 落在文字之间,而不是被强制换行单独成行;大图受 max-width:100% 限制,放不下时自然回落到独立一行 '.gthq-opt img,.gthq-stem img,.gthq-material img,.gthq-analysis img,.gth-note-text img{max-width:100%;height:auto;display:inline-block;vertical-align:middle;margin:2px 0;border-radius:6px}', '.gthq-tip{color:var(--gth-muted);font-size:12px;margin:10px 0;display:inline-flex;align-items:center;gap:6px;', 'padding:4px 10px;background:var(--gth-subtle);border-radius:14px;border:1px solid var(--gth-border)}', '.gthq-sheet{display:flex;flex-wrap:wrap;gap:6px;max-width:880px;margin:18px auto 0}', '.gthq-cell{width:34px;height:34px;line-height:32px;text-align:center;border:1px solid var(--gth-border);', 'border-radius:8px;cursor:pointer;font-size:12px;background:var(--gth-bg);color:var(--gth-muted);', 'transition:all .15s}', '.gthq-cell:hover{border-color:var(--gth-border-strong);color:var(--gth-fg)}', '.gthq-cell.cur{border-color:var(--gth-primary);color:var(--gth-primary);font-weight:600}', '.gthq-cell.answered{background:var(--gth-subtle);border-color:var(--gth-border-strong);color:var(--gth-fg)}', '.gthq-nav{max-width:880px;margin:16px auto 40px;display:flex;gap:10px;justify-content:space-between}', '.gthq-r-item{background:var(--gth-bg);border:1px solid var(--gth-border);border-radius:12px;padding:16px 20px;', 'max-width:880px;margin:0 auto 12px;border-left:3px solid var(--gth-border)}', '.gthq-r-item.right{border-left-color:#16a34a}', '.gthq-r-item.wrong{border-left-color:var(--gth-destructive)}', '.gthq-r-item.unanswered{border-left-color:#ca8a04}', '.gthq-r-h .tag.unanswered{background:#fef9c3;color:#a16207}', '.gthq-r-h{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:12px;color:var(--gth-muted);flex-wrap:wrap}', '.gthq-r-h .tag{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;border-radius:12px;font-size:11px;font-weight:500}', '.gthq-r-h .tag.right{background:#dcfce7;color:#166534}', '.gthq-r-h .tag.wrong{background:#fee2e2;color:#991b1b}', '.gthq-ans{margin-top:12px;font-size:14px;display:flex;flex-wrap:wrap;gap:12px}', '.gthq-ans .ok{color:#16a34a}.gthq-ans .bad{color:var(--gth-destructive)}', '.gthq-analysis{margin-top:12px;background:var(--gth-subtle);border-radius:8px;padding:10px 14px;', 'font-size:14px;line-height:1.8;color:var(--gth-fg);border:1px solid var(--gth-border)}', '.gthq-notebox{margin-top:12px;display:flex;flex-direction:column;gap:6px}', '.gthq-nb-act{display:flex;align-items:center;gap:8px;flex-wrap:wrap}', '.gthq-notebox textarea{width:100%;min-height:60px;border:1px solid var(--gth-border);border-radius:8px;', 'padding:8px;font-size:13px;resize:vertical;box-sizing:border-box;font-family:inherit;background:var(--gth-bg)}', /* ---- 划线 ---- */ 'mark.gth-hl{background:transparent;color:inherit;border-radius:2px;padding:0 1px}', 'mark.gth-hl.yellow{background:#fde68a;box-shadow:inset 0 -2px 0 #f59e0b}', 'mark.gth-hl.red{background:#fecaca;box-shadow:inset 0 -2px 0 #dc2626}', /* 选中即现的浮动工具条。用 fixed + 视口坐标,避免受站点内部滚动容器影响 */ '#gth-hlbar{position:fixed;z-index:100001;display:none;align-items:center;gap:2px;padding:4px;', 'background:var(--gth-bg);border:1px solid var(--gth-border);border-radius:10px;', 'box-shadow:0 6px 20px rgba(15,23,42,.16);', 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif}', '#gth-hlbar.on{display:inline-flex}', '#gth-hlbar button{display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 9px;', 'border:none;background:transparent;border-radius:7px;font-size:12px;color:var(--gth-fg);', 'cursor:pointer;font-family:inherit;white-space:nowrap}', '#gth-hlbar button:hover{background:var(--gth-hover)}', '#gth-hlbar .dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:0 0 auto}', '#gth-hlbar .dot.yellow{background:#f59e0b}', '#gth-hlbar .dot.red{background:#dc2626}', '#gth-hlbar .sep{width:1px;height:16px;background:var(--gth-border);margin:0 2px}', '#gth-toast{position:fixed;left:50%;bottom:44px;transform:translateX(-50%) translateY(8px);z-index:100003;', 'background:#0f172a;color:#f8fafc;font-size:12px;padding:8px 14px;border-radius:8px;', 'opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;', 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif}', '#gth-toast.on{opacity:1;transform:translateX(-50%) translateY(0)}', /* 批注栏里的划线清单:内联展开,不用浮层——轨道容器 overflow:hidden 会裁掉浮层 */ '.gth-aside-hl{margin-top:6px;border-top:1px dashed #fde68a;padding-top:6px}', '.gth-aside-hl-t{display:flex;align-items:center;gap:5px;font-size:11px;color:#a16207;cursor:pointer;user-select:none}', '.gth-aside-hl-t .gth-ic{font-size:12px}', '.gth-aside-hl-t .sp{flex:1}', '.gth-aside-hl-t .caret{font-size:10px;opacity:.7}', '.gth-aside-hl-list{margin-top:5px;display:none;flex-direction:column;gap:5px}', '.gth-aside-hl-list.on{display:flex}', '.gth-hlp-item{display:flex;align-items:flex-start;gap:6px;font-size:11px;line-height:1.6;', 'background:#fff;border:1px solid #fde68a;border-radius:6px;padding:5px 7px}', '.gth-hlp-item .dot{width:8px;height:8px;border-radius:50%;margin-top:4px;flex:0 0 auto}', '.gth-hlp-item .dot.yellow{background:#f59e0b}', '.gth-hlp-item .dot.red{background:#dc2626}', '.gth-hlp-item .t{flex:1;min-width:0;word-break:break-word;cursor:pointer}', '.gth-hlp-item .rm{flex:0 0 auto;color:#a16207;opacity:.55;cursor:pointer;font-size:11px;padding:0 2px}', '.gth-hlp-item .rm:hover{opacity:1}', '.gth-hlp-item.lost .t{color:#b45309;text-decoration:line-through;opacity:.7}', '.gth-hlp-empty{font-size:11px;color:#a16207;opacity:.7}', '.gth-aside-hl-act{margin-top:6px;display:flex;gap:6px}', /* 一键整理面板 */ '#gth-collect{position:fixed;inset:0;z-index:100002;background:rgba(15,23,42,.45);', 'display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box}', '#gth-collect[hidden]{display:none}', '.gthc-box{background:var(--gth-bg);border-radius:14px;width:min(880px,100%);max-height:86vh;', 'display:flex;flex-direction:column;overflow:hidden;box-shadow:0 20px 60px rgba(15,23,42,.28);', 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;', 'font-size:13px;color:var(--gth-fg)}', '.gthc-h{display:flex;align-items:center;gap:8px;padding:14px 18px;border-bottom:1px solid var(--gth-border);font-size:14px}', '.gthc-h .sp{flex:1}', '.gthc-b{padding:14px 18px;overflow:auto;flex:1}', '.gthc-opts{display:flex;align-items:flex-start;gap:8px;margin-bottom:8px}', '.gthc-opts .lb{color:var(--gth-muted);font-size:12px;min-width:28px;padding-top:7px;flex:0 0 auto}', '.gthc-check{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--gth-fg);cursor:pointer;padding-top:7px}', '.gthc-pre{margin-top:10px;background:var(--gth-subtle);border:1px solid var(--gth-border);border-radius:8px;', 'padding:12px 14px;font-size:12px;line-height:1.75;white-space:pre-wrap;word-break:break-word;', 'max-height:42vh;overflow:auto;font-family:inherit}', '.gthc-f{display:flex;gap:8px;align-items:center;padding:12px 18px;border-top:1px solid var(--gth-border)}', '.gthc-f .sp{flex:1}', /* ---- 划线的批注(Word 式) ---- */ '#gth-hlbar .dot,#gth-hlmenu .dot,#gth-hlnote .dot,.gth-balloon-h .dot{width:9px;height:9px;', 'border-radius:50%;display:inline-block;flex:0 0 auto}', '#gth-hlmenu .dot.yellow,#gth-hlnote .dot.yellow,.gth-balloon-h .dot.yellow{background:#f59e0b}', '#gth-hlmenu .dot.red,#gth-hlnote .dot.red,.gth-balloon-h .dot.red{background:#dc2626}', 'mark.gth-hl{cursor:pointer}', 'mark.gth-hl.gth-hl-flash{outline:2px solid #0f172a;outline-offset:1px}', '.gth-hlp-item .bd{flex:1;min-width:0}', '.gth-hlp-item .t,.gth-hlp-item .n{cursor:pointer}', '.gth-hlp-item .n{margin-top:3px;padding-left:7px;border-left:2px solid #fcd34d;color:#92400e;white-space:pre-wrap}', '.gth-hlp-item .n:empty{display:none}', '.gth-hl-cap{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--gth-muted);margin-bottom:4px}', '.gth-hl-cap .gth-ic{font-size:12px}', '.gthq-hls:empty{display:none}', '#gth-hlmenu{position:fixed;z-index:100004;display:flex;flex-direction:column;gap:2px;padding:6px;', 'background:var(--gth-bg);border:1px solid var(--gth-border);border-radius:10px;min-width:172px;', 'box-shadow:0 8px 24px rgba(15,23,42,.18);', 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif}', '#gth-hlmenu[hidden]{display:none}', '#gth-hlmenu button{display:flex;align-items:center;gap:6px;height:28px;padding:0 9px;border:none;', 'background:transparent;border-radius:6px;font-size:12px;color:var(--gth-fg);cursor:pointer;', 'font-family:inherit;text-align:left;width:100%}', '#gth-hlmenu button:hover{background:var(--gth-hover)}', '#gth-hlmenu button.danger{color:var(--gth-destructive)}', '#gth-hlmenu .gth-hlm-q{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--gth-muted);', 'padding:2px 9px 6px;border-bottom:1px solid var(--gth-border);margin-bottom:4px;word-break:break-all}', '#gth-hlmenu .gth-hlm-c{display:flex;gap:2px}', '#gth-hlmenu .gth-hlm-c button{width:auto;flex:1}', '#gth-hlmenu .gth-hlm-c button.on{background:var(--gth-subtle);font-weight:600}', '#gth-hlnote{position:fixed;z-index:100005;width:min(420px,calc(100vw - 32px));', 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif}', '#gth-hlnote[hidden]{display:none}', '.gth-hln-box{background:var(--gth-bg);border:1px solid var(--gth-border);border-radius:12px;', 'box-shadow:0 12px 36px rgba(15,23,42,.22);overflow:hidden}', '.gth-hln-h{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--gth-border);font-size:13px}', '.gth-hln-h .sp{flex:1}', '.gth-hln-q{display:flex;align-items:flex-start;gap:6px;padding:9px 12px;font-size:12px;', 'line-height:1.7;color:var(--gth-muted);background:var(--gth-subtle);word-break:break-word}', '.gth-hln-q .dot{margin-top:5px}', '.gth-hln-box textarea{display:block;width:100%;min-height:84px;box-sizing:border-box;border:none;', 'border-bottom:1px solid var(--gth-border);padding:10px 12px;font-size:13px;line-height:1.7;', 'resize:vertical;font-family:inherit;background:var(--gth-bg);color:var(--gth-fg);outline:none}', '.gth-hln-f{display:flex;align-items:center;gap:6px;padding:9px 12px}', '.gth-hln-f .sp{flex:1}', /* 右侧页边距的批注气球:锚定到划线所在行,Word 批注的观感 */ '.gth-balloon{position:absolute;left:0;width:100%;box-sizing:border-box;pointer-events:auto;', 'border:1px solid var(--gth-border);border-left:3px solid #fcd34d;border-radius:8px;background:#fffbeb;', 'padding:7px 9px;font-size:11px;line-height:1.65;color:#713f12;cursor:pointer}', '.gth-balloon:hover{border-color:var(--gth-border-strong);box-shadow:0 2px 8px rgba(15,23,42,.08)}', '.gth-balloon[hidden]{display:none}', '.gth-balloon-h{display:flex;align-items:flex-start;gap:5px;font-size:10px;color:#a16207;', 'opacity:.85;margin-bottom:3px;word-break:break-word}', '.gth-balloon-h .dot{margin-top:3px}', '.gth-balloon-h .q{flex:1;min-width:0}', '.gth-balloon-b{white-space:pre-wrap;word-break:break-word;color:var(--gth-fg);font-size:12px}' ].join('')); // 助手视图:先挂在 body 上以便立即绑定事件,注入时再整体移入 .right-content var viewEl = document.createElement('div'); viewEl.className = 'gth-view zero-flex-1'; viewEl.hidden = true; viewEl.innerHTML = [ '
', '
', ' ', ' ', ' ', '
', '
', '
', // ---- 共用筛选区 ---- '
', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '
', ' ', // ---- 导出 ---- '
', '
', ' ', ' ', ' ', '
', '
', ' ', ' ', ' 首次导出会自动按当前来源与筛选载入题目', '
', '
', ' ', ' ', ' ', '
', '
', // ---- 重练 ---- ' ', // ---- 笔记 ---- ' ', '
', '
', ' ', ' ', ' ', ' ', '
', '
数据保存在本浏览器(localStorage)。清理浏览器数据或换设备前请先备份。
', ' ', '
' ].join(''); document.body.appendChild(viewEl); // 批注轨道:脱离题目滚动容器,靠 JS 镜像其位置并跟随 scrollTop var railEl = document.createElement('div'); railEl.id = 'gth-rail'; railEl.className = 'gth-rail'; railEl.hidden = true; railEl.innerHTML = '
'; document.body.appendChild(railEl); var statusEl = $('#gth-status'); function setStatus(msg, kind) { if (!msg) { statusEl.hidden = true; statusEl.textContent = ''; statusEl.className = ''; return; } statusEl.hidden = false; statusEl.textContent = msg; statusEl.className = kind || ''; } // 选中的行测模块名;空数组表示「全部模块」 var selectedModules = []; var moduleList = []; var SRC_NAME = { error: '错题本', favorite: '收藏夹', both: '错题+收藏' }; // 来源写进描述里:刷题进度 resumeKey 与组卷历史都靠这串字区分条件 function filterDesc(f) { var pre = (SRC_NAME[f.src] || SRC_NAME.error) + ' · '; if (f.mode === 'date') { var d = DAY_RANGES.filter(function (x) { return x[0] === String(f.dayRange); })[0]; return pre + '日期:' + (d ? d[1] : f.dayRange); } var s = SUBJECT_NAME[f.subject] || f.subject; if (f.subject === SUBJ_XC) { var names = f.module_names || []; s += names.length ? '(' + names.join('、') + ')' : '(全部模块)'; } return pre + s; } function readFilter() { var mode = $('#gth-mode').value; var f = { mode: mode, src: $('#gth-src') ? $('#gth-src').value : 'error' }; if (mode === 'date') { f.dayRange = $('#gth-day').value; } else { f.subject = Number($('#gth-subject').value); if (f.subject === SUBJ_XC) f.module_names = selectedModules.slice(); } return f; } function renderModChips() { var box = $('#gth-mod-chips'); if (!box) return; if (!moduleList.length) { box.innerHTML = '
未获取到行测模块,可直接开始(将按全部错题处理)
'; return; } var allOn = selectedModules.length === 0; var html = '
' + (allOn ? icon('checkCircle') : '') + '全部模块
'; html += moduleList.map(function (m, i) { var on = selectedModules.indexOf(m.name) >= 0; return '
' + (on ? icon('checkCircle') : '') + esc(m.name) + '
'; }).join(''); box.innerHTML = html; $$('.gth-chip', box).forEach(function (chip) { chip.addEventListener('click', function () { var idx = Number(chip.dataset.idx); if (idx < 0) { selectedModules = []; } else { var name = moduleList[idx].name; var i = selectedModules.indexOf(name); if (i >= 0) selectedModules.splice(i, 1); else selectedModules.push(name); } invalidateLoaded(); renderModChips(); }); }); } function syncFilterUI() { var mode = $('#gth-mode').value; $('.gth-date-only').hidden = mode !== 'date'; $('.gth-subject-only').hidden = mode !== 'subject'; var xc = mode === 'subject' && Number($('#gth-subject').value) === SUBJ_XC; $('.gth-xc-only').hidden = !xc; if (xc && !moduleList.length) loadModules(); renderPracticeHint(); } function loadModules() { return fetchSubcategory().then(function (list) { moduleList = buildModuleOptions(list); // 结构诊断:若模块 chips 仍非预期,可据此定位真实层级 console.log('[错题助手] 行测模块:', moduleList.map(function (m) { return m.name; }), '|原始 subcategory_list:', list.map(function (c) { return c.name + ' × ' + (c.exampoint_list || []).length; }).join(' / ')); renderModChips(); }).catch(function (e) { setStatus('行测模块加载失败:' + e.message, 'err'); }); } /* ---------- 入口:注入到左侧菜单「公安专业知识」下方,并作为原生视图切换 ---------- */ var viewOn = false; // 助手生效的路由:错题页(#/error)与收藏页(#/shoucang)。两页模板同构, // 左侧菜单同样是「日期 / 行政职业能力测试 / 公安专业知识」三个二级标签。 function routeKind() { var h = location.hash || ''; if (h.indexOf('#/error') === 0) return 'error'; if (h.indexOf('#/shoucang') === 0) return 'collect'; return ''; } function isErrorRoute() { return routeKind() === 'error'; } function isCollectRoute() { return routeKind() === 'collect'; } function isListRoute() { return !!routeKind(); } // 切换助手视图:与站点原生右侧内容互斥显示,左菜单同步高亮。 // 可重复调用(AngularJS 重绘后需重新压住原生内容),因此不做「状态未变就返回」的短路。 function setView(on) { viewOn = on; var rc = $('.gongan2-container .right-content') || $('.right-content'); if (rc) { // .inner-content 带 zero-flex-* 会设置 display,必须用 !important 类隐藏 $$('.second-menu, .inner-content', rc).forEach(function (el) { el.classList.toggle('gth-hide', on); }); if (viewEl.parentNode !== rc) rc.appendChild(viewEl); } viewEl.hidden = !on; document.body.classList.toggle('gth-view-on', on); var entry = $('.gth-menu-item'); if (entry) entry.classList.toggle('active', on); if (on) { renderHistory(); updateExportHint(); } syncRail(); } function injectMenuEntry() { var menu = $('.gongan2-container .left-menu') || $('.left-menu'); var existing = $('.gth-menu-item'); var kind = routeKind(); // 非错题页 / 收藏页不注入入口,并清掉遗留的入口 if (!kind) { if (existing && existing.parentNode) existing.parentNode.removeChild(existing); return; } if (!menu) return; var label = kind === 'collect' ? '收藏助手' : '错题助手'; var html = '
' + icon('sparkles') + label + '
'; // 两页共用同一个入口按钮,路由切回来后标题要跟着换 if (existing && menu.contains(existing)) { if (existing.dataset.kind !== kind) { existing.dataset.kind = kind; existing.innerHTML = html; } return; } var items = $$('.item', menu); var anchor = null; for (var i = 0; i < items.length; i++) { if ((items[i].textContent || '').indexOf('公安专业知识') >= 0) { anchor = items[i]; break; } } if (!anchor) return; var btn = document.createElement('div'); btn.className = 'item zero-flex-ver-center gth-menu-item'; btn.dataset.kind = kind; btn.innerHTML = html; btn.title = '笔记 / 导出 / 重练 / 掌握状态'; btn.addEventListener('click', function () { setView(!viewOn); }); if (anchor.nextSibling) menu.insertBefore(btn, anchor.nextSibling); else menu.appendChild(btn); if (viewOn) btn.classList.add('active'); } // 点击原生二级标签(日期 / 行政职业能力测试 / 公安专业知识)时交还原生内容。 // 必须显式关闭:这些标签切换时不改变路由,仅靠 hashchange 感知不到; // 而 viewOn 若保持为 true,观察器会持续重新隐藏原生内容,导致二级标签打不开。 document.addEventListener('click', function (e) { if (!viewOn) return; var it = e.target && e.target.closest && e.target.closest('.left-menu .item'); if (it && !it.classList.contains('gth-menu-item')) setView(false); }); $$('.gth-tabs button').forEach(function (b) { b.addEventListener('click', function () { $$('.gth-tabs button').forEach(function (x) { x.classList.remove('on'); }); b.classList.add('on'); $$('[data-pane]').forEach(function (p) { p.hidden = p.getAttribute('data-pane') !== b.dataset.tab; }); if (b.dataset.tab === 'notes') renderNotesList(); if (b.dataset.tab === 'practice') { renderHistory(); renderPracticeHint(); } }); }); // 筛选条件一变,之前缓存的题目就作废,下次导出 / 备份会按新条件重新拉 function invalidateLoaded() { loaded.filter = null; loaded.list = []; } $('#gth-mode').addEventListener('change', function () { invalidateLoaded(); syncFilterUI(); }); $('#gth-subject').addEventListener('change', function () { invalidateLoaded(); syncFilterUI(); }); $('#gth-day').addEventListener('change', invalidateLoaded); $('#gth-order').addEventListener('change', renderPracticeHint); $('#gth-src').addEventListener('change', function () { $('#gth-src').dataset.touched = '1'; invalidateLoaded(); updateExportHint(); }); // 默认来源跟随当前页面:错题页默认错题本、收藏页默认收藏夹(用户手动改过就不再自动切) function syncSourceDefault() { var el = $('#gth-src'); if (!el || el.dataset.touched) return; var want = isCollectRoute() ? 'favorite' : 'error'; if (el.value !== want) { el.value = want; invalidateLoaded(); } } var loaded = { filter: null, list: [] }; // ---- 增量导出:本地按来源记录「已导出过的题目 id」,只导出从未导出过的题 ---- function currentSrc() { var el = $('#gth-src'); return (el && el.value) || 'error'; } function exIds(src) { return (store.exported && store.exported[src]) || {}; } function exAt(src) { return (store.exportAt && store.exportAt[src]) || 0; } function newOnes(list) { var ex = exIds(currentSrc()); return list.filter(function (q) { return !ex[q.id]; }); } function markExported(list) { var src = currentSrc(); if (!store.exported[src]) store.exported[src] = {}; list.forEach(function (q) { store.exported[src][q.id] = 1; }); store.exportAt[src] = Date.now(); saveStore(); updateExportHint(); } function updateExportHint() { var el = $('#gth-export-hint'); if (!el) return; var src = currentSrc(); var n = Object.keys(exIds(src)).length; el.textContent = n ? SRC_NAME[src] + ' 已导出 ' + n + ' 题 · 上次 ' + fmtAgo(exAt(src)) : SRC_NAME[src] + ' 尚未导出过任何题目'; } var EXPORT_PREFIX = { error: '上岸村错题_', favorite: '上岸村收藏_', both: '上岸村错题收藏_' }; function runExport(list, tag) { var fmt = $('#gth-fmt').value; var name = (EXPORT_PREFIX[currentSrc()] || '上岸村错题_') + tag + '_' + stamp(); if (fmt === 'md') download(name + '.md', exportMarkdown(list)); else if (fmt === 'csv') download(name + '.csv', exportCsv(list)); else download(name + '.xlsx', exportXlsx(list)); } // 首次导出时按当前筛选自动载入题目,省掉单独的「加载」按钮 function ensureLoaded() { if (loaded.list.length) return Promise.resolve(loaded.list); var f = readFilter(); setStatus('正在载入 ' + (SRC_NAME[f.src] || '题目') + '…'); return fetchByFilter(f).then(function (list) { loaded.filter = f; loaded.list = list; $('#gth-count').textContent = SRC_NAME[f.src] + ' · 共 ' + list.length + ' 题 · 新增 ' + newOnes(list).length + ' 题'; updateExportHint(); return list; }); } $('#gth-export-new').addEventListener('click', function () { ensureLoaded().then(function () { var list = newOnes(loaded.list); if (!list.length) { setStatus('没有新增题目(' + loaded.list.length + ' 题此前均已导出过)', ''); return; } try { runExport(list, '新增'); var src = currentSrc(), tag = SRC_NAME[src]; markExported(list); setStatus('已导出新增 ' + list.length + ' 题(' + tag + ' 累计已导出 ' + Object.keys(exIds(src)).length + ' 题)', 'ok'); } catch (e) { setStatus('导出失败:' + e.message, 'err'); } }).catch(function (e) { setStatus('载入失败:' + e.message, 'err'); }); }); $('#gth-export-all').addEventListener('click', function () { ensureLoaded().then(function () { try { runExport(loaded.list, '全量'); markExported(loaded.list); setStatus('已导出全部 ' + loaded.list.length + ' 题', 'ok'); } catch (e) { setStatus('导出失败:' + e.message, 'err'); } }).catch(function (e) { setStatus('载入失败:' + e.message, 'err'); }); }); $('#gth-reset-base').addEventListener('click', function () { var src = currentSrc(); if (!Object.keys(exIds(src)).length) { setStatus(SRC_NAME[src] + ' 的增量基线本就是空的', ''); return; } if (!confirm('重置后,下次「导出新增」会把' + SRC_NAME[src] + '当前全部题目视为新增。确定继续?')) return; store.exported[src] = {}; store.exportAt[src] = 0; saveStore(); updateExportHint(); setStatus(SRC_NAME[src] + ' 增量基线已重置', 'ok'); }); $('#gth-backup').addEventListener('click', function () { download('上岸村错题_备份全部数据_' + stamp() + '.json', exportJson(loaded.list)); setStatus('已备份全部数据(' + (loaded.list.length || '0') + ' 题 + 本地笔记与掌握记录)', 'ok'); }); $('#gth-export-notes-md').addEventListener('click', function () { var n = Object.keys(store.notes).length; if (!n) { setStatus('暂无笔记可导出', 'err'); return; } download('上岸村错题_笔记_' + stamp() + '.md', exportNotesMarkdown()); }); $('#gth-collect-open').addEventListener('click', function () { openCollect(null); // 不限题:整理全部有划线或笔记的题目 }); $('#gth-restore').addEventListener('click', function () { $('#gth-file').click(); }); $('#gth-file').addEventListener('change', function (e) { var file = e.target.files[0]; if (!file) return; var fr = new FileReader(); fr.onload = function () { try { setStatus(restoreJson(String(fr.result)), 'ok'); } catch (err) { setStatus('恢复失败:' + err.message, 'err'); } }; fr.readAsText(file); e.target.value = ''; }); // 一键清空本脚本写入浏览器 localStorage 的全部数据 $('#gth-wipe').addEventListener('click', function () { var nNote = Object.keys(store.notes).length; var nStat = Object.keys(store.mastered).length; var nExp = ['error', 'favorite', 'both'].reduce(function (a, k) { return a + Object.keys(exIds(k)).length; }, 0); var nHis = (store.history || []).length; var nRes = Object.keys(store.resume || {}).length; if (!nNote && !nStat && !nExp && !nHis && !nRes) { setStatus('本地数据已为空', ''); return; } if (!confirm( '将删除本脚本存在此浏览器中的全部数据:\n' + '· 笔记 ' + nNote + ' 条\n' + '· 掌握记录 ' + nStat + ' 条\n' + '· 答错次数统计\n' + '· 增量导出记录 ' + nExp + ' 题\n' + '· 组卷历史 ' + nHis + ' 条\n' + '· 刷题进度 ' + nRes + ' 处\n\n' + '此操作不可恢复。建议先点「备份全部数据」再清空。\n\n确定继续?' )) return; store.notes = {}; store.mastered = {}; store.wrongCount = {}; store.exported = { _v: 2, error: {}, favorite: {}, both: {} }; store.exportAt = { error: 0, favorite: 0, both: 0 }; store.history = []; store.resume = {}; saveStore(); refreshBadges(); renderPracticeHint(); renderNotesList(); renderHistory(); updateExportHint(); setStatus('已清空本地数据', 'ok'); }); var notesSearchKey = ''; var gthNoteEditing = false; // 编辑器中途打开时,任何 renderNotesList 都跳过,避免被观察器/其他保存冲掉 var openNoteBox = null; // 当前打开的笔记编辑器(单编辑器约束,防止多个框叠加) $('#gth-note-search').addEventListener('input', debounce(function (e) { notesSearchKey = e.target.value; renderNotesList(); }, 150)); function renderNotesList() { if (gthNoteEditing) return; // 编辑中途不重绘,防止正在输入的 textarea 被冲掉 var listEl = $('#gth-notes-list'); var countEl = $('#gth-note-count'); if (!listEl) return; // 笔记 tab 同时收纳「只有划线、还没写笔记」的题目,否则在答题页划的东西会无处可寻 var ids = Object.keys(store.notes); Object.keys(store.highlights).forEach(function (id) { if (getHighlights(id).length && ids.indexOf(id) < 0) ids.push(id); }); var lastAct = function (id) { var t = (store.notes[id] && store.notes[id].updated) || 0; getHighlights(id).forEach(function (h) { if ((h.at || 0) > t) t = h.at; }); return t; }; var q = notesSearchKey.trim().toLowerCase(); var filtered = ids.filter(function (id) { if (!q) return true; var n = store.notes[id] || {}; // 用户不认识题目 ID,搜索只针对笔记内容、题干快照与划线原文 if (((n.text || '') + ' ' + (n.snapshot || '')).toLowerCase().indexOf(q) >= 0) return true; return getHighlights(id).some(function (h) { return ((h.quote || '') + ' ' + (h.snap || '')).toLowerCase().indexOf(q) >= 0; }); }); countEl.textContent = (q ? filtered.length + ' / ' : '') + ids.length + ' 题'; if (!ids.length) { listEl.innerHTML = '
' + icon('bookOpen') + '
暂无笔记与划线。在错题页或答题报告里选中解析文字即可划线。
'; return; } if (!filtered.length) { listEl.innerHTML = '
' + icon('search') + '
没有匹配的内容
'; return; } listEl.innerHTML = filtered.sort(function (a, b) { return lastAct(b) - lastAct(a); }).map(function (id) { var n = store.notes[id] || {}; var hls = getHighlights(id); var subj = SUBJECT_NAME[n.subject != null ? n.subject : (hls[0] && hls[0].subject)] || '未知科目'; var dyn = masteredLabel(id); var dynCls = dyn === '已掌握' ? 'done' : (dyn.indexOf('待巩固') === 0 ? 'dyn' : ''); return '' + '
' + '
' + '' + esc(subj) + '' + '' + esc(dyn) + '' + (hls.length ? '' + icon('highlighter') + esc(String(hls.length)) + ' 划' : '') + '' + fmtAgo(lastAct(id)) + '' + '' + '' + '' + '
' + ((n.snapshot || (hls[0] && hls[0].snap)) ? '
' + esc(n.snapshot || hls[0].snap) + '
' : '') + '
' + esc(n.text || (hls.length ? '' : '(无笔记内容)')) + '
' + (hls.length ? '
' + hls.map(function (h, i) { return '
' + '' + '
' + esc(h.quote || '') + '
' + (h.note ? '
' + esc(h.note) + '
' : '') + '
' + '' + icon('x') + '
'; }).join('') + '
' : '') + '
'; }).join(''); $$('.gth-note-item', listEl).forEach(function (item) { var id = item.dataset.id; $('[data-act="edit"]', item).addEventListener('click', function () { openNoteEditor(id, item); }); $('[data-act="del"]', item).addEventListener('click', function () { var hasNote = !!store.notes[id], hasHl = countHighlights(id) > 0; var what = hasNote ? (hasHl ? '笔记和划线' : '笔记') : '划线'; if (!confirm('确定删除这道题的' + what + '?此操作不可恢复。')) return; delete store.notes[id]; delete store.highlights[id]; saveStore(); repaintHighlights(); refreshBadges(); renderNotesList(); }); $$('.gth-hlp-item .rm', item).forEach(function (rm) { rm.addEventListener('click', function () { hlRemove(id, Number(rm.parentNode.dataset.hl)); toast('已取消划线'); afterHlChange(); }); }); $$('.gth-hlp-item .t, .gth-hlp-item .n', item).forEach(function (node) { node.addEventListener('click', function () { openHlNote(id, Number(node.parentNode.dataset.hl), node.getBoundingClientRect()); }); }); }); } // 关闭已打开的编辑器(还原被隐藏的笔记正文)。单编辑器约束:任何时候只保留一个编辑框。 function closeNoteEditor(s) { if (!s) return; s.box.remove(); if (s.textEl && s.textEl.isConnected) s.textEl.style.display = ''; gthNoteEditing = false; } function openNoteEditor(id, itemEl) { // 同一道题再次点击「编辑」:聚焦已有框,不重复创建 if (openNoteBox && openNoteBox.itemEl === itemEl) { var t0 = $('textarea', openNoteBox.box); if (t0) t0.focus(); return; } closeNoteEditor(openNoteBox); // 切换到别的笔记前,先关掉上一个,避免叠加 blurEditors('panel'); // 同时收掉批注栏 / 划线的编辑器(焦点切换) gthNoteEditing = true; var n = store.notes[id] || { text: '' }; var box = document.createElement('div'); box.className = 'gth-note-edit'; box.innerHTML = '
' + '' + '
'; var ta = $('textarea', box); ta.value = n.text || ''; var textEl = $('.gth-note-text', itemEl); textEl.style.display = 'none'; itemEl.insertBefore(box, textEl.nextSibling); ta.focus(); openNoteBox = { box: box, itemEl: itemEl, textEl: textEl }; $('[data-act="cancel"]', box).addEventListener('click', function () { closeNoteEditor(openNoteBox); openNoteBox = null; }); $('[data-act="save"]', box).addEventListener('click', function () { openNoteBox = null; gthNoteEditing = false; // 解除重绘保护,否则 renderNotesList 会被拦截、列表不刷新 var newText = ta.value.trim(); if (newText) { setNote(id, newText); } else { delete store.notes[id]; saveStore(); } refreshBadges(); renderNotesList(); }); } function getAngular() { try { return window.angular || (typeof unsafeWindow !== 'undefined' && unsafeWindow.angular); } catch (e) { return null; } } // ---- 笔记批注栏:吸附在题目右侧页边距,默认只读,点击可二次编辑 ---- var itemsById = {}; // 恢复 / 清空后据此重绘批注 function renderAside(el, item) { var text = getNote(item.id); var wasEditing = el.dataset.editing; el.dataset.editing = ''; var head, body; if (text) { head = '
' + icon('pencil') + '笔记' + '
'; body = '
' + esc(text) + '
'; } else { head = '
' + icon('pencil') + '笔记
'; body = '
+ 添加笔记
'; } var html = head + body + asideHlHtml(item.id); // 幂等:内容没变就一个字都不写。观察器每次唤醒都会走到这里,而重写 innerHTML // 既会喂给 MutationObserver 形成 400ms 往复循环,又会把展开的划线清单、 // 正在编辑的批注框「一瞬间收回」。只在真的变了(或刚从编辑态退出)时才重绘 if (el.dataset.gthHtml === html && !wasEditing) return; el.dataset.gthHtml = html; var keepOpen = el.dataset.hlOpen === '1'; // 展开状态不在 HTML 里,重绘后要还原 el.innerHTML = html; if (keepOpen) { var hlList = $('.gth-aside-hl-list', el), hlT = $('.gth-aside-hl-t', el); if (hlList) hlList.classList.add('on'); var caret = hlT && $('.caret', hlT); if (caret) caret.textContent = '收起'; } var ed = $('[data-act="edit"]', el), ad = $('[data-act="add"]', el); if (ed) ed.addEventListener('click', function () { editAside(el, item); }); if (ad) ad.addEventListener('click', function () { editAside(el, item); }); bindAsideHl(el, item); } // 批注栏里的划线清单:内联展开,因为轨道容器 overflow:hidden 会裁掉浮层 function asideHlHtml(id) { var list = getHighlights(id); if (!list.length) return ''; var nr = list.filter(function (h) { return h.color === 'red'; }).length; var ny = list.length - nr; var desc = list.length + ' 条划线' + (ny && nr ? '(黄 ' + ny + ' · 红 ' + nr + ')' : ''); var rows = list.map(function (h, i) { return '
' + '' + '
' + esc(h.quote || '') + '
' + (h.note ? '
' + esc(h.note) + '
' : '') + '
' + '' + icon('x') + '
'; }).join(''); var lost = list.filter(function (h) { return h.lost; }).length; return '
' + '
' + icon('highlighter') + esc(desc) + (lost ? ' · ' + lost + ' 条已失效' : '') + '展开
' + '
' + rows + '
' + '
' + '
'; } function bindAsideHl(el, item) { var t = $('.gth-aside-hl-t', el), list = $('.gth-aside-hl-list', el); if (!t || !list) return; t.addEventListener('click', function () { var on = list.classList.toggle('on'); el.dataset.hlOpen = on ? '1' : ''; // 记住展开状态,重绘后由 renderAside 还原 var c = $('.caret', t), want = on ? '收起' : '展开'; if (c && c.textContent !== want) c.textContent = want; syncRail(); }); $$('.gth-hlp-item .t, .gth-hlp-item .n', list).forEach(function (node) { node.addEventListener('click', function () { openHlNote(item.id, Number(node.parentNode.dataset.hl), node.getBoundingClientRect()); }); }); $$('.gth-hlp-item .rm', list).forEach(function (rm) { rm.addEventListener('click', function (e) { e.stopPropagation(); hlRemove(item.id, Number(rm.parentNode.dataset.hl)); toast('已取消划线'); afterHlChange(); }); }); var cb = $('[data-act="collect"]', list); if (cb) cb.addEventListener('click', function () { openCollect([String(item.id)]); }); } function insertAtCursor(ta, text) { var s = ta.selectionStart == null ? ta.value.length : ta.selectionStart; var e = ta.selectionEnd == null ? ta.value.length : ta.selectionEnd; ta.value = ta.value.slice(0, s) + text + ta.value.slice(e); var p = s + text.length; try { ta.setSelectionRange(p, p); } catch (err) { /* 某些输入类型不支持 */ } ta.focus(); } // 收起题目批注栏里打开的编辑器(还原成只读态) function closeAsideEditor() { $$('.gth-aside[data-editing="1"]').forEach(function (a) { var it = itemsById[a.dataset.id]; if (it) renderAside(a, it); else a.dataset.editing = ''; }); } /* 焦点切换:三处笔记编辑器(题目批注栏 / 划线的批注浮层 / 笔记列表里的编辑框) 同一时刻只保留一个。否则划线的批注浮层会压住批注栏,两个 textarea 还会抢输入焦点。 keep 传本次要留下的那一个,其余全部收起 */ function blurEditors(keep) { if (keep !== 'aside') closeAsideEditor(); if (keep !== 'hlnote') closeHlNote(); if (keep !== 'hlmenu') closeHlMenu(); if (keep !== 'panel') { closeNoteEditor(openNoteBox); openNoteBox = null; } } function editAside(el, item) { blurEditors('aside'); el.dataset.editing = '1'; el.innerHTML = '
' + icon('pencil') + '编辑笔记
' + '' + '
' + '' + '' + '
' + '
'; var ta = $('textarea', el); ta.value = getNote(item.id); ta.focus(); var picker = $('[data-ins="1"]', el); $('[data-act="ins"]', el).addEventListener('click', function () { var list = getHighlights(item.id); picker.classList.toggle('on'); if (!picker.classList.contains('on')) { picker.innerHTML = ''; return; } if (!list.length) { picker.innerHTML = '
本题还没有划线
'; return; } picker.innerHTML = list.map(function (h, i) { return '
' + '' + '' + esc(h.quote || '') + '
'; }).join(''); $$('.gth-hlp-item .t', picker).forEach(function (node) { node.addEventListener('click', function () { var h = getHighlights(item.id)[Number(node.parentNode.dataset.hl)]; if (!h) return; var pre = ta.value && !/\n$/.test(ta.value) ? '\n' : ''; insertAtCursor(ta, pre + '> ' + h.quote + '\n'); }); }); }); $('[data-act="save"]', el).addEventListener('click', function () { setNote(item.id, ta.value.trim(), { snapshot: stripHtml(item.content).slice(0, 240), subject: item.content_type }); renderAside(el, item); refreshBadges(); renderNotesList(); }); $('[data-act="cancel"]', el).addEventListener('click', function () { renderAside(el, item); }); } // 只在真的变了才写 DOM。徽章的文案/图标每次都是同一份,无脑重写会不停惊动 // MutationObserver(→ refreshPageUI → 又一轮重绘) function setIf(el, prop, val) { if (!el) return; if (prop === 'hidden') { if (el.hidden !== !!val) el.hidden = !!val; return; } if (el[prop] !== val) el[prop] = val; } function refreshBadges() { $$('.gth-qbar').forEach(function (bar) { var id = bar.dataset.id; if (!id) return; var badge = $('.gth-badge', bar); var errEl = $('.gth-err', bar); var rec = store.mastered[id]; var done = isMastered(id); // 没做过也没标记过的题,未掌握没有信息量,弱化成幽灵样式;但它仍可点(点一下就标记已掌握) setIf(badge, 'className', 'gth-badge' + (done ? ' done' : (!rec ? ' ghost' : (getNote(id) ? ' has' : '')))); setIf(badge, 'textContent', done && rec.manual ? '已掌握 · 手动' : masteredLabel(id)); setIf(badge, 'title', done ? '点一下取消掌握标记' : '点一下标记为已掌握'); var n = errCountFor(id, bar.dataset.serverErr, bar.dataset.kind); if (n) { setIf(errEl, 'hidden', false); var t = errTag(n); setIf(errEl, 'className', t.cls); setIf(errEl, 'innerHTML', t.html); } else { // 收藏页里从没做过的题:不谎报「答错 1 次」。错题本里的题必然错过一次,所以那边照旧从 1 起算 setIf(errEl, 'hidden', true); setIf(errEl, 'className', 'gth-err'); setIf(errEl, 'innerHTML', ''); } }); // 恢复 / 清空本地数据后同步刷新已渲染的批注 $$('.gth-aside').forEach(function (a) { var it = itemsById[a.dataset.id]; if (it && !a.dataset.editing) renderAside(a, it); }); } function errCountFor(id, serverVal, kind) { var s = Number(serverVal) || 0; if (s) return s; var local = (store.wrongCount && store.wrongCount[id]) || 0; if (kind === 'collect') return local ? local + WRONG_BASE : 0; return local + WRONG_BASE; } // 手动掌握开关:与自动判分共用同一份 mastered 记录,只是直接把连对次数顶到阈值。 // 标记时留 manual 记号,日后重练交卷仍按自动规则走(答错清零、答对递增) function toggleMastery(id) { var rec = store.mastered[id]; if (rec && rec.streak >= MASTER_STREAK) { delete store.mastered[id]; toast('已取消掌握标记'); } else { store.mastered[id] = { streak: MASTER_STREAK, updated: Date.now(), manual: 1 }; toast('已标记为已掌握'); } saveStore(); refreshBadges(); renderNotesList(); } // 操作条的「笔记」按钮:滚到这道题,并把右侧批注栏切到编辑态 function focusNote(item) { if (viewOn) setView(false); var id = String(item.id); var aside = $('#gth-rail-in .gth-aside[data-id="' + id + '"]'); if (!aside) { toast('批注栏还没就绪,稍后再点一次'); return; } var content = railContent(), node = qNodes[id]; if (node && node.isConnected) { // 以「可见切片顶部」为基准滚动,错题页的内滚动盒与收藏页的页面滚动都适用 var base = railFrame ? railFrame.top : (content ? content.getBoundingClientRect().top : 0); var delta = node.getBoundingClientRect().top - base - 8; var sc = railScroller(content); if (sc) sc.scrollTop += delta; else window.scrollBy(0, delta); } aside.hidden = false; editAside(aside, item); var ta = aside.querySelector('textarea'); if (ta) { try { ta.focus(); } catch (e) {} } syncRail(); toast('笔记栏在题目右侧'); } /* ---------- 批注轨道:镜像题目滚动容器的位置,跟随其 scrollTop ---------- */ // 必须用子选择器:题目区里也有 .content(题干那一层),用后代选择器会在收藏页 // 误命中第一道题的题干。错题页的滚动层是 .inner-content 的直接子节点 var CONTENT_SEL = '.gongan2-container .right-content .inner-content > .content'; var qNodes = {}; // 题目 id -> ng-repeat 节点 // 错题页的列表包在 .content(height:600px;overflow:scroll)里,收藏页没有这层包裹, // 所以滚动容器要动态解析,不能写死选择器 function railContent() { return $(CONTENT_SEL) || $('.gongan2-container .right-content .inner-content') || $('.right-content .inner-content'); } // 往上找真正在滚动的那个祖先;返回 null 表示列表不自己滚、跟着页面整体滚动 function railScroller(content) { var el = content; while (el && el !== document.body && el !== document.documentElement) { if (el.scrollHeight - el.clientHeight > 4) { var ov = ''; try { ov = getComputedStyle(el).overflowY; } catch (e) {} if (ov === 'auto' || ov === 'scroll' || ov === 'overlay') return el; } el = el.parentElement; } return null; } /* 轨道的坐标模型:不猜「谁在滚」,只算「列表当前露出哪一段视口」。 错题页的列表在 600px 的内部滚动盒里,收藏页没有那层包裹、跟着页面滚, 两种情况下用同一套算法都能对齐: 可见切片 top = max(列表盒子 top, 0),bottom = min(盒子 bottom, 视口高) 批注框 top = 题目矩形 top − 可见切片 top */ var railFrame = null; // {rect, top, height} var railRaf = 0; function railVisible() { var content = railContent(); if (!content) return null; var r = content.getBoundingClientRect(); if (!r.height) return null; // 切到助手视图时原生内容被隐藏 var top = Math.max(r.top, 0); var bottom = Math.min(r.bottom, window.innerHeight); if (bottom - top < 40) return null; // 只剩一条缝时不摆东西 return { rect: r, top: top, height: bottom - top }; } // 只读布局 + 写 top,按 rAF 节流,可以挂在 scroll 上 function placeRailItems() { var inner = $('#gth-rail-in'); if (railEl.hidden || !railFrame || !inner) return; $$('.gth-aside', inner).forEach(function (a) { var node = qNodes[a.dataset.id]; if (node && node.isConnected) { a.hidden = false; a.style.top = Math.round(node.getBoundingClientRect().top - railFrame.top) + 'px'; } else { a.hidden = true; // 题目已从列表移除 / 节点还没就绪,别在轨道里留无主的「鬼影」框 } }); syncBalloons(); } function onRailScroll() { if (railEl.hidden || railRaf) return; railRaf = requestAnimationFrame(function () { railRaf = 0; var f = railVisible(); if (!f) { railEl.hidden = true; railFrame = null; return; } if (!railFrame || f.top !== railFrame.top || f.height !== railFrame.height) { // 页面整体滚动时列表盒子的 top 会变,轨道几何要跟着走 railEl.style.top = Math.round(f.top) + 'px'; railEl.style.height = Math.round(f.height) + 'px'; } railFrame = f; placeRailItems(); }); } function syncRail() { var rail = railEl, inner = $('#gth-rail-in'); if (!isListRoute() || !inner) { rail.hidden = true; railFrame = null; return; } // 滚动可能发生在内部容器(错题页的 .content),也可能发生在页面本身(收藏页)。 // capture 阶段的监听能同时收到两者,不必再猜哪个元素在滚 if (!document.body.dataset.gthRailScroll) { document.body.dataset.gthRailScroll = '1'; document.addEventListener('scroll', onRailScroll, { capture: true, passive: true }); } var content = railContent(); if (content && !content.dataset.gthRailLoad) { content.dataset.gthRailLoad = '1'; content.addEventListener('load', syncRail, true); // 图片加载后行高变化需重新对齐 } var f = railVisible(); if (!f) { rail.hidden = true; railFrame = null; return; } railFrame = f; rail.hidden = false; rail.style.left = Math.round(f.rect.right + 24) + 'px'; rail.style.top = Math.round(f.top) + 'px'; rail.style.height = Math.round(f.height) + 'px'; inner.style.transform = 'none'; // 老版本用 translate 跟随滚动,现在改按可视区算绝对坐标 placeRailItems(); } function injectListUI() { if (!isListRoute()) return; var ang = getAngular(); if (!ang) return; var railInner = $('#gth-rail-in'); if (!railInner) return; $$('[ng-repeat="item in subjectList"]').forEach(function (node) { if (node.dataset.gth) return; var item = null; try { item = ang.element(node).scope().item; } catch (e) { return; } if (!item || !item.id) return; // 防同一道题在轨道里出现多个批注栏: // 节点被站点重渲染替换(带图题目常见,图片加载/布局变化触发 digest)时,旧的 aside 会残留在轨道里。 // 用 item.id 作为稳定键去重。 railInner.querySelectorAll('.gth-aside[data-id="' + item.id + '"]').forEach(function (a) { a.remove(); }); node.dataset.gth = '1'; // 划线的定位根:paintRoot 靠它把题目 id 和 DOM 子树对上 node.setAttribute('data-gth-qid', item.id); itemsById[item.id] = item; qNodes[item.id] = node; if (!probed) { probed = true; probeFields([item]); } var bar = document.createElement('div'); bar.className = 'gth-qbar'; bar.dataset.id = item.id; bar.dataset.kind = routeKind(); bar.dataset.serverErr = serverErrCount(item); // 节点被站点重渲染替换时,旧的 qbar 还挂在原节点上。重新挂之前先清掉同题旧 qbar,避免重复 $$('.gth-qbar[data-id="' + item.id + '"]').forEach(function (b) { b.remove(); }); bar.innerHTML = '' + '' + '' + '' + ''; bar.querySelector('.gth-qbar-copy').addEventListener('click', function (e) { e.stopPropagation(); // 防止站点原有点击展开/收起等行为被误触 var btn = e.currentTarget; var it = itemsById[btn.dataset.id]; if (!it) return; var ok = copyText(copyQuestion(it)); btn.classList.add('copied'); btn.innerHTML = (ok ? icon('check') : icon('xCircle')) + (ok ? '已复制' : '复制失败'); setTimeout(function () { btn.classList.remove('copied'); btn.innerHTML = icon('copy') + '复制题目'; }, 1200); }); // 掌握状态:手动开关。站点的掌握度只由重练交卷驱动,而收藏页根本没有交卷场景, // 没有手动入口的话两处徽标就只是装饰,收藏题永远停在「未掌握」 bar.querySelector('.gth-badge').addEventListener('click', function (e) { e.stopPropagation(); toggleMastery(item.id); }); // 笔记入口:滚到这道题,并直接展开右侧批注栏的编辑器 bar.querySelector('.gth-qbar-note').addEventListener('click', function (e) { e.stopPropagation(); focusNote(item); }); var aside = document.createElement('div'); aside.className = 'gth-aside'; aside.dataset.id = item.id; renderAside(aside, item); // 操作条挂在 ng-repeat 节点的第一个子节点位置,也就是 .question-box(材料 + 题干)之前。 // 之前是 appendChild,落在解析之后,等于「整道题看完才看见控件」 node.insertBefore(bar, node.firstChild); railInner.appendChild(aside); }); refreshBadges(); } // 错题列表与左侧菜单均由 AngularJS 异步渲染,用观察器在重绘后补回 var refreshPageUI = debounce(function () { injectListUI(); injectMenuEntry(); // 原生内容被 AngularJS 重绘后会丢掉隐藏类,需要重新应用 if (viewOn) setView(true); syncRail(); repaintHighlights(); // 站点重绘后按锚点把划线重新落笔(自带幂等签名,不会往复触发) syncBalloons(); // 划线重画后,右侧页边距的批注气球要跟着重新对位 }, 400); new MutationObserver(refreshPageUI).observe(document.body, { childList: true, subtree: true }); window.addEventListener('resize', syncRail); injectListUI(); injectMenuEntry(); syncRail(); /* ================= 划线交互:选中即划 ================= */ var hlBar = document.createElement('div'); hlBar.id = 'gth-hlbar'; hlBar.innerHTML = '' + '' + '' + '' + ''; document.body.appendChild(hlBar); var pendingSel = null; var toastTimer = null; function toast(msg) { var t = $('#gth-toast'); if (!t) { t = document.createElement('div'); t.id = 'gth-toast'; document.body.appendChild(t); } t.textContent = msg; t.classList.add('on'); clearTimeout(toastTimer); toastTimer = setTimeout(function () { t.classList.remove('on'); }, 1600); } // 这些区域不允许划线:插件自己的 UI、输入控件、答题页的导航与答题卡 var HL_BLOCK_SEL = '#gth-hlbar,#gth-collect,#gth-toast,#gth-rail,.gth-aside,.gth-view,' + '#gth-quiz .gthq-top,#gth-quiz .gthq-sheet,#gth-quiz .gthq-nav,textarea,input,select'; function contextOfRange(range) { var node = range.commonAncestorContainer; if (node.nodeType === 3) node = node.parentNode; var el = node && node.nodeType === 1 ? node : null; if (el && el.closest && el.closest(HL_BLOCK_SEL)) return null; while (el) { if (el.getAttribute && el.getAttribute('data-gth-qid')) { return { root: el, qid: el.getAttribute('data-gth-qid') }; } el = el.parentElement; } return null; } // 选区必须在这里就被抓成快照:一点工具条按钮,浏览器选区就没了 function captureSelection() { var sel = window.getSelection(); if (!sel || sel.isCollapsed || !sel.rangeCount) return null; var range = sel.getRangeAt(0); var quote = String(sel.toString()); if (!quote.trim()) return null; var ctx = contextOfRange(range); if (!ctx) return null; var start = offsetInRoot(ctx.root, range.startContainer, range.startOffset); if (start < 0) return null; var end = offsetInRoot(ctx.root, range.endContainer, range.endOffset); if (end < 0 || end <= start) return null; return { quote: quote, start: start, end: end, ctx: ctx, rect: range.getBoundingClientRect() }; } // 取(必要时先建)选区对应的那条划线。已有一条完整覆盖选区的就复用,不重复建 function ensureHl(p, color) { var hits = hlOverlap(p.ctx.root, p.start, p.end, null); if (hits.length === 1) return { qid: p.ctx.qid, idx: hits[0], reused: true }; var rec = addHighlight(p.ctx.qid, p.quote, p.start, color || 'yellow'); if (!rec) return null; var list = store.highlights[p.ctx.qid] || []; return { qid: p.ctx.qid, idx: list.length - 1, reused: false }; } /* Word 式切换:对选区再点一次「同色」= 取消这段的划线;点别的颜色 = 改色 */ function toggleHl(p, color) { var same = hlOverlap(p.ctx.root, p.start, p.end, color); if (same.length) { same.sort(function (a, b) { return b - a; }).forEach(function (i) { hlRemove(p.ctx.qid, i); }); return { removed: same.length }; } var other = hlOverlap(p.ctx.root, p.start, p.end, null); if (other.length === 1) { setHighlightColor(p.ctx.qid, other[0], color); return { recolored: 1 }; } var rec = addHighlight(p.ctx.qid, p.quote, p.start, color); return rec ? { added: 1 } : null; } function hideBar() { hlBar.classList.remove('on'); pendingSel = null; } function showBar() { var p = captureSelection(); pendingSel = p; if (!p) { hlBar.classList.remove('on'); return; } hlBar.classList.add('on'); var r = p.rect, bw = hlBar.offsetWidth, bh = hlBar.offsetHeight; var top = r.top - bh - 8; if (top < 8) top = r.bottom + 8; // 顶部放不下就翻到选区下方 var left = Math.max(8, Math.min(r.left + r.width / 2 - bw / 2, window.innerWidth - bw - 8)); hlBar.style.left = left + 'px'; hlBar.style.top = top + 'px'; } hlBar.addEventListener('mousedown', function (e) { e.preventDefault(); }); // 保住选区 hlBar.addEventListener('click', function (e) { var btn = e.target.closest && e.target.closest('button'); if (!btn || !pendingSel) { hideBar(); return; } var p = pendingSel; if (btn.dataset.act === 'copy') { copyText(p.quote); toast('已复制'); hideBar(); return; } if (btn.dataset.act === 'note') { var h = ensureHl(p, 'yellow'); hideBar(); if (!h) { toast('批注失败:没能在题目里定位到这段文字'); return; } openHlNote(h.qid, h.idx, p.rect); return; } var color = btn.dataset.c === 'red' ? 'red' : 'yellow'; var r = toggleHl(p, color); hideBar(); if (!r) { toast('划线失败:没能在题目里定位到这段文字'); return; } if (r.removed) toast('已取消 ' + r.removed + ' 条划线'); else if (r.recolored) toast('已改为' + HL_LABEL[color]); else toast('已标为' + HL_LABEL[color] + (r && r.added ? '' : '')); afterHlChange(); }); document.addEventListener('mouseup', function (e) { if (e.target && e.target.closest && e.target.closest('#gth-hlbar')) return; setTimeout(showBar, 10); }); document.addEventListener('mousedown', function (e) { if (e.target && e.target.closest && e.target.closest('#gth-hlbar')) return; hideBar(); }); window.addEventListener('scroll', hideBar, true); window.addEventListener('resize', hideBar); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { hideBar(); closeHlMenu(); closeHlNote(); closeCollect(); return; } if (!e.altKey || !/^[123]$/.test(e.key)) return; var p = captureSelection(); if (!p) return; e.preventDefault(); if (e.key === '3') { var h = ensureHl(p, 'yellow'); if (!h) { toast('批注失败:没能在题目里定位到这段文字'); return; } openHlNote(h.qid, h.idx, p.rect); return; } var color = e.key === '2' ? 'red' : 'yellow'; var r = toggleHl(p, color); hideBar(); if (!r) { toast('划线失败:没能在题目里定位到这段文字'); return; } toast(r.removed ? '已取消 ' + r.removed + ' 条划线' : (r.recolored ? '已改为' + HL_LABEL[color] : '已标为' + HL_LABEL[color])); afterHlChange(); }); /* 点已划线的文字(无选区)→ 弹出这条划线的菜单:批注 / 改色 / 取消划线 */ document.addEventListener('mouseup', function (e) { var sel = window.getSelection(); if (sel && !sel.isCollapsed) return; // 有选区时归工具条处理 var mk = e.target && e.target.closest && e.target.closest('mark.gth-hl'); if (!mk) { closeHlMenu(); return; } var root = mk.closest('[data-gth-qid]'); if (!root || mk.dataset.gthI == null) return; openHlMenu(root.getAttribute('data-gth-qid'), Number(mk.dataset.gthI), mk.getBoundingClientRect()); }); /* ================= 划线的批注与取消(Word 式) ================= */ var hlMenuEl = null, hlMenuAnchor = null; var hlNoteEl = null, hlNoteCtx = null; function placeFloating(el, rect) { el.hidden = false; var bw = el.offsetWidth, bh = el.offsetHeight; var top = rect.bottom + 8; if (top + bh > window.innerHeight - 8) top = Math.max(8, rect.top - bh - 8); var left = Math.max(8, Math.min(rect.left || 0, window.innerWidth - bw - 8)); el.style.left = left + 'px'; el.style.top = top + 'px'; } function flashMark(qid, idx) { var root = $('[data-gth-qid="' + String(qid).replace(/"/g, '') + '"]'); if (!root) return; $$('mark.gth-hl[data-gth-i="' + idx + '"]', root).forEach(function (m) { m.classList.add('gth-hl-flash'); setTimeout(function () { m.classList.remove('gth-hl-flash'); }, 900); }); } function buildHlMenu() { hlMenuEl = document.createElement('div'); hlMenuEl.id = 'gth-hlmenu'; hlMenuEl.hidden = true; document.body.appendChild(hlMenuEl); hlMenuEl.addEventListener('mousedown', function (e) { e.preventDefault(); }); hlMenuEl.addEventListener('click', function (e) { var btn = e.target.closest && e.target.closest('button'); if (!btn || !hlMenuEl.dataset.q) return; var qid = hlMenuEl.dataset.q, idx = Number(hlMenuEl.dataset.i); var rect = hlMenuAnchor || { left: 0, top: 0, bottom: 0 }; if (btn.dataset.act === 'note') { closeHlMenu(); openHlNote(qid, idx, rect); return; } if (btn.dataset.act === 'del') { closeHlMenu(); hlRemove(qid, idx); toast('已取消划线'); afterHlChange(); return; } if (btn.dataset.c) { setHighlightColor(qid, idx, btn.dataset.c); closeHlMenu(); toast('已改为' + HL_LABEL[btn.dataset.c]); afterHlChange(); } }); } function openHlMenu(qid, idx, rect) { var rec = (store.highlights[qid] || [])[idx]; if (!rec) return; blurEditors('hlmenu'); // 焦点切换:先收起别的编辑器 if (!hlMenuEl) buildHlMenu(); hlMenuAnchor = rect; hlMenuEl.dataset.q = qid; hlMenuEl.dataset.i = String(idx); var txt = rec.quote || ''; if (txt.length > 26) txt = txt.slice(0, 26) + '…'; hlMenuEl.innerHTML = '
' + esc(txt) + '
' + '' + '
' + '' + '' + '
' + ''; placeFloating(hlMenuEl, rect); flashMark(qid, idx); } function closeHlMenu() { if (hlMenuEl) hlMenuEl.hidden = true; } function buildHlNote() { hlNoteEl = document.createElement('div'); hlNoteEl.id = 'gth-hlnote'; hlNoteEl.hidden = true; hlNoteEl.innerHTML = [ '
', '
' + icon('quote') + '批注', '
', '
', '', '
', '', '', '', '', '
' ].join(''); document.body.appendChild(hlNoteEl); hlNoteEl.addEventListener('click', function (e) { var btn = e.target.closest && e.target.closest('button[data-act]'); if (!btn || !hlNoteCtx) return; var act = btn.dataset.act; if (act === 'close' || act === 'cancel') { closeHlNote(); return; } var ta = $('textarea', hlNoteEl); if (act === 'save') { hlNoteSet(hlNoteCtx.qid, hlNoteCtx.idx, ta ? ta.value.trim() : ''); closeHlNote(); toast('批注已保存'); afterHlChange(); return; } if (act === 'del-note') { hlNoteSet(hlNoteCtx.qid, hlNoteCtx.idx, ''); closeHlNote(); toast('批注已删除'); afterHlChange(); } }); } function openHlNote(qid, idx, rect) { var rec = (store.highlights[qid] || [])[idx]; if (!rec) return; blurEditors('hlnote'); // 焦点切换:批注栏的笔记框同时只留一个 if (!hlNoteEl) buildHlNote(); hlNoteCtx = { qid: qid, idx: idx }; $('.gth-hln-q', hlNoteEl).innerHTML = '' + esc(rec.quote || ''); var ta = $('textarea', hlNoteEl); ta.value = rec.note || ''; placeFloating(hlNoteEl, rect || { left: 0, top: 0, bottom: 0 }); ta.focus(); flashMark(qid, idx); } function closeHlNote() { if (hlNoteEl) { hlNoteEl.hidden = true; hlNoteCtx = null; } } // 一处划线变动后,把所有「看到划线」的界面一起刷新 function afterHlChange() { refreshBadges(); renderNotesList(); $$('.gth-aside').forEach(function (a) { var it = itemsById[a.dataset.id]; if (it && !a.dataset.editing) renderAside(a, it); }); refreshQuizHl(); syncBalloons(); } function quizHlHtml(qid) { var list = getHighlights(qid); if (!list.length) return ''; return '
' + icon('highlighter') + list.length + ' 条划线
' + list.map(function (h, i) { return '
' + '' + '
' + esc(h.quote || '') + '
' + (h.note ? '
' + esc(h.note) + '
' : '') + '
' + '' + icon('x') + '
'; }).join(''); } function refreshQuizHl() { $$('#gth-quiz [data-hls]').forEach(function (box) { box.innerHTML = quizHlHtml(box.getAttribute('data-hls')); }); } // 答题报告页里的划线清单:点文字写批注,点 ✕ 取消划线 document.addEventListener('click', function (e) { if (!e.target || !e.target.closest) return; var box = e.target.closest('#gth-quiz [data-hls]'); if (!box) return; var item = e.target.closest('.gth-hlp-item'); if (!item) return; var qid = box.getAttribute('data-hls'), idx = Number(item.dataset.hl); if (e.target.closest('.rm')) { hlRemove(qid, idx); toast('已取消划线'); afterHlChange(); return; } if (e.target.closest('.t') || e.target.closest('.n')) { openHlNote(qid, idx, item.getBoundingClientRect()); } }); /* ---- 右侧页边距的批注气球:只在错题页,锚定到划线所在的那一行 ---- */ var balloonEls = {}; function syncBalloons() { var inner = $('#gth-rail-in'); // 助手视图打开 / 不在列表页时轨道没有可见切片,批注气球一并收起 if (!inner || !isListRoute() || !railFrame) return; var want = {}; Object.keys(store.highlights).forEach(function (qid) { if (!qNodes[qid]) return; // 这道题不在当前列表里,没必要摆气球(也让滚动时的开销只跟当前列表有关) (store.highlights[qid] || []).forEach(function (rec, i) { if (rec.note) want[qid + ':' + i] = { qid: qid, idx: i, rec: rec }; }); }); Object.keys(balloonEls).forEach(function (k) { if (!want[k]) { balloonEls[k].remove(); delete balloonEls[k]; } }); var rows = []; Object.keys(want).forEach(function (k) { var w = want[k], el = balloonEls[k]; if (!el) { el = document.createElement('div'); el.className = 'gth-balloon'; el.dataset.k = k; inner.appendChild(el); balloonEls[k] = el; } // 只在内容真的变了才写 innerHTML,否则会不停触发观察器造成往复重绘 var q = w.rec.quote || ''; if (q.length > 40) q = q.slice(0, 40) + '…'; var sig = (w.rec.color || 'yellow') + '|' + q + '|' + (w.rec.note || ''); if (el.dataset.sig !== sig) { el.dataset.sig = sig; el.innerHTML = '
' + '' + esc(q) + '
' + '
' + esc(w.rec.note || '') + '
'; } rows.push({ el: el, qid: w.qid, idx: w.idx }); }); if (!rows.length) return; // 用 rect 相减得到「划线相对可见切片顶部」的偏移,与滚动位置无关 rows.forEach(function (row) { row.top = null; var node = qNodes[row.qid]; if (!node || !node.isConnected) return; var mk = $('mark.gth-hl[data-gth-i="' + row.idx + '"]', node); if (!mk) return; row.top = mk.getBoundingClientRect().top - railFrame.top; row.h = row.el.offsetHeight || 60; // 先量高度,避免写 top 再读高度来回触发重排 }); rows.sort(function (a, b) { return (a.top == null ? 1e9 : a.top) - (b.top == null ? 1e9 : b.top); }); var last = -1e9; rows.forEach(function (row) { if (row.top == null) { row.el.hidden = true; return; } // 题目不在当前列表里 row.el.hidden = false; var t = Math.max(row.top, last + 8); // 简单纵向避让:紧跟上一条,不互相压住 row.el.style.top = Math.round(t) + 'px'; last = t + row.h; }); } /* ================= 一键整理为笔记 ================= */ var collectEl = null; var collectState = { qids: null, src: 'both', onlyAnalysis: true, group: 'q' }; function collectIds() { var ids = Object.keys(store.notes); Object.keys(store.highlights).forEach(function (id) { if (getHighlights(id).length && ids.indexOf(id) < 0) ids.push(id); }); if (collectState.qids) { var want = collectState.qids.map(String); ids = ids.filter(function (id) { return want.indexOf(String(id)) >= 0; }); } return ids; } function collectItems() { var out = []; collectIds().forEach(function (id) { var note = store.notes[id]; var hls = []; if (collectState.src !== 'note') { hls = getHighlights(id).filter(function (h) { return !collectState.onlyAnalysis || h.block === 'analysis'; }); } var noteText = (collectState.src === 'hl' || !note) ? '' : (note.text || ''); if (!noteText && !hls.length) return; var at = (note && note.updated) || 0; hls.forEach(function (h) { if ((h.at || 0) > at) at = h.at; }); out.push({ id: id, subject: (note && note.subject != null) ? note.subject : (hls[0] ? hls[0].subject : null), snap: (note && note.snapshot) || (hls[0] && hls[0].snap) || '', note: noteText, hls: hls, at: at }); }); out.sort(function (a, b) { return b.at - a.at; }); return out; } function tagOf(it) { return '[' + (SUBJECT_NAME[it.subject] || '题目') + ' #' + it.id + ']'; } function collectMarkdown() { var items = collectItems(); var nHl = items.reduce(function (s, it) { return s + it.hls.length; }, 0); var L = ['# 上岸村划线笔记', '', '> 生成时间:' + new Date().toLocaleString() + ' 共 ' + items.length + ' 题 ' + nHl + ' 条划线', '']; if (!items.length) { L.push('(没有可整理的内容)'); return L.join('\n'); } // 按颜色归拢:把散在各题里的红色易错点收成一节,这才是划线真正的复习价值 if (collectState.group === 'color') { var red = [], yel = [], notes = []; items.forEach(function (it) { it.hls.forEach(function (h) { (h.color === 'red' ? red : yel).push(tagOf(it) + ' ' + (h.quote || '') + (h.note ? ' (批注:' + h.note.replace(/\s*\n\s*/g, ' ') + ')' : '')); }); if (it.note) notes.push(tagOf(it) + ' ' + it.note.replace(/\s*\n\s*/g, ' ')); }); if (red.length) { L.push('## 易错(' + red.length + ' 条)'); L.push(''); red.forEach(function (s) { L.push('- ' + s); }); L.push(''); } if (yel.length) { L.push('## 重点(' + yel.length + ' 条)'); L.push(''); yel.forEach(function (s) { L.push('- ' + s); }); L.push(''); } if (notes.length) { L.push('## 我的笔记(' + notes.length + ' 条)'); L.push(''); notes.forEach(function (s) { L.push('- ' + s); }); L.push(''); } return L.join('\n'); } items.forEach(function (it, i) { L.push('## ' + (i + 1) + '. ' + tagOf(it)); L.push(''); if (it.snap) { L.push('> ' + it.snap); L.push(''); } if (it.note) { L.push('**我的笔记**'); L.push(''); L.push(it.note); L.push(''); } if (it.hls.length) { L.push('**划线摘录**'); L.push(''); it.hls.forEach(function (h) { L.push('- [' + (HL_LABEL[h.color] || '重点') + '] ' + (h.quote || '') + (h.lost ? ' (原文已变更,未能重新定位)' : '')); if (h.note) L.push(' - 批注:' + h.note.replace(/\s*\n\s*/g, ' ')); }); L.push(''); } L.push('---'); L.push(''); }); return L.join('\n'); } function collectPlain() { return collectMarkdown() .replace(/^> /gm, '') .replace(/^#{1,6} /gm, '') .replace(/\*\*/g, '') .replace(/^- \[(重点|易错)\] /gm, '$1:'); } function buildCollectEl() { collectEl = document.createElement('div'); collectEl.id = 'gth-collect'; collectEl.hidden = true; collectEl.innerHTML = [ '
', '
' + icon('highlighter') + '整理为笔记', '
', '
', '
内容
', '
仅划线
', '
仅笔记
', '
划线 + 笔记
', '
', '
分组
', '
按题目
', '
按颜色归拢
', '
', '
', '', '
', '
', '
', '
', '', '', '', '
', '
' ].join(''); document.body.appendChild(collectEl); collectEl.addEventListener('click', function (e) { if (e.target === collectEl) { closeCollect(); return; } var chip = e.target.closest && e.target.closest('.gth-chip'); if (chip) { collectState[chip.parentNode.dataset.g] = chip.dataset.v; renderCollect(); return; } var btn = e.target.closest && e.target.closest('button[data-act]'); if (!btn) return; var act = btn.dataset.act; if (act === 'close') { closeCollect(); return; } if (act === 'copy') { copyText($('#gthc-pre').textContent); toast('已复制到剪贴板'); return; } if (act === 'txt') { download('上岸村划线笔记_' + stamp() + '.txt', new Blob([collectPlain()], { type: 'text/plain;charset=utf-8' })); return; } if (act === 'md') { download('上岸村划线笔记_' + stamp() + '.md', new Blob([collectMarkdown()], { type: 'text/markdown;charset=utf-8' })); } }); $('#gthc-only-analysis').addEventListener('change', function (e) { collectState.onlyAnalysis = e.target.checked; renderCollect(); }); } function renderCollect() { if (!collectEl) return; $$('.gth-chip', collectEl).forEach(function (c) { c.classList.toggle('on', c.dataset.v === collectState[c.parentNode.dataset.g]); }); var cb = $('#gthc-only-analysis'); if (cb) { cb.checked = collectState.onlyAnalysis; cb.disabled = collectState.src === 'note'; } $('#gthc-pre').textContent = collectMarkdown(); var items = collectItems(); var nh = items.reduce(function (s, it) { return s + it.hls.length; }, 0); var nn = items.filter(function (it) { return it.note; }).length; $('#gthc-meta').textContent = items.length + ' 题 · 划线 ' + nh + ' 条' + (nn ? ' · 笔记 ' + nn + ' 条' : ''); } function openCollect(qids) { collectState.qids = (qids && qids.length) ? qids : null; if (!collectEl) buildCollectEl(); renderCollect(); collectEl.hidden = false; } function closeCollect() { if (collectEl) collectEl.hidden = true; } var quizEl = document.createElement('div'); quizEl.id = 'gth-quiz'; quizEl.hidden = true; document.body.appendChild(quizEl); var quiz = null; function shuffled(arr) { var a = arr.slice(); for (var i = a.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var t = a[i]; a[i] = a[j]; a[j] = t; } return a; } // 随机组卷:按答错次数加权抽样,错得越多越容易被抽中(Efraimidis-Spirakis) // 权值为「答错次数 + 1」,全为 0 时退化为等概率随机,与改造前一致 function weightedPick(arr, k) { var n = (k && k < arr.length) ? k : arr.length; // k=0 视为「全部」,仍按加权顺序打乱 return arr.map(function (q) { return { q: q, key: Math.pow(Math.random(), 1 / errCountOf(q)) }; }).sort(function (a, b) { return b.key - a.key; }) .slice(0, n) .map(function (x) { return x.q; }); } // ---- 顺序刷题进度(从上次继续)---- // 键 = 筛选描述 + 顺序,值 = 该题在「完整有序列表」中的下标(0 起) function resumeKey(f, order) { return filterDesc(f) + '|' + order; } function saveResume(key, idx, id, answered) { if (!key) return; store.resume[key] = { idx: idx, id: id || '', at: Date.now(), answered: answered || {} }; saveStore(); } // 优先按题目 id 定位(列表变动时下标会漂移),找不到才退回记录的下标 function resumeOffset(key, list) { var r = key && store.resume[key]; if (!r) return 0; for (var i = 0; i < list.length; i++) { if (String(list[i].id) === String(r.id)) return i; } return Math.min(Math.max(r.idx || 0, 0), list.length); } $('#gth-start').addEventListener('click', function () { var f = readFilter(); var num = Math.max(0, parseInt($('#gth-num').value, 10) || 0); var order = $('#gth-order').value; var seq = order !== 'random'; // 只有顺序刷题记录 / 续用进度 var key = seq ? resumeKey(f, order) : ''; var r = seq ? store.resume[key] : null; // 续刷时要拉到「上次位置 + 题量」,否则只取到第一页会拿不到后面的题 var want = !num ? 0 : ((r && r.idx) ? r.idx + num + 1 : num); setStatus('正在组卷…'); fetchByFilter(f, want).then(function (all) { if (!all.length) { setStatus('该条件下没有题目(来源:' + SRC_NAME[f.src] + ')', 'err'); return; } var ordered = order === 'desc' ? all.slice().reverse() : all; var offset = seq ? resumeOffset(key, ordered) : 0; var restarted = false; if (offset >= ordered.length) { // 已刷到末尾,从头再来 offset = 0; if (key) { delete store.resume[key]; saveStore(); } restarted = true; } var picked = order === 'random' ? weightedPick(ordered, num) : (num ? ordered.slice(offset, offset + num) : ordered.slice(offset)); if (!picked.length) { setStatus('没有可练习的题目', 'err'); return; } loaded.filter = f; // 供组卷历史复用同一筛选 // 顺序刷题:若上次有「未作答」的题,这次先跳到第一道未作答的题接着做 var startIdx = 0; if (seq && r && r.answered && picked.length) { for (var fu = 0; fu < picked.length; fu++) { if (!r.answered[String(picked[fu].id)]) { startIdx = fu; break; } } } var note = restarted ? '(已刷完,从头开始)' : ((offset + startIdx) ? '(从第 ' + (offset + startIdx + 1) + ' 题继续)' : ''); setStatus('组卷完成,共 ' + picked.length + ' 题' + note, 'ok'); startQuiz(picked, filterDesc(f), false, seq ? (r && r.answered) : null); if (seq) { quiz.resumeKey = key; quiz.resumeBase = offset; quiz.idx = startIdx; // 立刻记录起始位置;沿用上次已作答记录,作为本次续刷的基准 saveResume(key, offset + startIdx, picked[startIdx] && picked[startIdx].id, quiz.answeredIds); } renderQuiz(); // 用更新后的 idx 重新渲染,确保直接显示第一道未作答的题 renderPracticeHint(); }).catch(function (e) { setStatus('组卷失败:' + e.message, 'err'); }); }); // ---- 组卷历史:滚动保留最近三次 ---- function pushHistory(desc, list) { store.history.unshift({ at: Date.now(), desc: desc, n: list.length, filter: loaded.filter, ids: list.map(function (q) { return q.id; }) }); store.history = store.history.slice(0, 3); saveStore(); renderHistory(); } function renderHistory() { var box = $('#gth-history'); if (!box) return; var h = store.history || []; if (!h.length) { box.innerHTML = '
' + icon('play') + '
还没有组卷记录。设置题量后点「开始重练」即可。
'; return; } box.innerHTML = h.map(function (it, i) { return '
' + '' + esc(it.desc) + '' + '' + it.n + ' 题 · ' + fmtAgo(it.at) + '' + '' + '
'; }).join(''); $$('[data-his]', box).forEach(function (b) { b.addEventListener('click', function () { replayHistory(store.history[Number(b.dataset.his)]); }); }); } // 重练面板的引导文案:随机提示加权规则,顺序提示续刷位置并可重置 function renderPracticeHint() { var el = $('#gth-practice-hint'), btn = $('#gth-resume-reset'); if (!el) return; var order = $('#gth-order').value; if (order === 'random') { el.textContent = '随机组卷会优先抽「答错次数多」的题目'; if (btn) btn.hidden = true; return; } // r.idx 是「下次从第几题开始」的下标:中途退出=重做该题,交卷后=接着下一题 var r = store.resume[resumeKey(readFilter(), order)]; el.textContent = r ? '下次从第 ' + (r.idx + 1) + ' 题继续' : '顺序刷题会自动记录进度,下次可从上次继续'; if (btn) btn.hidden = !r; } $('#gth-resume-reset').addEventListener('click', function () { delete store.resume[resumeKey(readFilter(), $('#gth-order').value)]; saveStore(); renderPracticeHint(); setStatus('已清除该条件下的刷题进度,下次从头开始', 'ok'); }); // 按历史记录的筛选条件重新拉题,再与当时的题目 id 快照取交集 function replayHistory(h) { if (!h || !h.filter) return; setStatus('正在按历史条件重新拉取题目…'); fetchByFilter(h.filter).then(function (list) { var want = {}; (h.ids || []).forEach(function (id) { want[id] = 1; }); var picked = list.filter(function (q) { return want[q.id]; }); if (!picked.length) { setStatus('这套卷的题目已不在当前来源中,无法重练', 'err'); return; } var miss = h.ids.length - picked.length; if (miss) setStatus('已找回 ' + picked.length + '/' + h.ids.length + ' 题,其余已移出列表', 'err'); else setStatus(''); startQuiz(picked, h.desc, true); }).catch(function (e) { setStatus('重练失败:' + e.message, 'err'); }); } function startQuiz(list, desc, fromHistory, seedAnswered) { quiz = { list: list, desc: desc, idx: 0, answers: {}, submitted: false, answeredIds: seedAnswered || {}, // 续刷时沿用上次已作答记录,避免被首次渲染清空 startAt: Date.now(), timer: null }; if (!fromHistory) pushHistory(desc, list); quizEl.hidden = false; document.body.style.overflow = 'hidden'; quiz.timer = setInterval(function () { var el = $('#gth-timer'); if (el && quiz && !quiz.submitted) el.textContent = fmtTime(Date.now() - quiz.startAt); }, 1000); renderQuiz(); } function closeQuiz() { if (quiz && quiz.timer) clearInterval(quiz.timer); quiz = null; quizEl.hidden = true; quizEl.innerHTML = ''; document.body.style.overflow = ''; refreshBadges(); renderPracticeHint(); // 退出重练后刷新「上次刷到第几题」 } function toggleAnswer(id, label, multi) { var cur = quiz.answers[id] || []; if (!multi) { quiz.answers[id] = [label]; } else { var i = cur.indexOf(label); if (i >= 0) cur.splice(i, 1); else cur.push(label); quiz.answers[id] = cur.slice().sort(); } if ((quiz.answers[id] || []).length) quiz.answeredIds[id] = true; // 只要作答过就记一笔 renderQuiz(); } function answeredCount() { return Object.keys(quiz.answers).filter(function (k) { return (quiz.answers[k] || []).length > 0; }).length; } function renderQuiz() { if (!quiz) return; // 顺序刷题:实时记录刷到第几题 + 已作答集合,中途退出后可从「第一道未作答」继续 if (quiz.resumeKey && !quiz.submitted && quiz.list[quiz.idx]) { saveResume(quiz.resumeKey, quiz.resumeBase + quiz.idx, quiz.list[quiz.idx].id, quiz.answeredIds); } quizEl.innerHTML = quiz.submitted ? reportHtml() : doingHtml(); bindQuiz(); repaintHighlights(); // 报告页每道题都是新 DOM,渲染完立刻把划线画回去 } function doingHtml() { var q = quiz.list[quiz.idx]; var multi = ansKey(q.correct_answer).length > 1; var sel = quiz.answers[q.id] || []; var opts = (q.opt || []).map(function (o) { return '
' + '' + esc(o.label) + '.' + '' + wakeImgs(o.content || '') + '
'; }).join(''); var cells = quiz.list.map(function (item, i) { var cls = 'gthq-cell'; if (i === quiz.idx) cls += ' cur'; else if ((quiz.answers[item.id] || []).length) cls += ' answered'; return '
' + (i + 1) + '
'; }).join(''); return '' + '
' + '' + icon('listChecks') + '第 ' + (quiz.idx + 1) + ' / ' + quiz.list.length + ' 题' + '已答 ' + answeredCount() + '' + '' + icon('clock') + fmtTime(Date.now() - quiz.startAt) + '' + '' + '' + '' + '
' + '
' + '
' + '
' + icon('hash') + '题目ID ' + esc(q.id) + ' ' + esc(quiz.desc) + '
' + (q.material ? '
' + wakeImgs(q.material) + '
' : '') + '
' + wakeImgs(q.content || '') + '
' + '
' + (opts || '
该题型无选项
') + '
' + (multi ? '
多选题,共 ' + ansKey(q.correct_answer).length + ' 个正确选项
' : '') + '
' + '
' + cells + '
' + '
' + '' + '' + '
' + '
'; } function reportHtml() { var right = 0; var items = quiz.list.map(function (q, i) { var ans = quiz.answers[q.id] || []; var answered = ans.length > 0; var mine = ansKey(ans); var ok = answered && mine === ansKey(q.correct_answer); if (ok) right++; var opts = (q.opt || []).map(function (o) { var isRight = ansKey(q.correct_answer).indexOf(o.label) >= 0; var isMine = (quiz.answers[q.id] || []).indexOf(o.label) >= 0; var mark = isRight ? '(正确)' : (isMine ? '(你选的)' : ''); var style = isRight ? ' style="color:#16a34a;font-weight:600"' : (isMine ? ' style="color:var(--gth-destructive)"' : ''); return '' + esc(o.label) + '. ' + wakeImgs(o.content || '') + mark + ''; }).join(''); return '' + '
' + '
' + '' + (ok ? icon('checkCircle') : (answered ? icon('xCircle') : icon('warn'))) + (ok ? '正确' : (answered ? '错误' : '未作答')) + '' + '第 ' + (i + 1) + ' 题' + '·' + esc(SUBJECT_NAME[q.content_type] || '') + '' + '·#' + esc(q.id) + '' + (function () { var t = errTag(errCountOf(q)); return '' + t.html + ''; })() + '
' + (q.material ? '
' + wakeImgs(q.material) + '
' : '') + '
' + wakeImgs(q.content || '') + '
' + '
' + opts + '
' + '
你的答案:' + (mine || '未作答') + '
' + '
正确答案:' + ansKey(q.correct_answer) + '
' + (analysisOf(q) ? '
解析
' + wakeImgs(analysisOf(q)) + '
' : '') + '
' + '
' + '' + '
' + '
' + '
' + '
'; }).join(''); var pct = quiz.list.length ? Math.round(right / quiz.list.length * 100) : 0; // 错题数只统计「答过且答错」的,未作答不计入错题 var wrongCount = quiz.list.filter(function (q) { var a = quiz.answers[q.id] || []; return a.length > 0 && ansKey(a) !== ansKey(q.correct_answer); }).length; return '' + '
' + '' + icon('checkCircle') + '得分 ' + right + ' / ' + quiz.list.length + '' + '正确率 ' + pct + '%' + '' + icon('clock') + '用时 ' + fmtTime(Date.now() - quiz.startAt) + '' + '' + '' + '' + '' + '
' + '
' + (wrongCount ? '
' + icon('warn') + '错题 ' + wrongCount + ' 道
' : '') + '
' + items + '
' + '
'; } function bindQuiz() { var q = quiz.list[quiz.idx]; $$('.gthq-opt', quizEl).forEach(function (el) { el.addEventListener('click', function () { toggleAnswer(q.id, el.dataset.label, ansKey(q.correct_answer).length > 1); }); }); $$('.gthq-cell', quizEl).forEach(function (el) { el.addEventListener('click', function () { quiz.idx = Number(el.dataset.i); renderQuiz(); }); }); var prev = $('#gth-prev', quizEl); if (prev) prev.addEventListener('click', function () { if (quiz.idx > 0) { quiz.idx--; renderQuiz(); } }); var next = $('#gth-next', quizEl); if (next) next.addEventListener('click', function () { if (quiz.idx < quiz.list.length - 1) { quiz.idx++; renderQuiz(); } }); var submit = $('#gth-submit', quizEl); if (submit) submit.addEventListener('click', function () { var un = quiz.list.length - answeredCount(); if (un > 0 && !confirm('还有 ' + un + ' 题未作答,确定交卷吗?')) return; clearInterval(quiz.timer); quiz.timer = null; applyMastery(); quiz.submitted = true; // 交卷:未作答的不算「做过」。全部作答 -> 整套完成(下次从头开始); // 否则下次从「第一道未作答」继续,而不是径直跳到这套之后 if (quiz.resumeKey) { var fu = 0; for (; fu < quiz.list.length; fu++) { if (!quiz.answeredIds[String(quiz.list[fu].id)]) break; } if (fu >= quiz.list.length) saveResume(quiz.resumeKey, quiz.resumeBase + quiz.list.length, '', {}); else saveResume(quiz.resumeKey, quiz.resumeBase + fu, quiz.list[fu].id, quiz.answeredIds); } renderQuiz(); quizEl.querySelector('.gthq-main').scrollTop = 0; }); var ow = $('#gth-onlywrong', quizEl); if (ow) ow.addEventListener('click', function () { $$('#gth-report .gthq-r-item').forEach(function (el) { el.hidden = !el.classList.contains('wrong'); }); }); var al = $('#gth-all', quizEl); if (al) al.addEventListener('click', function () { $$('#gth-report .gthq-r-item').forEach(function (el) { el.hidden = false; }); }); var exit = $('#gth-exit', quizEl); if (exit) exit.addEventListener('click', closeQuiz); $$('textarea[data-note]', quizEl).forEach(function (ta) { ta.addEventListener('input', debounce(function () { var q2 = null; for (var i = 0; i < quiz.list.length; i++) if (String(quiz.list[i].id) === ta.dataset.note) { q2 = quiz.list[i]; break; } setNote(ta.dataset.note, ta.value.trim(), { snapshot: q2 ? stripHtml(q2.content || '').slice(0, 240) : '', subject: q2 ? q2.content_type : null }); }, 400)); }); // 报告页:划线清单(点文字写批注 / 点 ✕ 取消)由 document 上的委托统一处理 var cb = $('#gth-report'); if (cb) refreshQuizHl(); $$('.gthq-r-item[data-gth-qid] [data-act="collect"]', quizEl).forEach(function (btn) { var item = btn.closest('.gthq-r-item'); btn.addEventListener('click', function () { openCollect([String(item.getAttribute('data-gth-qid'))]); }); }); } // 交卷时统计对错:未作答的题目既不计入对错,也不累计答错次数、不改变掌握度 function applyMastery() { quiz.list.forEach(function (q) { var ans = quiz.answers[q.id]; if (!ans || !ans.length) return; // 未作答:直接跳过,当作「没做过」处理 var ok = ansKey(ans) === ansKey(q.correct_answer); var prev = (store.mastered[q.id] && store.mastered[q.id].streak) || 0; store.mastered[q.id] = { streak: ok ? prev + 1 : 0, updated: Date.now() }; bumpWrongCount(q, ok); }); saveStore(); } document.addEventListener('keydown', function (e) { if (quizEl.hidden) return; if (e.key === 'Escape') { closeQuiz(); return; } if (quiz && !quiz.submitted) { if (e.key === 'ArrowLeft' && quiz.idx > 0) { quiz.idx--; renderQuiz(); } if (e.key === 'ArrowRight' && quiz.idx < quiz.list.length - 1) { quiz.idx++; renderQuiz(); } } }); // 批注栏在错题页与收藏页都生效,用 body 上的标记类限定样式作用域 // (类名沿用 gth-error,改名会牵动一批 CSS 选择器,没必要) function syncRouteClass() { document.body.classList.toggle('gth-error', isListRoute()); syncSourceDefault(); } window.addEventListener('hashchange', function () { syncRouteClass(); invalidateLoaded(); // 换了路由,缓存的题目列表跟着作废 // 离开错题页 / 收藏页时收起助手视图,避免它跟着显示到别的界面上 if (!isListRoute() && viewOn) setView(false); refreshPageUI(); }); if (!getToken()) { setStatus('未检测到登录 token,请先登录站点后再使用。', 'err'); } syncRouteClass(); syncFilterUI(); renderHistory(); updateExportHint(); })();