Files
examination/index.html
T

1235 lines
48 KiB
HTML
Raw Normal View History

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>
: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;
--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; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
2026-09-02 18:22:20 +08:00
/* Header */
.header {
background: var(--header-bg); text-align: center; border-bottom: 1px solid var(--surface2);
--header-pad-y: 28px;
padding: var(--header-pad-y) 24px;
transition: padding 0.3s ease, box-shadow 0.3s ease;
flex-shrink: 0;
}
.header.compact {
--header-pad-y: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.25);
}
.header h1 {
font-size: 26px; font-weight: 700; margin-bottom: 6px;
transition: font-size 0.3s ease, margin 0.3s ease;
}
.header.compact h1 { font-size: 18px; margin-bottom: 0; }
.header p {
color: rgba(255,255,255,0.7); font-size: 14px;
transition: opacity 0.25s ease, max-height 0.3s ease, margin 0.3s ease;
max-height: 40px; overflow: hidden;
}
.header.compact p { opacity: 0; max-height: 0; margin: 0; }
.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, top 0.3s ease;
}
.header.compact .theme-toggle { top: 8px; }
.theme-toggle:hover { background: rgba(255,255,255,0.25); }
2026-09-02 18:22:20 +08:00
/* Layout */
.app { display: flex; flex: 1; min-height: 0; }
2026-09-02 18:22:20 +08:00
.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; }
2026-09-02 18:22:20 +08:00
/* Sidebar */
.sidebar-title { font-size: 13px; color: var(--text2); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; padding: 0 8px; }
/* 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); }
/* 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 */
.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); }
.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); }
.option .checkbox { width: 18px; height: 18px; border: 2px solid var(--surface2); border-radius: 4px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 12px; color: white; transition: all 0.2s; }
.option.selected .checkbox { border-color: var(--accent); background: var(--accent); }
.option.correct .checkbox { border-color: var(--green); background: var(--green); }
.option.wrong .checkbox { border-color: var(--red); background: var(--red); }
2026-09-02 18:22:20 +08:00
/* 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; }
/* Code block (code_reading / scenario) */
.code-block { margin-top: 12px; border: 1px solid var(--surface2); border-radius: 8px; overflow: hidden; }
.code-lang { padding: 4px 12px; background: var(--surface2); color: var(--text2); font-size: 11px; font-weight: 600; letter-spacing: 0.6px; text-transform: uppercase; }
.code-block pre { margin: 0; padding: 12px 14px; overflow-x: auto; background: #0b1220; }
.code-block code { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 12.5px; line-height: 1.6; color: #e2e8f0; white-space: pre; }
/* Sub-questions (code_reading / scenario) */
.sub-questions { margin-top: 12px; border-top: 1px dashed var(--surface2); padding-top: 8px; }
.sub-question { margin-bottom: 10px; }
.sub-q-text { font-weight: 500; margin-bottom: 6px; }
.sub-options { margin-top: 4px; }
.sub-answer { margin-top: 6px; color: var(--green); font-weight: 600; font-size: 13px; }
.sub-explain { color: var(--text2); font-size: 13px; line-height: 1.7; margin-top: 4px; padding-left: 10px; border-left: 2px solid var(--surface2); }
2026-09-02 18:22:20 +08:00
/* 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); }
/* Copy button in question card */
.q-header .copy-btn { padding: 3px 10px; font-size: 12px; border-radius: 6px; border: 1px solid var(--surface2); background: transparent; color: var(--text2); cursor: pointer; transition: all 0.2s; white-space: nowrap; }
.q-header .copy-btn:hover { border-color: var(--accent); color: var(--accent); }
.q-header .copy-btn.copied { border-color: var(--green); color: var(--green); }
/* Import prompt section */
.prompt-section { margin-bottom: 16px; border: 1px solid var(--surface2); border-radius: 8px; overflow: hidden; }
.prompt-toggle { width: 100%; padding: 10px 14px; background: var(--bg); border: none; color: var(--text2); cursor: pointer; font-size: 13px; text-align: left; display: flex; align-items: center; gap: 6px; transition: color 0.2s; }
.prompt-toggle:hover { color: var(--text); }
.prompt-toggle .chevron { font-size: 10px; transition: transform 0.2s; margin-left: auto; }
.prompt-section.open .prompt-toggle .chevron { transform: rotate(90deg); }
.prompt-body { display: none; padding: 12px 14px; border-top: 1px solid var(--surface2); }
.prompt-section.open .prompt-body { display: block; }
.prompt-type-select { width: 100%; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--surface2); background: var(--surface); color: var(--text); font-size: 13px; margin-bottom: 10px; outline: none; }
.prompt-type-select:focus { border-color: var(--accent); }
.prompt-preview { background: var(--bg); border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 12px; font-family: monospace; color: var(--text2); max-height: 200px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; margin-bottom: 10px; }
.prompt-copy-btn { width: 100%; padding: 8px; border-radius: 6px; border: 1px solid var(--accent); background: transparent; color: var(--accent); cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.2s; }
.prompt-copy-btn:hover { background: var(--accent); color: white; }
.prompt-copy-btn.copied { border-color: var(--green); color: var(--green); background: transparent; }
2026-09-02 18:22:20 +08:00
/* 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>
<button class="theme-toggle" onclick="toggleTheme()" title="切换主题">🌙</button>
2026-09-02 18:22:20 +08:00
</div>
<div class="app">
<div class="sidebar">
<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>
<div class="prompt-section" id="promptSection">
<button class="prompt-toggle" onclick="togglePromptSection()">
📋 复制 AI 生成提示词(让 AI 帮你生成标准格式的题目 JSON)
<span class="chevron">▶</span>
</button>
<div class="prompt-body">
<select class="prompt-type-select" id="promptTypeSelect" onchange="updatePromptPreview()">
<option value="single_choice">单选题 (single_choice)</option>
<option value="multiple_choice">多选题 (multiple_choice)</option>
<option value="true_false">判断题 (true_false)</option>
<option value="fill_blank">填空题 (fill_blank)</option>
<option value="short_answer">简答题 (short_answer)</option>
<option value="code_reading">代码阅读题 (code_reading)</option>
<option value="scenario">场景分析题 (scenario)</option>
</select>
<div class="prompt-preview" id="promptPreview"></div>
<button class="prompt-copy-btn" id="promptCopyBtn" onclick="copyImportPrompt()">📋 复制提示词</button>
</div>
</div>
2026-09-02 18:22:20 +08:00
<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>
// ─── 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 ───
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() {
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();
topicGroups = data.topics || [];
expandedGroup = localStorage.getItem('expandedGroup') || null;
renderAccordion();
2026-09-02 18:22:20 +08:00
} catch (e) {
document.getElementById('accordionContainer').innerHTML = '<div style="color:var(--red);padding:12px;">加载主题列表失败</div>';
2026-09-02 18:22:20 +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' ? '🌙' : '☀️';
}
// ─── 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 = '';
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 18:22:20 +08:00
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>`;
2026-09-02 18:22:20 +08:00
}
container.innerHTML = html;
2026-09-02 18:22:20 +08:00
}
function typeLabel(type) {
return QUESTION_TYPES[type]?.label || type;
2026-09-02 18:22:20 +08:00
}
// ─── Select Topic ───
async function selectTopic(slug) {
currentTopic = slug;
answers = {};
activeTab = 'all';
renderAccordion();
2026-09-02 18:22:20 +08:00
if (slug === '__custom__') {
currentQuestions = customQuestions;
renderQuiz();
return;
}
const topic = findSubtopic(slug);
2026-09-02 18:22:20 +08:00
if (!topic) return;
try {
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) {
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 ───
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;
}
// 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 = `
<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>
`;
const filtered = getFilteredQuestions();
filtered.forEach((q, i) => {
2026-09-02 18:22:20 +08:00
html += renderQuestionCard(q, i);
});
main.innerHTML = html;
}
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>`;
}
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>
<button class="copy-btn" data-copy="${q.id}" onclick="copyQuestionPrompt('${q.id}')">📋 复制题目</button>
2026-09-02 18:22:20 +08:00
<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>`;
// 代码块:code_reading / scenario
if ((q.type === 'code_reading' || q.type === 'scenario') && q.code) {
const lang = (q.language || '').trim();
html += `<div class="code-block">`
+ (lang ? `<div class="code-lang"><span>${escapeHtml(lang)}</span></div>` : '')
+ `<pre><code>${escapeHtml(q.code)}</code></pre></div>`;
}
// Options for single_choice and multiple_choice
if ((q.type === 'single_choice' || q.type === 'multiple_choice') && q.options) {
const isMC = q.type === 'multiple_choice';
const selectedSet = isMC ? (state?.selected || []) : [];
2026-09-02 18:22:20 +08:00
html += `<div class="options">`;
for (const [letter, text] of Object.entries(q.options)) {
let optClass = '';
if (revealed) {
if (isMC) {
// multiple_choice: 'correct' = 答案里的正确项; 'wrong' = 用户误选的非正确项
const isCorrectAnswer = (q.answer || []).includes(letter);
const userSelected = selectedSet.includes(letter);
if (isCorrectAnswer) optClass = 'correct';
else if (userSelected) optClass = 'wrong';
} else {
if (letter === q.answer) optClass = 'correct';
else if (state?.value === letter) optClass = 'wrong';
}
} else if (isMC ? selectedSet.includes(letter) : state?.value === letter) {
2026-09-02 18:22:20 +08:00
optClass = 'selected';
}
const clickFn = isMC ? `toggleOption('${q.id}','${letter}')` : `selectOption('${q.id}','${letter}')`;
const marker = isMC
? `<div class="checkbox"><span>${selectedSet.includes(letter) ? '✓' : ''}</span></div>`
: `<div class="radio"></div>`;
html += `<div class="option ${optClass}" onclick="${clickFn}">
${marker}
2026-09-02 18:22:20 +08:00
<span><strong>${letter}.</strong> ${escapeHtml(text)}</span>
</div>`;
}
html += `</div>`;
}
// 子问题:code_reading / scenario
if ((q.type === 'code_reading' || q.type === 'scenario') && q.sub_questions?.length) {
html += `<div class="sub-questions">`;
q.sub_questions.forEach(sq => {
const subType = sq.type || 'short_answer';
html += `<div class="sub-question">
<div class="sub-q-text"><strong>子题 ${sq.index}.</strong> ${escapeHtml(sq.question)}</div>`;
if (subType === 'single_choice' && sq.options) {
html += `<div class="options sub-options">`;
for (const [letter, text] of Object.entries(sq.options)) {
let optClass = '';
if (revealed && sq.answer === letter) optClass = 'correct';
html += `<div class="option ${optClass}">
<div class="radio"></div>
<span><strong>${letter}.</strong> ${escapeHtml(text)}</span>
</div>`;
}
html += `</div>`;
}
if (revealed) {
const sa = Array.isArray(sq.answer) ? sq.answer.join(' / ') : (sq.answer ?? '');
html += `<div class="sub-answer">答案:${escapeHtml(sa)}</div>`;
if (sq.explanation) html += `<div class="sub-explain">💡 ${escapeHtml(sq.explanation)}</div>`;
}
html += `</div>`;
});
html += `</div>`;
}
2026-09-02 18:22:20 +08:00
// 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 isSubQType = q.type === 'code_reading' || q.type === 'scenario';
let answerHtmlInner = '';
if (isSubQType) {
// 答案在各子题中已展示, 这里只放整体解析
answerHtmlInner += q.explanation
? `<div class="explanation">💡 ${escapeHtml(q.explanation)}</div>`
: `<div class="explanation">💡 各子题答案见上方对应子题。</div>`;
} else {
const answerText = Array.isArray(q.answer) ? q.answer.join(' / ') : String(q.answer ?? '');
answerHtmlInner += `<div class="label">正确答案</div>
<div class="answer-text">${escapeHtml(answerText)}</div>
<div class="explanation">💡 ${escapeHtml(q.explanation || '')}</div>`;
}
html += `<div class="answer-box ${revealed ? 'show' : ''}" id="answer-${q.id}">${answerHtmlInner}</div>`;
2026-09-02 18:22:20 +08:00
// 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();
}
// Toggle an option for multiple_choice questions
function toggleOption(qId, letter) {
const q = currentQuestions.find(x => x.id === qId);
if (!q || q.type !== 'multiple_choice' || answers[qId]?.revealed) return;
const cur = answers[qId];
const selected = cur?.selected ? [...cur.selected] : [];
const idx = selected.indexOf(letter);
if (idx >= 0) selected.splice(idx, 1);
else selected.push(letter);
answers[qId] = { value: selected.join(','), selected, correct: false, revealed: false };
renderQuiz();
}
2026-09-02 18:22:20 +08:00
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;
} else if (q.type === 'multiple_choice') {
const userSel = (answers[qId].selected || []).slice().sort();
const correctSel = (q.answer || []).slice().sort();
answers[qId].correct = userSel.length === correctSel.length &&
userSel.every((x, i) => x === correctSel[i]);
// 让多选选项未选中状态也能正确显示
2026-09-02 18:22:20 +08:00
}
answers[qId].revealed = true;
renderQuiz();
}
function showAllAnswers() {
currentQuestions.forEach(q => {
if (!answers[q.id]?.revealed) {
if (!answers[q.id]) answers[q.id] = { value: '', selected: [], correct: false };
2026-09-02 18:22:20 +08:00
if (q.type === 'single_choice') {
answers[q.id].correct = answers[q.id].value === q.answer;
} else if (q.type === 'multiple_choice') {
const userSel = (answers[q.id].selected || []).slice().sort();
const correctSel = (q.answer || []).slice().sort();
answers[q.id].correct = userSel.length === correctSel.length &&
userSel.every((x, i) => x === correctSel[i]);
2026-09-02 18:22:20 +08:00
}
answers[q.id].revealed = true;
}
});
renderQuiz();
}
function submitAll() {
currentQuestions.forEach(q => {
if (!answers[q.id]) answers[q.id] = { value: '', selected: [], correct: false };
2026-09-02 18:22:20 +08:00
if (!answers[q.id].revealed) {
if (q.type === 'single_choice') {
answers[q.id].correct = answers[q.id].value === q.answer;
} else if (q.type === 'multiple_choice') {
const userSel = (answers[q.id].selected || []).slice().sort();
const correctSel = (q.answer || []).slice().sort();
answers[q.id].correct = userSel.length === correctSel.length &&
userSel.every((x, i) => x === correctSel[i]);
2026-09-02 18:22:20 +08:00
} 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');
updatePromptPreview();
}
2026-09-02 18:22:20 +08:00
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();
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);
}
// ─── Import Prompt Templates ───
const IMPORT_PROMPTS = {
single_choice: `你是一个计算机科学教育专家。请根据以下要求生成题目。
**主题**: {在此填写主题名称}
**数量**: 10
**难度范围**: 1-5
**知识点标签**: {在此填写相关知识点}
请严格按照以下 JSON 格式输出,不要添加任何额外内容:
\`\`\`json
{
"topic": "{topic_slug}",
"type": "single_choice",
"schema_version": "1.0.0",
"generated": "{ISO-8601 时间戳}",
"questions": [
{
"id": "sc-001",
"type": "single_choice",
"difficulty": 3,
"tags": ["知识点1", "知识点2"],
"question": "题目内容?",
"options": {
"A": "选项 A 的内容",
"B": "选项 B 的内容",
"C": "选项 C 的内容",
"D": "选项 D 的内容"
},
"answer": "A",
"explanation": "详细解析:A 正确因为……,B 错误因为……,C 错误因为……,D 错误因为……",
"source": null,
"related": []
}
]
}
\`\`\`
要求:
- 每题 4 个选项,只有 1 个正确答案
- 选项应具有合理的干扰性
- explanation 需说明正确答案正确的原因和其他选项错误的原因`,
multiple_choice: `你是一个计算机科学教育专家。请根据以下要求生成题目。
**主题**: {在此填写主题名称}
**数量**: 10
**难度范围**: 1-5
**知识点标签**: {在此填写相关知识点}
请严格按照以下 JSON 格式输出,不要添加任何额外内容:
\`\`\`json
{
"topic": "{topic_slug}",
"type": "multiple_choice",
"schema_version": "1.0.0",
"generated": "{ISO-8601 时间戳}",
"questions": [
{
"id": "mc-001",
"type": "multiple_choice",
"difficulty": 3,
"tags": ["知识点1", "知识点2"],
"question": "以下哪些说法正确?(多选)",
"options": {
"A": "选项 A 的内容",
"B": "选项 B 的内容",
"C": "选项 C 的内容",
"D": "选项 D 的内容"
},
"answer": ["A", "C"],
"explanation": "A 正确因为……,B 错误因为……,C 正确因为……,D 错误因为……",
"source": null,
"related": []
}
]
}
\`\`\`
要求:
- 每题 4-6 个选项,2-3 个正确答案
- answer 为正确选项字母的数组
- explanation 需说明每个选项对错的原因`,
true_false: `你是一个计算机科学教育专家。请根据以下要求生成题目。
**主题**: {在此填写主题名称}
**数量**: 10
**难度范围**: 1-5
**知识点标签**: {在此填写相关知识点}
请严格按照以下 JSON 格式输出,不要添加任何额外内容:
\`\`\`json
{
"topic": "{topic_slug}",
"type": "true_false",
"schema_version": "1.0.0",
"generated": "{ISO-8601 时间戳}",
"questions": [
{
"id": "tf-001",
"type": "true_false",
"difficulty": 2,
"tags": ["知识点1", "知识点2"],
"question": "判断以下陈述是否正确:某概念的描述。",
"answer": true,
"explanation": "该陈述正确/错误,因为……",
"source": null,
"related": []
}
]
}
\`\`\`
要求:
- 陈述一个明确的事实或概念
- answer 为 true 或 false(布尔值)
- explanation 需解释为什么对或错`,
fill_blank: `你是一个计算机科学教育专家。请根据以下要求生成题目。
**主题**: {在此填写主题名称}
**数量**: 10
**难度范围**: 1-5
**知识点标签**: {在此填写相关知识点}
请严格按照以下 JSON 格式输出,不要添加任何额外内容:
\`\`\`json
{
"topic": "{topic_slug}",
"type": "fill_blank",
"schema_version": "1.0.0",
"generated": "{ISO-8601 时间戳}",
"questions": [
{
"id": "fb-001",
"type": "fill_blank",
"difficulty": 3,
"tags": ["知识点1", "知识点2"],
"question": "______ 协议工作在 OSI 模型的第 ______ 层。",
"answer": ["HTTP", "7"],
"answer_rule": "ordered",
"explanation": "HTTP 是应用层协议,工作在 OSI 模型的第 7 层(应用层)。",
"source": null,
"related": []
}
]
}
\`\`\`
要求:
- 题目中用 ______(至少 3 个下划线)标记空白处
- answer 数组可包含多个可接受的答案
- answer_rule: "any"(任一匹配)、"all"(全部匹配)、"ordered"(按顺序匹配)`,
short_answer: `你是一个计算机科学教育专家。请根据以下要求生成题目。
**主题**: {在此填写主题名称}
**数量**: 10
**难度范围**: 1-5
**知识点标签**: {在此填写相关知识点}
请严格按照以下 JSON 格式输出,不要添加任何额外内容:
\`\`\`json
{
"topic": "{topic_slug}",
"type": "short_answer",
"schema_version": "1.0.0",
"generated": "{ISO-8601 时间戳}",
"questions": [
{
"id": "sa-001",
"type": "short_answer",
"difficulty": 4,
"tags": ["知识点1", "知识点2"],
"question": "请简述某概念的原理和应用场景。",
"answer": "参考答案内容……",
"keywords": ["关键词1", "关键词2", "关键词3"],
"scoring_rubric": "答对核心原理得 60 分;举出应用场景得 20 分;说明优缺点得 20 分。",
"explanation": "完整解析……",
"source": null,
"related": []
}
]
}
\`\`\`
要求:
- 提出开放性问题
- answer 为参考答案
- keywords 用于自动评分匹配
- scoring_rubric 描述评分标准`,
code_reading: `你是一个计算机科学教育专家。请根据以下要求生成题目。
**主题**: {在此填写主题名称}
**数量**: 5
**难度范围**: 3-5
**知识点标签**: {在此填写相关知识点}
请严格按照以下 JSON 格式输出,不要添加任何额外内容:
\`\`\`json
{
"topic": "{topic_slug}",
"type": "code_reading",
"schema_version": "1.0.0",
"generated": "{ISO-8601 时间戳}",
"questions": [
{
"id": "cr-001",
"type": "code_reading",
"difficulty": 4,
"tags": ["知识点1", "知识点2"],
"question": "阅读以下代码,回答问题。",
"code": "// 完整的代码片段",
"language": "python",
"sub_questions": [
{
"index": 1,
"type": "single_choice",
"question": "子问题 1 的内容?",
"options": { "A": "选项 A", "B": "选项 B", "C": "选项 C", "D": "选项 D" },
"answer": "A",
"explanation": "解析……"
},
{
"index": 2,
"type": "short_answer",
"question": "子问题 2 的内容?",
"answer": "参考答案",
"keywords": ["关键词"],
"explanation": "解析……"
}
],
"explanation": "整体代码解析……",
"source": null,
"related": []
}
]
}
\`\`\`
要求:
- 提供完整的、有教学价值的代码片段
- 设置 2-3 个子问题(可以是 single_choice / true_false / short_answer 等)
- language 指定代码语言`,
scenario: `你是一个计算机科学教育专家。请根据以下要求生成题目。
**主题**: {在此填写主题名称}
**数量**: 5
**难度范围**: 3-5
**知识点标签**: {在此填写相关知识点}
请严格按照以下 JSON 格式输出,不要添加任何额外内容:
\`\`\`json
{
"topic": "{topic_slug}",
"type": "scenario",
"schema_version": "1.0.0",
"generated": "{ISO-8601 时间戳}",
"questions": [
{
"id": "sn-001",
"type": "scenario",
"difficulty": 5,
"tags": ["知识点1", "知识点2"],
"question": "请分析以下技术场景。",
"context": "详细场景描述……",
"code": null,
"sub_questions": [
{
"index": 1,
"type": "short_answer",
"question": "分析性问题 1",
"answer": "参考答案",
"keywords": ["关键词"],
"scoring_rubric": "评分标准",
"explanation": "解析……"
},
{
"index": 2,
"type": "multiple_choice",
"question": "选择性问题 2",
"options": { "A": "选项 A", "B": "选项 B", "C": "选项 C", "D": "选项 D" },
"answer": ["A", "C"],
"explanation": "解析……"
}
],
"explanation": "整体场景分析……",
"source": null,
"related": []
}
]
}
\`\`\`
要求:
- 描述一个实际的技术场景
- context 为场景描述,code 可为 null 或附带代码
- 设置 2-3 个分析性子问题`
};
function togglePromptSection() {
document.getElementById('promptSection').classList.toggle('open');
}
function updatePromptPreview() {
const type = document.getElementById('promptTypeSelect').value;
document.getElementById('promptPreview').textContent = IMPORT_PROMPTS[type] || '';
}
function copyImportPrompt() {
const type = document.getElementById('promptTypeSelect').value;
const text = IMPORT_PROMPTS[type];
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('promptCopyBtn');
btn.textContent = '✅ 已复制';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = '📋 复制提示词'; btn.classList.remove('copied'); }, 1500);
});
}
// ─── Copy Question as Prompt ───
function copyQuestionPrompt(qId) {
const q = [...currentQuestions, ...customQuestions].find(x => x.id === qId);
if (!q) return;
const typeNames = {
fill_blank: '填空题', single_choice: '单选题', multiple_choice: '多选题',
true_false: '判断题', short_answer: '简答题', code_reading: '代码阅读题', scenario: '场景分析题'
};
const typeName = typeNames[q.type] || q.type;
let prompt = `请帮我解析以下${typeName}题目,给出答案和详细解析。\n\n`;
prompt += `【题型】${typeName}\n`;
prompt += `【难度】${'★'.repeat(q.difficulty)}${'☆'.repeat(5 - q.difficulty)}\n`;
if (q.tags?.length) prompt += `【知识点】${q.tags.join('、')}\n`;
prompt += `\n【题目】${q.question}\n`;
if (q.type === 'fill_blank') {
prompt += `\n请填写空白处的内容。`;
} else if (q.type === 'single_choice' || q.type === 'multiple_choice') {
if (q.options) {
prompt += `\n【选项】\n`;
for (const [letter, text] of Object.entries(q.options)) {
prompt += `${letter}. ${text}\n`;
}
}
if (q.type === 'multiple_choice') prompt += `\n(多选)`;
} else if (q.type === 'true_false') {
prompt += `\n请判断该陈述是否正确。`;
} else if (q.type === 'short_answer') {
prompt += `\n请给出完整回答。`;
} else if (q.type === 'code_reading') {
if (q.code) {
prompt += `\n【代码】\n\`\`\`${q.language || ''}\n${q.code}\n\`\`\``;
}
if (q.sub_questions?.length) {
prompt += `\n\n【子问题】\n`;
q.sub_questions.forEach(sq => {
prompt += `${sq.index}. ${sq.question}\n`;
if (sq.options) {
for (const [letter, text] of Object.entries(sq.options)) {
prompt += ` ${letter}. ${text}\n`;
}
}
});
}
} else if (q.type === 'scenario') {
if (q.context) prompt += `\n【场景】\n${q.context}`;
if (q.code) prompt += `\n\n【代码】\n\`\`\`\n${q.code}\n\`\`\``;
if (q.sub_questions?.length) {
prompt += `\n\n【子问题】\n`;
q.sub_questions.forEach(sq => {
prompt += `${sq.index}. ${sq.question}\n`;
if (sq.options) {
for (const [letter, text] of Object.entries(sq.options)) {
prompt += ` ${letter}. ${text}\n`;
}
}
});
}
}
const btn = document.querySelector(`[data-copy="${qId}"]`);
const showCopied = () => {
if (btn) {
btn.textContent = '✅ 已复制';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = '📋 复制题目'; btn.classList.remove('copied'); }, 1500);
}
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(prompt).then(showCopied).catch(() => fallbackCopy(prompt, showCopied));
} else {
fallbackCopy(prompt, showCopied);
}
}
2026-09-02 18:22:20 +08:00
// ─── Util ───
function fallbackCopy(text, onSuccess) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;opacity:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); onSuccess(); } catch (_) {}
document.body.removeChild(ta);
}
2026-09-02 18:22:20 +08:00
function escapeHtml(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ─── Scroll: compact header on scroll ───
function setupHeaderScroll() {
const main = document.getElementById('mainContent');
const header = document.querySelector('.header');
if (!main || !header) return;
const THRESHOLD = 60;
main.addEventListener('scroll', () => {
header.classList.toggle('compact', main.scrollTop > THRESHOLD);
}, { passive: true });
}
2026-09-02 18:22:20 +08:00
// ─── Start ───
init();
setupHeaderScroll();
2026-09-02 18:22:20 +08:00
</script>
</body>
</html>