。所以逐层往上找,每层都扫紧邻的前兄弟。
// 只看紧邻块、不读祖先的 textContent——那会把隔了几层的正文算进来,统计表就被误压成一行高。
// 停手条件是「再往上一层就出了这一屏内容区」,而不是「当前节点在内容区里」:
// 图自己那个
时必漏)。
function imgLeadNearby(el) {
var box = el.parentElement;
var host = el.closest ? el.closest(IMG_TEXT_HOST) : null;
for (var up = 0; box && box !== host && up < 4; up++) {
var n = box.previousSibling;
while (n) {
if (n.nodeType === 3) {
if (imgIsLead(String(n.nodeValue || '').replace(/\s+/g, ''))) return true;
} else if (n.nodeType === 1 && !/^BR$/.test(n.nodeName)) {
if (imgIsLead(String(n.textContent || '').replace(/\s+/g, ''))) return true;
}
n = n.previousSibling;
}
box = box.parentElement;
}
return false;
}
// 站点自己不给内容区的 img 定尺寸(样式表里 .content img / .analysis img 一条都没有),
// 所以公式图在站点原生那一屏上同样过大——这里连站点渲染的图一起管。
// 带 dataset 比对:它在 400ms 观察器的射程里,无脑重写 class 就是每轮喂一次属性变更。
function fitImgs(root) {
$$('img', root || document).forEach(function (el) {
var role = imgFitRole(el);
if (el.dataset && el.dataset.gthFit === role) return;
if (el.dataset) el.dataset.gthFit = role;
el.classList.toggle('gth-img-1line', role === '1line');
el.classList.toggle('gth-img-2line', role === '2line');
});
}
// 材料里的图是题目本身(统计表、统计图),一个像素都不许动,先于一切判据豁免。
var IMG_MATERIAL_HOST = '.gthq-material,.material';
// 往上找领起词时到此为止,别跨出这一屏内容区
var IMG_TEXT_HOST = '.gthq-material,.material,.gthq-stem,.stem,.gthq-opts,.options,' +
'.gthq-analysis,.analysis,.gth-note-text,.gth-aside,.gth-apanel';
var IMG_FORMULA_HOST = '.gthq-analysis,.analysis,.gth-note-text,.gth-aside,.gth-apanel';
var IMG_FRACTION_HOST = '.gthq-stem,.stem,.gthq-opts,.options';
var IMG_FORMULA_RATIO = 2.2;
// 细长条的窄门:独立的一行式子(本批实测框高 62–76)压到一行高没有风险,框高过这条线的
// 独立图一律放过。80 这一条只有这一批数据撑着——10-09 那批「单行式子高 124–168」会从这儿漏过去,
// 那是选出来的取舍:漏过去只是偏大,压错了是读不了。
var IMG_STRIP_MAX_H = 80;
// '1line'(与文字同大)| '2line'(分式与竖式那种两行高的式子)| ''(原样)
function imgFitRole(el) {
if (!el.closest) return '';
if (el.closest(IMG_MATERIAL_HOST)) return '';
var role = imgTextRole(el);
if (role === 'figure') return '';
if (role) return imgSizeRole(el);
// 文字说不清:先看上一段有没有领起词(「如下图所示:」「如下表:」)——有就是一张图
if (imgLeadNearby(el)) return '';
// 还没有文字也没有领起词:这枚图自己独占一块。
// 选项与题干里,竖着的才是式子(整张选项就是一张分式,用户 10-09 第二张截图),
// 横着的多半是统计表——那里放一张表是常事,原样留着。
// 解析与笔记那一屏两种都有:竖式与两行宽公式都收,统计图与表格原样。
if (!el.naturalHeight) return '';
var tall = el.naturalHeight > el.naturalWidth;
if (el.closest(IMG_FRACTION_HOST)) return tall ? imgSizeRole(el) : '';
if (!el.closest(IMG_FORMULA_HOST)) return '';
if (tall) return imgSizeRole(el);
// 细长条这一支只收「确实占两行」的那种大公式。一枚几何上只有一行的独立图,压到一行高
// 收益小、误杀代价大——图形推理那块六图(628x153)就是这么被压成 80x20 的(用户 10-11 第二轮);
// 压错了读不了,偏大只是不好看,所以这一格宁可放过。竖着那一支不受此限:那里没有「只有一行」的图形块。
var strip = el.naturalWidth >= el.naturalHeight * IMG_FORMULA_RATIO;
var size = imgSizeRole(el);
return (strip && (size === '2line' || el.naturalHeight <= IMG_STRIP_MAX_H)) ? size : '';
}
// 该缩的图按「它占几行」定高:定高而不是封顶——封顶对本来就没到顶的图是空操作,
// 110x60 那枚判成两行也照样不动,那正是旧口径里「判对了还是大」的一半来源。
// 拿不到 intrinsic 尺寸(还没加载完)时返回空串,这一轮不动它,load 事件会再扫一遍。
function imgSizeRole(el) {
var w = el.naturalWidth || 0, h = el.naturalHeight || 0;
if (!h) return '';
if (w < h * IMG_2LINE_RATIO || h > IMG_2LINE_TALL ||
(h > IMG_2LINE_MID && w < h * IMG_2LINE_MID_RATIO)) return '2line';
return '1line';
}
var fitImgsSoon = debounce(function () { fitImgs(document.body); }, 150);
// 图片加载不产生 childList 变化,400ms 观察器叫不动它;不补这一句,晚加载的公式图永远判不到
document.addEventListener('load', function (e) {
if (e.target && e.target.tagName === 'IMG') fitImgsSoon();
}, true);
var imgZoomEl = null;
function closeImgZoom() { if (imgZoomEl) imgZoomEl.hidden = true; }
function openImgZoom(src) {
if (!src) return;
if (!imgZoomEl) {
imgZoomEl = document.createElement('div');
imgZoomEl.id = 'gth-imgzoom';
imgZoomEl.innerHTML = '
';
imgZoomEl.addEventListener('click', closeImgZoom);
document.body.appendChild(imgZoomEl);
}
$('img', imgZoomEl).src = src;
imgZoomEl.hidden = false;
}
// 被压小的式子点一下看原尺寸:这是「认错了也不把人堵死」的那一步退路
// 两档都算缩过:gth-img-1line(行内式)与 gth-img-2line(分式与竖式)
document.addEventListener('click', function (e) {
var t = e.target;
if (t && t.tagName === 'IMG' &&
(t.classList.contains('gth-img-2line') || t.classList.contains('gth-img-1line'))) {
openImgZoom(t.getAttribute('src') || t.getAttribute('data-src') || '');
}
}, true);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeImgZoom();
});
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: '
',
chevronDown: '
',
chevronUp: '
',
eye: '
',
sparkles: '
',
copy: '
',
check: '
',
highlighter: '
',
underline: '
',
quote: '
',
scissors: '
',
// 重练那颗暂停键与站点那颗同源;star 是空心,正好是站点「标记」那颗 ☆ 的样子
pause: '
',
star: '
'
};
function icon(name) {
var d = LUCIDE[name];
if (!d) return '';
return '
' + d + ' ';
}
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 = {};
if (!data.history) data.history = [];
if (!data.resume) data.resume = {};
if (!data.highlights) data.highlights = {};
if (!data.mockQs) data.mockQs = {};
if (!data.mocks) data.mocks = {};
if (!data.practiced) data.practiced = {};
if (!data.qModule) data.qModule = {};
if (!data.qSrc) data.qSrc = {};
if (!data.snaps) data.snaps = {};
if (!data._v) data._v = 1;
migrateExported(data);
var hlLostCleared = migrateHlLost(data);
if (hlLostCleared) console.log('[错题助手] 已撤掉 ' + hlLostCleared + ' 条误判的「划线已失效」标记');
return data;
})();
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 };
}
EXPORT_BUCKETS.forEach(function (k) { if (!data.exported[k]) data.exported[k] = {}; });
var at = data.exportAt;
if (!at || typeof at !== 'object') {
data.exportAt = { error: Number(at) || 0, favorite: 0, both: 0, mock: 0, book: 0 };
} else {
data.exportAt = {
error: at.error || 0, favorite: at.favorite || 0, both: at.both || 0,
mock: at.mock || 0, book: at.book || 0
};
}
return data;
}
function washHlLost(highlights) {
var n = 0;
Object.keys(highlights || {}).forEach(function (qid) {
var list = highlights[qid];
if (!Array.isArray(list)) return;
list.forEach(function (rec) {
if (rec && rec.lost && rec.block === 'analysis') { delete rec.lost; delete rec.miss; n++; }
});
});
return n;
}
function migrateHlLost(data) {
if (data.hlLostMigrated) return 0;
data.hlLostMigrated = 1;
return washHlLost(data.highlights);
}
function mergeConcurrent(data) {
var fresh;
try { fresh = JSON.parse(localStorage.getItem(LS_STORE) || ''); } catch (e) { return data; }
if (!fresh || typeof fresh !== 'object' || Array.isArray(fresh)) return data;
var mine = data.mockQs || (data.mockQs = {}), theirs = fresh.mockQs || {};
Object.keys(theirs).forEach(function (k) {
var a = mine[k], b = theirs[k];
if (!a || (b && (b.at || 0) > (a.at || 0))) mine[k] = b;
});
var myMocks = data.mocks || (data.mocks = {}), theirMocks = fresh.mocks || {};
Object.keys(theirMocks).forEach(function (eid) {
var a = myMocks[eid], b = theirMocks[eid];
if (!a) { myMocks[eid] = b; return; }
if (!a.processed) a.processed = {};
Object.keys(b.processed || {}).forEach(function (qid) { a.processed[qid] = 1; });
if ((b.at || 0) > (a.at || 0)) a.at = b.at;
});
var wc = data.wrongCount || (data.wrongCount = {}), fwc = fresh.wrongCount || {};
Object.keys(fwc).forEach(function (k) { if ((wc[k] || 0) < (fwc[k] || 0)) wc[k] = fwc[k]; });
var pr = data.practiced || (data.practiced = {}), fpr = fresh.practiced || {};
Object.keys(fpr).forEach(function (k) {
var a = pr[k], b = fpr[k];
if (!a || (b && (b.n || 0) > (a.n || 0))) pr[k] = b;
});
var qm = data.qModule || (data.qModule = {}), fqm = fresh.qModule || {};
Object.keys(fqm).forEach(function (k) { if (!qm[k] && fqm[k]) qm[k] = fqm[k]; });
var qs = data.qSrc || (data.qSrc = {}), fqs = fresh.qSrc || {};
var myAt = data.qSrcAt || 0, theirAt = fresh.qSrcAt || 0;
if (theirAt > myAt) {
data.qSrc = fqs;
data.qSrcAt = theirAt;
} else if (theirAt === myAt) {
Object.keys(fqs).forEach(function (k) {
if (!qs[k]) qs[k] = fqs[k];
else if (qs[k] !== fqs[k]) qs[k] = 'both';
});
}
var sn = data.snaps || (data.snaps = {}), fsn = fresh.snaps || {};
Object.keys(fsn).forEach(function (k) {
var a = sn[k], b = fsn[k];
if (!a) { sn[k] = b; return; }
if (!a.analysis && b && b.analysis) { a.analysis = b.analysis; a.at = b.at || a.at; }
});
return data;
}
var storeRev = 0;
function saveStore() {
storeRev++;
try { localStorage.setItem(LS_STORE, JSON.stringify(mergeConcurrent(store))); }
catch (e) { alert('本地存储写入失败:' + e.message); }
}
window.addEventListener('storage', function (e) {
if (e.key !== LS_STORE || !e.newValue) return;
mergeConcurrent(store);
storeRev++;
});
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()
};
harvestPageSnap(id);
} else {
delete store.notes[id];
}
saveStore();
}
function getNote(id) { return (store.notes[id] && store.notes[id].text) || ''; }
function pageFrom() {
if (isZyAnalysis()) return 'zy';
var k = routeKind();
return k === 'collect' ? 'favorite' : (k === 'error' ? 'error' : '');
}
function harvestPageSnap(id) {
var raw = itemsById[id];
if (!raw && quiz && quiz.list) {
for (var i = 0; i < quiz.list.length; i++) {
if (String(quiz.list[i].id) === String(id)) { raw = quiz.list[i]; break; }
}
}
if (!raw) return null;
return harvestSnapshot(raw, pageFrom());
}
function snapOf(id) {
var k = String(id);
return store.snaps[k] || store.mockQs[k] || null;
}
function snapState(id) {
var q = snapOf(id);
if (!q) return 'none';
return String(analysisOf(q) || '').trim() ? 'full' : 'part';
}
function snapCopy(q) {
return normalize({
id: q.id, content: q.content, material: q.material, opt: q.opt,
correct_answer: q.correct_answer, user_answer: q.user_answer,
analysis: q.analysis, analysis_shadow: q.analysis_shadow,
content_type: q.content_type, module: q.module
});
}
function harvestSnapshot(raw, from) {
if (!raw || typeof raw !== 'object') return null;
var q = (raw.id != null && raw.content != null) ? snapCopy(raw) : mockFieldMap(raw);
if (!q || q.id == null || !String(q.content || '').trim()) {
console.warn('[错题助手] 没认出这道题的题面,只记笔记不建快照(来源页:' + from + ')');
return null;
}
var k = String(q.id), ana = String(analysisOf(q) || '');
var cur = store.snaps[k];
if (cur) {
var touched = false;
if (!cur.analysis && ana) { cur.analysis = ana; cur.at = Date.now(); touched = true; }
if (!cur.module && q.module) { cur.module = q.module; touched = true; }
if (touched) saveStore();
return cur;
}
var m = store.mockQs[k];
if (m && stripHtml(m.content) !== stripHtml(q.content)) {
console.warn('[错题助手] 题面快照与模考收录的内容不一致,补充题本里会标出来请核对:' + k);
}
store.snaps[k] = {
id: q.id, content: String(q.content || ''), material: String(q.material || ''),
opt: Array.isArray(q.opt) ? q.opt : [],
correct_answer: q.correct_answer == null ? '' : q.correct_answer,
user_answer: q.user_answer == null ? '' : q.user_answer,
analysis: ana,
content_type: (typeof q.content_type === 'number' || /^\d+$/.test(String(q.content_type)))
? Number(q.content_type) : null,
module: q.module || '', from: from || '', at: Date.now()
};
saveStore();
return store.snaps[k];
}
function inSiteLists(id) { return !!(store.qSrc && store.qSrc[id]); }
function reconciled() { return !!store.qSrcAt; }
// 题号底册:本机替用户记下过账的每一个题目 ID,只增不减(为什么、并了哪几份见 ADR 0010)。
// snaps 故意不进:孤儿快照的判定拿底册当豁免表,把快照算进去就是自指,那一档永远扫不出东西。
function seenIds() {
var out = [], have = {};
function add(id) {
var k = String(id);
if (!k || k === 'undefined' || have[k]) return;
have[k] = 1;
out.push(k);
}
noteIdsWithHl().forEach(add);
Object.keys(store.mockQs || {}).forEach(add);
Object.keys(store.qSrc || {}).forEach(add);
Object.keys(store.qModule || {}).forEach(add);
Object.keys(store.wrongCount || {}).forEach(add);
Object.keys(store.mastered || {}).forEach(add);
Object.keys(store.practiced || {}).forEach(add);
var ex = store.exported || {};
EXPORT_BUCKETS.forEach(function (src) {
Object.keys(ex[src] || {}).forEach(add);
});
(store.history || []).forEach(function (h) { ((h && h.ids) || []).forEach(add); });
return out;
}
function bookIds() {
if (!reconciled()) return [];
var ids = [];
seenIds().forEach(function (id) { if (!inSiteLists(id)) ids.push(id); });
return ids;
}
function bookQuestion(id) {
var k = String(id), s = store.snaps[k], m = store.mockQs[k], q = s || m;
if (!q) {
var n = store.notes[k] || {};
return {
id: k, content: '', material: '', opt: [], analysis: '',
correct_answer: '', user_answer: '', content_type: n.subject,
module: '', _src: 'book', _noSnap: 1, _ex: n.snapshot || ''
};
}
var out = {
id: q.id == null ? k : q.id, content: q.content || '', material: q.material || '',
opt: q.opt || [], correct_answer: q.correct_answer, user_answer: q.user_answer,
analysis: q.analysis || '', content_type: q.content_type, module: q.module || '',
_src: 'book', _conflict: !!(s && m && stripHtml(s.content) !== stripHtml(m.content)),
_part: String(analysisOf(q) || '').trim() ? 0 : 1
};
if (!s && m) out._mock = m.examId;
return out;
}
function bookList() { return bookIds().map(bookQuestion); }
// 孤儿快照 = 有题面、题号底册里却找不到它(找回来的纯错题题面靠 qSrc / qModule 进底册,所以不会被误删)
function orphanSnapIds() {
var seen = {};
seenIds().forEach(function (id) { seen[id] = 1; });
return Object.keys(store.snaps).filter(function (k) { return !seen[k]; });
}
function backfillSnaps(list) {
var n = 0;
(list || []).forEach(function (q) {
if (!q || q.id == null) return;
var k = String(q.id);
if (!store.notes[k] && !(store.highlights[k] && store.highlights[k].length)) return;
if (store.snaps[k]) return;
if (harvestSnapshot(q, 'list')) n++;
});
return n;
}
var FACE_CAP = 300;
var faceFilling = false;
// 要不回来的题(已下架、认不出科目)不重试:整轮跑完还会重排一次队列,没这张表就是死循环
var faceTried = {};
function faceGapIds(pageIds) {
var gaps = bookIds().filter(function (id) {
return snapState(id) !== 'full' && !faceTried[String(id)];
});
var taken = {}, head = [];
(pageIds || []).forEach(function (id) {
var k = String(id);
if (!taken[k] && gaps.indexOf(k) >= 0) { taken[k] = 1; head.push(k); }
});
return head.concat(gaps.filter(function (k) { return !taken[k]; })).slice(0, FACE_CAP);
}
function faceTypesOf(id) {
var s = store.notes[id] && store.notes[id].subject;
if (s === SUBJ_XC || s === SUBJ_ZY) return [String(s)];
var m = store.mockQs[id];
if (m && (m.content_type === SUBJ_XC || m.content_type === SUBJ_ZY)) return [String(m.content_type)];
return [String(SUBJ_XC), String(SUBJ_ZY)];
}
function fillOneFace(id) {
var types = faceTypesOf(id);
function tryOne(i) {
if (i >= types.length) return Promise.resolve(null);
return apiSubjectFace(id, types[i]).then(function (data) {
var q = mockFieldMap(data || {});
if (!q || !String(q.content || '').trim()) return tryOne(i + 1);
if (q.id == null) q.id = id;
return harvestSnapshot(q, 'book') || tryOne(i + 1);
})['catch'](function () { return tryOne(i + 1); });
}
return tryOne(0);
}
// 补齐题面没有按钮:摊开题本就自动要,用户不该为了看题面先点一下。
// 当前这一页排在队首,其余往后排;每跑完一批重排一次,直到 faceTried 把队列清空。
function autoFillFaces(pageIds) {
if (faceFilling || !bookViewOn) return Promise.resolve();
var ids = faceGapIds(pageIds);
if (!ids.length) return Promise.resolve();
faceFilling = true;
var n = 0, gotN = 0, partN = 0, missN = 0;
return new Promise(function (resolve) {
function step() {
if (n >= ids.length) {
faceFilling = false;
setStatus('题面补回 ' + gotN + ' 题' + (partN ? '(其中 ' + partN + ' 题站点没给解析正文)' : '') +
(missN ? ',' + missN + ' 题没要回来——可能已下架,或认不出是行测还是公专。' : '。') +
(ids.length >= FACE_CAP ? '这一轮到了 ' + FACE_CAP + ' 题的上限,还缺题面的自己排下一轮。' : ''),
missN ? '' : 'ok');
renderBookList();
resolve();
return;
}
var id = ids[n++];
setStatus('正在补齐题面 ' + n + ' / ' + ids.length + '…');
fillOneFace(id).then(function (snap) {
faceTried[String(id)] = 1;
if (snap) { gotN++; if (snapState(id) === 'part') partN++; } else missN++;
}).then(function () { setTimeout(step, 180); });
}
step();
});
}
var HL_LABEL = { yellow: '重点', red: '易错' };
var HL_PREFIX_LEN = 12;
function getHighlights(id) { return store.highlights[id] || []; }
function countHighlights(id) { return getHighlights(id).length; }
function noteIdsWithHl() {
var ids = Object.keys(store.notes);
Object.keys(store.highlights).forEach(function (id) {
if (getHighlights(id).length && ids.indexOf(id) < 0) ids.push(id);
});
return ids;
}
function hlRowHtml(h, i, noteTitle) {
return '
' +
'
' +
'
' + esc(h.quote || '') + '
' +
(h.note ? '
' +
esc(h.note) + '
' : '') +
'
' +
'
' + icon('x') + ' ';
}
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';
}
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 snapOf(qid);
}
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,.!?;:'"()()【】《》、,。!?;:""''—\-–·]/;
function normMap(s) {
var map = [], out = '';
s = String(s == null ? '' : s);
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 };
}
function norm(s) { return normMap(s).text; }
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 };
}
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';
if (idx != null) mk.dataset.gthI = String(idx);
node.parentNode.insertBefore(mk, node);
mk.appendChild(node);
made++;
});
return made;
}
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);
});
}
function hlHoldLost(rec, analysisShown) {
return rec.block === 'analysis' && !analysisShown;
}
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;
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;
var ap = $('.analysis p', root);
var analysisShown = !!(ap && (ap.textContent || '').trim().length);
list.forEach(function (rec, i) {
if (rec.edited) return;
var pos = locate(root, rec);
if (!pos) {
if (hlHoldLost(rec, analysisShown)) return;
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();
root.setAttribute('data-gth-paint', retry ? '' : sig);
}
function repaintHighlights() {
$$('[data-gth-qid]').forEach(paintRoot);
}
function qRoot(qid) { return $('[data-gth-qid="' + String(qid).replace(/"/g, '') + '"]'); }
function repaintOne(qid) {
var root = qRoot(qid);
if (root) { root.removeAttribute('data-gth-paint'); paintRoot(root); }
}
function rerenderAsides() {
$$('.gth-aside').forEach(function (a) {
var it = itemsById[a.dataset.id];
if (it && !a.dataset.editing) renderAside(a, it);
});
}
function addHighlight(qid, quote, start, color) {
var root = qRoot(qid);
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: subjectOf(it)
};
if (!store.highlights[qid]) store.highlights[qid] = [];
store.highlights[qid].push(rec);
saveStore();
if (it && it.id != null) harvestSnapshot(it, pageFrom());
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();
repaintOne(qid);
}
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();
}
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();
repaintOne(qid);
}
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;
}
var WRONG_BASE = 1;
var STUBBORN_MIN = 3;
function errCountOf(q) {
var s = serverErrCount(q);
if (s) return s;
return ((store.wrongCount && store.wrongCount[q.id]) || 0) + WRONG_BASE;
}
function errTag(n) {
var hard = n >= STUBBORN_MIN;
return {
cls: 'gth-err' + (hard ? ' stubborn' : ''),
html: (hard ? icon('warn') : icon('xCircle')) +
(hard ? '顽固错题 · ' + n + ' 次' : '答错 ' + n + ' 次')
};
}
function bumpWrongCount(q, ok) {
if (!store.wrongCount) store.wrongCount = {};
if (!ok) store.wrongCount[q.id] = (store.wrongCount[q.id] || 0) + 1;
}
function markPracticed(id) {
if (!store.practiced) store.practiced = {};
var r = store.practiced[id];
store.practiced[id] = { at: Date.now(), n: ((r && r.n) || 0) + 1 };
}
function isPracticed(id) { return !!(store.practiced && store.practiced[id]); }
var MOCK_UNCLS = '未分类';
function moduleOf(id) {
return (store.qModule && store.qModule[id]) ||
(store.mockQs[id] && store.mockQs[id].module) ||
(store.snaps[id] && store.snaps[id].module) || '';
}
function buildExamPointIndex(list) {
var byPoint = {};
(list || []).forEach(function (c) {
(c.exampoint_list || []).forEach(function (top) {
(function mark(node) {
if (byPoint[String(node.id)] == null) byPoint[String(node.id)] = top.name;
(node.children || []).forEach(mark);
})(top);
});
});
return byPoint;
}
var examPointModule = null;
function rememberModulesFromPoints(list) {
if (!examPointModule) return 0;
var n = 0;
(list || []).forEach(function (q) {
if (!q || q.id == null || q.exam_point == null) return;
var m = examPointModule[String(q.exam_point)];
if (m && rememberModule(q.id, m)) n++;
});
if (n) saveStore();
return n;
}
function sweepFacets(siteList, zyList, index) {
var zy = {}, subjects = {}, srcs = {};
(zyList || []).forEach(function (q) { if (q && q.id != null) zy[String(q.id)] = 1; });
(siteList || []).concat(zyList || []).forEach(function (q) {
if (!q || q.id == null) return;
var id = String(q.id);
if (q._src) srcs[id] = !srcs[id] || srcs[id] === q._src ? q._src : 'both';
if (zy[id]) { subjects[id] = SUBJ_ZY; return; }
if (index && index[String(q.exam_point)]) subjects[id] = SUBJ_XC;
});
return { subjects: subjects, srcs: srcs };
}
function reconcileSources(srcs, complete) {
if (!store.qSrc) store.qSrc = {};
var next = srcs || {};
if (!complete) {
Object.keys(store.qSrc).forEach(function (id) {
if (next[id] === undefined) next[id] = store.qSrc[id];
});
}
var gone = 0;
Object.keys(store.qSrc).forEach(function (id) { if (next[id] === undefined) gone++; });
store.qSrc = next;
store.qSrcAt = Date.now();
saveStore();
return gone;
}
function backfillSubject(subjects) {
var n = 0;
Object.keys(subjects || {}).forEach(function (id) {
var s = subjects[id], note = store.notes[id];
if (note && note.subject == null) { note.subject = s; n++; }
var hls = store.highlights[id];
if (hls && hls.length && hls[0].subject == null) { hls[0].subject = s; n++; }
});
if (n) saveStore();
return n;
}
function rememberModule(id, name) {
if (!name) return false;
if (!store.qModule) store.qModule = {};
var k = String(id);
if (store.qModule[k]) return false;
store.qModule[k] = name;
return true;
}
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: (/^https?:/.test(path) ? path : 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;
}
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);
}
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;
}
function fetchXingceBy(kind, 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, modTouched = false;
function next() {
if (i >= pairs.length) { if (modTouched) saveStore(); 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;
if (kind === 'error') { params.page = 0; params.page_size = 100; }
return apiGet(listPath(kind, 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);
if (rememberModule(q.id, pair.name)) modTouched = true;
}
});
return next();
});
}
return next();
});
});
}
function fetchXingce(moduleNames, onProgress) { return fetchXingceBy('error', moduleNames, onProgress); }
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);
});
}
function listPath(kind, ci) {
return 'content/' + ci.content_id + '/' + (kind === 'favorite' ? 'favorite/view' : 'error/view');
}
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); });
});
}
function fetchFavoriteXingce(moduleNames, onProgress) {
return fetchXingceBy('favorite', moduleNames, onProgress);
}
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);
}
function srcList(f) {
var raw = (Array.isArray(f.srcs) && f.srcs.length) ? f.srcs : [f.src || 'error'];
var out = [];
function push(s) { if (out.indexOf(s) < 0) out.push(s); }
raw.forEach(function (s) {
if (s === 'both') { push('error'); push('favorite'); }
else if (SRC_KEYS.indexOf(s) >= 0) push(s);
});
return out;
}
function srcLabel(srcs) {
return srcList({ srcs: srcs }).map(function (s) { return SRC_NAME[s]; }).join('+');
}
function hasSiteSrc(srcs) {
return srcList({ srcs: srcs }).some(function (s) { return s === 'error' || s === 'favorite'; });
}
function fetchByFilter(f, limit) {
var srcs = srcList(f);
var wantErr = srcs.indexOf('error') >= 0, wantFav = srcs.indexOf('favorite') >= 0;
var mods = f.mock_modules || [];
function tag(list, src) {
(list || []).forEach(function (q) { if (q) q._src = src; });
return list;
}
var site = (!wantErr && !wantFav) ? Promise.resolve([])
: (wantErr && wantFav) ? Promise.all([fetchErrors(f, limit), fetchFavorites(f)])
.then(function (r) {
var by = {}, out = [];
function take(arr, src) {
(arr || []).forEach(function (q) {
if (!q || q.id == null) return;
var k = String(q.id), p = by[k];
if (p) { if (p._src !== src) p._src = 'both'; return; }
q._src = src; by[k] = q; out.push(q);
});
}
take(r[0], 'error'); take(r[1], 'favorite');
return out;
})
: wantErr ? fetchErrors(f, limit).then(function (l) { return tag(l, 'error'); })
: fetchFavorites(f).then(function (l) { return tag(l, 'favorite'); });
return site.then(function (list) {
rememberModulesFromPoints(list);
backfillSnaps(list);
var have = {};
list.forEach(function (q) { if (q && q.id != null) have[String(q.id)] = 1; });
var mock = srcs.indexOf('mock') >= 0 ? mockAllList(mods)
: (wantErr ? mockWrongList(mods) : []);
if (srcs.indexOf('book') >= 0) mock = mock.concat(bookList());
return list.concat(mock.filter(function (q) { return !have[String(q.id)]; }));
});
}
function fetchSubcategory() {
return getCommodity()
.then(function (ci) { return apiGet('content/' + ci.content_id + '/subcategory'); })
.then(function (d) {
var list = d.subcategory_list || [];
examPointModule = buildExamPointIndex(list);
return list;
});
}
function ensureExamPointIndex() {
return examPointModule ? Promise.resolve(examPointModule) : fetchSubcategory().then(function () {
return examPointModule;
});
}
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 subjectForExport(q) {
if (q && q.content_type != null) return q.content_type;
var f = noteFacets(q && q.id);
return f.subj === 'x' ? null : Number(f.subj);
}
function toRows(list) {
return list.map(function (q, i) {
return [
i + 1,
SUBJECT_NAME[subjectForExport(q)] || '',
q._mock ? '模考#' + q._mock : (SRC_LIST_LABEL[q._src] || '练习'),
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 (q._mock) {
lines.push('> 来源:全真模考 #' + q._mock);
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: 2,
_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 || [],
mockQs: store.mockQs,
mocks: store.mocks,
practiced: store.practiced,
qModule: store.qModule,
qSrc: store.qSrc,
qSrcAt: store.qSrcAt || 0,
snaps: store.snaps
};
return new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
}
function exportNotesMarkdown(ids) {
ids = (ids || Object.keys(store.notes)).filter(function (id) {
return store.notes[id] && store.notes[id].text;
});
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; washHlLost(store.highlights); }
if (j.mastered) store.mastered = j.mastered;
if (j.wrongCount) store.wrongCount = j.wrongCount;
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;
if (j.mockQs) store.mockQs = j.mockQs;
if (j.mocks) store.mocks = j.mocks;
if (j.practiced) store.practiced = j.practiced;
if (j.qModule) store.qModule = j.qModule;
if (j.qSrc) store.qSrc = j.qSrc;
store.qSrcAt = j.qSrcAt || 0;
var snapHint = '';
if (!j.qSrcAt) snapHint += ';这份备份没有核对时间,补充题本要先与站点核对一次';
if (j.snaps) store.snaps = j.snaps;
else snapHint += ';这份备份没有题面快照,补充题本按降级显示';
saveStore();
refreshBadges();
renderNotesList();
renderHistory();
renderMockChips();
updateExportHint();
repaintHighlights();
return '已恢复笔记 ' + Object.keys(store.notes).length +
' 条,划线 ' + Object.keys(store.highlights).length + ' 题,掌握记录 ' + Object.keys(store.mastered).length +
' 条,组卷历史 ' + (store.history || []).length + ' 条' + snapHint;
}
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-ctl-h:38px;--gth-ctl-r:8px;',
'}',
'.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}',
'.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}',
'body.gth-view-on .left-menu .item.active:not(.gth-menu-item){background:#f8f8f8}',
'.gth-hide{display:none!important}',
'.gth-view{padding:0 20px 8px;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}',
'.gth-view [hidden]{display:none !important}',
'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 var(--gth-border);background:var(--gth-bg);color:var(--gth-muted);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:var(--gth-hover);color:var(--gth-fg)}',
'.gth-mini.primary{background:#f59e0b;border-color:#f59e0b;color:#fff}',
'.gth-mini.primary:hover{background:#d97706;border-color:#d97706}',
'.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:999px;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-chip .n{font-weight:600;font-size:11px;opacity:.65;margin-left:2px}',
'.gth-dd{position:relative;display:inline-block}',
'.gth-dd-btn{display:inline-flex;align-items:center;gap:6px;cursor:pointer;text-align:left;',
'min-width:132px;white-space:nowrap}',
'.gth-dd-btn span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}',
'.gth-dd-menu{position:absolute;left:0;top:calc(100% + 4px);z-index:100002;min-width:172px;',
'background:var(--gth-bg);border:1px solid var(--gth-border);border-radius:8px;padding:5px;',
'box-shadow:0 10px 28px rgba(15,23,42,.16)}',
'.gth-dd-menu[hidden]{display:none}',
'.gth-dd-opt{display:flex;align-items:center;gap:8px;padding:7px 9px;border-radius:6px;',
'font-size:13px;color:var(--gth-fg);cursor:pointer;user-select:none}',
'.gth-dd-opt:hover{background:var(--gth-hover)}',
'.gth-dd-opt input{width:15px;height:15px;margin:0;accent-color:var(--gth-primary);cursor:pointer}',
'.gth-err{display:inline-flex;align-items:center;gap:3px;font-size:11px;padding:2px 8px;border-radius:999px;',
'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:10px;margin-bottom:12px;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-sep{height:1px;background:var(--gth-border);margin:14px 0}',
'.gth-data{margin-top:24px;padding:14px 16px 13px;border:1px solid var(--gth-border);',
'border-radius:12px;background:var(--gth-subtle)}',
'.gth-data .gth-sub{margin:0 0 11px}',
'.gth-data .gth-hint{margin:11px 0 0;color:#475569}',
'.gth-data-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
'.gth-data-row .sp{flex:1}',
'.gth-q{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;',
' width:14px;height:14px;margin-left:5px;border:1px solid var(--gth-border-strong);border-radius:50%;',
' font-size:9px;font-weight:700;color:var(--gth-muted);vertical-align:2px;cursor:help}',
'.gth-btn:hover .gth-q{color:var(--gth-primary)}',
'.gth-export-meta{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:6px}',
'.gth-export-meta .gth-notes-meta:empty{display:none}',
'.gth-btn{display:inline-flex;align-items:center;justify-content:center;gap:7px;',
'height:var(--gth-ctl-h);padding:0 16px;border-radius:var(--gth-ctl-r);',
'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;white-space:nowrap;',
'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:2px}',
'.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);font-weight:600}',
'.gth-btn.primary:hover{background:var(--gth-primary-hover);border-color:var(--gth-primary-hover)}',
// 「升级后点我」那一颗:绿底白字,是这一屏上唯一一颗"点它一定有好处"的键
'.gth-btn.go{background:#16a34a;border-color:#16a34a;color:#fff;font-weight:600}',
'.gth-btn.go:hover{background:#15803d;border-color:#15803d}',
'.gth-btn.danger{color:var(--gth-destructive);border-color:var(--gth-border-strong)}',
'.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:32px;padding:0 12px;font-size:12px;gap:5px}',
'.gth-btn .gth-ic{font-size:15px}',
'.gth-tabs button:focus-visible,.gth-mini:focus-visible,.gth-qbar-btn:focus-visible,',
'.gth-his-item button:focus-visible,.gth-dd-opt:focus-within{',
'outline:2px solid var(--gth-ring);outline-offset:2px}',
KEYACT.split(',').map(function (s) { return s.trim() + ':focus-visible'; }).join(',') + '{',
'outline:2px solid var(--gth-ring);outline-offset:2px}',
'.gth-input,.gth-select{display:inline-flex;align-items:center;height:var(--gth-ctl-h);',
'padding:0 12px;border-radius:var(--gth-ctl-r);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 12px center;padding-right:32px;',
"background-image:url(\"data:image/svg+xml;utf8,
\")}",
'.gth-select::-ms-expand{display:none}',
'#gth-status,#gth-book-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,#gth-book-status.err{background:#fef2f2;color:#b91c1c;border-color:#fecaca}',
'#gth-status.ok,#gth-book-status.ok{background:#f0fdf4;color:#15803d;border-color:#bbf7d0}',
'.gth-qbar{display:flex;align-items:center;gap:8px;margin:12px 0 0;padding:0 0 8px 40px}',
'.gth-qbar [hidden]{display:none!important}',
'.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:999px;',
'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:8px;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:999px;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-h .gth-pill.mock{color:#1d4ed8;background:#eff6ff;border-color:#bfdbfe;',
'display:inline-flex;align-items:center;gap:3px}',
'.gth-note-h .gth-pill.mock .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:8px;display:flex;flex-direction:column;align-items:center;gap:8px}',
// 重练那一屏照站点专项练习页:蓝色头部 + 左 230px 答题卡栏 + 右内容列 + 底部操作条。
// 尺寸与色值抄 gongan.min.ebe4763b.css(.gongan2-header 高 60 / .sheet-box 230 / .operation-btn li span
// 27px 格 / .jiaojuan a 30px·padding 0 30px·radius 5·#f97217),不自己定一套。
'#gth-quiz{position:fixed;inset:0;z-index:100000;background:#fff;display:flex;flex-direction:column;',
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;',
'font-size:14px;color:#222221}',
'#gth-quiz[hidden]{display:none}',
'.gthq-header{background:#2178db;color:#fff;flex:0 0 auto}',
'.gthq-hc{width:1000px;max-width:100%;height:60px;margin:0 auto;padding:0 20px;box-sizing:border-box;',
'display:flex;align-items:center;gap:12px}',
'.gthq-hc .sp{flex:1}',
'.gthq-name{font-size:16px;font-weight:700}',
'.gthq-sub{font-size:12px;opacity:.85}',
'.gthq-remain{font-size:16px;min-width:200px;text-align:right}',
'.gthq-btn{height:30px;line-height:30px;padding:0 30px;border-radius:5px;border:none;cursor:pointer;',
'font-size:14px;font-family:inherit;user-select:none;background:#f97217;color:#fff}',
'.gthq-btn:hover{background:#ffa466}',
'.gthq-btn:active{background:#ef6102}',
'.gthq-btn.ghost{background:#fff;color:#2178db}',
'.gthq-btn.ghost:hover{background:#e8f1fb;color:#2178db}',
'.gthq-btn.pause{background:#f59e0b;padding:0 16px;display:inline-flex;align-items:center}',
'.gthq-btn.pause:hover{background:#fbbf24}',
'.gthq-btn.blue{background:#2178db}',
'.gthq-btn.blue:hover{background:#4a90e6}',
'.gthq-btn.warn{background:#fff;color:#f97217;border:1px solid #f97217;line-height:28px}',
'.gthq-btn.warn:hover{background:#fff7ef;color:#ef6102}',
'.gthq-body{flex:1;display:flex;min-height:0;overflow:hidden}',
'.gthq-sheet-col{width:230px;flex:0 0 auto;border-right:1px solid #e4e4e4;padding:10px;',
'box-sizing:border-box;overflow-y:auto;user-select:none}',
'.gthq-title{margin-top:20px;padding:8px;color:#0fa0ff;font-size:15px}',
'.gthq-title.done{color:#a0a0a0}',
'.gthq-cells{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap}',
'.gthq-cell{padding:4px;box-sizing:border-box;cursor:pointer;position:relative}',
'.gthq-cell i{display:block;width:27px;height:27px;line-height:27px;font-size:12px;font-style:normal;',
'text-align:center;border:1px solid #2178db;background:#fff;color:#2178db;transition:background .2s}',
'.gthq-cell.done i,.gthq-cell.right i{background:#2178db;border-color:#2178db;color:#fff}',
'.gthq-cell.bad i{background:#d77e46;border-color:#d77e46;color:#fff}',
'.gthq-cell.miss i{background:#f2f2f2;border-color:#cfcfcf;color:#9a9a9a}',
// 当前题那一格要压过对错色,所以排在它们后面
'.gthq-cell:hover i,.gthq-cell.cur i{background:#f97217;border-color:#f97217;color:#fff}',
'.gthq-cell.stressed:after{content:"";position:absolute;top:3px;right:3px;border-top:15px solid red;',
'border-left:15px solid transparent}',
'.gthq-only{display:flex;align-items:center;gap:6px;padding:8px;font-size:13px;color:#2178db;cursor:pointer}',
'.gthq-content{flex:1;min-width:0;padding:0 24px 0 20px;box-sizing:border-box;overflow-y:auto}',
'.gthq-box{padding:30px 0 10px}',
'.gthq-tips{font-size:14px;line-height:2;color:#222221}',
'.gthq-kind{font-size:14px;line-height:25px;color:#222221;position:relative}',
'.gthq-qtime{position:absolute;right:0;top:0;font-size:12px;color:#8c8c8c;display:inline-flex;align-items:center;gap:4px}',
'.gthq-foot{flex:0 0 auto;border-top:1px solid #e4e4e4}',
'.gth-qcard{background:#fff;border:none;border-radius:0;padding:0;margin:0;max-width:none;',
'box-shadow:none}',
'.gth-qcard .gthq-stem{font-size:14px;line-height:2;color:#222221;margin:0;word-break:break-all}',
'.gth-qcard .gthq-material{font-size:14px;line-height:2;color:#222221;margin:0 0 10px;word-break:break-all;',
'background:transparent;border:none;padding:0}',
'.gth-qcard .gthq-analysis{font-size:14px;line-height:2;color:#222221;margin:6px 0 0;word-break:break-all;',
'background:transparent;border:none;padding:0}',
'.gthq-material img,.gthq-stem img,.gthq-opts img,.gthq-analysis img,.gth-note-text img,.gth-apanel img,.gth-aside img{max-width:100%;height:auto;display:inline-block;vertical-align:middle;margin:2px 0;border-radius:6px}',
// 两档都**定高**,不用 max-height:封顶对本来就没到顶的图是空操作
// (110x60 那枚判成两行,max-height 压不动它,照样 60px 高)。
// gth-img-1line —— 行内式:句子里当一个字用。取 1.4em 而不是 1.15em,因为 MathJax
// 出的图自带内边距,16px 的框里墨迹只有 11–12px,比 14px 正文明显小一圈。
// gth-img-2line —— 叠行式(分式与竖式):图里的字仍与正文同大,整枚占两行高。
// 两条同为 0,1,1 特异性,排在上面那条之后,后写的才盖得住「max-width:100% + height:auto」。
'img.gth-img-1line{height:1.4em;width:auto;max-width:100%;cursor:zoom-in}',
'img.gth-img-2line{height:2.8em;width:auto;max-width:100%;cursor:zoom-in}',
'#gth-imgzoom{position:fixed;top:0;left:0;right:0;bottom:0;z-index:100002;display:flex;',
'align-items:center;justify-content:center;padding:32px;box-sizing:border-box;background:rgba(15,23,42,.72)}',
'#gth-imgzoom[hidden]{display:none}',
'#gth-imgzoom img{max-width:100%;max-height:100%;background:#fff;border-radius:8px;cursor:zoom-out}',
'.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:999px;border:1px solid var(--gth-border)}',
'.gthq-ans{margin-top:12px;font-size:14px;display:flex;flex-wrap:wrap;gap:12px}',
'.gthq-ans .ok{color:#0c0}.gthq-ans .bad{color:red}',
'.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}',
'#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:8px;',
'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:6px;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)}',
'.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;flex:0 0 auto}',
'.gth-aside-hl-t .sp{flex:1}',
'.gth-aside-hl-t .gth-caret{flex:0 0 auto;width:12px;height:12px;display:inline-flex;',
'align-items:center;justify-content:center;opacity:.7;transition:transform .15s}',
'.gth-aside-hl-list{margin-top:5px;display:none;flex-direction:column;gap:5px}',
'.gth-aside-hl-list.on{display:flex}',
'.gth-aside-hl:has(.gth-aside-hl-list.on) .gth-caret{transform:rotate(180deg)}',
'.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:8px;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}',
'#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}',
// 题号列、圆圈选项、右下角那条线:数值抄 gongan.min.ebe4763b.css 的
// .sequence-box / .choice .label .circle / .bottom-line,题本与重练报告共用这一份。
'.gth-qmain{display:flex;align-items:flex-start;gap:15px}',
'.gth-seq{width:40px;flex:0 0 auto}',
'.gth-seq-box{width:25px;height:25px;text-align:center;line-height:25px;font-size:14px;background:#b3c8e9;margin:0 auto}',
'.gth-qbody{flex:1;min-width:0;line-height:2;color:#222221;font-size:14px;word-break:break-all}',
'.gth-choice{display:flex;align-items:center;margin-top:10px}',
'.gth-opt-plain{margin-top:10px;line-height:2}',
'.gth-circle{width:18px;height:18px;border-radius:80%;border:1px solid #b2b2b2;box-sizing:border-box;flex:0 0 auto}',
'.gth-circle.right{border:5px solid #0c0}',
'.gth-circle.wrong{border:5px solid red}',
'.gth-circle.checked{border:5px solid #5188f5}',
'.gth-ctext{display:flex;align-items:flex-start;padding-left:10px;flex:1;min-width:0}',
'.gth-clab{flex:0 0 auto}',
'.gth-cval{flex:1;min-width:0}',
'.gth-bline{display:flex;align-items:center;user-select:none;margin-top:14px}',
'.gth-bline .ln{flex:1;height:1px;background:#ddd}',
'.gth-btext{color:#8c8c8c;position:relative;padding:0 30px 0 10px;font-size:14px;cursor:pointer;flex:0 0 auto}',
'.gth-btext:after{position:absolute;right:0;top:50%;margin-top:-5px;content:"";' +
'border-top:10px solid #8c8c8c;border-left:10px solid transparent;border-right:10px solid transparent}',
'.gth-btext.on:after{border-top:none;border-bottom:10px solid #8c8c8c}',
'.gth-btext .t-close{display:none}',
'.gth-btext.on .t-open{display:none}',
'.gth-btext.on .t-close{display:inline}',
'.gth-book-head{display:flex;align-items:baseline;gap:10px;margin-bottom:10px;flex-wrap:wrap}',
'.gth-book-head b{font-size:15px}',
'.gth-book-head .sub{color:var(--gth-muted);font-size:12px}',
'.gth-book-scroll{height:min(600px,calc(100vh - 300px));min-height:320px;overflow-y:auto;overflow-x:hidden;',
'box-sizing:border-box;padding-right:4px}',
'.gth-book-list{display:flex;flex-direction:column;gap:22px}',
'.gth-book-filter{margin-bottom:8px}',
'.gth-book-search{position:relative;display:inline-flex;align-items:center;flex:1;min-width:180px;max-width:340px}',
'.gth-book-search .ic-l{position:absolute;left:9px;color:var(--gth-muted);display:inline-flex;pointer-events:none}',
'.gth-book-search input{width:100%;padding-left:30px}',
'.gth-book-pager{display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap;padding:14px 0 4px}',
'.gth-book-pager button{min-width:32px;height:32px;padding:0 8px;border:1px solid var(--gth-border);',
'border-radius:6px;background:var(--gth-bg);color:var(--gth-muted);font-size:12px;cursor:pointer;font-family:inherit}',
'.gth-book-pager button:hover{background:var(--gth-hover);color:var(--gth-fg)}',
'.gth-book-pager button.on{background:var(--gth-primary);border-color:var(--gth-primary);color:var(--gth-primary-fg);font-weight:600}',
'.gth-book-pager button:disabled{opacity:.45;cursor:not-allowed}',
'.gth-book-pager .gap{color:var(--gth-muted);padding:0 2px}',
'.gth-flash{animation:gth-flash 1.6s ease-out}',
'.gth-abar{position:relative;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:6px 0 8px;font-size:12px;color:var(--gth-muted)}',
'.gth-abar-tag{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--gth-muted);padding:0 2px}',
'.gth-abar-tag.off{color:#b45309}',
'.gth-abar .sp{flex:1}',
'.gth-qtime{position:relative;display:inline-flex}',
'.gth-ztop{position:absolute;top:12px;right:24px;z-index:6;display:flex;align-items:center;gap:8px;' +
'max-width:calc(100% - 48px)}',
'.gth-ztop .gth-btn{height:28px;padding:0 9px;font-size:12px;gap:5px;border-radius:6px;' +
'border-color:rgba(15,23,42,.06);background:rgba(255,255,255,.82);color:#475569}',
'.gth-ztop .gth-btn:hover{background:#fff;border-color:var(--gth-border);color:var(--gth-fg)}',
'.gth-ztop .gth-btn .gth-ic{font-size:13px}',
'.gth-ztop .gth-abar{padding:0;flex-wrap:nowrap}',
'.gth-qtime-h{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}',
'.gth-qtime-caret{display:inline-flex;align-items:center;opacity:.6}',
'.gth-qtime-panel{display:none;position:absolute;top:calc(100% + 2px);left:0;z-index:60;box-sizing:border-box;' +
'width:min(340px,72vw);max-height:340px;overflow:auto;pointer-events:auto;' +
'border:1px solid var(--gth-border);border-left:3px solid #94a3b8;border-radius:8px;background:#fff;' +
'padding:6px 0;font-size:12px;line-height:1.7;color:#1f2937;box-shadow:0 8px 22px rgba(15,23,42,.16)}',
'.gth-qtime:hover .gth-qtime-panel,.gth-qtime:focus-within .gth-qtime-panel,.gth-qtime.on .gth-qtime-panel{display:block}',
'.gth-qtime-row{display:flex;align-items:baseline;gap:8px;padding:2px 12px}',
'.gth-qtime-row .n{flex:0 0 auto;min-width:52px;white-space:nowrap;color:var(--gth-muted);font-variant-numeric:tabular-nums}',
'.gth-qtime-row .s{flex:1 1 auto;text-align:right;font-variant-numeric:tabular-nums;color:#713f12}',
'.gth-apanel{position:absolute;top:calc(100% + 2px);left:0;z-index:60;box-sizing:border-box;',
'width:min(340px,72vw);max-height:340px;overflow:auto;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;box-shadow:0 8px 22px rgba(15,23,42,.16)}',
'.gth-apanel-h{display:flex;align-items:center;gap:5px;font-size:11px;font-weight:600;color:#a16207;margin-bottom:5px}',
'.gth-apanel-h .sp{flex:1}',
'.gth-note-area{display:flex;flex-direction:column;gap:6px}',
'.gth-note-text{white-space:pre-wrap;word-break:break-word}',
'.gth-note-empty{color:var(--gth-muted);cursor:pointer}',
'.gth-note-empty:hover{color:var(--gth-fg)}',
'.gth-apanel .gth-note-empty{color:#a16207;opacity:.75}',
'.gth-apanel .gth-note-empty:hover{opacity:1}',
'.gth-note-act{display:flex;gap:6px}',
'.gth-apanel textarea{width:100%;min-height:96px;box-sizing:border-box;border:1px solid #fde68a;',
'border-radius:6px;padding:6px 8px;font:inherit;line-height:1.7;color:inherit;background:#fff}',
'@keyframes gth-flash{0%{box-shadow:0 0 0 3px rgba(245,158,11,.55)}70%{box-shadow:0 0 0 3px rgba(245,158,11,.25)}100%{box-shadow: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:8px;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:8px;',
'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}',
'.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(''));
var viewEl = document.createElement('div');
viewEl.className = 'gth-view zero-flex-1';
viewEl.hidden = true;
viewEl.innerHTML = [
'
',
'
',
' ' + icon('download') + '导出 ',
' ' + icon('play') + '重练 ',
' ' + icon('bookOpen') + '笔记 ',
'
',
'
',
'
',
'
',
'
',
' 来源 ',
' ',
' 错题本 ',
' 收藏夹 ',
' 两者合并 ',
' 模考收录 ',
' 补充题本 ',
' ',
'
',
'
',
' 范围 ',
' ',
' 按日期 ',
' 按科目 ',
' ',
' ',
' ',
DAY_RANGES.map(function (d) { return '' + d[1] + ' '; }).join(''),
' ',
' ',
' ',
' ',
' 公安专业知识 ',
' 行政职业能力测试 ',
' ',
' ',
'
',
'
',
'
',
'
',
'
',
'
',
' ',
' Markdown(.md) ',
' Excel(.xlsx) ',
' CSV(.csv) ',
' ',
' ' + icon('download') + '导出新增 ',
' 导出全部题目 ',
'
',
'
',
' ',
' ',
' 重置增量基线 ',
'
',
'
首次导出会自动按当前来源与筛选载入题目
',
'
',
'
',
'
',
'
来源 ',
'
',
' ',
' 错题本 ',
' ',
'
',
'
题量 ',
'
',
'
',
' 随机 ',
' 原序 ',
' 倒序 ',
' ',
'
限时 ',
'
',
'
' + icon('play') + '开始重练 ',
'
',
'
',
' ',
' ',
' 从头开始 ',
'
',
'
',
'
最近组卷
',
'
',
'
',
'
',
'
',
' ' + icon('search') + ' ',
' ',
'
',
'
',
' ',
' 全部科目 ',
' 公安专业知识 ',
' 行政职业能力测试 ',
' 科目未记录 ',
' ',
' ',
' ',
' 全部来源 ',
' 练习题 ',
' 练习题 · 错题本 ',
' 练习题 · 收藏夹 ',
' 练习题 · 两处都有 ',
' 模考收录 ',
' ',
' ',
' 全部状态 ',
' 未掌握 ',
' 待巩固 ',
' 已掌握 ',
' ',
' ',
' 笔记+划线 ',
' 仅笔记 ',
' 仅划线 ',
' 两者都有 ',
' ',
'
',
'
模块取自每题自带的考点编号,脚本在载入题目时顺手登记。这批笔记题还没登记上——在「导出」或「重练」里按当前条件载入一次题目就会补上。
',
'
',
' ',
' ',
' ' + icon('highlighter') + '一键整理 ',
' ' + icon('fileText') + '导出笔记(MD) ',
'
',
'
',
'
',
'
',
'
本地数据
',
'
',
' ' + icon('database') + '备份全部数据 ',
' ' + icon('upload') + '恢复备份 ',
' ' + icon('rotate') + '升级后点我' +
' ? ',
' ',
' ' + icon('trash') + '清空本地数据 ',
' ',
'
',
'
这些数据只存在本机浏览器(localStorage)里,不上传站点。清理浏览器数据、换设备或重装系统之前,先点「备份全部数据」存一份 JSON;要还回来就用「恢复备份」选回那个文件。
',
'
',
'
',
'
'
].join('');
document.body.appendChild(viewEl);
var bookViewEl = document.createElement('div');
bookViewEl.className = 'gth-view zero-flex-1';
bookViewEl.id = 'gth-book-view';
bookViewEl.hidden = true;
bookViewEl.innerHTML = [
'
',
' ' + icon('listChecks') + '补充题本 ',
' 站点错题本与收藏夹里没有的题:写过笔记、划过线,或模考收进来的。 ',
'
',
'
',
' ',
' 全部科目 ',
' 公安专业知识 ',
' 行政职业能力测试 ',
' 科目未记录 ',
' ',
' ',
' ',
' ' + icon('search') + ' ',
' ',
' ',
' ',
' 清空筛选 ',
'
',
'
',
' ',
'
',
'
',
' ',
'
'
].join('');
document.body.appendChild(bookViewEl);
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');
var bookViewOn = false;
function activeStatus() { return bookViewOn ? ($('#gth-book-status') || statusEl) : statusEl; }
function setStatus(msg, kind) {
var el = activeStatus();
if (!msg) { el.hidden = true; el.textContent = ''; el.className = ''; return; }
el.hidden = false;
el.textContent = msg;
el.className = kind || '';
}
function busy(btn, p) {
if (!btn) return p;
btn.disabled = true;
return p.then(function (v) { btn.disabled = false; return v; },
function (e) { btn.disabled = false; throw e; });
}
var selectedModules = [];
var moduleList = [];
var modulesLoading = false;
var modulesLoaded = false;
var practiceSrcs = ['error'];
var practiceSrcTouched = false;
var selectedMockModules = [];
var SRC_KEYS = ['error', 'favorite', 'mock', 'book'];
var SRC_NAME = { error: '错题本', favorite: '收藏夹', both: '错题+收藏', mock: '模考收录', book: '补充题本' };
var SRC_LIST_LABEL = { error: '错题本', favorite: '收藏夹', both: '错题本+收藏夹', book: '补充题本' };
function filterDesc(f) {
var srcs = srcList(f);
var parts = [];
var site = srcs.filter(function (s) { return s !== 'mock'; });
if (site.length) {
parts.push(srcLabel(site) + ' · ' +
(f.mode === 'date' ? '日期:' + dayLabel(f.dayRange) : subjectDesc(f)));
}
if (srcs.indexOf('mock') >= 0) {
var mods = f.mock_modules || [];
parts.push(SRC_NAME.mock + (mods.length ? '(' + mods.join('、') + ')' : '(全部模考题)'));
}
return parts.join(' + ');
}
function dayLabel(v) {
var d = DAY_RANGES.filter(function (x) { return x[0] === String(v); })[0];
return d ? d[1] : v;
}
function subjectDesc(f) {
var s = SUBJECT_NAME[f.subject] || f.subject;
if (f.subject === SUBJ_XC) {
var names = f.module_names || [];
s += names.length ? '(' + names.join('、') + ')' : '(全部模块)';
}
return s;
}
function readFilter(pane) {
var mode = $('#gth-mode').value;
var srcs = pane === 'practice' ? practiceSrcs.slice() : [currentSrc()];
var f = { mode: mode, srcs: srcs };
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();
}
if (srcs.indexOf('mock') >= 0) f.mock_modules = selectedMockModules.slice();
return f;
}
function chipsHtml(items, sel) {
var allOn = sel.length === 0;
var html = '
' +
(allOn ? icon('checkCircle') : '') + '全部
';
return html + items.map(function (m, i) {
var on = sel.indexOf(m.name) >= 0;
return '
' +
(on ? icon('checkCircle') : '') + esc(m.name) +
(m.n == null ? '' : '' + m.n + ' ') + '
';
}).join('');
}
function bindChips(box, getItems, sel, redraw) {
$$('.gth-chip', box).forEach(function (chip) {
chip.addEventListener('click', function () {
var items = getItems();
var idx = Number(chip.dataset.idx);
if (idx < 0) sel.length = 0;
else {
var name = items[idx] && items[idx].name;
var i = sel.indexOf(name);
if (i >= 0) sel.splice(i, 1);
else if (name) sel.push(name);
}
invalidateLoaded();
redraw();
});
});
}
function renderModChips() {
var box = $('#gth-mod-chips');
if (!box) return;
if (!moduleList.length) {
box.innerHTML = '
未获取到行测模块,可直接开始(将按全部错题处理)
';
return;
}
box.innerHTML = chipsHtml(moduleList, selectedModules);
bindChips(box, function () { return moduleList; }, selectedModules, renderModChips);
}
function renderMockChips() {
var box = $('#gth-mock-chips');
if (!box) return;
var items = mockModuleOptions();
var names = items.map(function (m) { return m.name; });
for (var i = selectedMockModules.length - 1; i >= 0; i--) {
if (names.indexOf(selectedMockModules[i]) < 0) selectedMockModules.splice(i, 1);
}
if (!items.length) {
box.innerHTML = '
还没有收录过模考题:先去模考报告解析页用「收齐全场答错题」收集。
';
return;
}
box.innerHTML = chipsHtml(items, selectedMockModules);
bindChips(box, function () { return mockModuleOptions(); }, selectedMockModules, renderMockChips);
}
function activePane() {
var on = $('.gth-tabs button.on');
return on ? on.dataset.tab : 'export';
}
function syncFilterUI() {
var mode = $('#gth-mode').value;
var pane = activePane();
$('#gth-shared').hidden = pane === 'notes' || pane === 'book';
$('#gth-src-row').hidden = pane === 'practice';
var srcs = srcList({ srcs: pane === 'practice' ? practiceSrcs : [currentSrc()] });
var siteOn = hasSiteSrc(pane === 'practice' ? practiceSrcs : [currentSrc()]);
$('#gth-scope-row').hidden = !siteOn;
$('.gth-date-only').hidden = !(siteOn && mode === 'date');
$('.gth-subject-only').hidden = !(siteOn && mode === 'subject');
var xc = siteOn && mode === 'subject' && Number($('#gth-subject').value) === SUBJ_XC;
$('.gth-xc-only').hidden = !xc;
if (xc && !moduleList.length && !modulesLoading && !modulesLoaded) loadModules();
$('#gth-mock-chiprow').hidden = srcs.indexOf('mock') < 0;
if (srcs.indexOf('mock') >= 0) renderMockChips();
renderPracticeHint();
}
function loadModules() {
modulesLoading = true;
return fetchSubcategory().then(function (list) {
modulesLoading = false;
modulesLoaded = true;
moduleList = buildModuleOptions(list);
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) {
modulesLoading = false;
setStatus('行测模块加载失败:' + e.message, 'err');
});
}
function renderPracticeSrc() {
var label = $('#gth-src-dd-label');
if (!label) return;
label.textContent = practiceSrcs.length
? practiceSrcs.map(function (s) { return SRC_NAME[s]; }).join('+')
: '选择来源';
$$('#gth-src-dd-menu input').forEach(function (cb) {
cb.checked = practiceSrcs.indexOf(cb.dataset.src) >= 0;
});
}
function checkedPracticeSrcs() {
return $$('#gth-src-dd-menu input').filter(function (cb) { return cb.checked; })
.map(function (cb) { return cb.dataset.src; });
}
$('#gth-src-dd-btn').addEventListener('click', function (e) {
e.stopPropagation();
var menu = $('#gth-src-dd-menu');
menu.hidden = !menu.hidden;
});
document.addEventListener('click', function (e) {
var dd = $('#gth-src-dd'), menu = $('#gth-src-dd-menu');
if (dd && menu && !menu.hidden && !dd.contains(e.target)) menu.hidden = true;
});
document.addEventListener('keydown', function (e) {
var menu = $('#gth-src-dd-menu');
if (e.key === 'Escape' && menu && !menu.hidden) menu.hidden = true;
});
$$('#gth-src-dd-menu input').forEach(function (cb) {
cb.addEventListener('change', function () {
practiceSrcTouched = true;
practiceSrcs = checkedPracticeSrcs();
invalidateLoaded();
renderPracticeSrc();
syncFilterUI();
});
});
var notesFilter = { subj: '', mod: '', src: '', mast: '', cont: '' };
var notesShownIds = [];
function notesNarrowed() {
return !!(notesFilter.subj || notesFilter.mod || notesFilter.src ||
notesFilter.mast || notesFilter.cont || notesSearchKey.trim());
}
function readNotesFilter() {
notesFilter.subj = $('#gth-nf-subj').value;
notesFilter.mod = $('#gth-nf-mod').value;
notesFilter.src = $('#gth-nf-src').value;
notesFilter.mast = $('#gth-nf-mast').value;
notesFilter.cont = $('#gth-nf-cont').value;
}
function masteryKey(id) {
var l = masteredLabel(id);
return l === '已掌握' ? 'done' : (l.indexOf('待巩固') === 0 ? 'dyn' : 'un');
}
function noteFacets(id) {
var n = store.notes[id] || {};
var hls = getHighlights(id);
var subj = n.subject != null ? n.subject : (hls[0] && hls[0].subject);
return {
subj: subj == null ? 'x' : String(subj),
mod: moduleOf(id) || MOCK_UNCLS,
src: store.mockQs[id] ? 'mock' : ((store.qSrc && store.qSrc[id]) || 'site'),
mast: masteryKey(id),
hasNote: !!n.text,
hasHl: hls.length > 0
};
}
function noteMatch(r, nf) {
if (nf.subj && r.subj !== nf.subj) return false;
if (nf.mod && r.mod !== nf.mod) return false;
if (nf.src && !(nf.src === 'site' ? r.src !== 'mock' : r.src === nf.src)) return false;
if (nf.mast && r.mast !== nf.mast) return false;
if (nf.cont === 'note' && !r.hasNote) return false;
if (nf.cont === 'hl' && !r.hasHl) return false;
if (nf.cont === 'both' && !(r.hasNote && r.hasHl)) return false;
return true;
}
function renderNoteFilterMods(ids) {
var sel = $('#gth-nf-mod');
if (!sel) return;
var names = {}, hasUnknown = false;
ids.forEach(function (id) {
var m = moduleOf(id);
if (m) names[m] = 1; else hasUnknown = true;
});
sel.innerHTML = '
全部模块 ' +
Object.keys(names).sort().map(function (n) {
return '
' + esc(n) + ' ';
}).join('') +
(hasUnknown ? '
模块未记录 ' : '');
sel.value = notesFilter.mod;
notesFilter.mod = sel.value;
setIf($('#gth-nf-mod-hint'), 'hidden', !ids.length || Object.keys(names).length > 0);
}
['gth-nf-subj', 'gth-nf-mod', 'gth-nf-src', 'gth-nf-mast', 'gth-nf-cont'].forEach(function (id) {
$('#' + id).addEventListener('change', function () { readNotesFilter(); renderNotesList(); });
});
var viewOn = false;
var panelRev = -1;
function routeKind() {
var h = location.hash || '';
if (h.indexOf('#/error') === 0) return 'error';
if (h.indexOf('#/shoucang') === 0) return 'collect';
return '';
}
function isCollectRoute() { return routeKind() === 'collect'; }
function isListRoute() { return !!routeKind(); }
function applyChrome() {
var any = viewOn || bookViewOn;
var rc = $('.gongan2-container .right-content') || $('.right-content');
if (rc) {
$$('.second-menu, .inner-content', rc).forEach(function (el) { el.classList.toggle('gth-hide', any); });
if (viewEl.parentNode !== rc) rc.appendChild(viewEl);
if (bookViewEl.parentNode !== rc) rc.appendChild(bookViewEl);
} else if (any) {
// 只在真要摊开某一屏时说:两块都关着时 applyChrome 每轮都跑,那时没有宿主不是缺陷
sayOnce('chrome', '要摊开视图却找不到站点内容列宿主(.right-content)——这一屏画不出来');
}
viewEl.hidden = !viewOn;
bookViewEl.hidden = !bookViewOn;
document.body.classList.toggle('gth-view-on', any);
var h = document.getElementById('gth-menu-helper'), b = document.getElementById('gth-menu-book');
if (h) h.classList.toggle('active', viewOn);
if (b) b.classList.toggle('active', bookViewOn);
}
function renderPanel() {
panelRev = storeRev;
renderHistory();
updateExportHint();
}
function setView(on) {
if (on) bookViewOn = false;
viewOn = on;
applyChrome();
if (on) {
renderPanel();
syncFilterUI();
}
syncRail();
}
function setBookView(on) {
if (on) viewOn = false;
bookViewOn = on;
applyChrome();
if (on) {
bookPage = 1;
renderBookList();
} else {
$$('.gth-aside[data-book="1"]').forEach(function (a) { a.remove(); });
injectListUI();
}
syncRail();
}
function addMenuEntry(menu, id, html, tip, onClick, active) {
var btn = document.getElementById(id);
if (!btn) {
btn = document.createElement('div');
btn.className = 'item zero-flex-ver-center gth-menu-item';
btn.id = id;
btn.innerHTML = html;
btn.title = tip;
btn.addEventListener('click', onClick);
// 落点只认「站点自己那几项里的最后一项」,不认科目名:2026-10-12 实测 error.template.html 与
// shoucang.template.html 里「公安专业知识」整块被注释掉了(注释外 0 次),锚在名字上
// 等于两条列表路由上两颗入口一起消失,而脚本自己一声不响。
var items = $$('.item', menu), anchor = null;
for (var i = 0; i < items.length; i++) {
if (items[i].parentNode === menu && !items[i].classList.contains('gth-menu-item')) {
anchor = items[i];
}
}
var mine = document.getElementById('gth-menu-helper');
// 题本紧跟在助手后面;助手坐在站点最后一项之后。ref 为 null 就是菜单末尾,用 appendChild。
var ref = (id === 'gth-menu-book' && mine) ? mine.nextSibling : (anchor ? anchor.nextSibling : null);
if (ref) menu.insertBefore(btn, ref);
else menu.appendChild(btn);
}
btn.classList.toggle('active', !!active);
return btn;
}
function injectMenuEntry() {
var menu = $('.gongan2-container .left-menu') || $('.left-menu');
var kind = routeKind();
if (!kind) {
$$('.gth-menu-item').forEach(function (el) {
if (el.parentNode) el.parentNode.removeChild(el);
});
return;
}
if (!menu) {
sayOnce('menu', '列表路由上找不到左菜单宿主(.left-menu)——两颗入口这一轮挂不上,观察器下一轮再试');
return;
}
addMenuEntry(menu, 'gth-menu-helper', '
' + icon('sparkles') + '机考助手
',
'笔记 / 导出 / 重练 / 掌握状态', function () { setView(!viewOn); }, viewOn);
addMenuEntry(menu, 'gth-menu-book', '
' + icon('listChecks') + '补充题本
',
'站点没收进来的题:本机记过的都算(笔记、划线、答错、导出、组卷、模考收录)', function () { setBookView(!bookViewOn); }, bookViewOn);
}
document.addEventListener('click', function (e) {
if (!viewOn && !bookViewOn) return;
var it = e.target && e.target.closest && e.target.closest('.left-menu .item');
if (it && !it.classList.contains('gth-menu-item')) { setView(false); setBookView(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();
syncFilterUI();
});
});
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();
syncFilterUI();
});
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(); updateExportHint(); }
if (!practiceSrcTouched && practiceSrcs.join() !== want) {
practiceSrcs = [want];
invalidateLoaded();
renderPracticeSrc();
}
}
var loaded = { filter: null, list: [] };
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: '上岸村错题收藏_',
mock: '上岸村模考收录_', book: '上岸村补充题本_'
};
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('export');
setStatus('正在载入 ' + (srcLabel(f.srcs) || '题目') + '…');
return fetchByFilter(f).then(function (list) {
loaded.filter = f;
loaded.list = list;
$('#gth-count').textContent = srcLabel(f.srcs) + ' · 共 ' + list.length +
' 题 · 新增 ' + newOnes(list).length + ' 题';
updateExportHint();
return list;
});
}
$('#gth-export-new').addEventListener('click', function () {
var btn = this;
busy(btn, 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 () {
var btn = this;
busy(btn, 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 ids = notesNarrowed() ? notesShownIds.slice() : null;
if (ids && !ids.length) { setStatus('当前条件下没有笔记可导出', 'err'); return; }
if (!ids && !Object.keys(store.notes).length) { setStatus('暂无笔记可导出', 'err'); return; }
download('上岸村错题_笔记_' + stamp() + '.md', exportNotesMarkdown(ids));
});
$('#gth-collect-open').addEventListener('click', function () {
if (!notesNarrowed()) { openCollect(null); return; }
if (!notesShownIds.length) { setStatus('当前条件下没有可整理的内容', 'err'); return; }
openCollect(notesShownIds.slice());
});
$('#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 = '';
});
$('#gth-wipe').addEventListener('click', function () {
var nNote = Object.keys(store.notes).length;
var nStat = Object.keys(store.mastered).length;
var nExp = EXPORT_BUCKETS.reduce(function (a, k) {
return a + Object.keys(exIds(k)).length;
}, 0);
var nHis = (store.history || []).length;
var nRes = Object.keys(store.resume || {}).length;
var nMock = Object.keys(store.mockQs || {}).length;
var nSnap = Object.keys(store.snaps || {}).length;
var nAux = Object.keys(store.practiced || {}).length + Object.keys(store.qModule || {}).length +
Object.keys(store.qSrc || {}).length;
if (!nNote && !nStat && !nExp && !nHis && !nRes && !nMock && !nSnap && !nAux) { setStatus('本地数据已为空', ''); return; }
if (!confirm(
'将删除本脚本存在此浏览器中的全部数据:\n' +
'· 笔记 ' + nNote + ' 条\n' +
'· 掌握记录 ' + nStat + ' 条\n' +
'· 答错次数统计\n' +
'· 增量导出记录 ' + nExp + ' 题\n' +
'· 组卷历史 ' + nHis + ' 条\n' +
'· 刷题进度 ' + nRes + ' 处\n' +
'· 模考收录 ' + nMock + ' 题(连带重练登记与模块记录)\n' +
'· 补充题本快照 ' + nSnap + ' 题\n\n' +
'此操作不可恢复。建议先点「备份全部数据」再清空。\n\n确定继续?'
)) return;
store.notes = {};
store.mastered = {};
store.wrongCount = {};
store.exported = { _v: 2, error: {}, favorite: {}, both: {}, mock: {}, book: {} };
store.exportAt = { error: 0, favorite: 0, both: 0, mock: 0, book: 0 };
store.history = [];
store.resume = {};
store.mockQs = {};
store.mocks = {};
store.practiced = {};
store.qModule = {};
store.qSrc = {};
store.qSrcAt = 0;
store.snaps = {};
selectedMockModules.length = 0;
renderMockChips();
saveStore();
refreshBadges();
renderPracticeHint();
renderNotesList();
renderHistory();
updateExportHint();
setStatus('已清空本地数据', 'ok');
});
var reconciling = false;
var reconcileTried = false;
// 题本成员要先知道站点此刻收着哪些题,所以这一趟不等人点:摊开错题页或收藏页时自动跑一次。
// 只试一次——失败了还在观察器里重试,就成了每 400ms 打一遍站点接口。
// 「升级后点我」那颗键里也带这一趟,失败时用户还能再点它。
function autoReconcile() {
if (reconciling || reconcileTried || reconciled()) return;
reconcileTried = true;
reconcileWithSite(null);
}
function reconcileWithSite(btn) {
var beforeMod = Object.keys(store.qModule || {}).length;
var siteList = [];
reconciling = true;
setStatus('正在取行测考点表…');
busy(btn, ensureExamPointIndex().then(function () {
setStatus('正在按日期载入错题本与收藏夹…');
return fetchByFilter({ mode: 'date', dayRange: '4', srcs: ['error', 'favorite'] }, 0);
}).then(function (list) {
siteList = list;
setStatus('正在按科目载入公专题目…');
return fetchByFilter({ mode: 'subject', subject: SUBJ_ZY, srcs: ['error', 'favorite'] }, 0);
}).then(function (zyList) {
var f = sweepFacets(siteList, zyList, examPointModule);
var gone = reconcileSources(f.srcs, siteList.length > 0 && siteList.length < SITE_LIST_CAP);
var nSubj = backfillSubject(f.subjects);
var all = noteIdsWithHl();
var gap = all.filter(function (id) { return !moduleOf(id); }).length;
var noSubj = all.filter(function (id) { return noteFacets(id).subj === 'x'; }).length;
setStatus('按日期过了 ' + siteList.length + ' 题,按科目过了 ' + zyList.length + ' 题;站点在列 ' +
Object.keys(store.qSrc).length + ' 题' +
(siteList.length < SITE_LIST_CAP ? ',' + gone + ' 题已不在站点那两个列表里。' : '(这一趟没跑全,在列名单只并不清)。') +
'新登记 ' + (Object.keys(store.qModule || {}).length - beforeMod) + ' 条模块、' + nSubj + ' 条科目。' +
(gap || noSubj
? '笔记与划线里还缺 模块 ' + gap + ' 题、科目 ' + noSubj + ' 题。'
: '笔记与划线的模块与科目已齐。'), 'ok');
reconciling = false;
renderNotesList();
renderBookList();
})).catch(function (e) {
reconciling = false;
setStatus('与站点核对失败:' + e.message, 'err');
});
}
// 三颗键合成一颗:原来那三名(与站点核对 / 清理残留 / 清孤儿快照)没有一张脸能让人看懂,
// 于是没人敢点。合成一次点击,但删除仍然先列清单问一句——静默删数据不是替用户省认知负担,
// 是替他做决定。
function upgradeScan() {
var names = [];
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
if (k) names.push(k);
}
return { keys: strayStorageKeys(names), old: staleResumeKeys(store.resume), snaps: orphanSnapIds() };
}
$('#gth-upgrade').addEventListener('click', function () {
var f = upgradeScan(), n = f.keys.length + f.old.length + f.snaps.length;
if (n) {
var kb = f.keys.reduce(function (a, x) { return a + (localStorage.getItem(x) || '').length; }, 0) / 1024;
var detail = f.keys.map(function (x) { return '· 存储键 ' + x; })
.concat(f.old.map(function (x) { return '· 旧刷题进度 ' + x; }))
.concat(f.snaps.length ? ['· 没笔记也没划线的题面快照 ' + f.snaps.length + ' 题'] : []).join('\n');
if (!confirm('将删掉 ' + n + ' 项本机不再需要的记录' +
(f.keys.length ? '(存储键约 ' + kb.toFixed(1) + ' KB)' : '') + ',不可恢复:\n\n' + detail +
'\n\n笔记、划线、掌握状态与已收录的题都不受影响。删完接着与站点核对一遍。\n\n确定继续?')) return;
f.keys.forEach(function (x) { localStorage.removeItem(x); });
f.old.forEach(function (x) { delete store.resume[x]; });
f.snaps.forEach(function (x) { delete store.snaps[x]; });
saveStore();
renderBookList();
setStatus('已清掉 ' + n + ' 项本机旧记录,正在与站点核对…');
}
reconcileWithSite(this);
});
var notesSearchKey = '';
var gthNoteEditing = false;
var openNoteBox = null;
$('#gth-note-search').addEventListener('input', debounce(function (e) {
notesSearchKey = e.target.value;
renderNotesList();
}, 150));
function renderNotesList() {
if (gthNoteEditing) return;
var listEl = $('#gth-notes-list');
var countEl = $('#gth-note-count');
if (!listEl) return;
var ids = noteIdsWithHl();
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;
};
renderNoteFilterMods(ids);
var q = notesSearchKey.trim().toLowerCase();
var filtered = ids.filter(function (id) {
if (!noteMatch(noteFacets(id), notesFilter)) return false;
if (!q) return true;
var n = store.notes[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;
});
});
notesShownIds = filtered.slice();
var lim = notesNarrowed() ? filtered.length + ' / ' : '';
countEl.textContent = lim + ids.length + ' 题';
if (!ids.length) {
listEl.innerHTML = '
' + icon('bookOpen') +
'
暂无笔记与划线。在错题页或答题报告里选中解析文字即可划线。
';
return;
}
if (!filtered.length) {
listEl.innerHTML = '
' + icon('search') +
'
' + (q ? '没有匹配的内容' : '当前筛选条件下没有笔记题') + '
';
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 f = noteFacets(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) + ' ' +
(f.src === 'mock' ? '' + icon('bookOpen') + '模考收录 ' : '') +
(f.mod && f.mod !== MOCK_UNCLS ? '' + esc(f.mod) + ' ' : '') +
'' + esc(dyn) + ' ' +
(hls.length ? '' + icon('highlighter') + esc(String(hls.length)) + ' 划 ' : '') +
'' + fmtAgo(lastAct(id)) + ' ' +
' ' +
'' + icon('listChecks') + '摊开题目 ' +
'' + icon('pencil') + '编辑 ' +
'' + icon('trash') + ' ' +
'
' +
((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 hlRowHtml(h, i, '');
}).join('') + '
' : '') +
'
';
}).join('');
$$('.gth-note-item', listEl).forEach(function (item) {
var id = item.dataset.id;
$('[data-act="book"]', item).addEventListener('click', function () { jumpToBook(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.closest('.gth-hlp-item').dataset.hl));
toast('已取消划线');
afterHlChange();
});
});
$$('.gth-hlp-item .t, .gth-hlp-item .n', item).forEach(function (node) {
node.addEventListener('click', function () {
openHlNote(id, Number(node.closest('.gth-hlp-item').dataset.hl), node.getBoundingClientRect());
});
});
});
}
var BOOK_PER_PAGE = 20;
var bookShownIds = [];
var bookPage = 1;
function ansList(v) {
if (Array.isArray(v)) {
return v.map(function (x) { return String(x).trim().toUpperCase(); }).filter(function (x) { return !!x; });
}
var s = String(v == null ? '' : v).replace(/[^A-Za-z]/g, '').toUpperCase();
return s ? s.split('') : [];
}
function bookLastAct(id) {
var t = (store.notes[id] && store.notes[id].updated) || 0;
getHighlights(id).forEach(function (h) { if ((h.at || 0) > t) t = h.at; });
var s = store.snaps[id];
if (s && (s.at || 0) > t) t = s.at;
var m = store.mockQs[id];
if (m && (m.at || 0) > t) t = m.at;
return t;
}
// 头部直接复用错题页那行小条的类名:答错次数与掌握状态由 refreshBadges() 一处刷,
// 题本再拼一遍标签文案迟早和错题页分叉。data-serverErr 给 0 是因为列表接口没返回过答错次数。
function bookCardHeadHtml(id) {
return '
' +
' ' +
'' +
icon('pencil') + '笔记 ' +
'' +
icon('copy') + '复制题目
';
}
function bookCardHtml(id, no) {
var q = bookQuestion(id);
return questionCardHtml(q, {
cls: 'gth-qcard gth-book-card',
head: bookCardHeadHtml(id),
no: no,
circle: 1,
picked: ansList(q.user_answer),
noSnap: q._noSnap ? 1 : 0,
partSnap: q._part ? 1 : 0,
conflict: q._conflict ? 1 : 0,
note: false,
anaToggle: 1,
excerpt: q._ex || ''
});
}
// 笔记写在内框外那一栏(和站点错题页同一套 .gth-aside):这一页摊几道题,那一栏就摆几张批注卡,
// 位置由 placeRailItems() 按卡片此刻在视口里的位置算。卡片整段重渲染,所以按 data-book 认自己人。
function renderBookSides() {
var inner = $('#gth-rail-in');
if (!inner) return;
var ids = bookViewOn ? bookPageIds() : [];
$$('.gth-aside[data-book="1"]', inner).forEach(function (a) {
if (ids.indexOf(a.dataset.id) < 0) a.remove();
});
ids.forEach(function (id) {
var el = $('.gth-aside[data-book="1"][data-id="' + id.replace(/"/g, '') + '"]', inner);
if (!el) {
el = document.createElement('div');
el.className = 'gth-aside';
el.dataset.book = '1';
el.dataset.id = id;
inner.appendChild(el);
}
if (el.contains(document.activeElement)) return;
renderAside(el, bookQuestion(id));
});
}
function bookCardNode(id) {
return $('.gth-book-card[data-id="' + String(id).replace(/"/g, '') + '"]');
}
// 右下角那一行只切 class 与 hidden:重写整张卡片会把展开的划线与正在编辑的笔记一起抹掉
function bindAnaToggle(root) {
$$('[data-act="ana"]', root).forEach(function (ab) {
ab.addEventListener('click', function () {
var card = ab.closest('.gth-qcard');
var box = card && $('.gthq-analysis', card);
var on = ab.classList.toggle('on');
if (box) box.hidden = !on;
syncRail();
});
});
}
function bindBookList(listEl) {
$$('.gth-book-card', listEl).forEach(function (el) {
bindQbar($('.gth-qbar', el), function () { return bookQuestion(el.dataset.id); });
});
bindAnaToggle(listEl);
$$('[data-gth-qid]', listEl).forEach(function (el) {
el.removeAttribute('data-gth-paint');
paintRoot(el);
});
}
function bindBookPager() {
var pagerEl = $('#gth-book-pager');
if (!pagerEl) return;
$$('button[data-page]', pagerEl).forEach(function (b) {
b.addEventListener('click', function () {
var n = Number(b.dataset.page);
if (!(n >= 1) || n === bookPage) return;
bookPage = n;
renderBookList();
var box = $('#gth-book-scroll');
if (box) box.scrollTop = 0;
});
});
}
// 题本顶部只摆三样:科目、模块、关键词。来源、掌握状态、快照齐不齐都不摆——
// 前两个在笔记那一屏已有入口,最后一个是内部概念,摆出来只会让人猜它是什么意思。
var bookFilter = { subj: '', mod: '', q: '' };
function readBookFilter() {
var g = function (id) { var el = $('#' + id); return el ? el.value : ''; };
bookFilter.subj = g('gth-bf-subj');
bookFilter.mod = g('gth-bf-mod');
bookFilter.q = g('gth-bf-q');
}
function bookNarrowed() {
return !!(bookFilter.subj || bookFilter.mod || bookFilter.q.trim());
}
function bookFacets(id) {
var f = noteFacets(id);
var s = store.snaps[id];
return { subj: f.subj === 'x' && s && s.content_type != null ? String(s.content_type) : f.subj,
mod: f.mod };
}
function bookMatch(nf) {
if (bookFilter.subj && nf.subj !== bookFilter.subj) return false;
if (bookFilter.mod && nf.mod !== bookFilter.mod) return false;
return true;
}
// 关键词要读题面,比科目与模块贵一档,所以单独一步、只在真输了字的时候跑;
// 匹配前先剥标签——题面是 HTML,不剥的话搜「p」能命中每一题
function bookSearchHit(id, q) {
var n = store.notes[id];
if (String((n && n.text) || '').toLowerCase().indexOf(q) >= 0) return true;
var s = store.snaps[id] || store.mockQs[id];
if (!s) return String((n && n.snapshot) || '').toLowerCase().indexOf(q) >= 0;
return (stripHtml(s.content) + stripHtml(s.analysis) + stripHtml(s.material))
.toLowerCase().indexOf(q) >= 0;
}
function renderBookFilterMods(ids) {
var sel = $('#gth-bf-mod');
if (!sel) return;
var names = {}, hasUnknown = false;
ids.forEach(function (id) { var m = moduleOf(id); if (m) names[m] = 1; else hasUnknown = true; });
sel.innerHTML = '
全部模块 ' +
Object.keys(names).sort().map(function (n) {
return '
' + esc(n) + ' ';
}).join('') +
(hasUnknown ? '
模块未记录 ' : '');
sel.value = bookFilter.mod;
bookFilter.mod = sel.value;
}
function bookFilteredIds() {
var ids = bookIds();
readBookFilter();
renderBookFilterMods(ids);
var q = bookFilter.q.trim().toLowerCase();
return ids.filter(function (id) {
return bookMatch(bookFacets(id)) && (!q || bookSearchHit(id, q));
}).sort(function (a, b) { return bookLastAct(b) - bookLastAct(a); });
}
function bookPageIds() {
return bookShownIds.slice((bookPage - 1) * BOOK_PER_PAGE, bookPage * BOOK_PER_PAGE).map(String);
}
function bookPageTotal() {
return Math.max(1, Math.ceil(bookShownIds.length / BOOK_PER_PAGE));
}
// 分页器照站点错题页那个形状:« ‹ 1 2 3 … N › »
function bookPagerHtml(cur, total) {
var nums = [], i;
for (i = 1; i <= total; i++) {
if (i === 1 || i === total || Math.abs(i - cur) <= 2) nums.push(i);
else if (nums[nums.length - 1] !== 0) nums.push(0);
}
function pb(page, label, off, on) {
return '
' + label + ' ';
}
var out = [pb(1, '«', cur <= 1), pb(cur - 1, '‹', cur <= 1)];
nums.forEach(function (n) {
out.push(n === 0 ? '
… ' : pb(n, String(n), false, n === cur));
});
out.push(pb(cur + 1, '›', cur >= total), pb(total, '»', cur >= total));
return out.join('');
}
function renderBookList() {
var listEl = $('#gth-book-list');
if (!listEl) return;
if (gthNoteEditing) return;
var pagerEl = $('#gth-book-pager');
var countEl = $('#gth-book-count');
function empty(html) {
listEl.innerHTML = html;
if (pagerEl) pagerEl.innerHTML = '';
bookShownIds = [];
bookPage = 1;
renderBookSides();
}
if (!reconciled()) {
if (countEl) countEl.textContent = '正在与站点核对';
empty('
' + icon('rotate') +
'
补充题本只收站点错题本与收藏夹里没有 的题——正在与站点核对这一批,跑完自己摊开。
');
autoReconcile();
return;
}
var ids = bookIds();
bookShownIds = bookFilteredIds();
if (!ids.length) {
if (countEl) countEl.textContent = '';
empty('
' + icon('listChecks') +
'
补充题本还是空的。本机记过的题——写过笔记、划过线、答错过、导出过、组过卷、模考收进来——只要站点错题本与收藏夹里还没有它,就会自己进来。
');
return;
}
if (!bookShownIds.length) {
if (countEl) countEl.textContent = '0 / ' + ids.length + ' 题';
empty('
' + icon('search') +
'
没有题符合这些条件。
' +
'
清空筛选 ');
var uf = $('[data-act="unfilter"]', listEl);
if (uf) uf.addEventListener('click', clearBookFilter);
return;
}
var total = bookPageTotal();
if (!(bookPage >= 1)) bookPage = 1;
if (bookPage > total) bookPage = total;
var pageIds = bookPageIds();
if (countEl) {
var gapN = ids.reduce(function (a, id) { return snapState(id) === 'full' ? a : a + 1; }, 0);
countEl.textContent = (bookNarrowed() ? bookShownIds.length + ' / ' : '') + ids.length +
' 题 · 第 ' + bookPage + ' / ' + total + ' 页' +
(gapN ? ' · 题面不齐 ' + gapN + ' 题' : '');
}
if (pagerEl) pagerEl.innerHTML = bookPagerHtml(bookPage, total);
listEl.innerHTML = pageIds.map(function (id, i) {
return bookCardHtml(id, (bookPage - 1) * BOOK_PER_PAGE + i + 1);
}).join('');
pageIds.forEach(function (id) { qNodes[id] = bookCardNode(id); });
bindBookList(listEl);
bindBookPager();
refreshBadges();
renderBookSides();
syncRail();
var rb = $('#gth-bf-reset');
if (rb) rb.hidden = !bookNarrowed();
autoFillFaces(pageIds);
}
function clearBookFilter() {
['gth-bf-subj', 'gth-bf-mod', 'gth-bf-q'].forEach(function (id) {
var el = $('#' + id);
if (el) el.value = '';
});
bookPage = 1;
renderBookList();
}
['gth-bf-subj', 'gth-bf-mod'].forEach(function (fid) {
var el = $('#' + fid);
if (el) el.addEventListener('change', function () { bookPage = 1; renderBookList(); });
});
var bookSearchEl = $('#gth-bf-q');
if (bookSearchEl) bookSearchEl.addEventListener('input', debounce(function () {
bookPage = 1;
renderBookList();
}, 200));
var bookResetEl = $('#gth-bf-reset');
if (bookResetEl) bookResetEl.addEventListener('click', clearBookFilter);
function jumpToBook(id) {
if (!bookViewOn) setBookView(true);
var idx = -1;
bookShownIds.forEach(function (x, i) { if (String(x) === String(id)) idx = i; });
if (idx < 0) {
setStatus('这题不在补充题本里——站点已经把它收进错题本或收藏夹了。');
return;
}
bookPage = Math.floor(idx / BOOK_PER_PAGE) + 1;
renderBookList();
var el = bookCardNode(id);
if (!el) return;
el.scrollIntoView({ block: 'center' });
el.classList.add('gth-flash');
setTimeout(function () { el.classList.remove('gth-flash'); }, 1600);
}
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 = '
' +
'' + icon('save') + '保存 ' +
'取消
';
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;
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);
if (el.dataset.gthHtml === html && !wasEditing) return;
el.dataset.gthHtml = html;
var keepOpen = el.dataset.hlOpen === '1';
el.innerHTML = html;
if (keepOpen) {
var hlList = $('.gth-aside-hl-list', el);
if (hlList) hlList.classList.add('on');
}
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);
}
function asideHlHtml(id) {
var list = getHighlights(id);
if (!list.length) return '';
var desc = list.length + ' 条划线';
var rows = list.map(function (h, i) { return hlRowHtml(h, i, '点击改批注'); }).join('');
var lost = list.filter(function (h) { return h.lost; }).length;
return '
' +
'
' + icon('highlighter') + esc(desc) +
(lost ? ' · ' + lost + ' 条已失效' : '') +
'' + icon('chevronDown') + '
' +
'
' + rows +
'
' +
'' + icon('fileText') + '整理本题
' +
'
';
}
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' : '';
syncRail();
});
$$('.gth-hlp-item .t, .gth-hlp-item .n', list).forEach(function (node) {
node.addEventListener('click', function () {
openHlNote(item.id, Number(node.closest('.gth-hlp-item').dataset.hl), node.getBoundingClientRect());
});
});
$$('.gth-hlp-item .rm', list).forEach(function (rm) {
rm.addEventListener('click', function (e) {
e.stopPropagation();
hlRemove(item.id, Number(rm.closest('.gth-hlp-item').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 = '';
});
}
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') + '编辑笔记
' +
'
' +
'
' +
'保存 ' +
'取消 ' +
'' + icon('quote') + '插入划线
' +
'
';
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.closest('.gth-hlp-item').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: subjectOf(item)
});
renderAside(el, item);
refreshBadges();
renderNotesList();
});
$('[data-act="cancel"]', el).addEventListener('click', function () { renderAside(el, item); });
}
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 {
setIf(errEl, 'hidden', true);
setIf(errEl, 'className', 'gth-err');
setIf(errEl, 'innerHTML', '');
}
});
rerenderAsides();
}
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;
}
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();
if (bookViewOn) renderBookList();
}
function focusNote(item) {
if (viewOn) setView(false);
var id = String(item.id);
// 题本开着时那一栏里同时挂着错题页的批注卡(站点内容只是藏起来没拆),按 data-book 认自己那张
var aside = $$('#gth-rail-in .gth-aside[data-id="' + id + '"]').filter(function (a) {
return !bookViewOn || a.dataset.book === '1';
})[0];
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('笔记栏在题目右侧');
}
var CONTENT_SEL = '.gongan2-container .right-content .inner-content > .content';
var qNodes = {};
function prunePageRefs() {
Object.keys(qNodes).forEach(function (k) {
var n = qNodes[k];
if (n && n.isConnected) return;
delete qNodes[k];
delete itemsById[k];
});
}
function railContent() {
if (bookViewOn) return $('#gth-book-scroll');
return $(CONTENT_SEL) ||
$('.gongan2-container .right-content .inner-content') ||
$('.right-content .inner-content');
}
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;
}
var railFrame = null;
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 };
}
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) {
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() || bookViewOn) || !inner) { rail.hidden = true; railFrame = null; return; }
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;
var railLeft = Math.round(f.rect.right + 24);
var avail = (document.documentElement.clientWidth || window.innerWidth) - railLeft - 8;
if (avail < 120) { rail.hidden = true; railFrame = null; return; }
rail.hidden = false;
rail.style.left = railLeft + 'px';
rail.style.width = Math.min(280, avail) + 'px';
rail.style.top = Math.round(f.top) + 'px';
rail.style.height = Math.round(f.height) + 'px';
inner.style.transform = 'none';
placeRailItems();
}
// 错题页与题本那行小条同形状,绑定也只留一份:取题面的函数由调用方给,两边数据源不搅在一起
function bindQbar(bar, itemOf) {
if (!bar || bar.dataset.gthBind) return;
bar.dataset.gthBind = '1';
var copy = $('.gth-qbar-copy', bar);
if (copy) copy.addEventListener('click', function (e) {
e.stopPropagation();
var it = itemOf();
if (!it) return;
var ok = copyWithToast(copyQuestion(it));
copy.classList.add('copied');
copy.innerHTML = (ok ? icon('check') : icon('xCircle')) + (ok ? '已复制' : '复制失败');
setTimeout(function () {
copy.classList.remove('copied');
copy.innerHTML = icon('copy') + '复制题目';
}, 1200);
});
var badge = $('.gth-badge', bar);
if (badge) badge.addEventListener('click', function (e) {
e.stopPropagation();
toggleMastery(bar.dataset.id);
});
var note = $('.gth-qbar-note', bar);
if (note) note.addEventListener('click', function (e) {
e.stopPropagation();
var it = itemOf();
if (it) focusNote(it);
});
}
function injectListUI() {
if (!isListRoute()) return;
if (bookViewOn) return;
var ang = getAngular();
if (!ang) return;
var railInner = $('#gth-rail-in');
if (!railInner) return;
var listNodes = findByNgRepeat('item', 'subjectList');
// 表达式改了才报:空列表时站点画的是「暂无数据」,那一屏连 .question-box 都不在,不会误报
if (!listNodes.length && $$('.question-box').length) {
sayOnce('list', '这一页有 ' + $$('.question-box').length + ' 个 .question-box,但没有一条 ' +
'ng-repeat 是「item in subjectList」——站点改了表达式,错题页那行小条挂不上');
}
listNodes.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;
railInner.querySelectorAll('.gth-aside[data-id="' + item.id + '"]').forEach(function (a) { a.remove(); });
node.dataset.gth = '1';
node.setAttribute('data-gth-qid', item.id);
itemsById[item.id] = snapCopy(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);
$$('.gth-qbar[data-id="' + item.id + '"]').forEach(function (b) { b.remove(); });
bar.innerHTML =
'
' +
'
' +
'
' +
'
' +
icon('pencil') + '笔记 ' +
'
' +
icon('copy') + '复制题目 ';
bindQbar(bar, function () { return itemsById[item.id]; });
var aside = document.createElement('div');
aside.className = 'gth-aside';
aside.dataset.id = item.id;
renderAside(aside, item);
node.insertBefore(bar, node.firstChild);
railInner.appendChild(aside);
});
refreshBadges();
autoReconcile();
}
function syncKeyTargets() {
$$(KEYACT).forEach(function (el) {
if (el.getAttribute('tabindex') === null) el.setAttribute('tabindex', '0');
if (!el.getAttribute('role')) el.setAttribute('role', 'button');
});
}
document.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
var t = e.target && e.target.closest ? e.target.closest(KEYACT) : null;
if (!t) return;
e.preventDefault();
t.click();
});
var refreshPageUI = debounce(function () {
// 重练那一屏是盖住整页的模态:底下的注入、轨道与划线重画跑一遍谁也看不见,
// 而时钟每秒写一次头部就会白排一轮,那正是上一轮「严重内存泄露」报上来的形状
if (!quizEl.hidden) return;
prunePageRefs();
if (isMocksPage()) { mocksScanTick(); return; }
if (isZyPage()) { injectAnalysisUI(); return; }
injectListUI();
injectMenuEntry();
fitImgs(document.body);
if (viewOn || bookViewOn) applyChrome();
if (viewOn && storeRev !== panelRev) renderPanel();
syncRail();
repaintHighlights();
syncBalloons();
syncKeyTargets();
}, 400);
new MutationObserver(refreshPageUI).observe(document.body, {
childList: true, subtree: true
});
window.addEventListener('resize', syncRail);
var hlBar = document.createElement('div');
hlBar.id = 'gth-hlbar';
hlBar.innerHTML =
'
重点' +
'
易错' +
'
' +
'
' + icon('quote') + '批注 ' +
'
' + icon('copy') + ' ';
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);
}
var HL_BLOCK_SEL = '#gth-hlbar,#gth-collect,#gth-toast,#gth-rail,.gth-aside,.gth-view,' +
'.gth-abar,.gth-apanel,' +
'#gth-quiz .gthq-header,#gth-quiz .gthq-sheet-col,#gth-quiz .gthq-foot,#gth-quiz .gthq-notebox,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 };
}
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(); });
function applyHl(act, p) {
if (act === 'note') {
var h = ensureHl(p, 'yellow');
hideBar();
if (!h) { toast('批注失败:没能在题目里定位到这段文字'); return; }
openHlNote(h.qid, h.idx, p.rect);
return;
}
var r = toggleHl(p, act);
hideBar();
if (!r) { toast('划线失败:没能在题目里定位到这段文字'); return; }
if (r.removed) toast('已取消 ' + r.removed + ' 条划线');
else if (r.recolored) toast('已改为' + HL_LABEL[act]);
else toast('已标为' + HL_LABEL[act]);
afterHlChange();
}
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') { copyWithToast(p.quote); hideBar(); return; }
applyHl(btn.dataset.act === 'note' ? 'note' : (btn.dataset.c === 'red' ? 'red' : 'yellow'), p);
});
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();
applyHl(e.key === '3' ? 'note' : (e.key === '2' ? 'red' : 'yellow'), p);
});
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());
});
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 = qRoot(qid);
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) + '
' +
'
' + icon('quote') + (rec.note ? '编辑批注' : '添加批注') + ' ' +
'
' +
' 重点 ' +
' 易错 ' +
'
' +
'
' + icon('x') + '取消划线 ';
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') + '批注 ',
'' + icon('x') + '
',
'
',
'
',
'
',
'' + icon('trash') + '删除批注 ',
' ',
'取消 ',
'' + icon('check') + '保存 ',
'
'
].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();
rerenderAsides();
refreshQuizHl();
syncBalloons();
if (bookViewOn && !gthNoteEditing) renderBookList();
}
function quizHlHtml(qid) {
var list = getHighlights(qid);
if (!list.length) return '';
return '
' + icon('highlighter') + list.length + ' 条划线
' +
list.map(function (h, i) { return hlRowHtml(h, i, '点击改批注'); }).join('');
}
function refreshQuizHl() {
$$('[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());
}
});
document.addEventListener('click', function (e) {
var el = e.target && e.target.closest ? e.target.closest('.gth-balloon') : null;
if (!el) return;
var k = el.dataset.k || '', c = k.lastIndexOf(':');
if (c <= 0) return;
openHlNote(k.slice(0, c), Number(k.slice(c + 1)), el.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;
el.title = '点击改这条批注';
inner.appendChild(el);
balloonEls[k] = el;
}
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;
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;
});
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 = noteIdsWithHl();
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') + '整理为笔记 ',
'' + icon('x') + '
',
'
',
'
内容 ',
'
仅划线
',
'
仅笔记
',
'
划线 + 笔记
',
'
',
'
',
'
',
' 只收解析区的划线(题干 / 选项的划线只作标记,不进笔记) ',
'
',
'
',
'
',
'
',
'' + icon('copy') + '复制 ',
'' + icon('fileText') + 'TXT ',
'' + icon('download') + '下载 Markdown ',
'
',
'
'
].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') { copyWithToast($('#gthc-pre').textContent); 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 tierOf(q) {
if (isMastered(q.id)) return 3;
if (errCountOf(q) >= STUBBORN_MIN) return 0;
if (!isPracticed(q.id)) return 1;
return 2;
}
function weightedShuffle(arr) {
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; })
.map(function (x) { return x.q; });
}
function weightedPick(arr, k) {
var n = (k && k < arr.length) ? k : arr.length;
var tiers = [[], [], [], []];
arr.forEach(function (q) { tiers[tierOf(q)].push(q); });
return tiers.reduce(function (out, t) { return out.concat(weightedShuffle(t)); }, []).slice(0, n);
}
function resumeKey(f, order) { return filterDesc(f) + '|' + order; }
function resumeKeyOld(key) {
return key.replace(srcLabel(['error', 'favorite']), SRC_NAME.both);
}
function readResume(key) {
if (!key) return null;
var r = store.resume[key];
if (r) return r;
var old = resumeKeyOld(key);
if (old === key || !store.resume[old]) return null;
store.resume[key] = store.resume[old];
delete store.resume[old];
saveStore();
return store.resume[key];
}
function clearResume(key) {
if (!key) return;
var old = resumeKeyOld(key);
if (!store.resume[key] && !store.resume[old]) return;
delete store.resume[key];
delete store.resume[old];
saveStore();
}
function staleResumeKeys(resume) {
return Object.keys(resume || {}).filter(function (k) {
return k && k.indexOf(' · ') < 0 && k.indexOf(SRC_NAME.mock) !== 0;
});
}
function strayStorageKeys(names) {
return (names || []).filter(function (k) {
return /^gongan_exam_helper_/.test(k) || /^gongan_tiku_helper_[\w-]+__bak_/.test(k);
});
}
function saveResume(key, idx, id, answered) {
if (!key) return;
store.resume[key] = { idx: idx, id: id || '', at: Date.now(), answered: answered || {} };
saveStore();
}
function resumeOffset(key, list) {
var r = readResume(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('practice');
if (!f.srcs.length) { setStatus('至少勾一个来源(错题本 / 收藏夹 / 模考收录)', 'err'); return; }
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 ? readResume(key) : null;
var desc0 = filterDesc(f);
var want = !num ? 0 : ((r && r.idx) ? r.idx + num + 1 : num);
setStatus('正在组卷…');
busy(this, fetchByFilter(f, want).then(function (all) {
if (!all.length) { setStatus('该条件下没有题目(来源:' + srcLabel(f.srcs) + ')', '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;
clearResume(key);
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');
if (filterDesc(readFilter('practice')) !== desc0) {
toast('筛选在取题期间改过了:这份卷子按「' + desc0 + '」生成');
}
startQuiz(picked, filterDesc(f), false, seq ? (r && r.answered) : null, minLimit());
if (seq) {
quiz.resumeKey = key;
quiz.resumeBase = offset;
quiz.idx = startIdx;
saveResume(key, offset + startIdx, picked[startIdx] && picked[startIdx].id, quiz.answeredIds);
}
renderQuiz();
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) + ' ' +
'' + icon('play') + '重练这套 ' +
'
';
}).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;
}
var r = readResume(resumeKey(readFilter('practice'), order));
el.textContent = r
? '下次从第 ' + (r.idx + 1) + ' 题继续'
: '顺序刷题会自动记录进度,下次可从上次继续';
if (btn) btn.hidden = !r;
}
$('#gth-resume-reset').addEventListener('click', function () {
clearResume(resumeKey(readFilter('practice'), $('#gth-order').value));
renderPracticeHint();
setStatus('已清除该条件下的刷题进度,下次从头开始', 'ok');
});
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, null, minLimit());
}).catch(function (e) { setStatus('重练失败:' + e.message, 'err'); });
}
// 限时留空 = 不限时:头部改说「已用时间」,也就没有到点交卷这一说(暂停键无条件在,见 quizHeadHtml)
function minLimit() {
var el = $('#gth-min');
var v = el ? parseInt(el.value, 10) : 0;
return v > 0 ? v * 60 : 0;
}
function startQuiz(list, desc, fromHistory, seedAnswered, limitSec) {
quiz = {
list: list, desc: desc, idx: 0,
answers: {}, submitted: false,
answeredIds: seedAnswered || {},
marked: {}, onlyWrong: false, spent: {},
limitSec: limitSec || 0, paused: false, runMs: 0, lastAt: Date.now(),
timer: null
};
if (!fromHistory) pushHistory(desc, list);
quizEl.hidden = false;
document.body.style.overflow = 'hidden';
quiz.timer = setInterval(tickQuiz, 1000);
renderQuiz();
}
// 时钟只在未暂停时累加:暂停那一段根本不进 runMs,所以「剩余时间」停得住,
// 而「已用时间」也不会像站点那样被冻出一个少了一截的数
function tickQuiz() {
if (!quiz || quiz.submitted) return;
var now = Date.now(), dt = now - quiz.lastAt;
quiz.lastAt = now;
if (quiz.paused) return;
quiz.runMs += dt;
// 每秒把这一段记在当前那一题头上:重练是一屏一题,归属没有歧义,
// 所以不必像专项页那样另设「认不出就落 lost」
var cur = quiz.list[quiz.idx];
if (cur) quiz.spent[cur.id] = (quiz.spent[cur.id] || 0) + dt;
var el = $('#gth-remain');
if (el) setClockText(el, quizClockHtml());
if (quiz.limitSec && quiz.runMs >= quiz.limitSec * 1000) submitQuiz(true);
}
// 只改文本节点的 nodeValue:观察器盯的是 childList,用 innerHTML 每秒重写一次
// 就等于每秒给页面排一轮无意义的刷新
function setClockText(el, txt) {
if (!el.firstChild) el.appendChild(document.createTextNode(''));
if (el.firstChild.nodeValue !== txt) el.firstChild.nodeValue = txt;
}
function quizClockHtml() {
if (!quiz.limitSec) return '已用时间:' + fmtTime(quiz.runMs);
return '剩余时间:' + fmtTime(Math.max(0, quiz.limitSec * 1000 - quiz.runMs)) +
(quiz.paused ? '(已暂停)' : '');
}
function closeQuiz() {
if (quiz && quiz.timer) clearInterval(quiz.timer);
quiz = null;
quizEl.hidden = true;
quizEl.innerHTML = '';
document.body.style.overflow = '';
refreshBadges();
renderPracticeHint();
}
function answeredCount() {
return Object.keys(quiz.answers).filter(function (k) {
return (quiz.answers[k] || []).length > 0;
}).length;
}
function isRight(q) {
var a = quiz.answers[q.id] || [];
return !!a.length && ansKey(a) === ansKey(q.correct_answer);
}
function rightCount() { return quiz.list.filter(isRight).length; }
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;
// 选一个选项只改这一题的圈与答题卡那一格:整屏重写会把滚动位置与展开态一起抹掉
paintChoices();
paintCells();
}
function toggleMark(id) {
if (quiz.marked[id]) delete quiz.marked[id]; else quiz.marked[id] = 1;
paintCells();
}
function cellState(i) {
var q = quiz.list[i], a = quiz.answers[q.id] || [];
if (quiz.submitted) {
if (!a.length) return 'miss';
return ansKey(a) === ansKey(q.correct_answer) ? 'right' : 'bad';
}
return a.length ? 'done' : '';
}
function cellCls(i) {
return 'gthq-cell ' + cellState(i) +
(quiz.marked[quiz.list[i].id] ? ' stressed' : '') +
(i === quiz.idx ? ' cur' : '');
}
function paintChoices() {
var q = quiz.list[quiz.idx];
var sel = quiz.answers[q.id] || [];
$$('.gth-choice', quizEl).forEach(function (el) {
var c = $('.gth-circle', el);
if (c) c.className = 'gth-circle' + (sel.indexOf(el.dataset.label) >= 0 ? ' checked' : '');
});
}
function paintCells() {
$$('.gthq-cell', quizEl).forEach(function (el) {
el.className = cellCls(Number(el.dataset.i));
});
}
// 勾上「只看错题」之后,答题卡、上一题与下一题都只在错题那一列里走——
// 只把别的题藏起来是假「只看」,F5 一按就跳回做对的题
function navIdxs() {
if (!quiz.submitted || !quiz.onlyWrong) return quiz.list.map(function (q, i) { return i; });
var out = [];
quiz.list.forEach(function (q, i) {
var a = quiz.answers[q.id] || [];
if (a.length && ansKey(a) !== ansKey(q.correct_answer)) out.push(i);
});
return out;
}
function goIdx(i) {
var seq = navIdxs();
if (!seq.length) {
// 一道错题都没有却还挂着「只看错题」,界面就停在第一题(做对的那道)上,
// 看着像那颗复选框坏了——收回勾选并说一句
quiz.onlyWrong = false;
quiz.idx = 0;
toast('这一卷没有答错的题');
renderQuiz();
return;
}
quiz.idx = seq.indexOf(i) >= 0 ? i : seq[0];
renderQuiz();
}
function stepIdx(d) {
var seq = navIdxs(), at = seq.indexOf(quiz.idx);
if (at < 0) { goIdx(seq.length ? seq[0] : 0); return; }
if (seq[at + d] == null) return;
quiz.idx = seq[at + d];
renderQuiz();
}
function submitQuiz(auto) {
if (!quiz || quiz.submitted) return;
var un = quiz.list.length - answeredCount();
if (!auto && un > 0 && !confirm('还有 ' + un + ' 题未作答,确定交卷吗?')) return;
clearInterval(quiz.timer);
quiz.timer = null;
applyMastery();
quiz.submitted = true;
quiz.paused = false;
quiz.onlyWrong = false;
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);
}
if (auto) toast('时间到,已自动交卷');
renderQuiz();
}
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();
// 这一屏开着时观察器整轮早退,公式图的尺寸就得在这里自己管一次
fitImgs(quizEl);
}
function noteboxHtml(q) {
var subj = subjectOf(q);
return '
' +
'
' +
'' + icon('highlighter') + '整理本题 ' +
'
' +
'
' +
'
' +
noteAreaHtml(q.id, 'view') + '
';
}
function questionCardHtml(q, o) {
o = o || {};
var picked = o.picked || [];
var right = ansKey(q.correct_answer);
var ana = analysisOf(q);
var opts = (q.opt || []).map(function (x) {
var isRight = right.indexOf(x.label) >= 0;
var isMine = picked.indexOf(x.label) >= 0;
if (o.circle) {
// 上色只靠圆圈,照站点错题页:正确绿、错选红,不再往文字后面追加「(正确)」
return '
' +
'
' + esc(x.label) + '.
' +
'
' + wakeImgs(x.content || '') + '
';
}
// 复盘那一屏照站点解析页:选项就是纯文字,谁对谁错由下面那行「正确答案/我的答案」说,
// 不再给整行上色、也不在文字后面追「(正确)」
return '
' + esc(x.label) + '. ' + wakeImgs(x.content || '') + '
';
}).join('');
var body = q.content ? wakeImgs(q.content) : (o.excerpt ? esc(o.excerpt) : '');
return '' +
'
' +
(o.head || '') +
'
' +
(o.no ? '
' : '') +
'
' +
(o.kind || o.time ? '
' +
(o.kind ? '(' + esc(o.kind) + ')' : '') +
(o.time ? '' + o.time + ' ' : '') + '
' : '') +
(q.material ? '
' + wakeImgs(q.material) + '
' : '') +
(body ? '
' + body + '
' : '') +
(opts ? '
' + opts + '
' : '') +
(o.mine == null ? '' :
'
正确答案:' + right + '
' +
'
我的答案:' +
(o.mine || '未作答') + '
') +
(ana ? '
' : '') +
(ana && o.anaToggle ?
'
' : '') +
(o.conflict ? '
' + icon('warn') + '题面快照与模考收录的内容不一致,请核对是哪一份
' : '') +
(o.noSnap ? '
' + icon('rotate') + '这题还没有题面,正在自动向站点要
' : '') +
(o.partSnap ? '
' + icon('rotate') + '这份快照缺解析正文(抓的那一刻这一页没展开解析),正在自动再要一次
' : '') +
(o.note === false ? '' : noteboxHtml(q)) +
(o.foot || '') +
'
';
}
function quizStatsHtml() {
var right = rightCount(), miss = 0;
quiz.list.forEach(function (q) { if (!(quiz.answers[q.id] || []).length) miss++; });
var wrong = quiz.list.length - right - miss;
return '得分 ' + right + ' / ' + quiz.list.length +
' 正确率 ' + Math.round(right / quiz.list.length * 100) + '%' +
' 错题 ' + wrong + ' 未做 ' + miss + ' 用时 ' + fmtTime(quiz.runMs);
}
function quizHeadHtml() {
var right = quiz.submitted
? '
' + quizStatsHtml() + ' '
: '
' + quizClockHtml() + ' ' +
'
' +
icon(quiz.paused ? 'play' : 'pause') + ' ' +
'
交卷 ';
return '';
}
function quizSheetHtml() {
var seq = navIdxs();
return '
' +
(quiz.submitted ? '
只看错题' : '') +
'
答题卡
' +
// 格子跟着那份序列收窄:勾了「只看错题」还画整张答题卡,就等于只筛了内容没筛答题卡
'
' + seq.map(function (i) {
return '' + (i + 1) + ' ';
}).join('') + ' ';
}
function quizTips() {
return '
共 ' + quiz.list.length + ' 题 请按每道题的答题要求作答
';
}
function quizFootHtml() {
return '';
}
function quizBodyHtml(inner) {
return quizHeadHtml() + '
' + quizSheetHtml() +
'
' +
quizTips() + inner + '
' + quizFootHtml();
}
function doingHtml() {
var q = quiz.list[quiz.idx];
var sel = quiz.answers[q.id] || [];
var multi = ansKey(q.correct_answer).length > 1;
var opts = (q.opt || []).map(function (x) {
return '
' +
'
' +
'
' + esc(x.label) + '.
' +
'
' + wakeImgs(x.content || '') + '
';
}).join('');
return quizBodyHtml(
'
' +
'
' +
'
' +
'
(' + (multi ? '多选' : '单选') + ')
' +
(q.material ? '
' + wakeImgs(q.material) + '
' : '') +
'
' + wakeImgs(q.content || '') + '
' +
'
' + (opts || '
该题型无选项
') + '
' +
'
');
}
function reportHtml() {
var q = quiz.list[quiz.idx];
var ans = quiz.answers[q.id] || [];
var mine = ansKey(ans);
var spent = quiz.spent[q.id];
return quizBodyHtml(
questionCardHtml(q, {
cls: 'gth-qcard', no: quiz.idx + 1,
kind: ansKey(q.correct_answer).length > 1 ? '多选' : '单选',
time: spent >= 1000 ? icon('clock') + fmtTime(spent) : '',
mine: mine, mineOk: isRight(q), picked: ans
}) +
'
');
}
function bindQuiz() {
var q = quiz.list[quiz.idx];
bindAnaToggle(quizEl);
$$('.gthq-cell', quizEl).forEach(function (el) {
el.addEventListener('click', function () { goIdx(Number(el.dataset.i)); });
});
if (!quiz.submitted) {
$$('.gth-choice', quizEl).forEach(function (el) {
el.addEventListener('click', function () {
toggleAnswer(q.id, el.dataset.label, ansKey(q.correct_answer).length > 1);
});
});
var mk = $('#gth-mark', quizEl);
if (mk) mk.addEventListener('click', function () { toggleMark(q.id); });
var pb = $('#gth-pause', quizEl);
if (pb) pb.addEventListener('click', function () {
quiz.paused = !quiz.paused;
if (!quiz.paused) quiz.lastAt = Date.now();
renderQuiz();
});
}
var prev = $('#gth-prev', quizEl);
if (prev) prev.addEventListener('click', function () { stepIdx(-1); });
var next = $('#gth-next', quizEl);
if (next) next.addEventListener('click', function () { stepIdx(1); });
var submit = $('#gth-submit', quizEl);
if (submit) submit.addEventListener('click', function () { submitQuiz(false); });
var exit = $('#gth-exit', quizEl);
if (exit) exit.addEventListener('click', closeQuiz);
var ow = $('#gth-onlywrong', quizEl);
if (ow) ow.addEventListener('change', function () {
quiz.onlyWrong = ow.checked;
goIdx(quiz.idx);
});
bindNoteAreas(quizEl);
var cb = $('[data-hls]', quizEl);
if (cb) refreshQuizHl();
$$('.gth-qcard[data-gth-qid] [data-act="collect"]', quizEl).forEach(function (btn) {
var item = btn.closest('.gth-qcard');
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);
markPracticed(q.id);
});
saveStore();
}
var MOCK_QID_KEYS = ['id', 'question_id', 'subject_id', 'qid', 'content_id'];
var MOCK_STEM_KEYS = ['content', 'stem', 'question', 'title', 'topic'];
var MOCK_MAT_KEYS = ['material', 'materials'];
var MOCK_OPT_KEYS = ['opt', 'option', 'options', 'opt_list', 'option_list'];
var MOCK_USER_KEYS = ['userAnswers', 'user_answer', 'my_answer', 'self_answer', 'user_ans', 'answer'];
var MOCK_CORRECT_KEYS = ['correct_answer', 'right_answer', 'true_answer', 'correct', 'answer_right'];
var MOCK_ANALYSIS_KEYS = ['analysis', 'analysis_shadow', 'explain', 'explanation'];
var MOCK_SUBJECT_KEYS = ['content_type', 'subject_type', 'subject'];
var MOCK_RESULT_KEYS = ['result'];
var MOCK_MODULE_KEYS = ['examPointName', 'exam_point', 'check_point'];
function mockModuleOf(raw) {
var v = pickKey(raw, MOCK_MODULE_KEYS);
return v == null ? '' : String(v).trim();
}
function mockResultOf(raw) {
var r = pickKey(raw, MOCK_RESULT_KEYS);
if (typeof r === 'string' && /^-?\d+$/.test(r)) r = Number(r);
return (r === 0 || r === 1 || r === 2) ? r : (typeof r === 'number' && isFinite(r) ? r : null);
}
function isMocksPage() {
return /\/wxpage\/tiku\/mocks\/index\.html/.test(location.pathname);
}
function mockExamIdOf() {
var m = /(\d{3,})/.exec(location.hash || '');
return m ? m[1] : '';
}
function pickKey(obj, keys) {
for (var i = 0; i < keys.length; i++) {
var v = obj[keys[i]];
if (v != null && v !== '') return v;
}
return undefined;
}
function looksLikeQuestion(o) {
if (!o || typeof o !== 'object' || Array.isArray(o)) return false;
var id = pickKey(o, MOCK_QID_KEYS);
if (id === undefined || !/^\d+$/.test(String(id))) return false;
var stem = pickKey(o, MOCK_STEM_KEYS);
if (!stem || !String(stem).trim()) return false;
var ans = pickKey(o, MOCK_CORRECT_KEYS);
var ana = pickKey(o, MOCK_ANALYSIS_KEYS);
return (ans !== undefined && ans !== '') || (ana !== undefined && ana !== '');
}
function mockParseAns(v) {
if (v == null || v === '') return '';
if (Array.isArray(v)) return v;
if (typeof v === 'string' && /^[\[{"]/.test(v)) {
try { return JSON.parse(v); } catch (e) {}
}
return v;
}
function mockParseOpt(v) {
if (v == null || v === '') return [];
if (typeof v === 'string') {
if (!/^[\[{]/.test(v)) return [];
try { v = JSON.parse(v); } catch (e) { return []; }
}
if (!Array.isArray(v)) {
if (typeof v === 'object') {
return Object.keys(v).map(function (k) {
return { label: String(k).toUpperCase(), content: v[k] == null ? '' : String(v[k]) };
});
}
return [];
}
return v.map(function (o) {
if (typeof o === 'string') {
var m = /^([A-Za-z])[.、.::\s]\s*(.*)$/.exec(o.trim());
return m ? { label: m[1].toUpperCase(), content: m[2] } : { label: '', content: o };
}
if (o && typeof o === 'object') {
var label = pickKey(o, ['label', 'name', 'key', 'option', 'id']);
var content = pickKey(o, ['content', 'value', 'text', 'title', 'desc']);
return {
label: label == null ? '' : String(label).toUpperCase(),
content: content == null ? '' : String(content)
};
}
return { label: '', content: String(o) };
});
}
function mockFieldMap(raw) {
if (!looksLikeQuestion(raw)) return null;
var q = {
id: pickKey(raw, MOCK_QID_KEYS),
content: String(pickKey(raw, MOCK_STEM_KEYS) || ''),
material: String(pickKey(raw, MOCK_MAT_KEYS) || ''),
opt: mockParseOpt(pickKey(raw, MOCK_OPT_KEYS)),
correct_answer: mockParseAns(pickKey(raw, MOCK_CORRECT_KEYS)),
user_answer: mockParseAns(pickKey(raw, MOCK_USER_KEYS)),
analysis: String(pickKey(raw, MOCK_ANALYSIS_KEYS) || ''),
content_type: null,
module: mockModuleOf(raw),
result: mockResultOf(raw)
};
var ct = pickKey(raw, MOCK_SUBJECT_KEYS);
if (typeof ct === 'number' || (typeof ct === 'string' && /^\d+$/.test(ct))) q.content_type = Number(ct);
return q;
}
var mockSampleLogged = false;
function logMockSample(raw) {
if (mockSampleLogged) return;
mockSampleLogged = true;
try {
console.log('[错题助手] mocks 题目字段:' + Object.keys(raw).join(', '));
console.log('[错题助手] mocks 样本:' + JSON.stringify(raw).slice(0, 1500));
} catch (e) {}
}
function dedupeById(list) {
var seen = {}, out = [];
list.forEach(function (q) {
if (!q || q.id == null) return;
var k = String(q.id);
if (seen[k]) return;
seen[k] = 1;
out.push(q);
});
return out;
}
function harvestFromScope(root) {
var ng = getAngular();
if (!ng) return [];
var out = [];
$$('[ng-repeat],[data-ng-repeat]', root).forEach(function (node) {
var attr = node.getAttribute('ng-repeat') || node.getAttribute('data-ng-repeat') || '';
var m = /([A-Za-z_$][\w$]*)\s+in\s+/.exec(attr);
var it = null;
try {
var sc = ng.element(node).scope();
if (!sc) return;
if (m && sc[m[1]]) it = sc[m[1]];
if (!it) it = sc.item || sc.q || sc.question || null;
} catch (e) { return; }
if (!it || typeof it !== 'object') return;
var q = mockFieldMap(it);
if (q) { out.push(q); logMockSample(it); }
});
return dedupeById(out);
}
var mockSniffCache = {};
var mockSniffOrder = [];
var mockSniffHooked = false;
function mockSniffRemember(q) {
var k = String(q.id);
if (!mockSniffCache[k]) {
mockSniffOrder.push(k);
if (mockSniffOrder.length > 3000) delete mockSniffCache[mockSniffOrder.shift()];
}
mockSniffCache[k] = q;
}
function mockSniffed() {
return mockSniffOrder.map(function (k) { return mockSniffCache[k]; });
}
function sniffJson(j, depth) {
if (!j || typeof j !== 'object' || depth > 6) return;
if (Array.isArray(j)) {
var hit = 0;
for (var i = 0; i < j.length; i++) {
if (j[i] && typeof j[i] === 'object' && looksLikeQuestion(j[i])) hit++;
}
if (j.length && hit / j.length >= 0.6) {
j.forEach(function (o) {
var q = mockFieldMap(o);
if (q) { mockSniffRemember(q); logMockSample(o); }
});
} else {
j.forEach(function (o) { sniffJson(o, depth + 1); });
}
return;
}
if (looksLikeQuestion(j)) {
var q = mockFieldMap(j);
if (q) { mockSniffRemember(q); logMockSample(j); }
return;
}
Object.keys(j).forEach(function (k) { sniffJson(j[k], depth + 1); });
}
function hookXhrSniffer() {
try {
var w = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
var proto = w.XMLHttpRequest && w.XMLHttpRequest.prototype;
if (!proto || proto.__gthSniffed) return;
proto.__gthSniffed = true;
var origOpen = proto.open;
proto.open = function (method, url) {
this.__gthUrl = url;
return origOpen.apply(this, arguments);
};
var origSend = proto.send;
proto.send = function () {
var self = this;
this.addEventListener('load', function () {
try {
var t = self.responseText || '';
if (t.length > 200 && t.length < 3000000 &&
/analysis|subject|question|correct_answer|opt/i.test(t)) {
sniffJson(JSON.parse(t), 0);
}
} catch (e) {}
});
return origSend.apply(this, arguments);
};
mockSniffHooked = true;
} catch (e) { }
}
function mergeMockQs(list, examId) {
if (!examId) examId = 'unknown';
if (!store.mocks[examId]) store.mocks[examId] = { at: Date.now(), processed: {} };
if (!store.mocks[examId].processed) store.mocks[examId].processed = {};
var reg = store.mocks[examId];
var added = 0, wrongNew = 0, changed = false;
list.forEach(function (q) {
var k = String(q.id);
var done, ok;
if (q.result !== null) {
done = q.result === 2 ? 0 : 1;
ok = (q.result === 0 || q.result === 2) ? 0 : 1;
} else {
done = (ansKey(q.correct_answer) && ansKey(q.user_answer)) ? 1 : 0;
ok = (done && ansKey(q.user_answer) === ansKey(q.correct_answer)) ? 1 : 0;
}
var cur = store.mockQs[k];
if (!cur) {
if (store.notes[k] || store.mastered[k]) {
console.warn('[错题助手] 模考题 id 与已有练习题记录撞键:' + k + '(请核对内容是否同一题)');
}
store.mockQs[k] = {
id: q.id, content: q.content, material: q.material, opt: q.opt,
correct_answer: q.correct_answer, user_answer: done ? q.user_answer : '',
analysis: q.analysis, content_type: q.content_type, module: q.module || '',
examId: examId, done: done, ok: ok, at: Date.now()
};
cur = store.mockQs[k];
added++; changed = true;
} else {
var touched = false;
if (!cur.done && done) {
cur.user_answer = q.user_answer;
cur.done = done; cur.ok = ok;
touched = true;
}
if (!cur.module && q.module) { cur.module = q.module; touched = true; }
if (touched) { cur.at = Date.now(); changed = true; }
}
if (done && !ok && !reg.processed[k]) {
reg.processed[k] = 1;
bumpWrongCount({ id: q.id }, false);
wrongNew++; changed = true;
}
});
reg.at = Date.now();
if (changed) saveStore();
return { added: added, wrongNew: wrongNew };
}
function mockMatchModule(m, mods) {
return !mods || !mods.length || mods.indexOf(m.module || MOCK_UNCLS) >= 0;
}
function mockToList(onlyWrong, mods) {
var out = [];
Object.keys(store.mockQs).forEach(function (k) {
var m = store.mockQs[k];
if (onlyWrong && !(m.done && !m.ok)) return;
if (!mockMatchModule(m, mods)) return;
out.push({
id: m.id, content: m.content, material: m.material, opt: m.opt,
correct_answer: m.correct_answer, user_answer: m.user_answer,
analysis: m.analysis, content_type: m.content_type, module: m.module || '',
_mock: m.examId
});
});
return out;
}
function mockAllList(mods) { return mockToList(false, mods); }
function mockWrongList(mods) { return mockToList(true, mods); }
function mockModuleOptions() {
var by = {}, out = [];
Object.keys(store.mockQs).forEach(function (k) {
var name = store.mockQs[k].module || MOCK_UNCLS;
if (!by[name]) { by[name] = { name: name, n: 0 }; out.push(by[name]); }
by[name].n++;
});
out.sort(function (a, b) { return b.n - a.n || (a.name < b.name ? -1 : 1); });
return out;
}
var MOCK_COLLECT_CAP = 300;
var mockCollecting = false;
function examTypeFrom(search, hash) {
var m = /exam_type=(\d+)/.exec(search || '') || /exam_type=(\d+)/.exec(hash || '');
return m ? Number(m[1]) : null;
}
function subjectFromUrl(search, hash) {
var t = examTypeFrom(search, hash);
return (t === SUBJ_XC || t === SUBJ_ZY) ? t : null;
}
function routeExamType() {
return isListRoute() ? subjectFromUrl(location.search, location.hash) : null;
}
function subjectOf(it) {
var t = it ? it.content_type : null;
return t != null ? t : routeExamType();
}
function mockExamType() {
var t = examTypeFrom(location.search, location.hash);
return t == null ? '1' : String(t);
}
function mockStatus(html) {
var st = $('#gth-mock-status');
if (st) st.innerHTML = html;
}
function mockSheetItems() {
var ng = getAngular();
if (!ng) return [];
var out = [], seen = {};
findByNgRepeat('item', 'jiexieSubjects').forEach(function (node) {
var it;
try { it = ng.element(node).scope().item; } catch (e) { return; }
if (!it || typeof it !== 'object' || !/^\d+$/.test(String(it.id || ''))) return;
if (seen[String(it.id)]) return;
seen[String(it.id)] = 1;
out.push(it);
});
return out;
}
function apiSubjectFace(id, t) {
return apiGet(API_V2 + 'tiku/' + t + '/' + id + '/analysis', { id: id, exam_type: t });
}
function fetchMockSubject(id) {
return apiSubjectFace(id, mockExamType());
}
function collectMockWrong() {
if (mockCollecting) { mockStatus('这一轮还在补题面,等它跑完再点。'); return; }
var examId = mockExamIdOf();
var items = mockSheetItems();
if (!items.length) { mockStatus('这一页还没有题目列表:先进「全部解析」或「错题解析」再收。'); return; }
var wrongAll = items.filter(function (it) { return it.result === 0; });
var need = wrongAll.filter(function (it) {
var cur = store.mockQs[String(it.id)];
return !(cur && cur.content);
}).slice(0, MOCK_COLLECT_CAP);
if (!need.length) {
mockStatus('本场答错
' + wrongAll.length + ' 题,题面都已收录,不用补。');
return;
}
mockCollecting = true;
var allBtn = $('#gth-mock-all');
if (allBtn) allBtn.disabled = true;
var doneN = 0, okN = 0, failN = 0, i = 0, batch = [], firstErr = '';
function flush() { if (batch.length) { mergeMockQs(batch, examId); batch = []; } }
function step() {
if (i >= need.length) {
flush();
mockCollecting = false;
if (allBtn) allBtn.disabled = false;
mocksScanTick();
mockStatus('补题面结束:成功
' + okN + ' 题,失败
' + failN + ' 题。' +
(firstErr ? '
首个失败原因:' + esc(firstErr) : '答错的已并入错题本,回错题页即可导出 / 重练。'));
return;
}
var it = need[i++];
fetchMockSubject(it.id).then(function (data) {
var raw = Object.assign({}, data || {}, {
result: it.result, userAnswers: it.userAnswers, examPointName: it.examPointName
});
var q = mockFieldMap(raw);
if (q) { batch.push(q); okN++; } else { failN++; if (!firstErr) firstErr = '题目形状不符(缺题干或答案)'; }
})['catch'](function (e) {
failN++;
if (!firstErr) firstErr = (e && e.message) || String(e);
}).then(function () {
doneN++;
if (batch.length >= 10) flush();
mockStatus('正在补题面 ' + doneN + ' / ' + need.length + '(失败 ' + failN + ')…');
setTimeout(step, 180);
});
}
step();
}
var mockPanelBuilt = false;
function buildMocksUI() {
if (mockPanelBuilt) return;
mockPanelBuilt = true;
GM_addStyle([
'#gth-mock-btn{position:fixed;right:18px;bottom:18px;z-index:100000;display:flex;align-items:center;gap:7px;',
'height:var(--gth-ctl-h,38px);padding:0 16px;border-radius:999px;',
'background:var(--gth-primary,#0f172a);color:var(--gth-primary-fg,#f8fafc);font-size:13px;font-weight:600;',
'cursor:pointer;box-shadow:0 4px 16px rgba(15,23,42,.25);user-select:none;}',
'#gth-mock-btn:hover{background:var(--gth-primary-hover,#1e293b);}',
'#gth-mock-btn .gth-ic svg{width:15px;height:15px;}',
'#gth-mock-panel{position:fixed;right:18px;bottom:66px;z-index:100001;width:268px;background:var(--gth-bg,#fff);',
'color:var(--gth-fg,#0f172a);border:1px solid var(--gth-border,#e2e8f0);border-radius:8px;',
'box-shadow:0 12px 40px rgba(15,23,42,.18);padding:14px;font-size:13px;line-height:1.6;}',
'#gth-mock-panel .gthm-head{display:flex;align-items:center;gap:6px;font-weight:600;margin-bottom:8px;}',
'#gth-mock-panel .gthm-head .gth-ic svg{width:16px;height:16px;}',
'#gth-mock-panel #gth-mock-close{margin-left:auto;border:0;background:none;cursor:pointer;',
'color:var(--gth-muted,#64748b);display:flex;padding:2px;border-radius:6px;}',
'#gth-mock-panel #gth-mock-close:hover{background:var(--gth-hover,#f1f5f9);}',
'#gth-mock-panel #gth-mock-close .gth-ic svg{width:14px;height:14px;}',
'#gth-mock-panel .gthm-status{background:var(--gth-subtle,#f8fafc);border:1px solid var(--gth-border,#e2e8f0);',
'border-radius:8px;padding:8px 10px;margin-bottom:10px;}',
'#gth-mock-panel .gthm-status b{font-weight:600;}',
'#gth-mock-panel .gthm-btn{width:100%;height:var(--gth-ctl-h,38px);display:flex;align-items:center;justify-content:center;',
'gap:6px;border:1px solid var(--gth-border-strong,#cbd5e1);background:var(--gth-bg,#fff);color:var(--gth-fg,#0f172a);',
'border-radius:var(--gth-ctl-r,8px);cursor:pointer;font-size:13px;}',
'#gth-mock-panel .gthm-btn:hover{background:var(--gth-hover,#f1f5f9);}',
'#gth-mock-panel .gthm-btn:disabled{opacity:.5;cursor:not-allowed;background:var(--gth-bg,#fff);}',
'#gth-mock-panel .gthm-btn .gth-ic svg{width:14px;height:14px;}',
'#gth-mock-panel .gthm-hint{margin-top:10px;font-size:12px;color:var(--gth-muted,#64748b);}'
].join('\n'));
var btn = document.createElement('div');
btn.id = 'gth-mock-btn';
btn.innerHTML = icon('bookOpen') + '
模考收录 ';
var panel = document.createElement('div');
panel.id = 'gth-mock-panel';
panel.hidden = true;
panel.innerHTML = [
'
' + icon('bookOpen') + '全真模考收录 ',
'' + icon('x') + '
',
'
正在检测页面…
',
'
' + icon('download') + '收齐全场答错题 ',
'
重新扫描本页 ',
'
站点的解析页只把「当前这一题」的题面加载出来,答错的题要靠上面那个按钮逐题补齐(走站点自己的题目解析接口)。不点的话,翻到哪题收哪题。收录后回错题页即可导出 / 重练。
'
].join('');
document.body.appendChild(btn);
document.body.appendChild(panel);
btn.addEventListener('click', function () {
panel.hidden = !panel.hidden;
if (!panel.hidden) mocksScanTick();
});
$('#gth-mock-close', panel).addEventListener('click', function () { panel.hidden = true; });
$('#gth-mock-scan', panel).addEventListener('click', mocksScanTick);
$('#gth-mock-all', panel).addEventListener('click', collectMockWrong);
}
function mocksScanTick() {
if (!isMocksPage()) return;
buildMocksUI();
var examId = mockExamIdOf();
var list = harvestFromScope(document);
if (!list.length) list = mockSniffed();
var r = list.length ? mergeMockQs(list, examId) : null;
var total = 0, wrong = 0;
Object.keys(store.mockQs).forEach(function (k) {
total++;
if (store.mockQs[k].done && !store.mockQs[k].ok) wrong++;
});
var st = $('#gth-mock-status');
if (st) {
st.innerHTML = (examId ? '场次 #' + esc(examId) : '未识别场次(仍可收录)') +
'
已收录
' + total + ' 题 · 错题
' + wrong + ' 题' +
(r && r.added ? '
本次新增 ' + r.added + ' 题(错题 ' + r.wrongNew + ')' : '') +
(list.length ? '' : '
本页暂未检测到题目,进入解析页后再试');
}
}
function isZyPage() { return /\/wxpage\/tiku\/gongan\/zhuanxiang\.html$/.test(location.pathname); }
function zyUrlSubmitted() {
var s = new URLSearchParams(location.search);
return s.get('status') === 'finished' && !!s.get('submit_id');
}
function zyScopeSubmitted() {
var ng = getAngular();
if (!ng) return false;
var root;
try { root = ng.element(document.querySelector('[ng-view]') || document.body).scope(); } catch (e) { return false; }
if (!root) return false;
while (root.$parent) root = root.$parent;
var hit = false, seen = 0;
(function walk(sc, d) {
if (!sc || d > 14 || hit || seen++ > 400) return;
if (sc.isAlreadySubmit === true) { hit = true; return; }
var kid = sc.$$childHead;
while (kid) { walk(kid, d + 1); if (hit) return; kid = kid.$$nextSibling; }
})(root, 0);
return hit;
}
function analysisReady() {
return isZyPage() && zyUrlSubmitted() && zyScopeSubmitted();
}
function isZyAnalysis() { return isZyPage() && analysisReady(); }
var zyGateWarned = false;
function zyGateWarn() {
if (zyGateWarned || !isZyPage() || !zyUrlSubmitted()) return;
zyGateWarned = true;
console.warn('[错题助手] 专项页 URL 说是交卷态,但 scope 上没等到 isAlreadySubmit,本页先不挂笔记入口');
}
// 站点那条 ng-repeat 表达式是我们认题目的唯一依据。整串等值匹配(CSS 的 [ng-repeat="a in b"])
// 会被排版打死:多一个空格、或换成正统的 data-ng-repeat 写法,就一颗都认不出,而且一声不响。
// 所以解析只留这一份——两种写法都读、空格折掉,比的是「变量名 + 表达式」这两件事实。
function ngRepeatOf(node) {
var attr = node.getAttribute('ng-repeat') || node.getAttribute('data-ng-repeat') || '';
var m = /^\s*([A-Za-z_$][\w$]*)\s+in\s+(.+?)\s*$/.exec(attr);
if (!m) return null;
return { name: m[1], src: m[2].replace(/\s+/g, '') };
}
function ngRepeatIs(node, name, src) {
var r = ngRepeatOf(node);
return !!r && r.name === name && r.src === src;
}
// 全页扫一遍再按表达式筛:列表页上一共只有 4 条 ng-repeat,扫得起;
// 而等值选择器一旦不匹配,代价是整块功能静默消失。
function findByNgRepeat(name, src) {
return $$('[ng-repeat],[data-ng-repeat]').filter(function (n) {
return ngRepeatIs(n, name, src);
});
}
function analysisNodeVar(node) {
var r = ngRepeatOf(node);
if (!r) return null;
if (r.name === 'subjectObj' && /currentPartData\.subjectList$/.test(r.src)) return r.name;
return null;
}
function analysisScopeItem(sc, name) {
if (!sc || !name) return null;
var it = sc[name];
if (!it || typeof it !== 'object' || !looksLikeQuestion(it)) return null;
return it;
}
function analysisNodes() {
var ng = getAngular(), out = [];
if (!ng) return out;
$$('[ng-repeat],[data-ng-repeat]').forEach(function (node) {
var name = analysisNodeVar(node);
if (!name) return;
var sc = null;
try { sc = ng.element(node).scope(); } catch (e) { return; }
var it = analysisScopeItem(sc, name);
if (!it) return;
out.push({ node: node, item: it });
});
return out;
}
function bindHlRows(scopeEl, id) {
$$('.gth-hlp-item .rm', scopeEl).forEach(function (rm) {
rm.addEventListener('click', function () {
hlRemove(id, Number(rm.closest('.gth-hlp-item').dataset.hl));
toast('已取消划线');
afterHlChange();
});
});
$$('.gth-hlp-item .t, .gth-hlp-item .n', scopeEl).forEach(function (node) {
node.addEventListener('click', function () {
openHlNote(id, Number(node.closest('.gth-hlp-item').dataset.hl), node.getBoundingClientRect());
});
});
}
// 笔记编辑区:预览态(正文 + 「编辑」)与编辑态(文本框 + 「保存 / 取消」)两段 HTML;
// 两态的切换自己重画自己那一小块,不牵连外面的卡片与浮窗。解析页那扇浮窗与重练报告的卡片笔记
// 共用这一份(2026-10-08 用户口径:编辑模式要有保存、取消;写了才知道存没存的那套不收)。
function noteAreaHtml(id, mode) {
if (mode === 'edit') {
return '
' +
'
' +
'' + icon('check') + '保存 ' +
'取消
';
}
var text = getNote(id);
if (!text) return '
+ 添加笔记
';
return '
' + esc(text) + '
' +
'
' +
icon('pencil') + '编辑
';
}
function noteExcerpt(id) {
var s = snapOf(id), raw = itemsById[id];
return stripHtml((s && s.content) || (raw && raw.content) || '').slice(0, 240);
}
function bindNoteArea(el) {
var id = String(el.getAttribute('data-note-area'));
var subj = el.getAttribute('data-subj');
function paint(mode) {
el.setAttribute('data-mode', mode);
el.innerHTML = noteAreaHtml(id, mode);
bindNoteArea(el);
var ta = $('[data-note]', el);
if (ta) ta.focus();
}
var ed = $('[data-act="edit"]', el);
if (ed) ed.addEventListener('click', function () { paint('edit'); });
var ta = $('[data-note]', el);
if (!ta) return;
$('[data-act="save"]', el).addEventListener('click', function () {
setNote(id, ta.value.trim(), {
snapshot: noteExcerpt(id),
subject: subj == null || subj === '' ? undefined : Number(subj)
});
refreshBadges();
paint('view');
});
$('[data-act="cancel"]', el).addEventListener('click', function () { paint('view'); });
}
function bindNoteAreas(root) { $$('[data-note-area]', root).forEach(bindNoteArea); }
// 笔记面板只有一扇门:小条上那颗「笔记」键。开着的再点一次是收起,点空白处也收起;
// 编辑态下这两条都等于「取消」,草稿不写库(2026-10-08 用户口径)。
function closeAbarPanel(box) {
if (box && box.parentNode) box.parentNode.removeChild(box);
}
function toggleAbarNote(bar, id) {
var box = $('.gth-apanel', bar);
if (box) { closeAbarPanel(box); return; }
openAbarPanel(bar, String(id), getNote(id) ? 'view' : 'edit');
}
document.addEventListener('mousedown', function (e) {
var box = $('.gth-apanel');
if (!box) return;
var t = e.target;
if (t && t.closest && (t.closest(HL_BLOCK_SEL) || t.closest('#gth-hlnote,#gth-hlmenu'))) return;
closeAbarPanel(box);
}, true);
function abarHtml(id, unit) {
var hls = countHighlights(id);
var got = !!(store.snaps[id] || store.mockQs[id]);
var isMat = !!unit;
var sec = qtimeSec(unit || id);
return '
' +
icon('pencil') + '笔记 ' +
'
' + icon('copy') + '复制题目 ' +
'
' + icon('highlighter') + '整理本题 ' +
'
' +
(got ? icon('check') + '已收录' : icon('warn') + '未收录') + ' ' +
(hls ? '
' + icon('highlighter') + hls + ' 划 ' : '') +
(sec ? '
' +
icon('clock') + (isMat ? '本材料 ' : '') + qtimeText(sec) + ' ' : '');
}
function abarPanelHtml(id, mode) {
return '
' + icon('pencil') + '笔记' +
' ' +
'' +
icon('x') + '收起
' +
'
' +
'
' +
noteAreaHtml(id, mode) + '
';
}
function abarSig(id, unit) {
var isMat = !!unit;
return String(id) + '|' + (store.notes[id] ? 1 : 0) + '|' + countHighlights(id) +
'|' + ((store.snaps[id] || store.mockQs[id]) ? 1 : 0) + '|' +
(isMat ? 'M' + qtimeSec(unit) : qtimeSec(id));
}
function bindAbar(bar, node, id, unit) {
bar.setAttribute('data-sig', abarSig(id, unit));
bar.innerHTML = abarHtml(id, unit);
$('[data-act="note"]', bar).addEventListener('click', function () { toggleAbarNote(bar, id); });
$('[data-act="collect"]', bar).addEventListener('click', function () { openCollect([String(id)]); });
$('[data-act="copy"]', bar).addEventListener('click', function () {
var q = snapOf(id) || (itemsById[id] ? snapCopy(itemsById[id]) : null);
if (q) copyWithToast(copyQuestion(q));
else toast('复制失败:这道题的题面还没补回来');
});
}
function openAbarPanel(bar, id, mode) {
if (!bar) return;
id = String(id);
if (mode !== 'edit' && mode !== 'view') mode = getNote(id) ? 'view' : 'edit';
var box = $('.gth-apanel', bar);
if (!box) {
box = document.createElement('div');
box.className = 'gth-apanel';
bar.appendChild(box);
}
box.innerHTML = abarPanelHtml(id, mode);
refreshQuizHl();
bindHlRows(box, id);
bindNoteAreas(box);
var cl = $('[data-act="close"]', box);
if (cl) cl.addEventListener('click', function () { closeAbarPanel(box); });
var ta = $('[data-note]', box);
if (ta) ta.focus();
}
var analysisLogTag = '';
var QTIME_KEY = 'gth_qtime_' + CTX;
var qtimeErr = '';
function qtimeExamId() {
var out = '';
try {
out = new URLSearchParams(location.search).get('exam_id') || '';
var h = String(location.hash || '');
var i = h.indexOf('?');
var fromHash = i < 0 ? '' : new URLSearchParams(h.slice(i + 1)).get('exam_id');
if (fromHash) out = fromHash;
} catch (e) { qtimeErr = '读不出场次号:' + (e && e.name ? e.name : String(e)); }
return out || '';
}
function qtimeAll() {
var raw = '';
try { raw = localStorage.getItem(QTIME_KEY) || ''; }
catch (e) { qtimeErr = '用时记录读不出(当作没有):' + (e && e.name ? e.name : String(e)); return {}; }
if (!raw) return {};
try { return JSON.parse(raw) || {}; }
catch (e) { qtimeErr = '用时记录不是合法 JSON(当作没有)'; return {}; }
}
function qtimeSession() {
var id = qtimeExamId();
if (!id) return null;
var all = qtimeAll();
var r = all[id];
return r && typeof r === 'object' ? r : null;
}
function qtimeSec(qid) {
var r = qtimeSession();
if (!r || !r.q) return 0;
var v = r.q[String(qid)];
return typeof v === 'number' && v > 0 ? v : 0;
}
function qtimeText(s) {
if (!(s > 0)) return '--:--';
var h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), sec = s % 60;
return (h ? pad2(h) + ':' : '') + pad2(m) + ':' + pad2(sec);
}
function qtimeOrder() {
var ng = getAngular(), out = [];
if (!ng) return out;
var nodes = analysisNodes();
if (!nodes.length) return out;
var sc;
try { sc = ng.element(nodes[0].node).scope(); }
catch (e) { qtimeErr = '题目节点上取不到 scope,用题号顺序兜底'; return out; }
var ls = sc && sc.localStorage;
var list = ls && ls.currentPartData && ls.currentPartData.subjectList;
if (!list || !list.length) { qtimeErr = '这一页没给出题目顺序,用题号顺序兜底'; return out; }
list.forEach(function (it) {
if (!it || !(it.id || it.id === 0)) return;
var mat = it.opts && it.opts.length > 1 ? it.opts : null;
out.push({
id: String(it.id), no: it.index,
from: mat ? mat[0].index : null,
to: mat ? mat[mat.length - 1].index : null,
size: mat ? mat.length : 1
});
});
return out;
}
function qtimeRows(r) {
var order = qtimeOrder(), out = [], seen = {};
order.forEach(function (o) {
var s = r.q && r.q[o.id] || 0;
if (!s) return;
seen[o.id] = 1;
out.push({ id: o.id, no: o.no, from: o.from, to: o.to, size: o.size, sec: s });
});
Object.keys(r.q || {}).forEach(function (k) {
if (seen[k] || !(r.q[k] > 0)) return;
out.push({ id: k, no: null, from: null, to: null, size: 1, sec: r.q[k] });
});
if (!order.length) out.sort(function (a, b) { return Number(a.id) - Number(b.id); });
return out;
}
function recapSig(r) {
var parts = [];
Object.keys(r.q || {}).forEach(function (k) { parts.push(k + ':' + r.q[k]); });
return parts.join(',') + '|' + (r.paused || 0) + '|' + (r.lost || 0) + '|' + (r.merged || 0);
}
function qtimeBandHtml(r) {
var rows = qtimeRows(r);
var done = 0, cover = 0, mats = 0;
rows.forEach(function (x) {
done += x.sec;
cover += x.size;
if (x.from != null) mats++;
});
var count = mats ? cover + ' 题 · ' + mats + ' 段材料' : cover + ' 题';
var html = '
' +
icon('clock') + '本场做题用时 ' + qtimeText(done) + '(' + count + ')' +
'' + icon('chevronDown') + ' ';
rows.forEach(function (x) {
var no = x.from != null ? '第 ' + esc(x.from) + '–' + esc(x.to) + ' 题'
: (x.no == null ? '题 ' + esc(x.id) : '第 ' + esc(x.no) + ' 题');
html += '
' +
'' + no + ' ' +
'' + qtimeText(x.sec) + '
';
});
return html + '
';
}
var qtimeLogTag = '';
function qtimeSay(what) {
if (qtimeLogTag === what) return;
qtimeLogTag = what;
console.log('[错题助手] 用时:' + what);
}
function zyTopHost(node) {
var box = node && node.closest ? node.closest('.question-box') : null;
if (box) return box;
if (node && node.parentNode) {
qtimeSay('这一屏没有 .question-box,用时入口退到题目节点的父容器');
return node.parentNode;
}
qtimeSay('这一屏没找到题目节点,不挂用时入口');
return null;
}
function zyTopBar(host) {
var top = $('[data-gth-ztop]', host);
if (!top) {
top = document.createElement('div');
top.className = 'gth-ztop';
top.setAttribute('data-gth-ztop', '1');
host.insertBefore(top, host.firstChild);
}
return top;
}
function mountQtimeBand(host) {
if (!host) return;
var top = zyTopBar(host);
var r = qtimeSession();
var band = $('[data-gth-qtime]', top);
if (!r) {
if (band) band.remove();
qtimeSay('场次 ' + (qtimeExamId() || '未识别') + ' 本机没有计时记录,这一页不挂用时入口');
return;
}
var sig = qtimeExamId() + '|' + recapSig(r);
if (band && band.getAttribute('data-sig') === sig) return;
if (!band) {
band = document.createElement('div');
band.className = 'gth-qtime';
band.setAttribute('data-gth-qtime', '1');
top.insertBefore(band, top.firstChild);
}
band.setAttribute('data-sig', sig);
band.innerHTML = qtimeBandHtml(r);
qtimeSay('已挂上,场次 ' + qtimeExamId() + ' 记到 ' + qtimeRows(r).length + ' 题');
var btn = $('[data-act="qtime-toggle"]', band);
if (btn) btn.addEventListener('click', function () {
band.classList.toggle('on');
});
}
// 点空白收起:与解析页那扇笔记浮窗同一口径。只由那颗键收回的话,展开的卡片就赖在屏幕右上角,
// 用户点到哪儿都收不掉(2026-10-09 报的「卡片展开后无法收回」)
document.addEventListener('click', function (e) {
var t = e.target;
if (t && t.closest && t.closest('.gth-qtime')) return;
$$('.gth-qtime.on').forEach(function (b) { b.classList.remove('on'); });
}, true);
// 材料组卷面(资料分析):答题态一屏摊的是整段材料,导航只在材料首题号之间跳,
// 所以账按材料记、键落在材料首题 id 上;展示端认出 opts 有多个小题就写区间,
// 不拆分也不均摊(ADR 0007 决策 8)。非材料组卷面下每题自己就是一个单位。
function unitIdOf(it) {
var opts = it && it.opts;
if (!opts || opts.length < 2) return '';
var head = opts[0];
if (!head || !(head.id || head.id === 0)) return '';
return String(head.id);
}
function injectAnalysisUI() {
if (!analysisReady()) { zyGateWarn(); return 0; }
var n = 0, first = null, curId = '', curUnit = '';
analysisNodes().forEach(function (e) {
var node = e.node, it = e.item;
var id = String(pickKey(it, MOCK_QID_KEYS));
itemsById[id] = snapCopy(it);
qNodes[id] = node;
n++;
if (!first) { first = node; curId = id; curUnit = unitIdOf(it); }
if (node.getAttribute('data-gth-qid') !== id) node.setAttribute('data-gth-qid', id);
});
var host = zyTopHost(first);
if (host) {
var top = zyTopBar(host);
var bar = $('.gth-abar', top);
if (!bar) {
bar = document.createElement('div');
bar.className = 'gth-abar';
top.appendChild(bar);
}
if ($('.gth-apanel', bar)) {
/* 笔记浮窗开着:不重画小条,重写会把正在编辑的框抹掉 */
} else if (bar.getAttribute('data-sig') !== abarSig(curId, curUnit)) {
bindAbar(bar, first, curId, curUnit);
}
mountQtimeBand(host);
}
if (analysisLogTag !== String(n)) {
analysisLogTag = String(n);
console.log('[错题助手] 专项解析页绑上题目 ' + n + ' 个');
}
repaintHighlights();
return n;
}
function initMocksUI() {
hookXhrSniffer();
buildMocksUI();
syncKeyTargets();
mocksScanTick();
console.log('[错题助手] 模考助手已注入' + (mockSniffHooked ? '(嗅探已挂)' : '') + ':' + location.href);
}
document.addEventListener('keydown', function (e) {
if (quizEl.hidden) return;
if (e.key === 'Escape') { closeQuiz(); return; }
if (e.key === 'F4' || e.key === 'F5') { e.preventDefault(); stepIdx(e.key === 'F4' ? -1 : 1); return; }
if (e.key === 'ArrowLeft') stepIdx(-1);
if (e.key === 'ArrowRight') stepIdx(1);
});
function syncRouteClass() {
document.body.classList.toggle('gth-error', isListRoute());
syncSourceDefault();
}
window.addEventListener('hashchange', function () {
syncRouteClass();
invalidateLoaded();
if (!isListRoute() && (viewOn || bookViewOn)) { setView(false); setBookView(false); }
refreshPageUI();
});
if (isMocksPage()) {
initMocksUI();
return;
}
if (isZyPage()) {
syncKeyTargets();
injectAnalysisUI();
return;
}
if (!getToken()) {
setStatus('未检测到登录 token,请先登录站点后再使用。', 'err');
}
injectListUI();
injectMenuEntry();
syncRail();
syncKeyTargets();
syncRouteClass();
renderPracticeSrc();
syncFilterUI();
renderHistory();
updateExportHint();
} catch (e) {
console.warn('[机考助手] 初始化失败:' + (e && e.message ? e.message : String(e)));
}
})();