chore: refine linux learning lab overview and auth

This commit is contained in:
Codex
2026-03-24 17:31:18 +08:00
parent d61730bf17
commit 2f7bd50a36

View File

@@ -42,6 +42,11 @@
} }
button, input { font: inherit; } button, input { font: inherit; }
.shell { max-width: 1480px; margin: 0 auto; padding: 20px; } .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 { .topbar, .card {
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -408,6 +413,14 @@
border: 1px solid var(--line); border: 1px solid var(--line);
background: rgba(255,255,255,0.2); 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 { .category-card em {
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);
@@ -496,6 +509,55 @@
gap: 12px; gap: 12px;
margin-bottom: 16px; 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 { .overview-nav .nav-card {
padding: 16px; padding: 16px;
border-radius: 18px; border-radius: 18px;
@@ -542,7 +604,7 @@
</style> </style>
</head> </head>
<body> <body>
<div class="shell"> <div class="shell" id="contentShell">
<header class="topbar"> <header class="topbar">
<div class="title"> <div class="title">
<div class="eyebrow" id="topEyebrow">Linux Ops Learning Lab</div> <div class="eyebrow" id="topEyebrow">Linux Ops Learning Lab</div>
@@ -792,6 +854,7 @@
overviewEyebrow: '总览', overviewEyebrow: '总览',
overviewTitle: '课程总览', overviewTitle: '课程总览',
overviewText: '课程按模块和命令家族分组,重点是把摘要讲清楚、把沙箱练习串起来。', overviewText: '课程按模块和命令家族分组,重点是把摘要讲清楚、把沙箱练习串起来。',
navMoreLessons: '本模块还有 {count} 节课程可跳转。',
moduleCountLabel: '模块', moduleCountLabel: '模块',
lessonCountLabel: '课程', lessonCountLabel: '课程',
exerciseCountLabel: '练习', exerciseCountLabel: '练习',
@@ -921,6 +984,8 @@
requestFailed: '请求失败,请稍后再试。', requestFailed: '请求失败,请稍后再试。',
sandboxResetComplete: '沙箱已重置。', sandboxResetComplete: '沙箱已重置。',
commandFallback: '命令', commandFallback: '命令',
commandFamilyCountLabel: '课程命令数',
commandFamilyCommandsHint: '代表命令',
nameOrigin: '名字来源', nameOrigin: '名字来源',
learningHook: '记忆钩子', learningHook: '记忆钩子',
noOrigin: '暂无来源说明。', noOrigin: '暂无来源说明。',
@@ -974,6 +1039,7 @@
diagnosticSupported: 'All commands referenced by the course are covered by the sandbox command set.', 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.', diagnosticUnsupported: 'Some course commands are not fully supported by the sandbox yet.',
metaLine: (version, updated) => `Version ${version} | Updated ${updated}`, metaLine: (version, updated) => `Version ${version} | Updated ${updated}`,
navMoreLessons: '{count} more lessons inside this module.',
waitingCommandIndex: 'Waiting for command index', waitingCommandIndex: 'Waiting for command index',
noModulesFound: 'No modules found.', noModulesFound: 'No modules found.',
completedPrefix: 'Completed - ', completedPrefix: 'Completed - ',
@@ -1077,6 +1143,8 @@
requestFailed: 'The request failed. Please try again.', requestFailed: 'The request failed. Please try again.',
sandboxResetComplete: 'Sandbox reset complete.', sandboxResetComplete: 'Sandbox reset complete.',
commandFallback: 'command', commandFallback: 'command',
commandFamilyCountLabel: 'Commands in course',
commandFamilyCommandsHint: 'Representative commands',
nameOrigin: 'Name origin', nameOrigin: 'Name origin',
learningHook: 'Learning hook', learningHook: 'Learning hook',
noOrigin: 'No origin note yet.', noOrigin: 'No origin note yet.',
@@ -1548,12 +1616,20 @@
return fetch(path, Object.assign({}, options, { headers })); 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() { function showAuthGate() {
setExperienceLock(true);
document.getElementById('authGate').classList.remove('hidden'); document.getElementById('authGate').classList.remove('hidden');
} }
function hideAuthGate() { function hideAuthGate() {
document.getElementById('authGate').classList.add('hidden'); document.getElementById('authGate').classList.add('hidden');
setExperienceLock(false);
} }
async function loadOverview() { async function loadOverview() {
@@ -1915,12 +1991,14 @@
} }
const COMMAND_CATEGORY_META = [ const COMMAND_CATEGORY_META = [
{ id: "navigation", title: "导航", description: "帮你定位在系统中的上下文、目录和状态。" }, { id: "navigation", title: "导航", description: "帮你定位在系统中的上下文、目录和状态。", commands: ["pwd","ls","cd","which","whereis","clear"] },
{ id: "filesystem", title: "文件系统", description: "读写/移动/备份关键资产的安全操作。" }, { id: "filesystem", title: "文件系统", description: "读写/移动/备份关键资产的安全操作。", commands: ["cat","echo","mkdir","touch","cp","mv","rm","chmod","stat","tar"] },
{ id: "text", title: "文本处理", description: "过滤日志、提取字段、整理数据。" }, { id: "text", title: "文本处理", description: "过滤日志、提取字段、整理数据。", commands: ["grep","find","head","tail","sort","uniq","cut","awk","sed"] },
{ id: "observation", title: "观测", description: "实时洞察服务、进程、资源使用。" }, { id: "observation", title: "观测", description: "实时洞察服务、进程、资源使用。", commands: ["ps","top","uptime","free","df","du","lsof","last","w","history","date"] },
{ id: "service", title: "服务控制", description: "管理 systemd 单元、日志和守护进程。" }, { id: "service", title: "服务控制", description: "管理 systemd 单元、日志和守护进程。", commands: ["systemctl","journalctl","nohup","crontab"] },
{ id: "network", title: "网络", description: "检查连接、响应、DNS 及路由。" }, { 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) { function escapeHtml(value) {
@@ -1970,51 +2048,81 @@
function renderCategoryGrid() { function renderCategoryGrid() {
const container = document.getElementById("categoryGrid"); const container = document.getElementById("categoryGrid");
const commandSet = new Set(state.overview?.commands || []); const text = uiText();
const counts = {}; const commands = (state.overview?.commands || [])
commandSet.forEach((cmd) => { .map((cmd) => String(cmd).trim().toLowerCase())
for (const category of COMMAND_CATEGORY_META) { .filter(Boolean);
if (!counts[category.title]) { const counts = COMMAND_CATEGORY_META.reduce((acc, category) => {
counts[category.title] = 0; 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) => ` container.innerHTML = COMMAND_CATEGORY_META.map((category) => {
<article class="category-card"> const count = counts[category.id] || 0;
<h4>${escapeHtml(category.title)}</h4> const samples = (category.commands || []).slice(0, 3);
<p>${escapeHtml(category.description)}</p> return `
<p style="margin-top: 8px; font-size: 13px;">课程命令:${counts[category.title] || 0}</p> <article class="category-card">
</article> <h4>${escapeHtml(category.title)}</h4>
`).join(""); <p>${escapeHtml(category.description)}</p>
<div class="command-list">
<span>${escapeHtml(text.commandFamilyCountLabel)} ${count}</span>
${samples.length ? `<span>${escapeHtml(text.commandFamilyCommandsHint)} ${escapeHtml(samples.join(", "))}</span>` : ""}
</div>
</article>
`;
}).join("");
} }
function renderNavLessons() { function renderNavLessons() {
const container = document.getElementById("navLessonResults"); const container = document.getElementById("navLessonResults");
const modules = state.overview?.modules || []; const modules = state.overview?.modules || [];
const exerciseCount = modules.reduce((sum, module) => sum + (module.exercise_count || 0), 0); const exerciseCount = modules.reduce((sum, module) => sum + (module.exercise_count || 0), 0);
const lessonCountEl = document.getElementById("navLessonCount");
const exerciseCountEl = document.getElementById("navExerciseCount"); const exerciseCountEl = document.getElementById("navExerciseCount");
if (lessonCountEl) { const text = uiText();
lessonCountEl.textContent = modules.length;
}
if (exerciseCountEl) { if (exerciseCountEl) {
exerciseCountEl.textContent = exerciseCount; exerciseCountEl.textContent = exerciseCount;
} }
if (!modules.length) { if (!modules.length) {
container.innerHTML = `<div class="empty">${escapeHtml(uiText().noModulesFound)}</div>`; container.innerHTML = `<div class="empty">${escapeHtml(text.noModulesFound)}</div>`;
return; return;
} }
container.innerHTML = modules.map((module) => ` const visibleModules = modules.slice(0, 6);
<button class="result" type="button" onclick="openLesson('${(module.lessons || [])[0]?.id || ''}')"> container.innerHTML = visibleModules.map((module) => {
<strong>${escapeHtml(localizedModuleTitle(module))}</strong> const lessons = (module.lessons || []).slice(0, 3);
<span class="muted">${(module.lesson_count || 0)} lessons · ${(module.exercise_count || 0)} exercises</span> const lessonButtons = lessons.map((lesson) => `
</button> <button class="nav-lesson-link ${state.lesson?.id === lesson.id ? "active" : ""}" type="button" onclick="openLesson('${lesson.id}')">
`).join(""); <strong>${escapeHtml(localizedLessonTitle(lesson))}</strong>
<span class="muted">${escapeHtml((lesson.command_tokens || []).join(" | ") || lesson.command || text.commandFallback)}</span>
</button>
`).join("");
const declaredLessons = module.lesson_count || (module.lessons || []).length;
const extraLessons = Math.max(0, declaredLessons - lessons.length);
return `
<article class="nav-module-card">
<div class="nav-module-header">
<div>
<strong>${escapeHtml(localizedModuleTitle(module))}</strong>
<p class="muted" style="margin: 6px 0 0;">${escapeHtml(localizedModuleSummary(module))}</p>
</div>
<div class="nav-module-meta">
<span>${escapeHtml(text.stageLessons)} ${declaredLessons}</span>
<span>${escapeHtml(text.moduleSummaryExercises)} ${module.exercise_count || 0}</span>
</div>
</div>
<div class="lesson-navigator">
${lessonButtons}
</div>
${extraLessons ? `<div class="nav-module-extra">${escapeHtml(text.navMoreLessons.replace("{count}", extraLessons))}</div>` : ""}
</article>
`;
}).join("");
} }
async function loginToLab(event) { async function loginToLab(event) {
@@ -2049,10 +2157,15 @@
} }
} }
function logoutLab() { async function logoutLab() {
const zh = state.language === 'zh'; const zh = state.language === 'zh';
state.auth = { loggedIn: false, user: "", token: "" }; state.auth = { loggedIn: false, user: "", token: "" };
persistAuth(); persistAuth();
try {
await apiFetch('/api/logout', { method: 'POST' });
} catch (error) {
console.warn('Logout request failed', error);
}
showAuthGate(); showAuthGate();
renderAuthControls(); renderAuthControls();
document.getElementById("gateMessage").textContent = zh ? "已退出,重新登录即可继续。" : "Logged out. Sign in again to continue."; document.getElementById("gateMessage").textContent = zh ? "已退出,重新登录即可继续。" : "Logged out. Sign in again to continue.";