Files
examination/index.html
T
wonder d3cba0e23c feat: 初始化 CS 知识应试强化系统
- 纯 HTML/CSS/JS 单页应用,支持填空题和选择题交互
- 5 个主题共 100 道题(GC/JVM、AI 结构化输出、记忆管理、幻觉、工具链)
- 按主题组织、按题型拆分的 JSON 数据结构
- JSON Schema 校验 + 提示词模板 + 题型模板
- 自定义导入功能(粘贴/上传 JSON)
- 项目文档(需求 + 架构)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-02 18:22:20 +08:00

536 lines
22 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 {
--bg: #0f172a; --surface: #1e293b; --surface2: #334155;
--text: #e2e8f0; --text2: #94a3b8; --accent: #6366f1;
--accent2: #818cf8; --green: #22c55e; --red: #ef4444;
--yellow: #eab308; --radius: 12px;
}
* { 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 h1 { font-size: 26px; font-weight: 700; margin-bottom: 6px; }
.header p { color: var(--text2); font-size: 14px; }
/* Layout */
.app { display: flex; min-height: calc(100vh - 100px); }
.sidebar { width: 280px; background: var(--surface); border-right: 1px solid var(--surface2); padding: 16px; overflow-y: auto; flex-shrink: 0; }
.main { flex: 1; padding: 24px; overflow-y: auto; max-height: calc(100vh - 100px); }
/* Sidebar */
.sidebar-title { font-size: 13px; color: var(--text2); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; padding: 0 8px; }
.topic-list { list-style: none; }
.topic-item { padding: 12px 16px; border-radius: 8px; cursor: pointer; transition: all 0.2s; margin-bottom: 4px; }
.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); }
/* 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-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; }
/* 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-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); }
.q-text { font-size: 15px; font-weight: 500; margin-bottom: 16px; line-height: 1.7; }
/* Fill blank input inline */
.blank-input { display: inline-block; min-width: 100px; padding: 2px 8px; border: none; border-bottom: 2px dashed var(--accent); background: transparent; color: var(--text); font-size: 15px; font-weight: 500; outline: none; transition: all 0.2s; }
.blank-input:focus { border-bottom-color: var(--accent2); border-bottom-style: solid; }
.blank-input.correct { border-bottom-color: var(--green); color: var(--green); }
.blank-input.wrong { border-bottom-color: var(--red); color: var(--red); }
/* Options */
.options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.option { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: 8px; border: 1px solid var(--surface2); cursor: pointer; transition: all 0.2s; font-size: 14px; }
.option:hover { border-color: var(--accent); background: rgba(99,102,241,0.05); }
.option.selected { border-color: var(--accent); background: rgba(99,102,241,0.1); }
.option.correct { border-color: var(--green); background: rgba(34,197,94,0.1); }
.option.wrong { border-color: var(--red); background: rgba(239,68,68,0.1); }
.option .radio { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--surface2); flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: all 0.2s; }
.option.selected .radio { border-color: var(--accent); }
.option.selected .radio::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.option.correct .radio { border-color: var(--green); }
.option.correct .radio::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--green); }
.option.wrong .radio { border-color: var(--red); }
.option.wrong .radio::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--red); }
/* Actions */
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 4px; }
/* Answer box */
.answer-box { display: none; margin-top: 12px; padding: 16px; border-radius: 8px; background: var(--bg); border: 1px solid var(--surface2); font-size: 14px; }
.answer-box.show { display: block; }
.answer-box .label { font-weight: 600; color: var(--accent2); margin-bottom: 4px; }
.answer-box .answer-text { color: var(--green); font-weight: 600; margin-bottom: 8px; }
.answer-box .explanation { color: var(--text2); line-height: 1.7; }
/* Tags */
.q-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 8px; }
.q-tag { font-size: 11px; padding: 2px 8px; border-radius: 4px; background: var(--surface2); color: var(--text2); }
/* Welcome */
.welcome { text-align: center; padding: 80px 20px; color: var(--text2); }
.welcome h2 { font-size: 20px; color: var(--text); margin-bottom: 8px; }
.welcome p { max-width: 400px; margin: 0 auto; }
/* Modal */
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 200; align-items: center; justify-content: center; }
.modal-overlay.show { display: flex; }
.modal { background: var(--surface); border-radius: var(--radius); padding: 24px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; }
.modal h3 { margin-bottom: 16px; }
.modal textarea { width: 100%; height: 200px; padding: 12px; border-radius: 8px; border: 1px solid var(--surface2); background: var(--bg); color: var(--text); font-family: monospace; font-size: 13px; resize: vertical; outline: none; }
.modal textarea:focus { border-color: var(--accent); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.modal-or { text-align: center; color: var(--text2); font-size: 13px; margin: 12px 0; }
.file-input { display: block; width: 100%; padding: 12px; border: 1px dashed var(--surface2); border-radius: 8px; text-align: center; color: var(--text2); cursor: pointer; font-size: 13px; }
.file-input:hover { border-color: var(--accent); }
/* Responsive */
@media (max-width: 768px) {
.app { flex-direction: column; }
.sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--surface2); max-height: 200px; }
.main { max-height: none; }
.header h1 { font-size: 20px; }
}
</style>
</head>
<body>
<div class="header">
<h1>🎯 CS 知识应试强化</h1>
<p>选择主题,开始训练</p>
</div>
<div class="app">
<div class="sidebar">
<div class="sidebar-title">📚 主题列表</div>
<ul class="topic-list" id="topicList"></ul>
<div class="sidebar-section">
<button class="import-btn" onclick="openImportModal()">📥 导入题目</button>
</div>
</div>
<div class="main" id="mainContent">
<div class="welcome">
<h2>👋 欢迎</h2>
<p>从左侧选择一个主题开始练习</p>
</div>
</div>
</div>
<!-- Import Modal -->
<div class="modal-overlay" id="importModal">
<div class="modal">
<h3>📥 导入题目</h3>
<p style="color:var(--text2);font-size:13px;margin-bottom:12px;">粘符合规范的 JSON 数据(topics 格式)</p>
<textarea id="importText" placeholder='{"topic":"custom","type":"single_choice","questions":[...]}'></textarea>
<div class="modal-or">— 或者 —</div>
<label class="file-input">
📁 选择 JSON 文件
<input type="file" accept=".json" style="display:none" onchange="handleFileImport(event)">
</label>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeImportModal()">取消</button>
<button class="btn btn-primary" onclick="doImport()">导入</button>
</div>
</div>
</div>
<script>
// ─── State ───
let topics = [];
let currentTopic = null;
let currentQuestions = []; // flattened from all type files
let answers = {}; // qId -> { value, correct, revealed }
let customQuestions = []; // imported
// ─── Init ───
async function init() {
try {
const res = await fetch('topics/index.json');
const data = await res.json();
topics = data.topics || [];
renderTopicList();
} catch (e) {
document.getElementById('topicList').innerHTML = '<li style="color:var(--red);padding:12px;">加载主题列表失败</li>';
}
}
// ─── 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>`;
}
ul.innerHTML = html;
}
function typeLabel(type) {
const map = { fill_blank: '填空', single_choice: '选择', multiple_choice: '多选', true_false: '判断', short_answer: '简答', code_reading: '代码', scenario: '场景' };
return map[type] || type;
}
// ─── Select Topic ───
async function selectTopic(slug) {
currentTopic = slug;
answers = {};
renderTopicList();
if (slug === '__custom__') {
currentQuestions = customQuestions;
renderQuiz();
return;
}
const topic = topics.find(t => t.slug === slug);
if (!topic) return;
try {
// Load meta to get question file list
const metaRes = await fetch(`topics/${slug}/meta.json`);
const meta = await metaRes.json();
currentQuestions = [];
for (const typeFile of meta.question_files) {
const qRes = await fetch(`topics/${slug}/${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 renderQuiz() {
const main = document.getElementById('mainContent');
if (!currentQuestions.length) {
main.innerHTML = '<div class="welcome"><h2>暂无题目</h2><p>该主题下没有题目数据</p></div>';
return;
}
const topicName = currentTopic === '__custom__' ? '自定义导入' : (topics.find(t => t.slug === currentTopic)?.name || '');
let html = `
<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) => {
html += renderQuestionCard(q, i);
});
main.innerHTML = html;
updateStats();
}
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}" 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">`;
if (q.type === 'fill_blank') {
// Replace ______ with input
const parts = q.question.split(/_{3,}/);
parts.forEach((part, pi) => {
html += escapeHtml(part);
if (pi < parts.length - 1) {
const val = revealed ? (Array.isArray(q.answer) ? q.answer[0] : q.answer) : (state?.value || '');
const cls = revealed ? (correct ? 'blank-input correct' : 'blank-input wrong') : 'blank-input';
html += `<input class="${cls}" id="input-${q.id}" value="${escapeHtml(val)}" ${revealed ? 'disabled' : ''} placeholder="填空" onchange="saveFill('${q.id}')" onkeydown="if(event.key==='Enter')checkFill('${q.id}')">`;
}
});
} else {
html += escapeHtml(q.question);
}
html += `</div>`;
// Options for single_choice
if (q.type === 'single_choice' && q.options) {
html += `<div class="options">`;
for (const [letter, text] of Object.entries(q.options)) {
let optClass = '';
if (revealed) {
if (letter === q.answer) optClass = 'correct';
else if (state?.value === letter) optClass = 'wrong';
} else if (state?.value === letter) {
optClass = 'selected';
}
html += `<div class="option ${optClass}" onclick="selectOption('${q.id}','${letter}')">
<div class="radio"></div>
<span><strong>${letter}.</strong> ${escapeHtml(text)}</span>
</div>`;
}
html += `</div>`;
}
// Actions
html += `<div class="actions">`;
if (!revealed) {
if (q.type === 'fill_blank') {
html += `<button class="btn btn-primary btn-sm" onclick="checkFill('${q.id}')">✓ 检查</button>`;
}
html += `<button class="btn btn-outline btn-sm" onclick="revealAnswer('${q.id}')">👁 显示答案</button>`;
}
html += `</div>`;
// Answer box
const answerText = Array.isArray(q.answer) ? q.answer.join(' / ') : String(q.answer);
html += `<div class="answer-box ${revealed ? 'show' : ''}" id="ans-${q.id}">
<div class="label">正确答案</div>
<div class="answer-text">${escapeHtml(answerText)}</div>
<div class="explanation">💡 ${escapeHtml(q.explanation || '')}</div>
</div>`;
// Tags
if (q.tags?.length) {
html += `<div class="q-tags">${q.tags.map(t => `<span class="q-tag">${escapeHtml(t)}</span>`).join('')}</div>`;
}
html += `</div>`;
return html;
}
// ─── Interactions ───
function selectOption(qId, letter) {
const q = currentQuestions.find(x => x.id === qId);
if (!q || answers[qId]?.revealed) return;
answers[qId] = { value: letter, correct: false, revealed: false };
renderQuiz();
}
function saveFill(qId) {
const input = document.getElementById(`input-${qId}`);
if (!input) return;
const val = input.value.trim();
if (val) {
answers[qId] = { value: val, correct: false, revealed: false };
}
}
function checkFill(qId) {
const input = document.getElementById(`input-${qId}`);
if (!input) return;
const val = input.value.trim();
if (!val) return;
const q = currentQuestions.find(x => x.id === qId);
if (!q) return;
const correct = matchFillAnswer(val, q.answer, q.answer_rule || 'any');
answers[qId] = { value: val, correct, revealed: true };
updateStats();
renderQuiz();
}
function matchFillAnswer(userVal, answers, rule) {
const norm = s => s.toLowerCase().replace(/[\s\-_\/\\,,。;;::'""''()()\[\]【]{}]+/g, '').trim();
const nv = norm(userVal);
if (!Array.isArray(answers)) answers = [answers];
const normalized = answers.map(norm);
if (rule === 'all') return normalized.every(a => nv.includes(a));
if (rule === 'ordered') {
let lastIdx = -1;
for (const a of normalized) {
const idx = nv.indexOf(a, lastIdx + 1);
if (idx === -1) return false;
lastIdx = idx;
}
return true;
}
// any
return normalized.some(a => nv === a || nv.includes(a));
}
function revealAnswer(qId) {
const q = currentQuestions.find(x => x.id === qId);
if (!q) return;
if (!answers[qId]) answers[qId] = { value: '', correct: false };
if (q.type === 'single_choice') {
answers[qId].correct = answers[qId].value === q.answer;
}
answers[qId].revealed = true;
updateStats();
renderQuiz();
}
function showAllAnswers() {
currentQuestions.forEach(q => {
if (!answers[q.id]?.revealed) {
if (!answers[q.id]) answers[q.id] = { value: '', correct: false };
if (q.type === 'single_choice') {
answers[q.id].correct = answers[q.id].value === q.answer;
}
answers[q.id].revealed = true;
}
});
updateStats();
renderQuiz();
}
function submitAll() {
currentQuestions.forEach(q => {
if (!answers[q.id]) answers[q.id] = { value: '', correct: false };
if (!answers[q.id].revealed) {
if (q.type === 'single_choice') {
answers[q.id].correct = answers[q.id].value === q.answer;
} else if (q.type === 'fill_blank') {
answers[q.id].correct = matchFillAnswer(answers[q.id].value, q.answer, q.answer_rule || 'any');
}
answers[q.id].revealed = true;
}
});
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'); }
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();
renderTopicList();
if (currentTopic === '__custom__') selectTopic('__custom__');
document.getElementById('importText').value = '';
} catch (e) {
alert('JSON 解析失败: ' + e.message);
}
}
function handleFileImport(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
document.getElementById('importText').value = e.target.result;
};
reader.readAsText(file);
}
// ─── Util ───
function escapeHtml(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ─── Start ───
init();
</script>
</body>
</html>