1350 lines
66 KiB
HTML
Executable File
1350 lines
66 KiB
HTML
Executable File
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||
<title>📚 StudyBuddy 题库示例</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',Roboto,sans-serif;background:#f5f7fb;color:#2d3142;height:100vh;overflow:hidden;display:flex;flex-direction:column;transition:background .2s,color .2s}
|
||
|
||
/* ============ 顶部条 ============ */
|
||
.topbar{background:#fff;border-bottom:1px solid #e8eaf2;padding:10px 20px;display:flex;flex-direction:column;gap:8px;flex-shrink:0;box-shadow:0 1px 3px rgba(0,0,0,0.03)}
|
||
.topbar-row1{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
||
.topbar-row2{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding-top:8px;border-top:1px dashed #f0f2f7}
|
||
.topbar h1{font-size:16px;color:#5b8def;white-space:nowrap;font-weight:700}
|
||
.topbar h1 .subtitle{font-size:12px;color:#8a8fa3;font-weight:400;margin-left:6px}
|
||
|
||
.cat-filter{display:flex;align-items:center;gap:6px;flex-wrap:wrap;width:100%}
|
||
.cat-filter-label{font-size:12px;color:#8a8fa3;white-space:nowrap;font-weight:600}
|
||
.cat-chip{font-size:12px;padding:4px 12px;border-radius:14px;border:1px solid #e0e3ed;background:#fff;cursor:pointer;user-select:none;transition:all .15s;color:#8a8fa3;white-space:nowrap}
|
||
.cat-chip:hover{border-color:#5b8def}
|
||
.cat-chip.active{background:#5b8def;color:#fff;border-color:#5b8def}
|
||
.cat-chip .chip-count{font-size:10px;opacity:.75;margin-left:4px}
|
||
|
||
.stat-filters{display:flex;gap:6px;align-items:center}
|
||
.sf-btn{display:flex;align-items:center;gap:5px;padding:5px 12px;border-radius:18px;border:1px solid #e0e3ed;background:#fff;cursor:pointer;transition:all .15s;user-select:none;font-size:12px;color:#8a8fa3}
|
||
.sf-btn:hover{border-color:#5b8def}
|
||
.sf-btn .dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;transition:opacity .15s}
|
||
.sf-btn .dot.green{background:#52c785}
|
||
.sf-btn .dot.gray{background:#b8bdcc}
|
||
.sf-btn .dot.red{background:#ff7a90}
|
||
.sf-btn .dot.yellow{background:#ffc857}
|
||
.sf-btn .num{font-weight:700;min-width:16px;color:#2d3142}
|
||
.sf-btn.off{opacity:.4}
|
||
|
||
.io-btns{display:flex;gap:5px;margin-left:auto;flex-wrap:wrap}
|
||
.io-btns button{font-size:12px;padding:5px 10px;border-radius:7px;border:1px solid #e0e3ed;background:#fff;color:#5b6172;cursor:pointer;transition:all .15s}
|
||
.io-btns button:hover{border-color:#5b8def;color:#5b8def}
|
||
.io-btns button.exam{background:#fef0f3;border-color:#ffd1d9;color:#ff5d77}
|
||
.io-btns button.exam:hover{background:#ff5d77;color:#fff;border-color:#ff5d77}
|
||
.io-btns button.danger:hover{border-color:#ff7a90;color:#ff7a90}
|
||
.io-btns .theme-toggle{padding:5px 9px;border-radius:50%;font-size:14px}
|
||
|
||
/* ============ 主体 ============ */
|
||
.main{display:flex;flex:1;overflow:hidden}
|
||
.left{width:300px;min-width:300px;background:#fff;border-right:1px solid #e8eaf2;display:flex;flex-direction:column;flex-shrink:0}
|
||
.left-header{padding:12px 16px;border-bottom:1px solid #e8eaf2;font-size:13px;color:#8a8fa3;display:flex;justify-content:space-between;align-items:center}
|
||
.left-header .progress{font-weight:700;color:#5b8def}
|
||
.left-scroll{flex:1;overflow-y:auto;padding:6px 0}
|
||
.left-scroll::-webkit-scrollbar{width:6px}
|
||
.left-scroll::-webkit-scrollbar-thumb{background:#d8dce8;border-radius:3px}
|
||
|
||
.cat-group{margin-bottom:2px}
|
||
.cat-top{padding:8px 16px;font-size:13px;font-weight:700;color:#2d3142;background:#fafbfd;display:flex;align-items:center;gap:6px;position:sticky;top:0;z-index:1;border-bottom:1px solid #f0f2f7}
|
||
.cat-count{font-size:11px;color:#a8adbd;font-weight:normal}
|
||
.cat-sub{padding:6px 16px 8px 16px}
|
||
.cat-sub-name{font-size:11px;color:#a8adbd;margin-bottom:5px;line-height:1.4}
|
||
.cat-nums{display:flex;flex-wrap:wrap;gap:3px}
|
||
.qnum{display:inline-flex;align-items:center;justify-content:center;min-width:30px;height:26px;padding:0 6px;border-radius:5px;font-size:12px;cursor:pointer;transition:all .15s;border:1.5px solid transparent;background:#f5f6fa;color:#5b6172;font-weight:600}
|
||
.qnum:hover{border-color:#5b8def;color:#5b8def;transform:translateY(-1px)}
|
||
.qnum.active{border-color:#5b8def!important;color:#fff!important;background:#5b8def!important;box-shadow:0 2px 6px rgba(91,141,239,0.3)}
|
||
.qnum.mastered{color:#3fb371;background:rgba(82,199,133,0.15)}
|
||
.qnum.unstudied{color:#8a8fa3;background:#f0f2f7}
|
||
.qnum.error{color:#ff5d77;background:rgba(255,122,144,0.15)}
|
||
.qnum.filtered-out{display:none!important}
|
||
|
||
/* ============ 右侧 ============ */
|
||
.right{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
|
||
.right-inner{flex:1;overflow-y:auto;padding:24px 32px}
|
||
.right-inner::-webkit-scrollbar{width:8px}
|
||
.right-inner::-webkit-scrollbar-thumb{background:#d8dce8;border-radius:4px}
|
||
|
||
.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:#a8adbd;font-size:15px;gap:12px;text-align:center}
|
||
.empty-state .icon{font-size:54px}
|
||
.empty-state .hint{font-size:13px;color:#b8bdcc}
|
||
|
||
.q-card{max-width:780px;margin:0 auto}
|
||
.q-meta{display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap}
|
||
.q-id{font-size:18px;font-weight:800;color:#5b8def}
|
||
.q-tag{font-size:11px;padding:3px 9px;border-radius:11px;font-weight:600;white-space:nowrap}
|
||
.q-tag.type-choice{background:#e8f0ff;color:#5b8def}
|
||
.q-tag.type-tf{background:#e8f6ed;color:#52c785}
|
||
.q-tag.type-blank{background:#fff4e0;color:#e9a82c}
|
||
.q-tag.type-short{background:#f0e8ff;color:#9168e8}
|
||
.q-tag.level{background:#f0f2f7;color:#8a8fa3}
|
||
.q-state-btn{font-size:11px;padding:4px 10px 4px 12px;border-radius:11px;border:1px solid #e0e3ed;background:#fff;cursor:pointer;color:#8a8fa3;transition:all .15s;font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,0.04)}
|
||
.q-state-btn:hover{border-color:#5b8def;color:#5b8def;box-shadow:0 2px 6px rgba(91,141,239,0.15);transform:translateY(-1px)}
|
||
.q-state-btn.mastered{color:#3fb371;border-color:#a3dcbb;background:#f0faf3}
|
||
.q-state-btn.error{color:#ff5d77;border-color:#ffb8c4;background:#fff5f6}
|
||
.q-knowledge{font-size:12px;color:#8a8fa3;margin-left:auto;max-width:50%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
|
||
.q-text{font-size:16px;line-height:1.85;color:#2d3142;margin-bottom:20px;font-weight:500;white-space:pre-wrap}
|
||
|
||
.opts{display:flex;flex-direction:column;gap:9px;margin-bottom:16px}
|
||
.opt{display:flex;align-items:flex-start;gap:10px;padding:13px 16px;border-radius:10px;border:1.5px solid #e8eaf2;background:#fff;cursor:pointer;transition:all .15s;font-size:14px;line-height:1.6}
|
||
.opt:hover{border-color:#5b8def;background:#f8faff}
|
||
.opt .letter{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:#f0f2f7;color:#5b6172;font-weight:700;flex-shrink:0;font-size:12px;transition:all .15s}
|
||
.opt:hover .letter{background:#5b8def;color:#fff}
|
||
.opt .text{flex:1;padding-top:2px;color:#2d3142}
|
||
.opt.selected{border-color:#5b8def;background:#f0f5ff}
|
||
.opt.selected .letter{background:#5b8def;color:#fff}
|
||
.opt.correct{border-color:#52c785;background:#f0faf3}
|
||
.opt.correct .letter{background:#52c785;color:#fff}
|
||
.opt.wrong{border-color:#ff7a90;background:#fff5f6}
|
||
.opt.wrong .letter{background:#ff7a90;color:#fff}
|
||
.opt.disabled{pointer-events:none}
|
||
|
||
.q-input{width:100%;padding:13px 15px;border-radius:10px;border:1.5px solid #e8eaf2;font-size:15px;font-family:inherit;background:#fff;color:#2d3142;outline:none;transition:all .15s;resize:vertical;margin-bottom:14px}
|
||
.q-input:focus{border-color:#5b8def;background:#f8faff}
|
||
.q-input.short{min-height:48px}
|
||
.q-input.long{min-height:120px}
|
||
.q-input:disabled{background:#f5f6fa;color:#8a8fa3}
|
||
|
||
.q-actions{display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap}
|
||
.q-actions button{padding:9px 18px;border-radius:8px;border:none;cursor:pointer;font-size:13px;font-weight:600;transition:all .15s}
|
||
.btn-submit{background:#5b8def;color:#fff}
|
||
.btn-submit:hover{background:#4a7adb}
|
||
.btn-submit:disabled{background:#b8bdcc;cursor:not-allowed}
|
||
.btn-reveal{background:#f0f2f7;color:#5b6172}
|
||
.btn-reveal:hover{background:#e0e3ed}
|
||
.btn-reset{background:#fff;color:#8a8fa3;border:1.5px solid #e0e3ed!important}
|
||
.btn-reset:hover{border-color:#ff7a90!important;color:#ff7a90}
|
||
|
||
.auto-state-hint{font-size:13px;padding:9px 14px;border-radius:8px;margin-bottom:14px;display:none;font-weight:600}
|
||
.auto-state-hint.mastered{display:block;background:rgba(82,199,133,0.12);color:#3fb371;border:1px solid rgba(82,199,133,0.3)}
|
||
.auto-state-hint.error{display:block;background:rgba(255,122,144,0.12);color:#ff5d77;border:1px solid rgba(255,122,144,0.3)}
|
||
.auto-state-hint.retry{display:block;background:rgba(255,200,87,0.18);color:#d68b00;border:1px solid rgba(255,200,87,0.4);animation:slideIn .25s}
|
||
@keyframes slideIn{from{transform:translateY(-4px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||
.opt.wrong-shake{animation:shake .5s;border-color:#ff7a90;background:#fff5f6}
|
||
@keyframes shake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-6px)}40%,80%{transform:translateX(6px)}}
|
||
|
||
.analysis{display:none;margin-top:14px;padding:14px 18px;background:#fafbfd;border-radius:10px;border-left:4px solid #5b8def}
|
||
.analysis.visible{display:block}
|
||
.analysis.correct{border-left-color:#52c785;background:#f0faf3}
|
||
.analysis.wrong{border-left-color:#ff7a90;background:#fff5f6}
|
||
.ans-line{font-size:14px;font-weight:700;margin-bottom:8px;display:flex;align-items:center;gap:6px}
|
||
.analysis .ans-line{color:#3fb371}
|
||
.analysis.wrong .ans-line{color:#ff5d77}
|
||
.ans-text{font-size:13px;color:#5b6172;line-height:1.75;margin-bottom:6px}
|
||
.ans-text strong{color:#2d3142}
|
||
.ans-text em{color:#5b8def;font-style:normal;font-weight:600}
|
||
.ans-text code{background:rgba(91,141,239,0.1);padding:1px 6px;border-radius:4px;font-size:12px;color:#5b8def}
|
||
.tip{font-size:13px;color:#d68b00;line-height:1.7;padding:8px 12px;background:rgba(255,200,87,0.15);border-radius:6px;margin-top:8px;display:none}
|
||
.tip.visible{display:block}
|
||
.kw{font-size:12px;color:#a8adbd;margin-top:8px}
|
||
|
||
/* ============ 导航条 ============ */
|
||
.nav-bar{padding:12px 32px;border-top:1px solid #e8eaf2;display:flex;align-items:center;justify-content:space-between;background:#fafbfd;flex-shrink:0}
|
||
.nav-btn{padding:7px 18px;border-radius:7px;border:1px solid #e0e3ed;background:#fff;color:#5b6172;cursor:pointer;font-size:13px;transition:all .15s}
|
||
.nav-btn:hover:not(:disabled){border-color:#5b8def;color:#5b8def}
|
||
.nav-btn:disabled{opacity:.4;cursor:default}
|
||
.nav-info{font-size:12px;color:#8a8fa3}
|
||
.kbd-hint{font-size:11px;color:#a8adbd;flex:1;text-align:center}
|
||
@media(max-width:900px){.kbd-hint{display:none}}
|
||
|
||
/* ============ Modal ============ */
|
||
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;align-items:center;justify-content:center}
|
||
.modal-overlay.visible{display:flex}
|
||
.modal{background:#fff;border-radius:14px;padding:24px 28px;max-width:480px;width:90%;box-shadow:0 10px 40px rgba(0,0,0,0.15)}
|
||
.modal h3{font-size:17px;color:#2d3142;margin-bottom:10px}
|
||
.modal p{font-size:13px;color:#8a8fa3;line-height:1.6;margin-bottom:16px}
|
||
.modal-row{margin-bottom:14px}
|
||
.modal-row label{display:block;font-size:12px;color:#5b6172;margin-bottom:6px;font-weight:600}
|
||
.modal-row .opts-row{display:flex;flex-wrap:wrap;gap:6px}
|
||
.modal-btn{padding:7px 14px;border-radius:7px;border:1px solid #e0e3ed;background:#fff;color:#5b6172;cursor:pointer;font-size:13px;transition:all .15s}
|
||
.modal-btn:hover{border-color:#5b8def;color:#5b8def}
|
||
.modal-btn.selected{border-color:#5b8def;background:#5b8def;color:#fff}
|
||
.modal-textarea{width:100%;min-height:140px;padding:10px;border-radius:8px;border:1.5px solid #e8eaf2;font-family:monospace;font-size:12px;resize:vertical;outline:none}
|
||
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
|
||
.modal-actions .primary{background:#5b8def;color:#fff;border:none;padding:8px 18px;border-radius:7px;cursor:pointer;font-weight:600}
|
||
.modal-actions .primary:hover{background:#4a7adb}
|
||
.modal-actions .danger{border-color:#ff7a90;color:#ff5d77}
|
||
|
||
/* ============ 模拟考模式 ============ */
|
||
.exam-overlay{display:none;position:fixed;inset:0;background:#f5f7fb;z-index:2000;flex-direction:column;overflow:hidden}
|
||
.exam-overlay.visible{display:flex}
|
||
.exam-topbar{background:#fff;border-bottom:1px solid #e8eaf2;padding:12px 20px;display:flex;align-items:center;gap:14px;flex-shrink:0;flex-wrap:wrap;box-shadow:0 1px 3px rgba(0,0,0,0.04)}
|
||
.exam-topbar h2{font-size:15px;color:#5b8def;font-weight:700}
|
||
.exam-progress{flex:1;display:flex;align-items:center;gap:8px;min-width:120px}
|
||
.exam-progress-bar{flex:1;height:6px;background:#e8eaf2;border-radius:3px;overflow:hidden}
|
||
.exam-progress-fill{height:100%;background:linear-gradient(90deg,#5b8def,#7a9fff);border-radius:3px;transition:width .3s}
|
||
.exam-progress-text{font-size:12px;color:#8a8fa3;white-space:nowrap}
|
||
.exam-timer{font-size:13px;color:#e9a82c;font-weight:700}
|
||
.exam-body{flex:1;overflow-y:auto;padding:20px 28px}
|
||
.exam-q-card{max-width:780px;margin:0 auto 16px;background:#fff;border:1px solid #e8eaf2;border-radius:12px;padding:18px 22px;box-shadow:0 2px 6px rgba(0,0,0,0.03)}
|
||
.exam-q-num{font-size:13px;color:#5b8def;font-weight:700;margin-bottom:8px}
|
||
.exam-q-text{font-size:15px;line-height:1.75;color:#2d3142;margin-bottom:12px;white-space:pre-wrap}
|
||
.exam-opts{display:flex;flex-direction:column;gap:6px}
|
||
.exam-opt{padding:9px 14px;border-radius:8px;border:1.5px solid #e8eaf2;background:#fff;cursor:pointer;transition:all .15s;display:flex;align-items:flex-start;gap:8px;font-size:13px;line-height:1.55}
|
||
.exam-opt:hover{border-color:#5b8def;background:#f8faff}
|
||
.exam-opt.selected{border-color:#5b8def;background:#f0f5ff}
|
||
.exam-opt .letter{font-weight:700;min-width:22px;color:#5b6172;flex-shrink:0}
|
||
.exam-opt.selected .letter{color:#5b8def}
|
||
.exam-input{width:100%;padding:9px 12px;border-radius:7px;border:1.5px solid #e8eaf2;font-size:13px;outline:none;font-family:inherit;resize:vertical;min-height:42px}
|
||
.exam-input:focus{border-color:#5b8def}
|
||
.exam-submit-bar{padding:14px 28px;border-top:1px solid #e8eaf2;background:#fff;display:flex;align-items:center;justify-content:center;gap:10px;flex-shrink:0}
|
||
.exam-submit-btn{padding:9px 28px;border-radius:8px;border:none;background:#5b8def;color:#fff;cursor:pointer;font-size:14px;font-weight:700}
|
||
.exam-submit-btn:hover{background:#4a7adb}
|
||
.exam-quit-btn{padding:9px 18px;border-radius:8px;border:1px solid #e0e3ed;background:#fff;color:#8a8fa3;cursor:pointer;font-size:13px}
|
||
.exam-quit-btn:hover{border-color:#ff7a90;color:#ff5d77}
|
||
|
||
/* 结果页 */
|
||
.exam-results{display:none;position:fixed;inset:0;background:#f5f7fb;z-index:2000;flex-direction:column;overflow:hidden}
|
||
.exam-results.visible{display:flex}
|
||
.results-header{background:#fff;border-bottom:1px solid #e8eaf2;padding:24px;text-align:center;flex-shrink:0}
|
||
.results-score{font-size:54px;font-weight:900;margin-bottom:4px;line-height:1}
|
||
.results-score.pass{color:#52c785}
|
||
.results-score.fail{color:#ff5d77}
|
||
.results-detail{font-size:14px;color:#8a8fa3;margin-bottom:14px}
|
||
.results-actions{display:flex;gap:8px;justify-content:center;flex-wrap:wrap}
|
||
.results-actions button{padding:8px 18px;border-radius:7px;border:1px solid #e0e3ed;background:#fff;color:#5b6172;cursor:pointer;font-size:13px}
|
||
.results-actions button:hover{border-color:#5b8def;color:#5b8def}
|
||
.results-actions button.primary{background:#5b8def;color:#fff;border-color:#5b8def}
|
||
.results-body{flex:1;overflow-y:auto;padding:18px 24px}
|
||
.result-item{max-width:780px;margin:0 auto 12px;background:#fff;border:1px solid #e8eaf2;border-radius:10px;padding:14px 18px}
|
||
.result-item.is-correct{border-left:4px solid #52c785}
|
||
.result-item.is-wrong{border-left:4px solid #ff7a90}
|
||
.result-top{display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap}
|
||
.result-badge{font-size:11px;padding:2px 9px;border-radius:10px;font-weight:700}
|
||
.result-badge.correct{background:#f0faf3;color:#3fb371}
|
||
.result-badge.wrong{background:#fff5f6;color:#ff5d77}
|
||
.result-qid{font-size:12px;color:#5b8def;font-weight:700}
|
||
.result-text{font-size:13px;line-height:1.7;color:#2d3142;margin-bottom:6px;white-space:pre-wrap}
|
||
.result-answers{font-size:12px;color:#5b6172;margin-bottom:6px}
|
||
.result-answers .correct-ans{color:#3fb371;font-weight:700}
|
||
.result-answers .wrong-ans{color:#ff5d77;text-decoration:line-through}
|
||
.result-analysis{font-size:12px;color:#5b6172;line-height:1.7;padding:7px 11px;background:#fafbfd;border-radius:6px;margin-bottom:6px}
|
||
.result-tip{font-size:12px;color:#d68b00;line-height:1.6;padding:6px 10px;background:rgba(255,200,87,0.15);border-radius:6px;margin-bottom:6px}
|
||
|
||
/* ============ 移动端 ============ */
|
||
.sidebar-toggle{display:none;position:fixed;bottom:20px;right:20px;z-index:999;width:48px;height:48px;border-radius:50%;background:#5b8def;color:#fff;border:none;font-size:20px;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,0.2)}
|
||
.sidebar-toggle:active{transform:scale(0.92)}
|
||
@media(max-width:768px){
|
||
.left{width:100%;min-width:100%;max-height:50vh;border-right:none;border-bottom:1px solid #e8eaf2;transition:max-height .3s ease}
|
||
.left.hidden{max-height:0;overflow:hidden;border-bottom:none}
|
||
.main{flex-direction:column}
|
||
.topbar{gap:8px;padding:8px 12px}
|
||
.topbar h1{font-size:14px}
|
||
.cat-filter{order:10;width:100%}
|
||
.io-btns{margin-left:0;width:100%;justify-content:flex-end}
|
||
.right-inner{padding:14px}
|
||
.sidebar-toggle{display:flex;align-items:center;justify-content:center}
|
||
.nav-bar{padding:10px 14px}
|
||
}
|
||
|
||
/* ============ Dark Mode ============ */
|
||
body.dark{background:#1a1a2e;color:#e0e3ed}
|
||
body.dark .topbar{background:#16213e;border-bottom-color:#0f3460;box-shadow:none}
|
||
body.dark .topbar h1{color:#e88ca5}
|
||
body.dark .cat-filter-label,body.dark .cat-sub-name,body.dark .left-header,body.dark .nav-info,body.dark .empty-state,body.dark .ans-text,body.dark .modal p{color:#888}
|
||
body.dark .cat-chip{background:transparent;border-color:#333;color:#888}
|
||
body.dark .cat-chip.active{background:#e88ca5;color:#fff;border-color:#e88ca5}
|
||
body.dark .sf-btn{background:transparent;border-color:#333;color:#aaa}
|
||
body.dark .sf-btn:hover{border-color:#e88ca5}
|
||
body.dark .sf-btn .num{color:#e0e3ed}
|
||
body.dark .io-btns button{background:transparent;border-color:#333;color:#aaa}
|
||
body.dark .io-btns button:hover{border-color:#e88ca5;color:#e88ca5}
|
||
body.dark .io-btns button.exam{background:rgba(232,140,165,0.15);border-color:#e88ca5;color:#e88ca5}
|
||
body.dark .left{background:#16213e;border-right-color:#0f3460}
|
||
body.dark .left-scroll::-webkit-scrollbar-thumb{background:#333}
|
||
body.dark .cat-top{background:#16213e;color:#e0e3ed;border-bottom-color:#0f3460}
|
||
body.dark .cat-count{color:#666}
|
||
body.dark .qnum{background:rgba(100,100,100,0.15);color:#777;border-color:transparent}
|
||
body.dark .qnum:hover{border-color:#e88ca5;color:#fff}
|
||
body.dark .qnum.active{background:#e88ca5!important;border-color:#e88ca5!important;color:#fff!important}
|
||
body.dark .qnum.mastered{background:rgba(110,197,168,0.15);color:#6ec5a8}
|
||
body.dark .qnum.unstudied{background:rgba(100,100,100,0.15);color:#888}
|
||
body.dark .qnum.error{background:rgba(232,140,165,0.15);color:#e88ca5}
|
||
body.dark .right-inner::-webkit-scrollbar-thumb{background:#333}
|
||
body.dark .q-text{color:#e0e3ed}
|
||
body.dark .q-id{color:#e88ca5}
|
||
body.dark .q-tag.type-choice{background:rgba(91,141,239,0.15);color:#7a9fff}
|
||
body.dark .q-tag.type-tf{background:rgba(110,197,168,0.15);color:#6ec5a8}
|
||
body.dark .q-tag.type-blank{background:rgba(255,200,87,0.15);color:#f4a261}
|
||
body.dark .q-tag.type-short{background:rgba(145,104,232,0.15);color:#b08fff}
|
||
body.dark .q-tag.level{background:rgba(100,100,100,0.2);color:#888}
|
||
body.dark .q-state-btn{background:transparent;border-color:#333;color:#aaa}
|
||
body.dark .q-knowledge{color:#666}
|
||
body.dark .opt{background:#1f2541;border-color:#333;color:#e0e3ed}
|
||
body.dark .opt:hover{background:rgba(232,140,165,0.05);border-color:#555}
|
||
body.dark .opt .text{color:#e0e3ed}
|
||
body.dark .opt .letter{background:#333;color:#888}
|
||
body.dark .opt.selected{background:rgba(232,140,165,0.1);border-color:#e88ca5}
|
||
body.dark .opt.correct{background:rgba(110,197,168,0.1);border-color:#6ec5a8}
|
||
body.dark .opt.wrong{background:rgba(232,140,165,0.1);border-color:#e88ca5}
|
||
body.dark .q-input{background:#1f2541;border-color:#333;color:#e0e3ed}
|
||
body.dark .q-input:focus{background:#1a1a2e;border-color:#e88ca5}
|
||
body.dark .btn-submit{background:#e88ca5}
|
||
body.dark .btn-submit:hover{background:#d97a93}
|
||
body.dark .btn-reveal{background:#333;color:#aaa}
|
||
body.dark .btn-reset{background:transparent;color:#888}
|
||
body.dark .analysis{background:#1a1a2e;border-left-color:#e88ca5}
|
||
body.dark .analysis.correct{background:#1a1a2e;border-left-color:#6ec5a8}
|
||
body.dark .analysis.wrong{background:#1a1a2e;border-left-color:#e88ca5}
|
||
body.dark .nav-bar{background:#16213e;border-top-color:#0f3460}
|
||
body.dark .nav-btn{background:transparent;border-color:#333;color:#aaa}
|
||
body.dark .kbd-hint{color:#555}
|
||
body.dark .modal{background:#16213e;color:#e0e3ed}
|
||
body.dark .modal h3{color:#e0e3ed}
|
||
body.dark .modal-btn{background:transparent;border-color:#333;color:#aaa}
|
||
body.dark .modal-btn.selected{background:#e88ca5;border-color:#e88ca5;color:#fff}
|
||
body.dark .modal-textarea{background:#1f2541;border-color:#333;color:#e0e3ed}
|
||
body.dark .exam-overlay,body.dark .exam-results{background:#1a1a2e}
|
||
body.dark .exam-topbar,body.dark .exam-submit-bar,body.dark .results-header{background:#16213e;border-color:#0f3460}
|
||
body.dark .exam-q-card,body.dark .result-item{background:#16213e;border-color:#0f3460}
|
||
body.dark .exam-q-text,body.dark .result-text{color:#e0e3ed}
|
||
body.dark .exam-opt{background:#1f2541;border-color:#333;color:#e0e3ed}
|
||
body.dark .exam-opt.selected{background:rgba(232,140,165,0.1);border-color:#e88ca5}
|
||
body.dark .result-analysis{background:#1a1a2e;color:#aaa}
|
||
body.dark .auto-state-hint.mastered{background:rgba(110,197,168,0.1);color:#6ec5a8;border-color:rgba(110,197,168,0.3)}
|
||
body.dark .auto-state-hint.error{background:rgba(232,140,165,0.1);color:#e88ca5;border-color:rgba(232,140,165,0.3)}
|
||
</style>
|
||
</head>
|
||
<body class="light">
|
||
|
||
<div class="topbar">
|
||
<div class="topbar-row1">
|
||
<h1>📚 StudyBuddy 题库示例 <span class="subtitle">K12 多学科 · 8 道题</span></h1>
|
||
<div class="stat-filters" id="statFilters">
|
||
<div class="sf-btn" data-f="mastered" onclick="toggleFilter('mastered',this)"><span class="dot green"></span><span class="num" id="numMastered">0</span><span>已掌握</span></div>
|
||
<div class="sf-btn" data-f="unstudied" onclick="toggleFilter('unstudied',this)"><span class="dot gray"></span><span class="num" id="numUnstudied">0</span><span>未做</span></div>
|
||
<div class="sf-btn" data-f="error" onclick="toggleFilter('error',this)"><span class="dot red"></span><span class="num" id="numError">0</span><span>错题</span></div>
|
||
</div>
|
||
<div class="io-btns">
|
||
<button class="exam" onclick="showExamModal()">📝 模拟考</button>
|
||
<button onclick="randomQ()">🎲 随机</button>
|
||
<button onclick="exportProgress()">💾 导出进度</button>
|
||
<button class="danger" onclick="showResetModal()">🗑️ 重置</button>
|
||
<button class="theme-toggle" onclick="toggleTheme()" title="切换主题">☀️</button>
|
||
</div>
|
||
</div>
|
||
<div class="topbar-row2">
|
||
<div class="cat-filter" id="catFilter"><span class="cat-filter-label">📂 分类:</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="sidebar-toggle" onclick="toggleSidebar()">📋</button>
|
||
|
||
<div class="main">
|
||
<div class="left" id="sidebar">
|
||
<div class="left-header"><span>📚 题号目录</span><span class="progress" id="progressTxt">0/0</span></div>
|
||
<div class="left-scroll" id="leftScroll"></div>
|
||
</div>
|
||
<div class="right">
|
||
<div class="right-inner" id="rightInner">
|
||
<div class="empty-state">
|
||
<div class="icon">📖</div>
|
||
<div>选择左侧题号开始学习</div>
|
||
<div class="hint">点击题号查看,做完会自动记录到本地</div>
|
||
</div>
|
||
</div>
|
||
<div class="nav-bar" id="navBar" style="display:none;">
|
||
<button class="nav-btn" id="prevBtn" onclick="goPrev()" title="← 上一题">← 上一题</button>
|
||
<span class="nav-info" id="navInfo"></span>
|
||
<span class="kbd-hint">⌨️ A/B/C/D 选项 · Enter 提交 · ← → 切题 · Space 看答案</span>
|
||
<button class="nav-btn" id="nextBtn" onclick="goNext()" title="→ 下一题">下一题 →</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== 模拟考 modal ===== -->
|
||
<div class="modal-overlay" id="examModal">
|
||
<div class="modal">
|
||
<h3>📝 开始模拟考</h3>
|
||
<p id="examPoolSize">从当前题池中随机抽题作答</p>
|
||
<div class="modal-row">
|
||
<label>题量</label>
|
||
<div class="opts-row">
|
||
<button class="modal-btn" data-n="5" onclick="selectExamCount(5)">5题</button>
|
||
<button class="modal-btn selected" data-n="10" onclick="selectExamCount(10)">10题</button>
|
||
<button class="modal-btn" data-n="20" onclick="selectExamCount(20)">20题</button>
|
||
<button class="modal-btn" data-n="30" onclick="selectExamCount(30)">30题</button>
|
||
<button class="modal-btn" data-n="50" onclick="selectExamCount(50)">50题</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-row">
|
||
<label>每题限时</label>
|
||
<div class="opts-row">
|
||
<button class="modal-btn selected" data-t="0" onclick="selectExamTime(0)">不限时</button>
|
||
<button class="modal-btn" data-t="30" onclick="selectExamTime(30)">30秒</button>
|
||
<button class="modal-btn" data-t="60" onclick="selectExamTime(60)">1分钟</button>
|
||
<button class="modal-btn" data-t="90" onclick="selectExamTime(90)">1.5分钟</button>
|
||
<button class="modal-btn" data-t="120" onclick="selectExamTime(120)">2分钟</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="modal-btn" onclick="hideExamModal()">取消</button>
|
||
<button class="primary" onclick="startExam()">🚀 开考</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== 导入 modal ===== -->
|
||
<div class="modal-overlay" id="importModal">
|
||
<div class="modal">
|
||
<h3 id="importTitle">📥 导入</h3>
|
||
<p id="importHint">粘贴 JSON 内容</p>
|
||
<textarea class="modal-textarea" id="importText" placeholder=""></textarea>
|
||
<div class="modal-actions">
|
||
<button class="modal-btn" onclick="hideImportModal()">取消</button>
|
||
<button class="primary" onclick="doImport()">导入</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== 重置 modal ===== -->
|
||
<div class="modal-overlay" id="resetModal">
|
||
<div class="modal">
|
||
<h3>🗑️ 重置答题记录</h3>
|
||
<p>这会清空所有"已掌握 / 错题 / 答题状态",但题目本身不会删除。确定吗?</p>
|
||
<div class="modal-actions">
|
||
<button class="modal-btn" onclick="hideResetModal()">取消</button>
|
||
<button class="primary danger" style="background:#ff5d77;" onclick="doReset()">确认重置</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== 模拟考答题界面 ===== -->
|
||
<div class="exam-overlay" id="examOverlay">
|
||
<div class="exam-topbar">
|
||
<h2>📝 模拟考</h2>
|
||
<div class="exam-progress">
|
||
<div class="exam-progress-bar"><div class="exam-progress-fill" id="examProgressFill" style="width:0%"></div></div>
|
||
<span class="exam-progress-text" id="examProgressText">0 / 0</span>
|
||
</div>
|
||
<span class="exam-timer" id="examTimer">⏱ 0:00</span>
|
||
<button class="exam-quit-btn" onclick="quitExam()">退出</button>
|
||
</div>
|
||
<div class="exam-body" id="examBody"></div>
|
||
<div class="exam-submit-bar">
|
||
<button class="exam-submit-btn" id="examSubmitBtn" onclick="submitExam()">📤 提交答卷</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== 成绩页 ===== -->
|
||
<div class="exam-results" id="examResults">
|
||
<div class="results-header">
|
||
<div class="results-score" id="resultsScore">0</div>
|
||
<div class="results-detail" id="resultsDetail"></div>
|
||
<div class="results-actions">
|
||
<button class="primary" onclick="closeResults()">返回</button>
|
||
<button onclick="retakeExam()">🔄 再考一次</button>
|
||
</div>
|
||
</div>
|
||
<div class="results-body" id="resultsBody"></div>
|
||
</div>
|
||
|
||
<!-- ===== 题库数据(脚本注入) ===== -->
|
||
<script>
|
||
window.__QUIZ_DATA__ = [{"type": "single_choice", "prompt": "下列关于并联电路电流规律的说法,正确的是( )。", "options": ["A. 干路电流等于各支路电流之差", "B. 干路电流等于各支路电流之和", "C. 各支路电流相等", "D. 干路电流大于任一支路电流的两倍"], "answer": "B", "explanation": "并联电路中,**干路电流等于各支路电流之和**:I = I₁ + I₂ + ...。各支路电流不一定相等(取决于电阻)。", "knowledge_point": "并联电路电流规律", "category": "物理 / 电学", "level": 1}, {"type": "single_choice", "prompt": "汽油机燃烧不充分时(冒黑烟),发动机的热机效率会( )。", "options": ["A. 升高", "B. 降低", "C. 不变", "D. 先升高后降低"], "answer": "B", "explanation": "燃烧不充分意味着燃料中的化学能没有完全转化为内能,更多能量以未燃烧的碳颗粒(黑烟)形式损失,所以**有效利用的能量比例下降**,效率**降低**。\n\n📌 经典易错点:凡是\"不充分\",效率一定降低!", "knowledge_point": "热机效率", "category": "物理 / 热学", "level": 2}, {"type": "true_false", "prompt": "串联电路中,每个用电器两端的电压相等。", "answer": "False", "explanation": "错误。串联电路中**总电压等于各部分电压之和**(U = U₁ + U₂),各用电器两端的电压**不一定相等**,与电阻成正比。只有阻值相同的用电器电压才相等。", "knowledge_point": "串联电路电压规律", "category": "物理 / 电学", "level": 1}, {"type": "fill_blank", "prompt": "一辆汽车做功冲程对外做功 120J,飞轮转速 1500 r/min,则 1 分钟内做功 ____ 次,功率为 ____ W。", "answer": "750次, 1500W", "explanation": "四冲程汽油机每 2 圈完成 1 个工作循环(即做功 1 次)。\n- 1 分钟内圈数 = 1500\n- 做功次数 = 1500 ÷ 2 = **750** 次\n- 功率 P = W/t = 120 × 750 / 60 = **1500 W**", "knowledge_point": "热机做功与功率计算", "category": "物理 / 热学", "level": 3}, {"type": "single_choice", "prompt": "下列分数中,与 3/4 大小相等的是( )。", "options": ["A. 6/12", "B. 9/12", "C. 12/15", "D. 15/24"], "answer": "B", "explanation": "分数的基本性质:分子分母同时乘以同一个数,分数大小不变。\n3/4 = 3×3 / 4×3 = **9/12**", "knowledge_point": "分数的基本性质", "category": "数学 / 分数", "level": 1}, {"type": "fill_blank", "prompt": "把 0.75 化成分数是 ____(最简分数)。", "answer": "3/4", "explanation": "0.75 = 75/100 = **3/4**(分子分母同时除以最大公因数 25)", "knowledge_point": "小数与分数互化", "category": "数学 / 分数", "level": 1}, {"type": "short_answer", "prompt": "请用自己的话解释\"并联电路中,干路电流为什么等于各支路电流之和\"。", "answer": "因为电流不会凭空产生也不会凭空消失(电荷守恒)。在并联电路的交点处,从干路流入的电荷必须等于流向各支路的电荷总和,所以干路电流 = 各支路电流之和。可以用\"水流分叉\"来类比:主管道的水量 = 各分支管道水量之和。", "explanation": "这是简答题,答案要点:① 电荷守恒思想;② 交点处电荷收支平衡;③ 能用例子说明加分。", "knowledge_point": "并联电路电流规律", "category": "物理 / 电学", "level": 3}, {"type": "single_choice", "prompt": "下面哪个词语用得不恰当?\n\n小明的作文写得很**生动**,老师读完后**心旷神怡**。", "options": ["A. 生动 用得恰当", "B. 心旷神怡 用得不恰当", "C. 两个词都恰当", "D. 两个词都不恰当"], "answer": "B", "explanation": "**心旷神怡**指看到美景或好事,心情舒畅;多用于描述风景、环境。\n用来形容读作文的感受不太合适,应该用\"赞不绝口\"\"拍案叫绝\"之类的词。", "knowledge_point": "成语运用", "category": "语文 / 词语", "level": 2}];
|
||
window.__META__ = {"id": "demo", "title": "StudyBuddy 题库示例", "subtitle": "K12 多学科 · 8 道题"};
|
||
</script>
|
||
|
||
<script>
|
||
'use strict';
|
||
|
||
/* ============================================
|
||
* Quiz Engine - K12 StudyBuddy 4.0
|
||
* 受 v15 启发,针对 K12 多学科多题型优化
|
||
* ============================================ */
|
||
const META = window.__META__ || {};
|
||
const STORAGE_KEY = 'k12_quiz_' + (META.id || 'default');
|
||
const FILTER_KEY = STORAGE_KEY + '_filter';
|
||
const CATS_KEY = STORAGE_KEY + '_cats';
|
||
const THEME_KEY = 'k12_quiz_theme';
|
||
|
||
const TYPE_LABELS = {
|
||
single_choice: { txt: '选择', cls: 'type-choice' },
|
||
true_false: { txt: '判断', cls: 'type-tf' },
|
||
fill_blank: { txt: '填空', cls: 'type-blank' },
|
||
short_answer: { txt: '简答', cls: 'type-short' }
|
||
};
|
||
const LEVEL_LABELS = { 1: 'L1·识记', 2: 'L2·理解', 3: 'L3·应用' };
|
||
const STATE_LABELS = { mastered: '✅ 已掌握', unstudied: '⬜ 未做', error: '❌ 错题' };
|
||
|
||
let QS = []; // 全部题目(含 id)
|
||
let states = {}; // {id: 'mastered'|'unstudied'|'error'}
|
||
let answered = {}; // {id: userAnswer}
|
||
let activeFilters = { mastered: true, unstudied: true, error: true };
|
||
let activeCats = new Set(); // 当前激活的学科
|
||
let currentId = null;
|
||
let filteredIds = [];
|
||
|
||
/* ===== 启动 ===== */
|
||
function init() {
|
||
// 主题恢复(默认 light)
|
||
try {
|
||
const t = localStorage.getItem(THEME_KEY);
|
||
if (t === 'dark') {
|
||
document.body.classList.remove('light');
|
||
document.body.classList.add('dark');
|
||
document.querySelector('.theme-toggle').textContent = '🌙';
|
||
}
|
||
} catch (e) {}
|
||
|
||
QS = (window.__QUIZ_DATA__ || []).map((q, i) => Object.assign({ id: i + 1 }, q));
|
||
if (QS.length === 0) {
|
||
document.getElementById('leftScroll').innerHTML = '<div style="padding:20px;color:#a8adbd;font-size:13px;text-align:center;">题库为空,请先导入题目</div>';
|
||
return;
|
||
}
|
||
|
||
loadState();
|
||
loadFilters();
|
||
buildCatChips();
|
||
buildSidebar();
|
||
applyFilters();
|
||
updateStats();
|
||
}
|
||
|
||
function loadState() {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (raw) {
|
||
const obj = JSON.parse(raw);
|
||
states = obj.states || {};
|
||
answered = obj.answered || {};
|
||
}
|
||
} catch (e) { states = {}; answered = {}; }
|
||
QS.forEach(q => { if (!states[q.id]) states[q.id] = 'unstudied'; });
|
||
}
|
||
|
||
function saveState() {
|
||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ states, answered })); } catch (e) {}
|
||
}
|
||
|
||
function loadFilters() {
|
||
try {
|
||
const raw = localStorage.getItem(FILTER_KEY);
|
||
if (raw) activeFilters = Object.assign(activeFilters, JSON.parse(raw));
|
||
} catch (e) {}
|
||
document.querySelectorAll('.sf-btn').forEach(btn => {
|
||
if (!activeFilters[btn.dataset.f]) btn.classList.add('off');
|
||
});
|
||
|
||
// 学科:默认全选
|
||
const cats = [...new Set(QS.map(q => (q.category || '通用').trim()))];
|
||
try {
|
||
const raw = localStorage.getItem(CATS_KEY);
|
||
const arr = raw ? JSON.parse(raw) : null;
|
||
activeCats = new Set(Array.isArray(arr) && arr.length ? arr : cats);
|
||
} catch (e) { activeCats = new Set(cats); }
|
||
}
|
||
|
||
/* ===== 分类 chips(按"学科/子分类"完整路径展示) ===== */
|
||
function getCatKey(q) {
|
||
// category 字段已含 "物理 / 电学" 这种完整路径,直接用即可
|
||
return (q.category || '通用').trim();
|
||
}
|
||
function buildCatChips() {
|
||
const cats = [...new Set(QS.map(getCatKey))];
|
||
const counts = {};
|
||
cats.forEach(c => { counts[c] = QS.filter(q => getCatKey(q) === c).length; });
|
||
|
||
const wrap = document.getElementById('catFilter');
|
||
wrap.querySelectorAll('.cat-chip').forEach(el => el.remove());
|
||
cats.forEach(c => {
|
||
const chip = document.createElement('span');
|
||
chip.className = 'cat-chip' + (activeCats.has(c) ? ' active' : '');
|
||
chip.dataset.cat = c;
|
||
chip.innerHTML = `${escapeHtml(c)}<span class="chip-count">${counts[c]}</span>`;
|
||
chip.onclick = () => toggleCat(c, chip);
|
||
wrap.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
function toggleCat(cat, el) {
|
||
if (activeCats.has(cat)) {
|
||
if (activeCats.size <= 1) return; // 至少保留一个
|
||
activeCats.delete(cat);
|
||
el.classList.remove('active');
|
||
} else {
|
||
activeCats.add(cat);
|
||
el.classList.add('active');
|
||
}
|
||
try { localStorage.setItem(CATS_KEY, JSON.stringify([...activeCats])); } catch (e) {}
|
||
buildSidebar();
|
||
applyFilters();
|
||
}
|
||
|
||
/* ===== 侧栏 ===== */
|
||
function buildSidebar() {
|
||
const groups = {};
|
||
QS.forEach(q => {
|
||
const cat = getCatKey(q);
|
||
if (!activeCats.has(cat)) return;
|
||
const sub = q.knowledge_point || q.knowledge || '未分类';
|
||
if (!groups[cat]) groups[cat] = {};
|
||
if (!groups[cat][sub]) groups[cat][sub] = [];
|
||
groups[cat][sub].push(q);
|
||
});
|
||
|
||
const container = document.getElementById('leftScroll');
|
||
container.innerHTML = '';
|
||
Object.keys(groups).forEach(cat => {
|
||
const subs = groups[cat];
|
||
const total = Object.values(subs).reduce((s, arr) => s + arr.length, 0);
|
||
let html = `<div class="cat-group"><div class="cat-top">📂 ${escapeHtml(cat)} <span class="cat-count">${total}题</span></div>`;
|
||
Object.keys(subs).forEach(sub => {
|
||
html += `<div class="cat-sub"><div class="cat-sub-name">${escapeHtml(sub)}</div><div class="cat-nums">`;
|
||
subs[sub].forEach(q => {
|
||
const st = states[q.id] || 'unstudied';
|
||
html += `<span class="qnum ${st}" data-id="${q.id}" onclick="selectQ(${q.id})">${q.id}</span>`;
|
||
});
|
||
html += `</div></div>`;
|
||
});
|
||
html += `</div>`;
|
||
container.insertAdjacentHTML('beforeend', html);
|
||
});
|
||
}
|
||
|
||
function applyStatesToDOM() {
|
||
document.querySelectorAll('.qnum').forEach(el => {
|
||
el.classList.remove('mastered', 'unstudied', 'error');
|
||
const id = parseInt(el.dataset.id, 10);
|
||
el.classList.add(states[id] || 'unstudied');
|
||
});
|
||
}
|
||
|
||
function updateStats() {
|
||
let m = 0, u = 0, e = 0;
|
||
QS.forEach(q => {
|
||
if (!activeCats.has(getCatKey(q))) return;
|
||
const s = states[q.id] || 'unstudied';
|
||
if (s === 'mastered') m++;
|
||
else if (s === 'error') e++;
|
||
else u++;
|
||
});
|
||
document.getElementById('numMastered').textContent = m;
|
||
document.getElementById('numUnstudied').textContent = u;
|
||
document.getElementById('numError').textContent = e;
|
||
const total = m + u + e;
|
||
document.getElementById('progressTxt').textContent = `${m}/${total}`;
|
||
}
|
||
|
||
/* ===== 过滤 ===== */
|
||
function toggleFilter(type, btn) {
|
||
activeFilters[type] = !activeFilters[type];
|
||
btn.classList.toggle('off', !activeFilters[type]);
|
||
try { localStorage.setItem(FILTER_KEY, JSON.stringify(activeFilters)); } catch (e) {}
|
||
applyFilters();
|
||
}
|
||
|
||
function applyFilters() {
|
||
filteredIds = [];
|
||
document.querySelectorAll('.qnum').forEach(el => {
|
||
const id = parseInt(el.dataset.id, 10);
|
||
const st = states[id] || 'unstudied';
|
||
const visible = activeFilters[st];
|
||
el.classList.toggle('filtered-out', !visible);
|
||
if (visible) filteredIds.push(id);
|
||
});
|
||
updateStats();
|
||
updateNav();
|
||
}
|
||
|
||
/* ===== 选题 + 渲染 ===== */
|
||
window.selectQ = function (id) {
|
||
currentId = id;
|
||
document.querySelectorAll('.qnum').forEach(el => {
|
||
el.classList.toggle('active', parseInt(el.dataset.id, 10) === id);
|
||
});
|
||
const q = QS.find(x => x.id === id);
|
||
if (!q) return;
|
||
renderQuestion(q);
|
||
document.getElementById('navBar').style.display = 'flex';
|
||
updateNav();
|
||
// 移动端:选完自动收起侧栏
|
||
if (window.innerWidth <= 768) {
|
||
document.getElementById('sidebar').classList.add('hidden');
|
||
}
|
||
};
|
||
|
||
function renderQuestion(q) {
|
||
const typeInfo = TYPE_LABELS[q.type] || { txt: '题', cls: 'type-choice' };
|
||
const lvl = LEVEL_LABELS[q.level] || '';
|
||
const kp = q.knowledge_point || q.knowledge || '';
|
||
const st = states[q.id] || 'unstudied';
|
||
|
||
let html = `<div class="q-card">
|
||
<div class="q-meta">
|
||
<span class="q-id">Q${q.id}</span>
|
||
<span class="q-tag ${typeInfo.cls}">${typeInfo.txt}</span>
|
||
${lvl ? `<span class="q-tag level">${lvl}</span>` : ''}
|
||
<button class="q-state-btn ${st}" id="qStateBtn" onclick="cycleState()" title="点击手动切换:未做 → 已掌握 → 错题">${STATE_LABELS[st]} <span style="opacity:.5;font-weight:400;">▾</span></button>
|
||
${kp ? `<span class="q-knowledge" title="${escapeHtml(kp)}">🏷️ ${escapeHtml(kp)}</span>` : ''}
|
||
</div>
|
||
<div class="q-text">${renderMD(q.prompt || q.question)}</div>`;
|
||
|
||
if (q.type === 'single_choice') {
|
||
html += `<div class="opts" id="qOpts">`;
|
||
(q.options || []).forEach((opt, i) => {
|
||
const m = opt.match(/^([A-Z])[\.\、]\s*(.+)/);
|
||
const letter = m ? m[1] : String.fromCharCode(65 + i);
|
||
const text = m ? m[2] : opt;
|
||
html += `<div class="opt" data-key="${letter}" onclick="chooseOpt(this,'${letter}',${q.id})">
|
||
<span class="letter">${letter}</span><span class="text">${renderMD(text)}</span>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
} else if (q.type === 'true_false') {
|
||
html += `<div class="opts" id="qOpts">
|
||
<div class="opt" data-key="True" onclick="chooseOpt(this,'True',${q.id})"><span class="letter">✓</span><span class="text">正确</span></div>
|
||
<div class="opt" data-key="False" onclick="chooseOpt(this,'False',${q.id})"><span class="letter">✗</span><span class="text">错误</span></div>
|
||
</div>`;
|
||
} else if (q.type === 'fill_blank') {
|
||
html += `<input class="q-input short" id="qInput" placeholder="在这里输入你的答案..." />`;
|
||
} else if (q.type === 'short_answer') {
|
||
html += `<textarea class="q-input long" id="qInput" placeholder="在这里写下你的回答..."></textarea>`;
|
||
}
|
||
|
||
if (q.type === 'fill_blank' || q.type === 'short_answer') {
|
||
html += `<div class="q-actions">
|
||
<button class="btn-submit" id="btnSubmit" onclick="submitTextAnswer(${q.id})">提交 (Enter)</button>
|
||
<button class="btn-reveal" onclick="revealAnswer(${q.id})">查看答案</button>
|
||
<button class="btn-reset" onclick="resetQ(${q.id})">↻ 重做</button>
|
||
</div>`;
|
||
} else {
|
||
html += `<div class="q-actions">
|
||
<button class="btn-submit" id="btnSubmit" onclick="submitChoice(${q.id})" disabled>提交 (Enter)</button>
|
||
<button class="btn-reveal" onclick="revealAnswer(${q.id})">查看答案</button>
|
||
<button class="btn-reset" onclick="resetQ(${q.id})">↻ 重做</button>
|
||
</div>`;
|
||
}
|
||
|
||
html += `<div class="auto-state-hint" id="autoHint"></div>
|
||
<div class="analysis" id="analysis">
|
||
<div class="ans-line" id="ansLine"></div>
|
||
<div class="ans-text" id="ansText"></div>
|
||
<div class="tip" id="tipBox"></div>
|
||
<div class="kw" id="kwDetail"></div>
|
||
</div>
|
||
</div>`;
|
||
|
||
document.getElementById('rightInner').innerHTML = html;
|
||
|
||
// 恢复已答状态
|
||
if (answered[q.id] !== undefined) {
|
||
if (q.type === 'single_choice' || q.type === 'true_false') {
|
||
const el = document.querySelector(`#qOpts .opt[data-key="${answered[q.id]}"]`);
|
||
if (el) showChoiceAnswer(q, answered[q.id]);
|
||
} else {
|
||
const inp = document.getElementById('qInput');
|
||
if (inp) { inp.value = answered[q.id]; inp.disabled = true; }
|
||
showTextAnswer(q, answered[q.id]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ===== 选择题 / 判断题作答 =====
|
||
* 新逻辑:选择 ≠ 提交
|
||
* - 点选项:仅高亮,不锁定,不判分
|
||
* - 点【提交】或按 Enter:判分;答错首次仅提示「再想想」并允许重选
|
||
* - 答错第二次:才显示答案 + 标错题
|
||
*/
|
||
window.__pendingChoice = null;
|
||
window.__wrongTriedOnce = {}; // {id: true} 记录这题是否已经错过一次
|
||
|
||
window.chooseOpt = function (el, key, id) {
|
||
// 已最终提交过的不能再选
|
||
if (answered[id] !== undefined) return;
|
||
window.__pendingChoice = key;
|
||
document.querySelectorAll('#qOpts .opt').forEach(o => {
|
||
o.classList.remove('selected');
|
||
});
|
||
el.classList.add('selected');
|
||
// 启用提交按钮
|
||
const btn = document.getElementById('btnSubmit');
|
||
if (btn) btn.disabled = false;
|
||
};
|
||
|
||
window.submitChoice = function (id) {
|
||
const q = QS.find(x => x.id === id);
|
||
if (!q) return;
|
||
const key = window.__pendingChoice;
|
||
if (!key) { showRetryHint('先选一个选项呀~ 😊'); return; }
|
||
const stdU = String(q.answer || '').toUpperCase();
|
||
const correct = key.toUpperCase() === stdU;
|
||
|
||
if (correct) {
|
||
answered[id] = key;
|
||
window.__wrongTriedOnce[id] = false;
|
||
showChoiceAnswer(q, key, true);
|
||
autoUpdateState(q, true);
|
||
} else {
|
||
if (!window.__wrongTriedOnce[id]) {
|
||
// 第一次错:给一次重试机会,不锁,不标错题
|
||
window.__wrongTriedOnce[id] = true;
|
||
const errOpt = document.querySelector(`#qOpts .opt[data-key="${key}"]`);
|
||
if (errOpt) {
|
||
errOpt.classList.add('wrong-shake');
|
||
setTimeout(() => errOpt.classList.remove('wrong-shake'), 600);
|
||
}
|
||
showRetryHint('🤔 不对哦,再想想?(这次错不算,可以重选)');
|
||
window.__pendingChoice = null;
|
||
if (errOpt) errOpt.classList.remove('selected');
|
||
return;
|
||
}
|
||
// 第二次错:真正锁定 + 标错题
|
||
answered[id] = key;
|
||
showChoiceAnswer(q, key, false);
|
||
autoUpdateState(q, false);
|
||
}
|
||
};
|
||
|
||
function showRetryHint(msg) {
|
||
const hint = document.getElementById('autoHint');
|
||
hint.className = 'auto-state-hint retry';
|
||
hint.textContent = msg;
|
||
// 自动消失
|
||
clearTimeout(window.__retryTimer);
|
||
window.__retryTimer = setTimeout(() => {
|
||
if (hint.classList.contains('retry')) {
|
||
hint.className = 'auto-state-hint';
|
||
hint.textContent = '';
|
||
}
|
||
}, 3500);
|
||
}
|
||
|
||
function showChoiceAnswer(q, chosen, correct) {
|
||
const stdU = String(q.answer || '').toUpperCase();
|
||
document.querySelectorAll('#qOpts .opt').forEach(el => {
|
||
el.classList.add('disabled');
|
||
el.classList.remove('selected');
|
||
const k = el.dataset.key.toUpperCase();
|
||
if (k === stdU) el.classList.add('correct');
|
||
if (k === String(chosen).toUpperCase() && k !== stdU) el.classList.add('wrong');
|
||
});
|
||
// 提交按钮锁死
|
||
const btn = document.getElementById('btnSubmit');
|
||
if (btn) { btn.disabled = true; btn.textContent = '已提交'; }
|
||
showAnalysis(q, chosen, correct);
|
||
}
|
||
|
||
/* ===== 填空 / 简答作答 ===== */
|
||
window.submitTextAnswer = function (id) {
|
||
const q = QS.find(x => x.id === id);
|
||
if (!q) return;
|
||
const inp = document.getElementById('qInput');
|
||
const userAns = (inp && inp.value || '').trim();
|
||
if (!userAns) { alert('先写点答案再提交呀~ ✍️'); return; }
|
||
answered[id] = userAns;
|
||
inp.disabled = true;
|
||
document.getElementById('btnSubmit').disabled = true;
|
||
document.getElementById('btnSubmit').textContent = '已提交';
|
||
|
||
if (q.type === 'fill_blank') {
|
||
const correct = fuzzyMatch(userAns, q.answer);
|
||
showAnalysis(q, userAns, correct);
|
||
autoUpdateState(q, correct);
|
||
} else {
|
||
// 简答题:不强判
|
||
showAnalysis(q, userAns, null);
|
||
// 状态不动,由用户手动判定(点 q-state-btn)
|
||
saveState();
|
||
}
|
||
};
|
||
|
||
window.revealAnswer = function (id) {
|
||
const q = QS.find(x => x.id === id);
|
||
if (!q) return;
|
||
showAnalysis(q, answered[id] || '', null);
|
||
};
|
||
|
||
function showTextAnswer(q, userAns) {
|
||
if (q.type === 'fill_blank') {
|
||
const correct = fuzzyMatch(userAns, q.answer);
|
||
showAnalysis(q, userAns, correct);
|
||
} else {
|
||
showAnalysis(q, userAns, null);
|
||
}
|
||
const btn = document.getElementById('btnSubmit');
|
||
if (btn) { btn.disabled = true; btn.textContent = '已提交'; }
|
||
}
|
||
|
||
function fuzzyMatch(a, b) {
|
||
if (a === null || b === null || a === undefined || b === undefined) return false;
|
||
const norm = s => String(s).replace(/[\s\.,,。、;;::!!??""''""()()]/g, '').toLowerCase();
|
||
return norm(a) === norm(b);
|
||
}
|
||
|
||
/* ===== 显示解析 ===== */
|
||
function showAnalysis(q, userAns, correct) {
|
||
const an = document.getElementById('analysis');
|
||
const line = document.getElementById('ansLine');
|
||
const txt = document.getElementById('ansText');
|
||
const tip = document.getElementById('tipBox');
|
||
const kw = document.getElementById('kwDetail');
|
||
|
||
an.classList.remove('correct', 'wrong');
|
||
let lineHtml = '';
|
||
if (q.type === 'short_answer') {
|
||
lineHtml = `📝 参考答案:${renderMD(String(q.answer || ''))}`;
|
||
} else if (correct) {
|
||
an.classList.add('correct');
|
||
lineHtml = `🎉 答对啦!正确答案:<strong>${renderMD(String(q.answer || ''))}</strong>`;
|
||
} else if (correct === false) {
|
||
an.classList.add('wrong');
|
||
lineHtml = `❌ 你的答案是 <span style="text-decoration:line-through;opacity:.7;">${escapeHtml(userAns)}</span>,正确答案是 <strong>${renderMD(String(q.answer || ''))}</strong>`;
|
||
} else {
|
||
lineHtml = `📌 正确答案:<strong>${renderMD(String(q.answer || ''))}</strong>`;
|
||
}
|
||
line.innerHTML = lineHtml;
|
||
|
||
txt.innerHTML = q.explanation ? '<strong>💡 解析:</strong><br>' + renderMD(q.explanation) : '';
|
||
if (q.memory_tip) { tip.innerHTML = '🧠 ' + renderMD(q.memory_tip); tip.classList.add('visible'); }
|
||
else { tip.classList.remove('visible'); tip.innerHTML = ''; }
|
||
const knp = q.knowledge_point || q.knowledge;
|
||
kw.innerHTML = knp ? '📚 知识点:' + escapeHtml(knp) : '';
|
||
|
||
an.classList.add('visible');
|
||
}
|
||
|
||
/* ===== 状态自动 / 手动 ===== */
|
||
function autoUpdateState(q, correct) {
|
||
const prev = states[q.id] || 'unstudied';
|
||
if (prev !== 'unstudied') { saveState(); return; } // 已有状态不覆盖
|
||
states[q.id] = correct ? 'mastered' : 'error';
|
||
saveState();
|
||
|
||
const hint = document.getElementById('autoHint');
|
||
if (correct) {
|
||
hint.className = 'auto-state-hint mastered';
|
||
hint.textContent = '✅ 回答正确,状态自动更新为「已掌握」';
|
||
} else {
|
||
hint.className = 'auto-state-hint error';
|
||
hint.textContent = '❌ 这题答错了,已加入「错题本」~ 别灰心,多看看解析就掌握啦';
|
||
}
|
||
document.getElementById('qStateBtn').className = 'q-state-btn ' + states[q.id];
|
||
document.getElementById('qStateBtn').textContent = STATE_LABELS[states[q.id]];
|
||
applyStatesToDOM();
|
||
applyFilters();
|
||
}
|
||
|
||
window.cycleState = function () {
|
||
if (!currentId) return;
|
||
const order = ['unstudied', 'mastered', 'error'];
|
||
const cur = states[currentId] || 'unstudied';
|
||
const next = order[(order.indexOf(cur) + 1) % order.length];
|
||
states[currentId] = next;
|
||
saveState();
|
||
document.getElementById('qStateBtn').className = 'q-state-btn ' + next;
|
||
document.getElementById('qStateBtn').textContent = STATE_LABELS[next];
|
||
applyStatesToDOM();
|
||
applyFilters();
|
||
};
|
||
|
||
window.resetQ = function (id) {
|
||
states[id] = 'unstudied';
|
||
delete answered[id];
|
||
saveState();
|
||
selectQ(id);
|
||
applyStatesToDOM();
|
||
applyFilters();
|
||
};
|
||
|
||
/* ===== 导航 ===== */
|
||
function updateNav() {
|
||
const info = document.getElementById('navInfo');
|
||
const prev = document.getElementById('prevBtn');
|
||
const next = document.getElementById('nextBtn');
|
||
if (!currentId || filteredIds.length === 0) { info.textContent = ''; prev.disabled = true; next.disabled = true; return; }
|
||
const idx = filteredIds.indexOf(currentId);
|
||
if (idx < 0) { info.textContent = `当前题在筛选外`; prev.disabled = true; next.disabled = true; return; }
|
||
info.textContent = `${idx + 1} / ${filteredIds.length}(当前筛选)`;
|
||
prev.disabled = idx <= 0;
|
||
next.disabled = idx >= filteredIds.length - 1;
|
||
}
|
||
|
||
window.goPrev = function () { const i = filteredIds.indexOf(currentId); if (i > 0) selectQ(filteredIds[i - 1]); };
|
||
window.goNext = function () { const i = filteredIds.indexOf(currentId); if (i >= 0 && i < filteredIds.length - 1) selectQ(filteredIds[i + 1]); };
|
||
|
||
/* ===== 随机抽题 ===== */
|
||
window.randomQ = function () {
|
||
if (filteredIds.length === 0) { alert('当前筛选下没有题目 🙃'); return; }
|
||
selectQ(filteredIds[Math.floor(Math.random() * filteredIds.length)]);
|
||
};
|
||
|
||
/* ===== 导入导出 ===== */
|
||
window.exportProgress = function () {
|
||
const data = { meta: META, states, answered, exportedAt: new Date().toISOString() };
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = `quiz_progress_${(META.id || 'quiz')}_${Date.now()}.json`; a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
let importMode = 'questions';
|
||
window.showImportModal = function (mode) {
|
||
importMode = mode;
|
||
document.getElementById('importTitle').textContent = mode === 'questions' ? '📥 导入题库' : '📂 导入进度';
|
||
document.getElementById('importHint').textContent = mode === 'questions'
|
||
? '粘贴题目 JSON 数组(会覆盖当前题库)'
|
||
: '粘贴之前导出的进度 JSON';
|
||
document.getElementById('importText').value = '';
|
||
document.getElementById('importText').placeholder = mode === 'questions'
|
||
? '[\n {"type":"single_choice","prompt":"...","options":["A. ...","B. ..."],"answer":"A","explanation":"...","category":"数学","knowledge_point":"分数"}\n]'
|
||
: '{"states":{...},"answered":{...}}';
|
||
document.getElementById('importModal').classList.add('visible');
|
||
};
|
||
window.hideImportModal = function () { document.getElementById('importModal').classList.remove('visible'); };
|
||
|
||
window.doImport = function () {
|
||
const txt = document.getElementById('importText').value.trim();
|
||
if (!txt) { alert('先粘贴点东西嘛 😊'); return; }
|
||
try {
|
||
const obj = JSON.parse(txt);
|
||
if (importMode === 'questions') {
|
||
if (!Array.isArray(obj)) { alert('题目必须是数组格式'); return; }
|
||
window.__QUIZ_DATA__ = obj;
|
||
states = {}; answered = {};
|
||
localStorage.removeItem(STORAGE_KEY);
|
||
QS = obj.map((q, i) => Object.assign({ id: i + 1 }, q));
|
||
QS.forEach(q => { states[q.id] = 'unstudied'; });
|
||
buildCatChips();
|
||
buildSidebar();
|
||
applyFilters();
|
||
updateStats();
|
||
hideImportModal();
|
||
alert(`✅ 导入 ${obj.length} 道题`);
|
||
} else {
|
||
if (!obj.states) { alert('进度文件格式不对'); return; }
|
||
states = obj.states;
|
||
answered = obj.answered || {};
|
||
saveState();
|
||
applyStatesToDOM();
|
||
applyFilters();
|
||
hideImportModal();
|
||
alert('✅ 进度已恢复');
|
||
}
|
||
} catch (e) {
|
||
alert('❌ JSON 解析失败:' + e.message);
|
||
}
|
||
};
|
||
|
||
window.showResetModal = function () { document.getElementById('resetModal').classList.add('visible'); };
|
||
window.hideResetModal = function () { document.getElementById('resetModal').classList.remove('visible'); };
|
||
window.doReset = function () {
|
||
states = {}; answered = {};
|
||
QS.forEach(q => { states[q.id] = 'unstudied'; });
|
||
saveState();
|
||
applyStatesToDOM();
|
||
applyFilters();
|
||
hideResetModal();
|
||
document.getElementById('rightInner').innerHTML = '<div class="empty-state"><div class="icon">✨</div><div>已重置,重新开始!</div></div>';
|
||
document.getElementById('navBar').style.display = 'none';
|
||
currentId = null;
|
||
};
|
||
|
||
/* ===== 主题 ===== */
|
||
window.toggleTheme = function () {
|
||
const isDark = document.body.classList.contains('dark');
|
||
document.body.classList.toggle('dark', !isDark);
|
||
document.body.classList.toggle('light', isDark);
|
||
document.querySelector('.theme-toggle').textContent = isDark ? '☀️' : '🌙';
|
||
try { localStorage.setItem(THEME_KEY, isDark ? 'light' : 'dark'); } catch (e) {}
|
||
};
|
||
|
||
/* ===== 侧栏(移动) ===== */
|
||
window.toggleSidebar = function () {
|
||
document.getElementById('sidebar').classList.toggle('hidden');
|
||
};
|
||
|
||
/* ============================================
|
||
* 模拟考模式
|
||
* ============================================ */
|
||
let examConfig = { count: 10, timeLimit: 0 };
|
||
let examQuestions = [];
|
||
let examAnswers = {};
|
||
let examTimerInterval = null;
|
||
let examTimeLeft = 0;
|
||
let examStartTime = 0;
|
||
|
||
window.selectExamCount = function (n) {
|
||
examConfig.count = n;
|
||
document.querySelectorAll('#examModal [data-n]').forEach(b => {
|
||
b.classList.toggle('selected', parseInt(b.dataset.n, 10) === n);
|
||
});
|
||
};
|
||
window.selectExamTime = function (t) {
|
||
examConfig.timeLimit = t;
|
||
document.querySelectorAll('#examModal [data-t]').forEach(b => {
|
||
b.classList.toggle('selected', parseInt(b.dataset.t, 10) === t);
|
||
});
|
||
};
|
||
|
||
window.showExamModal = function () {
|
||
const pool = QS.filter(q => activeCats.has(getCatKey(q)));
|
||
document.getElementById('examPoolSize').textContent = `当前题池:${pool.length} 题(按学科筛选)`;
|
||
document.getElementById('examModal').classList.add('visible');
|
||
};
|
||
window.hideExamModal = function () { document.getElementById('examModal').classList.remove('visible'); };
|
||
|
||
window.startExam = function () {
|
||
hideExamModal();
|
||
const pool = QS.filter(q => activeCats.has(getCatKey(q))).slice();
|
||
if (pool.length === 0) { alert('题池为空'); return; }
|
||
// shuffle
|
||
for (let i = pool.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||
}
|
||
examQuestions = pool.slice(0, Math.min(examConfig.count, pool.length));
|
||
examAnswers = {};
|
||
examStartTime = Date.now();
|
||
|
||
let html = '';
|
||
examQuestions.forEach((q, idx) => {
|
||
const typeInfo = TYPE_LABELS[q.type] || { txt: '', cls: '' };
|
||
html += `<div class="exam-q-card" id="eq-${q.id}">
|
||
<div class="exam-q-num">第 ${idx + 1} 题 · ${typeInfo.txt}</div>
|
||
<div class="exam-q-text">${renderMD(q.prompt || q.question)}</div>`;
|
||
if (q.type === 'single_choice') {
|
||
html += `<div class="exam-opts">`;
|
||
(q.options || []).forEach((opt, i) => {
|
||
const m = opt.match(/^([A-Z])[\.\、]\s*(.+)/);
|
||
const letter = m ? m[1] : String.fromCharCode(65 + i);
|
||
const text = m ? m[2] : opt;
|
||
html += `<div class="exam-opt" data-qid="${q.id}" data-key="${letter}" onclick="examChoose(this,${q.id},'${letter}')">
|
||
<span class="letter">${letter}</span><span>${renderMD(text)}</span>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
} else if (q.type === 'true_false') {
|
||
html += `<div class="exam-opts">
|
||
<div class="exam-opt" data-qid="${q.id}" data-key="True" onclick="examChoose(this,${q.id},'True')"><span class="letter">✓</span><span>正确</span></div>
|
||
<div class="exam-opt" data-qid="${q.id}" data-key="False" onclick="examChoose(this,${q.id},'False')"><span class="letter">✗</span><span>错误</span></div>
|
||
</div>`;
|
||
} else {
|
||
const tag = q.type === 'short_answer' ? 'textarea class="exam-input" rows="3"' : 'input class="exam-input"';
|
||
html += `<${tag} data-qid="${q.id}" oninput="examTextInput(${q.id},this.value)" placeholder="输入答案..."></${q.type === 'short_answer' ? 'textarea' : 'input'}>`;
|
||
}
|
||
html += `</div>`;
|
||
});
|
||
document.getElementById('examBody').innerHTML = html;
|
||
updateExamProgress();
|
||
document.getElementById('examOverlay').classList.add('visible');
|
||
document.getElementById('examSubmitBtn').disabled = false;
|
||
document.getElementById('examSubmitBtn').textContent = '📤 提交答卷';
|
||
|
||
// 计时
|
||
if (examTimerInterval) clearInterval(examTimerInterval);
|
||
if (examConfig.timeLimit > 0) {
|
||
examTimeLeft = examConfig.timeLimit * examQuestions.length;
|
||
updateExamTimer();
|
||
examTimerInterval = setInterval(() => {
|
||
examTimeLeft--;
|
||
updateExamTimer();
|
||
if (examTimeLeft <= 0) { clearInterval(examTimerInterval); submitExam(); }
|
||
}, 1000);
|
||
} else {
|
||
updateElapsed();
|
||
examTimerInterval = setInterval(updateElapsed, 1000);
|
||
}
|
||
};
|
||
|
||
function updateExamTimer() {
|
||
const el = document.getElementById('examTimer');
|
||
const m = Math.floor(examTimeLeft / 60), s = examTimeLeft % 60;
|
||
el.textContent = '⏱ ' + m + ':' + (s < 10 ? '0' : '') + s;
|
||
el.style.color = examTimeLeft <= 10 ? '#ff5d77' : '';
|
||
}
|
||
function updateElapsed() {
|
||
const e = Math.floor((Date.now() - examStartTime) / 1000);
|
||
const m = Math.floor(e / 60), s = e % 60;
|
||
document.getElementById('examTimer').textContent = '⏱ ' + m + ':' + (s < 10 ? '0' : '') + s;
|
||
}
|
||
|
||
window.examChoose = function (el, qid, key) {
|
||
examAnswers[qid] = key;
|
||
document.querySelectorAll(`#eq-${qid} .exam-opt`).forEach(o => o.classList.remove('selected'));
|
||
el.classList.add('selected');
|
||
updateExamProgress();
|
||
};
|
||
window.examTextInput = function (qid, val) {
|
||
examAnswers[qid] = val;
|
||
updateExamProgress();
|
||
};
|
||
|
||
function updateExamProgress() {
|
||
const total = examQuestions.length;
|
||
const done = Object.keys(examAnswers).filter(k => String(examAnswers[k] || '').trim() !== '').length;
|
||
document.getElementById('examProgressText').textContent = `${done} / ${total}`;
|
||
document.getElementById('examProgressFill').style.width = (total > 0 ? (done / total * 100) : 0) + '%';
|
||
}
|
||
|
||
window.submitExam = function () {
|
||
if (examTimerInterval) clearInterval(examTimerInterval);
|
||
document.getElementById('examOverlay').classList.remove('visible');
|
||
showResults();
|
||
};
|
||
|
||
window.quitExam = function () {
|
||
if (!confirm('确定退出本次模拟考?答题进度会丢失')) return;
|
||
if (examTimerInterval) clearInterval(examTimerInterval);
|
||
document.getElementById('examOverlay').classList.remove('visible');
|
||
};
|
||
|
||
function showResults() {
|
||
let correct = 0, gradable = 0;
|
||
const items = [];
|
||
examQuestions.forEach((q, idx) => {
|
||
const userAns = examAnswers[q.id];
|
||
let isCorrect = null;
|
||
if (q.type === 'single_choice' || q.type === 'true_false') {
|
||
gradable++;
|
||
isCorrect = String(userAns || '').toUpperCase() === String(q.answer || '').toUpperCase();
|
||
if (isCorrect) correct++;
|
||
} else if (q.type === 'fill_blank') {
|
||
gradable++;
|
||
isCorrect = fuzzyMatch(userAns, q.answer);
|
||
if (isCorrect) correct++;
|
||
}
|
||
// 同步主答题状态
|
||
if (isCorrect === true) states[q.id] = 'mastered';
|
||
else if (isCorrect === false) states[q.id] = 'error';
|
||
if (userAns !== undefined) answered[q.id] = userAns;
|
||
items.push({ q, userAns, isCorrect, idx });
|
||
});
|
||
saveState();
|
||
applyStatesToDOM();
|
||
applyFilters();
|
||
|
||
const score = gradable > 0 ? Math.round(correct / gradable * 100) : 0;
|
||
const passed = score >= 60;
|
||
const scoreEl = document.getElementById('resultsScore');
|
||
scoreEl.textContent = score;
|
||
scoreEl.className = 'results-score ' + (passed ? 'pass' : 'fail');
|
||
document.getElementById('resultsDetail').textContent =
|
||
`${examQuestions.length} 题 · 答对 ${correct}/${gradable}${gradable < examQuestions.length ? ` · ${examQuestions.length - gradable} 题需自评` : ''}`;
|
||
|
||
let html = '';
|
||
items.forEach(it => {
|
||
const { q, userAns, isCorrect, idx } = it;
|
||
const cls = isCorrect === true ? 'is-correct' : isCorrect === false ? 'is-wrong' : '';
|
||
const badge = isCorrect === true
|
||
? '<span class="result-badge correct">✓ 正确</span>'
|
||
: isCorrect === false
|
||
? '<span class="result-badge wrong">✗ 错误</span>'
|
||
: '<span class="result-badge" style="background:#fff4e0;color:#e9a82c;">📝 待自评</span>';
|
||
|
||
let ansHtml = '';
|
||
if (q.type === 'single_choice' || q.type === 'true_false' || q.type === 'fill_blank') {
|
||
if (isCorrect) {
|
||
ansHtml = `<div class="result-answers">✓ 你答:<span class="correct-ans">${escapeHtml(String(userAns || ''))}</span></div>`;
|
||
} else {
|
||
ansHtml = `<div class="result-answers">你答:<span class="wrong-ans">${escapeHtml(String(userAns || '(未作答)'))}</span> · 正确答案:<span class="correct-ans">${renderMD(String(q.answer || ''))}</span></div>`;
|
||
}
|
||
} else {
|
||
ansHtml = `<div class="result-answers">你答:${escapeHtml(String(userAns || '(未作答)'))}</div>
|
||
<div class="result-answers">参考:<span class="correct-ans">${renderMD(String(q.answer || ''))}</span></div>`;
|
||
}
|
||
|
||
html += `<div class="result-item ${cls}">
|
||
<div class="result-top">
|
||
<span class="result-qid">第${idx + 1}题 · Q${q.id}</span>
|
||
${badge}
|
||
</div>
|
||
<div class="result-text">${renderMD(q.prompt || q.question)}</div>
|
||
${ansHtml}
|
||
${q.explanation ? `<div class="result-analysis">💡 ${renderMD(q.explanation)}</div>` : ''}
|
||
${q.memory_tip ? `<div class="result-tip">🧠 ${renderMD(q.memory_tip)}</div>` : ''}
|
||
</div>`;
|
||
});
|
||
document.getElementById('resultsBody').innerHTML = html;
|
||
document.getElementById('examResults').classList.add('visible');
|
||
}
|
||
|
||
window.closeResults = function () { document.getElementById('examResults').classList.remove('visible'); };
|
||
window.retakeExam = function () { closeResults(); startExam(); };
|
||
|
||
/* ===== 工具 ===== */
|
||
function escapeHtml(s) {
|
||
if (s === null || s === undefined) return '';
|
||
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
}
|
||
function renderMD(s) {
|
||
if (s === null || s === undefined) return '';
|
||
return escapeHtml(s)
|
||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||
.replace(/\n/g, '<br>')
|
||
.replace(/____+/g, '<span style="display:inline-block;min-width:80px;border-bottom:2px solid currentColor;margin:0 4px;opacity:.5;"> </span>');
|
||
}
|
||
|
||
/* ===== 键盘快捷键 ===== */
|
||
document.addEventListener('keydown', (e) => {
|
||
// 在 input/textarea 内时,只允许 Enter 触发提交,其它不拦截
|
||
const inExam = document.getElementById('examOverlay').classList.contains('visible');
|
||
const inResults = document.getElementById('examResults').classList.contains('visible');
|
||
const inModal = document.querySelector('.modal-overlay.visible');
|
||
if (inExam || inResults || inModal) {
|
||
// 模拟考也支持 Enter 提交
|
||
if (inExam && e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||
e.preventDefault();
|
||
submitExam();
|
||
}
|
||
return;
|
||
}
|
||
|
||
const tag = (e.target.tagName || '').toLowerCase();
|
||
const inField = tag === 'input' || tag === 'textarea';
|
||
|
||
// Enter 提交(在文本框里只有 Ctrl/Cmd+Enter)
|
||
if (e.key === 'Enter') {
|
||
if (inField && !(e.ctrlKey || e.metaKey)) return;
|
||
e.preventDefault();
|
||
const btn = document.getElementById('btnSubmit');
|
||
if (btn && !btn.disabled) btn.click();
|
||
return;
|
||
}
|
||
|
||
if (inField) return; // 文本框内不再处理其它快捷键
|
||
|
||
// A/B/C/D 选项
|
||
if (/^[a-dA-D]$/.test(e.key)) {
|
||
const letter = e.key.toUpperCase();
|
||
const el = document.querySelector(`#qOpts .opt[data-key="${letter}"]`);
|
||
if (el && !el.classList.contains('disabled')) { el.click(); e.preventDefault(); }
|
||
return;
|
||
}
|
||
// T/F 判断题
|
||
if (e.key === 't' || e.key === 'T') {
|
||
const el = document.querySelector('#qOpts .opt[data-key="True"]');
|
||
if (el && !el.classList.contains('disabled')) { el.click(); e.preventDefault(); }
|
||
return;
|
||
}
|
||
if (e.key === 'f' || e.key === 'F') {
|
||
const el = document.querySelector('#qOpts .opt[data-key="False"]');
|
||
if (el && !el.classList.contains('disabled')) { el.click(); e.preventDefault(); }
|
||
return;
|
||
}
|
||
// 上下题
|
||
if (e.key === 'ArrowLeft') { goPrev(); e.preventDefault(); return; }
|
||
if (e.key === 'ArrowRight') { goNext(); e.preventDefault(); return; }
|
||
// Space: 查看答案
|
||
if (e.key === ' ') {
|
||
if (currentId !== null) { revealAnswer(currentId); e.preventDefault(); }
|
||
return;
|
||
}
|
||
});
|
||
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|