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 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 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 = ""; 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; copyCorrectedBtn.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 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 fetchJson(url) { const response = await fetch(url, { cache: "no-store" }); 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 button = event.target.closest(".correction-edit"); if (!button) return; openCorrectionDialog(button.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(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !correctionDialog.hidden) { closeCorrectionDialog(); } }); copyCorrectedBtn.addEventListener("click", copyCorrectedRecords); updateRecordSortButtons(); loadHealth();