2026-09-02 18:22:20 +08:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
|
<title>🎯 CS 知识应试强化</title>
|
|
|
|
|
|
<style>
|
2026-09-02 18:40:50 +08:00
|
|
|
|
:root, :root[data-theme="dark"] {
|
2026-09-02 18:22:20 +08:00
|
|
|
|
--bg: #0f172a; --surface: #1e293b; --surface2: #334155;
|
|
|
|
|
|
--text: #e2e8f0; --text2: #94a3b8; --accent: #6366f1;
|
|
|
|
|
|
--accent2: #818cf8; --green: #22c55e; --red: #ef4444;
|
|
|
|
|
|
--yellow: #eab308; --radius: 12px;
|
2026-09-02 18:40:50 +08:00
|
|
|
|
--header-bg: linear-gradient(135deg, #312e81, #1e1b4b);
|
|
|
|
|
|
}
|
|
|
|
|
|
:root[data-theme="light"] {
|
|
|
|
|
|
--bg: #f1f5f9; --surface: #ffffff; --surface2: #e2e8f0;
|
|
|
|
|
|
--text: #1e293b; --text2: #64748b; --accent: #6366f1;
|
|
|
|
|
|
--accent2: #4f46e5; --green: #16a34a; --red: #dc2626;
|
|
|
|
|
|
--yellow: #ca8a04; --radius: 12px;
|
|
|
|
|
|
--header-bg: linear-gradient(135deg, #6366f1, #4f46e5);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Question type colors */
|
|
|
|
|
|
:root {
|
|
|
|
|
|
--type-fill_blank: #818cf8;
|
|
|
|
|
|
--type-single_choice: #22c55e;
|
|
|
|
|
|
--type-multiple_choice: #ec4899;
|
|
|
|
|
|
--type-true_false: #eab308;
|
|
|
|
|
|
--type-short_answer: #3b82f6;
|
|
|
|
|
|
--type-code_reading: #f97316;
|
|
|
|
|
|
--type-scenario: #a855f7;
|
2026-09-02 18:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
|
|
body { font-family: -apple-system, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; min-height: 100vh; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Header */
|
2026-09-02 18:40:50 +08:00
|
|
|
|
.header { background: var(--header-bg); padding: 28px 24px; text-align: center; border-bottom: 1px solid var(--surface2); position: relative; }
|
2026-09-02 18:22:20 +08:00
|
|
|
|
.header h1 { font-size: 26px; font-weight: 700; margin-bottom: 6px; }
|
2026-09-02 18:40:50 +08:00
|
|
|
|
.header p { color: rgba(255,255,255,0.7); font-size: 14px; }
|
|
|
|
|
|
.theme-toggle { position: absolute; top: 16px; right: 16px; background: rgba(255,255,255,0.15); border: none; color: white; width: 36px; height: 36px; border-radius: 8px; cursor: pointer; font-size: 18px; transition: background 0.2s; }
|
|
|
|
|
|
.theme-toggle:hover { background: rgba(255,255,255,0.25); }
|
2026-09-02 18:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
/* Layout */
|
|
|
|
|
|
.app { display: flex; min-height: calc(100vh - 100px); }
|
|
|
|
|
|
.sidebar { width: 280px; background: var(--surface); border-right: 1px solid var(--surface2); padding: 16px; overflow-y: auto; flex-shrink: 0; }
|
|
|
|
|
|
.main { flex: 1; padding: 24px; overflow-y: auto; max-height: calc(100vh - 100px); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Sidebar */
|
|
|
|
|
|
.sidebar-title { font-size: 13px; color: var(--text2); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; padding: 0 8px; }
|
2026-09-02 19:10:14 +08:00
|
|
|
|
|
|
|
|
|
|
/* Accordion */
|
|
|
|
|
|
.accordion-section { margin-bottom: 4px; }
|
|
|
|
|
|
.accordion-header { padding: 10px 12px; cursor: pointer; user-select: none; border-radius: 8px; transition: background 0.2s; display: flex; align-items: center; gap: 8px; }
|
|
|
|
|
|
.accordion-header:hover { background: var(--surface2); }
|
|
|
|
|
|
.accordion-header .accordion-info { flex: 1; min-width: 0; }
|
|
|
|
|
|
.accordion-header .accordion-name { font-size: 14px; font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
|
|
|
|
.accordion-header .accordion-desc { font-size: 11px; color: var(--text2); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
|
|
|
|
.accordion-header .accordion-chevron { font-size: 11px; color: var(--text2); transition: transform 0.2s; flex-shrink: 0; }
|
|
|
|
|
|
.accordion-section.expanded .accordion-chevron { transform: rotate(180deg); }
|
|
|
|
|
|
.accordion-body { list-style: none; overflow: hidden; max-height: 0; opacity: 0; transition: max-height 0.25s ease, opacity 0.2s ease; }
|
|
|
|
|
|
.accordion-section.expanded .accordion-body { max-height: 2000px; opacity: 1; }
|
|
|
|
|
|
.accordion-body .topic-item { padding: 10px 12px 10px 24px; }
|
|
|
|
|
|
.topic-item { padding: 10px 12px; border-radius: 8px; cursor: pointer; transition: all 0.2s; margin-bottom: 2px; }
|
2026-09-02 18:22:20 +08:00
|
|
|
|
.topic-item:hover { background: var(--surface2); }
|
|
|
|
|
|
.topic-item.active { background: var(--accent); color: white; }
|
|
|
|
|
|
.topic-item .topic-name { font-size: 14px; font-weight: 600; }
|
|
|
|
|
|
.topic-item .topic-desc { font-size: 12px; color: var(--text2); margin-top: 2px; }
|
|
|
|
|
|
.topic-item.active .topic-desc { color: rgba(255,255,255,0.7); }
|
|
|
|
|
|
.topic-item .topic-stats { font-size: 11px; color: var(--text2); margin-top: 4px; }
|
|
|
|
|
|
.topic-item.active .topic-stats { color: rgba(255,255,255,0.6); }
|
|
|
|
|
|
|
|
|
|
|
|
.sidebar-section { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--surface2); }
|
|
|
|
|
|
.import-btn { width: 100%; padding: 10px; border-radius: 8px; border: 1px dashed var(--surface2); background: transparent; color: var(--text2); cursor: pointer; font-size: 13px; transition: all 0.2s; }
|
|
|
|
|
|
.import-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
|
|
|
|
|
2026-09-02 18:40:50 +08:00
|
|
|
|
/* Tabs */
|
|
|
|
|
|
.tabs { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 20px; padding: 4px; background: var(--surface); border-radius: var(--radius); }
|
|
|
|
|
|
.tab { padding: 8px 16px; border-radius: 8px; border: none; background: transparent; color: var(--text2); cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.2s; }
|
|
|
|
|
|
.tab:hover { background: var(--surface2); color: var(--text); }
|
|
|
|
|
|
.tab.active { background: var(--accent); color: white; }
|
|
|
|
|
|
|
2026-09-02 18:22:20 +08:00
|
|
|
|
/* Toolbar */
|
2026-09-02 18:40:50 +08:00
|
|
|
|
.toolbar { display: flex; flex-wrap: wrap; gap: 12px; justify-content: flex-end; align-items: center; margin-bottom: 16px; }
|
2026-09-02 18:22:20 +08:00
|
|
|
|
.toolbar-actions { display: flex; gap: 8px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Buttons */
|
|
|
|
|
|
.btn { padding: 8px 16px; border-radius: 8px; border: none; cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.2s; }
|
|
|
|
|
|
.btn-primary { background: var(--accent); color: white; }
|
|
|
|
|
|
.btn-primary:hover { background: var(--accent2); }
|
|
|
|
|
|
.btn-outline { background: transparent; border: 1px solid var(--surface2); color: var(--text2); }
|
|
|
|
|
|
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
|
|
|
|
|
.btn-sm { padding: 5px 12px; font-size: 12px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Question Card */
|
|
|
|
|
|
.q-card { background: var(--surface); border-radius: var(--radius); padding: 20px; margin-bottom: 16px; border: 1px solid var(--surface2); transition: border-color 0.3s; }
|
|
|
|
|
|
.q-card.correct { border-color: var(--green); }
|
|
|
|
|
|
.q-card.wrong { border-color: var(--red); }
|
2026-09-02 18:40:50 +08:00
|
|
|
|
.q-card.type-fill_blank { border-color: color-mix(in srgb, var(--type-fill_blank) 40%, transparent); }
|
|
|
|
|
|
.q-card.type-single_choice { border-color: color-mix(in srgb, var(--type-single_choice) 40%, transparent); }
|
|
|
|
|
|
.q-card.type-multiple_choice { border-color: color-mix(in srgb, var(--type-multiple_choice) 40%, transparent); }
|
|
|
|
|
|
.q-card.type-true_false { border-color: color-mix(in srgb, var(--type-true_false) 40%, transparent); }
|
|
|
|
|
|
.q-card.type-short_answer { border-color: color-mix(in srgb, var(--type-short_answer) 40%, transparent); }
|
|
|
|
|
|
.q-card.type-code_reading { border-color: color-mix(in srgb, var(--type-code_reading) 40%, transparent); }
|
|
|
|
|
|
.q-card.type-scenario { border-color: color-mix(in srgb, var(--type-scenario) 40%, transparent); }
|
2026-09-02 18:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
.q-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; gap: 8px; flex-wrap: wrap; }
|
|
|
|
|
|
.q-number { font-size: 13px; color: var(--text2); font-weight: 600; }
|
|
|
|
|
|
.q-difficulty { display: flex; gap: 2px; }
|
|
|
|
|
|
.q-difficulty .star { width: 8px; height: 8px; border-radius: 50%; background: var(--surface2); }
|
|
|
|
|
|
.q-difficulty .star.active { background: var(--yellow); }
|
|
|
|
|
|
|
|
|
|
|
|
.q-text { font-size: 15px; font-weight: 500; margin-bottom: 16px; line-height: 1.7; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Fill blank input inline */
|
|
|
|
|
|
.blank-input { display: inline-block; min-width: 100px; padding: 2px 8px; border: none; border-bottom: 2px dashed var(--accent); background: transparent; color: var(--text); font-size: 15px; font-weight: 500; outline: none; transition: all 0.2s; }
|
|
|
|
|
|
.blank-input:focus { border-bottom-color: var(--accent2); border-bottom-style: solid; }
|
|
|
|
|
|
.blank-input.correct { border-bottom-color: var(--green); color: var(--green); }
|
|
|
|
|
|
.blank-input.wrong { border-bottom-color: var(--red); color: var(--red); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Options */
|
|
|
|
|
|
.options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
|
|
|
|
|
.option { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: 8px; border: 1px solid var(--surface2); cursor: pointer; transition: all 0.2s; font-size: 14px; }
|
|
|
|
|
|
.option:hover { border-color: var(--accent); background: rgba(99,102,241,0.05); }
|
|
|
|
|
|
.option.selected { border-color: var(--accent); background: rgba(99,102,241,0.1); }
|
|
|
|
|
|
.option.correct { border-color: var(--green); background: rgba(34,197,94,0.1); }
|
|
|
|
|
|
.option.wrong { border-color: var(--red); background: rgba(239,68,68,0.1); }
|
|
|
|
|
|
.option .radio { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--surface2); flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: all 0.2s; }
|
|
|
|
|
|
.option.selected .radio { border-color: var(--accent); }
|
|
|
|
|
|
.option.selected .radio::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
|
|
|
|
|
.option.correct .radio { border-color: var(--green); }
|
|
|
|
|
|
.option.correct .radio::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--green); }
|
|
|
|
|
|
.option.wrong .radio { border-color: var(--red); }
|
|
|
|
|
|
.option.wrong .radio::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--red); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Actions */
|
|
|
|
|
|
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 4px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Answer box */
|
|
|
|
|
|
.answer-box { display: none; margin-top: 12px; padding: 16px; border-radius: 8px; background: var(--bg); border: 1px solid var(--surface2); font-size: 14px; }
|
|
|
|
|
|
.answer-box.show { display: block; }
|
|
|
|
|
|
.answer-box .label { font-weight: 600; color: var(--accent2); margin-bottom: 4px; }
|
|
|
|
|
|
.answer-box .answer-text { color: var(--green); font-weight: 600; margin-bottom: 8px; }
|
|
|
|
|
|
.answer-box .explanation { color: var(--text2); line-height: 1.7; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Tags */
|
|
|
|
|
|
.q-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 8px; }
|
|
|
|
|
|
.q-tag { font-size: 11px; padding: 2px 8px; border-radius: 4px; background: var(--surface2); color: var(--text2); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Welcome */
|
|
|
|
|
|
.welcome { text-align: center; padding: 80px 20px; color: var(--text2); }
|
|
|
|
|
|
.welcome h2 { font-size: 20px; color: var(--text); margin-bottom: 8px; }
|
|
|
|
|
|
.welcome p { max-width: 400px; margin: 0 auto; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Modal */
|
|
|
|
|
|
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 200; align-items: center; justify-content: center; }
|
|
|
|
|
|
.modal-overlay.show { display: flex; }
|
|
|
|
|
|
.modal { background: var(--surface); border-radius: var(--radius); padding: 24px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; }
|
|
|
|
|
|
.modal h3 { margin-bottom: 16px; }
|
|
|
|
|
|
.modal textarea { width: 100%; height: 200px; padding: 12px; border-radius: 8px; border: 1px solid var(--surface2); background: var(--bg); color: var(--text); font-family: monospace; font-size: 13px; resize: vertical; outline: none; }
|
|
|
|
|
|
.modal textarea:focus { border-color: var(--accent); }
|
|
|
|
|
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
|
|
|
|
|
.modal-or { text-align: center; color: var(--text2); font-size: 13px; margin: 12px 0; }
|
|
|
|
|
|
.file-input { display: block; width: 100%; padding: 12px; border: 1px dashed var(--surface2); border-radius: 8px; text-align: center; color: var(--text2); cursor: pointer; font-size: 13px; }
|
|
|
|
|
|
.file-input:hover { border-color: var(--accent); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Responsive */
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.app { flex-direction: column; }
|
|
|
|
|
|
.sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--surface2); max-height: 200px; }
|
|
|
|
|
|
.main { max-height: none; }
|
|
|
|
|
|
.header h1 { font-size: 20px; }
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="header">
|
|
|
|
|
|
<h1>🎯 CS 知识应试强化</h1>
|
|
|
|
|
|
<p>选择主题,开始训练</p>
|
2026-09-02 18:40:50 +08:00
|
|
|
|
<button class="theme-toggle" onclick="toggleTheme()" title="切换主题">🌙</button>
|
2026-09-02 18:22:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="app">
|
|
|
|
|
|
<div class="sidebar">
|
2026-09-02 19:10:14 +08:00
|
|
|
|
<div id="accordionContainer"></div>
|
2026-09-02 18:22:20 +08:00
|
|
|
|
<div class="sidebar-section">
|
|
|
|
|
|
<button class="import-btn" onclick="openImportModal()">📥 导入题目</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="main" id="mainContent">
|
|
|
|
|
|
<div class="welcome">
|
|
|
|
|
|
<h2>👋 欢迎</h2>
|
|
|
|
|
|
<p>从左侧选择一个主题开始练习</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Import Modal -->
|
|
|
|
|
|
<div class="modal-overlay" id="importModal">
|
|
|
|
|
|
<div class="modal">
|
|
|
|
|
|
<h3>📥 导入题目</h3>
|
|
|
|
|
|
<p style="color:var(--text2);font-size:13px;margin-bottom:12px;">粘符合规范的 JSON 数据(topics 格式)</p>
|
|
|
|
|
|
<textarea id="importText" placeholder='{"topic":"custom","type":"single_choice","questions":[...]}'></textarea>
|
|
|
|
|
|
<div class="modal-or">— 或者 —</div>
|
|
|
|
|
|
<label class="file-input">
|
|
|
|
|
|
📁 选择 JSON 文件
|
|
|
|
|
|
<input type="file" accept=".json" style="display:none" onchange="handleFileImport(event)">
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div class="modal-actions">
|
|
|
|
|
|
<button class="btn btn-outline" onclick="closeImportModal()">取消</button>
|
|
|
|
|
|
<button class="btn btn-primary" onclick="doImport()">导入</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
2026-09-02 18:40:50 +08:00
|
|
|
|
// ─── Constants ───
|
|
|
|
|
|
const QUESTION_TYPES = {
|
|
|
|
|
|
fill_blank: { label: '填空', color: '--type-fill_blank' },
|
|
|
|
|
|
single_choice: { label: '选择', color: '--type-single_choice' },
|
|
|
|
|
|
multiple_choice: { label: '多选', color: '--type-multiple_choice' },
|
|
|
|
|
|
true_false: { label: '判断', color: '--type-true_false' },
|
|
|
|
|
|
short_answer: { label: '简答', color: '--type-short_answer' },
|
|
|
|
|
|
code_reading: { label: '代码', color: '--type-code_reading' },
|
|
|
|
|
|
scenario: { label: '场景', color: '--type-scenario' },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-02 18:22:20 +08:00
|
|
|
|
// ─── State ───
|
2026-09-02 19:10:14 +08:00
|
|
|
|
let topicGroups = []; // [{slug, name, description, subtopics: [...]}, ...]
|
|
|
|
|
|
let expandedGroup = null; // slug of the expanded accordion section
|
|
|
|
|
|
let currentTopic = null; // slug of the selected subtopic
|
|
|
|
|
|
let currentQuestions = [];
|
|
|
|
|
|
let answers = {};
|
|
|
|
|
|
let customQuestions = [];
|
|
|
|
|
|
let activeTab = 'all';
|
2026-09-02 18:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
// ─── Init ───
|
|
|
|
|
|
async function init() {
|
2026-09-02 18:40:50 +08:00
|
|
|
|
const saved = localStorage.getItem('theme') || 'dark';
|
|
|
|
|
|
document.documentElement.dataset.theme = saved;
|
|
|
|
|
|
updateThemeToggle();
|
|
|
|
|
|
|
2026-09-02 18:22:20 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('topics/index.json');
|
|
|
|
|
|
const data = await res.json();
|
2026-09-02 19:10:14 +08:00
|
|
|
|
topicGroups = data.topics || [];
|
|
|
|
|
|
expandedGroup = localStorage.getItem('expandedGroup') || null;
|
|
|
|
|
|
renderAccordion();
|
2026-09-02 18:22:20 +08:00
|
|
|
|
} catch (e) {
|
2026-09-02 19:10:14 +08:00
|
|
|
|
document.getElementById('accordionContainer').innerHTML = '<div style="color:var(--red);padding:12px;">加载主题列表失败</div>';
|
2026-09-02 18:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 18:40:50 +08:00
|
|
|
|
// ─── Theme ───
|
|
|
|
|
|
function toggleTheme() {
|
|
|
|
|
|
const current = document.documentElement.dataset.theme;
|
|
|
|
|
|
const next = current === 'dark' ? 'light' : 'dark';
|
|
|
|
|
|
document.documentElement.dataset.theme = next;
|
|
|
|
|
|
localStorage.setItem('theme', next);
|
|
|
|
|
|
updateThemeToggle();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateThemeToggle() {
|
|
|
|
|
|
const btn = document.querySelector('.theme-toggle');
|
|
|
|
|
|
if (btn) btn.textContent = document.documentElement.dataset.theme === 'dark' ? '🌙' : '☀️';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 19:10:14 +08:00
|
|
|
|
// ─── Accordion ───
|
|
|
|
|
|
function toggleAccordion(slug) {
|
|
|
|
|
|
expandedGroup = expandedGroup === slug ? null : slug;
|
|
|
|
|
|
localStorage.setItem('expandedGroup', expandedGroup || '');
|
|
|
|
|
|
renderAccordion();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function findSubtopic(slug) {
|
|
|
|
|
|
for (const g of topicGroups) {
|
|
|
|
|
|
const sub = g.subtopics?.find(s => s.slug === slug);
|
|
|
|
|
|
if (sub) return sub;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderAccordion() {
|
|
|
|
|
|
const container = document.getElementById('accordionContainer');
|
2026-09-02 18:22:20 +08:00
|
|
|
|
let html = '';
|
2026-09-02 19:10:14 +08:00
|
|
|
|
|
|
|
|
|
|
topicGroups.forEach(g => {
|
|
|
|
|
|
const expanded = expandedGroup === g.slug;
|
|
|
|
|
|
html += `<div class="accordion-section ${expanded ? 'expanded' : ''}">
|
|
|
|
|
|
<div class="accordion-header" onclick="toggleAccordion('${g.slug}')">
|
|
|
|
|
|
<div class="accordion-info">
|
|
|
|
|
|
<div class="accordion-name">${escapeHtml(g.name)}</div>
|
|
|
|
|
|
<div class="accordion-desc">${escapeHtml(g.description)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="accordion-chevron">▼</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ul class="accordion-body">`;
|
|
|
|
|
|
|
|
|
|
|
|
(g.subtopics || []).forEach(t => {
|
|
|
|
|
|
const active = currentTopic === t.slug ? 'active' : '';
|
|
|
|
|
|
html += `<li class="topic-item ${active}" onclick="selectTopic('${t.slug}')">
|
|
|
|
|
|
<div class="topic-name">${t.name}</div>
|
|
|
|
|
|
<div class="topic-desc">${t.description}</div>
|
|
|
|
|
|
<div class="topic-stats">${t.stats.total} 题 · ${Object.entries(t.stats.by_type).map(([k,v])=>`${typeLabel(k)} ${v}`).join(' / ')}</div>
|
|
|
|
|
|
</li>`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
html += `</ul></div>`;
|
2026-09-02 18:22:20 +08:00
|
|
|
|
});
|
2026-09-02 19:10:14 +08:00
|
|
|
|
|
2026-09-02 18:22:20 +08:00
|
|
|
|
if (customQuestions.length) {
|
2026-09-02 19:10:14 +08:00
|
|
|
|
const active = currentTopic === '__custom__' ? 'active' : '';
|
|
|
|
|
|
html += `<div class="accordion-section expanded">
|
|
|
|
|
|
<ul class="accordion-body" style="max-height:2000px;opacity:1;">
|
|
|
|
|
|
<li class="topic-item ${active}" onclick="selectTopic('__custom__')">
|
|
|
|
|
|
<div class="topic-name">📥 自定义导入</div>
|
|
|
|
|
|
<div class="topic-desc">临时导入的题目</div>
|
|
|
|
|
|
<div class="topic-stats">${customQuestions.length} 题</div>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>`;
|
2026-09-02 18:22:20 +08:00
|
|
|
|
}
|
2026-09-02 19:10:14 +08:00
|
|
|
|
|
|
|
|
|
|
container.innerHTML = html;
|
2026-09-02 18:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function typeLabel(type) {
|
2026-09-02 18:40:50 +08:00
|
|
|
|
return QUESTION_TYPES[type]?.label || type;
|
2026-09-02 18:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Select Topic ───
|
|
|
|
|
|
async function selectTopic(slug) {
|
|
|
|
|
|
currentTopic = slug;
|
|
|
|
|
|
answers = {};
|
2026-09-02 18:40:50 +08:00
|
|
|
|
activeTab = 'all';
|
2026-09-02 19:10:14 +08:00
|
|
|
|
renderAccordion();
|
2026-09-02 18:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
if (slug === '__custom__') {
|
|
|
|
|
|
currentQuestions = customQuestions;
|
|
|
|
|
|
renderQuiz();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 19:10:14 +08:00
|
|
|
|
const topic = findSubtopic(slug);
|
2026-09-02 18:22:20 +08:00
|
|
|
|
if (!topic) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-09-02 19:32:58 +08:00
|
|
|
|
const topicPath = topic.path;
|
|
|
|
|
|
const metaRes = await fetch(`${topicPath}/meta.json`);
|
2026-09-02 18:22:20 +08:00
|
|
|
|
const meta = await metaRes.json();
|
|
|
|
|
|
|
|
|
|
|
|
currentQuestions = [];
|
|
|
|
|
|
for (const typeFile of meta.question_files) {
|
2026-09-02 19:32:58 +08:00
|
|
|
|
const qRes = await fetch(`${topicPath}/${typeFile}.json`);
|
2026-09-02 18:22:20 +08:00
|
|
|
|
const qData = await qRes.json();
|
|
|
|
|
|
currentQuestions.push(...qData.questions);
|
|
|
|
|
|
}
|
|
|
|
|
|
renderQuiz();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
document.getElementById('mainContent').innerHTML = `<div class="welcome"><h2>❌ 加载失败</h2><p>${e.message}</p></div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Render Quiz ───
|
2026-09-02 18:40:50 +08:00
|
|
|
|
function getFilteredQuestions() {
|
|
|
|
|
|
if (activeTab === 'all') return currentQuestions;
|
|
|
|
|
|
return currentQuestions.filter(q => q.type === activeTab);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 18:22:20 +08:00
|
|
|
|
function renderQuiz() {
|
|
|
|
|
|
const main = document.getElementById('mainContent');
|
|
|
|
|
|
if (!currentQuestions.length) {
|
|
|
|
|
|
main.innerHTML = '<div class="welcome"><h2>暂无题目</h2><p>该主题下没有题目数据</p></div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 18:40:50 +08:00
|
|
|
|
// Build tabs
|
|
|
|
|
|
const types = [...new Set(currentQuestions.map(q => q.type))];
|
|
|
|
|
|
let tabsHtml = `<button class="tab ${activeTab === 'all' ? 'active' : ''}" onclick="switchTab('all')">全部 ${currentQuestions.length}</button>`;
|
|
|
|
|
|
types.forEach(type => {
|
|
|
|
|
|
const count = currentQuestions.filter(q => q.type === type).length;
|
|
|
|
|
|
tabsHtml += `<button class="tab ${activeTab === type ? 'active' : ''}" onclick="switchTab('${type}')">${typeLabel(type)} ${count}</button>`;
|
|
|
|
|
|
});
|
2026-09-02 18:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
let html = `
|
2026-09-02 18:40:50 +08:00
|
|
|
|
<div class="tabs">${tabsHtml}</div>
|
2026-09-02 18:22:20 +08:00
|
|
|
|
<div class="toolbar">
|
|
|
|
|
|
<div class="toolbar-actions">
|
|
|
|
|
|
<button class="btn btn-outline btn-sm" onclick="resetAll()">🔄 重置</button>
|
|
|
|
|
|
<button class="btn btn-outline btn-sm" onclick="showAllAnswers()">👁 全部显示</button>
|
|
|
|
|
|
<button class="btn btn-primary btn-sm" onclick="submitAll()">📝 提交全部</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
2026-09-02 18:40:50 +08:00
|
|
|
|
const filtered = getFilteredQuestions();
|
|
|
|
|
|
filtered.forEach((q, i) => {
|
2026-09-02 18:22:20 +08:00
|
|
|
|
html += renderQuestionCard(q, i);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
main.innerHTML = html;
|
2026-09-02 18:40:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function switchTab(tab) {
|
|
|
|
|
|
activeTab = tab;
|
|
|
|
|
|
renderQuiz();
|
2026-09-02 18:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderQuestionCard(q, i) {
|
|
|
|
|
|
const state = answers[q.id];
|
|
|
|
|
|
const revealed = state?.revealed;
|
|
|
|
|
|
const correct = state?.correct;
|
|
|
|
|
|
const cardClass = revealed ? (correct ? 'correct' : 'wrong') : '';
|
|
|
|
|
|
|
|
|
|
|
|
let stars = '';
|
|
|
|
|
|
for (let s = 1; s <= 5; s++) {
|
|
|
|
|
|
stars += `<div class="star ${s <= q.difficulty ? 'active' : ''}"></div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 18:40:50 +08:00
|
|
|
|
let html = `<div class="q-card ${cardClass} type-${q.type}" id="card-${q.id}">
|
2026-09-02 18:22:20 +08:00
|
|
|
|
<div class="q-header">
|
|
|
|
|
|
<span class="q-number">#${i + 1}</span>
|
|
|
|
|
|
<div class="q-difficulty">${stars}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="q-text">`;
|
|
|
|
|
|
|
|
|
|
|
|
if (q.type === 'fill_blank') {
|
|
|
|
|
|
// Replace ______ with input
|
|
|
|
|
|
const parts = q.question.split(/_{3,}/);
|
|
|
|
|
|
parts.forEach((part, pi) => {
|
|
|
|
|
|
html += escapeHtml(part);
|
|
|
|
|
|
if (pi < parts.length - 1) {
|
|
|
|
|
|
const val = revealed ? (Array.isArray(q.answer) ? q.answer[0] : q.answer) : (state?.value || '');
|
|
|
|
|
|
const cls = revealed ? (correct ? 'blank-input correct' : 'blank-input wrong') : 'blank-input';
|
|
|
|
|
|
html += `<input class="${cls}" id="input-${q.id}" value="${escapeHtml(val)}" ${revealed ? 'disabled' : ''} placeholder="填空" onchange="saveFill('${q.id}')" onkeydown="if(event.key==='Enter')checkFill('${q.id}')">`;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
html += escapeHtml(q.question);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
// Options for single_choice
|
|
|
|
|
|
if (q.type === 'single_choice' && q.options) {
|
|
|
|
|
|
html += `<div class="options">`;
|
|
|
|
|
|
for (const [letter, text] of Object.entries(q.options)) {
|
|
|
|
|
|
let optClass = '';
|
|
|
|
|
|
if (revealed) {
|
|
|
|
|
|
if (letter === q.answer) optClass = 'correct';
|
|
|
|
|
|
else if (state?.value === letter) optClass = 'wrong';
|
|
|
|
|
|
} else if (state?.value === letter) {
|
|
|
|
|
|
optClass = 'selected';
|
|
|
|
|
|
}
|
|
|
|
|
|
html += `<div class="option ${optClass}" onclick="selectOption('${q.id}','${letter}')">
|
|
|
|
|
|
<div class="radio"></div>
|
|
|
|
|
|
<span><strong>${letter}.</strong> ${escapeHtml(text)}</span>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Actions
|
|
|
|
|
|
html += `<div class="actions">`;
|
|
|
|
|
|
if (!revealed) {
|
|
|
|
|
|
if (q.type === 'fill_blank') {
|
|
|
|
|
|
html += `<button class="btn btn-primary btn-sm" onclick="checkFill('${q.id}')">✓ 检查</button>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
html += `<button class="btn btn-outline btn-sm" onclick="revealAnswer('${q.id}')">👁 显示答案</button>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
// Answer box
|
|
|
|
|
|
const answerText = Array.isArray(q.answer) ? q.answer.join(' / ') : String(q.answer);
|
|
|
|
|
|
html += `<div class="answer-box ${revealed ? 'show' : ''}" id="ans-${q.id}">
|
|
|
|
|
|
<div class="label">正确答案</div>
|
|
|
|
|
|
<div class="answer-text">${escapeHtml(answerText)}</div>
|
|
|
|
|
|
<div class="explanation">💡 ${escapeHtml(q.explanation || '')}</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
// Tags
|
|
|
|
|
|
if (q.tags?.length) {
|
|
|
|
|
|
html += `<div class="q-tags">${q.tags.map(t => `<span class="q-tag">${escapeHtml(t)}</span>`).join('')}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Interactions ───
|
|
|
|
|
|
function selectOption(qId, letter) {
|
|
|
|
|
|
const q = currentQuestions.find(x => x.id === qId);
|
|
|
|
|
|
if (!q || answers[qId]?.revealed) return;
|
|
|
|
|
|
answers[qId] = { value: letter, correct: false, revealed: false };
|
|
|
|
|
|
renderQuiz();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function saveFill(qId) {
|
|
|
|
|
|
const input = document.getElementById(`input-${qId}`);
|
|
|
|
|
|
if (!input) return;
|
|
|
|
|
|
const val = input.value.trim();
|
|
|
|
|
|
if (val) {
|
|
|
|
|
|
answers[qId] = { value: val, correct: false, revealed: false };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function checkFill(qId) {
|
|
|
|
|
|
const input = document.getElementById(`input-${qId}`);
|
|
|
|
|
|
if (!input) return;
|
|
|
|
|
|
const val = input.value.trim();
|
|
|
|
|
|
if (!val) return;
|
|
|
|
|
|
const q = currentQuestions.find(x => x.id === qId);
|
|
|
|
|
|
if (!q) return;
|
|
|
|
|
|
|
|
|
|
|
|
const correct = matchFillAnswer(val, q.answer, q.answer_rule || 'any');
|
|
|
|
|
|
answers[qId] = { value: val, correct, revealed: true };
|
|
|
|
|
|
renderQuiz();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function matchFillAnswer(userVal, answers, rule) {
|
|
|
|
|
|
const norm = s => s.toLowerCase().replace(/[\s\-_\/\\,,。;;::'""''()()\[\]【]{}]+/g, '').trim();
|
|
|
|
|
|
const nv = norm(userVal);
|
|
|
|
|
|
if (!Array.isArray(answers)) answers = [answers];
|
|
|
|
|
|
const normalized = answers.map(norm);
|
|
|
|
|
|
|
|
|
|
|
|
if (rule === 'all') return normalized.every(a => nv.includes(a));
|
|
|
|
|
|
if (rule === 'ordered') {
|
|
|
|
|
|
let lastIdx = -1;
|
|
|
|
|
|
for (const a of normalized) {
|
|
|
|
|
|
const idx = nv.indexOf(a, lastIdx + 1);
|
|
|
|
|
|
if (idx === -1) return false;
|
|
|
|
|
|
lastIdx = idx;
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
// any
|
|
|
|
|
|
return normalized.some(a => nv === a || nv.includes(a));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function revealAnswer(qId) {
|
|
|
|
|
|
const q = currentQuestions.find(x => x.id === qId);
|
|
|
|
|
|
if (!q) return;
|
|
|
|
|
|
if (!answers[qId]) answers[qId] = { value: '', correct: false };
|
|
|
|
|
|
|
|
|
|
|
|
if (q.type === 'single_choice') {
|
|
|
|
|
|
answers[qId].correct = answers[qId].value === q.answer;
|
|
|
|
|
|
}
|
|
|
|
|
|
answers[qId].revealed = true;
|
|
|
|
|
|
renderQuiz();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showAllAnswers() {
|
|
|
|
|
|
currentQuestions.forEach(q => {
|
|
|
|
|
|
if (!answers[q.id]?.revealed) {
|
|
|
|
|
|
if (!answers[q.id]) answers[q.id] = { value: '', correct: false };
|
|
|
|
|
|
if (q.type === 'single_choice') {
|
|
|
|
|
|
answers[q.id].correct = answers[q.id].value === q.answer;
|
|
|
|
|
|
}
|
|
|
|
|
|
answers[q.id].revealed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
renderQuiz();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function submitAll() {
|
|
|
|
|
|
currentQuestions.forEach(q => {
|
|
|
|
|
|
if (!answers[q.id]) answers[q.id] = { value: '', correct: false };
|
|
|
|
|
|
if (!answers[q.id].revealed) {
|
|
|
|
|
|
if (q.type === 'single_choice') {
|
|
|
|
|
|
answers[q.id].correct = answers[q.id].value === q.answer;
|
|
|
|
|
|
} else if (q.type === 'fill_blank') {
|
|
|
|
|
|
answers[q.id].correct = matchFillAnswer(answers[q.id].value, q.answer, q.answer_rule || 'any');
|
|
|
|
|
|
}
|
|
|
|
|
|
answers[q.id].revealed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
renderQuiz();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetAll() {
|
|
|
|
|
|
answers = {};
|
|
|
|
|
|
renderQuiz();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Import ───
|
|
|
|
|
|
function openImportModal() { document.getElementById('importModal').classList.add('show'); }
|
|
|
|
|
|
function closeImportModal() { document.getElementById('importModal').classList.remove('show'); }
|
|
|
|
|
|
|
|
|
|
|
|
function doImport() {
|
|
|
|
|
|
const text = document.getElementById('importText').value.trim();
|
|
|
|
|
|
if (!text) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = JSON.parse(text);
|
|
|
|
|
|
const questions = data.questions || (Array.isArray(data) ? data : [data]);
|
|
|
|
|
|
customQuestions.push(...questions);
|
|
|
|
|
|
closeImportModal();
|
2026-09-02 19:10:14 +08:00
|
|
|
|
renderAccordion();
|
2026-09-02 18:22:20 +08:00
|
|
|
|
if (currentTopic === '__custom__') selectTopic('__custom__');
|
|
|
|
|
|
document.getElementById('importText').value = '';
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
alert('JSON 解析失败: ' + e.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleFileImport(event) {
|
|
|
|
|
|
const file = event.target.files[0];
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
|
reader.onload = e => {
|
|
|
|
|
|
document.getElementById('importText').value = e.target.result;
|
|
|
|
|
|
};
|
|
|
|
|
|
reader.readAsText(file);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Util ───
|
|
|
|
|
|
function escapeHtml(s) {
|
|
|
|
|
|
if (!s) return '';
|
|
|
|
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Start ───
|
|
|
|
|
|
init();
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|