新增重复课程小结审核

This commit is contained in:
Codex
2026-06-16 17:02:53 +08:00
parent 69644b4ae8
commit 5f6bf376b2
4 changed files with 446 additions and 17 deletions
+294 -1
View File
@@ -1021,6 +1021,88 @@ def list_admin_tasks(tasks_path: Path, status_filter: str = "", task_type: str =
} }
def duplicate_review_task_from_group(task_id: int, group: dict, now: str) -> dict:
key = group.get("key") or {}
return {
"id": task_id,
"type": "course_summary_duplicate_review",
"status": "pending",
"created_at": now,
"updated_at": now,
"duplicate_group_id": str(group.get("duplicate_group_id") or ""),
"student": str(key.get("student") or ""),
"teacher": str(key.get("teacher") or ""),
"subject": str(key.get("subject") or ""),
"date_iso": str(key.get("date_iso") or ""),
"time_range": str(key.get("time_range") or ""),
"summary": {
"student": str(key.get("student") or ""),
"teacher": str(key.get("teacher") or ""),
"subject": str(key.get("subject") or ""),
"date_iso": str(key.get("date_iso") or ""),
"time_range": str(key.get("time_range") or ""),
"group": "",
"body": "",
},
"duplicate_candidates": group.get("candidates") or [],
"reasons": ["同一节课存在多条课程小结,请选择删除其中一条"],
}
def create_course_summary_duplicate_review_tasks(
tasks_path: Path,
summaries_root: Path,
target_key: tuple[str, str, str, str, str] | None = None,
) -> dict:
tasks = read_admin_tasks(tasks_path)
active_tasks = {
str(task.get("duplicate_group_id") or ""): task
for task in tasks.get("items", [])
if task.get("type") == "course_summary_duplicate_review" and task.get("status") in {"pending", "conflict"}
}
now = datetime.now().isoformat(timespec="seconds")
created: list[dict] = []
groups = find_course_summary_duplicate_groups(summaries_root)
if target_key is not None:
groups = [
group
for group in groups
if course_summary_record_key(
str((group.get("key") or {}).get("student") or ""),
str((group.get("key") or {}).get("teacher") or ""),
str((group.get("key") or {}).get("subject") or ""),
str((group.get("key") or {}).get("date_iso") or ""),
str((group.get("key") or {}).get("time_range") or ""),
)
== target_key
]
changed = False
refreshed = 0
for group in groups:
group_id = str(group.get("duplicate_group_id") or "")
if not group_id:
continue
if group_id in active_tasks:
active_tasks[group_id]["duplicate_candidates"] = group.get("candidates") or []
active_tasks[group_id]["updated_at"] = now
changed = True
refreshed += 1
continue
task = duplicate_review_task_from_group(int(tasks["next_id"]), group, now)
tasks["next_id"] = int(tasks["next_id"]) + 1
tasks["items"].append(task)
active_tasks[group_id] = task
created.append(task)
if created or changed:
write_admin_tasks(tasks_path, tasks)
return {
"scanned": len(groups),
"created": len(created),
"refreshed": refreshed,
"items": [task_to_dict(task) for task in created],
}
def replace_class_record_line(original_text: str, original_line: str, corrected_line: str) -> str: def replace_class_record_line(original_text: str, original_line: str, corrected_line: str) -> str:
lines = original_text.splitlines() lines = original_text.splitlines()
matched = [index for index, line in enumerate(lines) if line.strip() == original_line] matched = [index for index, line in enumerate(lines) if line.strip() == original_line]
@@ -1501,6 +1583,8 @@ OPERATION_LABELS = {
"admin_task_reject": "审核驳回", "admin_task_reject": "审核驳回",
"admin_course_summary_update_time": "课程小结补齐时间", "admin_course_summary_update_time": "课程小结补齐时间",
"admin_course_summary_delete": "课程小结删除", "admin_course_summary_delete": "课程小结删除",
"admin_course_summary_duplicate_scan": "重复小结扫描",
"admin_course_summary_duplicate_delete": "重复小结删除",
"register-class-records": "登记上课记录", "register-class-records": "登记上课记录",
"register-payments": "登记缴费记录", "register-payments": "登记缴费记录",
"admin-create-account": "新增课时账户", "admin-create-account": "新增课时账户",
@@ -1532,6 +1616,7 @@ TYPE_LABELS = {
"class_record_correction": "上课记录纠错", "class_record_correction": "上课记录纠错",
"class_record_deletion": "上课记录删除", "class_record_deletion": "上课记录删除",
"course_summary_review": "课程小结审核", "course_summary_review": "课程小结审核",
"course_summary_duplicate_review": "重复小结审核",
} }
@@ -1891,6 +1976,9 @@ def iter_course_summary_markdown(root: Path) -> Iterable[dict]:
body = text[start:end].strip() body = text[start:end].strip()
body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", body).strip() body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", body).strip()
body_text = body_without_meta or body body_text = body_without_meta or body
source_id_match = re.search(r"来源ID`([^`]+)`", body)
sent_at_match = re.search(r"发送时间:`([^`]+)`", body)
sender_match = re.search(r"发送者:`([^`]+)`", body)
item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20) item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20)
yield { yield {
**identity, **identity,
@@ -1900,6 +1988,10 @@ def iter_course_summary_markdown(root: Path) -> Iterable[dict]:
"time_range": parse_course_summary_time_text(f"{title}\n{body_text}"), "time_range": parse_course_summary_time_text(f"{title}\n{body_text}"),
"group": group, "group": group,
"body": body_text, "body": body_text,
"raw_body": body,
"source_id": source_id_match.group(1) if source_id_match else "",
"message_time": sent_at_match.group(1) if sent_at_match else "",
"sender": sender_match.group(1) if sender_match else "",
"body_preview": body_text[:260] + ("..." if len(body_text) > 260 else ""), "body_preview": body_text[:260] + ("..." if len(body_text) > 260 else ""),
} }
@@ -1950,6 +2042,108 @@ def course_summary_record_key(student: str, teacher: str, subject: str, date_iso
) )
def course_summary_duplicate_key(item: dict) -> tuple[str, str, str, str, str]:
return course_summary_record_key(
str(item.get("student") or ""),
str(item.get("teacher") or ""),
str(item.get("subject") or ""),
str(item.get("date_iso") or ""),
str(item.get("time_range") or ""),
)
def course_summary_source_meta(item: dict) -> dict:
body = str(item.get("raw_body") or item.get("body") or "")
source_id_match = re.search(r"来源ID`([^`]+)`", body)
sent_at_match = re.search(r"发送时间:`([^`]+)`", body)
sender_match = re.search(r"发送者:`([^`]+)`", body)
return {
"source_id": source_id_match.group(1) if source_id_match else "",
"message_time": sent_at_match.group(1) if sent_at_match else "",
"sender": sender_match.group(1) if sender_match else "",
}
def course_summary_duplicate_candidate(item: dict) -> dict:
meta = course_summary_source_meta(item)
body = str(item.get("body") or "")
return {
"id": str(item.get("id") or ""),
"title": str(item.get("title") or ""),
"student": str(item.get("student") or ""),
"teacher": str(item.get("teacher") or ""),
"subject": str(item.get("subject") or ""),
"date_iso": str(item.get("date_iso") or ""),
"time_range": str(item.get("time_range") or ""),
"group": str(item.get("group") or ""),
"source_path": str(item.get("source_path") or ""),
"relative_path": str(item.get("relative_path") or ""),
"source_id": str(item.get("source_id") or meta["source_id"]),
"message_time": str(item.get("message_time") or meta["message_time"]),
"sender": str(item.get("sender") or meta["sender"]),
"body": body,
"body_preview": body[:260] + ("..." if len(body) > 260 else ""),
}
def course_summary_quality_score(item: dict) -> tuple[int, int, int, str]:
title = str(item.get("title") or "")
body = str(item.get("body") or "")
raw_body = str(item.get("raw_body") or body)
has_standard_time = 1 if re.search(r"\d{1,2}:\d{2}-\d{1,2}:\d{2}", title) else 0
has_source_meta = 1 if "来源ID`" in raw_body or "发送时间:`" in raw_body or "发送者:`" in raw_body else 0
return (has_standard_time, has_source_meta, len(body), str(item.get("id") or ""))
def course_summary_duplicate_group_id(key: tuple[str, str, str, str, str], candidate_ids: list[str]) -> str:
return sha1_text("|".join([*key, *sorted(candidate_ids)]), 20)
def find_course_summary_duplicate_groups(root: Path) -> list[dict]:
grouped: dict[tuple[str, str, str, str, str], list[dict]] = defaultdict(list)
for item in iter_course_summary_markdown(root):
if not item.get("date_iso") or not item.get("time_range"):
continue
key = course_summary_duplicate_key(item)
if not all(key):
continue
grouped[key].append(item)
groups: list[dict] = []
for key, items in grouped.items():
if len(items) <= 1:
continue
candidates = [course_summary_duplicate_candidate(item) for item in sorted(items, key=lambda item: str(item.get("id") or ""))]
groups.append(
{
"duplicate_group_id": course_summary_duplicate_group_id(key, [candidate["id"] for candidate in candidates]),
"key": {
"student": key[0],
"teacher": key[1],
"subject": key[2],
"date_iso": key[3],
"time_range": key[4],
},
"candidates": candidates,
}
)
groups.sort(key=lambda group: (
str(group["key"].get("date_iso") or ""),
str(group["key"].get("student") or ""),
str(group["key"].get("teacher") or ""),
str(group["key"].get("subject") or ""),
str(group["key"].get("time_range") or ""),
))
return groups
def refresh_course_summary_duplicate_group(root: Path, duplicate_group_id: str) -> dict | None:
for group in find_course_summary_duplicate_groups(root):
if str(group.get("duplicate_group_id") or "") == duplicate_group_id:
return group
return None
def course_summary_to_public(item: dict, teachers: list[Teacher]) -> dict: def course_summary_to_public(item: dict, teachers: list[Teacher]) -> dict:
display_names = teacher_alias_map(teachers) display_names = teacher_alias_map(teachers)
teacher = str(item.get("teacher") or "") teacher = str(item.get("teacher") or "")
@@ -1984,8 +2178,10 @@ def course_summary_index_for_records(root: Path) -> dict[tuple[str, str, str, st
time_range, time_range,
) )
index[key].append(item) index[key].append(item)
for values in index.values(): for key, values in list(index.items()):
values.sort(key=lambda item: (str(item.get("title") or ""), str(item.get("id") or ""))) values.sort(key=lambda item: (str(item.get("title") or ""), str(item.get("id") or "")))
if len(values) > 1:
index[key] = [max(values, key=course_summary_quality_score)]
return index return index
@@ -2114,6 +2310,72 @@ def delete_course_summary(root: Path, summary_id: str) -> dict:
return result return result
def resolve_duplicate_course_summary_task(
tasks_path: Path,
summaries_root: Path,
task_id: int,
delete_summary_id: str,
) -> dict:
target_id = delete_summary_id.strip()
if not target_id:
raise ValueError("请选择要删除的课程小结")
tasks = read_admin_tasks(tasks_path)
task = find_admin_task(tasks, task_id)
if task.get("type") != "course_summary_duplicate_review":
raise ValueError("该任务不是重复小结审核")
if task.get("status") not in {"pending", "conflict"}:
raise ValueError("该任务已处理,不能重复批准")
group_id = str(task.get("duplicate_group_id") or "")
current_group = refresh_course_summary_duplicate_group(summaries_root, group_id)
if current_group is None:
task["status"] = "conflict"
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
task["message"] = "重复小结已不存在或已被处理"
write_admin_tasks(tasks_path, tasks)
raise ValueError(task["message"])
candidates = current_group.get("candidates") or []
candidate_ids = {str(candidate.get("id") or "") for candidate in candidates}
if target_id not in candidate_ids:
task["status"] = "conflict"
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
task["duplicate_candidates"] = candidates
task["message"] = "选择的小结已不存在,请重新选择"
write_admin_tasks(tasks_path, tasks)
raise ValueError(task["message"])
if len(candidates) <= 1:
task["status"] = "conflict"
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
task["duplicate_candidates"] = candidates
task["message"] = "当前重复组已不足两条,无需删除"
write_admin_tasks(tasks_path, tasks)
raise ValueError(task["message"])
original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
delete_result = delete_course_summary(summaries_root, target_id)
now = datetime.now().isoformat(timespec="seconds")
task["status"] = "approved"
task["updated_at"] = now
task["reviewed_at"] = now
task["deleted_summary_id"] = target_id
task["deleted_summary_title"] = str(delete_result.get("title") or "")
task["backup_id"] = str(delete_result.get("backup_id") or "")
task["duplicate_candidates"] = candidates
new_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
try:
atomic_write_text(tasks_path, new_tasks)
except Exception:
atomic_write_text(tasks_path, original_tasks)
raise
return {
"task": task_to_dict(task),
"deleted_summary_id": target_id,
"backup_id": str(delete_result.get("backup_id") or ""),
"deleted": delete_result,
}
def course_summary_path(root: Path, summary: dict) -> Path: def course_summary_path(root: Path, summary: dict) -> Path:
student = safe_filename_part(summary["student"]) student = safe_filename_part(summary["student"])
teacher = safe_filename_part(summary["teacher"] or "待核对老师") teacher = safe_filename_part(summary["teacher"] or "待核对老师")
@@ -2397,6 +2659,11 @@ def register_course_summary_texts(
raise ValueError("".join(reasons)) raise ValueError("".join(reasons))
saved = save_course_summary_markdown(summaries_root, normalized) saved = save_course_summary_markdown(summaries_root, normalized)
duplicate_tasks = create_course_summary_duplicate_review_tasks(
tasks_path,
summaries_root,
target_key=course_summary_duplicate_key(normalized),
)
register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line) register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
seen_source_ids.add(source_id) seen_source_ids.add(source_id)
seen_semantic_keys.add(semantic_key) seen_semantic_keys.add(semantic_key)
@@ -2435,6 +2702,16 @@ def register_course_summary_texts(
result["saved"] += 1 if saved.get("added") else 0 result["saved"] += 1 if saved.get("added") else 0
result["auto_registered"] += 1 result["auto_registered"] += 1
result["operation_log_ids"].append(log_id) result["operation_log_ids"].append(log_id)
if duplicate_tasks["created"]:
scan_log_id = append_operation_log(
operation_logs_path,
"重复小结扫描",
"待审核",
source_id=source_id,
student=normalized["student"],
created_tasks=duplicate_tasks["created"],
)
result["operation_log_ids"].append(scan_log_id)
result["items"].append( result["items"].append(
{ {
"source_id": source_id, "source_id": source_id,
@@ -2519,6 +2796,11 @@ def ingest_course_summaries(
continue continue
saved = save_course_summary_markdown(summaries_root, normalized) saved = save_course_summary_markdown(summaries_root, normalized)
duplicate_tasks = create_course_summary_duplicate_review_tasks(
tasks_path,
summaries_root,
target_key=course_summary_duplicate_key(normalized),
)
result["saved"] += 1 if saved.get("added") else 0 result["saved"] += 1 if saved.get("added") else 0
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path) reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
@@ -2564,6 +2846,17 @@ def ingest_course_summaries(
saved_path=str(saved.get("path") or ""), saved_path=str(saved.get("path") or ""),
) )
result["operation_log_ids"].append(log_id) result["operation_log_ids"].append(log_id)
if duplicate_tasks["created"]:
scan_log_id = append_operation_log(
operation_logs_path,
"重复小结扫描",
"待审核",
batch_id=batch_id,
source_id=source_id,
student=normalized["student"],
created_tasks=duplicate_tasks["created"],
)
result["operation_log_ids"].append(scan_log_id)
result["items"].append( result["items"].append(
{ {
"source_id": source_id, "source_id": source_id,
+46
View File
@@ -16,12 +16,14 @@ from ..config import (
from ..data import ( from ..data import (
append_operation_log, append_operation_log,
approve_admin_task, approve_admin_task,
create_course_summary_duplicate_review_tasks,
delete_course_summary, delete_course_summary,
list_admin_tasks, list_admin_tasks,
list_operation_logs, list_operation_logs,
migrate_operation_log_labels, migrate_operation_log_labels,
query_course_summaries, query_course_summaries,
reject_admin_task, reject_admin_task,
resolve_duplicate_course_summary_task,
rollback_operation_log, rollback_operation_log,
update_course_summary_time, update_course_summary_time,
) )
@@ -139,6 +141,33 @@ def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
return {"ok": True, **result} return {"ok": True, **result}
@router.post("/api/admin/tasks/{task_id}/resolve-duplicate-summary")
def admin_resolve_duplicate_summary(task_id: int, payload: dict, _user: str = Depends(verify_admin_auth)):
delete_summary_id = str(payload.get("delete_summary_id") or "")
try:
with write_lock:
result = resolve_duplicate_course_summary_task(
ADMIN_TASKS_PATH,
COURSE_SUMMARIES_ROOT,
task_id,
delete_summary_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 ""),
summary_id=str(result.get("deleted_summary_id") or ""),
backup_id=str(result.get("backup_id") 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") @router.post("/api/admin/tasks/{task_id}/reject")
def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)): def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
try: try:
@@ -158,6 +187,23 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
return {"ok": True, "task": task} return {"ok": True, "task": task}
@router.post("/api/admin/course-summaries/duplicate-scan")
def admin_scan_duplicate_course_summaries(_user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = create_course_summary_duplicate_review_tasks(ADMIN_TASKS_PATH, COURSE_SUMMARIES_ROOT)
append_operation_log(
OPERATION_LOGS_PATH,
"重复小结扫描",
"待审核" if result.get("created") else "完成",
scanned_groups=int(result.get("scanned") or 0),
created_tasks=int(result.get("created") or 0),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/admin/course-summaries/{summary_id}/time") @router.post("/api/admin/course-summaries/{summary_id}/time")
def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)): def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)):
try: try:
+5
View File
@@ -219,6 +219,7 @@
<section id="summariesPanel" class="panel admin-panel" hidden> <section id="summariesPanel" class="panel admin-panel" hidden>
<div class="section-head"> <div class="section-head">
<h2>课程小结审核</h2> <h2>课程小结审核</h2>
<div class="quick-actions">
<select id="summaryReviewStatus" aria-label="课程小结审核状态筛选"> <select id="summaryReviewStatus" aria-label="课程小结审核状态筛选">
<option value="pending">待审核</option> <option value="pending">待审核</option>
<option value="conflict">冲突</option> <option value="conflict">冲突</option>
@@ -226,6 +227,8 @@
<option value="rejected">已驳回</option> <option value="rejected">已驳回</option>
<option value="">全部状态</option> <option value="">全部状态</option>
</select> </select>
<button id="duplicateSummaryScanBtn" class="chip" type="button">扫描重复小结</button>
</div>
</div> </div>
<div id="summaryReviewMeta" class="summary-grid"></div> <div id="summaryReviewMeta" class="summary-grid"></div>
<div class="table-wrap"> <div class="table-wrap">
@@ -338,6 +341,8 @@
<option value="修改老师档案">修改老师档案</option> <option value="修改老师档案">修改老师档案</option>
<option value="课程小结接收">课程小结接收</option> <option value="课程小结接收">课程小结接收</option>
<option value="课程小结登记">课程小结登记</option> <option value="课程小结登记">课程小结登记</option>
<option value="重复小结扫描">重复小结扫描</option>
<option value="重复小结删除">重复小结删除</option>
<option value="审核批准">审核批准</option> <option value="审核批准">审核批准</option>
<option value="审核驳回">审核驳回</option> <option value="审核驳回">审核驳回</option>
<option value="课程小结补齐时间">课程小结补齐时间</option> <option value="课程小结补齐时间">课程小结补齐时间</option>
+93 -8
View File
@@ -49,6 +49,7 @@ 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 duplicateSummaryScanBtn = document.querySelector("#duplicateSummaryScanBtn");
const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop"); const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop");
const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose"); const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose");
const summaryReviewDrawerTitle = document.querySelector("#summaryReviewDrawerTitle"); const summaryReviewDrawerTitle = document.querySelector("#summaryReviewDrawerTitle");
@@ -509,6 +510,7 @@ async function loadReviews() {
} }
function summaryReviewKind(item) { function summaryReviewKind(item) {
if (item.type === "course_summary_duplicate_review") return "重复小结";
const sourceId = String(item.source_id || ""); const sourceId = String(item.source_id || "");
const reasons = Array.isArray(item.reasons) ? item.reasons.join(" ") : ""; const reasons = Array.isArray(item.reasons) ? item.reasons.join(" ") : "";
if (sourceId.startsWith("history-missing:") || reasons.includes("历史 classnotes缺失")) { if (sourceId.startsWith("history-missing:") || reasons.includes("历史 classnotes缺失")) {
@@ -539,6 +541,31 @@ function renderSummaryPreview(summary) {
return `<div class="summary-body summary-preview">${escapeHtml(preview || "暂无原文")}</div>`; return `<div class="summary-body summary-preview">${escapeHtml(preview || "暂无原文")}</div>`;
} }
function isDuplicateSummaryReview(item) {
return item && item.type === "course_summary_duplicate_review";
}
function renderDuplicateCandidate(candidate, canReview) {
const source = [
candidate.source_id ? `来源ID${candidate.source_id}` : "",
candidate.message_time ? `发送时间:${candidate.message_time}` : "",
candidate.sender ? `发送者:${candidate.sender}` : "",
candidate.relative_path ? `文件:${candidate.relative_path}` : "",
].filter(Boolean);
return `<div class="record-summary-item duplicate-summary-candidate">
<div class="record-summary-title">${escapeHtml(candidate.title || "课程小结")}</div>
<div class="drawer-text">${source.map(escapeHtml).join("<br>") || "暂无来源信息"}</div>
<div class="summary-body">${escapeHtml(candidate.body || candidate.body_preview || "暂无正文")}</div>
<button class="small-button duplicate-summary-delete" type="button" data-summary-id="${escapeHtml(candidate.id)}" ${canReview ? "" : "disabled"}>删除这一条</button>
</div>`;
}
function renderDuplicateCandidates(item, canReview) {
const candidates = Array.isArray(item.duplicate_candidates) ? item.duplicate_candidates : [];
if (!candidates.length) return "<span class=\"muted\">暂无重复候选</span>";
return candidates.map((candidate) => renderDuplicateCandidate(candidate, canReview)).join("");
}
function findSummaryReview(taskId) { function findSummaryReview(taskId) {
return currentSummaryReviews.find((item) => String(item.id) === String(taskId)); return currentSummaryReviews.find((item) => String(item.id) === String(taskId));
} }
@@ -564,6 +591,7 @@ function openSummaryReviewDrawer(taskId) {
const reasons = Array.isArray(item.reasons) ? item.reasons : []; const reasons = Array.isArray(item.reasons) ? item.reasons : [];
const kind = summaryReviewKind(item); const kind = summaryReviewKind(item);
const canReview = canReviewSummary(item); const canReview = canReviewSummary(item);
const duplicateReview = isDuplicateSummaryReview(item);
activeSummaryReview = item; activeSummaryReview = item;
summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`; summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`;
@@ -573,35 +601,51 @@ function openSummaryReviewDrawer(taskId) {
].filter(Boolean).join(" "); ].filter(Boolean).join(" ");
summaryReviewDrawerStatus.innerHTML = `<span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span>${item.message ? ` <small>${escapeHtml(item.message)}</small>` : ""}`; 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>") || "暂无课程信息"; summaryReviewDrawerCourse.innerHTML = courseInfoLines(summary).map(escapeHtml).join("<br>") || "暂无课程信息";
summaryReviewDrawerLine.innerHTML = item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"; summaryReviewDrawerLine.innerHTML = duplicateReview
? "<span class=\"muted\">请选择下方重复小结删除</span>"
: item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>";
summaryReviewDrawerReasons.innerHTML = reasons.length ? reasons.map(escapeHtml).join("<br>") : "待人工复核"; summaryReviewDrawerReasons.innerHTML = reasons.length ? reasons.map(escapeHtml).join("<br>") : "待人工复核";
if (duplicateReview) {
summaryReviewDrawerBody.innerHTML = renderDuplicateCandidates(item, canReview);
} else {
summaryReviewDrawerBody.textContent = summary.body || "暂无原文"; summaryReviewDrawerBody.textContent = summary.body || "暂无原文";
}
summaryReviewDrawerSource.innerHTML = [ summaryReviewDrawerSource.innerHTML = [
item.source_id ? `来源ID${escapeHtml(item.source_id)}` : "", item.source_id ? `来源ID${escapeHtml(item.source_id)}` : "",
item.saved_path ? `保存文件:${escapeHtml(item.saved_path)}` : "", item.saved_path ? `保存文件:${escapeHtml(item.saved_path)}` : "",
item.batch_id ? `批次:${escapeHtml(item.batch_id)}` : "", item.batch_id ? `批次:${escapeHtml(item.batch_id)}` : "",
].filter(Boolean).join("<br>") || "暂无来源信息"; ].filter(Boolean).join("<br>") || "暂无来源信息";
summaryReviewDrawerApprove.disabled = !canReview; summaryReviewDrawerApprove.hidden = duplicateReview;
summaryReviewDrawerApprove.disabled = !canReview || duplicateReview;
summaryReviewDrawerReject.disabled = !canReview; summaryReviewDrawerReject.disabled = !canReview;
summaryReviewDrawerBackdrop.hidden = false; summaryReviewDrawerBackdrop.hidden = false;
} }
function closeSummaryReviewDrawer() { function closeSummaryReviewDrawer() {
activeSummaryReview = null; activeSummaryReview = null;
summaryReviewDrawerApprove.hidden = false;
summaryReviewDrawerBackdrop.hidden = true; summaryReviewDrawerBackdrop.hidden = true;
} }
async function loadSummaryReviews() { async function loadSummaryReviews() {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`; summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
const params = new URLSearchParams({ type: "course_summary_review" }); const reviewParams = new URLSearchParams({ type: "course_summary_review" });
if (summaryReviewStatus.value) params.set("status", summaryReviewStatus.value); const duplicateParams = new URLSearchParams({ type: "course_summary_duplicate_review" });
if (summaryReviewStatus.value) {
reviewParams.set("status", summaryReviewStatus.value);
duplicateParams.set("status", summaryReviewStatus.value);
}
try { try {
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`); const [reviewData, duplicateData] = await Promise.all([
currentSummaryReviews = data.items || []; fetchJson(`/api/admin/tasks?${reviewParams.toString()}`),
summaryReviewMeta.innerHTML = [metric("当前结果", `${data.count}`)].join(""); fetchJson(`/api/admin/tasks?${duplicateParams.toString()}`),
]);
currentSummaryReviews = [...(reviewData.items || []), ...(duplicateData.items || [])].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
summaryReviewMeta.innerHTML = [metric("当前结果", `${currentSummaryReviews.length}`)].join("");
summaryReviewRows.innerHTML = currentSummaryReviews summaryReviewRows.innerHTML = currentSummaryReviews
.map((item) => { .map((item) => {
const canReview = canReviewSummary(item); const canReview = canReviewSummary(item);
const duplicateReview = isDuplicateSummaryReview(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>
@@ -614,7 +658,7 @@ async function loadSummaryReviews() {
<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-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 && !duplicateReview ? "" : "disabled"}>${duplicateReview ? "详情选择" : "批准"}</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>
</td> </td>
@@ -710,6 +754,32 @@ async function deleteCourseSummary(summaryId) {
await loadSummarySearch(); await loadSummarySearch();
} }
async function resolveDuplicateSummary(taskId, summaryId) {
if (!confirm("确认删除这条重复课程小结?系统会先备份来源文件。")) return;
await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/resolve-duplicate-summary`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete_summary_id: summaryId }),
});
closeSummaryReviewDrawer();
await loadSummaryReviews();
await loadOperationLogs();
}
async function scanDuplicateSummaries() {
duplicateSummaryScanBtn.disabled = true;
try {
const result = await fetchJson("/api/admin/course-summaries/duplicate-scan", { method: "POST" });
alert(`扫描完成:发现 ${result.scanned || 0} 组重复,新增 ${result.created || 0} 条审核任务`);
await loadSummaryReviews();
await loadOperationLogs();
} catch (error) {
alert(error.message);
} finally {
duplicateSummaryScanBtn.disabled = false;
}
}
function renderLogDetail(item) { function renderLogDetail(item) {
const details = []; const details = [];
if (item.task_type) details.push(`类型:${item.task_type}`); if (item.task_type) details.push(`类型:${item.task_type}`);
@@ -719,6 +789,9 @@ function renderLogDetail(item) {
if (item.teacher || item.subject) details.push(`老师/科目:${item.teacher || ""} ${item.subject || ""}`.trim()); if (item.teacher || item.subject) details.push(`老师/科目:${item.teacher || ""} ${item.subject || ""}`.trim());
if (item.time_range) details.push(`时间:${item.time_range}`); if (item.time_range) details.push(`时间:${item.time_range}`);
if (item.proposed_line) details.push(`记录:${item.proposed_line}`); if (item.proposed_line) details.push(`记录:${item.proposed_line}`);
if (item.summary_id) details.push(`小结ID${item.summary_id}`);
if (item.scanned_groups !== undefined) details.push(`扫描重复组:${item.scanned_groups}`);
if (item.created_tasks !== undefined) details.push(`新增任务:${item.created_tasks}`);
if (Array.isArray(item.reasons) && item.reasons.length) details.push(`原因:${item.reasons.join("")}`); if (Array.isArray(item.reasons) && item.reasons.length) details.push(`原因:${item.reasons.join("")}`);
if (item.error) details.push(`错误:${item.error}`); if (item.error) details.push(`错误:${item.error}`);
if (item.backup_id) details.push(`备份:${item.backup_id}`); if (item.backup_id) details.push(`备份:${item.backup_id}`);
@@ -970,6 +1043,7 @@ reviewRows.addEventListener("click", (event) => {
if (reject) reviewTask(reject.dataset.taskId, "reject"); if (reject) reviewTask(reject.dataset.taskId, "reject");
}); });
summaryReviewStatus.addEventListener("change", loadSummaryReviews); summaryReviewStatus.addEventListener("change", loadSummaryReviews);
duplicateSummaryScanBtn.addEventListener("click", scanDuplicateSummaries);
summaryReviewRows.addEventListener("click", (event) => { summaryReviewRows.addEventListener("click", (event) => {
const detail = event.target.closest(".summary-detail"); const detail = event.target.closest(".summary-detail");
const approve = event.target.closest(".summary-approve"); const approve = event.target.closest(".summary-approve");
@@ -979,6 +1053,11 @@ summaryReviewRows.addEventListener("click", (event) => {
return; return;
} }
if (approve) { if (approve) {
const item = findSummaryReview(approve.dataset.taskId);
if (isDuplicateSummaryReview(item)) {
openSummaryReviewDrawer(approve.dataset.taskId);
return;
}
summaryReviewTask(approve.dataset.taskId, "approve"); summaryReviewTask(approve.dataset.taskId, "approve");
return; return;
} }
@@ -994,6 +1073,12 @@ summaryReviewDrawerApprove.addEventListener("click", () => {
summaryReviewDrawerReject.addEventListener("click", () => { summaryReviewDrawerReject.addEventListener("click", () => {
if (activeSummaryReview) summaryReviewTask(activeSummaryReview.id, "reject"); if (activeSummaryReview) summaryReviewTask(activeSummaryReview.id, "reject");
}); });
summaryReviewDrawerBody.addEventListener("click", (event) => {
const button = event.target.closest(".duplicate-summary-delete");
if (button && activeSummaryReview) {
resolveDuplicateSummary(activeSummaryReview.id, button.dataset.summaryId).catch((error) => alert(error.message));
}
});
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !summaryReviewDrawerBackdrop.hidden) closeSummaryReviewDrawer(); if (event.key === "Escape" && !summaryReviewDrawerBackdrop.hidden) closeSummaryReviewDrawer();
}); });