段落)。
// 材料图片按现有 base64 方案转存(key='materialImages',多图以 '|||' 连接);无材料时返回空对象,不影响普通题。
function extractMaterial(root, tasks) {
try {
let ti = null;
if (root && root.closest) ti = root.closest('.ti');
if (!ti || !ti.querySelector) return { text: '', images: '', tasks: tasks || [] };
const content = ti.querySelector('.materials-container .material-content') ||
ti.querySelector('.materials-container .material-body .material-content') ||
ti.querySelector('.material-content');
if (!content) return { text: '', images: '', tasks: tasks || [] };
let text = '';
// v0.3.25:防材料文字重复。若材料 DOM 存在 p 嵌套 p(或同一段文字被外层 p 与内层 p 各取一次),
// 直接收集全部 p 会把同一段汉字抓两遍;这里优先取直接子级 p,并对规范化文本去重兜底。
const seen = {};
const arr = [];
function pushP(p) {
const t = norm(p.innerText || p.textContent);
if (!t || seen[t]) return;
seen[t] = 1;
arr.push(t);
}
const directPs = [];
try {
for (let i = 0; i < content.children.length; i++) {
const c = content.children[i];
if (c && c.tagName && c.tagName.toLowerCase() === 'p') directPs.push(c);
}
} catch (e) { /* ignore */ }
if (directPs.length) {
directPs.forEach(pushP);
} else {
const ps = content.querySelectorAll('p');
ps.forEach(pushP);
if (!arr.length) {
const t = norm(content.innerText || content.textContent);
if (t) arr.push(t);
}
}
text = arr.join('\n');
const images = collectImgBase64(content, 'materialImages', tasks);
return { text: text.slice(0, 12000), images: images, tasks: tasks || [] };
} catch (e) { return { text: '', images: '', tasks: tasks || [] }; }
}
/* v1.0.1:收藏前等待解析区渲染(背题页 40 题整页渲染,但来源条/答题区可能晚于题干与选项挂载;
此时抓取会导致 source 与 correctAnswer 为空、只剩 yourAnswer。轮询不阻塞页面渲染,超时后照常抓取尽力而为) */
function waitParseAreaReady(root, timeoutMs) {
const fullRoot = (root && root.closest && root.closest('.questions-single-container')) || root || document;
const limit = timeoutMs || 2000;
return new Promise(function (resolve) {
const t0 = Date.now();
(function probe() {
try {
if (!fullRoot || !fullRoot.isConnected) return resolve(false);
if (fullRoot.querySelector('.question-overall-container, .result-common-container, .overall-item-value')) return resolve(true);
} catch (e) { return resolve(false); }
if (Date.now() - t0 >= limit) return resolve(false);
setTimeout(probe, 200);
})();
});
}
/* v1.0.3:从收藏按钮实时定位所属题根。
粉笔 SPA 在作答完成后会重建题目 DOM,注入时捕获的 root 会失联(isConnected=false),
必须从仍存活的按钮元素向上实时重定位,避免 getQuestion 拿不到题干。
优先命中题根容器(.questions-single-container / app-ti 等),避免组题 .ti 容器串题;
.ti 仅在找不到更精确题根时作为兜底(普通题 .ti 恰为单题根,安全)。 */
function favRootFromBtn(btn) {
if (!btn || !btn.closest) return null;
try {
const r = btn.closest('.questions-single-container, app-ti, .question-choice-container, app-question-choice, .questions-container');
if (r) return r;
return btn.closest('.ti') || null;
} catch (e) { return null; }
}
/* v1.0.2:点击收藏后轮询抓取,直到 来源/正确答案/你的答案 三项全部非空(或超时)。
每次探测从按钮实时重定位题根(v1.0.3),保证拿到解析区渲染完成后、且未被 SPA 重建替换的最新数据。
超时返回最近一次结果(含缺失项),由弹窗提示,不阻塞收藏。 */
function pollCapture(btnOrRoot, timeoutMs) {
const limit = timeoutMs || 2000;
return new Promise(function (resolve) {
const t0 = Date.now();
(function probe() {
let root = null;
try {
if (btnOrRoot && btnOrRoot.classList && btnOrRoot.classList.contains('fb-mk-fav-btn')) {
root = favRootFromBtn(btnOrRoot);
} else if (btnOrRoot && btnOrRoot.isConnected) {
root = btnOrRoot;
} else if (btnOrRoot && btnOrRoot.closest) {
root = favRootFromBtn(btnOrRoot);
}
} catch (e) { root = null; }
if (!root) {
if (Date.now() - t0 >= limit) return resolve(null);
setTimeout(probe, 300);
return;
}
try {
const q = getQuestion(root);
if (q && q.stem && q.source && q.correctAnswer && q.yourAnswer) return resolve(q);
if (Date.now() - t0 >= limit) return resolve(q); // 超时:返回最近一次结果,缺失项由弹窗提示
} catch (e) { /* ignore */ }
if (Date.now() - t0 >= limit) return resolve(null);
setTimeout(probe, 300);
})();
});
}
function getQuestion(rootArg) {
const root = rootArg || firstMatch(document, SEL.questionRoot) || document;
if (!root) return null;
const stemEl = firstMatch(root, SEL.stem);
const stem = stemEl ? stripHTML(stemEl.innerHTML) : '';
if (!stem) return null;
const imgTasks = [];
const stemImage = collectImgBase64(stemEl, 'stemImage', imgTasks);
// v0.3.16:资料分析小题所在组若有材料板块(.materials-container),一并抓取正文与图片
const materialInfo = extractMaterial(root, imgTasks);
const lis = collectOptionEls(root);
const options = [];
let yourAnswer = '';
lis.forEach((li, i) => {
const letterTxt = norm(textOf(li, ['.input-radio', '.choice-letter', '.option-letter', '.letter', '.choice-index', '.choice-no']));
const dotLetter = /^[A-Za-z]$/.test(letterTxt.slice(0, 1)) ? letterTxt.slice(0, 1).toUpperCase() : '';
const letter = dotLetter || optionLetter(i);
let row = li;
for (let k = 0; k < 3; k++) {
if (norm(row.innerText || row.textContent).length > 1) break;
const up = row.closest('li, .choice-item, .option-item, .choice-radio') || row.parentElement;
if (!up || up === row) break;
if (up.querySelectorAll('.input-radio').length > 1) break;
row = up;
}
let label = textOf(row, ['.input-text', '.choice-label', '.option-content', '.choice-content', '.option-text', '.answer-text', 'label']);
if (!label) {
let clone = null;
try { clone = row.cloneNode(true); } catch (e) { clone = null; }
if (clone) {
try {
clone.querySelectorAll('.input-radio, .choice-letter, .option-letter, .letter, .choice-index, .choice-no').forEach(x => { if (x.parentNode) x.parentNode.removeChild(x); });
} catch (e) { /* ignore */ }
label = norm(clone.innerText || clone.textContent);
}
}
if (!label) label = norm(row.innerText || row.textContent);
label = label.replace(/^\s*[A-Za-z]\s*[.、.))::]?\s*/, '');
if (!label) label = norm(row.innerText || row.textContent);
const cls = ((row.className && row.className.toString()) || '') + ' ' + ((li.className && li.className.toString()) || '');
let innerCls = '';
try {
row.querySelectorAll('.input-radio, .choice-letter, .option-letter').forEach(b => {
innerCls += ' ' + ((b.className && b.className.toString()) || '');
});
} catch (e) { /* ignore */ }
const clsAll = (cls + ' ' + innerCls).toLowerCase();
const isCorrect = row.matches(SEL.correctMark.join(',')) ||
li.matches(SEL.correctMark.join(',')) ||
!!row.querySelector('.correct, .correctLost, .right, .is-right, .success') ||
/(^|[\s])(correct|correctlost|right|is-right|success)($|[\s])/.test(clsAll);
// 粉笔 SPA:用户选中态不体现在 input 上(一律 disabled + checked=false),
// 而是体现为选项字母盒 div.input-radio 的 class:wrong=用户选的错误答案,correct=用户选对。
const checked = !!row.querySelector('input:checked') || !!li.querySelector('input:checked') ||
!!(row.querySelector('.input-radio.wrong, .choice-radio.wrong, .option-radio.wrong, .input-radio.correct, .choice-radio.correct, .option-radio.correct')) ||
!!(li.querySelector('.input-radio.wrong, .choice-radio.wrong, .option-radio.wrong, .input-radio.correct, .choice-radio.correct, .option-radio.correct')) ||
/(^|[\s])(checked|selected|active|choose|chosen|picked|wrong|correct)($|[\s])/.test(clsAll);
if (checked) yourAnswer += letter;
options.push({ letter: letter, label: label.slice(0, 300), correct: isCorrect, chosen: checked, image: collectImgBase64(row, 'options.' + options.length + '.image', imgTasks) });
});
let correctAnswer = options.filter(o => o.correct).map(o => o.letter).join('');
if (!correctAnswer) {
const cBox = answerBoxNear(root, ['.overall-item-value.correct-answer', '.correct-answer']);
const cTxt = cBox ? norm(cBox.innerText || cBox.textContent) : '';
const cm = (cTxt.match(/[A-Ea-e]/g) || []).join('').toUpperCase();
if (cm) correctAnswer = cm;
else {
const m = scopeTextNear(root).match(/正确答案[是为::]\s*([A-Ea-e][A-Ea-e、,,\s]*)/);
if (m) correctAnswer = (m[1].match(/[A-Ea-e]/g) || []).join('').toUpperCase();
}
}
if (!yourAnswer) {
// 兜底①:答案条里「你的答案」盒子(错题才有,值在 .your-answer-wrong 子元素内)
const yBox = answerBoxNear(root, ['.your-answer-wrong', '.overall-item-value.your-answer', '.your-answer', '.overall-item-value.wrong-answer']);
const yTxt = yBox ? norm(yBox.innerText || yBox.textContent) : '';
const ym = (yTxt.match(/[A-Ea-e]/g) || []).join('').toUpperCase();
if (ym) yourAnswer = ym;
}
// 兜底②:从选项选中态反推(wrong=用户选的错误项,correct=用户选对项,均视为你的答案)
if (!yourAnswer) {
const chosenLetters = options.filter(o => o.chosen).map(o => o.letter).join('');
if (chosenLetters) yourAnswer = chosenLetters;
}
const idx = norm(textOf(root, SEL.titleIndex));
const qid = extractQid(root, stem);
const keys = questionKeysOf(root);
return {
qid: qid,
qkey: keys.qkey,
anchorId: keys.anchorId,
anchorName: keys.anchorName,
stemHash: hashKey(stem),
stem: stem.slice(0, 4000),
source: extractSource(root),
options: options,
correctAnswer: correctAnswer,
yourAnswer: yourAnswer,
title: idx || stem.slice(0, 40),
stemImage: stemImage,
material: materialInfo.text,
materialImages: materialInfo.images,
_imgTasks: imgTasks,
url: (function () { try { return String(location.href); } catch (e) { return ''; } })()
};
}
/* =====================================================
* 4. 收藏按钮 + 分组选择弹窗
* ===================================================== */
/* ---------------- 4.0 分组树(最多四级) ----------------
* 数据结构:{ id, name, parentId, builtin, updatedAt };parentId 为空即顶级(第 1 级)。
* 级别上限 MAX_GROUP_DEPTH = 4,新建子分组时校验,超出直接拒绝。
* 口径说明:题数统计与筛选均为「含后代」,父分组可一眼看到子分组的题。
*/
const MAX_GROUP_DEPTH = 4;
function groupById(id) { return state.groups.find(g => g.id === id) || null; }
// 层级(1 起,带环检测;异常数据按 1 级处理)
function groupLevel(g) {
let lv = 1, cur = g, seen = {};
while (cur && cur.parentId && !seen[cur.id]) {
seen[cur.id] = 1;
cur = groupById(cur.parentId);
if (!cur) break;
lv++;
}
return lv;
}
// 顶级 -> 自身的分组链(面包屑 / 路径文案)
function groupChain(g) {
const arr = []; let cur = g, seen = {};
while (cur && !seen[cur.id]) { seen[cur.id] = 1; arr.unshift(cur); cur = cur.parentId ? groupById(cur.parentId) : null; }
return arr;
}
function groupPathText(g) { return g ? groupChain(g).map(x => x.name).join(' / ') : ''; }
// 直接子分组
function childGroups(id) { return state.groups.filter(g => (g.parentId || '') === (id || '')); }
// 含自身的全部后代 id
function groupSelfAndDescendants(id) {
const out = [id]; let i = 0;
while (i < out.length) {
const pid = out[i];
state.groups.forEach(g => { if (g.parentId === pid && out.indexOf(g.id) < 0) out.push(g.id); });
i++;
}
return out;
}
function groupItemCount(id) {
const ids = groupSelfAndDescendants(id);
return state.items.filter(i => ids.indexOf(i.group) >= 0).length;
}
// 迁移 / 自愈:补 parentId;孤儿(父不存在)降为顶级;超深链路回退到顶级,保证最多四级
function normalizeGroups() {
const ids = {};
state.groups.forEach(g => { if (g && g.id) ids[g.id] = g; });
state.groups.forEach(g => {
if (!g || !g.id) return;
if (!('parentId' in g)) g.parentId = null;
if (g.parentId && (!ids[g.parentId] || g.parentId === g.id)) g.parentId = null;
});
state.groups.forEach(g => { if (g.parentId && groupLevel(g) > MAX_GROUP_DEPTH) g.parentId = null; });
}
/* ---------------- v0.3.30 分组树拖拽(跨层级移动 + 同级排序) ----------------
* 交互约定:分组行可整体拖动(光标为 grab)。
* 拖到某行的上 / 下 1/3 → 插入为该行的前 / 后兄弟(同级排序,父级不变);
* 拖到某行的中间 1/3 → 成为该行的子分组(跨层级移动,受 MAX_GROUP_DEPTH 限制);
* 拖到「全部」行或树空白处 → 移到根级末尾。
* 拖拽中行位置以蓝线(前/后)或蓝框(成为子分组)提示;拖到自己或自己的后代、层级超限时拒绝。
* 落盘逻辑与重命名 / 新建一致:persist + renderNotebook + scheduleUpload(WebDAV 合并按 updatedAt 取新)。
*/
let dragGid = ''; // 正在拖拽的分组 id(模块级,跨行 / 容器共享)
const DRAG_MID_RATIO = 0.34; // 行内上下 1/3 为同级前后,中间 1/3 为成为子分组
// 一组 id 中的最大层级(用于拖拽后子树是否会超深)
function groupMaxLevel(ids) {
let max = 0;
ids.forEach(id => { const g = groupById(id); if (g) max = Math.max(max, groupLevel(g)); });
return max;
}
// 清空树内所有拖拽指示标记(before / after / inside / 禁止)
function clearGroupDropMarks(root) {
if (!root) return;
root.querySelectorAll('.fb-mk-kp-row.fb-mk-drop-top,.fb-mk-kp-row.fb-mk-drop-bottom,.fb-mk-kp-row.fb-mk-drop-inside,.fb-mk-kp-row.fb-mk-drop-no')
.forEach(r => r.classList.remove('fb-mk-drop-top', 'fb-mk-drop-bottom', 'fb-mk-drop-inside', 'fb-mk-drop-no'));
if (root.classList) root.classList.remove('fb-mk-drop-root');
}
// 校验拖拽目标是否合法:不能拖到自己 / 自己后代;成为子分组时不能超过 MAX_GROUP_DEPTH;
// targetId 为空表示根级末尾(总是合法)
function groupDropValid(srcId, mode, targetId) {
const src = groupById(srcId);
if (!src) return false;
if (!targetId) return true;
const t = groupById(targetId);
if (!t) return false;
const moveIds = groupSelfAndDescendants(srcId);
if (moveIds.indexOf(targetId) >= 0) return false; // 拖到自己或自己的后代里
if (mode !== 'inside') return true; // 同级前 / 后:父级不变,无层级风险
if (groupLevel(t) >= MAX_GROUP_DEPTH) return false; // 目标已是最深一级
const srcLv = groupLevel(src), tgtLv = groupLevel(t);
if (groupMaxLevel(moveIds) + (tgtLv + 1 - srcLv) > MAX_GROUP_DEPTH) return false; // 整棵子树会超深
return true;
}
// 执行拖拽落盘:把 srcId 整棵子树(含后代)移到目标位,返回是否成功
// mode: 'before' | 'after'(targetId 的兄弟前后,parentId 跟随 target 的 parentId)
// 'inside'(targetId 的直接子分组,插到其现有子分组最后;targetId 为空 = 根级末尾)
function applyGroupDrop(srcId, mode, targetId) {
const src = groupById(srcId);
if (!src || !groupDropValid(srcId, mode, targetId)) return false;
const moveIds = groupSelfAndDescendants(srcId);
const moved = state.groups.filter(g => moveIds.indexOf(g.id) >= 0);
const rest = state.groups.filter(g => moveIds.indexOf(g.id) < 0);
const t = targetId ? groupById(targetId) : null;
// 1) 改父子关系:inside → 挂到 target;before / after → 跟随 target 的父级;根级 → 顶级
if (mode === 'inside') src.parentId = targetId || null;
else src.parentId = t ? (t.parentId || null) : null;
src.updatedAt = Date.now();
// 2) 定位插入点(rest 中保持其它节点原有相对顺序)
let at;
if (!targetId) { // 根级末尾
at = rest.length;
} else if (mode === 'inside') { // target 的最后一个直接子分组之后
const kids = rest.filter(g => (g.parentId || '') === targetId);
const lastKid = kids.length ? kids[kids.length - 1] : null;
at = lastKid ? rest.indexOf(lastKid) + 1 : rest.indexOf(t) + 1;
} else if (mode === 'before') { // target 之前
at = rest.indexOf(t);
if (at < 0) at = rest.length;
} else { // after:target 之后
at = rest.indexOf(t) + 1;
}
rest.splice(at, 0, ...moved);
state.groups = rest;
return true;
}
// 拖拽落盘的统一收尾:持久化 + 重渲染 + WebDAV 同步
function commitGroupDrag(msg) {
persist();
if (renderNotebook) renderNotebook();
scheduleUpload();
if (msg) toast(msg);
}
// 兜底分组:默认「知识盲区」,若该分组已被删除则退回当前存在的第一个分组
function fallbackGroupId() {
if (groupById('blind')) return 'blind';
return (state.groups[0] && state.groups[0].id) || 'blind';
}
/* 分组结构变更(重命名 / 新建子分组 / 删除)统一收尾:落盘 + 刷新错题本页面 + 触发 WebDAV 同步 */
function commitGroupChange(msg) {
persist();
if (renderNotebook) renderNotebook();
scheduleUpload();
if (msg) toast(msg);
}
// 新建分组(收藏弹窗与分类编辑弹窗共用):同级不允许同名,深度不超过 MAX_GROUP_DEPTH
function createGroup(name, parentId) {
const nm = norm(name);
if (!nm) return { ok: false, msg: '请先填分组名称' };
if (nm.length > 30) return { ok: false, msg: '分组名称请控制在 30 个字以内' };
const pid = parentId || null;
const p = pid ? groupById(pid) : null;
if (pid && !p) return { ok: false, msg: '上级分组不存在(可能已被删除)' };
if (p && groupLevel(p) >= MAX_GROUP_DEPTH) return { ok: false, msg: '最多支持 ' + MAX_GROUP_DEPTH + ' 级分组,该分组已到最深一级' };
const dup = childGroups(pid).find(g => norm(g.name) === nm);
if (dup) return { ok: false, dup: dup.id, msg: '同级已有同名分组:' + groupPathText(dup) };
const gid = 'g' + uid();
state.groups.push({ id: gid, name: nm, parentId: pid, builtin: false, updatedAt: Date.now() });
return { ok: true, id: gid, name: nm };
}
// 重命名分组:只改名称,id / parentId 不变,题目归属与同步关系不受影响
function renameGroup(id, name) {
const g = groupById(id);
if (!g) return { ok: false, msg: '分组不存在(可能已被删除)' };
const nm = norm(name);
if (!nm) return { ok: false, msg: '名称不能为空' };
if (nm.length > 30) return { ok: false, msg: '分组名称请控制在 30 个字以内' };
if (nm === g.name) return { ok: true, name: nm, unchanged: true };
const dup = childGroups(g.parentId || null).find(x => x.id !== g.id && norm(x.name) === nm);
if (dup) return { ok: false, msg: '同级已有同名分组:' + groupPathText(dup) };
g.name = nm; g.updatedAt = Date.now();
return { ok: true, name: nm };
}
// 删除影响评估:将被移除的分组数(含子分组)、会被重新挂载的题数、以及删除后的落点分组
function groupDeleteInfo(id) {
const g = groupById(id);
const ids = g ? groupSelfAndDescendants(id) : [id];
const parent = (g && g.parentId) ? groupById(g.parentId) : null;
const moved = state.items.filter(it => ids.indexOf(it.group) >= 0).length;
const rest = state.groups.length - ids.length;
const target = parent ? ('「' + parent.name + '」') : '未分组';
return {
removed: ids.length,
moved: moved,
rest: rest,
allowed: rest >= 1,
targetName: parent ? parent.name : '',
tip: '删除后本机与其它设备上的该分类都会消失(不可恢复);组内错题不会被删除' + (moved ? ',其中 ' + moved + ' 道题会移到' + target : ''),
confirm: (ids.length > 1 ? '将同时删除 ' + (ids.length - 1) + ' 个子分类;' : '') + (moved ? moved + ' 道错题将移到' + target + '。' : '')
};
}
/* 删除分组(含其全部子分组):错题保留,改挂到最近存活的上层分组,无上层则转为未分组;
被删分组 id 写入删除墓碑(与题目删除同一套墓碑机制),WebDAV 拉取时不会把已删分组从远端复活 */
function deleteGroup(id) {
const g = groupById(id);
if (!g) return { ok: false, msg: '分组不存在(可能已被删除)' };
const ids = groupSelfAndDescendants(id);
if (state.groups.length - ids.length < 1) return { ok: false, msg: '至少要保留一个分组,不能全部删完' };
const parent = g.parentId ? groupById(g.parentId) : null;
const fallbackId = parent ? parent.id : '';
const now = Date.now();
let moved = 0;
state.items.forEach(it => {
if (ids.indexOf(it.group) >= 0) { it.group = fallbackId; it.updatedAt = now; moved++; }
});
state.groups = state.groups.filter(x => ids.indexOf(x.id) < 0);
ids.forEach(x => { state.deleted[x] = now; });
trimTombstones();
return { ok: true, name: g.name, removed: ids.length, moved: moved, fallbackId: fallbackId, fallbackName: parent ? groupPathText(parent) : '' };
}
function isSaved(q) { return !!(q && q.qid && findItemByQid(q.qid)); }
function saveItem(q, groupId) {
if (!q || !q.qid) { toast('抓取题目失败:没拿到题号,请刷新页面后重试'); return false; }
// v1.0.1:同题已收藏时改为「覆盖更新内容」:刷新来源/答案/题干等抓取字段,
// 保留原分组、笔记与创建时间(updatedAt 更新),用于修复旧版抓到的 source/correctAnswer 空数据
const exist = findItemByQid(q.qid);
if (exist) {
exist.stem = q.stem;
exist.options = q.options;
exist.correctAnswer = q.correctAnswer || exist.correctAnswer || '';
exist.yourAnswer = q.yourAnswer || exist.yourAnswer || '';
exist.title = q.title || exist.title || '';
exist.stemHash = q.stemHash || exist.stemHash || '';
exist.qkey = q.qkey || exist.qkey || '';
exist.anchorId = q.anchorId || exist.anchorId || '';
exist.anchorName = q.anchorName || exist.anchorName || '';
exist.source = q.source || exist.source || '';
exist.stemImage = q.stemImage || exist.stemImage || '';
exist.material = q.material || exist.material || '';
exist.materialImages = q.materialImages || exist.materialImages || '';
exist.url = q.url || exist.url || '';
// v1.0.5:弹窗确认时总是回传 note(可能为空串),q.note 有值则覆盖,未走弹窗路径(undefined)则保留原笔记
if (q.note !== undefined) exist.note = q.note;
exist.updatedAt = Date.now();
delete state.deleted[q.qid];
persist(); refreshBadge(); refreshFavButtons(); if (renderNotebook) renderNotebook();
scheduleUpload();
toast('已更新本题:来源/答案已刷新');
return true;
}
const imgTasks = (q && Array.isArray(q._imgTasks)) ? q._imgTasks : [];
state.items.unshift({
id: q.qid, qid: q.qid, group: (groupId === undefined || groupId === null) ? fallbackGroupId() : groupId,
stem: q.stem, options: q.options,
correctAnswer: q.correctAnswer, yourAnswer: q.yourAnswer,
title: q.title, stemHash: q.stemHash,
qkey: q.qkey, anchorId: q.anchorId, anchorName: q.anchorName,
source: q.source || '',
note: q.note || '', // v0.3.18:每题笔记(随 JSON 导出/导入、WebDAV 同步、mergeDocs 合并天然携带);v1.0.5:新题插入带上弹窗输入的 q.note
stemImage: q.stemImage || '',
material: q.material || '',
materialImages: q.materialImages || '',
url: q.url, createdAt: Date.now(), updatedAt: Date.now()
});
delete state.deleted[q.qid];
persist(); refreshBadge(); refreshFavButtons(); if (renderNotebook) renderNotebook();
scheduleUpload();
// v0.3.16:未同步转好的图片(跨域/未加载)异步补救,失败兜底为空并提示,不阻塞收藏流程
if (imgTasks.length) {
const savedItem = state.items[0];
let done = 0, fail = 0;
imgTasks.forEach(t => {
if (!t || !t.el) { done++; return; }
imgToDataURLAsync(t.el).then(b64 => {
done++;
if (b64 && state.items.indexOf(savedItem) >= 0 && setItemImgField(savedItem, t.key, b64)) {
persist(); if (renderNotebook) renderNotebook();
} else {
fail++;
if (done === imgTasks.length && fail > 0) toast('部分题目图片未能转换为 base64,已跳过(不影响收藏)', 3600);
}
});
});
}
const g = groupById(groupId || fallbackGroupId());
toast('已加入错题本:' + (g ? groupPathText(g) : '未分组') + '(共 ' + state.items.length + ' 道题)');
return true;
}
/* ---------------- 「打开原题」→ 定位到该题的题号 ----------------
* 实测(spa.fenbi.com):背题 / 解析页把整卷题目一次性渲染在同一页,URL 只有
* routecs / examcatid 这类渠道参数,不带题号(index= 之类参数无效,加 hash 又可能与
* 页面的 hash 路由 #/mistakes 冲突)。页面自身切题靠 JS 平滑滚动到该题偏移。
* 因此这里的做法是:点「打开原题」时把目标题的稳定标识写进一条「待定位记录」,
* 再跳转;落地页由本脚本轮询找到该题并滚动过去 + 高亮,可容忍 Angular 晚渲染。
* 定位键优先级:data-question-key > 标题 id > 锚点 name > 题干指纹 > 题号文本。
*/
const JUMP_TTL = 5 * 60 * 1000; // 待定位记录有效期,超时视为过期
function normTitle(t) { return norm(String(t == null ? '' : t)).replace(/[..、::\s]/g, ''); }
function jumpSave(rec) {
try { localStorage.setItem(JUMP_KEY, JSON.stringify(rec)); } catch (e) { /* ignore */ }
}
function jumpTake() {
let rec = null;
try {
const raw = localStorage.getItem(JUMP_KEY);
if (raw) { try { localStorage.removeItem(JUMP_KEY); } catch (e) { /* ignore */ } rec = JSON.parse(raw); }
} catch (e) { rec = null; }
if (!rec || !rec.ts || (Date.now() - rec.ts) > JUMP_TTL) return null;
return rec;
}
function questionRoots() {
const out = [];
try {
const nodes = document.querySelectorAll(SEL.questionRoot.join(','));
for (let i = 0; i < nodes.length; i++) out.push(nodes[i]);
} catch (e) { /* ignore */ }
return out;
}
function toQuestionRoot(el) {
if (!el) return null;
try { return (el.closest && el.closest('.questions-single-container')) || el; } catch (e) { return el; }
}
// 打开原题时的落点 URL:剥掉错题本自身的 hash 路由,避免落地又停在错题本上
function jumpTargetUrl(url) {
return norm(String(url == null ? '' : url)).replace(/#\/mistakes\b.*$/i, '');
}
function findQuestionByRec(rec) {
if (!rec) return null;
const roots = questionRoots();
if (rec.qkey) {
try {
const ti = document.querySelector('app-ti[data-question-key="' + rec.qkey + '"]');
if (ti) return toQuestionRoot(ti);
} catch (e) { /* ignore */ }
}
if (rec.anchorId) {
const el = document.getElementById(rec.anchorId);
if (el) return toQuestionRoot(el);
}
if (rec.anchorName) {
try { const a = document.getElementsByName(rec.anchorName)[0]; if (a) return toQuestionRoot(a); } catch (e) { /* ignore */ }
}
if (rec.stemHash) {
for (let i = 0; i < roots.length; i++) {
const el = firstMatch(roots[i], SEL.stem);
const txt = el ? norm(el.innerText || el.textContent) : '';
if (txt && hashKey(txt) === rec.stemHash) return roots[i];
}
}
const want = normTitle(rec.title);
if (want) {
for (let i = 0; i < roots.length; i++) {
if (normTitle(norm(textOf(roots[i], SEL.titleIndex))) === want) return roots[i];
}
}
return null;
}
let jumpTimer = null;
function jumpToEl(el) {
try {
if (el.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
else {
const y = el.getBoundingClientRect().top + (window.pageYOffset || 0) - 90;
window.scrollTo(0, Math.max(0, y));
}
} catch (e) { /* ignore */ }
// 平滑滚动可能被页面自身渲染 / 定位打断,稍后再校正一次
setTimeout(() => {
try {
const y = el.getBoundingClientRect().top + (window.pageYOffset || 0) - 90;
if (Math.abs(y) > 4) window.scrollTo(0, Math.max(0, y));
} catch (e) { /* ignore */ }
}, 450);
try {
el.classList.add('fb-mk-jump-hl');
setTimeout(() => { try { el.classList.remove('fb-mk-jump-hl'); } catch (e) { /* ignore */ } }, 3200);
} catch (e) { /* ignore */ }
}
// 轮询等待题目渲染(Angular 晚于首帧),找到即滚动定位
function tryPendingJump() {
const rec = jumpTake();
if (!rec) return false;
let tries = 0;
if (jumpTimer) { clearInterval(jumpTimer); jumpTimer = null; }
const step = () => {
tries++;
const el = findQuestionByRec(rec);
if (el) {
if (jumpTimer) { clearInterval(jumpTimer); jumpTimer = null; }
jumpToEl(el);
toast('已定位到' + (normTitle(rec.title) ? ('第 ' + normTitle(rec.title) + ' 题') : '该题'));
return;
}
if (tries >= 40) {
if (jumpTimer) { clearInterval(jumpTimer); jumpTimer = null; }
toast('未能定位到该题(题号 ' + (normTitle(rec.title) || '-') + '):题目可能已下架或页面结构变化');
}
};
step();
if (!jumpTimer) jumpTimer = setInterval(step, 400);
return true;
}
let pickerEl = null;
function closePicker() { if (pickerEl && pickerEl.parentNode) pickerEl.parentNode.removeChild(pickerEl); pickerEl = null; }
/* v1.0.2:抓取结果确认弹窗——展示 来源/正确答案/你的答案 三项,齐全高亮;
缺失项标红提示并提供「重新抓取」,确认后进入分组收藏(openPicker)。
支持直接在弹窗内记笔记,笔记随收藏一起存入。 */
function showCaptureVerify(q, onConfirm, onRetry, onCancel) {
closePicker();
const mask = document.createElement('div');
mask.className = 'fb-mk-mask fb-mk-mask-sheet';
const card = document.createElement('div');
card.className = 'fb-mk-card fb-mk-verify';
const allOk = !!(q && q.source && q.correctAnswer && q.yourAnswer);
card.appendChild(mkEl('div', 'fb-mk-title', allOk ? '已抓取本题信息' : '抓取未完整,缺失项标红'));
const stem = norm(q && q.stem ? q.stem : '') || '(题干缺失)';
card.appendChild(mkEl('div', 'fb-mk-sheet-stem', stem.length > 90 ? stem.slice(0, 90) + '…' : stem));
/* 笔记输入区 */
const noteRow = document.createElement('div');
noteRow.className = 'fb-mk-verify-note-row';
noteRow.appendChild(mkEl('label', 'fb-mk-verify-note-label', '笔记'));
const noteArea = document.createElement('textarea');
noteArea.className = 'fb-mk-verify-note';
noteArea.rows = 3;
noteArea.placeholder = '在此添加笔记(考试来源、思路分析、易错点等)';
// v1.0.5:弹窗笔记与错题本页面共用 item.note——已收藏题目预填已存笔记,保证两端显示同一内容
const existNote = (q && q.qid) ? (findItemByQid(q.qid) || {}).note : '';
if (q && q.note) noteArea.value = q.note;
else if (existNote) noteArea.value = existNote;
noteRow.appendChild(noteArea);
card.appendChild(noteRow);
const rows = document.createElement('div');
rows.className = 'fb-mk-verify-rows';
function addRow(label, val, ok) {
const r = document.createElement('div');
r.className = 'fb-mk-verify-row' + (ok ? '' : ' miss');
r.appendChild(mkEl('span', 'fb-mk-verify-label', label));
r.appendChild(mkEl('span', 'fb-mk-verify-val', ok ? val : '未抓到'));
rows.appendChild(r);
}
addRow('来源', q.source || '', !!q.source);
addRow('正确答案', q.correctAnswer || '', !!q.correctAnswer);
addRow('你的答案', q.yourAnswer || '', !!q.yourAnswer);
card.appendChild(rows);
const foot = document.createElement('div');
foot.className = 'fb-mk-verify-foot';
if (!allOk) {
const retryBtn = mkEl('button', 'fb-mk-gbtn', '重新抓取');
retryBtn.addEventListener('click', () => { if (onRetry) onRetry(); });
foot.appendChild(retryBtn);
}
const cancelBtn = mkEl('button', 'fb-mk-gbtn', '取消');
cancelBtn.addEventListener('click', () => { if (onCancel) onCancel(); });
foot.appendChild(cancelBtn);
const okBtn = mkEl('button', 'fb-mk-gbtn fb-mk-gbtn-main', allOk ? '确认收藏' : '仍要收藏');
okBtn.addEventListener('click', () => {
if (onConfirm) onConfirm({ note: noteArea.value });
});
foot.appendChild(okBtn);
card.appendChild(foot);
mask.appendChild(card);
mask.addEventListener('click', e => { if (e.target === mask && onCancel) onCancel(); });
document.body.appendChild(mask);
pickerEl = mask;
}
/* 底部抽屉式分组选择器
* 参照 sheet-preview demo:mask + 底部 sheet(62% 高),两列树、按层级缩进、名称 + 计数,
* 点击分组名即收藏到该分组;点行内「+」把该分组设为当前节点,可在其下新建子分组。
*/
function openPicker(q) {
closePicker();
let parentId = null; // 当前选中节点(底部「新建子分组」的挂载点)
const mask = document.createElement('div');
mask.className = 'fb-mk-mask fb-mk-mask-sheet';
const sheet = document.createElement('div');
sheet.className = 'fb-mk-sheet';
sheet.appendChild(mkEl('div', 'fb-mk-handle'));
const head = document.createElement('div');
head.className = 'fb-mk-sheet-head';
const titleRow = document.createElement('div');
titleRow.className = 'fb-mk-sheet-title-row';
titleRow.appendChild(mkEl('div', 'fb-mk-sheet-title', isSaved(q) ? '已在错题本(可换分组)' : '收藏到哪个分组?'));
const closeBtn = mkEl('button', 'fb-mk-gbtn fb-mk-sheet-close', '取消');
closeBtn.addEventListener('click', closePicker);
titleRow.appendChild(closeBtn);
head.appendChild(titleRow);
const stem = norm(q && q.stem ? q.stem : '') || '(题干缺失)';
head.appendChild(mkEl('div', 'fb-mk-sheet-stem', stem.length > 90 ? stem.slice(0, 90) + '…' : stem));
head.appendChild(mkEl('div', 'fb-mk-sheet-tip', '点分组名 = 收藏到该分组;点右侧「+」选中为当前节点,可在其下新建子分组(最多 ' + MAX_GROUP_DEPTH + ' 级)'));
sheet.appendChild(head);
const tree = document.createElement('div');
tree.className = 'fb-mk-tree';
const colL = mkEl('div', 'fb-mk-col'), colR = mkEl('div', 'fb-mk-col');
tree.appendChild(colL); tree.appendChild(colR);
sheet.appendChild(tree);
const addRow = document.createElement('div');
addRow.className = 'fb-mk-addbar';
const inp = document.createElement('input');
inp.className = 'fb-mk-input';
const addBtn = mkEl('button', 'fb-mk-gbtn fb-mk-gbtn-main', '新建并收藏');
addRow.appendChild(inp); addRow.appendChild(addBtn);
sheet.appendChild(addRow);
if (isSaved(q)) {
const foot = document.createElement('div');
foot.className = 'fb-mk-sheet-foot';
const delBtn = mkEl('button', 'fb-mk-gbtn fb-mk-gbtn-danger', '从错题本移除');
delBtn.addEventListener('click', () => { removeItem(q.qid); closePicker(); });
foot.appendChild(delBtn);
sheet.appendChild(foot);
}
function renderGroupNodes(nodes, depth, parent) {
nodes.forEach(g => {
const kids = childGroups(g.id);
const row = document.createElement('div');
row.className = 'fb-mk-node' + (parentId === g.id ? ' on' : '');
row.style.paddingLeft = (10 + depth * 14) + 'px';
row.appendChild(mkEl('span', 'fb-mk-node-arrow', kids.length ? '▼' : '·'));
row.appendChild(mkEl('span', 'fb-mk-node-name', g.name));
row.appendChild(mkEl('span', 'fb-mk-node-count', String(groupItemCount(g.id))));
if (kids.length) {
const sub = mkEl('span', 'fb-mk-node-sub', kids.length + ' 子');
row.appendChild(sub);
}
if (groupLevel(g) < MAX_GROUP_DEPTH) {
const plus = mkEl('span', 'fb-mk-node-add', '+');
plus.title = '选中它,可在其下新建子分组';
plus.addEventListener('click', ev => {
try { ev.stopPropagation(); } catch (e) { /* ignore */ }
parentId = (parentId === g.id) ? null : g.id;
refreshTree();
if (parentId) setTimeout(() => { try { inp.focus(); } catch (e) { /* ignore */ } }, 0);
});
row.appendChild(plus);
}
row.addEventListener('click', () => { commitPick(q, g.id); });
parent.appendChild(row);
if (kids.length) renderGroupNodes(kids, depth + 1, parent);
});
}
function refreshTree() {
colL.innerHTML = ''; colR.innerHTML = '';
// v0.3.32:收藏/改分组弹窗顶部提供「未分组」选项(对应 group 为空)
const ungRow = document.createElement('div');
ungRow.className = 'fb-mk-node';
ungRow.style.paddingLeft = '10px';
ungRow.appendChild(mkEl('span', 'fb-mk-node-arrow', '·'));
ungRow.appendChild(mkEl('span', 'fb-mk-node-name', '未分组'));
ungRow.appendChild(mkEl('span', 'fb-mk-node-count', String(state.items.filter(i => !i.group).length)));
ungRow.addEventListener('click', () => { commitPick(q, ''); });
colL.appendChild(ungRow);
const roots = state.groups.filter(g => !g.parentId);
const half = Math.ceil(roots.length / 2);
renderGroupNodes(roots.slice(0, half), 0, colL);
renderGroupNodes(roots.slice(half), 0, colR);
const p = parentId ? groupById(parentId) : null;
if (p && groupLevel(p) >= MAX_GROUP_DEPTH) {
inp.disabled = true; addBtn.disabled = true;
inp.placeholder = '「' + p.name + '」已是第 ' + MAX_GROUP_DEPTH + ' 级,不能再建子分组';
} else {
inp.disabled = false; addBtn.disabled = false;
inp.placeholder = p ? ('+ 在「' + p.name + '」下新建子分组') : '+ 新建顶级分组,例如:立体几何';
}
}
addBtn.addEventListener('click', () => {
const res = createGroup(inp.value, parentId);
if (!res.ok) {
if (res.dup) { toast('同级已有同名分组,直接收藏到「' + groupPathText(groupById(res.dup)) + '」'); commitPick(q, res.dup); return; }
toast(res.msg); return;
}
commitPick(q, res.id);
});
inp.addEventListener('keydown', ev => { if (ev.key === 'Enter') { try { ev.preventDefault(); } catch (e) { /* ignore */ } addBtn.click(); } });
refreshTree();
mask.appendChild(sheet);
mask.addEventListener('click', e => { if (e.target === mask) closePicker(); });
document.body.appendChild(mask);
pickerEl = mask;
}
function commitPick(q, gid) {
if (isSaved(q)) {
const it = findItemByQid(q.qid);
const g = groupById(gid);
const newGroup = gid || '';
let groupChanged = false, noteChanged = false;
if ((it.group || '') !== newGroup) { it.group = newGroup; groupChanged = true; }
// v1.0.5:已收藏题目在弹窗中修改笔记也要写回 item.note,与错题本页面读写同一字段
if (q && q.note !== undefined && q.note !== it.note) { it.note = q.note; noteChanged = true; }
if (!groupChanged && !noteChanged) {
closePicker();
toast('已在「' + (g ? groupPathText(g) : '未分组') + '」');
return;
}
it.updatedAt = Date.now();
persist(); refreshBadge(); if (renderNotebook) renderNotebook(); scheduleUpload();
const msg = [];
if (groupChanged) msg.push('已改为分组:' + (g ? groupPathText(g) : '未分组'));
if (noteChanged) msg.push('笔记已更新');
toast(msg.join(','));
} else {
saveItem(q, gid);
}
closePicker();
}
function removeItem(id) {
let changed = false;
const rest = [];
state.items.forEach(i => {
const key = i.id || i.qid;
if (key === id || i.qid === id) { changed = true; markDeleted(key); } else rest.push(i);
});
if (!changed) return false;
state.items = rest;
persist(); refreshBadge(); refreshFavButtons(); if (renderNotebook) renderNotebook(); scheduleUpload();
toast('已从错题本移除(共 ' + state.items.length + ' 道题)');
return true;
}
/* v0.3.18:删除二次确认——弹层明确展示将删除该题,确认才执行 removeItem,取消则关闭不删 */
function askRemoveItem(it) {
const key = it.id || it.qid;
const oldMask = document.querySelector('.fb-mk-mask.fb-mk-del');
if (oldMask && oldMask.parentNode) oldMask.parentNode.removeChild(oldMask);
const mask = document.createElement('div');
mask.className = 'fb-mk-mask fb-mk-del';
const card = document.createElement('div');
card.className = 'fb-mk-card';
card.appendChild(mkEl('div', 'fb-mk-title', '删除错题'));
const stemShort = String(it.stem || '(题干缺失)');
card.appendChild(mkEl('div', 'fb-mk-tip', '确定删除这道错题吗?删除后本机与其它设备同步后都会移除,且不可恢复。'));
card.appendChild(mkEl('div', 'fb-mk-tip', '题目:' + stemShort.slice(0, 80) + (stemShort.length > 80 ? '…' : '')));
const btns = document.createElement('div');
btns.className = 'fb-mk-item-acts';
const cancel = mkEl('button', 'fb-mk-gbtn', '取消');
cancel.addEventListener('click', () => { if (mask.parentNode) mask.parentNode.removeChild(mask); });
const yes = mkEl('button', 'fb-mk-gbtn fb-mk-gbtn-danger', '确认删除');
yes.addEventListener('click', () => {
if (mask.parentNode) mask.parentNode.removeChild(mask);
// v0.3.32:删除后保持右栏列表滚动位置,避免整页重建跳回第一题
const list = document.querySelector('.fb-mk-question-list');
const savedTop = list ? list.scrollTop : 0;
removeItem(key);
const list2 = document.querySelector('.fb-mk-question-list');
if (list2) list2.scrollTop = savedTop;
});
btns.appendChild(cancel); btns.appendChild(yes);
card.appendChild(btns);
mask.appendChild(card);
document.body.appendChild(mask);
return mask;
}
function favButtonHtml() {
return '收藏错题';
}
function questionRoots() {
const out = [];
let stems = [];
try { stems = Array.prototype.slice.call(document.querySelectorAll(SEL.stem.join(','))); } catch (e) { stems = []; }
stems.forEach(function (n) {
const r = n.closest('.questions-single-container, .question-choice-container, app-question-choice') || n.parentElement;
if (r && out.indexOf(r) < 0) out.push(r);
});
if (out.length) return out;
try { return Array.prototype.slice.call(document.querySelectorAll(SEL.questionRoot.join(','))); } catch (e) { return []; }
}
/* 题目标题盒子:div.title#title-0-x-0(内含 .title-left 与 ul.title-right) */
function favTitleBox(root) {
if (!root) return null;
if (root.classList && root.classList.contains('title')) return root;
if (!root.querySelector) return null;
let t = null;
try { t = root.querySelector('.title[id^="title-"]'); } catch (e) { t = null; }
if (!t) { try { t = root.querySelector('.title'); } catch (e) { t = null; } }
return t;
}
/* 按钮挂进题目最大盒子 .ti 里,靠绝对定位停在盒子外部左上角:
按钮 right 边贴 .ti 左边缘、top 边与 .ti 顶边齐平 => 按钮右上角与 .ti 左上角重合。
v0.3.16:资料分析组(.ti 内含 .materials-container)= 1 材料 + 5 小题,
若仍挂在组 .ti 顶,5 个按钮会绝对定位到同一点完全重叠;
故此类组内按钮宿主/定位锚点改取每个小题自己的容器(app-ti / .questions-container),每题一按钮、各归其位。 */
function favHostBox(root, anchor) {
let host = null;
if (anchor && anchor.closest) host = anchor.closest('.ti');
if (!host && anchor && anchor.classList && anchor.classList.contains('ti')) host = anchor;
if (!host && root) {
host = (root.classList && root.classList.contains('ti')) ? root
: (root.closest ? root.closest('.ti') : null);
}
if (host && host.querySelector && host.querySelector('.materials-container')) {
let sub = null;
if (anchor && anchor.closest) sub = anchor.closest('app-ti, .questions-container') || null;
if (!sub && root && root.closest) sub = root.closest('app-ti, .questions-container') || null;
if (!sub && anchor && anchor.closest) sub = anchor.closest('app-question-choice') || null;
if (!sub && root && root.closest) sub = root.closest('app-question-choice') || null;
if (sub && sub !== host) host = sub;
}
return host || anchor || root;
}
function placeFavInTitleBox(el, root, anchor) {
const host = favHostBox(root, anchor);
if (!host) return;
if (el.parentNode !== host || host.firstElementChild !== el) {
host.insertBefore(el, host.firstElementChild);
}
/* 若宿主原本是 static,补一层相对定位,保证按钮以该盒子为定位参照 */
try {
const cs = window.getComputedStyle(host);
if (cs && cs.position === 'static' && host.classList) host.classList.add('fb-mk-ti-host');
} catch (e) { /* ignore */ }
/* v0.3.17:资料分析小题宿主(组 .ti 内的 app-ti/.questions-container)改流内样式。
absolute right:100% 会把按钮顶到小题容器左侧外部,被 .questions-container/app-resizable
等祖先 overflow 裁剪 → 按钮完全消失;流内内嵌则始终位于小题容器顶部、可见可点。
普通题宿主仍是 .ti 本身(绝对定位贴 .ti 左上角),维持原行为不变。 */
let groupTi = null;
try { groupTi = host.closest ? host.closest('.ti') : null; } catch (e) { groupTi = null; }
const isSubHost = !!(groupTi && groupTi !== host && groupTi.querySelector && groupTi.querySelector('.materials-container'));
if (el.classList) {
if (isSubHost) el.classList.add('fb-mk-fav-standalone');
else el.classList.remove('fb-mk-fav-standalone');
}
if (isSubHost && host.classList) host.classList.add('fb-mk-ti-host');
}
let lastRootsSig = '';
function injectFavButton() {
// v0.3.11 性能修复:题目根集合未变化时跳过全量注入(站点其它 DOM 变动不再反复遍历/搬移整页题目)
const roots = questionRoots();
let sig = '';
try {
const first = roots[0];
const last = roots.length ? roots[roots.length - 1] : null;
sig = roots.length + '|' +
(first ? String((first.getAttribute && first.getAttribute('data-question-key')) || '') : '') + '|' +
(last ? String((last.getAttribute && last.getAttribute('data-question-key')) || '') : '');
} catch (e) { /* ignore */ }
if (sig && sig === lastRootsSig) {
/* v0.3.17:快速路径只跳过「全量重注」;若某题根的按钮已被站点重渲染清掉,仍需补注 */
let allInjected = true;
for (let i = 0; i < roots.length; i++) {
const h = favHostBox(roots[i], favTitleBox(roots[i]));
if (!h || !h.querySelector('.fb-mk-fav')) { allInjected = false; break; }
}
if (allInjected) {
refreshFavButtons();
return;
}
}
lastRootsSig = sig;
const rows = [];
roots.forEach(function (root) {
const anchor = favTitleBox(root);
/* v0.3.17:按钮宿主可能被降级到 root 的祖先(资料分析小题 app-ti),
root.querySelector 找不到已注入按钮会造成重复注入;改从宿主范围查找 */
const host = favHostBox(root, anchor);
let wrap = host ? host.querySelector('.fb-mk-fav') : null;
if (!wrap) {
const el = document.createElement('div');
el.className = 'fb-mk-fav';
const inner = document.createElement('button');
inner.className = 'fb-mk-fav-btn';
inner.textContent = favButtonHtml();
inner.addEventListener('click', function (e) {
try { e.preventDefault(); e.stopPropagation(); } catch (err) { /* ignore */ }
injectFavButton();
// v1.0.3:轮询内部通过按钮实时重定位题根,不再依赖注入时捕获的 root(SPA 重建后可能失联)
// v1.0.2:点击收藏立即开始轮询抓取,直到 来源/正确答案/你的答案 三项齐全后弹窗确认,
// 解决背题模式解析区渲染时序导致的 source/correctAnswer 空值问题。
if (inner.dataset.collecting === '1') { toast('正在抓取本题,请稍候…'); return; }
function startCollect() {
inner.dataset.collecting = '1';
const oldTxt = inner.textContent;
inner.textContent = '抓取中…';
inner.classList.add('fb-mk-fav-btn-loading');
function finish() {
inner.dataset.collecting = '';
inner.textContent = oldTxt;
inner.classList.remove('fb-mk-fav-btn-loading');
}
pollCapture(inner, 2000).then(function (q) {
if (!q || !q.stem) { finish(); toast('没识别到当前题目,请确认已打开题目详情'); return; }
if (wrap.dataset.qid && wrap.dataset.stem === q.stemHash) q.qid = wrap.dataset.qid;
showCaptureVerify(q, function (result) {
// 把笔记注入 q.note,后续 openPicker + saveItem 能拿到
if (result && result.note !== undefined) q.note = result.note;
finish();
if (isSaved(q)) { toast('已收藏 · 改分组(确认后同步刷新来源/答案)'); }
openPicker(q);
}, function () {
closePicker();
startCollect();
}, function () {
finish(); closePicker();
});
});
}
startCollect();
});
el.appendChild(inner);
placeFavInTitleBox(el, root, anchor);
wrap = el;
} else {
/* 旧版可能挂在 ul.title-right 里:改造成独立元素并搬进标题盒子内最前面 */
if (wrap.tagName === 'LI') {
const d = document.createElement('div');
d.className = 'fb-mk-fav';
if (wrap.dataset) {
if (wrap.dataset.qid) d.dataset.qid = wrap.dataset.qid;
if (wrap.dataset.stem) d.dataset.stem = wrap.dataset.stem;
}
while (wrap.firstChild) d.appendChild(wrap.firstChild);
if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
wrap = d;
}
placeFavInTitleBox(wrap, root, anchor);
}
rows.push({ root: root, wrap: wrap, q: null });
});
rows.forEach(function (r) { r.q = getQuestion(r.root); });
rows.forEach(function (r) {
const h = r.q ? r.q.stemHash : '';
if (h && r.wrap.dataset.stem !== h) { r.wrap.dataset.stem = h; r.wrap.dataset.qid = ''; }
});
const pool = {};
rows.forEach(function (r) {
const id = r.wrap.dataset.qid || (r.q && r.q.qid) || '';
if (id) pool[id] = (pool[id] || 0) + 1;
});
rows.forEach(function (r) {
if (!r.q || !r.q.qid) return;
let qid = r.q.qid;
if ((pool[qid] || 0) > 1) qid = r.q.stemHash || qid;
if (r.wrap.dataset.qid !== qid) r.wrap.dataset.qid = qid;
});
refreshFavButtons();
}
function refreshFavButtons() {
const btns = document.querySelectorAll('.fb-mk-fav-btn');
if (!btns.length) return;
btns.forEach(function (b) {
const wrap = b.closest ? b.closest('.fb-mk-fav') : b.parentNode;
const qid = (wrap && wrap.dataset) ? wrap.dataset.qid : '';
const txt = (qid && findItemByQid(qid)) ? '已收藏 · 改分组' : favButtonHtml();
if (b.textContent !== txt) b.textContent = txt;
});
}
function qidProbe() {
return questionRoots().map(function (r) {
const q = getQuestion(r);
/* v0.3.17:按钮宿主可能被降级到 root 的祖先(资料分析小题 app-ti),统一从宿主范围找 */
const w = (favHostBox(r, favTitleBox(r)) || r).querySelector('.fb-mk-fav');
return { qid: q && q.qid, stemHash: q && q.stemHash, domQid: w ? (w.dataset.qid || '') : '(未注入)', stem: q ? q.stem.slice(0, 60) : '' };
});
}
function favDiag() {
const out = [];
document.querySelectorAll('.fb-mk-fav').forEach(function (w, i) {
const b = w.querySelector('.fb-mk-fav-btn');
const qid = w.dataset.qid || '';
const host = w.parentNode;
const hostIsTi = !!(host && host.classList && host.classList.contains('ti'));
const hostPos = (host && window.getComputedStyle) ? window.getComputedStyle(host).position : '';
const hostOv = (host && window.getComputedStyle) ? window.getComputedStyle(host).overflow : '';
let gapRight = null, gapTop = null;
if (host && w.getBoundingClientRect) {
const hr = host.getBoundingClientRect(), wr = w.getBoundingClientRect();
gapRight = Math.round((hr.left - wr.right) * 10) / 10; /* 期望 0:按钮右边缘贴盒子左边缘 */
gapTop = Math.round((wr.top - hr.top) * 10) / 10; /* 期望 0:按钮上边缘与盒子顶边齐平 */
}
out.push({
index: i, qid: qid, stem: w.dataset.stem || '', text: b ? b.textContent : '',
saved: !!(qid && findItemByQid(qid)),
tag: w.tagName,
parent: host ? (host.tagName + '.' + String(host.className || '').split(' ')[0]) : '',
parentIsTiBox: hostIsTi,
hostPosition: hostPos,
hostOverflow: hostOv,
isFirstChild: !!(host && host.firstElementChild === w),
gapRight: gapRight,
gapTop: gapTop
});
});
return out;
}
function optionProbe() {
const root = firstMatch(document, SEL.questionRoot) || questionRoots()[0] || document;
const q = getQuestion(root);
const rows = collectOptionEls(root);
return {
rootTag: root && root.tagName,
rootCls: String((root && root.className) || ''),
optionCount: rows.length,
options: rows.map(function (r) {
return { tag: r.tagName, cls: String(r.className || ''), text: norm(r.innerText || r.textContent).slice(0, 120) };
}),
parsed: q ? q.options : null
};
}
/* =====================================================
* 5. 错题本页面(#/mistakes)+ 悬浮入口 + 徽章
* ===================================================== */
let renderNotebook = null;
// 错题本是否处于打开状态(用内存标志,不依赖站点路由 hash,
// 避免 hash 被题库自身路由抢回去导致「点了没反应」)
let notebookOpen = false;
function clickLog(step, extra) {
try {
const arr = window.__fbMkClickLog || (window.__fbMkClickLog = []);
arr.push({ t: Date.now(), step: step, extra: extra === undefined ? null : String(extra).slice(0, 200) });
if (arr.length > 40) arr.shift();
} catch (e) { /* ignore */ }
}
function openNotebook() {
clickLog('openNotebook:enter');
try {
notebookOpen = true;
mountNotebook();
clickLog('openNotebook:done', document.querySelector('.fb-mk-page') ? 'page=1' : 'page=0');
return true;
} catch (e) {
clickLog('openNotebook:error', errText(e));
try { toast('打开错题本失败:' + errText(e), 6000); } catch (_) { /* ignore */ }
return false;
}
}
/* ---------------- 6.1 分类编辑弹窗(重命名 / 新增子分类 / 删除) ----------------
* 入口:错题本页面每个分组按钮右侧的「⋯」。变更只涉及 state.groups 与错题归属,
* 复用现有持久化(persist)、页面重渲染(renderNotebook)与 WebDAV 同步(scheduleUpload + 分组墓碑)。
*/
let groupEditorEl = null;
function closeGroupEditor() {
if (groupEditorEl && groupEditorEl.parentNode) groupEditorEl.parentNode.removeChild(groupEditorEl);
groupEditorEl = null;
}
function openGroupEditor(gid) {
closeGroupEditor();
if (gid !== '' && !groupById(gid)) { toast('分组不存在(可能已被删除)'); return; }
const mask = document.createElement('div');
mask.className = 'fb-mk-mask fb-mk-editor';
const card = document.createElement('div');
card.className = 'fb-mk-card';
mask.appendChild(card);
mask.addEventListener('click', ev => { if (ev.target === mask) closeGroupEditor(); });
const rootGid = gid === '' ? '' : gid;
const rootName = gid === '' ? '全部' : groupPathText(groupById(gid));
function render() {
card.textContent = '';
const g = gid === '' ? null : groupById(gid);
if (gid !== '' && !g) { closeGroupEditor(); return; }
card.appendChild(mkEl('div', 'fb-mk-title', '编辑分类'));
card.appendChild(mkEl('div', 'fb-mk-sub', '当前:' + rootName + (gid !== '' ? ' · 第 ' + groupLevel(g) + ' 级 · 含子分组共 ' + groupItemCount(g.id) + ' 题' : '(根下)')));
// (1) 重命名(非根节点才显示)
if (gid !== '') {
const r1 = document.createElement('div');
r1.className = 'fb-mk-formrow';
r1.appendChild(mkEl('label', 'fb-mk-label', '重命名'));
const row1 = document.createElement('div');
row1.className = 'fb-mk-newline';
const nameInp = mkEl('input', 'fb-mk-input');
nameInp.value = g.name;
nameInp.maxLength = 30;
const nameBtn = mkEl('button', 'fb-mk-gbtn fb-mk-gbtn-main', '保存名称');
nameBtn.addEventListener('click', () => {
const res = renameGroup(gid, nameInp.value);
if (!res.ok) { toast(res.msg); return; }
if (res.unchanged) { toast('名称没有变化'); return; }
commitGroupChange('已重命名为「' + res.name + '」');
render();
});
nameInp.addEventListener('keydown', ev => { if (ev.key === 'Enter') { try { ev.preventDefault(); } catch (e) { /* ignore */ } nameBtn.click(); } });
row1.appendChild(nameInp); row1.appendChild(nameBtn);
r1.appendChild(row1);
card.appendChild(r1);
}
// (2) 新增子分类
const deepest = gid === ''
? childGroups('').some(c => groupLevel(c) + 1 >= MAX_GROUP_DEPTH)
: groupLevel(g) >= MAX_GROUP_DEPTH;
const r2 = document.createElement('div');
r2.className = 'fb-mk-formrow';
r2.appendChild(mkEl('label', 'fb-mk-label', '新增子分类'));
const row2 = document.createElement('div');
row2.className = 'fb-mk-newline';
const subInp = mkEl('input', 'fb-mk-input');
const subBtn = mkEl('button', 'fb-mk-gbtn', '新建子分类');
if (deepest) {
subInp.disabled = true; subBtn.disabled = true;
subInp.placeholder = '已是第 ' + MAX_GROUP_DEPTH + ' 级,不能再建子分类';
} else {
subInp.placeholder = '子分类名称,例如:三角函数';
subBtn.addEventListener('click', () => {
const res = createGroup(subInp.value, gid);
if (!res.ok) { toast(res.msg); return; }
commitGroupChange('已新建子分类「' + res.name + '」');
render();
});
subInp.addEventListener('keydown', ev => { if (ev.key === 'Enter') { try { ev.preventDefault(); } catch (e) { /* ignore */ } subBtn.click(); } });
}
row2.appendChild(subInp); row2.appendChild(subBtn);
r2.appendChild(row2);
r2.appendChild(mkEl('div', 'fb-mk-tip', deepest ? ('「' + (gid === '' ? '全部' : g.name) + '」已是第 ' + MAX_GROUP_DEPTH + ' 级,不能再建子分类') : ('同级不允许同名;最多 ' + MAX_GROUP_DEPTH + ' 级')));
card.appendChild(r2);
// (3) 删除分类(二次确认,避免误删)
const info = groupDeleteInfo(gid);
const r3 = document.createElement('div');
r3.className = 'fb-mk-formrow';
r3.appendChild(mkEl('label', 'fb-mk-label', '删除分类'));
const row3 = document.createElement('div');
row3.className = 'fb-mk-newline';
const delBtn = mkEl('button', 'fb-mk-gbtn fb-mk-gbtn-danger',
info.allowed ? ('删除该分类' + (info.removed > 1 ? '(含 ' + (info.removed - 1) + ' 个子分类)' : '')) : '不能删除(至少保留一个分组)');
// v0.3.27:根节点"全部"不允许删除
if (gid === '') { delBtn.disabled = true; }
if (!info.allowed) delBtn.disabled = true;
const confirmBox = document.createElement('div');
confirmBox.className = 'fb-mk-newline fb-mk-hide';
const warn = mkEl('div', 'fb-mk-tip', '确认删除「' + (gid === '' ? '全部' : g.name) + '」?' + info.confirm);
const yes = mkEl('button', 'fb-mk-gbtn fb-mk-gbtn-danger', '确认删除');
const no = mkEl('button', 'fb-mk-gbtn', '取消');
delBtn.addEventListener('click', () => {
if (!info.allowed || gid === '') return;
delBtn.classList.add('fb-mk-hide');
confirmBox.classList.remove('fb-mk-hide');
});
no.addEventListener('click', () => { confirmBox.classList.add('fb-mk-hide'); delBtn.classList.remove('fb-mk-hide'); });
yes.addEventListener('click', () => {
const hitActive = !!activeGroup && groupSelfAndDescendants(gid).indexOf(activeGroup) >= 0;
const res = deleteGroup(gid);
if (!res.ok) { toast(res.msg); return; }
if (hitActive) activeGroup = res.fallbackId || UNGROUPED_KEY;
closeGroupEditor();
commitGroupChange('已删除分类「' + res.name + '」' + (res.moved ? ',' + res.moved + ' 道题已移到' + (res.fallbackName ? '「' + res.fallbackName + '」' : '未分组') : ''));
});
row3.appendChild(delBtn);
confirmBox.appendChild(warn); confirmBox.appendChild(yes); confirmBox.appendChild(no);
r3.appendChild(row3);
r3.appendChild(confirmBox);
r3.appendChild(mkEl('div', 'fb-mk-tip', info.allowed ? info.tip : '至少要保留一个分组,请先新建或保留其它分组。'));
card.appendChild(r3);
const foot = document.createElement('div');
foot.className = 'fb-mk-foot';
const close = mkEl('button', 'fb-mk-gbtn', '关闭');
close.addEventListener('click', closeGroupEditor);
foot.appendChild(close);
card.appendChild(foot);
}
render();
document.body.appendChild(mask);
groupEditorEl = mask;
}
function mountNotebook() {
// 与站点路由解耦:页面不存在就创建,已存在就重渲染,绝不因路由变化而放弃挂载
let page = document.querySelector('.fb-mk-page');
if (!page) {
page = document.createElement('div');
page.className = 'fb-mk-page';
document.body.appendChild(page);
}
if (renderNotebook) renderNotebook();
}
function closeNotebook() {
notebookOpen = false;
try { closeGroupEditor(); } catch (e) { /* ignore */ }
const p = document.querySelector('.fb-mk-page');
if (p && p.parentNode) p.parentNode.removeChild(p);
clickLog('closeNotebook');
}
renderNotebook = function () {
const page = document.querySelector('.fb-mk-page');
if (!page) return;
page.innerHTML = '';
// v0.3.24:每次重渲染先复位列布局标记(两栏布局分支会重新加上,避免切回双列时残留)
page.classList.remove('fb-mk-page-cols');
const box = document.createElement('div');
box.className = 'fb-mk-wrap';
const head = document.createElement('div');
head.className = 'fb-mk-head';
head.appendChild(mkEl('h2', 'fb-mk-h2', '我的错题本(' + state.items.length + ')'));
const tools = document.createElement('div');
tools.className = 'fb-mk-tools';
const bAns = mkEl('button', 'fb-mk-gbtn' + (showAnswers ? ' fb-mk-gbtn-on' : ''), showAnswers ? '隐藏答案' : '显示答案');
bAns.title = showAnswers ? '隐藏正确答案 / 你的答案与选项高亮' : '显示正确答案 / 你的答案与选项高亮';
bAns.addEventListener('click', () => { showAnswers = !showAnswers; renderNotebook(); });
const bExport = mkEl('button', 'fb-mk-gbtn', '导出 JSON');
bExport.addEventListener('click', exportJson);
const bSync = mkEl('button', 'fb-mk-gbtn', '立即同步');
bSync.addEventListener('click', () => { scheduleUpload('manual'); });
const bPull = mkEl('button', 'fb-mk-gbtn', '从远端拉取');
bPull.addEventListener('click', () => { davPull(false).catch(() => {}); });
const bCfg = mkEl('button', 'fb-mk-gbtn', 'WebDAV 设置');
bCfg.addEventListener('click', openWebdavModal);
const bBack = mkEl('button', 'fb-mk-gbtn fb-mk-gbtn-main', '返回刷题');
bBack.addEventListener('click', () => { try { closeNotebook(); } catch (e) { toast('关闭失败'); } });
const bImport = mkEl('button', 'fb-mk-gbtn', '导入 JSON');
bImport.addEventListener('click', importJson);
// v0.3.26:已移除双列/两栏切换按钮,错题本固定两栏排版
tools.appendChild(bAns); tools.appendChild(bExport); tools.appendChild(bImport);
tools.appendChild(bSync); tools.appendChild(bPull); tools.appendChild(bCfg); tools.appendChild(bBack);
head.appendChild(tools);
box.appendChild(head);
const cfgLine = mkEl('div', 'fb-mk-sub', davIsConfigured() ?
('WebDAV:' + davGetCfg().host + ' · 路径 ' + davGetCfg().path + (davReqBrief() ? ' · 最近请求 ' + davReqBrief() : '')) :
'WebDAV:未配置(点「WebDAV 设置」)');
box.appendChild(cfgLine);
const watermark = document.createElement('span');
watermark.style.cssText = 'font-size:11px;color:#999;font-style:italic;margin-left:8px;user-select:none;';
watermark.textContent = '粉笔脚本君出品';
cfgLine.appendChild(watermark);
// v0.3.23:显示收藏分类——移植粉笔官网「错题本分类」keypoint-tree 样式
// 每个分组节点为单行 flex:箭头方块 → 分类名 → 题数 → 「⋯」编辑按钮;子分组左侧缩进;
// 交互保留(v0.3.7 语义):点箭头方块 = 展开 / 收起下级;点分类名 = 只筛选该分组(含全部后代);
// 点「⋯」= 打开分类编辑(重命名 / 新增子分类 / 删除);「全部」入口 + 一键展开/收起开关 + 激活分组蓝色高亮保留
const filter = document.createElement('div');
filter.className = 'fb-mk-filter fb-mk-kp-tree';
const active = activeGroup || '';
// 保证当前激活分组在树中可见:自动展开其祖先链
(function ensureExpanded() {
let g = active ? groupById(active) : null;
while (g && g.parentId) { expandedGroups[g.parentId] = true; g = groupById(g.parentId); }
})();
// 分类分组「一键展开 / 收起」开关:切换所有分组展开/收起状态,默认全部展开(置于分类卡片右上角)
const toggleCol = document.createElement('div');
toggleCol.className = 'fb-mk-kp-head';
const toggleBtn = mkEl('button', 'fb-mk-tag', allExpanded ? '收起子分组' : '展开子分组');
toggleBtn.title = allExpanded ? '一键收起所有子分组' : '一键展开所有子分组';
toggleBtn.addEventListener('click', () => { allExpanded = !allExpanded; expandedGroups = {}; renderNotebook(); });
toggleCol.appendChild(toggleBtn);
filter.appendChild(toggleCol);
// 「全部」入口:粉笔原生行样式(占位箭头方块置灰 + 分类名 + 总题数)
const allRow = document.createElement('div');
allRow.className = 'fb-mk-kp-row' + (active ? '' : ' on');
allRow.appendChild(mkEl('span', 'fb-mk-kp-arrow off', '·'));
// 2026-10:「全部」= 所有错题(含未分组),未分组作为「全部」的下一层子项
const groupedCount = state.items.length;
const allName = mkEl('span', 'fb-mk-kp-name', '全部');
allName.title = '查看全部错题(共 ' + groupedCount + ' 道,含未分组;点下方分组可只看该分类)';
allName.addEventListener('click', () => { activeGroup = ''; renderNotebook(); });
allRow.appendChild(allName);
allRow.appendChild(mkEl('span', 'fb-mk-kp-num', groupedCount + ' 道'));
// v0.3.27:「全部」入口旁加编辑按钮,用于在根下新建子分类
const allOp = mkEl('button', 'fb-mk-kp-more', '⋯');
allOp.title = '编辑「全部」:新增子分类 / 删除子分类';
allOp.addEventListener('click', ev => {
try { ev.stopPropagation(); ev.preventDefault(); } catch (e) { /* ignore */ }
openGroupEditor('');
});
allRow.appendChild(allOp);
// v0.3.30:把分组拖到「全部」行 = 移到根级末尾(不改变其原有子分组关系)
allRow.addEventListener('dragover', ev => {
try {
if (!dragGid) return;
ev.preventDefault(); ev.stopPropagation();
ev.dataTransfer.dropEffect = 'move';
clearGroupDropMarks(filter);
allRow.classList.add('fb-mk-drop-inside');
} catch (e) { /* ignore */ }
});
allRow.addEventListener('drop', ev => {
try {
ev.preventDefault(); ev.stopPropagation();
if (!dragGid) return;
const srcName = groupById(dragGid) ? groupById(dragGid).name : '';
applyGroupDrop(dragGid, 'inside', null);
dragGid = '';
clearGroupDropMarks(filter);
commitGroupDrag('已把分组移到根级:' + srcName);
} catch (e) { /* ignore */ }
});
filter.appendChild(allRow);
// v0.3.32:未分组入口——无分组(group 为空)的题自动归集到这里,不再混在「全部」;
// 2026-10:作为「全部」的下一层子项展示(包 .fb-mk-kp-children 缩进一级),结构为 全部 → 未分组
const ungCount = state.items.filter(i => !i.group).length;
const ungRow = document.createElement('div');
ungRow.className = 'fb-mk-kp-row' + (activeGroup === UNGROUPED_KEY ? ' on' : '');
ungRow.appendChild(mkEl('span', 'fb-mk-kp-arrow off', '·'));
const ungName = mkEl('span', 'fb-mk-kp-name', '未分组');
ungName.title = '未归入任何分类的错题(共 ' + ungCount + ' 道)';
ungName.addEventListener('click', () => { activeGroup = UNGROUPED_KEY; renderNotebook(); });
ungRow.appendChild(ungName);
ungRow.appendChild(mkEl('span', 'fb-mk-kp-num', ungCount + ' 道'));
const ungBox = document.createElement('div');
ungBox.className = 'fb-mk-kp-children';
ungBox.appendChild(ungRow);
filter.appendChild(ungBox);
// 分组树(粉笔原生样式):每行 = 箭头方块 + 分类名 + 题数 + 「⋯」编辑按钮;有子分组可点箭头展开/收起;子分组递归缩进
function buildKpNode(g, depth) {
const wrap = document.createElement('div');
wrap.className = 'fb-mk-kp-node';
const row = document.createElement('div');
row.className = 'fb-mk-kp-row' + (!!activeGroup && activeGroup === g.id ? ' on' : '');
const kids = childGroups(g.id);
const expanded = isExpanded(g.id);
const cnt = groupItemCount(g.id);
const arrow = mkEl('span', 'fb-mk-kp-arrow' + (kids.length ? '' : ' off'), kids.length ? (expanded ? '▾' : '▸') : '·');
if (kids.length) {
arrow.title = (expanded ? '收起子分组:' : '展开子分组:') + g.name;
arrow.addEventListener('click', ev => {
try { ev.stopPropagation(); ev.preventDefault(); } catch (e) { /* ignore */ }
expandedGroups[g.id] = !isExpanded(g.id);
renderNotebook();
});
}
row.appendChild(arrow);
const name = mkEl('span', 'fb-mk-kp-name', g.name);
name.title = groupPathText(g) + ' · 含子分组共 ' + cnt + ' 题';
name.addEventListener('click', () => { activeGroup = g.id; renderNotebook(); });
row.appendChild(name);
row.appendChild(mkEl('span', 'fb-mk-kp-num', cnt + ' 道'));
// 分类编辑入口(胶囊按钮)
const op = mkEl('button', 'fb-mk-kp-more', '⋯');
op.title = '编辑「' + g.name + '」:重命名 / 新增子分类 / 删除';
op.addEventListener('click', ev => {
try { ev.stopPropagation(); ev.preventDefault(); } catch (e) { /* ignore */ }
openGroupEditor(g.id);
});
row.appendChild(op);
// v0.3.30:分组树拖拽——拖拽手柄(行内三点拖拽图标),只通过手柄触发 dragstart
// 放在「⋯」编辑按钮之前,箭头/分类名/题数之后
const handle = mkEl('span', 'fb-mk-drag-handle', '⠿');
handle.title = '拖拽分组排序/重排';
handle.style.cursor = 'grab';
handle.style.marginLeft = '8px';
handle.style.flexShrink = '0';
// v0.3.31:只有手柄本身可拖,整行禁拖
row.draggable = false;
handle.draggable = true;
row.insertBefore(handle, op);
row.addEventListener('dragstart', ev => {
try {
const t = ev.target && ev.target.nodeType === 1 ? ev.target : (ev.target ? ev.target.parentElement : null);
if (!t || !t.closest('.fb-mk-drag-handle')) { ev.preventDefault(); return; }
if (t.closest('button')) { ev.preventDefault(); return; } // 从「⋯」编辑按钮上不启动拖拽
dragGid = g.id;
row.classList.add('fb-mk-dragging');
ev.dataTransfer.effectAllowed = 'move';
try { ev.dataTransfer.setData('text/plain', g.id); } catch (e) { /* ignore */ }
} catch (e) { /* ignore */ }
});
row.addEventListener('dragover', ev => {
try {
if (!dragGid) return;
ev.preventDefault(); ev.stopPropagation();
ev.dataTransfer.dropEffect = 'move';
clearGroupDropMarks(filter);
const rect = row.getBoundingClientRect();
const y = rect.height > 0 ? (ev.clientY - rect.top) / rect.height : 0;
const mode = y < DRAG_MID_RATIO ? 'before' : (y > 1 - DRAG_MID_RATIO ? 'after' : 'inside');
if (!groupDropValid(dragGid, mode, g.id)) { row.classList.add('fb-mk-drop-no'); return; }
row.classList.add(mode === 'before' ? 'fb-mk-drop-top' : (mode === 'after' ? 'fb-mk-drop-bottom' : 'fb-mk-drop-inside'));
} catch (e) { /* ignore */ }
});
row.addEventListener('drop', ev => {
try {
ev.preventDefault(); ev.stopPropagation();
if (!dragGid) return;
const rect = row.getBoundingClientRect();
const y = rect.height > 0 ? (ev.clientY - rect.top) / rect.height : 0;
const mode = y < DRAG_MID_RATIO ? 'before' : (y > 1 - DRAG_MID_RATIO ? 'after' : 'inside');
if (!groupDropValid(dragGid, mode, g.id)) { clearGroupDropMarks(filter); dragGid = ''; toast('不能拖到该位置:会拖到自身分组内或超过 4 级上限'); return; }
const srcName = groupById(dragGid) ? groupById(dragGid).name : '';
applyGroupDrop(dragGid, mode, g.id);
dragGid = '';
clearGroupDropMarks(filter);
commitGroupDrag('已移动分组:' + srcName);
} catch (e) { /* ignore */ }
});
row.addEventListener('dragend', () => {
try { dragGid = ''; clearGroupDropMarks(filter); } catch (e) { /* ignore */ }
});
wrap.appendChild(row);
if (kids.length && expanded) {
const kidBox = document.createElement('div');
kidBox.className = 'fb-mk-kp-children';
kids.forEach(k => kidBox.appendChild(buildKpNode(k, depth + 1)));
wrap.appendChild(kidBox);
}
return wrap;
}
// 2026-10:层级结构调整——顶层只有「全部」一个节点,所有真实分组(含根级)都是「全部」的下一层,
// 与「未分组」同级缩进;未分组为哨兵行(无 ⋯ 编辑按钮),不可删除。
const rootKids = document.createElement('div');
rootKids.className = 'fb-mk-kp-children';
childGroups('').forEach(g => rootKids.appendChild(buildKpNode(g, 0)));
filter.appendChild(rootKids);
// v0.3.30:拖到分组树空白处 = 移到根级末尾(行内已 stopPropagation,这里的 closest 兜底防误判)
filter.addEventListener('dragover', ev => {
try {
if (!dragGid) return;
if (ev.target && ev.target.closest && ev.target.closest('.fb-mk-kp-row')) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
clearGroupDropMarks(filter);
filter.classList.add('fb-mk-drop-root');
} catch (e) { /* ignore */ }
});
filter.addEventListener('drop', ev => {
try {
if (ev.target && ev.target.closest && ev.target.closest('.fb-mk-kp-row')) return;
ev.preventDefault();
if (!dragGid) return;
const srcName = groupById(dragGid) ? groupById(dragGid).name : '';
applyGroupDrop(dragGid, 'inside', null);
dragGid = '';
clearGroupDropMarks(filter);
commitGroupDrag('已把分组移到根级:' + srcName);
} catch (e) { /* ignore */ }
});
box.appendChild(filter);
const activeIds = active ? groupSelfAndDescendants(active) : null;
// 2026-10:「全部」= 所有错题(含未分组);未分组作为「全部」的下一层子项,点「未分组」只看未分组题
let listItems;
if (active === UNGROUPED_KEY) {
listItems = state.items.filter(i => !i.group);
} else if (!active) {
listItems = state.items.slice();
} else {
listItems = state.items.filter(i => !!i.group && activeIds.indexOf(i.group) >= 0);
}
// 2026-10:列表统一按收藏时间倒序(后添加的在前)
listItems.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
if (!listItems.length) {
if (active === UNGROUPED_KEY) {
toast('还没有未分组的错题');
} else if (active) {
toast('当前分组(含子分组)还没有错题');
} else {
box.appendChild(mkEl('div', 'fb-mk-empty', '这里还没有错题。去刷题页面点右上角「收藏错题」试试。'));
}
}
// v0.3.11 性能修复:卷内序号改为渲染前一次性构建索引(旧版保留,v0.3.27 打开原题按钮已移除但 volIndex 不再使用)
const volIndex = null;
// v0.3.19:页面左右两列卡片流——外层 Grid 容器统一包裹所有卡片(双列 1fr 1fr,窄屏回退单列)
const grid = document.createElement('div');
grid.className = 'fb-mk-grid';
listItems.forEach(it => {
const g = state.groups.find(x => x.id === it.group);
const cardEl = document.createElement('div');
// v0.3.18:隐藏答案状态给卡片加 fb-mk-answers-off(选项绿色/红色高亮由 CSS 统一隐藏)
cardEl.className = 'fb-mk-item' + (showAnswers ? '' : ' fb-mk-answers-off');
// v0.3.19:页面左右两列卡片流——外层 .fb-mk-grid 双列 Grid,卡片自身完整(题干/选项/笔记/操作不拆)
const meta = mkEl('div', 'fb-mk-item-meta',
(g ? groupPathText(g) : '未分组') +
(it.source ? ' · ' + it.source : '') +
' · ' + davFormatTime(new Date(it.createdAt || Date.now())) +
(showAnswers && it.correctAnswer ? ' · 正确答案 ' + it.correctAnswer : '') +
(showAnswers && it.yourAnswer ? ' · 你的答案 ' + it.yourAnswer : ''));
cardEl.appendChild(meta);
// v0.3.26:题干/选项(含材料)与笔记左右并排——qmain 为 flex 容器,
// 左列 qbody 放材料+题干+选项,右列 note 放笔记(见下方笔记区块)
const qmain = document.createElement('div');
qmain.className = 'fb-mk-qmain';
const qbody = document.createElement('div');
qbody.className = 'fb-mk-qbody';
// v0.3.16:资料分析收藏题展示组内材料板块(正文 + base64 图片),置于题干之前
if (it.material || it.materialImages) {
const matDiv = mkEl('div', 'fb-mk-material', it.material || '');
appendBase64Imgs(matDiv, it.materialImages, 'fb-mk-img');
qbody.appendChild(matDiv);
}
const stemDiv = document.createElement('div');
stemDiv.className = 'fb-mk-stem';
stemDiv.innerHTML = stripHTML(it.stem || '(题干缺失)');
appendBase64Imgs(stemDiv, it.stemImage, 'fb-mk-img');
qbody.appendChild(stemDiv);
const opts = document.createElement('div');
opts.className = 'fb-mk-opts';
(it.options || []).forEach(o => {
// v0.3.18:隐藏答案时同时去掉 ✓ / ✗ 正确错误标记字符
const note = (showAnswers ? (o.correct ? ' ✓' : '') + (o.chosen && !o.correct ? ' ✗' : '') : '');
const txt = o.letter + '、' + (o.label ? o.label : '(正文缺失)') + note;
const optEl = document.createElement('div');
optEl.className = 'fb-mk-opt' + (o.correct ? ' right' : '') + (o.chosen && !o.correct ? ' wrong' : '');
optEl.appendChild(mkEl('span', 'fb-mk-opt-txt', txt));
appendBase64Imgs(optEl, o.image, 'fb-mk-img');
opts.appendChild(optEl);
});
qbody.appendChild(opts);
qmain.appendChild(qbody);
// v0.3.26:笔记板块置于题干与选项右侧并排(qmain 右列),不再放在题干/选项下方
// v0.3.27:笔记占满右侧空白(flex:1),不再固定 400px
{
const noteBox = document.createElement('div');
noteBox.className = 'fb-mk-note';
noteBox.appendChild(mkEl('div', 'fb-mk-note-label', '笔记'));
const noteTa = document.createElement('textarea');
noteTa.className = 'fb-mk-note-input';
noteTa.placeholder = '记录本题的思路、易错点…';
noteTa.value = it.note || '';
noteBox.appendChild(noteTa);
const noteRow = document.createElement('div');
noteRow.className = 'fb-mk-note-row';
const noteSave = mkEl('button', 'fb-mk-gbtn', '保存笔记');
noteSave.addEventListener('click', () => {
const val = (noteTa.value || '').trim();
if ((it.note || '') === val) { toast('笔记内容未变化'); return; }
it.note = val;
it.updatedAt = Date.now();
persist(); scheduleUpload();
toast('笔记已保存');
});
noteRow.appendChild(noteSave);
// 与"保存笔记"同行:改分组 + 删除
const mv = mkEl('button', 'fb-mk-gbtn', '改分组');
mv.addEventListener('click', () => { openPicker({ qid: it.qid, stem: it.stem, options: it.options, correctAnswer: it.correctAnswer, yourAnswer: it.yourAnswer, url: it.url }); });
const rm = mkEl('button', 'fb-mk-gbtn fb-mk-gbtn-danger', '删除');
rm.addEventListener('click', () => { askRemoveItem(it); });
noteRow.appendChild(mv);
noteRow.appendChild(rm);
noteBox.appendChild(noteRow);
qmain.appendChild(noteBox);
}
cardEl.appendChild(qmain);
// acts 不再需要(按钮已移入 noteRow),仅保留容器用于将来扩展
// acts.className = 'fb-mk-item-acts';
// acts.appendChild(mv); acts.appendChild(rm);
// cardEl.appendChild(acts);
grid.appendChild(cardEl);
});
// v0.3.24:两栏布局包裹(左分组树 + 右题目列表;独立右侧笔记面板已移除,笔记直接在卡片内编辑)
// v0.3.26:两栏为唯一排版,双列卡片流(grid)与切换按钮已移除,无条件走两栏分支
// v0.3.24:两栏模式下页面不再整页滚动:外层容器切换为“列布局”样式(flex 纵向铺满视口),
// 两栏容器高度由 flex:1 自适应视口剩余,彻底规避固定 calc(100vh - 220px) 与页面实际内容叠加造成的底部空白
page.classList.add('fb-mk-page-cols');
box.classList.add('fb-mk-wrap-cols');
const threeCol = document.createElement('div');
threeCol.className = 'fb-mk-three-col';
// v0.3.21:读取持久化的左栏宽度(默认 200,未存过或损坏时回退默认;兼容旧 {left,right} 存储)
const cw = loadColWidths();
// 左栏:分组树(独立滚动)
const groupPanel = document.createElement('div');
groupPanel.className = 'fb-mk-group-panel';
groupPanel.style.width = cw.left + 'px';
groupPanel.style.flexBasis = cw.left + 'px';
groupPanel.appendChild(filter);
threeCol.appendChild(groupPanel);
// v0.3.21:左-右分隔条(拖拽调节左栏宽度,双击恢复默认;两栏布局仅保留这一个分隔条)
threeCol.appendChild(makeColDivider('left'));
// 右栏:题目列表(每道题完整卡片:题干/选项/卡片内笔记/操作按钮,独立滚动)
const questionList = document.createElement('div');
questionList.className = 'fb-mk-question-list';
questionList.appendChild(grid);
threeCol.appendChild(questionList);
box.appendChild(threeCol);
page.appendChild(box);
};
let activeGroup = '';
// v0.3.32:未分组筛选哨兵(不会与真实分组 id 冲突,真实 id 以 'g' 开头或为 builtin 英文 id)
const UNGROUPED_KEY = '__ungrouped__';
// v0.3.18:错题本「显示答案 / 隐藏答案」开关,默认显示
let showAnswers = true;
let expandedGroups = {};
let allExpanded = true;
// 单节点是否展开:优先取该节点本轮的显式覆盖,未覆盖则跟随全局开关(默认全部展开)
function isExpanded(id) {
return Object.prototype.hasOwnProperty.call(expandedGroups, id) ? !!expandedGroups[id] : allExpanded;
}
/* v0.3.24:独立右侧笔记面板与 updateNotePanel / savePanelNote 已移除,
笔记直接在每道题卡片内编辑(见 renderNotebook 卡片渲染区) */
/* =====================================================
* 5.1 v0.3.21:两栏宽度拖拽调节
* v0.3.24:三栏合并为两栏,仅保留左栏宽度 140-480px(默认 200),右栏自适应剩余;
* 宽度持久化到 localStorage(键 fb-mk-col-widths,兼容旧 {left, right} 存储,right 忽略),
* 双击分隔条恢复默认;拖拽用 mousedown + document mousemove/mouseup,
* mouseup 后解绑防止拖出窗口卡死。
* ===================================================== */
const COL_WIDTHS_KEY = 'fb-mk-col-widths';
const COL_MIN = { left: 140 };
const COL_MAX = { left: 480 };
const COL_DEF = { left: 238 }; // v0.3.29:默认左栏宽 200px + 1cm(≈38px)
function loadColWidths() {
const w = { left: COL_DEF.left };
try {
const v = JSON.parse(gmv.get(COL_WIDTHS_KEY, ''));
if (v && typeof v === 'object') {
if (typeof v.left === 'number' && v.left >= COL_MIN.left && v.left <= COL_MAX.left) {
// v0.3.29:旧持久化宽度(未带 ver 标记)一次性 +1cm(38px)迁移,避免重复累加
if (v.ver === 2) {
w.left = Math.round(v.left);
} else {
w.left = Math.min(COL_MAX.left, Math.round(v.left) + 38);
saveColWidths(w.left);
}
}
}
} catch (e) { /* 存储损坏时回退默认 */ }
return w;
}
function saveColWidths(left) {
try { gmv.set(COL_WIDTHS_KEY, JSON.stringify({ left: Math.round(left), ver: 2 })); } catch (e) { /* ignore */ }
}
function applyColWidths(left) {
const threeCol = document.querySelector('.fb-mk-three-col');
if (!threeCol) return;
const gp = threeCol.querySelector('.fb-mk-group-panel');
if (gp) { gp.style.width = left + 'px'; gp.style.flexBasis = left + 'px'; }
}
function makeColDivider(side) {
const d = document.createElement('div');
d.className = 'fb-mk-divider';
d.dataset.side = side;
d.title = '拖拽调节宽度,双击恢复默认';
d.addEventListener('mousedown', (e) => {
e.preventDefault();
const threeCol = d.closest('.fb-mk-three-col');
if (!threeCol) return;
const w = loadColWidths();
const startX = e.clientX;
let curLeft = w.left, moved = false;
const prevUserSelect = document.body.style.userSelect;
document.body.style.userSelect = 'none';
d.classList.add('fb-mk-divider-dragging');
function onMove(ev) {
const dx = ev.clientX - startX;
curLeft = Math.min(COL_MAX.left, Math.max(COL_MIN.left, w.left + dx));
applyColWidths(curLeft);
moved = true;
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.userSelect = prevUserSelect;
d.classList.remove('fb-mk-divider-dragging');
if (moved) saveColWidths(curLeft);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
d.addEventListener('dblclick', () => {
applyColWidths(COL_DEF.left);
saveColWidths(COL_DEF.left);
toast('已恢复默认栏宽(左栏 238px)');
});
return d;
}
/* =====================================================
* 6. WebDAV 配置弹窗
* ===================================================== */
function openWebdavModal() {
const old = document.querySelector('.fb-mk-mask.fb-mk-dav');
if (old && old.parentNode) old.parentNode.removeChild(old);
const c = davGetCfg();
const mask = document.createElement('div');
mask.className = 'fb-mk-mask fb-mk-dav';
const card = document.createElement('div');
card.className = 'fb-mk-card fb-mk-card-dav';
card.appendChild(mkEl('div', 'fb-mk-title', 'WebDAV 同步设置'));
const fields = [
{ k: 'host', label: '服务器地址', ph: 'https://dav.jianguoyun.com/dav', tip: '坚果云固定用这个地址' },
{ k: 'user', label: '账号', ph: '你的坚果云注册邮箱', tip: '' },
{ k: 'pass', label: '密码', ph: '坚果云「应用密码」', tip: '坚果云必须在 账户信息 → 安全选项 → 添加应用 生成应用密码,填登录密码会一直 401', type: 'password' },
{ k: 'path', label: '保存路径', ph: DAV_PATH_DEF, tip: '相对 WebDAV 根目录的路径;只填文件会按坚果云规则自动补成 ' + DAV_JGY_ROOT + '/文件名' }
];
const inputs = {};
fields.forEach(f => {
const row = document.createElement('div');
row.className = 'fb-mk-formrow';
row.appendChild(mkEl('label', 'fb-mk-label', f.label));
const inp = document.createElement('input');
inp.className = 'fb-mk-input';
inp.type = f.type || 'text';
inp.placeholder = f.ph;
inp.value = c[f.k] || '';
inputs[f.k] = inp;
row.appendChild(inp);
if (f.tip) row.appendChild(mkEl('div', 'fb-mk-tip', f.tip));
card.appendChild(row);
});
const msg = mkEl('div', 'fb-mk-sub', '');
card.appendChild(msg);
const foot = document.createElement('div');
foot.className = 'fb-mk-foot fb-mk-foot-wrap';
const readForm = () => ({
host: inputs.host.value, user: inputs.user.value,
pass: inputs.pass.value, path: inputs.path.value
});
const bTest = mkEl('button', 'fb-mk-gbtn', '测试连接');
bTest.addEventListener('click', () => {
msg.textContent = '正在测试…';
davTestConnection(readForm()).then(r => {
msg.textContent = '连接成功(HTTP ' + r.status + ',通道 ' + r.via + ');目标文件' +
(r.fileExists ? '已存在,' + r.bytes + ' 字节' : '尚不存在,首次同步会自动创建');
}, e => {
msg.textContent = '连接失败:' + errText(e);
});
});
const bSave = mkEl('button', 'fb-mk-gbtn fb-mk-gbtn-main', '保存并同步');
bSave.addEventListener('click', () => {
const ok = davSaveCfg(readForm());
if (!ok) { msg.textContent = '配置已应用,但写入本地存储失败(可能是隐私模式或存储配额已满)'; }
refreshBadge();
if (!davIsConfigured()) { msg.textContent = '地址和账号为必填项'; return; }
msg.textContent = '已保存,正在同步…';
davPush('manual').then(r => {
msg.textContent = '同步完成:' + r.count + ' 道题' + (r.created ? '(远端文件已新建)' : '');
toast('已同步到 WebDAV(' + r.count + ' 道题)');
}, e => {
msg.textContent = '同步失败:' + errText(e);
});
});
const bPull = mkEl('button', 'fb-mk-gbtn', '从远端拉取');
bPull.addEventListener('click', () => {
davSaveCfg(readForm()); refreshBadge();
msg.textContent = '正在拉取…';
davPull(false).then(r => {
msg.textContent = r.exists ? ('拉取完成:当前 ' + r.items + ' 道题') : '远端还没有文件';
}, e => { msg.textContent = '拉取失败:' + errText(e); });
});
const bDiag = mkEl('button', 'fb-mk-gbtn', '诊断信息');
bDiag.addEventListener('click', () => {
msg.textContent = '诊断信息已打印到浏览器控制台(F12 → Console)';
davLog(davDiagText());
});
const bClear = mkEl('button', 'fb-mk-gbtn fb-mk-gbtn-danger', '清空配置');
bClear.addEventListener('click', () => {
davClearCfg();
inputs.host.value = ''; inputs.user.value = ''; inputs.pass.value = ''; inputs.path.value = DAV_PATH_DEF;
msg.textContent = '已清空配置(远端已同步的文件不会删)';
refreshBadge();
});
const bJgy = mkEl('button', 'fb-mk-gbtn', '一键导入坚果云地址');
bJgy.addEventListener('click', () => {
inputs.host.value = 'https://dav.jianguoyun.com/dav/';
msg.textContent = '已填入坚果云服务器地址(https://dav.jianguoyun.com/dav/);账号填注册邮箱,密码请填坚果云「账户信息 → 安全选项 → 第三方应用管理」中生成的应用密码(非登录密码)';
});
const bClose = mkEl('button', 'fb-mk-gbtn', '关闭');
bClose.addEventListener('click', () => { if (mask.parentNode) mask.parentNode.removeChild(mask); });
[bTest, bSave, bPull, bDiag, bClear, bJgy, bClose].forEach(b => foot.appendChild(b));
card.appendChild(foot);
mask.appendChild(card);
mask.addEventListener('click', e => { if (e.target === mask && mask.parentNode) mask.parentNode.removeChild(mask); });
document.body.appendChild(mask);
}
/* =====================================================
* 7. 导出 / 导入 JSON(配置不含在内,避免密码外泄)
* ===================================================== */
function exportJson() {
const doc = buildDoc();
const text = JSON.stringify(doc, null, 2);
try {
const blob = new Blob([text], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'fenbi-mistakes-' + davFormatTime(new Date()).replace(/[: ]/g, '-') + '.json';
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); if (a.parentNode) a.parentNode.removeChild(a); }, 1000);
toast('已导出 ' + state.items.length + ' 道题(不含 WebDAV 密码)');
} catch (e) {
toast('导出失败:' + errText(e));
}
}
function importJson() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.addEventListener('change', function (ev) {
var f = input.files[0];
if (!f) return;
var reader = new FileReader();
reader.onload = function (e) {
try {
var doc = parseDoc(e.target.result);
if (!doc) { toast('导入失败:无法解析 JSON'); return; }
if (!doc.groups || !doc.items) { toast('导入失败:JSON 格式不兼容(缺少 groups/items)'); return; }
var oldCount = state.items.length;
mergeDocs(doc);
var addedCount = state.items.length - oldCount;
toast('导入成功:追加 ' + addedCount + ' 道题,共 ' + state.items.length + ' 道');
if (renderNotebook) renderNotebook();
} catch (err) {
toast('导入失败:' + errText(err));
}
};
reader.readAsText(f);
});
input.click();
}
/* =====================================================
* 8. 悬浮入口 / 徽章 / 全局样式
* ===================================================== */
function refreshBadge() {
// 2026-10-10:移除错题数小红点徽标;同时清理旧版脚本残留的徽标元素
const b = document.querySelector('.fb-mk-entry-count');
if (b && b.parentNode) b.parentNode.removeChild(b);
}
function ensureEntry() {
let wrap = document.querySelector('.fb-mk-entry');
if (!wrap) {
wrap = document.createElement('div');
wrap.className = 'fb-mk-entry';
const btn = document.createElement('button');
btn.className = 'fb-mk-entry-btn';
btn.textContent = '错题本';
btn.addEventListener('click', function () { clickLog('btn:own-click'); openNotebook(); });
const dragHandle = document.createElement('span');
dragHandle.className = 'fb-mk-entry-drag';
dragHandle.textContent = '⠿';
dragHandle.title = '拖动调整位置';
const syncBtn = document.createElement('button');
syncBtn.className = 'fb-mk-entry-sync';
syncBtn.textContent = '同步';
syncBtn.style.display = 'none'; // 隐藏同步按钮
syncBtn.addEventListener('click', () => {
if (!davIsConfigured()) { openWebdavModal(); return; }
toast('正在同步…', 15000);
davPush('manual').then(r => { toast('已同步(' + r.count + ' 道题)'); }, e => { toast('同步失败:' + errText(e), 6000); });
});
wrap.appendChild(btn); wrap.appendChild(dragHandle); wrap.appendChild(syncBtn);
document.body.appendChild(wrap);
} else if (!wrap.querySelector('.fb-mk-entry-drag')) {
// 2026-10-10:旧版 DOM 无拖拽手柄,补建
const dragHandle = document.createElement('span');
dragHandle.className = 'fb-mk-entry-drag';
dragHandle.textContent = '⠿';
dragHandle.title = '拖动调整位置';
wrap.appendChild(dragHandle);
}
// 2026-10-10:清理旧版脚本已注入的红点徽标残留
const oldCnt = wrap.querySelector('.fb-mk-entry-count');
if (oldCnt && oldCnt.parentNode) oldCnt.parentNode.removeChild(oldCnt);
// 固定左上角浮动 + 恢复记忆位置
positionEntry(wrap);
setupEntryDrag(wrap);
refreshBadge();
}
// 兜底:即使按钮被 header 内其它层覆盖导致自身 click 收不到,也用捕获阶段的委托点击兜住
if (!window.__fbMkEntryDelegate) {
window.__fbMkEntryDelegate = true;
document.addEventListener('click', function (ev) {
const t = ev.target;
if (!t || !t.closest) return;
const btn = t.closest('.fb-mk-entry-btn');
if (!btn) return;
ev.preventDefault();
ev.stopPropagation();
clickLog('delegate:capture-hit');
try { openNotebook(); } catch (e) { clickLog('delegate:error', errText(e)); }
}, true);
}
// 两个矩形是否相交(任一为空/不可见时视为不相交)
function rectsOverlap(a, b) {
if (!a || !b || !a.width || !a.height || !b.width || !b.height) return false;
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
}
function entryToFloat(wrap) {
wrap.classList.add('fb-mk-entry-float');
if (wrap.parentNode !== document.body) document.body.appendChild(wrap);
}
// 重写网页原生 back-btn():不删除不隐藏,拦截默认跳转改为跳转目录
(function hijackBackBtn() {
const backBtn = document.querySelector('.back-btn');
if (!backBtn) return;
if (!backBtn._fbBackHandler) {
const newHandler = function (ev) {
ev.preventDefault();
ev.stopPropagation();
try { location.href = 'https://www.fenbi.com/spa/tiku/guide/catalog'; } catch (e) { /* ignore */ }
};
newHandler._fbIsHijack = true;
backBtn._fbBackHandler = newHandler;
backBtn.addEventListener('click', newHandler, true);
}
})();
// 2026-10-10:入口按钮改为左上角固定浮动,位置可拖拽记忆。
// 不再做 header-left/back-btn/标题归位,避免与标题重叠;刷新后按 localStorage 恢复上次位置。
function positionEntry(wrap) {
try {
if (wrap.parentNode !== document.body) document.body.appendChild(wrap);
wrap.classList.add('fb-mk-entry-float');
applySavedEntryPos(wrap);
} catch (e) { /* ignore */ }
}
function applySavedEntryPos(wrap) {
let left = 18, top = 18;
try {
const raw = localStorage.getItem('fb_mk_entry_pos');
if (raw) {
const p = JSON.parse(raw);
if (p && typeof p.left === 'number' && typeof p.top === 'number') { left = p.left; top = p.top; }
}
} catch (e) { /* ignore */ }
const vw = window.innerWidth || document.documentElement.clientWidth;
const vh = window.innerHeight || document.documentElement.clientHeight;
const w = wrap.offsetWidth || 120, h = wrap.offsetHeight || 40;
left = Math.max(4, Math.min(left, vw - w - 4));
top = Math.max(4, Math.min(top, vh - h - 4));
wrap.style.left = left + 'px';
wrap.style.top = top + 'px';
}
// 2026-10-10:拖拽手柄移动浮动按钮,松手后把位置写入 localStorage
function setupEntryDrag(wrap) {
if (wrap._fbDragBound) return;
wrap._fbDragBound = true;
const handle = wrap.querySelector('.fb-mk-entry-drag');
if (!handle) return;
let sx = 0, sy = 0, sl = 0, st = 0, dragging = false;
handle.addEventListener('pointerdown', function (ev) {
if (ev.button !== undefined && ev.button !== 0) return;
ev.preventDefault();
const r = wrap.getBoundingClientRect();
sx = ev.clientX; sy = ev.clientY; sl = r.left; st = r.top;
dragging = true;
try { handle.setPointerCapture(ev.pointerId); } catch (e) { /* ignore */ }
});
handle.addEventListener('pointermove', function (ev) {
if (!dragging) return;
ev.preventDefault();
const dx = ev.clientX - sx, dy = ev.clientY - sy;
const vw = window.innerWidth || document.documentElement.clientWidth;
const vh = window.innerHeight || document.documentElement.clientHeight;
const w = wrap.offsetWidth || 120, h = wrap.offsetHeight || 40;
wrap.style.left = Math.max(4, Math.min(sl + dx, vw - w - 4)) + 'px';
wrap.style.top = Math.max(4, Math.min(st + dy, vh - h - 4)) + 'px';
});
const endDrag = function () {
if (!dragging) return;
dragging = false;
try {
const r = wrap.getBoundingClientRect();
localStorage.setItem('fb_mk_entry_pos', JSON.stringify({ left: Math.round(r.left), top: Math.round(r.top) }));
} catch (e) { /* ignore */ }
};
handle.addEventListener('pointerup', endDrag);
handle.addEventListener('pointercancel', endDrag);
}
// v0.3.16:把 base64 图片段('|||' 分隔,多图)渲染为 追加到容器,max-width 由 CSS 限制
function appendBase64Imgs(parent, b64Str, cls) {
if (!parent || !b64Str) return;
const parts = String(b64Str).split('|||');
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (!p) continue;
try {
const img = document.createElement('img');
img.className = cls || 'fb-mk-img';
img.src = p;
img.alt = '';
parent.appendChild(img);
} catch (e) { /* ignore */ }
}
}
function mkEl(tag, cls, text) {
const el = document.createElement(tag);
if (cls) el.className = cls;
if (text !== undefined && text !== null) el.textContent = String(text);
return el;
}
function toast(msg, ms, actionText, action) {
try {
let box = document.querySelector('.fb-mk-toast');
if (!box) { box = document.createElement('div'); box.className = 'fb-mk-toast'; document.body.appendChild(box); }
box.textContent = '';
const span = mkEl('span', 'fb-mk-toast-text', msg);
box.appendChild(span);
if (actionText && typeof action === 'function') {
const b = mkEl('button', 'fb-mk-toast-act', actionText);
b.addEventListener('click', () => { try { action(); } catch (e) { /* ignore */ } if (box.parentNode) box.parentNode.removeChild(box); });
box.appendChild(b);
}
box.classList.add('on');
clearTimeout(toast._t);
toast._t = setTimeout(() => { box.classList.remove('on'); }, ms || 2600);
} catch (e) { davLog(msg); }
}
const CSS = [
'.fb-mk-entry{display:inline-flex;align-items:center;gap:6px;font-size:13px;margin:0;align-self:center;vertical-align:middle;position:relative;z-index:2147483000;pointer-events:auto}',
'.fb-mk-entry-block{display:flex;width:100%;box-sizing:border-box;margin:6px 0 0}',
/* 2026-10-10:默认左上角固定浮动,位置可拖拽记忆(localStorage 持久化) */
'.fb-mk-entry-float{position:fixed;left:18px;top:18px;z-index:2147483000;margin:0;align-self:auto;cursor:grab;user-select:none;touch-action:none}',
'.fb-mk-entry-drag{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;background:rgba(47,107,255,.12);color:#2f6bff;font-size:13px;line-height:1;cursor:grab;user-select:none;flex:0 0 auto}',
'.fb-mk-entry-btn,.fb-mk-entry-sync{border:1px solid #0052D9;border-radius:18px;padding:8px 14px;background:#fff;color:#0052D9;cursor:pointer;font-weight:400;font-size:13px}',
'.fb-mk-entry-sync{background:#20a464}',
'.fb-mk-fav{position:absolute;right:100%;top:0;display:inline-flex;align-items:center;margin:0;z-index:5}',
'.fb-mk-fav-btn{border:1px solid #2f6bff;border-radius:6px;background:#fff;color:#2f6bff;padding:3px 8px;font-size:12px;line-height:1.2;white-space:nowrap;cursor:pointer}',
'.fb-mk-fav-btn-loading{opacity:.55;cursor:wait;pointer-events:none}',
/* 宿主若原本是 static,补相对定位以承载按钮的 absolute(普通题 .ti 与资料分析小题 app-ti/.questions-container 均适用) */
'.fb-mk-ti-host{position:relative}',
/* v0.3.17:资料分析小题按钮的流内样式——降级宿主(app-ti/.questions-container)若把 absolute 按钮顶到容器左侧外部,
会被 .questions-container/app-resizable 等祖先的 overflow 裁剪导致完全消失;改用流内 inline-flex 内嵌到小题容器顶部,
不依赖 absolute 贴边定位,保证每道小题旁都可见可点 */
'.fb-mk-fav-standalone{position:relative;right:auto;top:auto;display:inline-flex;align-items:center;margin:6px 8px 0 0;z-index:6;vertical-align:middle}',
'.fb-mk-mask{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483200;display:flex;align-items:center;justify-content:center}',
'.fb-mk-card{background:#fff;border-radius:12px;padding:18px;min-width:340px;max-width:min(560px,92vw);max-height:86vh;overflow:auto;box-shadow:0 12px 40px rgba(0,0,0,.25);font-size:14px;color:#222}',
'.fb-mk-card-dav{min-width:420px}',
'.fb-mk-title{font-size:16px;font-weight:600;margin-bottom:12px}',
'.fb-mk-list{display:flex;flex-wrap:wrap;gap:8px;max-height:240px;overflow:auto}',
'.fb-mk-gbtn{border:1px solid #d6dce8;background:#f7f9fc;color:#243;border-radius:8px;padding:7px 12px;cursor:pointer;font-size:13px}',
'.fb-mk-gbtn:hover{background:#eef3fb}',
'.fb-mk-gbtn-main{background:#2f6bff;border-color:#2f6bff;color:#fff}',
'.fb-mk-gbtn-danger{background:#fff2f2;border-color:#ffc9c9;color:#c22}',
'.fb-mk-newline{display:flex;gap:8px;margin-top:12px}',
'.fb-mk-input{flex:1;border:1px solid #d6dce8;border-radius:8px;padding:7px 10px;font-size:13px;outline:none}',
'.fb-mk-input:focus{border-color:#2f6bff}',
'.fb-mk-foot{display:flex;gap:8px;margin-top:16px;justify-content:flex-end}',
'.fb-mk-foot-wrap{flex-wrap:wrap}',
'.fb-mk-formrow{margin-bottom:12px}',
'.fb-mk-label{display:block;font-size:12px;color:#66718a;margin-bottom:4px}',
'.fb-mk-tip{font-size:11px;color:#96a0b5;margin-top:4px;line-height:1.5}',
'.fb-mk-sub{font-size:12px;color:#66718a;margin-top:6px;line-height:1.6;word-break:break-all}',
'.fb-mk-page{position:fixed;inset:0;background:#f4f6fb;z-index:2147483100;overflow:auto}',
'.fb-mk-wrap{max-width:none;width:100%;margin:0 auto;padding:24px 20px 80px;box-sizing:border-box}',
'.fb-mk-head{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:6px}',
'.fb-mk-h2{font-size:20px;margin:0;color:#1b2540}',
'.fb-mk-tools{display:flex;gap:8px;flex-wrap:wrap}',
'.fb-mk-filter{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}',
'.fb-mk-tag{border:1px solid #d6dce8;background:#fff;border-radius:999px;padding:5px 12px;font-size:12px;cursor:pointer;color:#44506b}',
'.fb-mk-tag.on{background:#2f6bff;border-color:#2f6bff;color:#fff}',
'.fb-mk-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start}',
// v0.3.26:卡片内题干/选项(含材料)与笔记左右并排
'.fb-mk-qmain{display:flex;gap:14px;align-items:flex-start}',
'.fb-mk-qbody{flex:1 1 auto;min-width:0}',
'@media (max-width:1100px){.fb-mk-qmain{flex-direction:column}.fb-mk-qmain .fb-mk-note{flex:1 1 auto;max-width:none;width:100%}}',
'.fb-mk-item{background:#fff;border-radius:10px;padding:14px 16px;box-shadow:0 2px 10px rgba(30,50,90,.06);display:flex;flex-direction:column;gap:12px;min-width:0}',
'@media (max-width:960px){.fb-mk-grid{grid-template-columns:1fr}}',
'.fb-mk-item-meta{font-size:12px;color:#8c97ad;margin-bottom:6px}',
'.fb-mk-material{font-size:13px;line-height:1.6;color:#4a5670;background:#f6f8fc;border:1px solid #e4e9f2;border-radius:6px;padding:10px 12px;margin:0 0 8px;white-space:pre-wrap;word-break:break-word}',
'.fb-mk-stem{font-size:16px;line-height:1.8;color:#1b2540;white-space:pre-wrap;word-break:break-word}',
'.fb-mk-img{display:block;max-width:100%;height:auto;margin:6px 0;border-radius:4px}',
'.fb-mk-opts{display:block;margin:8px 0}',
'.fb-mk-opt{display:block;margin:2px 0;line-height:1.6;border:1px solid #e2e7f0;border-radius:6px;padding:2px 8px;font-size:13px;color:#5b6580;word-break:break-word}',
'.fb-mk-opt.right{border-color:#20a464;color:#20a464}',
'.fb-mk-opt.wrong{border-color:#ff5a5f;color:#ff5a5f}',
'.fb-mk-answers-off .fb-mk-opt.right,.fb-mk-answers-off .fb-mk-opt.wrong{border-color:#e2e7f0;color:#5b6580}',
// v0.3.26:笔记为题干右侧固定宽列,v0.3.27:固定 400px、textarea 占满可用高度不可拉伸
'.fb-mk-note{flex:0 0 calc(400px - 3cm);min-width:calc(400px - 3cm);box-sizing:border-box;background:#f6f8fc;border:1px solid #e4e9f2;border-radius:8px;padding:10px 12px;display:flex;flex-direction:column}',
'.fb-mk-note-label{font-size:12px;font-weight:600;color:#44506b;margin-bottom:6px}',
'.fb-mk-note-input{width:100%;min-height:140px;box-sizing:border-box;border:1px solid #d6dce8;border-radius:6px;padding:8px 10px;font-size:13px;line-height:1.6;color:#1b2540;resize:none;background:#fff;font-family:inherit;flex:1 1 auto}',
'.fb-mk-note-input:focus{outline:none;border-color:#2f6bff}',
'.fb-mk-note-row{display:flex;gap:8px;justify-content:flex-end;margin-top:8px}',
'.fb-mk-gbtn-on{background:#2f6bff;border-color:#2f6bff;color:#fff}',
'.fb-mk-item-acts{display:flex;gap:8px;justify-content:flex-end}',
'.fb-mk-empty{background:#fff;border-radius:10px;padding:28px;text-align:center;color:#8c97ad}',
'.fb-mk-mask-sheet{align-items:flex-start;justify-content:center}',
'.fb-mk-sheet{width:100%;max-width:900px;background:#fff;border-radius:0;display:flex;flex-direction:column;box-shadow:0 -8px 40px rgba(0,0,0,.25);animation:fbMkDown .2s ease}',
'@keyframes fbMkDown{from{transform:translateY(-100%)}to{transform:translateY(0)}}',
'.fb-mk-handle{width:40px;height:4px;background:#ddd;border-radius:2px;margin:10px auto 0;flex:0 0 auto}',
'.fb-mk-sheet-head{padding:12px 20px;border-bottom:1px solid #f0f0f0;flex:0 0 auto}',
'.fb-mk-sheet-title-row{display:flex;justify-content:space-between;align-items:center;gap:10px}',
'.fb-mk-sheet-title{font-weight:600;font-size:15px;color:#1b2540}',
'.fb-mk-sheet-close{padding:4px 10px;font-size:12px}',
'.fb-mk-sheet-stem{font-size:12px;color:#999;margin-top:4px;line-height:1.5;max-height:36px;overflow:hidden}',
'.fb-mk-sheet-tip{font-size:11px;color:#b3bbcc;margin-top:4px;line-height:1.5}',
'.fb-mk-tree{flex:1;min-height:0;overflow-y:auto;padding:4px 0;display:flex}',
'.fb-mk-col{flex:1;min-width:0;overflow-y:auto}',
'.fb-mk-node{display:flex;align-items:center;gap:4px;padding:6px 16px;cursor:pointer;font-size:12px;color:#333;line-height:1.3}',
'.fb-mk-node:hover{background:#f5f7fa}',
'.fb-mk-node.on{background:#eef3fb;box-shadow:inset 2px 0 0 #2f6bff}',
'.fb-mk-node-arrow{width:12px;flex:0 0 12px;color:#bbb;font-size:8px;text-align:center}',
'.fb-mk-node-name{flex:1;word-break:break-word}',
'.fb-mk-node-count{font-size:10px;color:#8c97ad;background:#f0f0f0;border-radius:8px;padding:0 6px;min-width:18px;text-align:center}',
'.fb-mk-node-sub{font-size:10px;color:#a9b3c6;flex:0 0 auto}',
'.fb-mk-node-add{flex:0 0 auto;width:18px;height:18px;line-height:16px;text-align:center;border:1px solid #d6dce8;border-radius:6px;color:#2f6bff;font-size:12px;background:#fff;cursor:pointer}',
'.fb-mk-node-add:hover{background:#eef3fb}',
'.fb-mk-addbar{display:flex;gap:8px;padding:12px 20px;border-top:1px solid #f0f0f0;flex:0 0 auto}',
'.fb-mk-sheet-foot{display:flex;gap:8px;justify-content:flex-end;padding:0 20px 14px;flex:0 0 auto}',
'.fb-mk-tree{align-items:flex-start;row-gap:16px}',
/* v0.3.23:显示收藏分类——粉笔官网「错题本分类」keypoint-tree 样式移植(行内 flex 单行节点 + 箭头方块 + 题数 + 胶囊按钮 + 子分组缩进) */
'.fb-mk-kp-tree{display:flex;flex-direction:column;margin:14px 0;padding:0 0 30px;background:#fff;border:1px solid #eef1f6;border-radius:12px;box-shadow:0 6px 30px rgba(60,70,79,.06);min-width:0}',
'.fb-mk-kp-head{display:flex;justify-content:flex-end;padding:12px 20px 0;flex:0 0 auto}',
'.fb-mk-kp-node{display:block}',
'.fb-mk-kp-row{display:flex;align-items:center;padding:12px 20px;cursor:default;min-width:0}',
'.fb-mk-kp-row:hover{background:#fafbfe}',
'.fb-mk-kp-row.on{background:#eef3fb;box-shadow:inset 3px 0 0 #2f6bff}',
'.fb-mk-kp-arrow{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:4px;background:#f0f2f6;margin:0 14px 0 0;font-size:13px;line-height:1;color:#6b7482;cursor:pointer;flex:0 0 auto;user-select:none}',
'.fb-mk-kp-arrow:hover{background:#dbe6ff;color:#2f6bff}',
'.fb-mk-kp-arrow.off{color:#c3cad8;cursor:default}',
'.fb-mk-kp-arrow.off:hover{background:#f0f2f6;color:#c3cad8}',
'.fb-mk-kp-name{font-size:16px;font-weight:700;color:#3c464f;cursor:pointer;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}',
'.fb-mk-kp-name:hover{color:#2f6bff}',
'.fb-mk-kp-num{font-size:13px;font-weight:400;color:#8a9095;margin-left:10px;flex:0 0 auto}',
'.fb-mk-kp-more{border:1px solid #dee2ea;background:#fff;color:#636e92;border-radius:16px;font-size:12px;line-height:16px;padding:5px 12px;cursor:pointer;margin-left:auto;flex:0 0 auto}',
'.fb-mk-kp-more:hover{background:#eef3fb;border-color:#2f6bff;color:#2f6bff}',
'.fb-mk-kp-children{padding-left:38px;min-width:0}',
// v0.3.30 分组树拖拽:拖拽手柄(三点图标,鼠标放上去出现 grab 手势)、拖拽中半透明、位置指示
'.fb-mk-drag-handle{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;line-height:16px;border-radius:4px;font-size:14px;color:#8c97ad;background:transparent;cursor:grab;margin-right:6px;flex-shrink:0;user-select:none}',
'.fb-mk-drag-handle:hover{color:#2f6bff;background:#eef3fb}',
'.fb-mk-drag-handle:active{cursor:grabbing}',
'.fb-mk-kp-row.fb-mk-dragging{opacity:.45}',
'.fb-mk-kp-row.fb-mk-drop-top{box-shadow:inset 0 2px 0 0 #2f6bff}',
'.fb-mk-kp-row.fb-mk-drop-bottom{box-shadow:inset 0 -2px 0 0 #2f6bff}',
'.fb-mk-kp-row.fb-mk-drop-inside{background:#e8f1ff;box-shadow:inset 0 0 0 2px #2f6bff}',
'.fb-mk-kp-row.fb-mk-drop-no{opacity:.55}',
'.fb-mk-kp-tree.fb-mk-drop-root{box-shadow:inset 0 -3px 0 0 #2f6bff}',
/* v0.3.23:两栏模式下左栏分组面板适配(窄栏紧凑布局) */
'.fb-mk-three-col .fb-mk-group-panel .fb-mk-kp-tree{padding:6px 0;border:none;border-radius:0;box-shadow:none;margin:0}',
'.fb-mk-three-col .fb-mk-group-panel .fb-mk-kp-head{padding:6px 10px 2px}',
'.fb-mk-three-col .fb-mk-group-panel .fb-mk-kp-row{padding:7px 10px}',
'.fb-mk-three-col .fb-mk-group-panel .fb-mk-kp-arrow{width:20px;height:20px;margin:0 8px 0 0}',
'.fb-mk-three-col .fb-mk-group-panel .fb-mk-kp-name{font-size:13px}',
'.fb-mk-three-col .fb-mk-group-panel .fb-mk-kp-num{font-size:11px;margin-left:6px}',
'.fb-mk-three-col .fb-mk-group-panel .fb-mk-kp-more{padding:2px 8px;font-size:11px}',
'.fb-mk-three-col .fb-mk-group-panel .fb-mk-kp-children{padding-left:22px}',
'.fb-mk-tree-col{display:flex;flex-direction:column;gap:6px;flex:0 0 auto;max-width:300px}',
'.fb-mk-branch-row{display:flex;align-items:center;gap:3px}',
'.fb-mk-tri{display:inline-block;width:13px;text-align:center;margin-right:1px;border-radius:4px;color:#8c97ad;font-size:10px;cursor:pointer}',
'.fb-mk-tri:hover{background:#dbe6ff;color:#2f6bff}',
'.fb-mk-tri.off{color:#c9d2e0;cursor:inherit}',
'.fb-mk-op{flex:0 0 auto;border:1px solid #dfe5ef;background:#fff;color:#8c97ad;border-radius:6px;padding:1px 5px;font-size:12px;line-height:16px;cursor:pointer}',
'.fb-mk-op:hover{background:#eef3fb;border-color:#2f6bff;color:#2f6bff}',
'.fb-mk-hide{display:none}',
'.fb-mk-editor .fb-mk-card{min-width:min(360px,92vw)}',
'.fb-mk-elbow{color:#c3cad8;font-size:12px;flex:0 0 auto;font-family:Menlo,Consolas,monospace}',
'.fb-mk-kids{display:flex;flex-direction:column;gap:6px;margin:2px 0 0 12px;padding-left:10px;border-left:1px dashed #cdd6e6}',
'.fb-mk-tag-d1{font-size:12px}',
'.fb-mk-tag-d2{font-size:11px;padding:4px 10px}',
'.fb-mk-tag-d3{font-size:11px;padding:4px 10px;color:#7b869c}',
'.fb-mk-jump-hl{outline:2px solid #2f6bff;outline-offset:6px;border-radius:10px;box-shadow:0 0 0 6px rgba(47,107,255,.12)}',
'.fb-mk-verify-note{width:100%;min-height:64px;box-sizing:border-box;border:1px solid #d6dce8;border-radius:6px;padding:8px 10px;font-size:13px;line-height:1.6;color:#1b2540;resize:none;background:#fff;font-family:inherit;margin-top:6px}',
'.fb-mk-verify-note:focus{outline:none;border-color:#2f6bff}',
'.fb-mk-verify-note-row{margin-top:10px}',
'.fb-mk-verify-note-label{font-size:12px;font-weight:600;color:#44506b;margin-bottom:4px;display:block}',
'.fb-mk-verify-rows{margin-top:10px}',
'.fb-mk-verify-row{display:flex;gap:8px;padding:6px 0;border-bottom:1px solid #f0f0f0}',
'.fb-mk-verify-row.miss{color:#c22}',
'.fb-mk-verify-label{flex:0 0 70px;font-size:13px;color:#6b7482;text-align:right;line-height:1.6}',
'.fb-mk-verify-val{flex:1;font-size:13px;line-height:1.6;word-break:break-word}',
'.fb-mk-verify-foot{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}',
'.fb-mk-toast{position:fixed;left:50%;bottom:40px;transform:translate(-50%,20px);background:rgba(20,28,48,.92);color:#fff;padding:10px 16px;border-radius:10px;font-size:13px;z-index:2147483600;opacity:0;transition:all .22s;display:flex;align-items:center;gap:10px;max-width:80vw}',
'.fb-mk-toast.on{opacity:1;transform:translate(-50%,0)}',
'.fb-mk-toast-act{border:0;background:#2f6bff;color:#fff;border-radius:6px;padding:4px 10px;cursor:pointer}',
/* v0.3.20:两栏布局 CSS(v0.3.24:三栏合并为两栏) */
/* v0.3.24:底部空白修复——不再使用固定 calc(100vh - 220px)。
两栏模式下页面本身不滚动(.fb-mk-page-cols overflow:hidden),
.fb-mk-wrap-cols 用 flex 纵向铺满视口(height:100%),
两栏容器 .fb-mk-three-col 由 flex:1 + min-height:0 自适应视口剩余高度,
左/右两栏各自 overflow-y:auto 独立滚动,不会与页面高度叠加产生底部空白 */
'.fb-mk-page.fb-mk-page-cols{overflow:hidden}',
'.fb-mk-wrap.fb-mk-wrap-cols{display:flex;flex-direction:column;height:100%;min-height:0;padding:16px 20px 0}',
'.fb-mk-three-col{display:flex;gap:0;flex:1;min-height:0;margin-top:10px;align-items:stretch}',
'.fb-mk-three-col .fb-mk-group-panel{width:238px;flex:0 0 238px;border-right:1px solid #e4e9f2;padding:8px 0;overflow-y:auto}',
// v0.3.26:平板端优先保证左侧完整显示——左栏自适应加宽、分类名不截断
// v0.3.28:平板/Via 适配——左栏自适应完整显示(允许收缩、限占比、缩字号、容器防横向溢出),笔记缩为 200px 并保持与题干并排
'@media (max-width:1024px){.fb-mk-three-col{overflow:hidden}.fb-mk-three-col .fb-mk-group-panel{width:auto!important;flex:0 1 auto!important;min-width:218px;max-width:42vw;overflow-x:hidden}.fb-mk-three-col .fb-mk-group-panel .fb-mk-kp-name{white-space:normal;word-break:break-all;font-size:12px!important}.fb-mk-three-col .fb-mk-question-list{min-width:0}.fb-mk-qmain{flex-direction:row!important}.fb-mk-qmain .fb-mk-note{flex:0 0 calc(400px - 3cm)!important;min-width:calc(400px - 3cm)!important;max-width:calc(400px - 3cm)!important;width:calc(400px - 3cm)!important}}',
'.fb-mk-three-col .fb-mk-question-list{flex:1;min-width:0;padding:0 12px;overflow-y:auto}',
'.fb-mk-three-col .fb-mk-question-list .fb-mk-grid{display:block!important;gap:0!important}',
'.fb-mk-three-col .fb-mk-question-list .fb-mk-grid .fb-mk-item{margin-bottom:12px;border-bottom:1px solid #f0f0f0;padding-bottom:12px}',
'.fb-mk-three-col .fb-mk-question-list .fb-mk-grid .fb-mk-item:last-child{border-bottom:none;padding-bottom:0;margin-bottom:0}',
/* v0.3.21:三栏分隔条(拖拽调节栏宽,hover 高亮) */
'.fb-mk-divider{width:6px;flex:0 0 6px;cursor:col-resize;background:transparent;transition:background .15s;position:relative;z-index:5}',
'.fb-mk-divider:hover,.fb-mk-divider.fb-mk-divider-dragging{background:#d6e2ff}',
/* v0.3.20:题干字体调大(仅错题本页面) */
'#fb-mk-style .fb-mk-stem{font-size:16px!important}',
/* v0.3.20:选项字体调大(仅错题本页面) */
'#fb-mk-style .fb-mk-opt{font-size:15px!important}',
/* v0.3.24:选中高亮样式已随三栏合并移除(卡片内直接编辑笔记,无需选中态) */
'.fb-mk-item{cursor:default}'
].join('\n');
function injectCss() {
if (document.querySelector('#fb-mk-style')) return;
const s = document.createElement('style');
s.id = 'fb-mk-style';
s.textContent = CSS;
(document.head || document.documentElement).appendChild(s);
}
/* =====================================================
* 9. 启动
* ===================================================== */
function route() {
injectCss();
ensureEntry();
if (notebookOpen || String(location.hash || '').indexOf('#/mistakes') === 0) {
notebookOpen = true;
mountNotebook();
} else {
const p = document.querySelector('.fb-mk-page');
if (p && p.parentNode) p.parentNode.removeChild(p);
injectFavButton();
refreshFavButtons();
}
}
let pulledOnce = false;
function start() {
loadState(); // 必须先读本地存量,再决定是否拉远端(否则会被默认状态覆盖)
injectCss();
ensureEntry();
injectFavButton();
refreshFavButtons();
route();
// 页面本身还在加载时按钮可能被后渲染的 DOM 覆盖,这里补一次
setTimeout(route, 1500);
if (davIsConfigured() && !pulledOnce) {
pulledOnce = true;
davPull(true).then(() => { refreshFavButtons(); if (renderNotebook) renderNotebook(); }, () => { /* 静默失败,等用户手动同步 */ });
}
menu('打开错题本', openNotebook);
menu('WebDAV 设置', openWebdavModal);
menu('立即同步(上传)', () => { scheduleUpload('manual'); });
menu('从远端拉取', () => { davPull(false).catch(() => {}); });
menu('测试 WebDAV 连接', () => { davConsoleTest().catch(() => {}); });
menu('导出错题 JSON', exportJson);
// 若上一页点过「打开原题」,落地后定位到该题的题号
tryPendingJump();
}
let moQueued = false;
let moLastRun = 0;
// v0.3.11 性能修复:外部 DOM 变化触发的注入/归位增加最小间隔(ms)。
// 站点高频变更(Angular 渲染、懒加载、轮播等)不再每帧执行整轮全页重活,改为低频合并执行。
const MO_REFRESH_GAP = 400;
function scheduleExternalRefresh() {
if (moQueued) return;
moQueued = true;
const run = () => {
moQueued = false;
const now = Date.now();
if (now - moLastRun < MO_REFRESH_GAP) {
// 距上次执行太近:延后补齐剩余间隔,保证不漏刷新
setTimeout(run, MO_REFRESH_GAP - (now - moLastRun));
return;
}
moLastRun = now;
try {
if (document.querySelector('.fb-mk-page')) return;
injectFavButton();
refreshFavButtons();
ensureEntry(); // header-title 后渲染完成后,把按钮归位到其前面
} catch (e) { /* ignore */ }
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run); else setTimeout(run, 200);
}
let lastHref = '';
function boot() {
start();
setInterval(() => {
const h = String(location.href || '');
if (h !== lastHref) {
lastHref = h;
try { route(); } catch (e) { davWarn('路由切换处理异常', errText(e)); }
try { tryPendingJump(); } catch (e) { /* ignore */ }
}
}, 800);
try {
const mo = new MutationObserver(records => {
for (let i = 0; i < records.length; i++) {
const t = records[i].target;
const el = t && t.nodeType === 1 ? t : (t ? t.parentElement : null);
if (el && el.closest && el.closest('.fb-mk-page,.fb-mk-toast,.fb-mk-mask,.fb-mk-fav')) return;
}
scheduleExternalRefresh();
});
mo.observe(document.documentElement, { childList: true, subtree: true });
} catch (e) { /* 观察器不可用时降级为定时器 */ }
}
// 暴露少量调试 / 集成接口(与参考脚本 window.__fwFav 同风格)
try {
window.__fbMk = {
version: '0.3.24',
openNotebook: openNotebook,
closeNotebook: closeNotebook,
clickLog() { return (window.__fbMkClickLog || []).slice(); },
pageProbe() {
const p = document.querySelector('.fb-mk-page');
const cs = p ? getComputedStyle(p) : null;
return {
notebookOpen: notebookOpen,
hasPage: !!p,
pageInDom: !!(p && p.isConnected),
pageParent: p && p.parentNode ? p.parentNode.tagName : null,
pageDisplay: cs ? cs.display : null,
pageZ: cs ? cs.zIndex : null,
pageChildren: p ? p.children.length : 0,
pageRect: p ? (function (b) { return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height) }; })(p.getBoundingClientRect()) : null,
pageAtCenter: (function () {
if (!p) return null;
const b = p.getBoundingClientRect();
if (!b.width || !b.height) return null;
const el = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2);
return el ? (el.tagName + '.' + String(el.className || '').slice(0, 60)) : null;
})(),
itemCount: state.items.length,
hash: String(location.hash || '')
};
},
qidProbe: qidProbe,
jumpProbe() {
let pending = null;
try { pending = JSON.parse(localStorage.getItem(JUMP_KEY) || 'null'); } catch (e) { pending = null; }
const roots = questionRoots();
return {
pending: pending,
roots: roots.length,
rootsKeys: roots.slice(0, 3).map(r => questionKeysOf(r)),
titles: roots.slice(0, 3).map(r => norm(textOf(r, SEL.titleIndex))),
matched: pending ? !!findQuestionByRec(pending) : false
};
},
tryJump: tryPendingJump,
entryProbe() {
const wrap = document.querySelector('.fb-mk-entry');
const title = document.querySelector('.header-title');
const hl = document.querySelector('.header-left');
const r = el => { if (!el) return null; const b = el.getBoundingClientRect(); return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height) }; };
return {
hasWrap: !!wrap,
wrapParent: wrap && wrap.parentNode ? wrap.parentNode.tagName + '.' + String(wrap.parentNode.className).slice(0, 60) : null,
wrapPrevSib: wrap && wrap.previousElementSibling ? wrap.previousElementSibling.tagName + '.' + String(wrap.previousElementSibling.className).slice(0, 40) : null,
wrapClass: wrap ? wrap.className : null,
wrapRect: r(wrap),
hlRect: r(hl),
hlParent: hl && hl.parentNode ? hl.parentNode.tagName + '.' + String(hl.parentNode.className).slice(0, 60) : null,
wrapParentIsHlParent: !!(wrap && hl && wrap.parentNode === hl.parentNode),
wrapRightAfterHl: !!(wrap && hl && wrap.parentNode === hl.parentNode && wrap.previousElementSibling === hl),
titleRect: r(title),
titleParent: title && title.parentNode ? title.parentNode.tagName + '.' + String(title.parentNode.className).slice(0, 60) : null,
titleParentPos: title && title.parentNode ? getComputedStyle(title.parentNode).position : null,
titleParentDisplay: title && title.parentNode ? getComputedStyle(title.parentNode).display : null,
titlePos: title ? getComputedStyle(title).position : null,
overlap: (() => {
const a = wrap && wrap.getBoundingClientRect(), b = title && title.getBoundingClientRect();
if (!a || !b || !a.width || !b.width) return false;
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
})()
};
},
favDiag: favDiag,
optionProbe: optionProbe,
getState() { return JSON.parse(JSON.stringify(state)); },
getConfig() { const c = davGetCfg(); return { host: c.host, user: c.user, path: c.path, hasPass: !!c.pass }; },
saveConfig(cfg) { return davSaveCfg(cfg); },
testConnection(cfg) { return davTestConnection(cfg); },
syncNow(reason) { return davPush(reason || 'manual'); },
pullNow() { return davPull(false); },
diag() { return davDiagText(); },
probe() { const root = firstMatch(document, SEL.questionRoot); const attrs = []; if (root && root.attributes) { for (let i = 0; i < root.attributes.length; i++) attrs.push(root.attributes[i].name + '=' + String(root.attributes[i].value).slice(0, 40)); } const chain = []; let el = root, d = 0; while (el && d < 6) { chain.push(el.tagName + (el.className ? '.' + String(el.className).slice(0, 50) : '')); el = el.parentElement; d++; } const stemEl = firstMatch(root || document, SEL.stem); return { hasRoot: !!root, rootAttrs: attrs, upChain: chain, href: String(location.href), qidAttrs: qidFromAttrs(root), qidNg: qidFromAngular(root), qidHtml: qidFromHtml(root), qidUrl: qidFromUrl(), finalQid: extractQid(root, stemEl ? norm(stemEl.innerText || stemEl.textContent) : ''),
optionDiag: (function () {
const counts = SEL.option.map(s => { try { return s + ':' + document.querySelectorAll(s).length; } catch (e) { return s + ':ERR'; } });
const rows = collectOptionEls(firstMatch(document, SEL.questionRoot) || document);
return { counts: counts, rows: rows.length, radio: document.querySelectorAll('input[type=radio]').length, inputRadio: document.querySelectorAll('.input-radio').length, sample: rows.length ? String(rows[0].outerHTML || '').slice(0, 500) : '' };
})(),
parsed: (function () { const q = getQuestion(); return q ? { stem: q.stem.slice(0, 60), options: q.options, correctAnswer: q.correctAnswer, yourAnswer: q.yourAnswer } : null; })() }; },
groupProbe() { return { maxDepth: MAX_GROUP_DEPTH, total: state.groups.length, groups: state.groups.map(g => ({ id: g.id, name: g.name, parentId: g.parentId || null, level: groupLevel(g), path: groupPathText(g), count: groupItemCount(g.id) })) }; },
exportJson: exportJson,
reload() { loadState(); refreshBadge(); if (renderNotebook) renderNotebook(); }
};
} catch (e) { /* ignore */ }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();