Files
xsk-education-management/app/app/static/app.js
T
2026-06-26 15:11:25 +08:00

975 lines
35 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 healthText = document.querySelector("#healthText");
const refreshBtn = document.querySelector("#refreshBtn");
const recordForm = document.querySelector("#recordForm");
const recordQuery = document.querySelector("#recordQuery");
const recordMeta = document.querySelector("#recordMeta");
const recordRows = document.querySelector("#recordRows");
const recordPager = document.querySelector("#recordPager");
const dateSortBtn = document.querySelector("#dateSortBtn");
const timeSortBtn = document.querySelector("#timeSortBtn");
const inlineAccount = document.querySelector("#inlineAccount");
const correctionToolbar = document.querySelector("#correctionToolbar");
const correctionStatus = document.querySelector("#correctionStatus");
const copyCorrectedBtn = document.querySelector("#copyCorrectedBtn");
const submitCorrectionsBtn = document.querySelector("#submitCorrectionsBtn");
const correctionDialog = document.querySelector("#correctionDialog");
const correctionForm = document.querySelector("#correctionForm");
const correctionOriginal = document.querySelector("#correctionOriginal");
const correctionPreview = document.querySelector("#correctionPreview");
const correctionError = document.querySelector("#correctionError");
const correctionDate = document.querySelector("#correctionDate");
const correctionTime = document.querySelector("#correctionTime");
const correctionStudent = document.querySelector("#correctionStudent");
const correctionTeacher = document.querySelector("#correctionTeacher");
const correctionSubject = document.querySelector("#correctionSubject");
const closeCorrectionBtn = document.querySelector("#closeCorrectionBtn");
const cancelCorrectionBtn = document.querySelector("#cancelCorrectionBtn");
const deleteDialog = document.querySelector("#deleteDialog");
const deleteOriginal = document.querySelector("#deleteOriginal");
const deleteError = document.querySelector("#deleteError");
const closeDeleteBtn = document.querySelector("#closeDeleteBtn");
const cancelDeleteBtn = document.querySelector("#cancelDeleteBtn");
const confirmDeleteBtn = document.querySelector("#confirmDeleteBtn");
const summarySupplementDialog = document.querySelector("#summarySupplementDialog");
const summarySupplementForm = document.querySelector("#summarySupplementForm");
const summarySupplementOriginal = document.querySelector("#summarySupplementOriginal");
const summarySupplementBody = document.querySelector("#summarySupplementBody");
const summarySupplementError = document.querySelector("#summarySupplementError");
const summarySupplementPreview = document.querySelector("#summarySupplementPreview");
const closeSummarySupplementBtn = document.querySelector("#closeSummarySupplementBtn");
const cancelSummarySupplementBtn = document.querySelector("#cancelSummarySupplementBtn");
const submitSummarySupplementBtn = document.querySelector("#submitSummarySupplementBtn");
const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
const COPY_LINE_BREAK = "\r\n";
const SORT_DIRECTIONS = { asc: 1, desc: -1 };
const RECORD_PAGE_SIZE = 30;
const RECORD_TABLE_COLUMN_COUNT = 8;
let currentRecords = [];
let recordSort = { date: "asc", time: "asc" };
let currentRecordOrder = [];
let correctedRecords = new Map();
let correctedRecordOrder = [];
let expandedSummaryRecords = new Set();
let expandedSummaryBodies = new Set();
let collapsedTeacherGroups = new Set();
let currentRecordPage = { query: "", offset: 0, limit: RECORD_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
let activeCorrectionKey = "";
let activeDeleteKey = "";
let activeSummarySupplementKey = "";
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 metricHtml(label, valueHtml) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${valueHtml}</strong></div>`;
}
function currentPageNumber(state) {
return Math.floor((state.offset || 0) / (state.limit || RECORD_PAGE_SIZE)) + 1;
}
function totalPageNumber(state) {
const limit = state.limit || RECORD_PAGE_SIZE;
return Math.max(1, Math.ceil((state.total || 0) / limit));
}
function renderPager(container, state) {
if (!container) return;
const total = Number(state.total || 0);
const shown = Number(state.shown || 0);
const limit = Number(state.limit || RECORD_PAGE_SIZE);
const offset = Number(state.offset || 0);
if (total <= limit && offset === 0) {
container.hidden = true;
container.innerHTML = "";
return;
}
const start = shown ? offset + 1 : 0;
const end = offset + shown;
container.hidden = false;
container.innerHTML = `<div class="pager-info">第 ${currentPageNumber(state)} / ${totalPageNumber(state)} 页 · ${start}-${end} / ${total} 条</div>
<div class="pager-actions">
<button class="secondary-button pager-prev" type="button" ${offset <= 0 ? "disabled" : ""}>上一页</button>
<button class="secondary-button pager-next" type="button" ${state.hasMore ? "" : "disabled"}>下一页</button>
</div>`;
}
function pageRangeText(state) {
const total = Number(state.total || 0);
const shown = Number(state.shown || 0);
const offset = Number(state.offset || 0);
if (!total || !shown) return "0 条";
return `${offset + 1}-${offset + shown}`;
}
function makeRecordKey(row, index) {
return row.record_id || JSON.stringify([index, row.date, row.time, row.student, row.duration, row.teacher, row.subject]);
}
function buildRecordLine(row) {
return `${row.date}-${row.weekday}-${row.time}-${row.student}-${row.duration}-${row.teacher}-${row.subject}`;
}
function groupRecordsByTeacher(records) {
const groups = new Map();
records.forEach((row) => {
const teacher = row.teacher || "未标注老师";
if (!groups.has(teacher)) {
groups.set(teacher, { teacher, count: 0, totalHours: 0, records: [] });
}
const group = groups.get(teacher);
group.count += 1;
group.totalHours += Number(row.duration_hours || 0);
group.records.push(row);
});
return Array.from(groups.values()).sort((a, b) => {
if (b.totalHours !== a.totalHours) return b.totalHours - a.totalHours;
return a.teacher.localeCompare(b.teacher, "zh-CN");
});
}
function getDisplayRecord(row) {
return correctedRecords.get(row._recordKey) || row;
}
function normalizeDateForSort(value) {
const text = String(value || "");
const match = text.match(/^(\d{4})\.(\d{1,2})\.(\d{1,2})$/);
if (!match) return text;
return `${match[1]}.${match[2].padStart(2, "0")}.${match[3].padStart(2, "0")}`;
}
function compareDatesForSort(a, b) {
return normalizeDateForSort(a).localeCompare(normalizeDateForSort(b), "zh-CN", { numeric: true });
}
function parseStartMinutes(value) {
const match = String(value || "").match(/^(\d{1,2}):(\d{2})/);
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
function compareStartTimesForSort(a, b) {
const minutesA = parseStartMinutes(a);
const minutesB = parseStartMinutes(b);
if (minutesA !== null && minutesB !== null) return minutesA - minutesB;
if (minutesA !== null) return -1;
if (minutesB !== null) return 1;
return String(a || "").localeCompare(String(b || ""), "zh-CN", { numeric: true });
}
function compareRecordsForDisplay(a, b) {
const displayA = getDisplayRecord(a);
const displayB = getDisplayRecord(b);
const dateCompare = compareDatesForSort(displayA.date, displayB.date);
if (dateCompare !== 0) return dateCompare * SORT_DIRECTIONS[recordSort.date];
const timeCompare = compareStartTimesForSort(displayA.time, displayB.time);
if (timeCompare !== 0) return timeCompare * SORT_DIRECTIONS[recordSort.time];
return (a._recordIndex || 0) - (b._recordIndex || 0);
}
function sortRecordsForDisplay(records) {
return [...records].sort(compareRecordsForDisplay);
}
function sortDirectionLabel(direction) {
return direction === "asc" ? "升序排列" : "降序排列";
}
function sortDirectionName(direction) {
return sortDirectionLabel(direction);
}
function nextSortDirectionName(direction) {
return direction === "asc" ? "降序排列" : "升序排列";
}
function updateRecordSortButtons() {
dateSortBtn.textContent = `日期 ${sortDirectionLabel(recordSort.date)}`;
dateSortBtn.setAttribute(
"aria-label",
`日期排序:${sortDirectionName(recordSort.date)},点击切换为${nextSortDirectionName(recordSort.date)}`,
);
timeSortBtn.textContent = `时间 ${sortDirectionLabel(recordSort.time)}`;
timeSortBtn.setAttribute(
"aria-label",
`时间排序:${sortDirectionName(recordSort.time)},点击切换为${nextSortDirectionName(recordSort.time)}`,
);
}
function resetRecordSort() {
recordSort = { date: "asc", time: "asc" };
updateRecordSortButtons();
}
function renderCurrentRecords() {
if (!currentRecords.length) return;
recordRows.innerHTML = renderGroupedRecords(currentRecords);
}
function toggleRecordSort(field) {
recordSort = { ...recordSort, [field]: recordSort[field] === "asc" ? "desc" : "asc" };
updateRecordSortButtons();
renderCurrentRecords();
}
function renderRecordRow(row, rowNumber) {
const key = row._recordKey;
const corrected = correctedRecords.get(key);
const displayRow = getDisplayRecord(row);
const correctedClass = corrected ? " corrected-row" : "";
const summaryClass = Number(row.summary_count || 0) > 0 ? "" : " missing-summary";
const actionLabel = corrected ? "编辑" : "纠错";
const badge = corrected ? '<span class="correction-badge">已修改</span>' : "";
const summaryExpanded = expandedSummaryRecords.has(key);
return `<tr class="record-row${correctedClass}${summaryClass}" data-record-key="${escapeHtml(key)}" tabindex="0" role="button" aria-expanded="${summaryExpanded ? "true" : "false"}">
<td class="num record-index-cell" data-label="序号">${escapeHtml(rowNumber)}</td>
<td data-label="日期">${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
<td data-label="时间">${escapeHtml(displayRow.time)}</td>
<td data-label="学生">${escapeHtml(displayRow.student)}</td>
<td data-label="老师">${escapeHtml(displayRow.teacher)}</td>
<td data-label="科目">${escapeHtml(displayRow.subject)}</td>
<td class="num" data-label="时长">${escapeHtml(displayRow.duration)}</td>
<td class="record-action-cell" data-label="操作">
<div class="record-actions">
<button class="small-button correction-edit" type="button" data-record-key="${escapeHtml(key)}">${actionLabel}</button>
<button class="small-button record-delete" type="button" data-record-key="${escapeHtml(key)}">删除</button>
${badge}
</div>
</td>
</tr>`;
}
function summaryBodyKey(recordKey, index) {
return `${recordKey}:${index}`;
}
function summaryPreviewText(value) {
const text = String(value || "暂无正文");
return text.length > 120 ? `${text.slice(0, 120)}...` : text;
}
function renderSummaryEntry(summary, recordKey, index) {
const title = summary.title || "课程小结";
const time = summary.time_range ? ` · ${summary.time_range}` : "";
const meta = [
summary.date_iso || "",
summary.teacher || "",
summary.subject || "",
].filter(Boolean).join(" · ");
const bodyKey = summaryBodyKey(recordKey, index);
const body = summary.body || "暂无正文";
const expanded = expandedSummaryBodies.has(bodyKey);
const canToggle = body.length > 120;
return `<div class="record-summary-item">
<div class="record-summary-title">${escapeHtml(title)}${escapeHtml(time)}</div>
${meta ? `<div class="record-summary-meta">${escapeHtml(meta)}</div>` : ""}
<div class="summary-body${expanded ? " summary-full" : " summary-preview"}">${escapeHtml(expanded ? body : summaryPreviewText(body))}</div>
${canToggle ? `<button class="summary-toggle" type="button" data-summary-body-key="${escapeHtml(bodyKey)}">${expanded ? "收起全文" : "展开全文"}</button>` : ""}
</div>`;
}
function renderSummaryMissingRow(key, expanded) {
return `<tr class="summary-collapse-row summary-missing-row" data-summary-row="${escapeHtml(key)}" ${expanded ? "" : "hidden"}>
<td colspan="${RECORD_TABLE_COLUMN_COUNT}">
<div class="summary-missing">
<span>无课程小结</span>
<button class="small-button summary-supplement" type="button" data-record-key="${escapeHtml(key)}">补充课程小结</button>
</div>
</td>
</tr>`;
}
function renderGroupedRecords(records) {
currentRecordOrder = [];
return groupRecordsByTeacher(records)
.map(
(group) => {
let displayIndex = 0;
const collapsed = collapsedTeacherGroups.has(group.teacher);
const rows = collapsed
? ""
: sortRecordsForDisplay(group.records)
.map((row) => {
displayIndex += 1;
currentRecordOrder.push(row._recordKey);
const summaryCount = Number(row.summary_count || 0);
const summaryRow = summaryCount
? `<tr class="summary-collapse-row" data-summary-row="${escapeHtml(row._recordKey)}" ${expandedSummaryRecords.has(row._recordKey) ? "" : "hidden"}>
<td colspan="${RECORD_TABLE_COLUMN_COUNT}">${(row.summaries || []).map((summary, index) => renderSummaryEntry(summary, row._recordKey, index)).join("")}</td>
</tr>`
: renderSummaryMissingRow(row._recordKey, expandedSummaryRecords.has(row._recordKey));
return `${renderRecordRow(row, displayIndex)}${summaryRow}`;
})
.join("");
return `<tr class="teacher-group" data-teacher="${escapeHtml(group.teacher)}">
<td colspan="${RECORD_TABLE_COLUMN_COUNT}">
<button class="teacher-group-toggle" type="button" data-teacher="${escapeHtml(group.teacher)}" aria-expanded="${collapsed ? "false" : "true"}">
<strong>${escapeHtml(group.teacher)}</strong>
<span>${group.count} 条记录 · ${displayHours(group.totalHours)} · ${collapsed ? "展开" : "收起"}</span>
</button>
</td>
</tr>${rows}`;
},
)
.join("");
}
function toggleSummaryRow(key) {
if (expandedSummaryRecords.has(key)) {
expandedSummaryRecords.delete(key);
} else {
expandedSummaryRecords.add(key);
}
renderCurrentRecords();
}
function toggleRecordSummary(key) {
toggleSummaryRow(key);
}
function toggleTeacherGroup(teacher) {
if (collapsedTeacherGroups.has(teacher)) {
collapsedTeacherGroups.delete(teacher);
} else {
collapsedTeacherGroups.add(teacher);
}
renderCurrentRecords();
}
function toggleSummaryBody(key) {
if (expandedSummaryBodies.has(key)) {
expandedSummaryBodies.delete(key);
} else {
expandedSummaryBodies.add(key);
}
renderCurrentRecords();
}
function statusClass(status) {
if (status === "欠费") return "debt";
if (status === "预警") return "warning";
if (status === "正常") return "normal";
return "closed";
}
function renderPayments(payments) {
if (!payments.length) return "暂无缴费记录";
return payments
.map((item) => `<span class="payment-line">${escapeHtml(item.date)}${escapeHtml(displayHours(item.hours, item.duration))}</span>`)
.join("");
}
function renderInlineAccount(account) {
inlineAccount.hidden = false;
inlineAccount.innerHTML = `<div class="inline-account-head">
<div>
<h3>${escapeHtml(account.student)} 课时账户</h3>
<p>${escapeHtml(account.student_id)}</p>
</div>
<span class="status ${statusClass(account.account_status)}">${escapeHtml(account.account_status)}</span>
</div>
<div class="inline-account-grid">
${metric("剩余课时", displayHours(account.remaining, account.remaining_duration))}
${metric("缴费次数", account.payments_count)}
${metricHtml("缴费记录", renderPayments(account.payments))}
${metric("备注", account.note || "无")}
</div>`;
}
async function loadInlineAccount(student) {
inlineAccount.hidden = false;
inlineAccount.innerHTML = `<div class="inline-account-loading">正在读取 ${escapeHtml(student)} 的课时账户</div>`;
try {
const account = await fetchJson(`/api/student-account/${encodeURIComponent(student)}`);
renderInlineAccount(account);
} catch (error) {
inlineAccount.innerHTML = `<div class="inline-account-loading">课时账户读取失败:${escapeHtml(error.message)}</div>`;
}
}
function clearInlineAccount() {
inlineAccount.hidden = true;
inlineAccount.innerHTML = "";
}
function updateCorrectionToolbar(message = "", isError = false) {
const count = correctedRecords.size;
correctionToolbar.hidden = count === 0;
copyCorrectedBtn.disabled = count === 0;
submitCorrectionsBtn.disabled = count === 0;
copyCorrectedBtn.textContent = `复制已修改记录 ${count}`;
submitCorrectionsBtn.textContent = `提交审核 ${count}`;
correctionStatus.textContent = message || `已修改 ${count} 条记录`;
correctionStatus.classList.toggle("is-error", isError);
}
function resetCorrections() {
correctedRecords = new Map();
correctedRecordOrder = [];
currentRecordOrder = [];
expandedSummaryRecords = new Set();
expandedSummaryBodies = new Set();
collapsedTeacherGroups = new Set();
activeCorrectionKey = "";
updateCorrectionToolbar();
}
function parseCorrectionDate(value) {
const match = value.trim().match(/^(\d{4})\.(\d{2})\.(\d{2})$/);
if (!match) {
throw new Error("日期格式应为 YYYY.MM.DD,例如 2026.06.10");
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const parsed = new Date(year, month - 1, day);
if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) {
throw new Error("日期不存在,请检查年月日");
}
return {
date: `${year}.${String(month).padStart(2, "0")}.${String(day).padStart(2, "0")}`,
weekday: WEEKDAYS[parsed.getDay()],
};
}
function parseCorrectionTime(value) {
const match = value.trim().match(/^([01]?\d|2[0-3]):([0-5]\d)-([01]?\d|2[0-3]):([0-5]\d)$/);
if (!match) {
throw new Error("时间格式应为 HH:MM-HH:MM,例如 08:00-10:00");
}
const startHour = Number(match[1]);
const startMinute = Number(match[2]);
const endHour = Number(match[3]);
const endMinute = Number(match[4]);
const startTotal = startHour * 60 + startMinute;
const endTotal = endHour * 60 + endMinute;
if (endTotal <= startTotal) {
throw new Error("结束时间必须晚于开始时间");
}
const totalMinutes = endTotal - startTotal;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return {
time: `${String(startHour).padStart(2, "0")}:${String(startMinute).padStart(2, "0")}-${String(endHour).padStart(2, "0")}:${String(endMinute).padStart(2, "0")}`,
duration: `${hours}小时${minutes}`,
duration_hours: Math.round((totalMinutes / 60) * 100) / 100,
};
}
function requireText(value, label) {
const text = value.trim();
if (!text) {
throw new Error(`${label}不能为空`);
}
if (text.includes("-")) {
throw new Error(`${label}不能包含 -`);
}
return text;
}
function findCurrentRecord(key) {
return currentRecords.find((row) => row._recordKey === key);
}
function buildCorrectedRecord(original) {
const datePart = parseCorrectionDate(correctionDate.value);
const timePart = parseCorrectionTime(correctionTime.value);
return {
...original,
...datePart,
...timePart,
student: requireText(correctionStudent.value, "学生"),
teacher: requireText(correctionTeacher.value, "老师"),
subject: requireText(correctionSubject.value, "科目"),
};
}
function setCorrectionError(message) {
correctionError.textContent = message;
correctionError.hidden = !message;
}
function updateCorrectionPreview() {
const original = findCurrentRecord(activeCorrectionKey);
if (!original) return;
try {
const corrected = buildCorrectedRecord(original);
correctionPreview.textContent = buildRecordLine(corrected);
setCorrectionError("");
} catch (error) {
correctionPreview.textContent = "请修正上方字段后保存";
setCorrectionError(error.message);
}
}
function openCorrectionDialog(key) {
const original = findCurrentRecord(key);
if (!original) return;
const corrected = correctedRecords.get(key) || original;
activeCorrectionKey = key;
correctionOriginal.textContent = `原记录:${buildRecordLine(original)}`;
correctionDate.value = corrected.date;
correctionTime.value = corrected.time;
correctionStudent.value = corrected.student;
correctionTeacher.value = corrected.teacher;
correctionSubject.value = corrected.subject;
correctionDialog.hidden = false;
updateCorrectionPreview();
correctionDate.focus();
}
function closeCorrectionDialog() {
correctionDialog.hidden = true;
activeCorrectionKey = "";
setCorrectionError("");
}
function setDeleteError(message) {
deleteError.textContent = message;
deleteError.hidden = !message;
}
function openDeleteDialog(key) {
const original = findCurrentRecord(key);
if (!original) return;
activeDeleteKey = key;
deleteOriginal.textContent = `将提交删除审核:${buildRecordLine(original)}`;
setDeleteError("");
deleteDialog.hidden = false;
}
function closeDeleteDialog() {
deleteDialog.hidden = true;
activeDeleteKey = "";
setDeleteError("");
}
function setSummarySupplementError(message) {
summarySupplementError.textContent = message;
summarySupplementError.hidden = !message;
}
function buildSummarySupplementText(record, body) {
return [
`学生:${record.student}`,
`日期:${record.date}`,
`时间:${record.time}`,
`老师:${record.teacher}`,
`科目:${record.subject}`,
"",
body.trim(),
].join("\n");
}
function updateSummarySupplementPreview() {
const original = findCurrentRecord(activeSummarySupplementKey);
if (!original) return;
const body = summarySupplementBody.value.trim();
if (!body) {
summarySupplementPreview.textContent = "提交后会直接保存为这节课的课程小结。";
setSummarySupplementError("");
return;
}
summarySupplementPreview.textContent = buildSummarySupplementText(original, body);
setSummarySupplementError("");
}
function openSummarySupplementDialog(key) {
const original = findCurrentRecord(key);
if (!original) return;
activeSummarySupplementKey = key;
summarySupplementOriginal.textContent = `将补充课程小结:${buildRecordLine(original)}`;
summarySupplementBody.value = "";
summarySupplementPreview.textContent = "提交后会直接保存为这节课的课程小结。";
setSummarySupplementError("");
summarySupplementDialog.hidden = false;
summarySupplementBody.focus();
}
function closeSummarySupplementDialog() {
summarySupplementDialog.hidden = true;
activeSummarySupplementKey = "";
setSummarySupplementError("");
}
async function submitSummarySupplement(event) {
event.preventDefault();
const original = findCurrentRecord(activeSummarySupplementKey);
if (!original) return;
const body = summarySupplementBody.value.trim();
if (!body) {
setSummarySupplementError("课程小结正文不能为空");
return;
}
submitSummarySupplementBtn.disabled = true;
summarySupplementPreview.textContent = "正在提交";
try {
const key = activeSummarySupplementKey;
const data = await fetchJson("/api/course-summaries/supplement", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
record_id: original.record_id,
body,
}),
});
closeSummarySupplementDialog();
await loadHealth();
if (recordQuery.value.trim()) {
await queryRecords(recordQuery.value);
if (key) {
expandedSummaryRecords.add(key);
renderCurrentRecords();
}
}
} catch (error) {
setSummarySupplementError(`提交失败:${error.message}`);
summarySupplementPreview.textContent = "请修正后重新提交";
} finally {
submitSummarySupplementBtn.disabled = false;
}
}
async function submitDeleteRecord() {
const original = findCurrentRecord(activeDeleteKey);
if (!original) return;
confirmDeleteBtn.disabled = true;
try {
const data = await fetchJson("/api/deletions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: [{ record_id: original.record_id }] }),
});
closeDeleteDialog();
updateCorrectionToolbar(`已提交 ${data.submitted} 条删除审核`);
} catch (error) {
setDeleteError(`提交失败:${error.message}`);
} finally {
confirmDeleteBtn.disabled = false;
}
}
function saveCorrection() {
const original = findCurrentRecord(activeCorrectionKey);
if (!original) return;
const corrected = buildCorrectedRecord(original);
if (buildRecordLine(corrected) === buildRecordLine(original)) {
correctedRecords.delete(activeCorrectionKey);
correctedRecordOrder = correctedRecordOrder.filter((key) => key !== activeCorrectionKey);
} else {
correctedRecords.set(activeCorrectionKey, corrected);
if (!correctedRecordOrder.includes(activeCorrectionKey)) correctedRecordOrder.push(activeCorrectionKey);
}
recordRows.innerHTML = renderGroupedRecords(currentRecords);
closeCorrectionDialog();
updateCorrectionToolbar(`已保存 ${correctedRecords.size} 条修改`);
}
async function copyTextToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch (_error) {
// HTTP access on the VPS usually needs the fallback path below.
}
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
if (!copied) {
throw new Error("浏览器拒绝写入剪切板");
}
}
async function copyCorrectedRecords() {
const correctedInPageOrder = correctedRecordOrder
.map((key) => correctedRecords.get(key))
.filter(Boolean);
if (!correctedInPageOrder.length) return;
const text = correctedInPageOrder.map(buildRecordLine).join(COPY_LINE_BREAK);
try {
await copyTextToClipboard(text);
updateCorrectionToolbar(`已复制 ${correctedInPageOrder.length} 条修改`);
} catch (error) {
updateCorrectionToolbar(`复制失败:${error.message}`, true);
}
}
async function submitCorrectedRecords() {
const items = correctedRecordOrder
.map((key) => {
const corrected = correctedRecords.get(key);
if (!corrected) return null;
return {
record_id: corrected.record_id,
date: corrected.date,
time: corrected.time,
student: corrected.student,
teacher: corrected.teacher,
subject: corrected.subject,
};
})
.filter(Boolean);
if (!items.length) return;
submitCorrectionsBtn.disabled = true;
try {
const data = await fetchJson("/api/corrections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
});
updateCorrectionToolbar(`已提交 ${data.submitted} 条纠错审核`);
} catch (error) {
updateCorrectionToolbar(`提交失败:${error.message}`, true);
} finally {
submitCorrectionsBtn.disabled = correctedRecords.size === 0;
}
}
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();
}
async function loadHealth() {
try {
const data = await fetchJson("/api/health");
healthText.textContent = `数据更新时间 ${fmtTime(data.classnotes.mtime)}`;
} catch (error) {
healthText.textContent = `读取失败:${error.message}`;
}
}
async function queryRecords(query, options = {}) {
const q = query.trim();
if (!q) return;
const offset = Number(options.offset || 0);
recordRows.innerHTML = `<tr><td colspan="${RECORD_TABLE_COLUMN_COUNT}" class="empty">正在查询</td></tr>`;
recordMeta.innerHTML = "";
if (recordPager) {
recordPager.hidden = true;
recordPager.innerHTML = "";
}
currentRecords = [];
if (options.reset !== false) {
resetCorrections();
resetRecordSort();
clearInlineAccount();
} else {
currentRecordOrder = [];
expandedSummaryRecords = new Set();
expandedSummaryBodies = new Set();
collapsedTeacherGroups = new Set();
}
try {
const params = new URLSearchParams({ q, limit: String(RECORD_PAGE_SIZE), offset: String(offset) });
const data = await fetchJson(`/api/records?${params.toString()}`);
const summary = data.summary;
currentRecordPage = {
query: q,
offset: data.offset || offset,
limit: data.limit || RECORD_PAGE_SIZE,
total: data.total_records || 0,
shown: data.shown_records || (data.records || []).length,
hasMore: Boolean(data.has_more),
};
recordMeta.innerHTML = [
metric("识别日期", data.query.date_range),
metric("命中记录", `${summary.count}`),
metric("当前显示", pageRangeText(currentRecordPage)),
metric("总课时", displayHours(summary.total_hours, summary.total_duration)),
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
].join("");
if (options.reset !== false && data.query.students.length === 1) {
await loadInlineAccount(data.query.students[0]);
}
if (!data.records.length) {
recordRows.innerHTML = `<tr><td colspan="${RECORD_TABLE_COLUMN_COUNT}" class="empty">未找到符合条件的上课记录</td></tr>`;
renderPager(recordPager, currentRecordPage);
return;
}
currentRecords = data.records.map((row, index) => ({
...row,
_recordIndex: currentRecordPage.offset + index,
_recordKey: makeRecordKey(row, currentRecordPage.offset + index),
}));
recordRows.innerHTML = renderGroupedRecords(currentRecords);
renderPager(recordPager, currentRecordPage);
} catch (error) {
recordRows.innerHTML = `<tr><td colspan="${RECORD_TABLE_COLUMN_COUNT}" class="empty">查询失败:${escapeHtml(error.message)}</td></tr>`;
}
}
recordForm.addEventListener("submit", (event) => {
event.preventDefault();
queryRecords(recordQuery.value);
});
refreshBtn.addEventListener("click", () => {
loadHealth();
if (recordQuery.value.trim()) queryRecords(recordQuery.value);
});
dateSortBtn.addEventListener("click", () => toggleRecordSort("date"));
timeSortBtn.addEventListener("click", () => toggleRecordSort("time"));
document.querySelectorAll("[data-query]").forEach((button) => {
button.addEventListener("click", () => {
recordQuery.value = button.dataset.query;
queryRecords(recordQuery.value);
});
});
recordRows.addEventListener("click", (event) => {
const summarySupplementButton = event.target.closest(".summary-supplement");
const editButton = event.target.closest(".correction-edit");
const deleteButton = event.target.closest(".record-delete");
const teacherToggle = event.target.closest(".teacher-group-toggle");
const summaryToggle = event.target.closest(".summary-toggle");
if (summarySupplementButton) {
openSummarySupplementDialog(summarySupplementButton.dataset.recordKey);
return;
}
if (editButton) {
openCorrectionDialog(editButton.dataset.recordKey);
return;
}
if (deleteButton) {
openDeleteDialog(deleteButton.dataset.recordKey);
return;
}
if (teacherToggle) {
toggleTeacherGroup(teacherToggle.dataset.teacher);
return;
}
if (summaryToggle) {
toggleSummaryBody(summaryToggle.dataset.summaryBodyKey);
return;
}
const row = event.target.closest(".record-row");
if (row && !event.target.closest(".record-action-cell")) toggleRecordSummary(row.dataset.recordKey);
});
recordRows.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const row = event.target.closest(".record-row");
if (!row) return;
const target = event.target.closest(".summary-supplement, .correction-edit, .record-delete");
if (target) return;
event.preventDefault();
toggleRecordSummary(row.dataset.recordKey);
});
if (recordPager) {
recordPager.addEventListener("click", (event) => {
const previous = event.target.closest(".pager-prev");
const next = event.target.closest(".pager-next");
if (!previous && !next) return;
const delta = previous ? -currentRecordPage.limit : currentRecordPage.limit;
const nextOffset = Math.max(0, currentRecordPage.offset + delta);
queryRecords(currentRecordPage.query || recordQuery.value, { offset: nextOffset, reset: false });
});
}
[correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => {
input.addEventListener("input", updateCorrectionPreview);
});
correctionForm.addEventListener("submit", (event) => {
event.preventDefault();
try {
saveCorrection();
} catch (error) {
setCorrectionError(error.message);
correctionPreview.textContent = "请修正上方字段后保存";
}
});
closeCorrectionBtn.addEventListener("click", closeCorrectionDialog);
cancelCorrectionBtn.addEventListener("click", closeCorrectionDialog);
correctionDialog.addEventListener("click", (event) => {
if (event.target === correctionDialog) closeCorrectionDialog();
});
closeDeleteBtn.addEventListener("click", closeDeleteDialog);
cancelDeleteBtn.addEventListener("click", closeDeleteDialog);
confirmDeleteBtn.addEventListener("click", submitDeleteRecord);
deleteDialog.addEventListener("click", (event) => {
if (event.target === deleteDialog) closeDeleteDialog();
});
closeSummarySupplementBtn.addEventListener("click", closeSummarySupplementDialog);
cancelSummarySupplementBtn.addEventListener("click", closeSummarySupplementDialog);
summarySupplementDialog.addEventListener("click", (event) => {
if (event.target === summarySupplementDialog) closeSummarySupplementDialog();
});
summarySupplementBody.addEventListener("input", updateSummarySupplementPreview);
summarySupplementForm.addEventListener("submit", submitSummarySupplement);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !correctionDialog.hidden) {
closeCorrectionDialog();
}
if (event.key === "Escape" && !deleteDialog.hidden) {
closeDeleteDialog();
}
if (event.key === "Escape" && !summarySupplementDialog.hidden) {
closeSummarySupplementDialog();
}
});
copyCorrectedBtn.addEventListener("click", copyCorrectedRecords);
submitCorrectionsBtn.addEventListener("click", submitCorrectedRecords);
updateRecordSortButtons();
loadHealth();