From 25f32cc44b69b2e57843f38b48954cf7a645bc71 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 16 Jun 2026 20:03:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=AF=BE=E7=A8=8B=E5=B0=8F?= =?UTF-8?q?=E7=BB=93=E5=AE=A1=E6=A0=B8=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/data.py | 76 +++++++++++++++++++++++++++++++++++++++ app/app/routers/admin.py | 50 ++++++++++++++++++++++++++ app/app/static/admin.html | 29 +++++++++++++++ app/app/static/admin.js | 74 ++++++++++++++++++++++++++++++++++++++ app/app/static/styles.css | 4 +++ 5 files changed, 233 insertions(+) diff --git a/app/app/data.py b/app/app/data.py index 9d1f143..e67d213 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -1585,6 +1585,8 @@ OPERATION_LABELS = { "admin_course_summary_delete": "课程小结删除", "admin_course_summary_duplicate_scan": "重复小结扫描", "admin_course_summary_duplicate_delete": "重复小结删除", + "admin_course_summary_review_update": "课程小结审核修正", + "admin_course_summary_link_existing": "课程小结关联已有记录", "register-class-records": "登记上课记录", "register-payments": "登记缴费记录", "admin-create-account": "新增课时账户", @@ -2553,6 +2555,80 @@ def create_incomplete_course_summary_review_task( return create_course_summary_review_task(tasks_path, summary, "", reasons) +def editable_course_summary_payload(summary: dict, updates: dict) -> dict: + merged = dict(summary) + for key in ("student", "date_iso", "time_range", "teacher", "subject"): + if key in updates: + merged[key] = str(updates.get(key) or "").strip() + normalized = normalize_course_summary(merged) + normalized["source_id"] = str(summary.get("source_id") or normalized.get("source_id") or "") + normalized["group"] = str(summary.get("group") or "") + normalized["sender"] = str(summary.get("sender") or "") + normalized["sender_id"] = str(summary.get("sender_id") or "") + normalized["message_time"] = str(summary.get("message_time") or "") + normalized["message_date"] = str(summary.get("message_date") or normalized.get("message_date") or "") + normalized["db"] = str(summary.get("db") or "") + normalized["local_id"] = str(summary.get("local_id") or "") + normalized["title"] = str(summary.get("title") or "") + normalized["body"] = str(summary.get("body") or normalized.get("body") or "") + normalized["recognition_source"] = str(summary.get("recognition_source") or "") + normalized["confidence"] = str(summary.get("confidence") or "") + normalized["teacher_trusted"] = True + normalized["remark"] = str(summary.get("remark") or "") + return normalized + + +def update_course_summary_review_task( + tasks_path: Path, + classnotes_path: Path, + accounts_path: Path, + task_id: int, + updates: dict, +) -> dict: + tasks = read_admin_tasks(tasks_path) + task = find_admin_task(tasks, task_id) + if task.get("type") != "course_summary_review": + raise ValueError("该任务不是课程小结审核") + if task.get("status") not in {"pending", "conflict"}: + raise ValueError("该任务已处理,不能修改") + summary = task.get("summary") or {} + updated_summary = editable_course_summary_payload(summary, updates) + reasons, proposed_line = auto_register_reasons(updated_summary, classnotes_path, accounts_path) + now = datetime.now().isoformat(timespec="seconds") + task["status"] = "pending" + task["updated_at"] = now + task.pop("message", None) + task["student"] = updated_summary["student"] + task["summary"] = updated_summary + task["proposed_line"] = proposed_line + task["reasons"] = reasons + write_admin_tasks(tasks_path, tasks) + return {"task": task_to_dict(task)} + + +def link_existing_course_summary_task(tasks_path: Path, classnotes_path: Path, task_id: int) -> dict: + tasks = read_admin_tasks(tasks_path) + task = find_admin_task(tasks, task_id) + if task.get("type") != "course_summary_review": + raise ValueError("该任务不是课程小结审核") + if task.get("status") not in {"pending", "conflict"}: + raise ValueError("该任务已处理,不能重复操作") + proposed_line = str(task.get("proposed_line") or "").strip() + if not proposed_line: + raise ValueError("课程小结缺少候选登记行,不能关联已有记录") + existing_lines = {raw.strip() for raw in classnotes_path.read_text(encoding="utf-8").splitlines()} + if proposed_line not in existing_lines: + raise ValueError("候选登记行在上课记录中不存在,不能关联已有记录") + now = datetime.now().isoformat(timespec="seconds") + task["status"] = "approved" + task["updated_at"] = now + task["reviewed_at"] = now + task["linked_record_line"] = proposed_line + task["message"] = "已关联已有上课记录,未重复扣课时" + write_admin_tasks(tasks_path, tasks) + return {"task": task_to_dict(task), "linked_record_line": proposed_line} + + def approve_course_summary_task( tasks_path: Path, classnotes_path: Path, diff --git a/app/app/routers/admin.py b/app/app/routers/admin.py index c4c78e4..1420f08 100644 --- a/app/app/routers/admin.py +++ b/app/app/routers/admin.py @@ -18,6 +18,7 @@ from ..data import ( approve_admin_task, create_course_summary_duplicate_review_tasks, delete_course_summary, + link_existing_course_summary_task, list_admin_tasks, list_operation_logs, migrate_operation_log_labels, @@ -25,6 +26,7 @@ from ..data import ( reject_admin_task, resolve_duplicate_course_summary_task, rollback_operation_log, + update_course_summary_review_task, update_course_summary_time, ) @@ -168,6 +170,54 @@ def admin_resolve_duplicate_summary(task_id: int, payload: dict, _user: str = De return {"ok": True, **result} +@router.post("/api/admin/tasks/{task_id}/course-summary-review") +def admin_update_course_summary_review(task_id: int, payload: dict, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + result = update_course_summary_review_task( + ADMIN_TASKS_PATH, + CLASSNOTES_PATH, + ACCOUNTS_PATH, + task_id, + payload, + ) + task = result.get("task", {}) + append_operation_log( + OPERATION_LOGS_PATH, + "课程小结审核修正", + "已更新", + task_id=task_id, + task_type=str(task.get("type") or ""), + student=str(task.get("student") or ""), + source_id=str(task.get("source_id") or ""), + proposed_line=str(task.get("proposed_line") or ""), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} + + +@router.post("/api/admin/tasks/{task_id}/link-existing-course-summary") +def admin_link_existing_course_summary(task_id: int, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + result = link_existing_course_summary_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, task_id) + task = result.get("task", {}) + append_operation_log( + OPERATION_LOGS_PATH, + "课程小结关联已有记录", + "已批准", + task_id=task_id, + task_type=str(task.get("type") or ""), + student=str(task.get("student") or ""), + source_id=str(task.get("source_id") or ""), + proposed_line=str(result.get("linked_record_line") or ""), + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return {"ok": True, **result} + + @router.post("/api/admin/tasks/{task_id}/reject") def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)): try: diff --git a/app/app/static/admin.html b/app/app/static/admin.html index fe425ab..a58d509 100644 --- a/app/app/static/admin.html +++ b/app/app/static/admin.html @@ -267,6 +267,31 @@
课程信息
+
建议登记行
@@ -285,6 +310,8 @@
+ +
@@ -341,6 +368,8 @@ + + diff --git a/app/app/static/admin.js b/app/app/static/admin.js index 41ebae1..5a6034e 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -56,10 +56,18 @@ const summaryReviewDrawerTitle = document.querySelector("#summaryReviewDrawerTit const summaryReviewDrawerMeta = document.querySelector("#summaryReviewDrawerMeta"); const summaryReviewDrawerStatus = document.querySelector("#summaryReviewDrawerStatus"); const summaryReviewDrawerCourse = document.querySelector("#summaryReviewDrawerCourse"); +const summaryReviewEditSection = document.querySelector("#summaryReviewEditSection"); +const summaryReviewEditStudent = document.querySelector("#summaryReviewEditStudent"); +const summaryReviewEditDate = document.querySelector("#summaryReviewEditDate"); +const summaryReviewEditTime = document.querySelector("#summaryReviewEditTime"); +const summaryReviewEditTeacher = document.querySelector("#summaryReviewEditTeacher"); +const summaryReviewEditSubject = document.querySelector("#summaryReviewEditSubject"); const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine"); const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons"); const summaryReviewDrawerBody = document.querySelector("#summaryReviewDrawerBody"); const summaryReviewDrawerSource = document.querySelector("#summaryReviewDrawerSource"); +const summaryReviewDrawerSave = document.querySelector("#summaryReviewDrawerSave"); +const summaryReviewDrawerLink = document.querySelector("#summaryReviewDrawerLink"); const summaryReviewDrawerApprove = document.querySelector("#summaryReviewDrawerApprove"); const summaryReviewDrawerReject = document.querySelector("#summaryReviewDrawerReject"); const summarySearchForm = document.querySelector("#summarySearchForm"); @@ -574,6 +582,11 @@ function canReviewSummary(item) { return item && (item.status === "pending" || item.status === "conflict"); } +function canLinkExistingSummary(item) { + const reasons = Array.isArray(item && item.reasons) ? item.reasons : []; + return Boolean(item && item.proposed_line && reasons.includes("classnotes 已存在同一条上课记录")); +} + function courseInfoLines(summary) { return [ summary.student ? `学生:${summary.student}` : "", @@ -584,6 +597,24 @@ function courseInfoLines(summary) { ].filter(Boolean); } +function fillSummaryReviewEditor(summary) { + summaryReviewEditStudent.value = summary.student || ""; + summaryReviewEditDate.value = summary.date_iso || ""; + summaryReviewEditTime.value = summary.time_range || ""; + summaryReviewEditTeacher.value = summary.teacher || ""; + summaryReviewEditSubject.value = summary.subject || ""; +} + +function summaryReviewEditorPayload() { + return { + student: summaryReviewEditStudent.value.trim(), + date_iso: summaryReviewEditDate.value, + time_range: summaryReviewEditTime.value.trim(), + teacher: summaryReviewEditTeacher.value.trim(), + subject: summaryReviewEditSubject.value.trim(), + }; +} + function openSummaryReviewDrawer(taskId) { const item = findSummaryReview(taskId); if (!item) return; @@ -601,6 +632,8 @@ function openSummaryReviewDrawer(taskId) { ].filter(Boolean).join(" "); summaryReviewDrawerStatus.innerHTML = `${escapeHtml(item.status || "")}${item.message ? ` ${escapeHtml(item.message)}` : ""}`; summaryReviewDrawerCourse.innerHTML = courseInfoLines(summary).map(escapeHtml).join("
") || "暂无课程信息"; + summaryReviewEditSection.hidden = duplicateReview; + if (!duplicateReview) fillSummaryReviewEditor(summary); summaryReviewDrawerLine.innerHTML = duplicateReview ? "请选择下方重复小结删除" : item.proposed_line ? renderReviewLine(item.proposed_line) : "暂无"; @@ -615,6 +648,10 @@ function openSummaryReviewDrawer(taskId) { item.saved_path ? `保存文件:${escapeHtml(item.saved_path)}` : "", item.batch_id ? `批次:${escapeHtml(item.batch_id)}` : "", ].filter(Boolean).join("
") || "暂无来源信息"; + summaryReviewDrawerSave.hidden = duplicateReview; + summaryReviewDrawerSave.disabled = !canReview || duplicateReview; + summaryReviewDrawerLink.hidden = duplicateReview || !canLinkExistingSummary(item); + summaryReviewDrawerLink.disabled = !canReview || duplicateReview || !canLinkExistingSummary(item); summaryReviewDrawerApprove.hidden = duplicateReview; summaryReviewDrawerApprove.disabled = !canReview || duplicateReview; summaryReviewDrawerReject.disabled = !canReview; @@ -623,6 +660,9 @@ function openSummaryReviewDrawer(taskId) { function closeSummaryReviewDrawer() { activeSummaryReview = null; + summaryReviewEditSection.hidden = true; + summaryReviewDrawerSave.hidden = false; + summaryReviewDrawerLink.hidden = true; summaryReviewDrawerApprove.hidden = false; summaryReviewDrawerBackdrop.hidden = true; } @@ -754,6 +794,38 @@ async function deleteCourseSummary(summaryId) { await loadSummarySearch(); } +async function saveSummaryReviewEdits() { + if (!activeSummaryReview) return; + try { + const result = await fetchJson(`/api/admin/tasks/${encodeURIComponent(activeSummaryReview.id)}/course-summary-review`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(summaryReviewEditorPayload()), + }); + activeSummaryReview = result.task; + await loadSummaryReviews(); + openSummaryReviewDrawer(activeSummaryReview.id); + await loadOperationLogs(); + } catch (error) { + alert(error.message); + } +} + +async function linkExistingSummaryReview() { + if (!activeSummaryReview) return; + if (!confirm("确认关联已有上课记录?系统不会重复扣课时。")) return; + try { + await fetchJson(`/api/admin/tasks/${encodeURIComponent(activeSummaryReview.id)}/link-existing-course-summary`, { + method: "POST", + }); + closeSummaryReviewDrawer(); + await loadSummaryReviews(); + await loadOperationLogs(); + } catch (error) { + alert(error.message); + } +} + async function resolveDuplicateSummary(taskId, summaryId) { if (!confirm("确认删除这条重复课程小结?系统会先备份来源文件。")) return; await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/resolve-duplicate-summary`, { @@ -1067,6 +1139,8 @@ summaryReviewDrawerClose.addEventListener("click", closeSummaryReviewDrawer); summaryReviewDrawerBackdrop.addEventListener("click", (event) => { if (event.target === summaryReviewDrawerBackdrop) closeSummaryReviewDrawer(); }); +summaryReviewDrawerSave.addEventListener("click", saveSummaryReviewEdits); +summaryReviewDrawerLink.addEventListener("click", linkExistingSummaryReview); summaryReviewDrawerApprove.addEventListener("click", () => { if (activeSummaryReview) summaryReviewTask(activeSummaryReview.id, "approve"); }); diff --git a/app/app/static/styles.css b/app/app/static/styles.css index d18f3bd..0647c09 100644 --- a/app/app/static/styles.css +++ b/app/app/static/styles.css @@ -369,6 +369,10 @@ select:focus { font-weight: 700; } +.compact-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + .span-2 { grid-column: span 2; }