diff --git a/.env.example b/.env.example index 38cc04c..46fa570 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ INGEST_AUTH_TOKEN=change-this-ingest-token CLASSNOTES_PATH=/data/classnotes.txt ACCOUNTS_PATH=/data/学生课时账户.md +TEACHERS_PATH=/data/教师档案.md ADMIN_TASKS_PATH=/data/admin_tasks.json COURSE_SUMMARIES_ROOT=/data/course_summaries COURSE_SUMMARY_STATE_PATH=/data/course_summary_state.json diff --git a/README.md b/README.md index 4dc8e69..d1c097c 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ docker compose exec xsk-education-management python scripts/import_course_summar ```text /root/新时空教务管理系统/data/classnotes.txt /root/新时空教务管理系统/data/学生课时账户.md +/root/新时空教务管理系统/data/教师档案.md /root/新时空教务管理系统/data/course_summaries/ /root/新时空教务管理系统/data/course_summary_state.json /root/新时空教务管理系统/data/operation_logs.jsonl @@ -198,6 +199,9 @@ docker compose up -d - `GET /api/accounts/王鑫鹏`:单个学生账户。 - `POST /api/admin/accounts`:管理后台新增课时账户。 - `PUT /api/admin/accounts/{student_id}`:管理后台修改课时账户。 +- `GET /api/admin/teachers`:管理后台读取老师档案。 +- `POST /api/admin/teachers`:管理后台新增老师档案。 +- `PUT /api/admin/teachers/{teacher_id}`:管理后台修改老师档案。 - `POST /api/corrections`:课程记录页提交纠错审核。 - `GET /api/admin/tasks`:管理后台查看审核任务。 - `POST /api/admin/tasks/{task_id}/approve`:批准纠错并写入正式上课记录。 diff --git a/app/api_utils.py b/app/api_utils.py index d637b32..daa1c82 100644 --- a/app/api_utils.py +++ b/app/api_utils.py @@ -6,9 +6,9 @@ from pathlib import Path from fastapi import HTTPException, Request from pydantic import ValidationError -from .config import ACCOUNTS_PATH, CLASSNOTES_PATH -from .data import Account, Payment, read_accounts, read_classnotes -from .schemas import AccountPayload, RegisterLinesPayload +from .config import ACCOUNTS_PATH, CLASSNOTES_PATH, TEACHERS_PATH +from .data import Account, Payment, Teacher, read_accounts, read_classnotes, read_teachers +from .schemas import AccountPayload, RegisterLinesPayload, TeacherPayload async def read_register_payload(request: Request) -> RegisterLinesPayload: @@ -49,6 +49,12 @@ def load_accounts(): return read_accounts(ACCOUNTS_PATH) +def load_teachers(): + if not TEACHERS_PATH.exists(): + return [] + return read_teachers(TEACHERS_PATH) + + def file_meta(path: Path) -> dict: if not path.exists(): return {"exists": False, "path": str(path)} @@ -70,3 +76,14 @@ def payload_to_account(payload: AccountPayload, student_id: str | None = None) - account_status=payload.account_status, note=payload.note, ) + + +def payload_to_teacher(payload: TeacherPayload, teacher_id: str | None = None) -> Teacher: + return Teacher( + teacher_id=teacher_id if teacher_id is not None else payload.teacher_id, + name=payload.name, + alias=payload.alias, + subjects=payload.subjects, + status=payload.status, + note=payload.note, + ) diff --git a/app/config.py b/app/config.py index 646e099..ddd266f 100644 --- a/app/config.py +++ b/app/config.py @@ -10,6 +10,7 @@ STATIC_DIR = APP_DIR / "static" CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt")) ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md")) +TEACHERS_PATH = Path(os.getenv("TEACHERS_PATH", "/data/教师档案.md")) ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json")) COURSE_SUMMARIES_ROOT = Path(os.getenv("COURSE_SUMMARIES_ROOT", "/data/course_summaries")) COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/course_summary_state.json")) diff --git a/app/data.py b/app/data.py index ccef0c9..593f92b 100644 --- a/app/data.py +++ b/app/data.py @@ -22,6 +22,8 @@ from .domain import ( ROLE_WORDS, SUBJECTS, SUBJECT_ALIASES, + TEACHER_STATUSES, + Teacher, UNKNOWN_SUBJECTS, UNKNOWN_TEACHERS, WEEKDAYS, @@ -95,6 +97,11 @@ def format_account_row(account: Account) -> str: ) +def format_teacher_row(teacher: Teacher) -> str: + subjects = "、".join(teacher.subjects) + return f"| {teacher.teacher_id} | {teacher.name} | {teacher.alias} | {subjects} | {teacher.status} | {teacher.note} |" + + def parse_payments(text: str) -> list[Payment]: payments: list[Payment] = [] if not text.strip(): @@ -135,6 +142,34 @@ def validate_account(account: Account) -> Account: ) +def validate_teacher(teacher: Teacher) -> Teacher: + teacher_id = teacher.teacher_id.strip() + name = canonical_name(teacher.name) + alias = teacher.alias.strip() + subjects = [normalize_subject(subject) for subject in teacher.subjects if normalize_subject(subject)] + deduped_subjects = list(dict.fromkeys(subjects)) + status = teacher.status.strip() + note = teacher.note.strip() + if not re.fullmatch(r"T\d{3}", teacher_id): + raise ValueError("教师ID格式应为 T001 这样的三位编号") + if not name: + raise ValueError("教师姓名不能为空") + if "|" in name or "|" in alias or "|" in note: + raise ValueError("教师姓名、别名和备注不能包含 |") + if not deduped_subjects: + raise ValueError("任教学科不能为空") + if status not in TEACHER_STATUSES: + raise ValueError("教师状态必须是 在岗 或 离职") + return Teacher( + teacher_id=teacher_id, + name=name, + alias=alias, + subjects=deduped_subjects, + status=status, + note=note, + ) + + def read_classnotes(path: Path) -> list[ClassRecord]: records: list[ClassRecord] = [] with path.open("r", encoding="utf-8") as handle: @@ -166,7 +201,8 @@ def read_accounts(path: Path) -> list[Account]: with path.open("r", encoding="utf-8") as handle: for line_number, raw_line in enumerate(handle, start=1): line = raw_line.strip() - if not line.startswith("|") or line.startswith("|---") or "学生ID" in line: + compact = line.strip("|").replace(" ", "").replace("|", "") + if not line.startswith("|") or set(compact) <= {"-"} or "学生ID" in line: continue parts = [part.strip() for part in line.strip("|").split("|")] if len(parts) < 5: @@ -188,6 +224,51 @@ def read_accounts(path: Path) -> list[Account]: return accounts +def parse_teacher_subjects(text: str) -> list[str]: + subjects: list[str] = [] + for item in re.split(r"[、,,\s]+", text.strip()): + subject = normalize_subject(item) + if subject and subject not in subjects: + subjects.append(subject) + return subjects + + +def read_teachers(path: Path) -> list[Teacher]: + if not path.exists(): + return [] + teachers: list[Teacher] = [] + with path.open("r", encoding="utf-8") as handle: + for line_number, raw_line in enumerate(handle, start=1): + line = raw_line.strip() + compact = line.strip("|").replace(" ", "").replace("|", "") + if not line.startswith("|") or set(compact) <= {"-"} or "教师ID" in line: + continue + parts = [part.strip() for part in line.strip("|").split("|")] + if len(parts) == 5: + teacher_id, name, subjects, status, note = parts + alias = "" + elif len(parts) >= 6: + teacher_id, name, alias, subjects, status, note = parts[:6] + else: + continue + try: + teachers.append( + validate_teacher( + Teacher( + teacher_id=teacher_id, + name=canonical_name(name), + alias=alias.strip(), + subjects=parse_teacher_subjects(subjects), + status=status, + note=note, + ) + ) + ) + except ValueError as exc: + raise ValueError(f"{path}:{line_number} 教师档案错误: {exc}") from exc + return teachers + + def parse_class_record_line(line: str) -> ClassRecord: raw = line.strip() match = CLASSNOTE_RE.fullmatch(raw) @@ -303,7 +384,8 @@ def replace_account_lines(original_text: str, accounts_by_id: dict[str, Account] output: list[str] = [] for line in lines: stripped = line.strip() - if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped: + compact = stripped.strip("|").replace(" ", "").replace("|", "") + if stripped.startswith("|") and not set(compact) <= {"-"} and "学生ID" not in stripped: parts = [part.strip() for part in stripped.strip("|").split("|")] if parts and parts[0] in accounts_by_id: output.append(format_account_row(accounts_by_id[parts[0]])) @@ -318,7 +400,8 @@ def append_account_line(original_text: str, account: Account) -> str: insert_at = len(lines) for index, line in enumerate(lines): stripped = line.strip() - if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped: + compact = stripped.strip("|").replace(" ", "").replace("|", "") + if stripped.startswith("|") and not set(compact) <= {"-"} and "学生ID" not in stripped: insert_at = index + 1 lines.insert(insert_at, format_account_row(account)) trailing_newline = "\n" if original_text.endswith("\n") else "" @@ -331,7 +414,8 @@ def replace_single_account_line(original_text: str, old_student_id: str, account output: list[str] = [] for line in lines: stripped = line.strip() - if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped: + compact = stripped.strip("|").replace(" ", "").replace("|", "") + if stripped.startswith("|") and not set(compact) <= {"-"} and "学生ID" not in stripped: parts = [part.strip() for part in stripped.strip("|").split("|")] if parts and parts[0] == old_student_id: output.append(format_account_row(account)) @@ -359,6 +443,141 @@ def write_accounts(path: Path, accounts: list[Account]) -> None: atomic_write_text(path, replace_account_lines(original_text, accounts_by_id)) +def next_teacher_id(teachers: list[Teacher]) -> str: + values = [] + for teacher in teachers: + match = re.fullmatch(r"T(\d{3})", teacher.teacher_id) + if match: + values.append(int(match.group(1))) + return f"T{(max(values) if values else 0) + 1:03d}" + + +def replace_teacher_lines(original_text: str, teachers_by_id: dict[str, Teacher]) -> str: + lines = original_text.splitlines() + output: list[str] = [] + for line in lines: + stripped = line.strip() + compact = stripped.strip("|").replace(" ", "").replace("|", "") + if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped: + parts = [part.strip() for part in stripped.strip("|").split("|")] + if parts and parts[0] in teachers_by_id: + output.append(format_teacher_row(teachers_by_id[parts[0]])) + continue + output.append(line) + trailing_newline = "\n" if original_text.endswith("\n") else "" + return "\n".join(output) + trailing_newline + + +def append_teacher_line(original_text: str, teacher: Teacher) -> str: + if not original_text.strip(): + original_text = ( + "| 教师ID | 教师姓名 | 别名 | 任教学科 | 状态 | 备注 |\n" + "| ------ | -------- | ---- | -------- | ------ | ---- |\n" + ) + lines = original_text.splitlines() + insert_at = len(lines) + for index, line in enumerate(lines): + stripped = line.strip() + compact = stripped.strip("|").replace(" ", "").replace("|", "") + if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped: + insert_at = index + 1 + lines.insert(insert_at, format_teacher_row(teacher)) + trailing_newline = "\n" if original_text.endswith("\n") else "" + return "\n".join(lines) + trailing_newline + + +def replace_single_teacher_line(original_text: str, old_teacher_id: str, teacher: Teacher) -> str: + lines = original_text.splitlines() + replaced = False + output: list[str] = [] + for line in lines: + stripped = line.strip() + compact = stripped.strip("|").replace(" ", "").replace("|", "") + if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped: + parts = [part.strip() for part in stripped.strip("|").split("|")] + if parts and parts[0] == old_teacher_id: + output.append(format_teacher_row(teacher)) + replaced = True + continue + output.append(line) + if not replaced: + raise ValueError(f"未找到教师档案: {old_teacher_id}") + trailing_newline = "\n" if original_text.endswith("\n") else "" + return "\n".join(output) + trailing_newline + + +def write_teachers(path: Path, teachers: list[Teacher]) -> None: + original_text = path.read_text(encoding="utf-8") if path.exists() else "" + teachers_by_id = {teacher.teacher_id: teacher for teacher in teachers} + atomic_write_text(path, replace_teacher_lines(original_text, teachers_by_id)) + + +def create_teacher(path: Path, teacher: Teacher) -> dict: + teachers = read_teachers(path) + teacher = validate_teacher(replace(teacher, teacher_id=next_teacher_id(teachers))) + if any(item.teacher_id == teacher.teacher_id for item in teachers): + raise ValueError(f"教师ID已存在: {teacher.teacher_id}") + original_text = path.read_text(encoding="utf-8") if path.exists() else "" + backup_dir = create_data_backup("admin-create-teacher", {path: original_text}, [format_teacher_row(teacher)]) + atomic_write_text(path, append_teacher_line(original_text, teacher)) + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return {"teacher": teacher_to_dict(teacher), "backup_id": backup_dir.name} + + +def update_teacher(path: Path, old_teacher_id: str, teacher: Teacher) -> dict: + old_teacher_id = old_teacher_id.strip() + teachers = read_teachers(path) + teacher = validate_teacher(teacher) + if not any(item.teacher_id == old_teacher_id for item in teachers): + raise ValueError(f"未找到教师档案: {old_teacher_id}") + if teacher.teacher_id != old_teacher_id and any(item.teacher_id == teacher.teacher_id for item in teachers): + raise ValueError(f"教师ID已存在: {teacher.teacher_id}") + original_text = path.read_text(encoding="utf-8") if path.exists() else "" + backup_dir = create_data_backup("admin-update-teacher", {path: original_text}, [old_teacher_id, format_teacher_row(teacher)]) + atomic_write_text(path, replace_single_teacher_line(original_text, old_teacher_id, teacher)) + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return {"teacher": teacher_to_dict(teacher), "backup_id": backup_dir.name} + + +def teacher_to_dict(teacher: Teacher) -> dict: + return { + "teacher_id": teacher.teacher_id, + "name": teacher.name, + "alias": teacher.alias, + "display_name": teacher.alias or teacher.name, + "subjects": teacher.subjects, + "status": teacher.status, + "note": teacher.note, + } + + +def teacher_alias_map(teachers: list[Teacher]) -> dict[str, str]: + mapping: dict[str, str] = {} + for teacher in teachers: + display = teacher.alias or teacher.name + mapping[teacher.name] = display + if teacher.alias: + mapping[teacher.alias] = display + mapping[teacher.teacher_id] = display + return mapping + + +def teacher_name_map(teachers: list[Teacher]) -> dict[str, Teacher]: + mapping: dict[str, Teacher] = {} + for teacher in teachers: + mapping[teacher.name] = teacher + if teacher.alias: + mapping[teacher.alias] = teacher + mapping[teacher.teacher_id] = teacher + return mapping + + def create_account(path: Path, account: Account) -> dict: accounts = read_accounts(path) account = validate_account(replace(account, student_id=next_student_id(accounts))) @@ -609,6 +828,71 @@ def submit_deletion_tasks(tasks_path: Path, items: list[dict]) -> dict: return {"submitted": len(created), "items": [task_to_dict(task) for task in created]} +def resolve_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}") + return canonical_name(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) + student = canonical_name(str(item.get("student") or original.student)) + teacher = resolve_teacher_input(str(item.get("teacher") or original.teacher), teachers) + subject = normalize_subject(str(item.get("subject") or original.subject)) + record_date = parse_record_date(date_text) + weekday = WEEKDAYS[record_date.weekday()] + duration_hours = parse_time_range_hours(time_text) + minutes = int(round(duration_hours * 60)) + duration = duration_text_from_minutes(minutes) + return ClassRecord( + date=record_date.strftime("%Y.%m.%d"), + weekday=weekday, + time=normalize_time_range_text(time_text), + student=student, + duration=duration, + duration_hours=duration_hours, + teacher=teacher, + subject=subject, + ) + + +def submit_public_correction_tasks(tasks_path: Path, records: list[ClassRecord], teachers: list[Teacher], items: list[dict]) -> dict: + if not items: + raise ValueError("提交审核的纠错记录不能为空") + internal_items: list[dict] = [] + for item in items: + original = find_record_by_identity(records, str(item.get("record_id") or "")) + corrected = class_record_from_public_item(original, item, teachers) + internal_items.append( + { + "original_line": class_record_to_line(original), + "corrected_line": class_record_to_line(corrected), + } + ) + return submit_correction_tasks(tasks_path, internal_items) + + +def submit_public_deletion_tasks(tasks_path: Path, records: list[ClassRecord], items: list[dict]) -> dict: + if not items: + raise ValueError("提交审核的删除记录不能为空") + internal_items: list[dict] = [] + for item in items: + original = find_record_by_identity(records, str(item.get("record_id") or "")) + internal_items.append({"original_line": class_record_to_line(original)}) + return submit_deletion_tasks(tasks_path, internal_items) + + def list_admin_tasks(tasks_path: Path, status_filter: str = "", task_type: str = "") -> dict: tasks = read_admin_tasks(tasks_path) items = tasks["items"] @@ -1153,12 +1437,41 @@ def parse_course_summary_title_date(title: str) -> str: return "" +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*", + text, + ) + if colon_match: + start_hour, start_minute, end_hour, end_minute = (int(value) for value in colon_match.groups()) + 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}})?(?:分)?", + text, + ) + if not point_match: + return "" + start_hour = int(point_match.group(1)) + start_minute = int(point_match.group(2) or 0) + end_hour = int(point_match.group(3)) + end_minute = int(point_match.group(4) or 0) + candidate = f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}" + try: + return normalize_time_range_text(candidate) + except ValueError: + return "" + + def iter_course_summary_markdown(root: Path) -> Iterable[dict]: if not root.exists(): return for path in sorted(root.rglob("*.md")): if not path.is_file(): continue + if "backups" in path.relative_to(root).parts: + continue text = path.read_text(encoding="utf-8") matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text)) if not matches: @@ -1181,6 +1494,7 @@ def iter_course_summary_markdown(root: Path) -> Iterable[dict]: "id": item_id, "title": title, "date_iso": parse_course_summary_title_date(title), + "time_range": parse_course_summary_time_text(f"{title}\n{body_text}"), "group": group, "body": body_text, "body_preview": body_text[:260] + ("..." if len(body_text) > 260 else ""), @@ -1223,6 +1537,55 @@ def course_summary_matched_fields(item: dict, q: str) -> list[str]: return [label for key, label in labels.items() if q in str(item.get(key, ""))] +def course_summary_record_key(student: str, teacher: str, subject: str, date_iso: str, time_range: str) -> tuple[str, str, str, str, str]: + return ( + canonical_name(student.strip()), + canonical_name(teacher.strip()), + normalize_subject(subject.strip()), + date_iso.strip(), + normalize_time_range_text(time_range) if time_range else "", + ) + + +def course_summary_to_public(item: dict, teachers: list[Teacher]) -> dict: + display_names = teacher_alias_map(teachers) + teacher = str(item.get("teacher") or "") + body = str(item.get("body") or "") + title = str(item.get("title") or "") + display_teacher = display_names.get(teacher, teacher) + if teacher and display_teacher != teacher: + body = body.replace(teacher, display_teacher) + title = title.replace(teacher, display_teacher) + return { + "id": str(item.get("id") or ""), + "title": title, + "date_iso": str(item.get("date_iso") or ""), + "time_range": str(item.get("time_range") or ""), + "teacher": display_teacher, + "subject": str(item.get("subject") or ""), + "body": body, + } + + +def course_summary_index_for_records(root: Path) -> dict[tuple[str, str, str, str, str], list[dict]]: + index: dict[tuple[str, str, str, str, str], list[dict]] = defaultdict(list) + for item in iter_course_summary_markdown(root): + time_range = str(item.get("time_range") or "") + if not time_range: + continue + 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 ""), + time_range, + ) + index[key].append(item) + for values in index.values(): + values.sort(key=lambda item: (str(item.get("title") or ""), str(item.get("id") or ""))) + return index + + def query_course_summaries( root: Path, q: str = "", @@ -1231,6 +1594,7 @@ def query_course_summaries( subject: str = "", date_from: str = "", date_to: str = "", + missing_time: bool = False, limit: int = 200, ) -> dict: normalized_from = normalize_filter_date(date_from) @@ -1250,6 +1614,7 @@ def query_course_summaries( normalized_from, normalized_to, ) + and (not missing_time or not str(item.get("time_range") or "")) ] for item in matched: item["matched_fields"] = course_summary_matched_fields(item, keyword) @@ -1271,6 +1636,81 @@ def query_course_summaries( } +def replace_course_summary_block(root: Path, path: Path, summary_id: str, new_title: str | None = None, delete: bool = False) -> dict: + text = path.read_text(encoding="utf-8") + matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text)) + identity = parse_course_summary_file_identity(root, path) + 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) + body = text[body_start:end].strip() + body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", body).strip() + body_text = body_without_meta or body + item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20) + if item_id != summary_id: + continue + if delete: + new_text = text[:start].rstrip() + "\n\n" + text[end:].lstrip() + else: + if not new_title: + raise ValueError("课程小结标题不能为空") + new_text = f"{text[:match.start('title')]}{new_title}{text[match.end('title'):]}" + atomic_write_text(path, new_text.rstrip() + "\n") + return {"id": summary_id, "title": title, "path": str(path), "deleted": delete, "new_title": new_title or ""} + raise ValueError("未找到课程小结") + + +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: + return item + raise ValueError("未找到课程小结") + + +def update_course_summary_time(root: Path, summary_id: str, time_range: str) -> dict: + item = find_course_summary_item(root, summary_id) + normalized_time = normalize_time_range_text(time_range) + path = Path(str(item.get("source_path") or "")) + subject = normalize_subject(str(item.get("subject") or "待核对科目")) or "待核对科目" + date_iso = str(item.get("date_iso") or "") + if not date_iso: + raise ValueError("课程小结缺少日期,不能补齐时间") + old_title = str(item.get("title") or "") + date_prefix = date_iso + new_title = re.sub( + r"^\d{4}[.-]\d{1,2}[.-]\d{1,2}(?:\s+\d{1,2}:\d{2}-\d{1,2}:\d{2})?", + f"{date_prefix} {normalized_time}", + old_title, + ) + if new_title == old_title: + new_title = f"{date_prefix} {normalized_time} {subject}课堂小结" + original = path.read_text(encoding="utf-8") + backup_dir = create_data_backup("admin-update-course-summary-time", {path: original}, [summary_id, new_title]) + result = replace_course_summary_block(root, path, summary_id, new_title=new_title) + result["backup_id"] = backup_dir.name + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return result + + +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 "")) + original = path.read_text(encoding="utf-8") + backup_dir = create_data_backup("admin-delete-course-summary", {path: original}, [summary_id]) + result = replace_course_summary_block(root, path, summary_id, delete=True) + result["backup_id"] = backup_dir.name + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return result + + def course_summary_path(root: Path, summary: dict) -> Path: student = safe_filename_part(summary["student"]) teacher = safe_filename_part(summary["teacher"] or "待核对老师") @@ -1445,6 +1885,7 @@ def approve_course_summary_task( task["status"] = "approved" task["updated_at"] = datetime.now().isoformat(timespec="seconds") task["reviewed_at"] = task["updated_at"] + task["proposed_line"] = proposed_line task["registered_line"] = proposed_line task["backup_id"] = result.get("backup_id", "") write_admin_tasks(tasks_path, tasks) @@ -1651,7 +2092,6 @@ def ingest_course_summaries( result["review_pending"] += 1 status_value = "review" task_id = task.get("id") - backup_id = "" else: register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line) result["auto_registered"] += 1 @@ -1967,6 +2407,11 @@ def build_query_spec(query: str, records: list[ClassRecord], today: date | None ) +def build_public_query_spec(query: str, records: list[ClassRecord], teachers: list[Teacher], today: date | None = None) -> QuerySpec: + spec = build_query_spec(query, records, today=today) + return replace(spec, raw_query=query) + + def filter_records(records: list[ClassRecord], spec: QuerySpec) -> list[ClassRecord]: matched: list[ClassRecord] = [] for record in records: @@ -2014,6 +2459,28 @@ def summarize_records(records: list[ClassRecord]) -> dict: } +def summarize_public_records(records: list[ClassRecord], teachers: list[Teacher]) -> dict: + display_names = teacher_alias_map(teachers) + by_student: defaultdict[str, float] = defaultdict(float) + by_teacher: defaultdict[str, float] = defaultdict(float) + by_subject: defaultdict[str, float] = defaultdict(float) + for record in records: + by_student[record.student] += record.duration_hours + by_teacher[display_names.get(record.teacher, record.teacher)] += record.duration_hours + by_subject[record.subject] += record.duration_hours + return { + "count": len(records), + "total_hours": round(sum(record.duration_hours for record in records), 2), + "students": dict(sorted((key, round(value, 2)) for key, value in by_student.items())), + "teachers": dict(sorted((key, round(value, 2)) for key, value in by_teacher.items())), + "subjects": dict(sorted((key, round(value, 2)) for key, value in by_subject.items())), + } + + +def record_identity(record: ClassRecord) -> str: + return sha1_text(class_record_to_line(record), 20) + + def record_to_dict(record: ClassRecord) -> dict: return { "date": record.date, @@ -2027,6 +2494,38 @@ def record_to_dict(record: ClassRecord) -> dict: } +def public_record_to_dict( + record: ClassRecord, + teachers: list[Teacher], + summary_index: dict[tuple[str, str, str, str, str], list[dict]] | None = None, +) -> dict: + display_names = teacher_alias_map(teachers) + summary_key = course_summary_record_key( + record.student, + record.teacher, + record.subject, + record.date.replace(".", "-"), + record.time, + ) + summaries = [ + course_summary_to_public(item, teachers) + for item in (summary_index or {}).get(summary_key, []) + ] + return { + "record_id": record_identity(record), + "date": record.date, + "weekday": record.weekday, + "time": record.time, + "student": record.student, + "duration": record.duration, + "duration_hours": record.duration_hours, + "teacher": display_names.get(record.teacher, record.teacher), + "subject": record.subject, + "summary_count": len(summaries), + "summaries": summaries, + } + + def account_to_dict(account: Account) -> dict: return { "student_id": account.student_id, @@ -2062,6 +2561,41 @@ def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> d } +def query_public_records( + records: list[ClassRecord], + teachers: list[Teacher], + query: str, + limit: int = 200, + summaries_root: Path | None = None, +) -> dict: + spec = build_public_query_spec(query, records, teachers) + matched = filter_records(records, spec) if has_filter_condition(spec) else [] + shown = matched[:limit] if limit > 0 else matched + display_names = teacher_alias_map(teachers) + summary_index = course_summary_index_for_records(summaries_root) if summaries_root is not None else {} + return { + "query": { + "raw_query": spec.raw_query, + "date_range": format_date_range(spec), + "students": spec.students, + "teachers": [display_names.get(teacher, teacher) for teacher in spec.teachers], + "subjects": spec.subjects, + }, + "summary": summarize_public_records(matched, teachers), + "records": [public_record_to_dict(record, teachers, summary_index) for record in shown], + "total_records": len(matched), + "shown_records": len(shown), + } + + +def find_record_by_identity(records: list[ClassRecord], record_id: str) -> ClassRecord: + target = record_id.strip() + for record in records: + if record_identity(record) == target: + return record + raise ValueError(f"未找到上课记录: {record_id}") + + def account_summary(accounts: list[Account]) -> dict: result = { "total": len(accounts), diff --git a/app/domain.py b/app/domain.py index e78517c..a8b51cf 100644 --- a/app/domain.py +++ b/app/domain.py @@ -26,6 +26,7 @@ FALLBACK_ALIASES = { ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"} WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"} +TEACHER_STATUSES = {"在岗", "离职"} UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"} HIGH_CONFIDENCE_VALUES = {"high", "高", "高置信", "true", "1", "yes"} AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "manual_admin", "大模型高置信识别", "关键词"} @@ -63,6 +64,16 @@ class ClassRecord: subject: str +@dataclass(frozen=True) +class Teacher: + teacher_id: str + name: str + alias: str + subjects: list[str] + status: str + note: str + + @dataclass(frozen=True) class QuerySpec: raw_query: str diff --git a/app/routers/accounts.py b/app/routers/accounts.py index c7ac811..4116e0f 100644 --- a/app/routers/accounts.py +++ b/app/routers/accounts.py @@ -2,7 +2,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query, Request -from ..api_utils import file_meta, load_accounts, payload_to_account, read_register_payload +from ..api_utils import file_meta, load_accounts, load_teachers, payload_to_account, payload_to_teacher, read_register_payload from ..auth import verify_accounts_auth, verify_admin_auth from ..config import ( ACCOUNTS_PATH, @@ -11,21 +11,26 @@ from ..config import ( COURSE_SUMMARIES_ROOT, COURSE_SUMMARY_STATE_PATH, OPERATION_LOGS_PATH, + TEACHERS_PATH, write_lock, ) from ..data import ( ACCOUNT_STATUSES, + TEACHER_STATUSES, DuplicateRecordError, account_summary, account_to_dict, create_account, + create_teacher, filter_accounts, register_class_record_lines, register_course_summary_texts, register_payment_lines, + teacher_to_dict, update_account, + update_teacher, ) -from ..schemas import AccountPayload +from ..schemas import AccountPayload, TeacherPayload router = APIRouter() @@ -83,10 +88,13 @@ async def register_course_summaries(request: Request, _user: str = Depends(verif @router.get("/api/account-health") def account_health(_user: str = Depends(verify_accounts_auth)): accounts = load_accounts() + teachers = load_teachers() return { "ok": True, "accounts": file_meta(ACCOUNTS_PATH), + "teachers": file_meta(TEACHERS_PATH), "accounts_count": len(accounts), + "teachers_count": len(teachers), "account_summary": account_summary(accounts), } @@ -119,6 +127,42 @@ def admin_statuses(_user: str = Depends(verify_admin_auth)): return {"account_statuses": sorted(ACCOUNT_STATUSES)} +@router.get("/api/admin/teacher-statuses") +def admin_teacher_statuses(_user: str = Depends(verify_admin_auth)): + return {"teacher_statuses": sorted(TEACHER_STATUSES)} + + +@router.get("/api/admin/teachers") +def admin_teachers(_user: str = Depends(verify_admin_auth)): + return { + "teachers": [teacher_to_dict(teacher) for teacher in load_teachers()], + } + + +@router.post("/api/admin/teachers") +def admin_create_teacher(payload: TeacherPayload, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + result = create_teacher(TEACHERS_PATH, payload_to_teacher(payload)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} + + +@router.put("/api/admin/teachers/{teacher_id}") +def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + result = update_teacher( + TEACHERS_PATH, + teacher_id, + payload_to_teacher(payload, teacher_id=payload.teacher_id.strip() or teacher_id), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} + + @router.post("/api/admin/accounts") def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_admin_auth)): try: diff --git a/app/routers/admin.py b/app/routers/admin.py index fe46c35..d372407 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -14,10 +14,12 @@ from ..config import ( from ..data import ( append_operation_log, approve_admin_task, + delete_course_summary, list_admin_tasks, list_operation_logs, query_course_summaries, reject_admin_task, + update_course_summary_time, ) @@ -61,6 +63,7 @@ def admin_course_summaries( subject: str = Query(""), date_from: str = Query(""), date_to: str = Query(""), + missing_time: bool = Query(False), limit: int = Query(200, ge=1, le=1000), _user: str = Depends(verify_admin_auth), ): @@ -73,6 +76,7 @@ def admin_course_summaries( subject=subject, date_from=date_from, date_to=date_to, + missing_time=missing_time, limit=limit, ) except ValueError as exc: @@ -117,3 +121,38 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)): except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, "task": task} + + +@router.post("/api/admin/course-summaries/{summary_id}/time") +def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + result = update_course_summary_time(COURSE_SUMMARIES_ROOT, summary_id, str(payload.get("time_range") or "")) + append_operation_log( + OPERATION_LOGS_PATH, + "admin_course_summary_update_time", + "updated", + summary_id=summary_id, + time_range=str(payload.get("time_range") 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: + with write_lock: + result = delete_course_summary(COURSE_SUMMARIES_ROOT, summary_id) + append_operation_log( + OPERATION_LOGS_PATH, + "admin_course_summary_delete", + "deleted", + summary_id=summary_id, + 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} diff --git a/app/routers/health.py b/app/routers/health.py index 8055b4f..6c887dc 100644 --- a/app/routers/health.py +++ b/app/routers/health.py @@ -2,7 +2,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends -from ..api_utils import file_meta, load_accounts, load_records +from ..api_utils import file_meta, load_accounts, load_records, load_teachers from ..auth import verify_records_auth from ..config import ( ACCOUNTS_PATH, @@ -10,6 +10,7 @@ from ..config import ( COURSE_SUMMARIES_ROOT, COURSE_SUMMARY_STATE_PATH, OPERATION_LOGS_PATH, + TEACHERS_PATH, ) from ..data import account_summary @@ -21,14 +22,17 @@ router = APIRouter() def health(_user: str = Depends(verify_records_auth)): records = load_records() accounts = load_accounts() + teachers = load_teachers() return { "ok": True, "classnotes": file_meta(CLASSNOTES_PATH), "accounts": file_meta(ACCOUNTS_PATH), + "teachers": file_meta(TEACHERS_PATH), "course_summaries": file_meta(COURSE_SUMMARIES_ROOT), "course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH), "operation_logs": file_meta(OPERATION_LOGS_PATH), "records_count": len(records), "accounts_count": len(accounts), + "teachers_count": len(teachers), "account_summary": account_summary(accounts), } diff --git a/app/routers/records.py b/app/routers/records.py index dad2c4c..9e46e17 100644 --- a/app/routers/records.py +++ b/app/routers/records.py @@ -2,10 +2,15 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query -from ..api_utils import load_accounts, load_records +from ..api_utils import load_accounts, load_records, load_teachers from ..auth import verify_records_auth -from ..config import ADMIN_TASKS_PATH -from ..data import account_to_dict, query_records, submit_correction_tasks, submit_deletion_tasks +from ..config import ADMIN_TASKS_PATH, COURSE_SUMMARIES_ROOT +from ..data import ( + account_to_dict, + query_public_records, + submit_public_correction_tasks, + submit_public_deletion_tasks, +) from ..schemas import CorrectionSubmitPayload, DeletionSubmitPayload @@ -18,7 +23,7 @@ def records( limit: int = Query(200, ge=1, le=1000), _user: str = Depends(verify_records_auth), ): - return query_records(load_records(), q, limit=limit) + return query_public_records(load_records(), load_teachers(), q, limit=limit, summaries_root=COURSE_SUMMARIES_ROOT) @router.get("/api/student-account/{student}") @@ -32,8 +37,10 @@ def record_student_account(student: str, _user: str = Depends(verify_records_aut @router.post("/api/corrections") def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(verify_records_auth)): try: - result = submit_correction_tasks( + result = submit_public_correction_tasks( ADMIN_TASKS_PATH, + load_records(), + load_teachers(), [item.dict() for item in payload.items], ) except ValueError as exc: @@ -44,8 +51,9 @@ def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(ve @router.post("/api/deletions") def submit_deletions(payload: DeletionSubmitPayload, _user: str = Depends(verify_records_auth)): try: - result = submit_deletion_tasks( + result = submit_public_deletion_tasks( ADMIN_TASKS_PATH, + load_records(), [item.dict() for item in payload.items], ) except ValueError as exc: diff --git a/app/schemas.py b/app/schemas.py index 80ed36d..ef8618f 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -22,9 +22,22 @@ class AccountPayload(BaseModel): note: str = "" +class TeacherPayload(BaseModel): + teacher_id: str = "" + name: str + alias: str = "" + subjects: list[str] = Field(default_factory=list) + status: str = "在岗" + note: str = "" + + class CorrectionItemPayload(BaseModel): - original_line: str - corrected_line: str + record_id: str + date: str + time: str + student: str + teacher: str + subject: str class CorrectionSubmitPayload(BaseModel): @@ -32,7 +45,7 @@ class CorrectionSubmitPayload(BaseModel): class DeletionItemPayload(BaseModel): - original_line: str + record_id: str class DeletionSubmitPayload(BaseModel): diff --git a/app/static/admin.html b/app/static/admin.html index 1fdd797..087fb28 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -4,7 +4,7 @@ 管理后台 - +
@@ -22,6 +22,7 @@
- + diff --git a/app/static/admin.js b/app/static/admin.js index a0d3e3a..7ab3d53 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -2,6 +2,7 @@ const adminHealthText = document.querySelector("#adminHealthText"); const refreshBtn = document.querySelector("#refreshBtn"); const panels = { accounts: document.querySelector("#accountsPanel"), + teachers: document.querySelector("#teachersPanel"), reviews: document.querySelector("#reviewsPanel"), summaries: document.querySelector("#summariesPanel"), summarySearch: document.querySelector("#summarySearchPanel"), @@ -19,6 +20,23 @@ const accountEditForm = document.querySelector("#accountEditForm"); const accountEditError = document.querySelector("#accountEditError"); const cancelAccountEditBtn = document.querySelector("#cancelAccountEditBtn"); const newAccountBtn = document.querySelector("#newAccountBtn"); +const teacherForm = document.querySelector("#teacherForm"); +const teacherQuery = document.querySelector("#teacherQuery"); +const teacherStatus = document.querySelector("#teacherStatus"); +const teacherMeta = document.querySelector("#teacherMeta"); +const teacherRows = document.querySelector("#teacherRows"); +const teacherEditor = document.querySelector("#teacherEditor"); +const teacherEditorTitle = document.querySelector("#teacherEditorTitle"); +const teacherEditForm = document.querySelector("#teacherEditForm"); +const teacherEditError = document.querySelector("#teacherEditError"); +const cancelTeacherEditBtn = document.querySelector("#cancelTeacherEditBtn"); +const newTeacherBtn = document.querySelector("#newTeacherBtn"); +const editTeacherId = document.querySelector("#editTeacherId"); +const editTeacherName = document.querySelector("#editTeacherName"); +const editTeacherAlias = document.querySelector("#editTeacherAlias"); +const editTeacherStatus = document.querySelector("#editTeacherStatus"); +const editTeacherSubjects = document.querySelector("#editTeacherSubjects"); +const editTeacherNote = document.querySelector("#editTeacherNote"); const editStudentId = document.querySelector("#editStudentId"); const editStudent = document.querySelector("#editStudent"); const editRemaining = document.querySelector("#editRemaining"); @@ -50,6 +68,7 @@ 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 summarySearchMeta = document.querySelector("#summarySearchMeta"); const summarySearchRows = document.querySelector("#summarySearchRows"); const logOperation = document.querySelector("#logOperation"); @@ -71,6 +90,8 @@ const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus"); let currentAccounts = []; let editingAccountId = ""; +let currentTeachers = []; +let editingTeacherId = ""; let currentSummaryReviews = []; let activeSummaryReview = null; let summaryRegisterItemSeq = 0; @@ -147,6 +168,7 @@ function setActiveTab(tabName) { panel.hidden = name !== tabName; }); if (tabName === "accounts") loadAccounts(); + if (tabName === "teachers") loadTeachers(); if (tabName === "reviews") loadReviews(); if (tabName === "summaries") loadSummaryReviews(); if (tabName === "summarySearch") loadSummarySearch(); @@ -156,7 +178,8 @@ function setActiveTab(tabName) { async function loadAdminHealth() { try { const data = await fetchJson("/api/account-health"); - adminHealthText.textContent = `账户 ${data.accounts_count} 人;数据更新时间 ${fmtTime(data.accounts.mtime)}`; + const teacherText = data.teachers_count === undefined ? "" : `;老师 ${data.teachers_count} 位`; + adminHealthText.textContent = `账户 ${data.accounts_count} 人${teacherText};数据更新时间 ${fmtTime(data.accounts.mtime)}`; } catch (error) { adminHealthText.textContent = `读取失败:${error.message}`; } @@ -291,6 +314,130 @@ async function saveAccount(event) { } } +function renderTeacherSummary(rows) { + const activeCount = rows.filter((item) => item.status === "在岗").length; + const aliasCount = rows.filter((item) => String(item.alias || "").trim()).length; + teacherMeta.innerHTML = [ + metric("当前结果", `${rows.length} 位`), + metric("在岗", activeCount), + metric("已配置别名", aliasCount), + ].join(""); +} + +function teacherMatches(row) { + const keyword = teacherQuery.value.trim(); + if (teacherStatus.value && row.status !== teacherStatus.value) return false; + if (!keyword) return true; + const haystack = [ + row.teacher_id, + row.name, + row.alias, + row.display_name, + ...(row.subjects || []), + row.status, + row.note, + ].join("\n"); + return haystack.includes(keyword); +} + +async function loadTeachers() { + teacherRows.innerHTML = `正在读取`; + try { + const data = await fetchJson("/api/admin/teachers"); + currentTeachers = data.teachers || []; + const rows = currentTeachers.filter(teacherMatches); + renderTeacherSummary(rows); + teacherRows.innerHTML = rows + .map( + (row) => ` + ${escapeHtml(row.name)}
${escapeHtml(row.teacher_id)} + ${escapeHtml(row.alias || "")} + ${escapeHtml((row.subjects || []).join("、"))} + ${escapeHtml(row.status)} + ${escapeHtml(row.note || "")} + + `, + ) + .join(""); + if (!rows.length) { + teacherRows.innerHTML = `没有符合条件的老师档案`; + } + } catch (error) { + teacherRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; + } +} + +function openNewTeacherEditor() { + editingTeacherId = ""; + teacherEditorTitle.textContent = "新增老师"; + editTeacherId.value = ""; + editTeacherId.placeholder = "保存时自动生成"; + editTeacherName.value = ""; + editTeacherAlias.value = ""; + editTeacherSubjects.value = ""; + editTeacherStatus.value = "在岗"; + editTeacherNote.value = ""; + teacherEditError.hidden = true; + teacherEditor.hidden = false; + editTeacherName.focus(); +} + +function openEditTeacherEditor(teacherId) { + const teacher = currentTeachers.find((item) => item.teacher_id === teacherId); + if (!teacher) return; + editingTeacherId = teacher.teacher_id; + teacherEditorTitle.textContent = `编辑老师:${teacher.name}`; + editTeacherId.value = teacher.teacher_id; + editTeacherId.placeholder = ""; + editTeacherName.value = teacher.name; + editTeacherAlias.value = teacher.alias || ""; + editTeacherSubjects.value = (teacher.subjects || []).join("、"); + editTeacherStatus.value = teacher.status; + editTeacherNote.value = teacher.note || ""; + teacherEditError.hidden = true; + teacherEditor.hidden = false; + editTeacherName.focus(); +} + +function parseTeacherSubjects(value) { + return value + .split(/[、,,\s]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function buildTeacherPayload() { + return { + teacher_id: editTeacherId.value.trim(), + name: editTeacherName.value.trim(), + alias: editTeacherAlias.value.trim(), + subjects: parseTeacherSubjects(editTeacherSubjects.value), + status: editTeacherStatus.value, + note: editTeacherNote.value.trim(), + }; +} + +async function saveTeacher(event) { + event.preventDefault(); + try { + const payload = buildTeacherPayload(); + const url = editingTeacherId + ? `/api/admin/teachers/${encodeURIComponent(editingTeacherId)}` + : "/api/admin/teachers"; + const method = editingTeacherId ? "PUT" : "POST"; + await fetchJson(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + teacherEditor.hidden = true; + await loadTeachers(); + } catch (error) { + teacherEditError.textContent = error.message; + teacherEditError.hidden = false; + } +} + function renderReviewLine(line) { return `${escapeHtml(line)}`; } @@ -474,6 +621,7 @@ 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"); return params; } @@ -485,13 +633,25 @@ function renderSummarySearchBody(item) { `; } +function renderSummaryActions(item) { + if (item.time_range) { + return `已完整`; + } + const inputId = `summary-time-${item.id}`; + return `
+ + + +
`; +} + function renderMatchedFields(item) { const fields = Array.isArray(item.matched_fields) ? item.matched_fields : []; return fields.length ? `
命中:${fields.map(escapeHtml).join("、")}` : ""; } async function loadSummarySearch() { - summarySearchRows.innerHTML = `正在读取`; + summarySearchRows.innerHTML = `正在读取`; try { const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`); summarySearchMeta.innerHTML = [ @@ -501,21 +661,38 @@ async function loadSummarySearch() { summarySearchRows.innerHTML = data.items .map((item) => ` ${escapeHtml(item.date_iso || "未识别")} + ${escapeHtml(item.time_range || "缺少时间")} ${escapeHtml(item.student || "")}
${escapeHtml(item.group || "")} ${escapeHtml(item.teacher || "待核对老师")}
${escapeHtml(item.subject || "待核对科目")} ${escapeHtml(item.title || "")}${renderMatchedFields(item)} ${renderSummarySearchBody(item)} - ${escapeHtml(item.relative_path || "")} + ${renderSummaryActions(item)} `) .join(""); if (!data.items.length) { - summarySearchRows.innerHTML = `没有符合条件的课程小结`; + summarySearchRows.innerHTML = `没有符合条件的课程小结`; } } catch (error) { - summarySearchRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; + summarySearchRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; } } +async function updateCourseSummaryTime(summaryId, timeRange) { + await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/time`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ time_range: timeRange }), + }); + await loadSummarySearch(); +} + +async function deleteCourseSummary(summaryId) { + await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}`, { + method: "DELETE", + }); + await loadSummarySearch(); +} + function renderLogDetail(item) { const details = []; if (item.source_id) details.push(`来源:${item.source_id}`); @@ -711,6 +888,21 @@ accountRows.addEventListener("click", (event) => { if (!button) return; openEditAccountEditor(button.dataset.studentId); }); +teacherForm.addEventListener("submit", (event) => { + event.preventDefault(); + loadTeachers(); +}); +teacherStatus.addEventListener("change", loadTeachers); +newTeacherBtn.addEventListener("click", openNewTeacherEditor); +cancelTeacherEditBtn.addEventListener("click", () => { + teacherEditor.hidden = true; +}); +teacherEditForm.addEventListener("submit", saveTeacher); +teacherRows.addEventListener("click", (event) => { + const button = event.target.closest(".teacher-edit"); + if (!button) return; + openEditTeacherEditor(button.dataset.teacherId); +}); reviewStatus.addEventListener("change", loadReviews); reviewRows.addEventListener("click", (event) => { const approve = event.target.closest(".review-approve"); @@ -750,8 +942,26 @@ summarySearchForm.addEventListener("submit", (event) => { event.preventDefault(); loadSummarySearch(); }); +summarySearchMissingTime.addEventListener("change", loadSummarySearch); summarySearchRows.addEventListener("click", (event) => { const button = event.target.closest(".summary-toggle"); + const saveTime = event.target.closest(".summary-time-save"); + const deleteSummary = event.target.closest(".summary-delete"); + if (saveTime) { + const input = summarySearchRows.querySelector(`[data-summary-time-input='${saveTime.dataset.summaryId}']`); + const timeRange = input ? input.value.trim() : ""; + if (!timeRange) { + alert("请输入时间段"); + return; + } + updateCourseSummaryTime(saveTime.dataset.summaryId, timeRange).catch((error) => alert(error.message)); + return; + } + if (deleteSummary) { + if (!confirm("确认删除这条课程小结?")) return; + deleteCourseSummary(deleteSummary.dataset.summaryId).catch((error) => alert(error.message)); + return; + } if (!button) return; const target = document.getElementById(button.dataset.target); if (!target) return; diff --git a/app/static/app.js b/app/static/app.js index 78c499b..8ef6d9c 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -37,6 +37,7 @@ let currentRecords = []; let recordSort = { date: "asc", time: "asc" }; let currentRecordOrder = []; let correctedRecords = new Map(); +let expandedSummaryRecords = new Set(); let activeCorrectionKey = ""; let activeDeleteKey = ""; @@ -63,7 +64,7 @@ function metric(label, value) { } function makeRecordKey(row, index) { - return JSON.stringify([index, row.date, row.time, row.student, row.duration, row.teacher, row.subject]); + return row.record_id || JSON.stringify([index, row.date, row.time, row.student, row.duration, row.teacher, row.subject]); } function buildRecordLine(row) { @@ -182,6 +183,8 @@ function renderRecordRow(row) { const correctedClass = corrected ? " corrected-row" : ""; const actionLabel = corrected ? "编辑" : "纠错"; const badge = corrected ? '已修改' : ""; + const summaryCount = Number(displayRow.summary_count || 0); + const summaryExpanded = expandedSummaryRecords.has(key); return ` ${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)} ${escapeHtml(displayRow.time)} @@ -191,6 +194,7 @@ function renderRecordRow(row) { ${escapeHtml(displayRow.duration)}
+ ${badge} @@ -199,6 +203,15 @@ function renderRecordRow(row) { `; } +function renderSummaryEntry(summary) { + const title = summary.title || "课程小结"; + const time = summary.time_range ? ` · ${summary.time_range}` : ""; + return `
+
${escapeHtml(title)}${escapeHtml(time)}
+
${escapeHtml(summary.body || "暂无正文")}
+
`; +} + function renderGroupedRecords(records) { currentRecordOrder = []; return groupRecordsByTeacher(records) @@ -213,13 +226,28 @@ function renderGroupedRecords(records) { ${sortRecordsForDisplay(group.records) .map((row) => { currentRecordOrder.push(row._recordKey); - return renderRecordRow(row); + const summaryCount = Number(row.summary_count || 0); + const summaryRow = summaryCount + ? ` + ${(row.summaries || []).map(renderSummaryEntry).join("")} + ` + : ""; + return `${renderRecordRow(row)}${summaryRow}`; }) .join("")}`, ) .join(""); } +function toggleSummaryRow(key) { + if (expandedSummaryRecords.has(key)) { + expandedSummaryRecords.delete(key); + } else { + expandedSummaryRecords.add(key); + } + renderCurrentRecords(); +} + function statusClass(status) { if (status === "欠费") return "debt"; if (status === "预警") return "warning"; @@ -279,6 +307,7 @@ function updateCorrectionToolbar(message = "", isError = false) { function resetCorrections() { correctedRecords = new Map(); currentRecordOrder = []; + expandedSummaryRecords = new Set(); activeCorrectionKey = ""; updateCorrectionToolbar(); } @@ -421,7 +450,7 @@ async function submitDeleteRecord() { const data = await fetchJson("/api/deletions", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items: [{ original_line: buildRecordLine(original) }] }), + body: JSON.stringify({ items: [{ record_id: original.record_id }] }), }); closeDeleteDialog(); updateCorrectionToolbar(`已提交 ${data.submitted} 条删除审核`); @@ -488,12 +517,15 @@ async function copyCorrectedRecords() { async function submitCorrectedRecords() { const items = currentRecordOrder .map((key) => { - const original = findCurrentRecord(key); const corrected = correctedRecords.get(key); - if (!original || !corrected) return null; + if (!corrected) return null; return { - original_line: buildRecordLine(original), - corrected_line: buildRecordLine(corrected), + record_id: corrected.record_id, + date: corrected.date, + time: corrected.time, + student: corrected.student, + teacher: corrected.teacher, + subject: corrected.subject, }; }) .filter(Boolean); @@ -597,8 +629,13 @@ document.querySelectorAll("[data-query]").forEach((button) => { }); recordRows.addEventListener("click", (event) => { + const summaryButton = event.target.closest(".summary-toggle"); const editButton = event.target.closest(".correction-edit"); const deleteButton = event.target.closest(".record-delete"); + if (summaryButton) { + toggleSummaryRow(summaryButton.dataset.recordKey); + return; + } if (editButton) { openCorrectionDialog(editButton.dataset.recordKey); return; diff --git a/app/static/index.html b/app/static/index.html index d526764..c0b29c7 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -4,7 +4,7 @@ 新时空教务管理系统 - +
@@ -140,6 +140,6 @@
- + diff --git a/app/static/styles.css b/app/static/styles.css index 3d9056c..58436d7 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -211,7 +211,7 @@ h2 { } .summary-search-row { - grid-template-columns: minmax(180px, 1.4fr) repeat(5, minmax(110px, 0.8fr)) 88px; + grid-template-columns: minmax(180px, 1.4fr) repeat(6, minmax(100px, 0.8fr)) 88px; } input, @@ -630,6 +630,49 @@ td { color: var(--accent-strong); } +.small-button:disabled { + border-color: #e4e7ec; + background: #f2f4f7; + color: #98a2b3; + cursor: not-allowed; +} + +.small-button:disabled:hover { + border-color: #e4e7ec; + color: #98a2b3; +} + +.summary-collapse-row td { + padding: 0; + background: #fbfcfd; +} + +.record-summary-item { + padding: 12px 16px; + border-top: 1px solid var(--line); +} + +.record-summary-title { + margin-bottom: 8px; + color: var(--accent-strong); + font-size: 13px; + font-weight: 700; +} + +.summary-time-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.summary-time-input { + width: 120px; + height: 32px; + padding: 0 8px; + font-size: 13px; +} + .correction-badge { display: inline-flex; align-items: center; diff --git a/app/storage.py b/app/storage.py index 9143182..1d7701b 100644 --- a/app/storage.py +++ b/app/storage.py @@ -100,13 +100,13 @@ def create_data_backup( target_path = backup_dir / source_path.name atomic_write_text(target_path, content) apply_source_permissions(target_path, source_path) - source_stat = source_path.stat() + source_stat = source_path.stat() if source_path.exists() else None metadata["files"].append( { "name": source_path.name, "source_path": str(source_path), "size": len(content.encode("utf-8")), - "mtime": source_stat.st_mtime, + "mtime": source_stat.st_mtime if source_stat is not None else None, } ) metadata_path = backup_dir / "metadata.json" diff --git a/docker-compose.yml b/docker-compose.yml index 61063c2..c913c12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: TZ: ${TZ:-Asia/Shanghai} CLASSNOTES_PATH: ${CLASSNOTES_PATH:-/data/classnotes.txt} ACCOUNTS_PATH: ${ACCOUNTS_PATH:-/data/学生课时账户.md} + TEACHERS_PATH: ${TEACHERS_PATH:-/data/教师档案.md} ADMIN_TASKS_PATH: ${ADMIN_TASKS_PATH:-/data/admin_tasks.json} COURSE_SUMMARIES_ROOT: ${COURSE_SUMMARIES_ROOT:-/data/course_summaries} COURSE_SUMMARY_STATE_PATH: ${COURSE_SUMMARY_STATE_PATH:-/data/course_summary_state.json}