feat: add admin console and review workflow
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
<!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=20260613-admin-review" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>管理后台</h1>
|
||||
<p id="adminHealthText">正在读取管理状态</p>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<a class="nav-button" href="/">课程记录</a>
|
||||
<a class="nav-button" href="/admin/logout">退出</a>
|
||||
<button id="refreshBtn" class="icon-button" title="刷新" type="button" aria-label="刷新">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout account-layout admin-layout">
|
||||
<nav class="admin-tabs" aria-label="管理后台功能">
|
||||
<button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button>
|
||||
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
|
||||
<button class="admin-tab" data-admin-tab="register" type="button">登记</button>
|
||||
</nav>
|
||||
|
||||
<section id="accountsPanel" class="panel admin-panel">
|
||||
<div class="section-head">
|
||||
<h2>课时账户</h2>
|
||||
<div class="quick-actions">
|
||||
<select id="accountStatus" aria-label="账户状态筛选">
|
||||
<option value="">全部状态</option>
|
||||
<option value="欠费">欠费</option>
|
||||
<option value="预警">预警</option>
|
||||
<option value="正常">正常</option>
|
||||
<option value="结课">结课</option>
|
||||
<option value="退费">退费</option>
|
||||
</select>
|
||||
<button id="newAccountBtn" class="chip" type="button">新增账户</button>
|
||||
</div>
|
||||
</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 id="accountEditor" class="admin-editor" hidden>
|
||||
<div class="section-head compact-head">
|
||||
<h2 id="accountEditorTitle">新增账户</h2>
|
||||
<button id="cancelAccountEditBtn" class="secondary-button" type="button">取消</button>
|
||||
</div>
|
||||
<form id="accountEditForm" class="admin-form">
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
学生ID
|
||||
<input id="editStudentId" autocomplete="off" placeholder="新增时自动生成" />
|
||||
</label>
|
||||
<label>
|
||||
学生姓名
|
||||
<input id="editStudent" autocomplete="off" required />
|
||||
</label>
|
||||
<label>
|
||||
剩余课时
|
||||
<input id="editRemaining" inputmode="decimal" required />
|
||||
</label>
|
||||
<label>
|
||||
账户状态
|
||||
<select id="editStatus">
|
||||
<option value="正常">正常</option>
|
||||
<option value="预警">预警</option>
|
||||
<option value="欠费">欠费</option>
|
||||
<option value="结课">结课</option>
|
||||
<option value="退费">退费</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="span-2">
|
||||
缴费记录
|
||||
<textarea id="editPayments" rows="4" placeholder="每行一条:2026-06-01:53"></textarea>
|
||||
</label>
|
||||
<label class="span-2">
|
||||
备注
|
||||
<textarea id="editNote" rows="3"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<p id="accountEditError" class="correction-error" hidden></p>
|
||||
<div class="modal-actions">
|
||||
<button type="submit">保存账户</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-wrap account-table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>学生</th>
|
||||
<th>状态</th>
|
||||
<th class="num">剩余课时</th>
|
||||
<th>缴费记录</th>
|
||||
<th>备注</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="accountRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="reviewsPanel" class="panel admin-panel" hidden>
|
||||
<div class="section-head">
|
||||
<h2>纠错审核</h2>
|
||||
<select id="reviewStatus" aria-label="审核状态筛选">
|
||||
<option value="pending">待审核</option>
|
||||
<option value="conflict">冲突</option>
|
||||
<option value="approved">已批准</option>
|
||||
<option value="rejected">已驳回</option>
|
||||
<option value="">全部状态</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="reviewMeta" class="summary-grid"></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>编号</th>
|
||||
<th>状态</th>
|
||||
<th>原记录</th>
|
||||
<th>修改后</th>
|
||||
<th>提交时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reviewRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="registerPanel" class="panel admin-panel" hidden>
|
||||
<div class="section-head">
|
||||
<h2>登记</h2>
|
||||
</div>
|
||||
<div class="register-grid">
|
||||
<form id="classRegisterForm" class="admin-form">
|
||||
<h3>上课记录登记</h3>
|
||||
<textarea id="classRegisterLines" rows="10" placeholder="每行一条上课记录"></textarea>
|
||||
<p id="classRegisterStatus" class="inline-account-loading"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="submit">提交上课记录</button>
|
||||
</div>
|
||||
</form>
|
||||
<form id="paymentRegisterForm" class="admin-form">
|
||||
<h3>缴费登记</h3>
|
||||
<textarea id="paymentRegisterLines" rows="10" placeholder="每行一条:学生-2026-06-01:53"></textarea>
|
||||
<p id="paymentRegisterStatus" class="inline-account-loading"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="submit">提交缴费记录</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/admin.js?v=20260613-admin-review"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,346 @@
|
||||
const adminHealthText = document.querySelector("#adminHealthText");
|
||||
const refreshBtn = document.querySelector("#refreshBtn");
|
||||
const panels = {
|
||||
accounts: document.querySelector("#accountsPanel"),
|
||||
reviews: document.querySelector("#reviewsPanel"),
|
||||
register: document.querySelector("#registerPanel"),
|
||||
};
|
||||
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");
|
||||
const accountEditor = document.querySelector("#accountEditor");
|
||||
const accountEditorTitle = document.querySelector("#accountEditorTitle");
|
||||
const accountEditForm = document.querySelector("#accountEditForm");
|
||||
const accountEditError = document.querySelector("#accountEditError");
|
||||
const cancelAccountEditBtn = document.querySelector("#cancelAccountEditBtn");
|
||||
const newAccountBtn = document.querySelector("#newAccountBtn");
|
||||
const editStudentId = document.querySelector("#editStudentId");
|
||||
const editStudent = document.querySelector("#editStudent");
|
||||
const editRemaining = document.querySelector("#editRemaining");
|
||||
const editStatus = document.querySelector("#editStatus");
|
||||
const editPayments = document.querySelector("#editPayments");
|
||||
const editNote = document.querySelector("#editNote");
|
||||
const reviewStatus = document.querySelector("#reviewStatus");
|
||||
const reviewMeta = document.querySelector("#reviewMeta");
|
||||
const reviewRows = document.querySelector("#reviewRows");
|
||||
const classRegisterForm = document.querySelector("#classRegisterForm");
|
||||
const classRegisterLines = document.querySelector("#classRegisterLines");
|
||||
const classRegisterStatus = document.querySelector("#classRegisterStatus");
|
||||
const paymentRegisterForm = document.querySelector("#paymentRegisterForm");
|
||||
const paymentRegisterLines = document.querySelector("#paymentRegisterLines");
|
||||
const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus");
|
||||
|
||||
let currentAccounts = [];
|
||||
let editingAccountId = "";
|
||||
|
||||
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, options = {}) {
|
||||
const response = await fetch(url, { cache: "no-store", ...options });
|
||||
if (!response.ok) {
|
||||
let detail = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const payload = await response.json();
|
||||
detail = payload.detail || detail;
|
||||
} catch (_error) {
|
||||
detail = response.statusText || detail;
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function setActiveTab(tabName) {
|
||||
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
||||
button.classList.toggle("is-active", button.dataset.adminTab === tabName);
|
||||
});
|
||||
Object.entries(panels).forEach(([name, panel]) => {
|
||||
panel.hidden = name !== tabName;
|
||||
});
|
||||
if (tabName === "accounts") loadAccounts();
|
||||
if (tabName === "reviews") loadReviews();
|
||||
}
|
||||
|
||||
async function loadAdminHealth() {
|
||||
try {
|
||||
const data = await fetchJson("/api/account-health");
|
||||
adminHealthText.textContent = `账户 ${data.accounts_count} 人;数据更新时间 ${fmtTime(data.accounts.mtime)}`;
|
||||
} catch (error) {
|
||||
adminHealthText.textContent = `读取失败:${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAccountSummary(summary, count) {
|
||||
accountMeta.innerHTML = [
|
||||
metric("当前结果", `${count} 人`),
|
||||
metric("欠费", summary.debt),
|
||||
metric("预警", summary.warning),
|
||||
metric("正常", summary.normal),
|
||||
].join("");
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
accountRows.innerHTML = `<tr><td colspan="6" 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()}`);
|
||||
currentAccounts = data.accounts;
|
||||
renderAccountSummary(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>
|
||||
<td><button class="small-button account-edit" type="button" data-student-id="${escapeHtml(row.student_id)}">编辑</button></td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
if (!data.accounts.length) {
|
||||
accountRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的账户</td></tr>`;
|
||||
}
|
||||
} catch (error) {
|
||||
accountRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function paymentsToText(payments) {
|
||||
return payments.map((item) => `${item.date}:${item.hours}`).join("\n");
|
||||
}
|
||||
|
||||
function parsePaymentsInput(value) {
|
||||
return value
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => {
|
||||
const [date, hoursText] = item.split(":");
|
||||
if (!date || hoursText === undefined) {
|
||||
throw new Error(`缴费记录格式错误:${item}`);
|
||||
}
|
||||
const hours = Number(hoursText);
|
||||
if (!Number.isFinite(hours)) {
|
||||
throw new Error(`缴费课时格式错误:${item}`);
|
||||
}
|
||||
return { date: date.trim(), hours };
|
||||
});
|
||||
}
|
||||
|
||||
function openNewAccountEditor() {
|
||||
editingAccountId = "";
|
||||
accountEditorTitle.textContent = "新增账户";
|
||||
editStudentId.value = "";
|
||||
editStudentId.placeholder = "保存时自动生成";
|
||||
editStudent.value = "";
|
||||
editRemaining.value = "0";
|
||||
editStatus.value = "正常";
|
||||
editPayments.value = "";
|
||||
editNote.value = "";
|
||||
accountEditError.hidden = true;
|
||||
accountEditor.hidden = false;
|
||||
editStudent.focus();
|
||||
}
|
||||
|
||||
function openEditAccountEditor(studentId) {
|
||||
const account = currentAccounts.find((item) => item.student_id === studentId);
|
||||
if (!account) return;
|
||||
editingAccountId = account.student_id;
|
||||
accountEditorTitle.textContent = `编辑账户:${account.student}`;
|
||||
editStudentId.value = account.student_id;
|
||||
editStudentId.placeholder = "";
|
||||
editStudent.value = account.student;
|
||||
editRemaining.value = account.remaining;
|
||||
editStatus.value = account.account_status;
|
||||
editPayments.value = paymentsToText(account.payments);
|
||||
editNote.value = account.note || "";
|
||||
accountEditError.hidden = true;
|
||||
accountEditor.hidden = false;
|
||||
editStudent.focus();
|
||||
}
|
||||
|
||||
function buildAccountPayload() {
|
||||
const remaining = Number(editRemaining.value);
|
||||
if (!Number.isFinite(remaining)) {
|
||||
throw new Error("剩余课时必须是数字");
|
||||
}
|
||||
return {
|
||||
student_id: editStudentId.value.trim(),
|
||||
student: editStudent.value.trim(),
|
||||
payments: parsePaymentsInput(editPayments.value),
|
||||
remaining,
|
||||
account_status: editStatus.value,
|
||||
note: editNote.value.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function saveAccount(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const payload = buildAccountPayload();
|
||||
const url = editingAccountId
|
||||
? `/api/admin/accounts/${encodeURIComponent(editingAccountId)}`
|
||||
: "/api/admin/accounts";
|
||||
const method = editingAccountId ? "PUT" : "POST";
|
||||
await fetchJson(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
accountEditor.hidden = true;
|
||||
await loadAdminHealth();
|
||||
await loadAccounts();
|
||||
} catch (error) {
|
||||
accountEditError.textContent = error.message;
|
||||
accountEditError.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderReviewLine(line) {
|
||||
return `<code class="line-code">${escapeHtml(line)}</code>`;
|
||||
}
|
||||
|
||||
async function loadReviews() {
|
||||
reviewRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
|
||||
const params = new URLSearchParams({ type: "class_record_correction" });
|
||||
if (reviewStatus.value) params.set("status", reviewStatus.value);
|
||||
try {
|
||||
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`);
|
||||
reviewMeta.innerHTML = [metric("当前结果", `${data.count} 条`)].join("");
|
||||
reviewRows.innerHTML = data.items
|
||||
.map((item) => {
|
||||
const canReview = item.status === "pending" || item.status === "conflict";
|
||||
return `<tr>
|
||||
<td>#${escapeHtml(item.id)}</td>
|
||||
<td><span class="status ${item.status === "approved" ? "normal" : item.status === "rejected" ? "closed" : item.status === "conflict" ? "debt" : "warning"}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
|
||||
<td>${renderReviewLine(item.original_line)}</td>
|
||||
<td>${renderReviewLine(item.corrected_line)}</td>
|
||||
<td>${escapeHtml(item.created_at || "")}</td>
|
||||
<td class="record-action-cell">
|
||||
<div class="record-actions">
|
||||
<button class="small-button review-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>批准</button>
|
||||
<button class="small-button review-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
if (!data.items.length) {
|
||||
reviewRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的审核项</td></tr>`;
|
||||
}
|
||||
} catch (error) {
|
||||
reviewRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewTask(taskId, action) {
|
||||
try {
|
||||
await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/${action}`, { method: "POST" });
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
loadReviews();
|
||||
}
|
||||
|
||||
function linesFromTextarea(textarea) {
|
||||
return textarea.value
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function submitRegister(event, textarea, statusNode, url) {
|
||||
event.preventDefault();
|
||||
statusNode.textContent = "正在提交";
|
||||
try {
|
||||
const data = await fetchJson(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ lines: linesFromTextarea(textarea) }),
|
||||
});
|
||||
statusNode.textContent = `已登记 ${data.registered} 条;备份 ${data.backup_id}`;
|
||||
textarea.value = "";
|
||||
await loadAdminHealth();
|
||||
await loadAccounts();
|
||||
} catch (error) {
|
||||
statusNode.textContent = `提交失败:${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
||||
button.addEventListener("click", () => setActiveTab(button.dataset.adminTab));
|
||||
});
|
||||
|
||||
accountForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
loadAccounts();
|
||||
});
|
||||
accountStatus.addEventListener("change", loadAccounts);
|
||||
newAccountBtn.addEventListener("click", openNewAccountEditor);
|
||||
cancelAccountEditBtn.addEventListener("click", () => {
|
||||
accountEditor.hidden = true;
|
||||
});
|
||||
accountEditForm.addEventListener("submit", saveAccount);
|
||||
accountRows.addEventListener("click", (event) => {
|
||||
const button = event.target.closest(".account-edit");
|
||||
if (!button) return;
|
||||
openEditAccountEditor(button.dataset.studentId);
|
||||
});
|
||||
reviewStatus.addEventListener("change", loadReviews);
|
||||
reviewRows.addEventListener("click", (event) => {
|
||||
const approve = event.target.closest(".review-approve");
|
||||
const reject = event.target.closest(".review-reject");
|
||||
if (approve) reviewTask(approve.dataset.taskId, "approve");
|
||||
if (reject) reviewTask(reject.dataset.taskId, "reject");
|
||||
});
|
||||
classRegisterForm.addEventListener("submit", (event) => {
|
||||
submitRegister(event, classRegisterLines, classRegisterStatus, "/api/register/class-records");
|
||||
});
|
||||
paymentRegisterForm.addEventListener("submit", (event) => {
|
||||
submitRegister(event, paymentRegisterLines, paymentRegisterStatus, "/api/register/payments");
|
||||
});
|
||||
refreshBtn.addEventListener("click", () => {
|
||||
loadAdminHealth();
|
||||
if (!panels.accounts.hidden) loadAccounts();
|
||||
if (!panels.reviews.hidden) loadReviews();
|
||||
});
|
||||
|
||||
loadAdminHealth();
|
||||
loadAccounts();
|
||||
+34
-2
@@ -10,6 +10,7 @@ const inlineAccount = document.querySelector("#inlineAccount");
|
||||
const correctionToolbar = document.querySelector("#correctionToolbar");
|
||||
const correctionStatus = document.querySelector("#correctionStatus");
|
||||
const copyCorrectedBtn = document.querySelector("#copyCorrectedBtn");
|
||||
const submitCorrectionsBtn = document.querySelector("#submitCorrectionsBtn");
|
||||
const correctionDialog = document.querySelector("#correctionDialog");
|
||||
const correctionForm = document.querySelector("#correctionForm");
|
||||
const correctionOriginal = document.querySelector("#correctionOriginal");
|
||||
@@ -260,7 +261,9 @@ function updateCorrectionToolbar(message = "", isError = false) {
|
||||
const count = correctedRecords.size;
|
||||
correctionToolbar.hidden = count === 0;
|
||||
copyCorrectedBtn.disabled = count === 0;
|
||||
submitCorrectionsBtn.disabled = count === 0;
|
||||
copyCorrectedBtn.textContent = `复制已修改记录 ${count} 条`;
|
||||
submitCorrectionsBtn.textContent = `提交审核 ${count} 条`;
|
||||
correctionStatus.textContent = message || `已修改 ${count} 条记录`;
|
||||
correctionStatus.classList.toggle("is-error", isError);
|
||||
}
|
||||
@@ -435,8 +438,36 @@ async function copyCorrectedRecords() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
async function submitCorrectedRecords() {
|
||||
const items = currentRecordOrder
|
||||
.map((key) => {
|
||||
const original = findCurrentRecord(key);
|
||||
const corrected = correctedRecords.get(key);
|
||||
if (!original || !corrected) return null;
|
||||
return {
|
||||
original_line: buildRecordLine(original),
|
||||
corrected_line: buildRecordLine(corrected),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (!items.length) return;
|
||||
submitCorrectionsBtn.disabled = true;
|
||||
try {
|
||||
const data = await fetchJson("/api/corrections", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
updateCorrectionToolbar(`已提交 ${data.submitted} 条纠错审核`);
|
||||
} catch (error) {
|
||||
updateCorrectionToolbar(`提交失败:${error.message}`, true);
|
||||
} finally {
|
||||
submitCorrectionsBtn.disabled = correctedRecords.size === 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const response = await fetch(url, { cache: "no-store", ...options });
|
||||
if (!response.ok) {
|
||||
let detail = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
@@ -550,6 +581,7 @@ document.addEventListener("keydown", (event) => {
|
||||
});
|
||||
|
||||
copyCorrectedBtn.addEventListener("click", copyCorrectedRecords);
|
||||
submitCorrectionsBtn.addEventListener("click", submitCorrectedRecords);
|
||||
|
||||
updateRecordSortButtons();
|
||||
loadHealth();
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<p id="healthText">正在读取数据状态</p>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<a class="nav-button" href="/accounts">课时账户</a>
|
||||
<a class="nav-button" href="/admin">管理后台</a>
|
||||
<button id="refreshBtn" class="icon-button" title="刷新数据" type="button" aria-label="刷新数据">
|
||||
↻
|
||||
</button>
|
||||
@@ -43,7 +43,10 @@
|
||||
<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 class="toolbar-actions">
|
||||
<button id="copyCorrectedBtn" type="button">复制已修改记录</button>
|
||||
<button id="submitCorrectionsBtn" type="button">提交审核</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="inlineAccount" class="inline-account" hidden></div>
|
||||
<div class="table-wrap">
|
||||
@@ -116,6 +119,6 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js?v=20260613-sort-labels"></script>
|
||||
<script src="/static/app.js?v=20260613-admin-review"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -125,6 +125,10 @@ h2 {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
@@ -150,6 +154,30 @@ h2 {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-tab.is-active,
|
||||
.admin-tab:hover {
|
||||
border-color: var(--accent);
|
||||
background: #e9f5f3;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.chip,
|
||||
.search-row button {
|
||||
border: 0;
|
||||
@@ -280,6 +308,77 @@ select:focus {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-editor {
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.compact-head {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-form h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15);
|
||||
}
|
||||
|
||||
.register-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.line-code {
|
||||
display: block;
|
||||
max-width: 420px;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: Arial, "Songti SC", SimSun, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.correction-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -300,6 +399,12 @@ select:focus {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.correction-toolbar button,
|
||||
.modal-actions button {
|
||||
min-height: 38px;
|
||||
@@ -638,6 +743,10 @@ td {
|
||||
.inline-account-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.register-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -679,6 +788,11 @@ td {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.correction-toolbar button {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -687,6 +801,18 @@ td {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user