feat: ingest course summaries on vps
This commit is contained in:
+76
-2
@@ -4,7 +4,7 @@
|
||||
<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" />
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20260615-summary-ingest" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -23,6 +23,8 @@
|
||||
<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="summaries" type="button">课程小结审核</button>
|
||||
<button class="admin-tab" data-admin-tab="logs" type="button">操作记录</button>
|
||||
<button class="admin-tab" data-admin-tab="register" type="button">登记</button>
|
||||
</nav>
|
||||
|
||||
@@ -136,6 +138,78 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="summariesPanel" class="panel admin-panel" hidden>
|
||||
<div class="section-head">
|
||||
<h2>课程小结审核</h2>
|
||||
<select id="summaryReviewStatus" 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="summaryReviewMeta" class="summary-grid"></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>编号</th>
|
||||
<th>状态</th>
|
||||
<th>课程信息</th>
|
||||
<th>候选记录</th>
|
||||
<th>小结原文</th>
|
||||
<th>原因</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="summaryReviewRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="logsPanel" class="panel admin-panel" hidden>
|
||||
<div class="section-head">
|
||||
<h2>操作记录</h2>
|
||||
<div class="quick-actions">
|
||||
<select id="logOperation" aria-label="操作类型筛选">
|
||||
<option value="">全部操作</option>
|
||||
<option value="course_summary_ingest">小结接收</option>
|
||||
<option value="admin_task_approve">审核批准</option>
|
||||
<option value="admin_task_reject">审核驳回</option>
|
||||
</select>
|
||||
<select id="logStatus" aria-label="操作结果筛选">
|
||||
<option value="">全部结果</option>
|
||||
<option value="auto_registered">自动入账</option>
|
||||
<option value="review">待审核</option>
|
||||
<option value="duplicate">重复</option>
|
||||
<option value="rejected">失败/驳回</option>
|
||||
<option value="approved">已批准</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<form id="logFilterForm" class="search-row log-search-row">
|
||||
<input id="logStudent" autocomplete="off" placeholder="按学生筛选" />
|
||||
<button type="submit">筛选</button>
|
||||
</form>
|
||||
<div id="logMeta" 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="logRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="registerPanel" class="panel admin-panel" hidden>
|
||||
<div class="section-head">
|
||||
<h2>登记</h2>
|
||||
@@ -161,6 +235,6 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/admin.js?v=20260613-admin-review"></script>
|
||||
<script src="/static/admin.js?v=20260615-summary-ingest"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+137
-2
@@ -3,6 +3,8 @@ const refreshBtn = document.querySelector("#refreshBtn");
|
||||
const panels = {
|
||||
accounts: document.querySelector("#accountsPanel"),
|
||||
reviews: document.querySelector("#reviewsPanel"),
|
||||
summaries: document.querySelector("#summariesPanel"),
|
||||
logs: document.querySelector("#logsPanel"),
|
||||
register: document.querySelector("#registerPanel"),
|
||||
};
|
||||
const accountForm = document.querySelector("#accountForm");
|
||||
@@ -25,6 +27,15 @@ const editNote = document.querySelector("#editNote");
|
||||
const reviewStatus = document.querySelector("#reviewStatus");
|
||||
const reviewMeta = document.querySelector("#reviewMeta");
|
||||
const reviewRows = document.querySelector("#reviewRows");
|
||||
const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
|
||||
const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
|
||||
const summaryReviewRows = document.querySelector("#summaryReviewRows");
|
||||
const logOperation = document.querySelector("#logOperation");
|
||||
const logStatus = document.querySelector("#logStatus");
|
||||
const logFilterForm = document.querySelector("#logFilterForm");
|
||||
const logStudent = document.querySelector("#logStudent");
|
||||
const logMeta = document.querySelector("#logMeta");
|
||||
const logRows = document.querySelector("#logRows");
|
||||
const classRegisterForm = document.querySelector("#classRegisterForm");
|
||||
const classRegisterLines = document.querySelector("#classRegisterLines");
|
||||
const classRegisterStatus = document.querySelector("#classRegisterStatus");
|
||||
@@ -64,6 +75,13 @@ function statusClass(status) {
|
||||
return "closed";
|
||||
}
|
||||
|
||||
function taskStatusClass(status) {
|
||||
if (status === "approved" || status === "auto_registered") return "normal";
|
||||
if (status === "rejected" || status === "duplicate") return "closed";
|
||||
if (status === "conflict") return "debt";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function renderPayments(payments) {
|
||||
if (!payments.length) return "暂无";
|
||||
return payments.map((item) => `${escapeHtml(item.date)}:${fmtHours(item.hours)} 小时`).join("<br>");
|
||||
@@ -93,6 +111,8 @@ function setActiveTab(tabName) {
|
||||
});
|
||||
if (tabName === "accounts") loadAccounts();
|
||||
if (tabName === "reviews") loadReviews();
|
||||
if (tabName === "summaries") loadSummaryReviews();
|
||||
if (tabName === "logs") loadOperationLogs();
|
||||
}
|
||||
|
||||
async function loadAdminHealth() {
|
||||
@@ -270,13 +290,113 @@ async function loadReviews() {
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewTask(taskId, action) {
|
||||
function renderSummaryInfo(summary) {
|
||||
const parts = [
|
||||
summary.student,
|
||||
summary.date_iso,
|
||||
summary.time_range,
|
||||
summary.teacher,
|
||||
summary.subject,
|
||||
].filter(Boolean);
|
||||
return `${parts.map(escapeHtml).join("<br>")}<br><small>${escapeHtml(summary.group || "")}</small>`;
|
||||
}
|
||||
|
||||
function renderSummaryBody(summary) {
|
||||
const body = String(summary.body || "");
|
||||
const preview = body.length > 260 ? `${body.slice(0, 260)}...` : body;
|
||||
return `<div class="summary-body">${escapeHtml(preview)}</div>`;
|
||||
}
|
||||
|
||||
async function loadSummaryReviews() {
|
||||
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
|
||||
const params = new URLSearchParams({ type: "course_summary_review" });
|
||||
if (summaryReviewStatus.value) params.set("status", summaryReviewStatus.value);
|
||||
try {
|
||||
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`);
|
||||
summaryReviewMeta.innerHTML = [metric("当前结果", `${data.count} 条`)].join("");
|
||||
summaryReviewRows.innerHTML = data.items
|
||||
.map((item) => {
|
||||
const canReview = item.status === "pending" || item.status === "conflict";
|
||||
const summary = item.summary || {};
|
||||
const reasons = Array.isArray(item.reasons) ? item.reasons : [];
|
||||
return `<tr>
|
||||
<td>#${escapeHtml(item.id)}</td>
|
||||
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
|
||||
<td>${renderSummaryInfo(summary)}</td>
|
||||
<td>${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td>
|
||||
<td>${renderSummaryBody(summary)}</td>
|
||||
<td>${reasons.map(escapeHtml).join("<br>") || "待人工复核"}</td>
|
||||
<td class="record-action-cell">
|
||||
<div class="record-actions">
|
||||
<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>批准</button>
|
||||
<button class="small-button summary-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
if (!data.items.length) {
|
||||
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结审核项</td></tr>`;
|
||||
}
|
||||
} catch (error) {
|
||||
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogDetail(item) {
|
||||
const details = [];
|
||||
if (item.source_id) details.push(`来源:${item.source_id}`);
|
||||
if (item.teacher || item.subject) details.push(`老师/科目:${item.teacher || ""} ${item.subject || ""}`.trim());
|
||||
if (item.proposed_line) details.push(`记录:${item.proposed_line}`);
|
||||
if (Array.isArray(item.reasons) && item.reasons.length) details.push(`原因:${item.reasons.join(";")}`);
|
||||
if (item.error) details.push(`错误:${item.error}`);
|
||||
if (item.backup_id) details.push(`备份:${item.backup_id}`);
|
||||
if (item.saved_path) details.push(`文件:${item.saved_path}`);
|
||||
return `<div class="log-detail">${details.map(escapeHtml).join("<br>") || "暂无详情"}</div>`;
|
||||
}
|
||||
|
||||
async function loadOperationLogs() {
|
||||
logRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
|
||||
const params = new URLSearchParams({ limit: "200" });
|
||||
if (logOperation.value) params.set("operation", logOperation.value);
|
||||
if (logStatus.value) params.set("status", logStatus.value);
|
||||
if (logStudent.value.trim()) params.set("student", logStudent.value.trim());
|
||||
try {
|
||||
const data = await fetchJson(`/api/admin/operation-logs?${params.toString()}`);
|
||||
logMeta.innerHTML = [metric("当前结果", `${data.count} 条`)].join("");
|
||||
logRows.innerHTML = data.items
|
||||
.map((item) => `<tr>
|
||||
<td>${escapeHtml(item.created_at || "")}</td>
|
||||
<td>${escapeHtml(item.operation || "")}</td>
|
||||
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span></td>
|
||||
<td>${escapeHtml(item.student || "")}</td>
|
||||
<td>${escapeHtml(item.batch_id || "")}${item.task_id ? `<br><small>任务 #${escapeHtml(item.task_id)}</small>` : ""}</td>
|
||||
<td>${renderLogDetail(item)}</td>
|
||||
</tr>`)
|
||||
.join("");
|
||||
if (!data.items.length) {
|
||||
logRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的操作记录</td></tr>`;
|
||||
}
|
||||
} catch (error) {
|
||||
logRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function runTaskAction(taskId, action, reload) {
|
||||
try {
|
||||
await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/${action}`, { method: "POST" });
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
loadReviews();
|
||||
reload();
|
||||
}
|
||||
|
||||
async function reviewTask(taskId, action) {
|
||||
runTaskAction(taskId, action, loadReviews);
|
||||
}
|
||||
|
||||
async function summaryReviewTask(taskId, action) {
|
||||
runTaskAction(taskId, action, loadSummaryReviews);
|
||||
}
|
||||
|
||||
function linesFromTextarea(textarea) {
|
||||
@@ -330,6 +450,19 @@ reviewRows.addEventListener("click", (event) => {
|
||||
if (approve) reviewTask(approve.dataset.taskId, "approve");
|
||||
if (reject) reviewTask(reject.dataset.taskId, "reject");
|
||||
});
|
||||
summaryReviewStatus.addEventListener("change", loadSummaryReviews);
|
||||
summaryReviewRows.addEventListener("click", (event) => {
|
||||
const approve = event.target.closest(".summary-approve");
|
||||
const reject = event.target.closest(".summary-reject");
|
||||
if (approve) summaryReviewTask(approve.dataset.taskId, "approve");
|
||||
if (reject) summaryReviewTask(reject.dataset.taskId, "reject");
|
||||
});
|
||||
logOperation.addEventListener("change", loadOperationLogs);
|
||||
logStatus.addEventListener("change", loadOperationLogs);
|
||||
logFilterForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
loadOperationLogs();
|
||||
});
|
||||
classRegisterForm.addEventListener("submit", (event) => {
|
||||
submitRegister(event, classRegisterLines, classRegisterStatus, "/api/register/class-records");
|
||||
});
|
||||
@@ -340,6 +473,8 @@ refreshBtn.addEventListener("click", () => {
|
||||
loadAdminHealth();
|
||||
if (!panels.accounts.hidden) loadAccounts();
|
||||
if (!panels.reviews.hidden) loadReviews();
|
||||
if (!panels.summaries.hidden) loadSummaryReviews();
|
||||
if (!panels.logs.hidden) loadOperationLogs();
|
||||
});
|
||||
|
||||
loadAdminHealth();
|
||||
|
||||
@@ -379,6 +379,20 @@ textarea:focus {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.summary-body,
|
||||
.log-detail {
|
||||
max-width: 360px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.correction-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user