feat: add admin console and review workflow

This commit is contained in:
Codex
2026-06-14 21:13:23 +08:00
parent 2591549734
commit 6417d2baa9
13 changed files with 1184 additions and 39 deletions
+166
View File
@@ -0,0 +1,166 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理后台</title>
<link rel="stylesheet" href="/static/styles.css?v=20260613-admin-review" />
</head>
<body>
<header class="topbar">
<div>
<h1>管理后台</h1>
<p id="adminHealthText">正在读取管理状态</p>
</div>
<div class="top-actions">
<a class="nav-button" href="/">课程记录</a>
<a class="nav-button" href="/admin/logout">退出</a>
<button id="refreshBtn" class="icon-button" title="刷新" type="button" aria-label="刷新"></button>
</div>
</header>
<main class="layout account-layout admin-layout">
<nav class="admin-tabs" aria-label="管理后台功能">
<button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button>
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
<button class="admin-tab" data-admin-tab="register" type="button">登记</button>
</nav>
<section id="accountsPanel" class="panel admin-panel">
<div class="section-head">
<h2>课时账户</h2>
<div class="quick-actions">
<select id="accountStatus" aria-label="账户状态筛选">
<option value="">全部状态</option>
<option value="欠费">欠费</option>
<option value="预警">预警</option>
<option value="正常">正常</option>
<option value="结课">结课</option>
<option value="退费">退费</option>
</select>
<button id="newAccountBtn" class="chip" type="button">新增账户</button>
</div>
</div>
<form id="accountForm" class="search-row account-search-row">
<input id="accountQuery" autocomplete="off" placeholder="学生姓名或学生ID" />
<button type="submit">查询</button>
</form>
<div id="accountMeta" class="summary-grid"></div>
<div id="accountEditor" class="admin-editor" hidden>
<div class="section-head compact-head">
<h2 id="accountEditorTitle">新增账户</h2>
<button id="cancelAccountEditBtn" class="secondary-button" type="button">取消</button>
</div>
<form id="accountEditForm" class="admin-form">
<div class="form-grid">
<label>
学生ID
<input id="editStudentId" autocomplete="off" placeholder="新增时自动生成" />
</label>
<label>
学生姓名
<input id="editStudent" autocomplete="off" required />
</label>
<label>
剩余课时
<input id="editRemaining" inputmode="decimal" required />
</label>
<label>
账户状态
<select id="editStatus">
<option value="正常">正常</option>
<option value="预警">预警</option>
<option value="欠费">欠费</option>
<option value="结课">结课</option>
<option value="退费">退费</option>
</select>
</label>
<label class="span-2">
缴费记录
<textarea id="editPayments" rows="4" placeholder="每行一条:2026-06-01:53"></textarea>
</label>
<label class="span-2">
备注
<textarea id="editNote" rows="3"></textarea>
</label>
</div>
<p id="accountEditError" class="correction-error" hidden></p>
<div class="modal-actions">
<button type="submit">保存账户</button>
</div>
</form>
</div>
<div class="table-wrap account-table-wrap">
<table>
<thead>
<tr>
<th>学生</th>
<th>状态</th>
<th class="num">剩余课时</th>
<th>缴费记录</th>
<th>备注</th>
<th>操作</th>
</tr>
</thead>
<tbody id="accountRows"></tbody>
</table>
</div>
</section>
<section id="reviewsPanel" class="panel admin-panel" hidden>
<div class="section-head">
<h2>纠错审核</h2>
<select id="reviewStatus" aria-label="审核状态筛选">
<option value="pending">待审核</option>
<option value="conflict">冲突</option>
<option value="approved">已批准</option>
<option value="rejected">已驳回</option>
<option value="">全部状态</option>
</select>
</div>
<div id="reviewMeta" class="summary-grid"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>编号</th>
<th>状态</th>
<th>原记录</th>
<th>修改后</th>
<th>提交时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="reviewRows"></tbody>
</table>
</div>
</section>
<section id="registerPanel" class="panel admin-panel" hidden>
<div class="section-head">
<h2>登记</h2>
</div>
<div class="register-grid">
<form id="classRegisterForm" class="admin-form">
<h3>上课记录登记</h3>
<textarea id="classRegisterLines" rows="10" placeholder="每行一条上课记录"></textarea>
<p id="classRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions">
<button type="submit">提交上课记录</button>
</div>
</form>
<form id="paymentRegisterForm" class="admin-form">
<h3>缴费登记</h3>
<textarea id="paymentRegisterLines" rows="10" placeholder="每行一条:学生-2026-06-01:53"></textarea>
<p id="paymentRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions">
<button type="submit">提交缴费记录</button>
</div>
</form>
</div>
</section>
</main>
<script src="/static/admin.js?v=20260613-admin-review"></script>
</body>
</html>
+346
View File
@@ -0,0 +1,346 @@
const adminHealthText = document.querySelector("#adminHealthText");
const refreshBtn = document.querySelector("#refreshBtn");
const panels = {
accounts: document.querySelector("#accountsPanel"),
reviews: document.querySelector("#reviewsPanel"),
register: document.querySelector("#registerPanel"),
};
const accountForm = document.querySelector("#accountForm");
const accountQuery = document.querySelector("#accountQuery");
const accountStatus = document.querySelector("#accountStatus");
const accountMeta = document.querySelector("#accountMeta");
const accountRows = document.querySelector("#accountRows");
const accountEditor = document.querySelector("#accountEditor");
const accountEditorTitle = document.querySelector("#accountEditorTitle");
const accountEditForm = document.querySelector("#accountEditForm");
const accountEditError = document.querySelector("#accountEditError");
const cancelAccountEditBtn = document.querySelector("#cancelAccountEditBtn");
const newAccountBtn = document.querySelector("#newAccountBtn");
const editStudentId = document.querySelector("#editStudentId");
const editStudent = document.querySelector("#editStudent");
const editRemaining = document.querySelector("#editRemaining");
const editStatus = document.querySelector("#editStatus");
const editPayments = document.querySelector("#editPayments");
const editNote = document.querySelector("#editNote");
const reviewStatus = document.querySelector("#reviewStatus");
const reviewMeta = document.querySelector("#reviewMeta");
const reviewRows = document.querySelector("#reviewRows");
const classRegisterForm = document.querySelector("#classRegisterForm");
const classRegisterLines = document.querySelector("#classRegisterLines");
const classRegisterStatus = document.querySelector("#classRegisterStatus");
const paymentRegisterForm = document.querySelector("#paymentRegisterForm");
const paymentRegisterLines = document.querySelector("#paymentRegisterLines");
const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus");
let currentAccounts = [];
let editingAccountId = "";
function fmtHours(value) {
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
}
function fmtTime(seconds) {
if (!seconds) return "未知";
return new Date(seconds * 1000).toLocaleString("zh-CN", { hour12: false });
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function metric(label, value) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
}
function statusClass(status) {
if (status === "欠费") return "debt";
if (status === "预警") return "warning";
if (status === "正常") return "normal";
return "closed";
}
function renderPayments(payments) {
if (!payments.length) return "暂无";
return payments.map((item) => `${escapeHtml(item.date)}${fmtHours(item.hours)} 小时`).join("<br>");
}
async function fetchJson(url, options = {}) {
const response = await fetch(url, { cache: "no-store", ...options });
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
const payload = await response.json();
detail = payload.detail || detail;
} catch (_error) {
detail = response.statusText || detail;
}
throw new Error(detail);
}
return response.json();
}
function setActiveTab(tabName) {
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
button.classList.toggle("is-active", button.dataset.adminTab === tabName);
});
Object.entries(panels).forEach(([name, panel]) => {
panel.hidden = name !== tabName;
});
if (tabName === "accounts") loadAccounts();
if (tabName === "reviews") loadReviews();
}
async function loadAdminHealth() {
try {
const data = await fetchJson("/api/account-health");
adminHealthText.textContent = `账户 ${data.accounts_count} 人;数据更新时间 ${fmtTime(data.accounts.mtime)}`;
} catch (error) {
adminHealthText.textContent = `读取失败:${error.message}`;
}
}
function renderAccountSummary(summary, count) {
accountMeta.innerHTML = [
metric("当前结果", `${count}`),
metric("欠费", summary.debt),
metric("预警", summary.warning),
metric("正常", summary.normal),
].join("");
}
async function loadAccounts() {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
const params = new URLSearchParams();
if (accountQuery.value.trim()) params.set("q", accountQuery.value.trim());
if (accountStatus.value) params.set("status", accountStatus.value);
try {
const data = await fetchJson(`/api/accounts?${params.toString()}`);
currentAccounts = data.accounts;
renderAccountSummary(data.summary, data.count);
accountRows.innerHTML = data.accounts
.map(
(row) => `<tr>
<td>${escapeHtml(row.student)}<br><small>${escapeHtml(row.student_id)}</small></td>
<td><span class="status ${statusClass(row.account_status)}">${escapeHtml(row.account_status)}</span></td>
<td class="num">${fmtHours(row.remaining)}</td>
<td>${renderPayments(row.payments)}</td>
<td class="note-cell">${escapeHtml(row.note || "")}</td>
<td><button class="small-button account-edit" type="button" data-student-id="${escapeHtml(row.student_id)}">编辑</button></td>
</tr>`,
)
.join("");
if (!data.accounts.length) {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的账户</td></tr>`;
}
} catch (error) {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
function paymentsToText(payments) {
return payments.map((item) => `${item.date}:${item.hours}`).join("\n");
}
function parsePaymentsInput(value) {
return value
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const [date, hoursText] = item.split(":");
if (!date || hoursText === undefined) {
throw new Error(`缴费记录格式错误:${item}`);
}
const hours = Number(hoursText);
if (!Number.isFinite(hours)) {
throw new Error(`缴费课时格式错误:${item}`);
}
return { date: date.trim(), hours };
});
}
function openNewAccountEditor() {
editingAccountId = "";
accountEditorTitle.textContent = "新增账户";
editStudentId.value = "";
editStudentId.placeholder = "保存时自动生成";
editStudent.value = "";
editRemaining.value = "0";
editStatus.value = "正常";
editPayments.value = "";
editNote.value = "";
accountEditError.hidden = true;
accountEditor.hidden = false;
editStudent.focus();
}
function openEditAccountEditor(studentId) {
const account = currentAccounts.find((item) => item.student_id === studentId);
if (!account) return;
editingAccountId = account.student_id;
accountEditorTitle.textContent = `编辑账户:${account.student}`;
editStudentId.value = account.student_id;
editStudentId.placeholder = "";
editStudent.value = account.student;
editRemaining.value = account.remaining;
editStatus.value = account.account_status;
editPayments.value = paymentsToText(account.payments);
editNote.value = account.note || "";
accountEditError.hidden = true;
accountEditor.hidden = false;
editStudent.focus();
}
function buildAccountPayload() {
const remaining = Number(editRemaining.value);
if (!Number.isFinite(remaining)) {
throw new Error("剩余课时必须是数字");
}
return {
student_id: editStudentId.value.trim(),
student: editStudent.value.trim(),
payments: parsePaymentsInput(editPayments.value),
remaining,
account_status: editStatus.value,
note: editNote.value.trim(),
};
}
async function saveAccount(event) {
event.preventDefault();
try {
const payload = buildAccountPayload();
const url = editingAccountId
? `/api/admin/accounts/${encodeURIComponent(editingAccountId)}`
: "/api/admin/accounts";
const method = editingAccountId ? "PUT" : "POST";
await fetchJson(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
accountEditor.hidden = true;
await loadAdminHealth();
await loadAccounts();
} catch (error) {
accountEditError.textContent = error.message;
accountEditError.hidden = false;
}
}
function renderReviewLine(line) {
return `<code class="line-code">${escapeHtml(line)}</code>`;
}
async function loadReviews() {
reviewRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
const params = new URLSearchParams({ type: "class_record_correction" });
if (reviewStatus.value) params.set("status", reviewStatus.value);
try {
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`);
reviewMeta.innerHTML = [metric("当前结果", `${data.count}`)].join("");
reviewRows.innerHTML = data.items
.map((item) => {
const canReview = item.status === "pending" || item.status === "conflict";
return `<tr>
<td>#${escapeHtml(item.id)}</td>
<td><span class="status ${item.status === "approved" ? "normal" : item.status === "rejected" ? "closed" : item.status === "conflict" ? "debt" : "warning"}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td>${renderReviewLine(item.original_line)}</td>
<td>${renderReviewLine(item.corrected_line)}</td>
<td>${escapeHtml(item.created_at || "")}</td>
<td class="record-action-cell">
<div class="record-actions">
<button class="small-button review-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>批准</button>
<button class="small-button review-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
</div>
</td>
</tr>`;
})
.join("");
if (!data.items.length) {
reviewRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的审核项</td></tr>`;
}
} catch (error) {
reviewRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
async function reviewTask(taskId, action) {
try {
await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/${action}`, { method: "POST" });
} catch (error) {
alert(error.message);
}
loadReviews();
}
function linesFromTextarea(textarea) {
return textarea.value
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
async function submitRegister(event, textarea, statusNode, url) {
event.preventDefault();
statusNode.textContent = "正在提交";
try {
const data = await fetchJson(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lines: linesFromTextarea(textarea) }),
});
statusNode.textContent = `已登记 ${data.registered} 条;备份 ${data.backup_id}`;
textarea.value = "";
await loadAdminHealth();
await loadAccounts();
} catch (error) {
statusNode.textContent = `提交失败:${error.message}`;
}
}
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
button.addEventListener("click", () => setActiveTab(button.dataset.adminTab));
});
accountForm.addEventListener("submit", (event) => {
event.preventDefault();
loadAccounts();
});
accountStatus.addEventListener("change", loadAccounts);
newAccountBtn.addEventListener("click", openNewAccountEditor);
cancelAccountEditBtn.addEventListener("click", () => {
accountEditor.hidden = true;
});
accountEditForm.addEventListener("submit", saveAccount);
accountRows.addEventListener("click", (event) => {
const button = event.target.closest(".account-edit");
if (!button) return;
openEditAccountEditor(button.dataset.studentId);
});
reviewStatus.addEventListener("change", loadReviews);
reviewRows.addEventListener("click", (event) => {
const approve = event.target.closest(".review-approve");
const reject = event.target.closest(".review-reject");
if (approve) reviewTask(approve.dataset.taskId, "approve");
if (reject) reviewTask(reject.dataset.taskId, "reject");
});
classRegisterForm.addEventListener("submit", (event) => {
submitRegister(event, classRegisterLines, classRegisterStatus, "/api/register/class-records");
});
paymentRegisterForm.addEventListener("submit", (event) => {
submitRegister(event, paymentRegisterLines, paymentRegisterStatus, "/api/register/payments");
});
refreshBtn.addEventListener("click", () => {
loadAdminHealth();
if (!panels.accounts.hidden) loadAccounts();
if (!panels.reviews.hidden) loadReviews();
});
loadAdminHealth();
loadAccounts();
+34 -2
View File
@@ -10,6 +10,7 @@ const inlineAccount = document.querySelector("#inlineAccount");
const correctionToolbar = document.querySelector("#correctionToolbar");
const correctionStatus = document.querySelector("#correctionStatus");
const copyCorrectedBtn = document.querySelector("#copyCorrectedBtn");
const submitCorrectionsBtn = document.querySelector("#submitCorrectionsBtn");
const correctionDialog = document.querySelector("#correctionDialog");
const correctionForm = document.querySelector("#correctionForm");
const correctionOriginal = document.querySelector("#correctionOriginal");
@@ -260,7 +261,9 @@ function updateCorrectionToolbar(message = "", isError = false) {
const count = correctedRecords.size;
correctionToolbar.hidden = count === 0;
copyCorrectedBtn.disabled = count === 0;
submitCorrectionsBtn.disabled = count === 0;
copyCorrectedBtn.textContent = `复制已修改记录 ${count}`;
submitCorrectionsBtn.textContent = `提交审核 ${count}`;
correctionStatus.textContent = message || `已修改 ${count} 条记录`;
correctionStatus.classList.toggle("is-error", isError);
}
@@ -435,8 +438,36 @@ async function copyCorrectedRecords() {
}
}
async function fetchJson(url) {
const response = await fetch(url, { cache: "no-store" });
async function submitCorrectedRecords() {
const items = currentRecordOrder
.map((key) => {
const original = findCurrentRecord(key);
const corrected = correctedRecords.get(key);
if (!original || !corrected) return null;
return {
original_line: buildRecordLine(original),
corrected_line: buildRecordLine(corrected),
};
})
.filter(Boolean);
if (!items.length) return;
submitCorrectionsBtn.disabled = true;
try {
const data = await fetchJson("/api/corrections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
});
updateCorrectionToolbar(`已提交 ${data.submitted} 条纠错审核`);
} catch (error) {
updateCorrectionToolbar(`提交失败:${error.message}`, true);
} finally {
submitCorrectionsBtn.disabled = correctedRecords.size === 0;
}
}
async function fetchJson(url, options = {}) {
const response = await fetch(url, { cache: "no-store", ...options });
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
@@ -550,6 +581,7 @@ document.addEventListener("keydown", (event) => {
});
copyCorrectedBtn.addEventListener("click", copyCorrectedRecords);
submitCorrectionsBtn.addEventListener("click", submitCorrectedRecords);
updateRecordSortButtons();
loadHealth();
+6 -3
View File
@@ -13,7 +13,7 @@
<p id="healthText">正在读取数据状态</p>
</div>
<div class="top-actions">
<a class="nav-button" href="/accounts">课时账户</a>
<a class="nav-button" href="/admin">管理后台</a>
<button id="refreshBtn" class="icon-button" title="刷新数据" type="button" aria-label="刷新数据">
</button>
@@ -43,7 +43,10 @@
<div id="recordMeta" class="summary-grid"></div>
<div id="correctionToolbar" class="correction-toolbar" hidden>
<span id="correctionStatus">已修改 0 条记录</span>
<button id="copyCorrectedBtn" type="button">复制已修改记录</button>
<div class="toolbar-actions">
<button id="copyCorrectedBtn" type="button">复制已修改记录</button>
<button id="submitCorrectionsBtn" type="button">提交审核</button>
</div>
</div>
<div id="inlineAccount" class="inline-account" hidden></div>
<div class="table-wrap">
@@ -116,6 +119,6 @@
</section>
</div>
<script src="/static/app.js?v=20260613-sort-labels"></script>
<script src="/static/app.js?v=20260613-admin-review"></script>
</body>
</html>
+126
View File
@@ -125,6 +125,10 @@ h2 {
grid-template-columns: minmax(0, 1fr);
}
.admin-layout {
gap: 12px;
}
.panel {
min-width: 0;
border: 1px solid var(--line);
@@ -150,6 +154,30 @@ h2 {
justify-content: flex-end;
}
.admin-tabs {
display: flex;
gap: 8px;
padding: 0 0 4px;
}
.admin-tab {
min-height: 38px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
color: var(--text);
cursor: pointer;
font-weight: 700;
}
.admin-tab.is-active,
.admin-tab:hover {
border-color: var(--accent);
background: #e9f5f3;
color: var(--accent-strong);
}
.chip,
.search-row button {
border: 0;
@@ -280,6 +308,77 @@ select:focus {
font-size: 14px;
}
.admin-editor {
border-bottom: 1px solid var(--line);
background: #fbfcfd;
}
.compact-head {
padding: 12px 16px;
}
.admin-form {
padding: 16px;
}
.admin-form h3 {
margin: 0 0 10px;
font-size: 16px;
line-height: 1.25;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.form-grid label {
display: grid;
gap: 6px;
color: #344054;
font-size: 13px;
font-weight: 700;
}
.span-2 {
grid-column: span 2;
}
textarea {
width: 100%;
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--text);
font: inherit;
resize: vertical;
outline: none;
}
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15);
}
.register-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.line-code {
display: block;
max-width: 420px;
white-space: normal;
overflow-wrap: anywhere;
font-family: Arial, "Songti SC", SimSun, sans-serif;
font-size: 13px;
line-height: 1.4;
}
.correction-toolbar {
display: flex;
align-items: center;
@@ -300,6 +399,12 @@ select:focus {
color: var(--danger);
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.correction-toolbar button,
.modal-actions button {
min-height: 38px;
@@ -638,6 +743,10 @@ td {
.inline-account-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.register-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
@@ -679,6 +788,11 @@ td {
flex-direction: column;
}
.toolbar-actions {
width: 100%;
flex-direction: column;
}
.correction-toolbar button {
width: 100%;
}
@@ -687,6 +801,18 @@ td {
grid-template-columns: 1fr;
}
.admin-tabs {
overflow-x: auto;
}
.form-grid {
grid-template-columns: 1fr;
}
.span-2 {
grid-column: auto;
}
.modal-actions {
flex-direction: column-reverse;
}