Files
xsk-education-management/app/app/static/app.js
T
2026-06-16 05:09:55 +08:00

694 lines
24 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 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 expandedSummaryRecords = new Set();
let activeCorrectionKey = "";
let activeDeleteKey = "";
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 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 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) {
const key = row._recordKey;
const corrected = correctedRecords.get(key);
const displayRow = getDisplayRecord(row);
const correctedClass = corrected ? " corrected-row" : "";
const actionLabel = corrected ? "编辑" : "纠错";
const badge = corrected ? '<span class="correction-badge">已修改</span>' : "";
const summaryCount = Number(displayRow.summary_count || 0);
const summaryExpanded = expandedSummaryRecords.has(key);
return `<tr class="record-row${correctedClass}">
<td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
<td>${escapeHtml(displayRow.time)}</td>
<td>${escapeHtml(displayRow.student)}</td>
<td>${escapeHtml(displayRow.teacher)}</td>
<td>${escapeHtml(displayRow.subject)}</td>
<td class="num">${escapeHtml(displayRow.duration)}</td>
<td class="record-action-cell">
<div class="record-actions">
<button class="small-button summary-toggle" type="button" data-record-key="${escapeHtml(key)}" ${summaryCount > 0 ? "" : "disabled"}>${summaryExpanded ? "收起小结" : "课程小结"}</button>
<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 renderSummaryEntry(summary) {
const title = summary.title || "课程小结";
const time = summary.time_range ? ` · ${summary.time_range}` : "";
return `<div class="record-summary-item">
<div class="record-summary-title">${escapeHtml(title)}${escapeHtml(time)}</div>
<div class="summary-body">${escapeHtml(summary.body || "暂无正文")}</div>
</div>`;
}
function renderGroupedRecords(records) {
currentRecordOrder = [];
return groupRecordsByTeacher(records)
.map(
(group) => `<tr class="teacher-group">
<td colspan="7">
<div class="teacher-group-title">
<strong>${escapeHtml(group.teacher)}</strong>
<span>${group.count} 条记录 · ${fmtHours(group.totalHours)}</span>
</div>
</td>
</tr>${sortRecordsForDisplay(group.records)
.map((row) => {
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="7">${(row.summaries || []).map(renderSummaryEntry).join("")}</td>
</tr>`
: "";
return `${renderRecordRow(row)}${summaryRow}`;
})
.join("")}`,
)
.join("");
}
function toggleSummaryRow(key) {
if (expandedSummaryRecords.has(key)) {
expandedSummaryRecords.delete(key);
} else {
expandedSummaryRecords.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)}${fmtHours(item.hours)}</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("剩余课时", fmtHours(account.remaining))}
${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();
currentRecordOrder = [];
expandedSummaryRecords = 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("");
}
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);
} 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 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) {
const q = query.trim();
if (!q) return;
recordRows.innerHTML = `<tr><td colspan="7" class="empty">正在查询</td></tr>`;
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 = `<tr><td colspan="7" class="empty">未找到符合条件的上课记录</td></tr>`;
return;
}
currentRecords = data.records.map((row, index) => ({
...row,
_recordIndex: index,
_recordKey: makeRecordKey(row, index),
}));
recordRows.innerHTML = renderGroupedRecords(currentRecords);
} catch (error) {
recordRows.innerHTML = `<tr><td colspan="7" 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 summaryButton = event.target.closest(".summary-toggle");
const editButton = event.target.closest(".correction-edit");
const deleteButton = event.target.closest(".record-delete");
if (summaryButton) {
toggleSummaryRow(summaryButton.dataset.recordKey);
return;
}
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();