360 lines
14 KiB
HTML
360 lines
14 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 Event Lab</title>
|
||
<style>
|
||
: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="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>
|
||
</section>
|
||
|
||
<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 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 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>
|