feat: add admin console and review workflow
This commit is contained in:
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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();
|
||||
Reference in New Issue
Block a user