初始化新时空数据应用
This commit is contained in:
@@ -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>
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user