初始化新时空数据应用
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
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 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";
|
||||
let currentRecords = [];
|
||||
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 `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
||||
}
|
||||
|
||||
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 renderRecordRow(row) {
|
||||
const key = row._recordKey;
|
||||
const corrected = correctedRecords.get(key);
|
||||
const displayRow = corrected || row;
|
||||
const correctedClass = corrected ? " corrected-row" : "";
|
||||
const actionLabel = corrected ? "编辑" : "纠错";
|
||||
const badge = corrected ? '<span class="correction-badge">已修改</span>' : "";
|
||||
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 correction-edit" type="button" data-record-key="${escapeHtml(key)}">${actionLabel}</button>
|
||||
${badge}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
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>${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 = `<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)}
|
||||
${metric("缴费记录", 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;
|
||||
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 = `课程记录 ${data.records_count} 条;数据更新时间 ${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();
|
||||
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,
|
||||
_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);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
loadHealth();
|
||||
Reference in New Issue
Block a user