const adminHealthText = document.querySelector("#adminHealthText"); const refreshBtn = document.querySelector("#refreshBtn"); const panels = { accounts: document.querySelector("#accountsPanel"), teachers: document.querySelector("#teachersPanel"), reviews: document.querySelector("#reviewsPanel"), summaries: document.querySelector("#summariesPanel"), summarySearch: document.querySelector("#summarySearchPanel"), logs: document.querySelector("#logsPanel"), register: document.querySelector("#registerPanel"), }; const accountForm = document.querySelector("#accountForm"); const accountQuery = document.querySelector("#accountQuery"); const accountStatus = document.querySelector("#accountStatus"); const accountMeta = document.querySelector("#accountMeta"); const accountRows = document.querySelector("#accountRows"); const accountEditor = document.querySelector("#accountEditor"); const accountEditorTitle = document.querySelector("#accountEditorTitle"); const accountEditForm = document.querySelector("#accountEditForm"); const accountEditError = document.querySelector("#accountEditError"); const cancelAccountEditBtn = document.querySelector("#cancelAccountEditBtn"); const newAccountBtn = document.querySelector("#newAccountBtn"); const teacherForm = document.querySelector("#teacherForm"); const teacherQuery = document.querySelector("#teacherQuery"); const teacherStatus = document.querySelector("#teacherStatus"); const teacherMeta = document.querySelector("#teacherMeta"); const teacherRows = document.querySelector("#teacherRows"); const teacherEditor = document.querySelector("#teacherEditor"); const teacherEditorTitle = document.querySelector("#teacherEditorTitle"); const teacherEditForm = document.querySelector("#teacherEditForm"); const teacherEditError = document.querySelector("#teacherEditError"); const cancelTeacherEditBtn = document.querySelector("#cancelTeacherEditBtn"); const newTeacherBtn = document.querySelector("#newTeacherBtn"); const editTeacherId = document.querySelector("#editTeacherId"); const editTeacherName = document.querySelector("#editTeacherName"); const editTeacherAlias = document.querySelector("#editTeacherAlias"); const editTeacherStatus = document.querySelector("#editTeacherStatus"); const editTeacherSubjects = document.querySelector("#editTeacherSubjects"); const editTeacherNote = document.querySelector("#editTeacherNote"); const editStudentId = document.querySelector("#editStudentId"); const editStudent = document.querySelector("#editStudent"); const editRemaining = document.querySelector("#editRemaining"); const editStatus = document.querySelector("#editStatus"); const editPayments = document.querySelector("#editPayments"); const editNote = document.querySelector("#editNote"); const reviewStatus = document.querySelector("#reviewStatus"); const reviewMeta = document.querySelector("#reviewMeta"); const reviewRows = document.querySelector("#reviewRows"); const summaryReviewStatus = document.querySelector("#summaryReviewStatus"); const summaryReviewMeta = document.querySelector("#summaryReviewMeta"); const summaryReviewRows = document.querySelector("#summaryReviewRows"); const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop"); const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose"); const summaryReviewDrawerTitle = document.querySelector("#summaryReviewDrawerTitle"); const summaryReviewDrawerMeta = document.querySelector("#summaryReviewDrawerMeta"); const summaryReviewDrawerStatus = document.querySelector("#summaryReviewDrawerStatus"); const summaryReviewDrawerCourse = document.querySelector("#summaryReviewDrawerCourse"); const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine"); const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons"); const summaryReviewDrawerBody = document.querySelector("#summaryReviewDrawerBody"); const summaryReviewDrawerSource = document.querySelector("#summaryReviewDrawerSource"); const summaryReviewDrawerApprove = document.querySelector("#summaryReviewDrawerApprove"); const summaryReviewDrawerReject = document.querySelector("#summaryReviewDrawerReject"); const summarySearchForm = document.querySelector("#summarySearchForm"); const summarySearchQuery = document.querySelector("#summarySearchQuery"); const summarySearchStudent = document.querySelector("#summarySearchStudent"); const summarySearchTeacher = document.querySelector("#summarySearchTeacher"); const summarySearchSubject = document.querySelector("#summarySearchSubject"); const summarySearchDateFrom = document.querySelector("#summarySearchDateFrom"); const summarySearchDateTo = document.querySelector("#summarySearchDateTo"); const summarySearchMissingTime = document.querySelector("#summarySearchMissingTime"); const summarySearchMeta = document.querySelector("#summarySearchMeta"); const summarySearchRows = document.querySelector("#summarySearchRows"); const logOperation = document.querySelector("#logOperation"); const logStatus = document.querySelector("#logStatus"); const logFilterForm = document.querySelector("#logFilterForm"); const logStudent = document.querySelector("#logStudent"); const logMeta = document.querySelector("#logMeta"); const logRows = document.querySelector("#logRows"); const classRegisterForm = document.querySelector("#classRegisterForm"); const classRegisterLines = document.querySelector("#classRegisterLines"); const classRegisterPreview = document.querySelector("#classRegisterPreview"); const classRegisterStatus = document.querySelector("#classRegisterStatus"); const classRegisterConfirm = document.querySelector("#classRegisterConfirm"); const paymentRegisterForm = document.querySelector("#paymentRegisterForm"); const paymentRegisterLines = document.querySelector("#paymentRegisterLines"); const paymentRegisterPreview = document.querySelector("#paymentRegisterPreview"); const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus"); const paymentRegisterConfirm = document.querySelector("#paymentRegisterConfirm"); const summaryRegisterForm = document.querySelector("#summaryRegisterForm"); const summaryRegisterText = document.querySelector("#summaryRegisterText"); const summaryRegisterPreview = document.querySelector("#summaryRegisterPreview"); const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus"); const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm"); let currentAccounts = []; let editingAccountId = ""; let currentTeachers = []; let editingTeacherId = ""; let currentSummaryReviews = []; let activeSummaryReview = null; const registerPreviewState = { class_record: null, payment: null, course_summary: null, }; function fmtHours(value) { const totalMinutes = Math.round(Number(value || 0) * 60); const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return `${hours}小时${minutes}分`; } function displayHours(value, fallback) { return fallback || fmtHours(value); } function fmtTime(seconds) { if (!seconds) return "未知"; return new Date(seconds * 1000).toLocaleString("zh-CN", { hour12: false }); } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function metric(label, value) { return `
${escapeHtml(label)}${escapeHtml(value)}
`; } function statusClass(status) { if (status === "欠费") return "debt"; if (status === "预警") return "warning"; if (status === "正常") return "normal"; return "closed"; } function taskStatusClass(status) { if (["approved", "auto_registered", "已批准", "自动入账", "完成", "已更新", "已删除"].includes(status)) return "normal"; if (["rejected", "duplicate", "已驳回", "重复"].includes(status)) return "closed"; if (status === "conflict" || status === "冲突") return "debt"; return "warning"; } function renderPayments(payments) { if (!payments.length) return "暂无"; return payments.map((item) => `${escapeHtml(item.date)}:${escapeHtml(displayHours(item.hours, item.duration))}`).join("
"); } async function fetchJson(url, options = {}) { const response = await fetch(url, { cache: "no-store", ...options }); if (!response.ok) { let detail = `${response.status} ${response.statusText}`; try { const payload = await response.json(); detail = payload.detail || detail; } catch (_error) { detail = response.statusText || detail; } throw new Error(detail); } return response.json(); } function setActiveTab(tabName) { document.querySelectorAll("[data-admin-tab]").forEach((button) => { button.classList.toggle("is-active", button.dataset.adminTab === tabName); }); Object.entries(panels).forEach(([name, panel]) => { panel.hidden = name !== tabName; }); if (tabName === "accounts") loadAccounts(); if (tabName === "teachers") loadTeachers(); if (tabName === "reviews") loadReviews(); if (tabName === "summaries") loadSummaryReviews(); if (tabName === "summarySearch") loadSummarySearch(); if (tabName === "logs") loadOperationLogs(); } async function loadAdminHealth() { try { const data = await fetchJson("/api/account-health"); const teacherText = data.teachers_count === undefined ? "" : `;老师 ${data.teachers_count} 位`; adminHealthText.textContent = `账户 ${data.accounts_count} 人${teacherText};数据更新时间 ${fmtTime(data.accounts.mtime)}`; } catch (error) { adminHealthText.textContent = `读取失败:${error.message}`; } } function renderAccountSummary(summary, count) { accountMeta.innerHTML = [ metric("当前结果", `${count} 人`), metric("欠费", summary.debt), metric("预警", summary.warning), metric("正常", summary.normal), ].join(""); } async function loadAccounts() { accountRows.innerHTML = `正在读取`; const params = new URLSearchParams(); if (accountQuery.value.trim()) params.set("q", accountQuery.value.trim()); if (accountStatus.value) params.set("status", accountStatus.value); try { const data = await fetchJson(`/api/accounts?${params.toString()}`); currentAccounts = data.accounts; renderAccountSummary(data.summary, data.count); accountRows.innerHTML = data.accounts .map( (row) => ` ${escapeHtml(row.student)}
${escapeHtml(row.student_id)} ${escapeHtml(row.account_status)} ${escapeHtml(displayHours(row.remaining, row.remaining_duration))} ${renderPayments(row.payments)} ${escapeHtml(row.note || "")} `, ) .join(""); if (!data.accounts.length) { accountRows.innerHTML = `没有符合条件的账户`; } } catch (error) { accountRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; } } function paymentsToText(payments) { return payments.map((item) => `${item.date}:${displayHours(item.hours, item.duration)}`).join("\n"); } function parseHoursInput(value) { const text = String(value || "").trim(); const match = text.match(/^(\d+)小时(?:(\d+)分)?$/); if (match) return Number(match[1]) + Number(match[2] || 0) / 60; const minuteMatch = text.match(/^(\d+)分$/); if (minuteMatch) return Number(minuteMatch[1]) / 60; return Number(text); } function parsePaymentsInput(value) { return value .split(/[\n,]+/) .map((item) => item.trim()) .filter(Boolean) .map((item) => { const [date, hoursText] = item.split(":"); if (!date || hoursText === undefined) { throw new Error(`缴费记录格式错误:${item}`); } const hours = parseHoursInput(hoursText); if (!Number.isFinite(hours)) { throw new Error(`缴费课时格式错误:${item}`); } return { date: date.trim(), hours }; }); } function openNewAccountEditor() { editingAccountId = ""; accountEditorTitle.textContent = "新增账户"; editStudentId.value = ""; editStudentId.placeholder = "保存时自动生成"; editStudent.value = ""; editRemaining.value = "0"; editStatus.value = "正常"; editPayments.value = ""; editNote.value = ""; accountEditError.hidden = true; accountEditor.hidden = false; editStudent.focus(); } function openEditAccountEditor(studentId) { const account = currentAccounts.find((item) => item.student_id === studentId); if (!account) return; editingAccountId = account.student_id; accountEditorTitle.textContent = `编辑账户:${account.student}`; editStudentId.value = account.student_id; editStudentId.placeholder = ""; editStudent.value = account.student; editRemaining.value = account.remaining; editStatus.value = account.account_status; editPayments.value = paymentsToText(account.payments); editNote.value = account.note || ""; accountEditError.hidden = true; accountEditor.hidden = false; editStudent.focus(); } function buildAccountPayload() { const remaining = Number(editRemaining.value); if (!Number.isFinite(remaining)) { throw new Error("剩余课时必须是数字"); } return { student_id: editStudentId.value.trim(), student: editStudent.value.trim(), payments: parsePaymentsInput(editPayments.value), remaining, account_status: editStatus.value, note: editNote.value.trim(), }; } async function saveAccount(event) { event.preventDefault(); try { const payload = buildAccountPayload(); const url = editingAccountId ? `/api/admin/accounts/${encodeURIComponent(editingAccountId)}` : "/api/admin/accounts"; const method = editingAccountId ? "PUT" : "POST"; await fetchJson(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); accountEditor.hidden = true; await loadAdminHealth(); await loadAccounts(); } catch (error) { accountEditError.textContent = error.message; accountEditError.hidden = false; } } function renderTeacherSummary(rows) { const activeCount = rows.filter((item) => item.status === "在岗").length; const aliasCount = rows.filter((item) => String(item.alias || "").trim()).length; teacherMeta.innerHTML = [ metric("当前结果", `${rows.length} 位`), metric("在岗", activeCount), metric("已配置别名", aliasCount), ].join(""); } function teacherMatches(row) { const keyword = teacherQuery.value.trim(); if (teacherStatus.value && row.status !== teacherStatus.value) return false; if (!keyword) return true; const haystack = [ row.teacher_id, row.name, row.alias, row.display_name, ...(row.subjects || []), row.status, row.note, ].join("\n"); return haystack.includes(keyword); } async function loadTeachers() { teacherRows.innerHTML = `正在读取`; try { const data = await fetchJson("/api/admin/teachers"); currentTeachers = data.teachers || []; const rows = currentTeachers.filter(teacherMatches); renderTeacherSummary(rows); teacherRows.innerHTML = rows .map( (row) => ` ${escapeHtml(row.name)}
${escapeHtml(row.teacher_id)} ${escapeHtml(row.alias || "")} ${escapeHtml((row.subjects || []).join("、"))} ${escapeHtml(row.status)} ${escapeHtml(row.note || "")} `, ) .join(""); if (!rows.length) { teacherRows.innerHTML = `没有符合条件的老师档案`; } } catch (error) { teacherRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; } } function openNewTeacherEditor() { editingTeacherId = ""; teacherEditorTitle.textContent = "新增老师"; editTeacherId.value = ""; editTeacherId.placeholder = "保存时自动生成"; editTeacherName.value = ""; editTeacherAlias.value = ""; editTeacherSubjects.value = ""; editTeacherStatus.value = "在岗"; editTeacherNote.value = ""; teacherEditError.hidden = true; teacherEditor.hidden = false; editTeacherName.focus(); } function openEditTeacherEditor(teacherId) { const teacher = currentTeachers.find((item) => item.teacher_id === teacherId); if (!teacher) return; editingTeacherId = teacher.teacher_id; teacherEditorTitle.textContent = `编辑老师:${teacher.name}`; editTeacherId.value = teacher.teacher_id; editTeacherId.placeholder = ""; editTeacherName.value = teacher.name; editTeacherAlias.value = teacher.alias || ""; editTeacherSubjects.value = (teacher.subjects || []).join("、"); editTeacherStatus.value = teacher.status; editTeacherNote.value = teacher.note || ""; teacherEditError.hidden = true; teacherEditor.hidden = false; editTeacherName.focus(); } function parseTeacherSubjects(value) { return value .split(/[、,,\s]+/) .map((item) => item.trim()) .filter(Boolean); } function buildTeacherPayload() { return { teacher_id: editTeacherId.value.trim(), name: editTeacherName.value.trim(), alias: editTeacherAlias.value.trim(), subjects: parseTeacherSubjects(editTeacherSubjects.value), status: editTeacherStatus.value, note: editTeacherNote.value.trim(), }; } async function saveTeacher(event) { event.preventDefault(); try { const payload = buildTeacherPayload(); const url = editingTeacherId ? `/api/admin/teachers/${encodeURIComponent(editingTeacherId)}` : "/api/admin/teachers"; const method = editingTeacherId ? "PUT" : "POST"; await fetchJson(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); teacherEditor.hidden = true; await loadTeachers(); } catch (error) { teacherEditError.textContent = error.message; teacherEditError.hidden = false; } } function renderReviewLine(line) { return `${escapeHtml(line)}`; } function reviewTaskTypeLabel(item) { if (item.type === "class_record_deletion") return "删除"; return "纠错"; } function renderReviewTarget(item) { if (item.type === "class_record_deletion") { const restored = item.original && item.original.duration_hours ? `通过后恢复 ${fmtHours(item.original.duration_hours)}` : "通过后恢复课时"; return `删除
${escapeHtml(restored)}`; } return renderReviewLine(item.corrected_line); } async function loadReviews() { reviewRows.innerHTML = `正在读取`; try { const [correctionData, deletionData] = await Promise.all([ fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_correction", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`), fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_deletion", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`), ]); const items = [...(correctionData.items || []), ...(deletionData.items || [])].sort((a, b) => Number(b.id || 0) - Number(a.id || 0)); reviewMeta.innerHTML = [metric("当前结果", `${items.length} 条`)].join(""); reviewRows.innerHTML = items .map((item) => { const canReview = item.status === "pending" || item.status === "conflict"; return ` #${escapeHtml(item.id)} ${escapeHtml(item.status)}${item.message ? `
${escapeHtml(item.message)}` : ""} ${escapeHtml(reviewTaskTypeLabel(item))} ${renderReviewLine(item.original_line)} ${renderReviewTarget(item)} ${escapeHtml(item.created_at || "")}
`; }) .join(""); if (!items.length) { reviewRows.innerHTML = `没有符合条件的审核项`; } } catch (error) { reviewRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; } } function summaryReviewKind(item) { const sourceId = String(item.source_id || ""); const reasons = Array.isArray(item.reasons) ? item.reasons.join(" ") : ""; if (sourceId.startsWith("history-missing:") || reasons.includes("历史 classnotes缺失")) { return "历史缺失"; } return ""; } function renderReviewTag(label) { if (!label) return ""; return `${escapeHtml(label)}`; } function renderSummaryInfo(summary, item = {}) { const parts = [ summary.student, summary.date_iso, summary.time_range, summary.teacher, summary.subject, ].filter(Boolean); return `${renderReviewTag(summaryReviewKind(item))}${parts.map(escapeHtml).join("
")}
${escapeHtml(summary.group || "")}`; } function renderSummaryPreview(summary) { const body = String(summary.body || ""); const preview = body.length > 140 ? `${body.slice(0, 140)}...` : body; return `
${escapeHtml(preview || "暂无原文")}
`; } function findSummaryReview(taskId) { return currentSummaryReviews.find((item) => String(item.id) === String(taskId)); } function canReviewSummary(item) { return item && (item.status === "pending" || item.status === "conflict"); } function courseInfoLines(summary) { return [ summary.student ? `学生:${summary.student}` : "", summary.date_iso ? `日期:${summary.date_iso}` : "", summary.time_range ? `时间:${summary.time_range}` : "", summary.teacher || summary.subject ? `老师/科目:${summary.teacher || ""} ${summary.subject || ""}`.trim() : "", summary.group ? `班级/分组:${summary.group}` : "", ].filter(Boolean); } function openSummaryReviewDrawer(taskId) { const item = findSummaryReview(taskId); if (!item) return; const summary = item.summary || {}; const reasons = Array.isArray(item.reasons) ? item.reasons : []; const kind = summaryReviewKind(item); const canReview = canReviewSummary(item); activeSummaryReview = item; summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`; summaryReviewDrawerMeta.innerHTML = [ item.created_at ? `提交时间:${escapeHtml(item.created_at)}` : "", kind ? renderReviewTag(kind) : "", ].filter(Boolean).join(" "); summaryReviewDrawerStatus.innerHTML = `${escapeHtml(item.status || "")}${item.message ? ` ${escapeHtml(item.message)}` : ""}`; summaryReviewDrawerCourse.innerHTML = courseInfoLines(summary).map(escapeHtml).join("
") || "暂无课程信息"; summaryReviewDrawerLine.innerHTML = item.proposed_line ? renderReviewLine(item.proposed_line) : "暂无"; summaryReviewDrawerReasons.innerHTML = reasons.length ? reasons.map(escapeHtml).join("
") : "待人工复核"; summaryReviewDrawerBody.textContent = summary.body || "暂无原文"; summaryReviewDrawerSource.innerHTML = [ item.source_id ? `来源ID:${escapeHtml(item.source_id)}` : "", item.saved_path ? `保存文件:${escapeHtml(item.saved_path)}` : "", item.batch_id ? `批次:${escapeHtml(item.batch_id)}` : "", ].filter(Boolean).join("
") || "暂无来源信息"; summaryReviewDrawerApprove.disabled = !canReview; summaryReviewDrawerReject.disabled = !canReview; summaryReviewDrawerBackdrop.hidden = false; } function closeSummaryReviewDrawer() { activeSummaryReview = null; summaryReviewDrawerBackdrop.hidden = true; } async function loadSummaryReviews() { summaryReviewRows.innerHTML = `正在读取`; const params = new URLSearchParams({ type: "course_summary_review" }); if (summaryReviewStatus.value) params.set("status", summaryReviewStatus.value); try { const data = await fetchJson(`/api/admin/tasks?${params.toString()}`); currentSummaryReviews = data.items || []; summaryReviewMeta.innerHTML = [metric("当前结果", `${data.count} 条`)].join(""); summaryReviewRows.innerHTML = currentSummaryReviews .map((item) => { const canReview = canReviewSummary(item); const summary = item.summary || {}; const reasons = Array.isArray(item.reasons) ? item.reasons : []; return ` #${escapeHtml(item.id)} ${escapeHtml(item.status)}${item.message ? `
${escapeHtml(item.message)}` : ""} ${renderSummaryInfo(summary, item)} ${item.proposed_line ? renderReviewLine(item.proposed_line) : "暂无"} ${renderSummaryPreview(summary)} ${reasons.map(escapeHtml).join("
") || "待人工复核"}
`; }) .join(""); if (!currentSummaryReviews.length) { summaryReviewRows.innerHTML = `没有符合条件的课程小结审核项`; } } catch (error) { currentSummaryReviews = []; summaryReviewRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; } } function summarySearchParams() { const params = new URLSearchParams({ limit: "200" }); if (summarySearchQuery.value.trim()) params.set("q", summarySearchQuery.value.trim()); if (summarySearchStudent.value.trim()) params.set("student", summarySearchStudent.value.trim()); if (summarySearchTeacher.value.trim()) params.set("teacher", summarySearchTeacher.value.trim()); if (summarySearchSubject.value.trim()) params.set("subject", summarySearchSubject.value.trim()); if (summarySearchDateFrom.value) params.set("date_from", summarySearchDateFrom.value); if (summarySearchDateTo.value) params.set("date_to", summarySearchDateTo.value); if (summarySearchMissingTime.value) params.set("missing_time", "true"); return params; } function renderSummarySearchBody(item) { const fullId = `summary-full-${item.id}`; const hasFull = String(item.body || "") !== String(item.body_preview || ""); return `
${escapeHtml(item.body_preview || "")}
`; } function renderSummaryActions(item) { if (item.time_range) { return `已完整`; } const inputId = `summary-time-${item.id}`; return `
`; } function renderMatchedFields(item) { const fields = Array.isArray(item.matched_fields) ? item.matched_fields : []; return fields.length ? `
命中:${fields.map(escapeHtml).join("、")}` : ""; } async function loadSummarySearch() { summarySearchRows.innerHTML = `正在读取`; try { const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`); summarySearchMeta.innerHTML = [ metric("命中小结", `${data.count} 条`), metric("当前显示", `${data.returned} 条`), ].join(""); summarySearchRows.innerHTML = data.items .map((item) => ` ${escapeHtml(item.date_iso || "未识别")} ${escapeHtml(item.time_range || "缺少时间")} ${escapeHtml(item.student || "")}
${escapeHtml(item.group || "")} ${escapeHtml(item.teacher || "待核对老师")}
${escapeHtml(item.subject || "待核对科目")} ${escapeHtml(item.title || "")}${renderMatchedFields(item)} ${renderSummarySearchBody(item)} ${renderSummaryActions(item)} `) .join(""); if (!data.items.length) { summarySearchRows.innerHTML = `没有符合条件的课程小结`; } } catch (error) { summarySearchRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; } } async function updateCourseSummaryTime(summaryId, timeRange) { await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/time`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ time_range: timeRange }), }); await loadSummarySearch(); } async function deleteCourseSummary(summaryId) { await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}`, { method: "DELETE", }); await loadSummarySearch(); } function renderLogDetail(item) { const details = []; if (item.task_type) details.push(`类型:${item.task_type}`); if (item.source_id) details.push(`来源:${item.source_id}`); if (item.student_id) details.push(`学生ID:${item.student_id}`); if (item.teacher_id) details.push(`教师ID:${item.teacher_id}`); if (item.teacher || item.subject) details.push(`老师/科目:${item.teacher || ""} ${item.subject || ""}`.trim()); if (item.time_range) details.push(`时间:${item.time_range}`); if (item.proposed_line) details.push(`记录:${item.proposed_line}`); if (Array.isArray(item.reasons) && item.reasons.length) details.push(`原因:${item.reasons.join(";")}`); if (item.error) details.push(`错误:${item.error}`); if (item.backup_id) details.push(`备份:${item.backup_id}`); if (item.target_log_id) details.push(`撤回记录:${item.target_log_id}`); if (item.target_backup_id) details.push(`撤回备份:${item.target_backup_id}`); if (Array.isArray(item.restored_files) && item.restored_files.length) details.push(`恢复文件:${item.restored_files.join(";")}`); if (item.saved_path) details.push(`文件:${item.saved_path}`); return `
${details.map(escapeHtml).join("
") || "暂无详情"}
`; } function renderLogActions(item) { if (item.can_rollback) { return ``; } if (item.backup_id && item.rollback_block_reason) { return `${escapeHtml(item.rollback_block_reason)}`; } return `-`; } async function loadOperationLogs() { logRows.innerHTML = `正在读取`; const params = new URLSearchParams({ limit: "200" }); if (logOperation.value) params.set("operation", logOperation.value); if (logStatus.value) params.set("status", logStatus.value); if (logStudent.value.trim()) params.set("student", logStudent.value.trim()); try { const data = await fetchJson(`/api/admin/operation-logs?${params.toString()}`); logMeta.innerHTML = [metric("当前结果", `${data.count} 条`)].join(""); logRows.innerHTML = data.items .map((item) => ` ${escapeHtml(item.created_at || "")} ${escapeHtml(item.operation || "")} ${escapeHtml(item.status || "")} ${escapeHtml(item.student || "")} ${escapeHtml(item.batch_id || "")}${item.task_id ? `
任务 #${escapeHtml(item.task_id)}` : ""} ${renderLogDetail(item)} ${renderLogActions(item)} `) .join(""); if (!data.items.length) { logRows.innerHTML = `没有符合条件的操作记录`; } } catch (error) { logRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; } } async function rollbackOperationLog(logId) { if (!confirm("确认撤回这条操作记录?系统只会在没有后续相关改动时恢复备份。")) return; try { await fetchJson(`/api/admin/operation-logs/${encodeURIComponent(logId)}/rollback`, { method: "POST" }); await loadOperationLogs(); await loadAdminHealth(); await loadAccounts(); } catch (error) { alert(error.message); } } async function runTaskAction(taskId, action, reload) { try { await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/${action}`, { method: "POST" }); } catch (error) { alert(error.message); } reload(); } async function reviewTask(taskId, action) { runTaskAction(taskId, action, loadReviews); } async function summaryReviewTask(taskId, action) { await runTaskAction(taskId, action, loadSummaryReviews); closeSummaryReviewDrawer(); } function linesFromTextarea(textarea) { return textarea.value .split("\n") .map((line) => line.trim()) .filter(Boolean); } function clearRegisterPreview(type, resetState = true) { const previewNode = { class_record: classRegisterPreview, payment: paymentRegisterPreview, course_summary: summaryRegisterPreview, }[type]; const confirmButton = { class_record: classRegisterConfirm, payment: paymentRegisterConfirm, course_summary: summaryRegisterConfirm, }[type]; if (resetState) registerPreviewState[type] = null; if (previewNode) { previewNode.hidden = true; previewNode.innerHTML = ""; } if (confirmButton) confirmButton.hidden = true; } function renderRegisterPreview(type, data, statusNode) { const previewNode = { class_record: classRegisterPreview, payment: paymentRegisterPreview, course_summary: summaryRegisterPreview, }[type]; const confirmButton = { class_record: classRegisterConfirm, payment: paymentRegisterConfirm, course_summary: summaryRegisterConfirm, }[type]; previewNode.hidden = false; if (data.status === "ready") { const warning = data.warning ? `

${escapeHtml(data.warning)}

` : ""; previewNode.innerHTML = `${warning}标准登记内容
${escapeHtml((data.standard_lines || []).join("\n\n"))}
`; confirmButton.hidden = false; registerPreviewState[type] = { conversationId: data.conversation_id, standardLines: data.standard_lines || [], }; statusNode.textContent = data.ai_used ? "模型已生成预览,请确认后写入" : "已通过本地标准格式校验,请确认后写入"; return; } if (data.status === "needs_info") { const questions = (data.questions || []).map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); previewNode.innerHTML = `需要补充信息`; confirmButton.hidden = true; registerPreviewState[type] = { conversationId: data.conversation_id, standardLines: [], }; statusNode.textContent = "请补充信息后再次生成预览"; return; } const questions = (data.questions || []).map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); previewNode.innerHTML = `无法生成预览${questions ? `` : ""}`; confirmButton.hidden = true; registerPreviewState[type] = { conversationId: data.conversation_id, standardLines: [], }; statusNode.textContent = `预览失败:${data.error || "请修改原文后重试"}`; } async function previewRegister(event, type, lines, statusNode) { event.preventDefault(); if (!lines.length) { clearRegisterPreview(type); statusNode.textContent = "请先填写登记内容"; return; } const previewNode = { class_record: classRegisterPreview, payment: paymentRegisterPreview, course_summary: summaryRegisterPreview, }[type]; const answerNode = previewNode ? previewNode.querySelector("[data-ai-answer]") : null; const state = registerPreviewState[type] || {}; const answers = answerNode && answerNode.value.trim() ? { followup: answerNode.value.trim() } : {}; clearRegisterPreview(type, !state.conversationId); statusNode.textContent = "正在生成预览"; try { const data = await fetchJson("/api/ai/register/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type, lines, answers, conversation_id: state.conversationId || null, }), }); renderRegisterPreview(type, data, statusNode); } catch (error) { statusNode.textContent = `预览失败:${error.message}`; } } async function confirmRegister(type, statusNode, url, onSuccess) { const state = registerPreviewState[type]; if (!state || !state.standardLines || !state.standardLines.length) { statusNode.textContent = "请先生成可确认的预览"; return; } statusNode.textContent = "正在写入"; try { const data = await fetchJson(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lines: state.standardLines }), }); if (type === "course_summary") { statusNode.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;重复 ${data.duplicates} 条;舍弃 ${data.rejected} 条`; } else { statusNode.textContent = `已登记 ${data.registered} 条;备份 ${data.backup_id}`; } clearRegisterPreview(type); onSuccess(); await loadAdminHealth(); await loadAccounts(); } catch (error) { statusNode.textContent = `写入失败:${error.message}`; } } document.querySelectorAll("[data-admin-tab]").forEach((button) => { button.addEventListener("click", () => setActiveTab(button.dataset.adminTab)); }); accountForm.addEventListener("submit", (event) => { event.preventDefault(); loadAccounts(); }); accountStatus.addEventListener("change", loadAccounts); newAccountBtn.addEventListener("click", openNewAccountEditor); cancelAccountEditBtn.addEventListener("click", () => { accountEditor.hidden = true; }); accountEditForm.addEventListener("submit", saveAccount); accountRows.addEventListener("click", (event) => { const button = event.target.closest(".account-edit"); if (!button) return; openEditAccountEditor(button.dataset.studentId); }); teacherForm.addEventListener("submit", (event) => { event.preventDefault(); loadTeachers(); }); teacherStatus.addEventListener("change", loadTeachers); newTeacherBtn.addEventListener("click", openNewTeacherEditor); cancelTeacherEditBtn.addEventListener("click", () => { teacherEditor.hidden = true; }); teacherEditForm.addEventListener("submit", saveTeacher); teacherRows.addEventListener("click", (event) => { const button = event.target.closest(".teacher-edit"); if (!button) return; openEditTeacherEditor(button.dataset.teacherId); }); reviewStatus.addEventListener("change", loadReviews); reviewRows.addEventListener("click", (event) => { const approve = event.target.closest(".review-approve"); const reject = event.target.closest(".review-reject"); if (approve) reviewTask(approve.dataset.taskId, "approve"); if (reject) reviewTask(reject.dataset.taskId, "reject"); }); summaryReviewStatus.addEventListener("change", loadSummaryReviews); summaryReviewRows.addEventListener("click", (event) => { const detail = event.target.closest(".summary-detail"); const approve = event.target.closest(".summary-approve"); const reject = event.target.closest(".summary-reject"); if (detail) { openSummaryReviewDrawer(detail.dataset.taskId); return; } if (approve) { summaryReviewTask(approve.dataset.taskId, "approve"); return; } if (reject) summaryReviewTask(reject.dataset.taskId, "reject"); }); summaryReviewDrawerClose.addEventListener("click", closeSummaryReviewDrawer); summaryReviewDrawerBackdrop.addEventListener("click", (event) => { if (event.target === summaryReviewDrawerBackdrop) closeSummaryReviewDrawer(); }); summaryReviewDrawerApprove.addEventListener("click", () => { if (activeSummaryReview) summaryReviewTask(activeSummaryReview.id, "approve"); }); summaryReviewDrawerReject.addEventListener("click", () => { if (activeSummaryReview) summaryReviewTask(activeSummaryReview.id, "reject"); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !summaryReviewDrawerBackdrop.hidden) closeSummaryReviewDrawer(); }); summarySearchForm.addEventListener("submit", (event) => { event.preventDefault(); loadSummarySearch(); }); summarySearchMissingTime.addEventListener("change", loadSummarySearch); summarySearchRows.addEventListener("click", (event) => { const button = event.target.closest(".summary-toggle"); const saveTime = event.target.closest(".summary-time-save"); const deleteSummary = event.target.closest(".summary-delete"); if (saveTime) { const input = summarySearchRows.querySelector(`[data-summary-time-input='${saveTime.dataset.summaryId}']`); const timeRange = input ? input.value.trim() : ""; if (!timeRange) { alert("请输入时间段"); return; } updateCourseSummaryTime(saveTime.dataset.summaryId, timeRange).catch((error) => alert(error.message)); return; } if (deleteSummary) { if (!confirm("确认删除这条课程小结?")) return; deleteCourseSummary(deleteSummary.dataset.summaryId).catch((error) => alert(error.message)); return; } if (!button) return; const target = document.getElementById(button.dataset.target); if (!target) return; target.hidden = !target.hidden; button.textContent = target.hidden ? "展开" : "收起"; }); logOperation.addEventListener("change", loadOperationLogs); logStatus.addEventListener("change", loadOperationLogs); logFilterForm.addEventListener("submit", (event) => { event.preventDefault(); loadOperationLogs(); }); logRows.addEventListener("click", (event) => { const rollback = event.target.closest(".log-rollback"); if (rollback) rollbackOperationLog(rollback.dataset.logId); }); classRegisterForm.addEventListener("submit", (event) => { previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus); }); paymentRegisterForm.addEventListener("submit", (event) => { previewRegister(event, "payment", linesFromTextarea(paymentRegisterLines), paymentRegisterStatus); }); classRegisterConfirm.addEventListener("click", () => { confirmRegister("class_record", classRegisterStatus, "/api/register/class-records", () => { classRegisterLines.value = ""; }); }); paymentRegisterConfirm.addEventListener("click", () => { confirmRegister("payment", paymentRegisterStatus, "/api/register/payments", () => { paymentRegisterLines.value = ""; }); }); summaryRegisterForm.addEventListener("submit", (event) => { previewRegister(event, "course_summary", linesFromTextarea(summaryRegisterText), summaryRegisterStatus); }); summaryRegisterConfirm.addEventListener("click", () => { confirmRegister("course_summary", summaryRegisterStatus, "/api/register/course-summaries", () => { summaryRegisterText.value = ""; }); }); refreshBtn.addEventListener("click", () => { loadAdminHealth(); if (!panels.accounts.hidden) loadAccounts(); if (!panels.reviews.hidden) loadReviews(); if (!panels.summaries.hidden) loadSummaryReviews(); if (!panels.summarySearch.hidden) loadSummarySearch(); if (!panels.logs.hidden) loadOperationLogs(); }); loadAdminHealth(); addSummaryRegisterItem(); loadAccounts();