课堂小测验生成器

快速创建单选、多选、判断题,导出可独立运行的学生答题 HTML。

教师工具箱 · 课堂小测验生成器 离线版 · 生成于 2026-10-08
这个文件完全在本机运行,不需要网络。 离线版的数据不与在线站点同步,也不保证长期保存 —— 请用工具内的导出功能留备份。

会提前结束脚本块 const json = JSON.stringify(payload).replace(/ ${esc(quiz.title)} - 教师工具箱

${esc(quiz.title)}

${esc(quiz.intro || '请依次作答,完成后点「提交答卷」查看得分。')}

`; } /** 计分逻辑抽出来供测试用(与内嵌 runtime 的规则保持一致) */ function gradeQuiz(questions, answers) { let right = 0; let unanswered = 0; questions.forEach((q, i) => { const a = answers[i]; const answered = Array.isArray(a) ? a.length > 0 : (a !== undefined && a !== null); if (!answered) { unanswered += 1; return; } if (q.type === 'multi') { const got = [...a].sort().join(','); const want = [...q.correct].sort().join(','); if (got === want) right += 1; // 多选全对才得分 } else if (q.correct.includes(a)) { right += 1; } }); return { right, total: questions.length, unanswered }; } return { buildStudentHtml: buildStudentHtml, gradeQuiz: gradeQuiz }; })(); /* assets/js/core/i18n.js */ const _m_assets_js_core_i18n = (function () { /* 共享 i18n 模块 所有页面的 JS(工具、首页、工具大厅等)都从这里取当前语言。 与页面内联的 data-t 脚本共用同一个 localStorage key(teach-lang), 点击右上角语言切换时,这里会广播变化,已挂载的工具可重新渲染。 */ const KEY = 'teach-lang'; let lang = (() => { try { return localStorage.getItem(KEY); } catch { return null; } })() || ((navigator.language ?? 'zh-CN').startsWith('zh') ? 'zh' : 'en'); const listeners = new Set(); // 页面内联的 data-t 脚本点击切换时会派发该事件,保持模块与内联脚本同步 document.addEventListener('teach-lang-change', (e) => { if (e.detail && (e.detail.lang === 'zh' || e.detail.lang === 'en')) { setLang(e.detail.lang); } }); function setLang(next) { if (next !== 'zh' && next !== 'en') return; lang = next; try { localStorage.setItem(KEY, next); } catch {} listeners.forEach((fn) => { try { fn(lang); } catch (e) { console.error(e); } }); } function onLangChange(fn) { listeners.add(fn); return () => listeners.delete(fn); } /** 取工具的当前语言名称/简介:优先读 nameEn/descEn,没有就回退 name/desc */ function toolText(tool) { if (lang === 'en') { return { name: tool.nameEn || tool.name, short: tool.shortEn || tool.short, desc: tool.descEn || tool.desc, }; } return { name: tool.name, short: tool.short, desc: tool.desc }; } function categoryLabel(cat) { return lang === 'en' ? (cat.labelEn || cat.label) : cat.label; } /** 简单的 zh/en 二选一,像 photo 站的 tr() */ const tr = (zh, en) => (lang === 'zh' ? zh : en); return { setLang: setLang, onLangChange: onLangChange, toolText: toolText, categoryLabel: categoryLabel, lang: lang, tr: tr }; })(); /* assets/js/tools/quiz/index.js */ const _m_assets_js_tools_quiz_index = (function () { /* 课堂小测验生成器(文档 11.7) */ const el = _m_assets_js_core_dom.el; const downloadBlob = _m_assets_js_core_dom.downloadBlob; const exportFilename = _m_assets_js_core_dom.exportFilename; const safeFilename = _m_assets_js_core_dom.safeFilename; const readFileAsText = _m_assets_js_core_dom.readFileAsText; const toast = _m_assets_js_core_ui.toast; const render = _m_assets_js_core_ui.render; const dialog = _m_assets_js_core_ui.dialog; const confirmDanger = _m_assets_js_core_ui.confirmDanger; const toolLayout = _m_assets_js_core_toolshell.toolLayout; const panel = _m_assets_js_core_toolshell.panel; const actions = _m_assets_js_core_toolshell.actions; const startTool = _m_assets_js_core_toolshell.startTool; const buildStudentHtml = _m_assets_js_core_quiz_export.buildStudentHtml; const gradeQuiz = _m_assets_js_core_quiz_export.gradeQuiz; const storage = _m_assets_js_core_storage.storage; const uid = _m_assets_js_core_uid.uid; const LIMITS = _m_assets_js_core_schema.LIMITS; const tr = _m_assets_js_core_i18n.tr; const onLangChange = _m_assets_js_core_i18n.onLangChange; const TYPES = { single: tr('单选题','Single choice'), multi: tr('多选题','Multiple choice'), judge: tr('判断题','True/False') }; const state = { id: null, title: tr('课堂小测验','Class quiz'), intro: '', showExplain: true, allowRetry: true, questions: [], preview: false, previewAnswers: {}, }; let listHost = null; /* 当前渲染出来的编辑器与其 DOM 输入框的对应关系。 每次整列表重渲染前,先把 DOM 里的值回写进 state —— 否则只要有一次 input 事件没落地(输入法组合、自动化输入、粘贴等),内容就会凭空消失。 这比"指望每个 input 事件都可靠"稳妥得多。 */ let liveEditors = []; function commitEdits() { for (const ed of liveEditors) { if (ed.textEl?.isConnected) ed.q.text = ed.textEl.value; if (ed.explainEl?.isConnected) ed.q.explain = ed.explainEl.value; ed.optionEls.forEach((input, i) => { if (input?.isConnected && ed.q.options[i] !== undefined) { ed.q.options[i] = input.value; } }); } } function blankQuestion(type = 'single') { return { id: uid('quiz'), type, text: '', options: type === 'judge' ? ['正确', '错误'] : ['', '', '', ''], correct: [], explain: '', }; } /* -------------------------------------------------------------------------- 编辑 -------------------------------------------------------------------------- */ function questionCard(q, index) { const editor = { q, textEl: null, explainEl: null, optionEls: [], optionRows: [] }; liveEditors.push(editor); const typeSelect = el('select', { class: 'select', 'aria-label': tr('第 ','Q ')+`${index+1} `+tr('题题型','type'), on: { change: (e) => { q.type = e.target.value; if (q.type === 'judge') { q.options = [tr('正确','True'), tr('错误','False')]; q.correct = []; } else if (q.options.length < 2) q.options = ['', '', '', '']; q.correct = []; paint(); }, }, }, Object.entries(TYPES).map(([v, label]) => el('option', { value: v, text: label, selected: q.type === v }))); const optionRows = q.options.map((opt, oi) => { const isCorrect = q.correct.includes(oi); const optionInput = el('input', { class: 'input', type: 'text', value: opt, placeholder: tr('选项 ','Option ')+`${String.fromCharCode(65+oi)}`, disabled: q.type === 'judge', 'aria-label': tr('第 ','Q ')+`${index+1} `+tr('题选项','option ')+`${String.fromCharCode(65+oi)}`, on: { input: (e) => { q.options[oi] = e.target.value; } }, }); editor.optionEls[oi] = optionInput; const mark = el('input', { type: q.type === 'multi' ? 'checkbox' : 'radio', name: `correct-${q.id}`, checked: isCorrect, 'aria-label': tr('把选项 ','Set option ')+`${String.fromCharCode(65+oi)} `+tr('设为正确答案','as correct'), on: { change: (e) => { if (q.type === 'multi') { const at = q.correct.indexOf(oi); if (e.target.checked && at < 0) q.correct.push(oi); else if (!e.target.checked && at >= 0) q.correct.splice(at, 1); } else { q.correct = [oi]; } // 只切高亮,不整列表重渲染 —— 重建会打断正在输入的框 markCorrectOptions(q); }, }, }); const row = el('div', { class: `quiz-option${isCorrect ? ' is-correct' : ''}` }, [ mark, el('span', { class: 'quiz-option__letter', text: String.fromCharCode(65 + oi) }), optionInput, q.type !== 'judge' && q.options.length > 2 ? el('button', { type: 'button', class: 'member__lock', text: '✕', 'aria-label': `删除选项 ${String.fromCharCode(65 + oi)}`, on: { click: () => { q.options.splice(oi, 1); q.correct = q.correct.filter((c) => c !== oi).map((c) => (c > oi ? c - 1 : c)); paint(); }, }, }) : null, ]); editor.optionRows[oi] = row; return row; }); return el('div', { class: 'card quiz-card' }, [ el('div', { class: 'roster-toolbar' }, [ el('span', { class: 'q__no', text: tr('第 ','Q ')+`${index+1}` }), el('div', { class: 'panel__actions' }, [ typeSelect, el('button', { type: 'button', class: 'btn btn--sm', text: '↑', 'aria-label': tr('上移第 ','Move up Q ')+`${index+1}`, disabled: index === 0, on: { click: () => { move(index, index - 1); } }, }), el('button', { type: 'button', class: 'btn btn--sm', text: '↓', 'aria-label': tr('下移第 ','Move down Q ')+`${index+1}`, disabled: index === state.questions.length - 1, on: { click: () => { move(index, index + 1); } }, }), el('button', { type: 'button', class: 'btn btn--sm', text: tr('复制','Duplicate'), on: { click: () => { state.questions.splice(index + 1, 0, { ...structuredCloneSafe(q), id: uid('quiz') }); paint(); }, }, }), el('button', { type: 'button', class: 'btn btn--sm btn--link is-danger', text: tr('删除','Delete'), on: { click: () => { state.questions.splice(index, 1); paint(); } }, }), ]), ]), el('label', { class: 'field' }, [ el('span', { class: 'field__label', text: tr('题干','Question') }), (editor.textEl = el('textarea', { class: 'textarea', rows: '2', value: q.text, placeholder: tr('输入题目内容','Enter question text'), on: { input: (e) => { q.text = e.target.value; } }, })), ]), el('div', { class: 'field' }, [ el('span', { class: 'field__label', text: tr('选项(勾选正确答案)','Options (check correct answer)') }), ...optionRows, q.type !== 'judge' && q.options.length < 8 ? el('button', { type: 'button', class: 'btn btn--sm', text: tr('+ 添加选项','+ Add option'), on: { click: () => { q.options.push(''); paint(); } }, }) : null, ]), el('label', { class: 'field' }, [ el('span', { class: 'field__label', text: tr('答案解析(可选)','Explanation (optional)') }), (editor.explainEl = el('input', { class: 'input', type: 'text', value: q.explain, placeholder: tr('提交后显示给学生','Shown to students after submission'), on: { input: (e) => { q.explain = e.target.value; } }, })), ]), ]); } /** 勾选正确答案时只切高亮,不重建整张卡片 */ function markCorrectOptions(q) { const editor = liveEditors.find((e) => e.q === q); if (!editor) return; editor.optionRows.forEach((row, i) => { row?.classList.toggle('is-correct', q.correct.includes(i)); }); } function structuredCloneSafe(obj) { return JSON.parse(JSON.stringify(obj)); } function move(from, to) { if (to < 0 || to >= state.questions.length) return; const [item] = state.questions.splice(from, 1); state.questions.splice(to, 0, item); paint(); } /* -------------------------------------------------------------------------- 预览(学生视角) -------------------------------------------------------------------------- */ function previewNodes() { const cards = state.questions.map((q, i) => el('div', { class: 'card' }, [ el('div', { class: 'q__no', text: tr('第 ','Q ')+`${i+1} · ${TYPES[q.type]}${q.type === 'multi' ? tr('(全对才得分)',' (all correct to score)') : ''}` }), el('p', { class: 'q__text', text: q.text || tr('(题干为空)','(empty question)') }), ...q.options.map((opt, oi) => el('label', { class: 'quiz-option' }, [ el('input', { type: q.type === 'multi' ? 'checkbox' : 'radio', name: `preview-${q.id}`, on: { change: (e) => { if (q.type === 'multi') { const cur = state.previewAnswers[i] ?? []; const at = cur.indexOf(oi); if (e.target.checked && at < 0) cur.push(oi); else if (!e.target.checked && at >= 0) cur.splice(at, 1); state.previewAnswers[i] = cur; } else { state.previewAnswers[i] = oi; } }, }, }), el('span', { class: 'quiz-option__letter', text: String.fromCharCode(65 + oi) }), el('span', { text: opt || tr('(空选项)','(empty option)') }), ])), ])); return [ el('p', { class: 'stage__hint', text: tr('学生预览模式:这就是学生打开导出文件后看到的样子。','Student preview: this is what students see when they open the exported file.') }), ...cards, el('div', { class: 'card__actions' }, [ el('button', { type: 'button', class: 'btn btn--primary', text: tr('试算得分','Calculate score'), on: { click: () => { const r = gradeQuiz(state.questions, state.previewAnswers); toast( `${r.right} / ${r.total} ${tr('题正确','correct')}${r.unanswered ? tr(',',',')+`${r.unanswered} `+tr('题未作答(不计为答错)','unanswered (not counted as wrong)') : ''}。`, { type: 'success', duration: 6000 }, ); }, }, }), ]), ]; } /* -------------------------------------------------------------------------- 渲染 -------------------------------------------------------------------------- */ function paint() { if (!listHost) return; // 重建 DOM 之前先把用户已经敲进去的内容捞回 state commitEdits(); liveEditors = []; if (state.preview) { render(listHost, previewNodes()); return; } if (state.questions.length === 0) { render(listHost, el('div', { class: 'stage' }, [ el('p', { class: 'stage__result stage__result--placeholder', text: tr('还没有题目','No questions yet') }), el('p', { class: 'stage__hint', text: tr('点左侧「+ 添加题目」开始出题。','Click + Add question on the left to start.') }), ])); return; } render(listHost, state.questions.map(questionCard)); } /* -------------------------------------------------------------------------- 校验与导出 -------------------------------------------------------------------------- */ /** * 导出前整理题目:丢掉没填内容的选项,并把正确答案的下标重新映射。 * * 新建题目默认给 4 个空选项,但两选一、三选一都是常见题型, * 不该逼老师先手动删掉 C、D 才能导出。 */ function normalizeQuestions() { commitEdits(); // 导出前也要回写,避免最后一次输入没提交 return state.questions.map((q) => { if (q.type === 'judge') return { ...q, text: q.text.trim() }; const kept = []; const indexMap = new Map(); q.options.forEach((opt, i) => { if (opt.trim() === '') return; indexMap.set(i, kept.length); kept.push(opt.trim()); }); return { ...q, text: q.text.trim(), options: kept, correct: q.correct.map((c) => indexMap.get(c)).filter((c) => c !== undefined), }; }); } function validate(questions) { const problems = []; questions.forEach((q, i) => { if (!q.text) problems.push(tr('第 ','Q ')+`${i+1}:`+tr('题干为空','question text is empty')); if (q.options.length < 2) problems.push(tr('第 ','Q ')+`${i+1}:`+tr('至少需要 2 个有内容的选项','needs at least 2 non-empty options')); if (q.correct.length === 0) { // 正确答案指向的选项被当成空选项丢掉了,也会落到这里 problems.push(tr('第 ','Q ')+`${i+1}:`+tr('还没有指定正确答案(或所选的正确选项内容为空)','no correct answer specified (or the chosen option is empty)')); } }); return problems; } async function exportStudentHtml() { if (state.questions.length === 0) { toast(tr('还没有题目。','No questions yet.'), { type: 'error' }); return; } const questions = normalizeQuestions(); const dropped = state.questions.reduce((n, q, i) => n + (q.type === 'judge' ? 0 : q.options.length - questions[i].options.length), 0); const problems = validate(questions); if (problems.length) { await dialog({ title: tr('题目还有问题需要处理','Issues to fix'), body: el('div', {}, [ el('p', { text: tr('以下问题会影响学生作答或判分:','These issues affect answering or scoring:') }), el('ul', {}, problems.slice(0, 8).map((p) => el('li', { text: p }))), problems.length > 8 ? el('p', { text: tr('…以及另外 ','…and ')+`${problems.length-8} `+tr('处。','more.') }) : null, ]), confirmText: tr('知道了','OK'), cancelText: tr('关闭','Close'), }); return; } // 答案内嵌是本地判分的前提,必须如实告知(文档 11.7) const ok = await dialog({ title: tr('导出前请确认','Confirm before export'), confirmText: tr('我明白,继续导出','I understand, export'), body: el('div', {}, [ el('p', {}, [ el('strong', { text: tr('这个文件里包含正确答案。','This file contains the correct answers.') }), ' '+tr('学生查看网页源码就能看到——这是本地判分必需的,无法避免。','Students can see them by viewing the source — required for local scoring, unavoidable.'), ]), el('p', { text: tr('所以:适合课堂练习和课后自测,不适合正式考试。正式考试请用打印卷面。','Good for in-class practice and self-tests, not for formal exams. Use printed papers for exams.') }), el('p', { text: tr('文件不依赖网络,学生双击即可打开作答,答题数据只留在他自己的设备上。','No network needed; students double-click to open. Answers stay on their own device.') }), ]), }); if (!ok) return; const html = buildStudentHtml({ ...state, questions }); downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8' }), safeFilename(`${state.title}-学生版.html`)); toast( tr('已导出学生答题版 HTML','Exported student HTML')+(dropped ? tr('(自动忽略了 ',' (auto-skipped ')+`${dropped} `+tr('个未填写的空选项)','empty options') : '')+'。', { type: 'success', duration: dropped ? 6000 : 3200 }, ); } function exportJson() { if (state.questions.length === 0) { toast(tr('还没有题目。','No questions yet.'), { type: 'error' }); return; } const data = { title: state.title, intro: state.intro, showExplain: state.showExplain, allowRetry: state.allowRetry, questions: state.questions, }; downloadBlob(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' }), exportFilename(state.title, 'json')); toast(tr('已导出题库 JSON。','Question bank JSON exported.'), { type: 'success' }); } /* -------------------------------------------------------------------------- 页面 -------------------------------------------------------------------------- */ async function build() { const titleInput = el('input', { class: 'input', type: 'text', value: state.title, maxlength: '40', on: { input: (e) => { state.title = e.target.value.trim() || tr('课堂小测验','Class quiz'); } }, }); const introInput = el('textarea', { class: 'textarea', rows: '2', placeholder: tr('给学生的说明(可选)','Instructions for students (optional)'), on: { input: (e) => { state.intro = e.target.value; } }, }); const jsonFile = el('input', { class: 'visually-hidden', type: 'file', accept: '.json,application/json', id: 'quiz-file', on: { change: async (e) => { const f = e.target.files?.[0]; if (!f) return; try { const data = JSON.parse(await readFileAsText(f, LIMITS.importBytes)); if (!Array.isArray(data.questions)) throw new Error(tr('文件里没有找到题目列表。','No question list found in the file.')); state.title = data.title ?? tr('课堂小测验','Class quiz'); state.intro = data.intro ?? ''; state.showExplain = data.showExplain ?? true; state.allowRetry = data.allowRetry ?? true; state.questions = data.questions.map((q) => ({ ...q, id: q.id ?? uid('quiz') })); titleInput.value = state.title; introInput.value = state.intro; paint(); toast(tr('已导入 ','Imported ')+`${state.questions.length} `+tr('道题。','questions.'), { type: 'success' }); } catch (err) { toast(tr('导入失败:','Import failed: ')+`${err.message}`, { type: 'error' }); } e.target.value = ''; }, }, }); const previewBtn = el('button', { type: 'button', class: 'btn btn--block', text: tr('学生预览模式','Student preview'), on: { click: () => { if (state.questions.length === 0) { toast(tr('还没有题目。','No questions yet.'), { type: 'error' }); return; } state.preview = !state.preview; state.previewAnswers = {}; previewBtn.textContent = state.preview ? tr('← 返回编辑','← Back to edit') : tr('学生预览模式','Student preview'); paint(); }, }, }); listHost = el('div', { class: 'tool__col' }); const layout = toolLayout({ badge: '工具 07', settings: [ panel(tr('测验信息','Quiz info'), [ el('label', { class: 'field' }, [ el('span', { class: 'field__label', text: tr('测验标题','Quiz title') }), titleInput, ]), el('label', { class: 'field' }, [ el('span', { class: 'field__label', text: tr('说明','Instructions') }), introInput, ]), el('div', { class: 'field' }, [ el('label', { class: 'checkbox' }, [ el('input', { type: 'checkbox', checked: true, on: { change: (e) => { state.showExplain = e.target.checked; } }, }), tr('提交后显示答案解析','Show explanation after submit'), ]), ]), el('div', { class: 'field' }, [ el('label', { class: 'checkbox' }, [ el('input', { type: 'checkbox', checked: true, on: { change: (e) => { state.allowRetry = e.target.checked; } }, }), tr('允许重做','Allow retry'), ]), ]), ]), panel(tr('题目','Questions'), [ el('div', { class: 'panel__actions' }, [ el('button', { type: 'button', class: 'btn btn--primary btn--sm', text: tr('+ 单选题','+ Single choice'), on: { click: () => { state.questions.push(blankQuestion('single')); state.preview = false; paint(); } }, }), el('button', { type: 'button', class: 'btn btn--sm', text: tr('+ 多选题','+ Multiple choice'), on: { click: () => { state.questions.push(blankQuestion('multi')); state.preview = false; paint(); } }, }), el('button', { type: 'button', class: 'btn btn--sm', text: tr('+ 判断题','+ True/False'), on: { click: () => { state.questions.push(blankQuestion('judge')); state.preview = false; paint(); } }, }), ]), actions([previewBtn]), actions([ el('button', { type: 'button', class: 'btn', text: tr('导入 JSON','Import JSON'), on: { click: () => document.getElementById('quiz-file').click() }, }), el('button', { type: 'button', class: 'btn btn--link is-danger', text: tr('清空','Clear all'), on: { click: async () => { if (state.questions.length === 0) return; const ok = await confirmDanger({ title: tr('清空全部题目?','Clear all questions?'), body: tr('将删除当前 ','Will delete ')+`${state.questions.length} `+tr('道题。建议先「导出题库 JSON」留备份。','questions. Export the JSON as backup first.'), confirmText: tr('清空','Clear'), }); if (ok) { state.questions = []; paint(); } }, }, }), ]), jsonFile, ]), ], main: listHost, aside: [ panel(tr('导出','Export'), [ actions([ el('button', { type: 'button', class: 'btn btn--primary btn--block', text: tr('导出学生答题版 HTML','Export student HTML'), on: { click: exportStudentHtml }, }), ]), actions([ el('button', { type: 'button', class: 'btn btn--block', text: tr('导出题库 JSON','Export question bank JSON'), on: { click: exportJson } }), ]), actions([ el('button', { type: 'button', class: 'btn btn--block', text: tr('保存到本机','Save locally'), on: { click: async () => { if (state.questions.length === 0) { toast(tr('还没有题目。','No questions yet.'), { type: 'error' }); return; } state.id ??= uid('quiz'); await storage.put('quizzes', { id: state.id, title: state.title, intro: state.intro, showExplain: state.showExplain, allowRetry: state.allowRetry, questions: state.questions, updatedAt: new Date().toISOString(), }); toast(tr('已保存到本机。','Saved locally.'), { type: 'success' }); }, }, }), ]), ]), panel(tr('关于答案可见性','About answer visibility'), [ el('p', { class: 'panel__desc', text: tr('学生答题版为了在本地判分,必须把正确答案写进文件里,查看源码即可看到。适合课堂练习与课后自测,不适合正式考试——正式考试请用打印卷面。','The student version embeds correct answers for local scoring (visible in source). Good for practice and self-tests, not formal exams — use printed papers for those.'), }), ], { tone: 'accent' }), ], }); paint(); return layout; } onLangChange(() => location.reload()); startTool(build); return { }; })(); })();