feat: 前端优化 - 明暗模式、题型Tab分隔、移除统计
- 支持明暗主题切换,用户选择持久化到 localStorage - 题型通过 Tab 按钮分隔展示,支持过滤 - 移除正确/错误统计和进度条 - 题型颜色通过 CSS 变量集中管理(--type-*) - JS 抽取 QUESTION_TYPES 常量,便于扩展 - 更新架构文档反映上述变更 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+37
-25
@@ -236,23 +236,21 @@ examination/
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Header: 🎯 CS 知识应试强化 │
|
||||
│ Header: 🎯 CS 知识应试强化 [🌙/☀️] │
|
||||
├────────────┬─────────────────────────────┤
|
||||
│ Sidebar │ Main Content │
|
||||
│ │ │
|
||||
│ 📚 主题 │ ┌─ Toolbar ──────────────┐ │
|
||||
│ · gc-jvm │ │ 进度 ✅ ❌ ⏳ 按钮组 │ │
|
||||
│ 📚 主题 │ ┌─ Tabs ─────────────────┐ │
|
||||
│ · gc-jvm │ │ 全部 | 填空 | 选择 |.. │ │
|
||||
│ · ai-so │ └────────────────────────┘ │
|
||||
│ · ai-mm │ ┌─ Progress Bar ─────────┐ │
|
||||
│ · ai-he │ └────────────────────────┘ │
|
||||
│ · ai-at │ ┌─ Question Card ────────┐ │
|
||||
│ │ │ #1 填空 ★★★ │ │
|
||||
│ ──────── │ │ 题干 [____] │ │
|
||||
│ 📥 导入 │ │ [检查] [显示答案] │ │
|
||||
│ │ │ 💡 解析... │ │
|
||||
│ │ └────────────────────────┘ │
|
||||
│ · ai-mm │ ┌─ Toolbar ──────────────┐ │
|
||||
│ · ai-he │ │ 重置 全部显示 提交全部 │ │
|
||||
│ · ai-at │ └────────────────────────┘ │
|
||||
│ │ ┌─ Question Card ────────┐ │
|
||||
│ │ │ ... │ │
|
||||
│ ──────── │ │ #1 ★★★ │ │
|
||||
│ 📥 导入 │ │ 题干 [____] │ │
|
||||
│ │ │ [检查] [显示答案] │ │
|
||||
│ │ │ 💡 解析... │ │
|
||||
│ │ └────────────────────────┘ │
|
||||
└────────────┴─────────────────────────────┘
|
||||
```
|
||||
@@ -273,34 +271,48 @@ fetch topics/{slug}/meta.json ──→ 获取 question_files 列表
|
||||
fetch topics/{slug}/{type}.json (并行) ──→ 合并 questions 数组
|
||||
│
|
||||
▼
|
||||
renderQuiz() ──→ 按题型渲染题目卡片
|
||||
renderQuiz() ──→ 渲染 Tab 栏 + 按当前 Tab 过滤题目卡片
|
||||
│
|
||||
│ 用户交互(选择/输入/检查/显示答案)
|
||||
│ 用户交互(选择/输入/检查/显示答案/切换 Tab)
|
||||
▼
|
||||
answers 状态更新 ──→ re-render ──→ 更新统计
|
||||
answers / activeTab 状态更新 ──→ re-render
|
||||
```
|
||||
|
||||
### 4.3 状态管理
|
||||
|
||||
```javascript
|
||||
// 题型配置(常量)
|
||||
const QUESTION_TYPES = {
|
||||
fill_blank: { label: '填空', color: '--type-fill_blank' },
|
||||
single_choice: { label: '选择', color: '--type-single_choice' },
|
||||
// ...
|
||||
};
|
||||
|
||||
// 应用状态
|
||||
let topics = []; // 从 index.json 加载的主题列表
|
||||
let currentTopic = null; // 当前选中的主题 slug
|
||||
let currentQuestions = []; // 当前主题的所有题目(合并自多个题型文件)
|
||||
let answers = {}; // qId → { value, correct, revealed }
|
||||
let customQuestions = []; // 通过导入功能临时添加的题目
|
||||
let activeTab = 'all'; // 当前 Tab:'all' 或题型名称
|
||||
```
|
||||
|
||||
### 4.4 题型渲染逻辑
|
||||
### 4.4 题型配置与渲染
|
||||
|
||||
| 题型 | 渲染方式 | 判定逻辑 |
|
||||
|------|----------|----------|
|
||||
| fill_blank | 题干中的 `______` 替换为 `<input>` | `matchFillAnswer()` 根据 `answer_rule` 匹配 |
|
||||
| single_choice | 选项列表,radio 样式 | `value === answer` |
|
||||
| multiple_choice | 选项列表,checkbox 样式 | 数组比较 |
|
||||
| true_false | 两个大按钮 | `value === answer` |
|
||||
| short_answer | `<textarea>` | 关键词匹配(辅助) |
|
||||
| code_reading | 代码块 + 子题列表 | 按子题类型分别判定 |
|
||||
| scenario | 场景描述 + 子题列表 | 按子题类型分别判定 |
|
||||
题型颜色通过 CSS 变量定义在 `:root` 中,新增题型只需:
|
||||
1. 在 `QUESTION_TYPES` 添加配置
|
||||
2. 在 `:root` 添加 `--type-{name}` 变量
|
||||
3. CSS 自动生效(通过 `.q-card.type-{name}` 规则)
|
||||
|
||||
| 题型 | 渲染方式 | 判定逻辑 | 边框颜色变量 |
|
||||
|------|----------|----------|--------------|
|
||||
| fill_blank | 题干中的 `______` 替换为 `<input>` | `matchFillAnswer()` 根据 `answer_rule` 匹配 | `--type-fill_blank` |
|
||||
| single_choice | 选项列表,radio 样式 | `value === answer` | `--type-single_choice` |
|
||||
| multiple_choice | 选项列表,checkbox 样式 | 数组比较 | `--type-multiple_choice` |
|
||||
| true_false | 两个大按钮 | `value === answer` | `--type-true_false` |
|
||||
| short_answer | `<textarea>` | 关键词匹配(辅助) | `--type-short_answer` |
|
||||
| code_reading | 代码块 + 子题列表 | 按子题类型分别判定 | `--type-code_reading` |
|
||||
| scenario | 场景描述 + 子题列表 | 按子题类型分别判定 | `--type-scenario` |
|
||||
|
||||
## 5. 生成工作流
|
||||
|
||||
|
||||
+93
-52
@@ -5,19 +5,40 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🎯 CS 知识应试强化</title>
|
||||
<style>
|
||||
:root {
|
||||
:root, :root[data-theme="dark"] {
|
||||
--bg: #0f172a; --surface: #1e293b; --surface2: #334155;
|
||||
--text: #e2e8f0; --text2: #94a3b8; --accent: #6366f1;
|
||||
--accent2: #818cf8; --green: #22c55e; --red: #ef4444;
|
||||
--yellow: #eab308; --radius: 12px;
|
||||
--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;
|
||||
}
|
||||
* { 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 */
|
||||
.header { background: linear-gradient(135deg, #312e81, #1e1b4b); padding: 28px 24px; text-align: center; border-bottom: 1px solid var(--surface2); }
|
||||
.header { background: var(--header-bg); padding: 28px 24px; text-align: center; border-bottom: 1px solid var(--surface2); position: relative; }
|
||||
.header h1 { font-size: 26px; font-weight: 700; margin-bottom: 6px; }
|
||||
.header p { color: var(--text2); font-size: 14px; }
|
||||
.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); }
|
||||
|
||||
/* Layout */
|
||||
.app { display: flex; min-height: calc(100vh - 100px); }
|
||||
@@ -40,14 +61,14 @@ body { font-family: -apple-system, 'Segoe UI', sans-serif; background: var(--bg)
|
||||
.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); }
|
||||
|
||||
/* 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; }
|
||||
|
||||
/* Toolbar */
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 12px; justify-content: space-between; align-items: center; padding: 16px 20px; background: var(--surface); border-radius: var(--radius); margin-bottom: 20px; }
|
||||
.stats { display: flex; gap: 16px; font-size: 13px; color: var(--text2); }
|
||||
.stats span { display: flex; align-items: center; gap: 4px; }
|
||||
.stats .num { font-weight: 700; font-size: 16px; }
|
||||
.stats .num.green { color: var(--green); }
|
||||
.stats .num.red { color: var(--red); }
|
||||
.stats .num.yellow { color: var(--yellow); }
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 12px; justify-content: flex-end; align-items: center; margin-bottom: 16px; }
|
||||
.toolbar-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* Buttons */
|
||||
@@ -58,20 +79,20 @@ body { font-family: -apple-system, 'Segoe UI', sans-serif; background: var(--bg)
|
||||
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.btn-sm { padding: 5px 12px; font-size: 12px; }
|
||||
|
||||
/* Progress */
|
||||
.progress-bar { height: 4px; background: var(--surface2); border-radius: 2px; margin-bottom: 20px; overflow: hidden; }
|
||||
.progress-bar .fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--green)); transition: width 0.3s ease; }
|
||||
|
||||
/* 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); }
|
||||
.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); }
|
||||
|
||||
.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-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; }
|
||||
.q-badge.fill_blank { background: rgba(99,102,241,0.15); color: var(--accent2); }
|
||||
.q-badge.single_choice { background: rgba(34,197,94,0.15); color: var(--green); }
|
||||
.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); }
|
||||
@@ -144,6 +165,7 @@ body { font-family: -apple-system, 'Segoe UI', sans-serif; background: var(--bg)
|
||||
<div class="header">
|
||||
<h1>🎯 CS 知识应试强化</h1>
|
||||
<p>选择主题,开始训练</p>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" title="切换主题">🌙</button>
|
||||
</div>
|
||||
|
||||
<div class="app">
|
||||
@@ -181,15 +203,32 @@ body { font-family: -apple-system, 'Segoe UI', sans-serif; background: var(--bg)
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ─── 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' },
|
||||
};
|
||||
|
||||
// ─── State ───
|
||||
let topics = [];
|
||||
let currentTopic = null;
|
||||
let currentQuestions = []; // flattened from all type files
|
||||
let answers = {}; // qId -> { value, correct, revealed }
|
||||
let customQuestions = []; // imported
|
||||
let activeTab = 'all'; // 'all' or type name
|
||||
|
||||
// ─── Init ───
|
||||
async function init() {
|
||||
// Theme init
|
||||
const saved = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.dataset.theme = saved;
|
||||
updateThemeToggle();
|
||||
|
||||
try {
|
||||
const res = await fetch('topics/index.json');
|
||||
const data = await res.json();
|
||||
@@ -200,6 +239,20 @@ async function init() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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' ? '🌙' : '☀️';
|
||||
}
|
||||
|
||||
// ─── Topic List ───
|
||||
function renderTopicList() {
|
||||
const ul = document.getElementById('topicList');
|
||||
@@ -223,14 +276,14 @@ function renderTopicList() {
|
||||
}
|
||||
|
||||
function typeLabel(type) {
|
||||
const map = { fill_blank: '填空', single_choice: '选择', multiple_choice: '多选', true_false: '判断', short_answer: '简答', code_reading: '代码', scenario: '场景' };
|
||||
return map[type] || type;
|
||||
return QUESTION_TYPES[type]?.label || type;
|
||||
}
|
||||
|
||||
// ─── Select Topic ───
|
||||
async function selectTopic(slug) {
|
||||
currentTopic = slug;
|
||||
answers = {};
|
||||
activeTab = 'all';
|
||||
renderTopicList();
|
||||
|
||||
if (slug === '__custom__') {
|
||||
@@ -260,6 +313,11 @@ async function selectTopic(slug) {
|
||||
}
|
||||
|
||||
// ─── Render Quiz ───
|
||||
function getFilteredQuestions() {
|
||||
if (activeTab === 'all') return currentQuestions;
|
||||
return currentQuestions.filter(q => q.type === activeTab);
|
||||
}
|
||||
|
||||
function renderQuiz() {
|
||||
const main = document.getElementById('mainContent');
|
||||
if (!currentQuestions.length) {
|
||||
@@ -267,31 +325,36 @@ function renderQuiz() {
|
||||
return;
|
||||
}
|
||||
|
||||
const topicName = currentTopic === '__custom__' ? '自定义导入' : (topics.find(t => t.slug === currentTopic)?.name || '');
|
||||
// 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>`;
|
||||
});
|
||||
|
||||
let html = `
|
||||
<div class="tabs">${tabsHtml}</div>
|
||||
<div class="toolbar">
|
||||
<div class="stats">
|
||||
<span>📊 进度 <span class="num" id="answeredCount">0</span>/${currentQuestions.length}</span>
|
||||
<span>✅ <span class="num green" id="correctCount">0</span></span>
|
||||
<span>❌ <span class="num red" id="wrongCount">0</span></span>
|
||||
<span>⏳ <span class="num yellow" id="pendingCount">${currentQuestions.length}</span></span>
|
||||
</div>
|
||||
<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>
|
||||
<div class="progress-bar"><div class="fill" id="progressFill" style="width:0%"></div></div>
|
||||
`;
|
||||
|
||||
currentQuestions.forEach((q, i) => {
|
||||
const filtered = getFilteredQuestions();
|
||||
filtered.forEach((q, i) => {
|
||||
html += renderQuestionCard(q, i);
|
||||
});
|
||||
|
||||
main.innerHTML = html;
|
||||
updateStats();
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
activeTab = tab;
|
||||
renderQuiz();
|
||||
}
|
||||
|
||||
function renderQuestionCard(q, i) {
|
||||
@@ -305,10 +368,9 @@ function renderQuestionCard(q, i) {
|
||||
stars += `<div class="star ${s <= q.difficulty ? 'active' : ''}"></div>`;
|
||||
}
|
||||
|
||||
let html = `<div class="q-card ${cardClass}" id="card-${q.id}">
|
||||
let html = `<div class="q-card ${cardClass} type-${q.type}" id="card-${q.id}">
|
||||
<div class="q-header">
|
||||
<span class="q-number">#${i + 1}</span>
|
||||
<span class="q-badge ${q.type}">${typeLabel(q.type)}</span>
|
||||
<div class="q-difficulty">${stars}</div>
|
||||
</div>
|
||||
<div class="q-text">`;
|
||||
@@ -403,7 +465,6 @@ function checkFill(qId) {
|
||||
|
||||
const correct = matchFillAnswer(val, q.answer, q.answer_rule || 'any');
|
||||
answers[qId] = { value: val, correct, revealed: true };
|
||||
updateStats();
|
||||
renderQuiz();
|
||||
}
|
||||
|
||||
@@ -436,7 +497,6 @@ function revealAnswer(qId) {
|
||||
answers[qId].correct = answers[qId].value === q.answer;
|
||||
}
|
||||
answers[qId].revealed = true;
|
||||
updateStats();
|
||||
renderQuiz();
|
||||
}
|
||||
|
||||
@@ -450,7 +510,6 @@ function showAllAnswers() {
|
||||
answers[q.id].revealed = true;
|
||||
}
|
||||
});
|
||||
updateStats();
|
||||
renderQuiz();
|
||||
}
|
||||
|
||||
@@ -466,32 +525,14 @@ function submitAll() {
|
||||
answers[q.id].revealed = true;
|
||||
}
|
||||
});
|
||||
updateStats();
|
||||
renderQuiz();
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
answers = {};
|
||||
updateStats();
|
||||
renderQuiz();
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
let answered = 0, correct = 0, wrong = 0;
|
||||
currentQuestions.forEach(q => {
|
||||
if (answers[q.id]?.revealed) {
|
||||
answered++;
|
||||
if (answers[q.id].correct) correct++; else wrong++;
|
||||
}
|
||||
});
|
||||
const el = id => document.getElementById(id);
|
||||
if (el('answeredCount')) el('answeredCount').textContent = answered;
|
||||
if (el('correctCount')) el('correctCount').textContent = correct;
|
||||
if (el('wrongCount')) el('wrongCount').textContent = wrong;
|
||||
if (el('pendingCount')) el('pendingCount').textContent = currentQuestions.length - answered;
|
||||
if (el('progressFill')) el('progressFill').style.width = (currentQuestions.length ? (answered / currentQuestions.length * 100) : 0) + '%';
|
||||
}
|
||||
|
||||
// ─── Import ───
|
||||
function openImportModal() { document.getElementById('importModal').classList.add('show'); }
|
||||
function closeImportModal() { document.getElementById('importModal').classList.remove('show'); }
|
||||
|
||||
Reference in New Issue
Block a user