Files
linux-practice/index.html
2026-03-18 18:12:04 +08:00

605 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linux Learning Lab</title>
<style>
:root {
--bg: #f3f7fb;
--panel: rgba(255, 255, 255, 0.94);
--line: #d8e2ee;
--text: #102033;
--muted: #5d7187;
--brand: #0f6db6;
--accent: #1d9b6c;
--soft: #e9f3ff;
--terminal: #0e1724;
--shadow: 0 18px 40px rgba(16, 32, 51, 0.12);
}
[data-theme="dark"] {
--bg: #08111d;
--panel: rgba(12, 24, 38, 0.94);
--line: #1d3245;
--text: #edf4fb;
--muted: #9bb0c3;
--brand: #67baff;
--accent: #62d6ab;
--soft: rgba(103, 186, 255, 0.1);
--terminal: #06101c;
--shadow: 0 20px 44px rgba(0, 0, 0, 0.34);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top right, rgba(15, 109, 182, 0.15), transparent 25%),
radial-gradient(circle at bottom left, rgba(29, 155, 108, 0.12), transparent 20%),
var(--bg);
}
button, input { font: inherit; }
.shell { max-width: 1480px; margin: 0 auto; padding: 20px; }
.topbar, .card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.topbar {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
margin-bottom: 16px;
align-items: center;
}
.title h1 { margin: 0 0 8px; font-size: 30px; }
.title p, .muted { color: var(--muted); line-height: 1.8; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
.btn, .btn-soft, .btn-ghost {
border: 0;
border-radius: 999px;
padding: 11px 16px;
cursor: pointer;
font-weight: 700;
}
.btn { color: #fff; background: linear-gradient(135deg, var(--brand), #37a1ff); }
.btn-soft { color: var(--brand); background: var(--soft); }
.btn-ghost { color: var(--text); background: transparent; border: 1px solid var(--line); }
.layout {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.sidebar { padding: 20px; position: sticky; top: 20px; }
.section { margin-bottom: 18px; }
.eyebrow { font-size: 12px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--brand); }
h2 { margin: 8px 0 10px; font-size: 18px; }
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 12px;
}
.stat {
padding: 14px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.3);
}
.stat span { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.stat strong { font-size: 22px; }
.search {
display: flex;
gap: 8px;
margin-top: 12px;
}
.search input {
flex: 1;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: transparent;
color: var(--text);
outline: none;
}
.chip-list, .cmd-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.chip {
display: inline-flex;
align-items: center;
padding: 7px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: var(--soft);
color: var(--brand);
}
.results, .modules { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; max-height: 34vh; overflow: auto; }
.result, .lesson-btn, .module-card {
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.18);
}
.result, .lesson-btn {
width: 100%;
text-align: left;
padding: 12px 14px;
color: var(--text);
cursor: pointer;
}
.result strong, .lesson-btn strong { display: block; margin-bottom: 4px; font-size: 14px; }
.module-card { padding: 14px; }
.module-card h3 { margin: 0 0 8px; font-size: 16px; }
.module-card p { margin: 0 0 10px; color: var(--muted); line-height: 1.7; }
.lesson-list { display: flex; flex-direction: column; gap: 8px; }
.lesson-btn.active { background: rgba(15, 109, 182, 0.12); border-color: rgba(15, 109, 182, 0.28); }
.main { display: flex; flex-direction: column; gap: 16px; }
.hero, .detail, .practice { padding: 22px; }
.hero-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 14px;
}
.hero-head h2 { margin: 8px 0 10px; font-size: 34px; }
.hero-grid, .detail-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 14px;
}
.panel {
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
background: rgba(255,255,255,0.12);
}
.panel h3 { margin: 0 0 10px; font-size: 17px; }
.panel p, .panel li { color: var(--muted); line-height: 1.9; }
.panel ul { margin: 0; padding-left: 18px; }
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.code {
margin: 0;
padding: 13px 14px;
border-radius: 14px;
background: var(--terminal);
color: #d8e8ff;
font-family: Consolas, "Courier New", monospace;
font-size: 14px;
overflow: auto;
}
.examples { display: flex; flex-direction: column; gap: 10px; }
.exercise-list { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
.exercise {
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
background: rgba(255,255,255,0.12);
}
.badge {
display: inline-flex;
margin-bottom: 10px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(29, 155, 108, 0.12);
color: var(--accent);
font-size: 12px;
font-weight: 800;
}
.exercise h4 { margin: 0 0 10px; font-size: 20px; }
.terminal {
margin-top: 12px;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(151,177,201,0.18);
background: linear-gradient(180deg, var(--terminal), #172435);
}
.terminal-head { padding: 11px 14px; color: #b8d4ef; border-bottom: 1px solid rgba(151,177,201,0.16); }
.terminal-input { display: flex; align-items: center; gap: 8px; padding: 12px 14px; }
.prompt { color: #62d6ab; font-family: Consolas, "Courier New", monospace; font-weight: 800; }
.terminal-input input {
flex: 1;
border: 0;
outline: none;
background: transparent;
color: #f4fbff;
font-family: Consolas, "Courier New", monospace;
}
.terminal-input button {
border: 0;
border-radius: 12px;
padding: 9px 13px;
background: linear-gradient(135deg, var(--brand), #37a1ff);
color: #fff;
cursor: pointer;
font-weight: 800;
}
.output {
margin: 0;
min-height: 100px;
padding: 14px;
white-space: pre-wrap;
color: #d8e8ff;
font-family: Consolas, "Courier New", monospace;
}
.feedback { display: none; margin-top: 10px; padding: 11px 13px; border-radius: 14px; line-height: 1.7; font-weight: 700; }
.feedback.show { display: block; }
.feedback.success { background: rgba(29,155,108,0.1); border: 1px solid rgba(29,155,108,0.22); color: #187653; }
.feedback.warn { background: rgba(240,180,41,0.1); border: 1px solid rgba(240,180,41,0.24); color: #a26b04; }
.empty { padding: 12px; border: 1px dashed var(--line); border-radius: 14px; color: var(--muted); text-align: center; }
@media (max-width: 1180px) {
.layout { grid-template-columns: 1fr; }
.sidebar { position: static; }
.hero-grid, .detail-grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.shell { padding: 14px; }
.topbar, .hero-head, .search, .terminal-input { flex-direction: column; align-items: stretch; }
.stats { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="shell">
<header class="topbar">
<div class="title">
<div class="eyebrow">Linux Ops Learning Lab</div>
<h1 id="courseTitle">Linux Learning Lab</h1>
<p id="courseDescription">Search Linux lessons, practice commands in a sandbox, and build better troubleshooting habits.</p>
</div>
<div class="actions">
<button class="btn-ghost" type="button" id="themeBtn">Toggle theme</button>
<button class="btn-soft" type="button" onclick="resetSandbox()">Reset sandbox</button>
<a class="btn-ghost" href="/privacy" target="_blank" rel="noreferrer">Privacy</a>
</div>
</header>
<div class="layout">
<aside class="sidebar card">
<section class="section">
<div class="eyebrow">Overview</div>
<h2>Course overview</h2>
<p class="muted">Lessons are grouped by module and command family. The page focuses on readable summaries and guided sandbox practice.</p>
<div class="stats">
<div class="stat"><span>Modules</span><strong id="moduleCount">0</strong></div>
<div class="stat"><span>Lessons</span><strong id="lessonCount">0</strong></div>
<div class="stat"><span>Exercises</span><strong id="exerciseCount">0</strong></div>
<div class="stat"><span>Commands</span><strong id="commandCount">0</strong></div>
</div>
<div class="chip-list" id="commandTags"></div>
<p class="muted" id="metaLine" style="margin-top: 12px;">Version --</p>
</section>
<section class="section">
<div class="eyebrow">Search</div>
<h2>Quick lookup</h2>
<div class="search">
<input id="searchInput" type="text" placeholder="Try pwd, grep, module_1, or m1_l1_pwd" />
<button class="btn" type="button" onclick="runSearch()">Search</button>
</div>
<div class="results" id="searchResults"><div class="empty">Search by command, lesson id, or module id.</div></div>
</section>
<section class="section">
<div class="eyebrow">Modules</div>
<h2>Course map</h2>
<div class="modules" id="moduleList"><div class="empty">Loading modules...</div></div>
</section>
</aside>
<main class="main">
<section class="hero card">
<div class="hero-head">
<div>
<div class="eyebrow" id="moduleLabel">Pick a lesson to begin</div>
<h2 id="lessonTitle">Turn Linux practice into workflow thinking</h2>
<p class="muted" id="lessonGoal">Choose a lesson from the left to see goals, examples, common pitfalls, and runnable exercises.</p>
</div>
<div class="actions">
<button class="btn-ghost" type="button" id="prevBtn">Previous</button>
<button class="btn" type="button" id="nextBtn">Next</button>
</div>
</div>
<div class="cmd-list" id="lessonCommands"></div>
<div class="hero-grid">
<div class="panel">
<h3>Module summary</h3>
<p id="moduleSummary">This card shows the learning direction of the current module.</p>
</div>
<div class="panel">
<h3>Sandbox runtime</h3>
<p>You can execute commands directly below. The platform returns simulated output and exercise feedback.</p>
<ul>
<li>Current directory: <span id="runtimeCwd">/</span></li>
<li>Current user: <span id="runtimeUser">sandbox_user</span></li>
</ul>
</div>
</div>
</section>
<section class="detail card">
<div class="detail-grid">
<article class="panel">
<h3>Why this matters</h3>
<p id="lessonWhy">Understanding when to use a command matters more than memorizing flags in isolation.</p>
</article>
<article class="panel">
<h3>Teaching angle</h3>
<p id="classicView">Each lesson connects the command to a broader troubleshooting flow.</p>
</article>
<article class="panel">
<h3>Core ideas</h3>
<ul id="conceptList"></ul>
</article>
<article class="panel">
<h3>Common scenarios</h3>
<ul id="scenarioList"></ul>
</article>
<article class="panel">
<h3>Common pitfalls</h3>
<ul id="pitfallList"></ul>
</article>
<article class="panel">
<h3>Troubleshooting flow</h3>
<ul id="flowList"></ul>
</article>
<article class="panel">
<h3>Takeaways</h3>
<ul id="takeawayList"></ul>
<p id="afterClass" style="margin-top: 10px;"></p>
</article>
<article class="panel">
<h3>Example commands</h3>
<div class="examples" id="exampleList"></div>
</article>
</div>
</section>
<section class="practice card">
<div class="eyebrow">Practice</div>
<h2 style="margin: 8px 0 0;">Exercises and sandbox terminal</h2>
<p class="muted">Operation tasks run in the simulated terminal. Reflection tasks keep the learning flow readable even when source data is noisy.</p>
<div class="exercise-list" id="exerciseList"></div>
</section>
</main>
</div>
</div>
<script>
const state = { overview: null, lesson: null, theme: localStorage.getItem('linux_lab_theme') || 'light' };
document.addEventListener('DOMContentLoaded', async () => {
setTheme(state.theme);
document.getElementById('themeBtn').addEventListener('click', () => setTheme(state.theme === 'light' ? 'dark' : 'light'));
document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') runSearch();
});
document.getElementById('prevBtn').addEventListener('click', () => {
if (state.lesson?.previous_lesson) openLesson(state.lesson.previous_lesson.id);
});
document.getElementById('nextBtn').addEventListener('click', () => {
if (state.lesson?.next_lesson) openLesson(state.lesson.next_lesson.id);
});
await loadOverview();
});
async function loadOverview() {
const response = await fetch('/api/overview');
state.overview = await response.json();
renderOverview();
const firstLesson = state.overview.modules?.[0]?.lessons?.[0];
if (firstLesson) await openLesson(firstLesson.id);
}
function renderOverview() {
const meta = state.overview.meta || {};
document.getElementById('courseTitle').textContent = meta.title || 'Linux Learning Lab';
document.getElementById('courseDescription').textContent = meta.description || '';
document.getElementById('moduleCount').textContent = meta.module_count || 0;
document.getElementById('lessonCount').textContent = meta.lesson_count || 0;
document.getElementById('exerciseCount').textContent = meta.exercise_count || 0;
document.getElementById('commandCount').textContent = meta.command_count || 0;
document.getElementById('metaLine').textContent = `Version ${meta.version || '4.0'} | Updated ${meta.updated || '--'}`;
renderRuntime(state.overview.runtime || {});
renderCommandTags(state.overview.commands || []);
renderModules();
}
function renderRuntime(runtime) {
document.getElementById('runtimeCwd').textContent = runtime.cwd || '/';
document.getElementById('runtimeUser').textContent = runtime.user || 'sandbox_user';
}
function renderCommandTags(commands) {
const container = document.getElementById('commandTags');
if (!commands.length) {
container.innerHTML = '<span class="chip">Waiting for command index</span>';
return;
}
container.innerHTML = commands.slice(0, 10).map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('');
}
function renderModules() {
const modules = state.overview.modules || [];
const container = document.getElementById('moduleList');
if (!modules.length) {
container.innerHTML = '<div class="empty">No modules found.</div>';
return;
}
container.innerHTML = modules.map((module) => `
<article class="module-card">
<h3>${escapeHtml(module.display_title)}</h3>
<p>${escapeHtml(module.display_summary)}</p>
<div class="lesson-list">
${(module.lessons || []).map((lesson) => `
<button class="lesson-btn ${state.lesson?.id === lesson.id ? 'active' : ''}" type="button" onclick="openLesson('${lesson.id}')">
<strong>${escapeHtml(lesson.display_title)}</strong>
<span class="muted">${escapeHtml((lesson.command_tokens || []).join(' | ') || lesson.command || 'mixed commands')} | ${lesson.exercise_count || 0} exercises</span>
</button>
`).join('')}
</div>
</article>
`).join('');
}
async function openLesson(lessonId, focusExerciseId = '') {
const response = await fetch('/api/lesson?id=' + encodeURIComponent(lessonId));
const payload = await response.json();
state.lesson = payload.lesson;
renderModules();
renderLesson();
if (focusExerciseId) {
setTimeout(() => {
document.getElementById('exercise-' + focusExerciseId)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 120);
}
}
function renderLesson() {
const lesson = state.lesson;
if (!lesson) return;
document.getElementById('moduleLabel').textContent = lesson.display_module_title || 'Current lesson';
document.getElementById('lessonTitle').textContent = lesson.display_title || 'Linux lesson';
document.getElementById('lessonGoal').textContent = lesson.display_goal || '';
document.getElementById('moduleSummary').textContent = lesson.display_module_summary || '';
document.getElementById('lessonWhy').textContent = lesson.display_why || '';
document.getElementById('classicView').textContent = lesson.classic_view || '';
document.getElementById('afterClass').textContent = lesson.after_class || '';
document.getElementById('prevBtn').disabled = !lesson.previous_lesson;
document.getElementById('nextBtn').disabled = !lesson.next_lesson;
document.getElementById('lessonCommands').innerHTML = (lesson.command_tokens || [lesson.command || 'mixed commands']).map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('');
renderList('conceptList', lesson.concepts, 'No concept notes yet.');
renderList('scenarioList', lesson.scenarios, 'No scenario notes yet.');
renderList('pitfallList', lesson.pitfalls, 'No pitfall notes yet.');
renderList('flowList', lesson.troubleshooting_flow, 'No flow notes yet.');
renderList('takeawayList', lesson.takeaways, 'No takeaways yet.');
const examples = lesson.examples || [];
document.getElementById('exampleList').innerHTML = examples.length ? examples.map((item) => `<pre class="code">${escapeHtml(item)}</pre>`).join('') : '<div class="empty">No example commands yet.</div>';
renderExercises(lesson.exercises || []);
}
function renderList(id, items, emptyText) {
const target = document.getElementById(id);
target.innerHTML = (items && items.length) ? items.map((item) => `<li>${escapeHtml(item)}</li>`).join('') : `<li>${escapeHtml(emptyText)}</li>`;
}
function renderExercises(exercises) {
const container = document.getElementById('exerciseList');
if (!exercises.length) {
container.innerHTML = '<div class="empty">This lesson does not have exercises yet.</div>';
return;
}
container.innerHTML = exercises.map((exercise) => {
const label = exercise.type === 'operation' ? 'Operation task' : exercise.type === 'scenario' ? 'Scenario reflection' : 'Understanding task';
if (exercise.type === 'operation') {
const placeholder = exercise.solution?.[0] || state.lesson.command || 'pwd';
return `
<article class="exercise" id="exercise-${exercise.id}">
<span class="badge">${label}</span>
<h4>${escapeHtml(exercise.title || 'Operation task')}</h4>
<p class="muted">${escapeHtml(exercise.hint || 'Enter a command and inspect the output.')}</p>
<div class="terminal">
<div class="terminal-head">Linux sandbox terminal</div>
<div class="terminal-input">
<span class="prompt">$</span>
<input id="input-${exercise.id}" type="text" placeholder="${escapeHtml(placeholder)}" onkeydown="if(event.key==='Enter'){runExercise('${exercise.id}')}" />
<button type="button" onclick="runExercise('${exercise.id}')">Run</button>
</div>
<pre class="output" id="output-${exercise.id}">Waiting for a command...</pre>
</div>
<div class="feedback" id="feedback-${exercise.id}"></div>
</article>
`;
}
return `
<article class="exercise" id="exercise-${exercise.id}">
<span class="badge">${label}</span>
<h4>${escapeHtml(exercise.question || 'Reflection task')}</h4>
<p>${escapeHtml(exercise.answer || 'Summarize the purpose, output, and next step in your own words.')}</p>
</article>
`;
}).join('');
}
async function runExercise(exerciseId) {
const input = document.getElementById('input-' + exerciseId);
const output = document.getElementById('output-' + exerciseId);
const feedback = document.getElementById('feedback-' + exerciseId);
const cmd = input.value.trim();
if (!cmd) return;
output.textContent = `$ ${cmd}\n\nRunning...`;
feedback.className = 'feedback';
feedback.textContent = '';
try {
const runResponse = await fetch('/api/run?cmd=' + encodeURIComponent(cmd));
const runData = await runResponse.json();
output.textContent = `$ ${cmd}\n\n${runData.output || runData.message || '(no output)'}`;
renderRuntime({ cwd: runData.cwd || '/', user: state.overview?.runtime?.user || 'sandbox_user' });
const checkResponse = await fetch('/api/check?exercise_id=' + encodeURIComponent(exerciseId) + '&last_cmd=' + encodeURIComponent(cmd) + '&output=' + encodeURIComponent(runData.output || ''));
const checkData = await checkResponse.json();
feedback.className = 'feedback show ' + (checkData.success ? 'success' : 'warn');
feedback.textContent = checkData.message + (checkData.next_suggestion ? '\n' + checkData.next_suggestion : '');
} catch (error) {
output.textContent = `$ ${cmd}\n\nRequest failed: ${error.message}`;
feedback.className = 'feedback show warn';
feedback.textContent = 'The request failed. Please try again.';
}
}
async function resetSandbox() {
await fetch('/api/reset', { method: 'POST' });
renderRuntime({ cwd: '/', user: 'sandbox_user' });
alert('Sandbox reset complete.');
}
async function runSearch() {
const query = document.getElementById('searchInput').value.trim();
const container = document.getElementById('searchResults');
if (!query) {
container.innerHTML = '<div class="empty">Search by command, lesson id, or module id.</div>';
return;
}
container.innerHTML = '<div class="empty">Searching...</div>';
const response = await fetch('/api/course/search?q=' + encodeURIComponent(query));
const payload = await response.json();
const results = payload.results || [];
if (!results.length) {
container.innerHTML = '<div class="empty">No results found. Try a command name or lesson id.</div>';
return;
}
container.innerHTML = results.map((item) => `
<button class="result" type="button" onclick="openLesson('${item.lesson_id}', '${item.exercise_id || ''}')">
<strong>${escapeHtml(item.title)}</strong>
<span class="muted">${escapeHtml(item.subtitle || item.type)}</span>
</button>
`).join('');
}
function setTheme(theme) {
state.theme = theme;
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('linux_lab_theme', theme);
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
window.openLesson = openLesson;
window.runExercise = runExercise;
window.runSearch = runSearch;
window.resetSandbox = resetSandbox;
</script>
</body>
</html>