课堂小测验生成器

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

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

会提前结束脚本块 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/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 TYPES = { single: '单选题', multi: '多选题', judge: '判断题' }; const state = { id: null, title: '课堂小测验', 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': `第 ${index + 1} 题题型`, on: { change: (e) => { q.type = e.target.value; if (q.type === 'judge') { q.options = ['正确', '错误']; 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: `选项 ${String.fromCharCode(65 + oi)}`, disabled: q.type === 'judge', 'aria-label': `第 ${index + 1} 题选项 ${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': `把选项 ${String.fromCharCode(65 + oi)} 设为正确答案`, 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: `第 ${index + 1} 题` }), el('div', { class: 'panel__actions' }, [ typeSelect, el('button', { type: 'button', class: 'btn btn--sm', text: '↑', 'aria-label': `上移第 ${index + 1} 题`, disabled: index === 0, on: { click: () => { move(index, index - 1); } }, }), el('button', { type: 'button', class: 'btn btn--sm', text: '↓', 'aria-label': `下移第 ${index + 1} 题`, disabled: index === state.questions.length - 1, on: { click: () => { move(index, index + 1); } }, }), el('button', { type: 'button', class: 'btn btn--sm', text: '复制', 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: '删除', on: { click: () => { state.questions.splice(index, 1); paint(); } }, }), ]), ]), el('label', { class: 'field' }, [ el('span', { class: 'field__label', text: '题干' }), (editor.textEl = el('textarea', { class: 'textarea', rows: '2', value: q.text, placeholder: '输入题目内容', on: { input: (e) => { q.text = e.target.value; } }, })), ]), el('div', { class: 'field' }, [ el('span', { class: 'field__label', text: '选项(勾选正确答案)' }), ...optionRows, q.type !== 'judge' && q.options.length < 8 ? el('button', { type: 'button', class: 'btn btn--sm', text: '+ 添加选项', on: { click: () => { q.options.push(''); paint(); } }, }) : null, ]), el('label', { class: 'field' }, [ el('span', { class: 'field__label', text: '答案解析(可选)' }), (editor.explainEl = el('input', { class: 'input', type: 'text', value: q.explain, placeholder: '提交后显示给学生', 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: `第 ${i + 1} 题 · ${TYPES[q.type]}${q.type === 'multi' ? '(全对才得分)' : ''}` }), el('p', { class: 'q__text', text: q.text || '(题干为空)' }), ...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 || '(空选项)' }), ])), ])); return [ el('p', { class: 'stage__hint', text: '学生预览模式:这就是学生打开导出文件后看到的样子。' }), ...cards, el('div', { class: 'card__actions' }, [ el('button', { type: 'button', class: 'btn btn--primary', text: '试算得分', on: { click: () => { const r = gradeQuiz(state.questions, state.previewAnswers); toast( `${r.right} / ${r.total} 题正确${r.unanswered ? `,${r.unanswered} 题未作答(不计为答错)` : ''}。`, { 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: '还没有题目' }), el('p', { class: 'stage__hint', text: '点左侧「+ 添加题目」开始出题。' }), ])); 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(`第 ${i + 1} 题:题干为空`); if (q.options.length < 2) problems.push(`第 ${i + 1} 题:至少需要 2 个有内容的选项`); if (q.correct.length === 0) { // 正确答案指向的选项被当成空选项丢掉了,也会落到这里 problems.push(`第 ${i + 1} 题:还没有指定正确答案(或所选的正确选项内容为空)`); } }); return problems; } async function exportStudentHtml() { if (state.questions.length === 0) { toast('还没有题目。', { 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: '题目还有问题需要处理', body: el('div', {}, [ el('p', { text: '以下问题会影响学生作答或判分:' }), el('ul', {}, problems.slice(0, 8).map((p) => el('li', { text: p }))), problems.length > 8 ? el('p', { text: `…以及另外 ${problems.length - 8} 处。` }) : null, ]), confirmText: '知道了', cancelText: '关闭', }); return; } // 答案内嵌是本地判分的前提,必须如实告知(文档 11.7) const ok = await dialog({ title: '导出前请确认', confirmText: '我明白,继续导出', body: el('div', {}, [ el('p', {}, [ el('strong', { text: '这个文件里包含正确答案。' }), ' 学生查看网页源码就能看到——这是本地判分必需的,无法避免。', ]), el('p', { text: '所以:适合课堂练习和课后自测,不适合正式考试。正式考试请用打印卷面。' }), el('p', { text: '文件不依赖网络,学生双击即可打开作答,答题数据只留在他自己的设备上。' }), ]), }); if (!ok) return; const html = buildStudentHtml({ ...state, questions }); downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8' }), safeFilename(`${state.title}-学生版.html`)); toast( `已导出学生答题版 HTML${dropped ? `(自动忽略了 ${dropped} 个未填写的空选项)` : ''}。`, { type: 'success', duration: dropped ? 6000 : 3200 }, ); } function exportJson() { if (state.questions.length === 0) { toast('还没有题目。', { 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('已导出题库 JSON。', { 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() || '课堂小测验'; } }, }); const introInput = el('textarea', { class: 'textarea', rows: '2', placeholder: '给学生的说明(可选)', 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('文件里没有找到题目列表。'); state.title = data.title ?? '课堂小测验'; 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(`已导入 ${state.questions.length} 道题。`, { type: 'success' }); } catch (err) { toast(`导入失败:${err.message}`, { type: 'error' }); } e.target.value = ''; }, }, }); const previewBtn = el('button', { type: 'button', class: 'btn btn--block', text: '学生预览模式', on: { click: () => { if (state.questions.length === 0) { toast('还没有题目。', { type: 'error' }); return; } state.preview = !state.preview; state.previewAnswers = {}; previewBtn.textContent = state.preview ? '← 返回编辑' : '学生预览模式'; paint(); }, }, }); listHost = el('div', { class: 'tool__col' }); const layout = toolLayout({ badge: '工具 07', settings: [ panel('测验信息', [ el('label', { class: 'field' }, [ el('span', { class: 'field__label', text: '测验标题' }), titleInput, ]), el('label', { class: 'field' }, [ el('span', { class: 'field__label', text: '说明' }), introInput, ]), el('div', { class: 'field' }, [ el('label', { class: 'checkbox' }, [ el('input', { type: 'checkbox', checked: true, on: { change: (e) => { state.showExplain = e.target.checked; } }, }), '提交后显示答案解析', ]), ]), el('div', { class: 'field' }, [ el('label', { class: 'checkbox' }, [ el('input', { type: 'checkbox', checked: true, on: { change: (e) => { state.allowRetry = e.target.checked; } }, }), '允许重做', ]), ]), ]), panel('题目', [ el('div', { class: 'panel__actions' }, [ el('button', { type: 'button', class: 'btn btn--primary btn--sm', text: '+ 单选题', on: { click: () => { state.questions.push(blankQuestion('single')); state.preview = false; paint(); } }, }), el('button', { type: 'button', class: 'btn btn--sm', text: '+ 多选题', on: { click: () => { state.questions.push(blankQuestion('multi')); state.preview = false; paint(); } }, }), el('button', { type: 'button', class: 'btn btn--sm', text: '+ 判断题', on: { click: () => { state.questions.push(blankQuestion('judge')); state.preview = false; paint(); } }, }), ]), actions([previewBtn]), actions([ el('button', { type: 'button', class: 'btn', text: '导入 JSON', on: { click: () => document.getElementById('quiz-file').click() }, }), el('button', { type: 'button', class: 'btn btn--link is-danger', text: '清空', on: { click: async () => { if (state.questions.length === 0) return; const ok = await confirmDanger({ title: '清空全部题目?', body: `将删除当前 ${state.questions.length} 道题。建议先「导出题库 JSON」留备份。`, confirmText: '清空', }); if (ok) { state.questions = []; paint(); } }, }, }), ]), jsonFile, ]), ], main: listHost, aside: [ panel('导出', [ actions([ el('button', { type: 'button', class: 'btn btn--primary btn--block', text: '导出学生答题版 HTML', on: { click: exportStudentHtml }, }), ]), actions([ el('button', { type: 'button', class: 'btn btn--block', text: '导出题库 JSON', on: { click: exportJson } }), ]), actions([ el('button', { type: 'button', class: 'btn btn--block', text: '保存到本机', on: { click: async () => { if (state.questions.length === 0) { toast('还没有题目。', { 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('已保存到本机。', { type: 'success' }); }, }, }), ]), ]), panel('关于答案可见性', [ el('p', { class: 'panel__desc', text: '学生答题版为了在本地判分,必须把正确答案写进文件里,查看源码即可看到。适合课堂练习与课后自测,不适合正式考试——正式考试请用打印卷面。', }), ], { tone: 'accent' }), ], }); paint(); return layout; } startTool(build); return { }; })(); })();