Files
xsk-education-management/app/app/static/admin.js
T
2026-06-18 12:45:21 +08:00

1598 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const adminHealthText = document.querySelector("#adminHealthText");
const refreshBtn = document.querySelector("#refreshBtn");
const panels = {
accounts: document.querySelector("#accountsPanel"),
teachers: document.querySelector("#teachersPanel"),
reviews: document.querySelector("#reviewsPanel"),
summaries: document.querySelector("#summariesPanel"),
summarySearch: document.querySelector("#summarySearchPanel"),
logs: document.querySelector("#logsPanel"),
register: document.querySelector("#registerPanel"),
};
const accountForm = document.querySelector("#accountForm");
const accountQuery = document.querySelector("#accountQuery");
const accountStatus = document.querySelector("#accountStatus");
const accountMeta = document.querySelector("#accountMeta");
const accountRows = document.querySelector("#accountRows");
const accountEditor = document.querySelector("#accountEditor");
const accountEditorTitle = document.querySelector("#accountEditorTitle");
const accountEditForm = document.querySelector("#accountEditForm");
const accountEditError = document.querySelector("#accountEditError");
const cancelAccountEditBtn = document.querySelector("#cancelAccountEditBtn");
const newAccountBtn = document.querySelector("#newAccountBtn");
const teacherForm = document.querySelector("#teacherForm");
const teacherQuery = document.querySelector("#teacherQuery");
const teacherStatus = document.querySelector("#teacherStatus");
const teacherMeta = document.querySelector("#teacherMeta");
const teacherRows = document.querySelector("#teacherRows");
const teacherEditor = document.querySelector("#teacherEditor");
const teacherEditorTitle = document.querySelector("#teacherEditorTitle");
const teacherEditForm = document.querySelector("#teacherEditForm");
const teacherEditError = document.querySelector("#teacherEditError");
const cancelTeacherEditBtn = document.querySelector("#cancelTeacherEditBtn");
const newTeacherBtn = document.querySelector("#newTeacherBtn");
const editTeacherId = document.querySelector("#editTeacherId");
const editTeacherName = document.querySelector("#editTeacherName");
const editTeacherAlias = document.querySelector("#editTeacherAlias");
const editTeacherStatus = document.querySelector("#editTeacherStatus");
const editTeacherSubjects = document.querySelector("#editTeacherSubjects");
const editTeacherNote = document.querySelector("#editTeacherNote");
const editStudentId = document.querySelector("#editStudentId");
const editStudent = document.querySelector("#editStudent");
const editRemaining = document.querySelector("#editRemaining");
const editStatus = document.querySelector("#editStatus");
const editPayments = document.querySelector("#editPayments");
const editNote = document.querySelector("#editNote");
const reviewStatus = document.querySelector("#reviewStatus");
const reviewMeta = document.querySelector("#reviewMeta");
const reviewRows = document.querySelector("#reviewRows");
const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
const summaryReviewRows = document.querySelector("#summaryReviewRows");
const duplicateSummaryScanBtn = document.querySelector("#duplicateSummaryScanBtn");
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 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 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;
let currentSummarySearchItems = [];
let expandedSummarySearchId = "";
let editingSummarySearchId = "";
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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function metric(label, value) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
}
function statusClass(status) {
if (status === "欠费") return "debt";
if (status === "预警") return "warning";
if (status === "正常") return "normal";
return "closed";
}
function taskStatusClass(status) {
if (["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("<br>");
}
async function fetchJson(url, options = {}) {
const response = await fetch(url, { cache: "no-store", ...options });
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
const payload = await response.json();
detail = payload.detail || detail;
} catch (_error) {
detail = response.statusText || detail;
}
throw new Error(detail);
}
return response.json();
}
function setActiveTab(tabName) {
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
button.classList.toggle("is-active", button.dataset.adminTab === tabName);
});
Object.entries(panels).forEach(([name, panel]) => {
panel.hidden = name !== tabName;
});
if (tabName === "accounts") loadAccounts();
if (tabName === "teachers") loadTeachers();
if (tabName === "reviews") loadReviews();
if (tabName === "summaries") loadSummaryReviews();
if (tabName === "summarySearch") loadSummarySearch();
if (tabName === "logs") loadOperationLogs();
}
async function loadAdminHealth() {
try {
const data = await fetchJson("/api/account-health");
const teacherText = data.teachers_count === undefined ? "" : `;老师 ${data.teachers_count}`;
adminHealthText.textContent = `账户 ${data.accounts_count}${teacherText};数据更新时间 ${fmtTime(data.accounts.mtime)}`;
} catch (error) {
adminHealthText.textContent = `读取失败:${error.message}`;
}
}
function renderAccountSummary(summary, count) {
accountMeta.innerHTML = [
metric("当前结果", `${count}`),
metric("欠费", summary.debt),
metric("预警", summary.warning),
metric("正常", summary.normal),
].join("");
}
async function loadAccounts() {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
const params = new URLSearchParams();
if (accountQuery.value.trim()) params.set("q", accountQuery.value.trim());
if (accountStatus.value) params.set("status", accountStatus.value);
try {
const data = await fetchJson(`/api/accounts?${params.toString()}`);
currentAccounts = data.accounts;
renderAccountSummary(data.summary, data.count);
accountRows.innerHTML = data.accounts
.map(
(row) => `<tr>
<td>${escapeHtml(row.student)}<br><small>${escapeHtml(row.student_id)}</small></td>
<td><span class="status ${statusClass(row.account_status)}">${escapeHtml(row.account_status)}</span></td>
<td class="num">${escapeHtml(displayHours(row.remaining, row.remaining_duration))}</td>
<td>${renderPayments(row.payments)}</td>
<td class="note-cell">${escapeHtml(row.note || "")}</td>
<td><button class="small-button account-edit" type="button" data-student-id="${escapeHtml(row.student_id)}">编辑</button></td>
</tr>`,
)
.join("");
if (!data.accounts.length) {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的账户</td></tr>`;
}
} catch (error) {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
function paymentsToText(payments) {
return payments.map((item) => `${item.date}:${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 = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
try {
const data = await fetchJson("/api/admin/teachers");
currentTeachers = data.teachers || [];
const rows = currentTeachers.filter(teacherMatches);
renderTeacherSummary(rows);
teacherRows.innerHTML = rows
.map(
(row) => `<tr>
<td>${escapeHtml(row.name)}<br><small>${escapeHtml(row.teacher_id)}</small></td>
<td>${escapeHtml(row.alias || "")}</td>
<td>${escapeHtml((row.subjects || []).join("、"))}</td>
<td><span class="status ${row.status === "在岗" ? "normal" : "closed"}">${escapeHtml(row.status)}</span></td>
<td class="note-cell">${escapeHtml(row.note || "")}</td>
<td><button class="small-button teacher-edit" type="button" data-teacher-id="${escapeHtml(row.teacher_id)}">编辑</button></td>
</tr>`,
)
.join("");
if (!rows.length) {
teacherRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的老师档案</td></tr>`;
}
} catch (error) {
teacherRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
function openNewTeacherEditor() {
editingTeacherId = "";
teacherEditorTitle.textContent = "新增老师";
editTeacherId.value = "";
editTeacherId.placeholder = "保存时自动生成";
editTeacherName.value = "";
editTeacherAlias.value = "";
editTeacherSubjects.value = "";
editTeacherStatus.value = "在岗";
editTeacherNote.value = "";
teacherEditError.hidden = true;
teacherEditor.hidden = false;
editTeacherName.focus();
}
function openEditTeacherEditor(teacherId) {
const teacher = currentTeachers.find((item) => item.teacher_id === teacherId);
if (!teacher) return;
editingTeacherId = teacher.teacher_id;
teacherEditorTitle.textContent = `编辑老师:${teacher.name}`;
editTeacherId.value = teacher.teacher_id;
editTeacherId.placeholder = "";
editTeacherName.value = teacher.name;
editTeacherAlias.value = teacher.alias || "";
editTeacherSubjects.value = (teacher.subjects || []).join("、");
editTeacherStatus.value = teacher.status;
editTeacherNote.value = teacher.note || "";
teacherEditError.hidden = true;
teacherEditor.hidden = false;
editTeacherName.focus();
}
function parseTeacherSubjects(value) {
return value
.split(/[、,\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function buildTeacherPayload() {
return {
teacher_id: editTeacherId.value.trim(),
name: editTeacherName.value.trim(),
alias: editTeacherAlias.value.trim(),
subjects: parseTeacherSubjects(editTeacherSubjects.value),
status: editTeacherStatus.value,
note: editTeacherNote.value.trim(),
};
}
async function saveTeacher(event) {
event.preventDefault();
try {
const payload = buildTeacherPayload();
const url = editingTeacherId
? `/api/admin/teachers/${encodeURIComponent(editingTeacherId)}`
: "/api/admin/teachers";
const method = editingTeacherId ? "PUT" : "POST";
await fetchJson(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
teacherEditor.hidden = true;
await loadTeachers();
} catch (error) {
teacherEditError.textContent = error.message;
teacherEditError.hidden = false;
}
}
function renderReviewLine(line) {
return `<code class="line-code">${escapeHtml(line)}</code>`;
}
function reviewTaskTypeLabel(item) {
if (item.type === "class_record_deletion") return "删除";
return "纠错";
}
function renderReviewTarget(item) {
if (item.type === "class_record_deletion") {
const restored = item.original && item.original.duration_hours ? `通过后恢复 ${fmtHours(item.original.duration_hours)}` : "通过后恢复课时";
return `<span class="status closed">删除</span><br><small>${escapeHtml(restored)}</small>`;
}
return renderReviewLine(item.corrected_line);
}
async function loadReviews() {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
try {
const [correctionData, deletionData] = await Promise.all([
fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_correction", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`),
fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_deletion", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`),
]);
const items = [...(correctionData.items || []), ...(deletionData.items || [])].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
reviewMeta.innerHTML = [metric("当前结果", `${items.length}`)].join("");
reviewRows.innerHTML = items
.map((item) => {
const canReview = item.status === "pending" || item.status === "conflict";
return `<tr>
<td>#${escapeHtml(item.id)}</td>
<td><span class="status ${item.status === "approved" ? "normal" : item.status === "rejected" ? "closed" : item.status === "conflict" ? "debt" : "warning"}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td>${escapeHtml(reviewTaskTypeLabel(item))}</td>
<td>${renderReviewLine(item.original_line)}</td>
<td>${renderReviewTarget(item)}</td>
<td>${escapeHtml(item.created_at || "")}</td>
<td class="record-action-cell">
<div class="record-actions">
<button class="small-button review-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>批准</button>
<button class="small-button review-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
</div>
</td>
</tr>`;
})
.join("");
if (!items.length) {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的审核项</td></tr>`;
}
} catch (error) {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
function summaryReviewKind(item) {
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 `<span class="review-tag history">${escapeHtml(label)}</span>`;
}
function renderSummaryInfo(summary, item = {}) {
const parts = [
summary.student,
summary.date_iso,
summary.time_range,
summary.teacher,
summary.subject,
].filter(Boolean);
return `${renderReviewTag(summaryReviewKind(item))}${parts.map(escapeHtml).join("<br>")}<br><small>${escapeHtml(summary.group || "")}</small>`;
}
function renderSummaryPreview(summary) {
const body = String(summary.body || "");
const preview = body.length > 140 ? `${body.slice(0, 140)}...` : body;
return `<div class="summary-body summary-preview">${escapeHtml(preview || "暂无原文")}</div>`;
}
function 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 source = [
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 `<div class="record-summary-item duplicate-summary-candidate">
<div class="record-summary-title">${escapeHtml(candidate.title || "课程小结")}</div>
<div class="drawer-text">${source.map(escapeHtml).join("<br>") || "暂无来源信息"}</div>
<div class="summary-body">${escapeHtml(candidate.body || candidate.body_preview || "暂无正文")}</div>
${showDelete ? `<button class="small-button duplicate-summary-delete" type="button" data-summary-id="${escapeHtml(candidate.id)}" ${canReview ? "" : "disabled"}>删除这一条</button>` : ""}
</div>`;
}
function renderDuplicateCandidates(item, canReview) {
const candidates = Array.isArray(item.duplicate_candidates) ? item.duplicate_candidates : [];
if (!candidates.length) return "<span class=\"muted\">暂无重复候选</span>";
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(`<div class="summary-conflict-reasons">${reasonLines.map(escapeHtml).join("<br>")}</div>`);
}
if (source) {
parts.push(`<div class="summary-conflict-source">
<div class="record-summary-title">本次提交</div>
<div class="drawer-text">${[
source.source_id ? `来源ID${source.source_id}` : "",
source.message_time ? `发送时间:${source.message_time}` : "",
source.sender ? `发送者:${source.sender}` : "",
].filter(Boolean).map(escapeHtml).join("<br>") || "暂无来源信息"}</div>
<div class="summary-body">${escapeHtml(source.body || source.body_preview || "暂无正文")}</div>
</div>`);
}
if (conflicts.length) {
parts.push(`<div class="summary-conflict-heading">命中的已有小结</div>`);
parts.push(conflicts.map((candidate) => renderDuplicateCandidate(candidate, false, { showDelete: false })).join(""));
} else {
parts.push("<span class=\"muted\">状态文件命中重复,但正式小结库未找到对应小结。</span>");
}
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 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 fillSummaryReviewEditor(summary) {
summaryReviewEditStudent.value = summary.student || "";
summaryReviewEditDate.value = summary.date_iso || "";
summaryReviewEditTime.value = summary.time_range || "";
summaryReviewEditTeacher.value = summary.teacher || "";
summaryReviewEditSubject.value = summary.subject || "";
}
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);
activeSummaryReview = item;
summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`;
summaryReviewDrawerMeta.innerHTML = [
item.created_at ? `提交时间:${escapeHtml(item.created_at)}` : "",
kind ? renderReviewTag(kind) : "",
].filter(Boolean).join(" ");
summaryReviewDrawerStatus.innerHTML = `<span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span>${item.message ? ` <small>${escapeHtml(item.message)}</small>` : ""}`;
summaryReviewDrawerCourse.innerHTML = courseInfoLines(summary).map(escapeHtml).join("<br>") || "暂无课程信息";
summaryReviewEditSection.hidden = duplicateReview;
if (!duplicateReview) fillSummaryReviewEditor(summary);
summaryReviewDrawerLine.innerHTML = duplicateReview
? "<span class=\"muted\">请选择下方重复小结删除</span>"
: item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>";
summaryReviewDrawerReasons.innerHTML = reasons.length ? reasons.map(escapeHtml).join("<br>") : "待人工复核";
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("<br>") || "暂无来源信息";
summaryReviewDrawerSave.hidden = duplicateReview;
summaryReviewDrawerSave.disabled = !canReview || duplicateReview;
summaryReviewDrawerLink.hidden = duplicateReview || !canLinkExistingSummary(item);
summaryReviewDrawerLink.disabled = !canReview || duplicateReview || !canLinkExistingSummary(item);
summaryReviewDrawerApprove.hidden = duplicateReview;
summaryReviewDrawerApprove.disabled = !canReview || duplicateReview;
summaryReviewDrawerReject.disabled = !canReview;
summaryReviewDrawerBackdrop.hidden = false;
}
function closeSummaryReviewDrawer() {
activeSummaryReview = null;
summaryReviewEditSection.hidden = true;
summaryReviewDrawerSave.hidden = false;
summaryReviewDrawerLink.hidden = true;
summaryReviewDrawerApprove.hidden = false;
summaryReviewConflictSection.hidden = true;
summaryReviewDrawerConflicts.innerHTML = "";
summaryReviewDrawerBackdrop.hidden = true;
}
async function loadSummaryReviews() {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
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()}`),
]);
currentSummaryReviews = [...(reviewData.items || []), ...(duplicateData.items || [])].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
summaryReviewMeta.innerHTML = [metric("当前结果", `${currentSummaryReviews.length}`)].join("");
summaryReviewRows.innerHTML = currentSummaryReviews
.map((item) => {
const canReview = canReviewSummary(item);
const duplicateReview = isDuplicateSummaryReview(item);
const summary = item.summary || {};
const reasons = Array.isArray(item.reasons) ? item.reasons : [];
const approveLabel = duplicateReview ? "详情选择" : "批准";
return `<tr>
<td>#${escapeHtml(item.id)}</td>
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td>${renderSummaryInfo(summary, item)}</td>
<td>${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td>
<td>${renderSummaryPreview(summary)}</td>
<td>${reasons.map(escapeHtml).join("<br>") || "待人工复核"}</td>
<td class="record-action-cell">
<div class="record-actions">
<button class="small-button summary-detail" type="button" data-task-id="${escapeHtml(item.id)}">查看详情</button>
<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview && !duplicateReview ? "" : "disabled"}>${approveLabel}</button>
<button class="small-button summary-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
</div>
</td>
</tr>`;
})
.join("");
if (!currentSummaryReviews.length) {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结审核项</td></tr>`;
}
} catch (error) {
currentSummaryReviews = [];
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
function summarySearchParams() {
const params = new URLSearchParams({ limit: "200" });
if (summarySearchQuery.value.trim()) params.set("q", summarySearchQuery.value.trim());
if (summarySearchStudent.value.trim()) params.set("student", summarySearchStudent.value.trim());
if (summarySearchTeacher.value.trim()) params.set("teacher", summarySearchTeacher.value.trim());
if (summarySearchSubject.value.trim()) params.set("subject", summarySearchSubject.value.trim());
if (summarySearchDateFrom.value) params.set("date_from", summarySearchDateFrom.value);
if (summarySearchDateTo.value) params.set("date_to", summarySearchDateTo.value);
if (summarySearchMissingTime.value) params.set("missing_time", "true");
return params;
}
function renderSummaryActions(item) {
if (item.time_range) {
return `<span class="summary-action-note">时间已完整</span>`;
}
const inputId = `summary-time-${item.id}`;
return `<div class="summary-time-actions">
<input id="${escapeHtml(inputId)}" class="summary-time-input" data-summary-time-input="${escapeHtml(item.id)}" autocomplete="off" placeholder="08:00-10:00" />
<button class="secondary-button summary-time-save" type="button" data-summary-id="${escapeHtml(item.id)}">补齐时间</button>
<button class="secondary-button summary-delete" type="button" data-summary-id="${escapeHtml(item.id)}">删除课程小结</button>
</div>`;
}
function renderSummaryBodyEditor(item) {
const body = item.body || item.body_preview || "";
if (String(item.id) !== editingSummarySearchId) {
return `<div class="summary-body summary-search-full">${escapeHtml(body || "暂无正文")}</div>
<div class="summary-edit-actions">
<button class="secondary-button summary-body-edit" type="button" data-summary-id="${escapeHtml(item.id)}">修改课程小结</button>
</div>`;
}
return `<div class="summary-body-edit-panel">
<textarea class="summary-body-editor" data-summary-body-editor="${escapeHtml(item.id)}" rows="10">${escapeHtml(body)}</textarea>
<div class="summary-edit-actions">
<button class="secondary-button summary-body-save" type="button" data-summary-id="${escapeHtml(item.id)}">保存</button>
<button class="secondary-button summary-body-cancel" type="button" data-summary-id="${escapeHtml(item.id)}">取消</button>
</div>
</div>`;
}
function renderMatchedFields(item) {
const fields = Array.isArray(item.matched_fields) ? item.matched_fields : [];
return fields.length ? `<br><small>命中:${fields.map(escapeHtml).join("、")}</small>` : "";
}
function renderSummarySearchRows(items) {
return items
.map((item) => {
const expanded = String(item.id) === expandedSummarySearchId;
const mainRow = `<tr class="summary-search-result-row" data-summary-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}">
<td>${escapeHtml(item.date_iso || "未识别")}</td>
<td>${escapeHtml(item.time_range || "缺少时间")}</td>
<td>${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
<td>${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td>
</tr>`;
const detailRow = expanded
? `<tr class="summary-search-detail-row" data-summary-detail="${escapeHtml(item.id)}">
<td colspan="4">
<div class="summary-search-detail-panel">
<div class="summary-search-detail-head">
<div>
<div class="record-summary-title">${escapeHtml(item.title || "课程小结正文")}</div>
${renderMatchedFields(item)}
</div>
</div>
${renderSummaryBodyEditor(item)}
<div class="summary-search-actions">${renderSummaryActions(item)}</div>
</div>
</td>
</tr>`
: "";
return `${mainRow}${detailRow}`;
})
.join("");
}
function renderCurrentSummarySearch() {
summarySearchRows.innerHTML = renderSummarySearchRows(currentSummarySearchItems);
if (!currentSummarySearchItems.length) {
summarySearchRows.innerHTML = `<tr><td colspan="4" class="empty">没有符合条件的课程小结</td></tr>`;
}
}
async function loadSummarySearch() {
summarySearchRows.innerHTML = `<tr><td colspan="4" class="empty">正在读取</td></tr>`;
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 = "";
summarySearchMeta.innerHTML = [
metric("命中小结", `${data.count}`),
metric("当前显示", `${data.returned}`),
].join("");
renderCurrentSummarySearch();
} catch (error) {
currentSummarySearchItems = [];
summarySearchRows.innerHTML = `<tr><td colspan="4" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
async function updateCourseSummaryTime(summaryId, timeRange) {
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/time`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ time_range: timeRange }),
});
await loadSummarySearch();
}
async function deleteCourseSummary(summaryId) {
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}`, {
method: "DELETE",
});
await loadSummarySearch();
}
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 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 loadSummaryReviews();
openSummaryReviewDrawer(activeSummaryReview.id);
await loadOperationLogs();
} 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 loadSummaryReviews();
await loadOperationLogs();
} 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 loadSummaryReviews();
await loadOperationLogs();
}
async function scanDuplicateSummaries() {
duplicateSummaryScanBtn.disabled = true;
try {
const result = await fetchJson("/api/admin/course-summaries/duplicate-scan", { method: "POST" });
alert(`扫描完成:发现 ${result.scanned || 0} 组重复,新增 ${result.created || 0} 条审核任务`);
await loadSummaryReviews();
await loadOperationLogs();
} catch (error) {
alert(error.message);
} finally {
duplicateSummaryScanBtn.disabled = false;
}
}
function logField(label, value) {
if (value === undefined || value === null || value === "") return "";
return `<div class="log-detail-field"><span>${escapeHtml(label)}</span><code>${escapeHtml(value)}</code></div>`;
}
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 `<section class="log-detail-group">
<h3>${escapeHtml(title)}</h3>
<div class="log-detail-grid">${body}</div>
</section>`;
}
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("<br>") || '<span class="muted">-</span>';
}
function renderOperationLogRows(items) {
return items
.map((item) => {
const expanded = String(item.id) === expandedOperationLogId;
const mainRow = `<tr class="log-row" data-log-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}">
<td>${escapeHtml(item.created_at || "")}</td>
<td>${escapeHtml(item.operation || "")}</td>
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span></td>
<td>${escapeHtml(item.student || "")}</td>
<td>${renderLogAssociation(item)}</td>
<td class="record-action-cell">${renderLogActions(item)}</td>
</tr>`;
const detailRow = expanded
? `<tr class="log-detail-row" data-log-detail="${escapeHtml(item.id)}">
<td colspan="6">${renderLogDetail(item)}</td>
</tr>`
: "";
return `${mainRow}${detailRow}`;
})
.join("");
}
function renderCurrentOperationLogs() {
logRows.innerHTML = renderOperationLogRows(currentOperationLogs);
if (!currentOperationLogs.length) {
logRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的操作记录</td></tr>`;
}
}
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("批次", item.batch_id),
]),
renderLogDetailGroup("课程信息", [
logField("老师", item.teacher),
logField("教师ID", item.teacher_id),
logField("科目", item.subject),
logField("时间段", item.time_range),
logField("上课记录", item.proposed_line),
]),
renderLogDetailGroup("文件与备份", [
logField("保存文件", item.saved_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 `<div class="log-detail-panel">${sections.join("") || '<span class="muted">暂无详情</span>'}</div>`;
}
function renderLogActions(item) {
if (item.can_rollback) {
return `<button class="small-button log-rollback" type="button" data-log-id="${escapeHtml(item.id)}">撤回</button>`;
}
if (item.backup_id && item.rollback_block_reason) {
return `<small class="muted">${escapeHtml(item.rollback_block_reason)}</small>`;
}
return `<span class="muted">-</span>`;
}
async function loadOperationLogs() {
logRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
const params = new URLSearchParams({ limit: "200" });
if (logOperation.value) params.set("operation", logOperation.value);
if (logStatus.value) params.set("status", logStatus.value);
if (logStudent.value.trim()) params.set("student", logStudent.value.trim());
try {
const data = await fetchJson(`/api/admin/operation-logs?${params.toString()}`);
currentOperationLogs = data.items || [];
if (!currentOperationLogs.some((item) => String(item.id) === expandedOperationLogId)) expandedOperationLogId = "";
logMeta.innerHTML = [metric("当前结果", `${data.count}`)].join("");
renderCurrentOperationLogs();
} catch (error) {
currentOperationLogs = [];
logRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
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 renderStandardLinesPreview(type, lines) {
const standardLines = Array.isArray(lines) ? lines : [];
if (type === "class_record") {
const rows = standardLines
.map(
(line, index) =>
`<li><span>${index + 1}</span><code>${escapeHtml(line)}</code></li>`
)
.join("");
return `<div class="register-preview-head"><strong>标准登记内容</strong><span>共 ${standardLines.length} 条</span></div><ol class="register-line-list">${rows}</ol>`;
}
return `<strong>标准登记内容</strong><pre>${escapeHtml(standardLines.join("\n\n"))}</pre>`;
}
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 ? `<p>${escapeHtml(data.warning)}</p>` : "";
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) => `<li>${escapeHtml(item)}</li>`).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 ? `<ul>${questions}</ul>` : "";
const inputBlock = fieldInputs ? `<div class="register-field-grid">${fieldInputs}</div>` : "";
previewNode.innerHTML = `<strong>需要补充信息</strong>${questionBlock}${draft}${inputBlock}`;
confirmButton.hidden = true;
registerPreviewState[type] = {
conversationId: data.conversation_id,
standardLines: [],
};
statusNode.textContent = "请补充信息后再次生成预览";
return;
}
const questions = (data.questions || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("");
previewNode.innerHTML = `<strong>无法生成预览</strong>${questions ? `<ul>${questions}</ul>` : ""}`;
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 `<span${className}>${escapeHtml(text)}</span>`;
}
function renderDraftLine(draftLine) {
return escapeHtml(waitingText(draftLine));
}
function renderReadonlyRegisterDraft(type, fields, draftLine = "") {
if (draftLine) {
return `<div class="register-draft readonly-register-draft"><pre>${renderDraftLine(draftLine)}</pre></div>`;
}
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 `<div class="register-draft readonly-register-draft"><pre>${renderDraftLine(line)}</pre></div>`;
}
if (type === "payment") {
const line = `${waitingText(fields.student)}-${waitingText(fields.date)}:${waitingText(fields.hours)}`;
return `<div class="register-draft readonly-register-draft"><pre>${renderDraftLine(line)}</pre></div>`;
}
return `<div class="register-draft readonly-register-draft"><pre>${renderDraftLine(draftLine)}</pre></div>`;
}
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"
? `<textarea data-ai-field="${escapeHtml(fieldName)}" rows="4">${escapeHtml(value)}</textarea>`
: `<input data-ai-field="${escapeHtml(fieldName)}" value="${escapeHtml(value)}">`;
return `<label><span>${escapeHtml(label)}${requiredMark}</span>${input}</label>`;
})
.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 ? `<div class="register-field-grid">${inputs}</div>` : "";
const error = item.error ? `<small class="muted">第 ${itemIndex + 1} 条:${escapeHtml(item.error)}</small>` : "";
return `<div class="register-draft-item">${renderReadonlyRegisterDraft(type, fields, item.draft_line || "")}${inputBlock}${error}</div>`;
})
.join("");
return `<div class="register-draft-list">${rows}</div>`;
}
function inlineDraftPart(name, fields, missing, fallback) {
const value = fields[name] || "";
if (!missing.has(name) && value) return `<span>${escapeHtml(value)}</span>`;
return `<input data-ai-field="${escapeHtml(name)}" value="${escapeHtml(value)}" placeholder="${escapeHtml(fallback)}">`;
}
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 `<div class="register-draft"><span>${dateText}</span><span>星期?</span><span>${timeText}</span><span>${studentText}</span><span>${durationText}</span><span>${teacherText}</span><span>${subjectText}</span></div>`;
}
if (type === "payment") {
const studentText = inlineDraftPart("student", fields, missing, "学生");
const dateText = inlineDraftPart("date", fields, missing, "日期");
const hoursText = inlineDraftPart("hours", fields, missing, "课时数");
return `<div class="register-draft payment-draft"><span>${studentText}</span><b>-</b><span>${dateText}</span><b>:</b><span>${hoursText}</span></div>`;
}
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")
? `<textarea data-ai-field="body" rows="4" placeholder="小结正文">${escapeHtml(bodyValue)}</textarea>`
: `<pre>${escapeHtml(bodyValue)}</pre>`;
return `<div class="register-draft summary-draft"><span>学生:${studentText}</span><span>日期:${dateText}</span><span>时间:${timeText}</span><span>老师:${teacherText}</span><span>科目:${subjectText}</span>${bodyInput}</div>`;
}
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);
duplicateSummaryScanBtn.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();
});
summarySearchMissingTime.addEventListener("change", loadSummarySearch);
summarySearchRows.addEventListener("click", (event) => {
const saveTime = event.target.closest(".summary-time-save");
const deleteSummary = event.target.closest(".summary-delete");
const editSummary = event.target.closest(".summary-body-edit");
const saveSummaryBody = event.target.closest(".summary-body-save");
const cancelSummaryBody = event.target.closest(".summary-body-cancel");
if (editSummary) {
editingSummarySearchId = editSummary.dataset.summaryId;
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.summaries.hidden) loadSummaryReviews();
if (!panels.summarySearch.hidden) loadSummarySearch();
if (!panels.logs.hidden) loadOperationLogs();
});
loadAdminHealth();
loadAccounts();