feat: finish bilingual learning auth cockpit

This commit is contained in:
Codex
2026-03-23 13:05:44 +08:00
parent 09574c3400
commit bde4f6b9cf
56 changed files with 5043 additions and 1377 deletions

View File

@@ -0,0 +1,389 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spring Learning Login</title>
<style>
:root {
--bg: linear-gradient(135deg, #eef5ff 0%, #f8fff4 100%);
--card: rgba(255, 255, 255, 0.94);
--line: #dbe6f2;
--text: #122033;
--muted: #5d7288;
--brand: #177245;
--accent: #0f67b5;
--shadow: 0 20px 48px rgba(18, 32, 51, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(23, 114, 69, 0.13), transparent 30%),
radial-gradient(circle at bottom right, rgba(15, 103, 181, 0.12), transparent 24%),
var(--bg);
}
.page {
max-width: 1180px;
margin: 0 auto;
padding: 24px;
}
.hero,
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow);
}
.hero {
padding: 28px;
margin-bottom: 18px;
}
.eyebrow {
display: inline-flex;
padding: 7px 12px;
border-radius: 999px;
background: rgba(23, 114, 69, 0.1);
color: var(--brand);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1, h2, h3 { margin: 10px 0 12px; }
p {
margin: 0;
color: var(--muted);
line-height: 1.8;
}
.layout {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 18px;
}
.card {
padding: 22px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 14px;
}
label {
font-size: 13px;
font-weight: 700;
color: #21384f;
}
input {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
background: transparent;
color: var(--text);
outline: none;
font: inherit;
}
.actions,
.chips {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn,
.btn-soft {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 999px;
padding: 12px 18px;
cursor: pointer;
font-weight: 700;
}
.btn {
color: #fff;
background: linear-gradient(135deg, var(--brand), #35a465);
}
.btn-soft {
color: var(--accent);
background: #eaf3ff;
}
.note-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
}
.note {
padding: 16px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.5);
}
.status {
min-height: 22px;
margin-top: 12px;
color: var(--muted);
}
.status.error { color: #d64545; }
.status.success { color: #177245; }
pre {
margin: 16px 0 0;
min-height: 220px;
padding: 16px;
border-radius: 18px;
background: #0f1621;
color: #deebff;
white-space: pre-wrap;
font-family: Consolas, "Courier New", monospace;
overflow: auto;
}
.chips span {
display: inline-flex;
padding: 6px 10px;
border-radius: 999px;
background: rgba(15, 103, 181, 0.08);
color: var(--accent);
font-size: 12px;
font-weight: 700;
}
@media (max-width: 920px) {
.layout { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.page { padding: 14px; }
}
</style>
</head>
<body>
<div class="page">
<section class="hero">
<div class="eyebrow" data-i18n="heroBadge"></div>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="actions" style="margin-top:18px;">
<a class="btn-soft" href="/" data-i18n="homeLink"></a>
<a class="btn-soft" href="/users.html" data-i18n="usersLink"></a>
<a class="btn-soft" href="/aop.html" data-i18n="aopLink"></a>
<a class="btn-soft" href="/events.html" data-i18n="eventsLink"></a>
</div>
</section>
<div class="layout">
<section class="card">
<div class="eyebrow" data-i18n="loginBadge"></div>
<h2 data-i18n="loginTitle"></h2>
<p data-i18n="loginText"></p>
<div class="field">
<label for="username" data-i18n="usernameLabel"></label>
<input id="username" value="admin" autocomplete="username">
</div>
<div class="field">
<label for="password" data-i18n="passwordLabel"></label>
<input id="password" type="password" value="admin123" autocomplete="current-password">
</div>
<div class="actions" style="margin-top:18px;">
<button class="btn" type="button" id="loginButton" data-i18n="loginButton" onclick="login()"></button>
<button class="btn-soft" type="button" id="checkButton" data-i18n="checkButton" onclick="introspectToken()"></button>
<button class="btn-soft" type="button" id="logoutButton" data-i18n="logoutButton" onclick="logout()"></button>
</div>
<div class="status" id="statusBox"></div>
<pre id="resultBox"></pre>
</section>
<aside class="card">
<div class="eyebrow" data-i18n="guideBadge"></div>
<h2 data-i18n="guideTitle"></h2>
<div class="note-list">
<div class="note">
<strong data-i18n="note1Title"></strong>
<p data-i18n="note1Text"></p>
</div>
<div class="note">
<strong data-i18n="note2Title"></strong>
<p data-i18n="note2Text"></p>
</div>
<div class="note">
<strong data-i18n="note3Title"></strong>
<p data-i18n="note3Text"></p>
</div>
</div>
<div class="eyebrow" style="margin-top:20px;" data-i18n="accountBadge"></div>
<div class="chips" style="margin-top:12px;">
<span>admin / admin123</span>
<span>user / user123</span>
</div>
</aside>
</div>
</div>
<script src="/learning-shell.js"></script>
<script>
const I18N = {
zh: {
title: "Spring \u5b66\u4e60\u767b\u5f55\u9875",
heroBadge: "\u5b66\u4e60\u9274\u6743\u5165\u53e3",
heroTitle: "\u5148\u5b8c\u6210\u6f14\u793a\u767b\u5f55\uff0c\u518d\u8fdb\u5165\u53d7\u4fdd\u62a4\u5b9e\u9a8c\u3002",
heroText: "\u8fd9\u4e2a\u9875\u9762\u628a\u540e\u7aef\u5df2\u6709\u7684 JWT \u767b\u5f55\u80fd\u529b\u53d8\u6210\u771f\u6b63\u53ef\u89c1\u7684\u5b66\u4e60\u5165\u53e3\uff0c\u65b9\u4fbf\u4f60\u5728\u516c\u5f00 VPS \u73af\u5883\u91cc\u5148\u767b\u5f55\uff0c\u518d\u4f53\u9a8c\u7528\u6237\u3001AOP\u3001\u4e8b\u4ef6\u548c\u9ad8\u7ea7\u5b9e\u9a8c\u3002",
homeLink: "\u8fd4\u56de\u9996\u9875",
usersLink: "\u6253\u5f00\u7528\u6237\u5b9e\u9a8c",
aopLink: "\u6253\u5f00 AOP \u5b9e\u9a8c",
eventsLink: "\u6253\u5f00\u4e8b\u4ef6\u5b9e\u9a8c",
loginBadge: "\u767b\u5f55\u8868\u5355",
loginTitle: "\u4f7f\u7528\u6f14\u793a\u8d26\u53f7\u6362\u53d6 Bearer Token",
loginText: "\u767b\u5f55\u6210\u529f\u540e\uff0ctoken \u4f1a\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u672c\u5730\u3002\u4e4b\u540e\u672c\u9879\u76ee\u4e2d\u7684\u524d\u7aef\u9875\u9762\u4f1a\u81ea\u52a8\u643a\u5e26 Authorization \u5934\u8bbf\u95ee\u53d7\u4fdd\u62a4\u63a5\u53e3\u3002",
usernameLabel: "\u7528\u6237\u540d",
passwordLabel: "\u5bc6\u7801",
loginButton: "\u7acb\u5373\u767b\u5f55",
checkButton: "\u68c0\u67e5\u5f53\u524d\u4ee4\u724c",
logoutButton: "\u6e05\u9664\u4ee4\u724c",
guideBadge: "\u5b66\u4e60\u63d0\u793a",
guideTitle: "\u8fd9\u4e00\u6b65\u89e3\u51b3\u4e86\u4ec0\u4e48\u95ee\u9898",
note1Title: "1. \u8ba9\u767b\u5f55\u4e0d\u518d\u53ea\u662f\u540e\u7aef\u63a5\u53e3",
note1Text: "\u4e4b\u524d\u540e\u7aef\u63a5\u53e3\u5df2\u7ecf\u5b58\u5728\uff0c\u4f46\u9875\u9762\u91cc\u6ca1\u6709\u5165\u53e3\u3002\u73b0\u5728\u4f60\u53ef\u4ee5\u4ece\u524d\u7aef\u5b8c\u6210\u767b\u5f55\u3001\u4ee4\u724c\u6301\u4e45\u5316\u548c\u4ee4\u724c\u68c0\u67e5\u3002",
note2Title: "2. \u628a\u53d7\u4fdd\u62a4\u8def\u7531\u53d8\u6210\u53ef\u89c2\u5bdf\u6982\u5ff5",
note2Text: "\u767b\u5f55\u540e\u518d\u8bbf\u95ee /api/users/**\u3001/aop/**\u3001/learn/** \u548c /api/lab/**\uff0c\u5c31\u80fd\u6e05\u6670\u5bf9\u6bd4\u767b\u5f55\u524d\u540e\u7684\u884c\u4e3a\u5dee\u5f02\u3002",
note3Title: "3. \u4e3a\u4e2d\u82f1\u5207\u6362\u9884\u7559\u7edf\u4e00\u5165\u53e3",
note3Text: "\u9875\u9762\u9876\u90e8\u5df2\u7ecf\u63a5\u4e0a\u5171\u4eab\u5de5\u4f5c\u53f0\u811a\u672c\uff0c\u8bed\u8a00\u504f\u597d\u548c\u767b\u5f55\u72b6\u6001\u4f1a\u5728\u9875\u9762\u95f4\u540c\u6b65\u3002",
accountBadge: "\u6f14\u793a\u8d26\u53f7",
ready: "\u51c6\u5907\u5c31\u7eea\u3002\u4f60\u53ef\u4ee5\u76f4\u63a5\u4f7f\u7528\u9ed8\u8ba4\u8d26\u53f7\u767b\u5f55\u3002",
loginSuccess: "\u767b\u5f55\u6210\u529f\uff0c\u4ee4\u724c\u5df2\u4fdd\u5b58\u5230\u672c\u5730\u3002",
logoutSuccess: "\u672c\u5730\u4ee4\u724c\u5df2\u6e05\u9664\u3002",
introspectSuccess: "\u4ee4\u724c\u81ea\u68c0\u5b8c\u6210\u3002",
missingToken: "\u5f53\u524d\u6ca1\u6709\u53ef\u68c0\u67e5\u7684\u4ee4\u724c\uff0c\u8bf7\u5148\u767b\u5f55\u3002"
},
en: {
title: "Spring Learning Login",
heroBadge: "Auth Entry",
heroTitle: "Complete demo login before entering protected labs.",
heroText: "This page turns the existing backend JWT capability into a visible learning entry so you can authenticate first and then explore users, AOP, events, and advanced labs on a public VPS.",
homeLink: "Back home",
usersLink: "Open user lab",
aopLink: "Open AOP lab",
eventsLink: "Open event lab",
loginBadge: "Login Form",
loginTitle: "Exchange demo credentials for a Bearer token",
loginText: "After login, the token is stored locally. Frontend pages in this project will automatically attach the Authorization header when calling protected APIs.",
usernameLabel: "Username",
passwordLabel: "Password",
loginButton: "Login now",
checkButton: "Inspect token",
logoutButton: "Clear token",
guideBadge: "Study Notes",
guideTitle: "What this step solves",
note1Title: "1. Login is no longer only a backend endpoint",
note1Text: "The backend endpoint already existed, but there was no visible entry on the frontend. Now you can complete login, token persistence, and token inspection in the browser.",
note2Title: "2. Protected routes become observable",
note2Text: "After login, revisit /api/users/**, /aop/**, /learn/**, and /api/lab/** to compare protected behavior.",
note3Title: "3. Shared groundwork for language switching",
note3Text: "The shared workspace script is already mounted at the top of the page, so language preference and login state now sync across pages.",
accountBadge: "Demo Accounts",
ready: "Ready. You can sign in with the default accounts.",
loginSuccess: "Login succeeded and the token was saved locally.",
logoutSuccess: "Local token has been cleared.",
introspectSuccess: "Token introspection completed.",
missingToken: "There is no token to inspect yet. Please log in first."
}
};
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
});
}
function renderLanguage() {
const text = pageText();
applyTranslations(text);
const resultBox = document.getElementById("resultBox");
if (!resultBox.dataset.state || resultBox.dataset.state === "idle") {
resultBox.textContent = text.ready;
resultBox.dataset.state = "idle";
}
}
function setStatus(message, type) {
const box = document.getElementById("statusBox");
box.textContent = message;
box.className = type ? "status " + type : "status";
}
async function login() {
const username = document.getElementById("username").value.trim();
const password = document.getElementById("password").value;
try {
const payload = await window.learningShell.requestJson("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username, password: password })
});
window.learningShell.saveAuth(payload.data.token, payload.data.username);
setStatus(pageText().loginSuccess, "success");
document.getElementById("resultBox").textContent = JSON.stringify(payload, null, 2);
document.getElementById("resultBox").dataset.state = "live";
} catch (error) {
setStatus(window.learningShell.describeError(error), "error");
document.getElementById("resultBox").textContent = JSON.stringify(
error.payload || { message: error.message },
null,
2
);
document.getElementById("resultBox").dataset.state = "live";
}
}
async function introspectToken() {
const token = window.learningShell.getToken();
if (!token) {
setStatus(pageText().missingToken, "error");
return;
}
try {
const payload = await window.learningShell.requestJson("/api/auth/introspect");
setStatus(pageText().introspectSuccess, "success");
document.getElementById("resultBox").textContent = JSON.stringify(payload, null, 2);
document.getElementById("resultBox").dataset.state = "live";
} catch (error) {
setStatus(window.learningShell.describeError(error), "error");
document.getElementById("resultBox").textContent = JSON.stringify(
error.payload || { message: error.message },
null,
2
);
document.getElementById("resultBox").dataset.state = "live";
}
}
function logout() {
window.learningShell.clearAuth();
setStatus(pageText().logoutSuccess, "success");
document.getElementById("resultBox").textContent = JSON.stringify({ cleared: true }, null, 2);
document.getElementById("resultBox").dataset.state = "live";
}
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
</script>
</body>
</html>

View File

@@ -3,220 +3,347 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AOP 切面编程 - Spring Boot</title>
<title>Spring AOP Lab</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
h1 { color: #6DB33F; margin: 20px 0; }
h2 { color: #333; border-bottom: 2px solid #6DB33F; padding-bottom: 10px; margin: 20px 0 15px; }
h3 { color: #6DB33F; margin: 15px 0 10px; }
.card { background: white; padding: 25px; margin: 15px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.btn { display: inline-block; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 500; cursor: pointer; border: none; }
.btn-primary { background: #6DB33F; color: white; }
.btn-primary:hover { background: #5da32f; }
.btn-info { background: #17a2b8; color: white; }
code { background: #f0f0f0; padding: 2px 8px; border-radius: 4px; font-family: 'Fira Code', monospace; }
pre { background: #2d2d2d; color: #f8f8f2; padding: 20px; border-radius: 8px; overflow-x: auto; margin: 15px 0; }
pre code { background: none; color: inherit; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background: #6DB33F; color: white; }
tr:nth-child(even) { background: #f9f9f9; }
.tip { background: #e7f3ff; padding: 15px; border-radius: 8px; margin: 15px 0; border-left: 4px solid #6DB33F; }
.warn { background: #fff3cd; padding: 15px; border-radius: 8px; margin: 15px 0; border-left: 4px solid #ffc107; }
.result-box { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 8px; font-family: monospace; white-space: pre-wrap; margin-top: 10px; }
.nav { margin-bottom: 20px; }
.nav a { margin-right: 15px; color: #6DB33F; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.lab { background:#fff7e6; border-left:4px solid #fa8c16; padding:15px; border-radius:8px; margin:15px 0; }
.lab h4 { color:#ad6800; margin-bottom:8px; }
:root {
--bg: #eef7ef;
--panel: rgba(255,255,255,0.95);
--line: #d8e7d8;
--text: #102033;
--muted: #5c7184;
--brand: #3f8f2c;
--accent: #0f67b5;
--shadow: 0 20px 48px rgba(16, 32, 51, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top right, rgba(63, 143, 44, 0.14), transparent 30%),
radial-gradient(circle at bottom left, rgba(15, 103, 181, 0.1), transparent 24%),
var(--bg);
}
.page { max-width: 1280px; margin: 0 auto; padding: 24px; }
.hero, .card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow);
}
.hero { padding: 28px; margin-bottom: 18px; }
.eyebrow {
display: inline-flex;
padding: 7px 12px;
border-radius: 999px;
background: rgba(63, 143, 44, 0.1);
color: var(--brand);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1, h2, h3 { margin: 10px 0 12px; }
p { margin: 0; color: var(--muted); line-height: 1.8; }
.actions, .toolbar, .flow, .chips {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn, .btn-soft {
border: 0;
border-radius: 999px;
padding: 12px 18px;
font-weight: 700;
cursor: pointer;
}
.btn { background: linear-gradient(135deg, var(--brand), #62b049); color: #fff; }
.btn-soft { background: #eaf3ff; color: var(--accent); }
.grid {
display: grid;
gap: 18px;
grid-template-columns: 420px minmax(0, 1fr);
}
.card { padding: 22px; }
.list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
}
.list-item, .flow-step {
padding: 14px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.45);
}
.flow {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
margin-top: 14px;
}
.pill {
display: inline-flex;
padding: 6px 10px;
border-radius: 999px;
background: rgba(15, 103, 181, 0.08);
color: var(--accent);
font-size: 12px;
font-weight: 700;
}
pre {
margin: 0;
min-height: 280px;
padding: 16px;
border-radius: 18px;
background: #0f1621;
color: #deebff;
white-space: pre-wrap;
font-family: Consolas, "Courier New", monospace;
overflow: auto;
}
@media (max-width: 1060px) {
.grid, .flow { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.page { padding: 14px; }
}
</style>
</head>
<body>
<div class="nav">
<a href="/">← 返回首页</a>
<a href="/users.html">用户管理</a>
<a href="/events.html">事件机制</a>
</div>
<h1>🔪 AOP 切面编程</h1>
<div class="lab">
<h4>🧪 实验任务卡AOP</h4>
<label style="display:block;margin-bottom:8px;"><input id="aopTaskDone" type="checkbox" onchange="toggleAopTaskDone(this)"> 本任务我已经完成</label>
<ul style="padding-left:20px;line-height:1.8;">
<li>目标:观察同一请求如何触发 Before/After/Around 通知</li>
<li>步骤1调用用户接口 <code>/api/users</code></li>
<li>步骤2回到本页点击“刷新统计数据”</li>
<li>预期:统计里能看到 Controller/Service 方法耗时累积</li>
<li>常见坑:只看页面不看控制台,容易错过切面日志</li>
</ul>
</div>
<div class="card">
<h3>📊 实时性能统计</h3>
<p>AOP 自动统计所有 Controller 和 Service 方法的执行时间</p>
<button class="btn btn-primary" onclick="loadStats()">刷新统计数据</button>
<button class="btn btn-info" onclick="demoValidationError()">演示校验失败</button>
<div class="result-box" id="statsResult">点击按钮查看...</div>
</div>
<h2>📚 AOP 核心概念</h2>
<div class="card">
<h3>1. 什么是 AOP</h3>
<p>AOP (Aspect-Oriented Programming) 面向切面编程,是将<strong>横切关注点</strong><strong>业务逻辑</strong>分离的编程范式。</p>
<div class="tip">
<strong>横切关注点:</strong>日志、事务、安全、性能监控等,散布在多个模块中的公共功能。
<div class="page">
<section class="hero">
<div class="eyebrow" data-i18n="heroBadge"></div>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="actions" style="margin-top:18px;">
<a class="btn-soft" href="/access.html" data-i18n="loginLink"></a>
<a class="btn-soft" href="/" data-i18n="homeLink"></a>
<a class="btn-soft" href="/users.html" data-i18n="usersLink"></a>
<a class="btn-soft" href="/events.html" data-i18n="eventsLink"></a>
</div>
</section>
<div class="grid">
<aside class="card">
<div class="eyebrow" data-i18n="pathBadge"></div>
<h2 data-i18n="pathTitle"></h2>
<div class="list">
<div class="list-item">
<strong data-i18n="step1Title"></strong>
<p data-i18n="step1Text"></p>
</div>
<div class="list-item">
<strong data-i18n="step2Title"></strong>
<p data-i18n="step2Text"></p>
</div>
<div class="list-item">
<strong data-i18n="step3Title"></strong>
<p data-i18n="step3Text"></p>
</div>
</div>
<div class="eyebrow" style="margin-top:18px;" data-i18n="adviceBadge"></div>
<div class="chips" style="margin-top:12px;">
<span class="pill">@Before</span>
<span class="pill">@After</span>
<span class="pill">@AfterReturning</span>
<span class="pill">@AfterThrowing</span>
<span class="pill">@Around</span>
</div>
<div class="flow">
<div class="flow-step"><strong data-i18n="flow1Title"></strong><p data-i18n="flow1Text"></p></div>
<div class="flow-step"><strong data-i18n="flow2Title"></strong><p data-i18n="flow2Text"></p></div>
<div class="flow-step"><strong data-i18n="flow3Title"></strong><p data-i18n="flow3Text"></p></div>
<div class="flow-step"><strong data-i18n="flow4Title"></strong><p data-i18n="flow4Text"></p></div>
<div class="flow-step"><strong data-i18n="flow5Title"></strong><p data-i18n="flow5Text"></p></div>
</div>
</aside>
<main class="card">
<div class="eyebrow" data-i18n="liveBadge"></div>
<h2 data-i18n="liveTitle"></h2>
<p data-i18n="liveText"></p>
<div class="toolbar" style="margin-top:14px;">
<button class="btn" type="button" onclick="loadUsers()" data-i18n="loadUsersButton"></button>
<button class="btn-soft" type="button" onclick="loadUserStats()" data-i18n="loadStatsButton"></button>
<button class="btn-soft" type="button" onclick="sendInvalidUser()" data-i18n="invalidUserButton"></button>
<button class="btn-soft" type="button" onclick="loadAopStats()" data-i18n="aopStatsButton"></button>
</div>
<div class="list" style="margin-top:18px;">
<div class="list-item">
<strong data-i18n="observeTitle"></strong>
<p data-i18n="observeText"></p>
</div>
<div class="list-item">
<strong data-i18n="codeTitle"></strong>
<p data-i18n="codeText"></p>
</div>
</div>
<pre id="resultBox"></pre>
</main>
</div>
<div class="card">
<h3>2. 核心术语</h3>
<table>
<tr><th>术语</th><th>说明</th></tr>
<tr><td><strong>Aspect (切面)</strong></td><td>横切关注点的模块化封装</td></tr>
<tr><td><strong>JoinPoint (连接点)</strong></td><td>程序执行的某个点(方法调用、异常抛出等)</td></tr>
<tr><td><strong>Pointcut (切入点)</strong></td><td>匹配连接点的表达式</td></tr>
<tr><td><strong>Advice (通知)</strong></td><td>在连接点执行的动作</td></tr>
<tr><td><strong>Weaving (织入)</strong></td><td>将切面应用到目标对象的过程</td></tr>
</table>
</div>
<div class="card">
<h3>3. 五种通知类型</h3>
<table>
<tr><th>注解</th><th>执行时机</th><th>用途</th></tr>
<tr><td><code>@Before</code></td><td>方法执行前</td><td>参数校验、权限检查</td></tr>
<tr><td><code>@After</code></td><td>方法执行后(无论成功或异常)</td><td>资源清理</td></tr>
<tr><td><code>@AfterReturning</code></td><td>方法成功返回后</td><td>结果处理、日志记录</td></tr>
<tr><td><code>@AfterThrowing</code></td><td>方法抛出异常后</td><td>异常处理、错误日志</td></tr>
<tr><td><code>@Around</code></td><td>环绕方法执行(最强大)</td><td>性能统计、事务管理</td></tr>
</table>
</div>
<h2>💻 代码示例</h2>
<div class="card">
<h3>日志切面示例</h3>
<pre><code>@Aspect
@Component
public class LoggingAspect {
// 切入点:匹配所有 Controller 方法
@Pointcut("execution(* com.example.demo.controller.*.*(..))")
public void controllerMethods() {}
// 前置通知
@Before("controllerMethods()")
public void logBefore(JoinPoint jp) {
System.out.println("[AOP-Before] 方法开始: " + jp.getSignature().getName());
}
// 返回通知
@AfterReturning(pointcut = "controllerMethods()", returning = "result")
public void logAfterReturning(JoinPoint jp, Object result) {
System.out.println("[AOP-AfterReturning] 返回值: " + result);
}
}</code></pre>
</div>
<div class="card">
<h3>性能监控切面 (@Around)</h3>
<pre><code>@Aspect
@Component
public class PerformanceAspect {
@Around("execution(* com.example.demo..*.*(..))")
public Object measureTime(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed(); // 执行目标方法
long duration = System.currentTimeMillis() - start;
System.out.println("[AOP] " + pjp.getSignature() + " 耗时: " + duration + "ms");
return result;
} catch (Throwable e) {
System.out.println("[AOP] 方法异常: " + e.getMessage());
throw e;
</div>
<script src="/learning-shell.js"></script>
<script>
const I18N = {
zh: {
title: "Spring AOP 实验",
heroBadge: "AOP 实验",
heroTitle: "把横切行为从抽象概念变成可观察现象。",
heroText: "这一页专门展示学生最容易忽略的部分:通知是怎样包裹控制器和服务方法的、耗时如何被收集、以及校验失败为什么同样属于可观测的运行时行为。",
loginLink: "打开登录实验",
homeLink: "返回首页",
usersLink: "打开用户实验",
eventsLink: "打开事件实验",
pathBadge: "实验路径",
pathTitle: "推荐的 AOP 学习顺序",
step1Title: "1. 先触发控制器调用",
step1Text: "运行用户列表和统计接口,确保控制器和服务层方法都被执行到。",
step2Title: "2. 再触发一次校验失败",
step2Text: "发送一个非法用户载荷,对比失败请求和成功请求在切面统计上的表现。",
step3Title: "3. 最后查看切面输出",
step3Text: "加载统计接口,对比调用次数、总耗时和平均耗时。",
adviceBadge: "通知地图",
flow1Title: "Before",
flow1Text: "观察入参或鉴权上下文。",
flow2Title: "Around",
flow2Text: "开启计时器并继续执行连接点。",
flow3Title: "Controller",
flow3Text: "把业务交给服务层。",
flow4Title: "Service",
flow4Text: "执行具体业务规则。",
flow5Title: "After*",
flow5Text: "记录结果或失败细节。",
liveBadge: "实时运行区",
liveTitle: "发起请求并查看采集到的指标",
liveText: "下面的按钮会帮你制造真实流量,再直接查看切面输出,不需要再切换到别的工具。",
loadUsersButton: "加载用户",
loadStatsButton: "加载用户统计",
invalidUserButton: "发送非法用户",
aopStatsButton: "加载 AOP 统计",
observeTitle: "控制台里该观察什么",
observeText: "重点看控制器和服务层的耗时输出,对比成功路径和失败路径,理解 around 通知为什么仍然会记录方法执行时长。",
codeTitle: "代码里该先看什么",
codeText: "先读 PerformanceAspect再回头比对它包裹的用户接口。",
loadingPrefix: "正在加载 ",
requestFailedPrefix: "请求失败:",
placeholder: "请先运行上方任意实验,再查看实时 JSON 输出。"
},
en: {
title: "Spring AOP Lab",
heroBadge: "AOP Lab",
heroTitle: "Make cross-cutting behavior visible instead of abstract.",
heroText: "This page focuses on the parts students usually miss: where advice wraps controller and service methods, how timing is collected, and why a validation failure still counts as observable runtime behavior.",
loginLink: "Open login lab",
homeLink: "Back home",
usersLink: "Open user lab",
eventsLink: "Open event lab",
pathBadge: "Experiment Path",
pathTitle: "Recommended AOP sequence",
step1Title: "1. Trigger controller calls",
step1Text: "Run the list and stats endpoints so both controller and service methods execute.",
step2Title: "2. Trigger a validation failure",
step2Text: "Send an invalid user payload and compare the failed request with successful ones in the aspect metrics.",
step3Title: "3. Inspect aspect output",
step3Text: "Load the stats endpoint and compare call counts, total time, and average time.",
adviceBadge: "Advice Map",
flow1Title: "Before",
flow1Text: "Inspect inputs or auth context.",
flow2Title: "Around",
flow2Text: "Start the timer and continue the join point.",
flow3Title: "Controller",
flow3Text: "Delegate work to the service.",
flow4Title: "Service",
flow4Text: "Apply business logic.",
flow5Title: "After*",
flow5Text: "Record result or failure details.",
liveBadge: "Live Runs",
liveTitle: "Run requests and inspect collected metrics",
liveText: "The buttons below create real traffic and then surface the aspect output without switching tools.",
loadUsersButton: "Load users",
loadStatsButton: "Load user stats",
invalidUserButton: "Send invalid user",
aopStatsButton: "Load AOP stats",
observeTitle: "What to observe in the console",
observeText: "Look for controller and service timing lines. Compare success and failure paths to see why around advice still records method duration.",
codeTitle: "What to inspect in code",
codeText: "Read PerformanceAspect first, then compare it with the user endpoints that it wraps.",
loadingPrefix: "Loading ",
requestFailedPrefix: "Request failed: ",
placeholder: "Run one of the experiments above to inspect live JSON output."
}
};
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
}</code></pre>
</div>
<div class="card">
<h3>切入点表达式语法</h3>
<pre><code>// 匹配任意公共方法
execution(public * *(..))
// 匹配 com.example 包下所有方法
execution(* com.example.*.*(..))
// 匹配 Controller 层所有方法
execution(* com.example.demo.controller.*.*(..))
// 匹配所有 Service 层的 save 开头的方法
execution(* com.example.demo.service.*.save*(..))
// 匹配带有 @Service 注解的类
@within(org.springframework.stereotype.Service)
// 匹配带有自定义注解的方法
@annotation(com.example.demo.aop.RateLimited)</code></pre>
</div>
<h2>🎯 实际应用场景</h2>
<div class="card">
<table>
<tr><th>场景</th><th>实现方式</th></tr>
<tr><td>日志记录</td><td>@Before + @AfterReturning</td></tr>
<tr><td>性能监控</td><td>@Around</td></tr>
<tr><td>事务管理</td><td>@Around (Spring 已内置)</td></tr>
<tr><td>权限检查</td><td>@Before</td></tr>
<tr><td>限流控制</td><td>@Around + 自定义注解</td></tr>
<tr><td>缓存</td><td>@Around (Spring Cache)</td></tr>
</table>
</div>
<p style="margin-top: 30px;"><a href="/">← 返回学习中心</a></p>
<script>
const AOP_TASK_KEY = 'task.aop.done';
function toggleAopTaskDone(el) {
localStorage.setItem(AOP_TASK_KEY, el.checked ? '1' : '0');
}
function initAopTaskState() {
const done = localStorage.getItem(AOP_TASK_KEY) === '1';
const checkbox = document.getElementById('aopTaskDone');
if (checkbox) checkbox.checked = done;
}
async function demoValidationError() {
const box = document.getElementById('statsResult');
box.textContent = '发送错误示例请求中...';
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '', email: 'bad', age: 999 })
});
const data = await res.json();
box.textContent = JSON.stringify(data, null, 2);
} catch (e) {
box.textContent = '错误: ' + e.message;
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
}
});
}
async function loadStats() {
const res = await fetch('/aop/stats');
const data = await res.json();
document.getElementById('statsResult').textContent = JSON.stringify(data, null, 2);
}
function renderLanguage() {
const text = pageText();
applyTranslations(text);
initAopTaskState();
</script>
const box = document.getElementById("resultBox");
if (!box.dataset.state || box.dataset.state === "idle") {
box.textContent = text.placeholder;
box.dataset.state = "idle";
}
}
async function renderRequest(path, options) {
const box = document.getElementById("resultBox");
const text = pageText();
box.textContent = text.loadingPrefix + path + " ...";
box.dataset.state = "loading";
try {
const data = await window.learningShell.requestJson(path, options || {});
box.textContent = JSON.stringify(data, null, 2);
box.dataset.state = "live";
} catch (error) {
box.textContent = text.requestFailedPrefix + window.learningShell.describeError(error);
box.dataset.state = "live";
}
}
async function loadUsers() {
await renderRequest("/api/users");
}
async function loadUserStats() {
await renderRequest("/api/users/stats");
}
async function loadAopStats() {
await renderRequest("/aop/aop/stats");
}
async function sendInvalidUser() {
await renderRequest("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "", email: "bad", age: 999 })
});
}
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
</script>
</body>
</html>
</html>

View File

@@ -3,251 +3,357 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>事件机制 - Spring Boot</title>
<title>Spring Event Lab</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
h1 { color: #6DB33F; margin: 20px 0; }
h2 { color: #333; border-bottom: 2px solid #6DB33F; padding-bottom: 10px; margin: 20px 0 15px; }
h3 { color: #6DB33F; margin: 15px 0 10px; }
.card { background: white; padding: 25px; margin: 15px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.btn { display: inline-block; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 500; cursor: pointer; border: none; margin: 5px; }
.btn-primary { background: #6DB33F; color: white; }
.btn-primary:hover { background: #5da32f; }
.btn-info { background: #17a2b8; color: white; }
.btn-warning { background: #ffc107; color: #333; }
code { background: #f0f0f0; padding: 2px 8px; border-radius: 4px; font-family: 'Fira Code', monospace; }
pre { background: #2d2d2d; color: #f8f8f2; padding: 20px; border-radius: 8px; overflow-x: auto; margin: 15px 0; }
pre code { background: none; color: inherit; }
.tip { background: #e7f3ff; padding: 15px; border-radius: 8px; margin: 15px 0; border-left: 4px solid #6DB33F; }
.result-box { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 8px; font-family: monospace; white-space: pre-wrap; margin-top: 10px; max-height: 300px; overflow-y: auto; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background: #6DB33F; color: white; }
.event-flow { display: flex; align-items: center; justify-content: center; margin: 20px 0; }
.event-flow span { padding: 15px 25px; background: #6DB33F; color: white; border-radius: 8px; margin: 0 10px; }
.event-flow .arrow { font-size: 24px; color: #6DB33F; }
.nav { margin-bottom: 20px; }
.nav a { margin-right: 15px; color: #6DB33F; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.lab { background:#fff7e6; border-left:4px solid #fa8c16; padding:15px; border-radius:8px; margin:15px 0; }
.lab h4 { color:#ad6800; margin-bottom:8px; }
:root {
--bg: #fff4ea;
--panel: rgba(255,255,255,0.95);
--line: #eed9c9;
--text: #132238;
--muted: #6a7484;
--brand: #dd6c1f;
--accent: #0f67b5;
--shadow: 0 22px 52px rgba(18, 32, 51, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--text);
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top right, rgba(221, 108, 31, 0.16), transparent 28%),
radial-gradient(circle at bottom left, rgba(15, 103, 181, 0.1), transparent 22%),
var(--bg);
}
.page { max-width: 1280px; margin: 0 auto; padding: 24px; }
.hero, .card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow);
}
.hero { padding: 28px; margin-bottom: 18px; }
.eyebrow {
display: inline-flex;
padding: 7px 12px;
border-radius: 999px;
background: rgba(221, 108, 31, 0.1);
color: var(--brand);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1, h2, h3 { margin: 10px 0 12px; }
p { margin: 0; color: var(--muted); line-height: 1.8; }
.grid {
display: grid;
gap: 18px;
grid-template-columns: 420px minmax(0, 1fr);
}
.card { padding: 22px; }
.list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 14px;
}
.item, .timeline-step {
padding: 14px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.46);
}
.toolbar, .actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn, .btn-soft {
border: 0;
border-radius: 999px;
padding: 12px 18px;
font-weight: 700;
cursor: pointer;
}
.btn { background: linear-gradient(135deg, var(--brand), #f39a55); color: #fff; }
.btn-soft { background: #edf5ff; color: var(--accent); }
.fields {
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 14px;
}
label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: #22394f;
}
input, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
background: transparent;
color: var(--text);
outline: none;
}
.timeline {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
pre {
margin: 0;
min-height: 280px;
padding: 16px;
border-radius: 18px;
background: #111926;
color: #deebff;
white-space: pre-wrap;
font-family: Consolas, "Courier New", monospace;
overflow: auto;
}
@media (max-width: 1060px) {
.grid, .timeline, .fields { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.page { padding: 14px; }
}
</style>
</head>
<body>
<div class="nav">
<a href="/">← 返回首页</a>
<a href="/users.html">用户管理</a>
<a href="/aop.html">AOP 切面</a>
</div>
<h1>📡 Spring 事件机制</h1>
<div class="lab">
<h4>🧪 实验任务卡(事件)</h4>
<label style="display:block;margin-bottom:8px;"><input id="eventTaskDone" type="checkbox" onchange="toggleEventTaskDone(this)"> 本任务我已经完成</label>
<ul style="padding-left:20px;line-height:1.8;">
<li>目标:体验发布者与监听者解耦</li>
<li>步骤1输入 userId/userName点击“发布登录事件”</li>
<li>步骤2重复发布不同用户比较返回结果</li>
<li>预期:接口立即返回;监听处理在日志中可观察</li>
<li>常见坑:把事件当同步 RPC忽略异步监听特性</li>
</ul>
</div>
<div class="card">
<h3>🎉 事件发布演示</h3>
<p>模拟用户登录事件,观察事件发布和监听过程</p>
<div style="margin: 15px 0;">
<input type="text" id="userName" placeholder="用户名" value="张三" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; width: 150px;">
<input type="number" id="userId" placeholder="用户ID" value="1" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; width: 100px;">
<button class="btn btn-primary" onclick="publishEvent()">发布登录事件</button>
<button class="btn btn-warning" onclick="demoEventError()">演示参数错误</button>
<div class="page">
<section class="hero">
<div class="eyebrow" data-i18n="heroBadge"></div>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="actions" style="margin-top:18px;">
<a class="btn-soft" href="/access.html" data-i18n="loginLink"></a>
<a class="btn-soft" href="/" data-i18n="homeLink"></a>
<a class="btn-soft" href="/aop.html" data-i18n="aopLink"></a>
<a class="btn-soft" href="/users.html" data-i18n="usersLink"></a>
</div>
<div class="result-box" id="eventResult">等待事件发布...</div>
</div>
<h2>🔄 事件机制流程</h2>
<div class="event-flow">
<span>发布者</span>
<span class="arrow"></span>
<span>ApplicationEventPublisher</span>
<span class="arrow"></span>
<span>事件</span>
<span class="arrow"></span>
<span>监听者</span>
</div>
<div class="tip">
<strong>核心优势:</strong>
<ul style="margin-top: 10px; padding-left: 20px;">
<li><strong>解耦:</strong>发布者和监听者互不依赖</li>
<li><strong>扩展:</strong>新增监听器无需修改发布者</li>
<li><strong>异步:</strong>耗时操作不阻塞主流程</li>
</ul>
</div>
<h2>💻 代码实现</h2>
<div class="card">
<h3>1. 定义事件</h3>
<pre><code>public class UserEvent {
public enum Type { CREATED, UPDATED, DELETED, LOGIN }
private Type type;
private Long userId;
private String userName;
private LocalDateTime timestamp;
// constructor, getters...
}</code></pre>
</div>
<div class="card">
<h3>2. 发布事件</h3>
<pre><code>@Component
public class UserEventPublisher {
@Autowired
private ApplicationEventPublisher eventPublisher;
public void publishUserLogin(Long userId, String userName) {
UserEvent event = new UserEvent(
UserEvent.Type.LOGIN,
userId,
userName,
"用户登录成功"
);
eventPublisher.publishEvent(event);
}
}</code></pre>
</div>
<div class="card">
<h3>3. 监听事件</h3>
<pre><code>@Component
public class UserEventListener {
// 基础监听
@EventListener
public void handleUserEvent(UserEvent event) {
System.out.println("收到事件: " + event.getType());
}
// 条件监听 - 只处理登录事件
@EventListener(condition = "#event.type == T(com.example.demo.model.UserEvent$Type).LOGIN")
public void handleLogin(UserEvent event) {
System.out.println("用户登录: " + event.getUserName());
}
// 异步监听 - 不阻塞主流程
@Async
@EventListener
public void sendWelcomeEmail(UserEvent event) {
// 发送邮件...
}
}</code></pre>
</div>
<div class="card">
<h3>4. 控制器中使用</h3>
<pre><code>@RestController
@RequestMapping("/aop")
public class AopEventController {
@Autowired
private UserEventPublisher eventPublisher;
@PostMapping("/event/publish")
public Map&lt;String, Object&gt; publishEvent(
@RequestParam Long userId,
@RequestParam String userName) {
// 发布事件
eventPublisher.publishUserLogin(userId, userName);
return Map.of(
"message", "事件已发布",
"userId", userId,
"userName", userName
);
}
}</code></pre>
</div>
<h2>🎯 应用场景</h2>
<div class="card">
<table>
<tr><th>场景</th><th>事件类型</th><th>处理逻辑</th></tr>
<tr><td>用户注册</td><td>UserCreatedEvent</td><td>发送欢迎邮件、初始化数据</td></tr>
<tr><td>订单创建</td><td>OrderCreatedEvent</td><td>扣库存、发送通知</td></tr>
<tr><td>支付成功</td><td>PaymentSuccessEvent</td><td>更新订单状态、发送短信</td></tr>
<tr><td>用户登录</td><td>UserLoginEvent</td><td>记录登录日志、更新在线状态</td></tr>
</table>
</div>
<div class="card">
<h3>💡 最佳实践</h3>
<ul style="line-height: 2; padding-left: 20px;">
<li>事件类应该是<strong>不可变</strong>的(只读属性)</li>
<li>使用 <code>@Async</code> 处理耗时操作</li>
<li>避免在监听器中抛出异常</li>
<li>使用 <code>condition</code> 过滤不需要的事件</li>
<li>复杂场景考虑使用消息队列RabbitMQ/Kafka</li>
</ul>
</div>
<p style="margin-top: 30px;"><a href="/">← 返回学习中心</a></p>
<script>
const EVENT_TASK_KEY = 'task.event.done';
</section>
function toggleEventTaskDone(el) {
localStorage.setItem(EVENT_TASK_KEY, el.checked ? '1' : '0');
<div class="grid">
<aside class="card">
<div class="eyebrow" data-i18n="storyBadge"></div>
<h2 data-i18n="storyTitle"></h2>
<div class="timeline">
<div class="timeline-step"><strong data-i18n="timeline1Title"></strong><p data-i18n="timeline1Text"></p></div>
<div class="timeline-step"><strong data-i18n="timeline2Title"></strong><p data-i18n="timeline2Text"></p></div>
<div class="timeline-step"><strong data-i18n="timeline3Title"></strong><p data-i18n="timeline3Text"></p></div>
<div class="timeline-step"><strong data-i18n="timeline4Title"></strong><p data-i18n="timeline4Text"></p></div>
</div>
<div class="list">
<div class="item">
<strong data-i18n="exp1Title"></strong>
<p data-i18n="exp1Text"></p>
</div>
<div class="item">
<strong data-i18n="exp2Title"></strong>
<p data-i18n="exp2Text"></p>
</div>
<div class="item">
<strong data-i18n="pairingTitle"></strong>
<p data-i18n="pairingText"></p>
</div>
</div>
</aside>
<main class="card">
<div class="eyebrow" data-i18n="liveBadge"></div>
<h2 data-i18n="liveTitle"></h2>
<p data-i18n="liveText"></p>
<div class="fields">
<label>
<span data-i18n="eventTypeLabel"></span>
<select id="eventType">
<option value="LOGIN">LOGIN</option>
<option value="CREATED">CREATED</option>
<option value="UPDATED">UPDATED</option>
<option value="DELETED">DELETED</option>
</select>
</label>
<label>
<span data-i18n="userIdLabel"></span>
<input id="userId" value="21">
</label>
<label>
<span data-i18n="userNameLabel"></span>
<input id="userName" value="observer-demo">
</label>
</div>
<div class="toolbar" style="margin-top:14px;">
<button class="btn" type="button" onclick="publishEvent()" data-i18n="publishButton"></button>
<button class="btn-soft" type="button" onclick="loadHistory()" data-i18n="historyButton"></button>
<button class="btn-soft" type="button" onclick="loadInfo()" data-i18n="notesButton"></button>
</div>
<div class="list" style="margin-top:18px;">
<div class="item">
<strong data-i18n="hintTitle"></strong>
<p data-i18n="hintText"></p>
</div>
</div>
<pre id="resultBox"></pre>
</main>
</div>
</div>
<script src="/learning-shell.js"></script>
<script>
const I18N = {
zh: {
title: "Spring 事件实验",
heroBadge: "事件实验",
heroTitle: "通过可视化时间线理解发布者与监听器的解耦。",
heroText: "这个页面把平时隐藏起来的行为展现出来:请求可以先返回,而监听器还会在后台继续处理。你可以连续发布多种事件,然后再查看共享事件历史。",
loginLink: "打开登录实验",
homeLink: "返回首页",
aopLink: "打开 AOP 实验",
usersLink: "打开用户实验",
storyBadge: "事件故事线",
storyTitle: "应该重点观察什么",
timeline1Title: "Controller",
timeline1Text: "接收事件发布请求。",
timeline2Title: "Publisher",
timeline2Text: "组装 UserEvent 并发出事件。",
timeline3Title: "Listeners",
timeline3Text: "记录历史并执行异步后续工作。",
timeline4Title: "History API",
timeline4Text: "帮助你回看刚刚到底发生了什么。",
exp1Title: "实验 1",
exp1Text: "用不同用户连续发布两次 LOGIN确认历史记录会增长但控制器代码无需改动。",
exp2Title: "实验 2",
exp2Text: "发布 CREATED 再刷新历史,观察同一个入口如何支持多种监听路径。",
pairingTitle: "代码联读建议",
pairingText: "把 UserEventPublisher 和 UserEventListener 放在一起读,会更容易看出解耦边界。",
liveBadge: "实时事件台",
liveTitle: "发布事件并查看历史",
liveText: "通过这个表单发布事件,再刷新历史,观察类型、用户、说明和时间戳。",
eventTypeLabel: "事件类型",
userIdLabel: "用户 ID",
userNameLabel: "用户名",
publishButton: "发布事件",
historyButton: "加载历史",
notesButton: "加载事件说明",
hintTitle: "理解提示",
hintText: "如果控制器已经返回,但历史还在继续增长,你看到的就是解耦后的后续行为。",
loadingPrefix: "正在加载 ",
requestFailedPrefix: "请求失败:",
placeholder: "先发布一个事件,或直接加载历史查看实时输出。"
},
en: {
title: "Spring Event Lab",
heroBadge: "Event Lab",
heroTitle: "Understand publisher-listener decoupling through a visible event timeline.",
heroText: "This page surfaces what normally stays hidden: a request can return quickly while listeners keep reacting in the background. Publish multiple event types and then inspect shared event history.",
loginLink: "Open login lab",
homeLink: "Back home",
aopLink: "Open AOP lab",
usersLink: "Open user lab",
storyBadge: "Event Story",
storyTitle: "What to watch for",
timeline1Title: "Controller",
timeline1Text: "Receives the publish request.",
timeline2Title: "Publisher",
timeline2Text: "Builds a UserEvent and emits it.",
timeline3Title: "Listeners",
timeline3Text: "Track history and optional async work.",
timeline4Title: "History API",
timeline4Text: "Lets you inspect what actually happened.",
exp1Title: "Experiment 1",
exp1Text: "Publish LOGIN twice with different users and confirm the history list grows without changing controller code.",
exp2Title: "Experiment 2",
exp2Text: "Publish CREATED and then refresh history to see the same endpoint support multiple listener paths.",
pairingTitle: "Code pairing tip",
pairingText: "Read UserEventPublisher and UserEventListener side by side to see the decoupling boundary.",
liveBadge: "Live Event Console",
liveTitle: "Publish events and inspect history",
liveText: "Use the form to publish events, then reload history to inspect type, user, detail, and timestamp.",
eventTypeLabel: "Event type",
userIdLabel: "User id",
userNameLabel: "User name",
publishButton: "Publish event",
historyButton: "Load history",
notesButton: "Load event notes",
hintTitle: "Interpretation hint",
hintText: "If the controller returns immediately but history keeps growing, you are seeing decoupled follow-up behavior in action.",
loadingPrefix: "Loading ",
requestFailedPrefix: "Request failed: ",
placeholder: "Publish an event or load history to inspect live output."
}
};
function initEventTaskState() {
const done = localStorage.getItem(EVENT_TASK_KEY) === '1';
const checkbox = document.getElementById('eventTaskDone');
if (checkbox) checkbox.checked = done;
}
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
async function demoEventError() {
const resultBox = document.getElementById('eventResult');
try {
const res = await fetch('/aop/event/publish?userName=', { method: 'POST' });
const data = await res.json();
resultBox.textContent = JSON.stringify(data, null, 2);
} catch (e) {
resultBox.textContent = '错误: ' + e.message;
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
}
});
}
async function publishEvent() {
const userId = document.getElementById('userId').value;
const userName = document.getElementById('userName').value;
try {
const res = await fetch(`/aop/event/publish?userId=${userId}&userName=${encodeURIComponent(userName)}`, {
method: 'POST'
});
const data = await res.json();
const resultBox = document.getElementById('eventResult');
resultBox.textContent = `[${new Date().toLocaleTimeString()}] 事件已发布\n\n` +
JSON.stringify(data, null, 2) + '\n\n' +
'📊 查看控制台日志可以看到监听器的输出:\n' +
'[EventPublisher] 发布事件: LOGIN - ' + userName + '\n' +
'[EventListener] 收到事件: LOGIN - 用户: ' + userName + '\n' +
'[LoginTracker] 记录用户登录: ' + userName;
} catch (e) {
document.getElementById('eventResult').textContent = '错误: ' + e.message;
}
}
function renderLanguage() {
const text = pageText();
applyTranslations(text);
initEventTaskState();
</script>
const box = document.getElementById("resultBox");
if (!box.dataset.state || box.dataset.state === "idle") {
box.textContent = text.placeholder;
box.dataset.state = "idle";
}
}
async function renderRequest(path, options) {
const box = document.getElementById("resultBox");
const text = pageText();
box.textContent = text.loadingPrefix + path + " ...";
box.dataset.state = "loading";
try {
const data = await window.learningShell.requestJson(path, options || {});
box.textContent = JSON.stringify(data, null, 2);
box.dataset.state = "live";
} catch (error) {
box.textContent = text.requestFailedPrefix + window.learningShell.describeError(error);
box.dataset.state = "live";
}
}
async function publishEvent() {
const type = document.getElementById("eventType").value;
const userId = document.getElementById("userId").value.trim() || "21";
const userName = document.getElementById("userName").value.trim() || "observer-demo";
const params = new URLSearchParams({ userId: userId, userName: userName, eventType: type });
await renderRequest("/aop/event/publish?" + params.toString(), { method: "POST" });
}
async function loadHistory() {
await renderRequest("/aop/event/history");
}
async function loadInfo() {
await renderRequest("/aop/event");
}
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
</script>
</body>
</html>
</html>

View File

@@ -3,223 +3,643 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spring Boot 学习中心</title>
<title>Spring Boot Learning Cockpit</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
h1 { color: #6DB33F; text-align: center; margin: 30px 0; font-size: 2.5em; }
h2 { color: #333; border-bottom: 3px solid #6DB33F; padding-bottom: 10px; margin: 20px 0 15px; }
.card { background: white; padding: 25px; margin: 15px 0; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.card h3 { color: #6DB33F; margin-bottom: 15px; font-size: 1.3em; }
.btn-group { display: flex; flex-wrap: wrap; gap: 10px; margin: 15px 0; }
.btn { display: inline-block; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 500; transition: all 0.3s; }
.btn-primary { background: #6DB33F; color: white; }
.btn-primary:hover { background: #5da32f; transform: translateY(-2px); }
.btn-secondary { background: #333; color: white; }
.btn-secondary:hover { background: #444; }
.btn-info { background: #17a2b8; color: white; }
.btn-info:hover { background: #138496; }
.btn-warning { background: #ffc107; color: #333; }
.btn-warning:hover { background: #e0a800; }
code { background: #f0f0f0; padding: 2px 8px; border-radius: 4px; font-family: 'Fira Code', monospace; font-size: 14px; }
pre { background: #2d2d2d; color: #f8f8f2; padding: 20px; border-radius: 8px; overflow-x: auto; margin: 15px 0; }
pre code { background: none; color: inherit; }
ul { line-height: 2; padding-left: 20px; }
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; }
.feature-item { background: #f8f9fa; padding: 20px; border-radius: 8px; border-left: 4px solid #6DB33F; transition: all 0.3s; }
.feature-item:hover { transform: translateY(-3px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.feature-item h4 { color: #333; margin-bottom: 8px; font-size: 1.1em; }
.feature-item p { color: #666; font-size: 14px; margin: 0; }
.feature-item a { color: inherit; text-decoration: none; }
.api-test { background: #f8f9fa; padding: 15px; margin: 10px 0; border-radius: 8px; }
.api-test input, .api-test select { padding: 10px; border: 1px solid #ddd; border-radius: 4px; margin: 5px; }
.api-test button { padding: 10px 20px; background: #6DB33F; color: white; border: none; border-radius: 4px; cursor: pointer; }
.api-test button:hover { background: #5da32f; }
#result { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 8px; margin-top: 10px; white-space: pre-wrap; font-family: monospace; font-size: 14px; }
.footer { text-align: center; margin-top: 40px; padding: 20px; color: #666; border-top: 1px solid #ddd; }
.nav-links { display: flex; justify-content: center; gap: 15px; margin-bottom: 30px; }
.nav-links a { padding: 10px 20px; background: white; border-radius: 8px; text-decoration: none; color: #6DB33F; font-weight: 500; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.nav-links a:hover { background: #6DB33F; color: white; }
.nav-links a.active { background: #6DB33F; color: white; }
:root {
--bg: #eef5ff;
--panel: rgba(255, 255, 255, 0.94);
--line: #d8e4f0;
--text: #122033;
--muted: #5d7288;
--brand: #177245;
--accent: #0f67b5;
--warm: #f08c2b;
--shadow: 0 22px 54px rgba(18, 32, 51, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--text);
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(23, 114, 69, 0.14), transparent 28%),
radial-gradient(circle at bottom right, rgba(15, 103, 181, 0.14), transparent 26%),
var(--bg);
}
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; }
.page { max-width: 1380px; margin: 0 auto; padding: 24px; }
.hero, .card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.hero { padding: 28px; margin-bottom: 18px; }
.eyebrow {
display: inline-flex;
padding: 7px 12px;
border-radius: 999px;
background: rgba(23, 114, 69, 0.1);
color: var(--brand);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1, h2, h3 { margin: 10px 0 12px; }
p { margin: 0; color: var(--muted); line-height: 1.8; }
.hero-grid, .workspace, .triple {
display: grid;
gap: 18px;
}
.hero-grid {
grid-template-columns: 1.3fr 0.9fr;
align-items: start;
}
.actions, .chip-list, .toolbar, .demo-links {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn, .btn-soft {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 999px;
padding: 12px 18px;
cursor: pointer;
font-weight: 700;
}
.btn {
color: #fff;
background: linear-gradient(135deg, var(--brand), #35a465);
}
.btn-soft {
color: var(--accent);
background: #eaf3ff;
}
.card { padding: 22px; }
.stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.stat {
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.5);
}
.stat span { display: block; color: var(--muted); font-size: 12px; margin-bottom: 8px; }
.stat strong { font-size: 28px; }
.workspace {
grid-template-columns: 380px minmax(0, 1fr);
align-items: start;
}
.triple {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.flow {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.step, .lab, .note, .code-card {
border: 1px solid var(--line);
border-radius: 20px;
padding: 16px;
background: rgba(255,255,255,0.45);
}
.step strong, .lab strong, .code-card strong { display: block; margin-bottom: 8px; }
.lab small, .step small, .note small { color: var(--muted); display: block; line-height: 1.7; }
.field {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
label { font-size: 13px; font-weight: 700; color: #21384f; }
input, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
background: transparent;
color: var(--text);
outline: none;
}
.console {
margin-top: 14px;
border-radius: 18px;
overflow: hidden;
border: 1px solid #d6e2ef;
background: #0e1723;
}
.console-head {
padding: 12px 14px;
border-bottom: 1px solid rgba(216, 228, 240, 0.14);
color: #bdd2ea;
font-weight: 700;
}
pre {
margin: 0;
min-height: 240px;
padding: 16px;
color: #dceaff;
white-space: pre-wrap;
font-family: Consolas, "Courier New", monospace;
overflow: auto;
}
.pill {
display: inline-flex;
padding: 6px 10px;
border-radius: 999px;
background: rgba(15, 103, 181, 0.08);
color: var(--accent);
font-size: 12px;
font-weight: 700;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
}
.list-item {
padding: 14px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.45);
}
.list-item p { margin-top: 8px; }
@media (max-width: 1180px) {
.hero-grid, .workspace, .triple, .flow, .stats { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 780px) {
.page { padding: 14px; }
.hero-grid, .workspace, .triple, .flow, .stats { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<h1>🍃 Spring Boot 学习中心</h1>
<div class="nav-links">
<a href="/" class="active">首页</a>
<a href="/users.html">用户管理</a>
<a href="/aop.html">AOP 切面</a>
<a href="/events.html">事件机制</a>
</div>
<div class="card">
<h3>📚 学习模块</h3>
<div class="feature-grid">
<a href="/users.html" class="feature-item">
<h4>👥 用户管理</h4>
<p>RESTful API 设计、CRUD 操作、参数绑定</p>
</a>
<a href="/aop.html" class="feature-item">
<h4>🔪 AOP 切面编程</h4>
<p>日志记录、性能监控、限流控制</p>
</a>
<a href="/events.html" class="feature-item">
<h4>📡 事件机制</h4>
<p>发布/订阅模式、解耦业务逻辑</p>
</a>
<a href="/learn" class="feature-item">
<h4>🔐 鉴权演示(学习用)</h4>
<p>最小 JWT 流程:登录、携带 Token、访问受保护接口</p>
</a>
<div class="page">
<section class="hero">
<div class="hero-grid">
<div>
<div class="eyebrow" data-i18n="heroBadge"></div>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="actions" style="margin-top:18px;">
<a class="btn-soft" href="/access.html" data-i18n="loginLabLink"></a>
<a class="btn" href="/users.html" data-i18n="userLabLink"></a>
<a class="btn-soft" href="/aop.html" data-i18n="aopLabLink"></a>
<a class="btn-soft" href="/events.html" data-i18n="eventLabLink"></a>
<a class="btn-soft" href="/learn" data-i18n="learnApiLink"></a>
</div>
<div class="stats">
<div class="stat"><span data-i18n="statLabs"></span><strong>5</strong></div>
<div class="stat"><span data-i18n="statLayers"></span><strong>6</strong></div>
<div class="stat"><span data-i18n="statPages"></span><strong>4</strong></div>
<div class="stat"><span data-i18n="statPaths"></span><strong>2+</strong></div>
</div>
</div>
<div class="card" style="padding:0;">
<div class="card" style="box-shadow:none; border:none; border-radius:28px;">
<div class="eyebrow" data-i18n="startBadge"></div>
<h2 data-i18n="startTitle"></h2>
<div class="list">
<div class="list-item">
<strong data-i18n="startStep1Title"></strong>
<p data-i18n="startStep1Text"></p>
</div>
<div class="list-item">
<strong data-i18n="startStep2Title"></strong>
<p data-i18n="startStep2Text"></p>
</div>
<div class="list-item">
<strong data-i18n="startStep3Title"></strong>
<p data-i18n="startStep3Text"></p>
</div>
</div>
</div>
</div>
</div>
</div>
<h2>🔗 快速链接</h2>
<div class="card">
<div class="btn-group">
<a class="btn btn-primary" href="/learn">API 接口列表</a>
<a class="btn btn-info" href="/api/users">用户 JSON</a>
<a class="btn btn-secondary" href="/actuator/health">健康检查</a>
</div>
</div>
<h2>🧪 接口测试</h2>
<div class="card">
<h3>GET 参数示例</h3>
<div class="api-test">
<input type="text" id="param-name" placeholder="姓名" value="张三">
<input type="number" id="param-age" placeholder="年龄" value="25">
<button onclick="testParams()">测试</button>
<div id="result-params"></div>
</div>
<p><code>GET /learn/params?name=xxx&age=18</code></p>
</div>
<div class="card">
<h3>路径变量示例</h3>
<div class="api-test">
<input type="text" id="path-id" placeholder="ID" value="123">
<button onclick="testPath()">测试</button>
<div id="result-path"></div>
</div>
<p><code>GET /learn/path/{id}</code></p>
</div>
<div class="card">
<h3>POST JSON 示例</h3>
<div class="api-test">
<input type="text" id="post-data" placeholder='JSON 数据' value='{"name":"test","value":123}' style="width: 300px;">
<button onclick="testPost()">测试</button>
<div id="result-post"></div>
</div>
<p><code>POST /learn/body</code></p>
</div>
<h2>📖 学习路径</h2>
<div class="card">
<h3>1. IOC 容器</h3>
<ul>
<li><code>@Component</code>, <code>@Service</code>, <code>@Repository</code>, <code>@Controller</code></li>
<li><code>@Autowired</code> 依赖注入</li>
<li><code>@Configuration</code> + <code>@Bean</code> 配置类</li>
</ul>
</div>
<div class="card">
<h3>2. Web 开发</h3>
<ul>
<li><code>@RestController</code> = <code>@Controller</code> + <code>@ResponseBody</code></li>
<li><code>@RequestMapping</code>, <code>@GetMapping</code>, <code>@PostMapping</code></li>
<li><code>@PathVariable</code>, <code>@RequestParam</code>, <code>@RequestBody</code></li>
</ul>
</div>
<div class="card">
<h3>3. AOP 切面编程</h3>
<pre><code>@Aspect
@Component
public class LoggingAspect {
@Before("execution(* com.example.*.*(..))")
public void logBefore(JoinPoint jp) {
System.out.println("方法调用: " + jp.getSignature());
}
}</code></pre>
</div>
<div class="card">
<h3>4. 事件机制</h3>
<pre><code>// 发布事件
@Autowired
ApplicationEventPublisher publisher;
publisher.publishEvent(new UserEvent(...));
</section>
// 监听事件
@EventListener
public void onEvent(UserEvent event) {
// 处理事件
}</code></pre>
<section class="card" style="margin-bottom:18px;">
<div class="eyebrow" data-i18n="archBadge"></div>
<h2 data-i18n="archTitle"></h2>
<div class="flow">
<div class="step"><strong data-i18n="flow1Title"></strong><small data-i18n="flow1Text"></small></div>
<div class="step"><strong data-i18n="flow2Title"></strong><small data-i18n="flow2Text"></small></div>
<div class="step"><strong data-i18n="flow3Title"></strong><small data-i18n="flow3Text"></small></div>
<div class="step"><strong data-i18n="flow4Title"></strong><small data-i18n="flow4Text"></small></div>
<div class="step"><strong data-i18n="flow5Title"></strong><small data-i18n="flow5Text"></small></div>
<div class="step"><strong data-i18n="flow6Title"></strong><small data-i18n="flow6Text"></small></div>
</div>
</section>
<div class="workspace">
<aside class="card">
<div class="eyebrow" data-i18n="tracksBadge"></div>
<h2 data-i18n="tracksTitle"></h2>
<div class="list">
<div class="lab">
<strong data-i18n="track1Title"></strong>
<small data-i18n="track1Text"></small>
</div>
<div class="lab">
<strong data-i18n="track2Title"></strong>
<small data-i18n="track2Text"></small>
</div>
<div class="lab">
<strong data-i18n="track3Title"></strong>
<small data-i18n="track3Text"></small>
</div>
<div class="lab">
<strong data-i18n="track4Title"></strong>
<small data-i18n="track4Text"></small>
</div>
<div class="lab">
<strong data-i18n="track5Title"></strong>
<small data-i18n="track5Text"></small>
</div>
</div>
<div class="eyebrow" style="margin-top:20px;" data-i18n="readingBadge"></div>
<h2 data-i18n="readingTitle"></h2>
<div class="list">
<div class="code-card">
<strong data-i18n="reading1Title"></strong>
<small data-i18n="reading1Text"></small>
</div>
<div class="code-card">
<strong data-i18n="reading2Title"></strong>
<small data-i18n="reading2Text"></small>
</div>
<div class="code-card">
<strong data-i18n="reading3Title"></strong>
<small data-i18n="reading3Text"></small>
</div>
<div class="code-card">
<strong data-i18n="reading4Title"></strong>
<small data-i18n="reading4Text"></small>
</div>
</div>
</aside>
<main class="triple">
<section class="card" style="grid-column: 1 / -1;">
<div class="eyebrow" data-i18n="explorerBadge"></div>
<h2 data-i18n="explorerTitle"></h2>
<p data-i18n="explorerText"></p>
<div class="toolbar" style="margin-top:14px;">
<button class="btn" type="button" onclick="loadEndpoint('/actuator/health')" data-i18n="healthButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/users/stats')" data-i18n="userStatsButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/learn')" data-i18n="learnButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/aop/aop/stats')" data-i18n="aopStatsButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/aop/event/history')" data-i18n="eventHistoryButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/lab/reflection/routes')" data-i18n="reflectionButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/lab/concurrency/simulate?tasks=8&poolSize=4')" data-i18n="concurrencyButton"></button>
</div>
<p style="margin-top:12px;" data-i18n="protectedHint"></p>
<div class="triple" style="margin-top:16px;">
<div class="card" style="padding:0; box-shadow:none; background:transparent; border:none;">
<div class="field">
<label for="eventType" data-i18n="eventTypeLabel"></label>
<select id="eventType">
<option value="LOGIN">LOGIN</option>
<option value="CREATED">CREATED</option>
<option value="UPDATED">UPDATED</option>
<option value="DELETED">DELETED</option>
</select>
</div>
</div>
<div class="card" style="padding:0; box-shadow:none; background:transparent; border:none;">
<div class="field">
<label for="eventUserId" data-i18n="eventUserIdLabel"></label>
<input id="eventUserId" value="99">
</div>
</div>
<div class="card" style="padding:0; box-shadow:none; background:transparent; border:none;">
<div class="field">
<label for="eventUserName" data-i18n="eventUserNameLabel"></label>
<input id="eventUserName" value="learning-user">
</div>
</div>
</div>
<div class="toolbar" style="margin-top:14px;">
<button class="btn" type="button" onclick="publishEvent()" data-i18n="publishButton"></button>
<button class="btn-soft" type="button" onclick="loadEndpoint('/api/users')" data-i18n="loadUsersButton"></button>
</div>
<div class="console">
<div class="console-head" data-i18n="consoleTitle"></div>
<pre id="consoleOutput"></pre>
</div>
</section>
<section class="card">
<div class="eyebrow" data-i18n="exp1Badge"></div>
<h2 data-i18n="exp1Title"></h2>
<p data-i18n="exp1Text"></p>
<div class="chip-list" style="margin-top:12px;">
<span class="pill">UserController</span>
<span class="pill">UserService</span>
<span class="pill">GlobalExceptionHandler</span>
</div>
</section>
<section class="card">
<div class="eyebrow" data-i18n="exp2Badge"></div>
<h2 data-i18n="exp2Title"></h2>
<p data-i18n="exp2Text"></p>
<div class="chip-list" style="margin-top:12px;">
<span class="pill">PerformanceAspect</span>
<span class="pill">@Around</span>
<span class="pill">Cross-cutting</span>
</div>
</section>
<section class="card">
<div class="eyebrow" data-i18n="exp3Badge"></div>
<h2 data-i18n="exp3Title"></h2>
<p data-i18n="exp3Text"></p>
<div class="chip-list" style="margin-top:12px;">
<span class="pill">Publisher</span>
<span class="pill">Listener</span>
<span class="pill">@Async</span>
</div>
</section>
</main>
</div>
<h2>📁 项目结构</h2>
<div class="card">
<pre><code>├── src/main/java/com/example/demo/
├── DemoApplication.java # 启动类
│ ├── controller/ # 控制器层
├── LearnController.java # 学习示例
├── UserController.java # 用户 API
└── AopEventController.java
├── service/ # 业务逻辑层
├── model/ # 实体类
├── aop/ # AOP 切面
├── LoggingAspect.java
├── PerformanceAspect.java
└── RateLimitAspect.java
│ └── event/ # 事件机制
├── UserEventPublisher.java
└── UserEventListener.java
├── src/main/resources/
├── static/ # 静态资源
├── index.html
├── users.html
├── aop.html
└── events.html
└── application.properties # 配置文件
└── pom.xml # Maven 配置</code></pre>
</div>
<div class="footer">
<p>🍃 Spring Boot 学习脚手架 | <a href="https://spring.io" style="color: #6DB33F;">Spring 官网</a></p>
</div>
<script>
async function testParams() {
const name = document.getElementById('param-name').value;
const age = document.getElementById('param-age').value;
const res = await fetch(`/learn/params?name=${encodeURIComponent(name)}&age=${age}`);
const data = await res.json();
document.getElementById('result-params').innerHTML = '<div id="result">' + JSON.stringify(data, null, 2) + '</div>';
</div>
<script src="/learning-shell.js"></script>
<script>
const I18N = {
zh: {
title: "Spring Boot 学习总控台",
heroBadge: "Spring Boot 学习总控台",
heroTitle: "用一个工作台串起 MVC、校验、安全、AOP 和应用事件。",
heroText: "首页现在不只是导航页,而是一个带实验顺序、请求路径说明和实时接口调用能力的学习驾驶舱,帮助你把代码结构和真实行为对应起来。",
loginLabLink: "打开登录实验",
userLabLink: "打开用户实验",
aopLabLink: "打开 AOP 实验",
eventLabLink: "打开事件实验",
learnApiLink: "打开 MVC Learn API",
statLabs: "核心实验",
statLayers: "关键层次",
statPages: "交互页面",
statPaths: "已验证后端路径",
startBadge: "从这里开始",
startTitle: "推荐学习顺序",
startStep1Title: "1. 先看参数绑定",
startStep1Text: "先用下面的实时接口区观察 query、path、header、cookie 和 JSON body 的绑定方式,再对照控制器代码。",
startStep2Title: "2. 再学用户和校验",
startStep2Text: "进入用户实验页,验证 CRUD、重复邮箱保护、聚合统计和错误回传。",
startStep3Title: "3. 最后看横切与事件",
startStep3Text: "切到 AOP 与事件页,理解计时、限流、发布订阅和监听器历史。",
archBadge: "架构路径",
archTitle: "一个请求如何穿过整个 Demo",
flow1Title: "浏览器",
flow1Text: "表单提交或 fetch 调用发起请求。",
flow2Title: "安全层",
flow2Text: "JWT 学习路由判断请求是公开还是受保护。",
flow3Title: "控制器",
flow3Text: "Spring 把参数、请求体、请求头和 Cookie 绑定到方法参数。",
flow4Title: "服务层",
flow4Text: "这里运行重复邮箱校验、统计计算等业务规则。",
flow5Title: "AOP / 事件",
flow5Text: "横切逻辑和监听器在不污染主流程的前提下响应请求。",
flow6Title: "响应",
flow6Text: "最终以结构化 JSON 或 HTML 的形式回到页面。",
tracksBadge: "实验赛道",
tracksTitle: "每个区域该练什么",
track1Title: "用户管理",
track1Text: "创建用户、触发重复邮箱保护、搜索记录,并比较变更前后的统计数据。",
track2Title: "MVC 参数绑定",
track2Text: "使用 /learn 路由对比 @RequestParam、@PathVariable、@RequestBody、@RequestHeader 和 @CookieValue。",
track3Title: "AOP 追踪",
track3Text: "触发 /api/users 与 /api/users/stats再查看 /aop/aop/stats 里哪些方法被统计了。",
track4Title: "应用事件",
track4Text: "发布 LOGIN 或 CREATED 事件后刷新历史,理解发布者与监听器的解耦。",
track5Title: "高级实验",
track5Text: "直接调用 /api/lab 下的反射、并发和 JWT claims 实验接口,把高级知识点和当前 Demo 串起来。",
readingBadge: "代码阅读地图",
readingTitle: "接下来值得重点看的文件",
reading1Title: "UserController -> UserService",
reading1Text: "展示校验、CRUD 编排、搜索以及统计聚合。",
reading2Title: "LearningSecurityConfig + LearningJwtFilter",
reading2Text: "解释为什么大部分实验页保持公开,而 /api/secure/** 仍然受保护。",
reading3Title: "PerformanceAspect + UserEventPublisher + UserEventListener",
reading3Text: "这是横切逻辑与事件驱动最直观的两组示例。",
reading4Title: "AdvancedLabController + LearningJwtUtil",
reading4Text: "用反射、并发和 JWT claims 解析,把高级 Java 和安全实验纳入同一个学习工作台。",
explorerBadge: "实时接口区",
explorerTitle: "不离开页面,直接调用真实接口",
explorerText: "一边读代码,一边在这里看真实返回结果,会更容易把项目结构和运行行为建立联系。",
healthButton: "健康检查",
userStatsButton: "用户统计",
learnButton: "Learn 总览",
aopStatsButton: "AOP 统计",
eventHistoryButton: "事件历史",
reflectionButton: "反射路由图",
concurrencyButton: "并发实验",
protectedHint: "受保护接口已经接入前端令牌持久化。如果你还没登录,请先进入登录实验页。",
eventTypeLabel: "发布演示事件",
eventUserIdLabel: "用户 ID",
eventUserNameLabel: "用户名",
publishButton: "发布事件",
loadUsersButton: "加载用户",
consoleTitle: "接口输出",
consolePlaceholder: "请选择上方一个实验动作来加载实时输出。",
loadingPrefix: "正在加载 ",
requestFailedPrefix: "请求失败:",
exp1Badge: "实验 1",
exp1Title: "追踪校验链路",
exp1Text: "打开用户实验先创建一个用户再用同样邮箱重复创建。对比前端错误提示、DuplicateEmailException 和全局异常处理器。",
exp2Badge: "实验 2",
exp2Title: "追踪 AOP 计时",
exp2Text: "多次加载用户列表和统计,再调用 /aop/aop/stats观察控制器与服务方法如何累计耗时与调用次数。",
exp3Badge: "实验 3",
exp3Title: "追踪事件解耦",
exp3Text: "连续发布 CREATED 和 LOGIN 事件,再刷新历史,体会请求结束后监听器仍在处理副作用。"
},
en: {
title: "Spring Boot Learning Cockpit",
heroBadge: "Spring Boot Learning Cockpit",
heroTitle: "Use one workspace to understand MVC, validation, security, AOP, and application events.",
heroText: "This homepage is now a guided study cockpit. Instead of only linking to pages, it explains request paths, suggests experiment sequences, and lets you call real endpoints to observe behavior.",
loginLabLink: "Open login lab",
userLabLink: "Open user lab",
aopLabLink: "Open AOP lab",
eventLabLink: "Open event lab",
learnApiLink: "Open MVC Learn API",
statLabs: "Core labs",
statLayers: "Major layers",
statPages: "Interactive pages",
statPaths: "Tested backend paths",
startBadge: "Start Here",
startTitle: "Recommended study order",
startStep1Title: "1. Learn endpoint binding",
startStep1Text: "Use the live explorer below and compare query, path, header, cookie, and JSON body patterns before reading controller code.",
startStep2Title: "2. Study users and validation",
startStep2Text: "Open the user lab to inspect CRUD, duplicate email handling, aggregate stats, and error responses.",
startStep3Title: "3. Watch cross-cutting behavior",
startStep3Text: "Move to AOP and events to understand timing, rate limiting, publishing, and listener history.",
archBadge: "Architecture",
archTitle: "How one request travels through the demo",
flow1Title: "Browser",
flow1Text: "A form submit or fetch call starts the request.",
flow2Title: "Security",
flow2Text: "JWT learning routes decide whether the request is public or protected.",
flow3Title: "Controller",
flow3Text: "Spring binds params, body, headers, and cookies into method arguments.",
flow4Title: "Service",
flow4Text: "Business rules run here, such as duplicate email checks and stats calculation.",
flow5Title: "AOP / Events",
flow5Text: "Cross-cutting logic and listeners react without cluttering the core flow.",
flow6Title: "Response",
flow6Text: "The result returns to the page as structured JSON or HTML feedback.",
tracksBadge: "Lab Tracks",
tracksTitle: "What to practice in each area",
track1Title: "User management",
track1Text: "Create a user, trigger duplicate email protection, search records, and compare stats before and after changes.",
track2Title: "MVC parameter binding",
track2Text: "Use /learn routes to compare @RequestParam, @PathVariable, @RequestBody, @RequestHeader, and @CookieValue.",
track3Title: "AOP tracing",
track3Text: "Trigger /api/users and /api/users/stats, then inspect /aop/aop/stats to see which methods were counted.",
track4Title: "Application events",
track4Text: "Publish LOGIN or CREATED events and refresh history to understand publisher-listener decoupling.",
track5Title: "Advanced labs",
track5Text: "Call the reflection, concurrency, and JWT claims labs under /api/lab to connect advanced topics back to this demo.",
readingBadge: "Code Reading Map",
readingTitle: "Files worth reading next",
reading1Title: "UserController -> UserService",
reading1Text: "Shows validation, CRUD orchestration, search, and stats aggregation.",
reading2Title: "LearningSecurityConfig + LearningJwtFilter",
reading2Text: "Explains why most labs stay public while /api/secure/** remains protected.",
reading3Title: "PerformanceAspect + UserEventPublisher + UserEventListener",
reading3Text: "These are the clearest examples of cross-cutting and event-driven behavior.",
reading4Title: "AdvancedLabController + LearningJwtUtil",
reading4Text: "Use reflection, concurrency, and JWT claim parsing to fold advanced Java and security experiments into the same workspace.",
explorerBadge: "Live Explorer",
explorerTitle: "Call real endpoints without leaving the page",
explorerText: "Inspect real responses while you read the code so the project is easier to connect to concrete runtime behavior.",
healthButton: "Health",
userStatsButton: "User stats",
learnButton: "Learn overview",
aopStatsButton: "AOP stats",
eventHistoryButton: "Event history",
reflectionButton: "Reflection routes",
concurrencyButton: "Concurrency lab",
protectedHint: "Protected endpoints now support frontend token persistence. If you are not logged in yet, open the login lab first.",
eventTypeLabel: "Publish demo event",
eventUserIdLabel: "User id",
eventUserNameLabel: "User name",
publishButton: "Publish event",
loadUsersButton: "Load users",
consoleTitle: "Endpoint output",
consolePlaceholder: "Select one experiment above to load live output.",
loadingPrefix: "Loading ",
requestFailedPrefix: "Request failed: ",
exp1Badge: "Experiment 1",
exp1Title: "Trace validation",
exp1Text: "Open the user lab, create a user, then repeat with the same email. Compare the frontend error with DuplicateEmailException and the global exception handler.",
exp2Badge: "Experiment 2",
exp2Title: "Trace AOP timing",
exp2Text: "Load users and stats several times, then call /aop/aop/stats. Watch how controller and service methods accumulate timing and call count data.",
exp3Badge: "Experiment 3",
exp3Title: "Trace event decoupling",
exp3Text: "Publish CREATED and LOGIN events, then reload event history to see how listeners keep handling follow-up work."
}
async function testPath() {
const id = document.getElementById('path-id').value;
const res = await fetch(`/learn/path/${id}`);
const data = await res.json();
document.getElementById('result-path').innerHTML = '<div id="result">' + JSON.stringify(data, null, 2) + '</div>';
};
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
});
}
function renderLanguage() {
const text = pageText();
applyTranslations(text);
const output = document.getElementById("consoleOutput");
if (!output.dataset.state || output.dataset.state === "idle") {
output.textContent = text.consolePlaceholder;
output.dataset.state = "idle";
}
async function testPost() {
const data = document.getElementById('post-data').value;
const res = await fetch('/learn/body', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: data
});
const result = await res.json();
document.getElementById('result-post').innerHTML = '<div id="result">' + JSON.stringify(result, null, 2) + '</div>';
}
async function loadEndpoint(path, options) {
const output = document.getElementById("consoleOutput");
const text = pageText();
output.textContent = text.loadingPrefix + path + " ...";
output.dataset.state = "loading";
try {
const response = await window.learningShell.fetchWithAuth(path, options || {});
const contentType = response.headers.get("content-type") || "";
if (!response.ok) {
const failedContent = contentType.includes("application/json")
? await response.json()
: await response.text();
throw {
status: response.status,
message: typeof failedContent === "object" && failedContent && failedContent.message
? failedContent.message
: window.learningShell.describeError({ status: response.status }),
payload: failedContent
};
}
if (contentType.includes("application/json")) {
const json = await response.json();
output.textContent = JSON.stringify(json, null, 2);
} else {
output.textContent = await response.text();
}
output.dataset.state = "live";
} catch (error) {
output.textContent = text.requestFailedPrefix + window.learningShell.describeError(error);
output.dataset.state = "live";
}
</script>
}
async function publishEvent() {
const type = document.getElementById("eventType").value;
const userId = document.getElementById("eventUserId").value.trim() || "99";
const userName = document.getElementById("eventUserName").value.trim() || "learning-user";
const params = new URLSearchParams({
userId: userId,
userName: userName,
eventType: type
});
await loadEndpoint("/aop/event/publish?" + params.toString(), { method: "POST" });
}
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
</script>
</body>
</html>
</html>

View File

@@ -0,0 +1,329 @@
(function () {
const STORAGE = {
token: "learning-demo-token",
username: "learning-demo-username",
language: "learning-demo-language"
};
const TEXT = {
zh: {
brand: "Spring \u5b66\u4e60\u5de5\u4f5c\u53f0",
home: "\u9996\u9875",
access: "\u767b\u5f55\u9875",
loginReady: "\u5df2\u767b\u5f55\uff0c\u53ef\u8bbf\u95ee\u53d7\u4fdd\u62a4\u5b9e\u9a8c",
loginMissing: "\u672a\u767b\u5f55\uff0c\u53d7\u4fdd\u62a4\u5b9e\u9a8c\u4f1a\u8fd4\u56de 401",
currentUser: "\u5f53\u524d\u7528\u6237",
logout: "\u9000\u51fa\u767b\u5f55",
login: "\u53bb\u767b\u5f55",
languageToggle: "EN",
unauthorized: "\u672a\u767b\u5f55\u6216\u4ee4\u724c\u5df2\u5931\u6548\uff0c\u8bf7\u5148\u6253\u5f00\u767b\u5f55\u9875\u5b8c\u6210\u6f14\u793a\u767b\u5f55\u3002",
requestFailed: "\u8bf7\u6c42\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002"
},
en: {
brand: "Spring Learning Workspace",
home: "Home",
access: "Login",
loginReady: "Authenticated, protected labs are available",
loginMissing: "Not logged in, protected labs will return 401",
currentUser: "User",
logout: "Logout",
login: "Login",
languageToggle: "\u4e2d\u6587",
unauthorized: "Not logged in or token expired. Open the login page first.",
requestFailed: "Request failed. Please try again."
}
};
function normalizeLanguage(language) {
return language === "en" ? "en" : "zh";
}
function getLanguage() {
return normalizeLanguage(localStorage.getItem(STORAGE.language));
}
function setLanguage(language) {
const normalized = normalizeLanguage(language);
localStorage.setItem(STORAGE.language, normalized);
document.documentElement.lang = normalized === "zh" ? "zh-CN" : "en";
window.dispatchEvent(new CustomEvent("learning-language-changed", {
detail: { language: normalized }
}));
}
function t(key) {
const lang = getLanguage();
return (TEXT[lang] && TEXT[lang][key]) || key;
}
function getToken() {
return localStorage.getItem(STORAGE.token) || "";
}
function getUsername() {
return localStorage.getItem(STORAGE.username) || "";
}
function isLoggedIn() {
return Boolean(getToken());
}
function saveAuth(token, username) {
localStorage.setItem(STORAGE.token, token || "");
localStorage.setItem(STORAGE.username, username || "");
window.dispatchEvent(new CustomEvent("learning-auth-changed", {
detail: { loggedIn: isLoggedIn(), username: getUsername() }
}));
}
function clearAuth() {
localStorage.removeItem(STORAGE.token);
localStorage.removeItem(STORAGE.username);
window.dispatchEvent(new CustomEvent("learning-auth-changed", {
detail: { loggedIn: false, username: "" }
}));
}
function ensureStyle() {
if (document.getElementById("learning-shell-style")) {
return;
}
const style = document.createElement("style");
style.id = "learning-shell-style";
style.textContent = [
".learning-shell {",
" max-width: 1380px;",
" margin: 18px auto 0;",
" padding: 0 24px;",
"}",
".learning-shell-card {",
" display: flex;",
" justify-content: space-between;",
" align-items: center;",
" gap: 14px;",
" flex-wrap: wrap;",
" padding: 14px 18px;",
" border-radius: 22px;",
" border: 1px solid rgba(216, 228, 240, 0.9);",
" background: rgba(255, 255, 255, 0.88);",
" box-shadow: 0 12px 28px rgba(18, 32, 51, 0.08);",
" backdrop-filter: blur(10px);",
"}",
".learning-shell-brand {",
" display: flex;",
" flex-direction: column;",
" gap: 4px;",
"}",
".learning-shell-brand strong {",
" font-size: 15px;",
" color: #122033;",
"}",
".learning-shell-brand span {",
" font-size: 13px;",
" color: #5d7288;",
"}",
".learning-shell-actions {",
" display: flex;",
" align-items: center;",
" gap: 10px;",
" flex-wrap: wrap;",
"}",
".learning-shell-link,",
".learning-shell-button,",
".learning-shell-badge {",
" display: inline-flex;",
" align-items: center;",
" justify-content: center;",
" min-height: 36px;",
" padding: 0 14px;",
" border-radius: 999px;",
" text-decoration: none;",
" font-size: 13px;",
" font-weight: 700;",
"}",
".learning-shell-link {",
" color: #0f67b5;",
" background: rgba(15, 103, 181, 0.09);",
"}",
".learning-shell-button {",
" border: 0;",
" cursor: pointer;",
" color: #fff;",
" background: linear-gradient(135deg, #177245, #35a465);",
"}",
".learning-shell-button.alt {",
" color: #0f67b5;",
" background: rgba(15, 103, 181, 0.09);",
"}",
".learning-shell-badge {",
" color: #33485e;",
" background: rgba(18, 32, 51, 0.06);",
"}",
"@media (max-width: 780px) {",
" .learning-shell {",
" padding: 0 14px;",
" }",
"}"
].join("\n");
document.head.appendChild(style);
}
function mountShell(options) {
ensureStyle();
if (document.querySelector(".learning-shell")) {
if (options && typeof options.onLanguageChange === "function") {
options.onLanguageChange(getLanguage());
}
return;
}
const shell = document.createElement("div");
shell.className = "learning-shell";
shell.innerHTML = [
'<div class="learning-shell-card">',
' <div class="learning-shell-brand">',
' <strong data-role="brand"></strong>',
' <span data-role="status"></span>',
" </div>",
' <div class="learning-shell-actions">',
' <a class="learning-shell-link" href="/" data-role="home"></a>',
' <a class="learning-shell-link" href="/access.html" data-role="access"></a>',
' <span class="learning-shell-badge" data-role="user"></span>',
' <button class="learning-shell-button alt" type="button" data-role="language"></button>',
' <button class="learning-shell-button alt" type="button" data-role="login"></button>',
' <button class="learning-shell-button alt" type="button" data-role="logout"></button>',
" </div>",
"</div>"
].join("");
const firstPage = document.querySelector(".page");
if (firstPage) {
document.body.insertBefore(shell, firstPage);
} else {
document.body.insertBefore(shell, document.body.firstChild);
}
const els = {
brand: shell.querySelector('[data-role="brand"]'),
status: shell.querySelector('[data-role="status"]'),
home: shell.querySelector('[data-role="home"]'),
access: shell.querySelector('[data-role="access"]'),
user: shell.querySelector('[data-role="user"]'),
language: shell.querySelector('[data-role="language"]'),
login: shell.querySelector('[data-role="login"]'),
logout: shell.querySelector('[data-role="logout"]')
};
function render() {
const loggedIn = isLoggedIn();
const username = getUsername();
els.brand.textContent = t("brand");
els.status.textContent = loggedIn ? t("loginReady") : t("loginMissing");
els.home.textContent = t("home");
els.access.textContent = t("access");
els.user.textContent = loggedIn ? t("currentUser") + ": " + username : t("loginMissing");
els.language.textContent = t("languageToggle");
els.login.textContent = t("login");
els.logout.textContent = t("logout");
els.login.style.display = loggedIn ? "none" : "inline-flex";
els.logout.style.display = loggedIn ? "inline-flex" : "none";
if (options && typeof options.onLanguageChange === "function") {
options.onLanguageChange(getLanguage());
}
if (options && typeof options.onAuthChange === "function") {
options.onAuthChange({ loggedIn: loggedIn, username: username });
}
}
els.language.addEventListener("click", function () {
setLanguage(getLanguage() === "zh" ? "en" : "zh");
});
els.login.addEventListener("click", function () {
window.location.href = "/access.html";
});
els.logout.addEventListener("click", function () {
clearAuth();
render();
});
window.addEventListener("learning-auth-changed", render);
window.addEventListener("learning-language-changed", render);
render();
}
async function fetchWithAuth(url, options) {
const requestOptions = options || {};
const headers = new Headers(requestOptions.headers || {});
const token = getToken();
if (token && !headers.has("Authorization")) {
headers.set("Authorization", "Bearer " + token);
}
return fetch(url, Object.assign({}, requestOptions, { headers: headers }));
}
async function requestJson(url, options) {
const response = await fetchWithAuth(url, options);
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("application/json")
? await response.json()
: await response.text();
if (!response.ok) {
const error = new Error(
typeof payload === "object" && payload && payload.message
? payload.message
: t("requestFailed")
);
error.status = response.status;
error.payload = payload;
error.details = typeof payload === "object" && payload ? payload.data : null;
throw error;
}
if (
typeof payload === "object" &&
payload &&
Object.prototype.hasOwnProperty.call(payload, "code") &&
payload.code !== 0
) {
const error = new Error(payload.message || t("requestFailed"));
error.status = payload.code;
error.payload = payload;
error.details = payload.data;
throw error;
}
return payload;
}
function describeError(error) {
if (error && Number(error.status) === 401) {
return t("unauthorized");
}
return error && error.message ? error.message : t("requestFailed");
}
setLanguage(getLanguage());
window.learningShell = {
mountShell: mountShell,
getLanguage: getLanguage,
setLanguage: setLanguage,
getToken: getToken,
getUsername: getUsername,
isLoggedIn: isLoggedIn,
saveAuth: saveAuth,
clearAuth: clearAuth,
fetchWithAuth: fetchWithAuth,
requestJson: requestJson,
describeError: describeError
};
})();

View File

@@ -3,224 +3,659 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户管理 - Spring Boot</title>
<title>User Management Lab</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
h1 { color: #6DB33F; margin: 20px 0; }
h2 { color: #333; margin: 20px 0 10px; }
.card { background: white; padding: 20px; margin: 15px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background: #6DB33F; color: white; }
tr:nth-child(even) { background: #f9f9f9; }
tr:hover { background: #f0f0f0; }
.btn { display: inline-block; padding: 8px 16px; border-radius: 4px; text-decoration: none; font-size: 14px; cursor: pointer; border: none; }
.btn-primary { background: #6DB33F; color: white; }
.btn-primary:hover { background: #5da32f; }
.btn-danger { background: #dc3545; color: white; }
.btn-danger:hover { background: #c82333; }
.btn-secondary { background: #6c757d; color: white; }
.btn-success { background: #28a745; color: white; }
.form-group { margin: 15px 0; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
.form-group input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); }
.modal.active { display: flex; justify-content: center; align-items: center; }
.modal-content { background: white; padding: 30px; border-radius: 8px; width: 400px; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.modal-header h3 { margin: 0; }
.close-btn { background: none; border: none; font-size: 24px; cursor: pointer; }
.btn-group { display: flex; gap: 10px; margin-top: 20px; }
.tip { background: #e7f3ff; padding: 15px; border-radius: 4px; margin: 15px 0; border-left: 4px solid #6DB33F; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
pre { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 4px; overflow-x: auto; }
:root {
--bg: linear-gradient(135deg, #f4fff2 0%, #eef6ff 100%);
--card: rgba(255, 255, 255, 0.94);
--line: #dde7f3;
--text: #123;
--muted: #5c7289;
--green: #3f8f2c;
--blue: #0f6db5;
--red: #d64545;
--shadow: 0 18px 45px rgba(17, 47, 80, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "PingFang SC", sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1120px;
margin: 0 auto;
padding: 28px 18px 48px;
}
.card {
background: var(--card);
border: 1px solid rgba(255,255,255,0.8);
border-radius: 24px;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.hero {
display: grid;
gap: 18px;
grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.9fr);
margin-bottom: 22px;
}
.hero-main,
.hero-side,
.table-card,
.tool-card {
padding: 24px;
}
.eyebrow {
display: inline-flex;
padding: 8px 14px;
border-radius: 999px;
background: rgba(63, 143, 44, 0.12);
color: #2d6e20;
font-size: 12px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
h1 {
margin: 18px 0 12px;
font-size: clamp(32px, 5vw, 50px);
line-height: 1.05;
}
h2, h3 { margin: 0; }
p { color: var(--muted); line-height: 1.7; }
.hero-actions,
.toolbar,
.modal-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
border: 0;
border-radius: 14px;
padding: 12px 18px;
font-weight: 700;
cursor: pointer;
}
.btn-primary { background: linear-gradient(135deg, var(--green), #59b83f); color: #fff; }
.btn-secondary { background: rgba(15, 109, 181, 0.08); color: var(--blue); }
.btn-danger { background: rgba(214, 69, 69, 0.12); color: var(--red); }
.stats {
display: grid;
gap: 16px;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 22px;
}
.stat {
padding: 18px 20px;
}
.stat small {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: .08em;
}
.stat strong {
display: block;
margin-top: 10px;
font-size: 34px;
}
.workspace {
display: grid;
gap: 18px;
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.8fr);
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 999px;
background: rgba(63, 143, 44, 0.1);
color: #2d6e20;
font-size: 12px;
font-weight: 700;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 13px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
}
th {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: .08em;
}
tr:last-child td { border-bottom: 0; }
.segment {
display: inline-flex;
padding: 5px 10px;
border-radius: 999px;
background: rgba(15, 109, 181, 0.08);
color: var(--blue);
font-size: 12px;
font-weight: 700;
}
label {
display: grid;
gap: 8px;
font-size: 14px;
font-weight: 700;
}
input {
width: 100%;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
font: inherit;
}
input:focus {
outline: 2px solid rgba(15, 109, 181, 0.18);
border-color: rgba(15, 109, 181, 0.45);
}
.status {
min-height: 22px;
margin-top: 12px;
color: var(--muted);
font-size: 14px;
}
.status.error { color: var(--red); }
.status.success { color: #2d6e20; }
.empty {
border: 1px dashed var(--line);
border-radius: 18px;
text-align: center;
padding: 28px;
color: var(--muted);
}
.modal {
position: fixed;
inset: 0;
display: none;
justify-content: center;
align-items: center;
background: rgba(13, 26, 46, 0.5);
padding: 18px;
}
.modal.active { display: flex; }
.modal-card {
width: min(100%, 460px);
padding: 24px;
}
.close {
border: 0;
background: transparent;
font-size: 24px;
color: var(--muted);
cursor: pointer;
}
@media (max-width: 920px) {
.hero, .stats, .workspace { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<h1>👥 用户管理 - RESTful API 示例</h1>
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>用户列表</h2>
<button class="btn btn-primary" onclick="openModal()">+ 添加用户</button>
</div>
<table id="userTable">
<thead>
<tr>
<th>ID</th>
<th>姓名</th>
<th>邮箱</th>
<th>年龄</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="card">
<h2>📖 学习要点</h2>
<div class="tip">
<strong>RESTful API 设计:</strong>
<ul style="margin-top: 10px; padding-left: 20px;">
<li><code>GET /api/users</code> - 获取所有用户</li>
<li><code>GET /api/users/{id}</code> - 获取单个用户</li>
<li><code>POST /api/users</code> - 创建用户</li>
<li><code>PUT /api/users/{id}</code> - 更新用户</li>
<li><code>DELETE /api/users/{id}</code> - 删除用户</li>
</ul>
</div>
<h3>Controller 代码示例</h3>
<pre><code>@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping
public List&lt;User&gt; getAllUsers() { ... }
@GetMapping("/{id}")
public User getUserById(@PathVariable Long id) { ... }
@PostMapping
public User createUser(@RequestBody User user) { ... }
@PutMapping("/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) { ... }
@DeleteMapping("/{id}")
public String deleteUser(@PathVariable Long id) { ... }
}</code></pre>
</div>
<div class="card">
<h2>🔧 Spring 注解说明</h2>
<table>
<tr><th>注解</th><th>说明</th></tr>
<tr><td><code>@RestController</code></td><td>= @Controller + @ResponseBody</td></tr>
<tr><td><code>@RequestMapping</code></td><td>定义路由映射</td></tr>
<tr><td><code>@GetMapping</code></td><td>GET 请求映射</td></tr>
<tr><td><code>@PostMapping</code></td><td>POST 请求映射</td></tr>
<tr><td><code>@PathVariable</code></td><td>获取路径变量</td></tr>
<tr><td><code>@RequestBody</code></td><td>获取请求体 JSON</td></tr>
<tr><td><code>@RequestParam</code></td><td>获取查询参数</td></tr>
</table>
</div>
<p><a href="/">← 返回学习中心</a></p>
<!-- 添加/编辑用户模态框 -->
<div class="modal" id="userModal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">添加用户</h3>
<button class="close-btn" onclick="closeModal()">&times;</button>
<div class="page">
<section class="hero">
<div class="card hero-main">
<span class="eyebrow" data-i18n="heroBadge"></span>
<h1 data-i18n="heroTitle"></h1>
<p data-i18n="heroText"></p>
<div class="hero-actions">
<button class="btn btn-primary" onclick="openCreateModal()" data-i18n="createButton"></button>
<button class="btn btn-secondary" onclick="refreshDashboard(pageText().dashboardUpdated, 'success')" data-i18n="refreshButton"></button>
<button class="btn btn-secondary" onclick="window.location.href='/access.html'" data-i18n="loginLabButton"></button>
<button class="btn btn-secondary" onclick="window.location.href='/'" data-i18n="homeButton"></button>
</div>
<form id="userForm">
<input type="hidden" id="userId">
<div class="form-group">
<label>姓名</label>
<input type="text" id="userName" required>
</div>
<div class="card hero-side">
<h3 data-i18n="highlightsTitle"></h3>
<p data-i18n="highlightsText"></p>
</div>
</section>
<section class="stats">
<div class="card stat"><small data-i18n="statTotal"></small><strong id="totalUsers">0</strong></div>
<div class="card stat"><small data-i18n="statAdults"></small><strong id="adultUsers">0</strong></div>
<div class="card stat"><small data-i18n="statYoung"></small><strong id="youngUsers">0</strong></div>
<div class="card stat"><small data-i18n="statAverage"></small><strong id="averageAge">0.0</strong></div>
</section>
<section class="workspace">
<div class="card table-card">
<div class="section-head">
<div>
<h2 data-i18n="directoryTitle"></h2>
<p data-i18n="directoryText"></p>
</div>
<div class="form-group">
<label>邮箱</label>
<input type="email" id="userEmail" required>
<span class="pill" id="resultCount"></span>
</div>
<div id="tableArea"></div>
</div>
<div class="card tool-card">
<div class="section-head">
<div>
<h2 data-i18n="searchTitle"></h2>
<p data-i18n="searchText"></p>
</div>
<div class="form-group">
<label>年龄</label>
<input type="number" id="userAge" required>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary">保存</button>
<button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
</div>
<form onsubmit="searchUsers(event)">
<label>
<span data-i18n="keywordLabel"></span>
<input id="searchInput" type="text" data-i18n-placeholder="searchPlaceholder">
</label>
<div class="toolbar" style="margin-top:14px;">
<button class="btn btn-primary" type="submit" data-i18n="searchButton"></button>
<button class="btn btn-secondary" type="button" onclick="clearSearch()" data-i18n="clearButton"></button>
</div>
</form>
<p data-i18n="searchHint"></p>
<div class="status" id="pageStatus"></div>
</div>
</section>
</div>
<div class="modal" id="userModal">
<div class="card modal-card">
<div class="section-head">
<div>
<h2 id="modalTitle"></h2>
<p id="modalSubtitle"></p>
</div>
<button class="close" onclick="closeModal()" data-i18n-aria="closeLabel">&times;</button>
</div>
<form id="userForm">
<input type="hidden" id="userId">
<label><span data-i18n="nameLabel"></span><input id="userName" maxlength="40" required></label>
<label><span data-i18n="emailLabel"></span><input id="userEmail" type="email" maxlength="80" required></label>
<label><span data-i18n="ageLabel"></span><input id="userAge" type="number" min="1" max="120" required></label>
<div class="status" id="formStatus"></div>
<div class="modal-actions">
<button class="btn btn-primary" type="submit" data-i18n="saveButton"></button>
<button class="btn btn-secondary" type="button" onclick="closeModal()" data-i18n="cancelButton"></button>
</div>
</form>
</div>
<script>
// 加载用户列表
async function loadUsers() {
const res = await fetch('/api/users');
const payload = await res.json();
const users = payload.data || [];
const tbody = document.querySelector('#userTable tbody');
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${u.name}</td>
<td>${u.email}</td>
<td>${u.age}</td>
<td>
<button class="btn btn-primary" onclick="editUser(${u.id}, '${u.name}', '${u.email}', ${u.age})">编辑</button>
<button class="btn btn-danger" onclick="deleteUser(${u.id})">删除</button>
</td>
</tr>
`).join('');
}
// 打开模态框
function openModal() {
document.getElementById('userModal').classList.add('active');
document.getElementById('modalTitle').textContent = '添加用户';
document.getElementById('userForm').reset();
document.getElementById('userId').value = '';
}
// 关闭模态框
function closeModal() {
document.getElementById('userModal').classList.remove('active');
}
// 编辑用户
function editUser(id, name, email, age) {
document.getElementById('userModal').classList.add('active');
document.getElementById('modalTitle').textContent = '编辑用户';
document.getElementById('userId').value = id;
document.getElementById('userName').value = name;
document.getElementById('userEmail').value = email;
document.getElementById('userAge').value = age;
}
// 保存用户
document.getElementById('userForm').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('userId').value;
const user = {
name: document.getElementById('userName').value,
email: document.getElementById('userEmail').value,
age: parseInt(document.getElementById('userAge').value)
};
if (id) {
await fetch(`/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user)
});
} else {
await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user)
});
</div>
<script src="/learning-shell.js"></script>
<script>
const I18N = {
zh: {
title: "用户管理实验",
heroBadge: "Spring Boot Demo",
heroTitle: "让用户管理实验更接近真实项目。",
heroText: "这一页不再只是静态 CRUD 示例,而是接入了后端统计、关键字搜索、重复邮箱保护和清晰的错误处理。",
createButton: "创建用户",
refreshButton: "刷新看板",
loginLabButton: "登录实验",
homeButton: "返回首页",
highlightsTitle: "学习亮点",
highlightsText: "重复邮箱会返回 409校验错误会直接展示搜索同时匹配用户名和邮箱。",
statTotal: "总用户数",
statAdults: "成年人",
statYoung: "30 岁以下",
statAverage: "平均年龄",
directoryTitle: "用户目录",
directoryText: "表格结果与后端 API 实时同步。",
searchTitle: "搜索",
searchText: "输入关键字,按姓名或邮箱匹配。",
keywordLabel: "关键字",
searchPlaceholder: "例如alice 或 example.com",
searchButton: "搜索",
clearButton: "清空",
searchHint: "前端现在会明确解释服务端校验失败和重复邮箱错误,而不是静默失败。",
closeLabel: "关闭",
nameLabel: "姓名",
emailLabel: "邮箱",
ageLabel: "年龄",
saveButton: "保存",
cancelButton: "取消",
modalCreateTitle: "创建用户",
modalCreateSubtitle: "向内存数据仓中新增一个用户。",
modalEditTitle: "编辑用户",
modalEditSubtitle: "更新用户信息,同时保持邮箱唯一。",
emptyUsers: "当前查询没有匹配到任何用户。",
tableId: "ID",
tableName: "姓名",
tableEmail: "邮箱",
tableAge: "年龄",
tableSegment: "分组",
tableActions: "操作",
segmentAdult: "成年",
segmentMinor: "未成年",
editButton: "编辑",
deleteButton: "删除",
dashboardUpdated: "看板已刷新。",
showingAll: "正在显示全部用户。",
latestLoaded: "已加载最新用户数据。",
searchCompleted: function (keyword) {
return "已完成关键字搜索:" + keyword;
},
deleteSuccess: "用户删除成功。",
updateSuccess: "用户更新成功。",
createSuccess: "用户创建成功。",
resultCount: function (count) {
return count + " 个用户";
},
confirmDelete: function (id) {
return "确认删除用户 #" + id + " 吗?";
}
},
en: {
title: "User Management Lab",
heroBadge: "Spring Boot Demo",
heroTitle: "User management that feels production-ready.",
heroText: "This page is no longer a static CRUD sample. It now uses backend stats, keyword search, duplicate email protection, and clear error handling.",
createButton: "Create user",
refreshButton: "Refresh",
loginLabButton: "Login lab",
homeButton: "Back home",
highlightsTitle: "Highlights",
highlightsText: "Duplicate emails return 409, validation errors are shown directly, and search matches both names and emails.",
statTotal: "Total users",
statAdults: "Adults",
statYoung: "Under 30",
statAverage: "Average age",
directoryTitle: "User directory",
directoryText: "Table results stay in sync with the backend API.",
searchTitle: "Search",
searchText: "Use a keyword to match names or emails.",
keywordLabel: "Keyword",
searchPlaceholder: "Try: alice or example.com",
searchButton: "Search",
clearButton: "Clear",
searchHint: "The frontend now explains server-side validation and duplicate email errors instead of failing silently.",
closeLabel: "Close",
nameLabel: "Name",
emailLabel: "Email",
ageLabel: "Age",
saveButton: "Save",
cancelButton: "Cancel",
modalCreateTitle: "Create user",
modalCreateSubtitle: "Add a new user to the in-memory store.",
modalEditTitle: "Edit user",
modalEditSubtitle: "Update the user and keep the email unique.",
emptyUsers: "No users matched the current query.",
tableId: "ID",
tableName: "Name",
tableEmail: "Email",
tableAge: "Age",
tableSegment: "Segment",
tableActions: "Actions",
segmentAdult: "Adult",
segmentMinor: "Minor",
editButton: "Edit",
deleteButton: "Delete",
dashboardUpdated: "Dashboard updated.",
showingAll: "Showing all users.",
latestLoaded: "Latest users loaded.",
searchCompleted: function (keyword) {
return 'Search completed for "' + keyword + '".';
},
deleteSuccess: "User deleted successfully.",
updateSuccess: "User updated successfully.",
createSuccess: "User created successfully.",
resultCount: function (count) {
return count + " users";
},
confirmDelete: function (id) {
return "Delete user #" + id + "?";
}
}
};
const state = {
users: [],
modalMode: "create"
};
function pageText() {
return I18N[window.learningShell.getLanguage()] || I18N.zh;
}
function applyTranslations(text) {
document.title = text.title;
document.querySelectorAll("[data-i18n]").forEach(function (element) {
const key = element.getAttribute("data-i18n");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.textContent = text[key];
}
closeModal();
loadUsers();
});
// 删除用户
async function deleteUser(id) {
if (confirm('确定删除此用户?')) {
await fetch(`/api/users/${id}`, { method: 'DELETE' });
loadUsers();
document.querySelectorAll("[data-i18n-placeholder]").forEach(function (element) {
const key = element.getAttribute("data-i18n-placeholder");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.setAttribute("placeholder", text[key]);
}
});
document.querySelectorAll("[data-i18n-aria]").forEach(function (element) {
const key = element.getAttribute("data-i18n-aria");
if (Object.prototype.hasOwnProperty.call(text, key)) {
element.setAttribute("aria-label", text[key]);
}
});
}
function renderStats(stats) {
document.getElementById("totalUsers").textContent = stats.totalUsers;
document.getElementById("adultUsers").textContent = stats.adults;
document.getElementById("youngUsers").textContent = stats.underThirty;
document.getElementById("averageAge").textContent = Number(stats.averageAge).toFixed(1);
}
function renderModalText() {
const text = pageText();
document.getElementById("modalTitle").textContent = state.modalMode === "edit"
? text.modalEditTitle
: text.modalCreateTitle;
document.getElementById("modalSubtitle").textContent = state.modalMode === "edit"
? text.modalEditSubtitle
: text.modalCreateSubtitle;
}
function renderTable() {
const text = pageText();
const area = document.getElementById("tableArea");
document.getElementById("resultCount").textContent = text.resultCount(state.users.length);
if (!state.users.length) {
area.innerHTML = '<div class="empty">' + text.emptyUsers + "</div>";
return;
}
area.innerHTML = [
"<table>",
" <thead>",
" <tr>",
" <th>" + text.tableId + "</th>",
" <th>" + text.tableName + "</th>",
" <th>" + text.tableEmail + "</th>",
" <th>" + text.tableAge + "</th>",
" <th>" + text.tableSegment + "</th>",
" <th>" + text.tableActions + "</th>",
" </tr>",
" </thead>",
" <tbody>",
state.users.map(function (user) {
const segment = user.age >= 18 ? text.segmentAdult : text.segmentMinor;
return [
" <tr>",
" <td>" + user.id + "</td>",
" <td>" + user.name + "</td>",
" <td>" + user.email + "</td>",
" <td>" + user.age + "</td>",
' <td><span class="segment">' + segment + "</span></td>",
" <td>",
' <button class="btn btn-secondary" onclick="openEditModal(' + user.id + ')">' + text.editButton + "</button>",
' <button class="btn btn-danger" onclick="removeUser(' + user.id + ')">' + text.deleteButton + "</button>",
" </td>",
" </tr>"
].join("");
}).join(""),
" </tbody>",
"</table>"
].join("");
}
function renderLanguage() {
applyTranslations(pageText());
renderModalText();
renderTable();
}
async function request(url, options) {
return window.learningShell.requestJson(url, options);
}
function setStatus(id, message, type) {
const element = document.getElementById(id);
element.textContent = message;
element.className = type ? "status " + type : "status";
}
function openCreateModal() {
document.getElementById("userForm").reset();
document.getElementById("userId").value = "";
state.modalMode = "create";
renderModalText();
setStatus("formStatus", "");
document.getElementById("userModal").classList.add("active");
}
function openEditModal(id) {
const user = state.users.find(function (item) {
return item.id === id;
});
if (!user) {
return;
}
document.getElementById("userId").value = user.id;
document.getElementById("userName").value = user.name;
document.getElementById("userEmail").value = user.email;
document.getElementById("userAge").value = user.age;
state.modalMode = "edit";
renderModalText();
setStatus("formStatus", "");
document.getElementById("userModal").classList.add("active");
}
function closeModal() {
document.getElementById("userModal").classList.remove("active");
}
async function refreshDashboard(message, type) {
try {
const payloads = await Promise.all([
request("/api/users"),
request("/api/users/stats")
]);
state.users = payloads[0].data;
renderStats(payloads[1].data);
renderTable();
setStatus("pageStatus", message, type || "success");
} catch (error) {
setStatus("pageStatus", window.learningShell.describeError(error), "error");
}
}
async function searchUsers(event) {
event.preventDefault();
const keyword = document.getElementById("searchInput").value.trim();
const text = pageText();
try {
if (!keyword) {
await refreshDashboard(text.showingAll, "success");
return;
}
const payload = await request("/api/users/search?keyword=" + encodeURIComponent(keyword));
state.users = payload.data;
renderTable();
setStatus("pageStatus", text.searchCompleted(keyword), "success");
} catch (error) {
setStatus("pageStatus", window.learningShell.describeError(error), "error");
}
}
async function clearSearch() {
document.getElementById("searchInput").value = "";
await refreshDashboard(pageText().showingAll, "success");
}
async function removeUser(id) {
if (!confirm(pageText().confirmDelete(id))) {
return;
}
try {
await request("/api/users/" + id, { method: "DELETE" });
await refreshDashboard(pageText().deleteSuccess, "success");
} catch (error) {
setStatus("pageStatus", window.learningShell.describeError(error), "error");
}
}
document.getElementById("userForm").addEventListener("submit", async function (event) {
event.preventDefault();
const id = document.getElementById("userId").value;
const payload = {
name: document.getElementById("userName").value,
email: document.getElementById("userEmail").value,
age: Number(document.getElementById("userAge").value)
};
try {
if (id) {
await request("/api/users/" + id, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
closeModal();
await refreshDashboard(pageText().updateSuccess, "success");
} else {
await request("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
closeModal();
await refreshDashboard(pageText().createSuccess, "success");
}
} catch (error) {
if (error.details && typeof error.details === "object") {
const firstIssue = Object.values(error.details)[0];
setStatus("formStatus", String(firstIssue), "error");
} else {
setStatus("formStatus", window.learningShell.describeError(error), "error");
}
}
// 初始化
loadUsers();
</script>
});
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
refreshDashboard(pageText().latestLoaded, "success");
</script>
</body>
</html>
</html>