feat: rebuild linux learning lab experience
This commit is contained in:
870
index.html
870
index.html
@@ -1,494 +1,593 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Linux 运维全场景学习平台</title>
|
<title>Linux Learning Lab</title>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
:root {
|
:root {
|
||||||
--primary: #1677ff;
|
--bg: #f3f7fb;
|
||||||
--primary-dark: #0f5ed7;
|
--panel: rgba(255, 255, 255, 0.94);
|
||||||
--primary-soft: #eef5ff;
|
--line: #d8e2ee;
|
||||||
--accent: #36a3ff;
|
--text: #102033;
|
||||||
--success: #22c55e;
|
--muted: #5d7187;
|
||||||
--warning: #f59e0b;
|
--brand: #0f6db6;
|
||||||
--danger: #ef4444;
|
--accent: #1d9b6c;
|
||||||
--bg: #f5f9ff;
|
--soft: #e9f3ff;
|
||||||
--bg-2: #edf4ff;
|
--terminal: #0e1724;
|
||||||
--card: rgba(255,255,255,0.95);
|
--shadow: 0 18px 40px rgba(16, 32, 51, 0.12);
|
||||||
--text: #0f172a;
|
|
||||||
--text-2: #334155;
|
|
||||||
--text-3: #64748b;
|
|
||||||
--border: #dbe7f5;
|
|
||||||
--shadow: 0 18px 45px rgba(15, 94, 215, 0.12);
|
|
||||||
--radius: 22px;
|
|
||||||
--terminal: #0b1220;
|
|
||||||
--terminal-soft: #101a2f;
|
|
||||||
}
|
}
|
||||||
[data-theme='dark'] {
|
[data-theme="dark"] {
|
||||||
--bg: #08101d;
|
--bg: #08111d;
|
||||||
--bg-2: #101b31;
|
--panel: rgba(12, 24, 38, 0.94);
|
||||||
--card: rgba(16, 26, 47, 0.96);
|
--line: #1d3245;
|
||||||
--text: #edf4ff;
|
--text: #edf4fb;
|
||||||
--text-2: #cfdbf4;
|
--muted: #9bb0c3;
|
||||||
--text-3: #90a5ca;
|
--brand: #67baff;
|
||||||
--border: #1d3358;
|
--accent: #62d6ab;
|
||||||
--shadow: 0 18px 55px rgba(2, 8, 23, 0.42);
|
--soft: rgba(103, 186, 255, 0.1);
|
||||||
--primary-soft: rgba(22,119,255,.12);
|
--terminal: #06101c;
|
||||||
|
--shadow: 0 20px 44px rgba(0, 0, 0, 0.34);
|
||||||
}
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
margin: 0;
|
||||||
background: linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
|
|
||||||
color: var(--text);
|
|
||||||
min-height: 100vh;
|
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);
|
||||||
}
|
}
|
||||||
.header {
|
button, input { font: inherit; }
|
||||||
background: linear-gradient(135deg, #0f5ed7 0%, #1677ff 45%, #36a3ff 100%);
|
.shell { max-width: 1480px; margin: 0 auto; padding: 20px; }
|
||||||
color: #fff;
|
.topbar, .card {
|
||||||
padding: 18px 24px;
|
background: var(--panel);
|
||||||
box-shadow: 0 12px 35px rgba(22,119,255,.2);
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
}
|
}
|
||||||
.header-inner {
|
.topbar {
|
||||||
max-width: 1480px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 20px;
|
gap: 16px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.brand h1 { font-size: 26px; font-weight: 800; }
|
.title h1 { margin: 0 0 8px; font-size: 30px; }
|
||||||
.brand p { margin-top: 6px; opacity: .92; font-size: 13px; }
|
.title p, .muted { color: var(--muted); line-height: 1.8; }
|
||||||
.header-actions { display:flex; gap:10px; flex-wrap:wrap; }
|
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
.chip-btn {
|
.btn, .btn-soft, .btn-ghost {
|
||||||
border: none; border-radius: 999px; padding: 10px 15px; cursor: pointer; font-weight: 700;
|
border: 0;
|
||||||
background: rgba(255,255,255,.14); color:#fff;
|
border-radius: 999px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.chip-btn:hover, .chip-btn.active { background:#fff; color:var(--primary); }
|
.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 {
|
.layout {
|
||||||
max-width: 1480px; margin: 0 auto; padding: 20px;
|
display: grid;
|
||||||
display: grid; grid-template-columns: 320px minmax(0, 1fr) 340px; gap: 18px;
|
grid-template-columns: 360px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
.card {
|
.sidebar { padding: 20px; position: sticky; top: 20px; }
|
||||||
background: var(--card); border:1px solid var(--border); border-radius: var(--radius);
|
.section { margin-bottom: 18px; }
|
||||||
box-shadow: var(--shadow); backdrop-filter: blur(10px);
|
.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;
|
||||||
}
|
}
|
||||||
.sidebar, .content, .aside { padding: 20px; }
|
.stat {
|
||||||
.title-row { display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:14px; }
|
padding: 14px;
|
||||||
.title-row h2 { font-size: 18px; font-weight: 800; }
|
border: 1px solid var(--line);
|
||||||
.muted { color: var(--text-3); }
|
border-radius: 16px;
|
||||||
.course-meta {
|
background: rgba(255,255,255,0.3);
|
||||||
display:grid; grid-template-columns:repeat(2, 1fr); gap:10px; margin-bottom:16px;
|
|
||||||
}
|
}
|
||||||
.meta-box {
|
.stat span { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
|
||||||
padding: 14px; border-radius: 16px; border:1px solid var(--border);
|
.stat strong { font-size: 22px; }
|
||||||
background: linear-gradient(180deg, var(--primary-soft), rgba(255,255,255,.75));
|
.search {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
.meta-box .label { font-size:12px; color:var(--text-3); }
|
.search input {
|
||||||
.meta-box .value { margin-top:6px; font-size:22px; font-weight:800; }
|
flex: 1;
|
||||||
.module-item {
|
padding: 12px 14px;
|
||||||
border:1px solid var(--border); border-radius:18px; margin-bottom:14px; overflow:hidden;
|
border-radius: 14px;
|
||||||
background: rgba(255,255,255,.55);
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
.module-head {
|
.chip-list, .cmd-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||||
padding: 14px 16px; cursor:pointer; display:flex; justify-content:space-between; gap:12px; align-items:center;
|
.chip {
|
||||||
background: linear-gradient(90deg, #eef6ff 0%, #f8fbff 100%);
|
display: inline-flex;
|
||||||
color: var(--primary-dark); font-weight: 800;
|
align-items: center;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--soft);
|
||||||
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
.lesson-list { padding: 12px; }
|
.results, .modules { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; max-height: 34vh; overflow: auto; }
|
||||||
.lesson-item {
|
.result, .lesson-btn, .module-card {
|
||||||
padding: 12px 14px; border-radius: 14px; cursor:pointer; transition: all .2s ease; margin-bottom:8px;
|
border: 1px solid var(--line);
|
||||||
border:1px solid transparent;
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.18);
|
||||||
}
|
}
|
||||||
.lesson-item:hover { background: var(--primary-soft); }
|
.result, .lesson-btn {
|
||||||
.lesson-item.active {
|
width: 100%;
|
||||||
background: linear-gradient(90deg, var(--primary) 0%, var(--accent) 100%); color:#fff;
|
text-align: left;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.lesson-item .name { font-weight: 700; }
|
.result strong, .lesson-btn strong { display: block; margin-bottom: 4px; font-size: 14px; }
|
||||||
.lesson-item .desc { font-size: 12px; margin-top: 5px; opacity: .85; }
|
.module-card { padding: 14px; }
|
||||||
.hero {
|
.module-card h3 { margin: 0 0 8px; font-size: 16px; }
|
||||||
padding: 24px; border-radius: 24px; border:1px solid #d8e7fb;
|
.module-card p { margin: 0 0 10px; color: var(--muted); line-height: 1.7; }
|
||||||
background: linear-gradient(135deg, rgba(22,119,255,.12), rgba(54,163,255,.08));
|
.lesson-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
margin-bottom: 18px;
|
.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 h2 { font-size: 32px; margin-bottom: 10px; }
|
.hero-head h2 { margin: 8px 0 10px; font-size: 34px; }
|
||||||
.hero p { line-height: 1.9; color: var(--text-3); }
|
.hero-grid, .detail-grid {
|
||||||
.hero-actions { display:flex; gap:12px; flex-wrap:wrap; margin-top:16px; }
|
display: grid;
|
||||||
.btn {
|
grid-template-columns: 1.2fr 0.8fr;
|
||||||
border:none; border-radius:14px; padding: 12px 18px; cursor:pointer; font-weight:800;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.btn-primary { background: linear-gradient(135deg, var(--primary), var(--accent)); color:#fff; }
|
|
||||||
.btn-soft { background: #edf4ff; color: var(--primary-dark); }
|
|
||||||
.lesson-shell { display:none; }
|
|
||||||
.lesson-shell.show { display:block; }
|
|
||||||
.badge-row { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:14px; }
|
|
||||||
.badge {
|
|
||||||
display:inline-flex; align-items:center; padding:6px 10px; border-radius:999px;
|
|
||||||
font-size:12px; font-weight:700; background: var(--primary-soft); color: var(--primary-dark);
|
|
||||||
}
|
|
||||||
.lesson-title { font-size: 30px; font-weight: 800; margin-bottom: 10px; }
|
|
||||||
.panel {
|
.panel {
|
||||||
border:1px solid var(--border); border-radius:18px; padding:18px; margin-bottom:14px;
|
border: 1px solid var(--line);
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,.8), rgba(248,251,255,.92));
|
border-radius: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
}
|
}
|
||||||
.panel h3 { font-size:16px; font-weight:800; margin-bottom:12px; color: var(--text-2); }
|
.panel h3 { margin: 0 0 10px; font-size: 17px; }
|
||||||
.panel p, .panel li { line-height: 1.9; color: var(--text-2); }
|
.panel p, .panel li { color: var(--muted); line-height: 1.9; }
|
||||||
.panel ul { padding-left: 18px; }
|
.panel ul { margin: 0; padding-left: 18px; }
|
||||||
.example-list, .exercise-list { display:flex; flex-direction:column; gap:10px; }
|
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.code-block {
|
.code {
|
||||||
padding: 12px 14px; border-radius: 14px; background:#0b1220; color:#dbeafe; font-family: Consolas, monospace;
|
margin: 0;
|
||||||
overflow:auto;
|
padding: 13px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--terminal);
|
||||||
|
color: #d8e8ff;
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
.exercise-card {
|
.examples { display: flex; flex-direction: column; gap: 10px; }
|
||||||
border:1px solid var(--border); border-radius:16px; padding:14px; background: rgba(255,255,255,.85);
|
.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);
|
||||||
}
|
}
|
||||||
.exercise-type {
|
.badge {
|
||||||
display:inline-flex; margin-bottom:8px; padding:4px 8px; border-radius:999px; font-size:12px; font-weight:700;
|
display: inline-flex;
|
||||||
background: #eef4ff; color: var(--primary-dark);
|
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;
|
||||||
}
|
}
|
||||||
.terminal-box {
|
.exercise h4 { margin: 0 0 10px; font-size: 20px; }
|
||||||
margin-top: 12px; border-radius: 18px; overflow:hidden; border:1px solid rgba(59,130,246,.18);
|
.terminal {
|
||||||
background: linear-gradient(180deg, var(--terminal) 0%, var(--terminal-soft) 100%);
|
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-header { padding: 12px 14px; color:#b9d6ff; font-weight:700; border-bottom:1px solid rgba(148,163,184,.15); }
|
.terminal-head { padding: 11px 14px; color: #b8d4ef; border-bottom: 1px solid rgba(151,177,201,0.16); }
|
||||||
.terminal-input-row { display:flex; gap:10px; padding: 14px; align-items:center; }
|
.terminal-input { display: flex; align-items: center; gap: 8px; padding: 12px 14px; }
|
||||||
.prompt { color:#4ade80; font-family: Consolas, monospace; font-weight:700; }
|
.prompt { color: #62d6ab; font-family: Consolas, "Courier New", monospace; font-weight: 800; }
|
||||||
.cmd-input {
|
.terminal-input input {
|
||||||
flex:1; border:none; outline:none; background:transparent; color:#fff; font-family: Consolas, monospace; font-size:15px;
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #f4fbff;
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
}
|
}
|
||||||
.terminal-output { padding: 14px; min-height: 120px; white-space: pre-wrap; color:#dbeafe; font-family: Consolas, monospace; }
|
.terminal-input button {
|
||||||
.feedback { display:none; margin-top: 12px; padding: 14px 16px; border-radius: 14px; font-weight:700; }
|
border: 0;
|
||||||
.feedback.show { display:block; }
|
border-radius: 12px;
|
||||||
.feedback.success { background: rgba(34,197,94,.1); border:1px solid rgba(34,197,94,.2); color:#15803d; }
|
padding: 9px 13px;
|
||||||
.feedback.warn { background: rgba(245,158,11,.1); border:1px solid rgba(245,158,11,.2); color:#b45309; }
|
background: linear-gradient(135deg, var(--brand), #37a1ff);
|
||||||
.aside-card {
|
color: #fff;
|
||||||
border:1px solid var(--border); border-radius:18px; padding:16px; margin-bottom:14px;
|
cursor: pointer;
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,.88), rgba(247,250,255,.92));
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
.aside-card h3 { font-size:15px; font-weight:800; margin-bottom:10px; }
|
.output {
|
||||||
.aside-card li { margin-left: 18px; line-height: 1.8; color: var(--text-2); }
|
margin: 0;
|
||||||
.qa-box {
|
min-height: 100px;
|
||||||
padding:12px; border-radius:14px; background: var(--primary-soft); margin-top:10px; color:var(--text-2);
|
padding: 14px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: #d8e8ff;
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
}
|
}
|
||||||
@media (max-width: 1240px) {
|
.feedback { display: none; margin-top: 10px; padding: 11px 13px; border-radius: 14px; line-height: 1.7; font-weight: 700; }
|
||||||
.layout { grid-template-columns: 320px minmax(0, 1fr); }
|
.feedback.show { display: block; }
|
||||||
.aside { display:none; }
|
.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; }
|
||||||
@media (max-width: 860px) {
|
.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; }
|
.layout { grid-template-columns: 1fr; }
|
||||||
.header-inner { flex-direction: column; align-items: flex-start; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="header">
|
<div class="shell">
|
||||||
<div class="header-inner">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<div class="title">
|
||||||
<h1>🐧 Linux 运维全场景学习平台</h1>
|
<div class="eyebrow">Linux Ops Learning Lab</div>
|
||||||
<p>以知识理解为中心,尽量覆盖运维强相关全场景,帮助建立真正可迁移的 Linux 能力。</p>
|
<h1 id="courseTitle">Linux Learning Lab</h1>
|
||||||
</div>
|
<p id="courseDescription">Search Linux lessons, practice commands in a sandbox, and build better troubleshooting habits.</p>
|
||||||
<div class="header-actions">
|
|
||||||
<button class="chip-btn active" onclick="setTheme('light')">浅色</button>
|
|
||||||
<button class="chip-btn" onclick="setTheme('dark')">深色</button>
|
|
||||||
<button class="chip-btn" onclick="resetSandbox()">重置沙盒</button>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar card">
|
<aside class="sidebar card">
|
||||||
<div class="title-row">
|
<section class="section">
|
||||||
<h2>课程地图</h2>
|
<div class="eyebrow">Overview</div>
|
||||||
<span class="muted" id="courseVersion">v4.0</span>
|
<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>
|
||||||
<div class="course-meta">
|
<div class="chip-list" id="commandTags"></div>
|
||||||
<div class="meta-box"><div class="label">模块数</div><div class="value" id="moduleCount">6</div></div>
|
<p class="muted" id="metaLine" style="margin-top: 12px;">Version --</p>
|
||||||
<div class="meta-box"><div class="label">课时数</div><div class="value" id="lessonCount">18</div></div>
|
</section>
|
||||||
<div class="meta-box"><div class="label">练习数</div><div class="value" id="exerciseCount">54</div></div>
|
|
||||||
<div class="meta-box"><div class="label">学习方向</div><div class="value" style="font-size:18px">知识优先</div></div>
|
<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>
|
||||||
<div id="courseNav"></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>
|
</aside>
|
||||||
|
|
||||||
<main class="content card">
|
<main class="main">
|
||||||
<section class="hero" id="heroPanel">
|
<section class="hero card">
|
||||||
<h2>先建立运维视角,再去学命令</h2>
|
<div class="hero-head">
|
||||||
<p>
|
<div>
|
||||||
这个版本不再把 Linux 做成“命令闯关页”,而是尽量按运维全场景组织课程:
|
<div class="eyebrow" id="moduleLabel">Pick a lesson to begin</div>
|
||||||
从文件系统、日志、资源、服务、网络、权限,到包管理、自动化、综合排障,逐步建立完整认知。
|
<h2 id="lessonTitle">Turn Linux practice into workflow thinking</h2>
|
||||||
</p>
|
<p class="muted" id="lessonGoal">Choose a lesson from the left to see goals, examples, common pitfalls, and runnable exercises.</p>
|
||||||
<div class="hero-actions">
|
|
||||||
<button class="btn btn-primary" onclick="openFirstLesson()">从第一课开始</button>
|
|
||||||
<button class="btn btn-soft" onclick="openFirstPracticeLesson()">直接看第一组练习</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="actions">
|
||||||
|
<button class="btn-ghost" type="button" id="prevBtn">Previous</button>
|
||||||
<section class="lesson-shell" id="lessonShell">
|
<button class="btn" type="button" id="nextBtn">Next</button>
|
||||||
<div class="badge-row">
|
|
||||||
<span class="badge" id="moduleBadge">模块 1</span>
|
|
||||||
<span class="badge" id="commandBadge">命令</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="lesson-title" id="lessonTitle">课时标题</div>
|
</div>
|
||||||
<p class="muted" id="lessonSummary" style="margin-bottom: 18px;"></p>
|
<div class="cmd-list" id="lessonCommands"></div>
|
||||||
|
<div class="hero-grid">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>这一课学什么</h3>
|
<h3>Module summary</h3>
|
||||||
<p id="lessonGoal"></p>
|
<p id="moduleSummary">This card shows the learning direction of the current module.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3>为什么重要</h3>
|
<h3>Sandbox runtime</h3>
|
||||||
<p id="lessonWhy"></p>
|
<p>You can execute commands directly below. The platform returns simulated output and exercise feedback.</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<h3>核心知识点</h3>
|
|
||||||
<ul id="conceptList"></ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<h3>最小示例</h3>
|
|
||||||
<div class="example-list" id="exampleList"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<h3>常见误区</h3>
|
|
||||||
<ul id="pitfallList"></ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<h3>典型使用场景</h3>
|
|
||||||
<ul id="scenarioList"></ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<h3>教材视角</h3>
|
|
||||||
<p id="classicView"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<h3>相关命令</h3>
|
|
||||||
<div class="badge-row" id="relatedCommands"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<h3>排障链路 / 处理顺序</h3>
|
|
||||||
<ul id="flowList"></ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<h3>课后总结</h3>
|
|
||||||
<ul id="takeawayList"></ul>
|
|
||||||
<p id="afterClass" style="margin-top: 12px; color: var(--text-3);"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<h3>本课练习</h3>
|
|
||||||
<div class="exercise-list" id="exerciseList"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<aside class="aside card">
|
|
||||||
<div class="aside-card">
|
|
||||||
<h3>学习建议</h3>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>先理解“场景里为什么需要这个命令”,再记参数。</li>
|
<li>Current directory: <span id="runtimeCwd">/</span></li>
|
||||||
<li>不要只学单个命令,要学命令之间如何串成排障链路。</li>
|
<li>Current user: <span id="runtimeUser">sandbox_user</span></li>
|
||||||
<li>目录、日志、资源、服务、网络、权限,是 Linux 运维学习的六大核心场景。</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="aside-card">
|
|
||||||
<h3>当前课时提示</h3>
|
|
||||||
<div id="asideHint" class="muted">打开课程后,这里会显示与当前课时相关的补充提醒。</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="aside-card">
|
</section>
|
||||||
<h3>理解型问题</h3>
|
|
||||||
<div id="qaBox" class="muted">选择课时后,这里会显示理解题和场景题,帮助你形成真正的命令认识。</div>
|
<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>
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let COURSE = null;
|
const state = { overview: null, lesson: null, theme: localStorage.getItem('linux_lab_theme') || 'light' };
|
||||||
let currentLesson = null;
|
|
||||||
let currentTheme = localStorage.getItem('linux_course_theme') || 'light';
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
setTheme(currentTheme, false);
|
setTheme(state.theme);
|
||||||
await loadCourse();
|
document.getElementById('themeBtn').addEventListener('click', () => setTheme(state.theme === 'light' ? 'dark' : 'light'));
|
||||||
renderCourseNav();
|
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 loadCourse() {
|
async function loadOverview() {
|
||||||
const res = await fetch('/api/course');
|
const response = await fetch('/api/overview');
|
||||||
COURSE = await res.json();
|
state.overview = await response.json();
|
||||||
document.getElementById('courseVersion').textContent = 'v' + (COURSE.meta.version || '4.0');
|
renderOverview();
|
||||||
document.getElementById('moduleCount').textContent = COURSE.meta.module_count || COURSE.modules.length;
|
const firstLesson = state.overview.modules?.[0]?.lessons?.[0];
|
||||||
document.getElementById('lessonCount').textContent = COURSE.meta.total_lessons || getAllLessons().length;
|
if (firstLesson) await openLesson(firstLesson.id);
|
||||||
document.getElementById('exerciseCount').textContent = COURSE.meta.total_exercises || getAllExercises().length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllLessons() {
|
function renderOverview() {
|
||||||
return COURSE.modules.flatMap(module => module.lessons.map(lesson => ({ ...lesson, moduleId: module.id, moduleTitle: module.title, moduleSummary: module.summary })));
|
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 getAllExercises() {
|
function renderRuntime(runtime) {
|
||||||
return getAllLessons().flatMap(lesson => lesson.exercises.map(ex => ({ ...ex, lessonTitle: lesson.title, moduleTitle: lesson.moduleTitle })));
|
document.getElementById('runtimeCwd').textContent = runtime.cwd || '/';
|
||||||
|
document.getElementById('runtimeUser').textContent = runtime.user || 'sandbox_user';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCourseNav() {
|
function renderCommandTags(commands) {
|
||||||
const nav = document.getElementById('courseNav');
|
const container = document.getElementById('commandTags');
|
||||||
nav.innerHTML = COURSE.modules.map((module, index) => {
|
if (!commands.length) {
|
||||||
const lessonHtml = module.lessons.map(lesson => {
|
container.innerHTML = '<span class="chip">Waiting for command index</span>';
|
||||||
const active = currentLesson && currentLesson.id === lesson.id;
|
return;
|
||||||
return `
|
}
|
||||||
<div class="lesson-item ${active ? 'active' : ''}" onclick="openLesson('${module.id}', '${lesson.id}')">
|
container.innerHTML = commands.slice(0, 10).map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('');
|
||||||
<div class="name">${lesson.title}</div>
|
}
|
||||||
<div class="desc">${lesson.goal}</div>
|
|
||||||
</div>`;
|
function renderModules() {
|
||||||
}).join('');
|
const modules = state.overview.modules || [];
|
||||||
return `
|
const container = document.getElementById('moduleList');
|
||||||
<div class="module-item">
|
if (!modules.length) {
|
||||||
<div class="module-head" onclick="toggleModule(${index})">
|
container.innerHTML = '<div class="empty">No modules found.</div>';
|
||||||
<span>${module.title}</span>
|
return;
|
||||||
<span>${module.lessons.length} 课</span>
|
}
|
||||||
|
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>
|
</div>
|
||||||
<div class="lesson-list" id="module-${index}">${lessonHtml}</div>
|
</article>
|
||||||
</div>`;
|
`).join('');
|
||||||
}).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleModule(index) {
|
async function openLesson(lessonId, focusExerciseId = '') {
|
||||||
const el = document.getElementById(`module-${index}`);
|
const response = await fetch('/api/lesson?id=' + encodeURIComponent(lessonId));
|
||||||
el.style.display = el.style.display === 'none' ? 'block' : 'block';
|
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 openLesson(moduleId, lessonId) {
|
function renderLesson() {
|
||||||
const module = COURSE.modules.find(m => m.id === moduleId);
|
const lesson = state.lesson;
|
||||||
const lesson = module.lessons.find(l => l.id === lessonId);
|
if (!lesson) return;
|
||||||
currentLesson = { ...lesson, moduleTitle: module.title, moduleSummary: module.summary };
|
document.getElementById('moduleLabel').textContent = lesson.display_module_title || 'Current lesson';
|
||||||
renderCourseNav();
|
document.getElementById('lessonTitle').textContent = lesson.display_title || 'Linux lesson';
|
||||||
renderLesson(currentLesson);
|
document.getElementById('lessonGoal').textContent = lesson.display_goal || '';
|
||||||
}
|
document.getElementById('moduleSummary').textContent = lesson.display_module_summary || '';
|
||||||
|
document.getElementById('lessonWhy').textContent = lesson.display_why || '';
|
||||||
function renderLesson(lesson) {
|
document.getElementById('classicView').textContent = lesson.classic_view || '';
|
||||||
document.getElementById('heroPanel').style.display = 'none';
|
|
||||||
document.getElementById('lessonShell').classList.add('show');
|
|
||||||
document.getElementById('moduleBadge').textContent = lesson.moduleTitle;
|
|
||||||
document.getElementById('commandBadge').textContent = lesson.command || '综合命令';
|
|
||||||
document.getElementById('lessonTitle').textContent = lesson.title;
|
|
||||||
document.getElementById('lessonSummary').textContent = lesson.moduleSummary || '';
|
|
||||||
document.getElementById('lessonGoal').textContent = lesson.goal || '';
|
|
||||||
document.getElementById('lessonWhy').textContent = lesson.why_it_matters || '';
|
|
||||||
renderList('conceptList', lesson.concepts || []);
|
|
||||||
renderList('pitfallList', lesson.pitfalls || []);
|
|
||||||
renderList('scenarioList', lesson.scenarios || []);
|
|
||||||
renderList('flowList', lesson.troubleshooting_flow || []);
|
|
||||||
renderList('takeawayList', lesson.takeaways || []);
|
|
||||||
document.getElementById('classicView').textContent = lesson.classic_view || '教材视角:先理解问题,再选择命令。';
|
|
||||||
document.getElementById('afterClass').textContent = lesson.after_class || '';
|
document.getElementById('afterClass').textContent = lesson.after_class || '';
|
||||||
document.getElementById('relatedCommands').innerHTML = (lesson.related_commands || []).map(cmd => `<span class="badge">${escapeHtml(cmd)}</span>`).join('');
|
document.getElementById('prevBtn').disabled = !lesson.previous_lesson;
|
||||||
document.getElementById('exampleList').innerHTML = (lesson.examples || []).map(ex => `<div class="code-block">${escapeHtml(ex)}</div>`).join('');
|
document.getElementById('nextBtn').disabled = !lesson.next_lesson;
|
||||||
document.getElementById('exerciseList').innerHTML = (lesson.exercises || []).map(ex => renderExercise(ex)).join('');
|
document.getElementById('lessonCommands').innerHTML = (lesson.command_tokens || [lesson.command || 'mixed commands']).map((item) => `<span class="chip">${escapeHtml(item)}</span>`).join('');
|
||||||
document.getElementById('asideHint').textContent = (lesson.pitfalls || [])[0] || '这一课没有额外提示。';
|
renderList('conceptList', lesson.concepts, 'No concept notes yet.');
|
||||||
document.getElementById('qaBox').innerHTML = buildQaBox(lesson.exercises || []);
|
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(targetId, list) {
|
function renderList(id, items, emptyText) {
|
||||||
const el = document.getElementById(targetId);
|
const target = document.getElementById(id);
|
||||||
el.innerHTML = list.map(item => `<li>${escapeHtml(item)}</li>`).join('');
|
target.innerHTML = (items && items.length) ? items.map((item) => `<li>${escapeHtml(item)}</li>`).join('') : `<li>${escapeHtml(emptyText)}</li>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderExercise(ex) {
|
function renderExercises(exercises) {
|
||||||
const typeLabel = ex.type === 'operation' ? '操作练习' : ex.type === 'understanding' ? '理解题' : '场景题';
|
const container = document.getElementById('exerciseList');
|
||||||
const body = ex.type === 'operation'
|
if (!exercises.length) {
|
||||||
? `
|
container.innerHTML = '<div class="empty">This lesson does not have exercises yet.</div>';
|
||||||
<div style="font-weight:700; margin-bottom:8px;">${ex.title || '练习'}</div>
|
return;
|
||||||
<div class="muted" style="line-height:1.8; margin-bottom:10px;">${ex.hint || '请完成对应命令。'}</div>
|
}
|
||||||
<div class="terminal-box">
|
container.innerHTML = exercises.map((exercise) => {
|
||||||
<div class="terminal-header">终端练习区</div>
|
const label = exercise.type === 'operation' ? 'Operation task' : exercise.type === 'scenario' ? 'Scenario reflection' : 'Understanding task';
|
||||||
<div class="terminal-input-row">
|
if (exercise.type === 'operation') {
|
||||||
<span class="prompt">$</span>
|
const placeholder = exercise.solution?.[0] || state.lesson.command || 'pwd';
|
||||||
<input class="cmd-input" id="input-${ex.id}" placeholder="输入命令,例如 ${ex.solution ? ex.solution[0] : 'pwd'}" onkeypress="if(event.key==='Enter') runExercise('${ex.id}')" />
|
|
||||||
<button class="btn btn-primary" onclick="runExercise('${ex.id}')">执行</button>
|
|
||||||
</div>
|
|
||||||
<div class="terminal-output" id="output-${ex.id}">等待输入命令...</div>
|
|
||||||
</div>
|
|
||||||
<div class="feedback" id="feedback-${ex.id}"></div>
|
|
||||||
`
|
|
||||||
: `
|
|
||||||
<div style="font-weight:700; margin-bottom:8px;">${ex.question || '理解题'}</div>
|
|
||||||
<div class="qa-box">参考答案方向:${escapeHtml(ex.answer || '请结合本课内容自行总结')}</div>
|
|
||||||
`;
|
|
||||||
return `
|
return `
|
||||||
<div class="exercise-card">
|
<article class="exercise" id="exercise-${exercise.id}">
|
||||||
<div class="exercise-type">${typeLabel}</div>
|
<span class="badge">${label}</span>
|
||||||
${body}
|
<h4>${escapeHtml(exercise.title || 'Operation task')}</h4>
|
||||||
</div>`;
|
<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 `
|
||||||
function buildQaBox(exercises) {
|
<article class="exercise" id="exercise-${exercise.id}">
|
||||||
const textItems = exercises.filter(ex => ex.type !== 'operation');
|
<span class="badge">${label}</span>
|
||||||
if (!textItems.length) return '本课暂无理解型问题。';
|
<h4>${escapeHtml(exercise.question || 'Reflection task')}</h4>
|
||||||
return textItems.map(ex => `<div class="qa-box"><strong>${escapeHtml(ex.question || '问题')}</strong><br/>${escapeHtml(ex.answer || '')}</div>`).join('');
|
<p>${escapeHtml(exercise.answer || 'Summarize the purpose, output, and next step in your own words.')}</p>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runExercise(exerciseId) {
|
async function runExercise(exerciseId) {
|
||||||
const input = document.getElementById(`input-${exerciseId}`);
|
const input = document.getElementById('input-' + exerciseId);
|
||||||
const output = document.getElementById(`output-${exerciseId}`);
|
const output = document.getElementById('output-' + exerciseId);
|
||||||
const feedback = document.getElementById(`feedback-${exerciseId}`);
|
const feedback = document.getElementById('feedback-' + exerciseId);
|
||||||
const cmd = input.value.trim();
|
const cmd = input.value.trim();
|
||||||
if (!cmd) return;
|
if (!cmd) return;
|
||||||
output.textContent = `$ ${cmd}\n\n执行中...`;
|
output.textContent = `$ ${cmd}\n\nRunning...`;
|
||||||
feedback.className = 'feedback';
|
feedback.className = 'feedback';
|
||||||
feedback.textContent = '';
|
feedback.textContent = '';
|
||||||
try {
|
try {
|
||||||
const runRes = await fetch('/api/run?cmd=' + encodeURIComponent(cmd));
|
const runResponse = await fetch('/api/run?cmd=' + encodeURIComponent(cmd));
|
||||||
const runData = await runRes.json();
|
const runData = await runResponse.json();
|
||||||
output.textContent = `$ ${cmd}\n\n${runData.output || runData.message || '(无输出)'}`;
|
output.textContent = `$ ${cmd}\n\n${runData.output || runData.message || '(no output)'}`;
|
||||||
const checkRes = await fetch(`/api/check?exercise_id=${encodeURIComponent(exerciseId)}&last_cmd=${encodeURIComponent(cmd)}&output=${encodeURIComponent(runData.output || '')}`);
|
renderRuntime({ cwd: runData.cwd || '/', user: state.overview?.runtime?.user || 'sandbox_user' });
|
||||||
const checkData = await checkRes.json();
|
const checkResponse = await fetch('/api/check?exercise_id=' + encodeURIComponent(exerciseId) + '&last_cmd=' + encodeURIComponent(cmd) + '&output=' + encodeURIComponent(runData.output || ''));
|
||||||
feedback.className = `feedback show ${checkData.success ? 'success' : 'warn'}`;
|
const checkData = await checkResponse.json();
|
||||||
feedback.textContent = checkData.message + (checkData.next_suggestion ? `\n${checkData.next_suggestion}` : '');
|
feedback.className = 'feedback show ' + (checkData.success ? 'success' : 'warn');
|
||||||
} catch (e) {
|
feedback.textContent = checkData.message + (checkData.next_suggestion ? '\n' + checkData.next_suggestion : '');
|
||||||
output.textContent = `❌ 执行失败:${e.message}`;
|
} catch (error) {
|
||||||
|
output.textContent = `$ ${cmd}\n\nRequest failed: ${error.message}`;
|
||||||
feedback.className = 'feedback show warn';
|
feedback.className = 'feedback show warn';
|
||||||
feedback.textContent = '执行失败,请稍后重试。';
|
feedback.textContent = 'The request failed. Please try again.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetSandbox() {
|
async function resetSandbox() {
|
||||||
await fetch('/api/reset', { method: 'POST' });
|
await fetch('/api/reset', { method: 'POST' });
|
||||||
alert('沙盒环境已重置。');
|
renderRuntime({ cwd: '/', user: 'sandbox_user' });
|
||||||
|
alert('Sandbox reset complete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTheme(theme, save = true) {
|
async function runSearch() {
|
||||||
currentTheme = theme;
|
const query = document.getElementById('searchInput').value.trim();
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
const container = document.getElementById('searchResults');
|
||||||
if (save) localStorage.setItem('linux_course_theme', theme);
|
if (!query) {
|
||||||
}
|
container.innerHTML = '<div class="empty">Search by command, lesson id, or module id.</div>';
|
||||||
|
|
||||||
function openFirstLesson() {
|
|
||||||
const firstModule = COURSE.modules[0];
|
|
||||||
const firstLesson = firstModule.lessons[0];
|
|
||||||
openLesson(firstModule.id, firstLesson.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFirstPracticeLesson() {
|
|
||||||
for (const module of COURSE.modules) {
|
|
||||||
for (const lesson of module.lessons) {
|
|
||||||
if ((lesson.exercises || []).some(ex => ex.type === 'operation')) {
|
|
||||||
openLesson(module.id, lesson.id);
|
|
||||||
return;
|
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 escapeHtml(str) {
|
function setTheme(theme) {
|
||||||
return String(str)
|
state.theme = theme;
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem('linux_lab_theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
.replaceAll('&', '&')
|
.replaceAll('&', '&')
|
||||||
.replaceAll('<', '<')
|
.replaceAll('<', '<')
|
||||||
.replaceAll('>', '>')
|
.replaceAll('>', '>')
|
||||||
@@ -496,13 +595,10 @@
|
|||||||
.replaceAll("'", ''');
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
window.toggleModule = toggleModule;
|
|
||||||
window.openLesson = openLesson;
|
window.openLesson = openLesson;
|
||||||
window.runExercise = runExercise;
|
window.runExercise = runExercise;
|
||||||
|
window.runSearch = runSearch;
|
||||||
window.resetSandbox = resetSandbox;
|
window.resetSandbox = resetSandbox;
|
||||||
window.setTheme = setTheme;
|
|
||||||
window.openFirstLesson = openFirstLesson;
|
|
||||||
window.openFirstPracticeLesson = openFirstPracticeLesson;
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
109
privacy.html
109
privacy.html
@@ -1,67 +1,92 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Bot 隐私政策 / Privacy Policy</title>
|
<title>Linux Learning Lab Privacy Notice</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; line-height: 1.6; margin: 0; padding: 24px; color: #111; }
|
:root {
|
||||||
main { max-width: 860px; margin: 0 auto; }
|
--bg: #f4f7fb;
|
||||||
h1,h2 { line-height: 1.25; }
|
--panel: #ffffff;
|
||||||
code { background: #f6f8fa; padding: 2px 6px; border-radius: 6px; }
|
--line: #d8e2ee;
|
||||||
.muted { color: #555; }
|
--text: #122238;
|
||||||
ul { padding-left: 18px; }
|
--muted: #5b6f86;
|
||||||
hr { border: 0; border-top: 1px solid #eee; margin: 20px 0; }
|
--brand: #0f6db6;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
|
background: linear-gradient(180deg, #f4f7fb 0%, #eaf0f7 100%);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.85;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: rgba(255,255,255,0.94);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 28px;
|
||||||
|
box-shadow: 0 18px 40px rgba(16, 32, 51, 0.10);
|
||||||
|
}
|
||||||
|
h1, h2 { margin-top: 0; }
|
||||||
|
h1 { font-size: 30px; margin-bottom: 8px; }
|
||||||
|
h2 { margin: 26px 0 10px; font-size: 20px; }
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
ul { margin: 0; padding-left: 20px; }
|
||||||
|
code {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eef5ff;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>Bot 隐私政策 / Privacy Policy</h1>
|
<div class="eyebrow">Privacy Notice</div>
|
||||||
<p class="muted">最后更新:2026-03-10</p>
|
<h1>Linux Learning Lab Privacy Notice</h1>
|
||||||
|
<p class="muted">Last updated: 2026-03-18</p>
|
||||||
|
|
||||||
<h2>1. 我们是谁</h2>
|
<p>This page applies to the Linux Learning Lab web interface, lesson search endpoints, and sandbox command simulation features. The platform is designed to teach Linux command usage, troubleshooting flow, and common operations scenarios.</p>
|
||||||
<p>本隐私政策适用于与本 Telegram Bot(以下简称“Bot”)的交互。本 Bot 用于提供个人助理与技术学习/排障相关的对话服务。</p>
|
|
||||||
|
|
||||||
<h2>2. 我们收集哪些数据</h2>
|
<h2>1. What data the platform processes</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><b>你发送的消息内容</b>(文本、命令、你主动提供的上下文)。</li>
|
<li>Search queries, login details, and commands that you intentionally submit.</li>
|
||||||
<li><b>基础元数据</b>(如 Telegram 提供的 chat_id、消息时间戳、用户名/昵称等,用于路由回复与防滥用)。</li>
|
<li>Sandbox runtime state used to return a response, such as current directory, current user, and simulated command output.</li>
|
||||||
<li><b>运行日志</b>:为排障与稳定性,系统可能记录错误日志与请求失败信息(可能包含部分消息片段或上下文)。</li>
|
<li>Minimal operational logs used for debugging and service reliability.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>3. 数据如何被使用</h2>
|
<h2>2. Why the data is used</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>用于生成回复、执行你请求的任务(例如课程内容生成、命令模拟、排障建议)。</li>
|
<li>To render lesson content, search results, module summaries, and guided exercises.</li>
|
||||||
<li>用于安全控制与反滥用(例如鉴权、速率限制)。</li>
|
<li>To execute the controlled Linux sandbox simulation and return exercise feedback.</li>
|
||||||
<li>用于系统稳定性与问题定位(例如网络错误、API 调用失败)。</li>
|
<li>To protect remote endpoints with authentication and basic abuse prevention.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>4. 数据共享与第三方</h2>
|
<h2>3. Sandbox and safety boundary</h2>
|
||||||
<p>Bot 运行依赖以下第三方服务,数据会按功能需要流转:</p>
|
<p>The command interface is a controlled simulation, not a direct pass-through to a real operating system. The platform blocks clearly unsafe inputs such as destructive shell patterns. Even so, the sandbox endpoints such as <code>/api/run</code> should only be used for learning and testing purposes.</p>
|
||||||
<ul>
|
|
||||||
<li><b>Telegram</b>:消息收发与基础元数据由 Telegram 处理。</li>
|
|
||||||
<li><b>模型/推理服务提供商</b>:为生成回复,消息内容可能会被发送到大模型推理服务(以完成你的请求)。</li>
|
|
||||||
<li><b>托管/网络服务</b>:用于运行 Bot 与相关服务的基础设施提供商可能处理网络请求与日志。</li>
|
|
||||||
</ul>
|
|
||||||
<p>我们不会将你的个人数据出售给任何第三方。</p>
|
|
||||||
|
|
||||||
<h2>5. 数据保留</h2>
|
<h2>4. Retention and sharing</h2>
|
||||||
<p>我们会在实现功能与保障稳定性的必要范围内保留数据(例如对话上下文、课程数据、运行日志)。保留时间可能因用途不同而不同。</p>
|
<p>The platform does not sell personal data. Runtime logs may be kept for a limited period to support debugging and service maintenance. If the platform is hosted behind a proxy or third-party provider, that infrastructure may also process request metadata such as IP address, timestamp, and path.</p>
|
||||||
|
|
||||||
<h2>6. 你的权利</h2>
|
<h2>5. Your controls</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>你可以随时停止使用 Bot。</li>
|
<li>You can stop using the platform at any time.</li>
|
||||||
<li>你可以请求导出或删除与你相关的本地存储数据(在技术可行范围内)。</li>
|
<li>You can reset the sandbox to clear the current simulated file system state.</li>
|
||||||
|
<li>If you need local data removed, contact the current deployer or repository maintainer.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>7. 安全</h2>
|
<h2>6. Contact and maintenance</h2>
|
||||||
<p>我们采取合理的技术措施保护数据(例如服务访问控制、最小权限原则)。但任何系统都无法保证绝对安全。</p>
|
<p>If you have questions about privacy, logging, or sandbox safety, contact the current service owner. For self-hosted deployments, review the web server, proxy, and hosting platform logging settings alongside this application.</p>
|
||||||
|
|
||||||
<h2>8. 联系方式</h2>
|
|
||||||
<p>如需数据删除/导出或有隐私问题,请通过你与 Bot 的对话渠道联系管理员。</p>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
<p class="muted">English summary: We process the messages you send to provide replies and task automation. Basic Telegram metadata and operational logs may be stored for reliability and debugging. Data may be transmitted to Telegram and model providers as needed to generate responses. We do not sell personal data. You may request deletion/export where feasible.</p>
|
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
583
server.py
583
server.py
@@ -1,9 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""Linux learning lab HTTP server."""
|
||||||
Linux 学习平台 Server(知识导向版)
|
|
||||||
- 提供课程结构、练习判题、沙盒执行
|
|
||||||
- 课程模型:module -> lesson -> exercise
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -11,6 +7,7 @@ import hashlib
|
|||||||
import http.server
|
import http.server
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -25,19 +22,183 @@ HTML_FILE = os.path.join(os.path.dirname(__file__), "index.html")
|
|||||||
PRIVACY_FILE = os.path.join(os.path.dirname(__file__), "privacy.html")
|
PRIVACY_FILE = os.path.join(os.path.dirname(__file__), "privacy.html")
|
||||||
SANDBOX = LinuxSandbox()
|
SANDBOX = LinuxSandbox()
|
||||||
|
|
||||||
|
PUBLIC_GET_PATHS = {
|
||||||
|
"/",
|
||||||
|
"/privacy",
|
||||||
|
"/privacy.html",
|
||||||
|
"/api/course",
|
||||||
|
"/api/course/search",
|
||||||
|
"/api/health",
|
||||||
|
"/api/lesson",
|
||||||
|
"/api/overview",
|
||||||
|
}
|
||||||
|
PUBLIC_POST_PATHS = {
|
||||||
|
"/api/login",
|
||||||
|
}
|
||||||
|
SAFE_REMOTE_HOST = "xiaoxiaoluohao.indevs.in"
|
||||||
|
|
||||||
|
|
||||||
def load_course() -> dict[str, Any]:
|
def load_course() -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
with open(TASKS_FILE, "r", encoding="utf-8") as f:
|
with open(TASKS_FILE, "r", encoding="utf-8") as file:
|
||||||
return json.load(f)
|
return json.load(file)
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
print(f"Error loading course: {e}")
|
print(f"Error loading course: {exc}")
|
||||||
return {"meta": {}, "modules": []}
|
return {"meta": {}, "modules": []}
|
||||||
|
|
||||||
|
|
||||||
COURSE = load_course()
|
COURSE = load_course()
|
||||||
|
|
||||||
|
|
||||||
|
def looks_garbled(value: Any) -> bool:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
return False
|
||||||
|
if "\ufffd" in value:
|
||||||
|
return True
|
||||||
|
asciiish = sum(ch.isascii() and (ch.isalnum() or ch in " -_/.,:;`()[]{}'\"") for ch in value)
|
||||||
|
non_ascii = sum(not ch.isascii() for ch in value)
|
||||||
|
return non_ascii >= 3 and non_ascii > asciiish
|
||||||
|
|
||||||
|
|
||||||
|
def safe_text(value: Any, fallback: str) -> str:
|
||||||
|
if isinstance(value, str) and value.strip() and not looks_garbled(value):
|
||||||
|
return value.strip()
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def safe_list(values: Any, fallback: list[str]) -> list[str]:
|
||||||
|
if not isinstance(values, list) or not values:
|
||||||
|
return fallback
|
||||||
|
cleaned = [str(item).strip() for item in values if str(item).strip()]
|
||||||
|
if not cleaned:
|
||||||
|
return fallback
|
||||||
|
garbled = sum(1 for item in cleaned if looks_garbled(item))
|
||||||
|
if garbled >= max(1, len(cleaned) // 2):
|
||||||
|
return fallback
|
||||||
|
merged: list[str] = []
|
||||||
|
for index, item in enumerate(cleaned):
|
||||||
|
if looks_garbled(item):
|
||||||
|
if index < len(fallback):
|
||||||
|
merged.append(fallback[index])
|
||||||
|
continue
|
||||||
|
merged.append(item)
|
||||||
|
return merged or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def split_commands(command: str | None, lesson_id: str | None = None) -> list[str]:
|
||||||
|
if command:
|
||||||
|
tokens = [part.strip() for part in re.split(r"\s*/\s*|\s*,\s*", command) if part.strip()]
|
||||||
|
if tokens:
|
||||||
|
return tokens
|
||||||
|
if lesson_id:
|
||||||
|
tail = lesson_id.split("_")[-1].strip()
|
||||||
|
if tail:
|
||||||
|
return [tail]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def command_label(commands: list[str]) -> str:
|
||||||
|
if not commands:
|
||||||
|
return "mixed commands"
|
||||||
|
if len(commands) == 1:
|
||||||
|
return commands[0]
|
||||||
|
return " / ".join(commands[:3])
|
||||||
|
|
||||||
|
|
||||||
|
def module_number(module_id: str | None) -> str | None:
|
||||||
|
if not module_id:
|
||||||
|
return None
|
||||||
|
match = re.search(r"(\d+)", module_id)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def module_title(module: dict[str, Any]) -> str:
|
||||||
|
raw = module.get("title")
|
||||||
|
fallback = f"Module {module_number(module.get('id'))}" if module_number(module.get("id")) else "Course module"
|
||||||
|
return safe_text(raw, fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def module_summary(module: dict[str, Any]) -> str:
|
||||||
|
fallback = "Learn command purpose, expected output, and how each step fits into a Linux troubleshooting flow."
|
||||||
|
return safe_text(module.get("summary"), fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def lesson_title(lesson: dict[str, Any]) -> str:
|
||||||
|
commands = split_commands(lesson.get("command"), lesson.get("id"))
|
||||||
|
fallback = f"Learn {command_label(commands)}"
|
||||||
|
return safe_text(lesson.get("title"), fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def default_goal(commands: list[str]) -> str:
|
||||||
|
return f"Understand what {command_label(commands)} does, how to run it, and how to read the result."
|
||||||
|
|
||||||
|
|
||||||
|
def default_why(commands: list[str]) -> str:
|
||||||
|
return f"In day-to-day operations work, {command_label(commands)} helps you confirm the current state before taking the next step."
|
||||||
|
|
||||||
|
|
||||||
|
def default_concepts(commands: list[str]) -> list[str]:
|
||||||
|
label = command_label(commands)
|
||||||
|
return [
|
||||||
|
f"What problem {label} solves",
|
||||||
|
"Typical syntax and common options",
|
||||||
|
"How to verify the output",
|
||||||
|
"How it fits into a troubleshooting chain",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def default_pitfalls(commands: list[str]) -> list[str]:
|
||||||
|
label = command_label(commands)
|
||||||
|
return [
|
||||||
|
f"Running {label} without checking the context first",
|
||||||
|
"Reading the output but not validating the target state",
|
||||||
|
"Ignoring current directory, permissions, or file state",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def default_scenarios(commands: list[str]) -> list[str]:
|
||||||
|
label = command_label(commands)
|
||||||
|
return [
|
||||||
|
f"Use {label} to inspect a host before editing files or services",
|
||||||
|
"Confirm current state before and after a change",
|
||||||
|
"Connect one command to the next action in a real incident flow",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def default_flow(commands: list[str]) -> list[str]:
|
||||||
|
label = command_label(commands)
|
||||||
|
return [
|
||||||
|
"Confirm the goal and current working context",
|
||||||
|
f"Run {label} and inspect the output carefully",
|
||||||
|
"Decide the next command based on what the output shows",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def default_takeaways(commands: list[str]) -> list[str]:
|
||||||
|
label = command_label(commands)
|
||||||
|
return [
|
||||||
|
f"Explain when to use {label} in a real Linux task",
|
||||||
|
"Know what output matters most",
|
||||||
|
"Link the command to the next verification or repair step",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def default_after_class(commands: list[str]) -> str:
|
||||||
|
return f"Repeat this lesson in the sandbox two or three more times and explain every step out loud using {command_label(commands)}."
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_lessons() -> list[dict[str, Any]]:
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for module in COURSE.get("modules", []):
|
||||||
|
for lesson in module.get("lessons", []):
|
||||||
|
item = dict(lesson)
|
||||||
|
item["module_id"] = module.get("id")
|
||||||
|
item["module_title"] = module.get("title")
|
||||||
|
item["module_summary"] = module.get("summary")
|
||||||
|
rows.append(item)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def flatten_exercises() -> list[dict[str, Any]]:
|
def flatten_exercises() -> list[dict[str, Any]]:
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
for module in COURSE.get("modules", []):
|
for module in COURSE.get("modules", []):
|
||||||
@@ -48,24 +209,260 @@ def flatten_exercises() -> list[dict[str, Any]]:
|
|||||||
item["module_title"] = module.get("title")
|
item["module_title"] = module.get("title")
|
||||||
item["lesson_id"] = lesson.get("id")
|
item["lesson_id"] = lesson.get("id")
|
||||||
item["lesson_title"] = lesson.get("title")
|
item["lesson_title"] = lesson.get("title")
|
||||||
item["lesson_goal"] = lesson.get("goal")
|
|
||||||
item["lesson_command"] = lesson.get("command")
|
item["lesson_command"] = lesson.get("command")
|
||||||
rows.append(item)
|
rows.append(item)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def find_exercise(ex_id: str) -> dict[str, Any] | None:
|
def find_lesson(lesson_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||||
for item in flatten_exercises():
|
for module in COURSE.get("modules", []):
|
||||||
if item.get("id") == ex_id:
|
for lesson in module.get("lessons", []):
|
||||||
return item
|
if lesson.get("id") == lesson_id:
|
||||||
|
return module, lesson
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def find_exercise(exercise_id: str) -> dict[str, Any] | None:
|
||||||
|
for exercise in flatten_exercises():
|
||||||
|
if exercise.get("id") == exercise_id:
|
||||||
|
return exercise
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_lesson_preview(module: dict[str, Any], lesson: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
commands = split_commands(lesson.get("command"), lesson.get("id"))
|
||||||
|
return {
|
||||||
|
"id": lesson.get("id"),
|
||||||
|
"display_title": lesson_title(lesson),
|
||||||
|
"display_goal": safe_text(lesson.get("goal"), default_goal(commands)),
|
||||||
|
"command": lesson.get("command") or command_label(commands),
|
||||||
|
"command_tokens": commands,
|
||||||
|
"exercise_count": len(lesson.get("exercises", [])),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_module_preview(module: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
lessons = [build_lesson_preview(module, lesson) for lesson in module.get("lessons", [])]
|
||||||
|
return {
|
||||||
|
"id": module.get("id"),
|
||||||
|
"display_title": module_title(module),
|
||||||
|
"display_summary": module_summary(module),
|
||||||
|
"lesson_count": len(module.get("lessons", [])),
|
||||||
|
"exercise_count": sum(len(lesson.get("exercises", [])) for lesson in module.get("lessons", [])),
|
||||||
|
"lessons": lessons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_exercise(exercise: dict[str, Any], lesson_stub: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
commands = split_commands(lesson_stub.get("command"), lesson_stub.get("id"))
|
||||||
|
label = command_label(commands)
|
||||||
|
first_solution = ""
|
||||||
|
if isinstance(exercise.get("solution"), list) and exercise["solution"]:
|
||||||
|
first_solution = str(exercise["solution"][0]).strip()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"id": exercise.get("id"),
|
||||||
|
"type": exercise.get("type") or "operation",
|
||||||
|
"hint": safe_text(exercise.get("hint"), f"Try {first_solution or label} first, then inspect the result."),
|
||||||
|
"success_msg": safe_text(exercise.get("success_msg"), f"Nice work. You completed a {label} practice task."),
|
||||||
|
"solution": exercise.get("solution", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload["type"] == "operation":
|
||||||
|
payload["title"] = safe_text(exercise.get("title"), f"Run {first_solution or label}")
|
||||||
|
else:
|
||||||
|
payload["question"] = safe_text(
|
||||||
|
exercise.get("question"),
|
||||||
|
f"Explain when you would use {label} in a real Linux operations flow.",
|
||||||
|
)
|
||||||
|
payload["answer"] = safe_text(
|
||||||
|
exercise.get("answer"),
|
||||||
|
f"Focus on purpose, expected output, and the next step after {label}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def build_lesson_payload(module: dict[str, Any], lesson: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
commands = split_commands(lesson.get("command"), lesson.get("id"))
|
||||||
|
all_lessons = flatten_lessons()
|
||||||
|
lesson_ids = [item.get("id") for item in all_lessons]
|
||||||
|
current_id = lesson.get("id")
|
||||||
|
index = lesson_ids.index(current_id) if current_id in lesson_ids else -1
|
||||||
|
|
||||||
|
previous_lesson = None
|
||||||
|
next_lesson = None
|
||||||
|
if index > 0:
|
||||||
|
prev_module, prev_lesson = find_lesson(lesson_ids[index - 1] or "")
|
||||||
|
if prev_module and prev_lesson:
|
||||||
|
previous_lesson = build_lesson_preview(prev_module, prev_lesson)
|
||||||
|
if index >= 0 and index + 1 < len(lesson_ids):
|
||||||
|
next_module, next_lesson_item = find_lesson(lesson_ids[index + 1] or "")
|
||||||
|
if next_module and next_lesson_item:
|
||||||
|
next_lesson = build_lesson_preview(next_module, next_lesson_item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": lesson.get("id"),
|
||||||
|
"display_title": lesson_title(lesson),
|
||||||
|
"display_goal": safe_text(lesson.get("goal"), default_goal(commands)),
|
||||||
|
"display_why": safe_text(lesson.get("why_it_matters"), default_why(commands)),
|
||||||
|
"display_module_title": module_title(module),
|
||||||
|
"display_module_summary": module_summary(module),
|
||||||
|
"command": lesson.get("command") or command_label(commands),
|
||||||
|
"command_tokens": commands,
|
||||||
|
"examples": safe_list(lesson.get("examples"), commands or ["pwd"]),
|
||||||
|
"concepts": safe_list(lesson.get("concepts"), default_concepts(commands)),
|
||||||
|
"pitfalls": safe_list(lesson.get("pitfalls"), default_pitfalls(commands)),
|
||||||
|
"scenarios": safe_list(lesson.get("scenarios"), default_scenarios(commands)),
|
||||||
|
"troubleshooting_flow": safe_list(lesson.get("troubleshooting_flow"), default_flow(commands)),
|
||||||
|
"takeaways": safe_list(lesson.get("takeaways"), default_takeaways(commands)),
|
||||||
|
"classic_view": safe_text(
|
||||||
|
lesson.get("classic_view"),
|
||||||
|
f"Treat {command_label(commands)} as part of a workflow, not as an isolated command to memorize.",
|
||||||
|
),
|
||||||
|
"after_class": safe_text(lesson.get("after_class"), default_after_class(commands)),
|
||||||
|
"related_commands": safe_list(lesson.get("related_commands"), commands or ["pwd"]),
|
||||||
|
"exercises": [build_exercise(exercise, lesson) for exercise in lesson.get("exercises", [])],
|
||||||
|
"exercise_count": len(lesson.get("exercises", [])),
|
||||||
|
"module_id": module.get("id"),
|
||||||
|
"previous_lesson": previous_lesson,
|
||||||
|
"next_lesson": next_lesson,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_overview() -> dict[str, Any]:
|
||||||
|
modules = [build_module_preview(module) for module in COURSE.get("modules", [])]
|
||||||
|
lessons = flatten_lessons()
|
||||||
|
commands = sorted(
|
||||||
|
{
|
||||||
|
command
|
||||||
|
for lesson in lessons
|
||||||
|
for command in split_commands(lesson.get("command"), lesson.get("id"))
|
||||||
|
if command
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exercise_count = sum(len(lesson.get("exercises", [])) for lesson in lessons)
|
||||||
|
meta = COURSE.get("meta", {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"version": meta.get("version", "4.0"),
|
||||||
|
"title": safe_text(meta.get("title"), "Linux Learning Lab"),
|
||||||
|
"description": safe_text(
|
||||||
|
meta.get("description"),
|
||||||
|
"Searchable Linux lessons with command previews, sandbox practice, and guided troubleshooting.",
|
||||||
|
),
|
||||||
|
"updated": meta.get("updated", ""),
|
||||||
|
"module_count": meta.get("module_count", len(modules)),
|
||||||
|
"lesson_count": meta.get("total_lessons", len(lessons)),
|
||||||
|
"exercise_count": meta.get("total_exercises", exercise_count),
|
||||||
|
"command_count": len(commands),
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"cwd": SANDBOX.cwd,
|
||||||
|
"user": SANDBOX.user,
|
||||||
|
},
|
||||||
|
"modules": modules,
|
||||||
|
"commands": commands[:24],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def search_course(query: str) -> list[dict[str, Any]]:
|
||||||
|
needle = query.strip().lower()
|
||||||
|
if not needle:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for module in COURSE.get("modules", []):
|
||||||
|
module_view = build_module_preview(module)
|
||||||
|
module_score = 0
|
||||||
|
module_id = str(module.get("id", "")).lower()
|
||||||
|
if needle == module_id:
|
||||||
|
module_score += 120
|
||||||
|
elif needle in module_id:
|
||||||
|
module_score += 80
|
||||||
|
if needle in module_view["display_title"].lower():
|
||||||
|
module_score += 60
|
||||||
|
if needle in module_view["display_summary"].lower():
|
||||||
|
module_score += 40
|
||||||
|
if module_score and module.get("lessons"):
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"type": "module",
|
||||||
|
"title": module_view["display_title"],
|
||||||
|
"subtitle": module_view["display_summary"],
|
||||||
|
"module_id": module.get("id"),
|
||||||
|
"lesson_id": module.get("lessons", [{}])[0].get("id"),
|
||||||
|
"score": module_score,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for lesson in module.get("lessons", []):
|
||||||
|
lesson_view = build_lesson_preview(module, lesson)
|
||||||
|
lesson_score = 0
|
||||||
|
targets = [
|
||||||
|
str(lesson.get("id", "")).lower(),
|
||||||
|
lesson_view["display_title"].lower(),
|
||||||
|
lesson_view["display_goal"].lower(),
|
||||||
|
str(lesson_view["command"]).lower(),
|
||||||
|
" ".join(token.lower() for token in lesson_view["command_tokens"]),
|
||||||
|
]
|
||||||
|
for text in targets:
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if needle == text:
|
||||||
|
lesson_score += 110
|
||||||
|
elif needle in text:
|
||||||
|
lesson_score += 55
|
||||||
|
if lesson_score:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"type": "lesson",
|
||||||
|
"title": lesson_view["display_title"],
|
||||||
|
"subtitle": f"{module_view['display_title']} | {command_label(lesson_view['command_tokens'])}",
|
||||||
|
"module_id": module.get("id"),
|
||||||
|
"lesson_id": lesson.get("id"),
|
||||||
|
"score": lesson_score,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for exercise in lesson.get("exercises", []):
|
||||||
|
exercise_view = build_exercise(exercise, lesson)
|
||||||
|
exercise_score = 0
|
||||||
|
for text in [
|
||||||
|
str(exercise.get("id", "")).lower(),
|
||||||
|
str(exercise_view.get("title", "")).lower(),
|
||||||
|
str(exercise_view.get("question", "")).lower(),
|
||||||
|
str(exercise_view.get("hint", "")).lower(),
|
||||||
|
]:
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if needle == text:
|
||||||
|
exercise_score += 90
|
||||||
|
elif needle in text:
|
||||||
|
exercise_score += 40
|
||||||
|
if exercise_score:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"type": "exercise",
|
||||||
|
"title": exercise_view.get("title") or exercise_view.get("question") or exercise.get("id", ""),
|
||||||
|
"subtitle": f"{lesson_view['display_title']} | {exercise.get('id', '')}",
|
||||||
|
"module_id": module.get("id"),
|
||||||
|
"lesson_id": lesson.get("id"),
|
||||||
|
"exercise_id": exercise.get("id"),
|
||||||
|
"score": exercise_score,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ranked = sorted(results, key=lambda item: (-item["score"], item["title"]))
|
||||||
|
return [{key: value for key, value in item.items() if key != "score"} for item in ranked[:24]]
|
||||||
|
|
||||||
|
|
||||||
class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
||||||
def log_message(self, format, *args):
|
def log_message(self, format, *args):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def send_json(self, data: Any, status=200):
|
def send_json(self, data: Any, status: int = 200):
|
||||||
raw = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
raw = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
@@ -74,14 +471,21 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(raw)
|
self.wfile.write(raw)
|
||||||
|
|
||||||
def send_file(self, path: str, content_type: str):
|
def send_file(self, path: str, content_type: str):
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as file:
|
||||||
body = f.read()
|
body = file.read()
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", content_type)
|
self.send_header("Content-Type", content_type)
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def is_public_path(self, path: str, method: str) -> bool:
|
||||||
|
if method == "GET":
|
||||||
|
return path in PUBLIC_GET_PATHS
|
||||||
|
if method == "POST":
|
||||||
|
return path in PUBLIC_POST_PATHS
|
||||||
|
return False
|
||||||
|
|
||||||
def check_auth(self, auth_header: str, token: str) -> bool:
|
def check_auth(self, auth_header: str, token: str) -> bool:
|
||||||
if self.client_address[0] == "127.0.0.1":
|
if self.client_address[0] == "127.0.0.1":
|
||||||
return True
|
return True
|
||||||
@@ -91,15 +495,22 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def require_auth_if_needed(self, path: str, method: str) -> bool:
|
||||||
|
if self.is_public_path(path, method):
|
||||||
|
return True
|
||||||
|
host = self.headers.get("Host", "")
|
||||||
|
auth_header = self.headers.get("Authorization", "")
|
||||||
|
token = self.headers.get("X-Token", "")
|
||||||
|
if SAFE_REMOTE_HOST in host and not self.check_auth(auth_header, token):
|
||||||
|
self.send_json({"error": "Authentication required"}, 401)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
parsed = urllib.parse.urlparse(self.path)
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
path = parsed.path
|
path = parsed.path
|
||||||
|
|
||||||
if path not in ["/", "/privacy", "/privacy.html", "/api/login", "/api/logout", "/api/course", "/api/health"]:
|
if not self.require_auth_if_needed(path, "GET"):
|
||||||
auth_header = self.headers.get("Authorization", "")
|
|
||||||
token = self.headers.get("X-Token", "")
|
|
||||||
if not self.check_auth(auth_header, token) and "xiaoxiaoluohao.indevs.in" in self.headers.get("Host", ""):
|
|
||||||
self.send_json({"error": "Authentication required"}, 401)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if path == "/":
|
if path == "/":
|
||||||
@@ -111,16 +522,46 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if path == "/api/health":
|
if path == "/api/health":
|
||||||
self.send_json({"ok": True, "cwd": SANDBOX.cwd, "user": SANDBOX.user, "modules": len(COURSE.get("modules", []))})
|
overview = build_overview()
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"cwd": SANDBOX.cwd,
|
||||||
|
"user": SANDBOX.user,
|
||||||
|
"module_count": overview["meta"]["module_count"],
|
||||||
|
"lesson_count": overview["meta"]["lesson_count"],
|
||||||
|
"exercise_count": overview["meta"]["exercise_count"],
|
||||||
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if path == "/api/course":
|
if path == "/api/course":
|
||||||
self.send_json(COURSE)
|
self.send_json(COURSE)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if path == "/api/overview":
|
||||||
|
self.send_json(build_overview())
|
||||||
|
return
|
||||||
|
|
||||||
|
if path == "/api/lesson":
|
||||||
|
lesson_id = urllib.parse.parse_qs(parsed.query).get("id", [""])[0]
|
||||||
|
if not lesson_id:
|
||||||
|
self.send_json({"error": "id required"}, 400)
|
||||||
|
return
|
||||||
|
module, lesson = find_lesson(lesson_id)
|
||||||
|
if not module or not lesson:
|
||||||
|
self.send_json({"error": "Lesson not found"}, 404)
|
||||||
|
return
|
||||||
|
self.send_json({"lesson": build_lesson_payload(module, lesson)})
|
||||||
|
return
|
||||||
|
|
||||||
|
if path == "/api/course/search":
|
||||||
|
query = urllib.parse.parse_qs(parsed.query).get("q", [""])[0]
|
||||||
|
self.send_json({"query": query, "results": search_course(query)})
|
||||||
|
return
|
||||||
|
|
||||||
if path == "/api/run":
|
if path == "/api/run":
|
||||||
query = urllib.parse.parse_qs(parsed.query)
|
cmd = urllib.parse.parse_qs(parsed.query).get("cmd", [""])[0]
|
||||||
cmd = query.get("cmd", [""])[0]
|
|
||||||
if not cmd:
|
if not cmd:
|
||||||
self.send_json({"error": "No command provided"}, 400)
|
self.send_json({"error": "No command provided"}, 400)
|
||||||
return
|
return
|
||||||
@@ -129,16 +570,17 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
if path == "/api/check":
|
if path == "/api/check":
|
||||||
query = urllib.parse.parse_qs(parsed.query)
|
query = urllib.parse.parse_qs(parsed.query)
|
||||||
ex_id = query.get("exercise_id", [""])[0]
|
exercise_id = query.get("exercise_id", [""])[0]
|
||||||
cmd = query.get("last_cmd", [""])[0]
|
cmd = query.get("last_cmd", [""])[0]
|
||||||
output = query.get("output", [""])[0]
|
output = query.get("output", [""])[0]
|
||||||
if not ex_id:
|
if not exercise_id:
|
||||||
self.send_json({"error": "exercise_id required"}, 400)
|
self.send_json({"error": "exercise_id required"}, 400)
|
||||||
return
|
return
|
||||||
exercise = find_exercise(ex_id)
|
exercise = find_exercise(exercise_id)
|
||||||
if not exercise:
|
if not exercise:
|
||||||
self.send_json({"error": "Exercise not found"}, 404)
|
self.send_json({"error": "Exercise not found"}, 404)
|
||||||
return
|
return
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
"cmd": cmd,
|
"cmd": cmd,
|
||||||
"output": output,
|
"output": output,
|
||||||
@@ -147,26 +589,32 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
"is_executable": SANDBOX.is_executable,
|
"is_executable": SANDBOX.is_executable,
|
||||||
}
|
}
|
||||||
success, reason = self.evaluate_exercise(exercise, state)
|
success, reason = self.evaluate_exercise(exercise, state)
|
||||||
self.send_json({
|
lesson_stub = {"command": exercise.get("lesson_command"), "id": exercise.get("lesson_id")}
|
||||||
"exercise_id": ex_id,
|
exercise_view = build_exercise(exercise, lesson_stub)
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"exercise_id": exercise_id,
|
||||||
"success": success,
|
"success": success,
|
||||||
"message": exercise.get("success_msg", "✅ 练习通过") if success else reason,
|
"message": exercise_view["success_msg"] if success else reason,
|
||||||
"hint": exercise.get("hint"),
|
"hint": exercise_view.get("hint"),
|
||||||
"lesson_title": exercise.get("lesson_title"),
|
"lesson_title": safe_text(exercise.get("lesson_title"), lesson_title(lesson_stub)),
|
||||||
"module_title": exercise.get("module_title"),
|
"module_title": safe_text(exercise.get("module_title"), "Course module"),
|
||||||
"next_suggestion": self.build_next_suggestion(ex_id),
|
"next_suggestion": self.build_next_suggestion(exercise_id) if success else None,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.send_response(404)
|
self.send_json({"error": "Not found"}, 404)
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b"Not Found")
|
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
parsed = urllib.parse.urlparse(self.path)
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
path = parsed.path
|
path = parsed.path
|
||||||
|
|
||||||
|
if not self.require_auth_if_needed(path, "POST"):
|
||||||
|
return
|
||||||
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
raw = self.rfile.read(content_length).decode() if content_length else "{}"
|
raw = self.rfile.read(content_length).decode("utf-8") if content_length else "{}"
|
||||||
|
|
||||||
if path == "/api/login":
|
if path == "/api/login":
|
||||||
try:
|
try:
|
||||||
@@ -174,59 +622,66 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.send_json({"success": False, "error": "Invalid JSON"}, 400)
|
self.send_json({"success": False, "error": "Invalid JSON"}, 400)
|
||||||
return
|
return
|
||||||
|
|
||||||
username = data.get("username", "")
|
username = data.get("username", "")
|
||||||
password = data.get("password", "")
|
password = data.get("password", "")
|
||||||
if username in USERS and hashlib.sha256(password.encode()).hexdigest() == USERS[username]:
|
if username in USERS and hashlib.sha256(password.encode("utf-8")).hexdigest() == USERS[username]:
|
||||||
self.send_json({"success": True, "token": "safe_linux_2026", "message": "✅ 登录成功!"})
|
self.send_json({"success": True, "token": "safe_linux_2026", "message": "Login succeeded"})
|
||||||
return
|
return
|
||||||
self.send_json({"success": False, "error": "❌ 用户名或密码错误"}, 401)
|
|
||||||
|
self.send_json({"success": False, "error": "Invalid username or password"}, 401)
|
||||||
return
|
return
|
||||||
|
|
||||||
if path == "/api/logout":
|
if path == "/api/logout":
|
||||||
self.send_json({"success": True, "message": "👋 已退出登录"})
|
self.send_json({"success": True, "message": "Logged out"})
|
||||||
return
|
return
|
||||||
|
|
||||||
if path == "/api/reset":
|
if path == "/api/reset":
|
||||||
SANDBOX.reset()
|
SANDBOX.reset()
|
||||||
self.send_json({"success": True, "message": "♻️ 沙盒环境已重置"})
|
self.send_json({"success": True, "message": "Sandbox reset"})
|
||||||
return
|
return
|
||||||
|
|
||||||
self.send_response(404)
|
self.send_json({"error": "Not found"}, 404)
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(b"Not Found")
|
|
||||||
|
|
||||||
def evaluate_exercise(self, exercise: dict[str, Any], state: dict[str, Any]) -> tuple[bool, str]:
|
def evaluate_exercise(self, exercise: dict[str, Any], state: dict[str, Any]) -> tuple[bool, str]:
|
||||||
ex_type = exercise.get("type")
|
exercise_type = exercise.get("type")
|
||||||
if ex_type in {"understanding", "scenario"}:
|
if exercise_type in {"understanding", "scenario"}:
|
||||||
return False, "📝 这是理解类练习,请先阅读讲解并思考答案。"
|
return False, "This is a reflection task. Read the reference answer, then explain it in your own words."
|
||||||
|
|
||||||
cmd = state["cmd"].strip()
|
cmd = state["cmd"].strip()
|
||||||
if exercise.get("solution"):
|
for solution in exercise.get("solution", []):
|
||||||
for sol in exercise["solution"]:
|
if cmd == str(solution).strip():
|
||||||
if cmd == sol.strip():
|
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
success_test = exercise.get("success_test")
|
success_test = exercise.get("success_test")
|
||||||
if not success_test:
|
if not success_test:
|
||||||
return False, "❌ 暂未命中练习要求"
|
return False, "The command ran, but this task does not have an automated check yet."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ok = bool(eval(success_test, {"__builtins__": {}}, state))
|
ok = bool(eval(success_test, {"__builtins__": {}}, state))
|
||||||
return ok, "❌ 结果还没达到练习要求,再试一次"
|
if ok:
|
||||||
|
return True, ""
|
||||||
|
return False, "The command ran, but the target state has not been reached yet."
|
||||||
except Exception:
|
except Exception:
|
||||||
return False, "❌ 当前命令没有通过判定,建议对照示例重新尝试"
|
return False, "The checker could not validate this attempt. Compare your command with the example and try again."
|
||||||
|
|
||||||
def build_next_suggestion(self, current_ex_id: str) -> str | None:
|
def build_next_suggestion(self, current_exercise_id: str) -> str | None:
|
||||||
rows = flatten_exercises()
|
rows = flatten_exercises()
|
||||||
for i, item in enumerate(rows):
|
for index, item in enumerate(rows):
|
||||||
if item.get("id") == current_ex_id and i + 1 < len(rows):
|
if item.get("id") != current_exercise_id:
|
||||||
nxt = rows[i + 1]
|
continue
|
||||||
return f"继续下一练:{nxt.get('title') or nxt.get('question') or nxt.get('id')}"
|
if index + 1 >= len(rows):
|
||||||
|
return None
|
||||||
|
next_item = rows[index + 1]
|
||||||
|
lesson_stub = {"command": next_item.get("lesson_command"), "id": next_item.get("lesson_id")}
|
||||||
|
exercise_view = build_exercise(next_item, lesson_stub)
|
||||||
|
title = exercise_view.get("title") or exercise_view.get("question") or "the next exercise"
|
||||||
|
return f"Next up: {lesson_title(lesson_stub)} | {title}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
port = 8084
|
port = 8084
|
||||||
print(f"🐧 Linux 学习平台启动中... http://127.0.0.1:{port}")
|
print(f"Linux Learning Lab is running at http://127.0.0.1:{port}")
|
||||||
print("📚 线上地址: https://linux.xiaoxiaoluohao.indevs.in")
|
print("Remote host: https://linux.xiaoxiaoluohao.indevs.in")
|
||||||
http.server.ThreadingHTTPServer(("127.0.0.1", port), LinuxLearningHandler).serve_forever()
|
http.server.ThreadingHTTPServer(("127.0.0.1", port), LinuxLearningHandler).serve_forever()
|
||||||
|
|||||||
Reference in New Issue
Block a user