662 lines
24 KiB
HTML
662 lines
24 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>User Management Lab</title>
|
||
<style>
|
||
: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>
|
||
<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>
|
||
</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>
|
||
<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>
|
||
<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>
|
||
</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];
|
||
}
|
||
});
|
||
|
||
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");
|
||
}
|
||
}
|
||
});
|
||
|
||
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
|
||
renderLanguage();
|
||
refreshDashboard(pageText().latestLoaded, "success");
|
||
</script>
|
||
</body>
|
||
</html>
|