2026-03-07 05:43:15 +00:00
<!DOCTYPE html>
< html lang = "zh-CN" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
2026-03-23 13:05:44 +08:00
< title > Spring Event Lab< / title >
2026-03-07 05:43:15 +00:00
< style >
2026-03-23 13:05:44 +08:00
: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; }
}
2026-03-07 05:43:15 +00:00
< / style >
< / head >
< body >
2026-03-23 13:05:44 +08:00
< 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 >
2026-03-07 05:43:15 +00:00
< / div >
2026-03-23 13:05:44 +08:00
< / 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 >
2026-03-07 05:43:15 +00:00
< / div >
2026-03-23 13:05:44 +08:00
< / 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;
2026-03-07 05:43:15 +00:00
}
2026-03-23 13:05:44 +08:00
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";
}
2026-03-07 05:43:15 +00:00
}
2026-03-23 13:05:44 +08:00
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";
}
2026-03-07 05:43:15 +00:00
}
2026-03-23 13:05:44 +08:00
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" });
2026-03-07 05:43:15 +00:00
}
2026-03-23 13:05:44 +08:00
async function loadHistory() {
await renderRequest("/aop/event/history");
2026-03-07 05:43:15 +00:00
}
2026-03-18 15:18:30 +08:00
2026-03-23 13:05:44 +08:00
async function loadInfo() {
await renderRequest("/aop/event");
}
2026-03-18 15:18:30 +08:00
2026-03-23 13:05:44 +08:00
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
< / script >
2026-03-07 05:43:15 +00:00
< / body >
2026-03-23 13:05:44 +08:00
< / html >