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 classRegisterPreview = document.querySelector("#classRegisterPreview");
const classRegisterStatus = document.querySelector("#classRegisterStatus");
const classRegisterConfirm = document.querySelector("#classRegisterConfirm");
const paymentRegisterForm = document.querySelector("#paymentRegisterForm");
const paymentRegisterLines = document.querySelector("#paymentRegisterLines");
const paymentRegisterPreview = document.querySelector("#paymentRegisterPreview");
const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus");
const paymentRegisterConfirm = document.querySelector("#paymentRegisterConfirm");
const summaryRegisterForm = document.querySelector("#summaryRegisterForm");
const summaryRegisterText = document.querySelector("#summaryRegisterText");
const summaryRegisterPreview = document.querySelector("#summaryRegisterPreview");
const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus");
const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm");
let currentAccounts = [];
let editingAccountId = "";
let currentTeachers = [];
let editingTeacherId = "";
let currentSummaryReviews = [];
let activeSummaryReview = null;
const registerPreviewState = {
class_record: null,
payment: null,
course_summary: null,
};
function fmtHours(value) {
const totalMinutes = Math.round(Number(value || 0) * 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}小时${minutes}分`;
}
function displayHours(value, fallback) {
return fallback || fmtHours(value);
}
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 (["approved", "auto_registered", "已批准", "自动入账", "完成", "已更新", "已删除"].includes(status)) return "normal";
if (["rejected", "duplicate", "已驳回", "重复"].includes(status)) return "closed";
if (status === "conflict" || status === "冲突") return "debt";
return "warning";
}
function renderPayments(payments) {
if (!payments.length) return "暂无";
return payments.map((item) => `${escapeHtml(item.date)}:${escapeHtml(displayHours(item.hours, item.duration))}`).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 === "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 = `| 正在读取 |
`;
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)} |
${escapeHtml(displayHours(row.remaining, row.remaining_duration))} |
${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}:${displayHours(item.hours, item.duration)}`).join("\n");
}
function parseHoursInput(value) {
const text = String(value || "").trim();
const match = text.match(/^(\d+)小时(?:(\d+)分)?$/);
if (match) return Number(match[1]) + Number(match[2] || 0) / 60;
const minuteMatch = text.match(/^(\d+)分$/);
if (minuteMatch) return Number(minuteMatch[1]) / 60;
return Number(text);
}
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 = parseHoursInput(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 = `| 正在读取 |
`;
try {
const data = await fetchJson("/api/admin/teachers");
currentTeachers = data.teachers || [];
const rows = currentTeachers.filter(teacherMatches);
renderTeacherSummary(rows);
teacherRows.innerHTML = rows
.map(
(row) => `
${escapeHtml(row.name)} ${escapeHtml(row.teacher_id)} |
${escapeHtml(row.alias || "")} |
${escapeHtml((row.subjects || []).join("、"))} |
${escapeHtml(row.status)} |
${escapeHtml(row.note || "")} |
|
`,
)
.join("");
if (!rows.length) {
teacherRows.innerHTML = `| 没有符合条件的老师档案 |
`;
}
} catch (error) {
teacherRows.innerHTML = `| 读取失败:${escapeHtml(error.message)} |
`;
}
}
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 `${escapeHtml(line)}`;
}
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 `删除
${escapeHtml(restored)}`;
}
return renderReviewLine(item.corrected_line);
}
async function loadReviews() {
reviewRows.innerHTML = `| 正在读取 |
`;
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 `
| #${escapeHtml(item.id)} |
${escapeHtml(item.status)}${item.message ? ` ${escapeHtml(item.message)}` : ""} |
${escapeHtml(reviewTaskTypeLabel(item))} |
${renderReviewLine(item.original_line)} |
${renderReviewTarget(item)} |
${escapeHtml(item.created_at || "")} |
|
`;
})
.join("");
if (!items.length) {
reviewRows.innerHTML = `| 没有符合条件的审核项 |
`;
}
} catch (error) {
reviewRows.innerHTML = `| 读取失败:${escapeHtml(error.message)} |
`;
}
}
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 `${escapeHtml(label)}`;
}
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("
")}
${escapeHtml(summary.group || "")}`;
}
function renderSummaryPreview(summary) {
const body = String(summary.body || "");
const preview = body.length > 140 ? `${body.slice(0, 140)}...` : body;
return `${escapeHtml(preview || "暂无原文")}
`;
}
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 = `${escapeHtml(item.status || "")}${item.message ? ` ${escapeHtml(item.message)}` : ""}`;
summaryReviewDrawerCourse.innerHTML = courseInfoLines(summary).map(escapeHtml).join("
") || "暂无课程信息";
summaryReviewDrawerLine.innerHTML = item.proposed_line ? renderReviewLine(item.proposed_line) : "暂无";
summaryReviewDrawerReasons.innerHTML = reasons.length ? reasons.map(escapeHtml).join("
") : "待人工复核";
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("
") || "暂无来源信息";
summaryReviewDrawerApprove.disabled = !canReview;
summaryReviewDrawerReject.disabled = !canReview;
summaryReviewDrawerBackdrop.hidden = false;
}
function closeSummaryReviewDrawer() {
activeSummaryReview = null;
summaryReviewDrawerBackdrop.hidden = true;
}
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()}`);
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 `
| #${escapeHtml(item.id)} |
${escapeHtml(item.status)}${item.message ? ` ${escapeHtml(item.message)}` : ""} |
${renderSummaryInfo(summary, item)} |
${item.proposed_line ? renderReviewLine(item.proposed_line) : "暂无"} |
${renderSummaryPreview(summary)} |
${reasons.map(escapeHtml).join(" ") || "待人工复核"} |
|
`;
})
.join("");
if (!currentSummaryReviews.length) {
summaryReviewRows.innerHTML = `| 没有符合条件的课程小结审核项 |
`;
}
} catch (error) {
currentSummaryReviews = [];
summaryReviewRows.innerHTML = `| 读取失败:${escapeHtml(error.message)} |
`;
}
}
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 `${escapeHtml(item.body_preview || "")}
${escapeHtml(item.body || "")}
`;
}
function renderSummaryActions(item) {
if (item.time_range) {
return `已完整`;
}
const inputId = `summary-time-${item.id}`;
return `
`;
}
function renderMatchedFields(item) {
const fields = Array.isArray(item.matched_fields) ? item.matched_fields : [];
return fields.length ? `
命中:${fields.map(escapeHtml).join("、")}` : "";
}
async function loadSummarySearch() {
summarySearchRows.innerHTML = `| 正在读取 |
`;
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) => `
| ${escapeHtml(item.date_iso || "未识别")} |
${escapeHtml(item.time_range || "缺少时间")} |
${escapeHtml(item.student || "")} ${escapeHtml(item.group || "")} |
${escapeHtml(item.teacher || "待核对老师")} ${escapeHtml(item.subject || "待核对科目")} |
${escapeHtml(item.title || "")}${renderMatchedFields(item)} |
${renderSummarySearchBody(item)} |
${renderSummaryActions(item)} |
`)
.join("");
if (!data.items.length) {
summarySearchRows.innerHTML = `| 没有符合条件的课程小结 |
`;
}
} catch (error) {
summarySearchRows.innerHTML = `| 读取失败:${escapeHtml(error.message)} |
`;
}
}
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.task_type) details.push(`类型:${item.task_type}`);
if (item.source_id) details.push(`来源:${item.source_id}`);
if (item.student_id) details.push(`学生ID:${item.student_id}`);
if (item.teacher_id) details.push(`教师ID:${item.teacher_id}`);
if (item.teacher || item.subject) details.push(`老师/科目:${item.teacher || ""} ${item.subject || ""}`.trim());
if (item.time_range) details.push(`时间:${item.time_range}`);
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.target_log_id) details.push(`撤回记录:${item.target_log_id}`);
if (item.target_backup_id) details.push(`撤回备份:${item.target_backup_id}`);
if (Array.isArray(item.restored_files) && item.restored_files.length) details.push(`恢复文件:${item.restored_files.join(";")}`);
if (item.saved_path) details.push(`文件:${item.saved_path}`);
return `${details.map(escapeHtml).join("
") || "暂无详情"}
`;
}
function renderLogActions(item) {
if (item.can_rollback) {
return ``;
}
if (item.backup_id && item.rollback_block_reason) {
return `${escapeHtml(item.rollback_block_reason)}`;
}
return `-`;
}
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)} |
${renderLogActions(item)} |
`)
.join("");
if (!data.items.length) {
logRows.innerHTML = `| 没有符合条件的操作记录 |
`;
}
} catch (error) {
logRows.innerHTML = `| 读取失败:${escapeHtml(error.message)} |
`;
}
}
async function rollbackOperationLog(logId) {
if (!confirm("确认撤回这条操作记录?系统只会在没有后续相关改动时恢复备份。")) return;
try {
await fetchJson(`/api/admin/operation-logs/${encodeURIComponent(logId)}/rollback`, { method: "POST" });
await loadOperationLogs();
await loadAdminHealth();
await loadAccounts();
} catch (error) {
alert(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) {
await runTaskAction(taskId, action, loadSummaryReviews);
closeSummaryReviewDrawer();
}
function linesFromTextarea(textarea) {
return textarea.value
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function clearRegisterPreview(type, resetState = true) {
const previewNode = {
class_record: classRegisterPreview,
payment: paymentRegisterPreview,
course_summary: summaryRegisterPreview,
}[type];
const confirmButton = {
class_record: classRegisterConfirm,
payment: paymentRegisterConfirm,
course_summary: summaryRegisterConfirm,
}[type];
if (resetState) registerPreviewState[type] = null;
if (previewNode) {
previewNode.hidden = true;
previewNode.innerHTML = "";
}
if (confirmButton) confirmButton.hidden = true;
}
function renderRegisterPreview(type, data, statusNode) {
const previewNode = {
class_record: classRegisterPreview,
payment: paymentRegisterPreview,
course_summary: summaryRegisterPreview,
}[type];
const confirmButton = {
class_record: classRegisterConfirm,
payment: paymentRegisterConfirm,
course_summary: summaryRegisterConfirm,
}[type];
previewNode.hidden = false;
if (data.status === "ready") {
const warning = data.warning ? `${escapeHtml(data.warning)}
` : "";
previewNode.innerHTML = `${warning}标准登记内容${escapeHtml((data.standard_lines || []).join("\n\n"))}`;
confirmButton.hidden = false;
registerPreviewState[type] = {
conversationId: data.conversation_id,
standardLines: data.standard_lines || [],
};
statusNode.textContent = data.ai_used ? "模型已生成预览,请确认后写入" : "已通过本地标准格式校验,请确认后写入";
return;
}
if (data.status === "needs_info") {
const questions = (data.questions || []).map((item) => `${escapeHtml(item)}`).join("");
previewNode.innerHTML = `需要补充信息`;
confirmButton.hidden = true;
registerPreviewState[type] = {
conversationId: data.conversation_id,
standardLines: [],
};
statusNode.textContent = "请补充信息后再次生成预览";
return;
}
const questions = (data.questions || []).map((item) => `${escapeHtml(item)}`).join("");
previewNode.innerHTML = `无法生成预览${questions ? `` : ""}`;
confirmButton.hidden = true;
registerPreviewState[type] = {
conversationId: data.conversation_id,
standardLines: [],
};
statusNode.textContent = `预览失败:${data.error || "请修改原文后重试"}`;
}
async function previewRegister(event, type, lines, statusNode) {
event.preventDefault();
if (!lines.length) {
clearRegisterPreview(type);
statusNode.textContent = "请先填写登记内容";
return;
}
const previewNode = {
class_record: classRegisterPreview,
payment: paymentRegisterPreview,
course_summary: summaryRegisterPreview,
}[type];
const answerNode = previewNode ? previewNode.querySelector("[data-ai-answer]") : null;
const state = registerPreviewState[type] || {};
const answers = answerNode && answerNode.value.trim() ? { followup: answerNode.value.trim() } : {};
clearRegisterPreview(type, !state.conversationId);
statusNode.textContent = "正在生成预览";
try {
const data = await fetchJson("/api/ai/register/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type,
lines,
answers,
conversation_id: state.conversationId || null,
}),
});
renderRegisterPreview(type, data, statusNode);
} catch (error) {
statusNode.textContent = `预览失败:${error.message}`;
}
}
async function confirmRegister(type, statusNode, url, onSuccess) {
const state = registerPreviewState[type];
if (!state || !state.standardLines || !state.standardLines.length) {
statusNode.textContent = "请先生成可确认的预览";
return;
}
statusNode.textContent = "正在写入";
try {
const data = await fetchJson(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lines: state.standardLines }),
});
if (type === "course_summary") {
statusNode.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;重复 ${data.duplicates} 条;舍弃 ${data.rejected} 条`;
} else {
statusNode.textContent = `已登记 ${data.registered} 条;备份 ${data.backup_id}`;
}
clearRegisterPreview(type);
onSuccess();
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);
});
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();
});
logRows.addEventListener("click", (event) => {
const rollback = event.target.closest(".log-rollback");
if (rollback) rollbackOperationLog(rollback.dataset.logId);
});
classRegisterForm.addEventListener("submit", (event) => {
previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus);
});
paymentRegisterForm.addEventListener("submit", (event) => {
previewRegister(event, "payment", linesFromTextarea(paymentRegisterLines), paymentRegisterStatus);
});
classRegisterConfirm.addEventListener("click", () => {
confirmRegister("class_record", classRegisterStatus, "/api/register/class-records", () => {
classRegisterLines.value = "";
});
});
paymentRegisterConfirm.addEventListener("click", () => {
confirmRegister("payment", paymentRegisterStatus, "/api/register/payments", () => {
paymentRegisterLines.value = "";
});
});
summaryRegisterForm.addEventListener("submit", (event) => {
previewRegister(event, "course_summary", linesFromTextarea(summaryRegisterText), summaryRegisterStatus);
});
summaryRegisterConfirm.addEventListener("click", () => {
confirmRegister("course_summary", summaryRegisterStatus, "/api/register/course-summaries", () => {
summaryRegisterText.value = "";
});
});
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();