好友
阅读权限 10
听众
最后登录 1970-1-1
部落稻草人
发表于 2026-7-27 12:41
本帖最后由 部落稻草人 于 2026-8-2 17:45 编辑
【分享】自用题库刷题系统,免安装浏览器即用,支持 Excel/Word 导入
做这个工具的初衷是自己偶尔要背题考试,一直找不到一个符合自己一些要求且能跨设备的刷题工具。最近突然发现AI制作软件,自己纯小白就参考吧内各位大佬模板,自己搞了一个,分享给大家。
软件特点:
✅ 免安装,一个 HTML 文件浏览器打开就用
✅ 数据存本地浏览器,不上传任何服务器
✅ Windows 电脑和安卓手机都能用
✅ 支持单选、多选、判断、填空、主观题五种题型
✅ 顺序练习、随机练习、模拟考试、错题专项四种模式
✅ Excel 题库一键导入/导出,自动识别题型
✅ Word 题库(.docx)直接导入,规则引擎自动解析
✅ 多题库管理,自由选择题库练习
✅ 错题自动收集,按错误次数排序
✅ 模拟考试支持自定义组卷、导出空白试卷打印
✅ 试题搜索、收藏夹、个人笔记、图片附件
✅ 深色模式、字体大小调节
✅ 答题进度自动保存,关闭再打开可继续
使用方式:
Windows 双击 index.html,安卓用 Chrome 打开,就这么简单。
题库格式:
下载演示模板 → 按模板填入题目 → 导入 → 开始刷题。
列:题干、答案、选项A~H、解析、类型、领域
v1.1 更新说明
本次更新:界面重构 + 错题增强 + 手机适配 + Bug修复
界面重构
移除左侧导航栏,全部功能入口合并到顶部,不再遮挡题目
主题切换和字体调节从底部移到顶部右侧
首页移除占位文字
顶部新增收藏入口
手机端适配
答题进度改为顶部紧凑色点条(14px)+ 题号文字,单行横向滚动
上一题/下一题按钮紧凑排列
题目区域自适应高度,不被遮挡
错题专项同步适配
错题集增强
批量选择删除(全选 / 按答错次数筛选 / 删除选中)
答对自动移出错题集(可开关,默认关闭)
手动显示答案模式(选择不立即出答案,确认后才显示)
错题导出 Excel
筛选后全选只选可见项
Bug 修复
多选题选项乱序后对错判断错误
选项乱序回看上一题勾选位置不对
主观题回看显示原始 JSON 代码
错题集多选题手动模式无效
错题导出无反应
其他
填空题支持多答案(用 / 或 ; 分隔,填任一个即对)
模拟考试加入自动下一题选项
下载地址:
通过网盘分享的文件:刷题题库
链接: https://pan.baidu.com/s/1c5DUaVmLiz5_8y4ya3q44w?pwd=kdw6 提取码: kdw6
把 dist/index.html 发给朋友,或者放 NAS 共享文件夹里,
电脑手机都能访问,题库通过 Excel 互传即可。
纯本地纯免费,没有任何广告和收费,放心用。第一次制作,也非常不成熟
有问题欢迎回帖交流。
/**
Excel 读写服务(基于 SheetJS)
功能:
import * as XLSX from 'xlsx'
// ============================================================
// 常量定义
// ============================================================
/* Excel 列名映射 /
const COL = {
stem: '题干',
answer: '答案',
optionA: '选项 A',
optionB: '选项 B',
optionC: '选项 C',
optionD: '选项 D',
optionE: '选项 E',
optionF: '选项 F',
optionG: '选项 G',
optionH: '选项 H',
explanation: '解析',
type: '类型',
domain: '领域',
}
const ALL_COLS = [
COL.stem, COL.answer,
COL.optionA, COL.optionB, COL.optionC, COL.optionD,
COL.optionE, COL.optionF, COL.optionG, COL.optionH,
COL.explanation, COL.type, COL.domain,
]
/* 题型枚举映射:Excel 中文 → 内部 ID /
const TYPE_MAP = {
'单选题': 'single',
'单选': 'single',
'多选题': 'multi',
'多选': 'multi',
'判断题': 'judge',
'判断': 'judge',
'填空题': 'fill',
'填空': 'fill',
'主观题': 'essay',
'简答题': 'essay',
'简答': 'essay',
'分析题': 'essay',
'论述题': 'essay',
'论述': 'essay',
}
/* 内部 ID → 中文名称 /
const TYPE_LABEL = {
single: '单选题',
multi: '多选题',
judge: '判断题',
fill: '填空题',
essay: '主观题',
}
/* 对错关键词 /
const JUDGE_KEYWORDS = [
'对', '错', '正确', '错误', '✓', '✗',
'true', 'false', 'TRUE', 'FALSE', 'True', 'False',
'是', '否', 'yes', 'no', 'YES', 'NO', '1', '0',
]
// ============================================================
// 题型自动识别
// ============================================================
/**
自动识别单道题目的题型
@Param {Object} q - { stem, answer, options: string[] }
@returns {string} - 'single' | 'multi' | 'judge' | 'fill' | 'essay'
*/
function autoDetectType(q) {
const answer = (q.answer || '').trim()
const options = (q.options || []).filter(o => o && o.trim() !== '')
const stem = q.stem || ''
const hasOptions = options.length > 0
const optionCount = options.length
// 获取选项中有内容的个数
const filledOptions = options.filter(o => o.trim() !== '')
// 条件①:答案是对错关键词,且无选项 → 判断题
if (!hasOptions && JUDGE_KEYWORDS.includes(answer)) {
return 'judge'
}
// 条件②:题干含空白括号或下划线,且无选项 → 填空题
if (!hasOptions && /(\s)|(\s )|_{3,}/.test(stem)) {
return 'fill'
}
// 条件③:仅 A、B 有内容,C~H 全空,答案为单字母
if (optionCount === 2 && filledOptions.length === 2 && /^[A-Ha-h]$/.test(answer)) {
// 检查 A、B 内容是否为对错类词汇
const aContent = options[0].trim()
const bContent = options[1].trim()
if (JUDGE_KEYWORDS.includes(aContent) || JUDGE_KEYWORDS.includes(bContent)) {
return 'judge'
}
return 'single'
}
// 条件④:答案含 ≥2 字母,且有选项 → 多选题
if (hasOptions && /[A-Ha-h]{2,}/.test(answer.replace(/[,,、\s]+/g, ''))) {
return 'multi'
}
// 条件⑤:答案为单字母,且有选项 → 单选题
if (hasOptions && /^[A-Ha-h]$/.test(answer)) {
return 'single'
}
// 条件⑥:无选项,答案为较长自然文本 → 主观题
if (!hasOptions && answer.length > 2) {
return 'essay'
}
// 条件⑦:兜底 → 填空题
return 'fill'
}
/* 提取多选题答案中的多个字母 /
function extractMultiAnswer(raw) {
return raw.replace(/[,,、\s]+/g, '').toUpperCase().split('').sort().join(',')
}
/* 规范化答案文本 /
function normalizeAnswer(raw, type) {
const text = (raw || '').trim()
if (type === 'multi') {
return extractMultiAnswer(text)
}
if (type === 'single') {
return text.toUpperCase().replace(/[,,、\s]+/g, '')
}
return text
}
// ============================================================
// 选项自动提取(从题干中解析 A/B/C/D 选项)
// ============================================================
/**
从题干文字中提取选项内容
支持格式:A. xxx B. xxx / A、xxx B、xxx / A.xxx B.xxx
同行并列或换行分隔均可
@param {string} stem - 题干原文
@returns {string[]} - 提取的选项数组,如 ['上海', '北京', '广州', '深圳']
*/
function extractOptionsFromStem(stem) {
if (!stem) return []
// 匹配 A~H 后跟分隔符 + 内容的模式
// 分隔符:. . 、 。) ) 空格
const letterLabels = 'ABCDEFGH'
const patterns = []
for (const letter of letterLabels) {
// 匹配选项标签:A. 或 A、或 A.或 A) 或 A) 后跟内容
const re = new RegExp(
${letter}[..、。\\))]\\s*([^A-H\\n]*),
'g'
)
const matches = [...stem.matchAll(re)]
for (const m of matches) {
const content = m[1].trim()
if (content) {
patterns.push({ letter, content, index: m.index })
}
}
}
// 至少找到 A 和 B 两个选项才算有效
const foundLetters = [...new Set(patterns.map(p => p.letter))]
if (foundLetters.length < 2) return []
// 按出现顺序排序,每个字母取第一次出现
const result = []
const seen = new Set()
patterns.sort((a, b) => a.index - b.index)
for (const p of patterns) {
if (!seen.has(p.letter)) {
seen.add(p.letter)
result.push(p.content)
}
}
// 净化:去掉选项内容末尾可能残留的其他选项标签
const cleaned = result.map(content => {
// 去掉末尾被误包含的下一个选项标签
return content.replace(/\s[A-H][.、.)\)]\s $/, '').trim()
})
return cleaned
}
/**
从题干中移除已提取的选项文字,使题干更干净
@param {string} stem - 原始题干
@returns {string} - 移除选项后的题干
/
function cleanStemFromOptions(stem) {
// 去掉题干中从第一个选项标签开始的所有内容
// 匹配第一个 A. 或 A、 出现的位置
const re = /\s [Aa][.、.\))]\s*/
const match = stem.match(re)
if (match) {
return stem.substring(0, match.index).trim()
}
// 另一个常见模式:题干结尾有括号 (A) (B) 这种,不处理
return stem
}
// ============================================================
// 表头兼容读取
// ============================================================
/**
从 Excel 行中读取单元格值,兼容多种表头写法
例如 '选项 A' 和 '选项A' 都能匹配
*/
function getCell(row, primaryKey, altKeys = []) {
const keys = [primaryKey, ...altKeys]
for (const key of keys) {
if (row[key] !== undefined && row[key] !== null && String(row[key]).trim() !== '') {
return String(row[key]).trim()
}
}
// 模糊匹配:遍历 row 的 key,找包含主键关键词的
for (const rowKey of Object.keys(row)) {
// '选项 A' 匹配 '选项A'、'选项 A' 等
const normalized = rowKey.replace(/\s+/g, '')
const primaryNormalized = primaryKey.replace(/\s+/g, '')
if (normalized === primaryNormalized) {
const val = String(row[rowKey] || '').trim()
if (val) return val
}
}
return ''
}
// ============================================================
// Excel 读取
// ============================================================
/**
从 Excel 文件读取题目数组
@param {File} file - 用户选择的 .xlsx 文件
@returns {Promise<{questions: Array, errors: Array}>}
*/
export async function importFromExcel(file) {
const buffer = await file.arrayBuffer()
const workbook = XLSX.read(buffer, { type: 'array' })
const sheetName = workbook.SheetNames[0]
const sheet = workbook.Sheets[sheetName]
// 转为 JSON 数组(每行为对象,key 为列标题)
const rows = XLSX.utils.sheet_to_json(sheet, { defval: '' })
if (rows.length === 0) {
throw new Error('Excel 文件中没有数据')
}
const questions = []
const errors = []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const rowNum = i + 2 // Excel 行号(第 1 行是表头)
const stem = getCell(row, COL.stem, ['题目', '问题'])
const rawAnswer = getCell(row, COL.answer, ['参考答案'])
// 必填校验
if (!stem && !rawAnswer) {
continue // 完全空行跳过
}
if (!stem) {
errors.push({ row: rowNum, reason: '题干为空' })
continue
}
if (!rawAnswer) {
errors.push({ row: rowNum, reason: '答案为空' })
continue
}
// 提取选项 — 先从 Excel 列读取(兼容 '选项 A' / '选项A' / 'A' 等表头)
let options = [
getCell(row, COL.optionA, ['选项A', 'A']),
getCell(row, COL.optionB, ['选项B', 'B']),
getCell(row, COL.optionC, ['选项C', 'C']),
getCell(row, COL.optionD, ['选项D', 'D']),
getCell(row, COL.optionE, ['选项E', 'E']),
getCell(row, COL.optionF, ['选项F', 'F']),
getCell(row, COL.optionG, ['选项G', 'G']),
getCell(row, COL.optionH, ['选项H', 'H']),
].filter(o => o !== '')
// 选项列为空 → 从题干中自动提取
let finalStem = stem
if (options.length === 0) {
const extracted = extractOptionsFromStem(stem)
if (extracted.length >= 2) {
options = extracted
finalStem = cleanStemFromOptions(stem)
}
}
// 题型判定:优先读取类型列,为空则自动识别
let type
const typeCell = getCell(row, COL.type, ['题型'])
if (typeCell && TYPE_MAP[typeCell]) {
type = TYPE_MAP[typeCell]
} else {
type = autoDetectType({ stem: finalStem, answer: rawAnswer, options })
}
const question = {
stem: finalStem,
answer: normalizeAnswer(rawAnswer, type),
options,
explanation: getCell(row, COL.explanation, ['解析内容']),
type,
domain: getCell(row, COL.domain, ['分类', '类别']),
orderIndex: i, // 保留 Excel 行顺序
}
questions.push(question)
}
return { questions, errors }
}
// ============================================================
// Excel 导出
// ============================================================
/**
导出题目数组为 .xlsx 并触发下载
@param {Array} questions - 题目数组
@param {string} filename - 文件名
*/
export function exportToExcel(questions, filename = '题库导出') {
const rows = questions.map(q => ({
[COL.optionA]: q.options[0] || '',
[COL.optionB]: q.options[1] || '',
[COL.optionC]: q.options[2] || '',
[COL.optionD]: q.options[3] || '',
[COL.optionE]: q.options[4] || '',
[COL.optionF]: q.options[5] || '',
[COL.optionG]: q.options[6] || '',
[COL.optionH]: q.options[7] || '',
[COL.explanation]: q.explanation || '',
[COL.type]: TYPE_LABEL[q.type] || '',
[COL.domain]: q.domain || '',
}))
const worksheet = XLSX.utils.json_to_sheet(rows, { header: ALL_COLS })
// 设置列宽
worksheet['!cols'] = [
{ wch: 40 }, // 题干
{ wch: 20 }, // 答案
...Array(8).fill({ wch: 15 }), // 选项 A~H
{ wch: 30 }, // 解析
{ wch: 10 }, // 类型
{ wch: 15 }, // 领域
]
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, worksheet, '题库')
// 触发浏览器下载
XLSX.writeFile(workbook, ${filename}.xlsx)
}
// ============================================================
// 演示模板
// ============================================================
/* 演示模板的示例数据 /
const DEMO_QUESTIONS = [
{
[COL.answer]: 'B',
[COL.optionA]: '上海',
[COL.optionB]: '北京',
[COL.optionC]: '广州',
[COL.optionD]: '深圳',
[COL.optionE]: '', [COL.optionF]: '', [COL.optionG]: '', [COL.optionH]: '',
[COL.explanation]: '北京是中华人民共和国的首都。',
[COL.type]: '单选题',
[COL.domain]: '常识',
},
{
[COL.answer]: '对',
[COL.optionA]: '', [COL.optionB]: '', [COL.optionC]: '', [COL.optionD]: '',
[COL.optionE]: '', [COL.optionF]: '', [COL.optionG]: '', [COL.optionH]: '',
[COL.explanation]: '金星自转方向与公转方向相反,是太阳系中唯一逆向自转的行星。',
[COL.type]: '判断题',
[COL.domain]: '天文',
},
{
[COL.answer]: 'A,B,D',
[COL.optionA]: '太阳能',
[COL.optionB]: '风能',
[COL.optionC]: '煤炭',
[COL.optionD]: '水能',
[COL.optionE]: '', [COL.optionF]: '', [COL.optionG]: '', [COL.optionH]: '',
[COL.explanation]: '太阳能、风能、水能属于可再生能源;煤炭属于不可再生化石能源。',
[COL.type]: '多选题',
[COL.domain]: '能源',
},
{
[COL.answer]: '叶绿体',
[COL.optionA]: '', [COL.optionB]: '', [COL.optionC]: '', [COL.optionD]: '',
[COL.optionE]: '', [COL.optionF]: '', [COL.optionG]: '', [COL.optionH]: '',
[COL.explanation]: '光合作用在叶绿体中进行,线粒体是呼吸作用的场所。',
[COL.type]: '填空题',
[COL.domain]: '生物',
},
{
[COL.answer]: '四大发明是指南针、造纸术、火药、印刷术。它们对中国古代的政治、经济、文化发展产生了巨大推动作用,并经丝绸之路传播至西方,对世界文明进程产生了深远影响。',
[COL.optionA]: '', [COL.optionB]: '', [COL.optionC]: '', [COL.optionD]: '',
[COL.optionE]: '', [COL.optionF]: '', [COL.optionG]: '', [COL.optionH]: '',
[COL.explanation]: '答题要点:需完整列举四大发明名称,并阐述其对中国及世界的影响。',
[COL.type]: '主观题',
[COL.domain]: '历史',
},
]
/* 下载演示模板 Excel 文件 /
export function downloadDemoTemplate() {
const worksheet = XLSX.utils.json_to_sheet(DEMO_QUESTIONS, { header: ALL_COLS })
worksheet['!cols'] = [
{ wch: 45 }, // 题干
{ wch: 20 }, // 答案
...Array(8).fill({ wch: 15 }), // 选项
{ wch: 35 }, // 解析
{ wch: 10 }, // 类型
{ wch: 10 }, // 领域
]
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, worksheet, '题库模板')
XLSX.writeFile(workbook, '题库演示模板.xlsx')
}
// ============================================================
// 工具导出
// ============================================================
export { TYPE_MAP, TYPE_LABEL, autoDetectType }
免费评分
查看全部评分
本帖被以下淘专辑推荐:
· 软件合辑 | 主题: 4641, 订阅: 2810
· 收藏帖子 | 主题: 1401, 订阅: 44