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;
}