重复课程小结进入审核
This commit is contained in:
+331
-18
@@ -2201,6 +2201,111 @@ def course_summary_duplicate_candidate(item: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summary_as_duplicate_candidate(summary: dict, saved_path: str = "") -> dict:
|
||||||
|
body = str(summary.get("body") or "")
|
||||||
|
return {
|
||||||
|
"id": str(summary.get("source_id") or sha1_text(json.dumps(summary, ensure_ascii=False, sort_keys=True), 20)),
|
||||||
|
"title": str(summary.get("title") or ""),
|
||||||
|
"student": str(summary.get("student") or ""),
|
||||||
|
"teacher": str(summary.get("teacher") or ""),
|
||||||
|
"subject": str(summary.get("subject") or ""),
|
||||||
|
"date_iso": str(summary.get("date_iso") or ""),
|
||||||
|
"time_range": str(summary.get("time_range") or ""),
|
||||||
|
"group": str(summary.get("group") or ""),
|
||||||
|
"source_path": saved_path,
|
||||||
|
"relative_path": "",
|
||||||
|
"source_id": str(summary.get("source_id") or ""),
|
||||||
|
"message_time": str(summary.get("message_time") or ""),
|
||||||
|
"sender": str(summary.get("sender") or ""),
|
||||||
|
"body": body,
|
||||||
|
"body_preview": body[:260] + ("..." if len(body) > 260 else ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_semantic_key_text(value: str) -> str:
|
||||||
|
return re.sub(r"\s+", "", value.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def find_course_summary_duplicate_conflicts(
|
||||||
|
root: Path,
|
||||||
|
summary: dict,
|
||||||
|
*,
|
||||||
|
source_id_duplicate: bool,
|
||||||
|
semantic_duplicate: bool,
|
||||||
|
) -> list[dict]:
|
||||||
|
conflicts: list[dict] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
summary_source_id = str(summary.get("source_id") or "")
|
||||||
|
summary_body_key = normalize_semantic_key_text(str(summary.get("body") or ""))
|
||||||
|
try:
|
||||||
|
summary_key = course_summary_duplicate_key(summary)
|
||||||
|
except ValueError:
|
||||||
|
summary_key = ("", "", "", "", "")
|
||||||
|
|
||||||
|
for item in iter_course_summary_markdown(root):
|
||||||
|
matched = False
|
||||||
|
if source_id_duplicate and summary_source_id and str(item.get("source_id") or "") == summary_source_id:
|
||||||
|
matched = True
|
||||||
|
if semantic_duplicate:
|
||||||
|
item_body_key = normalize_semantic_key_text(str(item.get("body") or ""))
|
||||||
|
try:
|
||||||
|
item_key = course_summary_duplicate_key(item)
|
||||||
|
except ValueError:
|
||||||
|
item_key = ("", "", "", "", "")
|
||||||
|
matched = matched or (
|
||||||
|
item_key == summary_key
|
||||||
|
and item_body_key == summary_body_key
|
||||||
|
)
|
||||||
|
if not matched:
|
||||||
|
continue
|
||||||
|
candidate = course_summary_duplicate_candidate(item)
|
||||||
|
candidate_id = str(candidate.get("id") or candidate.get("source_id") or "")
|
||||||
|
if candidate_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(candidate_id)
|
||||||
|
conflicts.append(candidate)
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
|
def course_summary_duplicate_review_context(
|
||||||
|
summaries_root: Path,
|
||||||
|
summary: dict,
|
||||||
|
seen_source_ids: set[str],
|
||||||
|
seen_semantic_keys: set[str],
|
||||||
|
) -> tuple[list[str], list[dict], bool, bool]:
|
||||||
|
source_id = str(summary.get("source_id") or "")
|
||||||
|
semantic_key = course_summary_semantic_key(summary)
|
||||||
|
source_id_duplicate = source_id in seen_source_ids
|
||||||
|
semantic_duplicate = semantic_key in seen_semantic_keys
|
||||||
|
reasons: list[str] = []
|
||||||
|
if source_id_duplicate:
|
||||||
|
reasons.append("来源ID重复,新增课程小结需人工复核")
|
||||||
|
if semantic_duplicate:
|
||||||
|
reasons.append("学生、日期、老师、科目、时间和正文均重复,新增课程小结需人工复核")
|
||||||
|
conflicts = find_course_summary_duplicate_conflicts(
|
||||||
|
summaries_root,
|
||||||
|
summary,
|
||||||
|
source_id_duplicate=source_id_duplicate,
|
||||||
|
semantic_duplicate=semantic_duplicate,
|
||||||
|
)
|
||||||
|
if not conflicts:
|
||||||
|
if source_id_duplicate:
|
||||||
|
reasons.append("状态文件中已存在相同来源ID,但正式小结库未找到对应文件")
|
||||||
|
if semantic_duplicate:
|
||||||
|
reasons.append("状态文件中已存在相同语义指纹,但正式小结库未找到对应文件")
|
||||||
|
return reasons, conflicts, source_id_duplicate, semantic_duplicate
|
||||||
|
|
||||||
|
|
||||||
|
def is_duplicate_course_summary_review_task(task: dict) -> bool:
|
||||||
|
return bool(
|
||||||
|
task.get("pending_summary_save")
|
||||||
|
or task.get("duplicate_source_id")
|
||||||
|
or task.get("duplicate_semantic_key")
|
||||||
|
or task.get("duplicate_reasons")
|
||||||
|
or task.get("duplicate_conflicts")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def course_summary_quality_score(item: dict) -> tuple[int, int, int, str]:
|
def course_summary_quality_score(item: dict) -> tuple[int, int, int, str]:
|
||||||
title = str(item.get("title") or "")
|
title = str(item.get("title") or "")
|
||||||
body = str(item.get("body") or "")
|
body = str(item.get("body") or "")
|
||||||
@@ -2507,17 +2612,21 @@ def course_summary_heading(summary: dict, existing_headings: set[str]) -> str:
|
|||||||
return f"{base}({sha1_text(str(summary.get('source_id') or '') + str(summary.get('body') or ''), 8)})"
|
return f"{base}({sha1_text(str(summary.get('source_id') or '') + str(summary.get('body') or ''), 8)})"
|
||||||
|
|
||||||
|
|
||||||
def save_course_summary_markdown(root: Path, summary: dict) -> dict:
|
def course_summary_markdown_text(
|
||||||
path = course_summary_path(root, summary)
|
existing: str,
|
||||||
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
path: Path,
|
||||||
|
summary: dict,
|
||||||
|
*,
|
||||||
|
allow_same_source_id: bool = False,
|
||||||
|
) -> tuple[str, bool, str]:
|
||||||
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(existing))
|
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(existing))
|
||||||
existing_headings = {match.group("title").strip() for match in matches}
|
existing_headings = {match.group("title").strip() for match in matches}
|
||||||
body = str(summary.get("body") or "").rstrip()
|
body = str(summary.get("body") or "").rstrip()
|
||||||
group_name = str(summary.get("group") or summary["student"])
|
group_name = str(summary.get("group") or summary["student"])
|
||||||
heading = course_summary_heading(summary, existing_headings)
|
heading = course_summary_heading(summary, existing_headings)
|
||||||
source_id = str(summary.get("source_id") or "").strip()
|
source_id = str(summary.get("source_id") or "").strip()
|
||||||
if source_id and f"来源ID:`{source_id}`" in existing:
|
if not allow_same_source_id and source_id and f"来源ID:`{source_id}`" in existing:
|
||||||
return {"path": str(path), "added": False}
|
return existing, False, heading
|
||||||
existing_blocks: set[tuple[str, str]] = set()
|
existing_blocks: set[tuple[str, str]] = set()
|
||||||
for index, match in enumerate(matches):
|
for index, match in enumerate(matches):
|
||||||
start = match.end()
|
start = match.end()
|
||||||
@@ -2526,7 +2635,7 @@ def save_course_summary_markdown(root: Path, summary: dict) -> dict:
|
|||||||
block_body = re.sub(r"^(?:>\s+.*\n)+\s*", "", block_body).strip()
|
block_body = re.sub(r"^(?:>\s+.*\n)+\s*", "", block_body).strip()
|
||||||
existing_blocks.add((match.group("title").strip(), re.sub(r"\s+", "", block_body)))
|
existing_blocks.add((match.group("title").strip(), re.sub(r"\s+", "", block_body)))
|
||||||
if (heading, re.sub(r"\s+", "", body)) in existing_blocks:
|
if (heading, re.sub(r"\s+", "", body)) in existing_blocks:
|
||||||
return {"path": str(path), "added": False}
|
return existing, False, heading
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
if not existing.strip():
|
if not existing.strip():
|
||||||
lines.extend([f"# {path.stem}", "", f"## {group_name}", ""])
|
lines.extend([f"# {path.stem}", "", f"## {group_name}", ""])
|
||||||
@@ -2544,11 +2653,20 @@ def save_course_summary_markdown(root: Path, summary: dict) -> dict:
|
|||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
prefix = existing
|
||||||
|
if existing and not existing.endswith("\n"):
|
||||||
|
prefix += "\n"
|
||||||
|
return prefix + "\n".join(lines).rstrip() + "\n", True, heading
|
||||||
|
|
||||||
|
|
||||||
|
def save_course_summary_markdown(root: Path, summary: dict) -> dict:
|
||||||
|
path = course_summary_path(root, summary)
|
||||||
|
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||||
|
new_text, added, heading = course_summary_markdown_text(existing, path, summary)
|
||||||
|
if not added:
|
||||||
|
return {"path": str(path), "added": False, "heading": heading}
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with path.open("a", encoding="utf-8") as handle:
|
atomic_write_text(path, new_text)
|
||||||
if existing and not existing.endswith("\n"):
|
|
||||||
handle.write("\n")
|
|
||||||
handle.write("\n".join(lines).rstrip() + "\n")
|
|
||||||
return {"path": str(path), "added": True, "heading": heading}
|
return {"path": str(path), "added": True, "heading": heading}
|
||||||
|
|
||||||
|
|
||||||
@@ -2621,6 +2739,7 @@ def create_course_summary_review_task(
|
|||||||
proposed_line: str,
|
proposed_line: str,
|
||||||
reasons: list[str],
|
reasons: list[str],
|
||||||
saved_path: str = "",
|
saved_path: str = "",
|
||||||
|
extra_fields: dict | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
tasks = read_admin_tasks(tasks_path)
|
tasks = read_admin_tasks(tasks_path)
|
||||||
now = datetime.now().isoformat(timespec="seconds")
|
now = datetime.now().isoformat(timespec="seconds")
|
||||||
@@ -2637,6 +2756,8 @@ def create_course_summary_review_task(
|
|||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
"saved_path": saved_path,
|
"saved_path": saved_path,
|
||||||
}
|
}
|
||||||
|
if extra_fields:
|
||||||
|
task.update(extra_fields)
|
||||||
tasks["next_id"] = int(tasks["next_id"]) + 1
|
tasks["next_id"] = int(tasks["next_id"]) + 1
|
||||||
tasks["items"].append(task)
|
tasks["items"].append(task)
|
||||||
write_admin_tasks(tasks_path, tasks)
|
write_admin_tasks(tasks_path, tasks)
|
||||||
@@ -2701,6 +2822,8 @@ def editable_course_summary_payload(summary: dict, updates: dict) -> dict:
|
|||||||
|
|
||||||
def update_course_summary_review_task(
|
def update_course_summary_review_task(
|
||||||
tasks_path: Path,
|
tasks_path: Path,
|
||||||
|
summaries_root: Path,
|
||||||
|
state_path: Path,
|
||||||
classnotes_path: Path,
|
classnotes_path: Path,
|
||||||
accounts_path: Path,
|
accounts_path: Path,
|
||||||
task_id: int,
|
task_id: int,
|
||||||
@@ -2715,6 +2838,39 @@ def update_course_summary_review_task(
|
|||||||
summary = task.get("summary") or {}
|
summary = task.get("summary") or {}
|
||||||
updated_summary = editable_course_summary_payload(summary, updates)
|
updated_summary = editable_course_summary_payload(summary, updates)
|
||||||
reasons, proposed_line = auto_register_reasons(updated_summary, classnotes_path, accounts_path)
|
reasons, proposed_line = auto_register_reasons(updated_summary, classnotes_path, accounts_path)
|
||||||
|
if is_duplicate_course_summary_review_task(task):
|
||||||
|
state = read_course_summary_state(state_path)
|
||||||
|
source_ids = {str(item) for item in state.get("seen_source_ids", [])}
|
||||||
|
semantic_keys = {str(item) for item in state.get("seen_semantic_keys", [])}
|
||||||
|
source_ids.discard(str(summary.get("source_id") or ""))
|
||||||
|
if task.get("semantic_key"):
|
||||||
|
semantic_keys.discard(str(task.get("semantic_key") or ""))
|
||||||
|
semantic_keys.discard(course_summary_semantic_key(summary))
|
||||||
|
duplicate_reasons, duplicate_conflicts, source_id_duplicate, semantic_duplicate = course_summary_duplicate_review_context(
|
||||||
|
summaries_root,
|
||||||
|
updated_summary,
|
||||||
|
source_ids,
|
||||||
|
semantic_keys,
|
||||||
|
)
|
||||||
|
merged_reasons = [*duplicate_reasons]
|
||||||
|
merged_reasons.extend(reason for reason in reasons if reason not in merged_reasons)
|
||||||
|
reasons = merged_reasons
|
||||||
|
task["duplicate_reasons"] = duplicate_reasons
|
||||||
|
task["duplicate_conflicts"] = duplicate_conflicts
|
||||||
|
task["duplicate_source_id"] = source_id_duplicate
|
||||||
|
task["duplicate_semantic_key"] = semantic_duplicate
|
||||||
|
task["duplicate_source"] = summary_as_duplicate_candidate(
|
||||||
|
updated_summary,
|
||||||
|
str(course_summary_path(summaries_root, updated_summary)),
|
||||||
|
)
|
||||||
|
task["semantic_key"] = course_summary_semantic_key(updated_summary)
|
||||||
|
task["pending_summary_save"] = True
|
||||||
|
task["saved_path"] = str(course_summary_path(summaries_root, updated_summary))
|
||||||
|
source_ids.add(str(updated_summary.get("source_id") or ""))
|
||||||
|
semantic_keys.add(task["semantic_key"])
|
||||||
|
state["seen_source_ids"] = sorted(source_ids)
|
||||||
|
state["seen_semantic_keys"] = sorted(semantic_keys)
|
||||||
|
write_course_summary_state(state_path, state)
|
||||||
now = datetime.now().isoformat(timespec="seconds")
|
now = datetime.now().isoformat(timespec="seconds")
|
||||||
task["status"] = "pending"
|
task["status"] = "pending"
|
||||||
task["updated_at"] = now
|
task["updated_at"] = now
|
||||||
@@ -2752,6 +2908,7 @@ def link_existing_course_summary_task(tasks_path: Path, classnotes_path: Path, t
|
|||||||
|
|
||||||
def approve_course_summary_task(
|
def approve_course_summary_task(
|
||||||
tasks_path: Path,
|
tasks_path: Path,
|
||||||
|
summaries_root: Path,
|
||||||
classnotes_path: Path,
|
classnotes_path: Path,
|
||||||
accounts_path: Path,
|
accounts_path: Path,
|
||||||
task_id: int,
|
task_id: int,
|
||||||
@@ -2764,13 +2921,40 @@ def approve_course_summary_task(
|
|||||||
raise ValueError("该任务已处理,不能重复批准")
|
raise ValueError("该任务已处理,不能重复批准")
|
||||||
|
|
||||||
proposed_line = str(task.get("proposed_line") or "").strip()
|
proposed_line = str(task.get("proposed_line") or "").strip()
|
||||||
|
summary = task.get("summary") or {}
|
||||||
if not proposed_line:
|
if not proposed_line:
|
||||||
raise ValueError("课程小结信息未补齐,不能直接批准入账")
|
try:
|
||||||
|
proposed_line = course_summary_to_class_record_line(summary)
|
||||||
|
except ValueError as exc:
|
||||||
|
task["status"] = "conflict"
|
||||||
|
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
|
||||||
|
task["message"] = str(exc)
|
||||||
|
write_admin_tasks(tasks_path, tasks)
|
||||||
|
raise
|
||||||
|
if not summary:
|
||||||
|
raise ValueError("课程小结信息缺失,不能批准入账")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
original_classnotes = classnotes_path.read_text(encoding="utf-8")
|
original_classnotes = classnotes_path.read_text(encoding="utf-8")
|
||||||
original_accounts = accounts_path.read_text(encoding="utf-8")
|
original_accounts = accounts_path.read_text(encoding="utf-8")
|
||||||
original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
|
original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
|
||||||
|
should_save_summary = bool(task.get("pending_summary_save"))
|
||||||
|
summary_path: Path | None = None
|
||||||
|
original_summary_text = ""
|
||||||
|
summary_existed = False
|
||||||
|
new_summary_text = ""
|
||||||
|
summary_added = False
|
||||||
|
summary_heading = ""
|
||||||
|
if should_save_summary:
|
||||||
|
summary_path = course_summary_path(summaries_root, summary)
|
||||||
|
summary_existed = summary_path.exists()
|
||||||
|
original_summary_text = summary_path.read_text(encoding="utf-8") if summary_existed else ""
|
||||||
|
new_summary_text, summary_added, summary_heading = course_summary_markdown_text(
|
||||||
|
original_summary_text,
|
||||||
|
summary_path,
|
||||||
|
summary,
|
||||||
|
allow_same_source_id=True,
|
||||||
|
)
|
||||||
record = parse_class_record_line(proposed_line)
|
record = parse_class_record_line(proposed_line)
|
||||||
record_line = class_record_to_line(record)
|
record_line = class_record_to_line(record)
|
||||||
existing_lines = {raw.strip() for raw in original_classnotes.splitlines()}
|
existing_lines = {raw.strip() for raw in original_classnotes.splitlines()}
|
||||||
@@ -2792,12 +2976,17 @@ def approve_course_summary_task(
|
|||||||
task["reviewed_at"] = task["updated_at"]
|
task["reviewed_at"] = task["updated_at"]
|
||||||
task["proposed_line"] = record_line
|
task["proposed_line"] = record_line
|
||||||
task["registered_line"] = record_line
|
task["registered_line"] = record_line
|
||||||
|
if summary_path is not None:
|
||||||
|
task["saved_path"] = str(summary_path)
|
||||||
|
if summary_heading:
|
||||||
|
task["summary_heading"] = summary_heading
|
||||||
backup_dir = create_data_backup(
|
backup_dir = create_data_backup(
|
||||||
"admin-approve-course-summary",
|
"admin-approve-course-summary",
|
||||||
{
|
{
|
||||||
accounts_path: original_accounts,
|
accounts_path: original_accounts,
|
||||||
classnotes_path: original_classnotes,
|
classnotes_path: original_classnotes,
|
||||||
tasks_path: original_tasks,
|
tasks_path: original_tasks,
|
||||||
|
**({summary_path: original_summary_text} if summary_path is not None else {}),
|
||||||
},
|
},
|
||||||
[record_line],
|
[record_line],
|
||||||
)
|
)
|
||||||
@@ -2806,11 +2995,18 @@ def approve_course_summary_task(
|
|||||||
try:
|
try:
|
||||||
atomic_write_text(accounts_path, new_accounts)
|
atomic_write_text(accounts_path, new_accounts)
|
||||||
atomic_write_text(classnotes_path, new_classnotes)
|
atomic_write_text(classnotes_path, new_classnotes)
|
||||||
|
if summary_path is not None and summary_added:
|
||||||
|
atomic_write_text(summary_path, new_summary_text)
|
||||||
atomic_write_text(tasks_path, new_tasks)
|
atomic_write_text(tasks_path, new_tasks)
|
||||||
except Exception:
|
except Exception:
|
||||||
atomic_write_text(accounts_path, original_accounts)
|
atomic_write_text(accounts_path, original_accounts)
|
||||||
atomic_write_text(classnotes_path, original_classnotes)
|
atomic_write_text(classnotes_path, original_classnotes)
|
||||||
atomic_write_text(tasks_path, original_tasks)
|
atomic_write_text(tasks_path, original_tasks)
|
||||||
|
if summary_path is not None:
|
||||||
|
if summary_existed:
|
||||||
|
atomic_write_text(summary_path, original_summary_text)
|
||||||
|
elif summary_path.exists():
|
||||||
|
summary_path.unlink()
|
||||||
raise
|
raise
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
task["status"] = "conflict"
|
task["status"] = "conflict"
|
||||||
@@ -2828,6 +3024,7 @@ def approve_course_summary_task(
|
|||||||
|
|
||||||
def approve_admin_task(
|
def approve_admin_task(
|
||||||
tasks_path: Path,
|
tasks_path: Path,
|
||||||
|
summaries_root: Path,
|
||||||
classnotes_path: Path,
|
classnotes_path: Path,
|
||||||
accounts_path: Path,
|
accounts_path: Path,
|
||||||
task_id: int,
|
task_id: int,
|
||||||
@@ -2838,7 +3035,7 @@ def approve_admin_task(
|
|||||||
if task.get("type") == "class_record_deletion":
|
if task.get("type") == "class_record_deletion":
|
||||||
return approve_deletion_task(tasks_path, classnotes_path, accounts_path, task_id)
|
return approve_deletion_task(tasks_path, classnotes_path, accounts_path, task_id)
|
||||||
if task.get("type") == "course_summary_review":
|
if task.get("type") == "course_summary_review":
|
||||||
return approve_course_summary_task(tasks_path, classnotes_path, accounts_path, task_id)
|
return approve_course_summary_task(tasks_path, summaries_root, classnotes_path, accounts_path, task_id)
|
||||||
raise ValueError("不支持的审核任务类型")
|
raise ValueError("不支持的审核任务类型")
|
||||||
|
|
||||||
|
|
||||||
@@ -2879,16 +3076,84 @@ def register_course_summary_texts(
|
|||||||
seen_source_ids = set(str(item) for item in state.get("seen_source_ids", []))
|
seen_source_ids = set(str(item) for item in state.get("seen_source_ids", []))
|
||||||
seen_semantic_keys = set(str(item) for item in state.get("seen_semantic_keys", []))
|
seen_semantic_keys = set(str(item) for item in state.get("seen_semantic_keys", []))
|
||||||
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
|
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
|
||||||
result["duplicates"] += 1
|
duplicate_reasons, duplicate_conflicts, source_id_duplicate, semantic_duplicate = course_summary_duplicate_review_context(
|
||||||
|
summaries_root,
|
||||||
|
normalized,
|
||||||
|
seen_source_ids,
|
||||||
|
seen_semantic_keys,
|
||||||
|
)
|
||||||
|
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
|
||||||
|
if source_id_duplicate:
|
||||||
|
duplicate_reason = "来源ID重复,已转入审核"
|
||||||
|
else:
|
||||||
|
duplicate_reason = "课程内容与已有课程小结重复,已转入审核"
|
||||||
|
review_reasons = [duplicate_reason, *duplicate_reasons]
|
||||||
|
review_reasons.extend(reason for reason in reasons if reason not in review_reasons)
|
||||||
|
task = create_course_summary_review_task(
|
||||||
|
tasks_path,
|
||||||
|
normalized,
|
||||||
|
proposed_line,
|
||||||
|
review_reasons,
|
||||||
|
str(course_summary_path(summaries_root, normalized)),
|
||||||
|
extra_fields={
|
||||||
|
"duplicate_source": summary_as_duplicate_candidate(normalized),
|
||||||
|
"duplicate_reasons": duplicate_reasons,
|
||||||
|
"duplicate_conflicts": duplicate_conflicts,
|
||||||
|
"duplicate_source_id": source_id_duplicate,
|
||||||
|
"duplicate_semantic_key": semantic_duplicate,
|
||||||
|
"semantic_key": semantic_key,
|
||||||
|
"pending_summary_save": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
seen_source_ids.add(source_id)
|
||||||
|
seen_semantic_keys.add(semantic_key)
|
||||||
|
state["seen_source_ids"] = sorted(seen_source_ids)
|
||||||
|
state["seen_semantic_keys"] = sorted(seen_semantic_keys)
|
||||||
|
state.setdefault("batches", []).append(
|
||||||
|
{
|
||||||
|
"batch_id": f"manual-{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text(source_id, 8)}",
|
||||||
|
"received_at": now,
|
||||||
|
"window": {"source": "admin_register", "submitted_at": now},
|
||||||
|
"students": [normalized["student"]],
|
||||||
|
"result": {
|
||||||
|
"received": 1,
|
||||||
|
"saved": 0,
|
||||||
|
"auto_registered": 0,
|
||||||
|
"review_pending": 1,
|
||||||
|
"duplicates": 1,
|
||||||
|
"rejected": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
state["batches"] = state["batches"][-200:]
|
||||||
|
write_course_summary_state(state_path, state)
|
||||||
log_id = append_operation_log(
|
log_id = append_operation_log(
|
||||||
operation_logs_path,
|
operation_logs_path,
|
||||||
"课程小结登记",
|
"课程小结登记",
|
||||||
"重复",
|
"待审核",
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
student=normalized["student"],
|
student=normalized["student"],
|
||||||
|
teacher=normalized.get("teacher", ""),
|
||||||
|
subject=normalized.get("subject", ""),
|
||||||
|
proposed_line=proposed_line,
|
||||||
|
reasons=review_reasons,
|
||||||
|
task_id=str(task.get("id") or ""),
|
||||||
|
saved_path=str(course_summary_path(summaries_root, normalized)),
|
||||||
|
duplicate_source_id=source_id_duplicate,
|
||||||
|
duplicate_semantic_key=semantic_duplicate,
|
||||||
)
|
)
|
||||||
|
result["review_pending"] += 1
|
||||||
|
result["duplicates"] += 1
|
||||||
result["operation_log_ids"].append(log_id)
|
result["operation_log_ids"].append(log_id)
|
||||||
result["items"].append({"source_id": source_id, "status": "duplicate"})
|
result["items"].append(
|
||||||
|
{
|
||||||
|
"source_id": source_id,
|
||||||
|
"status": "review_pending",
|
||||||
|
"task_id": task.get("id"),
|
||||||
|
"reasons": review_reasons,
|
||||||
|
"duplicate_conflicts": duplicate_conflicts,
|
||||||
|
}
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
|
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
|
||||||
@@ -2963,7 +3228,6 @@ def register_course_summary_texts(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
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(
|
duplicate_tasks = create_course_summary_duplicate_review_tasks(
|
||||||
tasks_path,
|
tasks_path,
|
||||||
@@ -3088,17 +3352,66 @@ def ingest_course_summaries(
|
|||||||
source_id = normalized["source_id"]
|
source_id = normalized["source_id"]
|
||||||
semantic_key = course_summary_semantic_key(normalized)
|
semantic_key = course_summary_semantic_key(normalized)
|
||||||
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
|
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
|
||||||
|
duplicate_reasons, duplicate_conflicts, source_id_duplicate, semantic_duplicate = course_summary_duplicate_review_context(
|
||||||
|
summaries_root,
|
||||||
|
normalized,
|
||||||
|
seen_source_ids,
|
||||||
|
seen_semantic_keys,
|
||||||
|
)
|
||||||
|
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
|
||||||
|
if source_id_duplicate:
|
||||||
|
duplicate_reason = "来源ID重复,已转入审核"
|
||||||
|
else:
|
||||||
|
duplicate_reason = "课程内容与已有课程小结重复,已转入审核"
|
||||||
|
review_reasons = [duplicate_reason, *duplicate_reasons]
|
||||||
|
review_reasons.extend(reason for reason in reasons if reason not in review_reasons)
|
||||||
|
task = create_course_summary_review_task(
|
||||||
|
tasks_path,
|
||||||
|
normalized,
|
||||||
|
proposed_line,
|
||||||
|
review_reasons,
|
||||||
|
str(course_summary_path(summaries_root, normalized)),
|
||||||
|
extra_fields={
|
||||||
|
"duplicate_source": summary_as_duplicate_candidate(normalized),
|
||||||
|
"duplicate_reasons": duplicate_reasons,
|
||||||
|
"duplicate_conflicts": duplicate_conflicts,
|
||||||
|
"duplicate_source_id": source_id_duplicate,
|
||||||
|
"duplicate_semantic_key": semantic_duplicate,
|
||||||
|
"semantic_key": semantic_key,
|
||||||
|
"pending_summary_save": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
seen_source_ids.add(source_id)
|
||||||
|
seen_semantic_keys.add(semantic_key)
|
||||||
|
result["review_pending"] += 1
|
||||||
result["duplicates"] += 1
|
result["duplicates"] += 1
|
||||||
log_id = append_operation_log(
|
log_id = append_operation_log(
|
||||||
operation_logs_path,
|
operation_logs_path,
|
||||||
"课程小结接收",
|
"课程小结接收",
|
||||||
"重复",
|
"待审核",
|
||||||
batch_id=batch_id,
|
batch_id=batch_id,
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
student=normalized["student"],
|
student=normalized["student"],
|
||||||
|
teacher=normalized.get("teacher", ""),
|
||||||
|
subject=normalized.get("subject", ""),
|
||||||
|
proposed_line=proposed_line,
|
||||||
|
reasons=review_reasons,
|
||||||
|
task_id=task.get("id"),
|
||||||
|
saved_path=str(course_summary_path(summaries_root, normalized)),
|
||||||
|
duplicate_source_id=source_id_duplicate,
|
||||||
|
duplicate_semantic_key=semantic_duplicate,
|
||||||
)
|
)
|
||||||
result["operation_log_ids"].append(log_id)
|
result["operation_log_ids"].append(log_id)
|
||||||
result["items"].append({"source_id": source_id, "status": "duplicate"})
|
result["items"].append(
|
||||||
|
{
|
||||||
|
"source_id": source_id,
|
||||||
|
"status": "待审核",
|
||||||
|
"task_id": task.get("id"),
|
||||||
|
"backup_id": "",
|
||||||
|
"reasons": review_reasons,
|
||||||
|
"duplicate_conflicts": duplicate_conflicts,
|
||||||
|
}
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
saved = save_course_summary_markdown(summaries_root, normalized)
|
saved = save_course_summary_markdown(summaries_root, normalized)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from ..config import (
|
|||||||
ADMIN_TASKS_PATH,
|
ADMIN_TASKS_PATH,
|
||||||
CLASSNOTES_PATH,
|
CLASSNOTES_PATH,
|
||||||
COURSE_SUMMARIES_ROOT,
|
COURSE_SUMMARIES_ROOT,
|
||||||
|
COURSE_SUMMARY_STATE_PATH,
|
||||||
OPERATION_LOGS_PATH,
|
OPERATION_LOGS_PATH,
|
||||||
write_lock,
|
write_lock,
|
||||||
)
|
)
|
||||||
@@ -129,7 +130,7 @@ def admin_course_summaries(
|
|||||||
def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
||||||
try:
|
try:
|
||||||
with write_lock:
|
with write_lock:
|
||||||
result = approve_admin_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id)
|
result = approve_admin_task(ADMIN_TASKS_PATH, COURSE_SUMMARIES_ROOT, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id)
|
||||||
task = result.get("task", {})
|
task = result.get("task", {})
|
||||||
append_operation_log(
|
append_operation_log(
|
||||||
OPERATION_LOGS_PATH,
|
OPERATION_LOGS_PATH,
|
||||||
@@ -140,6 +141,7 @@ def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
|||||||
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
|
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
|
||||||
source_id=str(task.get("source_id") or ""),
|
source_id=str(task.get("source_id") or ""),
|
||||||
backup_id=str(result.get("backup_id") or task.get("backup_id") or ""),
|
backup_id=str(result.get("backup_id") or task.get("backup_id") or ""),
|
||||||
|
saved_path=str(task.get("saved_path") or ""),
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
@@ -179,6 +181,8 @@ def admin_update_course_summary_review(task_id: int, payload: dict, _user: str =
|
|||||||
with write_lock:
|
with write_lock:
|
||||||
result = update_course_summary_review_task(
|
result = update_course_summary_review_task(
|
||||||
ADMIN_TASKS_PATH,
|
ADMIN_TASKS_PATH,
|
||||||
|
COURSE_SUMMARIES_ROOT,
|
||||||
|
COURSE_SUMMARY_STATE_PATH,
|
||||||
CLASSNOTES_PATH,
|
CLASSNOTES_PATH,
|
||||||
ACCOUNTS_PATH,
|
ACCOUNTS_PATH,
|
||||||
task_id,
|
task_id,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>管理后台</title>
|
<title>管理后台</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=20260618-summary-search-expand" />
|
<link rel="stylesheet" href="/static/styles.css?v=20260618-summary-duplicate-review" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -300,6 +300,10 @@
|
|||||||
<div class="drawer-label">审核原因</div>
|
<div class="drawer-label">审核原因</div>
|
||||||
<div id="summaryReviewDrawerReasons" class="drawer-text"></div>
|
<div id="summaryReviewDrawerReasons" class="drawer-text"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="summaryReviewConflictSection" class="drawer-section" hidden>
|
||||||
|
<div class="drawer-label">冲突信息</div>
|
||||||
|
<div id="summaryReviewDrawerConflicts" class="summary-conflict-list"></div>
|
||||||
|
</div>
|
||||||
<div class="drawer-section">
|
<div class="drawer-section">
|
||||||
<div class="drawer-label">小结原文</div>
|
<div class="drawer-label">小结原文</div>
|
||||||
<div id="summaryReviewDrawerBody" class="drawer-body"></div>
|
<div id="summaryReviewDrawerBody" class="drawer-body"></div>
|
||||||
@@ -450,6 +454,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/admin.js?v=20260618-summary-search-expand"></script>
|
<script src="/static/admin.js?v=20260618-summary-duplicate-review"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+57
-4
@@ -64,6 +64,8 @@ const summaryReviewEditTeacher = document.querySelector("#summaryReviewEditTeach
|
|||||||
const summaryReviewEditSubject = document.querySelector("#summaryReviewEditSubject");
|
const summaryReviewEditSubject = document.querySelector("#summaryReviewEditSubject");
|
||||||
const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine");
|
const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine");
|
||||||
const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons");
|
const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons");
|
||||||
|
const summaryReviewConflictSection = document.querySelector("#summaryReviewConflictSection");
|
||||||
|
const summaryReviewDrawerConflicts = document.querySelector("#summaryReviewDrawerConflicts");
|
||||||
const summaryReviewDrawerBody = document.querySelector("#summaryReviewDrawerBody");
|
const summaryReviewDrawerBody = document.querySelector("#summaryReviewDrawerBody");
|
||||||
const summaryReviewDrawerSource = document.querySelector("#summaryReviewDrawerSource");
|
const summaryReviewDrawerSource = document.querySelector("#summaryReviewDrawerSource");
|
||||||
const summaryReviewDrawerSave = document.querySelector("#summaryReviewDrawerSave");
|
const summaryReviewDrawerSave = document.querySelector("#summaryReviewDrawerSave");
|
||||||
@@ -523,6 +525,7 @@ async function loadReviews() {
|
|||||||
|
|
||||||
function summaryReviewKind(item) {
|
function summaryReviewKind(item) {
|
||||||
if (item.type === "course_summary_duplicate_review") return "重复小结";
|
if (item.type === "course_summary_duplicate_review") return "重复小结";
|
||||||
|
if (hasDuplicateReviewContext(item)) 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缺失")) {
|
||||||
@@ -557,18 +560,33 @@ function isDuplicateSummaryReview(item) {
|
|||||||
return item && item.type === "course_summary_duplicate_review";
|
return item && item.type === "course_summary_duplicate_review";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDuplicateCandidate(candidate, canReview) {
|
function hasDuplicateReviewContext(item) {
|
||||||
|
return Boolean(
|
||||||
|
item
|
||||||
|
&& !isDuplicateSummaryReview(item)
|
||||||
|
&& (
|
||||||
|
item.duplicate_source_id
|
||||||
|
|| item.duplicate_semantic_key
|
||||||
|
|| (Array.isArray(item.duplicate_reasons) && item.duplicate_reasons.length)
|
||||||
|
|| (Array.isArray(item.duplicate_conflicts) && item.duplicate_conflicts.length)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDuplicateCandidate(candidate, canReview, options = {}) {
|
||||||
|
const showDelete = options.showDelete !== false;
|
||||||
const source = [
|
const source = [
|
||||||
candidate.source_id ? `来源ID:${candidate.source_id}` : "",
|
candidate.source_id ? `来源ID:${candidate.source_id}` : "",
|
||||||
candidate.message_time ? `发送时间:${candidate.message_time}` : "",
|
candidate.message_time ? `发送时间:${candidate.message_time}` : "",
|
||||||
candidate.sender ? `发送者:${candidate.sender}` : "",
|
candidate.sender ? `发送者:${candidate.sender}` : "",
|
||||||
candidate.relative_path ? `文件:${candidate.relative_path}` : "",
|
candidate.relative_path ? `文件:${candidate.relative_path}` : "",
|
||||||
|
candidate.source_path && !candidate.relative_path ? `文件:${candidate.source_path}` : "",
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return `<div class="record-summary-item duplicate-summary-candidate">
|
return `<div class="record-summary-item duplicate-summary-candidate">
|
||||||
<div class="record-summary-title">${escapeHtml(candidate.title || "课程小结")}</div>
|
<div class="record-summary-title">${escapeHtml(candidate.title || "课程小结")}</div>
|
||||||
<div class="drawer-text">${source.map(escapeHtml).join("<br>") || "暂无来源信息"}</div>
|
<div class="drawer-text">${source.map(escapeHtml).join("<br>") || "暂无来源信息"}</div>
|
||||||
<div class="summary-body">${escapeHtml(candidate.body || candidate.body_preview || "暂无正文")}</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>
|
${showDelete ? `<button class="small-button duplicate-summary-delete" type="button" data-summary-id="${escapeHtml(candidate.id)}" ${canReview ? "" : "disabled"}>删除这一条</button>` : ""}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,6 +596,34 @@ function renderDuplicateCandidates(item, canReview) {
|
|||||||
return candidates.map((candidate) => renderDuplicateCandidate(candidate, canReview)).join("");
|
return candidates.map((candidate) => renderDuplicateCandidate(candidate, canReview)).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSummaryDuplicateConflicts(item) {
|
||||||
|
const conflicts = Array.isArray(item.duplicate_conflicts) ? item.duplicate_conflicts : [];
|
||||||
|
const reasonLines = Array.isArray(item.duplicate_reasons) ? item.duplicate_reasons : [];
|
||||||
|
const source = item.duplicate_source;
|
||||||
|
const parts = [];
|
||||||
|
if (reasonLines.length) {
|
||||||
|
parts.push(`<div class="summary-conflict-reasons">${reasonLines.map(escapeHtml).join("<br>")}</div>`);
|
||||||
|
}
|
||||||
|
if (source) {
|
||||||
|
parts.push(`<div class="summary-conflict-source">
|
||||||
|
<div class="record-summary-title">本次提交</div>
|
||||||
|
<div class="drawer-text">${[
|
||||||
|
source.source_id ? `来源ID:${source.source_id}` : "",
|
||||||
|
source.message_time ? `发送时间:${source.message_time}` : "",
|
||||||
|
source.sender ? `发送者:${source.sender}` : "",
|
||||||
|
].filter(Boolean).map(escapeHtml).join("<br>") || "暂无来源信息"}</div>
|
||||||
|
<div class="summary-body">${escapeHtml(source.body || source.body_preview || "暂无正文")}</div>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
if (conflicts.length) {
|
||||||
|
parts.push(`<div class="summary-conflict-heading">命中的已有小结</div>`);
|
||||||
|
parts.push(conflicts.map((candidate) => renderDuplicateCandidate(candidate, false, { showDelete: false })).join(""));
|
||||||
|
} else {
|
||||||
|
parts.push("<span class=\"muted\">状态文件命中重复,但正式小结库未找到对应小结。</span>");
|
||||||
|
}
|
||||||
|
return parts.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));
|
||||||
}
|
}
|
||||||
@@ -627,6 +673,8 @@ function openSummaryReviewDrawer(taskId) {
|
|||||||
const kind = summaryReviewKind(item);
|
const kind = summaryReviewKind(item);
|
||||||
const canReview = canReviewSummary(item);
|
const canReview = canReviewSummary(item);
|
||||||
const duplicateReview = isDuplicateSummaryReview(item);
|
const duplicateReview = isDuplicateSummaryReview(item);
|
||||||
|
const duplicateContext = hasDuplicateReviewContext(item);
|
||||||
|
const pendingSummarySave = Boolean(item.pending_summary_save);
|
||||||
activeSummaryReview = item;
|
activeSummaryReview = item;
|
||||||
|
|
||||||
summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`;
|
summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`;
|
||||||
@@ -647,9 +695,11 @@ function openSummaryReviewDrawer(taskId) {
|
|||||||
} else {
|
} else {
|
||||||
summaryReviewDrawerBody.textContent = summary.body || "暂无原文";
|
summaryReviewDrawerBody.textContent = summary.body || "暂无原文";
|
||||||
}
|
}
|
||||||
|
summaryReviewConflictSection.hidden = !duplicateContext;
|
||||||
|
summaryReviewDrawerConflicts.innerHTML = duplicateContext ? renderSummaryDuplicateConflicts(item) : "";
|
||||||
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 ? `${duplicateContext || pendingSummarySave ? "目标文件" : "保存文件"}:${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>") || "暂无来源信息";
|
||||||
summaryReviewDrawerSave.hidden = duplicateReview;
|
summaryReviewDrawerSave.hidden = duplicateReview;
|
||||||
@@ -668,6 +718,8 @@ function closeSummaryReviewDrawer() {
|
|||||||
summaryReviewDrawerSave.hidden = false;
|
summaryReviewDrawerSave.hidden = false;
|
||||||
summaryReviewDrawerLink.hidden = true;
|
summaryReviewDrawerLink.hidden = true;
|
||||||
summaryReviewDrawerApprove.hidden = false;
|
summaryReviewDrawerApprove.hidden = false;
|
||||||
|
summaryReviewConflictSection.hidden = true;
|
||||||
|
summaryReviewDrawerConflicts.innerHTML = "";
|
||||||
summaryReviewDrawerBackdrop.hidden = true;
|
summaryReviewDrawerBackdrop.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,6 +744,7 @@ async function loadSummaryReviews() {
|
|||||||
const duplicateReview = isDuplicateSummaryReview(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 : [];
|
||||||
|
const approveLabel = duplicateReview ? "详情选择" : "批准";
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td>#${escapeHtml(item.id)}</td>
|
<td>#${escapeHtml(item.id)}</td>
|
||||||
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
|
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
|
||||||
@@ -702,7 +755,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 && !duplicateReview ? "" : "disabled"}>${duplicateReview ? "详情选择" : "批准"}</button>
|
<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview && !duplicateReview ? "" : "disabled"}>${approveLabel}</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>
|
||||||
|
|||||||
@@ -658,6 +658,36 @@ textarea:focus {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-conflict-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-conflict-list .record-summary-item,
|
||||||
|
.summary-conflict-source,
|
||||||
|
.summary-conflict-reasons {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-conflict-list .summary-body {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-conflict-heading {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-conflict-reasons {
|
||||||
|
color: #344054;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.summary-search-result-row {
|
.summary-search-result-row {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user