新增重复课程小结审核
This commit is contained in:
+294
-1
@@ -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:
|
||||
lines = original_text.splitlines()
|
||||
matched = [index for index, line in enumerate(lines) if line.strip() == original_line]
|
||||
@@ -1501,6 +1583,8 @@ OPERATION_LABELS = {
|
||||
"admin_task_reject": "审核驳回",
|
||||
"admin_course_summary_update_time": "课程小结补齐时间",
|
||||
"admin_course_summary_delete": "课程小结删除",
|
||||
"admin_course_summary_duplicate_scan": "重复小结扫描",
|
||||
"admin_course_summary_duplicate_delete": "重复小结删除",
|
||||
"register-class-records": "登记上课记录",
|
||||
"register-payments": "登记缴费记录",
|
||||
"admin-create-account": "新增课时账户",
|
||||
@@ -1532,6 +1616,7 @@ TYPE_LABELS = {
|
||||
"class_record_correction": "上课记录纠错",
|
||||
"class_record_deletion": "上课记录删除",
|
||||
"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_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", body).strip()
|
||||
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)
|
||||
yield {
|
||||
**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}"),
|
||||
"group": group,
|
||||
"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 ""),
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
display_names = teacher_alias_map(teachers)
|
||||
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,
|
||||
)
|
||||
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 "")))
|
||||
if len(values) > 1:
|
||||
index[key] = [max(values, key=course_summary_quality_score)]
|
||||
return index
|
||||
|
||||
|
||||
@@ -2114,6 +2310,72 @@ def delete_course_summary(root: Path, summary_id: str) -> dict:
|
||||
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:
|
||||
student = safe_filename_part(summary["student"])
|
||||
teacher = safe_filename_part(summary["teacher"] or "待核对老师")
|
||||
@@ -2397,6 +2659,11 @@ def register_course_summary_texts(
|
||||
raise ValueError(";".join(reasons))
|
||||
|
||||
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)
|
||||
seen_source_ids.add(source_id)
|
||||
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["auto_registered"] += 1
|
||||
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(
|
||||
{
|
||||
"source_id": source_id,
|
||||
@@ -2519,6 +2796,11 @@ def ingest_course_summaries(
|
||||
continue
|
||||
|
||||
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
|
||||
|
||||
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 ""),
|
||||
)
|
||||
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(
|
||||
{
|
||||
"source_id": source_id,
|
||||
|
||||
@@ -16,12 +16,14 @@ from ..config import (
|
||||
from ..data import (
|
||||
append_operation_log,
|
||||
approve_admin_task,
|
||||
create_course_summary_duplicate_review_tasks,
|
||||
delete_course_summary,
|
||||
list_admin_tasks,
|
||||
list_operation_logs,
|
||||
migrate_operation_log_labels,
|
||||
query_course_summaries,
|
||||
reject_admin_task,
|
||||
resolve_duplicate_course_summary_task,
|
||||
rollback_operation_log,
|
||||
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}
|
||||
|
||||
|
||||
@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")
|
||||
def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
@@ -158,6 +187,23 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
||||
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")
|
||||
def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
|
||||
@@ -219,6 +219,7 @@
|
||||
<section id="summariesPanel" class="panel admin-panel" hidden>
|
||||
<div class="section-head">
|
||||
<h2>课程小结审核</h2>
|
||||
<div class="quick-actions">
|
||||
<select id="summaryReviewStatus" aria-label="课程小结审核状态筛选">
|
||||
<option value="pending">待审核</option>
|
||||
<option value="conflict">冲突</option>
|
||||
@@ -226,6 +227,8 @@
|
||||
<option value="rejected">已驳回</option>
|
||||
<option value="">全部状态</option>
|
||||
</select>
|
||||
<button id="duplicateSummaryScanBtn" class="chip" type="button">扫描重复小结</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="summaryReviewMeta" class="summary-grid"></div>
|
||||
<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>
|
||||
|
||||
+93
-8
@@ -49,6 +49,7 @@ const reviewRows = document.querySelector("#reviewRows");
|
||||
const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
|
||||
const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
|
||||
const summaryReviewRows = document.querySelector("#summaryReviewRows");
|
||||
const duplicateSummaryScanBtn = document.querySelector("#duplicateSummaryScanBtn");
|
||||
const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop");
|
||||
const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose");
|
||||
const summaryReviewDrawerTitle = document.querySelector("#summaryReviewDrawerTitle");
|
||||
@@ -509,6 +510,7 @@ async function loadReviews() {
|
||||
}
|
||||
|
||||
function summaryReviewKind(item) {
|
||||
if (item.type === "course_summary_duplicate_review") return "重复小结";
|
||||
const sourceId = String(item.source_id || "");
|
||||
const reasons = Array.isArray(item.reasons) ? item.reasons.join(" ") : "";
|
||||
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>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
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 kind = summaryReviewKind(item);
|
||||
const canReview = canReviewSummary(item);
|
||||
const duplicateReview = isDuplicateSummaryReview(item);
|
||||
activeSummaryReview = item;
|
||||
|
||||
summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`;
|
||||
@@ -573,35 +601,51 @@ function openSummaryReviewDrawer(taskId) {
|
||||
].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>";
|
||||
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>") : "待人工复核";
|
||||
if (duplicateReview) {
|
||||
summaryReviewDrawerBody.innerHTML = renderDuplicateCandidates(item, canReview);
|
||||
} else {
|
||||
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;
|
||||
summaryReviewDrawerApprove.hidden = duplicateReview;
|
||||
summaryReviewDrawerApprove.disabled = !canReview || duplicateReview;
|
||||
summaryReviewDrawerReject.disabled = !canReview;
|
||||
summaryReviewDrawerBackdrop.hidden = false;
|
||||
}
|
||||
|
||||
function closeSummaryReviewDrawer() {
|
||||
activeSummaryReview = null;
|
||||
summaryReviewDrawerApprove.hidden = false;
|
||||
summaryReviewDrawerBackdrop.hidden = true;
|
||||
}
|
||||
|
||||
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);
|
||||
const reviewParams = new URLSearchParams({ type: "course_summary_review" });
|
||||
const duplicateParams = new URLSearchParams({ type: "course_summary_duplicate_review" });
|
||||
if (summaryReviewStatus.value) {
|
||||
reviewParams.set("status", summaryReviewStatus.value);
|
||||
duplicateParams.set("status", summaryReviewStatus.value);
|
||||
}
|
||||
try {
|
||||
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`);
|
||||
currentSummaryReviews = data.items || [];
|
||||
summaryReviewMeta.innerHTML = [metric("当前结果", `${data.count} 条`)].join("");
|
||||
const [reviewData, duplicateData] = await Promise.all([
|
||||
fetchJson(`/api/admin/tasks?${reviewParams.toString()}`),
|
||||
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
|
||||
.map((item) => {
|
||||
const canReview = canReviewSummary(item);
|
||||
const duplicateReview = isDuplicateSummaryReview(item);
|
||||
const summary = item.summary || {};
|
||||
const reasons = Array.isArray(item.reasons) ? item.reasons : [];
|
||||
return `<tr>
|
||||
@@ -614,7 +658,7 @@ async function loadSummaryReviews() {
|
||||
<td class="record-action-cell">
|
||||
<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 && !duplicateReview ? "" : "disabled"}>${duplicateReview ? "详情选择" : "批准"}</button>
|
||||
<button class="small-button summary-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -710,6 +754,32 @@ async function deleteCourseSummary(summaryId) {
|
||||
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) {
|
||||
const details = [];
|
||||
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.time_range) details.push(`时间:${item.time_range}`);
|
||||
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 (item.error) details.push(`错误:${item.error}`);
|
||||
if (item.backup_id) details.push(`备份:${item.backup_id}`);
|
||||
@@ -970,6 +1043,7 @@ reviewRows.addEventListener("click", (event) => {
|
||||
if (reject) reviewTask(reject.dataset.taskId, "reject");
|
||||
});
|
||||
summaryReviewStatus.addEventListener("change", loadSummaryReviews);
|
||||
duplicateSummaryScanBtn.addEventListener("click", scanDuplicateSummaries);
|
||||
summaryReviewRows.addEventListener("click", (event) => {
|
||||
const detail = event.target.closest(".summary-detail");
|
||||
const approve = event.target.closest(".summary-approve");
|
||||
@@ -979,6 +1053,11 @@ summaryReviewRows.addEventListener("click", (event) => {
|
||||
return;
|
||||
}
|
||||
if (approve) {
|
||||
const item = findSummaryReview(approve.dataset.taskId);
|
||||
if (isDuplicateSummaryReview(item)) {
|
||||
openSummaryReviewDrawer(approve.dataset.taskId);
|
||||
return;
|
||||
}
|
||||
summaryReviewTask(approve.dataset.taskId, "approve");
|
||||
return;
|
||||
}
|
||||
@@ -994,6 +1073,12 @@ summaryReviewDrawerApprove.addEventListener("click", () => {
|
||||
summaryReviewDrawerReject.addEventListener("click", () => {
|
||||
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) => {
|
||||
if (event.key === "Escape" && !summaryReviewDrawerBackdrop.hidden) closeSummaryReviewDrawer();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user