feat: ingest course summaries on vps

This commit is contained in:
yangdawei
2026-06-15 11:22:52 +08:00
parent b2913dbd8a
commit a39f01b1e3
12 changed files with 1226 additions and 13 deletions
+137 -2
View File
@@ -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();