调查问卷

在线加密问卷:扫码填写,回答在浏览器端加密后汇总。

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

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

${esc(survey.title)}

${esc(survey.intro || '请如实填写,填完后按提示把回答交给老师。')}

`; } return { optionCount: optionCount, isAnswered: isAnswered, summarize: summarize, share: share, optionLabel: optionLabel, answerText: answerText, responseRows: responseRows, summaryRows: summaryRows, toCompact: toCompact, fromCompact: fromCompact, encodeAnswerCode: encodeAnswerCode, decodeAnswerCode: decodeAnswerCode, makeReceipt: makeReceipt, parseReceipts: parseReceipts, buildSurveyHtml: buildSurveyHtml, SURVEY_TYPES: SURVEY_TYPES, CODE_PREFIX: CODE_PREFIX, DEFAULT_SCALE: DEFAULT_SCALE }; })(); /* 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/survey/index.js */ const _m_assets_js_tools_survey_index = (function () { /* 调查问卷(教学反馈 / 课堂调查) 和课堂小测的分工:小测关心「答得对不对」,问卷关心「大家怎么想」—— 所以这里没有正确答案,重点全在把回答收回来并汇总。 站点没有后端,回答只能靠三条本地路径(见 core/survey.js 开头): 举手计票、本机作答、学生填写版 HTML + 回答码/回答文件。 */ 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 enterFullscreen = _m_assets_js_core_fullscreen.enterFullscreen; const bindFullscreenHotkey = _m_assets_js_core_fullscreen.bindFullscreenHotkey; const isActive = _m_assets_js_core_fullscreen.isActive; const drawBarChart = _m_assets_js_core_chart.drawBarChart; const chartToBlob = _m_assets_js_core_chart.chartToBlob; const toCSV = _m_assets_js_core_csv.toCSV; 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 SURVEY_TYPES = _m_assets_js_core_survey.SURVEY_TYPES; const DEFAULT_SCALE = _m_assets_js_core_survey.DEFAULT_SCALE; const optionCount = _m_assets_js_core_survey.optionCount; const optionLabel = _m_assets_js_core_survey.optionLabel; const isAnswered = _m_assets_js_core_survey.isAnswered; const summarize = _m_assets_js_core_survey.summarize; const share = _m_assets_js_core_survey.share; const responseRows = _m_assets_js_core_survey.responseRows; const summaryRows = _m_assets_js_core_survey.summaryRows; const buildSurveyHtml = _m_assets_js_core_survey.buildSurveyHtml; const decodeAnswerCode = _m_assets_js_core_survey.decodeAnswerCode; const parseReceipts = _m_assets_js_core_survey.parseReceipts; const tr = _m_assets_js_core_i18n.tr; const onLangChange = _m_assets_js_core_i18n.onLangChange; const MODES = [ { key: 'edit', label: tr('编辑问卷','Edit') }, { key: 'tally', label: tr('举手计票','Tally') }, { key: 'answer', label: tr('本机作答','Answer here') }, { key: 'result', label: tr('结果汇总','Results') }, { key: 'paper', label: tr('纸质问卷','Paper') }, ]; const state = { id: null, title: tr('课堂调查问卷','Class survey'), intro: '', questions: [], responses: [], // [{ id, at, answers: { [qid]: value } }] tallies: {}, // 举手计票:题 id → 各选项票数 mode: 'edit', draft: {}, // 本机作答中还没提交的一份 tallyAt: 0, // 计票模式停在第几题 }; let mainHost = null; let modeBar = null; let responseCountEl = null; /* 和小测验一样的保命机制:整列表重渲染前,先把输入框里的内容回写进 state。只要有一次 input 事件没落地(输入法组合、粘贴、自动填充), 老师敲进去的题干就会凭空消失。 */ let liveEditors = []; function commitEdits() { for (const ed of liveEditors) { if (ed.textEl?.isConnected) ed.q.text = ed.textEl.value; if (ed.lowEl?.isConnected) ed.q.lowLabel = ed.lowEl.value; if (ed.highEl?.isConnected) ed.q.highLabel = ed.highEl.value; ed.optionEls.forEach((input, i) => { if (input?.isConnected && ed.q.options[i] !== undefined) ed.q.options[i] = input.value; }); } } function blankQuestion(type) { return { id: uid('sq'), type, text: '', required: false, options: type === 'single' || type === 'multi' ? ['', '', ''] : [], scale: DEFAULT_SCALE, lowLabel: tr('很不满意','Very unsatisfied'), highLabel: tr('非常满意','Very satisfied'), }; } /* 取某题的票数数组。 关键:数组长度对得上时必须返回**同一个**数组引用 —— 计票按钮是闭包着 这个数组在加减的,每次调用都换一个新数组的话,按钮改的就是一个已经 从 state 里掉队的副本,界面上数字在涨、存下来的票数却停在第一次。 */ function tallyOf(q) { const size = optionCount(q); const cur = state.tallies[q.id]; if (Array.isArray(cur) && cur.length === size) { for (let i = 0; i < size; i += 1) cur[i] = Number(cur[i]) || 0; // 导入的数据可能是字符串 return cur; } const next = new Array(size).fill(0); if (Array.isArray(cur)) cur.forEach((n, i) => { if (i < size) next[i] = Number(n) || 0; }); state.tallies[q.id] = next; return next; } const hasQuestions = () => state.questions.length > 0; function requireQuestions() { if (hasQuestions()) return true; toast(tr('还没有题目,先在左边添加。','No questions yet. Add some on the left.'), { type: 'error' }); return false; } /* -------------------------------------------------------------------------- 编辑 -------------------------------------------------------------------------- */ function questionCard(q, index) { const editor = { q, textEl: null, lowEl: null, highEl: null, optionEls: [] }; 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 === 'single' || q.type === 'multi') && q.options.length < 2) { q.options = ['', '', '']; } delete state.tallies[q.id]; // 题型换了,旧票数对不上选项 paint(); }, }, }, Object.entries(SURVEY_TYPES).map(([v, label]) => el('option', { value: v, text: label, selected: q.type === v }))); const optionRows = (q.type === 'single' || q.type === 'multi') ? q.options.map((opt, oi) => { const input = el('input', { class: 'input', type: 'text', value: opt, placeholder: tr('选项 ','Option ')+`${String.fromCharCode(65+oi)}`, 'aria-label': tr('第 ','Q ')+`${index+1} `+tr('题选项','option ')+`${String.fromCharCode(65+oi)}`, on: { input: (e) => { q.options[oi] = e.target.value; } }, }); editor.optionEls[oi] = input; return el('div', { class: 'quiz-option' }, [ el('span', { class: 'quiz-option__letter', text: String.fromCharCode(65 + oi) }), input, q.options.length > 2 ? el('button', { type: 'button', class: 'member__lock', text: '✕', 'aria-label': tr('删除选项 ','Delete option ')+`${String.fromCharCode(65+oi)}`, on: { click: () => { q.options.splice(oi, 1); delete state.tallies[q.id]; paint(); }, }, }) : null, ]); }) : []; const scaleRow = q.type === 'rating' ? el('div', { class: 'field' }, [ el('span', { class: 'field__label', text: tr('量表与两端说明','Scale and end labels') }), el('div', { class: 'survey-scale-row' }, [ el('select', { class: 'select', 'aria-label': tr('第 ','Q ')+`${index+1} `+tr('题量表档数','scale points'), on: { change: (e) => { q.scale = Number(e.target.value); delete state.tallies[q.id]; paint(); }, }, }, [3, 4, 5, 6, 10].map((n) => el('option', { value: String(n), text: `${n} `+tr('档','pts'), selected: (q.scale ?? DEFAULT_SCALE) === n }))), (editor.lowEl = el('input', { class: 'input', type: 'text', value: q.lowLabel ?? '', placeholder: tr('最低分的说法','Lowest label'), 'aria-label': tr('第 ','Q ')+`${index+1} `+tr('题最低分说明','lowest label'), on: { input: (e) => { q.lowLabel = e.target.value; } }, })), (editor.highEl = el('input', { class: 'input', type: 'text', value: q.highLabel ?? '', placeholder: tr('最高分的说法','Highest label'), 'aria-label': tr('第 ','Q ')+`${index+1} `+tr('题最高分说明','highest label'), on: { input: (e) => { q.highLabel = e.target.value; } }, })), ]), ]) : null; 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: () => { commitEdits(); state.questions.splice(index + 1, 0, { ...JSON.parse(JSON.stringify(q)), id: uid('sq') }); paint(); }, }, }), el('button', { type: 'button', class: 'btn btn--sm btn--link is-danger', text: tr('删除','Delete'), on: { click: () => { commitEdits(); delete state.tallies[q.id]; 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('例如:这节课的节奏你觉得怎么样?','e.g. How was the pace of this class?'), on: { input: (e) => { q.text = e.target.value; } }, })), ]), optionRows.length ? el('div', { class: 'field' }, [ el('span', { class: 'field__label', text: tr('选项','Options') }), ...optionRows, q.options.length < 10 ? el('button', { type: 'button', class: 'btn btn--sm', text: tr('+ 添加选项','+ Add option'), on: { click: () => { commitEdits(); q.options.push(''); delete state.tallies[q.id]; paint(); }, }, }) : null, ]) : null, scaleRow, q.type === 'text' ? el('p', { class: 'field__hint', text: tr('简答题只能由学生自己填写,无法举手计票。','Text questions must be filled by students; cannot tally by show of hands.') }) : null, el('div', { class: 'field' }, [ el('label', { class: 'checkbox' }, [ el('input', { type: 'checkbox', checked: q.required, on: { change: (e) => { q.required = e.target.checked; } }, }), tr('必答题(学生填写版会拦住空着不填的人)','Required (the student version blocks empty answers)'), ]), ]), ]); } function move(from, to) { if (to < 0 || to >= state.questions.length) return; commitEdits(); const [item] = state.questions.splice(from, 1); state.questions.splice(to, 0, item); paint(); } function editNodes() { if (!hasQuestions()) { return el('div', { class: 'stage' }, [ el('p', { class: 'stage__result stage__result--placeholder', text: tr('还没有题目','No questions yet') }), el('p', { class: 'stage__hint', text: tr('在左边点「+ 单选题」开始,或先导入一份问卷 JSON。','Click + Single choice on the left, or import a survey JSON.') }), ]); } return state.questions.map(questionCard); } /* -------------------------------------------------------------------------- 举手计票 -------------------------------------------------------------------------- */ /** 计票用的一题:每个选项一行,点一下加一票 */ function tallyQuestion(q, opts = {}) { const big = Boolean(opts.big); const counts = tallyOf(q); const valueEls = []; const rows = []; for (let i = 0; i < optionCount(q); i += 1) { const value = el('span', { class: 'survey-tally__count', text: String(counts[i]) }); valueEls[i] = value; const bump = (delta) => { counts[i] = Math.max(0, counts[i] + delta); value.textContent = String(counts[i]); if (!big) refreshTallyTotal(); }; rows.push(el('div', { class: 'survey-tally__row' }, [ el('span', { class: 'quiz-option__letter', text: q.type === 'rating' ? String(i + 1) : String.fromCharCode(65 + i) }), el('span', { class: 'survey-tally__label', text: optionLabel(q, i) || tr('(选项 ','(option ')+`${String.fromCharCode(65+i)} `+tr('还没写内容)','empty)') }), value, el('button', { type: 'button', class: 'btn btn--sm', text: '−', 'aria-label': (optionLabel(q,i)||tr('这个选项','this option'))+' '+tr('减一票','-1'), on: { click: () => bump(-1) }, }), el('button', { type: 'button', class: `btn btn--primary${big ? ' btn--stage' : ' btn--sm'}`, text: '+1', 'aria-label': (optionLabel(q,i)||tr('这个选项','this option'))+' '+tr('加一票','+1'), on: { click: () => bump(1) }, }), ])); } return { node: el('div', { class: `survey-tally${big ? ' survey-tally--big' : ''}` }, rows), valueEls }; } let tallyTotalEl = null; function refreshTallyTotal() { if (!tallyTotalEl) return; const q = state.questions[state.tallyAt]; const total = tallyOf(q).reduce((a, b) => a + b, 0); tallyTotalEl.textContent = tr('本题已计 ','Tallied ')+`${total} `+tr('票','votes')+tr(' 本题',' for this question'); } function tallyNodes() { if (!hasQuestions()) { return el('div', { class: 'stage' }, [ el('p', { class: 'stage__result stage__result--placeholder', text: tr('还没有题目','No questions yet') }), el('p', { class: 'stage__hint', text: tr('举手计票需要先有题目。','Need questions before tallying.') }), ]); } state.tallyAt = Math.min(state.tallyAt, state.questions.length - 1); const q = state.questions[state.tallyAt]; if (q.type === 'text') { return el('div', { class: 'card' }, [ el('div', { class: 'q__no', text: tr('第 ','Q ')+`${state.tallyAt+1} · `+tr('简答题','Text') }), el('p', { class: 'q__text', text: q.text || tr('(题目还没写)','(question not written)') }), el('p', { class: 'stage__hint', text: tr('简答题没法举手统计,请用「本机作答」录入,或让学生用填写版交回来。','Text questions cannot be tallied by hands; use Answer here or the student version.') }), tallyNav(), ]); } tallyTotalEl = el('p', { class: 'stage__hint' }); const { node } = tallyQuestion(q); refreshTallyTotal(); return el('div', { class: 'card' }, [ el('div', { class: 'q__no', text: tr('第 ','Q ')+`${state.tallyAt+1} / ${state.questions.length} · ${SURVEY_TYPES[q.type]}` }), el('p', { class: 'q__text', text: q.text || tr('(题目还没写)','(question not written)') }), node, tallyTotalEl, tallyNav(), ]); } function tallyNav() { return el('div', { class: 'panel__actions' }, [ el('button', { type: 'button', class: 'btn btn--sm', text: tr('← 上一题','← Previous'), disabled: state.tallyAt === 0, on: { click: () => { state.tallyAt -= 1; paint(); } }, }), el('button', { type: 'button', class: 'btn btn--sm', text: tr('下一题 →','Next →'), disabled: state.tallyAt >= state.questions.length - 1, on: { click: () => { state.tallyAt += 1; paint(); } }, }), el('button', { type: 'button', class: 'btn btn--sm btn--link is-danger', text: tr('清零本题票数','Reset this question'), on: { click: () => { delete state.tallies[state.questions[state.tallyAt].id]; paint(); }, }, }), ]); } function projectTally() { if (!requireQuestions()) return; const q = state.questions[state.tallyAt]; if (q.type === 'text') { toast(tr('简答题没法举手计票。','Text questions cannot be tallied.'), { type: 'error' }); return; } const { node } = tallyQuestion(q, { big: true }); enterFullscreen({ title: tr('第 ','Q ')+`${state.tallyAt+1}`, content: el('div', { class: 'survey-fs' }, [ el('p', { class: 'survey-fs__question', text: q.text || tr('(题目还没写)','(question not written)') }), node, ]), onExit: () => paint(), // 投影时按的票要回到页面上 }); } /* -------------------------------------------------------------------------- 本机作答(平板传着填 / 老师照纸质问卷录入) -------------------------------------------------------------------------- */ function answerNodes() { if (!hasQuestions()) { return el('div', { class: 'stage' }, [ el('p', { class: 'stage__result stage__result--placeholder', text: tr('还没有题目','No questions yet') }), el('p', { class: 'stage__hint', text: tr('先编辑问卷,再回来作答。','Edit the survey first, then come back to answer.') }), ]); } const cards = state.questions.map((q, i) => { const body = []; if (q.type === 'text') { body.push(el('textarea', { class: 'textarea', rows: '3', value: state.draft[q.id] ?? '', placeholder: tr('写下想法','Write your thoughts'), on: { input: (e) => { state.draft[q.id] = e.target.value; } }, })); } else { for (let oi = 0; oi < optionCount(q); oi += 1) { const chosen = q.type === 'multi' ? (state.draft[q.id] ?? []).includes(oi) : state.draft[q.id] === oi; body.push(el('label', { class: 'quiz-option' }, [ el('input', { type: q.type === 'multi' ? 'checkbox' : 'radio', name: `answer-${q.id}`, checked: chosen, on: { change: (e) => { if (q.type === 'multi') { const cur = state.draft[q.id] ?? []; 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.draft[q.id] = cur; } else { state.draft[q.id] = oi; } }, }, }), el('span', { class: 'quiz-option__letter', text: q.type === 'rating' ? String(oi + 1) : String.fromCharCode(65 + oi) }), el('span', { text: optionLabel(q, oi) || tr('(空选项)','(empty option)') }), ])); } if (q.type === 'rating') { body.push(el('p', { class: 'field__hint', text: `1 = ${q.lowLabel || tr('最低','low')} ,${optionCount(q)} = ${q.highLabel || tr('最高','high')}`, })); } } return el('div', { class: 'card' }, [ el('div', { class: 'q__no', text: tr('第 ','Q ')+`${i+1} · ${SURVEY_TYPES[q.type]}${q.required ? ' · '+tr('必答','required') : ''}` }), el('p', { class: 'q__text', text: q.text || tr('(题目还没写)','(question not written)') }), ...body, ]); }); return [ el('p', { class: 'stage__hint', text: tr('这就是学生看到的样子。填完点「提交这一份」,把设备交给下一位。','This is what students see. Fill in, click Submit, then pass the device to the next person.') }), ...cards, el('div', { class: 'card__actions' }, [ el('button', { type: 'button', class: 'btn btn--primary', text: tr('提交这一份','Submit this one'), on: { click: submitDraft }, }), el('button', { type: 'button', class: 'btn', text: tr('清空这一份','Clear this one'), on: { click: () => { state.draft = {}; paint(); } }, }), ]), ]; } function submitDraft() { const missing = state.questions .map((q, i) => (q.required && !isAnswered(q, state.draft[q.id]) ? i + 1 : null)) .filter((n) => n !== null); if (missing.length) { toast(tr('第 ','Q ')+`${missing.join('、')} `+tr('题是必答题,还没填。','are required and not answered.'), { type: 'error' }); return; } if (!state.questions.some((q) => isAnswered(q, state.draft[q.id]))) { toast(tr('这一份还什么都没填。','Nothing answered yet.'), { type: 'error' }); return; } addResponses([{ at: new Date().toISOString(), answers: { ...state.draft } }]); state.draft = {}; paint(); toast(tr('已记下第 ','Recorded response #')+`${state.responses.length},`+tr('可以交给下一位了。','ready for the next person.'), { type: 'success' }); } function addResponses(list) { for (const r of list) { state.responses.push({ id: uid('sr'), at: r.at, answers: r.answers }); } refreshResponseCount(); } function refreshResponseCount() { if (responseCountEl) responseCountEl.textContent = tr('已收到 ','Received ')+`${state.responses.length} `+tr('份回答','responses'); } /* -------------------------------------------------------------------------- 结果汇总 -------------------------------------------------------------------------- */ function statCard(q, stat, index) { const rows = []; if (q.type === 'text') { if (stat.texts.length === 0) { rows.push(el('p', { class: 'stage__hint', text: tr('还没有人写。','No one wrote anything yet.') })); } else { rows.push(el('ul', { class: 'survey-texts' }, stat.texts.map((t) => el('li', { text: t })))); } } else { for (let oi = 0; oi < stat.size; oi += 1) { const pct = share(stat, oi); const fill = el('span', { class: 'survey-bar__fill' }); // 宽度只能走 CSSOM:行内 style 属性会被 CSP 拦掉(见 dom.js 的注释) fill.style.width = `${stat.max > 0 ? (stat.counts[oi] / stat.max) * 100 : 0}%`; rows.push(el('div', { class: 'survey-bar' }, [ el('span', { class: 'survey-bar__label' }, [ el('span', { class: 'quiz-option__letter', text: q.type === 'rating' ? String(oi + 1) : String.fromCharCode(65 + oi) }), el('span', { text: optionLabel(q, oi) || tr('(空选项)','(empty option)') }), ]), el('span', { class: 'survey-bar__track' }, [fill]), el('span', { class: 'survey-bar__value', text: pct === null ? `${stat.counts[oi]} `+tr('票','votes') : `${stat.counts[oi]} `+tr('票 · ','votes · ')+`${(pct*100).toFixed(0)}%`, }), ])); } } const meta = []; if (q.type === 'rating' && stat.average !== null) { meta.push(tr('平均 ','Average ')+`${stat.average.toFixed(2)} `+tr('分','pts')); } if (stat.respondents > 0) meta.push(`${stat.respondents} `+tr('份回答','responses')); if (stat.tallyVotes > 0) meta.push(tr('举手计票 ','Tally ')+`${stat.tallyVotes} `+tr('票','votes')); if (q.type === 'multi' && stat.base === null && stat.votes > 0) { meta.push(tr('举手计票下一人可投多票,无法换算人数,故只报票数','In tally mode one person can vote multiple times; cannot convert to people, so only votes are reported.')); } return el('div', { class: 'card' }, [ el('div', { class: 'q__no', text: tr('第 ','Q ')+`${index+1} · ${SURVEY_TYPES[q.type]}` }), el('p', { class: 'q__text', text: q.text || tr('(题目还没写)','(question not written)') }), ...rows, meta.length ? el('p', { class: 'field__hint', text: meta.join(' · ') }) : null, q.type === 'text' || stat.votes === 0 ? null : el('div', { class: 'panel__actions no-print' }, [ el('button', { type: 'button', class: 'btn btn--sm', text: tr('导出这题的图表 PNG','Export chart PNG for this Q'), on: { click: () => exportQuestionChart(q, stat, index) }, }), ]), ]); } function resultNodes() { if (!hasQuestions()) { return el('div', { class: 'stage' }, [ el('p', { class: 'stage__result stage__result--placeholder', text: tr('还没有题目','No questions yet') }), ]); } const stats = summarize(state.questions, state.responses, state.tallies); const anything = stats.some((s) => s.votes > 0 || s.texts.length > 0); if (!anything) { return el('div', { class: 'stage' }, [ el('p', { class: 'stage__result stage__result--placeholder', text: tr('还没有任何回答','No responses yet') }), el('p', { class: 'stage__hint', text: tr('可以去「举手计票」现场统计,或用「本机作答」录入,也可以导出学生填写版收回来。','Tally live, enter via Answer here, or export the student version and collect responses.') }), ]); } return [ el('p', { class: 'stage__hint', text: tr('共 ','Total ')+`${state.responses.length} `+tr('份回答,另有举手计票并入统计。','responses, plus hand votes included.') }), ...state.questions.map((q, i) => statCard(q, stats[i], i)), ]; } async function exportQuestionChart(q, stat, index) { const canvas = document.createElement('canvas'); canvas.width = 720; canvas.height = 360; drawBarChart(canvas, { labels: Array.from({ length: stat.size }, (_, i) => (q.type === 'rating' ? String(i + 1) : String.fromCharCode(65 + i))), values: stat.counts, captions: Array.from({ length: stat.size }, (_, i) => { const label = optionLabel(q, i); return label.length > 6 ? `${label.slice(0, 6)}…` : label; }), }, { yLabel: tr('票数','Votes') }); const blob = await chartToBlob(canvas); if (!blob) { toast(tr('导出图片失败,可以改用打印。','Image export failed; try printing.'), { type: 'error' }); return; } downloadBlob(blob, safeFilename(`${state.title}-`+tr('第','Q')+`${index+1}`+tr('题','')+'.png')); toast(tr('已导出图表 PNG。','Chart PNG exported.'), { type: 'success' }); } /* -------------------------------------------------------------------------- 纸质问卷 -------------------------------------------------------------------------- */ function paperNodes() { if (!hasQuestions()) { return el('div', { class: 'stage' }, [ el('p', { class: 'stage__result stage__result--placeholder', text: tr('还没有题目','No questions yet') }), ]); } const questions = state.questions.map((q, i) => { const body = []; if (q.type === 'text') { body.push(el('div', { class: 'survey-write' }), el('div', { class: 'survey-write' })); } else { const boxes = []; for (let oi = 0; oi < optionCount(q); oi += 1) { boxes.push(el('span', { class: 'survey-paper__opt' }, [ el('span', { class: 'survey-paper__box', 'aria-hidden': 'true', text: q.type === 'multi' ? '□' : '○' }), el('span', { text: q.type === 'rating' ? String(oi + 1) : optionLabel(q, oi) || tr('选项 ','Option ')+`${String.fromCharCode(65+oi)}` }), ])); } body.push(el('div', { class: 'survey-paper__opts' }, boxes)); if (q.type === 'rating') { body.push(el('p', { class: 'field__hint', text: `1 = ${q.lowLabel || tr('最低','low')} ,${optionCount(q)} = ${q.highLabel || tr('最高','high')}`, })); } } return el('div', { class: 'survey-paper__q' }, [ el('p', { class: 'q__text', text: `${i+1}. ${q.text || tr('(题目还没写)','(question not written)')}${q.required ? tr('(必答)','(required)') : ''}` }), ...body, ]); }); return el('div', { class: 'card survey-paper' }, [ el('h2', { class: 'card__title', text: state.title }), state.intro ? el('p', { class: 'card__desc', text: state.intro }) : null, ...questions, el('p', { class: 'field__hint no-print', text: tr('这一页就是打印出来的样子(页眉页脚和左右面板不会打印)。','This is how it looks printed (headers/footers and side panels are not printed).') }), ]); } /* -------------------------------------------------------------------------- 渲染 -------------------------------------------------------------------------- */ function paint() { if (!mainHost) return; commitEdits(); liveEditors = []; tallyTotalEl = null; for (const btn of modeBar?.children ?? []) { const on = btn.dataset.mode === state.mode; btn.classList.toggle('is-on', on); btn.setAttribute('aria-pressed', String(on)); } if (state.mode === 'tally') render(mainHost, tallyNodes()); else if (state.mode === 'answer') render(mainHost, answerNodes()); else if (state.mode === 'result') render(mainHost, resultNodes()); else if (state.mode === 'paper') render(mainHost, paperNodes()); else render(mainHost, editNodes()); } function setMode(mode) { state.mode = mode; paint(); } /* -------------------------------------------------------------------------- 收回答 -------------------------------------------------------------------------- */ async function pasteCode() { if (!requireQuestions()) return; const input = el('textarea', { class: 'textarea', rows: '4', placeholder: tr('把学生发来的回答码粘在这里,一行一个,可以一次粘多段','Paste students\' answer codes here, one per line, multiple at once'), }); const ok = await dialog({ title: tr('粘贴回答码','Paste answer codes'), confirmText: tr('导入','Import'), body: el('div', {}, [ el('p', { text: tr('学生在填写版里点「复制回答码」得到的那段文字(以 TPLS1. 开头)。','The text students get by clicking “Copy answer code” in the fill-in version (starts with TPLS1.).') }), input, ]), }); if (!ok) return; const codes = input.value.split(/[\n\r]+/).map((s) => s.trim()).filter(Boolean); if (codes.length === 0) { toast(tr('没有粘进任何回答码。','No answer codes pasted.'), { type: 'error' }); return; } const added = []; const problems = []; codes.forEach((code, i) => { try { added.push({ at: new Date().toISOString(), answers: decodeAnswerCode(code, state.questions) }); } catch (err) { problems.push(tr('第 ','Segment ')+`${i+1}:${err.message}`); } }); if (added.length) { addResponses(added); paint(); } toast( tr('导入 ','Imported ')+`${added.length} `+tr('份回答','responses')+(problems.length ? tr(',',',')+`${problems.length} `+tr('段没能识别:','segments failed: ')+`${problems[0]}` : '。'), { type: problems.length ? 'error' : 'success', duration: problems.length ? 8000 : 3200 }, ); } async function importReceipts(files) { if (!requireQuestions()) return; let added = 0; const problems = []; for (const file of files) { try { const raw = JSON.parse(await readFileAsText(file, LIMITS.importBytes)); const list = parseReceipts(raw, state.questions); addResponses(list); added += list.length; } catch (err) { problems.push(`${file.name}:${err.message}`); } } if (added) paint(); toast( added ? tr('已导入 ','Imported ')+`${added} `+tr('份回答','responses')+(problems.length ? tr(',',',')+`${problems.length} `+tr('个文件失败','files failed') : '')+'。' : tr('导入失败:','Import failed: ')+`${problems[0] ?? tr('文件里没有回答','no responses in file')}`, { type: added ? 'success' : 'error', duration: problems.length ? 8000 : 3200 }, ); } /* -------------------------------------------------------------------------- 导出 -------------------------------------------------------------------------- */ function surveyPayload() { commitEdits(); return { title: state.title, intro: state.intro, questions: state.questions, responses: state.responses, tallies: state.tallies, }; } async function exportStudentHtml() { if (!requireQuestions()) return; commitEdits(); const problems = []; state.questions.forEach((q, i) => { if (!q.text.trim()) problems.push(tr('第 ','Q ')+`${i+1}:`+tr('题目为空','question is empty')); if ((q.type === 'single' || q.type === 'multi') && q.options.filter((o) => o.trim()).length < 2) { problems.push(tr('第 ','Q ')+`${i+1}:`+tr('至少要有 2 个填了内容的选项','needs at least 2 filled options')); } }); if (problems.length) { await dialog({ title: tr('问卷还有地方没填完','Survey incomplete'), body: el('div', {}, [ el('p', { text: tr('这些问题会让学生看到空题或空选项:','These issues will show students empty questions or options:') }), 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; } 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 is offline and cannot collect any data.') }), ' '+tr('学生填完后会拿到一段「回答码」和一个「回答文件」,需要他们主动发回给你,你再用「粘贴回答码」或「导入回答文件」并进统计。','After filling in, students get an “answer code” and an “answer file”; they must send them back, and you import them for statistics.'), ]), el('p', { text: tr('也就是说:回答是不是匿名,取决于学生用什么方式发给你。想真匿名,用「举手计票」或纸质问卷更稳妥。','Anonymity depends on how students send responses. For true anonymity, use tally or paper.') }), ]), }); if (!ok) return; const html = buildSurveyHtml({ title: state.title, intro: state.intro, questions: state.questions }); downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8' }), safeFilename(`${state.title}-`+tr('学生填写版','student')+'.html')); toast(tr('已导出学生填写版 HTML。','Student fill-in HTML exported.'), { type: 'success' }); } function exportSummaryCsv() { if (!requireQuestions()) return; const stats = summarize(state.questions, state.responses, state.tallies); downloadBlob( new Blob([toCSV(summaryRows(state.questions, stats))], { type: 'text/csv;charset=utf-8' }), exportFilename(`${state.title}-`+tr('汇总','summary'), 'csv'), ); toast(tr('已导出汇总 CSV。','Summary CSV exported.'), { type: 'success' }); } function exportDetailCsv() { if (state.responses.length === 0) { toast(tr('还没有收到回答(举手计票没有明细)。','No responses received (tally has no details).'), { type: 'error' }); return; } downloadBlob( new Blob([toCSV(responseRows(state.questions, state.responses))], { type: 'text/csv;charset=utf-8' }), exportFilename(`${state.title}-`+tr('明细','details'), 'csv'), ); toast(tr('已导出明细 CSV。','Details CSV exported.'), { type: 'success' }); } function exportJson() { if (!requireQuestions()) return; downloadBlob( new Blob([JSON.stringify(surveyPayload(), null, 2)], { type: 'application/json;charset=utf-8' }), exportFilename(state.title, 'json'), ); toast(tr('已导出问卷 JSON(含已收到的回答)。','Survey JSON exported (with responses).'), { 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 survey'); } }, }); 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: 'survey-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 survey'); state.intro = data.intro ?? ''; state.questions = data.questions.map((q) => ({ ...q, id: q.id ?? uid('sq'), options: Array.isArray(q.options) ? q.options : [], })); state.responses = Array.isArray(data.responses) ? data.responses.map((r) => ({ ...r, id: r.id ?? uid('sr') })) : []; state.tallies = (data.tallies && typeof data.tallies === 'object') ? data.tallies : {}; state.draft = {}; titleInput.value = state.title; introInput.value = state.intro; refreshResponseCount(); setMode('edit'); toast(tr('已导入 ','Imported ')+`${state.questions.length} `+tr('道题、','questions, ')+`${state.responses.length} `+tr('份回答。','responses.'), { type: 'success' }); } catch (err) { toast(tr('导入失败:','Import failed: ')+`${err.message}`, { type: 'error' }); } e.target.value = ''; }, }, }); const receiptFile = el('input', { class: 'visually-hidden', type: 'file', accept: '.json,application/json', multiple: true, id: 'survey-receipts', on: { change: async (e) => { const files = [...(e.target.files ?? [])]; if (files.length) await importReceipts(files); e.target.value = ''; }, }, }); responseCountEl = el('p', { class: 'panel__desc', text: tr('已收到 0 份回答','Received 0 responses') }); modeBar = el('div', { class: 'toolbar__filters no-print', role: 'group', 'aria-label': tr('切换视图','Switch view') }, MODES.map((m) => el('button', { type: 'button', class: `filter-chip${state.mode === m.key ? ' is-on' : ''}`, dataset: { mode: m.key }, 'aria-pressed': String(state.mode === m.key), text: m.label, on: { click: () => setMode(m.key) }, }))); mainHost = el('div', { class: 'tool__col' }); const addButton = (type, label, primary = false) => el('button', { type: 'button', class: `btn btn--sm${primary ? ' btn--primary' : ''}`, text: label, on: { click: () => { commitEdits(); state.questions.push(blankQuestion(type)); setMode('edit'); }, }, }); const layout = toolLayout({ badge: '工具 16', settings: [ panel(tr('问卷信息','Survey info'), [ el('label', { class: 'field' }, [ el('span', { class: 'field__label', text: tr('问卷标题','Survey title') }), titleInput, ]), el('label', { class: 'field' }, [ el('span', { class: 'field__label', text: tr('说明','Instructions') }), introInput, ]), ]), panel(tr('题目','Questions'), [ el('div', { class: 'panel__actions' }, [ addButton('single', tr('+ 单选题','+ Single choice'), true), addButton('multi', tr('+ 多选题','+ Multiple choice')), addButton('rating', tr('+ 评分题','+ Rating')), addButton('text', tr('+ 简答题','+ Text')), ]), actions([ el('button', { type: 'button', class: 'btn', text: tr('导入问卷 JSON','Import survey JSON'), on: { click: () => document.getElementById('survey-file').click() }, }), el('button', { type: 'button', class: 'btn btn--link is-danger', text: tr('清空题目','Clear questions'), on: { click: async () => { if (!hasQuestions()) return; const okClear = await confirmDanger({ title: tr('清空全部题目?','Clear all questions?'), body: tr('将删除当前 ','Will delete ')+`${state.questions.length} `+tr('道题以及已收到的 ','questions and ')+`${state.responses.length} `+tr('份回答。建议先「导出问卷 JSON」留备份。','responses. Export the JSON as backup first.'), confirmText: tr('清空','Clear'), }); if (!okClear) return; state.questions = []; state.responses = []; state.tallies = {}; state.draft = {}; refreshResponseCount(); paint(); }, }, }), ]), jsonFile, ]), panel(tr('已收到的回答','Responses received'), [ responseCountEl, actions([ el('button', { type: 'button', class: 'btn btn--block', text: tr('粘贴回答码','Paste answer code'), on: { click: pasteCode }, }), ]), actions([ el('button', { type: 'button', class: 'btn btn--block', text: tr('导入回答文件','Import answer files'), on: { click: () => document.getElementById('survey-receipts').click() }, }), ]), actions([ el('button', { type: 'button', class: 'btn btn--link is-danger', text: tr('清空回答与票数','Clear responses and votes'), on: { click: async () => { if (state.responses.length === 0 && Object.keys(state.tallies).length === 0) return; const okClear = await confirmDanger({ title: tr('清空全部回答?','Clear all responses?'), body: tr('将删除 ','Will delete ')+`${state.responses.length} `+tr('份回答和全部举手票数,题目会保留。','responses and all hand votes; questions remain.'), confirmText: tr('清空','Clear'), }); if (!okClear) return; state.responses = []; state.tallies = {}; state.draft = {}; refreshResponseCount(); paint(); }, }, }), ]), receiptFile, ]), ], main: [modeBar, mainHost], aside: [ panel(tr('课堂模式','Classroom mode'), [ actions([ el('button', { type: 'button', class: 'btn btn--primary btn--block', text: tr('投影当前这题计票','Project this question for tally'), on: { click: projectTally }, }), ]), el('p', { class: 'panel__desc', text: tr('大字投出题目和选项,举手时点 +1,退出全屏后票数会留在页面上。快捷键 F。','Project the question and options large; tap +1 on hands. Votes stay on the page after exiting fullscreen. Shortcut: F.') }), ]), panel(tr('发给学生填','For students'), [ actions([ el('button', { type: 'button', class: 'btn btn--block', text: tr('导出学生填写版 HTML','Export student fill-in HTML'), on: { click: exportStudentHtml }, }), ]), el('p', { class: 'panel__desc', text: tr('单个文件,双击即用、不联网。学生填完把「回答码」或「回答文件」交回来,你在左边导入。','One file, double-click to use, offline. Students send back the “answer code” or “answer file”; import on the left.') }), ]), panel(tr('导出','Export'), [ actions([ el('button', { type: 'button', class: 'btn btn--block', text: tr('导出汇总 CSV','Export summary CSV'), on: { click: exportSummaryCsv } }), ]), actions([ el('button', { type: 'button', class: 'btn btn--block', text: tr('导出明细 CSV','Export details CSV'), on: { click: exportDetailCsv } }), ]), actions([ el('button', { type: 'button', class: 'btn btn--block', text: tr('打印纸质问卷','Print paper survey'), on: { click: () => { if (!requireQuestions()) return; setMode('paper'); window.print(); }, }, }), ]), actions([ el('button', { type: 'button', class: 'btn btn--block', text: tr('导出问卷 JSON','Export survey JSON'), on: { click: exportJson } }), ]), actions([ el('button', { type: 'button', class: 'btn btn--block', text: tr('保存到本机','Save locally'), on: { click: async () => { if (!requireQuestions()) return; state.id ??= uid('svy'); await storage.put('surveys', { id: state.id, ...surveyPayload(), updatedAt: new Date().toISOString(), }); toast(tr('已保存到本机。','Saved locally.'), { type: 'success' }); }, }, }), ]), ]), panel(tr('回答是不是匿名','Anonymity'), [ el('p', { class: 'panel__desc', text: tr('问卷本身不问姓名,回答里也不带身份信息。但学生填写版是学生自己把回答码发给你的,发的渠道会暴露是谁——需要真匿名就用举手计票或纸质问卷。','The survey does not ask for names and responses carry no identity. But students send the code themselves, so the channel reveals who they are — use tally or paper for true anonymity.'), }), ], { tone: 'accent' }), ], }); /* 打印时只留中间那栏:左边是编辑面板、右边是导出按钮, 印在纸质问卷上纯属噪音。这条只对本工具生效,不动全站打印样式。 */ layout.querySelector('.tool__col--settings')?.classList.add('no-print'); layout.querySelector('.tool__col--aside')?.classList.add('no-print'); bindFullscreenHotkey(() => { if (!isActive()) projectTally(); }); paint(); return layout; } onLangChange(() => location.reload()); startTool(build); return { }; })(); })();