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

662 lines
24 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>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">&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>
</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>