chore: refine linux learning lab overview and auth
This commit is contained in:
189
index.html
189
index.html
@@ -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.";
|
||||||
|
|||||||
Reference in New Issue
Block a user