diff --git a/.gitignore b/.gitignore
index 8fa5518..d03a13e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,7 @@ __pycache__/
# OpenClaw interactive edit backups
*.interactive.backup.*
+
+# Runtime logs
+server.out.log
+server.err.log
diff --git a/index.html b/index.html
index 07922d9..805bc98 100644
--- a/index.html
+++ b/index.html
@@ -342,6 +342,92 @@
font-size: 15px;
line-height: 1.6;
}
+ .auth-card {
+ padding: 18px;
+ border-radius: 18px;
+ border: 1px solid var(--line);
+ background: rgba(255,255,255,0.18);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+ .auth-card .auth-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 10px;
+ }
+ .auth-card form {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .auth-card input {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px 12px;
+ background: transparent;
+ color: var(--text);
+ }
+ .auth-status {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--accent);
+ }
+ .stage-map {
+ padding: 18px;
+ border-radius: 20px;
+ border: 1px solid var(--line);
+ background: rgba(255,255,255,0.2);
+ display: grid;
+ gap: 12px;
+ }
+ .stage-map .stage-step {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+ .stage-map .stage-step::before {
+ content: "";
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--soft);
+ border: 2px solid var(--brand);
+ flex-shrink: 0;
+ }
+ .category-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 10px;
+ margin-top: 12px;
+ }
+ .category-card {
+ padding: 14px;
+ border-radius: 16px;
+ border: 1px solid var(--line);
+ background: rgba(255,255,255,0.2);
+ }
+ .category-card em {
+ font-size: 12px;
+ color: var(--muted);
+ }
+ .progress-track {
+ display: flex;
+ gap: 12px;
+ overflow-x: auto;
+ margin-top: 10px;
+ }
+ .progress-pill {
+ flex: 0 0 auto;
+ padding: 10px 16px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: rgba(255,255,255,0.25);
+ font-size: 13px;
+ color: var(--text);
+ min-width: 160px;
+ }
.variant-list {
display: flex;
flex-direction: column;
@@ -448,6 +534,27 @@
Checking course and sandbox alignment...
+
+
@@ -482,6 +589,21 @@
Master a lesson after you can explain the command, predict the output, and connect it to a real operations step.
+
+ Stage map
+ 阶段路线清单
+ 按阶段划分的学习路线,帮你把命令练习串联成一条完整的运维路径。
+
+
+
+
+
+ Command categories
+ 命令家族可视化
+ 了解各类命令在当前课程中的分布,以及建议的学习顺序。
+
+
+
Learning stages
System route from Linux basics to operations habits
@@ -1248,12 +1370,18 @@
}
}
+ const persistedAuth = JSON.parse(localStorage.getItem('linux_lab_auth') || '{}');
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') || '{}')
+ progress: JSON.parse(localStorage.getItem('linux_lab_progress') || '{}'),
+ auth: {
+ loggedIn: persistedAuth.loggedIn || false,
+ user: persistedAuth.user || '',
+ token: persistedAuth.token || ''
+ }
};
document.addEventListener('DOMContentLoaded', async () => {
@@ -1272,6 +1400,7 @@
});
document.getElementById('masteryBtn').addEventListener('click', toggleMastery);
await loadOverview();
+ renderAuthStatus();
});
function saveProgress() {
@@ -1301,6 +1430,26 @@
renderLesson();
}
+ function persistAuth() {
+ localStorage.setItem('linux_lab_auth', JSON.stringify(state.auth));
+ }
+
+ function renderAuthStatus() {
+ const badge = document.getElementById('authStatusBadge');
+ const modeLabel = document.getElementById('authModeLabel');
+ const hint = document.getElementById('authHint');
+ const form = document.getElementById('loginForm');
+ const logoutBtn = document.getElementById('logoutBtn');
+ badge.textContent = state.auth.loggedIn ? '已登录' : '公开访问';
+ modeLabel.textContent = state.auth.loggedIn ? `当前登录:${state.auth.user}` : '未登录';
+ hint.textContent = state.auth.loggedIn
+ ? '您已解锁全部实验和进度保存功能。'
+ : '登录后可解锁任务追踪、保存进度和补全排障建议提示。';
+ form.style.display = state.auth.loggedIn ? 'none' : 'flex';
+ logoutBtn.style.display = state.auth.loggedIn ? 'inline-flex' : 'none';
+ badge.classList.toggle('auth-status', !!state.auth.loggedIn);
+ }
+
async function loadOverview() {
const response = await fetch('/api/overview');
state.overview = await response.json();
@@ -1658,19 +1807,125 @@
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) {
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 = "尚无阶段数据
";
+ return;
+ }
+ map.innerHTML = modules.map((module, index) => `
+
+ ${index + 1}. ${escapeHtml(localizedModuleTitle(module))}
+ ${(module.lesson_count || 0)} lessons · ${(module.exercise_count || 0)} exercises
+
+ `).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 `${escapeHtml(localizedModuleTitle(module))}: ${percent}%`;
+ }).join("");
+ }
+
+ 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) => `
+
+ ${escapeHtml(category.title)}
+ ${escapeHtml(category.description)}
+ 课程命令:${counts[category.title] || 0}
+
+ `).join("");
+ }
+
+ async function loginToLab(event) {
+ event.preventDefault();
+ const username = document.getElementById("authUser").value.trim();
+ const password = document.getElementById("authPassword").value.trim();
+ const messageNode = document.getElementById("authMessage");
+ if (!username || !password) {
+ messageNode.textContent = "请输入用户名和密码";
+ 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 };
+ messageNode.textContent = payload.message || "登录成功";
+ document.getElementById("authStatusBadge").textContent = "已登录";
+ document.getElementById("authModeLabel").textContent = `当前登录:${username}`;
+ document.getElementById("logoutBtn").style.display = "inline-flex";
+ document.getElementById("loginForm").style.display = "none";
+ return;
+ }
+ messageNode.textContent = payload.error || "登录失败";
+ } catch (error) {
+ messageNode.textContent = "网络异常,请稍后再试";
+ }
+ }
+
+ function logoutLab() {
+ state.auth = { loggedIn: false, user: "", token: "" };
+ document.getElementById("authMessage").textContent = "已退出";
+ document.getElementById("authStatusBadge").textContent = "仅公开";
+ document.getElementById("authModeLabel").textContent = "未登录";
+ document.getElementById("logoutBtn").style.display = "none";
+ document.getElementById("loginForm").style.display = "flex";
+ }
+
+ function renderVisualModules() {
+ renderStageTimeline();
+ renderCategoryGrid();
+ }
+
+ document.getElementById("loginForm").addEventListener("submit", loginToLab);
+ document.getElementById("logoutBtn").addEventListener("click", logoutLab);
+
window.openLesson = openLesson;
window.runExercise = runExercise;
window.runSearch = runSearch;
window.resetSandbox = resetSandbox;
+ renderVisualModules();