feat: finish bilingual learning auth cockpit
This commit is contained in:
@@ -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<User> 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()">×</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">×</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>
|
||||
|
||||
Reference in New Issue
Block a user