feat: polish linux lab cockpit and auth flow
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,3 +5,7 @@ __pycache__/
|
||||
|
||||
# OpenClaw interactive edit backups
|
||||
*.interactive.backup.*
|
||||
|
||||
# Runtime logs
|
||||
server.out.log
|
||||
server.err.log
|
||||
|
||||
267
index.html
267
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 @@
|
||||
</div>
|
||||
<div class="diagnostic" id="diagnosticNote" style="margin-top: 12px;">Checking course and sandbox alignment...</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="eyebrow">登录培训入口</div>
|
||||
<h2>登录并解锁专属实验</h2>
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<div>
|
||||
<strong id="authModeLabel">未登录</strong>
|
||||
<p style="margin:4px 0 0; font-size:12px; color:var(--muted);" id="authHint">登录后可访问加速练习、保存进度、查看历史。</p>
|
||||
</div>
|
||||
<span class="auth-status" id="authStatusBadge">公开访问</span>
|
||||
</div>
|
||||
<form id="loginForm">
|
||||
<input id="authUser" type="text" placeholder="用户名" required />
|
||||
<input id="authPassword" type="password" placeholder="密码" required />
|
||||
<button type="submit" class="btn">登录并解锁</button>
|
||||
</form>
|
||||
<button type="button" class="btn-soft" id="logoutBtn" style="display:none;">退出登录</button>
|
||||
<div id="authMessage" style="font-size:13px; color:var(--muted);"></div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
@@ -482,6 +589,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>
|
||||
</section>
|
||||
|
||||
<section class="detail card">
|
||||
<div class="eyebrow">Stage map</div>
|
||||
<h2>阶段路线清单</h2>
|
||||
<p class="muted">按阶段划分的学习路线,帮你把命令练习串联成一条完整的运维路径。</p>
|
||||
<div class="stage-map" id="stageMap"></div>
|
||||
<div class="progress-track" id="progressTrack"></div>
|
||||
</section>
|
||||
|
||||
<section class="detail card">
|
||||
<div class="eyebrow">Command categories</div>
|
||||
<h2>命令家族可视化</h2>
|
||||
<p class="muted">了解各类命令在当前课程中的分布,以及建议的学习顺序。</p>
|
||||
<div class="category-grid" id="categoryGrid"></div>
|
||||
</section>
|
||||
|
||||
<section class="detail card">
|
||||
<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>
|
||||
@@ -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 = "<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("");
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
|
||||
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();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user