feat: improve summary review layout

This commit is contained in:
Codex
2026-06-15 15:11:12 +08:00
parent 6cd1288b18
commit cc46f651dc
3 changed files with 327 additions and 14 deletions
+44 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理后台</title> <title>管理后台</title>
<link rel="stylesheet" href="/static/styles.css?v=20260615-summary-ingest" /> <link rel="stylesheet" href="/static/styles.css?v=20260615-summary-review-drawer" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -169,6 +169,48 @@
</div> </div>
</section> </section>
<div id="summaryReviewDrawerBackdrop" class="drawer-backdrop" hidden>
<aside class="summary-review-drawer" role="dialog" aria-modal="true" aria-labelledby="summaryReviewDrawerTitle">
<div class="drawer-head">
<div>
<h2 id="summaryReviewDrawerTitle">课程小结详情</h2>
<div id="summaryReviewDrawerMeta" class="drawer-meta"></div>
</div>
<button id="summaryReviewDrawerClose" class="modal-close" type="button">关闭</button>
</div>
<div class="drawer-content">
<div class="drawer-section">
<div class="drawer-label">状态</div>
<div id="summaryReviewDrawerStatus"></div>
</div>
<div class="drawer-section">
<div class="drawer-label">课程信息</div>
<div id="summaryReviewDrawerCourse" class="drawer-text"></div>
</div>
<div class="drawer-section">
<div class="drawer-label">建议登记行</div>
<div id="summaryReviewDrawerLine"></div>
</div>
<div class="drawer-section">
<div class="drawer-label">审核原因</div>
<div id="summaryReviewDrawerReasons" class="drawer-text"></div>
</div>
<div class="drawer-section">
<div class="drawer-label">小结原文</div>
<div id="summaryReviewDrawerBody" class="drawer-body"></div>
</div>
<div class="drawer-section">
<div class="drawer-label">来源</div>
<div id="summaryReviewDrawerSource" class="drawer-text"></div>
</div>
</div>
<div class="drawer-actions">
<button id="summaryReviewDrawerReject" class="secondary-button" type="button">驳回</button>
<button id="summaryReviewDrawerApprove" type="button">批准</button>
</div>
</aside>
</div>
<section id="summarySearchPanel" class="panel admin-panel" hidden> <section id="summarySearchPanel" class="panel admin-panel" hidden>
<div class="section-head"> <div class="section-head">
<h2>课程小结查询</h2> <h2>课程小结查询</h2>
@@ -267,6 +309,6 @@
</section> </section>
</main> </main>
<script src="/static/admin.js?v=20260615-summary-ingest"></script> <script src="/static/admin.js?v=20260615-summary-review-drawer"></script>
</body> </body>
</html> </html>
+117 -12
View File
@@ -31,6 +31,18 @@ const reviewRows = document.querySelector("#reviewRows");
const summaryReviewStatus = document.querySelector("#summaryReviewStatus"); const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
const summaryReviewMeta = document.querySelector("#summaryReviewMeta"); const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
const summaryReviewRows = document.querySelector("#summaryReviewRows"); const summaryReviewRows = document.querySelector("#summaryReviewRows");
const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop");
const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose");
const summaryReviewDrawerTitle = document.querySelector("#summaryReviewDrawerTitle");
const summaryReviewDrawerMeta = document.querySelector("#summaryReviewDrawerMeta");
const summaryReviewDrawerStatus = document.querySelector("#summaryReviewDrawerStatus");
const summaryReviewDrawerCourse = document.querySelector("#summaryReviewDrawerCourse");
const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine");
const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons");
const summaryReviewDrawerBody = document.querySelector("#summaryReviewDrawerBody");
const summaryReviewDrawerSource = document.querySelector("#summaryReviewDrawerSource");
const summaryReviewDrawerApprove = document.querySelector("#summaryReviewDrawerApprove");
const summaryReviewDrawerReject = document.querySelector("#summaryReviewDrawerReject");
const summarySearchForm = document.querySelector("#summarySearchForm"); const summarySearchForm = document.querySelector("#summarySearchForm");
const summarySearchQuery = document.querySelector("#summarySearchQuery"); const summarySearchQuery = document.querySelector("#summarySearchQuery");
const summarySearchStudent = document.querySelector("#summarySearchStudent"); const summarySearchStudent = document.querySelector("#summarySearchStudent");
@@ -55,6 +67,8 @@ const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus");
let currentAccounts = []; let currentAccounts = [];
let editingAccountId = ""; let editingAccountId = "";
let currentSummaryReviews = [];
let activeSummaryReview = null;
function fmtHours(value) { function fmtHours(value) {
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
@@ -301,7 +315,21 @@ async function loadReviews() {
} }
} }
function renderSummaryInfo(summary) { function summaryReviewKind(item) {
const sourceId = String(item.source_id || "");
const reasons = Array.isArray(item.reasons) ? item.reasons.join(" ") : "";
if (sourceId.startsWith("history-missing:") || reasons.includes("历史 classnotes缺失")) {
return "历史缺失";
}
return "";
}
function renderReviewTag(label) {
if (!label) return "";
return `<span class="review-tag history">${escapeHtml(label)}</span>`;
}
function renderSummaryInfo(summary, item = {}) {
const parts = [ const parts = [
summary.student, summary.student,
summary.date_iso, summary.date_iso,
@@ -309,13 +337,65 @@ function renderSummaryInfo(summary) {
summary.teacher, summary.teacher,
summary.subject, summary.subject,
].filter(Boolean); ].filter(Boolean);
return `${parts.map(escapeHtml).join("<br>")}<br><small>${escapeHtml(summary.group || "")}</small>`; return `${renderReviewTag(summaryReviewKind(item))}${parts.map(escapeHtml).join("<br>")}<br><small>${escapeHtml(summary.group || "")}</small>`;
} }
function renderSummaryBody(summary) { function renderSummaryPreview(summary) {
const body = String(summary.body || ""); const body = String(summary.body || "");
const preview = body.length > 260 ? `${body.slice(0, 260)}...` : body; const preview = body.length > 140 ? `${body.slice(0, 140)}...` : body;
return `<div class="summary-body">${escapeHtml(preview)}</div>`; return `<div class="summary-body summary-preview">${escapeHtml(preview || "暂无原文")}</div>`;
}
function findSummaryReview(taskId) {
return currentSummaryReviews.find((item) => String(item.id) === String(taskId));
}
function canReviewSummary(item) {
return item && (item.status === "pending" || item.status === "conflict");
}
function courseInfoLines(summary) {
return [
summary.student ? `学生:${summary.student}` : "",
summary.date_iso ? `日期:${summary.date_iso}` : "",
summary.time_range ? `时间:${summary.time_range}` : "",
summary.teacher || summary.subject ? `老师/科目:${summary.teacher || ""} ${summary.subject || ""}`.trim() : "",
summary.group ? `班级/分组:${summary.group}` : "",
].filter(Boolean);
}
function openSummaryReviewDrawer(taskId) {
const item = findSummaryReview(taskId);
if (!item) return;
const summary = item.summary || {};
const reasons = Array.isArray(item.reasons) ? item.reasons : [];
const kind = summaryReviewKind(item);
const canReview = canReviewSummary(item);
activeSummaryReview = item;
summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`;
summaryReviewDrawerMeta.innerHTML = [
item.created_at ? `提交时间:${escapeHtml(item.created_at)}` : "",
kind ? renderReviewTag(kind) : "",
].filter(Boolean).join(" ");
summaryReviewDrawerStatus.innerHTML = `<span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span>${item.message ? ` <small>${escapeHtml(item.message)}</small>` : ""}`;
summaryReviewDrawerCourse.innerHTML = courseInfoLines(summary).map(escapeHtml).join("<br>") || "暂无课程信息";
summaryReviewDrawerLine.innerHTML = item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>";
summaryReviewDrawerReasons.innerHTML = reasons.length ? reasons.map(escapeHtml).join("<br>") : "待人工复核";
summaryReviewDrawerBody.textContent = summary.body || "暂无原文";
summaryReviewDrawerSource.innerHTML = [
item.source_id ? `来源ID${escapeHtml(item.source_id)}` : "",
item.saved_path ? `保存文件:${escapeHtml(item.saved_path)}` : "",
item.batch_id ? `批次:${escapeHtml(item.batch_id)}` : "",
].filter(Boolean).join("<br>") || "暂无来源信息";
summaryReviewDrawerApprove.disabled = !canReview;
summaryReviewDrawerReject.disabled = !canReview;
summaryReviewDrawerBackdrop.hidden = false;
}
function closeSummaryReviewDrawer() {
activeSummaryReview = null;
summaryReviewDrawerBackdrop.hidden = true;
} }
async function loadSummaryReviews() { async function loadSummaryReviews() {
@@ -324,21 +404,23 @@ async function loadSummaryReviews() {
if (summaryReviewStatus.value) params.set("status", summaryReviewStatus.value); if (summaryReviewStatus.value) params.set("status", summaryReviewStatus.value);
try { try {
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`); const data = await fetchJson(`/api/admin/tasks?${params.toString()}`);
currentSummaryReviews = data.items || [];
summaryReviewMeta.innerHTML = [metric("当前结果", `${data.count}`)].join(""); summaryReviewMeta.innerHTML = [metric("当前结果", `${data.count}`)].join("");
summaryReviewRows.innerHTML = data.items summaryReviewRows.innerHTML = currentSummaryReviews
.map((item) => { .map((item) => {
const canReview = item.status === "pending" || item.status === "conflict"; const canReview = canReviewSummary(item);
const summary = item.summary || {}; const summary = item.summary || {};
const reasons = Array.isArray(item.reasons) ? item.reasons : []; const reasons = Array.isArray(item.reasons) ? item.reasons : [];
return `<tr> return `<tr>
<td>#${escapeHtml(item.id)}</td> <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><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td>${renderSummaryInfo(summary)}</td> <td>${renderSummaryInfo(summary, item)}</td>
<td>${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td> <td>${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td>
<td>${renderSummaryBody(summary)}</td> <td>${renderSummaryPreview(summary)}</td>
<td>${reasons.map(escapeHtml).join("<br>") || "待人工复核"}</td> <td>${reasons.map(escapeHtml).join("<br>") || "待人工复核"}</td>
<td class="record-action-cell"> <td class="record-action-cell">
<div class="record-actions"> <div class="record-actions">
<button class="small-button summary-detail" type="button" data-task-id="${escapeHtml(item.id)}">查看详情</button>
<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>批准</button> <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> <button class="small-button summary-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
</div> </div>
@@ -346,10 +428,11 @@ async function loadSummaryReviews() {
</tr>`; </tr>`;
}) })
.join(""); .join("");
if (!data.items.length) { if (!currentSummaryReviews.length) {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结审核项</td></tr>`; summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结审核项</td></tr>`;
} }
} catch (error) { } catch (error) {
currentSummaryReviews = [];
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`; summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
} }
} }
@@ -457,7 +540,8 @@ async function reviewTask(taskId, action) {
} }
async function summaryReviewTask(taskId, action) { async function summaryReviewTask(taskId, action) {
runTaskAction(taskId, action, loadSummaryReviews); await runTaskAction(taskId, action, loadSummaryReviews);
closeSummaryReviewDrawer();
} }
function linesFromTextarea(textarea) { function linesFromTextarea(textarea) {
@@ -513,11 +597,32 @@ reviewRows.addEventListener("click", (event) => {
}); });
summaryReviewStatus.addEventListener("change", loadSummaryReviews); summaryReviewStatus.addEventListener("change", loadSummaryReviews);
summaryReviewRows.addEventListener("click", (event) => { summaryReviewRows.addEventListener("click", (event) => {
const detail = event.target.closest(".summary-detail");
const approve = event.target.closest(".summary-approve"); const approve = event.target.closest(".summary-approve");
const reject = event.target.closest(".summary-reject"); const reject = event.target.closest(".summary-reject");
if (approve) summaryReviewTask(approve.dataset.taskId, "approve"); if (detail) {
openSummaryReviewDrawer(detail.dataset.taskId);
return;
}
if (approve) {
summaryReviewTask(approve.dataset.taskId, "approve");
return;
}
if (reject) summaryReviewTask(reject.dataset.taskId, "reject"); if (reject) summaryReviewTask(reject.dataset.taskId, "reject");
}); });
summaryReviewDrawerClose.addEventListener("click", closeSummaryReviewDrawer);
summaryReviewDrawerBackdrop.addEventListener("click", (event) => {
if (event.target === summaryReviewDrawerBackdrop) closeSummaryReviewDrawer();
});
summaryReviewDrawerApprove.addEventListener("click", () => {
if (activeSummaryReview) summaryReviewTask(activeSummaryReview.id, "approve");
});
summaryReviewDrawerReject.addEventListener("click", () => {
if (activeSummaryReview) summaryReviewTask(activeSummaryReview.id, "reject");
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !summaryReviewDrawerBackdrop.hidden) closeSummaryReviewDrawer();
});
summarySearchForm.addEventListener("submit", (event) => { summarySearchForm.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
loadSummarySearch(); loadSummarySearch();
+166
View File
@@ -393,6 +393,14 @@ textarea:focus {
line-height: 1.45; line-height: 1.45;
} }
.summary-preview {
display: -webkit-box;
max-height: 4.35em;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.summary-full { .summary-full {
max-width: min(720px, 72vw); max-width: min(720px, 72vw);
margin-top: 8px; margin-top: 8px;
@@ -560,6 +568,7 @@ td {
.record-actions { .record-actions {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
@@ -592,6 +601,25 @@ td {
font-weight: 700; font-weight: 700;
} }
.review-tag {
display: inline-flex;
align-items: center;
width: fit-content;
min-height: 22px;
margin: 0 0 6px;
padding: 0 8px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 12px;
font-weight: 700;
}
.review-tag.history {
background: #fef0c7;
color: var(--warn);
}
.note-cell { .note-cell {
color: var(--muted); color: var(--muted);
white-space: normal; white-space: normal;
@@ -638,6 +666,126 @@ td {
color: #475467; color: #475467;
} }
.drawer-backdrop {
position: fixed;
inset: 0;
z-index: 30;
display: flex;
justify-content: flex-end;
background: rgba(16, 24, 40, 0.38);
}
.drawer-backdrop[hidden] {
display: none;
}
.summary-review-drawer {
display: flex;
flex-direction: column;
width: min(720px, 100%);
max-height: 100vh;
border-left: 1px solid var(--line);
background: var(--panel);
box-shadow: -20px 0 48px rgba(16, 24, 40, 0.2);
}
.drawer-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 20px;
border-bottom: 1px solid var(--line);
}
.drawer-head h2 {
margin: 0 0 6px;
}
.drawer-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 13px;
}
.drawer-content {
display: grid;
gap: 14px;
padding: 18px 20px;
overflow: auto;
}
.drawer-section {
display: grid;
gap: 7px;
}
.drawer-label {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.drawer-text,
.drawer-body {
white-space: pre-wrap;
overflow-wrap: anywhere;
color: #344054;
font-size: 14px;
line-height: 1.55;
}
.drawer-body {
padding: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fafbfc;
}
.drawer-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 20px;
border-top: 1px solid var(--line);
background: #fff;
}
.drawer-actions button {
min-height: 38px;
padding: 0 16px;
border: 0;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
font-weight: 700;
}
.drawer-actions .secondary-button {
border: 1px solid var(--line);
background: #fff;
color: var(--text);
}
.drawer-actions button:hover {
background: var(--accent-strong);
}
.drawer-actions .secondary-button:hover {
border-color: var(--accent);
background: #fff;
color: var(--accent-strong);
}
.drawer-actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -851,6 +999,24 @@ td {
width: 100%; width: 100%;
} }
.drawer-backdrop {
padding-left: 0;
}
.summary-review-drawer {
width: 100%;
}
.drawer-head,
.drawer-actions {
align-items: stretch;
flex-direction: column;
}
.drawer-actions button {
width: 100%;
}
.inline-account-head { .inline-account-head {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;