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 duplicateSummaryScanBtns = Array.from(document.querySelectorAll(".duplicate-summary-scan"));
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 summaryReviewEditSection = document.querySelector("#summaryReviewEditSection");
const summaryReviewEditStudent = document.querySelector("#summaryReviewEditStudent");
const summaryReviewEditDate = document.querySelector("#summaryReviewEditDate");
const summaryReviewEditTime = document.querySelector("#summaryReviewEditTime");
const summaryReviewEditTeacher = document.querySelector("#summaryReviewEditTeacher");
const summaryReviewEditSubject = document.querySelector("#summaryReviewEditSubject");
const summaryReviewEditHint = document.querySelector("#summaryReviewEditHint");
const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine");
const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons");
const summaryReviewConflictSection = document.querySelector("#summaryReviewConflictSection");
const summaryReviewDrawerConflicts = document.querySelector("#summaryReviewDrawerConflicts");
const summaryReviewDrawerBody = document.querySelector("#summaryReviewDrawerBody");
const summaryReviewDrawerSource = document.querySelector("#summaryReviewDrawerSource");
const summaryReviewDrawerSave = document.querySelector("#summaryReviewDrawerSave");
const summaryReviewDrawerLink = document.querySelector("#summaryReviewDrawerLink");
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 summarySearchBindingStatus = document.querySelector("#summarySearchBindingStatus");
const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate");
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;
let currentSummarySearchItems = [];
let expandedSummarySearchId = "";
let editingSummarySearchId = "";
let editingSummaryIdentityId = "";
let currentOperationLogs = [];
let expandedOperationLogId = "";
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) {
const activeTabName = tabName === "summaries" ? "summarySearch" : tabName;
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
button.classList.toggle("is-active", button.dataset.adminTab === activeTabName);
});
Object.entries(panels).forEach(([name, panel]) => {
panel.hidden = name !== activeTabName;
});
if (activeTabName === "accounts") loadAccounts();
if (activeTabName === "teachers") loadTeachers();
if (activeTabName === "reviews") loadReviews();
if (activeTabName === "summarySearch") {
loadSummarySearch();
loadSummaryReviews();
}
if (activeTabName === "logs") loadOperationLogs();
}
async function loadAdminHealth() {
try {
const data = await fetchJson("/api/account-health");
adminHealthText.textContent = [
`账户 ${data.accounts_count || 0} 人`,
`在岗老师 ${data.active_teachers_count || 0} 位`,
`课程记录 ${data.records_count || 0} 条`,
`小结 ${data.course_summaries_count || 0} 条`,
`本月已上 ${data.current_month_duration || fmtHours(data.current_month_hours || 0)}`,
].join(";");
} 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) {
if (item.type === "course_summary_duplicate_review") return "重复小结";
if (hasDuplicateReviewContext(item)) return "重复待审";
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 || item.suggested_time_range,
summary.teacher,
summary.subject,
].filter(Boolean);
const suggestedTime = !summary.time_range && item.suggested_time_range
? `
原文时间,待保存`
: "";
return `${renderReviewTag(summaryReviewKind(item))}${parts.map(escapeHtml).join("
")}${suggestedTime}
${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 isDuplicateSummaryReview(item) {
return item && item.type === "course_summary_duplicate_review";
}
function hasDuplicateReviewContext(item) {
return Boolean(
item
&& !isDuplicateSummaryReview(item)
&& (
item.duplicate_source_id
|| item.duplicate_semantic_key
|| (Array.isArray(item.duplicate_reasons) && item.duplicate_reasons.length)
|| (Array.isArray(item.duplicate_conflicts) && item.duplicate_conflicts.length)
)
);
}
function renderDuplicateCandidate(candidate, canReview, options = {}) {
const showDelete = options.showDelete !== false;
const binding = summaryBinding(candidate);
const bindingDetail = binding.record
? `绑定记录:${renderBindingRecordLine(binding.record)}`
: (Array.isArray(binding.candidates) && binding.candidates.length
? `候选记录:${binding.candidates.length} 条`
: (binding.reason || ""));
const source = [
bindingDetail,
candidate.source_id ? `来源ID:${candidate.source_id}` : "",
candidate.message_time ? `发送时间:${candidate.message_time}` : "",
candidate.sender ? `发送者:${candidate.sender}` : "",
candidate.relative_path ? `文件:${candidate.relative_path}` : "",
candidate.source_path && !candidate.relative_path ? `文件:${candidate.source_path}` : "",
].filter(Boolean);
return `
${escapeHtml(candidate.title || "课程小结")}
${renderBindingBadge(candidate)}
${source.map(escapeHtml).join("
") || "暂无来源信息"}
${escapeHtml(candidate.body || candidate.body_preview || "暂无正文")}
${showDelete ? `
` : ""}
`;
}
function renderDuplicateCandidates(item, canReview) {
const candidates = Array.isArray(item.duplicate_candidates) ? item.duplicate_candidates : [];
if (!candidates.length) return "暂无重复候选";
return candidates.map((candidate) => renderDuplicateCandidate(candidate, canReview)).join("");
}
function renderSummaryDuplicateConflicts(item) {
const conflicts = Array.isArray(item.duplicate_conflicts) ? item.duplicate_conflicts : [];
const reasonLines = Array.isArray(item.duplicate_reasons) ? item.duplicate_reasons : [];
const source = item.duplicate_source;
const parts = [];
if (reasonLines.length) {
parts.push(`${reasonLines.map(escapeHtml).join("
")}
`);
}
if (source) {
parts.push(`
本次提交
${[
source.source_id ? `来源ID:${source.source_id}` : "",
source.message_time ? `发送时间:${source.message_time}` : "",
source.sender ? `发送者:${source.sender}` : "",
].filter(Boolean).map(escapeHtml).join("
") || "暂无来源信息"}
${escapeHtml(source.body || source.body_preview || "暂无正文")}
`);
}
if (conflicts.length) {
parts.push(`命中的已有小结
`);
parts.push(conflicts.map((candidate) => renderDuplicateCandidate(candidate, false, { showDelete: false })).join(""));
} else {
parts.push("状态文件命中重复,但正式小结库未找到对应小结。");
}
return parts.join("");
}
function findSummaryReview(taskId) {
return currentSummaryReviews.find((item) => String(item.id) === String(taskId));
}
function canReviewSummary(item) {
return item && (item.status === "pending" || item.status === "conflict");
}
function canLinkExistingSummary(item) {
const reasons = Array.isArray(item && item.reasons) ? item.reasons : [];
return Boolean(item && item.proposed_line && reasons.includes("classnotes 已存在同一条上课记录"));
}
function canApproveSummaryDirectly(item) {
return Boolean(
canReviewSummary(item)
&& !isDuplicateSummaryReview(item)
&& item.proposed_line
&& !canLinkExistingSummary(item)
);
}
function isMissingTimeReason(reason) {
const text = String(reason || "");
return (
text.includes("时间段缺失")
|| text.includes("时长缺失")
|| text.includes("课程小结缺少时长")
|| text.includes("请补充时间")
);
}
function summaryReviewReasonLines(item) {
const reasons = Array.isArray(item && item.reasons) ? item.reasons : [];
const lines = [];
if (reasons.some(isMissingTimeReason)) {
const suggestion = item.suggested_time_range
? `:原文疑似 ${item.suggested_time_range}${item.suggested_duration ? `,${item.suggested_duration}` : ""}`
: "";
lines.push(`待补时间${suggestion}`);
}
reasons.forEach((reason) => {
if (isMissingTimeReason(reason)) return;
if (!lines.includes(reason)) lines.push(reason);
});
return lines.length ? lines : ["待人工复核"];
}
function renderSummaryReviewReasons(item) {
return `${summaryReviewReasonLines(item)
.map((reason) => `${escapeHtml(reason)}`)
.join("")}
`;
}
function summaryReviewDetailLabel(item) {
if (isDuplicateSummaryReview(item)) return "处理重复";
if (canLinkExistingSummary(item)) return "关联记录";
if (!item.proposed_line) return item.suggested_time_range ? "补时间" : "修正";
return "查看详情";
}
function courseInfoLines(summary, item = {}) {
return [
summary.student ? `学生:${summary.student}` : "",
summary.date_iso ? `日期:${summary.date_iso}` : "",
summary.time_range ? `时间:${summary.time_range}` : "",
!summary.time_range && item.suggested_time_range ? `原文时间:${item.suggested_time_range}` : "",
summary.teacher || summary.subject ? `老师/科目:${summary.teacher || ""} ${summary.subject || ""}`.trim() : "",
summary.group ? `班级/分组:${summary.group}` : "",
].filter(Boolean);
}
function fillSummaryReviewEditor(summary, item = {}) {
summaryReviewEditStudent.value = summary.student || "";
summaryReviewEditDate.value = summary.date_iso || "";
summaryReviewEditTime.value = summary.time_range || item.suggested_time_range || "";
summaryReviewEditTeacher.value = summary.teacher || "";
summaryReviewEditSubject.value = summary.subject || "";
const hasSuggestedTime = Boolean(!summary.time_range && item.suggested_time_range);
summaryReviewEditTime.classList.toggle("suggested-input", hasSuggestedTime);
summaryReviewEditHint.hidden = !hasSuggestedTime;
summaryReviewEditHint.textContent = hasSuggestedTime
? `已从原文识别到 ${item.suggested_time_range}${item.suggested_duration ? `(${item.suggested_duration})` : ""},保存后会生成建议登记行。`
: "";
}
function summaryReviewEditorPayload() {
return {
student: summaryReviewEditStudent.value.trim(),
date_iso: summaryReviewEditDate.value,
time_range: summaryReviewEditTime.value.trim(),
teacher: summaryReviewEditTeacher.value.trim(),
subject: summaryReviewEditSubject.value.trim(),
};
}
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);
const duplicateReview = isDuplicateSummaryReview(item);
const duplicateContext = hasDuplicateReviewContext(item);
const pendingSummarySave = Boolean(item.pending_summary_save);
const canApproveDirectly = canApproveSummaryDirectly(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, item).map(escapeHtml).join("
") || "暂无课程信息";
summaryReviewEditSection.hidden = duplicateReview;
if (!duplicateReview) fillSummaryReviewEditor(summary, item);
summaryReviewDrawerLine.innerHTML = duplicateReview
? "请选择下方重复小结删除"
: item.proposed_line ? renderReviewLine(item.proposed_line) : "保存修正后生成";
summaryReviewDrawerReasons.innerHTML = renderSummaryReviewReasons(item);
if (duplicateReview) {
summaryReviewDrawerBody.innerHTML = renderDuplicateCandidates(item, canReview);
} else {
summaryReviewDrawerBody.textContent = summary.body || "暂无原文";
}
summaryReviewConflictSection.hidden = !duplicateContext;
summaryReviewDrawerConflicts.innerHTML = duplicateContext ? renderSummaryDuplicateConflicts(item) : "";
summaryReviewDrawerSource.innerHTML = [
item.source_id ? `来源ID:${escapeHtml(item.source_id)}` : "",
item.saved_path ? `${duplicateContext || pendingSummarySave ? "目标文件" : "保存文件"}:${escapeHtml(item.saved_path)}` : "",
item.batch_id ? `批次:${escapeHtml(item.batch_id)}` : "",
].filter(Boolean).join("
") || "暂无来源信息";
summaryReviewDrawerSave.hidden = duplicateReview;
summaryReviewDrawerSave.disabled = !canReview || duplicateReview;
summaryReviewDrawerSave.textContent = !summary.time_range && item.suggested_time_range ? "保存时间修正" : "保存修正";
summaryReviewDrawerLink.hidden = duplicateReview || !canLinkExistingSummary(item);
summaryReviewDrawerLink.disabled = !canReview || duplicateReview || !canLinkExistingSummary(item);
summaryReviewDrawerApprove.hidden = duplicateReview || !item.proposed_line || canLinkExistingSummary(item);
summaryReviewDrawerApprove.disabled = !canApproveDirectly;
summaryReviewDrawerReject.disabled = !canReview;
summaryReviewDrawerBackdrop.hidden = false;
}
function closeSummaryReviewDrawer() {
activeSummaryReview = null;
summaryReviewEditSection.hidden = true;
summaryReviewDrawerSave.hidden = false;
summaryReviewDrawerSave.textContent = "保存修正";
summaryReviewDrawerLink.hidden = true;
summaryReviewDrawerApprove.hidden = false;
summaryReviewConflictSection.hidden = true;
summaryReviewDrawerConflicts.innerHTML = "";
summaryReviewEditHint.hidden = true;
summaryReviewEditHint.textContent = "";
summaryReviewEditTime.classList.remove("suggested-input");
summaryReviewDrawerBackdrop.hidden = true;
}
async function loadSummaryReviews() {
summaryReviewRows.innerHTML = `| 正在读取 |
`;
const reviewParams = new URLSearchParams({ type: "course_summary_review" });
const duplicateParams = new URLSearchParams({ type: "course_summary_duplicate_review" });
if (summaryReviewStatus.value) {
reviewParams.set("status", summaryReviewStatus.value);
duplicateParams.set("status", summaryReviewStatus.value);
}
try {
const [reviewData, duplicateData] = await Promise.all([
fetchJson(`/api/admin/tasks?${reviewParams.toString()}`),
fetchJson(`/api/admin/tasks?${duplicateParams.toString()}`),
]);
const reviewItems = reviewData.items || [];
const duplicateItems = duplicateData.items || [];
currentSummaryReviews = [...reviewItems, ...duplicateItems].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
summaryReviewMeta.innerHTML = [
metric("待处理任务", `${currentSummaryReviews.length} 条`),
metric("课程小结审核", `${reviewItems.length} 条`),
metric("重复小结", `${duplicateItems.length} 条`),
].join("");
summaryReviewRows.innerHTML = currentSummaryReviews
.map((item) => {
const canReview = canReviewSummary(item);
const duplicateReview = isDuplicateSummaryReview(item);
const canApproveDirectly = canApproveSummaryDirectly(item);
const summary = item.summary || {};
const approveButton = canApproveDirectly
? ``
: "";
return `
| #${escapeHtml(item.id)} |
${escapeHtml(item.status)}${item.message ? ` ${escapeHtml(item.message)}` : ""} |
${renderSummaryInfo(summary, item)} |
${item.proposed_line ? renderReviewLine(item.proposed_line) : "暂无"} |
${renderSummaryPreview(summary)} |
${renderSummaryReviewReasons(item)} |
${approveButton}
|
`;
})
.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 (summarySearchBindingStatus.value) params.set("binding_status", summarySearchBindingStatus.value);
if (summarySearchHasCandidate.value) params.set("has_candidate", summarySearchHasCandidate.value);
return params;
}
function renderSummaryActions(item) {
const identityButton = ``;
const editButton = ``;
const deleteButton = ``;
if (item.time_range) {
return `时间已完整${identityButton}${editButton}${deleteButton}
`;
}
const inputId = `summary-time-${item.id}`;
return `
${identityButton}
${editButton}
${deleteButton}
`;
}
function renderSummaryIdentityEditor(item) {
if (String(item.id) !== editingSummaryIdentityId) return "";
return ``;
}
function renderSummaryBodyEditor(item) {
const body = item.body || item.body_preview || "";
if (String(item.id) !== editingSummarySearchId) {
return `${escapeHtml(body || "暂无正文")}
`;
}
return ``;
}
function renderMatchedFields(item) {
const fields = Array.isArray(item.matched_fields) ? item.matched_fields : [];
return fields.length ? `
命中:${fields.map(escapeHtml).join("、")}` : "";
}
function summaryBinding(item) {
return item.binding || {
status: item.matched_record ? "matched" : "unmatched",
label: item.matched_record ? "已绑定" : "未绑定",
record: null,
candidates: [],
reason: "",
differences: [],
};
}
function bindingStatusClass(status) {
if (status === "matched") return "normal";
if (status === "missing_time" || status === "mismatch") return "warning";
return "debt";
}
function renderBindingBadge(item) {
const binding = summaryBinding(item);
const label = binding.label || (binding.status === "matched" ? "已绑定" : "未绑定");
return `${escapeHtml(label)}`;
}
function renderBindingRecordLine(record) {
if (!record) return "";
const pieces = [
`${record.date || record.date_iso || ""} ${record.weekday || ""}`.trim(),
record.time || "",
record.student || "",
record.teacher || "",
record.subject || "",
record.duration || "",
].filter(Boolean);
return pieces.map(escapeHtml).join(" · ");
}
function renderSummaryBindingStatus(item) {
const binding = summaryBinding(item);
const detail = binding.record ? renderBindingRecordLine(binding.record) : (binding.reason || "");
const candidateCount = Array.isArray(binding.candidates) ? binding.candidates.length : 0;
const suffix = !binding.record && candidateCount ? `候选 ${candidateCount} 条` : detail;
return `
${renderBindingBadge(item)}
${suffix ? `${escapeHtml(suffix)}` : ""}
`;
}
function summarizeSummaryBindings(items) {
return items.reduce(
(summary, item) => {
const binding = summaryBinding(item);
if (binding.status === "matched") summary.matched += 1;
if (binding.status !== "matched") summary.pending += 1;
if (Array.isArray(binding.candidates) && binding.candidates.length) summary.withCandidate += 1;
return summary;
},
{ matched: 0, pending: 0, withCandidate: 0 },
);
}
function renderSummaryBindingDetail(item) {
const binding = summaryBinding(item);
if (binding.status === "matched" && binding.record) {
return `
已绑定上课记录
${renderBindingRecordLine(binding.record)}
`;
}
const candidates = Array.isArray(binding.candidates) ? binding.candidates : [];
const candidateRows = candidates
.map((record) => {
const differences = Array.isArray(record.differences) && record.differences.length
? record.differences.map(escapeHtml).join("、")
: "可直接匹配";
return `
${renderBindingRecordLine(record)}
${differences}
`;
})
.join("");
return `
${escapeHtml(binding.reason || "未绑定到上课记录")}
${candidateRows || '
没有找到可参考的上课记录。
'}
`;
}
function renderSummarySearchRows(items) {
return items
.map((item) => {
const expanded = String(item.id) === expandedSummarySearchId;
const binding = summaryBinding(item);
const unmatchedClass = binding.status === "matched" ? "" : " unmatched-summary";
const mainRow = `
| ${escapeHtml(item.date_iso || "未识别")} |
${escapeHtml(item.time_range || "缺少时间")} |
${escapeHtml(item.student || "")} ${escapeHtml(item.group || "")} |
${escapeHtml(item.teacher || "待核对老师")} ${escapeHtml(item.subject || "待核对科目")} |
${renderSummaryBindingStatus(item)} |
`;
const detailRow = expanded
? `
${escapeHtml(item.title || "课程小结正文")}
${renderMatchedFields(item)}
${renderSummaryBindingDetail(item)}
${renderSummaryIdentityEditor(item)}
${renderSummaryBodyEditor(item)}
${renderSummaryActions(item)}
|
`
: "";
return `${mainRow}${detailRow}`;
})
.join("");
}
function renderCurrentSummarySearch() {
summarySearchRows.innerHTML = renderSummarySearchRows(currentSummarySearchItems);
if (!currentSummarySearchItems.length) {
summarySearchRows.innerHTML = `| 没有符合条件的课程小结 |
`;
}
}
async function loadSummarySearch() {
summarySearchRows.innerHTML = `| 正在读取 |
`;
try {
const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`);
currentSummarySearchItems = data.items || [];
if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = "";
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = "";
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummaryIdentityId)) editingSummaryIdentityId = "";
const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems);
summarySearchMeta.innerHTML = [
metric("命中小结", `${data.count} 条`),
metric("当前显示", `${data.returned} 条`),
metric("已绑定", `${bindingSummary.matched} 条`),
metric("待处理", `${bindingSummary.pending} 条`),
metric("有候选", `${bindingSummary.withCandidate} 条`),
].join("");
renderCurrentSummarySearch();
} catch (error) {
currentSummarySearchItems = [];
summarySearchRows.innerHTML = `| 读取失败:${escapeHtml(error.message)} |
`;
}
}
async function refreshSummaryWorkspace({ logs = true } = {}) {
await Promise.all([loadSummarySearch(), loadSummaryReviews()]);
if (logs) await loadOperationLogs();
}
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();
}
async function updateCourseSummaryBody(summaryId, body) {
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/body`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body }),
});
editingSummarySearchId = "";
expandedSummarySearchId = "";
await loadSummarySearch();
await loadOperationLogs();
}
async function updateCourseSummaryIdentity(summaryId, payload) {
const result = await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/identity`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
editingSummaryIdentityId = "";
editingSummarySearchId = "";
expandedSummarySearchId = result.new_id || "";
await loadSummarySearch();
await loadOperationLogs();
}
function findCurrentSummary(summaryId) {
return currentSummarySearchItems.find((item) => String(item.id) === String(summaryId));
}
async function applySummaryBindingCandidate(summaryId, record) {
const item = findCurrentSummary(summaryId);
if (!item) throw new Error("未找到当前课程小结");
let activeSummaryId = summaryId;
const targetStudent = String(record.student || "").trim();
const targetTeacher = String(record.teacher || "").trim();
const targetSubject = String(record.subject || "").trim();
const targetTime = String(record.time || "").trim();
const needsIdentity = targetStudent && targetTeacher && targetSubject && (
targetStudent !== String(item.student || "")
|| targetTeacher !== String(item.teacher || "")
|| targetSubject !== String(item.subject || "")
);
if (needsIdentity) {
const result = await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(activeSummaryId)}/identity`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ student: targetStudent, teacher: targetTeacher, subject: targetSubject }),
});
activeSummaryId = result.new_id || activeSummaryId;
}
if (targetTime && targetTime !== String(item.time_range || "")) {
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(activeSummaryId)}/time`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ time_range: targetTime }),
});
}
editingSummaryIdentityId = "";
editingSummarySearchId = "";
expandedSummarySearchId = activeSummaryId;
await loadSummarySearch();
await loadOperationLogs();
}
async function saveSummaryReviewEdits() {
if (!activeSummaryReview) return;
try {
const result = await fetchJson(`/api/admin/tasks/${encodeURIComponent(activeSummaryReview.id)}/course-summary-review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(summaryReviewEditorPayload()),
});
activeSummaryReview = result.task;
await refreshSummaryWorkspace();
openSummaryReviewDrawer(activeSummaryReview.id);
} catch (error) {
alert(error.message);
}
}
async function linkExistingSummaryReview() {
if (!activeSummaryReview) return;
if (!confirm("确认关联已有上课记录?系统不会重复扣课时。")) return;
try {
await fetchJson(`/api/admin/tasks/${encodeURIComponent(activeSummaryReview.id)}/link-existing-course-summary`, {
method: "POST",
});
closeSummaryReviewDrawer();
await refreshSummaryWorkspace();
} catch (error) {
alert(error.message);
}
}
async function resolveDuplicateSummary(taskId, summaryId) {
if (!confirm("确认删除这条重复课程小结?系统会先备份来源文件。")) return;
await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/resolve-duplicate-summary`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete_summary_id: summaryId }),
});
closeSummaryReviewDrawer();
await refreshSummaryWorkspace();
}
async function scanDuplicateSummaries() {
duplicateSummaryScanBtns.forEach((button) => {
button.disabled = true;
});
try {
const result = await fetchJson("/api/admin/course-summaries/duplicate-scan", { method: "POST" });
alert(`扫描完成:发现 ${result.scanned || 0} 组重复,新增 ${result.created || 0} 条审核任务`);
summaryReviewStatus.value = "pending";
setActiveTab("summarySearch");
await loadOperationLogs();
} catch (error) {
alert(error.message);
} finally {
duplicateSummaryScanBtns.forEach((button) => {
button.disabled = false;
});
}
}
function logField(label, value) {
if (value === undefined || value === null || value === "") return "";
return `${escapeHtml(label)}${escapeHtml(value)}
`;
}
function logArrayField(label, values) {
if (!Array.isArray(values) || !values.length) return "";
return logField(label, values.join(";"));
}
function renderLogDetailGroup(title, fields) {
const body = fields.filter(Boolean).join("");
if (!body) return "";
return `
${escapeHtml(title)}
${body}
`;
}
function renderLogAssociation(item) {
const parts = [];
if (item.batch_id) parts.push(`批次 ${item.batch_id}`);
if (item.task_id) parts.push(`任务 #${item.task_id}`);
if (!parts.length && item.backup_id) parts.push(`备份 ${item.backup_id}`);
if (!parts.length && item.source_id) parts.push(String(item.source_id));
if (!parts.length && item.record_id) parts.push(`记录 ${item.record_id}`);
return parts.slice(0, 2).map(escapeHtml).join("
") || '-';
}
function renderOperationLogRows(items) {
return items
.map((item) => {
const expanded = String(item.id) === expandedOperationLogId;
const mainRow = `
| ${escapeHtml(item.created_at || "")} |
${escapeHtml(item.operation || "")} |
${escapeHtml(item.status || "")} |
${escapeHtml(item.student || "")} |
${renderLogAssociation(item)} |
${renderLogActions(item)} |
`;
const detailRow = expanded
? `
| ${renderLogDetail(item)} |
`
: "";
return `${mainRow}${detailRow}`;
})
.join("");
}
function renderCurrentOperationLogs() {
logRows.innerHTML = renderOperationLogRows(currentOperationLogs);
if (!currentOperationLogs.length) {
logRows.innerHTML = `| 没有符合条件的操作记录 |
`;
}
}
function renderLogDetail(item) {
const sections = [
renderLogDetailGroup("基本信息", [
logField("记录ID", item.id),
logField("操作时间", item.created_at),
logField("操作", item.operation),
logField("状态", item.status),
logField("学生", item.student),
logField("学生ID", item.student_id),
]),
renderLogDetailGroup("关联对象", [
logField("任务类型", item.task_type),
logField("任务号", item.task_id ? `#${item.task_id}` : ""),
logField("来源ID", item.source_id),
logField("记录ID", item.record_id),
logField("课程小结ID", item.summary_id),
logField("新课程小结ID", item.new_summary_id),
logField("批次", item.batch_id),
]),
renderLogDetailGroup("课程信息", [
logField("老师", item.teacher),
logField("原老师", item.old_teacher),
logField("原学生", item.old_student),
logField("教师ID", item.teacher_id),
logField("科目", item.subject),
logField("原科目", item.old_subject),
logField("时间段", item.time_range),
logField("上课记录", item.proposed_line),
]),
renderLogDetailGroup("文件与备份", [
logField("保存文件", item.saved_path),
logField("原文件", item.old_path),
logField("新文件", item.new_path),
logField("备份ID", item.backup_id),
logField("撤回记录", item.target_log_id),
logField("撤回备份", item.target_backup_id),
logField("撤回方式", item.rollback_mode),
logArrayField("恢复文件", item.restored_files),
]),
renderLogDetailGroup("结果与原因", [
logArrayField("原因", item.reasons),
logField("错误", item.error),
logField("扫描重复组", item.scanned_groups),
logField("新增任务", item.created_tasks),
logArrayField("删除记录", item.removed_lines),
logField("返还课时", item.restored_hours),
logField("标题", item.heading),
]),
].filter(Boolean);
return `${sections.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()}`);
currentOperationLogs = data.items || [];
if (!currentOperationLogs.some((item) => String(item.id) === expandedOperationLogId)) expandedOperationLogId = "";
logMeta.innerHTML = [metric("当前结果", `${data.count} 条`)].join("");
renderCurrentOperationLogs();
} catch (error) {
currentOperationLogs = [];
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, () => refreshSummaryWorkspace({ logs: false }));
await loadOperationLogs();
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 renderStandardLinesPreview(type, lines) {
const standardLines = Array.isArray(lines) ? lines : [];
if (type === "class_record") {
const rows = standardLines
.map(
(line, index) =>
`${index + 1}${escapeHtml(line)}`
)
.join("");
return `标准登记内容共 ${standardLines.length} 条
${rows}
`;
}
return `标准登记内容${escapeHtml(standardLines.join("\n\n"))}`;
}
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)}
` : "";
const standardLines = data.standard_lines || [];
previewNode.innerHTML = `${warning}${renderStandardLinesPreview(type, standardLines)}`;
confirmButton.hidden = false;
registerPreviewState[type] = {
conversationId: data.conversation_id,
standardLines,
};
statusNode.textContent = "已通过脚本生成预览,请确认后写入";
return;
}
if (data.status === "needs_info") {
const questions = (data.questions || []).map((item) => `${escapeHtml(item)}`).join("");
const fields = data.fields || {};
const labels = data.field_labels || {};
const missing = new Set(data.missing_fields || []);
const usesReadonlyDraft = type === "class_record" || type === "payment";
const draftItems = data.draft_items || [];
const draft = usesReadonlyDraft && draftItems.length
? renderReadonlyDraftItems(type, draftItems, labels)
: usesReadonlyDraft
? renderReadonlyRegisterDraft(type, fields, data.draft_line || "")
: renderRegisterDraft(type, fields, missing);
const fieldInputs = usesReadonlyDraft && draftItems.length
? ""
: renderFieldInputs(usesReadonlyDraft ? data.missing_fields || [] : Object.keys(labels), fields, labels, missing);
const questionBlock = questions ? `` : "";
const inputBlock = fieldInputs ? `${fieldInputs}
` : "";
previewNode.innerHTML = `需要补充信息${questionBlock}${draft}${inputBlock}`;
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 || "请修改原文后重试"}`;
}
function waitingText(value) {
return value || "[待补充]";
}
function previewPart(value) {
const text = waitingText(value);
const className = text === "[待补充]" ? ` class="pending-field"` : "";
return `${escapeHtml(text)}`;
}
function renderDraftLine(draftLine) {
return escapeHtml(waitingText(draftLine));
}
function renderReadonlyRegisterDraft(type, fields, draftLine = "") {
if (draftLine) {
return `${renderDraftLine(draftLine)} `;
}
if (type === "class_record") {
const line = [
waitingText(fields.date),
"[待补充]",
waitingText(fields.time),
waitingText(fields.student),
waitingText(fields.duration),
waitingText(fields.teacher),
waitingText(fields.subject),
].join("-");
return ``;
}
if (type === "payment") {
const line = `${waitingText(fields.student)}-${waitingText(fields.date)}:${waitingText(fields.hours)}`;
return ``;
}
return `${renderDraftLine(draftLine)} `;
}
function renderFieldInputs(fieldNames, fields, labels, missing, prefix = "") {
return fieldNames
.map((name) => {
const label = labels[name] || name;
const value = fields[name] || "";
const requiredMark = missing.has(name) ? " *" : "";
const fieldName = `${prefix}${name}`;
const input =
name === "body"
? ``
: ``;
return ``;
})
.join("");
}
function renderReadonlyDraftItems(type, items, labels) {
const rows = items
.map((item, index) => {
const itemIndex = Number.isInteger(item.index) ? item.index : index;
const fields = item.fields || {};
const missingFields = item.missing_fields || [];
const missing = new Set(missingFields);
const inputs = renderFieldInputs(missingFields, fields, labels, missing, `items.${itemIndex}.`);
const inputBlock = inputs ? `${inputs}
` : "";
const error = item.error ? `第 ${itemIndex + 1} 条:${escapeHtml(item.error)}` : "";
return `${renderReadonlyRegisterDraft(type, fields, item.draft_line || "")}${inputBlock}${error}
`;
})
.join("");
return `${rows}
`;
}
function inlineDraftPart(name, fields, missing, fallback) {
const value = fields[name] || "";
if (!missing.has(name) && value) return `${escapeHtml(value)}`;
return ``;
}
function renderRegisterDraft(type, fields, missing) {
if (type === "class_record") {
const dateText = inlineDraftPart("date", fields, missing, "日期");
const timeText = inlineDraftPart("time", fields, missing, "时间");
const studentText = inlineDraftPart("student", fields, missing, "学生");
const durationText = inlineDraftPart("duration", fields, missing, "时长");
const teacherText = inlineDraftPart("teacher", fields, missing, "老师");
const subjectText = inlineDraftPart("subject", fields, missing, "科目");
return `${dateText}星期?${timeText}${studentText}${durationText}${teacherText}${subjectText}
`;
}
if (type === "payment") {
const studentText = inlineDraftPart("student", fields, missing, "学生");
const dateText = inlineDraftPart("date", fields, missing, "日期");
const hoursText = inlineDraftPart("hours", fields, missing, "课时数");
return `${studentText}-${dateText}:${hoursText}
`;
}
const studentText = inlineDraftPart("student", fields, missing, "学生");
const dateText = inlineDraftPart("date", fields, missing, "日期");
const timeText = inlineDraftPart("time", fields, missing, "时间");
const teacherText = inlineDraftPart("teacher", fields, missing, "老师");
const subjectText = inlineDraftPart("subject", fields, missing, "科目");
const bodyValue = fields.body || "";
const bodyInput = missing.has("body")
? ``
: `${escapeHtml(bodyValue)}`;
return `学生:${studentText}日期:${dateText}时间:${timeText}老师:${teacherText}科目:${subjectText}${bodyInput}
`;
}
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 fieldNodes = previewNode ? previewNode.querySelectorAll("[data-ai-field]") : [];
const state = registerPreviewState[type] || {};
const answers = {};
if (answerNode && answerNode.value.trim()) answers.followup = answerNode.value.trim();
fieldNodes.forEach((node) => {
if (node.value.trim()) answers[node.dataset.aiField] = node.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.review_pending} 条;重复 ${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);
duplicateSummaryScanBtns.forEach((button) => {
button.addEventListener("click", scanDuplicateSummaries);
});
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) {
const item = findSummaryReview(approve.dataset.taskId);
if (isDuplicateSummaryReview(item)) {
openSummaryReviewDrawer(approve.dataset.taskId);
return;
}
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();
});
summaryReviewDrawerSave.addEventListener("click", saveSummaryReviewEdits);
summaryReviewDrawerLink.addEventListener("click", linkExistingSummaryReview);
summaryReviewDrawerApprove.addEventListener("click", () => {
if (activeSummaryReview) summaryReviewTask(activeSummaryReview.id, "approve");
});
summaryReviewDrawerReject.addEventListener("click", () => {
if (activeSummaryReview) summaryReviewTask(activeSummaryReview.id, "reject");
});
summaryReviewDrawerBody.addEventListener("click", (event) => {
const button = event.target.closest(".duplicate-summary-delete");
if (button && activeSummaryReview) {
resolveDuplicateSummary(activeSummaryReview.id, button.dataset.summaryId).catch((error) => alert(error.message));
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !summaryReviewDrawerBackdrop.hidden) closeSummaryReviewDrawer();
});
summarySearchForm.addEventListener("submit", (event) => {
event.preventDefault();
loadSummarySearch();
});
summarySearchBindingStatus.addEventListener("change", loadSummarySearch);
summarySearchHasCandidate.addEventListener("change", loadSummarySearch);
summarySearchRows.addEventListener("click", (event) => {
const saveTime = event.target.closest(".summary-time-save");
const deleteSummary = event.target.closest(".summary-delete");
const editIdentity = event.target.closest(".summary-identity-edit");
const saveIdentity = event.target.closest(".summary-identity-save");
const cancelIdentity = event.target.closest(".summary-identity-cancel");
const editSummary = event.target.closest(".summary-body-edit");
const saveSummaryBody = event.target.closest(".summary-body-save");
const cancelSummaryBody = event.target.closest(".summary-body-cancel");
const applyCandidate = event.target.closest(".summary-apply-candidate");
if (applyCandidate) {
if (!confirm("确认按候选上课记录修正课程小结的学生、老师、科目和时间?系统不会修改上课记录和课时账户。")) return;
applySummaryBindingCandidate(applyCandidate.dataset.summaryId, {
student: applyCandidate.dataset.student,
teacher: applyCandidate.dataset.teacher,
time: applyCandidate.dataset.time,
subject: applyCandidate.dataset.subject,
}).catch((error) => alert(error.message));
return;
}
if (editIdentity) {
editingSummaryIdentityId = editIdentity.dataset.summaryId;
editingSummarySearchId = "";
renderCurrentSummarySearch();
return;
}
if (cancelIdentity) {
editingSummaryIdentityId = "";
renderCurrentSummarySearch();
return;
}
if (saveIdentity) {
const studentInput = summarySearchRows.querySelector(`[data-summary-identity-student='${saveIdentity.dataset.summaryId}']`);
const teacherInput = summarySearchRows.querySelector(`[data-summary-identity-teacher='${saveIdentity.dataset.summaryId}']`);
const subjectInput = summarySearchRows.querySelector(`[data-summary-identity-subject='${saveIdentity.dataset.summaryId}']`);
const student = studentInput ? studentInput.value.trim() : "";
const teacher = teacherInput ? teacherInput.value.trim() : "";
const subject = subjectInput ? subjectInput.value.trim() : "";
if (!student || !teacher || !subject) {
alert("学生、老师和科目不能为空");
return;
}
updateCourseSummaryIdentity(saveIdentity.dataset.summaryId, { student, teacher, subject }).catch((error) => alert(error.message));
return;
}
if (editSummary) {
editingSummarySearchId = editSummary.dataset.summaryId;
editingSummaryIdentityId = "";
renderCurrentSummarySearch();
return;
}
if (cancelSummaryBody) {
editingSummarySearchId = "";
renderCurrentSummarySearch();
return;
}
if (saveSummaryBody) {
const textarea = summarySearchRows.querySelector(`[data-summary-body-editor='${saveSummaryBody.dataset.summaryId}']`);
const body = textarea ? textarea.value.trim() : "";
if (!body) {
alert("课程小结正文不能为空");
return;
}
updateCourseSummaryBody(saveSummaryBody.dataset.summaryId, body).catch((error) => alert(error.message));
return;
}
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;
}
const row = event.target.closest(".summary-search-result-row");
if (!row) return;
expandedSummarySearchId = expandedSummarySearchId === row.dataset.summaryId ? "" : row.dataset.summaryId;
renderCurrentSummarySearch();
});
summarySearchRows.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const row = event.target.closest(".summary-search-result-row");
if (!row) return;
event.preventDefault();
expandedSummarySearchId = expandedSummarySearchId === row.dataset.summaryId ? "" : row.dataset.summaryId;
renderCurrentSummarySearch();
});
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);
return;
}
const row = event.target.closest(".log-row");
if (!row) return;
if (event.target.closest(".record-action-cell")) return;
expandedOperationLogId = expandedOperationLogId === row.dataset.logId ? "" : row.dataset.logId;
renderCurrentOperationLogs();
});
logRows.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const row = event.target.closest(".log-row");
if (!row) return;
if (event.target.closest(".log-rollback")) return;
event.preventDefault();
expandedOperationLogId = expandedOperationLogId === row.dataset.logId ? "" : row.dataset.logId;
renderCurrentOperationLogs();
});
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.summarySearch.hidden) refreshSummaryWorkspace({ logs: false });
if (!panels.logs.hidden) loadOperationLogs();
});
loadAdminHealth();
loadAccounts();