975 lines
35 KiB
JavaScript
975 lines
35 KiB
JavaScript
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("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
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();
|