diff --git a/app/MAINTENANCE.md b/app/MAINTENANCE.md index 6495598..e9e03fc 100644 --- a/app/MAINTENANCE.md +++ b/app/MAINTENANCE.md @@ -49,6 +49,33 @@ - `make logs`:查看最近服务日志。 - `make install-gitea-backup`:安装提交后自动推送到 Gitea 的 Git hook。 +## 收尾验证注意事项 + +收尾阶段要验证“当前运行服务”,不要只验证工作区文件。应用镜像在构建时把源码复制进容器;修改 Python、HTML、JS、CSS 后,如果没有重新执行 `make build` 和 `make up`,`curl` 到的接口和浏览器加载的页面仍可能是旧镜像内容。 + +- 部署类验证按依赖顺序执行,不要并行运行 `make up`、`make ps`、`curl`。先等 `make up` 完成,再看 `make ps`,最后访问接口或页面。 +- 如果接口返回旧字段或旧页面,先确认是否已重建并重启容器;不要直接把旧响应判断成代码逻辑错误。 +- 前端脚本或样式变更后,记得同步更新 HTML 中对应静态资源的 `?v=` 参数,再构建镜像,避免浏览器继续使用缓存。 +- 用 `curl` 做收尾验证时优先使用简单命令。需要检查 HTML 内容时,先直接获取页面;如果要配合 `grep`,先在本地确认引号转义正确,避免把 shell 引号错误误判成服务问题。 +- 如果 `curl http://127.0.0.1:<端口>` 连接失败,但 `make ps` 显示容器和端口正常,先区分执行环境网络限制和服务异常;必要时再看 `make logs`。 + +推荐收尾顺序: + +```bash +make check +make build +make up +make ps +``` + +随后再访问具体接口或页面,例如: + +```bash +set -a; . ./app/.env +curl -sS -u "admin:${ACCOUNTS_AUTH_PASSWORD}" "http://127.0.0.1:${APP_PORT:-18080}/api/account-health" +curl -sS -u "admin:${ACCOUNTS_AUTH_PASSWORD}" "http://127.0.0.1:${APP_PORT:-18080}/admin" +``` + ## Gitea 自动备份 新时空教务管理系统推荐把 Gitea SSH 仓库配置为 `origin`,并用 `post-commit` hook 在每次提交后自动推送当前分支。 diff --git a/app/app/data.py b/app/app/data.py index 350ca06..93b92bb 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -45,7 +45,8 @@ TIME_RANGE_RE = re.compile(r"^(?P\d{1,2}):(?P\d{2})-(?P\d{1,2}):(?P< COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P.+)$", re.M) COURSE_SUMMARY_DATE_RE = re.compile(r"(?P<date>\d{4}[.-]\d{1,2}[.-]\d{1,2})") SUMMARY_FIELD_RE = re.compile(r"^(?P<label>学生|学员|日期|上课日期|时间|上课时间|老师|教师|科目|课程|班级|分组|正文|内容|小结)[::]\s*(?P<value>.*)$") -DATE_RANGE_SEPARATOR = r"(?:到|至|-|-|~|—|–)" +TIME_RANGE_SEPARATOR = r"[--–—~到至‐‑‒]" +DATE_RANGE_SEPARATOR = r"(?:到|至|-|-|~|—|–|‐|‑|‒)" CHINESE_DATE_RANGE_RE = re.compile( rf"(?:(?P<sy>\d{{4}})\s*年\s*)?" rf"(?P<sm>\d{{1,2}})\s*月\s*(?P<sd>\d{{1,2}})\s*[日号]?\s*" @@ -874,7 +875,47 @@ def write_admin_tasks(path: Path, tasks: dict) -> None: def task_to_dict(task: dict) -> dict: - return dict(task) + item = dict(task) + if item.get("type") == "course_summary_review": + summary = item.get("summary") or {} + if isinstance(summary, dict) and not summary.get("time_range"): + suggested_time = parse_course_summary_time_text( + "\n".join( + str(value or "") + for value in (summary.get("title"), summary.get("body")) + if value + ) + ) + if suggested_time: + item["suggested_time_range"] = suggested_time + minutes = duration_minutes_from_time_range(suggested_time) + if minutes is not None: + item["suggested_duration"] = duration_text_from_minutes(minutes) + return item + + +def course_summary_duplicate_candidates_with_binding( + candidates: list[dict], + records: list[ClassRecord], +) -> list[dict]: + record_keys = {class_record_binding_key(record): record for record in records} + return [ + { + **candidate, + "binding": course_summary_binding_status(candidate, record_keys, records), + } + for candidate in candidates + ] + + +def task_to_dict_with_context(task: dict, records: list[ClassRecord] | None = None) -> dict: + item = task_to_dict(task) + if records is not None and item.get("type") == "course_summary_duplicate_review": + item["duplicate_candidates"] = course_summary_duplicate_candidates_with_binding( + item.get("duplicate_candidates") or [], + records, + ) + return item def find_admin_task(tasks: dict, task_id: int) -> dict: @@ -962,6 +1003,22 @@ def resolve_teacher_input(value: str, teachers: list[Teacher]) -> str: return canonical_name(text) +def resolve_existing_teacher_input(value: str, teachers: list[Teacher]) -> str: + text = value.strip() + if not text: + raise ValueError("老师不能为空") + matches = [ + teacher + for teacher in teachers + if text in {teacher.teacher_id, teacher.name, teacher.alias} + ] + if len(matches) == 1: + return matches[0].name + if len(matches) > 1: + raise ValueError(f"老师别名不唯一,请在后台修正别名: {text}") + raise ValueError(f"老师不存在,请先在教师档案中维护: {text}") + + def class_record_from_public_item(original: ClassRecord, item: dict, teachers: list[Teacher]) -> ClassRecord: date_text = str(item.get("date") or original.date) time_text = str(item.get("time") or original.time) @@ -1011,18 +1068,27 @@ def submit_public_deletion_tasks(tasks_path: Path, records: list[ClassRecord], i return submit_deletion_tasks(tasks_path, internal_items) -def list_admin_tasks(tasks_path: Path, status_filter: str = "", task_type: str = "") -> dict: +def list_admin_tasks( + tasks_path: Path, + status_filter: str = "", + task_type: str = "", + classnotes_path: Path | None = None, +) -> dict: tasks = read_admin_tasks(tasks_path) items = tasks["items"] if status_filter: items = [item for item in items if item.get("status") == status_filter] if task_type: items = [item for item in items if item.get("type") == task_type] + records = read_classnotes(classnotes_path) if classnotes_path is not None and classnotes_path.exists() else None return { "version": tasks["version"], "next_id": tasks["next_id"], "count": len(items), - "items": [task_to_dict(item) for item in sorted(items, key=lambda item: int(item.get("id", 0)), reverse=True)], + "items": [ + task_to_dict_with_context(item, records) + for item in sorted(items, key=lambda item: int(item.get("id", 0)), reverse=True) + ], } @@ -1057,6 +1123,7 @@ def duplicate_review_task_from_group(task_id: int, group: dict, now: str) -> dic def create_course_summary_duplicate_review_tasks( tasks_path: Path, summaries_root: Path, + classnotes_path: Path | None = None, target_key: tuple[str, str, str, str, str] | None = None, ) -> dict: tasks = read_admin_tasks(tasks_path) @@ -1100,11 +1167,12 @@ def create_course_summary_duplicate_review_tasks( created.append(task) if created or changed: write_admin_tasks(tasks_path, tasks) + records = read_classnotes(classnotes_path) if classnotes_path is not None and classnotes_path.exists() else None return { "scanned": len(groups), "created": len(created), "refreshed": refreshed, - "items": [task_to_dict(task) for task in created], + "items": [task_to_dict_with_context(task, records) for task in created], } @@ -1329,6 +1397,9 @@ def normalize_time_range_text(value: object) -> str: text = str(value or "").strip() if not text: return "" + text = re.sub(r"[:..]", ":", text) + text = re.sub(TIME_RANGE_SEPARATOR, "-", text) + text = re.sub(r"\s+", "", text) match = TIME_RANGE_RE.fullmatch(text) if not match: raise ValueError(f"课程小结时间段格式错误: {text}") @@ -1629,6 +1700,7 @@ OPERATION_LABELS = { "admin-approve-deletion": "审核批准上课记录删除", "admin-update-course-summary-time": "课程小结补齐时间", "admin-update-course-summary-body": "课程小结修改正文", + "admin-update-course-summary-identity": "课程小结修改归属", "admin-delete-course-summary": "课程小结删除", "rollback-operation": "撤回操作", } @@ -2042,9 +2114,8 @@ def parse_course_summary_title_date(title: str) -> str: def parse_course_summary_time_text(text: str) -> str: - separators = r"[--–—~到至]" colon_match = re.search( - rf"(\d{{1,2}})\s*[::..]\s*(\d{{1,2}})\d*\s*{separators}\s*(\d{{1,2}})\s*[::..]\s*(\d{{1,2}})\d*", + rf"(\d{{1,2}})\s*[::..]\s*(\d{{1,2}})\d*\s*{TIME_RANGE_SEPARATOR}\s*(\d{{1,2}})\s*[::..]\s*(\d{{1,2}})\d*", text, ) if colon_match: @@ -2052,7 +2123,7 @@ def parse_course_summary_time_text(text: str) -> str: candidate = f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}" else: point_match = re.search( - rf"(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?\s*{separators}\s*(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?", + rf"(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?\s*{TIME_RANGE_SEPARATOR}\s*(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?", text, ) if not point_match: @@ -2406,6 +2477,150 @@ def course_summary_index_for_records(root: Path) -> dict[tuple[str, str, str, st return index +def class_record_binding_key(record: ClassRecord) -> tuple[str, str, str, str, str]: + return course_summary_record_key( + record.student, + record.teacher, + record.subject, + record.date.replace(".", "-"), + record.time, + ) + + +def course_summary_record_snapshot(record: ClassRecord) -> dict: + return { + "record_id": record_identity(record), + "date": record.date, + "date_iso": record.date.replace(".", "-"), + "weekday": record.weekday, + "time": record.time, + "student": record.student, + "duration": record.duration, + "duration_hours": record.duration_hours, + "teacher": record.teacher, + "subject": record.subject, + } + + +def course_summary_binding_diff(item: dict, record: ClassRecord) -> list[str]: + differences: list[str] = [] + item_key = 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 ""), + ) + record_key = class_record_binding_key(record) + labels = ["学生", "老师", "科目", "日期", "时间"] + for label, item_value, record_value in zip(labels, item_key, record_key): + if item_value != record_value: + differences.append(f"{label}不一致") + return differences + + +def course_summary_candidate_score(item: dict, record: ClassRecord) -> tuple[int, int, int, int, int, str, str]: + item_student = canonical_name(str(item.get("student") or "")) + item_teacher = canonical_teacher_name(str(item.get("teacher") or "")) + item_subject = normalize_subject(str(item.get("subject") or "")) + item_date = str(item.get("date_iso") or "") + item_time = normalize_time_range_text(str(item.get("time_range") or "")) if item.get("time_range") else "" + record_date = record.date.replace(".", "-") + record_subject = normalize_subject(record.subject) + score = 0 + score += 40 if item_student and item_student == record.student else 0 + score += 25 if item_date and item_date == record_date else 0 + score += 15 if item_teacher and item_teacher == record.teacher else 0 + score += 12 if item_subject and item_subject == record_subject else 0 + score += 8 if item_time and item_time == record.time else 0 + same_day_student = 1 if item_student and item_date and item_student == record.student and item_date == record_date else 0 + same_day = 1 if item_date and item_date == record_date else 0 + same_student = 1 if item_student and item_student == record.student else 0 + return (score, same_day_student, same_day, same_student, -abs(len(record.subject) - len(item_subject)), record.date, record.time) + + +def course_summary_binding_reason(item: dict, candidates: list[dict]) -> str: + if not str(item.get("date_iso") or ""): + return "课程小结缺少可识别日期" + if not str(item.get("time_range") or ""): + return "课程小结缺少时间,无法完成五元组绑定" + if not str(item.get("student") or ""): + return "课程小结缺少学生" + if not str(item.get("teacher") or ""): + return "课程小结缺少老师" + if not str(item.get("subject") or ""): + return "课程小结缺少科目" + if candidates: + return "未找到完全一致的上课记录,可按候选记录修正小结字段" + return "未找到相同学生和日期的上课记录" + + +def course_summary_binding_status(item: dict, record_keys: dict[tuple[str, str, str, str, str], ClassRecord], records: list[ClassRecord]) -> dict: + try: + summary_key = course_summary_duplicate_key(item) + except ValueError: + summary_key = ("", "", "", "", "") + if summary_key and all(summary_key) and summary_key in record_keys: + record = record_keys[summary_key] + return { + "status": "matched", + "label": "已绑定", + "record": course_summary_record_snapshot(record), + "candidates": [], + "reason": "", + "differences": [], + } + + item_student = canonical_name(str(item.get("student") or "")) + item_date = str(item.get("date_iso") or "") + same_student_date = [ + record + for record in records + if item_student and item_date and record.student == item_student and record.date.replace(".", "-") == item_date + ] + same_date = [ + record + for record in records + if item_date and record.date.replace(".", "-") == item_date + ] + same_student = [ + record + for record in records + if item_student and record.student == item_student + ] + candidate_pool = same_student_date or same_date or same_student + scored: list[tuple[tuple[int, int, int, int, int, str, str], ClassRecord]] = [] + for record in candidate_pool: + score = course_summary_candidate_score(item, record) + if score[0] <= 0: + continue + scored.append((score, record)) + scored.sort(key=lambda pair: pair[0], reverse=True) + candidates = [ + { + **course_summary_record_snapshot(record), + "differences": course_summary_binding_diff(item, record), + } + for _score, record in scored[:3] + ] + status = "missing_time" if not str(item.get("time_range") or "") else "unmatched" + if candidates and str(item.get("time_range") or ""): + status = "mismatch" + labels = { + "missing_time": "缺时间", + "mismatch": "字段不一致", + "unmatched": "未绑定", + } + return { + "status": status, + "label": labels[status], + "record": None, + "candidates": candidates, + "reason": course_summary_binding_reason(item, candidates), + "differences": candidates[0]["differences"] if candidates else [], + } + + def query_course_summaries( root: Path, classnotes_path: Path | None = None, @@ -2416,12 +2631,21 @@ def query_course_summaries( date_from: str = "", date_to: str = "", missing_time: bool = False, + binding_status: str = "", + has_candidate: str = "", limit: int = 200, ) -> dict: normalized_from = normalize_filter_date(date_from) normalized_to = normalize_filter_date(date_to) if normalized_from and normalized_to and normalized_from > normalized_to: raise ValueError("开始日期不能晚于结束日期") + normalized_binding_status = binding_status.strip() + allowed_binding_statuses = {"", "matched", "unmatched", "missing_time", "mismatch"} + if normalized_binding_status not in allowed_binding_statuses: + raise ValueError("绑定状态筛选无效") + normalized_has_candidate = has_candidate.strip().lower() + if normalized_has_candidate not in {"", "true", "false"}: + raise ValueError("候选记录筛选无效") keyword = q.strip() matched = [ item @@ -2440,17 +2664,34 @@ def query_course_summaries( for item in matched: item["matched_fields"] = course_summary_matched_fields(item, keyword) item["matched_record"] = False - if classnotes_path is not None and classnotes_path.exists(): - record_keys = { - course_summary_record_key(record.student, record.teacher, record.subject, record.date.replace(".", "-"), record.time) - for record in read_classnotes(classnotes_path) + item["binding"] = { + "status": "unchecked", + "label": "未检查", + "record": None, + "candidates": [], + "reason": "未读取上课记录", + "differences": [], } + if classnotes_path is not None and classnotes_path.exists(): + records = read_classnotes(classnotes_path) + record_keys = {class_record_binding_key(record): record for record in records} for item in matched: - try: - key = course_summary_duplicate_key(item) - except ValueError: - key = ("", "", "", "", "") - item["matched_record"] = bool(key and all(key) and key in record_keys) + binding = course_summary_binding_status(item, record_keys, records) + item["binding"] = binding + item["matched_record"] = binding["status"] == "matched" + if normalized_binding_status: + matched = [ + item + for item in matched + if str((item.get("binding") or {}).get("status") or "") == normalized_binding_status + ] + if normalized_has_candidate: + expect_candidate = normalized_has_candidate == "true" + matched = [ + item + for item in matched + if bool((item.get("binding") or {}).get("candidates") or []) == expect_candidate + ] matched.sort( key=lambda item: ( str(item.get("date_iso") or "0000-00-00"), @@ -2526,6 +2767,75 @@ def replace_course_summary_body(root: Path, path: Path, summary_id: str, new_bod raise ValueError("未找到课程小结") +def course_summary_block_payload(root: Path, path: Path, summary_id: str) -> dict: + text = path.read_text(encoding="utf-8") + matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text)) + identity = parse_course_summary_file_identity(root, path) + group = "" + if matches: + group_match = re.search(r"^##\s+(.+)$", text[: matches[0].start()], flags=re.M) + if group_match: + group = group_match.group(1).strip() + for index, match in enumerate(matches): + title = match.group("title").strip() + start = match.start() + body_start = match.end() + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + raw_body = text[body_start:end].strip() + body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", raw_body).strip() + body_text = body_without_meta or raw_body + item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20) + if item_id != summary_id: + continue + return { + **identity, + "id": item_id, + "title": title, + "index": index, + "start": start, + "end": end, + "group": group, + "raw_body": raw_body, + "body": body_text, + "date_iso": parse_course_summary_title_date(title), + "time_range": parse_course_summary_time_text(f"{title}\n{body_text}"), + "source_id": source_match.group(1) if (source_match := re.search(r"来源ID:`([^`]+)`", raw_body)) else "", + "message_time": sent_at_match.group(1) if (sent_at_match := re.search(r"发送时间:`([^`]+)`", raw_body)) else "", + "sender": sender_match.group(1) if (sender_match := re.search(r"发送者:`([^`]+)`", raw_body)) else "", + } + raise ValueError("未找到课程小结") + + +def remove_course_summary_block_text(text: str, block: dict) -> str: + start = int(block["start"]) + end = int(block["end"]) + return (text[:start].rstrip() + "\n\n" + text[end:].lstrip()).rstrip() + "\n" + + +def course_summary_identity_markdown_text(root: Path, existing: str, path: Path, item: dict) -> tuple[str, str, str]: + matches = list(COURSE_SUMMARY_HEADING_RE.finditer(existing)) + existing_headings = {match.group("title").strip() for match in matches} + title = str(item.get("title") or "").strip() + if not title: + raise ValueError("课程小结标题不能为空") + heading = title if title not in existing_headings else f"{title}({sha1_text(str(item.get('source_id') or '') + str(item.get('body') or ''), 8)})" + group_name = str(item.get("group") or item.get("student") or "").strip() + raw_body = str(item.get("raw_body") or item.get("body") or "").strip() + lines: list[str] = [] + if not existing.strip(): + lines.extend([f"# {path.stem}", "", f"## {group_name}", ""]) + elif group_name and f"## {group_name}" not in existing: + lines.extend(["", f"## {group_name}", ""]) + lines.extend([f"### {heading}", "", raw_body, ""]) + prefix = existing + if existing and not existing.endswith("\n"): + prefix += "\n" + new_text = prefix + "\n".join(lines).rstrip() + "\n" + body_text = re.sub(r"^(?:>\s+.*\n)+\s*", "", raw_body).strip() or raw_body + relative_path = str(path.relative_to(root)) + return new_text, heading, sha1_text(f"{relative_path}|{heading}|{len(matches)}|{body_text[:200]}", 20) + + def find_course_summary_item(root: Path, summary_id: str) -> dict: for item in iter_course_summary_markdown(root): if str(item.get("id") or "") == summary_id: @@ -2578,6 +2888,123 @@ def update_course_summary_body(root: Path, summary_id: str, body: str) -> dict: return result +def update_course_summary_identity( + root: Path, + state_path: Path, + teachers_path: Path, + summary_id: str, + student: str, + teacher: str, + subject: str = "", +) -> dict: + new_student = canonical_name(str(student or "").strip()) + if not new_student: + raise ValueError("学生不能为空") + new_teacher = resolve_existing_teacher_input(str(teacher or ""), read_teachers(teachers_path)) + item = find_course_summary_item(root, summary_id) + new_subject = normalize_subject(str(subject or "").strip()) or normalize_subject(str(item.get("subject") or "")) + if not new_subject: + raise ValueError("科目不能为空") + source_path = Path(str(item.get("source_path") or "")) + block = course_summary_block_payload(root, source_path, summary_id) + old_student = str(item.get("student") or "") + old_teacher = str(item.get("teacher") or "") + old_subject = normalize_subject(str(item.get("subject") or "")) + if new_student == old_student and new_teacher == old_teacher and new_subject == old_subject: + raise ValueError("学生、老师和科目没有变化") + + title = str(block.get("title") or "") + if new_subject != old_subject: + updated_title = title.replace(old_subject, new_subject, 1) if old_subject else title + if updated_title == title: + time_range = str(item.get("time_range") or "") + date_iso = str(item.get("date_iso") or "") + updated_title = f"{date_iso} {time_range + ' ' if time_range else ''}{new_subject}课堂小结".strip() + block = {**block, "title": updated_title} + + moved_item = { + **block, + "student": new_student, + "teacher": new_teacher, + "subject": new_subject, + "date_iso": str(item.get("date_iso") or ""), + "time_range": str(item.get("time_range") or ""), + "duration_minutes": duration_minutes_from_time_range(str(item.get("time_range") or "")), + } + target_path = course_summary_path(root, moved_item) + source_text = source_path.read_text(encoding="utf-8") + target_exists = target_path.exists() + target_text = target_path.read_text(encoding="utf-8") if target_exists else "" + new_source_text = remove_course_summary_block_text(source_text, block) + target_base_text = new_source_text if target_path == source_path else target_text + new_target_text, heading, new_id = course_summary_identity_markdown_text(root, target_base_text, target_path, moved_item) + + state = read_course_summary_state(state_path) + original_state_text = state_path.read_text(encoding="utf-8") if state_path.exists() else json.dumps(default_course_summary_state(), ensure_ascii=False, indent=2) + "\n" + semantic_keys = {str(value) for value in state.get("seen_semantic_keys", [])} + old_key_item = { + **moved_item, + "student": old_student, + "teacher": old_teacher, + "subject": old_subject, + } + semantic_keys.discard(course_summary_semantic_key(old_key_item)) + semantic_keys.discard(course_summary_semantic_key({ + **item, + "duration_minutes": duration_minutes_from_time_range(str(item.get("time_range") or "")), + })) + semantic_keys.add(course_summary_semantic_key(moved_item)) + state["seen_semantic_keys"] = sorted(semantic_keys) + new_state_text = json.dumps(state, ensure_ascii=False, indent=2) + "\n" + + backup_contents = { + source_path: source_text, + state_path: original_state_text, + } + if target_path != source_path: + backup_contents[target_path] = target_text + backup_dir = create_data_backup( + "admin-update-course-summary-identity", + backup_contents, + [summary_id, old_student, old_teacher, old_subject, new_student, new_teacher, new_subject], + ) + try: + if target_path == source_path: + atomic_write_text(source_path, new_target_text) + else: + atomic_write_text(source_path, new_source_text) + target_path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_text(target_path, new_target_text) + atomic_write_text(state_path, new_state_text) + except Exception: + atomic_write_text(source_path, source_text) + if target_path != source_path: + if target_exists: + atomic_write_text(target_path, target_text) + elif target_path.exists(): + target_path.unlink() + atomic_write_text(state_path, original_state_text) + raise + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return { + "id": summary_id, + "new_id": new_id, + "old_student": old_student, + "old_teacher": old_teacher, + "old_subject": old_subject, + "student": new_student, + "teacher": new_teacher, + "subject": new_subject, + "old_path": str(source_path), + "new_path": str(target_path), + "heading": heading, + "backup_id": backup_dir.name, + } + + def delete_course_summary(root: Path, summary_id: str) -> dict: item = find_course_summary_item(root, summary_id) path = Path(str(item.get("source_path") or "")) diff --git a/app/app/routers/accounts.py b/app/app/routers/accounts.py index 8bc7ec3..3eb8bb3 100644 --- a/app/app/routers/accounts.py +++ b/app/app/routers/accounts.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import date + from fastapi import APIRouter, Depends, HTTPException, Query, Request from ..api_utils import file_meta, load_accounts, load_teachers, payload_to_account, payload_to_teacher, read_register_payload @@ -23,8 +25,11 @@ from ..data import ( append_operation_log, create_account, create_teacher, + duration_text_from_hours, filter_accounts, + iter_course_summary_markdown, parse_class_record_line, + read_classnotes, register_class_record_lines, register_course_summary_texts, register_payment_lines, @@ -38,6 +43,11 @@ from ..schemas import AccountPayload, TeacherPayload router = APIRouter() +def compact_duration_text(hours: float) -> str: + text = duration_text_from_hours(hours) + return text.removesuffix("0分") + + @router.post("/api/register/class-records") async def register_class_records(request: Request, _user: str = Depends(verify_admin_auth)): try: @@ -111,12 +121,26 @@ async def register_course_summaries(request: Request, _user: str = Depends(verif def account_health(_user: str = Depends(verify_accounts_auth)): accounts = load_accounts() teachers = load_teachers() + records = read_classnotes(CLASSNOTES_PATH) if CLASSNOTES_PATH.exists() else [] + current_month_prefix = date.today().strftime("%Y.%m.") + current_month_hours = round( + sum(record.duration_hours for record in records if record.date.startswith(current_month_prefix)), + 2, + ) + course_summaries_count = sum(1 for _item in iter_course_summary_markdown(COURSE_SUMMARIES_ROOT)) return { "ok": True, "accounts": file_meta(ACCOUNTS_PATH), "teachers": file_meta(TEACHERS_PATH), + "classnotes": file_meta(CLASSNOTES_PATH), + "course_summaries": file_meta(COURSE_SUMMARIES_ROOT), "accounts_count": len(accounts), "teachers_count": len(teachers), + "active_teachers_count": sum(1 for teacher in teachers if teacher.status == "在岗"), + "records_count": len(records), + "course_summaries_count": course_summaries_count, + "current_month_hours": current_month_hours, + "current_month_duration": compact_duration_text(current_month_hours), "account_summary": account_summary(accounts), } diff --git a/app/app/routers/admin.py b/app/app/routers/admin.py index da0c45c..c4ad8a3 100644 --- a/app/app/routers/admin.py +++ b/app/app/routers/admin.py @@ -12,6 +12,7 @@ from ..config import ( COURSE_SUMMARIES_ROOT, COURSE_SUMMARY_STATE_PATH, OPERATION_LOGS_PATH, + TEACHERS_PATH, write_lock, ) from ..data import ( @@ -28,6 +29,7 @@ from ..data import ( resolve_duplicate_course_summary_task, rollback_operation_log, update_course_summary_body, + update_course_summary_identity, update_course_summary_review_task, update_course_summary_time, ) @@ -53,7 +55,12 @@ def admin_tasks( _user: str = Depends(verify_admin_auth), ): try: - return list_admin_tasks(ADMIN_TASKS_PATH, status_filter=status_filter, task_type=task_type) + return list_admin_tasks( + ADMIN_TASKS_PATH, + status_filter=status_filter, + task_type=task_type, + classnotes_path=CLASSNOTES_PATH, + ) except ValueError as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc @@ -108,6 +115,8 @@ def admin_course_summaries( date_from: str = Query(""), date_to: str = Query(""), missing_time: bool = Query(False), + binding_status: str = Query(""), + has_candidate: str = Query(""), limit: int = Query(200, ge=1, le=1000), _user: str = Depends(verify_admin_auth), ): @@ -122,6 +131,8 @@ def admin_course_summaries( date_from=date_from, date_to=date_to, missing_time=missing_time, + binding_status=binding_status, + has_candidate=has_candidate, limit=limit, ) except ValueError as exc: @@ -250,7 +261,11 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)): 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) + result = create_course_summary_duplicate_review_tasks( + ADMIN_TASKS_PATH, + COURSE_SUMMARIES_ROOT, + classnotes_path=CLASSNOTES_PATH, + ) append_operation_log( OPERATION_LOGS_PATH, "重复小结扫描", @@ -298,6 +313,41 @@ def admin_update_course_summary_body(summary_id: str, payload: dict, _user: str return {"ok": True, **result} +@router.post("/api/admin/course-summaries/{summary_id}/identity") +def admin_update_course_summary_identity(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + result = update_course_summary_identity( + COURSE_SUMMARIES_ROOT, + COURSE_SUMMARY_STATE_PATH, + TEACHERS_PATH, + summary_id, + str(payload.get("student") or ""), + str(payload.get("teacher") or ""), + str(payload.get("subject") or ""), + ) + append_operation_log( + OPERATION_LOGS_PATH, + "课程小结修改归属", + "已更新", + summary_id=summary_id, + new_summary_id=str(result.get("new_id") or ""), + student=str(result.get("student") or ""), + old_student=str(result.get("old_student") or ""), + teacher=str(result.get("teacher") or ""), + old_teacher=str(result.get("old_teacher") or ""), + subject=str(result.get("subject") or ""), + old_subject=str(result.get("old_subject") or ""), + old_path=str(result.get("old_path") or ""), + new_path=str(result.get("new_path") or ""), + heading=str(result.get("heading") or ""), + backup_id=str(result.get("backup_id") or ""), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} + + @router.delete("/api/admin/course-summaries/{summary_id}") def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_admin_auth)): try: diff --git a/app/app/static/admin.html b/app/app/static/admin.html index 3c3fa74..82bd0ab 100644 --- a/app/app/static/admin.html +++ b/app/app/static/admin.html @@ -4,7 +4,7 @@ <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>管理后台 - +
@@ -24,7 +24,6 @@ - @@ -216,38 +215,7 @@ - + +
建议登记行
@@ -325,6 +294,9 @@
+
+
+

待处理任务

+
+ +
+
+
+
+ + + + + + + + + + + + + +
编号状态课程信息候选记录小结原文原因操作
+
+
- + diff --git a/app/app/static/admin.js b/app/app/static/admin.js index 731e86c..34698c1 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -49,7 +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 duplicateSummaryScanBtns = Array.from(document.querySelectorAll(".duplicate-summary-scan")); const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop"); const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose"); const summaryReviewDrawerTitle = document.querySelector("#summaryReviewDrawerTitle"); @@ -62,6 +62,7 @@ const summaryReviewEditDate = document.querySelector("#summaryReviewEditDate"); const summaryReviewEditTime = document.querySelector("#summaryReviewEditTime"); const summaryReviewEditTeacher = document.querySelector("#summaryReviewEditTeacher"); const summaryReviewEditSubject = document.querySelector("#summaryReviewEditSubject"); +const summaryReviewEditHint = document.querySelector("#summaryReviewEditHint"); const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine"); const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons"); const summaryReviewConflictSection = document.querySelector("#summaryReviewConflictSection"); @@ -79,7 +80,8 @@ const summarySearchTeacher = document.querySelector("#summarySearchTeacher"); const summarySearchSubject = document.querySelector("#summarySearchSubject"); const summarySearchDateFrom = document.querySelector("#summarySearchDateFrom"); const summarySearchDateTo = document.querySelector("#summarySearchDateTo"); -const summarySearchMissingTime = document.querySelector("#summarySearchMissingTime"); +const summarySearchBindingStatus = document.querySelector("#summarySearchBindingStatus"); +const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate"); const summarySearchMeta = document.querySelector("#summarySearchMeta"); const summarySearchRows = document.querySelector("#summarySearchRows"); const logOperation = document.querySelector("#logOperation"); @@ -113,6 +115,7 @@ let activeSummaryReview = null; let currentSummarySearchItems = []; let expandedSummarySearchId = ""; let editingSummarySearchId = ""; +let editingSummaryIdentityId = ""; let currentOperationLogs = []; let expandedOperationLogId = ""; const registerPreviewState = { @@ -185,25 +188,33 @@ async function fetchJson(url, options = {}) { } function setActiveTab(tabName) { + const activeTabName = tabName === "summaries" ? "summarySearch" : tabName; document.querySelectorAll("[data-admin-tab]").forEach((button) => { - button.classList.toggle("is-active", button.dataset.adminTab === tabName); + button.classList.toggle("is-active", button.dataset.adminTab === activeTabName); }); Object.entries(panels).forEach(([name, panel]) => { - panel.hidden = name !== tabName; + panel.hidden = name !== activeTabName; }); - if (tabName === "accounts") loadAccounts(); - if (tabName === "teachers") loadTeachers(); - if (tabName === "reviews") loadReviews(); - if (tabName === "summaries") loadSummaryReviews(); - if (tabName === "summarySearch") loadSummarySearch(); - if (tabName === "logs") loadOperationLogs(); + if (activeTabName === "accounts") loadAccounts(); + if (activeTabName === "teachers") loadTeachers(); + if (activeTabName === "reviews") loadReviews(); + if (activeTabName === "summarySearch") { + loadSummarySearch(); + loadSummaryReviews(); + } + if (activeTabName === "logs") loadOperationLogs(); } async function loadAdminHealth() { try { const data = await fetchJson("/api/account-health"); - const teacherText = data.teachers_count === undefined ? "" : `;老师 ${data.teachers_count} 位`; - adminHealthText.textContent = `账户 ${data.accounts_count} 人${teacherText};数据更新时间 ${fmtTime(data.accounts.mtime)}`; + adminHealthText.textContent = [ + `账户 ${data.accounts_count || 0} 人`, + `在岗老师 ${data.active_teachers_count || 0} 位`, + `课程记录 ${data.records_count || 0} 条`, + `小结 ${data.course_summaries_count || 0} 条`, + `本月已上 ${data.current_month_duration || fmtHours(data.current_month_hours || 0)}`, + ].join(";"); } catch (error) { adminHealthText.textContent = `读取失败:${error.message}`; } @@ -544,11 +555,14 @@ function renderSummaryInfo(summary, item = {}) { const parts = [ summary.student, summary.date_iso, - summary.time_range, + summary.time_range || item.suggested_time_range, summary.teacher, summary.subject, ].filter(Boolean); - return `${renderReviewTag(summaryReviewKind(item))}${parts.map(escapeHtml).join("
")}
${escapeHtml(summary.group || "")}`; + const suggestedTime = !summary.time_range && item.suggested_time_range + ? `
原文时间,待保存` + : ""; + return `${renderReviewTag(summaryReviewKind(item))}${parts.map(escapeHtml).join("
")}${suggestedTime}
${escapeHtml(summary.group || "")}`; } function renderSummaryPreview(summary) { @@ -576,7 +590,14 @@ function hasDuplicateReviewContext(item) { function renderDuplicateCandidate(candidate, canReview, options = {}) { const showDelete = options.showDelete !== false; + const binding = summaryBinding(candidate); + const bindingDetail = binding.record + ? `绑定记录:${renderBindingRecordLine(binding.record)}` + : (Array.isArray(binding.candidates) && binding.candidates.length + ? `候选记录:${binding.candidates.length} 条` + : (binding.reason || "")); const source = [ + bindingDetail, candidate.source_id ? `来源ID:${candidate.source_id}` : "", candidate.message_time ? `发送时间:${candidate.message_time}` : "", candidate.sender ? `发送者:${candidate.sender}` : "", @@ -584,7 +605,10 @@ function renderDuplicateCandidate(candidate, canReview, options = {}) { candidate.source_path && !candidate.relative_path ? `文件:${candidate.source_path}` : "", ].filter(Boolean); return `
-
${escapeHtml(candidate.title || "课程小结")}
+
+
${escapeHtml(candidate.title || "课程小结")}
+ ${renderBindingBadge(candidate)} +
${source.map(escapeHtml).join("
") || "暂无来源信息"}
${escapeHtml(candidate.body || candidate.body_preview || "暂无正文")}
${showDelete ? `` : ""} @@ -638,22 +662,77 @@ function canLinkExistingSummary(item) { return Boolean(item && item.proposed_line && reasons.includes("classnotes 已存在同一条上课记录")); } -function courseInfoLines(summary) { +function canApproveSummaryDirectly(item) { + return Boolean( + canReviewSummary(item) + && !isDuplicateSummaryReview(item) + && item.proposed_line + && !canLinkExistingSummary(item) + ); +} + +function isMissingTimeReason(reason) { + const text = String(reason || ""); + return ( + text.includes("时间段缺失") + || text.includes("时长缺失") + || text.includes("课程小结缺少时长") + || text.includes("请补充时间") + ); +} + +function summaryReviewReasonLines(item) { + const reasons = Array.isArray(item && item.reasons) ? item.reasons : []; + const lines = []; + if (reasons.some(isMissingTimeReason)) { + const suggestion = item.suggested_time_range + ? `:原文疑似 ${item.suggested_time_range}${item.suggested_duration ? `,${item.suggested_duration}` : ""}` + : ""; + lines.push(`待补时间${suggestion}`); + } + reasons.forEach((reason) => { + if (isMissingTimeReason(reason)) return; + if (!lines.includes(reason)) lines.push(reason); + }); + return lines.length ? lines : ["待人工复核"]; +} + +function renderSummaryReviewReasons(item) { + return `
${summaryReviewReasonLines(item) + .map((reason) => `${escapeHtml(reason)}`) + .join("")}
`; +} + +function summaryReviewDetailLabel(item) { + if (isDuplicateSummaryReview(item)) return "处理重复"; + if (canLinkExistingSummary(item)) return "关联记录"; + if (!item.proposed_line) return item.suggested_time_range ? "补时间" : "修正"; + return "查看详情"; +} + +function courseInfoLines(summary, item = {}) { return [ summary.student ? `学生:${summary.student}` : "", summary.date_iso ? `日期:${summary.date_iso}` : "", summary.time_range ? `时间:${summary.time_range}` : "", + !summary.time_range && item.suggested_time_range ? `原文时间:${item.suggested_time_range}` : "", summary.teacher || summary.subject ? `老师/科目:${summary.teacher || ""} ${summary.subject || ""}`.trim() : "", summary.group ? `班级/分组:${summary.group}` : "", ].filter(Boolean); } -function fillSummaryReviewEditor(summary) { +function fillSummaryReviewEditor(summary, item = {}) { summaryReviewEditStudent.value = summary.student || ""; summaryReviewEditDate.value = summary.date_iso || ""; - summaryReviewEditTime.value = summary.time_range || ""; + summaryReviewEditTime.value = summary.time_range || item.suggested_time_range || ""; summaryReviewEditTeacher.value = summary.teacher || ""; summaryReviewEditSubject.value = summary.subject || ""; + const hasSuggestedTime = Boolean(!summary.time_range && item.suggested_time_range); + summaryReviewEditTime.classList.toggle("suggested-input", hasSuggestedTime); + summaryReviewEditHint.hidden = !hasSuggestedTime; + summaryReviewEditHint.textContent = hasSuggestedTime + ? `已从原文识别到 ${item.suggested_time_range}${item.suggested_duration ? `(${item.suggested_duration})` : ""},保存后会生成建议登记行。` + : ""; } function summaryReviewEditorPayload() { @@ -676,6 +755,7 @@ function openSummaryReviewDrawer(taskId) { const duplicateReview = isDuplicateSummaryReview(item); const duplicateContext = hasDuplicateReviewContext(item); const pendingSummarySave = Boolean(item.pending_summary_save); + const canApproveDirectly = canApproveSummaryDirectly(item); activeSummaryReview = item; summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`; @@ -684,13 +764,13 @@ function openSummaryReviewDrawer(taskId) { kind ? renderReviewTag(kind) : "", ].filter(Boolean).join(" "); summaryReviewDrawerStatus.innerHTML = `${escapeHtml(item.status || "")}${item.message ? ` ${escapeHtml(item.message)}` : ""}`; - summaryReviewDrawerCourse.innerHTML = courseInfoLines(summary).map(escapeHtml).join("
") || "暂无课程信息"; + summaryReviewDrawerCourse.innerHTML = courseInfoLines(summary, item).map(escapeHtml).join("
") || "暂无课程信息"; summaryReviewEditSection.hidden = duplicateReview; - if (!duplicateReview) fillSummaryReviewEditor(summary); + if (!duplicateReview) fillSummaryReviewEditor(summary, item); summaryReviewDrawerLine.innerHTML = duplicateReview ? "请选择下方重复小结删除" - : item.proposed_line ? renderReviewLine(item.proposed_line) : "暂无"; - summaryReviewDrawerReasons.innerHTML = reasons.length ? reasons.map(escapeHtml).join("
") : "待人工复核"; + : item.proposed_line ? renderReviewLine(item.proposed_line) : "保存修正后生成"; + summaryReviewDrawerReasons.innerHTML = renderSummaryReviewReasons(item); if (duplicateReview) { summaryReviewDrawerBody.innerHTML = renderDuplicateCandidates(item, canReview); } else { @@ -705,10 +785,11 @@ function openSummaryReviewDrawer(taskId) { ].filter(Boolean).join("
") || "暂无来源信息"; summaryReviewDrawerSave.hidden = duplicateReview; summaryReviewDrawerSave.disabled = !canReview || duplicateReview; + summaryReviewDrawerSave.textContent = !summary.time_range && item.suggested_time_range ? "保存时间修正" : "保存修正"; summaryReviewDrawerLink.hidden = duplicateReview || !canLinkExistingSummary(item); summaryReviewDrawerLink.disabled = !canReview || duplicateReview || !canLinkExistingSummary(item); - summaryReviewDrawerApprove.hidden = duplicateReview; - summaryReviewDrawerApprove.disabled = !canReview || duplicateReview; + summaryReviewDrawerApprove.hidden = duplicateReview || !item.proposed_line || canLinkExistingSummary(item); + summaryReviewDrawerApprove.disabled = !canApproveDirectly; summaryReviewDrawerReject.disabled = !canReview; summaryReviewDrawerBackdrop.hidden = false; } @@ -717,10 +798,14 @@ function closeSummaryReviewDrawer() { activeSummaryReview = null; summaryReviewEditSection.hidden = true; summaryReviewDrawerSave.hidden = false; + summaryReviewDrawerSave.textContent = "保存修正"; summaryReviewDrawerLink.hidden = true; summaryReviewDrawerApprove.hidden = false; summaryReviewConflictSection.hidden = true; summaryReviewDrawerConflicts.innerHTML = ""; + summaryReviewEditHint.hidden = true; + summaryReviewEditHint.textContent = ""; + summaryReviewEditTime.classList.remove("suggested-input"); summaryReviewDrawerBackdrop.hidden = true; } @@ -737,26 +822,34 @@ async function loadSummaryReviews() { 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(""); + const reviewItems = reviewData.items || []; + const duplicateItems = duplicateData.items || []; + currentSummaryReviews = [...reviewItems, ...duplicateItems].sort((a, b) => Number(b.id || 0) - Number(a.id || 0)); + summaryReviewMeta.innerHTML = [ + metric("待处理任务", `${currentSummaryReviews.length} 条`), + metric("课程小结审核", `${reviewItems.length} 条`), + metric("重复小结", `${duplicateItems.length} 条`), + ].join(""); summaryReviewRows.innerHTML = currentSummaryReviews .map((item) => { const canReview = canReviewSummary(item); const duplicateReview = isDuplicateSummaryReview(item); + const canApproveDirectly = canApproveSummaryDirectly(item); const summary = item.summary || {}; - const reasons = Array.isArray(item.reasons) ? item.reasons : []; - const approveLabel = duplicateReview ? "详情选择" : "批准"; + const approveButton = canApproveDirectly + ? `` + : ""; return ` #${escapeHtml(item.id)} ${escapeHtml(item.status)}${item.message ? `
${escapeHtml(item.message)}` : ""} ${renderSummaryInfo(summary, item)} ${item.proposed_line ? renderReviewLine(item.proposed_line) : "暂无"} ${renderSummaryPreview(summary)} - ${reasons.map(escapeHtml).join("
") || "待人工复核"} + ${renderSummaryReviewReasons(item)}
- - + + ${approveButton}
@@ -764,7 +857,7 @@ async function loadSummaryReviews() { }) .join(""); if (!currentSummaryReviews.length) { - summaryReviewRows.innerHTML = `没有符合条件的课程小结审核项`; + summaryReviewRows.innerHTML = `没有符合条件的课程小结任务`; } } catch (error) { currentSummaryReviews = []; @@ -780,25 +873,53 @@ function summarySearchParams() { if (summarySearchSubject.value.trim()) params.set("subject", summarySearchSubject.value.trim()); if (summarySearchDateFrom.value) params.set("date_from", summarySearchDateFrom.value); if (summarySearchDateTo.value) params.set("date_to", summarySearchDateTo.value); - if (summarySearchMissingTime.value) params.set("missing_time", "true"); + if (summarySearchBindingStatus.value) params.set("binding_status", summarySearchBindingStatus.value); + if (summarySearchHasCandidate.value) params.set("has_candidate", summarySearchHasCandidate.value); return params; } function renderSummaryActions(item) { + const identityButton = ``; const editButton = ``; const deleteButton = ``; if (item.time_range) { - return `
时间已完整${editButton}${deleteButton}
`; + return `
时间已完整${identityButton}${editButton}${deleteButton}
`; } const inputId = `summary-time-${item.id}`; return `
+ ${identityButton} ${editButton} ${deleteButton}
`; } +function renderSummaryIdentityEditor(item) { + if (String(item.id) !== editingSummaryIdentityId) return ""; + return `
+
+ + + +
+
只修改课程小结的学生、老师和科目,不同步修改上课记录和课时账户;老师必须已在教师档案中维护。
+
+ + +
+
`; +} + function renderSummaryBodyEditor(item) { const body = item.body || item.body_preview || ""; if (String(item.id) !== editingSummarySearchId) { @@ -818,20 +939,117 @@ function renderMatchedFields(item) { return fields.length ? `
命中:${fields.map(escapeHtml).join("、")}` : ""; } +function summaryBinding(item) { + return item.binding || { + status: item.matched_record ? "matched" : "unmatched", + label: item.matched_record ? "已绑定" : "未绑定", + record: null, + candidates: [], + reason: "", + differences: [], + }; +} + +function bindingStatusClass(status) { + if (status === "matched") return "normal"; + if (status === "missing_time" || status === "mismatch") return "warning"; + return "debt"; +} + +function renderBindingBadge(item) { + const binding = summaryBinding(item); + const label = binding.label || (binding.status === "matched" ? "已绑定" : "未绑定"); + return `${escapeHtml(label)}`; +} + +function renderBindingRecordLine(record) { + if (!record) return ""; + const pieces = [ + `${record.date || record.date_iso || ""} ${record.weekday || ""}`.trim(), + record.time || "", + record.student || "", + record.teacher || "", + record.subject || "", + record.duration || "", + ].filter(Boolean); + return pieces.map(escapeHtml).join(" · "); +} + +function renderSummaryBindingStatus(item) { + const binding = summaryBinding(item); + const detail = binding.record ? renderBindingRecordLine(binding.record) : (binding.reason || ""); + const candidateCount = Array.isArray(binding.candidates) ? binding.candidates.length : 0; + const suffix = !binding.record && candidateCount ? `候选 ${candidateCount} 条` : detail; + return `
+ ${renderBindingBadge(item)} + ${suffix ? `${escapeHtml(suffix)}` : ""} +
`; +} + +function summarizeSummaryBindings(items) { + return items.reduce( + (summary, item) => { + const binding = summaryBinding(item); + if (binding.status === "matched") summary.matched += 1; + if (binding.status !== "matched") summary.pending += 1; + if (Array.isArray(binding.candidates) && binding.candidates.length) summary.withCandidate += 1; + return summary; + }, + { matched: 0, pending: 0, withCandidate: 0 }, + ); +} + +function renderSummaryBindingDetail(item) { + const binding = summaryBinding(item); + if (binding.status === "matched" && binding.record) { + return `
+
已绑定上课记录
+
${renderBindingRecordLine(binding.record)}
+
`; + } + const candidates = Array.isArray(binding.candidates) ? binding.candidates : []; + const candidateRows = candidates + .map((record) => { + const differences = Array.isArray(record.differences) && record.differences.length + ? record.differences.map(escapeHtml).join("、") + : "可直接匹配"; + return `
+
+
${renderBindingRecordLine(record)}
+ ${differences} +
+ +
`; + }) + .join(""); + return `
+
${escapeHtml(binding.reason || "未绑定到上课记录")}
+ ${candidateRows || '
没有找到可参考的上课记录。
'} +
`; +} + function renderSummarySearchRows(items) { return items .map((item) => { const expanded = String(item.id) === expandedSummarySearchId; - const unmatchedClass = item.matched_record ? "" : " unmatched-summary"; + const binding = summaryBinding(item); + const unmatchedClass = binding.status === "matched" ? "" : " unmatched-summary"; const mainRow = ` ${escapeHtml(item.date_iso || "未识别")} ${escapeHtml(item.time_range || "缺少时间")} ${escapeHtml(item.student || "")}
${escapeHtml(item.group || "")} ${escapeHtml(item.teacher || "待核对老师")}
${escapeHtml(item.subject || "待核对科目")} + ${renderSummaryBindingStatus(item)} `; const detailRow = expanded ? ` - +
@@ -839,6 +1057,8 @@ function renderSummarySearchRows(items) { ${renderMatchedFields(item)}
+ ${renderSummaryBindingDetail(item)} + ${renderSummaryIdentityEditor(item)} ${renderSummaryBodyEditor(item)}
${renderSummaryActions(item)}
@@ -853,28 +1073,38 @@ function renderSummarySearchRows(items) { function renderCurrentSummarySearch() { summarySearchRows.innerHTML = renderSummarySearchRows(currentSummarySearchItems); if (!currentSummarySearchItems.length) { - summarySearchRows.innerHTML = `没有符合条件的课程小结`; + summarySearchRows.innerHTML = `没有符合条件的课程小结`; } } async function loadSummarySearch() { - summarySearchRows.innerHTML = `正在读取`; + summarySearchRows.innerHTML = `正在读取`; try { const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`); currentSummarySearchItems = data.items || []; if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = ""; if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = ""; + if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummaryIdentityId)) editingSummaryIdentityId = ""; + const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems); summarySearchMeta.innerHTML = [ metric("命中小结", `${data.count} 条`), metric("当前显示", `${data.returned} 条`), + metric("已绑定", `${bindingSummary.matched} 条`), + metric("待处理", `${bindingSummary.pending} 条`), + metric("有候选", `${bindingSummary.withCandidate} 条`), ].join(""); renderCurrentSummarySearch(); } catch (error) { currentSummarySearchItems = []; - summarySearchRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; + summarySearchRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; } } +async function refreshSummaryWorkspace({ logs = true } = {}) { + await Promise.all([loadSummarySearch(), loadSummaryReviews()]); + if (logs) await loadOperationLogs(); +} + async function updateCourseSummaryTime(summaryId, timeRange) { await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/time`, { method: "POST", @@ -903,6 +1133,58 @@ async function updateCourseSummaryBody(summaryId, body) { await loadOperationLogs(); } +async function updateCourseSummaryIdentity(summaryId, payload) { + const result = await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/identity`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + editingSummaryIdentityId = ""; + editingSummarySearchId = ""; + expandedSummarySearchId = result.new_id || ""; + await loadSummarySearch(); + await loadOperationLogs(); +} + +function findCurrentSummary(summaryId) { + return currentSummarySearchItems.find((item) => String(item.id) === String(summaryId)); +} + +async function applySummaryBindingCandidate(summaryId, record) { + const item = findCurrentSummary(summaryId); + if (!item) throw new Error("未找到当前课程小结"); + let activeSummaryId = summaryId; + const targetStudent = String(record.student || "").trim(); + const targetTeacher = String(record.teacher || "").trim(); + const targetSubject = String(record.subject || "").trim(); + const targetTime = String(record.time || "").trim(); + const needsIdentity = targetStudent && targetTeacher && targetSubject && ( + targetStudent !== String(item.student || "") + || targetTeacher !== String(item.teacher || "") + || targetSubject !== String(item.subject || "") + ); + if (needsIdentity) { + const result = await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(activeSummaryId)}/identity`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ student: targetStudent, teacher: targetTeacher, subject: targetSubject }), + }); + activeSummaryId = result.new_id || activeSummaryId; + } + if (targetTime && targetTime !== String(item.time_range || "")) { + await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(activeSummaryId)}/time`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ time_range: targetTime }), + }); + } + editingSummaryIdentityId = ""; + editingSummarySearchId = ""; + expandedSummarySearchId = activeSummaryId; + await loadSummarySearch(); + await loadOperationLogs(); +} + async function saveSummaryReviewEdits() { if (!activeSummaryReview) return; try { @@ -912,9 +1194,8 @@ async function saveSummaryReviewEdits() { body: JSON.stringify(summaryReviewEditorPayload()), }); activeSummaryReview = result.task; - await loadSummaryReviews(); + await refreshSummaryWorkspace(); openSummaryReviewDrawer(activeSummaryReview.id); - await loadOperationLogs(); } catch (error) { alert(error.message); } @@ -928,8 +1209,7 @@ async function linkExistingSummaryReview() { method: "POST", }); closeSummaryReviewDrawer(); - await loadSummaryReviews(); - await loadOperationLogs(); + await refreshSummaryWorkspace(); } catch (error) { alert(error.message); } @@ -943,21 +1223,25 @@ async function resolveDuplicateSummary(taskId, summaryId) { body: JSON.stringify({ delete_summary_id: summaryId }), }); closeSummaryReviewDrawer(); - await loadSummaryReviews(); - await loadOperationLogs(); + await refreshSummaryWorkspace(); } async function scanDuplicateSummaries() { - duplicateSummaryScanBtn.disabled = true; + duplicateSummaryScanBtns.forEach((button) => { + button.disabled = true; + }); try { const result = await fetchJson("/api/admin/course-summaries/duplicate-scan", { method: "POST" }); alert(`扫描完成:发现 ${result.scanned || 0} 组重复,新增 ${result.created || 0} 条审核任务`); - await loadSummaryReviews(); + summaryReviewStatus.value = "pending"; + setActiveTab("summarySearch"); await loadOperationLogs(); } catch (error) { alert(error.message); } finally { - duplicateSummaryScanBtn.disabled = false; + duplicateSummaryScanBtns.forEach((button) => { + button.disabled = false; + }); } } @@ -1035,17 +1319,23 @@ function renderLogDetail(item) { logField("来源ID", item.source_id), logField("记录ID", item.record_id), logField("课程小结ID", item.summary_id), + logField("新课程小结ID", item.new_summary_id), logField("批次", item.batch_id), ]), renderLogDetailGroup("课程信息", [ logField("老师", item.teacher), + logField("原老师", item.old_teacher), + logField("原学生", item.old_student), logField("教师ID", item.teacher_id), logField("科目", item.subject), + logField("原科目", item.old_subject), logField("时间段", item.time_range), logField("上课记录", item.proposed_line), ]), renderLogDetailGroup("文件与备份", [ logField("保存文件", item.saved_path), + logField("原文件", item.old_path), + logField("新文件", item.new_path), logField("备份ID", item.backup_id), logField("撤回记录", item.target_log_id), logField("撤回备份", item.target_backup_id), @@ -1119,7 +1409,8 @@ async function reviewTask(taskId, action) { } async function summaryReviewTask(taskId, action) { - await runTaskAction(taskId, action, loadSummaryReviews); + await runTaskAction(taskId, action, () => refreshSummaryWorkspace({ logs: false })); + await loadOperationLogs(); closeSummaryReviewDrawer(); } @@ -1434,7 +1725,9 @@ reviewRows.addEventListener("click", (event) => { if (reject) reviewTask(reject.dataset.taskId, "reject"); }); summaryReviewStatus.addEventListener("change", loadSummaryReviews); -duplicateSummaryScanBtn.addEventListener("click", scanDuplicateSummaries); +duplicateSummaryScanBtns.forEach((button) => { + button.addEventListener("click", scanDuplicateSummaries); +}); summaryReviewRows.addEventListener("click", (event) => { const detail = event.target.closest(".summary-detail"); const approve = event.target.closest(".summary-approve"); @@ -1479,15 +1772,56 @@ summarySearchForm.addEventListener("submit", (event) => { event.preventDefault(); loadSummarySearch(); }); -summarySearchMissingTime.addEventListener("change", loadSummarySearch); +summarySearchBindingStatus.addEventListener("change", loadSummarySearch); +summarySearchHasCandidate.addEventListener("change", loadSummarySearch); summarySearchRows.addEventListener("click", (event) => { const saveTime = event.target.closest(".summary-time-save"); const deleteSummary = event.target.closest(".summary-delete"); + const editIdentity = event.target.closest(".summary-identity-edit"); + const saveIdentity = event.target.closest(".summary-identity-save"); + const cancelIdentity = event.target.closest(".summary-identity-cancel"); const editSummary = event.target.closest(".summary-body-edit"); const saveSummaryBody = event.target.closest(".summary-body-save"); const cancelSummaryBody = event.target.closest(".summary-body-cancel"); + const applyCandidate = event.target.closest(".summary-apply-candidate"); + if (applyCandidate) { + if (!confirm("确认按候选上课记录修正课程小结的学生、老师、科目和时间?系统不会修改上课记录和课时账户。")) return; + applySummaryBindingCandidate(applyCandidate.dataset.summaryId, { + student: applyCandidate.dataset.student, + teacher: applyCandidate.dataset.teacher, + time: applyCandidate.dataset.time, + subject: applyCandidate.dataset.subject, + }).catch((error) => alert(error.message)); + return; + } + if (editIdentity) { + editingSummaryIdentityId = editIdentity.dataset.summaryId; + editingSummarySearchId = ""; + renderCurrentSummarySearch(); + return; + } + if (cancelIdentity) { + editingSummaryIdentityId = ""; + renderCurrentSummarySearch(); + return; + } + if (saveIdentity) { + const studentInput = summarySearchRows.querySelector(`[data-summary-identity-student='${saveIdentity.dataset.summaryId}']`); + const teacherInput = summarySearchRows.querySelector(`[data-summary-identity-teacher='${saveIdentity.dataset.summaryId}']`); + const subjectInput = summarySearchRows.querySelector(`[data-summary-identity-subject='${saveIdentity.dataset.summaryId}']`); + const student = studentInput ? studentInput.value.trim() : ""; + const teacher = teacherInput ? teacherInput.value.trim() : ""; + const subject = subjectInput ? subjectInput.value.trim() : ""; + if (!student || !teacher || !subject) { + alert("学生、老师和科目不能为空"); + return; + } + updateCourseSummaryIdentity(saveIdentity.dataset.summaryId, { student, teacher, subject }).catch((error) => alert(error.message)); + return; + } if (editSummary) { editingSummarySearchId = editSummary.dataset.summaryId; + editingSummaryIdentityId = ""; renderCurrentSummarySearch(); return; } @@ -1589,8 +1923,7 @@ refreshBtn.addEventListener("click", () => { loadAdminHealth(); if (!panels.accounts.hidden) loadAccounts(); if (!panels.reviews.hidden) loadReviews(); - if (!panels.summaries.hidden) loadSummaryReviews(); - if (!panels.summarySearch.hidden) loadSummarySearch(); + if (!panels.summarySearch.hidden) refreshSummaryWorkspace({ logs: false }); if (!panels.logs.hidden) loadOperationLogs(); }); diff --git a/app/app/static/app.js b/app/app/static/app.js index 4f4af5c..077576c 100644 --- a/app/app/static/app.js +++ b/app/app/static/app.js @@ -226,8 +226,14 @@ function renderRecordRow(row) { function renderSummaryEntry(summary) { const title = summary.title || "课程小结"; const time = summary.time_range ? ` · ${summary.time_range}` : ""; + const meta = [ + summary.date_iso || "", + summary.teacher || "", + summary.subject || "", + ].filter(Boolean).join(" · "); return `
${escapeHtml(title)}${escapeHtml(time)}
+ ${meta ? `
${escapeHtml(meta)}
` : ""}
${escapeHtml(summary.body || "暂无正文")}
`; } diff --git a/app/app/static/index.html b/app/app/static/index.html index fc12bc2..37ad5a6 100644 --- a/app/app/static/index.html +++ b/app/app/static/index.html @@ -4,7 +4,7 @@ 新时空教务管理系统 - +
@@ -165,6 +165,6 @@
- + diff --git a/app/app/static/styles.css b/app/app/static/styles.css index a58595a..23d7e55 100644 --- a/app/app/static/styles.css +++ b/app/app/static/styles.css @@ -230,7 +230,7 @@ h2 { } .summary-search-row { - grid-template-columns: minmax(180px, 1.4fr) repeat(6, minmax(100px, 0.8fr)) 88px; + grid-template-columns: minmax(180px, 1.4fr) repeat(7, minmax(104px, 0.8fr)) 88px; } input, @@ -373,6 +373,18 @@ select:focus { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.form-hint { + grid-column: 1 / -1; + color: #475467; + font-size: 13px; + line-height: 1.45; +} + +.suggested-input { + border-color: #f59e0b; + background: #fffbeb; +} + .span-2 { grid-column: span 2; } @@ -676,6 +688,34 @@ textarea:focus { -webkit-line-clamp: 3; } +.summary-suggested { + color: var(--warn); + font-weight: 700; +} + +.summary-review-reasons { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + gap: 6px; +} + +.summary-review-reasons span { + max-width: 260px; + padding: 3px 8px; + border-radius: 999px; + background: #f2f4f7; + color: #475467; + font-size: 12px; + font-weight: 700; + line-height: 1.35; +} + +.summary-review-reasons span:first-child { + background: #fef0c7; + color: var(--warn); +} + .summary-full { max-width: min(720px, 72vw); margin-top: 8px; @@ -706,6 +746,13 @@ textarea:focus { max-width: none; } +.duplicate-summary-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + .summary-conflict-heading { color: var(--accent-strong); font-size: 13px; @@ -774,6 +821,68 @@ textarea:focus { margin-bottom: 8px; } +.summary-binding-status { + display: grid; + gap: 4px; + align-items: start; +} + +.summary-binding-badge { + width: max-content; +} + +.summary-binding-status small { + color: var(--muted); + line-height: 1.4; +} + +.summary-binding-panel { + display: grid; + gap: 10px; + margin-bottom: 12px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; +} + +.summary-binding-panel.matched { + border-color: #bbf7d0; + background: #f0fdf4; +} + +.summary-binding-title { + color: #344054; + font-size: 13px; + font-weight: 700; +} + +.summary-binding-record { + color: var(--text); + font-size: 14px; + line-height: 1.5; +} + +.summary-binding-candidate { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding-top: 10px; + border-top: 1px solid var(--line); +} + +.summary-binding-candidate small { + color: var(--muted); + line-height: 1.4; +} + +.summary-task-section { + margin-top: 22px; + padding-top: 18px; + border-top: 1px solid var(--line); +} + .summary-search-full { max-width: none; } @@ -783,6 +892,35 @@ textarea:focus { gap: 10px; } +.summary-identity-edit-panel { + display: grid; + gap: 10px; + margin-bottom: 12px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; +} + +.summary-identity-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.summary-identity-grid label { + display: grid; + gap: 6px; + color: #344054; + font-size: 13px; + font-weight: 700; +} + +.summary-identity-grid input { + width: 100%; + min-width: 0; +} + .summary-body-editor { min-height: 220px; font-family: Arial, "Songti SC", SimSun, sans-serif; @@ -1044,6 +1182,13 @@ td { border-top: 1px solid var(--line); } +.record-summary-meta { + margin-top: 4px; + color: var(--muted); + font-size: 13px; + line-height: 1.4; +} + .record-row.missing-summary td:first-child { position: relative; padding-left: 18px; @@ -1750,11 +1895,23 @@ td { justify-content: flex-start; } + .summary-identity-grid { + grid-template-columns: 1fr; + } + .summary-search-actions .secondary-button, .summary-search-actions .summary-time-input { width: 100%; } + .summary-binding-candidate { + grid-template-columns: 1fr; + } + + .summary-binding-candidate .secondary-button { + width: 100%; + } + .log-detail-grid { grid-template-columns: 1fr; gap: 10px;