Files
xsk-education-management/app/app/static/admin.js
T
2026-06-20 19:28:26 +08:00

1932 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const adminHealthText = document.querySelector("#adminHealthText");
const refreshBtn = document.querySelector("#refreshBtn");
const panels = {
accounts: document.querySelector("#accountsPanel"),
teachers: document.querySelector("#teachersPanel"),
reviews: document.querySelector("#reviewsPanel"),
summaries: document.querySelector("#summariesPanel"),
summarySearch: document.querySelector("#summarySearchPanel"),
logs: document.querySelector("#logsPanel"),
register: document.querySelector("#registerPanel"),
};
const accountForm = document.querySelector("#accountForm");
const accountQuery = document.querySelector("#accountQuery");
const accountStatus = document.querySelector("#accountStatus");
const accountMeta = document.querySelector("#accountMeta");
const accountRows = document.querySelector("#accountRows");
const accountEditor = document.querySelector("#accountEditor");
const accountEditorTitle = document.querySelector("#accountEditorTitle");
const accountEditForm = document.querySelector("#accountEditForm");
const accountEditError = document.querySelector("#accountEditError");
const cancelAccountEditBtn = document.querySelector("#cancelAccountEditBtn");
const newAccountBtn = document.querySelector("#newAccountBtn");
const teacherForm = document.querySelector("#teacherForm");
const teacherQuery = document.querySelector("#teacherQuery");
const teacherStatus = document.querySelector("#teacherStatus");
const teacherMeta = document.querySelector("#teacherMeta");
const teacherRows = document.querySelector("#teacherRows");
const teacherEditor = document.querySelector("#teacherEditor");
const teacherEditorTitle = document.querySelector("#teacherEditorTitle");
const teacherEditForm = document.querySelector("#teacherEditForm");
const teacherEditError = document.querySelector("#teacherEditError");
const cancelTeacherEditBtn = document.querySelector("#cancelTeacherEditBtn");
const newTeacherBtn = document.querySelector("#newTeacherBtn");
const editTeacherId = document.querySelector("#editTeacherId");
const editTeacherName = document.querySelector("#editTeacherName");
const editTeacherAlias = document.querySelector("#editTeacherAlias");
const editTeacherStatus = document.querySelector("#editTeacherStatus");
const editTeacherSubjects = document.querySelector("#editTeacherSubjects");
const editTeacherNote = document.querySelector("#editTeacherNote");
const editStudentId = document.querySelector("#editStudentId");
const editStudent = document.querySelector("#editStudent");
const editRemaining = document.querySelector("#editRemaining");
const editStatus = document.querySelector("#editStatus");
const editPayments = document.querySelector("#editPayments");
const editNote = document.querySelector("#editNote");
const reviewStatus = document.querySelector("#reviewStatus");
const reviewMeta = document.querySelector("#reviewMeta");
const reviewRows = document.querySelector("#reviewRows");
const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
const summaryReviewRows = document.querySelector("#summaryReviewRows");
const duplicateSummaryScanBtns = Array.from(document.querySelectorAll(".duplicate-summary-scan"));
const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop");
const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose");
const summaryReviewDrawerTitle = document.querySelector("#summaryReviewDrawerTitle");
const summaryReviewDrawerMeta = document.querySelector("#summaryReviewDrawerMeta");
const summaryReviewDrawerStatus = document.querySelector("#summaryReviewDrawerStatus");
const summaryReviewDrawerCourse = document.querySelector("#summaryReviewDrawerCourse");
const summaryReviewEditSection = document.querySelector("#summaryReviewEditSection");
const summaryReviewEditStudent = document.querySelector("#summaryReviewEditStudent");
const summaryReviewEditDate = document.querySelector("#summaryReviewEditDate");
const summaryReviewEditTime = document.querySelector("#summaryReviewEditTime");
const summaryReviewEditTeacher = document.querySelector("#summaryReviewEditTeacher");
const summaryReviewEditSubject = document.querySelector("#summaryReviewEditSubject");
const summaryReviewEditHint = document.querySelector("#summaryReviewEditHint");
const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine");
const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons");
const summaryReviewConflictSection = document.querySelector("#summaryReviewConflictSection");
const summaryReviewDrawerConflicts = document.querySelector("#summaryReviewDrawerConflicts");
const summaryReviewDrawerBody = document.querySelector("#summaryReviewDrawerBody");
const summaryReviewDrawerSource = document.querySelector("#summaryReviewDrawerSource");
const summaryReviewDrawerSave = document.querySelector("#summaryReviewDrawerSave");
const summaryReviewDrawerLink = document.querySelector("#summaryReviewDrawerLink");
const summaryReviewDrawerApprove = document.querySelector("#summaryReviewDrawerApprove");
const summaryReviewDrawerReject = document.querySelector("#summaryReviewDrawerReject");
const summarySearchForm = document.querySelector("#summarySearchForm");
const summarySearchQuery = document.querySelector("#summarySearchQuery");
const summarySearchStudent = document.querySelector("#summarySearchStudent");
const summarySearchTeacher = document.querySelector("#summarySearchTeacher");
const summarySearchSubject = document.querySelector("#summarySearchSubject");
const summarySearchDateFrom = document.querySelector("#summarySearchDateFrom");
const summarySearchDateTo = document.querySelector("#summarySearchDateTo");
const summarySearchBindingStatus = document.querySelector("#summarySearchBindingStatus");
const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate");
const summarySearchMeta = document.querySelector("#summarySearchMeta");
const summarySearchRows = document.querySelector("#summarySearchRows");
const logOperation = document.querySelector("#logOperation");
const logStatus = document.querySelector("#logStatus");
const logFilterForm = document.querySelector("#logFilterForm");
const logStudent = document.querySelector("#logStudent");
const logMeta = document.querySelector("#logMeta");
const logRows = document.querySelector("#logRows");
const classRegisterForm = document.querySelector("#classRegisterForm");
const classRegisterLines = document.querySelector("#classRegisterLines");
const classRegisterPreview = document.querySelector("#classRegisterPreview");
const classRegisterStatus = document.querySelector("#classRegisterStatus");
const classRegisterConfirm = document.querySelector("#classRegisterConfirm");
const paymentRegisterForm = document.querySelector("#paymentRegisterForm");
const paymentRegisterLines = document.querySelector("#paymentRegisterLines");
const paymentRegisterPreview = document.querySelector("#paymentRegisterPreview");
const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus");
const paymentRegisterConfirm = document.querySelector("#paymentRegisterConfirm");
const summaryRegisterForm = document.querySelector("#summaryRegisterForm");
const summaryRegisterText = document.querySelector("#summaryRegisterText");
const summaryRegisterPreview = document.querySelector("#summaryRegisterPreview");
const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus");
const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm");
let currentAccounts = [];
let editingAccountId = "";
let currentTeachers = [];
let editingTeacherId = "";
let currentSummaryReviews = [];
let activeSummaryReview = null;
let currentSummarySearchItems = [];
let expandedSummarySearchId = "";
let editingSummarySearchId = "";
let editingSummaryIdentityId = "";
let currentOperationLogs = [];
let expandedOperationLogId = "";
const registerPreviewState = {
class_record: null,
payment: null,
course_summary: null,
};
function fmtHours(value) {
const totalMinutes = Math.round(Number(value || 0) * 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}小时${minutes}`;
}
function displayHours(value, fallback) {
return fallback || fmtHours(value);
}
function fmtTime(seconds) {
if (!seconds) return "未知";
return new Date(seconds * 1000).toLocaleString("zh-CN", { hour12: false });
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function metric(label, value) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
}
function statusClass(status) {
if (status === "欠费") return "debt";
if (status === "预警") return "warning";
if (status === "正常") return "normal";
return "closed";
}
function taskStatusClass(status) {
if (["approved", "auto_registered", "已批准", "自动入账", "完成", "已更新", "已删除"].includes(status)) return "normal";
if (["rejected", "duplicate", "已驳回", "重复"].includes(status)) return "closed";
if (status === "conflict" || status === "冲突") return "debt";
return "warning";
}
function renderPayments(payments) {
if (!payments.length) return "暂无";
return payments.map((item) => `${escapeHtml(item.date)}${escapeHtml(displayHours(item.hours, item.duration))}`).join("<br>");
}
async function fetchJson(url, options = {}) {
const response = await fetch(url, { cache: "no-store", ...options });
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
const payload = await response.json();
detail = payload.detail || detail;
} catch (_error) {
detail = response.statusText || detail;
}
throw new Error(detail);
}
return response.json();
}
function setActiveTab(tabName) {
const activeTabName = tabName === "summaries" ? "summarySearch" : tabName;
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
button.classList.toggle("is-active", button.dataset.adminTab === activeTabName);
});
Object.entries(panels).forEach(([name, panel]) => {
panel.hidden = name !== activeTabName;
});
if (activeTabName === "accounts") loadAccounts();
if (activeTabName === "teachers") loadTeachers();
if (activeTabName === "reviews") loadReviews();
if (activeTabName === "summarySearch") {
loadSummarySearch();
loadSummaryReviews();
}
if (activeTabName === "logs") loadOperationLogs();
}
async function loadAdminHealth() {
try {
const data = await fetchJson("/api/account-health");
adminHealthText.textContent = [
`账户 ${data.accounts_count || 0}`,
`在岗老师 ${data.active_teachers_count || 0}`,
`课程记录 ${data.records_count || 0}`,
`小结 ${data.course_summaries_count || 0}`,
`本月已上 ${data.current_month_duration || fmtHours(data.current_month_hours || 0)}`,
].join("");
} catch (error) {
adminHealthText.textContent = `读取失败:${error.message}`;
}
}
function renderAccountSummary(summary, count) {
accountMeta.innerHTML = [
metric("当前结果", `${count}`),
metric("欠费", summary.debt),
metric("预警", summary.warning),
metric("正常", summary.normal),
].join("");
}
async function loadAccounts() {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
const params = new URLSearchParams();
if (accountQuery.value.trim()) params.set("q", accountQuery.value.trim());
if (accountStatus.value) params.set("status", accountStatus.value);
try {
const data = await fetchJson(`/api/accounts?${params.toString()}`);
currentAccounts = data.accounts;
renderAccountSummary(data.summary, data.count);
accountRows.innerHTML = data.accounts
.map(
(row) => `<tr>
<td>${escapeHtml(row.student)}<br><small>${escapeHtml(row.student_id)}</small></td>
<td><span class="status ${statusClass(row.account_status)}">${escapeHtml(row.account_status)}</span></td>
<td class="num">${escapeHtml(displayHours(row.remaining, row.remaining_duration))}</td>
<td>${renderPayments(row.payments)}</td>
<td class="note-cell">${escapeHtml(row.note || "")}</td>
<td><button class="small-button account-edit" type="button" data-student-id="${escapeHtml(row.student_id)}">编辑</button></td>
</tr>`,
)
.join("");
if (!data.accounts.length) {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的账户</td></tr>`;
}
} catch (error) {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
function paymentsToText(payments) {
return payments.map((item) => `${item.date}:${displayHours(item.hours, item.duration)}`).join("\n");
}
function parseHoursInput(value) {
const text = String(value || "").trim();
const match = text.match(/^(\d+)小时(?:(\d+)分)?$/);
if (match) return Number(match[1]) + Number(match[2] || 0) / 60;
const minuteMatch = text.match(/^(\d+)分$/);
if (minuteMatch) return Number(minuteMatch[1]) / 60;
return Number(text);
}
function parsePaymentsInput(value) {
return value
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const [date, hoursText] = item.split(":");
if (!date || hoursText === undefined) {
throw new Error(`缴费记录格式错误:${item}`);
}
const hours = parseHoursInput(hoursText);
if (!Number.isFinite(hours)) {
throw new Error(`缴费课时格式错误:${item}`);
}
return { date: date.trim(), hours };
});
}
function openNewAccountEditor() {
editingAccountId = "";
accountEditorTitle.textContent = "新增账户";
editStudentId.value = "";
editStudentId.placeholder = "保存时自动生成";
editStudent.value = "";
editRemaining.value = "0";
editStatus.value = "正常";
editPayments.value = "";
editNote.value = "";
accountEditError.hidden = true;
accountEditor.hidden = false;
editStudent.focus();
}
function openEditAccountEditor(studentId) {
const account = currentAccounts.find((item) => item.student_id === studentId);
if (!account) return;
editingAccountId = account.student_id;
accountEditorTitle.textContent = `编辑账户:${account.student}`;
editStudentId.value = account.student_id;
editStudentId.placeholder = "";
editStudent.value = account.student;
editRemaining.value = account.remaining;
editStatus.value = account.account_status;
editPayments.value = paymentsToText(account.payments);
editNote.value = account.note || "";
accountEditError.hidden = true;
accountEditor.hidden = false;
editStudent.focus();
}
function buildAccountPayload() {
const remaining = Number(editRemaining.value);
if (!Number.isFinite(remaining)) {
throw new Error("剩余课时必须是数字");
}
return {
student_id: editStudentId.value.trim(),
student: editStudent.value.trim(),
payments: parsePaymentsInput(editPayments.value),
remaining,
account_status: editStatus.value,
note: editNote.value.trim(),
};
}
async function saveAccount(event) {
event.preventDefault();
try {
const payload = buildAccountPayload();
const url = editingAccountId
? `/api/admin/accounts/${encodeURIComponent(editingAccountId)}`
: "/api/admin/accounts";
const method = editingAccountId ? "PUT" : "POST";
await fetchJson(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
accountEditor.hidden = true;
await loadAdminHealth();
await loadAccounts();
} catch (error) {
accountEditError.textContent = error.message;
accountEditError.hidden = false;
}
}
function renderTeacherSummary(rows) {
const activeCount = rows.filter((item) => item.status === "在岗").length;
const aliasCount = rows.filter((item) => String(item.alias || "").trim()).length;
teacherMeta.innerHTML = [
metric("当前结果", `${rows.length}`),
metric("在岗", activeCount),
metric("已配置别名", aliasCount),
].join("");
}
function teacherMatches(row) {
const keyword = teacherQuery.value.trim();
if (teacherStatus.value && row.status !== teacherStatus.value) return false;
if (!keyword) return true;
const haystack = [
row.teacher_id,
row.name,
row.alias,
row.display_name,
...(row.subjects || []),
row.status,
row.note,
].join("\n");
return haystack.includes(keyword);
}
async function loadTeachers() {
teacherRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
try {
const data = await fetchJson("/api/admin/teachers");
currentTeachers = data.teachers || [];
const rows = currentTeachers.filter(teacherMatches);
renderTeacherSummary(rows);
teacherRows.innerHTML = rows
.map(
(row) => `<tr>
<td>${escapeHtml(row.name)}<br><small>${escapeHtml(row.teacher_id)}</small></td>
<td>${escapeHtml(row.alias || "")}</td>
<td>${escapeHtml((row.subjects || []).join("、"))}</td>
<td><span class="status ${row.status === "在岗" ? "normal" : "closed"}">${escapeHtml(row.status)}</span></td>
<td class="note-cell">${escapeHtml(row.note || "")}</td>
<td><button class="small-button teacher-edit" type="button" data-teacher-id="${escapeHtml(row.teacher_id)}">编辑</button></td>
</tr>`,
)
.join("");
if (!rows.length) {
teacherRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的老师档案</td></tr>`;
}
} catch (error) {
teacherRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
function openNewTeacherEditor() {
editingTeacherId = "";
teacherEditorTitle.textContent = "新增老师";
editTeacherId.value = "";
editTeacherId.placeholder = "保存时自动生成";
editTeacherName.value = "";
editTeacherAlias.value = "";
editTeacherSubjects.value = "";
editTeacherStatus.value = "在岗";
editTeacherNote.value = "";
teacherEditError.hidden = true;
teacherEditor.hidden = false;
editTeacherName.focus();
}
function openEditTeacherEditor(teacherId) {
const teacher = currentTeachers.find((item) => item.teacher_id === teacherId);
if (!teacher) return;
editingTeacherId = teacher.teacher_id;
teacherEditorTitle.textContent = `编辑老师:${teacher.name}`;
editTeacherId.value = teacher.teacher_id;
editTeacherId.placeholder = "";
editTeacherName.value = teacher.name;
editTeacherAlias.value = teacher.alias || "";
editTeacherSubjects.value = (teacher.subjects || []).join("、");
editTeacherStatus.value = teacher.status;
editTeacherNote.value = teacher.note || "";
teacherEditError.hidden = true;
teacherEditor.hidden = false;
editTeacherName.focus();
}
function parseTeacherSubjects(value) {
return value
.split(/[、,\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function buildTeacherPayload() {
return {
teacher_id: editTeacherId.value.trim(),
name: editTeacherName.value.trim(),
alias: editTeacherAlias.value.trim(),
subjects: parseTeacherSubjects(editTeacherSubjects.value),
status: editTeacherStatus.value,
note: editTeacherNote.value.trim(),
};
}
async function saveTeacher(event) {
event.preventDefault();
try {
const payload = buildTeacherPayload();
const url = editingTeacherId
? `/api/admin/teachers/${encodeURIComponent(editingTeacherId)}`
: "/api/admin/teachers";
const method = editingTeacherId ? "PUT" : "POST";
await fetchJson(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
teacherEditor.hidden = true;
await loadTeachers();
} catch (error) {
teacherEditError.textContent = error.message;
teacherEditError.hidden = false;
}
}
function renderReviewLine(line) {
return `<code class="line-code">${escapeHtml(line)}</code>`;
}
function reviewTaskTypeLabel(item) {
if (item.type === "class_record_deletion") return "删除";
return "纠错";
}
function renderReviewTarget(item) {
if (item.type === "class_record_deletion") {
const restored = item.original && item.original.duration_hours ? `通过后恢复 ${fmtHours(item.original.duration_hours)}` : "通过后恢复课时";
return `<span class="status closed">删除</span><br><small>${escapeHtml(restored)}</small>`;
}
return renderReviewLine(item.corrected_line);
}
async function loadReviews() {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
try {
const [correctionData, deletionData] = await Promise.all([
fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_correction", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`),
fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_deletion", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`),
]);
const items = [...(correctionData.items || []), ...(deletionData.items || [])].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
reviewMeta.innerHTML = [metric("当前结果", `${items.length}`)].join("");
reviewRows.innerHTML = items
.map((item) => {
const canReview = item.status === "pending" || item.status === "conflict";
return `<tr>
<td>#${escapeHtml(item.id)}</td>
<td><span class="status ${item.status === "approved" ? "normal" : item.status === "rejected" ? "closed" : item.status === "conflict" ? "debt" : "warning"}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td>${escapeHtml(reviewTaskTypeLabel(item))}</td>
<td>${renderReviewLine(item.original_line)}</td>
<td>${renderReviewTarget(item)}</td>
<td>${escapeHtml(item.created_at || "")}</td>
<td class="record-action-cell">
<div class="record-actions">
<button class="small-button review-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>批准</button>
<button class="small-button review-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
</div>
</td>
</tr>`;
})
.join("");
if (!items.length) {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的审核项</td></tr>`;
}
} catch (error) {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
function summaryReviewKind(item) {
if (item.type === "course_summary_duplicate_review") return "重复小结";
if (hasDuplicateReviewContext(item)) return "重复待审";
const sourceId = String(item.source_id || "");
const reasons = Array.isArray(item.reasons) ? item.reasons.join(" ") : "";
if (sourceId.startsWith("history-missing:") || reasons.includes("历史 classnotes缺失")) {
return "历史缺失";
}
return "";
}
function renderReviewTag(label) {
if (!label) return "";
return `<span class="review-tag history">${escapeHtml(label)}</span>`;
}
function renderSummaryInfo(summary, item = {}) {
const parts = [
summary.student,
summary.date_iso,
summary.time_range || item.suggested_time_range,
summary.teacher,
summary.subject,
].filter(Boolean);
const suggestedTime = !summary.time_range && item.suggested_time_range
? `<br><small class="summary-suggested">原文时间,待保存</small>`
: "";
return `${renderReviewTag(summaryReviewKind(item))}${parts.map(escapeHtml).join("<br>")}${suggestedTime}<br><small>${escapeHtml(summary.group || "")}</small>`;
}
function renderSummaryPreview(summary) {
const body = String(summary.body || "");
const preview = body.length > 140 ? `${body.slice(0, 140)}...` : body;
return `<div class="summary-body summary-preview">${escapeHtml(preview || "暂无原文")}</div>`;
}
function isDuplicateSummaryReview(item) {
return item && item.type === "course_summary_duplicate_review";
}
function hasDuplicateReviewContext(item) {
return Boolean(
item
&& !isDuplicateSummaryReview(item)
&& (
item.duplicate_source_id
|| item.duplicate_semantic_key
|| (Array.isArray(item.duplicate_reasons) && item.duplicate_reasons.length)
|| (Array.isArray(item.duplicate_conflicts) && item.duplicate_conflicts.length)
)
);
}
function renderDuplicateCandidate(candidate, canReview, options = {}) {
const showDelete = options.showDelete !== false;
const 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 `<div class="record-summary-item duplicate-summary-candidate">
<div class="duplicate-summary-title-row">
<div class="record-summary-title">${escapeHtml(candidate.title || "课程小结")}</div>
${renderBindingBadge(candidate)}
</div>
<div class="drawer-text">${source.map(escapeHtml).join("<br>") || "暂无来源信息"}</div>
<div class="summary-body">${escapeHtml(candidate.body || candidate.body_preview || "暂无正文")}</div>
${showDelete ? `<button class="small-button duplicate-summary-delete" type="button" data-summary-id="${escapeHtml(candidate.id)}" ${canReview ? "" : "disabled"}>删除这一条</button>` : ""}
</div>`;
}
function renderDuplicateCandidates(item, canReview) {
const candidates = Array.isArray(item.duplicate_candidates) ? item.duplicate_candidates : [];
if (!candidates.length) return "<span class=\"muted\">暂无重复候选</span>";
return candidates.map((candidate) => renderDuplicateCandidate(candidate, canReview)).join("");
}
function renderSummaryDuplicateConflicts(item) {
const conflicts = Array.isArray(item.duplicate_conflicts) ? item.duplicate_conflicts : [];
const reasonLines = Array.isArray(item.duplicate_reasons) ? item.duplicate_reasons : [];
const source = item.duplicate_source;
const parts = [];
if (reasonLines.length) {
parts.push(`<div class="summary-conflict-reasons">${reasonLines.map(escapeHtml).join("<br>")}</div>`);
}
if (source) {
parts.push(`<div class="summary-conflict-source">
<div class="record-summary-title">本次提交</div>
<div class="drawer-text">${[
source.source_id ? `来源ID${source.source_id}` : "",
source.message_time ? `发送时间:${source.message_time}` : "",
source.sender ? `发送者:${source.sender}` : "",
].filter(Boolean).map(escapeHtml).join("<br>") || "暂无来源信息"}</div>
<div class="summary-body">${escapeHtml(source.body || source.body_preview || "暂无正文")}</div>
</div>`);
}
if (conflicts.length) {
parts.push(`<div class="summary-conflict-heading">命中的已有小结</div>`);
parts.push(conflicts.map((candidate) => renderDuplicateCandidate(candidate, false, { showDelete: false })).join(""));
} else {
parts.push("<span class=\"muted\">状态文件命中重复,但正式小结库未找到对应小结。</span>");
}
return parts.join("");
}
function findSummaryReview(taskId) {
return currentSummaryReviews.find((item) => String(item.id) === String(taskId));
}
function canReviewSummary(item) {
return item && (item.status === "pending" || item.status === "conflict");
}
function canLinkExistingSummary(item) {
const reasons = Array.isArray(item && item.reasons) ? item.reasons : [];
return Boolean(item && item.proposed_line && reasons.includes("classnotes 已存在同一条上课记录"));
}
function 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 `<div class="summary-review-reasons">${summaryReviewReasonLines(item)
.map((reason) => `<span>${escapeHtml(reason)}</span>`)
.join("")}</div>`;
}
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 = `<span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span>${item.message ? ` <small>${escapeHtml(item.message)}</small>` : ""}`;
summaryReviewDrawerCourse.innerHTML = courseInfoLines(summary, item).map(escapeHtml).join("<br>") || "暂无课程信息";
summaryReviewEditSection.hidden = duplicateReview;
if (!duplicateReview) fillSummaryReviewEditor(summary, item);
summaryReviewDrawerLine.innerHTML = duplicateReview
? "<span class=\"muted\">请选择下方重复小结删除</span>"
: item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">保存修正后生成</span>";
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("<br>") || "暂无来源信息";
summaryReviewDrawerSave.hidden = duplicateReview;
summaryReviewDrawerSave.disabled = !canReview || duplicateReview;
summaryReviewDrawerSave.textContent = !summary.time_range && item.suggested_time_range ? "保存时间修正" : "保存修正";
summaryReviewDrawerLink.hidden = duplicateReview || !canLinkExistingSummary(item);
summaryReviewDrawerLink.disabled = !canReview || duplicateReview || !canLinkExistingSummary(item);
summaryReviewDrawerApprove.hidden = duplicateReview || !item.proposed_line || canLinkExistingSummary(item);
summaryReviewDrawerApprove.disabled = !canApproveDirectly;
summaryReviewDrawerReject.disabled = !canReview;
summaryReviewDrawerBackdrop.hidden = false;
}
function closeSummaryReviewDrawer() {
activeSummaryReview = null;
summaryReviewEditSection.hidden = true;
summaryReviewDrawerSave.hidden = false;
summaryReviewDrawerSave.textContent = "保存修正";
summaryReviewDrawerLink.hidden = true;
summaryReviewDrawerApprove.hidden = false;
summaryReviewConflictSection.hidden = true;
summaryReviewDrawerConflicts.innerHTML = "";
summaryReviewEditHint.hidden = true;
summaryReviewEditHint.textContent = "";
summaryReviewEditTime.classList.remove("suggested-input");
summaryReviewDrawerBackdrop.hidden = true;
}
async function loadSummaryReviews() {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
const reviewParams = new URLSearchParams({ type: "course_summary_review" });
const duplicateParams = new URLSearchParams({ type: "course_summary_duplicate_review" });
if (summaryReviewStatus.value) {
reviewParams.set("status", summaryReviewStatus.value);
duplicateParams.set("status", summaryReviewStatus.value);
}
try {
const [reviewData, duplicateData] = await Promise.all([
fetchJson(`/api/admin/tasks?${reviewParams.toString()}`),
fetchJson(`/api/admin/tasks?${duplicateParams.toString()}`),
]);
const reviewItems = reviewData.items || [];
const duplicateItems = duplicateData.items || [];
currentSummaryReviews = [...reviewItems, ...duplicateItems].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
summaryReviewMeta.innerHTML = [
metric("待处理任务", `${currentSummaryReviews.length}`),
metric("课程小结审核", `${reviewItems.length}`),
metric("重复小结", `${duplicateItems.length}`),
].join("");
summaryReviewRows.innerHTML = currentSummaryReviews
.map((item) => {
const canReview = canReviewSummary(item);
const duplicateReview = isDuplicateSummaryReview(item);
const canApproveDirectly = canApproveSummaryDirectly(item);
const summary = item.summary || {};
const approveButton = canApproveDirectly
? `<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}">批准</button>`
: "";
return `<tr>
<td>#${escapeHtml(item.id)}</td>
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td>${renderSummaryInfo(summary, item)}</td>
<td>${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td>
<td>${renderSummaryPreview(summary)}</td>
<td>${renderSummaryReviewReasons(item)}</td>
<td class="record-action-cell">
<div class="record-actions">
<button class="small-button summary-detail" type="button" data-task-id="${escapeHtml(item.id)}">${escapeHtml(summaryReviewDetailLabel(item))}</button>
${approveButton}
<button class="small-button summary-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
</div>
</td>
</tr>`;
})
.join("");
if (!currentSummaryReviews.length) {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结任务</td></tr>`;
}
} catch (error) {
currentSummaryReviews = [];
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
function summarySearchParams() {
const params = new URLSearchParams({ limit: "200" });
if (summarySearchQuery.value.trim()) params.set("q", summarySearchQuery.value.trim());
if (summarySearchStudent.value.trim()) params.set("student", summarySearchStudent.value.trim());
if (summarySearchTeacher.value.trim()) params.set("teacher", summarySearchTeacher.value.trim());
if (summarySearchSubject.value.trim()) params.set("subject", summarySearchSubject.value.trim());
if (summarySearchDateFrom.value) params.set("date_from", summarySearchDateFrom.value);
if (summarySearchDateTo.value) params.set("date_to", summarySearchDateTo.value);
if (summarySearchBindingStatus.value) params.set("binding_status", summarySearchBindingStatus.value);
if (summarySearchHasCandidate.value) params.set("has_candidate", summarySearchHasCandidate.value);
return params;
}
function renderSummaryActions(item) {
const identityButton = `<button class="secondary-button summary-identity-edit" type="button" data-summary-id="${escapeHtml(item.id)}">修改绑定字段</button>`;
const editButton = `<button class="secondary-button summary-body-edit" type="button" data-summary-id="${escapeHtml(item.id)}">修改课程小结</button>`;
const deleteButton = `<button class="secondary-button summary-delete" type="button" data-summary-id="${escapeHtml(item.id)}">删除课程小结</button>`;
if (item.time_range) {
return `<div class="summary-time-actions"><span class="summary-action-note">时间已完整</span>${identityButton}${editButton}${deleteButton}</div>`;
}
const inputId = `summary-time-${item.id}`;
return `<div class="summary-time-actions">
<input id="${escapeHtml(inputId)}" class="summary-time-input" data-summary-time-input="${escapeHtml(item.id)}" autocomplete="off" placeholder="08:00-10:00" />
<button class="secondary-button summary-time-save" type="button" data-summary-id="${escapeHtml(item.id)}">补齐时间</button>
${identityButton}
${editButton}
${deleteButton}
</div>`;
}
function renderSummaryIdentityEditor(item) {
if (String(item.id) !== editingSummaryIdentityId) return "";
return `<div class="summary-identity-edit-panel">
<div class="summary-identity-grid">
<label>
<span>学生</span>
<input data-summary-identity-student="${escapeHtml(item.id)}" autocomplete="off" value="${escapeHtml(item.student || "")}" />
</label>
<label>
<span>老师</span>
<input data-summary-identity-teacher="${escapeHtml(item.id)}" autocomplete="off" value="${escapeHtml(item.teacher || "")}" />
</label>
<label>
<span>科目</span>
<input data-summary-identity-subject="${escapeHtml(item.id)}" autocomplete="off" value="${escapeHtml(item.subject || "")}" />
</label>
</div>
<div class="form-hint">只修改课程小结的学生、老师和科目,不同步修改上课记录和课时账户;老师必须已在教师档案中维护。</div>
<div class="summary-edit-actions">
<button class="secondary-button summary-identity-save" type="button" data-summary-id="${escapeHtml(item.id)}">保存绑定字段</button>
<button class="secondary-button summary-identity-cancel" type="button" data-summary-id="${escapeHtml(item.id)}">取消</button>
</div>
</div>`;
}
function renderSummaryBodyEditor(item) {
const body = item.body || item.body_preview || "";
if (String(item.id) !== editingSummarySearchId) {
return `<div class="summary-body summary-search-full">${escapeHtml(body || "暂无正文")}</div>`;
}
return `<div class="summary-body-edit-panel">
<textarea class="summary-body-editor" data-summary-body-editor="${escapeHtml(item.id)}" rows="10">${escapeHtml(body)}</textarea>
<div class="summary-edit-actions">
<button class="secondary-button summary-body-save" type="button" data-summary-id="${escapeHtml(item.id)}">保存</button>
<button class="secondary-button summary-body-cancel" type="button" data-summary-id="${escapeHtml(item.id)}">取消</button>
</div>
</div>`;
}
function renderMatchedFields(item) {
const fields = Array.isArray(item.matched_fields) ? item.matched_fields : [];
return fields.length ? `<br><small>命中:${fields.map(escapeHtml).join("、")}</small>` : "";
}
function 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 `<span class="status ${bindingStatusClass(binding.status)} summary-binding-badge">${escapeHtml(label)}</span>`;
}
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 `<div class="summary-binding-status">
${renderBindingBadge(item)}
${suffix ? `<small>${escapeHtml(suffix)}</small>` : ""}
</div>`;
}
function summarizeSummaryBindings(items) {
return items.reduce(
(summary, item) => {
const binding = summaryBinding(item);
if (binding.status === "matched") summary.matched += 1;
if (binding.status !== "matched") summary.pending += 1;
if (Array.isArray(binding.candidates) && binding.candidates.length) summary.withCandidate += 1;
return summary;
},
{ matched: 0, pending: 0, withCandidate: 0 },
);
}
function renderSummaryBindingDetail(item) {
const binding = summaryBinding(item);
if (binding.status === "matched" && binding.record) {
return `<div class="summary-binding-panel matched">
<div class="summary-binding-title">已绑定上课记录</div>
<div class="summary-binding-record">${renderBindingRecordLine(binding.record)}</div>
</div>`;
}
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 `<div class="summary-binding-candidate">
<div>
<div class="summary-binding-record">${renderBindingRecordLine(record)}</div>
<small>${differences}</small>
</div>
<button class="secondary-button summary-apply-candidate" type="button"
data-summary-id="${escapeHtml(item.id)}"
data-student="${escapeHtml(record.student || "")}"
data-teacher="${escapeHtml(record.teacher || "")}"
data-time="${escapeHtml(record.time || "")}"
data-subject="${escapeHtml(record.subject || "")}"
data-differences="${escapeHtml((record.differences || []).join(","))}">按此记录修正</button>
</div>`;
})
.join("");
return `<div class="summary-binding-panel">
<div class="summary-binding-title">${escapeHtml(binding.reason || "未绑定到上课记录")}</div>
${candidateRows || '<div class="form-hint">没有找到可参考的上课记录。</div>'}
</div>`;
}
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 = `<tr class="summary-search-result-row${unmatchedClass}" data-summary-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}">
<td>${escapeHtml(item.date_iso || "未识别")}</td>
<td>${escapeHtml(item.time_range || "缺少时间")}</td>
<td>${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
<td>${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td>
<td>${renderSummaryBindingStatus(item)}</td>
</tr>`;
const detailRow = expanded
? `<tr class="summary-search-detail-row" data-summary-detail="${escapeHtml(item.id)}">
<td colspan="5">
<div class="summary-search-detail-panel${unmatchedClass}">
<div class="summary-search-detail-head">
<div>
<div class="record-summary-title">${escapeHtml(item.title || "课程小结正文")}</div>
${renderMatchedFields(item)}
</div>
</div>
${renderSummaryBindingDetail(item)}
${renderSummaryIdentityEditor(item)}
${renderSummaryBodyEditor(item)}
<div class="summary-search-actions">${renderSummaryActions(item)}</div>
</div>
</td>
</tr>`
: "";
return `${mainRow}${detailRow}`;
})
.join("");
}
function renderCurrentSummarySearch() {
summarySearchRows.innerHTML = renderSummarySearchRows(currentSummarySearchItems);
if (!currentSummarySearchItems.length) {
summarySearchRows.innerHTML = `<tr><td colspan="5" class="empty">没有符合条件的课程小结</td></tr>`;
}
}
async function loadSummarySearch() {
summarySearchRows.innerHTML = `<tr><td colspan="5" class="empty">正在读取</td></tr>`;
try {
const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`);
currentSummarySearchItems = data.items || [];
if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = "";
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = "";
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummaryIdentityId)) editingSummaryIdentityId = "";
const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems);
summarySearchMeta.innerHTML = [
metric("命中小结", `${data.count}`),
metric("当前显示", `${data.returned}`),
metric("已绑定", `${bindingSummary.matched}`),
metric("待处理", `${bindingSummary.pending}`),
metric("有候选", `${bindingSummary.withCandidate}`),
].join("");
renderCurrentSummarySearch();
} catch (error) {
currentSummarySearchItems = [];
summarySearchRows.innerHTML = `<tr><td colspan="5" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
async function refreshSummaryWorkspace({ logs = true } = {}) {
await Promise.all([loadSummarySearch(), loadSummaryReviews()]);
if (logs) await loadOperationLogs();
}
async function updateCourseSummaryTime(summaryId, timeRange) {
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/time`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ time_range: timeRange }),
});
await loadSummarySearch();
}
async function deleteCourseSummary(summaryId) {
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}`, {
method: "DELETE",
});
await loadSummarySearch();
}
async function updateCourseSummaryBody(summaryId, body) {
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/body`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body }),
});
editingSummarySearchId = "";
expandedSummarySearchId = "";
await loadSummarySearch();
await loadOperationLogs();
}
async function updateCourseSummaryIdentity(summaryId, payload) {
const result = await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/identity`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
editingSummaryIdentityId = "";
editingSummarySearchId = "";
expandedSummarySearchId = result.new_id || "";
await loadSummarySearch();
await loadOperationLogs();
}
function findCurrentSummary(summaryId) {
return currentSummarySearchItems.find((item) => String(item.id) === String(summaryId));
}
async function applySummaryBindingCandidate(summaryId, record) {
const item = findCurrentSummary(summaryId);
if (!item) throw new Error("未找到当前课程小结");
let activeSummaryId = summaryId;
const targetStudent = String(record.student || "").trim();
const targetTeacher = String(record.teacher || "").trim();
const targetSubject = String(record.subject || "").trim();
const targetTime = String(record.time || "").trim();
const needsIdentity = targetStudent && targetTeacher && targetSubject && (
targetStudent !== String(item.student || "")
|| targetTeacher !== String(item.teacher || "")
|| targetSubject !== String(item.subject || "")
);
if (needsIdentity) {
const result = await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(activeSummaryId)}/identity`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ student: targetStudent, teacher: targetTeacher, subject: targetSubject }),
});
activeSummaryId = result.new_id || activeSummaryId;
}
if (targetTime && targetTime !== String(item.time_range || "")) {
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(activeSummaryId)}/time`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ time_range: targetTime }),
});
}
editingSummaryIdentityId = "";
editingSummarySearchId = "";
expandedSummarySearchId = activeSummaryId;
await loadSummarySearch();
await loadOperationLogs();
}
async function saveSummaryReviewEdits() {
if (!activeSummaryReview) return;
try {
const result = await fetchJson(`/api/admin/tasks/${encodeURIComponent(activeSummaryReview.id)}/course-summary-review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(summaryReviewEditorPayload()),
});
activeSummaryReview = result.task;
await refreshSummaryWorkspace();
openSummaryReviewDrawer(activeSummaryReview.id);
} catch (error) {
alert(error.message);
}
}
async function linkExistingSummaryReview() {
if (!activeSummaryReview) return;
if (!confirm("确认关联已有上课记录?系统不会重复扣课时。")) return;
try {
await fetchJson(`/api/admin/tasks/${encodeURIComponent(activeSummaryReview.id)}/link-existing-course-summary`, {
method: "POST",
});
closeSummaryReviewDrawer();
await refreshSummaryWorkspace();
} catch (error) {
alert(error.message);
}
}
async function resolveDuplicateSummary(taskId, summaryId) {
if (!confirm("确认删除这条重复课程小结?系统会先备份来源文件。")) return;
await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/resolve-duplicate-summary`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete_summary_id: summaryId }),
});
closeSummaryReviewDrawer();
await refreshSummaryWorkspace();
}
async function scanDuplicateSummaries() {
duplicateSummaryScanBtns.forEach((button) => {
button.disabled = true;
});
try {
const result = await fetchJson("/api/admin/course-summaries/duplicate-scan", { method: "POST" });
alert(`扫描完成:发现 ${result.scanned || 0} 组重复,新增 ${result.created || 0} 条审核任务`);
summaryReviewStatus.value = "pending";
setActiveTab("summarySearch");
await loadOperationLogs();
} catch (error) {
alert(error.message);
} finally {
duplicateSummaryScanBtns.forEach((button) => {
button.disabled = false;
});
}
}
function logField(label, value) {
if (value === undefined || value === null || value === "") return "";
return `<div class="log-detail-field"><span>${escapeHtml(label)}</span><code>${escapeHtml(value)}</code></div>`;
}
function logArrayField(label, values) {
if (!Array.isArray(values) || !values.length) return "";
return logField(label, values.join(""));
}
function renderLogDetailGroup(title, fields) {
const body = fields.filter(Boolean).join("");
if (!body) return "";
return `<section class="log-detail-group">
<h3>${escapeHtml(title)}</h3>
<div class="log-detail-grid">${body}</div>
</section>`;
}
function renderLogAssociation(item) {
const parts = [];
if (item.batch_id) parts.push(`批次 ${item.batch_id}`);
if (item.task_id) parts.push(`任务 #${item.task_id}`);
if (!parts.length && item.backup_id) parts.push(`备份 ${item.backup_id}`);
if (!parts.length && item.source_id) parts.push(String(item.source_id));
if (!parts.length && item.record_id) parts.push(`记录 ${item.record_id}`);
return parts.slice(0, 2).map(escapeHtml).join("<br>") || '<span class="muted">-</span>';
}
function renderOperationLogRows(items) {
return items
.map((item) => {
const expanded = String(item.id) === expandedOperationLogId;
const mainRow = `<tr class="log-row" data-log-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}">
<td>${escapeHtml(item.created_at || "")}</td>
<td>${escapeHtml(item.operation || "")}</td>
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span></td>
<td>${escapeHtml(item.student || "")}</td>
<td>${renderLogAssociation(item)}</td>
<td class="record-action-cell">${renderLogActions(item)}</td>
</tr>`;
const detailRow = expanded
? `<tr class="log-detail-row" data-log-detail="${escapeHtml(item.id)}">
<td colspan="6">${renderLogDetail(item)}</td>
</tr>`
: "";
return `${mainRow}${detailRow}`;
})
.join("");
}
function renderCurrentOperationLogs() {
logRows.innerHTML = renderOperationLogRows(currentOperationLogs);
if (!currentOperationLogs.length) {
logRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的操作记录</td></tr>`;
}
}
function renderLogDetail(item) {
const sections = [
renderLogDetailGroup("基本信息", [
logField("记录ID", item.id),
logField("操作时间", item.created_at),
logField("操作", item.operation),
logField("状态", item.status),
logField("学生", item.student),
logField("学生ID", item.student_id),
]),
renderLogDetailGroup("关联对象", [
logField("任务类型", item.task_type),
logField("任务号", item.task_id ? `#${item.task_id}` : ""),
logField("来源ID", item.source_id),
logField("记录ID", item.record_id),
logField("课程小结ID", item.summary_id),
logField("新课程小结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 `<div class="log-detail-panel">${sections.join("") || '<span class="muted">暂无详情</span>'}</div>`;
}
function renderLogActions(item) {
if (item.can_rollback) {
return `<button class="small-button log-rollback" type="button" data-log-id="${escapeHtml(item.id)}">撤回</button>`;
}
if (item.backup_id && item.rollback_block_reason) {
return `<small class="muted">${escapeHtml(item.rollback_block_reason)}</small>`;
}
return `<span class="muted">-</span>`;
}
async function loadOperationLogs() {
logRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
const params = new URLSearchParams({ limit: "200" });
if (logOperation.value) params.set("operation", logOperation.value);
if (logStatus.value) params.set("status", logStatus.value);
if (logStudent.value.trim()) params.set("student", logStudent.value.trim());
try {
const data = await fetchJson(`/api/admin/operation-logs?${params.toString()}`);
currentOperationLogs = data.items || [];
if (!currentOperationLogs.some((item) => String(item.id) === expandedOperationLogId)) expandedOperationLogId = "";
logMeta.innerHTML = [metric("当前结果", `${data.count}`)].join("");
renderCurrentOperationLogs();
} catch (error) {
currentOperationLogs = [];
logRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
async function rollbackOperationLog(logId) {
if (!confirm("确认撤回这条操作记录?系统只会在没有后续相关改动时恢复备份。")) return;
try {
await fetchJson(`/api/admin/operation-logs/${encodeURIComponent(logId)}/rollback`, { method: "POST" });
await loadOperationLogs();
await loadAdminHealth();
await loadAccounts();
} catch (error) {
alert(error.message);
}
}
async function runTaskAction(taskId, action, reload) {
try {
await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/${action}`, { method: "POST" });
} catch (error) {
alert(error.message);
}
reload();
}
async function reviewTask(taskId, action) {
runTaskAction(taskId, action, loadReviews);
}
async function summaryReviewTask(taskId, action) {
await runTaskAction(taskId, action, () => 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) =>
`<li><span>${index + 1}</span><code>${escapeHtml(line)}</code></li>`
)
.join("");
return `<div class="register-preview-head"><strong>标准登记内容</strong><span>共 ${standardLines.length} 条</span></div><ol class="register-line-list">${rows}</ol>`;
}
return `<strong>标准登记内容</strong><pre>${escapeHtml(standardLines.join("\n\n"))}</pre>`;
}
function renderRegisterPreview(type, data, statusNode) {
const previewNode = {
class_record: classRegisterPreview,
payment: paymentRegisterPreview,
course_summary: summaryRegisterPreview,
}[type];
const confirmButton = {
class_record: classRegisterConfirm,
payment: paymentRegisterConfirm,
course_summary: summaryRegisterConfirm,
}[type];
previewNode.hidden = false;
if (data.status === "ready") {
const warning = data.warning ? `<p>${escapeHtml(data.warning)}</p>` : "";
const standardLines = data.standard_lines || [];
previewNode.innerHTML = `${warning}${renderStandardLinesPreview(type, standardLines)}`;
confirmButton.hidden = false;
registerPreviewState[type] = {
conversationId: data.conversation_id,
standardLines,
};
statusNode.textContent = "已通过脚本生成预览,请确认后写入";
return;
}
if (data.status === "needs_info") {
const questions = (data.questions || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("");
const fields = data.fields || {};
const labels = data.field_labels || {};
const missing = new Set(data.missing_fields || []);
const usesReadonlyDraft = type === "class_record" || type === "payment";
const draftItems = data.draft_items || [];
const draft = usesReadonlyDraft && draftItems.length
? renderReadonlyDraftItems(type, draftItems, labels)
: usesReadonlyDraft
? renderReadonlyRegisterDraft(type, fields, data.draft_line || "")
: renderRegisterDraft(type, fields, missing);
const fieldInputs = usesReadonlyDraft && draftItems.length
? ""
: renderFieldInputs(usesReadonlyDraft ? data.missing_fields || [] : Object.keys(labels), fields, labels, missing);
const questionBlock = questions ? `<ul>${questions}</ul>` : "";
const inputBlock = fieldInputs ? `<div class="register-field-grid">${fieldInputs}</div>` : "";
previewNode.innerHTML = `<strong>需要补充信息</strong>${questionBlock}${draft}${inputBlock}`;
confirmButton.hidden = true;
registerPreviewState[type] = {
conversationId: data.conversation_id,
standardLines: [],
};
statusNode.textContent = "请补充信息后再次生成预览";
return;
}
const questions = (data.questions || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("");
previewNode.innerHTML = `<strong>无法生成预览</strong>${questions ? `<ul>${questions}</ul>` : ""}`;
confirmButton.hidden = true;
registerPreviewState[type] = {
conversationId: data.conversation_id,
standardLines: [],
};
statusNode.textContent = `预览失败:${data.error || "请修改原文后重试"}`;
}
function waitingText(value) {
return value || "[待补充]";
}
function previewPart(value) {
const text = waitingText(value);
const className = text === "[待补充]" ? ` class="pending-field"` : "";
return `<span${className}>${escapeHtml(text)}</span>`;
}
function renderDraftLine(draftLine) {
return escapeHtml(waitingText(draftLine));
}
function renderReadonlyRegisterDraft(type, fields, draftLine = "") {
if (draftLine) {
return `<div class="register-draft readonly-register-draft"><pre>${renderDraftLine(draftLine)}</pre></div>`;
}
if (type === "class_record") {
const line = [
waitingText(fields.date),
"[待补充]",
waitingText(fields.time),
waitingText(fields.student),
waitingText(fields.duration),
waitingText(fields.teacher),
waitingText(fields.subject),
].join("-");
return `<div class="register-draft readonly-register-draft"><pre>${renderDraftLine(line)}</pre></div>`;
}
if (type === "payment") {
const line = `${waitingText(fields.student)}-${waitingText(fields.date)}:${waitingText(fields.hours)}`;
return `<div class="register-draft readonly-register-draft"><pre>${renderDraftLine(line)}</pre></div>`;
}
return `<div class="register-draft readonly-register-draft"><pre>${renderDraftLine(draftLine)}</pre></div>`;
}
function renderFieldInputs(fieldNames, fields, labels, missing, prefix = "") {
return fieldNames
.map((name) => {
const label = labels[name] || name;
const value = fields[name] || "";
const requiredMark = missing.has(name) ? " *" : "";
const fieldName = `${prefix}${name}`;
const input =
name === "body"
? `<textarea data-ai-field="${escapeHtml(fieldName)}" rows="4">${escapeHtml(value)}</textarea>`
: `<input data-ai-field="${escapeHtml(fieldName)}" value="${escapeHtml(value)}">`;
return `<label><span>${escapeHtml(label)}${requiredMark}</span>${input}</label>`;
})
.join("");
}
function renderReadonlyDraftItems(type, items, labels) {
const rows = items
.map((item, index) => {
const itemIndex = Number.isInteger(item.index) ? item.index : index;
const fields = item.fields || {};
const missingFields = item.missing_fields || [];
const missing = new Set(missingFields);
const inputs = renderFieldInputs(missingFields, fields, labels, missing, `items.${itemIndex}.`);
const inputBlock = inputs ? `<div class="register-field-grid">${inputs}</div>` : "";
const error = item.error ? `<small class="muted">第 ${itemIndex + 1} 条:${escapeHtml(item.error)}</small>` : "";
return `<div class="register-draft-item">${renderReadonlyRegisterDraft(type, fields, item.draft_line || "")}${inputBlock}${error}</div>`;
})
.join("");
return `<div class="register-draft-list">${rows}</div>`;
}
function inlineDraftPart(name, fields, missing, fallback) {
const value = fields[name] || "";
if (!missing.has(name) && value) return `<span>${escapeHtml(value)}</span>`;
return `<input data-ai-field="${escapeHtml(name)}" value="${escapeHtml(value)}" placeholder="${escapeHtml(fallback)}">`;
}
function renderRegisterDraft(type, fields, missing) {
if (type === "class_record") {
const dateText = inlineDraftPart("date", fields, missing, "日期");
const timeText = inlineDraftPart("time", fields, missing, "时间");
const studentText = inlineDraftPart("student", fields, missing, "学生");
const durationText = inlineDraftPart("duration", fields, missing, "时长");
const teacherText = inlineDraftPart("teacher", fields, missing, "老师");
const subjectText = inlineDraftPart("subject", fields, missing, "科目");
return `<div class="register-draft"><span>${dateText}</span><span>星期?</span><span>${timeText}</span><span>${studentText}</span><span>${durationText}</span><span>${teacherText}</span><span>${subjectText}</span></div>`;
}
if (type === "payment") {
const studentText = inlineDraftPart("student", fields, missing, "学生");
const dateText = inlineDraftPart("date", fields, missing, "日期");
const hoursText = inlineDraftPart("hours", fields, missing, "课时数");
return `<div class="register-draft payment-draft"><span>${studentText}</span><b>-</b><span>${dateText}</span><b>:</b><span>${hoursText}</span></div>`;
}
const studentText = inlineDraftPart("student", fields, missing, "学生");
const dateText = inlineDraftPart("date", fields, missing, "日期");
const timeText = inlineDraftPart("time", fields, missing, "时间");
const teacherText = inlineDraftPart("teacher", fields, missing, "老师");
const subjectText = inlineDraftPart("subject", fields, missing, "科目");
const bodyValue = fields.body || "";
const bodyInput = missing.has("body")
? `<textarea data-ai-field="body" rows="4" placeholder="小结正文">${escapeHtml(bodyValue)}</textarea>`
: `<pre>${escapeHtml(bodyValue)}</pre>`;
return `<div class="register-draft summary-draft"><span>学生:${studentText}</span><span>日期:${dateText}</span><span>时间:${timeText}</span><span>老师:${teacherText}</span><span>科目:${subjectText}</span>${bodyInput}</div>`;
}
async function previewRegister(event, type, lines, statusNode) {
event.preventDefault();
if (!lines.length) {
clearRegisterPreview(type);
statusNode.textContent = "请先填写登记内容";
return;
}
const previewNode = {
class_record: classRegisterPreview,
payment: paymentRegisterPreview,
course_summary: summaryRegisterPreview,
}[type];
const answerNode = previewNode ? previewNode.querySelector("[data-ai-answer]") : null;
const fieldNodes = previewNode ? previewNode.querySelectorAll("[data-ai-field]") : [];
const state = registerPreviewState[type] || {};
const answers = {};
if (answerNode && answerNode.value.trim()) answers.followup = answerNode.value.trim();
fieldNodes.forEach((node) => {
if (node.value.trim()) answers[node.dataset.aiField] = node.value.trim();
});
clearRegisterPreview(type, !state.conversationId);
statusNode.textContent = "正在生成预览";
try {
const data = await fetchJson("/api/ai/register/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type,
lines,
answers,
conversation_id: state.conversationId || null,
}),
});
renderRegisterPreview(type, data, statusNode);
} catch (error) {
statusNode.textContent = `预览失败:${error.message}`;
}
}
async function confirmRegister(type, statusNode, url, onSuccess) {
const state = registerPreviewState[type];
if (!state || !state.standardLines || !state.standardLines.length) {
statusNode.textContent = "请先生成可确认的预览";
return;
}
statusNode.textContent = "正在写入";
try {
const data = await fetchJson(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lines: state.standardLines }),
});
if (type === "course_summary") {
statusNode.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;待审核 ${data.review_pending} 条;重复 ${data.duplicates} 条;舍弃 ${data.rejected}`;
} else {
statusNode.textContent = `已登记 ${data.registered} 条;备份 ${data.backup_id}`;
}
clearRegisterPreview(type);
onSuccess();
await loadAdminHealth();
await loadAccounts();
} catch (error) {
statusNode.textContent = `写入失败:${error.message}`;
}
}
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
button.addEventListener("click", () => setActiveTab(button.dataset.adminTab));
});
accountForm.addEventListener("submit", (event) => {
event.preventDefault();
loadAccounts();
});
accountStatus.addEventListener("change", loadAccounts);
newAccountBtn.addEventListener("click", openNewAccountEditor);
cancelAccountEditBtn.addEventListener("click", () => {
accountEditor.hidden = true;
});
accountEditForm.addEventListener("submit", saveAccount);
accountRows.addEventListener("click", (event) => {
const button = event.target.closest(".account-edit");
if (!button) return;
openEditAccountEditor(button.dataset.studentId);
});
teacherForm.addEventListener("submit", (event) => {
event.preventDefault();
loadTeachers();
});
teacherStatus.addEventListener("change", loadTeachers);
newTeacherBtn.addEventListener("click", openNewTeacherEditor);
cancelTeacherEditBtn.addEventListener("click", () => {
teacherEditor.hidden = true;
});
teacherEditForm.addEventListener("submit", saveTeacher);
teacherRows.addEventListener("click", (event) => {
const button = event.target.closest(".teacher-edit");
if (!button) return;
openEditTeacherEditor(button.dataset.teacherId);
});
reviewStatus.addEventListener("change", loadReviews);
reviewRows.addEventListener("click", (event) => {
const approve = event.target.closest(".review-approve");
const reject = event.target.closest(".review-reject");
if (approve) reviewTask(approve.dataset.taskId, "approve");
if (reject) reviewTask(reject.dataset.taskId, "reject");
});
summaryReviewStatus.addEventListener("change", loadSummaryReviews);
duplicateSummaryScanBtns.forEach((button) => {
button.addEventListener("click", scanDuplicateSummaries);
});
summaryReviewRows.addEventListener("click", (event) => {
const detail = event.target.closest(".summary-detail");
const approve = event.target.closest(".summary-approve");
const reject = event.target.closest(".summary-reject");
if (detail) {
openSummaryReviewDrawer(detail.dataset.taskId);
return;
}
if (approve) {
const item = findSummaryReview(approve.dataset.taskId);
if (isDuplicateSummaryReview(item)) {
openSummaryReviewDrawer(approve.dataset.taskId);
return;
}
summaryReviewTask(approve.dataset.taskId, "approve");
return;
}
if (reject) summaryReviewTask(reject.dataset.taskId, "reject");
});
summaryReviewDrawerClose.addEventListener("click", closeSummaryReviewDrawer);
summaryReviewDrawerBackdrop.addEventListener("click", (event) => {
if (event.target === summaryReviewDrawerBackdrop) closeSummaryReviewDrawer();
});
summaryReviewDrawerSave.addEventListener("click", saveSummaryReviewEdits);
summaryReviewDrawerLink.addEventListener("click", linkExistingSummaryReview);
summaryReviewDrawerApprove.addEventListener("click", () => {
if (activeSummaryReview) summaryReviewTask(activeSummaryReview.id, "approve");
});
summaryReviewDrawerReject.addEventListener("click", () => {
if (activeSummaryReview) summaryReviewTask(activeSummaryReview.id, "reject");
});
summaryReviewDrawerBody.addEventListener("click", (event) => {
const button = event.target.closest(".duplicate-summary-delete");
if (button && activeSummaryReview) {
resolveDuplicateSummary(activeSummaryReview.id, button.dataset.summaryId).catch((error) => alert(error.message));
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !summaryReviewDrawerBackdrop.hidden) closeSummaryReviewDrawer();
});
summarySearchForm.addEventListener("submit", (event) => {
event.preventDefault();
loadSummarySearch();
});
summarySearchBindingStatus.addEventListener("change", loadSummarySearch);
summarySearchHasCandidate.addEventListener("change", loadSummarySearch);
summarySearchRows.addEventListener("click", (event) => {
const saveTime = event.target.closest(".summary-time-save");
const deleteSummary = event.target.closest(".summary-delete");
const editIdentity = event.target.closest(".summary-identity-edit");
const saveIdentity = event.target.closest(".summary-identity-save");
const cancelIdentity = event.target.closest(".summary-identity-cancel");
const editSummary = event.target.closest(".summary-body-edit");
const saveSummaryBody = event.target.closest(".summary-body-save");
const cancelSummaryBody = event.target.closest(".summary-body-cancel");
const applyCandidate = event.target.closest(".summary-apply-candidate");
if (applyCandidate) {
if (!confirm("确认按候选上课记录修正课程小结的学生、老师、科目和时间?系统不会修改上课记录和课时账户。")) return;
applySummaryBindingCandidate(applyCandidate.dataset.summaryId, {
student: applyCandidate.dataset.student,
teacher: applyCandidate.dataset.teacher,
time: applyCandidate.dataset.time,
subject: applyCandidate.dataset.subject,
}).catch((error) => alert(error.message));
return;
}
if (editIdentity) {
editingSummaryIdentityId = editIdentity.dataset.summaryId;
editingSummarySearchId = "";
renderCurrentSummarySearch();
return;
}
if (cancelIdentity) {
editingSummaryIdentityId = "";
renderCurrentSummarySearch();
return;
}
if (saveIdentity) {
const studentInput = summarySearchRows.querySelector(`[data-summary-identity-student='${saveIdentity.dataset.summaryId}']`);
const teacherInput = summarySearchRows.querySelector(`[data-summary-identity-teacher='${saveIdentity.dataset.summaryId}']`);
const subjectInput = summarySearchRows.querySelector(`[data-summary-identity-subject='${saveIdentity.dataset.summaryId}']`);
const student = studentInput ? studentInput.value.trim() : "";
const teacher = teacherInput ? teacherInput.value.trim() : "";
const subject = subjectInput ? subjectInput.value.trim() : "";
if (!student || !teacher || !subject) {
alert("学生、老师和科目不能为空");
return;
}
updateCourseSummaryIdentity(saveIdentity.dataset.summaryId, { student, teacher, subject }).catch((error) => alert(error.message));
return;
}
if (editSummary) {
editingSummarySearchId = editSummary.dataset.summaryId;
editingSummaryIdentityId = "";
renderCurrentSummarySearch();
return;
}
if (cancelSummaryBody) {
editingSummarySearchId = "";
renderCurrentSummarySearch();
return;
}
if (saveSummaryBody) {
const textarea = summarySearchRows.querySelector(`[data-summary-body-editor='${saveSummaryBody.dataset.summaryId}']`);
const body = textarea ? textarea.value.trim() : "";
if (!body) {
alert("课程小结正文不能为空");
return;
}
updateCourseSummaryBody(saveSummaryBody.dataset.summaryId, body).catch((error) => alert(error.message));
return;
}
if (saveTime) {
const input = summarySearchRows.querySelector(`[data-summary-time-input='${saveTime.dataset.summaryId}']`);
const timeRange = input ? input.value.trim() : "";
if (!timeRange) {
alert("请输入时间段");
return;
}
updateCourseSummaryTime(saveTime.dataset.summaryId, timeRange).catch((error) => alert(error.message));
return;
}
if (deleteSummary) {
if (!confirm("确认删除这条课程小结?")) return;
deleteCourseSummary(deleteSummary.dataset.summaryId).catch((error) => alert(error.message));
return;
}
const row = event.target.closest(".summary-search-result-row");
if (!row) return;
expandedSummarySearchId = expandedSummarySearchId === row.dataset.summaryId ? "" : row.dataset.summaryId;
renderCurrentSummarySearch();
});
summarySearchRows.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const row = event.target.closest(".summary-search-result-row");
if (!row) return;
event.preventDefault();
expandedSummarySearchId = expandedSummarySearchId === row.dataset.summaryId ? "" : row.dataset.summaryId;
renderCurrentSummarySearch();
});
logOperation.addEventListener("change", loadOperationLogs);
logStatus.addEventListener("change", loadOperationLogs);
logFilterForm.addEventListener("submit", (event) => {
event.preventDefault();
loadOperationLogs();
});
logRows.addEventListener("click", (event) => {
const rollback = event.target.closest(".log-rollback");
if (rollback) {
rollbackOperationLog(rollback.dataset.logId);
return;
}
const row = event.target.closest(".log-row");
if (!row) return;
if (event.target.closest(".record-action-cell")) return;
expandedOperationLogId = expandedOperationLogId === row.dataset.logId ? "" : row.dataset.logId;
renderCurrentOperationLogs();
});
logRows.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const row = event.target.closest(".log-row");
if (!row) return;
if (event.target.closest(".log-rollback")) return;
event.preventDefault();
expandedOperationLogId = expandedOperationLogId === row.dataset.logId ? "" : row.dataset.logId;
renderCurrentOperationLogs();
});
classRegisterForm.addEventListener("submit", (event) => {
previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus);
});
paymentRegisterForm.addEventListener("submit", (event) => {
previewRegister(event, "payment", linesFromTextarea(paymentRegisterLines), paymentRegisterStatus);
});
classRegisterConfirm.addEventListener("click", () => {
confirmRegister("class_record", classRegisterStatus, "/api/register/class-records", () => {
classRegisterLines.value = "";
});
});
paymentRegisterConfirm.addEventListener("click", () => {
confirmRegister("payment", paymentRegisterStatus, "/api/register/payments", () => {
paymentRegisterLines.value = "";
});
});
summaryRegisterForm.addEventListener("submit", (event) => {
previewRegister(event, "course_summary", linesFromTextarea(summaryRegisterText), summaryRegisterStatus);
});
summaryRegisterConfirm.addEventListener("click", () => {
confirmRegister("course_summary", summaryRegisterStatus, "/api/register/course-summaries", () => {
summaryRegisterText.value = "";
});
});
refreshBtn.addEventListener("click", () => {
loadAdminHealth();
if (!panels.accounts.hidden) loadAccounts();
if (!panels.reviews.hidden) loadReviews();
if (!panels.summarySearch.hidden) refreshSummaryWorkspace({ logs: false });
if (!panels.logs.hidden) loadOperationLogs();
});
loadAdminHealth();
loadAccounts();