1932 lines
82 KiB
JavaScript
1932 lines
82 KiB
JavaScript
const adminHealthText = document.querySelector("#adminHealthText");
|
||
const refreshBtn = document.querySelector("#refreshBtn");
|
||
const panels = {
|
||
accounts: document.querySelector("#accountsPanel"),
|
||
teachers: document.querySelector("#teachersPanel"),
|
||
reviews: document.querySelector("#reviewsPanel"),
|
||
summaries: document.querySelector("#summariesPanel"),
|
||
summarySearch: document.querySelector("#summarySearchPanel"),
|
||
logs: document.querySelector("#logsPanel"),
|
||
register: document.querySelector("#registerPanel"),
|
||
};
|
||
const accountForm = document.querySelector("#accountForm");
|
||
const accountQuery = document.querySelector("#accountQuery");
|
||
const accountStatus = document.querySelector("#accountStatus");
|
||
const accountMeta = document.querySelector("#accountMeta");
|
||
const accountRows = document.querySelector("#accountRows");
|
||
const accountEditor = document.querySelector("#accountEditor");
|
||
const accountEditorTitle = document.querySelector("#accountEditorTitle");
|
||
const accountEditForm = document.querySelector("#accountEditForm");
|
||
const accountEditError = document.querySelector("#accountEditError");
|
||
const cancelAccountEditBtn = document.querySelector("#cancelAccountEditBtn");
|
||
const newAccountBtn = document.querySelector("#newAccountBtn");
|
||
const teacherForm = document.querySelector("#teacherForm");
|
||
const teacherQuery = document.querySelector("#teacherQuery");
|
||
const teacherStatus = document.querySelector("#teacherStatus");
|
||
const teacherMeta = document.querySelector("#teacherMeta");
|
||
const teacherRows = document.querySelector("#teacherRows");
|
||
const teacherEditor = document.querySelector("#teacherEditor");
|
||
const teacherEditorTitle = document.querySelector("#teacherEditorTitle");
|
||
const teacherEditForm = document.querySelector("#teacherEditForm");
|
||
const teacherEditError = document.querySelector("#teacherEditError");
|
||
const cancelTeacherEditBtn = document.querySelector("#cancelTeacherEditBtn");
|
||
const newTeacherBtn = document.querySelector("#newTeacherBtn");
|
||
const editTeacherId = document.querySelector("#editTeacherId");
|
||
const editTeacherName = document.querySelector("#editTeacherName");
|
||
const editTeacherAlias = document.querySelector("#editTeacherAlias");
|
||
const editTeacherStatus = document.querySelector("#editTeacherStatus");
|
||
const editTeacherSubjects = document.querySelector("#editTeacherSubjects");
|
||
const editTeacherNote = document.querySelector("#editTeacherNote");
|
||
const editStudentId = document.querySelector("#editStudentId");
|
||
const editStudent = document.querySelector("#editStudent");
|
||
const editRemaining = document.querySelector("#editRemaining");
|
||
const editStatus = document.querySelector("#editStatus");
|
||
const editPayments = document.querySelector("#editPayments");
|
||
const editNote = document.querySelector("#editNote");
|
||
const reviewStatus = document.querySelector("#reviewStatus");
|
||
const reviewMeta = document.querySelector("#reviewMeta");
|
||
const reviewRows = document.querySelector("#reviewRows");
|
||
const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
|
||
const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
|
||
const summaryReviewRows = document.querySelector("#summaryReviewRows");
|
||
const duplicateSummaryScanBtns = Array.from(document.querySelectorAll(".duplicate-summary-scan"));
|
||
const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop");
|
||
const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose");
|
||
const summaryReviewDrawerTitle = document.querySelector("#summaryReviewDrawerTitle");
|
||
const summaryReviewDrawerMeta = document.querySelector("#summaryReviewDrawerMeta");
|
||
const summaryReviewDrawerStatus = document.querySelector("#summaryReviewDrawerStatus");
|
||
const summaryReviewDrawerCourse = document.querySelector("#summaryReviewDrawerCourse");
|
||
const summaryReviewEditSection = document.querySelector("#summaryReviewEditSection");
|
||
const summaryReviewEditStudent = document.querySelector("#summaryReviewEditStudent");
|
||
const summaryReviewEditDate = document.querySelector("#summaryReviewEditDate");
|
||
const summaryReviewEditTime = document.querySelector("#summaryReviewEditTime");
|
||
const summaryReviewEditTeacher = document.querySelector("#summaryReviewEditTeacher");
|
||
const summaryReviewEditSubject = document.querySelector("#summaryReviewEditSubject");
|
||
const summaryReviewEditHint = document.querySelector("#summaryReviewEditHint");
|
||
const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine");
|
||
const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons");
|
||
const summaryReviewConflictSection = document.querySelector("#summaryReviewConflictSection");
|
||
const summaryReviewDrawerConflicts = document.querySelector("#summaryReviewDrawerConflicts");
|
||
const summaryReviewDrawerBody = document.querySelector("#summaryReviewDrawerBody");
|
||
const summaryReviewDrawerSource = document.querySelector("#summaryReviewDrawerSource");
|
||
const summaryReviewDrawerSave = document.querySelector("#summaryReviewDrawerSave");
|
||
const summaryReviewDrawerLink = document.querySelector("#summaryReviewDrawerLink");
|
||
const summaryReviewDrawerApprove = document.querySelector("#summaryReviewDrawerApprove");
|
||
const summaryReviewDrawerReject = document.querySelector("#summaryReviewDrawerReject");
|
||
const summarySearchForm = document.querySelector("#summarySearchForm");
|
||
const summarySearchQuery = document.querySelector("#summarySearchQuery");
|
||
const summarySearchStudent = document.querySelector("#summarySearchStudent");
|
||
const summarySearchTeacher = document.querySelector("#summarySearchTeacher");
|
||
const summarySearchSubject = document.querySelector("#summarySearchSubject");
|
||
const summarySearchDateFrom = document.querySelector("#summarySearchDateFrom");
|
||
const summarySearchDateTo = document.querySelector("#summarySearchDateTo");
|
||
const summarySearchBindingStatus = document.querySelector("#summarySearchBindingStatus");
|
||
const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate");
|
||
const summarySearchMeta = document.querySelector("#summarySearchMeta");
|
||
const summarySearchRows = document.querySelector("#summarySearchRows");
|
||
const logOperation = document.querySelector("#logOperation");
|
||
const logStatus = document.querySelector("#logStatus");
|
||
const logFilterForm = document.querySelector("#logFilterForm");
|
||
const logStudent = document.querySelector("#logStudent");
|
||
const logMeta = document.querySelector("#logMeta");
|
||
const logRows = document.querySelector("#logRows");
|
||
const classRegisterForm = document.querySelector("#classRegisterForm");
|
||
const classRegisterLines = document.querySelector("#classRegisterLines");
|
||
const classRegisterPreview = document.querySelector("#classRegisterPreview");
|
||
const classRegisterStatus = document.querySelector("#classRegisterStatus");
|
||
const classRegisterConfirm = document.querySelector("#classRegisterConfirm");
|
||
const paymentRegisterForm = document.querySelector("#paymentRegisterForm");
|
||
const paymentRegisterLines = document.querySelector("#paymentRegisterLines");
|
||
const paymentRegisterPreview = document.querySelector("#paymentRegisterPreview");
|
||
const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus");
|
||
const paymentRegisterConfirm = document.querySelector("#paymentRegisterConfirm");
|
||
const summaryRegisterForm = document.querySelector("#summaryRegisterForm");
|
||
const summaryRegisterText = document.querySelector("#summaryRegisterText");
|
||
const summaryRegisterPreview = document.querySelector("#summaryRegisterPreview");
|
||
const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus");
|
||
const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm");
|
||
|
||
let currentAccounts = [];
|
||
let editingAccountId = "";
|
||
let currentTeachers = [];
|
||
let editingTeacherId = "";
|
||
let currentSummaryReviews = [];
|
||
let activeSummaryReview = null;
|
||
let currentSummarySearchItems = [];
|
||
let expandedSummarySearchId = "";
|
||
let editingSummarySearchId = "";
|
||
let editingSummaryIdentityId = "";
|
||
let currentOperationLogs = [];
|
||
let expandedOperationLogId = "";
|
||
const registerPreviewState = {
|
||
class_record: null,
|
||
payment: null,
|
||
course_summary: null,
|
||
};
|
||
|
||
function fmtHours(value) {
|
||
const totalMinutes = Math.round(Number(value || 0) * 60);
|
||
const hours = Math.floor(totalMinutes / 60);
|
||
const minutes = totalMinutes % 60;
|
||
return `${hours}小时${minutes}分`;
|
||
}
|
||
|
||
function displayHours(value, fallback) {
|
||
return fallback || fmtHours(value);
|
||
}
|
||
|
||
function fmtTime(seconds) {
|
||
if (!seconds) return "未知";
|
||
return new Date(seconds * 1000).toLocaleString("zh-CN", { hour12: false });
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function metric(label, value) {
|
||
return `<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();
|