diff --git a/index.html b/index.html index 4b2dfec..218c753 100644 --- a/index.html +++ b/index.html @@ -42,6 +42,11 @@ } button, input { font: inherit; } .shell { max-width: 1480px; margin: 0 auto; padding: 20px; } + .shell.locked { + opacity: 0.45; + filter: blur(0.5px); + transition: opacity 0.2s ease, filter 0.2s ease; + } .topbar, .card { background: var(--panel); border: 1px solid var(--line); @@ -408,6 +413,14 @@ border: 1px solid var(--line); background: rgba(255,255,255,0.2); } + .category-card .command-list { + margin-top: 10px; + font-size: 12px; + color: var(--muted); + display: flex; + flex-wrap: wrap; + gap: 6px; + } .category-card em { font-size: 12px; color: var(--muted); @@ -496,6 +509,55 @@ gap: 12px; margin-bottom: 16px; } + .nav-module-card { + padding: 14px; + border-radius: 18px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.18); + display: flex; + flex-direction: column; + gap: 10px; + } + .nav-module-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + } + .nav-module-meta { + display: flex; + gap: 10px; + flex-wrap: wrap; + font-size: 12px; + color: var(--muted); + } + .lesson-navigator { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .nav-lesson-link { + flex: 1 1 180px; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 255, 255, 0.1); + padding: 10px 12px; + text-align: left; + color: var(--text); + } + .nav-lesson-link.active { + border-color: rgba(15, 109, 182, 0.6); + background: rgba(15, 109, 182, 0.1); + } + .nav-lesson-link strong { + display: block; + font-size: 14px; + margin-bottom: 4px; + } + .nav-module-extra { + font-size: 12px; + color: var(--muted); + } .overview-nav .nav-card { padding: 16px; border-radius: 18px; @@ -542,7 +604,7 @@ -
+
Linux Ops Learning Lab
@@ -792,6 +854,7 @@ overviewEyebrow: '总览', overviewTitle: '课程总览', overviewText: '课程按模块和命令家族分组,重点是把摘要讲清楚、把沙箱练习串起来。', + navMoreLessons: '本模块还有 {count} 节课程可跳转。', moduleCountLabel: '模块', lessonCountLabel: '课程', exerciseCountLabel: '练习', @@ -921,6 +984,8 @@ requestFailed: '请求失败,请稍后再试。', sandboxResetComplete: '沙箱已重置。', commandFallback: '命令', + commandFamilyCountLabel: '课程命令数', + commandFamilyCommandsHint: '代表命令', nameOrigin: '名字来源', learningHook: '记忆钩子', noOrigin: '暂无来源说明。', @@ -974,6 +1039,7 @@ diagnosticSupported: 'All commands referenced by the course are covered by the sandbox command set.', diagnosticUnsupported: 'Some course commands are not fully supported by the sandbox yet.', metaLine: (version, updated) => `Version ${version} | Updated ${updated}`, + navMoreLessons: '{count} more lessons inside this module.', waitingCommandIndex: 'Waiting for command index', noModulesFound: 'No modules found.', completedPrefix: 'Completed - ', @@ -1077,6 +1143,8 @@ requestFailed: 'The request failed. Please try again.', sandboxResetComplete: 'Sandbox reset complete.', commandFallback: 'command', + commandFamilyCountLabel: 'Commands in course', + commandFamilyCommandsHint: 'Representative commands', nameOrigin: 'Name origin', learningHook: 'Learning hook', noOrigin: 'No origin note yet.', @@ -1548,12 +1616,20 @@ return fetch(path, Object.assign({}, options, { headers })); } + function setExperienceLock(locked) { + const shell = document.getElementById('contentShell'); + if (!shell) return; + shell.classList.toggle('locked', locked); + } + function showAuthGate() { + setExperienceLock(true); document.getElementById('authGate').classList.remove('hidden'); } function hideAuthGate() { document.getElementById('authGate').classList.add('hidden'); + setExperienceLock(false); } async function loadOverview() { @@ -1915,12 +1991,14 @@ } 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 及路由。" }, + { id: "navigation", title: "导航", description: "帮你定位在系统中的上下文、目录和状态。", commands: ["pwd","ls","cd","which","whereis","clear"] }, + { id: "filesystem", title: "文件系统", description: "读写/移动/备份关键资产的安全操作。", commands: ["cat","echo","mkdir","touch","cp","mv","rm","chmod","stat","tar"] }, + { id: "text", title: "文本处理", description: "过滤日志、提取字段、整理数据。", commands: ["grep","find","head","tail","sort","uniq","cut","awk","sed"] }, + { id: "observation", title: "观测", description: "实时洞察服务、进程、资源使用。", commands: ["ps","top","uptime","free","df","du","lsof","last","w","history","date"] }, + { id: "service", title: "服务控制", description: "管理 systemd 单元、日志和守护进程。", commands: ["systemctl","journalctl","nohup","crontab"] }, + { id: "network", title: "网络", description: "检查连接、响应、DNS 及路由。", commands: ["ip","ping","ss","curl","wget","netstat","traceroute","dig"] }, + { id: "identity", title: "身份", description: "了解当前身份、环境变量与别名设置。", commands: ["whoami","id","env","export","alias"] }, + { id: "package", title: "包管理", description: "检查、安装与验证平台级软件。", commands: ["apt","yum","rpm","dpkg"] }, ]; function escapeHtml(value) { @@ -1970,51 +2048,81 @@ 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 text = uiText(); + const commands = (state.overview?.commands || []) + .map((cmd) => String(cmd).trim().toLowerCase()) + .filter(Boolean); + const counts = COMMAND_CATEGORY_META.reduce((acc, category) => { + acc[category.id] = 0; + return acc; + }, {}); + commands.forEach((cmd) => { + COMMAND_CATEGORY_META.forEach((category) => { + const family = (category.commands || []).map((item) => String(item).toLowerCase()); + if (family.includes(cmd)) { + counts[category.id] = (counts[category.id] || 0) + 1; } - 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(""); + container.innerHTML = COMMAND_CATEGORY_META.map((category) => { + const count = counts[category.id] || 0; + const samples = (category.commands || []).slice(0, 3); + return ` +
+

${escapeHtml(category.title)}

+

${escapeHtml(category.description)}

+
+ ${escapeHtml(text.commandFamilyCountLabel)} ${count} + ${samples.length ? `${escapeHtml(text.commandFamilyCommandsHint)} ${escapeHtml(samples.join(", "))}` : ""} +
+
+ `; + }).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; - } + const text = uiText(); if (exerciseCountEl) { exerciseCountEl.textContent = exerciseCount; } if (!modules.length) { - container.innerHTML = `
${escapeHtml(uiText().noModulesFound)}
`; + container.innerHTML = `
${escapeHtml(text.noModulesFound)}
`; return; } - container.innerHTML = modules.map((module) => ` - - `).join(""); + const visibleModules = modules.slice(0, 6); + container.innerHTML = visibleModules.map((module) => { + const lessons = (module.lessons || []).slice(0, 3); + const lessonButtons = lessons.map((lesson) => ` + + `).join(""); + const declaredLessons = module.lesson_count || (module.lessons || []).length; + const extraLessons = Math.max(0, declaredLessons - lessons.length); + return ` + + `; + }).join(""); } async function loginToLab(event) { @@ -2049,10 +2157,15 @@ } } - function logoutLab() { + async function logoutLab() { const zh = state.language === 'zh'; state.auth = { loggedIn: false, user: "", token: "" }; persistAuth(); + try { + await apiFetch('/api/logout', { method: 'POST' }); + } catch (error) { + console.warn('Logout request failed', error); + } showAuthGate(); renderAuthControls(); document.getElementById("gateMessage").textContent = zh ? "已退出,重新登录即可继续。" : "Logged out. Sign in again to continue.";