初始化新时空数据应用

This commit is contained in:
Codex
2026-06-12 02:11:47 +08:00
commit aab9da4f31
18 changed files with 3492 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>课时账户查询</title>
<link rel="stylesheet" href="/static/styles.css?v=20260611-accounts-split" />
</head>
<body>
<header class="topbar">
<div>
<h1>课时账户查询</h1>
<p id="accountHealthText">正在读取账户状态</p>
</div>
<div class="top-actions">
<a class="nav-button" href="/">课程记录</a>
<button id="refreshBtn" class="icon-button" title="刷新账户" type="button" aria-label="刷新账户">
</button>
</div>
</header>
<main class="layout account-layout">
<section class="panel accounts-panel">
<div class="section-head">
<h2>课时账户</h2>
<select id="accountStatus" aria-label="账户状态筛选">
<option value="">全部状态</option>
<option value="欠费">欠费</option>
<option value="预警">预警</option>
<option value="正常">正常</option>
<option value="结课">结课</option>
<option value="退费">退费</option>
</select>
</div>
<form id="accountForm" class="search-row account-search-row">
<input id="accountQuery" autocomplete="off" placeholder="学生姓名或学生ID" />
<button type="submit">查询</button>
</form>
<div id="accountMeta" class="summary-grid"></div>
<div class="table-wrap account-table-wrap">
<table>
<thead>
<tr>
<th>学生</th>
<th>状态</th>
<th class="num">剩余课时</th>
<th>缴费记录</th>
<th>备注</th>
</tr>
</thead>
<tbody id="accountRows"></tbody>
</table>
</div>
</section>
</main>
<script src="/static/accounts.js?v=20260611-accounts-split"></script>
</body>
</html>
+116
View File
@@ -0,0 +1,116 @@
const accountHealthText = document.querySelector("#accountHealthText");
const refreshBtn = document.querySelector("#refreshBtn");
const accountForm = document.querySelector("#accountForm");
const accountQuery = document.querySelector("#accountQuery");
const accountStatus = document.querySelector("#accountStatus");
const accountMeta = document.querySelector("#accountMeta");
const accountRows = document.querySelector("#accountRows");
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("&", "&amp;")
.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 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) => `${escapeHtml(item.date)}${fmtHours(item.hours)} 小时`).join("<br>");
}
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();
}
function renderSummary(summary, count) {
accountMeta.innerHTML = [
metric("当前结果", `${count}`),
metric("欠费", summary.debt),
metric("预警", summary.warning),
metric("正常", summary.normal),
].join("");
}
async function loadAccountHealth() {
try {
const data = await fetchJson("/api/account-health");
accountHealthText.textContent = `账户 ${data.accounts_count} 人;数据更新时间 ${fmtTime(data.accounts.mtime)}`;
} catch (error) {
accountHealthText.textContent = `读取失败:${error.message}`;
}
}
async function loadAccounts() {
accountRows.innerHTML = `<tr><td colspan="5" class="empty">正在读取</td></tr>`;
const params = new URLSearchParams();
if (accountQuery.value.trim()) params.set("q", accountQuery.value.trim());
if (accountStatus.value) params.set("status", accountStatus.value);
try {
const data = await fetchJson(`/api/accounts?${params.toString()}`);
renderSummary(data.summary, data.count);
accountRows.innerHTML = data.accounts
.map(
(row) => `<tr>
<td>${escapeHtml(row.student)}<br><small>${escapeHtml(row.student_id)}</small></td>
<td><span class="status ${statusClass(row.account_status)}">${escapeHtml(row.account_status)}</span></td>
<td class="num">${fmtHours(row.remaining)}</td>
<td>${renderPayments(row.payments)}</td>
<td class="note-cell">${escapeHtml(row.note || "")}</td>
</tr>`,
)
.join("");
if (!data.accounts.length) {
accountRows.innerHTML = `<tr><td colspan="5" class="empty">没有符合条件的账户</td></tr>`;
}
} catch (error) {
accountRows.innerHTML = `<tr><td colspan="5" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
accountForm.addEventListener("submit", (event) => {
event.preventDefault();
loadAccounts();
});
accountStatus.addEventListener("change", loadAccounts);
refreshBtn.addEventListener("click", () => {
loadAccountHealth();
loadAccounts();
});
loadAccountHealth();
loadAccounts();
+458
View File
@@ -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("&", "&amp;")
.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 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();
+115
View File
@@ -0,0 +1,115 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>新时空课程记录查询</title>
<link rel="stylesheet" href="/static/styles.css?v=20260611-weekend-shortcuts" />
</head>
<body>
<header class="topbar">
<div>
<h1>新时空课程记录查询</h1>
<p id="healthText">正在读取数据状态</p>
</div>
<div class="top-actions">
<a class="nav-button" href="/accounts">课时账户</a>
<button id="refreshBtn" class="icon-button" title="刷新数据" type="button" aria-label="刷新数据">
</button>
</div>
</header>
<main class="layout records-layout">
<section class="panel records-panel">
<div class="section-head">
<h2>上课记录</h2>
<div class="quick-actions">
<button class="chip" data-query="今天上课记录" type="button">今天</button>
<button class="chip" data-query="上周末上课记录" type="button">上周末</button>
</div>
</div>
<form id="recordForm" class="search-row">
<input
id="recordQuery"
name="q"
autocomplete="off"
placeholder="例如:王鑫鹏5.5-5.10、王鑫鹏英语"
/>
<button type="submit">查询</button>
</form>
<div id="recordMeta" class="summary-grid"></div>
<div id="correctionToolbar" class="correction-toolbar" hidden>
<span id="correctionStatus">已修改 0 条记录</span>
<button id="copyCorrectedBtn" type="button">复制已修改记录</button>
</div>
<div id="inlineAccount" class="inline-account" hidden></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>日期</th>
<th>时间</th>
<th>学生</th>
<th>老师</th>
<th>科目</th>
<th class="num">时长</th>
<th>操作</th>
</tr>
</thead>
<tbody id="recordRows">
<tr>
<td colspan="7" class="empty">输入查询条件后显示明细</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
<div id="correctionDialog" class="modal-backdrop" hidden>
<section class="correction-modal" role="dialog" aria-modal="true" aria-labelledby="correctionTitle">
<div class="modal-head">
<h2 id="correctionTitle">纠错上课记录</h2>
<button id="closeCorrectionBtn" class="modal-close" type="button" aria-label="关闭">关闭</button>
</div>
<form id="correctionForm" class="correction-form">
<p id="correctionOriginal" class="correction-original"></p>
<div class="correction-fields">
<label>
日期
<input id="correctionDate" name="date" autocomplete="off" placeholder="2026.06.10" />
</label>
<label>
时间
<input id="correctionTime" name="time" autocomplete="off" placeholder="08:00-10:00" />
</label>
<label>
学生
<input id="correctionStudent" name="student" autocomplete="off" />
</label>
<label>
老师
<input id="correctionTeacher" name="teacher" autocomplete="off" />
</label>
<label>
科目
<input id="correctionSubject" name="subject" autocomplete="off" />
</label>
</div>
<p id="correctionError" class="correction-error" hidden></p>
<div class="correction-preview">
<span>复制预览</span>
<code id="correctionPreview"></code>
</div>
<div class="modal-actions">
<button id="cancelCorrectionBtn" class="secondary-button" type="button">取消</button>
<button type="submit">保存修改</button>
</div>
</form>
</section>
</div>
<script src="/static/app.js?v=20260611-weekend-shortcuts"></script>
</body>
</html>
+695
View File
@@ -0,0 +1,695 @@
:root {
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--text: #18202a;
--muted: #687487;
--line: #d9dee7;
--accent: #0f766e;
--accent-strong: #0b5d57;
--danger: #b42318;
--warn: #b54708;
--ok: #237a42;
--shadow: 0 12px 28px rgba(20, 31, 46, 0.08);
}
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: Arial, "Songti SC", SimSun, sans-serif;
font-size: 15px;
}
button,
input,
select {
font: inherit;
}
.topbar {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 24px;
border-bottom: 1px solid var(--line);
background: rgba(246, 247, 249, 0.96);
backdrop-filter: blur(10px);
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 22px;
line-height: 1.25;
letter-spacing: 0;
}
h2 {
font-size: 18px;
line-height: 1.3;
letter-spacing: 0;
}
.topbar p {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.nav-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 14px;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
}
.nav-button:hover {
border-color: var(--accent);
color: var(--accent-strong);
}
.icon-button {
width: 42px;
height: 42px;
flex: 0 0 auto;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(340px, 0.8fr);
gap: 18px;
padding: 18px 24px 28px;
}
.records-layout,
.account-layout {
grid-template-columns: minmax(0, 1fr);
}
.panel {
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
overflow: hidden;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--line);
}
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.chip,
.search-row button {
border: 0;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.chip {
min-height: 32px;
padding: 6px 10px;
font-size: 13px;
}
.chip:hover,
.search-row button:hover {
background: var(--accent-strong);
}
.search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 88px;
gap: 10px;
padding: 16px;
border-bottom: 1px solid var(--line);
}
.search-row.compact {
grid-template-columns: minmax(0, 1fr) 76px;
}
input,
select {
min-width: 0;
height: 42px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--text);
outline: none;
}
input {
padding: 0 12px;
}
select {
padding: 0 10px;
}
input:focus,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15);
}
.search-row button {
min-height: 42px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.metric {
min-width: 0;
padding: 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fafbfc;
}
.metric span {
display: block;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.metric strong {
display: block;
margin-top: 4px;
overflow-wrap: anywhere;
font-size: 18px;
line-height: 1.2;
}
.inline-account {
padding: 14px 16px;
border-bottom: 1px solid var(--line);
background: #fbfcfd;
}
.inline-account-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.inline-account h3 {
margin: 0;
font-size: 16px;
line-height: 1.25;
letter-spacing: 0;
}
.inline-account p {
margin-top: 4px;
color: var(--muted);
font-size: 13px;
}
.inline-account-grid {
display: grid;
grid-template-columns: 130px 130px minmax(0, 1fr) minmax(160px, 0.6fr);
gap: 10px;
}
.inline-account-loading {
color: var(--muted);
font-size: 14px;
}
.correction-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--line);
background: #f8fbfb;
}
.correction-toolbar span {
color: var(--accent-strong);
font-size: 14px;
font-weight: 700;
}
.correction-toolbar span.is-error {
color: var(--danger);
}
.correction-toolbar button,
.modal-actions button {
min-height: 38px;
border: 0;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
font-weight: 700;
}
.correction-toolbar button {
padding: 0 14px;
}
.correction-toolbar button:hover,
.modal-actions button:hover {
background: var(--accent-strong);
}
.correction-toolbar button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.table-wrap {
max-height: calc(100vh - 292px);
overflow: auto;
}
.account-table-wrap {
max-height: calc(100vh - 262px);
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
white-space: nowrap;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: #f3f5f8;
color: #344054;
font-size: 13px;
font-weight: 700;
}
td {
font-size: 14px;
}
.teacher-group td {
padding: 11px 12px;
border-top: 1px solid #b7d8d4;
border-bottom: 1px solid #b7d8d4;
background: #e9f5f3;
}
.teacher-group-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--accent-strong);
}
.teacher-group-title strong {
font-size: 15px;
line-height: 1.25;
}
.teacher-group-title span {
color: #344054;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.record-row td:nth-child(4) {
color: var(--muted);
}
.corrected-row td {
background: #fffaf0;
}
.record-actions {
display: flex;
align-items: center;
gap: 8px;
}
.small-button {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--text);
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
.small-button:hover {
border-color: var(--accent);
color: var(--accent-strong);
}
.correction-badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
background: #fef0c7;
color: var(--warn);
font-size: 12px;
font-weight: 700;
}
.note-cell {
color: var(--muted);
white-space: normal;
}
.num {
text-align: right;
}
.empty {
color: var(--muted);
text-align: center;
white-space: normal;
}
.status {
display: inline-flex;
align-items: center;
min-width: 44px;
justify-content: center;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.status.debt {
background: #fee4e2;
color: var(--danger);
}
.status.warning {
background: #fef0c7;
color: var(--warn);
}
.status.normal {
background: #dcfae6;
color: var(--ok);
}
.status.closed {
background: #e4e7ec;
color: #475467;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 18px;
background: rgba(16, 24, 40, 0.42);
}
.correction-modal {
width: min(100%, 760px);
max-height: calc(100vh - 36px);
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: 0 20px 48px rgba(16, 24, 40, 0.22);
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--line);
}
.modal-close,
.secondary-button {
min-height: 36px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--text);
cursor: pointer;
font-weight: 700;
}
.modal-close:hover,
.secondary-button:hover {
border-color: var(--accent);
color: var(--accent-strong);
}
.correction-form {
padding: 16px;
}
.correction-original {
margin-bottom: 14px;
color: var(--muted);
font-size: 13px;
overflow-wrap: anywhere;
}
.correction-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.correction-fields label {
display: grid;
gap: 6px;
color: #344054;
font-size: 13px;
font-weight: 700;
}
.correction-error {
margin-top: 12px;
color: var(--danger);
font-size: 13px;
font-weight: 700;
}
.correction-preview {
margin-top: 14px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fafbfc;
}
.correction-preview span {
display: block;
margin-bottom: 6px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.correction-preview code {
display: block;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--text);
font-family: Arial, "Songti SC", SimSun, sans-serif;
font-size: 14px;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.modal-actions button {
padding: 0 16px;
}
.modal-actions .secondary-button {
border: 1px solid var(--line);
background: #fff;
color: var(--text);
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
.table-wrap {
max-height: none;
}
.inline-account-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.topbar {
align-items: flex-start;
padding: 14px;
}
h1 {
font-size: 19px;
}
.layout {
padding: 12px;
}
.section-head {
align-items: flex-start;
flex-direction: column;
}
.quick-actions {
width: 100%;
justify-content: flex-start;
}
.search-row,
.search-row.compact,
.account-search-row {
grid-template-columns: 1fr;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.correction-toolbar {
align-items: flex-start;
flex-direction: column;
}
.correction-toolbar button {
width: 100%;
}
.correction-fields {
grid-template-columns: 1fr;
}
.modal-actions {
flex-direction: column-reverse;
}
.modal-actions button {
width: 100%;
}
.inline-account-head {
align-items: flex-start;
flex-direction: column;
}
.inline-account-grid {
grid-template-columns: 1fr;
}
th,
td {
padding: 9px 10px;
}
.teacher-group-title {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.teacher-group-title span {
white-space: normal;
}
}