Compare commits
2 Commits
f5bcfa2259
...
d61730bf17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d61730bf17 | ||
|
|
d2b667f569 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,3 +5,7 @@ __pycache__/
|
|||||||
|
|
||||||
# OpenClaw interactive edit backups
|
# OpenClaw interactive edit backups
|
||||||
*.interactive.backup.*
|
*.interactive.backup.*
|
||||||
|
|
||||||
|
# Runtime logs
|
||||||
|
server.out.log
|
||||||
|
server.err.log
|
||||||
|
|||||||
437
index.html
437
index.html
@@ -342,12 +342,166 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
.auth-card {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.18);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.auth-card .auth-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.auth-card form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.auth-card input {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.auth-status {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.stage-map {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.stage-map .stage-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.stage-map .stage-step::before {
|
||||||
|
content: "";
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--soft);
|
||||||
|
border: 2px solid var(--brand);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.category-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.category-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
.category-card em {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.progress-track {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.progress-pill {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.25);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
.variant-list {
|
.variant-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
.auth-gate {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.auth-gate.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.auth-gate-card {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.auth-gate-card h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
.auth-gate-card p {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.auth-gate-card form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.auth-gate-card input {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.auth-gate-card button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, var(--brand), #37a1ff);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.overview-nav {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.overview-nav .nav-card {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
.variant {
|
.variant {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -400,6 +554,7 @@
|
|||||||
<button class="btn-ghost" type="button" id="languageBtn">EN</button>
|
<button class="btn-ghost" type="button" id="languageBtn">EN</button>
|
||||||
<button class="btn-soft" type="button" id="resetBtn" onclick="resetSandbox()">Reset sandbox</button>
|
<button class="btn-soft" type="button" id="resetBtn" onclick="resetSandbox()">Reset sandbox</button>
|
||||||
<a class="btn-ghost" id="privacyLink" href="/privacy" target="_blank" rel="noreferrer">Privacy</a>
|
<a class="btn-ghost" id="privacyLink" href="/privacy" target="_blank" rel="noreferrer">Privacy</a>
|
||||||
|
<button class="btn-ghost" type="button" id="logoutBtn" style="display:none;">退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -448,9 +603,30 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="diagnostic" id="diagnosticNote" style="margin-top: 12px;">Checking course and sandbox alignment...</div>
|
<div class="diagnostic" id="diagnosticNote" style="margin-top: 12px;">Checking course and sandbox alignment...</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
|
<section class="card overview-nav" id="overviewNav">
|
||||||
|
<article class="nav-card">
|
||||||
|
<div class="eyebrow" id="navEyebrow">总览菜单</div>
|
||||||
|
<h3 id="navTitle">课程总览</h3>
|
||||||
|
<p class="muted" id="navText">按模块、阶段或练习快速跳转,不再只能上一课下一课。</p>
|
||||||
|
<div class="mini-stats">
|
||||||
|
<div class="mini-stat"><span id="navStageCountLabel">已设置阶段</span><strong id="navStageCount">0</strong></div>
|
||||||
|
<div class="mini-stat"><span id="navExerciseCountLabel">练习总数</span><strong id="navExerciseCount">0</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="results" id="navLessonResults"><div class="empty" id="navLessonEmpty">登录后即可选择模块导航。</div></div>
|
||||||
|
</article>
|
||||||
|
<article class="nav-card">
|
||||||
|
<div class="eyebrow" id="navStageEyebrow">阶段地图</div>
|
||||||
|
<div id="navStageMap"></div>
|
||||||
|
</article>
|
||||||
|
<article class="nav-card">
|
||||||
|
<div class="eyebrow" id="navCategoryEyebrow">命令分类</div>
|
||||||
|
<div id="navCategoryGrid"></div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
<section class="hero card">
|
<section class="hero card">
|
||||||
<div class="hero-head">
|
<div class="hero-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -482,6 +658,21 @@
|
|||||||
<div class="mastery-note" id="masteryNote">Master a lesson after you can explain the command, predict the output, and connect it to a real operations step.</div>
|
<div class="mastery-note" id="masteryNote">Master a lesson after you can explain the command, predict the output, and connect it to a real operations step.</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="detail card">
|
||||||
|
<div class="eyebrow" id="stageMapEyebrow">Stage map</div>
|
||||||
|
<h2 id="stageMapTitle">阶段路线清单</h2>
|
||||||
|
<p class="muted" id="stageMapText">按阶段划分的学习路线,帮你把命令练习串联成一条完整的运维路径。</p>
|
||||||
|
<div class="stage-map" id="stageMap"></div>
|
||||||
|
<div class="progress-track" id="progressTrack"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail card">
|
||||||
|
<div class="eyebrow" id="categoryEyebrow">Command categories</div>
|
||||||
|
<h2 id="categoryTitle">命令家族可视化</h2>
|
||||||
|
<p class="muted" id="categoryText">了解各类命令在当前课程中的分布,以及建议的学习顺序。</p>
|
||||||
|
<div class="category-grid" id="categoryGrid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="detail card">
|
<section class="detail card">
|
||||||
<div class="eyebrow" id="stagesEyebrow">Learning stages</div>
|
<div class="eyebrow" id="stagesEyebrow">Learning stages</div>
|
||||||
<h2 id="stagesTitle" style="margin: 8px 0 0;">System route from Linux basics to operations habits</h2>
|
<h2 id="stagesTitle" style="margin: 8px 0 0;">System route from Linux basics to operations habits</h2>
|
||||||
@@ -1221,6 +1412,7 @@
|
|||||||
document.getElementById('practiceText').textContent = text.practiceText;
|
document.getElementById('practiceText').textContent = text.practiceText;
|
||||||
document.getElementById('prevBtn').textContent = text.prevBtn;
|
document.getElementById('prevBtn').textContent = text.prevBtn;
|
||||||
document.getElementById('nextBtn').textContent = text.nextBtn;
|
document.getElementById('nextBtn').textContent = text.nextBtn;
|
||||||
|
renderAuthControls();
|
||||||
|
|
||||||
if (!state.overview) {
|
if (!state.overview) {
|
||||||
document.getElementById('courseTitle').textContent = text.courseTitle;
|
document.getElementById('courseTitle').textContent = text.courseTitle;
|
||||||
@@ -1248,12 +1440,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const persistedAuth = JSON.parse(localStorage.getItem('linux_lab_auth') || '{}');
|
||||||
const state = {
|
const state = {
|
||||||
overview: null,
|
overview: null,
|
||||||
lesson: null,
|
lesson: null,
|
||||||
language: localStorage.getItem('linux_lab_language') || 'zh',
|
language: localStorage.getItem('linux_lab_language') || 'zh',
|
||||||
theme: localStorage.getItem('linux_lab_theme') || 'light',
|
theme: localStorage.getItem('linux_lab_theme') || 'light',
|
||||||
progress: JSON.parse(localStorage.getItem('linux_lab_progress') || '{}')
|
progress: JSON.parse(localStorage.getItem('linux_lab_progress') || '{}'),
|
||||||
|
auth: {
|
||||||
|
loggedIn: persistedAuth.loggedIn || false,
|
||||||
|
user: persistedAuth.user || '',
|
||||||
|
token: persistedAuth.token || ''
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
@@ -1261,6 +1459,7 @@
|
|||||||
renderStaticText();
|
renderStaticText();
|
||||||
document.getElementById('themeBtn').addEventListener('click', () => setTheme(state.theme === 'light' ? 'dark' : 'light'));
|
document.getElementById('themeBtn').addEventListener('click', () => setTheme(state.theme === 'light' ? 'dark' : 'light'));
|
||||||
document.getElementById('languageBtn').addEventListener('click', () => setLanguage(state.language === 'zh' ? 'en' : 'zh'));
|
document.getElementById('languageBtn').addEventListener('click', () => setLanguage(state.language === 'zh' ? 'en' : 'zh'));
|
||||||
|
document.getElementById('logoutBtn').addEventListener('click', logoutLab);
|
||||||
document.getElementById('searchInput').addEventListener('keydown', (event) => {
|
document.getElementById('searchInput').addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Enter') runSearch();
|
if (event.key === 'Enter') runSearch();
|
||||||
});
|
});
|
||||||
@@ -1271,7 +1470,12 @@
|
|||||||
if (state.lesson?.next_lesson) openLesson(state.lesson.next_lesson.id);
|
if (state.lesson?.next_lesson) openLesson(state.lesson.next_lesson.id);
|
||||||
});
|
});
|
||||||
document.getElementById('masteryBtn').addEventListener('click', toggleMastery);
|
document.getElementById('masteryBtn').addEventListener('click', toggleMastery);
|
||||||
await loadOverview();
|
if (state.auth.loggedIn) {
|
||||||
|
hideAuthGate();
|
||||||
|
await loadOverview();
|
||||||
|
} else {
|
||||||
|
showAuthGate();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function saveProgress() {
|
function saveProgress() {
|
||||||
@@ -1301,8 +1505,59 @@
|
|||||||
renderLesson();
|
renderLesson();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistAuth() {
|
||||||
|
localStorage.setItem('linux_lab_auth', JSON.stringify(state.auth));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuthControls() {
|
||||||
|
const zh = state.language === 'zh';
|
||||||
|
const logoutBtn = document.getElementById('logoutBtn');
|
||||||
|
logoutBtn.style.display = state.auth.loggedIn ? 'inline-flex' : 'none';
|
||||||
|
logoutBtn.textContent = zh ? '退出登录' : 'Log out';
|
||||||
|
|
||||||
|
document.getElementById('navEyebrow').textContent = zh ? '总览菜单' : 'Overview menu';
|
||||||
|
document.getElementById('navTitle').textContent = zh ? '课程总览' : 'Course overview';
|
||||||
|
document.getElementById('navText').textContent = zh ? '按模块、阶段或练习快速跳转,不再只能上一课下一课。' : 'Jump by module, stage, or exercise instead of relying only on previous and next.';
|
||||||
|
document.getElementById('navStageCountLabel').textContent = zh ? '已设置阶段' : 'Mapped stages';
|
||||||
|
document.getElementById('navExerciseCountLabel').textContent = zh ? '练习总数' : 'Exercises';
|
||||||
|
document.getElementById('navLessonEmpty').textContent = zh ? '登录后即可选择模块导航。' : 'Sign in to unlock module navigation.';
|
||||||
|
document.getElementById('navStageEyebrow').textContent = zh ? '阶段地图' : 'Stage map';
|
||||||
|
document.getElementById('navCategoryEyebrow').textContent = zh ? '命令分类' : 'Command categories';
|
||||||
|
document.getElementById('stageMapEyebrow').textContent = zh ? '阶段地图' : 'Stage map';
|
||||||
|
document.getElementById('stageMapTitle').textContent = zh ? '阶段路线清单' : 'Stage route checklist';
|
||||||
|
document.getElementById('stageMapText').textContent = zh ? '按阶段划分的学习路线,帮你把命令练习串联成一条完整的运维路径。' : 'Study by stages so commands connect into a complete operations path.';
|
||||||
|
document.getElementById('categoryEyebrow').textContent = zh ? '命令分类' : 'Command categories';
|
||||||
|
document.getElementById('categoryTitle').textContent = zh ? '命令家族可视化' : 'Command family map';
|
||||||
|
document.getElementById('categoryText').textContent = zh ? '了解各类命令在当前课程中的分布,以及建议的学习顺序。' : 'See how command families are distributed and which order makes sense.';
|
||||||
|
document.getElementById('gateTitle').textContent = zh ? '请先登录' : 'Sign in first';
|
||||||
|
document.getElementById('gateDescription').textContent = zh ? '登录后解锁课程总览、阶段地图、命令分类和练习仪表盘。' : 'Sign in to unlock the overview, stage map, command categories, and practice dashboard.';
|
||||||
|
document.getElementById('gateUser').setAttribute('placeholder', zh ? '用户名' : 'Username');
|
||||||
|
document.getElementById('gatePassword').setAttribute('placeholder', zh ? '密码' : 'Password');
|
||||||
|
document.getElementById('gateSubmit').textContent = zh ? '登录并进入总览' : 'Login and enter overview';
|
||||||
|
if (!state.auth.loggedIn) {
|
||||||
|
document.getElementById('gateMessage').textContent = zh ? '将自动保存进度与阶段状态,方便继续学习。' : 'Progress and stage state will be saved automatically so you can resume later.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path, options = {}) {
|
||||||
|
const headers = new Headers(options.headers || {});
|
||||||
|
if (state.auth.token) {
|
||||||
|
headers.set('Authorization', `Bearer ${state.auth.token}`);
|
||||||
|
headers.set('X-Token', state.auth.token);
|
||||||
|
}
|
||||||
|
return fetch(path, Object.assign({}, options, { headers }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAuthGate() {
|
||||||
|
document.getElementById('authGate').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAuthGate() {
|
||||||
|
document.getElementById('authGate').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOverview() {
|
async function loadOverview() {
|
||||||
const response = await fetch('/api/overview');
|
const response = await apiFetch('/api/overview');
|
||||||
state.overview = await response.json();
|
state.overview = await response.json();
|
||||||
renderOverview();
|
renderOverview();
|
||||||
const firstLesson = state.overview.modules?.[0]?.lessons?.[0];
|
const firstLesson = state.overview.modules?.[0]?.lessons?.[0];
|
||||||
@@ -1333,6 +1588,7 @@
|
|||||||
renderModules();
|
renderModules();
|
||||||
renderStageGrid();
|
renderStageGrid();
|
||||||
renderModuleSummaryGrid();
|
renderModuleSummaryGrid();
|
||||||
|
renderVisualModules();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRuntime(runtime) {
|
function renderRuntime(runtime) {
|
||||||
@@ -1421,7 +1677,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openLesson(lessonId, focusExerciseId = '') {
|
async function openLesson(lessonId, focusExerciseId = '') {
|
||||||
const response = await fetch('/api/lesson?id=' + encodeURIComponent(lessonId));
|
const response = await apiFetch('/api/lesson?id=' + encodeURIComponent(lessonId));
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
state.lesson = payload.lesson;
|
state.lesson = payload.lesson;
|
||||||
renderModules();
|
renderModules();
|
||||||
@@ -1601,11 +1857,11 @@
|
|||||||
feedback.className = 'feedback';
|
feedback.className = 'feedback';
|
||||||
feedback.textContent = '';
|
feedback.textContent = '';
|
||||||
try {
|
try {
|
||||||
const runResponse = await fetch('/api/run?cmd=' + encodeURIComponent(cmd));
|
const runResponse = await apiFetch('/api/run?cmd=' + encodeURIComponent(cmd));
|
||||||
const runData = await runResponse.json();
|
const runData = await runResponse.json();
|
||||||
output.textContent = `$ ${cmd}\n\n${runData.output || runData.message || text.noOutput}`;
|
output.textContent = `$ ${cmd}\n\n${runData.output || runData.message || text.noOutput}`;
|
||||||
renderRuntime({ cwd: runData.cwd || '/', user: state.overview?.runtime?.user || 'sandbox_user' });
|
renderRuntime({ cwd: runData.cwd || '/', user: state.overview?.runtime?.user || 'sandbox_user' });
|
||||||
const checkResponse = await fetch('/api/check?exercise_id=' + encodeURIComponent(exerciseId) + '&last_cmd=' + encodeURIComponent(cmd) + '&output=' + encodeURIComponent(runData.output || ''));
|
const checkResponse = await apiFetch('/api/check?exercise_id=' + encodeURIComponent(exerciseId) + '&last_cmd=' + encodeURIComponent(cmd) + '&output=' + encodeURIComponent(runData.output || ''));
|
||||||
const checkData = await checkResponse.json();
|
const checkData = await checkResponse.json();
|
||||||
feedback.className = 'feedback show ' + (checkData.success ? 'success' : 'warn');
|
feedback.className = 'feedback show ' + (checkData.success ? 'success' : 'warn');
|
||||||
feedback.textContent = checkData.message + (checkData.next_suggestion ? '\n' + checkData.next_suggestion : '');
|
feedback.textContent = checkData.message + (checkData.next_suggestion ? '\n' + checkData.next_suggestion : '');
|
||||||
@@ -1618,7 +1874,7 @@
|
|||||||
|
|
||||||
async function resetSandbox() {
|
async function resetSandbox() {
|
||||||
const text = uiText();
|
const text = uiText();
|
||||||
await fetch('/api/reset', { method: 'POST' });
|
await apiFetch('/api/reset', { method: 'POST' });
|
||||||
renderRuntime({ cwd: '/', user: 'sandbox_user' });
|
renderRuntime({ cwd: '/', user: 'sandbox_user' });
|
||||||
alert(text.sandboxResetComplete);
|
alert(text.sandboxResetComplete);
|
||||||
}
|
}
|
||||||
@@ -1633,7 +1889,7 @@
|
|||||||
}
|
}
|
||||||
container.innerHTML = `<div class="empty">${escapeHtml(text.searchLoading)}</div>`;
|
container.innerHTML = `<div class="empty">${escapeHtml(text.searchLoading)}</div>`;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/course/search?q=' + encodeURIComponent(query));
|
const response = await apiFetch('/api/course/search?q=' + encodeURIComponent(query));
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
const results = payload.results || [];
|
const results = payload.results || [];
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
@@ -1658,19 +1914,174 @@
|
|||||||
document.getElementById('themeBtn').textContent = theme === 'light' ? uiText().themeToDark : uiText().themeToLight;
|
document.getElementById('themeBtn').textContent = theme === 'light' ? uiText().themeToDark : uiText().themeToLight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMMAND_CATEGORY_META = [
|
||||||
|
{ id: "navigation", title: "导航", description: "帮你定位在系统中的上下文、目录和状态。" },
|
||||||
|
{ id: "filesystem", title: "文件系统", description: "读写/移动/备份关键资产的安全操作。" },
|
||||||
|
{ id: "text", title: "文本处理", description: "过滤日志、提取字段、整理数据。" },
|
||||||
|
{ id: "observation", title: "观测", description: "实时洞察服务、进程、资源使用。" },
|
||||||
|
{ id: "service", title: "服务控制", description: "管理 systemd 单元、日志和守护进程。" },
|
||||||
|
{ id: "network", title: "网络", description: "检查连接、响应、DNS 及路由。" },
|
||||||
|
];
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value)
|
return String(value)
|
||||||
.replaceAll('&', '&')
|
.replaceAll("&", "&")
|
||||||
.replaceAll('<', '<')
|
.replaceAll("<", "<")
|
||||||
.replaceAll('>', '>')
|
.replaceAll(">", ">")
|
||||||
.replaceAll('"', '"')
|
.replaceAll('"', """)
|
||||||
.replaceAll("'", ''');
|
.replaceAll("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStageTimeline() {
|
||||||
|
const map = document.getElementById("stageMap");
|
||||||
|
const modules = (state.overview?.modules || []).slice(0, 5);
|
||||||
|
if (!modules.length) {
|
||||||
|
map.innerHTML = "<div class='empty'>尚无阶段数据</div>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
map.innerHTML = modules.map((module, index) => `
|
||||||
|
<div class="stage-step">
|
||||||
|
<strong>${index + 1}. ${escapeHtml(localizedModuleTitle(module))}</strong>
|
||||||
|
<span class="muted">${(module.lesson_count || 0)} lessons · ${(module.exercise_count || 0)} exercises</span>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
const track = document.getElementById("progressTrack");
|
||||||
|
const completion = completionRate();
|
||||||
|
track.innerHTML = modules.map((module, index) => {
|
||||||
|
const masteredLessons = (module.lessons || []).filter((lesson) => isMastered(lesson.id)).length;
|
||||||
|
const totalLessons = module.lesson_count || (module.lessons || []).length;
|
||||||
|
const percent = totalLessons ? Math.round((masteredLessons / totalLessons) * 100) : 0;
|
||||||
|
return `<span class="progress-pill">${escapeHtml(localizedModuleTitle(module))}: ${percent}%</span>`;
|
||||||
|
}).join("");
|
||||||
|
const navStageMap = document.getElementById("navStageMap");
|
||||||
|
const navStageCount = document.getElementById("navStageCount");
|
||||||
|
if (navStageMap) {
|
||||||
|
navStageMap.innerHTML = modules.map((module, index) => `
|
||||||
|
<div class="stage-step">
|
||||||
|
<strong>${index + 1}. ${escapeHtml(localizedModuleTitle(module))}</strong>
|
||||||
|
<span class="muted">${(module.lesson_count || 0)} lessons</span>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
if (navStageCount) {
|
||||||
|
navStageCount.textContent = modules.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategoryGrid() {
|
||||||
|
const container = document.getElementById("categoryGrid");
|
||||||
|
const commandSet = new Set(state.overview?.commands || []);
|
||||||
|
const counts = {};
|
||||||
|
commandSet.forEach((cmd) => {
|
||||||
|
for (const category of COMMAND_CATEGORY_META) {
|
||||||
|
if (!counts[category.title]) {
|
||||||
|
counts[category.title] = 0;
|
||||||
|
}
|
||||||
|
const family = category.id;
|
||||||
|
const supported = window.COMMAND_FAMILIES?.[family] || [];
|
||||||
|
if (supported.includes(cmd)) {
|
||||||
|
counts[category.title]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.innerHTML = COMMAND_CATEGORY_META.map((category) => `
|
||||||
|
<article class="category-card">
|
||||||
|
<h4>${escapeHtml(category.title)}</h4>
|
||||||
|
<p>${escapeHtml(category.description)}</p>
|
||||||
|
<p style="margin-top: 8px; font-size: 13px;">课程命令:${counts[category.title] || 0}</p>
|
||||||
|
</article>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNavLessons() {
|
||||||
|
const container = document.getElementById("navLessonResults");
|
||||||
|
const modules = state.overview?.modules || [];
|
||||||
|
const exerciseCount = modules.reduce((sum, module) => sum + (module.exercise_count || 0), 0);
|
||||||
|
const lessonCountEl = document.getElementById("navLessonCount");
|
||||||
|
const exerciseCountEl = document.getElementById("navExerciseCount");
|
||||||
|
if (lessonCountEl) {
|
||||||
|
lessonCountEl.textContent = modules.length;
|
||||||
|
}
|
||||||
|
if (exerciseCountEl) {
|
||||||
|
exerciseCountEl.textContent = exerciseCount;
|
||||||
|
}
|
||||||
|
if (!modules.length) {
|
||||||
|
container.innerHTML = `<div class="empty">${escapeHtml(uiText().noModulesFound)}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = modules.map((module) => `
|
||||||
|
<button class="result" type="button" onclick="openLesson('${(module.lessons || [])[0]?.id || ''}')">
|
||||||
|
<strong>${escapeHtml(localizedModuleTitle(module))}</strong>
|
||||||
|
<span class="muted">${(module.lesson_count || 0)} lessons · ${(module.exercise_count || 0)} exercises</span>
|
||||||
|
</button>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginToLab(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const zh = state.language === 'zh';
|
||||||
|
const username = document.getElementById("gateUser").value.trim();
|
||||||
|
const password = document.getElementById("gatePassword").value.trim();
|
||||||
|
const messageNode = document.getElementById("gateMessage");
|
||||||
|
if (!username || !password) {
|
||||||
|
messageNode.textContent = zh ? "请输入用户名和密码" : "Please enter a username and password.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (payload.success) {
|
||||||
|
state.auth = { loggedIn: true, user: username, token: payload.token };
|
||||||
|
persistAuth();
|
||||||
|
hideAuthGate();
|
||||||
|
messageNode.textContent = payload.message || (zh ? "登录成功" : "Login succeeded.");
|
||||||
|
renderAuthControls();
|
||||||
|
await loadOverview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messageNode.textContent = payload.error || (zh ? "登录失败" : "Login failed.");
|
||||||
|
} catch (error) {
|
||||||
|
messageNode.textContent = zh ? "网络异常,请稍后再试" : "Network error. Please try again later.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoutLab() {
|
||||||
|
const zh = state.language === 'zh';
|
||||||
|
state.auth = { loggedIn: false, user: "", token: "" };
|
||||||
|
persistAuth();
|
||||||
|
showAuthGate();
|
||||||
|
renderAuthControls();
|
||||||
|
document.getElementById("gateMessage").textContent = zh ? "已退出,重新登录即可继续。" : "Logged out. Sign in again to continue.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVisualModules() {
|
||||||
|
renderStageTimeline();
|
||||||
|
renderCategoryGrid();
|
||||||
|
renderNavLessons();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("gateLoginForm").addEventListener("submit", loginToLab);
|
||||||
|
|
||||||
window.openLesson = openLesson;
|
window.openLesson = openLesson;
|
||||||
window.runExercise = runExercise;
|
window.runExercise = runExercise;
|
||||||
window.runSearch = runSearch;
|
window.runSearch = runSearch;
|
||||||
window.resetSandbox = resetSandbox;
|
window.resetSandbox = resetSandbox;
|
||||||
</script>
|
</script>
|
||||||
|
<div class="auth-gate" id="authGate">
|
||||||
|
<div class="auth-gate-card">
|
||||||
|
<h2 id="gateTitle">请先登录</h2>
|
||||||
|
<p id="gateDescription">登录后解锁课程总览、阶段地图、命令分类和练习仪表盘。</p>
|
||||||
|
<form id="gateLoginForm">
|
||||||
|
<input id="gateUser" type="text" placeholder="用户名" required />
|
||||||
|
<input id="gatePassword" type="password" placeholder="密码" required />
|
||||||
|
<button type="submit" id="gateSubmit">登录并进入总览</button>
|
||||||
|
</form>
|
||||||
|
<p id="gateMessage" class="muted">将自动保存进度与阶段状态,方便继续学习。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
11
server.py
11
server.py
@@ -26,15 +26,11 @@ PUBLIC_GET_PATHS = {
|
|||||||
"/",
|
"/",
|
||||||
"/privacy",
|
"/privacy",
|
||||||
"/privacy.html",
|
"/privacy.html",
|
||||||
"/api/course",
|
|
||||||
"/api/course/search",
|
|
||||||
"/api/diagnostics",
|
|
||||||
"/api/health",
|
"/api/health",
|
||||||
"/api/lesson",
|
|
||||||
"/api/overview",
|
|
||||||
}
|
}
|
||||||
PUBLIC_POST_PATHS = {
|
PUBLIC_POST_PATHS = {
|
||||||
"/api/login",
|
"/api/login",
|
||||||
|
"/api/logout",
|
||||||
}
|
}
|
||||||
SAFE_REMOTE_HOST = "xiaoxiaoluohao.indevs.in"
|
SAFE_REMOTE_HOST = "xiaoxiaoluohao.indevs.in"
|
||||||
|
|
||||||
@@ -1009,8 +1005,6 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
return False
|
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":
|
|
||||||
return True
|
|
||||||
if token == "safe_linux_2026":
|
if token == "safe_linux_2026":
|
||||||
return True
|
return True
|
||||||
if auth_header.startswith("Bearer ") and auth_header[7:] == "safe_linux_2026":
|
if auth_header.startswith("Bearer ") and auth_header[7:] == "safe_linux_2026":
|
||||||
@@ -1020,10 +1014,9 @@ class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
def require_auth_if_needed(self, path: str, method: str) -> bool:
|
def require_auth_if_needed(self, path: str, method: str) -> bool:
|
||||||
if self.is_public_path(path, method):
|
if self.is_public_path(path, method):
|
||||||
return True
|
return True
|
||||||
host = self.headers.get("Host", "")
|
|
||||||
auth_header = self.headers.get("Authorization", "")
|
auth_header = self.headers.get("Authorization", "")
|
||||||
token = self.headers.get("X-Token", "")
|
token = self.headers.get("X-Token", "")
|
||||||
if SAFE_REMOTE_HOST in host and not self.check_auth(auth_header, token):
|
if not self.check_auth(auth_header, token):
|
||||||
self.send_json({"error": "Authentication required"}, 401)
|
self.send_json({"error": "Authentication required"}, 401)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user