Files
examination/index.html
T

1176 lines
45 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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"] {
--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; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
/* 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); }
/* Layout */
.app { display: flex; flex: 1; min-height: 0; }
.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; }
/* 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; }
.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; }
/* Toolbar */
.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 */
.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); }
.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); }
/* 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); }
/* 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; }
/* 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>
</div>
<div class="app">
<div class="sidebar">
<div id="accordionContainer"></div>
<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>
<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' },
};
// ─── 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';
// ─── Init ───
async function 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();
topicGroups = data.topics || [];
expandedGroup = localStorage.getItem('expandedGroup') || null;
renderAccordion();
} catch (e) {
document.getElementById('accordionContainer').innerHTML = '<div style="color:var(--red);padding:12px;">加载主题列表失败</div>';
}
}
// ─── 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');
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) {
return QUESTION_TYPES[type]?.label || type;
}
// ─── Select Topic ───
async function selectTopic(slug) {
currentTopic = slug;
answers = {};
activeTab = 'all';
renderAccordion();
if (slug === '__custom__') {
currentQuestions = customQuestions;
renderQuiz();
return;
}
const topic = findSubtopic(slug);
if (!topic) return;
try {
const topicPath = topic.path;
const metaRes = await fetch(`${topicPath}/meta.json`);
const meta = await metaRes.json();
currentQuestions = [];
for (const typeFile of meta.question_files) {
const qRes = await fetch(`${topicPath}/${typeFile}.json`);
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);
}
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>`;
});
let html = `
<div class="tabs">${tabsHtml}</div>
<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) => {
html += renderQuestionCard(q, i);
});
main.innerHTML = html;
}
function switchTab(tab) {
activeTab = tab;
renderQuiz();
}
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}">
<div class="q-header">
<span class="q-number">#${i + 1}</span>
<button class="copy-btn" data-copy="${q.id}" onclick="copyQuestionPrompt('${q.id}')">📋 复制题目</button>
<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 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 || []) : [];
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) {
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}
<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();
}
// 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();
}
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]);
// 让多选选项未选中状态也能正确显示
}
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 };
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]);
}
answers[q.id].revealed = true;
}
});
renderQuiz();
}
function submitAll() {
currentQuestions.forEach(q => {
if (!answers[q.id]) answers[q.id] = { value: '', selected: [], 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 === '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]);
} 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();
}
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();
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);
}
}
// ─── 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);
}
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 });
}
// ─── Start ───
init();
setupHeaderScroll();
</script>
</body>
</html>