feat: finish bilingual learning auth cockpit

This commit is contained in:
Codex
2026-03-23 13:05:44 +08:00
parent 09574c3400
commit bde4f6b9cf
56 changed files with 5043 additions and 1377 deletions

View File

@@ -3,224 +3,659 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户管理 - Spring Boot</title>
<title>User Management Lab</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
h1 { color: #6DB33F; margin: 20px 0; }
h2 { color: #333; margin: 20px 0 10px; }
.card { background: white; padding: 20px; margin: 15px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background: #6DB33F; color: white; }
tr:nth-child(even) { background: #f9f9f9; }
tr:hover { background: #f0f0f0; }
.btn { display: inline-block; padding: 8px 16px; border-radius: 4px; text-decoration: none; font-size: 14px; cursor: pointer; border: none; }
.btn-primary { background: #6DB33F; color: white; }
.btn-primary:hover { background: #5da32f; }
.btn-danger { background: #dc3545; color: white; }
.btn-danger:hover { background: #c82333; }
.btn-secondary { background: #6c757d; color: white; }
.btn-success { background: #28a745; color: white; }
.form-group { margin: 15px 0; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
.form-group input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); }
.modal.active { display: flex; justify-content: center; align-items: center; }
.modal-content { background: white; padding: 30px; border-radius: 8px; width: 400px; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.modal-header h3 { margin: 0; }
.close-btn { background: none; border: none; font-size: 24px; cursor: pointer; }
.btn-group { display: flex; gap: 10px; margin-top: 20px; }
.tip { background: #e7f3ff; padding: 15px; border-radius: 4px; margin: 15px 0; border-left: 4px solid #6DB33F; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
pre { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 4px; overflow-x: auto; }
: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>
<h1>👥 用户管理 - RESTful API 示例</h1>
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>用户列表</h2>
<button class="btn btn-primary" onclick="openModal()">+ 添加用户</button>
</div>
<table id="userTable">
<thead>
<tr>
<th>ID</th>
<th>姓名</th>
<th>邮箱</th>
<th>年龄</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="card">
<h2>📖 学习要点</h2>
<div class="tip">
<strong>RESTful API 设计:</strong>
<ul style="margin-top: 10px; padding-left: 20px;">
<li><code>GET /api/users</code> - 获取所有用户</li>
<li><code>GET /api/users/{id}</code> - 获取单个用户</li>
<li><code>POST /api/users</code> - 创建用户</li>
<li><code>PUT /api/users/{id}</code> - 更新用户</li>
<li><code>DELETE /api/users/{id}</code> - 删除用户</li>
</ul>
</div>
<h3>Controller 代码示例</h3>
<pre><code>@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping
public List&lt;User&gt; getAllUsers() { ... }
@GetMapping("/{id}")
public User getUserById(@PathVariable Long id) { ... }
@PostMapping
public User createUser(@RequestBody User user) { ... }
@PutMapping("/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) { ... }
@DeleteMapping("/{id}")
public String deleteUser(@PathVariable Long id) { ... }
}</code></pre>
</div>
<div class="card">
<h2>🔧 Spring 注解说明</h2>
<table>
<tr><th>注解</th><th>说明</th></tr>
<tr><td><code>@RestController</code></td><td>= @Controller + @ResponseBody</td></tr>
<tr><td><code>@RequestMapping</code></td><td>定义路由映射</td></tr>
<tr><td><code>@GetMapping</code></td><td>GET 请求映射</td></tr>
<tr><td><code>@PostMapping</code></td><td>POST 请求映射</td></tr>
<tr><td><code>@PathVariable</code></td><td>获取路径变量</td></tr>
<tr><td><code>@RequestBody</code></td><td>获取请求体 JSON</td></tr>
<tr><td><code>@RequestParam</code></td><td>获取查询参数</td></tr>
</table>
</div>
<p><a href="/">← 返回学习中心</a></p>
<!-- 添加/编辑用户模态框 -->
<div class="modal" id="userModal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">添加用户</h3>
<button class="close-btn" onclick="closeModal()">&times;</button>
<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>
<form id="userForm">
<input type="hidden" id="userId">
<div class="form-group">
<label>姓名</label>
<input type="text" id="userName" required>
</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>
<div class="form-group">
<label>邮箱</label>
<input type="email" id="userEmail" required>
<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 class="form-group">
<label>年龄</label>
<input type="number" id="userAge" required>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary">保存</button>
<button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
</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">&times;</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>
<script>
// 加载用户列表
async function loadUsers() {
const res = await fetch('/api/users');
const payload = await res.json();
const users = payload.data || [];
const tbody = document.querySelector('#userTable tbody');
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${u.name}</td>
<td>${u.email}</td>
<td>${u.age}</td>
<td>
<button class="btn btn-primary" onclick="editUser(${u.id}, '${u.name}', '${u.email}', ${u.age})">编辑</button>
<button class="btn btn-danger" onclick="deleteUser(${u.id})">删除</button>
</td>
</tr>
`).join('');
}
// 打开模态框
function openModal() {
document.getElementById('userModal').classList.add('active');
document.getElementById('modalTitle').textContent = '添加用户';
document.getElementById('userForm').reset();
document.getElementById('userId').value = '';
}
// 关闭模态框
function closeModal() {
document.getElementById('userModal').classList.remove('active');
}
// 编辑用户
function editUser(id, name, email, age) {
document.getElementById('userModal').classList.add('active');
document.getElementById('modalTitle').textContent = '编辑用户';
document.getElementById('userId').value = id;
document.getElementById('userName').value = name;
document.getElementById('userEmail').value = email;
document.getElementById('userAge').value = age;
}
// 保存用户
document.getElementById('userForm').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('userId').value;
const user = {
name: document.getElementById('userName').value,
email: document.getElementById('userEmail').value,
age: parseInt(document.getElementById('userAge').value)
};
if (id) {
await fetch(`/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user)
});
} else {
await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user)
});
</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];
}
closeModal();
loadUsers();
});
// 删除用户
async function deleteUser(id) {
if (confirm('确定删除此用户?')) {
await fetch(`/api/users/${id}`, { method: 'DELETE' });
loadUsers();
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");
}
}
// 初始化
loadUsers();
</script>
});
window.learningShell.mountShell({ onLanguageChange: renderLanguage });
renderLanguage();
refreshDashboard(pageText().latestLoaded, "success");
</script>
</body>
</html>
</html>