Files
linux-practice/index.html

2201 lines
98 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="zh-CN">
<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; }
.shell.locked {
opacity: 0.45;
filter: blur(0.5px);
transition: opacity 0.2s ease, filter 0.2s ease;
}
.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; }
2026-03-19 13:53:49 +08:00
.mini-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.mini-stat {
padding: 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.2);
}
.mini-stat span {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.mini-stat strong { font-size: 20px; }
.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; }
2026-03-19 13:53:49 +08:00
.mastery-note {
margin-top: 12px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.18);
color: var(--muted);
line-height: 1.8;
}
2026-03-19 14:58:56 +08:00
.diagnostic {
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255,255,255,0.18);
color: var(--muted);
line-height: 1.8;
}
.module-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
2026-03-19 14:58:56 +08:00
gap: 12px;
margin-top: 14px;
}
.module-summary-card {
padding: 14px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.18);
}
.module-summary-card h4 {
margin: 0 0 8px;
font-size: 16px;
}
.module-summary-card p {
margin: 0 0 8px;
color: var(--muted);
line-height: 1.7;
}
.stage-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 12px;
margin-top: 14px;
}
.stage-card {
padding: 14px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.18);
}
.stage-card h4 {
margin: 0 0 8px;
font-size: 16px;
}
.stage-card p {
margin: 0 0 8px;
color: var(--muted);
line-height: 1.7;
}
2026-03-19 13:53:49 +08:00
.lesson-btn.done {
border-color: rgba(29, 155, 108, 0.4);
background: rgba(29, 155, 108, 0.1);
}
.ordered {
margin: 0;
padding-left: 18px;
color: var(--muted);
line-height: 1.9;
}
.guide-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.guide-card {
padding: 14px;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.16);
}
.guide-card h4 { margin: 0 0 8px; font-size: 17px; }
.guide-card p { margin: 6px 0 0; }
.guide-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.guide-meta .mini-stat strong {
font-size: 15px;
line-height: 1.6;
}
.auth-card {
padding: 18px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.18);
display: flex;
flex-direction: column;
gap: 12px;
}
.auth-card .auth-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.auth-card form {
display: flex;
flex-direction: column;
gap: 8px;
}
.auth-card input {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: transparent;
color: var(--text);
}
.auth-status {
font-size: 13px;
font-weight: 700;
color: var(--accent);
}
.stage-map {
padding: 18px;
border-radius: 20px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.2);
display: grid;
gap: 12px;
}
.stage-map .stage-step {
display: flex;
align-items: center;
gap: 12px;
}
.stage-map .stage-step::before {
content: "";
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--soft);
border: 2px solid var(--brand);
flex-shrink: 0;
}
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
margin-top: 12px;
}
.category-card {
padding: 14px;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.2);
}
.category-card .command-list {
margin-top: 10px;
font-size: 12px;
color: var(--muted);
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.category-card em {
font-size: 12px;
color: var(--muted);
}
.progress-track {
display: flex;
gap: 12px;
overflow-x: auto;
margin-top: 10px;
}
.progress-pill {
flex: 0 0 auto;
padding: 10px 16px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.25);
font-size: 13px;
color: var(--text);
min-width: 160px;
}
.variant-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.auth-gate {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 100;
}
.auth-gate.hidden {
opacity: 0;
pointer-events: none;
}
.auth-gate-card {
width: min(520px, 100%);
border-radius: 24px;
border: 1px solid var(--line);
background: var(--panel);
padding: 30px;
text-align: center;
box-shadow: var(--shadow);
}
.auth-gate-card h2 {
margin: 0 0 10px;
font-size: 28px;
}
.auth-gate-card p {
margin: 0 0 18px;
color: var(--muted);
line-height: 1.7;
}
.auth-gate-card form {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.auth-gate-card input {
border-radius: 12px;
border: 1px solid var(--line);
padding: 12px 14px;
background: transparent;
color: var(--text);
font-size: 14px;
}
.auth-gate-card button {
border: 0;
border-radius: 14px;
padding: 12px 18px;
font-weight: 700;
background: linear-gradient(135deg, var(--brand), #37a1ff);
color: #fff;
cursor: pointer;
font-size: 14px;
}
.overview-nav {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.nav-module-card {
padding: 14px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.18);
display: flex;
flex-direction: column;
gap: 10px;
}
.nav-module-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.nav-module-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
font-size: 12px;
color: var(--muted);
}
.lesson-navigator {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.nav-lesson-link {
flex: 1 1 180px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.1);
padding: 10px 12px;
text-align: left;
color: var(--text);
}
.nav-lesson-link.active {
border-color: rgba(15, 109, 182, 0.6);
background: rgba(15, 109, 182, 0.1);
}
.nav-lesson-link strong {
display: block;
font-size: 14px;
margin-bottom: 4px;
}
.nav-module-extra {
font-size: 12px;
color: var(--muted);
}
.overview-nav .nav-card {
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.18);
}
.variant {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.18);
}
.variant code {
display: inline-block;
margin-bottom: 6px;
padding: 4px 8px;
border-radius: 999px;
background: var(--terminal);
color: #dceaff;
font-family: Consolas, "Courier New", monospace;
font-size: 13px;
}
.variant strong {
display: block;
font-size: 14px;
margin-bottom: 4px;
}
.span-2 { grid-column: span 2; }
.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; }
2026-03-19 13:53:49 +08:00
.mini-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.span-2 { grid-column: span 1; }
}
@media (max-width: 720px) {
.shell { padding: 14px; }
.topbar, .hero-head, .search, .terminal-input { flex-direction: column; align-items: stretch; }
.stats { grid-template-columns: 1fr; }
2026-03-19 13:53:49 +08:00
.mini-stats { grid-template-columns: 1fr; }
.guide-meta { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="shell" id="contentShell">
<header class="topbar">
<div class="title">
<div class="eyebrow" id="topEyebrow">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-ghost" type="button" id="languageBtn">EN</button>
<button class="btn-soft" type="button" id="resetBtn" onclick="resetSandbox()">Reset sandbox</button>
<a class="btn-ghost" id="privacyLink" href="/privacy" target="_blank" rel="noreferrer">Privacy</a>
<button class="btn-ghost" type="button" id="logoutBtn" style="display:none;">退出登录</button>
</div>
</header>
<div class="layout">
<aside class="sidebar card">
<section class="section">
<div class="eyebrow" id="overviewEyebrow">Overview</div>
<h2 id="overviewTitle">Course overview</h2>
<p class="muted" id="overviewText">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 id="moduleCountLabel">Modules</span><strong id="moduleCount">0</strong></div>
<div class="stat"><span id="lessonCountLabel">Lessons</span><strong id="lessonCount">0</strong></div>
<div class="stat"><span id="exerciseCountLabel">Exercises</span><strong id="exerciseCount">0</strong></div>
<div class="stat"><span id="commandCountLabel">Commands</span><strong id="commandCount">0</strong></div>
<div class="stat"><span id="masteredCountLabel">Mastered</span><strong id="masteredCount">0</strong></div>
<div class="stat"><span id="completionRateLabel">Completion</span><strong id="completionRate">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" id="searchEyebrow">Search</div>
<h2 id="searchTitle">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" id="searchBtn" 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" id="modulesEyebrow">Modules</div>
<h2 id="modulesTitle">Course map</h2>
<div class="modules" id="moduleList"><div class="empty">Loading modules...</div></div>
</section>
2026-03-19 14:58:56 +08:00
<section class="section">
<div class="eyebrow" id="diagnosticsEyebrow">Diagnostics</div>
<h2 id="diagnosticsTitle">Course coverage check</h2>
2026-03-19 14:58:56 +08:00
<div class="mini-stats">
<div class="mini-stat"><span id="coverageRateLabel">Coverage</span><strong id="coverageRate">0%</strong></div>
<div class="mini-stat"><span id="operationCountLabel">Operations</span><strong id="operationCount">0</strong></div>
<div class="mini-stat"><span id="reflectionCountLabel">Reflection</span><strong id="reflectionCount">0</strong></div>
<div class="mini-stat"><span id="unsupportedCountLabel">Unsupported</span><strong id="unsupportedCount">0</strong></div>
2026-03-19 14:58:56 +08:00
</div>
<div class="diagnostic" id="diagnosticNote" style="margin-top: 12px;">Checking course and sandbox alignment...</div>
</section>
</aside>
<main class="main">
<section class="card overview-nav" id="overviewNav">
<article class="nav-card">
<div class="eyebrow" id="navEyebrow">总览菜单</div>
<h3 id="navTitle">课程总览</h3>
<p class="muted" id="navText">按模块、阶段或练习快速跳转,不再只能上一课下一课。</p>
<div class="mini-stats">
<div class="mini-stat"><span id="navStageCountLabel">已设置阶段</span><strong id="navStageCount">0</strong></div>
<div class="mini-stat"><span id="navExerciseCountLabel">练习总数</span><strong id="navExerciseCount">0</strong></div>
</div>
<div class="results" id="navLessonResults"><div class="empty" id="navLessonEmpty">登录后即可选择模块导航。</div></div>
</article>
<article class="nav-card">
<div class="eyebrow" id="navStageEyebrow">阶段地图</div>
<div id="navStageMap"></div>
</article>
<article class="nav-card">
<div class="eyebrow" id="navCategoryEyebrow">命令分类</div>
<div id="navCategoryGrid"></div>
</article>
</section>
<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">
2026-03-19 13:53:49 +08:00
<button class="btn-soft" type="button" id="masteryBtn">Mark mastered</button>
<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 id="moduleSummaryTitle">Module summary</h3>
<p id="moduleSummary">This card shows the learning direction of the current module.</p>
</div>
<div class="panel">
<h3 id="runtimeTitle">Sandbox runtime</h3>
<p id="runtimeText">You can execute commands directly below. The platform returns simulated output and exercise feedback.</p>
<ul>
<li><span id="runtimeCwdLabel">Current directory</span>: <span id="runtimeCwd">/</span></li>
<li><span id="runtimeUserLabel">Current user</span>: <span id="runtimeUser">sandbox_user</span></li>
</ul>
</div>
</div>
2026-03-19 13:53:49 +08:00
<div class="mastery-note" id="masteryNote">Master a lesson after you can explain the command, predict the output, and connect it to a real operations step.</div>
</section>
<section class="detail card">
<div class="eyebrow" id="stageMapEyebrow">Stage map</div>
<h2 id="stageMapTitle">阶段路线清单</h2>
<p class="muted" id="stageMapText">按阶段划分的学习路线,帮你把命令练习串联成一条完整的运维路径。</p>
<div class="stage-map" id="stageMap"></div>
<div class="progress-track" id="progressTrack"></div>
</section>
<section class="detail card">
<div class="eyebrow" id="categoryEyebrow">Command categories</div>
<h2 id="categoryTitle">命令家族可视化</h2>
<p class="muted" id="categoryText">了解各类命令在当前课程中的分布,以及建议的学习顺序。</p>
<div class="category-grid" id="categoryGrid"></div>
</section>
<section class="detail card">
<div class="eyebrow" id="stagesEyebrow">Learning stages</div>
<h2 id="stagesTitle" style="margin: 8px 0 0;">System route from Linux basics to operations habits</h2>
<p class="muted" id="stagesText">This view helps you study in phases instead of bouncing between unrelated commands.</p>
<div class="stage-grid" id="stageGrid"></div>
</section>
2026-03-19 14:58:56 +08:00
<section class="detail card">
<div class="eyebrow" id="heatmapEyebrow">Module heatmap</div>
<h2 id="heatmapTitle" style="margin: 8px 0 0;">See how the learning path is distributed</h2>
<p class="muted" id="heatmapText">This panel helps you check whether the project really teaches both command basics and operations workflows.</p>
2026-03-19 14:58:56 +08:00
<div class="module-summary-grid" id="moduleSummaryGrid"></div>
</section>
2026-03-19 13:53:49 +08:00
<section class="detail card">
<div class="eyebrow" id="cockpitEyebrow">Learning cockpit</div>
<h2 id="cockpitTitle" style="margin: 8px 0 0;">Visual study map for the current lesson</h2>
<p class="muted" id="cockpitText">These cards help you move from memorizing a command to understanding the workflow around it.</p>
2026-03-19 13:53:49 +08:00
<div class="detail-grid" style="margin-top: 16px;">
<article class="panel">
<h3 id="lessonRadarTitle">Lesson radar</h3>
2026-03-19 13:53:49 +08:00
<div class="mini-stats" id="lessonRadar"></div>
</article>
<article class="panel">
<h3 id="experimentTitle">Experiment ladder</h3>
2026-03-19 13:53:49 +08:00
<ol class="ordered" id="experimentList"></ol>
</article>
<article class="panel">
<h3 id="observationTitle">Observation checklist</h3>
2026-03-19 13:53:49 +08:00
<ul id="observationList"></ul>
</article>
<article class="panel">
<h3 id="relatedCommandsTitle">Related commands</h3>
2026-03-19 13:53:49 +08:00
<div class="chip-list" id="relatedCommandList"></div>
</article>
</div>
</section>
<section class="detail card">
<div class="eyebrow" id="depthEyebrow">Depth Layer</div>
<h2 id="depthTitle" style="margin: 8px 0 0;">Parameters, command history, and reinforcement</h2>
<p class="muted" id="depthText">Use this layer to extend beyond the base syntax into variants, Unix naming history, and review prompts that stick.</p>
<div class="detail-grid" style="margin-top: 16px;">
<article class="panel span-2">
<h3 id="commandDossierTitle">Command dossier</h3>
<div class="guide-stack" id="commandGuideList"></div>
</article>
<article class="panel">
<h3 id="opsExtensionTitle">Operations extension</h3>
<ul id="opsExtensionList"></ul>
</article>
<article class="panel">
<h3 id="reviewPromptsTitle">Review prompts</h3>
<ol class="ordered" id="reviewPromptList"></ol>
</article>
</div>
</section>
<section class="detail card">
<div class="detail-grid">
<article class="panel">
<h3 id="whyThisMattersTitle">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 id="teachingAngleTitle">Teaching angle</h3>
<p id="classicView">Each lesson connects the command to a broader troubleshooting flow.</p>
</article>
<article class="panel">
<h3 id="coreIdeasTitle">Core ideas</h3>
<ul id="conceptList"></ul>
</article>
<article class="panel">
<h3 id="commonScenariosTitle">Common scenarios</h3>
<ul id="scenarioList"></ul>
</article>
<article class="panel">
<h3 id="commonPitfallsTitle">Common pitfalls</h3>
<ul id="pitfallList"></ul>
</article>
<article class="panel">
<h3 id="troubleshootingFlowTitle">Troubleshooting flow</h3>
<ul id="flowList"></ul>
</article>
<article class="panel">
<h3 id="takeawaysTitle">Takeaways</h3>
<ul id="takeawayList"></ul>
<p id="afterClass" style="margin-top: 10px;"></p>
</article>
<article class="panel">
<h3 id="exampleCommandsTitle">Example commands</h3>
<div class="examples" id="exampleList"></div>
</article>
</div>
</section>
<section class="practice card">
<div class="eyebrow" id="practiceEyebrow">Practice</div>
<h2 id="practiceTitle" style="margin: 8px 0 0;">Exercises and sandbox terminal</h2>
<p class="muted" id="practiceText">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 I18N = {
zh: {
topEyebrow: 'Linux 运维学习实验室',
courseTitle: 'Linux 学习实验室',
courseDescription: '搜索 Linux 课程、在沙箱里练命令,并把零散命令串成真正的排障习惯。',
themeToDark: '深色模式',
themeToLight: '浅色模式',
resetBtn: '重置沙箱',
privacyLink: '隐私说明',
overviewEyebrow: '总览',
overviewTitle: '课程总览',
overviewText: '课程按模块和命令家族分组,重点是把摘要讲清楚、把沙箱练习串起来。',
navMoreLessons: '本模块还有 {count} 节课程可跳转。',
moduleCountLabel: '模块',
lessonCountLabel: '课程',
exerciseCountLabel: '练习',
commandCountLabel: '命令',
masteredCountLabel: '已掌握',
completionRateLabel: '完成率',
searchEyebrow: '搜索',
searchTitle: '快速定位',
searchPlaceholder: '试试 pwd、grep、module_1 或 m1_l1_pwd',
searchBtn: '搜索',
searchEmpty: '可以按命令名、课程 ID 或模块 ID 搜索。',
searchLoading: '正在搜索...',
searchNoResult: '没有找到结果,试试命令名或课程 ID。',
modulesEyebrow: '模块',
modulesTitle: '课程地图',
modulesLoading: '正在加载模块...',
diagnosticsEyebrow: '诊断',
diagnosticsTitle: '课程覆盖检查',
coverageRateLabel: '覆盖率',
operationCountLabel: '操作题',
reflectionCountLabel: '反思题',
unsupportedCountLabel: '未覆盖',
diagnosticChecking: '正在检查课程和沙箱的一致性...',
diagnosticSupported: '课程引用到的命令都已经被沙箱命令集覆盖。',
diagnosticUnsupported: '课程里仍有部分命令没有被沙箱完全支持。',
metaLine: (version, updated) => `版本 ${version} | 更新于 ${updated}`,
waitingCommandIndex: '等待命令索引...',
noModulesFound: '没有找到模块。',
completedPrefix: '已完成 - ',
exerciseUnit: '个练习',
mixedCommands: '混合命令',
noModuleDiagnostics: '暂无模块诊断数据。',
moduleSummaryLessons: '课程',
moduleSummaryExercises: '练习',
moduleSummaryOps: '操作题',
moduleSummaryCommands: '命令',
stagesEyebrow: '学习阶段',
stagesTitle: '从 Linux 基础走到运维习惯的学习路线',
stagesText: '这个视图帮助你按阶段推进,而不是在零散命令之间来回跳。',
stageGroups: [
{ title: '阶段 1基础定位', summary: '先建立目录、列表、文件操作和权限的直觉。', range: [0, 1] },
{ title: '阶段 2搜索与观察', summary: '进入文本检索、日志预览、进程、服务与网络观察。', range: [2, 3] },
{ title: '阶段 3故障处置', summary: '把磁盘、认证与服务链路故障串成完整排障流程。', range: [4, 4] },
{ title: '阶段 4自动化与平台', summary: '覆盖 shell 状态、包管理、归档、监控与定时任务。', range: [5, 7] },
{ title: '阶段 5交付与恢复', summary: '把 Web 交付、备份与恢复变成可演练的运维套路。', range: [8, 9] }
],
stageModules: '模块',
stageLessons: '课程',
stageExercises: '练习',
stageMastered: '已掌握',
heatmapEyebrow: '模块热力图',
heatmapTitle: '看看学习路径是怎么分布的',
heatmapText: '这个面板帮助你判断项目是否同时覆盖了命令基础和运维工作流。',
cockpitEyebrow: '学习驾驶舱',
cockpitTitle: '当前课程的可视化学习地图',
cockpitText: '这些卡片帮助你从记命令,走到理解命令周围的排障流程。',
lessonRadarTitle: '课程雷达',
experimentTitle: '实验阶梯',
observationTitle: '观察清单',
relatedCommandsTitle: '相关命令',
depthEyebrow: '深入层',
depthTitle: '参数、命令历史和强化记忆',
depthText: '这一层把基础语法继续延伸到常见变体、Unix 名称来源和复盘提示。',
commandDossierTitle: '命令档案',
opsExtensionTitle: '运维扩展',
reviewPromptsTitle: '复习提示',
whyThisMattersTitle: '为什么重要',
teachingAngleTitle: '教学视角',
coreIdeasTitle: '核心概念',
commonScenariosTitle: '常见场景',
commonPitfallsTitle: '常见坑点',
troubleshootingFlowTitle: '排障流程',
takeawaysTitle: '本课收获',
exampleCommandsTitle: '示例命令',
practiceEyebrow: '练习',
practiceTitle: '练习题与沙箱终端',
practiceText: '操作题会直接在模拟终端里运行;反思题则保留成清晰的学习链路,避免被脏数据打断。',
initialModuleLabel: '先选一节课开始',
initialLessonTitle: '把 Linux 练习变成流程化思维',
initialLessonGoal: '从左侧选择一节课,查看目标、示例、常见坑点和可执行练习。',
moduleSummaryTitle: '模块摘要',
moduleSummaryPlaceholder: '这里会显示当前模块的学习方向。',
runtimeTitle: '沙箱运行态',
runtimeText: '你可以直接在下方执行命令,平台会返回模拟输出和练习反馈。',
runtimeCwdLabel: '当前目录',
runtimeUserLabel: '当前用户',
masteryDefault: '标记已掌握',
masteryReview: '标记待复习',
masteryPendingNote: '当你能解释命令目标、预判输出,并说出下一步排障动作时,再标记掌握。',
masteryDoneNote: '这节课已经标记为已掌握;如果你已经说不清命令链路,就回头复习。',
currentLessonFallback: '当前课程',
linuxLessonFallback: 'Linux 课程',
radarCommands: '命令',
radarExercises: '练习',
radarPitfalls: '坑点',
radarRelated: '相关项',
experimentStep1: (goal) => `先确认目标:${goal || '先搞清楚这条命令要解决什么问题。'}`,
experimentStep2: (command) => `执行 ${command || '主命令'},先看最直接的输出。`,
experimentStep3: '把输出和本课的一个示例或场景对照起来。',
experimentStep4: '根据命令揭示出来的状态,选择下一步排障动作。',
observationStep1: '执行前:先确认当前上下文和目标路径。',
observationStep2: '看输出时:找到真正回答本课目标的那一行或那个字段。',
observationStep3: '执行后:说明在真实运维流程里下一步该做什么。',
noRelatedCommands: '暂无相关命令',
noConcepts: '暂无概念说明。',
noScenarios: '暂无场景说明。',
noPitfalls: '暂无坑点说明。',
noFlow: '暂无流程说明。',
noTakeaways: '暂无本课收获。',
noExamples: '暂无示例命令。',
noCommandGuides: '暂无命令档案说明。',
noOpsExtensions: '暂无扩展练习。',
noReviewPrompts: '暂无复习提示。',
noExercises: '这节课暂时还没有练习。',
operationTask: '操作题',
scenarioReflection: '场景反思',
understandingTask: '理解题',
exerciseTitleFallback: '操作题',
reflectionTaskFallback: '反思题',
exerciseHintFallback: '输入一条命令并观察输出。',
terminalHead: 'Linux 沙箱终端',
runBtn: '运行',
waitingForCommand: '等待输入命令...',
running: '运行中...',
noOutput: '(没有输出)',
requestFailedPrefix: '请求失败:',
requestFailed: '请求失败,请稍后再试。',
sandboxResetComplete: '沙箱已重置。',
commandFallback: '命令',
commandFamilyCountLabel: '课程命令数',
commandFamilyCommandsHint: '代表命令',
nameOrigin: '名字来源',
learningHook: '记忆钩子',
noOrigin: '暂无来源说明。',
noFunFact: '暂无强化提示。',
examplePrefix: '示例:',
nextSuggestionPrefix: '下一步:',
prevBtn: '上一课',
nextBtn: '下一课',
moduleType: '模块',
lessonType: '课程',
exerciseType: '练习',
tryFirstPrefix: '可先尝试:',
originalHintLabel: '原始提示',
originalQuestionLabel: '原题',
referenceAnswerLabel: '参考答案'
},
en: {
topEyebrow: 'Linux Ops Learning Lab',
courseTitle: 'Linux Learning Lab',
courseDescription: 'Search Linux lessons, practice commands in a sandbox, and build better troubleshooting habits.',
themeToDark: 'Dark mode',
themeToLight: 'Light mode',
resetBtn: 'Reset sandbox',
privacyLink: 'Privacy',
overviewEyebrow: 'Overview',
overviewTitle: 'Course overview',
overviewText: 'Lessons are grouped by module and command family. The page focuses on readable summaries and guided sandbox practice.',
moduleCountLabel: 'Modules',
lessonCountLabel: 'Lessons',
exerciseCountLabel: 'Exercises',
commandCountLabel: 'Commands',
masteredCountLabel: 'Mastered',
completionRateLabel: 'Completion',
searchEyebrow: 'Search',
searchTitle: 'Quick lookup',
searchPlaceholder: 'Try pwd, grep, module_1, or m1_l1_pwd',
searchBtn: 'Search',
searchEmpty: 'Search by command, lesson id, or module id.',
searchLoading: 'Searching...',
searchNoResult: 'No results found. Try a command name or lesson id.',
modulesEyebrow: 'Modules',
modulesTitle: 'Course map',
modulesLoading: 'Loading modules...',
diagnosticsEyebrow: 'Diagnostics',
diagnosticsTitle: 'Course coverage check',
coverageRateLabel: 'Coverage',
operationCountLabel: 'Operations',
reflectionCountLabel: 'Reflection',
unsupportedCountLabel: 'Unsupported',
diagnosticChecking: 'Checking course and sandbox alignment...',
diagnosticSupported: 'All commands referenced by the course are covered by the sandbox command set.',
diagnosticUnsupported: 'Some course commands are not fully supported by the sandbox yet.',
metaLine: (version, updated) => `Version ${version} | Updated ${updated}`,
navMoreLessons: '{count} more lessons inside this module.',
waitingCommandIndex: 'Waiting for command index',
noModulesFound: 'No modules found.',
completedPrefix: 'Completed - ',
exerciseUnit: 'exercises',
mixedCommands: 'mixed commands',
noModuleDiagnostics: 'No module diagnostics found.',
moduleSummaryLessons: 'Lessons',
moduleSummaryExercises: 'Exercises',
moduleSummaryOps: 'Ops',
moduleSummaryCommands: 'Commands',
stagesEyebrow: 'Learning stages',
stagesTitle: 'System route from Linux basics to operations habits',
stagesText: 'This view helps you study in phases instead of bouncing between unrelated commands.',
stageGroups: [
{ title: 'Stage 1: Foundations', summary: 'Orientation, listing, file operations, and permissions.', range: [0, 1] },
{ title: 'Stage 2: Search & Observation', summary: 'Text search, log preview, process, service, and network inspection.', range: [2, 3] },
{ title: 'Stage 3: Incidents', summary: 'Disk, auth, and service-path drills that connect commands into playbooks.', range: [4, 4] },
{ title: 'Stage 4: Automation & Platform', summary: 'Shell state, package tools, archives, monitoring, and scheduling.', range: [5, 7] },
{ title: 'Stage 5: Delivery & Recovery', summary: 'Web delivery checks, backups, restore paths, and recovery discipline.', range: [8, 9] }
],
stageModules: 'Modules',
stageLessons: 'Lessons',
stageExercises: 'Exercises',
stageMastered: 'Mastered',
heatmapEyebrow: 'Module heatmap',
heatmapTitle: 'See how the learning path is distributed',
heatmapText: 'This panel helps you check whether the project really teaches both command basics and operations workflows.',
cockpitEyebrow: 'Learning cockpit',
cockpitTitle: 'Visual study map for the current lesson',
cockpitText: 'These cards help you move from memorizing a command to understanding the workflow around it.',
lessonRadarTitle: 'Lesson radar',
experimentTitle: 'Experiment ladder',
observationTitle: 'Observation checklist',
relatedCommandsTitle: 'Related commands',
depthEyebrow: 'Depth layer',
depthTitle: 'Parameters, command history, and reinforcement',
depthText: 'Use this layer to extend beyond the base syntax into variants, Unix naming history, and review prompts that stick.',
commandDossierTitle: 'Command dossier',
opsExtensionTitle: 'Operations extension',
reviewPromptsTitle: 'Review prompts',
whyThisMattersTitle: 'Why this matters',
teachingAngleTitle: 'Teaching angle',
coreIdeasTitle: 'Core ideas',
commonScenariosTitle: 'Common scenarios',
commonPitfallsTitle: 'Common pitfalls',
troubleshootingFlowTitle: 'Troubleshooting flow',
takeawaysTitle: 'Takeaways',
exampleCommandsTitle: 'Example commands',
practiceEyebrow: 'Practice',
practiceTitle: 'Exercises and sandbox terminal',
practiceText: 'Operation tasks run in the simulated terminal. Reflection tasks keep the learning flow readable even when source data is noisy.',
initialModuleLabel: 'Pick a lesson to begin',
initialLessonTitle: 'Turn Linux practice into workflow thinking',
initialLessonGoal: 'Choose a lesson from the left to see goals, examples, common pitfalls, and runnable exercises.',
moduleSummaryTitle: 'Module summary',
moduleSummaryPlaceholder: 'This card shows the learning direction of the current module.',
runtimeTitle: 'Sandbox runtime',
runtimeText: 'You can execute commands directly below. The platform returns simulated output and exercise feedback.',
runtimeCwdLabel: 'Current directory',
runtimeUserLabel: 'Current user',
masteryDefault: 'Mark mastered',
masteryReview: 'Mark for review',
masteryPendingNote: 'Mark a lesson as mastered after you can explain the command, predict the output, and choose the next troubleshooting step.',
masteryDoneNote: 'This lesson is marked as mastered. Revisit it if you cannot explain the command chain from memory.',
currentLessonFallback: 'Current lesson',
linuxLessonFallback: 'Linux lesson',
radarCommands: 'Commands',
radarExercises: 'Exercises',
radarPitfalls: 'Pitfalls',
radarRelated: 'Related',
experimentStep1: (goal) => `Confirm the goal: ${goal || 'Understand the command objective.'}`,
experimentStep2: (command) => `Run ${command || 'the main command'} and inspect the immediate output.`,
experimentStep3: 'Compare the output with one example or scenario from this lesson.',
experimentStep4: 'Choose the next troubleshooting step based on what the command revealed.',
observationStep1: 'Before running: confirm the current context and target path.',
observationStep2: 'During output review: look for the field or line that answers the lesson goal.',
observationStep3: 'After running: explain what should happen next in a real operations flow.',
noRelatedCommands: 'No related commands yet',
noConcepts: 'No concept notes yet.',
noScenarios: 'No scenario notes yet.',
noPitfalls: 'No pitfall notes yet.',
noFlow: 'No flow notes yet.',
noTakeaways: 'No takeaways yet.',
noExamples: 'No example commands yet.',
noCommandGuides: 'No command guide notes yet.',
noOpsExtensions: 'No extension drills yet.',
noReviewPrompts: 'No review prompts yet.',
noExercises: 'This lesson does not have exercises yet.',
operationTask: 'Operation task',
scenarioReflection: 'Scenario reflection',
understandingTask: 'Understanding task',
exerciseTitleFallback: 'Operation task',
reflectionTaskFallback: 'Reflection task',
exerciseHintFallback: 'Enter a command and inspect the output.',
terminalHead: 'Linux sandbox terminal',
runBtn: 'Run',
waitingForCommand: 'Waiting for a command...',
running: 'Running...',
noOutput: '(no output)',
requestFailedPrefix: 'Request failed: ',
requestFailed: 'The request failed. Please try again.',
sandboxResetComplete: 'Sandbox reset complete.',
commandFallback: 'command',
commandFamilyCountLabel: 'Commands in course',
commandFamilyCommandsHint: 'Representative commands',
nameOrigin: 'Name origin',
learningHook: 'Learning hook',
noOrigin: 'No origin note yet.',
noFunFact: 'No reinforcement note yet.',
examplePrefix: 'Example:',
nextSuggestionPrefix: 'Next up:',
prevBtn: 'Previous',
nextBtn: 'Next',
moduleType: 'Module',
lessonType: 'Lesson',
exerciseType: 'Exercise',
tryFirstPrefix: 'Try first:',
originalHintLabel: 'Original hint',
originalQuestionLabel: 'Original prompt',
referenceAnswerLabel: 'Reference answer'
}
};
const MODULE_ZH = {
module_1_foundation: {
title: '模块 1建立 Linux 空间感',
summary: '从定位与列表开始,让后续每一步都有上下文。'
},
module_2_filesystem: {
title: '模块 2安全操作文件',
summary: '练习创建、复制、移动与权限调整,把文件系统操作做稳。'
},
module_3_search: {
title: '模块 3用证据做搜索与预览',
summary: '从大范围搜索走到精确答案,减少噪音并保留关键线索。'
},
module_4_operations: {
title: '模块 4把命令串成运维流程',
summary: '从进程、网络到服务状态,建立系统化观察路径。'
},
module_5_incidents: {
title: '模块 5故障排查演练',
summary: '围绕磁盘、认证与服务链路,练习完整的事故处理套路。'
},
module_6_shell_environment: {
title: '模块 6提高 Shell 操作效率',
summary: '用环境变量、历史与文本管道,把命令行工作做顺手。'
},
module_7_packages_and_delivery: {
title: '模块 7定位工具、检查软件与交付制品',
summary: '学会找到命令来源、读懂包管理器输出,并处理归档与后台任务。'
},
module_8_monitoring_and_scheduling: {
title: '模块 8系统监控与计划任务',
summary: '用系统快照、端口路径与时间调度建立主机健康视角。'
},
module_9_web_delivery: {
title: '模块 9Web 交付与运行验证',
summary: '从服务状态走到 HTTP 验证,贴近真实上线与检查过程。'
},
module_10_backup_and_recovery: {
title: '模块 10备份、恢复与安全变更',
summary: '把回滚、恢复和变更窗口纳入日常运维基本功。'
}
};
const LESSON_ZH = {
m1_l1_pwd: '用 pwd 确认当前位置',
m1_l2_ls: '用 ls 查看目录内容',
m1_l3_cd_cat_echo: '结合 cd、cat、echo 完成基础流程',
m2_l1_create: '用 mkdir 和 touch 搭建结构',
m2_l2_copy_move_permission: '用 cp、mv、chmod、stat 安全处理文件',
m2_l3_cleanup_and_modes: '用 rm 和 chmod 做清理与权限调整',
m3_l1_grep: '用 grep 搜索文件内容',
m3_l2_find_preview: '用 find 定位文件并用 tail 预览',
m3_l3_preview_and_pipeline: '用 head、tail 与简单管道预览文件',
m4_l1_process: '用 top 和 ps 观察系统压力',
m4_l2_network: '用 ip、ping、ss、curl 检查网络链路',
m4_l3_service: '用 systemctl 和 journalctl 追踪服务健康',
m5_l1_disk: '故障演练:排查磁盘压力',
m5_l2_auth: '故障演练:排查登录或权限失败',
m5_l3_service_path: '故障演练:服务正常但应用仍异常',
m6_l1_env_alias: '用 env、export、alias 管好 Shell 上下文',
m6_l2_history_replay: '用 history、date、clear 管理命令会话',
m6_l3_text_pipeline: '用 sort、uniq、cut、awk、sed 降噪',
m7_l1_locate_tools: '用 which 和 whereis 定位工具',
m7_l2_package_basics: '读懂 apt、yum、rpm、dpkg 输出',
m7_l3_archives_and_background: '用 tar 和 nohup 处理归档与后台任务',
m8_l1_system_snapshot: '用 uptime、free、w、last 做主机快照',
m8_l2_ports_and_paths: '用 lsof、netstat、traceroute、dig 观察链路',
m8_l3_time_and_schedule: '用 date、cal、crontab 理解时间与调度',
m9_l1_service_to_http: '从服务状态走到 HTTP 验证',
m9_l2_fetch_and_compare: '用 curl 和 wget 拉取并对比远端内容',
m9_l3_config_runtime: '用 cat、grep、journalctl 观察运行配置线索',
m10_l1_backup_patterns: '用 cp 和 tar 建立回滚余地',
m10_l2_restore_path: '恢复到干净路径并检查结果',
m10_l3_change_window: '用 date、cal、history、journalctl 规划安全变更窗口'
};
function uiText() {
return I18N[state.language] || I18N.zh;
}
function lessonCommands(lesson) {
if (Array.isArray(lesson?.command_tokens) && lesson.command_tokens.length) {
return lesson.command_tokens.filter(Boolean);
}
return String(lesson?.command || '')
.split('/')
.map((item) => item.trim())
.filter(Boolean);
}
function localizedCommandLabel(lesson) {
const commands = lessonCommands(lesson);
return commands.length ? commands.join(' / ') : uiText().commandFallback;
}
function localizedModuleTitle(module) {
if (state.language === 'zh') {
return MODULE_ZH[module?.id]?.title || module?.display_title || module?.title || uiText().moduleType;
}
return module?.display_title || module?.title || uiText().moduleType;
}
function localizedModuleSummary(module) {
if (state.language === 'zh') {
return MODULE_ZH[module?.id]?.summary
|| `围绕 ${localizedCommandLabel(module)} 建立命令理解、证据判断与排障衔接。`;
}
return module?.display_summary || module?.summary || '';
}
function localizedLessonTitle(lesson) {
if (state.language === 'zh') {
return LESSON_ZH[lesson?.id] || `学习 ${localizedCommandLabel(lesson)}`;
}
return lesson?.display_title || lesson?.title || uiText().linuxLessonFallback;
}
function localizedLessonGoal(lesson) {
if (state.language === 'zh') {
return `围绕 ${localizedCommandLabel(lesson)} 练习“先确认上下文、再观察输出、再决定下一步”的 Linux 操作思路。`;
}
return lesson?.display_goal || lesson?.goal || '';
}
function localizedLessonWhy(lesson) {
if (state.language === 'zh') {
return `在真实运维里,${localizedCommandLabel(lesson)} 往往不是终点,而是帮助你判断后续检查、修复或验证动作的起点。`;
}
return lesson?.display_why || lesson?.why_it_matters || '';
}
function localizedClassicView(lesson) {
if (state.language === 'zh') {
return `不要把 ${localizedCommandLabel(lesson)} 当成孤立命令来背,而要把它放进完整的排障链路里理解。`;
}
return lesson?.classic_view || '';
}
function localizedAfterClass(lesson) {
if (state.language === 'zh') {
return `回到沙箱把 ${localizedCommandLabel(lesson)} 再练 2 到 3 遍,并尝试口述每一步的目的、输出与下一步动作。`;
}
return lesson?.after_class || '';
}
function findModulePreview(moduleId) {
return (state.overview?.modules || []).find((module) => module.id === moduleId) || null;
}
function findLessonPreview(lessonId) {
for (const module of state.overview?.modules || []) {
const lesson = (module.lessons || []).find((item) => item.id === lessonId);
if (lesson) {
return { ...lesson, module_id: module.id };
}
}
return null;
}
function localizedSearchTitle(item) {
if (state.language !== 'zh') {
return item.title;
}
if (item.type === 'module') {
const module = findModulePreview(item.module_id);
return module ? localizedModuleTitle(module) : item.title;
}
const lesson = findLessonPreview(item.lesson_id);
if (!lesson) {
return item.title;
}
if (item.type === 'exercise') {
return `${localizedLessonTitle(lesson)} / ${uiText().exerciseType}`;
}
return localizedLessonTitle(lesson);
}
function localizedSearchSubtitle(item) {
if (state.language !== 'zh') {
return item.subtitle || item.type;
}
const text = uiText();
const typeLabel = item.type === 'module'
? text.moduleType
: item.type === 'lesson'
? text.lessonType
: text.exerciseType;
return [typeLabel, item.subtitle].filter(Boolean).join(' | ');
}
function formatMultiline(value) {
return escapeHtml(value).replaceAll('\n', '<br />');
}
function localizedExerciseHeading(exercise) {
if (state.language !== 'zh') {
return exercise.type === 'operation'
? exercise.title || uiText().exerciseTitleFallback
: exercise.question || uiText().reflectionTaskFallback;
}
if (exercise.type === 'operation') {
const primary = exercise.solution?.[0] || state.lesson?.command || localizedCommandLabel(state.lesson || {});
return `动手练习:${primary}`;
}
if (exercise.type === 'scenario') {
return `场景思考:${localizedLessonTitle(state.lesson || {})}`;
}
return `理解检查:${localizedLessonTitle(state.lesson || {})}`;
}
function localizedExerciseBody(exercise) {
const text = uiText();
if (state.language !== 'zh') {
return exercise.type === 'operation'
? exercise.hint || text.exerciseHintFallback
: exercise.answer || 'Summarize the purpose, output, and next step in your own words.';
}
if (exercise.type === 'operation') {
const primary = exercise.solution?.[0] || state.lesson?.command || 'pwd';
const lines = [`${text.tryFirstPrefix} ${primary}`];
if (exercise.hint) {
lines.push(`${text.originalHintLabel}${exercise.hint}`);
}
return lines.join('\n');
}
const lines = [];
if (exercise.question) {
lines.push(`${text.originalQuestionLabel}${exercise.question}`);
}
if (exercise.answer) {
lines.push(`${text.referenceAnswerLabel}${exercise.answer}`);
}
return lines.join('\n') || text.exerciseHintFallback;
}
function setLanguage(language) {
state.language = language === 'en' ? 'en' : 'zh';
document.documentElement.lang = state.language === 'zh' ? 'zh-CN' : 'en';
localStorage.setItem('linux_lab_language', state.language);
renderStaticText();
if (state.overview) {
renderOverview();
}
if (state.lesson) {
renderLesson();
}
runSearch();
}
function renderStaticText() {
const text = uiText();
document.title = text.courseTitle;
document.getElementById('topEyebrow').textContent = text.topEyebrow;
document.getElementById('themeBtn').textContent = state.theme === 'light' ? text.themeToDark : text.themeToLight;
document.getElementById('languageBtn').textContent = state.language === 'zh' ? 'EN' : '中文';
document.getElementById('resetBtn').textContent = text.resetBtn;
document.getElementById('privacyLink').textContent = text.privacyLink;
document.getElementById('overviewEyebrow').textContent = text.overviewEyebrow;
document.getElementById('overviewTitle').textContent = text.overviewTitle;
document.getElementById('overviewText').textContent = text.overviewText;
document.getElementById('moduleCountLabel').textContent = text.moduleCountLabel;
document.getElementById('lessonCountLabel').textContent = text.lessonCountLabel;
document.getElementById('exerciseCountLabel').textContent = text.exerciseCountLabel;
document.getElementById('commandCountLabel').textContent = text.commandCountLabel;
document.getElementById('masteredCountLabel').textContent = text.masteredCountLabel;
document.getElementById('completionRateLabel').textContent = text.completionRateLabel;
document.getElementById('searchEyebrow').textContent = text.searchEyebrow;
document.getElementById('searchTitle').textContent = text.searchTitle;
document.getElementById('searchInput').setAttribute('placeholder', text.searchPlaceholder);
document.getElementById('searchBtn').textContent = text.searchBtn;
document.getElementById('modulesEyebrow').textContent = text.modulesEyebrow;
document.getElementById('modulesTitle').textContent = text.modulesTitle;
document.getElementById('diagnosticsEyebrow').textContent = text.diagnosticsEyebrow;
document.getElementById('diagnosticsTitle').textContent = text.diagnosticsTitle;
document.getElementById('coverageRateLabel').textContent = text.coverageRateLabel;
document.getElementById('operationCountLabel').textContent = text.operationCountLabel;
document.getElementById('reflectionCountLabel').textContent = text.reflectionCountLabel;
document.getElementById('unsupportedCountLabel').textContent = text.unsupportedCountLabel;
document.getElementById('moduleSummaryTitle').textContent = text.moduleSummaryTitle;
document.getElementById('runtimeTitle').textContent = text.runtimeTitle;
document.getElementById('runtimeText').textContent = text.runtimeText;
document.getElementById('runtimeCwdLabel').textContent = text.runtimeCwdLabel;
document.getElementById('runtimeUserLabel').textContent = text.runtimeUserLabel;
document.getElementById('stagesEyebrow').textContent = text.stagesEyebrow;
document.getElementById('stagesTitle').textContent = text.stagesTitle;
document.getElementById('stagesText').textContent = text.stagesText;
document.getElementById('heatmapEyebrow').textContent = text.heatmapEyebrow;
document.getElementById('heatmapTitle').textContent = text.heatmapTitle;
document.getElementById('heatmapText').textContent = text.heatmapText;
document.getElementById('cockpitEyebrow').textContent = text.cockpitEyebrow;
document.getElementById('cockpitTitle').textContent = text.cockpitTitle;
document.getElementById('cockpitText').textContent = text.cockpitText;
document.getElementById('lessonRadarTitle').textContent = text.lessonRadarTitle;
document.getElementById('experimentTitle').textContent = text.experimentTitle;
document.getElementById('observationTitle').textContent = text.observationTitle;
document.getElementById('relatedCommandsTitle').textContent = text.relatedCommandsTitle;
document.getElementById('depthEyebrow').textContent = text.depthEyebrow;
document.getElementById('depthTitle').textContent = text.depthTitle;
document.getElementById('depthText').textContent = text.depthText;
document.getElementById('commandDossierTitle').textContent = text.commandDossierTitle;
document.getElementById('opsExtensionTitle').textContent = text.opsExtensionTitle;
document.getElementById('reviewPromptsTitle').textContent = text.reviewPromptsTitle;
document.getElementById('whyThisMattersTitle').textContent = text.whyThisMattersTitle;
document.getElementById('teachingAngleTitle').textContent = text.teachingAngleTitle;
document.getElementById('coreIdeasTitle').textContent = text.coreIdeasTitle;
document.getElementById('commonScenariosTitle').textContent = text.commonScenariosTitle;
document.getElementById('commonPitfallsTitle').textContent = text.commonPitfallsTitle;
document.getElementById('troubleshootingFlowTitle').textContent = text.troubleshootingFlowTitle;
document.getElementById('takeawaysTitle').textContent = text.takeawaysTitle;
document.getElementById('exampleCommandsTitle').textContent = text.exampleCommandsTitle;
document.getElementById('practiceEyebrow').textContent = text.practiceEyebrow;
document.getElementById('practiceTitle').textContent = text.practiceTitle;
document.getElementById('practiceText').textContent = text.practiceText;
document.getElementById('prevBtn').textContent = text.prevBtn;
document.getElementById('nextBtn').textContent = text.nextBtn;
renderAuthControls();
if (!state.overview) {
document.getElementById('courseTitle').textContent = text.courseTitle;
document.getElementById('courseDescription').textContent = text.courseDescription;
document.getElementById('metaLine').textContent = text.metaLine('--', '--');
document.getElementById('commandTags').innerHTML = `<span class="chip">${escapeHtml(text.waitingCommandIndex)}</span>`;
document.getElementById('searchResults').innerHTML = `<div class="empty">${escapeHtml(text.searchEmpty)}</div>`;
document.getElementById('moduleList').innerHTML = `<div class="empty">${escapeHtml(text.modulesLoading)}</div>`;
document.getElementById('diagnosticNote').textContent = text.diagnosticChecking;
}
if (!state.lesson) {
document.getElementById('moduleLabel').textContent = text.initialModuleLabel;
document.getElementById('lessonTitle').textContent = text.initialLessonTitle;
document.getElementById('lessonGoal').textContent = text.initialLessonGoal;
document.getElementById('moduleSummary').textContent = text.moduleSummaryPlaceholder;
document.getElementById('masteryBtn').textContent = text.masteryDefault;
document.getElementById('masteryNote').textContent = text.masteryPendingNote;
document.getElementById('lessonWhy').textContent = state.language === 'zh'
? '理解什么时候该用一条命令,比孤立地背参数更重要。'
: 'Understanding when to use a command matters more than memorizing flags in isolation.';
document.getElementById('classicView').textContent = state.language === 'zh'
? '每节课都会把命令连接回更大的排障流程。'
: 'Each lesson connects the command to a broader troubleshooting flow.';
}
}
const persistedAuth = JSON.parse(localStorage.getItem('linux_lab_auth') || '{}');
2026-03-19 13:53:49 +08:00
const state = {
overview: null,
lesson: null,
language: localStorage.getItem('linux_lab_language') || 'zh',
2026-03-19 13:53:49 +08:00
theme: localStorage.getItem('linux_lab_theme') || 'light',
progress: JSON.parse(localStorage.getItem('linux_lab_progress') || '{}'),
auth: {
loggedIn: persistedAuth.loggedIn || false,
user: persistedAuth.user || '',
token: persistedAuth.token || ''
}
2026-03-19 13:53:49 +08:00
};
document.addEventListener('DOMContentLoaded', async () => {
setTheme(state.theme);
renderStaticText();
document.getElementById('themeBtn').addEventListener('click', () => setTheme(state.theme === 'light' ? 'dark' : 'light'));
document.getElementById('languageBtn').addEventListener('click', () => setLanguage(state.language === 'zh' ? 'en' : 'zh'));
document.getElementById('logoutBtn').addEventListener('click', logoutLab);
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);
});
2026-03-19 13:53:49 +08:00
document.getElementById('masteryBtn').addEventListener('click', toggleMastery);
if (state.auth.loggedIn) {
hideAuthGate();
await loadOverview();
} else {
showAuthGate();
}
});
2026-03-19 13:53:49 +08:00
function saveProgress() {
localStorage.setItem('linux_lab_progress', JSON.stringify(state.progress));
}
function masteredCount() {
return Object.values(state.progress).filter(Boolean).length;
}
function isMastered(lessonId) {
return Boolean(state.progress[lessonId]);
}
function completionRate() {
const total = state.overview?.meta?.lesson_count || 0;
if (!total) return 0;
return Math.round((masteredCount() / total) * 100);
}
function toggleMastery() {
if (!state.lesson?.id) return;
state.progress[state.lesson.id] = !state.progress[state.lesson.id];
saveProgress();
renderOverview();
renderModules();
renderLesson();
}
function persistAuth() {
localStorage.setItem('linux_lab_auth', JSON.stringify(state.auth));
}
function renderAuthControls() {
const zh = state.language === 'zh';
const logoutBtn = document.getElementById('logoutBtn');
logoutBtn.style.display = state.auth.loggedIn ? 'inline-flex' : 'none';
logoutBtn.textContent = zh ? '退出登录' : 'Log out';
document.getElementById('navEyebrow').textContent = zh ? '总览菜单' : 'Overview menu';
document.getElementById('navTitle').textContent = zh ? '课程总览' : 'Course overview';
document.getElementById('navText').textContent = zh ? '按模块、阶段或练习快速跳转,不再只能上一课下一课。' : 'Jump by module, stage, or exercise instead of relying only on previous and next.';
document.getElementById('navStageCountLabel').textContent = zh ? '已设置阶段' : 'Mapped stages';
document.getElementById('navExerciseCountLabel').textContent = zh ? '练习总数' : 'Exercises';
document.getElementById('navLessonEmpty').textContent = zh ? '登录后即可选择模块导航。' : 'Sign in to unlock module navigation.';
document.getElementById('navStageEyebrow').textContent = zh ? '阶段地图' : 'Stage map';
document.getElementById('navCategoryEyebrow').textContent = zh ? '命令分类' : 'Command categories';
document.getElementById('stageMapEyebrow').textContent = zh ? '阶段地图' : 'Stage map';
document.getElementById('stageMapTitle').textContent = zh ? '阶段路线清单' : 'Stage route checklist';
document.getElementById('stageMapText').textContent = zh ? '按阶段划分的学习路线,帮你把命令练习串联成一条完整的运维路径。' : 'Study by stages so commands connect into a complete operations path.';
document.getElementById('categoryEyebrow').textContent = zh ? '命令分类' : 'Command categories';
document.getElementById('categoryTitle').textContent = zh ? '命令家族可视化' : 'Command family map';
document.getElementById('categoryText').textContent = zh ? '了解各类命令在当前课程中的分布,以及建议的学习顺序。' : 'See how command families are distributed and which order makes sense.';
document.getElementById('gateTitle').textContent = zh ? '请先登录' : 'Sign in first';
document.getElementById('gateDescription').textContent = zh ? '登录后解锁课程总览、阶段地图、命令分类和练习仪表盘。' : 'Sign in to unlock the overview, stage map, command categories, and practice dashboard.';
document.getElementById('gateUser').setAttribute('placeholder', zh ? '用户名' : 'Username');
document.getElementById('gatePassword').setAttribute('placeholder', zh ? '密码' : 'Password');
document.getElementById('gateSubmit').textContent = zh ? '登录并进入总览' : 'Login and enter overview';
if (!state.auth.loggedIn) {
document.getElementById('gateMessage').textContent = zh ? '将自动保存进度与阶段状态,方便继续学习。' : 'Progress and stage state will be saved automatically so you can resume later.';
}
}
async function apiFetch(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.auth.token) {
headers.set('Authorization', `Bearer ${state.auth.token}`);
headers.set('X-Token', state.auth.token);
}
return fetch(path, Object.assign({}, options, { headers }));
}
function setExperienceLock(locked) {
const shell = document.getElementById('contentShell');
if (!shell) return;
shell.classList.toggle('locked', locked);
}
function showAuthGate() {
setExperienceLock(true);
document.getElementById('authGate').classList.remove('hidden');
}
function hideAuthGate() {
document.getElementById('authGate').classList.add('hidden');
setExperienceLock(false);
}
async function loadOverview() {
const response = await apiFetch('/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 text = uiText();
const meta = state.overview.meta || {};
document.getElementById('courseTitle').textContent = state.language === 'zh' ? text.courseTitle : (meta.title || text.courseTitle);
document.getElementById('courseDescription').textContent = state.language === 'zh' ? text.courseDescription : (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;
2026-03-19 13:53:49 +08:00
document.getElementById('masteredCount').textContent = masteredCount();
document.getElementById('completionRate').textContent = completionRate() + '%';
2026-03-19 14:58:56 +08:00
document.getElementById('coverageRate').textContent = (state.overview.diagnostics?.coverage_rate ?? 0) + '%';
document.getElementById('operationCount').textContent = state.overview.diagnostics?.operation_exercises ?? 0;
document.getElementById('reflectionCount').textContent = state.overview.diagnostics?.reflection_exercises ?? 0;
document.getElementById('unsupportedCount').textContent = state.overview.diagnostics?.unsupported_count ?? 0;
document.getElementById('diagnosticNote').textContent = (state.overview.diagnostics?.unsupported_count ?? 0) === 0
? text.diagnosticSupported
: text.diagnosticUnsupported;
document.getElementById('metaLine').textContent = text.metaLine(meta.version || '4.0', meta.updated || '--');
renderRuntime(state.overview.runtime || {});
renderCommandTags(state.overview.commands || []);
renderModules();
renderStageGrid();
2026-03-19 14:58:56 +08:00
renderModuleSummaryGrid();
renderVisualModules();
}
function renderRuntime(runtime) {
document.getElementById('runtimeCwd').textContent = runtime.cwd || '/';
document.getElementById('runtimeUser').textContent = runtime.user || 'sandbox_user';
}
function renderCommandTags(commands) {
const text = uiText();
const container = document.getElementById('commandTags');
if (!commands.length) {
container.innerHTML = `<span class="chip">${escapeHtml(text.waitingCommandIndex)}</span>`;
return;
}
container.innerHTML = commands.slice(0, 10).map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('');
}
function renderModules() {
const text = uiText();
const modules = state.overview.modules || [];
const container = document.getElementById('moduleList');
if (!modules.length) {
container.innerHTML = `<div class="empty">${escapeHtml(text.noModulesFound)}</div>`;
return;
}
container.innerHTML = modules.map((module) => `
<article class="module-card">
<h3>${escapeHtml(localizedModuleTitle(module))}</h3>
<p>${escapeHtml(localizedModuleSummary(module))}</p>
<div class="lesson-list">
${(module.lessons || []).map((lesson) => `
2026-03-19 13:53:49 +08:00
<button class="lesson-btn ${state.lesson?.id === lesson.id ? 'active' : ''} ${isMastered(lesson.id) ? 'done' : ''}" type="button" onclick="openLesson('${lesson.id}')">
<strong>${isMastered(lesson.id) ? escapeHtml(text.completedPrefix) : ''}${escapeHtml(localizedLessonTitle(lesson))}</strong>
<span class="muted">${escapeHtml((lesson.command_tokens || []).join(' | ') || lesson.command || text.mixedCommands)} | ${lesson.exercise_count || 0} ${escapeHtml(text.exerciseUnit)}</span>
</button>
`).join('')}
</div>
2026-03-19 14:58:56 +08:00
</article>
`).join('');
}
function renderModuleSummaryGrid() {
const text = uiText();
2026-03-19 14:58:56 +08:00
const modules = state.overview.modules || [];
const target = document.getElementById('moduleSummaryGrid');
if (!modules.length) {
target.innerHTML = `<div class="empty">${escapeHtml(text.noModuleDiagnostics)}</div>`;
2026-03-19 14:58:56 +08:00
return;
}
target.innerHTML = modules.map((module) => `
<article class="module-summary-card">
<h4>${escapeHtml(localizedModuleTitle(module))}</h4>
<p>${escapeHtml(localizedModuleSummary(module))}</p>
2026-03-19 14:58:56 +08:00
<div class="mini-stats">
<div class="mini-stat"><span>${escapeHtml(text.moduleSummaryLessons)}</span><strong>${module.lesson_count || 0}</strong></div>
<div class="mini-stat"><span>${escapeHtml(text.moduleSummaryExercises)}</span><strong>${module.exercise_count || 0}</strong></div>
<div class="mini-stat"><span>${escapeHtml(text.moduleSummaryOps)}</span><strong>${module.operation_count || 0}</strong></div>
<div class="mini-stat"><span>${escapeHtml(text.moduleSummaryCommands)}</span><strong>${module.command_count || 0}</strong></div>
2026-03-19 14:58:56 +08:00
</div>
</article>
`).join('');
}
function renderStageGrid() {
const text = uiText();
const groups = text.stageGroups || [];
const modules = state.overview.modules || [];
const target = document.getElementById('stageGrid');
target.innerHTML = groups.map((group) => {
const selectedModules = modules.slice(group.range[0], group.range[1] + 1);
const lessonTotal = selectedModules.reduce((sum, item) => sum + (item.lesson_count || 0), 0);
const exerciseTotal = selectedModules.reduce((sum, item) => sum + (item.exercise_count || 0), 0);
return `
<article class="stage-card">
<h4>${escapeHtml(group.title)}</h4>
<p>${escapeHtml(group.summary)}</p>
<div class="mini-stats">
<div class="mini-stat"><span>${escapeHtml(text.stageModules)}</span><strong>${selectedModules.length}</strong></div>
<div class="mini-stat"><span>${escapeHtml(text.stageLessons)}</span><strong>${lessonTotal}</strong></div>
<div class="mini-stat"><span>${escapeHtml(text.stageExercises)}</span><strong>${exerciseTotal}</strong></div>
<div class="mini-stat"><span>${escapeHtml(text.stageMastered)}</span><strong>${selectedModules.reduce((sum, item) => sum + (item.lessons || []).filter((lesson) => isMastered(lesson.id)).length, 0)}</strong></div>
</div>
</article>
`;
}).join('');
}
async function openLesson(lessonId, focusExerciseId = '') {
const response = await apiFetch('/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 text = uiText();
const lesson = state.lesson;
if (!lesson) return;
document.getElementById('moduleLabel').textContent = state.language === 'zh'
? localizedModuleTitle({ id: lesson.module_id, display_title: lesson.display_module_title })
: (lesson.display_module_title || text.currentLessonFallback);
document.getElementById('lessonTitle').textContent = localizedLessonTitle(lesson);
document.getElementById('lessonGoal').textContent = localizedLessonGoal(lesson);
document.getElementById('moduleSummary').textContent = localizedModuleSummary({
id: lesson.module_id,
display_summary: lesson.display_module_summary,
command_tokens: lesson.command_tokens
});
document.getElementById('lessonWhy').textContent = localizedLessonWhy(lesson);
document.getElementById('classicView').textContent = localizedClassicView(lesson);
document.getElementById('afterClass').textContent = localizedAfterClass(lesson);
document.getElementById('masteryBtn').textContent = isMastered(lesson.id) ? text.masteryReview : text.masteryDefault;
2026-03-19 13:53:49 +08:00
document.getElementById('masteryNote').textContent = isMastered(lesson.id)
? text.masteryDoneNote
: text.masteryPendingNote;
document.getElementById('prevBtn').disabled = !lesson.previous_lesson;
document.getElementById('nextBtn').disabled = !lesson.next_lesson;
document.getElementById('lessonCommands').innerHTML = (lesson.command_tokens || [lesson.command || text.mixedCommands]).map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('');
2026-03-19 13:53:49 +08:00
renderLessonCockpit(lesson);
renderList('conceptList', lesson.concepts, text.noConcepts);
renderList('scenarioList', lesson.scenarios, text.noScenarios);
renderList('pitfallList', lesson.pitfalls, text.noPitfalls);
renderList('flowList', lesson.troubleshooting_flow, text.noFlow);
renderList('takeawayList', lesson.takeaways, text.noTakeaways);
renderCommandGuides(lesson.command_guides || []);
renderList('opsExtensionList', lesson.ops_extensions, text.noOpsExtensions);
renderList('reviewPromptList', lesson.review_prompts, text.noReviewPrompts);
const examples = lesson.examples || [];
document.getElementById('exampleList').innerHTML = examples.length
? examples.map((item) => `<pre class="code">${escapeHtml(item)}</pre>`).join('')
: `<div class="empty">${escapeHtml(text.noExamples)}</div>`;
renderExercises(lesson.exercises || []);
}
2026-03-19 13:53:49 +08:00
function renderLessonCockpit(lesson) {
const text = uiText();
2026-03-19 13:53:49 +08:00
document.getElementById('lessonRadar').innerHTML = [
{ label: text.radarCommands, value: lesson.command_tokens?.length || 1 },
{ label: text.radarExercises, value: lesson.exercise_count || 0 },
{ label: text.radarPitfalls, value: lesson.pitfalls?.length || 0 },
{ label: text.radarRelated, value: lesson.related_commands?.length || 0 }
2026-03-19 13:53:49 +08:00
].map((item) => `
<div class="mini-stat">
<span>${escapeHtml(item.label)}</span>
<strong>${item.value}</strong>
</div>
`).join('');
const experimentSteps = [
text.experimentStep1(localizedLessonGoal(lesson)),
text.experimentStep2(lesson.command || localizedCommandLabel(lesson)),
text.experimentStep3,
text.experimentStep4
2026-03-19 13:53:49 +08:00
];
document.getElementById('experimentList').innerHTML = experimentSteps.map((item) => `<li>${escapeHtml(item)}</li>`).join('');
const observationItems = [
text.observationStep1,
text.observationStep2,
text.observationStep3
2026-03-19 13:53:49 +08:00
];
document.getElementById('observationList').innerHTML = observationItems.map((item) => `<li>${escapeHtml(item)}</li>`).join('');
const related = lesson.related_commands || [];
document.getElementById('relatedCommandList').innerHTML = related.length
? related.map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('')
: `<span class="chip">${escapeHtml(text.noRelatedCommands)}</span>`;
}
function renderCommandGuides(guides) {
const text = uiText();
const target = document.getElementById('commandGuideList');
if (!guides.length) {
target.innerHTML = `<div class="empty">${escapeHtml(text.noCommandGuides)}</div>`;
return;
}
target.innerHTML = guides.map((guide) => `
<article class="guide-card">
<h4>${escapeHtml(guide.command || text.commandFallback)}</h4>
<p>${escapeHtml(guide.role || '')}</p>
<div class="guide-meta">
<div class="mini-stat">
<span>${escapeHtml(text.nameOrigin)}</span>
<strong>${escapeHtml(guide.origin || text.noOrigin)}</strong>
</div>
<div class="mini-stat">
<span>${escapeHtml(text.learningHook)}</span>
<strong>${escapeHtml(guide.fun_fact || text.noFunFact)}</strong>
</div>
</div>
<div class="variant-list">
${(guide.variants || []).map((variant) => `
<div class="variant">
<code>${escapeHtml(variant.syntax || '')}</code>
<strong>${escapeHtml(variant.purpose || '')}</strong>
<span class="muted">${escapeHtml(text.examplePrefix)} ${escapeHtml(variant.example || '')}</span>
</div>
`).join('')}
</div>
</article>
`).join('');
2026-03-19 13:53:49 +08:00
}
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 text = uiText();
const container = document.getElementById('exerciseList');
if (!exercises.length) {
container.innerHTML = `<div class="empty">${escapeHtml(text.noExercises)}</div>`;
return;
}
container.innerHTML = exercises.map((exercise) => {
const label = exercise.type === 'operation'
? text.operationTask
: exercise.type === 'scenario'
? text.scenarioReflection
: text.understandingTask;
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(localizedExerciseHeading(exercise))}</h4>
<p class="muted" style="white-space: pre-line;">${formatMultiline(localizedExerciseBody(exercise))}</p>
<div class="terminal">
<div class="terminal-head">${escapeHtml(text.terminalHead)}</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}')">${escapeHtml(text.runBtn)}</button>
</div>
<pre class="output" id="output-${exercise.id}">${escapeHtml(text.waitingForCommand)}</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(localizedExerciseHeading(exercise))}</h4>
<p style="white-space: pre-line;">${formatMultiline(localizedExerciseBody(exercise))}</p>
</article>
`;
}).join('');
}
async function runExercise(exerciseId) {
const text = uiText();
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\n${text.running}`;
feedback.className = 'feedback';
feedback.textContent = '';
try {
const runResponse = await apiFetch('/api/run?cmd=' + encodeURIComponent(cmd));
const runData = await runResponse.json();
output.textContent = `$ ${cmd}\n\n${runData.output || runData.message || text.noOutput}`;
renderRuntime({ cwd: runData.cwd || '/', user: state.overview?.runtime?.user || 'sandbox_user' });
const checkResponse = await apiFetch('/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\n${text.requestFailedPrefix}${error.message}`;
feedback.className = 'feedback show warn';
feedback.textContent = text.requestFailed;
}
}
async function resetSandbox() {
const text = uiText();
await apiFetch('/api/reset', { method: 'POST' });
renderRuntime({ cwd: '/', user: 'sandbox_user' });
alert(text.sandboxResetComplete);
}
async function runSearch() {
const text = uiText();
const query = document.getElementById('searchInput').value.trim();
const container = document.getElementById('searchResults');
if (!query) {
container.innerHTML = `<div class="empty">${escapeHtml(text.searchEmpty)}</div>`;
return;
}
container.innerHTML = `<div class="empty">${escapeHtml(text.searchLoading)}</div>`;
try {
const response = await apiFetch('/api/course/search?q=' + encodeURIComponent(query));
const payload = await response.json();
const results = payload.results || [];
if (!results.length) {
container.innerHTML = `<div class="empty">${escapeHtml(text.searchNoResult)}</div>`;
return;
}
container.innerHTML = results.map((item) => `
<button class="result" type="button" onclick="openLesson('${item.lesson_id}', '${item.exercise_id || ''}')">
<strong>${escapeHtml(localizedSearchTitle(item))}</strong>
<span class="muted">${escapeHtml(localizedSearchSubtitle(item))}</span>
</button>
`).join('');
} catch (error) {
container.innerHTML = `<div class="empty">${escapeHtml(text.requestFailedPrefix + error.message)}</div>`;
}
}
function setTheme(theme) {
state.theme = theme;
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('linux_lab_theme', theme);
document.getElementById('themeBtn').textContent = theme === 'light' ? uiText().themeToDark : uiText().themeToLight;
}
const COMMAND_CATEGORY_META = [
{ id: "navigation", title: "导航", description: "帮你定位在系统中的上下文、目录和状态。", commands: ["pwd","ls","cd","which","whereis","clear"] },
{ id: "filesystem", title: "文件系统", description: "读写/移动/备份关键资产的安全操作。", commands: ["cat","echo","mkdir","touch","cp","mv","rm","chmod","stat","tar"] },
{ id: "text", title: "文本处理", description: "过滤日志、提取字段、整理数据。", commands: ["grep","find","head","tail","sort","uniq","cut","awk","sed"] },
{ id: "observation", title: "观测", description: "实时洞察服务、进程、资源使用。", commands: ["ps","top","uptime","free","df","du","lsof","last","w","history","date"] },
{ id: "service", title: "服务控制", description: "管理 systemd 单元、日志和守护进程。", commands: ["systemctl","journalctl","nohup","crontab"] },
{ id: "network", title: "网络", description: "检查连接、响应、DNS 及路由。", commands: ["ip","ping","ss","curl","wget","netstat","traceroute","dig"] },
{ id: "identity", title: "身份", description: "了解当前身份、环境变量与别名设置。", commands: ["whoami","id","env","export","alias"] },
{ id: "package", title: "包管理", description: "检查、安装与验证平台级软件。", commands: ["apt","yum","rpm","dpkg"] },
];
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function renderStageTimeline() {
const map = document.getElementById("stageMap");
const modules = (state.overview?.modules || []).slice(0, 5);
if (!modules.length) {
map.innerHTML = "<div class='empty'>尚无阶段数据</div>";
return;
}
map.innerHTML = modules.map((module, index) => `
<div class="stage-step">
<strong>${index + 1}. ${escapeHtml(localizedModuleTitle(module))}</strong>
<span class="muted">${(module.lesson_count || 0)} lessons · ${(module.exercise_count || 0)} exercises</span>
</div>
`).join("");
const track = document.getElementById("progressTrack");
const completion = completionRate();
track.innerHTML = modules.map((module, index) => {
const masteredLessons = (module.lessons || []).filter((lesson) => isMastered(lesson.id)).length;
const totalLessons = module.lesson_count || (module.lessons || []).length;
const percent = totalLessons ? Math.round((masteredLessons / totalLessons) * 100) : 0;
return `<span class="progress-pill">${escapeHtml(localizedModuleTitle(module))}: ${percent}%</span>`;
}).join("");
const navStageMap = document.getElementById("navStageMap");
const navStageCount = document.getElementById("navStageCount");
if (navStageMap) {
navStageMap.innerHTML = modules.map((module, index) => `
<div class="stage-step">
<strong>${index + 1}. ${escapeHtml(localizedModuleTitle(module))}</strong>
<span class="muted">${(module.lesson_count || 0)} lessons</span>
</div>
`).join("");
}
if (navStageCount) {
navStageCount.textContent = modules.length;
}
}
function renderCategoryGrid() {
const container = document.getElementById("categoryGrid");
const text = uiText();
const commands = (state.overview?.commands || [])
.map((cmd) => String(cmd).trim().toLowerCase())
.filter(Boolean);
const counts = COMMAND_CATEGORY_META.reduce((acc, category) => {
acc[category.id] = 0;
return acc;
}, {});
commands.forEach((cmd) => {
COMMAND_CATEGORY_META.forEach((category) => {
const family = (category.commands || []).map((item) => String(item).toLowerCase());
if (family.includes(cmd)) {
counts[category.id] = (counts[category.id] || 0) + 1;
}
});
});
container.innerHTML = COMMAND_CATEGORY_META.map((category) => {
const count = counts[category.id] || 0;
const samples = (category.commands || []).slice(0, 3);
return `
<article class="category-card">
<h4>${escapeHtml(category.title)}</h4>
<p>${escapeHtml(category.description)}</p>
<div class="command-list">
<span>${escapeHtml(text.commandFamilyCountLabel)} ${count}</span>
${samples.length ? `<span>${escapeHtml(text.commandFamilyCommandsHint)} ${escapeHtml(samples.join(", "))}</span>` : ""}
</div>
</article>
`;
}).join("");
}
function renderNavLessons() {
const container = document.getElementById("navLessonResults");
const modules = state.overview?.modules || [];
const exerciseCount = modules.reduce((sum, module) => sum + (module.exercise_count || 0), 0);
const exerciseCountEl = document.getElementById("navExerciseCount");
const text = uiText();
if (exerciseCountEl) {
exerciseCountEl.textContent = exerciseCount;
}
if (!modules.length) {
container.innerHTML = `<div class="empty">${escapeHtml(text.noModulesFound)}</div>`;
return;
}
const visibleModules = modules.slice(0, 6);
container.innerHTML = visibleModules.map((module) => {
const lessons = (module.lessons || []).slice(0, 3);
const lessonButtons = lessons.map((lesson) => `
<button class="nav-lesson-link ${state.lesson?.id === lesson.id ? "active" : ""}" type="button" onclick="openLesson('${lesson.id}')">
<strong>${escapeHtml(localizedLessonTitle(lesson))}</strong>
<span class="muted">${escapeHtml((lesson.command_tokens || []).join(" | ") || lesson.command || text.commandFallback)}</span>
</button>
`).join("");
const declaredLessons = module.lesson_count || (module.lessons || []).length;
const extraLessons = Math.max(0, declaredLessons - lessons.length);
return `
<article class="nav-module-card">
<div class="nav-module-header">
<div>
<strong>${escapeHtml(localizedModuleTitle(module))}</strong>
<p class="muted" style="margin: 6px 0 0;">${escapeHtml(localizedModuleSummary(module))}</p>
</div>
<div class="nav-module-meta">
<span>${escapeHtml(text.stageLessons)} ${declaredLessons}</span>
<span>${escapeHtml(text.moduleSummaryExercises)} ${module.exercise_count || 0}</span>
</div>
</div>
<div class="lesson-navigator">
${lessonButtons}
</div>
${extraLessons ? `<div class="nav-module-extra">${escapeHtml(text.navMoreLessons.replace("{count}", extraLessons))}</div>` : ""}
</article>
`;
}).join("");
}
async function loginToLab(event) {
event.preventDefault();
const zh = state.language === 'zh';
const username = document.getElementById("gateUser").value.trim();
const password = document.getElementById("gatePassword").value.trim();
const messageNode = document.getElementById("gateMessage");
if (!username || !password) {
messageNode.textContent = zh ? "请输入用户名和密码" : "Please enter a username and password.";
return;
}
try {
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const payload = await response.json();
if (payload.success) {
state.auth = { loggedIn: true, user: username, token: payload.token };
persistAuth();
hideAuthGate();
messageNode.textContent = payload.message || (zh ? "登录成功" : "Login succeeded.");
renderAuthControls();
await loadOverview();
return;
}
messageNode.textContent = payload.error || (zh ? "登录失败" : "Login failed.");
} catch (error) {
messageNode.textContent = zh ? "网络异常,请稍后再试" : "Network error. Please try again later.";
}
}
async function logoutLab() {
const zh = state.language === 'zh';
state.auth = { loggedIn: false, user: "", token: "" };
persistAuth();
try {
await apiFetch('/api/logout', { method: 'POST' });
} catch (error) {
console.warn('Logout request failed', error);
}
showAuthGate();
renderAuthControls();
document.getElementById("gateMessage").textContent = zh ? "已退出,重新登录即可继续。" : "Logged out. Sign in again to continue.";
}
function renderVisualModules() {
renderStageTimeline();
renderCategoryGrid();
renderNavLessons();
}
document.getElementById("gateLoginForm").addEventListener("submit", loginToLab);
window.openLesson = openLesson;
window.runExercise = runExercise;
window.runSearch = runSearch;
window.resetSandbox = resetSandbox;
</script>
<div class="auth-gate" id="authGate">
<div class="auth-gate-card">
<h2 id="gateTitle">请先登录</h2>
<p id="gateDescription">登录后解锁课程总览、阶段地图、命令分类和练习仪表盘。</p>
<form id="gateLoginForm">
<input id="gateUser" type="text" placeholder="用户名" required />
<input id="gatePassword" type="password" placeholder="密码" required />
<button type="submit" id="gateSubmit">登录并进入总览</button>
</form>
<p id="gateMessage" class="muted">将自动保存进度与阶段状态,方便继续学习。</p>
</div>
</div>
</body>
</html>