const adminHealthText = document.querySelector("#adminHealthText"); const refreshBtn = document.querySelector("#refreshBtn"); const panels = { dashboard: document.querySelector("#dashboardPanel"), 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 dashboardMeta = document.querySelector("#dashboardMeta"); const dashboardContent = document.querySelector("#dashboardContent"); const dashboardPeriodBtns = Array.from(document.querySelectorAll(".dashboard-period")); 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 reviewPager = document.querySelector("#reviewPager"); const summaryReviewStatus = document.querySelector("#summaryReviewStatus"); const summaryReviewMeta = document.querySelector("#summaryReviewMeta"); const summaryReviewRows = document.querySelector("#summaryReviewRows"); const summaryReviewPager = document.querySelector("#summaryReviewPager"); 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 summarySearchPager = document.querySelector("#summarySearchPager"); const quickMismatchBtn = document.querySelector("#quickMismatchBtn"); 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 logPager = document.querySelector("#logPager"); 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"); const ADMIN_PAGE_SIZE = 50; let currentAccounts = []; let currentDashboardPeriod = "month"; let editingAccountId = ""; let currentTeachers = []; let editingTeacherId = ""; let currentReviewPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false }; let currentSummaryReviews = []; let currentSummaryReviewPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false }; let activeSummaryReview = null; let currentSummarySearchItems = []; let currentSummarySearchPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false }; let expandedSummarySearchId = ""; let editingSummarySearchId = ""; let editingSummaryIdentityId = ""; let quickSummaryMismatchMode = false; let quickSummaryProcessing = false; let currentOperationLogs = []; let currentLogPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false }; 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 pageRangeText(state) { const total = Number(state.total || 0); const shown = Number(state.shown || 0); const offset = Number(state.offset || 0); if (!total || !shown) return "0 条"; return `${offset + 1}-${offset + shown} 条`; } function currentPageNumber(state) { return Math.floor((state.offset || 0) / (state.limit || ADMIN_PAGE_SIZE)) + 1; } function totalPageNumber(state) { const limit = state.limit || ADMIN_PAGE_SIZE; return Math.max(1, Math.ceil((state.total || 0) / limit)); } function renderPager(container, state, action) { if (!container) return; const total = Number(state.total || 0); const shown = Number(state.shown || 0); const limit = Number(state.limit || ADMIN_PAGE_SIZE); const offset = Number(state.offset || 0); if (total <= limit && offset === 0) { container.hidden = true; container.innerHTML = ""; return; } container.hidden = false; container.dataset.pagerAction = action; container.innerHTML = `
第 ${currentPageNumber(state)} / ${totalPageNumber(state)} 页 · ${pageRangeText(state)} / ${total} 条
`; } function setPagerLoading(container) { if (!container) return; container.hidden = true; container.innerHTML = ""; } function dashboardPeriodLabel(period) { if (period === "today") return "今日"; if (period === "7d") return "近7日"; if (period === "all") return "全部"; return "本月"; } function dashboardRangeText(period) { if (!period) return ""; if (period.value === "all") return "全部数据"; if (period.start && period.end && period.start !== period.end) return `${period.start} 至 ${period.end}`; return period.start || period.end || dashboardPeriodLabel(period.value); } function dashboardTaskTypeLabel(type) { if (type === "course_summary_review") return "课程小结审核"; if (type === "course_summary_duplicate_review") return "重复小结"; if (type === "class_record_correction") return "纠错审核"; if (type === "class_record_deletion") return "删除审核"; return type || "其他任务"; } function renderDashboardEmpty(text) { return `
${escapeHtml(text)}
`; } function renderDashboardList(items, renderItem, emptyText) { const rows = Array.isArray(items) ? items : []; if (!rows.length) return renderDashboardEmpty(emptyText); return `
${rows.map(renderItem).join("")}
`; } function renderDashboardBarList(items, emptyText) { const rows = Array.isArray(items) ? items : []; if (!rows.length) return renderDashboardEmpty(emptyText); const max = Math.max(...rows.map((item) => Number(item.hours || 0)), 0); return `
${rows .map((item) => { const hours = Number(item.hours || 0); const width = max > 0 ? Math.max(6, Math.round((hours / max) * 100)) : 0; return `
${escapeHtml(item.name || "")}
${escapeHtml(item.duration || fmtHours(hours))}
`; }) .join("")}
`; } function renderDashboardDaily(items) { const rows = Array.isArray(items) ? items : []; if (!rows.length) return renderDashboardEmpty("当前周期没有课程记录"); const shown = rows.slice(-14); return renderDashboardBarList(shown.map((item) => ({ name: item.date, hours: item.hours, duration: item.duration, })), "当前周期没有课程记录"); } function renderDashboardActions(actions) { return `
${actions.filter(Boolean).join("")}
`; } function dashboardAction(label, attrs = {}) { const attrText = Object.entries(attrs) .map(([key, value]) => ` ${key}="${escapeHtml(value)}"`) .join(""); return ``; } function renderDashboard(data) { const overview = data.overview || {}; const accounts = data.accounts || {}; const accountSummary = accounts.summary || {}; const teachers = data.teachers || {}; const summaries = data.course_summaries || {}; const tasks = data.tasks || {}; const teaching = data.teaching || {}; const period = data.period || {}; dashboardMeta.innerHTML = [ metric("统计周期", dashboardRangeText(period)), metric("课次", `${overview.records || 0} 条`), metric("课时", overview.duration || fmtHours(overview.hours || 0)), metric("参与学生", `${overview.students || 0} 人`), metric("授课老师", `${overview.teachers || 0} 位`), metric("科目", `${overview.subjects || 0} 个`), ].join(""); const taskTypeRows = Object.entries(tasks.by_type || {}).map(([type, count]) => ({ type, count })); dashboardContent.innerHTML = `

教学数据

${renderDashboardActions([ dashboardAction("查看课程记录", { "data-dashboard-action": "public-records" }), dashboardAction("登记课程", { "data-dashboard-tab": "register" }), ])}

老师课时排行

${renderDashboardBarList(teaching.teachers, "当前周期没有老师课时")}

科目课时排行

${renderDashboardBarList(teaching.subjects, "当前周期没有科目课时")}

学生上课排行

${renderDashboardBarList(teaching.students, "当前周期没有学生课时")}

每日课时

${renderDashboardDaily(teaching.daily)}

账户与老师

${renderDashboardActions([ dashboardAction("欠费账户", { "data-dashboard-tab": "accounts", "data-account-status": "欠费" }), dashboardAction("预警账户", { "data-dashboard-tab": "accounts", "data-account-status": "预警" }), dashboardAction("老师档案", { "data-dashboard-tab": "teachers" }), ])}

账户概况

${metric("总账户", `${accountSummary.total || 0} 人`)} ${metric("正常", accountSummary.normal || 0)} ${metric("预警", accountSummary.warning || 0)} ${metric("欠费", accountSummary.debt || 0)} ${metric("结课", accountSummary.completed || 0)} ${metric("退费", accountSummary.refunded || 0)}

低剩余课时

${renderDashboardList(accounts.low_remaining, (item) => ` `, "没有需要重点关注的账户")}

老师概况

${metric("老师总数", `${teachers.total || 0} 位`)} ${metric("在岗", `${teachers.active || 0} 位`)} ${metric("离职", `${teachers.inactive || 0} 位`)}

课程小结

${renderDashboardActions([ dashboardAction("未绑定", { "data-dashboard-tab": "summarySearch", "data-summary-binding": "unmatched" }), dashboardAction("缺时间", { "data-dashboard-tab": "summarySearch", "data-summary-binding": "missing_time" }), dashboardAction("字段不一致", { "data-dashboard-tab": "summarySearch", "data-summary-binding": "mismatch", "data-summary-candidate": "true" }), dashboardAction("扫描重复", { "data-dashboard-action": "duplicate-scan" }), ])}

绑定状态

${metric("小结总数", `${summaries.total || 0} 条`)} ${metric("已绑定", summaries.matched || 0)} ${metric("自动绑定", summaries.auto_bound || 0)} ${metric("未绑定", summaries.unmatched || 0)} ${metric("缺时间", summaries.missing_time || 0)} ${metric("字段不一致", summaries.mismatch || 0)} ${metric("有候选", summaries.with_candidate || 0)}

待处理任务

${metric("待处理", `${tasks.active || 0} 条`)} ${metric("待审核", tasks.pending || 0)} ${metric("冲突", tasks.conflict || 0)}
${renderDashboardList(taskTypeRows, (item) => ` `, "暂无待处理任务")}

最近操作

${renderDashboardList(data.logs, (item) => ` `, "暂无操作记录")}
`; } async function loadDashboard() { dashboardContent.innerHTML = `
正在读取仪表盘数据
`; dashboardPeriodBtns.forEach((button) => { button.classList.toggle("is-active", button.dataset.dashboardPeriod === currentDashboardPeriod); }); try { const data = await fetchJson(`/api/admin/dashboard?${new URLSearchParams({ period: currentDashboardPeriod }).toString()}`); renderDashboard(data); } catch (error) { dashboardMeta.innerHTML = ""; dashboardContent.innerHTML = `
读取失败:${escapeHtml(error.message)}
`; } } 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; let activeButton = null; document.querySelectorAll("[data-admin-tab]").forEach((button) => { const isActive = button.dataset.adminTab === activeTabName; button.classList.toggle("is-active", isActive); if (isActive) activeButton = button; }); if (activeButton) activeButton.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); Object.entries(panels).forEach(([name, panel]) => { panel.hidden = name !== activeTabName; }); if (activeTabName === "dashboard") loadDashboard(); if (activeTabName === "accounts") loadAccounts(); if (activeTabName === "teachers") loadTeachers(); if (activeTabName === "reviews") loadReviews(0); if (activeTabName === "summarySearch") { loadSummarySearch(0); loadSummaryReviews(0); } if (activeTabName === "logs") loadOperationLogs(0); } 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(offset = currentReviewPage.offset || 0) { reviewRows.innerHTML = `正在读取`; setPagerLoading(reviewPager); const normalizedOffset = Math.max(0, Number(offset || 0)); const params = new URLSearchParams({ limit: String(ADMIN_PAGE_SIZE), offset: String(normalizedOffset), type: "class_record_correction,class_record_deletion", }); if (reviewStatus.value) params.set("status", reviewStatus.value); try { const data = await fetchJson(`/api/admin/tasks?${params.toString()}`); const items = data.items || []; const totalCount = Number(data.count || 0); const typeCounts = data.type_counts || {}; currentReviewPage = { offset: normalizedOffset, limit: Number(data.limit || ADMIN_PAGE_SIZE), total: totalCount, shown: Number(data.returned || items.length), hasMore: Boolean(data.has_more), }; reviewMeta.innerHTML = [ metric("当前结果", `${totalCount} 条`), metric("当前显示", pageRangeText(currentReviewPage)), metric("纠错", `${typeCounts.class_record_correction || 0} 条`), metric("删除", `${typeCounts.class_record_deletion || 0} 条`), ].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 = `没有符合条件的审核项`; } renderPager(reviewPager, currentReviewPage, "reviews"); } 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(offset = currentSummaryReviewPage.offset || 0) { summaryReviewRows.innerHTML = `正在读取`; setPagerLoading(summaryReviewPager); const normalizedOffset = Math.max(0, Number(offset || 0)); const params = new URLSearchParams({ type: "course_summary_review,course_summary_duplicate_review", limit: String(ADMIN_PAGE_SIZE), offset: String(normalizedOffset), }); if (summaryReviewStatus.value) params.set("status", summaryReviewStatus.value); try { const data = await fetchJson(`/api/admin/tasks?${params.toString()}`); currentSummaryReviews = data.items || []; const totalCount = Number(data.count || 0); const typeCounts = data.type_counts || {}; currentSummaryReviewPage = { offset: normalizedOffset, limit: Number(data.limit || ADMIN_PAGE_SIZE), total: totalCount, shown: Number(data.returned || currentSummaryReviews.length), hasMore: Boolean(data.has_more), }; summaryReviewMeta.innerHTML = [ metric("待处理任务", `${totalCount} 条`), metric("当前显示", pageRangeText(currentSummaryReviewPage)), metric("课程小结审核", `${typeCounts.course_summary_review || 0} 条`), metric("重复小结", `${typeCounts.course_summary_duplicate_review || 0} 条`), ].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 = `没有符合条件的课程小结任务`; } renderPager(summaryReviewPager, currentSummaryReviewPage, "summaryReviews"); } catch (error) { currentSummaryReviews = []; summaryReviewRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; } } function summarySearchParams() { const params = new URLSearchParams({ limit: String(ADMIN_PAGE_SIZE), offset: String(currentSummarySearchPage.offset || 0) }); 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 mismatchItemsWithCandidates() { return currentSummarySearchItems.filter((item) => { const binding = summaryBinding(item); return binding.status === "mismatch" && Array.isArray(binding.candidates) && binding.candidates.length; }); } function firstMismatchWithCandidate() { return mismatchItemsWithCandidates()[0] || null; } function currentQuickMismatchItem() { const items = mismatchItemsWithCandidates(); return items.find((item) => String(item.id) === String(expandedSummarySearchId)) || items[0] || null; } function updateQuickMismatchButton() { if (!quickMismatchBtn) return; const remaining = mismatchItemsWithCandidates().length; quickMismatchBtn.disabled = quickSummaryProcessing || (quickSummaryMismatchMode && remaining === 0); if (quickSummaryProcessing) { quickMismatchBtn.textContent = "正在处理"; } else if (!quickSummaryMismatchMode) { quickMismatchBtn.textContent = "快速处理字段不一致"; } else if (remaining) { quickMismatchBtn.textContent = `按首个候选修正并下一条(剩 ${remaining})`; } else { quickMismatchBtn.textContent = "字段不一致已处理完"; } } function selectFirstQuickMismatchItem() { const item = firstMismatchWithCandidate(); expandedSummarySearchId = item ? String(item.id) : ""; editingSummarySearchId = ""; editingSummaryIdentityId = ""; } 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(offset = currentSummarySearchPage.offset || 0) { currentSummarySearchPage.offset = Math.max(0, Number(offset || 0)); summarySearchRows.innerHTML = `正在读取`; setPagerLoading(summarySearchPager); try { const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`); currentSummarySearchItems = data.items || []; currentSummarySearchPage = { offset: data.offset || currentSummarySearchPage.offset || 0, limit: data.limit || ADMIN_PAGE_SIZE, total: data.count || 0, shown: data.returned || currentSummarySearchItems.length, hasMore: Boolean(data.has_more), }; 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 = ""; if ( quickSummaryMismatchMode && !mismatchItemsWithCandidates().some((item) => String(item.id) === String(expandedSummarySearchId)) ) { selectFirstQuickMismatchItem(); } const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems); summarySearchMeta.innerHTML = [ metric("命中小结", `${data.count} 条`), metric("当前显示", pageRangeText(currentSummarySearchPage)), metric("已绑定", `${bindingSummary.matched} 条`), metric("待处理", `${bindingSummary.pending} 条`), metric("有候选", `${bindingSummary.withCandidate} 条`), ].join(""); renderCurrentSummarySearch(); renderPager(summarySearchPager, currentSummarySearchPage, "summarySearch"); updateQuickMismatchButton(); } catch (error) { currentSummarySearchItems = []; summarySearchRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; updateQuickMismatchButton(); } } 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, options = {}) { 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 = options.next ? "" : activeSummaryId; await loadSummarySearch(); await loadOperationLogs(); } async function startQuickMismatchMode() { quickSummaryMismatchMode = true; quickSummaryProcessing = false; summarySearchBindingStatus.value = "mismatch"; summarySearchHasCandidate.value = "true"; await loadSummarySearch(); selectFirstQuickMismatchItem(); renderCurrentSummarySearch(); updateQuickMismatchButton(); } async function processQuickMismatchItem() { if (!quickSummaryMismatchMode) { await startQuickMismatchMode(); return; } const item = currentQuickMismatchItem(); const binding = item ? summaryBinding(item) : null; const candidate = binding && Array.isArray(binding.candidates) ? binding.candidates[0] : null; if (!item || !candidate) { selectFirstQuickMismatchItem(); renderCurrentSummarySearch(); updateQuickMismatchButton(); return; } quickSummaryProcessing = true; updateQuickMismatchButton(); try { await applySummaryBindingCandidate(item.id, { student: candidate.student, teacher: candidate.teacher, time: candidate.time, subject: candidate.subject, }, { next: true }); selectFirstQuickMismatchItem(); renderCurrentSummarySearch(); } finally { quickSummaryProcessing = false; updateQuickMismatchButton(); } } 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(offset = currentLogPage.offset || 0) { currentLogPage.offset = Math.max(0, Number(offset || 0)); logRows.innerHTML = `正在读取`; setPagerLoading(logPager); const params = new URLSearchParams({ limit: String(ADMIN_PAGE_SIZE), offset: String(currentLogPage.offset || 0) }); 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 || []; currentLogPage = { offset: data.offset || currentLogPage.offset || 0, limit: data.limit || ADMIN_PAGE_SIZE, total: data.count || 0, shown: data.returned || currentOperationLogs.length, hasMore: Boolean(data.has_more), }; if (!currentOperationLogs.some((item) => String(item.id) === expandedOperationLogId)) expandedOperationLogId = ""; logMeta.innerHTML = [ metric("当前结果", `${data.count} 条`), metric("当前显示", pageRangeText(currentLogPage)), ].join(""); renderCurrentOperationLogs(); renderPager(logPager, currentLogPage, "logs"); } 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 `
    ${renderDraftLine(line)}
    `; } if (type === "payment") { const line = `${waitingText(fields.student)}-${waitingText(fields.date)}:${waitingText(fields.hours)}`; return `
    ${renderDraftLine(line)}
    `; } 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}`; } } function resetAccountFilters() { accountQuery.value = ""; accountStatus.value = ""; } function resetSummarySearchFilters() { summarySearchQuery.value = ""; summarySearchStudent.value = ""; summarySearchTeacher.value = ""; summarySearchSubject.value = ""; summarySearchDateFrom.value = ""; summarySearchDateTo.value = ""; summarySearchBindingStatus.value = ""; summarySearchHasCandidate.value = ""; quickSummaryMismatchMode = false; } function openDashboardTarget(button) { const targetTab = button.dataset.dashboardTab; if (targetTab === "accounts") { resetAccountFilters(); if (button.dataset.accountStatus !== undefined) accountStatus.value = button.dataset.accountStatus; if (button.dataset.accountQuery !== undefined) accountQuery.value = button.dataset.accountQuery; setActiveTab("accounts"); return; } if (targetTab === "summarySearch") { resetSummarySearchFilters(); if (button.dataset.summaryBinding !== undefined) summarySearchBindingStatus.value = button.dataset.summaryBinding; if (button.dataset.summaryCandidate !== undefined) summarySearchHasCandidate.value = button.dataset.summaryCandidate; summaryReviewStatus.value = "pending"; setActiveTab("summarySearch"); return; } if (targetTab === "reviews") { reviewStatus.value = "pending"; setActiveTab("reviews"); return; } if (targetTab === "logs") { logStatus.value = button.dataset.logStatus || ""; logOperation.value = ""; logStudent.value = ""; setActiveTab("logs"); return; } if (targetTab) { setActiveTab(targetTab); } } document.querySelectorAll("[data-admin-tab]").forEach((button) => { button.addEventListener("click", () => setActiveTab(button.dataset.adminTab)); }); dashboardPeriodBtns.forEach((button) => { button.addEventListener("click", () => { currentDashboardPeriod = button.dataset.dashboardPeriod || "month"; loadDashboard(); }); }); dashboardContent.addEventListener("click", (event) => { const actionButton = event.target.closest("[data-dashboard-action]"); if (actionButton && actionButton.dataset.dashboardAction === "public-records") { window.location.href = "/"; return; } if (actionButton && actionButton.dataset.dashboardAction === "duplicate-scan") { scanDuplicateSummaries(); return; } const jumpButton = event.target.closest(".dashboard-jump, .dashboard-list-row"); if (!jumpButton) return; openDashboardTarget(jumpButton); }); 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(0)); 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(0)); 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(); quickSummaryMismatchMode = false; loadSummarySearch(0); }); summarySearchBindingStatus.addEventListener("change", () => { quickSummaryMismatchMode = false; loadSummarySearch(0); }); summarySearchHasCandidate.addEventListener("change", () => { quickSummaryMismatchMode = false; loadSummarySearch(0); }); quickMismatchBtn.addEventListener("click", () => { processQuickMismatchItem().catch((error) => { quickSummaryProcessing = false; updateQuickMismatchButton(); alert(error.message); }); }); 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(0)); logStatus.addEventListener("change", () => loadOperationLogs(0)); logFilterForm.addEventListener("submit", (event) => { event.preventDefault(); loadOperationLogs(0); }); 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(); }); function bindPager(container, getState, loadPage) { if (!container) return; container.addEventListener("click", (event) => { const previous = event.target.closest(".pager-prev"); const next = event.target.closest(".pager-next"); if (!previous && !next) return; const state = getState(); const limit = state.limit || ADMIN_PAGE_SIZE; const nextOffset = Math.max(0, (state.offset || 0) + (previous ? -limit : limit)); loadPage(nextOffset); }); } bindPager(summarySearchPager, () => currentSummarySearchPage, loadSummarySearch); bindPager(summaryReviewPager, () => currentSummaryReviewPage, loadSummaryReviews); bindPager(reviewPager, () => currentReviewPage, loadReviews); bindPager(logPager, () => currentLogPage, loadOperationLogs); 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.dashboard.hidden) loadDashboard(); if (!panels.accounts.hidden) loadAccounts(); if (!panels.reviews.hidden) loadReviews(); if (!panels.summarySearch.hidden) refreshSummaryWorkspace({ logs: false }); if (!panels.logs.hidden) loadOperationLogs(currentLogPage.offset || 0); }); loadAdminHealth(); updateQuickMismatchButton(); loadDashboard();