390 lines
16 KiB
HTML
390 lines
16 KiB
HTML
<!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>
|