// ==UserScript==
// @name 小雅粘粘粘
// @namespace http://tampermonkey.net/
// @author Qy
// @version 2.0
// @description 小雅粘粘粘:提取题目、生成 AI 作答模板,并保存作答记录
// @match *://*.ai-augmented.com/*
// @require https://scriptcat.org/lib/6742/14.1.0/markdown-it.js?sha384-wLhprpjsmjc/XYIcF+LpMxd8yS1gss6jhevOp6F6zhiIoFK6AmHtm4bGKtehTani
// @resource mathjax https://scriptcat.org/lib/6741/3.2.2/MathJax%203.2.2.js?sha384-4kE/rQ11E8xT9QgrCBTyvenkuPfQo8rXYQvJZuMgxyPOoUfpatjQPlgdv6V5yhUK
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @grant GM_getResourceText
// @grant GM_getValue
// @grant GM_setValue
// @grant unsafeWindow
// @connect *.ai-augmented.com
// @connect *.aliyuncs.com
// @connect xiaoya.qyovo.com
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTYiIGZpbGw9IiNlOGVkZmMiLz48cG9seWdvbiBwb2ludHM9IjI1LDM1IDU4LDM1IDU0LjUsMzkuNSA1OCw0NCAyNSw0NCIgZmlsbD0iIzJmNTRkNCIvPjxjaXJjbGUgY3g9IjI1IiBjeT0iMjciIHI9IjE3IiBmaWxsPSIjMmY1NGQ0Ii8+PGNpcmNsZSBjeD0iMjUiIGN5PSIyNyIgcj0iMTIuNSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNmQ4YmVjIiBzdHJva2Utd2lkdGg9IjEuNSIvPjxjaXJjbGUgY3g9IjI1IiBjeT0iMjciIHI9IjguNSIgZmlsbD0iI2U4ZWRmYyIvPjwvc3ZnPg==
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
const NAME = '小雅粘粘粘';
const VERSION = '2.0';
const NOTICE_API = 'https://xiaoya.qyovo.com/notice';
const NOTICE_CHANNEL = 'zhanzhanzhan';
const PAPER_API = '/api/jx-iresource/survey/course/queryStuPaper/v2';
const GROUP_API = '/api/jx-iresource/group/queryGroup/';
const WATCHED_API = /queryStuPaper\/v2|group\/queryGroup\//;
const TYPE = { 1: '单选题', 2: '多选题', 4: '填空题', 5: '判断题', 6: '简答题', 7: '附件题', 9: '题组', 13: '匹配题' };
const PRINT_TIP = '在打印窗口里选择「另存为 PDF」,文字可以复制和搜索。';
const page = unsafeWindow;
const rawFetch = page.fetch;
let paper = null;
let notice = null;
const courses = {};
const prefs = Object.assign({ theme: '', welcomed: false, right: 24, top: 96, header: 'both', withAnswers: false, answerAt: 'inline', seen: '' }, GM_getValue('prefs', {}));
const savePrefs = () => GM_setValue('prefs', prefs);
function dropLegacyKeys() {
try {
Object.keys(page.localStorage)
.filter(key => /^xy_(magic_box_position_|zhanzhanzhan_notice_|homework_export_options_|course_name_v18_)/.test(key))
.forEach(key => page.localStorage.removeItem(key));
} catch (e) { }
}
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const num = value => (value === null || value === undefined || value === '' || !Number.isFinite(Number(value))) ? null : Number(value);
const pad2 = n => String(n).padStart(2, '0');
const fileSafe = value => String(value || '课程作业').replace(/[\\/:*?"<>|]+/g, '_').replace(/\s+/g, ' ').trim().slice(0, 80) || '课程作业';
function stamp() {
const d = new Date();
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}_${pad2(d.getHours())}${pad2(d.getMinutes())}`;
}
function isNewer(latest, current) {
const a = String(latest || '').split('.').map(Number);
const b = String(current || '').split('.').map(Number);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
}
return false;
}
function parseJson(value) {
if (typeof value !== 'string' || /^\s*\d+\s*$/.test(value)) return value;
try { return JSON.parse(value); } catch (e) { return value; }
}
function getToken() {
const match = document.cookie.match(/(?:^|;\s*)prd-access-token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : '';
}
async function api(path, init = {}) {
const token = getToken();
if (!token) throw new Error('没读到登录信息,刷新页面后再试');
const res = await rawFetch.call(page, location.origin + path, {
...init,
credentials: 'include',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json; charset=UTF-8' },
});
let data = null;
try { data = await res.json(); } catch (e) { }
if (!res.ok || !data || data.success === false) throw new Error(data?.message || `请求失败(${res.status})`);
return data;
}
function gmRequest(options) {
return new Promise((resolve, reject) => GM_xmlhttpRequest({
timeout: 20000,
...options,
onload: res => (res.status >= 200 && res.status < 300) ? resolve(res) : reject(new Error(`请求失败(${res.status})`)),
onerror: () => reject(new Error('网络请求失败')),
ontimeout: () => reject(new Error('网络请求超时')),
}));
}
function mediaOf(data) {
const type = String(data?.type || '').toUpperCase();
if (type.includes('IMAGE') || type === 'IMG' || data?.src) {
const src = data.src || data.imageUrl || data.url;
return src ? { type: 'image', src } : null;
}
if (type.includes('TEX') || type.includes('MATH') || type.includes('FORMULA')) {
const tex = String(data.teX || data.tex || data.latex || '').trim();
return tex ? { type: 'math', value: tex, display: data.display === true } : null;
}
return undefined;
}
function rich(segments) {
return { segments, text: plainText(segments) };
}
function parseRich(raw) {
const doc = typeof raw === 'string' ? parseJson(raw) : raw;
if (!doc || typeof doc !== 'object' || !Array.isArray(doc.blocks)) {
const text = typeof raw === 'string' ? raw.replace(/^"|"$/g, '').trim()
: (raw && typeof raw === 'object') ? JSON.stringify(raw) : String(raw ?? '').trim();
return rich(text ? [{ type: 'text', value: text }] : []);
}
const entities = doc.entityMap || {};
const segs = [];
const text = value => { if (value) segs.push({ type: 'text', value }); };
doc.blocks.forEach(block => {
if (!block) return;
if (segs.length && segs[segs.length - 1].type !== 'break') segs.push({ type: 'break' });
const atomic = block.type === 'atomic' && mediaOf(block.data);
if (atomic) { segs.push(atomic); return; }
const str = String(block.text || '');
const ranges = (block.entityRanges || [])
.map(r => ({ start: Math.max(0, Number(r.offset) || 0), length: Math.max(0, Number(r.length) || 0), entity: entities[r.key] }))
.filter(r => r.entity)
.sort((a, b) => a.start - b.start);
let cursor = 0;
ranges.forEach(r => {
const start = Math.min(r.start, str.length);
const end = Math.min(start + r.length, str.length);
if (start < cursor) return;
text(str.slice(cursor, start));
const media = mediaOf({ ...r.entity.data, type: r.entity.type || r.entity.data?.type });
if (media) segs.push(media);
else if (media === undefined) text(str.slice(start, end));
cursor = end;
});
text(str.slice(cursor));
});
while (segs.length && segs[segs.length - 1].type === 'break') segs.pop();
return rich(segs);
}
function plainText(segments) {
return segments.map(s => s.type === 'break' ? '\n'
: s.type === 'image' ? '[图片]'
: s.type === 'math' ? (s.display ? `$$${s.value}$$` : `$${s.value}$`)
: s.value).join('')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function sortByIds(items, order) {
const list = Array.isArray(items) ? items.filter(Boolean) : [];
const ids = String(order || '').split(',').map(s => s.trim()).filter(Boolean);
if (!ids.length) return list;
const byId = new Map(list.map(item => [String(item.id), item]));
const sorted = ids.map(id => byId.get(id)).filter(Boolean);
return sorted.concat(list.filter(item => !sorted.includes(item)));
}
function addImages(images, content, label) {
content.segments.forEach(s => {
if (s.type === 'image' && !images.some(img => img.src === s.src && img.label === label)) images.push({ src: s.src, label });
});
}
function buildQuestion(raw, index, images) {
const type = Number(raw.type);
const title = parseRich(raw.title);
const items = sortByIds(raw.answer_items, raw.answer_items_sort);
const q = { index, id: raw.id, type, label: TYPE[type] || '其他题型', score: num(raw.score), title, items, options: [], left: [], right: [] };
addImages(images, title, `第 ${index} 题 · 题干`);
const option = (item, letter) => {
const content = parseRich(item.value);
addImages(images, content, `第 ${index} 题 · 选项 ${letter}`);
return { id: item.id, letter, content, text: content.text, correct: item.answer_checked === 2, answer: item.answer };
};
if (type === 1 || type === 2 || type === 5) {
q.options = items.map((item, i) => option(item, String.fromCharCode(65 + i)));
if (type === 5) q.options.forEach(o => { if (!o.text) { o.text = o.letter === 'A' ? '正确' : '错误'; o.content = rich([{ type: 'text', value: o.text }]); } });
} else if (type === 13) {
q.left = items.filter(item => item.is_target_opt !== true).map((item, i) => option(item, String.fromCharCode(65 + i)));
q.right = items.filter(item => item.is_target_opt === true).map((item, i) => option(item, String.fromCharCode(97 + i)));
}
return q;
}
function buildPaper(data, ids) {
const images = [];
const sections = [];
const questions = [];
(data.questions || []).forEach(raw => {
const subs = sortByIds(raw.subQuestions || raw.sub_questions, raw.sub_questions_sort);
const group = Number(raw.type) === 9 && subs.length > 0;
const section = { title: group ? parseRich(raw.title) : null, questions: [] };
if (group) addImages(images, section.title, `题组材料(第 ${questions.length + 1} 题起)`);
(group ? subs : [raw]).forEach(item => {
const q = buildQuestion(item, questions.length + 1, images);
section.questions.push(q);
questions.push(q);
});
sections.push(section);
});
const p = {
...ids,
key: [ids.groupId, ids.nodeId, ids.paperId].join(':'),
title: String(data.title || '课程作业'),
total: num(data.total_score),
showAnswer: data.publish_record?.is_show_answer === true,
sections,
questions,
images,
};
p.result = buildResult(data, p);
return p;
}
function answerText(value) {
const v = parseJson(value);
if (v === null || v === undefined) return '';
if (typeof v === 'object' && Array.isArray(v.blocks)) return parseRich(v).text;
if (typeof v === 'object') return Object.values(v).map(answerText).filter(Boolean).join(';');
return String(v).trim();
}
function idsOf(value) {
const v = parseJson(value);
if (Array.isArray(v)) return v.map(x => String(x).trim()).filter(Boolean);
return v === null || v === undefined ? [] : String(v).split(/[,,、\s]+/).filter(Boolean);
}
function formatMatch(q, value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return answerText(value);
const right = new Map(q.right.map(r => [String(r.id), r]));
return q.left.map(l => {
const picked = idsOf(value[l.id]).map(id => right.get(id)).map(r => r ? `${r.letter}. ${r.text}` : '').filter(Boolean);
return picked.length ? `${l.letter}. ${l.text} → ${picked.join('、')}` : '';
}).filter(Boolean).join('\n');
}
function formatAnswer(q, value) {
if (q.type === 7) return '附件题';
if (value === null || value === undefined || value === '') return '';
if ([1, 2, 5].includes(q.type)) {
return idsOf(value).map(id => q.options.find(o => String(o.id) === id)).map(o => o ? `${o.letter}. ${o.text}` : '').filter(Boolean).join(';');
}
if (q.type === 4) {
const v = parseJson(value);
if (!v || typeof v !== 'object' || Array.isArray(v)) return answerText(v);
return q.items.map((item, i) => `空${i + 1}:${answerText(v[item.id]) || '未填'}`).join(';');
}
if (q.type === 13) return formatMatch(q, parseJson(value));
return answerText(value);
}
function standardAnswer(q) {
if ([1, 2, 5].includes(q.type)) return q.options.filter(o => o.correct).map(o => `${o.letter}. ${o.text}`).join(';');
if (q.type === 4) return q.items.map((item, i) => answerText(item.answer) ? `空${i + 1}:${answerText(item.answer)}` : '').filter(Boolean).join(';');
if (q.type === 6) return answerText(q.items[0]?.answer);
if (q.type === 13) {
const map = {};
q.left.forEach(l => { if (l.answer) map[l.id] = l.answer; });
return formatMatch(q, map);
}
return '';
}
function resultState(record, q) {
if (!record) return 'none';
const score = num(record.score);
const correct = num(record.correct);
if (correct === 2 || (score !== null && q.score > 0 && score >= q.score)) return 'ok';
if (score > 0) return 'part';
if (correct === 1 || score === 0) return 'bad';
return 'pending';
}
function buildResult(data, p) {
const record = data.answer_record;
if (!record || Number(record.status) !== 2) return null;
const byId = new Map((record.answers || []).map(a => [String(a.question_id), a]));
const rows = p.questions.map(q => {
const answer = byId.get(String(q.id));
const state = resultState(answer, q);
const mine = formatAnswer(q, answer?.answer);
return { q, state, score: num(answer?.score), mine, standard: p.showAnswer ? standardAnswer(q) : '', confirmed: state === 'ok' ? mine : '' };
});
return { score: num(record.actual_score ?? record.score), total: p.total, rows };
}
function exportAnswers(p) {
const map = new Map();
p.questions.forEach((q, i) => {
const answer = (p.showAnswer && standardAnswer(q)) || p.result?.rows[i].confirmed;
if (answer) map.set(q.index, answer);
});
return map;
}
function questionLines(q, indent) {
const lines = [];
const list = items => items.forEach(o => lines.push(`${indent}${o.letter}. ${o.text}`));
if (q.options.length) list(q.options);
else if (q.type === 4) lines.push(`${indent}(本题共 ${q.items.length} 个填空)`);
else if (q.type === 7) lines.push(`${indent}附件题无需回答。`);
else if (q.type === 13) {
lines.push(`${indent}左侧:`);
list(q.left);
lines.push('', `${indent}右侧候选:`);
list(q.right);
}
return lines;
}
function buildPrompt(p) {
const course = getCourse(p.groupId);
const lines = ['【小雅粘粘粘:AI 作答模板】'];
if (course) lines.push(`课程:${course}`);
lines.push(
'请根据以下题目,严格按照指定格式输出答案,不要输出解析、注释或额外说明。',
'【输出格式要求】',
'单选/判断题格式:[题号] => [大写字母] (如 1 => A)',
'多选题格式:[题号] => [大写字母] (多选请用英文逗号分隔,如 2 => A,C)',
'填空题格式:[题号] => [空1] | [空2] (如 3 => const | let)',
'简答题格式:[题号] => [完整文字答案]',
'匹配题格式:[题号] => A:a,d | B:b,c',
'附件题无需回答。',
'',
'--- 以下为考试内容 ---',
'',
);
p.sections.forEach(section => {
if (section.title?.text) lines.push('【题组】', section.title.text, '');
section.questions.forEach(q => lines.push(`${q.index}. ${q.title.text} [${q.label}]`, ...questionLines(q, ' '), ''));
});
return lines.join('\n');
}
function buildHomeworkText(p) {
const course = getCourse(p.groupId);
const head = { both: [p.title, course && `课程:${course}`], course: [course && `课程:${course}`], paper: [p.title], none: [] }[prefs.header].filter(Boolean);
const lines = head.length ? [...head, ''] : [];
const answers = prefs.withAnswers ? exportAnswers(p) : new Map();
p.sections.forEach(section => {
if (section.title?.text) lines.push(section.title.text, '');
section.questions.forEach(q => {
lines.push(`${q.index}. ${q.label}`, q.title.text, ...questionLines(q, ''));
if (answers.has(q.index) && prefs.answerAt === 'inline') lines.push(`答案:${answers.get(q.index)}`);
lines.push('');
});
});
if (answers.size && prefs.answerAt === 'end') lines.push('答案', ...[...answers].map(([n, a]) => `${n}. ${a}`));
return lines.join('\n').trim();
}
function parseAnswers(text) {
const blocks = new Map();
let current = null;
String(text || '').split(/\r?\n/).forEach(line => {
if (/^\s*```/.test(line)) return;
const m = line.match(/^\s*(?:\*\*)?\s*(\d+)\s*(?:\*\*)?\s*(?:=>|=>|⇒)\s*(.*)$/);
if (m) {
current = Number(m[1]);
blocks.set(current, [m[2]]);
} else if (current !== null) {
blocks.get(current).push(line.trimEnd());
}
});
const answers = new Map();
blocks.forEach((lines, n) => {
const value = lines.join('\n').trim();
if (value) answers.set(n, value);
});
return answers;
}
function richTextAnswer(text) {
return JSON.stringify({
blocks: String(text).split(/\r?\n/).map((line, i) => ({ key: `ans-${i}`, text: line, type: 'unstyled', depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} })),
entityMap: {},
});
}
const JUDGE_TRUE = /^(正确|对|是|√|✓|✔|T|TRUE)(?![A-Za-z])/i;
const JUDGE_FALSE = /^(错误|错|否|×|✗|✘|X|F|FALSE)(?![A-Za-z])/i;
function payloadFor(q, text) {
if (q.type === 7) return { skip: true };
if ([1, 2, 5].includes(q.type)) {
let picked;
if (q.type === 5 && JUDGE_TRUE.test(text)) picked = [q.options.find(o => /^(正确|对)$/.test(o.text)) || q.options[0]];
else if (q.type === 5 && JUDGE_FALSE.test(text)) picked = [q.options.find(o => /^(错误|错)$/.test(o.text)) || q.options[1]];
else {
const head = (text.match(/^[A-Za-z\s,,、;;]+/) || [''])[0];
const letters = [...new Set(head.replace(/[^A-Za-z]/g, '').toUpperCase())];
if (!letters.length) return { error: '没写选项字母' };
const missing = letters.filter(l => !q.options.some(o => o.letter === l));
if (missing.length) return { error: `没有选项 ${missing.join('、')}` };
picked = letters.map(l => q.options.find(o => o.letter === l));
}
if (picked.some(o => !o)) return { error: '选项读取失败' };
if (q.type !== 2 && picked.length > 1) return { error: `${q.label}只能选一个` };
return { payload: picked.map(o => o.id) };
}
if (q.type === 4) {
const parts = q.items.length > 1 ? text.split(/[||]/).map(s => s.trim()) : [text.trim()];
if (parts.length > q.items.length) return { error: `只有 ${q.items.length} 个空,答案写了 ${parts.length} 个` };
const blanks = {};
q.items.forEach((item, i) => { if (parts[i]) blanks[item.id] = parts[i]; });
return Object.keys(blanks).length ? { payload: [blanks] } : { error: '没有填写内容' };
}
if (q.type === 6) return { payload: [richTextAnswer(text)] };
if (q.type === 13) {
const left = new Map(q.left.map(l => [l.letter, l]));
const right = new Map(q.right.map(r => [r.letter, r]));
const pairs = {};
for (const part of text.split(/[||;;\n]+/).map(s => s.trim()).filter(Boolean)) {
const m = part.match(/^([A-Za-z])\s*(?:=>|->|[::=])\s*(.+)$/);
if (!m) return { error: `看不懂「${part}」,应写成 A:a,b` };
const l = left.get(m[1].toUpperCase());
if (!l) return { error: `左侧没有 ${m[1].toUpperCase()}` };
const letters = m[2].split(/[,,、\s]+/).map(s => s.replace(/[.。]/g, '').toLowerCase()).filter(Boolean);
const bad = letters.filter(x => !right.has(x));
if (bad.length) return { error: `右侧没有 ${bad.join('、')}` };
pairs[l.id] = letters.map(x => right.get(x).id).join(',');
}
return Object.keys(pairs).length ? { payload: [pairs] } : { error: '没有匹配内容' };
}
return { skip: true };
}
function checkAnswers(p, text) {
const answers = parseAnswers(text);
const jobs = [];
const issues = [];
const ok = new Set();
const bad = new Set();
answers.forEach((value, n) => {
const q = p.questions[n - 1];
if (!q) { issues.push({ n, message: '没有这道题' }); return; }
const r = payloadFor(q, value);
if (r.error) { issues.push({ n, message: r.error }); bad.add(n); }
else if (r.payload) { jobs.push({ q, payload: r.payload }); ok.add(n); }
});
const missing = p.questions.filter(q => q.type !== 7 && !answers.has(q.index)).map(q => q.index);
return { jobs, issues, ok, bad, missing };
}
async function fetchRecordId(p) {
const data = await api(`/api/jx-iresource/survey/course/task/flow/v2?node_id=${encodeURIComponent(p.nodeId)}&group_id=${encodeURIComponent(p.groupId)}`);
const id = data.data?.task_flow_record?.[0]?.answer_record_id || data.data?.task_flow_template?.[0]?.answer_record_id;
if (!id) throw new Error('没读到作答记录,刷新页面后再试');
return id;
}
async function saveAnswers(p, jobs, onProgress) {
const recordId = await fetchRecordId(p);
const failed = [];
for (let i = 0; i < jobs.length; i++) {
const { q, payload } = jobs[i];
try {
await api('/api/jx-iresource/survey/answer', {
method: 'POST',
body: JSON.stringify({ record_id: recordId, question_id: q.id, answer: payload, ext_answer: '', group_id: p.groupId, paper_id: p.paperId, is_try: 0 }),
});
} catch (e) {
failed.push({ n: q.index, message: `保存失败:${e.message}` });
}
onProgress(i + 1, jobs.length);
}
return failed;
}
function getCourse(groupId) {
if (!groupId) return '';
if (!(groupId in courses)) courses[groupId] = GM_getValue(`course:${groupId}`, '');
return courses[groupId];
}
function setCourse(groupId, name) {
const value = String(name || '').trim();
if (!groupId || getCourse(groupId) === value) return;
courses[groupId] = value;
GM_setValue(`course:${groupId}`, value);
if (paper?.groupId === groupId) view.course();
}
async function loadCourse(groupId) {
if (!groupId || getCourse(groupId)) return;
try {
const data = await api(GROUP_API + encodeURIComponent(groupId));
setCourse(groupId, data.data?.name);
} catch (e) {
console.warn(`[${NAME}] 课程名读取失败`, e);
}
}
function onCaptured(url, json) {
try {
if (url.includes(PAPER_API)) onPaper(url, json);
else {
const groupId = decodeURIComponent(new URL(url, location.origin).pathname.split(GROUP_API)[1] || '').split('/')[0];
if (json?.data?.name) setCourse(groupId, json.data.name);
}
} catch (e) {
console.error(`[${NAME}] 接口数据处理失败`, e);
}
}
function onPaper(url, json) {
const data = json?.data;
if (!data || !Array.isArray(data.questions)) return;
const params = new URL(url, location.origin).searchParams;
const p = buildPaper(data, {
groupId: params.get('group_id') || String(data.group_id || ''),
nodeId: params.get('node_id') || '',
paperId: params.get('paper_id') || String(data.paper_id || data.id || ''),
});
setPaper(p);
loadCourse(p.groupId);
console.log(`[${NAME}] 已读取 ${p.questions.length} 道题`);
}
function setPaper(p) {
paper = p;
view.paper();
}
function hookRequests() {
const urls = new WeakMap();
const proto = page.XMLHttpRequest.prototype;
const rawOpen = proto.open;
const rawSend = proto.send;
const readXhr = async xhr => {
if (xhr.responseType === 'json') return xhr.response;
if (xhr.responseType === 'blob') return JSON.parse(await xhr.response.text());
if (xhr.responseType === 'arraybuffer') return JSON.parse(new TextDecoder().decode(xhr.response));
return JSON.parse(xhr.responseText);
};
proto.open = function (method, url) {
urls.set(this, String(url));
return rawOpen.apply(this, arguments);
};
proto.send = function () {
const url = urls.get(this);
if (url && WATCHED_API.test(url)) {
this.addEventListener('load', () => readXhr(this).then(json => onCaptured(url, json), e => console.warn(`[${NAME}] 解析接口数据失败`, e)));
}
return rawSend.apply(this, arguments);
};
page.fetch = function (input) {
const promise = rawFetch.apply(this, arguments);
const url = typeof input === 'string' ? input : (input?.url || String(input));
if (WATCHED_API.test(url)) promise.then(res => res.clone().json()).then(json => onCaptured(url, json), () => {});
return promise;
};
}
function watchRoute() {
const check = () => setTimeout(() => {
if (paper && ![paper.groupId, paper.nodeId].filter(Boolean).every(id => location.href.includes(id))) setPaper(null);
}, 80);
['pushState', 'replaceState'].forEach(name => {
const raw = page.history[name];
page.history[name] = function () {
const result = raw.apply(this, arguments);
check();
return result;
};
});
page.addEventListener('popstate', check);
}
function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
async function fetchImage(src) {
return (await gmRequest({ method: 'GET', url: src, responseType: 'blob', withCredentials: true })).response;
}
async function inlineImages(p) {
const map = new Map();
await Promise.all([...new Set(p.images.map(img => img.src))].map(async src => {
try {
map.set(src, await blobToDataUrl(await fetchImage(src)));
} catch (e) {
map.set(src, '');
console.warn(`[${NAME}] 图片读取失败`, src, e);
}
}));
return map;
}
let markdownIt;
function markdown(source, block) {
markdownIt ??= typeof globalThis.markdownit === 'function'
? globalThis.markdownit({ html: false, linkify: false, typographer: false, breaks: true })
: null;
if (!markdownIt) return esc(source).replace(/\n/g, '
');
const isBlock = block || /\n\s*\n|(^|\n)\s{0,3}(#{1,6}\s+|>\s+|[-*+]\s+|\d+\.\s+|```|~~~)|(^|\n)\s*\|.+\|\s*(\n|$)/.test(source);
return isBlock ? markdownIt.render(source) : markdownIt.renderInline(source).replace(/\n/g, '
');
}
function splitDollarMath(value) {
const text = String(value || '');
const parts = [];
let cursor = 0;
let i = 0;
const escaped = at => { let n = 0; for (let j = at - 1; j >= 0 && text[j] === '\\'; j--) n++; return n % 2 === 1; };
const find = (delim, from) => { for (let at = text.indexOf(delim, from); at >= 0; at = text.indexOf(delim, at + delim.length)) if (!escaped(at)) return at; return -1; };
while ((i = text.indexOf('$', i)) >= 0) {
if (escaped(i)) { i++; continue; }
const delim = text[i + 1] === '$' ? '$$' : '$';
const close = find(delim, i + delim.length);
if (close < 0) break;
const tex = text.slice(i + delim.length, close).trim();
if (tex) {
if (i > cursor) parts.push({ type: 'text', value: text.slice(cursor, i) });
parts.push({ type: 'math', value: tex, display: delim === '$$' });
cursor = close + delim.length;
}
i = close + delim.length;
}
if (cursor < text.length) parts.push({ type: 'text', value: text.slice(cursor) });
return parts;
}
function mathHtml(tex, display, ctx) {
return ctx.renderMath
? `${esc(tex)}`
: `${esc(display ? `$$${tex}$$` : `$${tex}$`)}`;
}
function inlineHtml(segments, ctx, block) {
const tokens = [];
let source = '';
const math = (tex, display) => { tokens.push(mathHtml(tex, display, ctx)); return `XYMATH${tokens.length - 1}XY`; };
segments.forEach(s => {
if (s.type === 'math') source += math(s.value, s.display);
else splitDollarMath(s.value).forEach(part => { source += part.type === 'math' ? math(part.value, part.display) : part.value; });
});
return markdown(source, block).replace(/XYMATH(\d+)XY/g, (_, i) => tokens[i]);
}
function richHtml(segments, ctx, block = false) {
const html = [];
let group = [];
const flush = () => {
if (group.length) html.push(`
${inlineHtml(group, ctx, block)}
`);
group = [];
};
segments.forEach(s => {
if (s.type === 'break') flush();
else if (s.type === 'image') {
flush();
const data = ctx.images.get(s.src);
html.push(data ? `
` : '[图片读取失败]
');
} else group.push(s);
});
flush();
return html.join('');
}
function questionHtml(q, ctx, answer) {
const options = list => list.map(o => `${o.letter}.${richHtml(o.content.segments, ctx)}
`).join('');
const body = q.options.length ? options(q.options)
: q.type === 4 ? `(本题共 ${q.items.length} 个填空)
`
: q.type === 7 ? '附件题,无需作答。
'
: q.type === 13 ? `左侧
${options(q.left)}右侧候选
${options(q.right)}`
: '';
const answerHtml = answer ? `答案${richHtml([{ type: 'text', value: answer }], ctx, true)}
` : '';
return `${q.index}. ${esc(q.label)}
${richHtml(q.title.segments, ctx)}
${body}${answerHtml}`;
}
const PRINT_CSS = `
@page { size: A4 portrait; margin: 16mm 15mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #16181d; font: 14px/1.75 "Microsoft YaHei", "PingFang SC", Arial, sans-serif; }
header { margin-bottom: 18px; padding-bottom: 12px; border-bottom: 2px solid #16181d; }
h1 { margin: 0; font-size: 22px; line-height: 1.35; }
h2 { margin: 0 0 8px; font-size: 16px; }
h3 { margin: 0 0 6px; font-size: 14px; }
.meta { margin: 4px 0 0; color: #5d6371; font-size: 12px; }
.rules { margin-top: 12px; padding: 9px 11px; border: 1px solid #e3e5ea; background: #f8f9fb; color: #444a56; font-size: 11.5px; line-height: 1.7; }
.tip { margin: 10px 0 0; padding: 8px 11px; background: #eef3ff; color: #2c4fb8; font-size: 12px; font-weight: 600; }
.group { margin: 16px 0; padding: 12px 14px; border: 1px solid #dfe3ea; border-radius: 6px; background: #f8f9fb; }
.q { margin: 0 0 14px; padding-bottom: 14px; border-bottom: 1px solid #e6e8ec; break-inside: avoid-page; }
.stem { font-weight: 600; }
.rt { overflow-wrap: anywhere; }
.rt p { margin: 0 0 6px; }
.rt p:last-child { margin-bottom: 0; }
.rt ul, .rt ol { margin: 6px 0 8px 22px; padding: 0; }
.rt blockquote { margin: 8px 0; padding: 6px 10px; border-left: 3px solid #a3abb9; background: #f8f9fb; color: #4b5260; }
.rt code { padding: 1px 3px; border-radius: 3px; background: #f1f2f4; font: 12px Consolas, monospace; }
.rt pre { margin: 8px 0; padding: 9px 10px; border: 1px solid #e3e5ea; background: #f8f9fb; white-space: pre-wrap; }
.rt table { width: 100%; margin: 8px 0; border-collapse: collapse; font-size: 12px; }
.rt th, .rt td { padding: 5px 7px; border: 1px solid #d3d7de; vertical-align: top; }
.opt { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 4px; margin-top: 4px; padding-left: 12px; break-inside: avoid-page; }
.sub { margin: 10px 0 2px 12px; font-weight: 700; }
.note { margin: 6px 0 0 12px; color: #5d6371; }
.pic { display: block; max-width: 100%; max-height: 88mm; margin: 6px 0; object-fit: contain; break-inside: avoid-page; }
.pic-failed { margin: 6px 0; padding: 12px; border: 1px dashed #c9cdd5; color: #6b7280; }
.ans { margin-top: 8px; padding: 7px 10px; border-left: 3px solid #16181d; background: #f8f9fb; overflow-wrap: anywhere; }
.ans > b { display: block; font-size: 12px; }
.appendix { break-before: page; }
.appendix .row { display: grid; grid-template-columns: 32px minmax(0, 1fr); margin-top: 6px; }
.tex-src { font-family: Consolas, "Courier New", monospace; overflow-wrap: anywhere; }
.tex:not(.ok) { font-family: Consolas, "Courier New", monospace; }
.tex:not(.ok)::before, .tex:not(.ok)::after { content: "$"; }
.tex[data-d="1"] { display: block; margin: 6px 0; text-align: center; overflow-x: auto; }
@media print { .tip { display: none; } }
`;
function documentHtml(p, kind, ctx) {
const course = getCourse(p.groupId);
const answers = kind === 'homework' && prefs.withAnswers ? exportAnswers(p) : new Map();
const inline = prefs.answerAt === 'inline';
let header = '';
if (kind === 'ai') {
header = `${esc(p.title)}
${course ? `课程:${esc(course)} · ` : ''}共 ${p.questions.length} 题
请根据以下题目作答,严格按指定格式返回答案,不要输出解析、注释或额外说明。
单选/判断:1 => A 多选:2 => A,C 填空:3 => const | let
简答:4 => 完整文字答案 匹配:5 => A:a,d | B:b,c 附件题无需回答
${PRINT_TIP}
`;
} else {
const title = { both: p.title, course: course && `课程:${course}`, paper: p.title, none: '' }[prefs.header];
const meta = prefs.header === 'both' && course ? `课程:${esc(course)}
` : '';
header = title ? `${esc(title)}
${meta}${PRINT_TIP}
` : `${PRINT_TIP}
`;
}
const body = p.sections.map(section =>
(section.title?.segments.length ? `${richHtml(section.title.segments, ctx, true)}
` : '')
+ section.questions.map(q => questionHtml(q, ctx, inline ? answers.get(q.index) : '')).join('')).join('');
const appendix = !inline && answers.size
? `答案
${[...answers].map(([n, a]) => `${n}.${richHtml([{ type: 'text', value: a }], ctx, true)}
`).join('')}`
: '';
return `${esc(p.title)}${header}${body}${appendix}`;
}
async function typesetIn(win) {
const nodes = [...win.document.querySelectorAll('.tex')];
const source = nodes.length ? GM_getResourceText('mathjax') : '';
if (!source) return;
const run = code => {
const script = win.document.createElement('script');
script.textContent = code;
win.document.head.appendChild(script);
};
run("window.MathJax = { startup: { typeset: false }, svg: { fontCache: 'none' }, options: { enableMenu: false } };");
run(source);
const mathJax = win.MathJax;
await mathJax.startup.promise;
nodes.forEach(node => {
try {
node.replaceChildren(mathJax.tex2svg(node.textContent, { display: node.dataset.d === '1' }));
node.classList.add('ok');
} catch (e) {
console.warn(`[${NAME}] 公式渲染失败`, node.textContent, e);
}
});
win.document.head.appendChild(mathJax.svgStylesheet());
}
async function printDocument(kind) {
const p = paper;
const win = window.open('', '_blank');
if (!win) throw new Error('浏览器拦截了新窗口,请允许本站弹出窗口后再试');
win.document.write(`正在生成正在整理题目和图片…`);
win.document.close();
try {
const ctx = { images: await inlineImages(p), renderMath: kind === 'homework' };
win.document.open();
win.document.write(documentHtml(p, kind, ctx));
win.document.close();
if (ctx.renderMath) await typesetIn(win).catch(e => console.warn(`[${NAME}] MathJax 加载失败,公式保留 TeX`, e));
await Promise.all([...win.document.images].map(img => img.decode().catch(() => {})));
win.focus();
win.print();
} catch (e) {
win.document.body.textContent = `导出失败:${e.message}`;
throw e;
}
}
async function downloadDoc() {
const p = paper;
const html = documentHtml(p, 'homework', { images: await inlineImages(p), renderMath: false });
const url = URL.createObjectURL(new Blob(['\ufeff', html], { type: 'application/msword' }));
const link = Object.assign(document.createElement('a'), { href: url, download: `${fileSafe(p.title)}_${stamp()}.doc` });
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
async function copyImage(src) {
const blob = await fetchImage(src);
let png = blob;
if (blob.type !== 'image/png') {
const bitmap = await createImageBitmap(blob);
const canvas = Object.assign(document.createElement('canvas'), { width: bitmap.width, height: bitmap.height });
canvas.getContext('2d').drawImage(bitmap, 0, 0);
png = await new Promise((resolve, reject) => canvas.toBlob(b => b ? resolve(b) : reject(new Error('图片转换失败')), 'image/png'));
}
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
}
async function loadNotice() {
try {
const res = await gmRequest({
method: 'POST',
url: NOTICE_API,
timeout: 10000,
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
data: JSON.stringify({ action: 'get_notice', channel: NOTICE_CHANNEL, client: `xiaoya-zhanzhanzhan-v${VERSION}` }),
});
const data = JSON.parse(res.responseText);
if (data.ok !== true || data.channel !== NOTICE_CHANNEL) throw new Error('公告格式不对');
notice = { content: String(data.content || '暂无公告'), version: String(data.version || ''), updatedAt: String(data.updatedAt || '') };
} catch (e) {
console.warn(`[${NAME}] 公告读取失败`, e);
notice = { error: true, content: '公告加载失败' };
}
view.notice();
}
const noticeKey = () => notice && !notice.error ? `${notice.version}|${notice.updatedAt}|${notice.content}` : '';
const noticeUnread = () => !!noticeKey() && notice.content !== '暂无公告' && prefs.seen !== noticeKey();
const hasUpdate = () => !!notice && !notice.error && isNewer(notice.version, VERSION);
const ICON = {
copy: '',
out: '',
img: '',
score: '',
min: '',
x: '',
go: '',
theme: '',
};
const SVG = {
tape: '',
zhu: '',
ring: '',
check: color => ``,
cross: color => ``,
dashed: '',
};
const svgUrl = svg => `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
const THEMES = { tape: '胶带', zhu: '朱批' };
const STATE_LABEL = { ok: '正确', part: '部分得分', bad: '错误', none: '未作答', pending: '待批改' };
const MARGIN = 8;
const CSS = `
.zz { all: initial; --head-h: 55px; --z-sans: "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", sans-serif; --z-num: "Segoe UI", sans-serif; position: fixed; z-index: 2147483000; font: 13px/1.5 var(--z-sans); color: var(--ink); -webkit-font-smoothing: antialiased; text-align: left; }
.zz *, .zz *::before, .zz *::after { box-sizing: border-box; }
.zz button { margin: 0; font: inherit; color: inherit; cursor: pointer; }
.zz button:disabled { cursor: default; opacity: .45; }
.zz :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.zz p { margin: 0; }
[hidden] { display: none !important; }
.trigger { display: none; position: relative; align-items: center; border: 0; touch-action: none; user-select: none; }
.zz[data-open="0"] .trigger { display: flex; }
.zz[data-open="0"] .shell { display: none; }
.shell { position: relative; }
.zz.enter .shell { animation: zz-in .2s cubic-bezier(.2, .8, .2, 1); }
.zz.leave .shell { animation: zz-out .14s ease forwards; }
@keyframes zz-in { from { opacity: 0; transform: translateY(-6px) scale(.985); } }
@keyframes zz-out { to { opacity: 0; } }
.t-dot { position: absolute; top: 5px; right: 5px; width: 7px; height: 7px; border-radius: 50%; background: var(--alert); }
.t-mark, .mark { display: block; flex: none; background: center / contain no-repeat var(--logo); }
.tape, .ring, .mk, .cn { display: none; }
/* 首次引导:选风格 → 用法。引导时只显示引导内容 */
.guide { display: none; flex: 1 1 auto; min-height: 0; padding: 4px 16px 18px; overflow: auto; }
.zz[data-guide="1"] .guide { display: block; }
.zz[data-guide="1"] .body, .zz[data-guide="1"] .foot, .zz[data-guide="1"] .result, .zz[data-guide="1"] [data-res-btn] { display: none !important; }
.g-page { display: none; }
.guide[data-step="1"] .g-page[data-step="1"], .guide[data-step="2"] .g-page[data-step="2"] { display: grid; gap: 14px; }
.g-kicker { font-size: 11.5px; color: var(--muted); }
.g-title { margin: -8px 0 0; font-size: 18px; line-height: 1.35; font-weight: 700; }
.themes { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.theme-card { display: grid; gap: 4px; padding: 0 0 10px; text-align: left; overflow: hidden; border: 1.5px solid var(--line); background: transparent; }
.theme-card[aria-pressed="true"] { border-color: var(--accent); }
.theme-card b { padding: 0 10px; font-size: 13.5px; }
.theme-card small { padding: 0 10px; font-size: 11px; line-height: 1.5; color: var(--muted); }
.tc-art { display: block; position: relative; height: 72px; margin-bottom: 6px; }
.tc-art.tape-art { background: linear-gradient(135deg, #eef2fb, #dce4f6); }
.tc-art.tape-art::before { content: ""; position: absolute; left: 16px; right: 16px; top: 20px; bottom: -8px; border-radius: 9px; background: rgba(255, 255, 255, .9); box-shadow: 0 6px 16px -8px rgba(24, 34, 64, .4); }
.tc-art.tape-art::after { content: ""; position: absolute; left: calc(50% - 26px); top: 12px; width: 52px; height: 15px; transform: rotate(-4deg); background: rgba(98, 131, 236, .7); clip-path: polygon(0 0, 100% 0, 95% 25%, 100% 50%, 95% 75%, 100% 100%, 0 100%, 5% 75%, 0 50%, 5% 25%); }
.tc-art.zhu-art { background: ${svgUrl(SVG.check('#e2583e'))} 58% 52% / 34px no-repeat, #18202e; }
.tc-art.zhu-art::after { content: ""; position: absolute; left: 22px; right: 22px; bottom: 14px; height: 1px; background: rgba(241, 237, 228, .25); }
.g-hint { font-size: 11.5px; color: var(--muted); }
.g-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
.g-list li { display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; align-items: start; }
.g-t { font-size: 13px; font-weight: 600; }
.g-list li > span { grid-column: 2; font-size: 12px; color: var(--ink-2); }
.g-tips { margin: 0; padding: 12px 0 0 16px; border-top: 1px solid var(--line); display: grid; gap: 4px; font-size: 11.5px; color: var(--muted); }
.g-acts { display: grid; grid-template-columns: 1fr 2fr; gap: 8px; }
.main { position: relative; width: 356px; max-height: calc(100vh - ${MARGIN * 2}px); display: flex; flex-direction: column; }
.head { display: flex; align-items: center; gap: 9px; padding: 14px 14px 12px 16px; cursor: move; user-select: none; touch-action: none; }
.mark { width: 22px; height: 22px; }
.brand { font-weight: 700; font-size: 14px; }
.ver { font: 500 11px var(--z-num); color: var(--muted); }
.sp { flex: 1; }
.ib { width: 28px; height: 28px; display: grid; place-items: center; padding: 0; border: 0; background: transparent; border-radius: 8px; color: var(--muted); }
.ib:hover { background: var(--hover); color: var(--ink); }
svg.i { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.body { flex: 1 1 auto; min-height: 0; overflow: auto; scrollbar-width: thin; }
.task { padding: 4px 16px 12px; }
.course { font-size: 12px; color: var(--muted); min-height: 18px; }
.paper { font-size: 18px; font-weight: 700; line-height: 1.35; overflow-wrap: anywhere; }
.meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.meta span { font-size: 11px; color: var(--muted); }
.empty { padding: 4px 16px 16px; color: var(--muted); font-size: 12.5px; }
.zz[data-paper="0"] .task, .zz[data-paper="0"] .steps, .zz[data-paper="0"] .foot { display: none; }
.zz[data-paper="1"] .empty { display: none; }
.notice { margin: 0 16px 12px; display: flex; align-items: baseline; gap: 8px; font-size: 12px; }
.n-tag { flex: none; position: relative; font-weight: 700; }
.n-tag.unread::after { content: ""; position: absolute; top: 0; right: -6px; width: 5px; height: 5px; border-radius: 50%; background: var(--alert); }
.n-badge { flex: none; font: 600 10.5px var(--z-num); padding: 0 6px; border-radius: 999px; color: var(--alert); background: var(--alert-soft); }
.n-txt { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ink-2); }
.notice[data-open="1"] .n-txt { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; }
.n-txt a { color: inherit; text-decoration: underline; text-decoration-color: var(--line-strong); text-underline-offset: 2px; }
.n-more { flex: none; border: 0; background: none; padding: 0; color: var(--accent); font-size: 12px; }
.steps { list-style: none; margin: 0; padding: 6px 16px 16px; display: grid; gap: 16px; }
.step-h { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 12px; }
.btn { width: 100%; height: 40px; display: flex; align-items: center; justify-content: center; gap: 8px; border: 1px solid transparent; font-weight: 600; font-size: 13px; transition: background .15s ease, color .15s ease, border-color .15s ease, transform .1s ease; }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn.sm { height: 32px; font-size: 12px; font-weight: 500; }
.hint, .s-msg { font-size: 11.5px; color: var(--muted); margin-top: 7px; }
.s-msg[data-kind="ok"] { color: var(--good); }
.s-msg[data-kind="warn"] { color: var(--warn); }
.s-msg[data-kind="error"] { color: var(--bad); }
textarea { display: block; width: 100%; height: 128px; margin: 0; resize: vertical; padding: 8px 10px; font: 12px/22px "Cascadia Mono", Consolas, ui-monospace, monospace; color: var(--ink); outline: none; }
.parse { display: flex; justify-content: space-between; gap: 8px; margin-top: 8px; font-size: 11.5px; color: var(--muted); }
.p-txt { flex: none; white-space: nowrap; }
.p-miss { color: var(--warn); text-align: right; }
.bubbles { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
.bubbles i { font-style: normal; }
.issues { margin: 8px 0 0; padding: 0; list-style: none; display: grid; gap: 2px; font-size: 11.5px; color: var(--bad); }
.bar { height: 3px; margin-top: 8px; border-radius: 2px; background: var(--track); overflow: hidden; visibility: hidden; }
.bar.on { visibility: visible; }
.bar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .1s linear; }
.foot { flex: none; display: flex; gap: 4px; padding: 8px 10px; }
.fb { display: inline-flex; align-items: center; gap: 6px; border: 0; background: none; padding: 6px 8px; border-radius: 8px; font-size: 12.5px; color: var(--ink-2); }
.fb:hover:not(:disabled) { background: var(--hover); }
.fb em { font-style: normal; font-family: var(--z-num); color: var(--muted); }
.toast { position: absolute; left: 12px; right: 12px; bottom: 54px; z-index: 4; padding: 8px 12px; font-size: 12px; color: var(--toast-ink); background: var(--toast); border-radius: var(--toast-r); opacity: 0; transform: translateY(4px); transition: opacity .16s ease, transform .16s ease; pointer-events: none; }
.toast.on { opacity: 1; transform: none; }
.toast[data-kind="error"] { background: var(--bad); color: #fff; }
.sheet-clip { position: absolute; left: 0; right: 0; bottom: 0; top: var(--head-h); overflow: hidden; border-radius: inherit; border-top-left-radius: 0; border-top-right-radius: 0; pointer-events: none; }
.sheet { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--sheet); transform: translateY(102%); visibility: hidden; transition: transform .22s cubic-bezier(.2, .8, .2, 1), visibility 0s .22s; pointer-events: auto; }
.main[data-sheet="export"] .sheet, .main[data-sheet="img"] .sheet { transform: none; visibility: visible; transition: transform .22s cubic-bezier(.2, .8, .2, 1), visibility 0s; }
.sh-head { display: flex; align-items: center; padding: 12px 14px 8px 16px; font-weight: 700; font-size: 13.5px; }
[data-v] { display: none; }
.main[data-sheet="export"] [data-v="export"], .main[data-sheet="img"] [data-v="img"] { display: block; }
.sh-body { flex: 1; min-height: 0; padding: 4px 16px 16px; overflow: auto; scrollbar-width: thin; }
.grp + .grp { margin-top: 16px; }
.g-h { font-size: 11.5px; color: var(--muted); margin-bottom: 8px; }
.row-btn { width: 100%; display: flex; align-items: center; gap: 10px; text-align: left; padding: 10px 12px; border: 1px solid var(--line); background: transparent; }
.row-btn > span { flex: 1; }
.row-btn b { display: block; font-size: 13px; }
.row-btn small { display: block; font-size: 11.5px; color: var(--muted); }
.row-btn svg { flex: none; color: var(--muted); }
.fld { display: grid; grid-template-columns: 76px 1fr; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 12px; }
.fld > span { color: var(--ink-2); }
.fld input { width: 100%; height: 30px; margin: 0; padding: 0 9px; font: 12.5px var(--z-sans); color: var(--ink); outline: none; }
.seg { display: flex; flex-wrap: wrap; gap: 2px; padding: 2px; }
.seg button { flex: 1 1 auto; border: 0; background: none; padding: 3px 6px; font-size: 11.5px; color: var(--muted); white-space: nowrap; }
.seg button[aria-pressed="true"] { color: var(--ink); }
.fld[data-off="1"] { opacity: .45; pointer-events: none; }
.sw { width: 34px; height: 20px; border: 0; padding: 0; position: relative; justify-self: start; }
.sw::after { content: ""; position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; transition: transform .15s ease; }
.sw[aria-checked="true"]::after { transform: translateX(14px); }
.g-note { font-size: 11px; color: var(--muted); margin: 2px 0 12px !important; }
.acts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.imgs { display: grid; gap: 14px; }
.img-item { display: grid; gap: 6px; }
.img-item .cap { font-size: 11.5px; color: var(--muted); }
.img-item img { display: block; max-width: 100%; max-height: 220px; object-fit: contain; border: 1px solid var(--line); background: #fff; }
.img-item .acts { grid-template-columns: 1fr 1fr; }
/* 成绩面板挂在主面板左边,高度跟主面板一样,题目多时在内部滚动;窄屏时叠在主面板上 */
.result { position: absolute; top: 0; bottom: 0; right: calc(100% + 12px); width: 296px; display: flex; flex-direction: column; }
.zz[data-result="0"] .result { display: none; }
.result.r-enter { animation: zz-in .18s cubic-bezier(.2, .8, .2, 1); }
.zz.narrow .result { z-index: 5; right: 0; }
.r-head { display: flex; align-items: center; padding: 14px 10px 4px 16px; }
.r-title { font-weight: 700; font-size: 13.5px; }
.score { position: relative; display: flex; align-items: baseline; gap: 6px; padding: 4px 16px 6px; }
.score b { font-family: var(--z-num); }
.of { font-size: 12px; color: var(--muted); }
.counts { display: flex; flex-wrap: wrap; gap: 4px 12px; padding: 0 16px; font-size: 12px; color: var(--ink-2); }
.counts .c-bad { color: var(--bad); }
.r-note { padding: 6px 16px 10px; font-size: 11px; color: var(--muted); }
.tabs { display: flex; gap: 4px; padding: 0 12px 8px; }
.tab { border: 0; background: none; padding: 4px 9px; font-size: 12px; color: var(--muted); }
.tab em { font-style: normal; font-family: var(--z-num); }
.tab[aria-pressed="true"] { color: var(--ink); }
.list { flex: 1; min-height: 0; overflow: auto; padding: 0 8px 10px; scrollbar-width: thin; }
.list[data-filter="bad"] .row:not([data-s="bad"]):not([data-s="part"]):not([data-s="none"]),
.list[data-filter="pending"] .row:not([data-s="pending"]) { display: none; }
.list[data-filter="bad"] .sec-h, .list[data-filter="pending"] .sec-h { display: none; }
.sec-h { padding: 10px 8px 2px; font-size: 11px; color: var(--muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.row { padding: 10px 8px; display: grid; gap: 3px; }
.row-top { display: flex; align-items: center; gap: 8px; }
.qn { font: 600 12px var(--z-num); color: var(--ink); min-width: 18px; }
.qt { font-size: 11px; color: var(--muted); }
.pts { margin-left: auto; font: 500 11.5px var(--z-num); color: var(--muted); }
.chip { font-size: 10.5px; font-weight: 600; padding: 0 7px; line-height: 18px; }
.t { font-size: 12px; color: var(--ink-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.mine { font-size: 11.5px; color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; }
.mine b { font-weight: 600; color: var(--ink-2); }
.filter-empty { display: none; padding: 16px 8px; font-size: 12px; color: var(--muted); }
@media (prefers-reduced-motion: reduce) { .zz.enter .shell, .zz.leave .shell, .result.r-enter { animation-duration: 1ms; } .sheet, .toast { transition: none; } }
`;
const TAPE = `
.zz[data-theme="tape"] {
--ink: #1b1f27; --ink-2: #3d4350; --muted: #6c7383; --blue: #2f54d4; --tape: rgba(98, 131, 236, .62);
--glass: rgba(251, 252, 254, .9); --soft: #eff1f5; --line: rgba(27, 31, 39, .09); --line-strong: rgba(27, 31, 39, .25);
--accent: #2f54d4; --alert: #e0484f; --alert-soft: #fde9ea; --warn: #b5711a; --bad: #c43b3b; --good: #1d7547;
--hover: rgba(27, 31, 39, .05); --track: #e4e8f0; --sheet: #fbfcfe; --head-h: 58px;
--toast: #1b1f27; --toast-ink: #fff; --toast-r: 12px;
--z-num: "Segoe UI Variable Display", "Segoe UI", "SF Pro Display", sans-serif;
--logo: ${svgUrl(SVG.tape)};
}
[data-theme="tape"] .main, [data-theme="tape"] .result { background: var(--glass); backdrop-filter: blur(18px) saturate(1.2); -webkit-backdrop-filter: blur(18px) saturate(1.2); border-radius: 18px; box-shadow: 0 0 0 1px var(--line), 0 26px 50px -22px rgba(24, 34, 64, .3), 0 2px 6px rgba(24, 34, 64, .05); }
[data-theme="tape"] .tape { display: block; position: absolute; z-index: 3; width: 88px; height: 22px; pointer-events: none; background: repeating-linear-gradient(90deg, rgba(255, 255, 255, .16) 0 2px, transparent 2px 6px), var(--tape); clip-path: polygon(0 0, 100% 0, 96% 20%, 100% 40%, 96% 60%, 100% 80%, 96% 100%, 0 100%, 4% 80%, 0 60%, 4% 40%, 0 20%); }
[data-theme="tape"] .tape-m { top: -11px; left: calc(50% - 44px); transform: rotate(-3deg); }
[data-theme="tape"] .tape-r { top: -10px; left: 30px; width: 70px; transform: rotate(4deg); }
[data-theme="tape"] .head { padding-top: 18px; }
[data-theme="tape"] .ver { padding: 0 6px; border-radius: 999px; background: var(--soft); }
[data-theme="tape"] .meta span { background: var(--soft); border-radius: 999px; padding: 1px 8px; }
[data-theme="tape"] .notice { background: var(--soft); border-radius: 12px; padding: 8px 11px; }
[data-theme="tape"] .n-tag { color: var(--blue); }
[data-theme="tape"] .step-h { font-weight: 600; font-size: 12.5px; }
[data-theme="tape"] .no { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: rgba(47, 84, 212, .1); color: var(--blue); font: 600 11px/1 var(--z-num); }
[data-theme="tape"] .btn { border-radius: 12px; }
[data-theme="tape"] .btn.pri { background: var(--blue); color: #fff; box-shadow: 0 8px 18px -10px rgba(47, 84, 212, .8); }
[data-theme="tape"] .btn.pri:hover:not(:disabled) { background: #2648c0; }
[data-theme="tape"] .btn.sec { background: var(--ink); color: #fff; }
[data-theme="tape"] .btn.sec:hover:not(:disabled) { background: #000; }
[data-theme="tape"] .btn.sm, [data-theme="tape"] .g-acts .btn.sec { background: var(--soft); color: var(--ink); }
[data-theme="tape"] .btn.sm:hover:not(:disabled), [data-theme="tape"] .g-acts .btn.sec:hover { background: #e4e7ee; }
[data-theme="tape"] textarea { background: var(--soft); border: 1px solid transparent; border-radius: 12px; }
[data-theme="tape"] textarea:focus { background: #fff; border-color: rgba(47, 84, 212, .5); }
[data-theme="tape"] .bubbles { gap: 5px; }
[data-theme="tape"] .bubbles i { width: 8px; height: 8px; border-radius: 50%; background: #d8dde7; font-size: 0; transition: background .15s ease; }
[data-theme="tape"] .bubbles i.on { background: var(--blue); }
[data-theme="tape"] .bubbles i.err { background: var(--bad); }
[data-theme="tape"] .row-btn { border: 0; border-radius: 12px; background: var(--soft); }
[data-theme="tape"] .row-btn:hover { background: #e4e7ee; }
[data-theme="tape"] .fld input { border: 1px solid transparent; border-radius: 9px; background: var(--soft); }
[data-theme="tape"] .fld input:focus { background: #fff; border-color: rgba(47, 84, 212, .5); }
[data-theme="tape"] .seg { background: var(--soft); border-radius: 10px; }
[data-theme="tape"] .seg button { border-radius: 8px; }
[data-theme="tape"] .seg button[aria-pressed="true"] { background: #fff; box-shadow: 0 1px 3px rgba(27, 31, 39, .14); }
[data-theme="tape"] .sw { border-radius: 10px; background: #d5dae4; }
[data-theme="tape"] .sw::after { background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .2); }
[data-theme="tape"] .sw[aria-checked="true"] { background: var(--blue); }
[data-theme="tape"] .sheet { border-radius: 18px 18px 0 0; box-shadow: 0 -10px 30px -18px rgba(24, 34, 64, .4); }
[data-theme="tape"] .r-head { padding-top: 18px; }
[data-theme="tape"] .r-score { font-size: 46px; line-height: 1; font-weight: 600; letter-spacing: -.02em; }
[data-theme="tape"] .tab { border-radius: 999px; }
[data-theme="tape"] .tab[aria-pressed="true"] { background: var(--ink); color: #fff; }
[data-theme="tape"] .row { border-radius: 10px; }
[data-theme="tape"] .row:hover { background: var(--hover); }
[data-theme="tape"] .chip { border-radius: 999px; }
[data-theme="tape"] .row[data-s="ok"] .chip { background: #e5f3eb; color: #1d7547; }
[data-theme="tape"] .row[data-s="bad"] .chip { background: #fdeaea; color: #c43b3b; }
[data-theme="tape"] .row[data-s="part"] .chip { background: #fbf1dc; color: #94650f; }
[data-theme="tape"] .row[data-s="pending"] .chip { background: #e8edf8; color: #3b55a8; }
[data-theme="tape"] .row[data-s="none"] .chip { background: #eceef2; color: #6c7383; }
[data-theme="tape"] .theme-card { border-radius: 14px; }
[data-theme="tape"] .trigger { width: 58px; height: 58px; padding: 0; justify-content: center; border-radius: 50%; background: radial-gradient(circle, #fff 0 17px, rgba(255, 255, 255, .5) 17.5px 18.5px, transparent 19px), repeating-radial-gradient(circle, #5d7fec 0 2px, #6a8bf0 2px 4px); box-shadow: 0 14px 28px -12px rgba(47, 84, 212, .6), inset 0 0 0 1px rgba(255, 255, 255, .25); transition: transform .15s ease; }
[data-theme="tape"] .trigger:hover { transform: rotate(-8deg); }
[data-theme="tape"][data-dragging] .trigger { transform: none; }
[data-theme="tape"] .t-mark, [data-theme="tape"] .t-name, [data-theme="tape"] .t-num small { display: none; }
[data-theme="tape"] .t-num { font: 700 15px/1 var(--z-num); color: var(--ink); }
[data-theme="tape"] .t-dot { top: 2px; right: 2px; box-shadow: 0 0 0 2px #fff; }
`;
const ZHU = `
.zz[data-theme="zhu"] {
color-scheme: dark;
--ink: #e8ecf2; --ink-2: #c3cad6; --muted: #8e9aad; --surf: #1d2737; --deep: #141b27; --zhu: #e2583e; --paper: #f1ede4;
--line: rgba(232, 236, 242, .1); --line-strong: rgba(232, 236, 242, .3);
--accent: #f1ede4; --alert: #e2583e; --alert-soft: rgba(226, 88, 62, .16); --warn: #e9a46a; --bad: #ef7a62; --good: #9fd3b0;
--hover: rgba(232, 236, 242, .06); --track: rgba(232, 236, 242, .1); --sheet: #1d2737; --head-h: 55px;
--toast: #f1ede4; --toast-ink: #18202e; --toast-r: 6px;
--z-num: "Segoe UI", "SF Pro Text", sans-serif;
--z-kai: "KaiTi", "STKaiti", "Kaiti SC", "楷体", serif;
--logo: ${svgUrl(SVG.zhu)};
}
[data-theme="zhu"] .main, [data-theme="zhu"] .result { background: var(--surf); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 32px 60px -26px rgba(8, 12, 20, .7), 0 2px 8px rgba(8, 12, 20, .25); }
[data-theme="zhu"] .head { border-bottom: 1px solid var(--line); }
[data-theme="zhu"] .brand { font: 400 18px var(--z-kai); letter-spacing: .06em; }
[data-theme="zhu"] .ib:hover { color: var(--paper); }
[data-theme="zhu"] .task { padding-top: 12px; }
[data-theme="zhu"] .paper, [data-theme="zhu"] .g-title { font: 400 24px/1.3 var(--z-kai); margin-top: 2px; }
[data-theme="zhu"] .g-title { margin-top: -8px; }
[data-theme="zhu"] .meta span + span::before { content: "·"; margin-right: 6px; color: var(--line-strong); }
[data-theme="zhu"] .notice { padding: 8px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
[data-theme="zhu"] .n-tag { font: 400 14px var(--z-kai); color: var(--paper); }
[data-theme="zhu"] .n-more { color: var(--ink-2); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line-strong); }
[data-theme="zhu"] .no { display: none; }
[data-theme="zhu"] .cn { display: inline; font-weight: 400; }
[data-theme="zhu"] .step-h { font: 400 15.5px var(--z-kai); color: var(--paper); gap: 0; }
[data-theme="zhu"] .g-t, [data-theme="zhu"] .g-list .cn { font: 400 15.5px var(--z-kai); color: var(--paper); }
[data-theme="zhu"] .btn { border-radius: 6px; }
[data-theme="zhu"] .btn.pri { background: var(--paper); color: #18202e; }
[data-theme="zhu"] .btn.pri:hover:not(:disabled) { background: #fff; }
[data-theme="zhu"] .btn.sec { background: transparent; border-color: rgba(232, 236, 242, .26); color: var(--ink); }
[data-theme="zhu"] .btn.sec:hover:not(:disabled) { border-color: rgba(232, 236, 242, .5); background: var(--hover); }
[data-theme="zhu"] textarea { background: var(--deep); border: 1px solid var(--line); border-radius: 6px; color: var(--ink); }
[data-theme="zhu"] textarea:focus { border-color: rgba(241, 237, 228, .4); }
[data-theme="zhu"] textarea::placeholder { color: #5f6b7e; }
[data-theme="zhu"] .bubbles { gap: 3px; }
[data-theme="zhu"] .bubbles i { width: 10px; height: 10px; border: 1px solid rgba(232, 236, 242, .22); font-size: 0; transition: background .15s ease; }
[data-theme="zhu"] .bubbles i.on { background: var(--paper); border-color: var(--paper); }
[data-theme="zhu"] .bubbles i.err { background: var(--zhu); border-color: var(--zhu); }
[data-theme="zhu"] .foot { border-top: 1px solid var(--line); }
[data-theme="zhu"] .row-btn { border: 1px solid var(--line); border-radius: 6px; }
[data-theme="zhu"] .row-btn:hover { background: var(--hover); }
[data-theme="zhu"] .fld input { border: 1px solid var(--line); border-radius: 5px; background: var(--deep); }
[data-theme="zhu"] .seg { border: 1px solid var(--line); border-radius: 6px; }
[data-theme="zhu"] .seg button { border-radius: 4px; }
[data-theme="zhu"] .seg button[aria-pressed="true"] { background: rgba(241, 237, 228, .12); color: var(--paper); }
[data-theme="zhu"] .sw { border-radius: 10px; background: rgba(232, 236, 242, .16); }
[data-theme="zhu"] .sw::after { background: var(--muted); }
[data-theme="zhu"] .sw[aria-checked="true"] { background: var(--paper); }
[data-theme="zhu"] .sw[aria-checked="true"]::after { background: #18202e; }
[data-theme="zhu"] .sh-head { font: 400 16px var(--z-kai); border-bottom: 1px solid var(--line); }
[data-theme="zhu"] .img-item img { border-radius: 4px; }
[data-theme="zhu"] .r-title { font: 400 16px var(--z-kai); }
[data-theme="zhu"] .score { padding: 12px 16px 10px 26px; }
[data-theme="zhu"] .sc { position: relative; display: inline-block; }
[data-theme="zhu"] .ring { display: block; position: absolute; inset: -12px -20px -10px -18px; background: ${svgUrl(SVG.ring)} center / 100% 100% no-repeat; pointer-events: none; }
[data-theme="zhu"] .r-score { position: relative; font: 400 56px/1 var(--z-kai); color: var(--zhu); }
[data-theme="zhu"] .of { font: 15px var(--z-kai); margin-left: 18px; }
[data-theme="zhu"] .tabs { border-bottom: 1px solid var(--line); padding: 0 12px; gap: 14px; }
[data-theme="zhu"] .tab { padding: 6px 0; border-bottom: 1.5px solid transparent; margin-bottom: -1px; }
[data-theme="zhu"] .tab[aria-pressed="true"] { color: var(--paper); border-bottom-color: var(--paper); }
[data-theme="zhu"] .list { padding-top: 4px; }
[data-theme="zhu"] .row { grid-template-columns: 26px 1fr; column-gap: 6px; border-bottom: 1px solid var(--line); }
[data-theme="zhu"] .row > * { grid-column: 2; }
[data-theme="zhu"] .row > .mk { display: block; grid-column: 1; grid-row: 1 / span 4; width: 22px; height: 22px; background: center / contain no-repeat; }
[data-theme="zhu"] .row[data-s="ok"] .mk { background-image: ${svgUrl(SVG.check('#e2583e'))}; }
[data-theme="zhu"] .row[data-s="part"] .mk { background-image: ${svgUrl(SVG.check('#e9a46a'))}; }
[data-theme="zhu"] .row[data-s="bad"] .mk { background-image: ${svgUrl(SVG.cross('#e2583e'))}; }
[data-theme="zhu"] .row[data-s="none"] .mk { background-image: ${svgUrl(SVG.cross('#8e9aad'))}; }
[data-theme="zhu"] .row[data-s="pending"] .mk { background-image: ${svgUrl(SVG.dashed)}; }
[data-theme="zhu"] .chip { display: none; }
[data-theme="zhu"] .sec-h { padding: 12px 8px 2px; font: 400 13px var(--z-kai); }
[data-theme="zhu"] .theme-card { border-radius: 8px; }
[data-theme="zhu"] .trigger { gap: 10px; height: 46px; padding: 0 16px 0 8px; background: var(--surf); border: 1px solid var(--line); border-radius: 23px; color: var(--ink); box-shadow: 0 16px 32px -14px rgba(8, 12, 20, .7); }
[data-theme="zhu"] .t-mark { width: 30px; height: 30px; }
[data-theme="zhu"] .t-name { font: 400 16px var(--z-kai); letter-spacing: .04em; }
[data-theme="zhu"] .t-num { font: 500 12px var(--z-num); color: var(--muted); }
[data-theme="zhu"] .t-num small { margin-left: 1px; }
`;
function template() {
return `
小雅粘粘粘v${VERSION}
初次使用 · 1 / 2
选一个喜欢的样子
以后随时可以点右上角的半圆按钮切换。
2 / 2
三步完成作答
- 1一、复制题目打开作业或测验页面,点「复制题目给 AI」,把内容发给任意 AI。
- 2二、粘贴答案把 AI 的回答整段粘贴到框里,面板会标出识别到哪些题、缺哪些题。
- 3三、保存作答点「保存作答记录」逐题保存,不会替你交卷;全部成功后自动刷新页面。
- 交卷以后,左边会显示每题的得分和对错。
- 「导出」里有给 AI 看的图文 PDF,也有不含提示词的纯净作业。
- 按住标题栏或收起后的图标,可以拖到别的位置。
打开小雅的作业或测验页面后,这里会显示题目。
公告正在加载公告…
-
1一、复制题目
题号可能和网页上的不同,保存时按题目本身对应,不会错位。
-
2二、粘贴答案
-
3三、保存作答
只保存作答,不会替你交卷。
导出题目图片
给 AI 看
纯净作业(不含 AI 提示词)
附正确答案
只附平台公开的标准答案,或已被判为正确的作答。
`;
}
const view = (() => {
let root = null;
let el = {};
let failures = [];
let saving = false;
let imagesLoaded = false;
let shownKey;
let guideStep = 0;
let toastTimer = null;
let closeTimer = null;
const $ = selector => root.querySelector(selector);
const label = (button, text) => { button.querySelector('span').textContent = text; };
function mount() {
const host = document.createElement('div');
host.style.cssText = 'all: initial !important';
root = host.attachShadow({ mode: 'closed' });
root.innerHTML = `${template()}`;
(document.body || document.documentElement).appendChild(host);
el = {
zz: $('.zz'), trigger: $('.trigger'), tNum: $('.t-num'), tDot: $('.t-dot'), shell: $('.shell'),
main: $('.main'), result: $('.result'), resBtn: $('[data-res-btn]'),
course: $('.course'), paperTitle: $('.paper'), meta: $('.meta'),
notice: $('.notice'), nTag: $('.n-tag'), nBadge: $('.n-badge'), nTxt: $('.n-txt'), nMore: $('.n-more'),
input: $('textarea'), pTxt: $('.p-txt'), pMiss: $('.p-miss'), bubbles: $('.bubbles'), issues: $('.issues'),
bar: $('.bar'), barFill: $('.bar i'), sMsg: $('.s-msg'), imgCount: $('.img-count'), imgBtn: $('[data-v-open="img"]'),
toast: $('.toast'), courseInput: $('.course-input'), sw: $('.sw'), dep: $('[data-dep]'), imgs: $('.imgs'),
score: $('.r-score'), of: $('.of'), counts: $('.counts'), rNote: $('.r-note'), list: $('.list'),
themeBtn: $('.theme-btn'), guide: $('.guide'),
};
guideStep = prefs.theme ? 0 : 1;
applyTheme();
renderGuide();
prefsToControls();
bind();
renderNotice();
renderPaper();
place(true);
if (!prefs.welcomed) {
prefs.welcomed = true;
savePrefs();
setOpen(true);
}
}
function applyTheme() {
const theme = prefs.theme || 'tape';
const other = theme === 'tape' ? 'zhu' : 'tape';
el.zz.dataset.theme = theme;
el.themeBtn.title = `换成「${THEMES[other]}」风格`;
root.querySelectorAll('.theme-card').forEach(card => card.setAttribute('aria-pressed', String(card.dataset.theme === prefs.theme)));
}
function setTheme(theme) {
prefs.theme = theme;
savePrefs();
applyTheme();
renderTrigger();
}
function renderGuide() {
el.zz.dataset.guide = guideStep ? '1' : '0';
el.guide.dataset.step = guideStep || 1;
}
function prefsToControls() {
root.querySelectorAll('.seg').forEach(seg => seg.querySelectorAll('button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.val === prefs[seg.dataset.pref]))));
el.sw.setAttribute('aria-checked', String(prefs.withAnswers));
el.dep.dataset.off = prefs.withAnswers ? '0' : '1';
}
function renderTrigger() {
el.tNum.innerHTML = paper ? `${paper.questions.length}题` : '—';
el.trigger.title = paper ? `${NAME}:已读取 ${paper.questions.length} 道题,点击展开` : `${NAME}:点击展开`;
el.tDot.hidden = !(noticeUnread() || hasUpdate() || !prefs.theme);
}
function renderCourse() {
if (!root) return;
const name = paper ? getCourse(paper.groupId) : '';
el.course.textContent = name;
if (root.activeElement !== el.courseInput) el.courseInput.value = name;
}
function renderNotice() {
if (!root) return;
el.nTxt.replaceChildren();
const content = notice ? notice.content : '正在加载公告…';
content.split(/(https?:\/\/[^\s,。、;))]+)/).forEach((part, i) => {
if (i % 2) el.nTxt.append(Object.assign(document.createElement('a'), { href: part, target: '_blank', rel: 'noopener noreferrer', textContent: part }));
else if (part) el.nTxt.append(part);
});
el.nMore.hidden = !notice || notice.error || !(content.includes('\n') || content.length > 24);
el.nBadge.hidden = !hasUpdate();
el.nBadge.textContent = hasUpdate() ? `v${notice.version} 可用` : '';
el.nTag.classList.toggle('unread', noticeUnread());
renderTrigger();
}
function renderPaper() {
if (!root) return;
const p = paper;
const changed = (p?.key ?? null) !== shownKey;
const hadResult = !el.resBtn.hidden;
shownKey = p?.key ?? null;
el.zz.dataset.paper = p ? '1' : '0';
renderTrigger();
renderCourse();
el.paperTitle.textContent = p?.title || '';
el.meta.replaceChildren(...(p ? metaChips(p) : []));
el.imgCount.textContent = p ? p.images.length : '';
el.imgBtn.disabled = !p?.images.length;
result(p, changed || !hadResult);
if (changed) {
el.main.dataset.sheet = '';
el.bubbles.replaceChildren(...(p ? p.questions.map(q => Object.assign(document.createElement('i'), { textContent: q.index, title: `第 ${q.index} 题 · ${q.label}` })) : []));
el.input.value = '';
failures = [];
setMsg('只保存作答,不会替你交卷。');
el.imgs.replaceChildren();
imagesLoaded = false;
}
check();
place(false);
}
function metaChips(p) {
const counts = {};
p.questions.forEach(q => { counts[q.label] = (counts[q.label] || 0) + 1; });
const chip = text => Object.assign(document.createElement('span'), { textContent: text });
return [chip(`${p.questions.length} 题`), ...Object.entries(counts).map(([type, n]) => chip(`${type.replace(/题$/, '')} ${n}`))];
}
function result(p, reveal) {
const r = p?.result;
el.resBtn.hidden = !r;
if (!r || reveal) el.zz.dataset.result = r ? '1' : '0';
el.list.replaceChildren();
if (!r) return;
const count = s => r.rows.filter(row => s.includes(row.state)).length;
const wrong = count(['bad', 'none']);
const part = count(['part']);
const pending = count(['pending']);
el.score.textContent = r.score ?? '-';
el.of.textContent = `/ ${r.total ?? '-'} 分`;
el.counts.innerHTML = `${count(['ok'])} 正确${wrong} 错误${part ? `${part} 部分得分` : ''}${pending} 待批改`;
el.rNote.textContent = p.showAnswer ? '老师已公开标准答案。' : '老师没有公开标准答案,这里只显示得分和对错。';
const tabs = root.querySelectorAll('.tab em');
tabs[0].textContent = r.rows.length;
tabs[1].textContent = wrong + part;
tabs[2].textContent = pending;
const html = p.sections.map(section => {
const head = section.title ? `${esc(section.title.text || '题组')}
` : '';
return head + section.questions.map(q => {
const row = r.rows[q.index - 1];
return `
${pad2(q.index)}${esc(q.label)}${STATE_LABEL[row.state]}${row.score ?? '-'} / ${q.score ?? '-'}
${esc(q.title.text)}
我的答案 ${esc(row.mine || '未作答')}
${row.standard ? `
标准答案 ${esc(row.standard)}
` : ''}
`;
}).join('');
}).join('');
el.list.innerHTML = html + '没有这类题目。
';
setFilter(reveal ? 'all' : el.list.dataset.filter);
}
function setFilter(filter) {
el.list.dataset.filter = filter;
root.querySelectorAll('.tab').forEach(tab => tab.setAttribute('aria-pressed', String(tab.dataset.f === filter)));
const visible = filter === 'all' || el.list.querySelector(filter === 'bad' ? '.row[data-s="bad"], .row[data-s="part"], .row[data-s="none"]' : '.row[data-s="pending"]');
el.list.querySelector('.filter-empty').style.display = visible ? 'none' : 'block';
}
function check() {
const p = paper;
if (!p) return null;
const c = checkAnswers(p, el.input.value);
[...el.bubbles.children].forEach((b, i) => {
b.classList.toggle('on', c.ok.has(i + 1));
b.classList.toggle('err', c.bad.has(i + 1));
});
const answerable = p.questions.filter(q => q.type !== 7).length;
el.pTxt.textContent = el.input.value.trim() ? `已识别 ${c.ok.size} / ${answerable} 题` : `共 ${answerable} 题要作答`;
el.pMiss.textContent = !el.input.value.trim() ? '' : c.missing.length ? `缺 ${c.missing.slice(0, 6).join('、')}${c.missing.length > 6 ? ` 等 ${c.missing.length} 题` : ''}` : '全部识别';
const problems = [...c.issues, ...failures].sort((a, b) => a.n - b.n);
el.issues.replaceChildren(...problems.slice(0, 4).map(i => Object.assign(document.createElement('li'), { textContent: `第 ${i.n} 题:${i.message}` })));
if (problems.length > 4) el.issues.append(Object.assign(document.createElement('li'), { textContent: `还有 ${problems.length - 4} 题有问题` }));
return c;
}
function images() {
if (imagesLoaded || !paper) return;
imagesLoaded = true;
el.imgs.replaceChildren(...paper.images.map((img, i) => {
const item = document.createElement('div');
item.className = 'img-item';
item.innerHTML = `${esc(img.label)}
`;
item.querySelector('img').src = img.src;
return item;
}));
}
function toast(message, kind = 'ok') {
el.toast.textContent = message;
el.toast.dataset.kind = kind;
el.toast.classList.add('on');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.toast.classList.remove('on'), kind === 'error' ? 4200 : 2400);
}
function place(full) {
if (!root) return;
const open = el.zz.dataset.open === '1';
const both = open && el.zz.dataset.result === '1' && !guideStep;
if (full) el.main.style.maxHeight = '';
const bothWidth = el.main.offsetWidth + el.result.offsetWidth + 12;
const narrow = both && innerWidth < bothWidth + MARGIN * 2;
el.zz.classList.toggle('narrow', narrow);
const box = open ? el.main : el.trigger;
const width = both && !narrow ? bothWidth : box.offsetWidth;
const right = Math.min(Math.max(prefs.right, MARGIN), Math.max(MARGIN, innerWidth - width - MARGIN));
el.zz.style.right = `${right}px`;
if (!full && open && el.zz.style.top) return;
const top = Math.min(Math.max(prefs.top, MARGIN), Math.max(MARGIN, innerHeight - box.offsetHeight - MARGIN));
el.zz.style.top = `${top}px`;
if (open) el.main.style.maxHeight = `${innerHeight - top - MARGIN}px`;
}
function setOpen(open) {
clearTimeout(closeTimer);
if (open) {
el.zz.classList.remove('leave');
el.zz.dataset.open = '1';
el.zz.classList.add('enter');
place(true);
if (noticeUnread()) { prefs.seen = noticeKey(); savePrefs(); renderTrigger(); }
} else {
el.main.dataset.sheet = '';
el.zz.classList.remove('enter');
el.zz.classList.add('leave');
closeTimer = setTimeout(() => {
el.zz.classList.remove('leave');
el.zz.dataset.open = '0';
el.nTag.classList.toggle('unread', noticeUnread());
place(true);
}, 140);
}
}
function drag(handle, isTrigger) {
handle.addEventListener('pointerdown', event => {
if (event.button !== 0 || (!isTrigger && event.target.closest('button'))) return;
const rect = el.zz.getBoundingClientRect();
const start = { x: event.clientX, y: event.clientY, right: innerWidth - rect.right, top: rect.top };
let moved = false;
handle.setPointerCapture(event.pointerId);
const move = e => {
const dx = e.clientX - start.x;
const dy = e.clientY - start.y;
if (!moved && Math.hypot(dx, dy) < 4) return;
moved = true;
el.zz.dataset.dragging = '1';
prefs.right = start.right - dx;
prefs.top = start.top + dy;
place(true);
};
const up = () => {
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
handle.removeEventListener('pointercancel', up);
delete el.zz.dataset.dragging;
if (!moved) return;
const r = el.zz.getBoundingClientRect();
prefs.right = innerWidth - r.right;
prefs.top = r.top;
savePrefs();
if (isTrigger) handle.addEventListener('click', e => e.stopImmediatePropagation(), { capture: true, once: true });
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
handle.addEventListener('pointercancel', up);
});
}
async function busy(button, text, task) {
const original = button.querySelector('span').textContent;
button.disabled = true;
label(button, text);
try { await task(); } catch (e) { toast(e.message, 'error'); console.error(`[${NAME}]`, e); }
finally { button.disabled = false; label(button, original); }
}
async function save(button) {
const p = paper;
const c = check();
if (!c.jobs.length) {
setMsg(c.issues.length ? '答案格式有误,按上面的提示改一下再保存。' : '没有识别到答案。每题要以「题号 =>」开头。', 'error');
return;
}
saving = true;
button.disabled = true;
el.bar.classList.add('on');
el.barFill.style.width = '0';
setMsg('正在读取作答记录…');
let reloading = false;
try {
failures = await saveAnswers(p, c.jobs, (done, total) => {
label(button, `正在保存 ${done} / ${total}`);
el.barFill.style.width = `${done / total * 100}%`;
});
const saved = c.jobs.length - failures.length;
check();
if (!failures.length && !c.issues.length) {
reloading = true;
label(button, '已保存');
setMsg(`已保存 ${saved} 题,正在刷新页面…`, 'ok');
setTimeout(() => location.reload(), 1200);
return;
}
const parts = [`已保存 ${saved} 题`];
if (failures.length) parts.push(`${failures.length} 题保存失败`);
if (c.issues.length) parts.push(`${c.issues.length} 题格式有误没有保存`);
setMsg(`${parts.join(',')}。改好后再保存一次即可,页面没有刷新。`, 'warn');
} catch (e) {
setMsg(e.message, 'error');
} finally {
saving = false;
el.bar.classList.remove('on');
if (!reloading) {
button.disabled = false;
label(button, '保存作答记录');
}
}
}
function setMsg(text, kind) {
el.sMsg.textContent = text;
if (kind) el.sMsg.dataset.kind = kind;
else delete el.sMsg.dataset.kind;
}
function onClick(event) {
const t = event.target.closest('[data-act]');
if (!t) return;
const p = paper;
switch (t.dataset.act) {
case 'open': setOpen(true); break;
case 'close': setOpen(false); break;
case 'theme': setTheme(el.zz.dataset.theme === 'tape' ? 'zhu' : 'tape'); break;
case 'pick-theme': setTheme(t.dataset.theme); break;
case 'guide-next':
if (!prefs.theme) setTheme('tape');
guideStep = 2;
renderGuide();
break;
case 'guide-back': guideStep = 1; renderGuide(); break;
case 'guide-done':
guideStep = 0;
renderGuide();
place(true);
break;
case 'result': {
if (!p?.result) return;
const show = el.zz.dataset.result !== '1';
el.zz.dataset.result = show ? '1' : '0';
if (show) { el.result.classList.remove('r-enter'); void el.result.offsetWidth; el.result.classList.add('r-enter'); }
place(true);
break;
}
case 'filter': setFilter(t.dataset.f); break;
case 'notice': {
const open = el.notice.dataset.open !== '1';
el.notice.dataset.open = open ? '1' : '0';
t.textContent = open ? '收起' : '展开';
break;
}
case 'sheet':
el.main.dataset.sheet = el.main.dataset.sheet === t.dataset.vOpen ? '' : t.dataset.vOpen;
if (el.main.dataset.sheet === 'img') images();
break;
case 'sheet-close': el.main.dataset.sheet = ''; break;
case 'seg': {
const seg = t.parentElement;
prefs[seg.dataset.pref] = t.dataset.val;
savePrefs();
prefsToControls();
break;
}
case 'switch':
prefs.withAnswers = !prefs.withAnswers;
savePrefs();
prefsToControls();
break;
case 'copy':
GM_setClipboard(buildPrompt(p), 'text');
label(t, `已复制 ${p.questions.length} 题`);
clearTimeout(t.timer);
t.timer = setTimeout(() => label(t, '复制题目给 AI'), 1600);
break;
case 'save': if (!saving) save(t); break;
case 'ai-pdf': busy(t, '正在生成…', () => printDocument('ai')); break;
case 'hw-pdf': busy(t, '生成中…', () => printDocument('homework')); break;
case 'hw-doc': busy(t, '生成中…', async () => { await downloadDoc(); toast('已下载 DOC,用 Word 或 WPS 打开'); }); break;
case 'hw-text':
GM_setClipboard(buildHomeworkText(p), 'text');
toast('已复制纯作业文本');
break;
case 'img-copy': {
const img = p.images[t.dataset.i];
busy(t, '复制中…', async () => {
try {
await copyImage(img.src);
toast('图片已复制');
} catch (e) {
GM_setClipboard(img.src, 'text');
toast('浏览器不让复制图片,已改为复制图片链接', 'error');
}
});
break;
}
case 'img-link':
GM_setClipboard(p.images[t.dataset.i].src, 'text');
toast('图片链接已复制');
break;
}
}
function bind() {
root.addEventListener('click', onClick);
el.input.addEventListener('input', () => { failures = []; check(); });
el.courseInput.addEventListener('input', () => { const p = paper; if (p) setCourse(p.groupId, el.courseInput.value); });
el.zz.addEventListener('animationend', event => {
if (event.animationName !== 'zz-in') return;
el.zz.classList.remove('enter');
el.result.classList.remove('r-enter');
});
drag(el.trigger, true);
drag($('.head'), false);
addEventListener('resize', () => place(true));
new ResizeObserver(() => place(false)).observe(el.shell);
}
return { mount, paper: renderPaper, course: renderCourse, notice: renderNotice };
})();
hookRequests();
watchRoute();
dropLegacyKeys();
loadNotice();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', view.mount, { once: true });
else view.mount();
})();