feat: finish bilingual learning auth cockpit
This commit is contained in:
389
target/classes/static/access.html
Normal file
389
target/classes/static/access.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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<String, Object> 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
329
target/classes/static/learning-shell.js
Normal file
329
target/classes/static/learning-shell.js
Normal 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
|
||||
};
|
||||
})();
|
||||
@@ -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<User> 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()">×</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">×</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>
|
||||
|
||||
Reference in New Issue
Block a user