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 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 WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
const COPY_LINE_BREAK = "\r\n";
const SORT_DIRECTIONS = { asc: 1, desc: -1 };
let currentRecords = [];
let recordSort = { date: "asc", time: "asc" };
let currentRecordOrder = [];
let correctedRecords = new Map();
let activeCorrectionKey = "";
let activeDeleteKey = "";
function fmtHours(value) {
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
}
function fmtTime(seconds) {
if (!seconds) return "未知";
return new Date(seconds * 1000).toLocaleString("zh-CN", { hour12: false });
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function metric(label, value) {
return `
${escapeHtml(label)}${escapeHtml(value)}
`;
}
function makeRecordKey(row, index) {
return 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) {
const key = row._recordKey;
const corrected = correctedRecords.get(key);
const displayRow = getDisplayRecord(row);
const correctedClass = corrected ? " corrected-row" : "";
const actionLabel = corrected ? "编辑" : "纠错";
const badge = corrected ? '已修改' : "";
return `
| ${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)} |
${escapeHtml(displayRow.time)} |
${escapeHtml(displayRow.student)} |
${escapeHtml(displayRow.teacher)} |
${escapeHtml(displayRow.subject)} |
${escapeHtml(displayRow.duration)} |
${badge}
|
`;
}
function renderGroupedRecords(records) {
currentRecordOrder = [];
return groupRecordsByTeacher(records)
.map(
(group) => `
|
${escapeHtml(group.teacher)}
${group.count} 条记录 · ${fmtHours(group.totalHours)} 小时
|
${sortRecordsForDisplay(group.records)
.map((row) => {
currentRecordOrder.push(row._recordKey);
return renderRecordRow(row);
})
.join("")}`,
)
.join("");
}
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) => `${item.date}:${fmtHours(item.hours)} 小时`).join(",");
}
function renderInlineAccount(account) {
inlineAccount.hidden = false;
inlineAccount.innerHTML = `
${escapeHtml(account.student)} 课时账户
${escapeHtml(account.student_id)}
${escapeHtml(account.account_status)}
${metric("剩余课时", fmtHours(account.remaining))}
${metric("缴费次数", account.payments_count)}
${metric("缴费记录", renderPayments(account.payments))}
${metric("备注", account.note || "无")}
`;
}
async function loadInlineAccount(student) {
inlineAccount.hidden = false;
inlineAccount.innerHTML = `正在读取 ${escapeHtml(student)} 的课时账户
`;
try {
const account = await fetchJson(`/api/student-account/${encodeURIComponent(student)}`);
renderInlineAccount(account);
} catch (error) {
inlineAccount.innerHTML = `课时账户读取失败:${escapeHtml(error.message)}
`;
}
}
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();
currentRecordOrder = [];
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("");
}
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: [{ original_line: buildRecordLine(original) }] }),
});
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);
} else {
correctedRecords.set(activeCorrectionKey, corrected);
}
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 = currentRecordOrder
.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 = currentRecordOrder
.map((key) => {
const original = findCurrentRecord(key);
const corrected = correctedRecords.get(key);
if (!original || !corrected) return null;
return {
original_line: buildRecordLine(original),
corrected_line: buildRecordLine(corrected),
};
})
.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) {
const q = query.trim();
if (!q) return;
recordRows.innerHTML = `| 正在查询 |
`;
recordMeta.innerHTML = "";
currentRecords = [];
resetCorrections();
resetRecordSort();
clearInlineAccount();
try {
const data = await fetchJson(`/api/records?q=${encodeURIComponent(q)}&limit=500`);
const summary = data.summary;
recordMeta.innerHTML = [
metric("识别日期", data.query.date_range),
metric("命中记录", `${summary.count} 条`),
metric("总课时", `${fmtHours(summary.total_hours)} 小时`),
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
].join("");
if (data.query.students.length === 1) {
await loadInlineAccount(data.query.students[0]);
}
if (!data.records.length) {
recordRows.innerHTML = `| 未找到符合条件的上课记录 |
`;
return;
}
currentRecords = data.records.map((row, index) => ({
...row,
_recordIndex: index,
_recordKey: makeRecordKey(row, index),
}));
recordRows.innerHTML = renderGroupedRecords(currentRecords);
} catch (error) {
recordRows.innerHTML = `| 查询失败:${escapeHtml(error.message)} |
`;
}
}
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 editButton = event.target.closest(".correction-edit");
const deleteButton = event.target.closest(".record-delete");
if (editButton) {
openCorrectionDialog(editButton.dataset.recordKey);
return;
}
if (deleteButton) openDeleteDialog(deleteButton.dataset.recordKey);
});
[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();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !correctionDialog.hidden) {
closeCorrectionDialog();
}
if (event.key === "Escape" && !deleteDialog.hidden) {
closeDeleteDialog();
}
});
copyCorrectedBtn.addEventListener("click", copyCorrectedRecords);
submitCorrectionsBtn.addEventListener("click", submitCorrectedRecords);
updateRecordSortButtons();
loadHealth();