Files
springboot-demo/target/classes/static/index.html
2026-03-23 13:05:44 +08:00

646 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spring Boot Learning Cockpit</title>
<style>
: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>
<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>
</section>
<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>
</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."
}
};
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 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";
}
}
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>