feat: 添加 Topic-SubTopic 层级结构与 Accordion 侧边栏

- topics/index.json 重构为嵌套模型(topic → subtopics)
- 侧边栏改为 Accordion 交互:默认折叠、同时只展开一个
- 展开/折叠状态通过 localStorage 持久化
- 更新架构文档同步数据模型变更

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-02 19:10:14 +08:00
parent 273b88219a
commit 10a464eea4
3 changed files with 191 additions and 124 deletions
+85 -36
View File
@@ -47,8 +47,20 @@ body { font-family: -apple-system, 'Segoe UI', sans-serif; background: var(--bg)
/* Sidebar */
.sidebar-title { font-size: 13px; color: var(--text2); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; padding: 0 8px; }
.topic-list { list-style: none; }
.topic-item { padding: 12px 16px; border-radius: 8px; cursor: pointer; transition: all 0.2s; margin-bottom: 4px; }
/* 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; }
.topic-item:hover { background: var(--surface2); }
.topic-item.active { background: var(--accent); color: white; }
.topic-item .topic-name { font-size: 14px; font-weight: 600; }
@@ -170,8 +182,7 @@ body { font-family: -apple-system, 'Segoe UI', sans-serif; background: var(--bg)
<div class="app">
<div class="sidebar">
<div class="sidebar-title">📚 主题列表</div>
<ul class="topic-list" id="topicList"></ul>
<div id="accordionContainer"></div>
<div class="sidebar-section">
<button class="import-btn" onclick="openImportModal()">📥 导入题目</button>
</div>
@@ -215,16 +226,16 @@ const QUESTION_TYPES = {
};
// ─── 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
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';
// ─── Init ───
async function init() {
// Theme init
const saved = localStorage.getItem('theme') || 'dark';
document.documentElement.dataset.theme = saved;
updateThemeToggle();
@@ -232,10 +243,11 @@ async function init() {
try {
const res = await fetch('topics/index.json');
const data = await res.json();
topics = data.topics || [];
renderTopicList();
topicGroups = data.topics || [];
expandedGroup = localStorage.getItem('expandedGroup') || null;
renderAccordion();
} catch (e) {
document.getElementById('topicList').innerHTML = '<li style="color:var(--red);padding:12px;">加载主题列表失败</li>';
document.getElementById('accordionContainer').innerHTML = '<div style="color:var(--red);padding:12px;">加载主题列表失败</div>';
}
}
@@ -253,26 +265,63 @@ function updateThemeToggle() {
if (btn) btn.textContent = document.documentElement.dataset.theme === 'dark' ? '🌙' : '☀️';
}
// ─── Topic List ───
function renderTopicList() {
const ul = document.getElementById('topicList');
let html = '';
topics.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>`;
});
if (customQuestions.length) {
html += `<li class="topic-item ${currentTopic === '__custom__' ? 'active' : ''}" onclick="selectTopic('__custom__')">
<div class="topic-name">📥 自定义导入</div>
<div class="topic-desc">临时导入的题目</div>
<div class="topic-stats">${customQuestions.length} 题</div>
</li>`;
// ─── 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;
}
ul.innerHTML = html;
return null;
}
function renderAccordion() {
const container = document.getElementById('accordionContainer');
let html = '';
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>`;
});
if (customQuestions.length) {
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>`;
}
container.innerHTML = html;
}
function typeLabel(type) {
@@ -284,7 +333,7 @@ async function selectTopic(slug) {
currentTopic = slug;
answers = {};
activeTab = 'all';
renderTopicList();
renderAccordion();
if (slug === '__custom__') {
currentQuestions = customQuestions;
@@ -292,7 +341,7 @@ async function selectTopic(slug) {
return;
}
const topic = topics.find(t => t.slug === slug);
const topic = findSubtopic(slug);
if (!topic) return;
try {
@@ -545,7 +594,7 @@ function doImport() {
const questions = data.questions || (Array.isArray(data) ? data : [data]);
customQuestions.push(...questions);
closeImportModal();
renderTopicList();
renderAccordion();
if (currentTopic === '__custom__') selectTopic('__custom__');
document.getElementById('importText').value = '';
} catch (e) {