Files
xsk-education-management/app/static/admin.js
T
2026-06-16 01:09:51 +08:00

1008 lines
42 KiB
JavaScript
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.
const adminHealthText = document.querySelector("#adminHealthText");
const refreshBtn = document.querySelector("#refreshBtn");
const panels = {
accounts: document.querySelector("#accountsPanel"),
teachers: document.querySelector("#teachersPanel"),
reviews: document.querySelector("#reviewsPanel"),
summaries: document.querySelector("#summariesPanel"),
summarySearch: document.querySelector("#summarySearchPanel"),
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 teacherForm = document.querySelector("#teacherForm");
const teacherQuery = document.querySelector("#teacherQuery");
const teacherStatus = document.querySelector("#teacherStatus");
const teacherMeta = document.querySelector("#teacherMeta");
const teacherRows = document.querySelector("#teacherRows");
const teacherEditor = document.querySelector("#teacherEditor");
const teacherEditorTitle = document.querySelector("#teacherEditorTitle");
const teacherEditForm = document.querySelector("#teacherEditForm");
const teacherEditError = document.querySelector("#teacherEditError");
const cancelTeacherEditBtn = document.querySelector("#cancelTeacherEditBtn");
const newTeacherBtn = document.querySelector("#newTeacherBtn");
const editTeacherId = document.querySelector("#editTeacherId");
const editTeacherName = document.querySelector("#editTeacherName");
const editTeacherAlias = document.querySelector("#editTeacherAlias");
const editTeacherStatus = document.querySelector("#editTeacherStatus");
const editTeacherSubjects = document.querySelector("#editTeacherSubjects");
const editTeacherNote = document.querySelector("#editTeacherNote");
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 summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop");
const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose");
const summaryReviewDrawerTitle = document.querySelector("#summaryReviewDrawerTitle");
const summaryReviewDrawerMeta = document.querySelector("#summaryReviewDrawerMeta");
const summaryReviewDrawerStatus = document.querySelector("#summaryReviewDrawerStatus");
const summaryReviewDrawerCourse = document.querySelector("#summaryReviewDrawerCourse");
const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine");
const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons");
const summaryReviewDrawerBody = document.querySelector("#summaryReviewDrawerBody");
const summaryReviewDrawerSource = document.querySelector("#summaryReviewDrawerSource");
const summaryReviewDrawerApprove = document.querySelector("#summaryReviewDrawerApprove");
const summaryReviewDrawerReject = document.querySelector("#summaryReviewDrawerReject");
const summarySearchForm = document.querySelector("#summarySearchForm");
const summarySearchQuery = document.querySelector("#summarySearchQuery");
const summarySearchStudent = document.querySelector("#summarySearchStudent");
const summarySearchTeacher = document.querySelector("#summarySearchTeacher");
const summarySearchSubject = document.querySelector("#summarySearchSubject");
const summarySearchDateFrom = document.querySelector("#summarySearchDateFrom");
const summarySearchDateTo = document.querySelector("#summarySearchDateTo");
const summarySearchMissingTime = document.querySelector("#summarySearchMissingTime");
const summarySearchMeta = document.querySelector("#summarySearchMeta");
const summarySearchRows = document.querySelector("#summarySearchRows");
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");
const summaryRegisterForm = document.querySelector("#summaryRegisterForm");
const summaryRegisterItems = document.querySelector("#summaryRegisterItems");
const addSummaryRegisterItemBtn = document.querySelector("#addSummaryRegisterItemBtn");
const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus");
let currentAccounts = [];
let editingAccountId = "";
let currentTeachers = [];
let editingTeacherId = "";
let currentSummaryReviews = [];
let activeSummaryReview = null;
let summaryRegisterItemSeq = 0;
const SUMMARY_REQUIRED_FIELDS = [
["student", "学生"],
["date", "日期"],
["time", "时间"],
["teacher", "老师"],
["subject", "科目"],
];
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("<", "&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 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("<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 === "teachers") loadTeachers();
if (tabName === "reviews") loadReviews();
if (tabName === "summaries") loadSummaryReviews();
if (tabName === "summarySearch") loadSummarySearch();
if (tabName === "logs") loadOperationLogs();
}
async function loadAdminHealth() {
try {
const data = await fetchJson("/api/account-health");
const teacherText = data.teachers_count === undefined ? "" : `;老师 ${data.teachers_count}`;
adminHealthText.textContent = `账户 ${data.accounts_count}${teacherText};数据更新时间 ${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 renderTeacherSummary(rows) {
const activeCount = rows.filter((item) => item.status === "在岗").length;
const aliasCount = rows.filter((item) => String(item.alias || "").trim()).length;
teacherMeta.innerHTML = [
metric("当前结果", `${rows.length}`),
metric("在岗", activeCount),
metric("已配置别名", aliasCount),
].join("");
}
function teacherMatches(row) {
const keyword = teacherQuery.value.trim();
if (teacherStatus.value && row.status !== teacherStatus.value) return false;
if (!keyword) return true;
const haystack = [
row.teacher_id,
row.name,
row.alias,
row.display_name,
...(row.subjects || []),
row.status,
row.note,
].join("\n");
return haystack.includes(keyword);
}
async function loadTeachers() {
teacherRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
try {
const data = await fetchJson("/api/admin/teachers");
currentTeachers = data.teachers || [];
const rows = currentTeachers.filter(teacherMatches);
renderTeacherSummary(rows);
teacherRows.innerHTML = rows
.map(
(row) => `<tr>
<td>${escapeHtml(row.name)}<br><small>${escapeHtml(row.teacher_id)}</small></td>
<td>${escapeHtml(row.alias || "")}</td>
<td>${escapeHtml((row.subjects || []).join("、"))}</td>
<td><span class="status ${row.status === "在岗" ? "normal" : "closed"}">${escapeHtml(row.status)}</span></td>
<td class="note-cell">${escapeHtml(row.note || "")}</td>
<td><button class="small-button teacher-edit" type="button" data-teacher-id="${escapeHtml(row.teacher_id)}">编辑</button></td>
</tr>`,
)
.join("");
if (!rows.length) {
teacherRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的老师档案</td></tr>`;
}
} catch (error) {
teacherRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
function openNewTeacherEditor() {
editingTeacherId = "";
teacherEditorTitle.textContent = "新增老师";
editTeacherId.value = "";
editTeacherId.placeholder = "保存时自动生成";
editTeacherName.value = "";
editTeacherAlias.value = "";
editTeacherSubjects.value = "";
editTeacherStatus.value = "在岗";
editTeacherNote.value = "";
teacherEditError.hidden = true;
teacherEditor.hidden = false;
editTeacherName.focus();
}
function openEditTeacherEditor(teacherId) {
const teacher = currentTeachers.find((item) => item.teacher_id === teacherId);
if (!teacher) return;
editingTeacherId = teacher.teacher_id;
teacherEditorTitle.textContent = `编辑老师:${teacher.name}`;
editTeacherId.value = teacher.teacher_id;
editTeacherId.placeholder = "";
editTeacherName.value = teacher.name;
editTeacherAlias.value = teacher.alias || "";
editTeacherSubjects.value = (teacher.subjects || []).join("、");
editTeacherStatus.value = teacher.status;
editTeacherNote.value = teacher.note || "";
teacherEditError.hidden = true;
teacherEditor.hidden = false;
editTeacherName.focus();
}
function parseTeacherSubjects(value) {
return value
.split(/[、,\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function buildTeacherPayload() {
return {
teacher_id: editTeacherId.value.trim(),
name: editTeacherName.value.trim(),
alias: editTeacherAlias.value.trim(),
subjects: parseTeacherSubjects(editTeacherSubjects.value),
status: editTeacherStatus.value,
note: editTeacherNote.value.trim(),
};
}
async function saveTeacher(event) {
event.preventDefault();
try {
const payload = buildTeacherPayload();
const url = editingTeacherId
? `/api/admin/teachers/${encodeURIComponent(editingTeacherId)}`
: "/api/admin/teachers";
const method = editingTeacherId ? "PUT" : "POST";
await fetchJson(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
teacherEditor.hidden = true;
await loadTeachers();
} catch (error) {
teacherEditError.textContent = error.message;
teacherEditError.hidden = false;
}
}
function renderReviewLine(line) {
return `<code class="line-code">${escapeHtml(line)}</code>`;
}
function reviewTaskTypeLabel(item) {
if (item.type === "class_record_deletion") return "删除";
return "纠错";
}
function renderReviewTarget(item) {
if (item.type === "class_record_deletion") {
const restored = item.original && item.original.duration_hours ? `通过后恢复 ${fmtHours(item.original.duration_hours)} 小时` : "通过后恢复课时";
return `<span class="status closed">删除</span><br><small>${escapeHtml(restored)}</small>`;
}
return renderReviewLine(item.corrected_line);
}
async function loadReviews() {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
try {
const [correctionData, deletionData] = await Promise.all([
fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_correction", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`),
fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_deletion", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`),
]);
const items = [...(correctionData.items || []), ...(deletionData.items || [])].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
reviewMeta.innerHTML = [metric("当前结果", `${items.length}`)].join("");
reviewRows.innerHTML = 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>${escapeHtml(reviewTaskTypeLabel(item))}</td>
<td>${renderReviewLine(item.original_line)}</td>
<td>${renderReviewTarget(item)}</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 (!items.length) {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的审核项</td></tr>`;
}
} catch (error) {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
function summaryReviewKind(item) {
const sourceId = String(item.source_id || "");
const reasons = Array.isArray(item.reasons) ? item.reasons.join(" ") : "";
if (sourceId.startsWith("history-missing:") || reasons.includes("历史 classnotes缺失")) {
return "历史缺失";
}
return "";
}
function renderReviewTag(label) {
if (!label) return "";
return `<span class="review-tag history">${escapeHtml(label)}</span>`;
}
function renderSummaryInfo(summary, item = {}) {
const parts = [
summary.student,
summary.date_iso,
summary.time_range,
summary.teacher,
summary.subject,
].filter(Boolean);
return `${renderReviewTag(summaryReviewKind(item))}${parts.map(escapeHtml).join("<br>")}<br><small>${escapeHtml(summary.group || "")}</small>`;
}
function renderSummaryPreview(summary) {
const body = String(summary.body || "");
const preview = body.length > 140 ? `${body.slice(0, 140)}...` : body;
return `<div class="summary-body summary-preview">${escapeHtml(preview || "暂无原文")}</div>`;
}
function findSummaryReview(taskId) {
return currentSummaryReviews.find((item) => String(item.id) === String(taskId));
}
function canReviewSummary(item) {
return item && (item.status === "pending" || item.status === "conflict");
}
function courseInfoLines(summary) {
return [
summary.student ? `学生:${summary.student}` : "",
summary.date_iso ? `日期:${summary.date_iso}` : "",
summary.time_range ? `时间:${summary.time_range}` : "",
summary.teacher || summary.subject ? `老师/科目:${summary.teacher || ""} ${summary.subject || ""}`.trim() : "",
summary.group ? `班级/分组:${summary.group}` : "",
].filter(Boolean);
}
function openSummaryReviewDrawer(taskId) {
const item = findSummaryReview(taskId);
if (!item) return;
const summary = item.summary || {};
const reasons = Array.isArray(item.reasons) ? item.reasons : [];
const kind = summaryReviewKind(item);
const canReview = canReviewSummary(item);
activeSummaryReview = item;
summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`;
summaryReviewDrawerMeta.innerHTML = [
item.created_at ? `提交时间:${escapeHtml(item.created_at)}` : "",
kind ? renderReviewTag(kind) : "",
].filter(Boolean).join(" ");
summaryReviewDrawerStatus.innerHTML = `<span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span>${item.message ? ` <small>${escapeHtml(item.message)}</small>` : ""}`;
summaryReviewDrawerCourse.innerHTML = courseInfoLines(summary).map(escapeHtml).join("<br>") || "暂无课程信息";
summaryReviewDrawerLine.innerHTML = item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>";
summaryReviewDrawerReasons.innerHTML = reasons.length ? reasons.map(escapeHtml).join("<br>") : "待人工复核";
summaryReviewDrawerBody.textContent = summary.body || "暂无原文";
summaryReviewDrawerSource.innerHTML = [
item.source_id ? `来源ID${escapeHtml(item.source_id)}` : "",
item.saved_path ? `保存文件:${escapeHtml(item.saved_path)}` : "",
item.batch_id ? `批次:${escapeHtml(item.batch_id)}` : "",
].filter(Boolean).join("<br>") || "暂无来源信息";
summaryReviewDrawerApprove.disabled = !canReview;
summaryReviewDrawerReject.disabled = !canReview;
summaryReviewDrawerBackdrop.hidden = false;
}
function closeSummaryReviewDrawer() {
activeSummaryReview = null;
summaryReviewDrawerBackdrop.hidden = true;
}
async function loadSummaryReviews() {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
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()}`);
currentSummaryReviews = data.items || [];
summaryReviewMeta.innerHTML = [metric("当前结果", `${data.count}`)].join("");
summaryReviewRows.innerHTML = currentSummaryReviews
.map((item) => {
const canReview = canReviewSummary(item);
const summary = item.summary || {};
const reasons = Array.isArray(item.reasons) ? item.reasons : [];
return `<tr>
<td>#${escapeHtml(item.id)}</td>
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td>${renderSummaryInfo(summary, item)}</td>
<td>${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td>
<td>${renderSummaryPreview(summary)}</td>
<td>${reasons.map(escapeHtml).join("<br>") || "待人工复核"}</td>
<td class="record-action-cell">
<div class="record-actions">
<button class="small-button summary-detail" type="button" data-task-id="${escapeHtml(item.id)}">查看详情</button>
<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>批准</button>
<button class="small-button summary-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
</div>
</td>
</tr>`;
})
.join("");
if (!currentSummaryReviews.length) {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结审核项</td></tr>`;
}
} catch (error) {
currentSummaryReviews = [];
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
function summarySearchParams() {
const params = new URLSearchParams({ limit: "200" });
if (summarySearchQuery.value.trim()) params.set("q", summarySearchQuery.value.trim());
if (summarySearchStudent.value.trim()) params.set("student", summarySearchStudent.value.trim());
if (summarySearchTeacher.value.trim()) params.set("teacher", summarySearchTeacher.value.trim());
if (summarySearchSubject.value.trim()) params.set("subject", summarySearchSubject.value.trim());
if (summarySearchDateFrom.value) params.set("date_from", summarySearchDateFrom.value);
if (summarySearchDateTo.value) params.set("date_to", summarySearchDateTo.value);
if (summarySearchMissingTime.value) params.set("missing_time", "true");
return params;
}
function renderSummarySearchBody(item) {
const fullId = `summary-full-${item.id}`;
const hasFull = String(item.body || "") !== String(item.body_preview || "");
return `<div class="summary-body">${escapeHtml(item.body_preview || "")}</div>
<div id="${escapeHtml(fullId)}" class="summary-body summary-full" hidden>${escapeHtml(item.body || "")}</div>
<button class="small-button summary-toggle" type="button" data-target="${escapeHtml(fullId)}" ${hasFull ? "" : "hidden"}>展开</button>`;
}
function renderSummaryActions(item) {
if (item.time_range) {
return `<span class="muted">已完整</span>`;
}
const inputId = `summary-time-${item.id}`;
return `<div class="summary-time-actions">
<input id="${escapeHtml(inputId)}" class="summary-time-input" data-summary-time-input="${escapeHtml(item.id)}" autocomplete="off" placeholder="08:00-10:00" />
<button class="small-button summary-time-save" type="button" data-summary-id="${escapeHtml(item.id)}">补齐时间</button>
<button class="small-button summary-delete" type="button" data-summary-id="${escapeHtml(item.id)}">删除</button>
</div>`;
}
function renderMatchedFields(item) {
const fields = Array.isArray(item.matched_fields) ? item.matched_fields : [];
return fields.length ? `<br><small>命中:${fields.map(escapeHtml).join("、")}</small>` : "";
}
async function loadSummarySearch() {
summarySearchRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
try {
const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`);
summarySearchMeta.innerHTML = [
metric("命中小结", `${data.count}`),
metric("当前显示", `${data.returned}`),
].join("");
summarySearchRows.innerHTML = data.items
.map((item) => `<tr>
<td>${escapeHtml(item.date_iso || "未识别")}</td>
<td>${escapeHtml(item.time_range || "缺少时间")}</td>
<td>${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
<td>${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td>
<td>${escapeHtml(item.title || "")}${renderMatchedFields(item)}</td>
<td>${renderSummarySearchBody(item)}</td>
<td>${renderSummaryActions(item)}</td>
</tr>`)
.join("");
if (!data.items.length) {
summarySearchRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结</td></tr>`;
}
} catch (error) {
summarySearchRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
async function updateCourseSummaryTime(summaryId, timeRange) {
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/time`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ time_range: timeRange }),
});
await loadSummarySearch();
}
async function deleteCourseSummary(summaryId) {
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}`, {
method: "DELETE",
});
await loadSummarySearch();
}
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 `<div class="log-detail">${details.map(escapeHtml).join("<br>") || "暂无详情"}</div>`;
}
async function loadOperationLogs() {
logRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
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) => `<tr>
<td>${escapeHtml(item.created_at || "")}</td>
<td>${escapeHtml(item.operation || "")}</td>
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span></td>
<td>${escapeHtml(item.student || "")}</td>
<td>${escapeHtml(item.batch_id || "")}${item.task_id ? `<br><small>任务 #${escapeHtml(item.task_id)}</small>` : ""}</td>
<td>${renderLogDetail(item)}</td>
</tr>`)
.join("");
if (!data.items.length) {
logRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的操作记录</td></tr>`;
}
} catch (error) {
logRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
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) {
await runTaskAction(taskId, action, loadSummaryReviews);
closeSummaryReviewDrawer();
}
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}`;
}
}
function addSummaryRegisterItem(value = "") {
summaryRegisterItemSeq += 1;
const itemId = `summary-register-${summaryRegisterItemSeq}`;
const item = document.createElement("div");
item.className = "summary-register-item";
item.innerHTML = `<label for="${itemId}">课程小结</label>
<textarea id="${itemId}" data-summary-field="body" rows="7" placeholder="每个输入框填写一条课程小结">${escapeHtml(value)}</textarea>
<div class="summary-register-fields">
<input data-summary-field="student" autocomplete="off" placeholder="学生" />
<input data-summary-field="date" autocomplete="off" placeholder="日期 2026.06.15" />
<input data-summary-field="time" autocomplete="off" placeholder="时间 08:00-09:00" />
<input data-summary-field="teacher" autocomplete="off" placeholder="老师" />
<input data-summary-field="subject" autocomplete="off" placeholder="科目" />
</div>
<p class="summary-register-missing" hidden></p>
<button class="small-button summary-register-remove" type="button">删除本框</button>`;
summaryRegisterItems.appendChild(item);
}
function inferSummaryField(body, field) {
const patterns = {
student: /(?:学生|学员)[:]\s*([^\n;,]+)/,
date: /(?:日期|上课日期)[:]\s*(\d{4}[./-]\d{1,2}[./-]\d{1,2})|(\d{4}[./-]\d{1,2}[./-]\d{1,2})/,
time: /(?:时间|上课时间)[:]\s*(\d{1,2}:\d{2}-\d{1,2}:\d{2})|(\d{1,2}:\d{2}-\d{1,2}:\d{2})/,
teacher: /(?:老师|教师)[:]\s*([^\n;,]+)|([\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)/,
subject: /(?:科目|课程)[:]\s*([^\n;,]+)|(数学|语文|英语|物理|化学|生物|历史|地理|政治|道法)/,
};
const match = String(body || "").match(patterns[field]);
if (!match) return "";
return (match[1] || match[2] || "").trim();
}
function summaryRegisterItemPayload(item) {
const body = item.querySelector("[data-summary-field='body']").value.trim();
const values = { body };
SUMMARY_REQUIRED_FIELDS.forEach(([field]) => {
const input = item.querySelector(`[data-summary-field='${field}']`);
values[field] = input.value.trim() || inferSummaryField(body, field);
if (!input.value.trim() && values[field]) input.value = values[field];
});
const missing = SUMMARY_REQUIRED_FIELDS.filter(([field]) => !values[field]).map(([, label]) => label);
const missingNode = item.querySelector(".summary-register-missing");
missingNode.hidden = missing.length === 0;
missingNode.textContent = missing.length ? `请补齐:${missing.join("、")};无法补齐请删除本框,本条不会提交。` : "";
item.classList.toggle("has-missing", missing.length > 0);
if (!body || missing.length) return null;
return [
`学生:${values.student}`,
`日期:${values.date}`,
`时间:${values.time}`,
`老师:${values.teacher}`,
`科目:${values.subject}`,
"小结:",
body,
].join("\n");
}
function summaryRegisterLines() {
const items = Array.from(summaryRegisterItems.querySelectorAll(".summary-register-item"));
const lines = items.map(summaryRegisterItemPayload).filter(Boolean);
return {
lines,
blocked: items.some((item) => item.classList.contains("has-missing")),
};
}
async function submitSummaryRegister(event) {
event.preventDefault();
const { lines, blocked } = summaryRegisterLines();
if (blocked) {
summaryRegisterStatus.textContent = "还有课程小结缺字段,请补齐后再提交;无法补齐的请删除本框。";
return;
}
if (!lines.length) {
summaryRegisterStatus.textContent = "请至少填写一条课程小结";
return;
}
summaryRegisterStatus.textContent = "正在提交";
try {
const data = await fetchJson("/api/register/course-summaries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lines }),
});
summaryRegisterStatus.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;重复 ${data.duplicates} 条;舍弃 ${data.rejected}`;
summaryRegisterItems.innerHTML = "";
addSummaryRegisterItem();
await loadAdminHealth();
await loadAccounts();
} catch (error) {
summaryRegisterStatus.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);
});
teacherForm.addEventListener("submit", (event) => {
event.preventDefault();
loadTeachers();
});
teacherStatus.addEventListener("change", loadTeachers);
newTeacherBtn.addEventListener("click", openNewTeacherEditor);
cancelTeacherEditBtn.addEventListener("click", () => {
teacherEditor.hidden = true;
});
teacherEditForm.addEventListener("submit", saveTeacher);
teacherRows.addEventListener("click", (event) => {
const button = event.target.closest(".teacher-edit");
if (!button) return;
openEditTeacherEditor(button.dataset.teacherId);
});
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 detail = event.target.closest(".summary-detail");
const approve = event.target.closest(".summary-approve");
const reject = event.target.closest(".summary-reject");
if (detail) {
openSummaryReviewDrawer(detail.dataset.taskId);
return;
}
if (approve) {
summaryReviewTask(approve.dataset.taskId, "approve");
return;
}
if (reject) summaryReviewTask(reject.dataset.taskId, "reject");
});
summaryReviewDrawerClose.addEventListener("click", closeSummaryReviewDrawer);
summaryReviewDrawerBackdrop.addEventListener("click", (event) => {
if (event.target === summaryReviewDrawerBackdrop) closeSummaryReviewDrawer();
});
summaryReviewDrawerApprove.addEventListener("click", () => {
if (activeSummaryReview) summaryReviewTask(activeSummaryReview.id, "approve");
});
summaryReviewDrawerReject.addEventListener("click", () => {
if (activeSummaryReview) summaryReviewTask(activeSummaryReview.id, "reject");
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !summaryReviewDrawerBackdrop.hidden) closeSummaryReviewDrawer();
});
summarySearchForm.addEventListener("submit", (event) => {
event.preventDefault();
loadSummarySearch();
});
summarySearchMissingTime.addEventListener("change", loadSummarySearch);
summarySearchRows.addEventListener("click", (event) => {
const button = event.target.closest(".summary-toggle");
const saveTime = event.target.closest(".summary-time-save");
const deleteSummary = event.target.closest(".summary-delete");
if (saveTime) {
const input = summarySearchRows.querySelector(`[data-summary-time-input='${saveTime.dataset.summaryId}']`);
const timeRange = input ? input.value.trim() : "";
if (!timeRange) {
alert("请输入时间段");
return;
}
updateCourseSummaryTime(saveTime.dataset.summaryId, timeRange).catch((error) => alert(error.message));
return;
}
if (deleteSummary) {
if (!confirm("确认删除这条课程小结?")) return;
deleteCourseSummary(deleteSummary.dataset.summaryId).catch((error) => alert(error.message));
return;
}
if (!button) return;
const target = document.getElementById(button.dataset.target);
if (!target) return;
target.hidden = !target.hidden;
button.textContent = target.hidden ? "展开" : "收起";
});
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");
});
addSummaryRegisterItemBtn.addEventListener("click", () => addSummaryRegisterItem());
summaryRegisterItems.addEventListener("click", (event) => {
const button = event.target.closest(".summary-register-remove");
if (!button) return;
const items = summaryRegisterItems.querySelectorAll(".summary-register-item");
if (items.length <= 1) {
const textarea = button.closest(".summary-register-item").querySelector("textarea");
if (textarea) textarea.value = "";
return;
}
button.closest(".summary-register-item").remove();
});
summaryRegisterForm.addEventListener("submit", submitSummaryRegister);
refreshBtn.addEventListener("click", () => {
loadAdminHealth();
if (!panels.accounts.hidden) loadAccounts();
if (!panels.reviews.hidden) loadReviews();
if (!panels.summaries.hidden) loadSummaryReviews();
if (!panels.summarySearch.hidden) loadSummarySearch();
if (!panels.logs.hidden) loadOperationLogs();
});
loadAdminHealth();
addSummaryRegisterItem();
loadAccounts();