Compare commits
11 Commits
3cce199fbf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3db89417d7 | ||
|
|
efaa715e93 | ||
|
|
2f7bd50a36 | ||
|
|
d61730bf17 | ||
|
|
d2b667f569 | ||
|
|
f5bcfa2259 | ||
|
|
8c5aafbe8b | ||
|
|
f61376aa8a | ||
|
|
83831b8622 | ||
|
|
9effdee625 | ||
|
|
3f8d4c0ce6 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,3 +5,7 @@ __pycache__/
|
||||
|
||||
# OpenClaw interactive edit backups
|
||||
*.interactive.backup.*
|
||||
|
||||
# Runtime logs
|
||||
server.out.log
|
||||
server.err.log
|
||||
|
||||
2803
COURSE_TASKS.json
2803
COURSE_TASKS.json
File diff suppressed because it is too large
Load Diff
587
index.html
587
index.html
@@ -1,508 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linux 运维全场景学习平台</title>
|
||||
<title>Linux Learning Lab</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--primary: #1677ff;
|
||||
--primary-dark: #0f5ed7;
|
||||
--primary-soft: #eef5ff;
|
||||
--accent: #36a3ff;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--bg: #f5f9ff;
|
||||
--bg-2: #edf4ff;
|
||||
--card: rgba(255,255,255,0.95);
|
||||
--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'] {
|
||||
--bg: #08101d;
|
||||
--bg-2: #101b31;
|
||||
--card: rgba(16, 26, 47, 0.96);
|
||||
--text: #edf4ff;
|
||||
--text-2: #cfdbf4;
|
||||
--text-3: #90a5ca;
|
||||
--border: #1d3358;
|
||||
--shadow: 0 18px 55px rgba(2, 8, 23, 0.42);
|
||||
--primary-soft: rgba(22,119,255,.12);
|
||||
}
|
||||
body {
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
background: linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #0f5ed7 0%, #1677ff 45%, #36a3ff 100%);
|
||||
color: #fff;
|
||||
padding: 18px 24px;
|
||||
box-shadow: 0 12px 35px rgba(22,119,255,.2);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.brand h1 { font-size: 26px; font-weight: 800; }
|
||||
.brand p { margin-top: 6px; opacity: .92; font-size: 13px; }
|
||||
.header-actions { display:flex; gap:10px; flex-wrap:wrap; }
|
||||
.chip-btn {
|
||||
border: none; border-radius: 999px; padding: 10px 15px; cursor: pointer; font-weight: 700;
|
||||
background: rgba(255,255,255,.14); color:#fff;
|
||||
}
|
||||
.chip-btn:hover, .chip-btn.active { background:#fff; color:var(--primary); }
|
||||
.layout {
|
||||
max-width: 1480px; margin: 0 auto; padding: 20px;
|
||||
display: grid; grid-template-columns: 320px minmax(0, 1fr) 340px; gap: 18px;
|
||||
}
|
||||
.card {
|
||||
background: var(--card); border:1px solid var(--border); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow); backdrop-filter: blur(10px);
|
||||
}
|
||||
.sidebar, .content, .aside { padding: 20px; }
|
||||
.title-row { display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:14px; }
|
||||
.title-row h2 { font-size: 18px; font-weight: 800; }
|
||||
.muted { color: var(--text-3); }
|
||||
.course-meta {
|
||||
display:grid; grid-template-columns:repeat(2, 1fr); gap:10px; margin-bottom:16px;
|
||||
}
|
||||
.meta-box {
|
||||
padding: 14px; border-radius: 16px; border:1px solid var(--border);
|
||||
background: linear-gradient(180deg, var(--primary-soft), rgba(255,255,255,.75));
|
||||
}
|
||||
.meta-box .label { font-size:12px; color:var(--text-3); }
|
||||
.meta-box .value { margin-top:6px; font-size:22px; font-weight:800; }
|
||||
.module-item {
|
||||
border:1px solid var(--border); border-radius:18px; margin-bottom:14px; overflow:hidden;
|
||||
background: rgba(255,255,255,.55);
|
||||
}
|
||||
.module-head {
|
||||
padding: 14px 16px; cursor:pointer; display:flex; justify-content:space-between; gap:12px; align-items:center;
|
||||
background: linear-gradient(90deg, #eef6ff 0%, #f8fbff 100%);
|
||||
color: var(--primary-dark); font-weight: 800;
|
||||
}
|
||||
.lesson-list { padding: 12px; }
|
||||
.lesson-item {
|
||||
padding: 12px 14px; border-radius: 14px; cursor:pointer; transition: all .2s ease; margin-bottom:8px;
|
||||
border:1px solid transparent;
|
||||
}
|
||||
.lesson-item:hover { background: var(--primary-soft); }
|
||||
.lesson-item.active {
|
||||
background: linear-gradient(90deg, var(--primary) 0%, var(--accent) 100%); color:#fff;
|
||||
}
|
||||
.lesson-item .name { font-weight: 700; }
|
||||
.lesson-item .desc { font-size: 12px; margin-top: 5px; opacity: .85; }
|
||||
.hero {
|
||||
padding: 24px; border-radius: 24px; border:1px solid #d8e7fb;
|
||||
background: linear-gradient(135deg, rgba(22,119,255,.12), rgba(54,163,255,.08));
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.hero h2 { font-size: 32px; margin-bottom: 10px; }
|
||||
.hero p { line-height: 1.9; color: var(--text-3); }
|
||||
.hero-actions { display:flex; gap:12px; flex-wrap:wrap; margin-top:16px; }
|
||||
.btn {
|
||||
border:none; border-radius:14px; padding: 12px 18px; cursor:pointer; font-weight:800;
|
||||
}
|
||||
.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 {
|
||||
border:1px solid var(--border); border-radius:18px; padding:18px; margin-bottom:14px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.8), rgba(248,251,255,.92));
|
||||
}
|
||||
.panel h3 { font-size:16px; font-weight:800; margin-bottom:12px; color: var(--text-2); }
|
||||
.panel p, .panel li { line-height: 1.9; color: var(--text-2); }
|
||||
.panel ul { padding-left: 18px; }
|
||||
.example-list, .exercise-list { display:flex; flex-direction:column; gap:10px; }
|
||||
.code-block {
|
||||
padding: 12px 14px; border-radius: 14px; background:#0b1220; color:#dbeafe; font-family: Consolas, monospace;
|
||||
overflow:auto;
|
||||
}
|
||||
.exercise-card {
|
||||
border:1px solid var(--border); border-radius:16px; padding:14px; background: rgba(255,255,255,.85);
|
||||
}
|
||||
.exercise-type {
|
||||
display:inline-flex; margin-bottom:8px; padding:4px 8px; border-radius:999px; font-size:12px; font-weight:700;
|
||||
background: #eef4ff; color: var(--primary-dark);
|
||||
}
|
||||
.terminal-box {
|
||||
margin-top: 12px; border-radius: 18px; overflow:hidden; border:1px solid rgba(59,130,246,.18);
|
||||
background: linear-gradient(180deg, var(--terminal) 0%, var(--terminal-soft) 100%);
|
||||
}
|
||||
.terminal-header { padding: 12px 14px; color:#b9d6ff; font-weight:700; border-bottom:1px solid rgba(148,163,184,.15); }
|
||||
.terminal-input-row { display:flex; gap:10px; padding: 14px; align-items:center; }
|
||||
.prompt { color:#4ade80; font-family: Consolas, monospace; font-weight:700; }
|
||||
.cmd-input {
|
||||
flex:1; border:none; outline:none; background:transparent; color:#fff; font-family: Consolas, monospace; font-size:15px;
|
||||
}
|
||||
.terminal-output { padding: 14px; min-height: 120px; white-space: pre-wrap; color:#dbeafe; font-family: Consolas, monospace; }
|
||||
.feedback { display:none; margin-top: 12px; padding: 14px 16px; border-radius: 14px; font-weight:700; }
|
||||
.feedback.show { display:block; }
|
||||
.feedback.success { background: rgba(34,197,94,.1); border:1px solid rgba(34,197,94,.2); color:#15803d; }
|
||||
.feedback.warn { background: rgba(245,158,11,.1); border:1px solid rgba(245,158,11,.2); color:#b45309; }
|
||||
.aside-card {
|
||||
border:1px solid var(--border); border-radius:18px; padding:16px; margin-bottom:14px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.88), rgba(247,250,255,.92));
|
||||
}
|
||||
.aside-card h3 { font-size:15px; font-weight:800; margin-bottom:10px; }
|
||||
.aside-card li { margin-left: 18px; line-height: 1.8; color: var(--text-2); }
|
||||
.qa-box {
|
||||
padding:12px; border-radius:14px; background: var(--primary-soft); margin-top:10px; color:var(--text-2);
|
||||
}
|
||||
@media (max-width: 1240px) {
|
||||
.layout { grid-template-columns: 320px minmax(0, 1fr); }
|
||||
.aside { display:none; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.header-inner { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
:root{--bg:#f4f8fc;--panel:#fff;--line:#d8e2ee;--text:#102033;--muted:#5d7187;--brand:#0f6db6;--soft:#e9f3ff;--terminal:#0e1724}
|
||||
[data-theme="dark"]{--bg:#08111d;--panel:#0c1826;--line:#1d3245;--text:#edf4fb;--muted:#9bb0c3;--brand:#67baff;--soft:rgba(103,186,255,.1);--terminal:#06101c}
|
||||
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font-family:"Segoe UI","Microsoft YaHei",sans-serif}
|
||||
button,input{font:inherit} .shell{max-width:1400px;margin:0 auto;padding:16px} .card{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:16px}
|
||||
.top,.toolbar,.chips,.filters{display:flex;flex-wrap:wrap;gap:10px}.top{justify-content:space-between;align-items:center;margin-bottom:16px}.layout{display:grid;grid-template-columns:320px 1fr;gap:16px}
|
||||
.sidebar,.main{display:flex;flex-direction:column;gap:16px}.stats{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stat{padding:12px;border:1px solid var(--line);border-radius:14px;background:var(--soft)} .stat span{display:block;font-size:12px;color:var(--muted)} .stat strong{font-size:22px}
|
||||
.results,.modules{display:flex;flex-direction:column;gap:10px;max-height:34vh;overflow:auto}.btn,.ghost,.chip,.filter{border-radius:999px;padding:8px 12px;font-weight:700}.btn{border:0;background:linear-gradient(135deg,var(--brand),#37a1ff);color:#fff;cursor:pointer}.ghost,.filter{border:1px solid var(--line);background:transparent;color:var(--text);cursor:pointer}.chip{background:var(--soft);color:var(--brand);display:inline-flex}
|
||||
.filter.active,.lesson.active,.moduleJump.active{background:var(--soft);border-color:rgba(15,109,182,.4);color:var(--brand)} .lesson,.searchBtn{width:100%;text-align:left;border:1px solid var(--line);background:transparent;border-radius:14px;padding:12px;cursor:pointer;color:var(--text)}
|
||||
.search{display:flex;gap:8px}.search input,.terminal input{flex:1;padding:12px;border:1px solid var(--line);border-radius:12px;background:transparent;color:var(--text)} .grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
|
||||
.terminal{margin-top:12px;border:1px solid var(--line);border-radius:14px;background:var(--terminal);overflow:hidden}.terminalHead,.output{color:#d8e8ff;font-family:Consolas,"Courier New",monospace}.terminalHead{padding:10px 12px;border-bottom:1px solid rgba(151,177,201,.16)}.terminalRow{display:flex;gap:8px;padding:12px}.terminalRow button{border:0;border-radius:10px;padding:8px 12px;background:linear-gradient(135deg,var(--brand),#37a1ff);color:#fff;cursor:pointer}.output{margin:0;min-height:96px;padding:12px;white-space:pre-wrap}.note{padding:10px 12px;border:1px solid var(--line);border-radius:12px;color:var(--muted)}
|
||||
h1,h2,h3,p{margin:0}.eyebrow{font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--brand)} .muted{color:var(--muted);line-height:1.8}
|
||||
@media (max-width:1100px){.layout{grid-template-columns:1fr}.grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
<h1>🐧 Linux 运维全场景学习平台</h1>
|
||||
<p>以知识理解为中心,尽量覆盖运维强相关全场景,帮助建立真正可迁移的 Linux 能力。</p>
|
||||
<div class="shell">
|
||||
<div class="card top">
|
||||
<div>
|
||||
<div class="eyebrow" id="topEyebrow"></div>
|
||||
<h1 id="courseTitle"></h1>
|
||||
<p class="muted" id="courseDesc"></p>
|
||||
</div>
|
||||
<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 class="toolbar">
|
||||
<span class="chip" id="sessionUser"></span>
|
||||
<button class="ghost" id="themeBtn"></button>
|
||||
<button class="ghost" id="langBtn"></button>
|
||||
<button class="ghost" id="logoutBtn"></button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="sidebar card">
|
||||
<div class="title-row">
|
||||
<h2>课程地图</h2>
|
||||
<span class="muted" id="courseVersion">v4.0</span>
|
||||
</div>
|
||||
<div class="course-meta">
|
||||
<div class="meta-box"><div class="label">模块数</div><div class="value" id="moduleCount">6</div></div>
|
||||
<div class="meta-box"><div class="label">课时数</div><div class="value" id="lessonCount">18</div></div>
|
||||
<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>
|
||||
</div>
|
||||
<div id="courseNav"></div>
|
||||
</aside>
|
||||
|
||||
<main class="content card">
|
||||
<section class="hero" id="heroPanel">
|
||||
<h2>先建立运维视角,再去学命令</h2>
|
||||
<p>
|
||||
这个版本不再把 Linux 做成“命令闯关页”,而是尽量按运维全场景组织课程:
|
||||
从文件系统、日志、资源、服务、网络、权限,到包管理、自动化、综合排障,逐步建立完整认知。
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<button class="btn btn-primary" onclick="openFirstLesson()">从第一课开始</button>
|
||||
<button class="btn btn-soft" onclick="openFirstPracticeLesson()">直接看第一组练习</button>
|
||||
</div>
|
||||
<aside class="sidebar">
|
||||
<section class="card">
|
||||
<div class="eyebrow" id="overviewEyebrow"></div>
|
||||
<h2 id="overviewTitle"></h2>
|
||||
<div class="stats" id="stats"></div>
|
||||
<div class="chips" id="commandTags" style="margin-top:12px;"></div>
|
||||
<p class="muted" id="metaLine" style="margin-top:12px;"></p>
|
||||
</section>
|
||||
|
||||
<section class="lesson-shell" id="lessonShell">
|
||||
<div class="badge-row">
|
||||
<span class="badge" id="moduleBadge">模块 1</span>
|
||||
<span class="badge" id="commandBadge">命令</span>
|
||||
<section class="card">
|
||||
<div class="eyebrow" id="searchEyebrow"></div>
|
||||
<h2 id="searchTitle"></h2>
|
||||
<div class="search" style="margin-top:12px;">
|
||||
<input id="searchInput" type="text" />
|
||||
<button class="btn" id="searchBtn"></button>
|
||||
</div>
|
||||
<div class="lesson-title" id="lessonTitle">课时标题</div>
|
||||
<p class="muted" id="lessonSummary" style="margin-bottom: 18px;"></p>
|
||||
|
||||
<div class="panel">
|
||||
<h3>这一课学什么</h3>
|
||||
<p id="lessonGoal"></p>
|
||||
<div class="results" id="searchResults" style="margin-top:12px;"></div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<div class="eyebrow" id="modulesEyebrow"></div>
|
||||
<h2 id="modulesTitle"></h2>
|
||||
<div class="modules" id="moduleList" style="margin-top:12px;"></div>
|
||||
</section>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<section class="card">
|
||||
<div class="eyebrow" id="navEyebrow"></div>
|
||||
<h2 id="navTitle"></h2>
|
||||
<p class="muted" id="navText" style="margin-top:8px;"></p>
|
||||
<div class="filters" id="stageFilters" style="margin-top:12px;"></div>
|
||||
<p class="muted" id="stageHint" style="margin-top:12px;"></p>
|
||||
<div class="filters" id="moduleJumps" style="margin-top:12px;"></div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<div class="eyebrow" id="lessonLabel"></div>
|
||||
<h2 id="lessonTitle" style="margin-top:8px;"></h2>
|
||||
<p class="muted" id="lessonGoal" style="margin-top:8px;"></p>
|
||||
<div class="toolbar" style="margin-top:12px;">
|
||||
<button class="ghost" id="masteryBtn"></button>
|
||||
<button class="ghost" id="prevBtn"></button>
|
||||
<button class="btn" id="nextBtn"></button>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>为什么重要</h3>
|
||||
<p id="lessonWhy"></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 class="chips" id="lessonCommands" style="margin-top:12px;"></div>
|
||||
<div class="grid" style="margin-top:16px;">
|
||||
<article class="card"><h3 id="summaryTitle"></h3><p class="muted" id="moduleSummary" style="margin-top:10px;"></p></article>
|
||||
<article class="card"><h3 id="runtimeTitle"></h3><p class="muted" id="runtimeText" style="margin-top:10px;"></p><p class="muted" id="runtimeMeta" style="margin-top:10px;"></p></article>
|
||||
</div>
|
||||
<div class="note" id="masteryNote" style="margin-top:12px;"></div>
|
||||
</section>
|
||||
<section class="card grid">
|
||||
<article class="card"><h3 id="whyTitle"></h3><p class="muted" id="lessonWhy" style="margin-top:10px;"></p></article>
|
||||
<article class="card"><h3 id="relatedTitle"></h3><div class="chips" id="relatedList" style="margin-top:10px;"></div></article>
|
||||
<article class="card"><h3 id="conceptsTitle"></h3><ul id="conceptList" class="muted"></ul></article>
|
||||
<article class="card"><h3 id="flowTitle"></h3><ul id="flowList" class="muted"></ul></article>
|
||||
</section>
|
||||
<section class="card">
|
||||
<div class="eyebrow" id="practiceEyebrow"></div>
|
||||
<h2 id="practiceTitle"></h2>
|
||||
<p class="muted" id="practiceText" style="margin-top:8px;"></p>
|
||||
<div id="exerciseList" style="display:grid;gap:12px;margin-top:12px;"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside class="aside card">
|
||||
<div class="aside-card">
|
||||
<h3>学习建议</h3>
|
||||
<ul>
|
||||
<li>先理解“场景里为什么需要这个命令”,再记参数。</li>
|
||||
<li>不要只学单个命令,要学命令之间如何串成排障链路。</li>
|
||||
<li>目录、日志、资源、服务、网络、权限,是 Linux 运维学习的六大核心场景。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="aside-card">
|
||||
<h3>当前课时提示</h3>
|
||||
<div id="asideHint" class="muted">打开课程后,这里会显示与当前课时相关的补充提醒。</div>
|
||||
</div>
|
||||
<div class="aside-card">
|
||||
<h3>理解型问题</h3>
|
||||
<div id="qaBox" class="muted">选择课时后,这里会显示理解题和场景题,帮助你形成真正的命令认识。</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let COURSE = null;
|
||||
let currentLesson = null;
|
||||
let currentTheme = localStorage.getItem('linux_course_theme') || 'light';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
setTheme(currentTheme, false);
|
||||
await loadCourse();
|
||||
renderCourseNav();
|
||||
});
|
||||
|
||||
async function loadCourse() {
|
||||
const res = await fetch('/api/course');
|
||||
COURSE = await res.json();
|
||||
document.getElementById('courseVersion').textContent = 'v' + (COURSE.meta.version || '4.0');
|
||||
document.getElementById('moduleCount').textContent = COURSE.meta.module_count || COURSE.modules.length;
|
||||
document.getElementById('lessonCount').textContent = COURSE.meta.total_lessons || getAllLessons().length;
|
||||
document.getElementById('exerciseCount').textContent = COURSE.meta.total_exercises || getAllExercises().length;
|
||||
}
|
||||
|
||||
function getAllLessons() {
|
||||
return COURSE.modules.flatMap(module => module.lessons.map(lesson => ({ ...lesson, moduleId: module.id, moduleTitle: module.title, moduleSummary: module.summary })));
|
||||
}
|
||||
|
||||
function getAllExercises() {
|
||||
return getAllLessons().flatMap(lesson => lesson.exercises.map(ex => ({ ...ex, lessonTitle: lesson.title, moduleTitle: lesson.moduleTitle })));
|
||||
}
|
||||
|
||||
function renderCourseNav() {
|
||||
const nav = document.getElementById('courseNav');
|
||||
nav.innerHTML = COURSE.modules.map((module, index) => {
|
||||
const lessonHtml = module.lessons.map(lesson => {
|
||||
const active = currentLesson && currentLesson.id === lesson.id;
|
||||
return `
|
||||
<div class="lesson-item ${active ? 'active' : ''}" onclick="openLesson('${module.id}', '${lesson.id}')">
|
||||
<div class="name">${lesson.title}</div>
|
||||
<div class="desc">${lesson.goal}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="module-item">
|
||||
<div class="module-head" onclick="toggleModule(${index})">
|
||||
<span>${module.title}</span>
|
||||
<span>${module.lessons.length} 课</span>
|
||||
</div>
|
||||
<div class="lesson-list" id="module-${index}">${lessonHtml}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function toggleModule(index) {
|
||||
const el = document.getElementById(`module-${index}`);
|
||||
el.style.display = el.style.display === 'none' ? 'block' : 'block';
|
||||
}
|
||||
|
||||
function openLesson(moduleId, lessonId) {
|
||||
const module = COURSE.modules.find(m => m.id === moduleId);
|
||||
const lesson = module.lessons.find(l => l.id === lessonId);
|
||||
currentLesson = { ...lesson, moduleTitle: module.title, moduleSummary: module.summary };
|
||||
renderCourseNav();
|
||||
renderLesson(currentLesson);
|
||||
}
|
||||
|
||||
function renderLesson(lesson) {
|
||||
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('relatedCommands').innerHTML = (lesson.related_commands || []).map(cmd => `<span class="badge">${escapeHtml(cmd)}</span>`).join('');
|
||||
document.getElementById('exampleList').innerHTML = (lesson.examples || []).map(ex => `<div class="code-block">${escapeHtml(ex)}</div>`).join('');
|
||||
document.getElementById('exerciseList').innerHTML = (lesson.exercises || []).map(ex => renderExercise(ex)).join('');
|
||||
document.getElementById('asideHint').textContent = (lesson.pitfalls || [])[0] || '这一课没有额外提示。';
|
||||
document.getElementById('qaBox').innerHTML = buildQaBox(lesson.exercises || []);
|
||||
}
|
||||
|
||||
function renderList(targetId, list) {
|
||||
const el = document.getElementById(targetId);
|
||||
el.innerHTML = list.map(item => `<li>${escapeHtml(item)}</li>`).join('');
|
||||
}
|
||||
|
||||
function renderExercise(ex) {
|
||||
const typeLabel = ex.type === 'operation' ? '操作练习' : ex.type === 'understanding' ? '理解题' : '场景题';
|
||||
const body = ex.type === 'operation'
|
||||
? `
|
||||
<div style="font-weight:700; margin-bottom:8px;">${ex.title || '练习'}</div>
|
||||
<div class="muted" style="line-height:1.8; margin-bottom:10px;">${ex.hint || '请完成对应命令。'}</div>
|
||||
<div class="terminal-box">
|
||||
<div class="terminal-header">终端练习区</div>
|
||||
<div class="terminal-input-row">
|
||||
<span class="prompt">$</span>
|
||||
<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 `
|
||||
<div class="exercise-card">
|
||||
<div class="exercise-type">${typeLabel}</div>
|
||||
${body}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildQaBox(exercises) {
|
||||
const textItems = exercises.filter(ex => ex.type !== 'operation');
|
||||
if (!textItems.length) return '本课暂无理解型问题。';
|
||||
return textItems.map(ex => `<div class="qa-box"><strong>${escapeHtml(ex.question || '问题')}</strong><br/>${escapeHtml(ex.answer || '')}</div>`).join('');
|
||||
}
|
||||
|
||||
async function runExercise(exerciseId) {
|
||||
const input = document.getElementById(`input-${exerciseId}`);
|
||||
const output = document.getElementById(`output-${exerciseId}`);
|
||||
const feedback = document.getElementById(`feedback-${exerciseId}`);
|
||||
const cmd = input.value.trim();
|
||||
if (!cmd) return;
|
||||
output.textContent = `$ ${cmd}\n\n执行中...`;
|
||||
feedback.className = 'feedback';
|
||||
feedback.textContent = '';
|
||||
try {
|
||||
const runRes = await fetch('/api/run?cmd=' + encodeURIComponent(cmd));
|
||||
const runData = await runRes.json();
|
||||
output.textContent = `$ ${cmd}\n\n${runData.output || runData.message || '(无输出)'}`;
|
||||
const checkRes = await fetch(`/api/check?exercise_id=${encodeURIComponent(exerciseId)}&last_cmd=${encodeURIComponent(cmd)}&output=${encodeURIComponent(runData.output || '')}`);
|
||||
const checkData = await checkRes.json();
|
||||
feedback.className = `feedback show ${checkData.success ? 'success' : 'warn'}`;
|
||||
feedback.textContent = checkData.message + (checkData.next_suggestion ? `\n${checkData.next_suggestion}` : '');
|
||||
} catch (e) {
|
||||
output.textContent = `❌ 执行失败:${e.message}`;
|
||||
feedback.className = 'feedback show warn';
|
||||
feedback.textContent = '执行失败,请稍后重试。';
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSandbox() {
|
||||
await fetch('/api/reset', { method: 'POST' });
|
||||
alert('沙盒环境已重置。');
|
||||
}
|
||||
|
||||
function setTheme(theme, save = true) {
|
||||
currentTheme = theme;
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
if (save) localStorage.setItem('linux_course_theme', theme);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
window.toggleModule = toggleModule;
|
||||
window.openLesson = openLesson;
|
||||
window.runExercise = runExercise;
|
||||
window.resetSandbox = resetSandbox;
|
||||
window.setTheme = setTheme;
|
||||
window.openFirstLesson = openFirstLesson;
|
||||
window.openFirstPracticeLesson = openFirstPracticeLesson;
|
||||
const D=v=>{const e=document.createElement('textarea');e.innerHTML=v;return e.value};const $=id=>document.getElementById(id);const E=v=>String(v).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('\"','"').replaceAll("'",''');
|
||||
const LANG={zh:{top:'Linux 运维学习实验室',title:'Linux 学习实验室',desc:'登录后在沙箱里学命令、做练习、看总览。',session:'当前用户',themeDark:'深色模式',themeLight:'浅色模式',logout:'退出登录',overview:'总览',nav:'课程快速入口',navText:'按阶段或模块快速跳转。',search:'搜索',searchBtn:'搜索',searchPlaceholder:'试试 pwd、grep 或 module_1',modules:'模块',all:'全部阶段',startLabel:'先选一节课开始',startTitle:'Linux 学习驾驶舱',startGoal:'从左侧选择课程,查看目标与练习。',summary:'模块摘要',runtime:'沙箱运行态',runtimeText:'下方可直接执行命令。',mark:'标记已掌握',review:'标记待复习',prev:'上一课',next:'下一课',why:'为什么重要',related:'相关命令',concepts:'核心概念',flow:'排障流程',practice:'练习',practiceText:'操作题直接在模拟终端中运行。',run:'运行',terminal:'Linux 沙箱终端',waiting:'等待输入命令...',empty:'暂无内容。',searchEmpty:'可以按命令名或课程 ID 搜索。',searchLoading:'正在搜索...',searchNo:'没有找到结果。',authExpired:'登录状态已失效,请重新登录。',logoutDone:'已退出登录,请重新登录。',stages:[{key:'all',title:'全部阶段'},{key:'stage-1',title:'阶段 1',range:[0,1]},{key:'stage-2',title:'阶段 2',range:[2,3]},{key:'stage-3',title:'阶段 3',range:[4,4]},{key:'stage-4',title:'阶段 4',range:[5,7]},{key:'stage-5',title:'阶段 5',range:[8,9]}]},en:{top:'Linux Ops Learning Lab',title:'Linux Learning Lab',desc:'Sign in to learn commands, practice in the sandbox, and navigate the course overview.',session:'Current user',themeDark:'Dark mode',themeLight:'Light mode',logout:'Log out',overview:'Overview',nav:'Course quick access',navText:'Jump quickly by stage or module.',search:'Search',searchBtn:'Search',searchPlaceholder:'Try pwd, grep, or module_1',modules:'Modules',all:'All stages',startLabel:'Pick a lesson to begin',startTitle:'Linux learning cockpit',startGoal:'Choose a lesson from the left to inspect goals and exercises.',summary:'Module summary',runtime:'Sandbox runtime',runtimeText:'Run commands directly below.',mark:'Mark mastered',review:'Mark for review',prev:'Previous',next:'Next',why:'Why this matters',related:'Related commands',concepts:'Core ideas',flow:'Troubleshooting flow',practice:'Practice',practiceText:'Operation tasks run inside the simulated terminal.',run:'Run',terminal:'Linux sandbox terminal',waiting:'Waiting for a command...',empty:'No content yet.',searchEmpty:'Search by command or lesson id.',searchLoading:'Searching...',searchNo:'No results found.',authExpired:'Session expired. Please sign in again.',logoutDone:'Logged out. Please sign in again.',stages:[{key:'all',title:'All stages'},{key:'stage-1',title:'Stage 1',range:[0,1]},{key:'stage-2',title:'Stage 2',range:[2,3]},{key:'stage-3',title:'Stage 3',range:[4,4]},{key:'stage-4',title:'Stage 4',range:[5,7]},{key:'stage-5',title:'Stage 5',range:[8,9]}]}};
|
||||
const state={overview:null,lesson:null,language:localStorage.getItem('linux_lab_language')||'zh',theme:localStorage.getItem('linux_lab_theme')||'light',progress:JSON.parse(localStorage.getItem('linux_lab_progress')||'{}'),user:'',stage:'all'}; const T=()=>LANG[state.language]; const tx=k=>D(T()[k]||'');
|
||||
const api=async(u,o={})=>{const r=await fetch(u,{credentials:'same-origin',...o}); if(r.status===401){localStorage.setItem('linux_lab_login_notice',tx('authExpired')); location.href='/login.html'; throw new Error(tx('authExpired'));} return r}; const save=()=>localStorage.setItem('linux_lab_progress',JSON.stringify(state.progress)); const mastered=id=>Boolean(state.progress[id]); const tokens=t=>Array.isArray(t?.command_tokens)&&t.command_tokens.length?t.command_tokens.filter(Boolean):String(t?.command||'').split('/').map(s=>s.trim()).filter(Boolean); const label=t=>tokens(t).join(' / ')||'command';
|
||||
const buildStageGroups=()=>{const modules=state.overview?.modules||[];const stageCount=Math.max(1,Math.min(5,Math.ceil(modules.length/2)));const chunkSize=Math.max(1,Math.ceil(modules.length/stageCount));const allLabel=state.language==='zh'?'全部阶段':'All stages';const stagePrefix=state.language==='zh'?'阶段':'Stage';const groups=[{key:'all',title:allLabel,modules}];for(let i=0;i<stageCount;i+=1){const start=i*chunkSize;const end=Math.min(start+chunkSize,modules.length);if(start>=modules.length)break;groups.push({key:`stage-${i+1}`,title:`${stagePrefix} ${i+1}`,modules:modules.slice(start,end)});}return groups}; const groups=()=>buildStageGroups(); const visibleModules=()=>state.stage==='all'?(state.overview?.modules||[]):(groups().find(g=>g.key===state.stage)?.modules||[]);
|
||||
function setTheme(theme,persist=true){state.theme=theme==='dark'?'dark':'light';document.documentElement.setAttribute('data-theme',state.theme);if(persist)localStorage.setItem('linux_lab_theme',state.theme);$('themeBtn').textContent=state.theme==='light'?tx('themeDark'):tx('themeLight')}
|
||||
function setLanguage(lang,persist=true){state.language=lang==='en'?'en':'zh';if(persist)localStorage.setItem('linux_lab_language',state.language);renderText();if(state.overview)renderOverview();if(state.lesson)renderLesson()}
|
||||
function renderText(){document.title=tx('title');document.documentElement.lang=state.language==='zh'?'zh-CN':'en';$('topEyebrow').textContent=tx('top');$('courseTitle').textContent=tx('title');$('courseDesc').textContent=tx('desc');$('sessionUser').textContent=`${tx('session')}: ${state.user||'admin'}`;$('themeBtn').textContent=state.theme==='light'?tx('themeDark'):tx('themeLight');$('langBtn').textContent=state.language==='zh'?'EN':'\u4E2D\u6587';$('logoutBtn').textContent=tx('logout');$('overviewEyebrow').textContent=tx('overview');$('overviewTitle').textContent=tx('overview');$('searchEyebrow').textContent=tx('search');$('searchTitle').textContent=tx('search');$('searchBtn').textContent=tx('searchBtn');$('searchInput').placeholder=tx('searchPlaceholder');$('modulesEyebrow').textContent=tx('modules');$('modulesTitle').textContent=tx('modules');$('navEyebrow').textContent=tx('nav');$('navTitle').textContent=tx('nav');$('navText').textContent=tx('navText');$('lessonLabel').textContent=tx('startLabel');$('lessonTitle').textContent=tx('startTitle');$('lessonGoal').textContent=tx('startGoal');$('summaryTitle').textContent=tx('summary');$('runtimeTitle').textContent=tx('runtime');$('runtimeText').textContent=tx('runtimeText');$('masteryBtn').textContent=tx('mark');$('prevBtn').textContent=tx('prev');$('nextBtn').textContent=tx('next');$('whyTitle').textContent=tx('why');$('relatedTitle').textContent=tx('related');$('conceptsTitle').textContent=tx('concepts');$('flowTitle').textContent=tx('flow');$('practiceEyebrow').textContent=tx('practice');$('practiceTitle').textContent=tx('practice');$('practiceText').textContent=tx('practiceText')}
|
||||
function renderEmpty(){$('searchResults').innerHTML=`<div class=\"note\">${E(tx('searchEmpty'))}</div>`;$('moduleList').innerHTML=`<div class=\"note\">${E(tx('empty'))}</div>`;$('lessonCommands').innerHTML='';$('moduleSummary').textContent=tx('startGoal');$('masteryNote').textContent=tx('empty');$('relatedList').innerHTML='';$('conceptList').innerHTML=`<li>${E(tx('empty'))}</li>`;$('flowList').innerHTML=`<li>${E(tx('empty'))}</li>`;$('exampleList')&&($('exampleList').innerHTML='');$('exerciseList').innerHTML=`<div class=\"note\">${E(tx('empty'))}</div>`}
|
||||
function renderOverview(){const m=state.overview.meta||{};const labels=state.language==='zh'?['\u6A21\u5757','\u8BFE\u7A0B','\u7EC3\u4E60','\u547D\u4EE4','\u5DF2\u638C\u63E1','\u5B8C\u6210\u5EA6']:['Modules','Lessons','Exercises','Commands','Mastered','Completion'];const masteredCount=Object.values(state.progress).filter(Boolean).length;const stageGroups=groups();$('stats').innerHTML=[[labels[0],m.module_count||0],[labels[1],m.lesson_count||0],[labels[2],m.exercise_count||0],[labels[3],m.command_count||0],[labels[4],masteredCount],[labels[5],`${m.lesson_count?Math.round(masteredCount*100/m.lesson_count):0}%`]].map(([k,v])=>`<div class=\"stat\"><span>${E(k)}</span><strong>${v}</strong></div>`).join('');$('commandTags').innerHTML=(state.overview.commands||[]).map(c=>`<span class=\"chip\">${E(c)}</span>`).join('');$('metaLine').textContent=`v${m.version||'4.0'} | ${m.updated||'--'}`;$('runtimeMeta').textContent=`${state.language==='zh'?'\u6C99\u7BB1\u8DEF\u5F84':'Sandbox path'}: ${state.overview.runtime?.cwd||'/'} / ${state.overview.runtime?.user||'sandbox_user'}`;$('stageFilters').innerHTML=stageGroups.map(g=>`<button class=\"filter ${state.stage===g.key?'active':''}\" type=\"button\" onclick=\"setStage('${g.key}')\">${E(D(g.title||g.key))}</button>`).join('');$('stageHint').textContent=state.stage==='all'?tx('navText'):D(stageGroups.find(g=>g.key===state.stage)?.title||'');const modules=visibleModules();$('moduleJumps').innerHTML=modules.map(mod=>`<button class=\"ghost moduleJump ${state.lesson?.module_id===mod.id?'active':''}\" type=\"button\" onclick=\"openModule('${mod.id}')\">${E(mod.display_title||mod.title||mod.id)}</button>`).join('');$('moduleList').innerHTML=modules.map(mod=>`<button class=\"lesson ${state.lesson?.module_id===mod.id?'active':''}\" type=\"button\" onclick=\"openModule('${mod.id}')\"><strong>${E(mod.display_title||mod.title||mod.id)}</strong><span class=\"muted\">${E(mod.display_summary||mod.summary||'')}</span></button>`).join('')||`<div class=\"note\">${E(tx('empty'))}</div>`}
|
||||
function renderLesson(){if(!state.lesson)return;const l=state.lesson;const commandLabel=label(l);$('lessonLabel').textContent=l.display_module_title||l.module_id;$('lessonTitle').textContent=l.display_title||l.id;$('lessonGoal').textContent=state.language==='zh'?`\u56F4\u7ED5 ${commandLabel} \u7EC3\u4E60\u201C\u5148\u786E\u8BA4\u4E0A\u4E0B\u6587\uFF0C\u518D\u89C2\u5BDF\u8F93\u51FA\uFF0C\u518D\u51B3\u5B9A\u4E0B\u4E00\u6B65\u201D\u7684 Linux \u64CD\u4F5C\u601D\u8DEF\u3002`:(l.display_goal||`Practice a Linux workflow around ${commandLabel}: confirm context, inspect output, then decide the next step.`);$('moduleSummary').textContent=l.display_module_summary||'';$('masteryBtn').textContent=mastered(l.id)?tx('review'):tx('mark');$('masteryNote').textContent=mastered(l.id)?tx('review'):tx('mark');$('prevBtn').disabled=!l.previous_lesson;$('nextBtn').disabled=!l.next_lesson;$('lessonCommands').innerHTML=tokens(l).map(c=>`<span class=\"chip\">${E(c)}</span>`).join('');$('lessonWhy').textContent=state.language==='zh'?`\u5728\u771F\u5B9E\u8FD0\u7EF4\u91CC\uFF0C${commandLabel} \u5F80\u5F80\u4E0D\u662F\u7EC8\u70B9\uFF0C\u800C\u662F\u4E0B\u4E00\u6B65\u68C0\u67E5\u3001\u4FEE\u590D\u6216\u9A8C\u8BC1\u52A8\u4F5C\u7684\u8D77\u70B9\u3002`:(l.display_why||`In real operations work, ${commandLabel} often becomes the starting point for the next inspection, repair, or verification step.`);$('relatedList').innerHTML=(l.related_commands||[]).map(c=>`<span class=\"chip\">${E(c)}</span>`).join('')||`<span class=\"chip\">${E(tx('empty'))}</span>`;$('conceptList').innerHTML=(l.concepts||[]).map(i=>`<li>${E(i)}</li>`).join('')||`<li>${E(tx('empty'))}</li>`;$('flowList').innerHTML=(l.troubleshooting_flow||[]).map(i=>`<li>${E(i)}</li>`).join('')||`<li>${E(tx('empty'))}</li>`;renderExercises(l.exercises||[]);renderOverview()}
|
||||
function renderExercises(items){$('exerciseList').innerHTML=items.length?items.map(ex=>ex.type!=='operation'?`<article class=\"card\"><h3>${E(ex.question||ex.title||tx('practice'))}</h3><p class=\"muted\" style=\"margin-top:10px;white-space:pre-line;\">${E(ex.answer||tx('empty'))}</p></article>`:`<article class=\"card\"><div class=\"badge\">${E(tx('practice'))}</div><h3 style=\"margin-top:10px;\">${E(ex.title||tx('practice'))}</h3><p class=\"muted\" style=\"margin-top:10px;white-space:pre-line;\">${E(ex.hint||tx('empty'))}</p><div class=\"terminal\"><div class=\"terminalHead\">${E(tx('terminal'))}</div><div class=\"terminalRow\"><input id=\"input-${ex.id}\" type=\"text\" placeholder=\"${E((ex.solution||[])[0]||'pwd')}\" onkeydown=\"if(event.key==='Enter'){runExercise('${ex.id}')}\"><button type=\"button\" onclick=\"runExercise('${ex.id}')\">${E(tx('run'))}</button></div><pre class=\"output\" id=\"output-${ex.id}\">${E(tx('waiting'))}</pre></div><div class=\"note\" id=\"feedback-${ex.id}\" style=\"display:none;margin-top:10px;\"></div></article>`).join(''):`<div class=\"note\">${E(tx('empty'))}</div>`}
|
||||
async function openLesson(id){const r=await api('/api/lesson?id='+encodeURIComponent(id));const p=await r.json();state.lesson=p.lesson;renderLesson()}
|
||||
function openModule(id){const m=(state.overview?.modules||[]).find(x=>x.id===id);const l=m?.lessons?.[0];if(l)openLesson(l.id)}
|
||||
function setStage(key){state.stage=key;renderOverview()}
|
||||
function toggleMastery(){if(!state.lesson?.id)return;state.progress[state.lesson.id]=!state.progress[state.lesson.id];save();renderLesson()}
|
||||
async function runSearch(){const q=$('searchInput').value.trim();if(!q){$('searchResults').innerHTML=`<div class=\"note\">${E(tx('searchEmpty'))}</div>`;return}$('searchResults').innerHTML=`<div class=\"note\">${E(tx('searchLoading'))}</div>`;const r=await api('/api/course/search?q='+encodeURIComponent(q));const p=await r.json();$('searchResults').innerHTML=(p.results||[]).length?(p.results||[]).map(it=>`<button class=\"searchBtn\" type=\"button\" onclick=\"openLesson('${it.lesson_id}')\"><strong>${E(it.title)}</strong><span class=\"muted\">${E(it.subtitle||'')}</span></button>`).join(''):`<div class=\"note\">${E(tx('searchNo'))}</div>`}
|
||||
async function runExercise(id){const input=$('input-'+id),out=$('output-'+id),fb=$('feedback-'+id),cmd=input.value.trim();if(!cmd)return;out.textContent=`$ ${cmd}\\n\\n...`;const rr=await api('/api/run?cmd='+encodeURIComponent(cmd));const rd=await rr.json();out.textContent=`$ ${cmd}\\n\\n${rd.output||rd.message||''}`;const cr=await api('/api/check?exercise_id='+encodeURIComponent(id)+'&last_cmd='+encodeURIComponent(cmd)+'&output='+encodeURIComponent(rd.output||''));const cd=await cr.json();fb.style.display='block';fb.textContent=cd.message+(cd.next_suggestion?'\\n'+cd.next_suggestion:'')}
|
||||
async function logoutLab(){try{await fetch('/api/logout',{method:'POST',credentials:'same-origin'})}catch(e){}localStorage.setItem('linux_lab_login_notice',tx('logoutDone'));location.href='/login.html'}
|
||||
async function boot(){const s=await fetch('/api/session',{credentials:'same-origin'});const p=await s.json();if(!p.authenticated){localStorage.setItem('linux_lab_login_notice',tx('authExpired'));location.href='/login.html';return}state.user=p.user||'admin';renderText();renderEmpty();setTheme(state.theme,false);const r=await api('/api/overview');state.overview=await r.json();renderOverview();const first=state.overview?.modules?.[0]?.lessons?.[0];if(first)await openLesson(first.id)}
|
||||
document.addEventListener('DOMContentLoaded',()=>{$('themeBtn').addEventListener('click',()=>setTheme(state.theme==='light'?'dark':'light'));$('langBtn').addEventListener('click',()=>setLanguage(state.language==='zh'?'en':'zh'));$('logoutBtn').addEventListener('click',logoutLab);$('searchBtn').addEventListener('click',runSearch);$('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter')runSearch()});$('masteryBtn').addEventListener('click',toggleMastery);$('prevBtn').addEventListener('click',()=>{if(state.lesson?.previous_lesson)openLesson(state.lesson.previous_lesson.id)});$('nextBtn').addEventListener('click',()=>{if(state.lesson?.next_lesson)openLesson(state.lesson.next_lesson.id)});boot()});
|
||||
window.openLesson=openLesson;window.openModule=openModule;window.setStage=setStage;window.runExercise=runExercise;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
48
login.html
Normal file
48
login.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linux Login</title>
|
||||
<style>
|
||||
:root{--bg:#f4f8fc;--panel:#fff;--line:#d8e2ee;--text:#102033;--muted:#5d7187;--brand:#0f6db6;--soft:#e9f3ff}
|
||||
[data-theme="dark"]{--bg:#08111d;--panel:#0c1826;--line:#1d3245;--text:#edf4fb;--muted:#9bb0c3;--brand:#67baff;--soft:rgba(103,186,255,.1)}
|
||||
*{box-sizing:border-box} body{margin:0;min-height:100vh;display:grid;place-items:center;padding:20px;background:var(--bg);color:var(--text);font-family:"Segoe UI","Microsoft YaHei",sans-serif}
|
||||
.card{width:min(100%,460px);background:var(--panel);border:1px solid var(--line);border-radius:20px;padding:28px} .toolbar,.tips{display:flex;flex-wrap:wrap;gap:10px}.toolbar{justify-content:flex-end;margin-bottom:16px}
|
||||
button,input{font:inherit}.btn,.ghost,.chip{border-radius:999px;padding:10px 14px;font-weight:700}.btn{border:0;background:linear-gradient(135deg,var(--brand),#37a1ff);color:#fff;cursor:pointer;width:100%}.ghost{border:1px solid var(--line);background:transparent;color:var(--text);cursor:pointer}.chip{display:inline-flex;background:var(--soft);color:var(--brand)}
|
||||
form{display:grid;gap:12px;margin-top:16px} input{padding:12px;border:1px solid var(--line);border-radius:12px;background:transparent;color:var(--text)} .eyebrow{font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--brand)} h1,p{margin:0} p{color:var(--muted);line-height:1.8}.msg{margin-top:14px;padding:12px;border:1px solid var(--line);border-radius:12px;color:var(--muted);white-space:pre-wrap}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="card">
|
||||
<div class="toolbar">
|
||||
<button class="ghost" id="themeBtn"></button>
|
||||
<button class="ghost" id="langBtn"></button>
|
||||
</div>
|
||||
<div class="eyebrow" id="eyebrow"></div>
|
||||
<h1 id="title" style="margin:10px 0 8px;"></h1>
|
||||
<p id="desc"></p>
|
||||
<form id="loginForm">
|
||||
<input id="user" type="text" autocomplete="username" />
|
||||
<input id="pass" type="password" autocomplete="current-password" />
|
||||
<button class="btn" type="submit" id="submitBtn"></button>
|
||||
</form>
|
||||
<div class="tips" style="margin-top:14px;">
|
||||
<span class="chip" id="accountTip"></span>
|
||||
<span class="chip" id="passwordTip"></span>
|
||||
</div>
|
||||
<div class="msg" id="msg"></div>
|
||||
</section>
|
||||
<script>
|
||||
const D=v=>{const e=document.createElement('textarea');e.innerHTML=v;return e.value};const $=id=>document.getElementById(id);
|
||||
const LANG={zh:{eyebrow:'安全入口',title:'Linux 学习实验室登录',desc:'登录后才能访问课程总览、练习终端和学习面板。',user:'用户名',pass:'密码',submit:'登录并进入学习台',themeDark:'深色模式',themeLight:'浅色模式',lang:'EN',account:'账号:按部署配置',password:'密码:按部署配置',ready:'请输入部署时配置的账号密码进入 Linux 学习项目。',loading:'正在登录...',failed:'登录失败,请检查账号与密码。'},en:{eyebrow:'Secure entry',title:'Linux Learning Lab Login',desc:'Sign in before opening the course overview, practice terminal, and learning dashboard.',user:'Username',pass:'Password',submit:'Sign in and enter lab',themeDark:'Dark mode',themeLight:'Light mode',lang:'涓枃',account:'Account: deployment configured',password:'Password: deployment configured',ready:'Enter the deployment-configured credentials to access the Linux learning project.',loading:'Signing in...',failed:'Login failed. Check the username and password.'}};
|
||||
const state={language:localStorage.getItem('linux_lab_language')||'zh',theme:localStorage.getItem('linux_lab_theme')||'light'};const t=()=>LANG[state.language];const tx=k=>D(t()[k]||'');
|
||||
function setTheme(theme,persist=true){state.theme=theme==='dark'?'dark':'light';document.documentElement.setAttribute('data-theme',state.theme);if(persist)localStorage.setItem('linux_lab_theme',state.theme);$('themeBtn').textContent=state.theme==='light'?tx('themeDark'):tx('themeLight')}
|
||||
function render(){document.title=tx('title');document.documentElement.lang=state.language==='zh'?'zh-CN':'en';$('eyebrow').textContent=tx('eyebrow');$('title').textContent=tx('title');$('desc').textContent=tx('desc');$('user').placeholder=tx('user');$('pass').placeholder=tx('pass');$('submitBtn').textContent=tx('submit');$('langBtn').textContent=state.language==='zh'?'EN':'\u4E2D\u6587';$('accountTip').textContent=tx('account');$('passwordTip').textContent=tx('password');setTheme(state.theme,false);const n=localStorage.getItem('linux_lab_login_notice');$('msg').textContent=n||tx('ready');localStorage.removeItem('linux_lab_login_notice')}
|
||||
async function checkSession(){const r=await fetch('/api/session',{credentials:'same-origin'});const p=await r.json();if(p.authenticated)location.href='/app'}
|
||||
async function login(e){e.preventDefault();$('msg').textContent=tx('loading');const r=await fetch('/api/login',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:$('user').value.trim(),password:$('pass').value.trim()})});const p=await r.json();if(r.ok&&p.success){location.href='/app';return}$('msg').textContent=p.error||tx('failed')}
|
||||
document.addEventListener('DOMContentLoaded',async()=>{render();await checkSession();$('themeBtn').addEventListener('click',()=>setTheme(state.theme==='light'?'dark':'light'));$('langBtn').addEventListener('click',()=>{state.language=state.language==='zh'?'en':'zh';localStorage.setItem('linux_lab_language',state.language);render()});$('loginForm').addEventListener('submit',login)})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
109
privacy.html
109
privacy.html
@@ -1,67 +1,92 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Bot 隐私政策 / Privacy Policy</title>
|
||||
<title>Linux Learning Lab Privacy Notice</title>
|
||||
<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; }
|
||||
main { max-width: 860px; margin: 0 auto; }
|
||||
h1,h2 { line-height: 1.25; }
|
||||
code { background: #f6f8fa; padding: 2px 6px; border-radius: 6px; }
|
||||
.muted { color: #555; }
|
||||
ul { padding-left: 18px; }
|
||||
hr { border: 0; border-top: 1px solid #eee; margin: 20px 0; }
|
||||
:root {
|
||||
--bg: #f4f7fb;
|
||||
--panel: #ffffff;
|
||||
--line: #d8e2ee;
|
||||
--text: #122238;
|
||||
--muted: #5b6f86;
|
||||
--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>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Bot 隐私政策 / Privacy Policy</h1>
|
||||
<p class="muted">最后更新:2026-03-10</p>
|
||||
<div class="eyebrow">Privacy Notice</div>
|
||||
<h1>Linux Learning Lab Privacy Notice</h1>
|
||||
<p class="muted">Last updated: 2026-03-18</p>
|
||||
|
||||
<h2>1. 我们是谁</h2>
|
||||
<p>本隐私政策适用于与本 Telegram Bot(以下简称“Bot”)的交互。本 Bot 用于提供个人助理与技术学习/排障相关的对话服务。</p>
|
||||
<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>
|
||||
|
||||
<h2>2. 我们收集哪些数据</h2>
|
||||
<h2>1. What data the platform processes</h2>
|
||||
<ul>
|
||||
<li><b>你发送的消息内容</b>(文本、命令、你主动提供的上下文)。</li>
|
||||
<li><b>基础元数据</b>(如 Telegram 提供的 chat_id、消息时间戳、用户名/昵称等,用于路由回复与防滥用)。</li>
|
||||
<li><b>运行日志</b>:为排障与稳定性,系统可能记录错误日志与请求失败信息(可能包含部分消息片段或上下文)。</li>
|
||||
<li>Search queries, login details, and commands that you intentionally submit.</li>
|
||||
<li>Sandbox runtime state used to return a response, such as current directory, current user, and simulated command output.</li>
|
||||
<li>Minimal operational logs used for debugging and service reliability.</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. 数据如何被使用</h2>
|
||||
<h2>2. Why the data is used</h2>
|
||||
<ul>
|
||||
<li>用于生成回复、执行你请求的任务(例如课程内容生成、命令模拟、排障建议)。</li>
|
||||
<li>用于安全控制与反滥用(例如鉴权、速率限制)。</li>
|
||||
<li>用于系统稳定性与问题定位(例如网络错误、API 调用失败)。</li>
|
||||
<li>To render lesson content, search results, module summaries, and guided exercises.</li>
|
||||
<li>To execute the controlled Linux sandbox simulation and return exercise feedback.</li>
|
||||
<li>To protect remote endpoints with authentication and basic abuse prevention.</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. 数据共享与第三方</h2>
|
||||
<p>Bot 运行依赖以下第三方服务,数据会按功能需要流转:</p>
|
||||
<ul>
|
||||
<li><b>Telegram</b>:消息收发与基础元数据由 Telegram 处理。</li>
|
||||
<li><b>模型/推理服务提供商</b>:为生成回复,消息内容可能会被发送到大模型推理服务(以完成你的请求)。</li>
|
||||
<li><b>托管/网络服务</b>:用于运行 Bot 与相关服务的基础设施提供商可能处理网络请求与日志。</li>
|
||||
</ul>
|
||||
<p>我们不会将你的个人数据出售给任何第三方。</p>
|
||||
<h2>3. Sandbox and safety boundary</h2>
|
||||
<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>
|
||||
|
||||
<h2>5. 数据保留</h2>
|
||||
<p>我们会在实现功能与保障稳定性的必要范围内保留数据(例如对话上下文、课程数据、运行日志)。保留时间可能因用途不同而不同。</p>
|
||||
<h2>4. Retention and sharing</h2>
|
||||
<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>
|
||||
<li>你可以随时停止使用 Bot。</li>
|
||||
<li>你可以请求导出或删除与你相关的本地存储数据(在技术可行范围内)。</li>
|
||||
<li>You can stop using the platform at any time.</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>
|
||||
|
||||
<h2>7. 安全</h2>
|
||||
<p>我们采取合理的技术措施保护数据(例如服务访问控制、最小权限原则)。但任何系统都无法保证绝对安全。</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>
|
||||
<h2>6. Contact and maintenance</h2>
|
||||
<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>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
14
sandbox.py
14
sandbox.py
@@ -75,6 +75,17 @@ COMMAND_INDEX = {
|
||||
"python3": "/usr/bin/python3",
|
||||
}
|
||||
|
||||
SUPPORTED_COMMANDS = {
|
||||
"alias", "apt", "awk", "bash", "bc", "cat", "cd", "chmod", "chgrp", "chown",
|
||||
"clear", "cp", "crontab", "curl", "cut", "date", "df", "dig", "du", "echo",
|
||||
"env", "export", "fdisk", "find", "free", "grep", "head", "history", "id",
|
||||
"ifconfig", "ip", "journalctl", "kill", "last", "less", "ls", "lsof", "mkdir",
|
||||
"more", "mount", "mv", "netstat", "nohup", "passwd", "ping", "pkill", "ps",
|
||||
"pwd", "rm", "rpm", "sed", "service", "sort", "ss", "stat", "su", "systemctl",
|
||||
"tail", "tar", "top", "touch", "traceroute", "uniq", "uptime", "vi", "vim", "w",
|
||||
"wc", "wget", "whereis", "which", "whoami", "yum", "dpkg", "cal"
|
||||
}
|
||||
|
||||
|
||||
class LinuxSandbox:
|
||||
def __init__(self):
|
||||
@@ -121,6 +132,9 @@ class LinuxSandbox:
|
||||
node = self.get_node(self.resolve_path(path))
|
||||
return bool(node and len(node.get("perm", "")) >= 3 and node["perm"][2] == "1" or node and "x" in node.get("perm_human", ""))
|
||||
|
||||
def supported_commands(self) -> set[str]:
|
||||
return set(SUPPORTED_COMMANDS)
|
||||
|
||||
def _perm_human(self, perm: str, is_dir: bool) -> str:
|
||||
mapping = {
|
||||
"0": "---", "1": "--x", "2": "-w-", "3": "-wx",
|
||||
|
||||
Reference in New Issue
Block a user