const adminHealthText = document.querySelector("#adminHealthText"); const refreshBtn = document.querySelector("#refreshBtn"); const panels = { accounts: document.querySelector("#accountsPanel"), reviews: document.querySelector("#reviewsPanel"), summaries: document.querySelector("#summariesPanel"), logs: document.querySelector("#logsPanel"), 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 summaryReviewStatus = document.querySelector("#summaryReviewStatus"); const summaryReviewMeta = document.querySelector("#summaryReviewMeta"); const summaryReviewRows = document.querySelector("#summaryReviewRows"); const logOperation = document.querySelector("#logOperation"); const logStatus = document.querySelector("#logStatus"); const logFilterForm = document.querySelector("#logFilterForm"); const logStudent = document.querySelector("#logStudent"); const logMeta = document.querySelector("#logMeta"); const logRows = document.querySelector("#logRows"); 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 `
${escapeHtml(label)}${escapeHtml(value)}
`; } function statusClass(status) { if (status === "欠费") return "debt"; if (status === "预警") return "warning"; if (status === "正常") return "normal"; return "closed"; } function taskStatusClass(status) { if (status === "approved" || status === "auto_registered") return "normal"; if (status === "rejected" || status === "duplicate") return "closed"; if (status === "conflict") return "debt"; return "warning"; } function renderPayments(payments) { if (!payments.length) return "暂无"; return payments.map((item) => `${escapeHtml(item.date)}:${fmtHours(item.hours)} 小时`).join("
"); } 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(); if (tabName === "summaries") loadSummaryReviews(); if (tabName === "logs") loadOperationLogs(); } 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 = `正在读取`; 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) => ` ${escapeHtml(row.student)}
${escapeHtml(row.student_id)} ${escapeHtml(row.account_status)} ${fmtHours(row.remaining)} ${renderPayments(row.payments)} ${escapeHtml(row.note || "")} `, ) .join(""); if (!data.accounts.length) { accountRows.innerHTML = `没有符合条件的账户`; } } catch (error) { accountRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; } } 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 `${escapeHtml(line)}`; } async function loadReviews() { reviewRows.innerHTML = `正在读取`; 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 ` #${escapeHtml(item.id)} ${escapeHtml(item.status)}${item.message ? `
${escapeHtml(item.message)}` : ""} ${renderReviewLine(item.original_line)} ${renderReviewLine(item.corrected_line)} ${escapeHtml(item.created_at || "")}
`; }) .join(""); if (!data.items.length) { reviewRows.innerHTML = `没有符合条件的审核项`; } } catch (error) { reviewRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; } } function renderSummaryInfo(summary) { const parts = [ summary.student, summary.date_iso, summary.time_range, summary.teacher, summary.subject, ].filter(Boolean); return `${parts.map(escapeHtml).join("
")}
${escapeHtml(summary.group || "")}`; } function renderSummaryBody(summary) { const body = String(summary.body || ""); const preview = body.length > 260 ? `${body.slice(0, 260)}...` : body; return `
${escapeHtml(preview)}
`; } async function loadSummaryReviews() { summaryReviewRows.innerHTML = `正在读取`; const params = new URLSearchParams({ type: "course_summary_review" }); if (summaryReviewStatus.value) params.set("status", summaryReviewStatus.value); try { const data = await fetchJson(`/api/admin/tasks?${params.toString()}`); summaryReviewMeta.innerHTML = [metric("当前结果", `${data.count} 条`)].join(""); summaryReviewRows.innerHTML = data.items .map((item) => { const canReview = item.status === "pending" || item.status === "conflict"; const summary = item.summary || {}; const reasons = Array.isArray(item.reasons) ? item.reasons : []; return ` #${escapeHtml(item.id)} ${escapeHtml(item.status)}${item.message ? `
${escapeHtml(item.message)}` : ""} ${renderSummaryInfo(summary)} ${item.proposed_line ? renderReviewLine(item.proposed_line) : "暂无"} ${renderSummaryBody(summary)} ${reasons.map(escapeHtml).join("
") || "待人工复核"}
`; }) .join(""); if (!data.items.length) { summaryReviewRows.innerHTML = `没有符合条件的课程小结审核项`; } } catch (error) { summaryReviewRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; } } function renderLogDetail(item) { const details = []; if (item.source_id) details.push(`来源:${item.source_id}`); if (item.teacher || item.subject) details.push(`老师/科目:${item.teacher || ""} ${item.subject || ""}`.trim()); if (item.proposed_line) details.push(`记录:${item.proposed_line}`); if (Array.isArray(item.reasons) && item.reasons.length) details.push(`原因:${item.reasons.join(";")}`); if (item.error) details.push(`错误:${item.error}`); if (item.backup_id) details.push(`备份:${item.backup_id}`); if (item.saved_path) details.push(`文件:${item.saved_path}`); return `
${details.map(escapeHtml).join("
") || "暂无详情"}
`; } async function loadOperationLogs() { logRows.innerHTML = `正在读取`; const params = new URLSearchParams({ limit: "200" }); if (logOperation.value) params.set("operation", logOperation.value); if (logStatus.value) params.set("status", logStatus.value); if (logStudent.value.trim()) params.set("student", logStudent.value.trim()); try { const data = await fetchJson(`/api/admin/operation-logs?${params.toString()}`); logMeta.innerHTML = [metric("当前结果", `${data.count} 条`)].join(""); logRows.innerHTML = data.items .map((item) => ` ${escapeHtml(item.created_at || "")} ${escapeHtml(item.operation || "")} ${escapeHtml(item.status || "")} ${escapeHtml(item.student || "")} ${escapeHtml(item.batch_id || "")}${item.task_id ? `
任务 #${escapeHtml(item.task_id)}` : ""} ${renderLogDetail(item)} `) .join(""); if (!data.items.length) { logRows.innerHTML = `没有符合条件的操作记录`; } } catch (error) { logRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; } } async function runTaskAction(taskId, action, reload) { try { await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/${action}`, { method: "POST" }); } catch (error) { alert(error.message); } reload(); } async function reviewTask(taskId, action) { runTaskAction(taskId, action, loadReviews); } async function summaryReviewTask(taskId, action) { runTaskAction(taskId, action, loadSummaryReviews); } 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"); }); summaryReviewStatus.addEventListener("change", loadSummaryReviews); summaryReviewRows.addEventListener("click", (event) => { const approve = event.target.closest(".summary-approve"); const reject = event.target.closest(".summary-reject"); if (approve) summaryReviewTask(approve.dataset.taskId, "approve"); if (reject) summaryReviewTask(reject.dataset.taskId, "reject"); }); logOperation.addEventListener("change", loadOperationLogs); logStatus.addEventListener("change", loadOperationLogs); logFilterForm.addEventListener("submit", (event) => { event.preventDefault(); loadOperationLogs(); }); 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(); if (!panels.summaries.hidden) loadSummaryReviews(); if (!panels.logs.hidden) loadOperationLogs(); }); loadAdminHealth(); loadAccounts();