From 124ffca03eb5ceaeb02d8ad9a2a95596b06f4782 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 11:35:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=AD=A6=E7=94=9F=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/MAINTENANCE.md | 4 +- app/README.md | 17 +-- app/app/api_utils.py | 5 +- app/app/data.py | 80 +++++++++---- app/app/db.py | 10 +- app/app/domain.py | 1 + app/app/repository.py | 26 +++-- app/app/routers/accounts.py | 40 +++---- app/app/routers/pages.py | 23 ---- app/app/routers/records.py | 11 +- app/app/schemas.py | 2 +- app/app/static/accounts.html | 60 ---------- app/app/static/accounts.js | 123 -------------------- app/app/static/admin.html | 45 ++++---- app/app/static/admin.js | 212 ++++++++++++++++++----------------- app/app/static/app.js | 33 +++--- app/scripts/smoke_test.js | 153 ++++++++++++++++++++++--- 17 files changed, 409 insertions(+), 436 deletions(-) delete mode 100644 app/app/static/accounts.html delete mode 100644 app/app/static/accounts.js diff --git a/app/MAINTENANCE.md b/app/MAINTENANCE.md index e9e03fc..05683d6 100644 --- a/app/MAINTENANCE.md +++ b/app/MAINTENANCE.md @@ -72,7 +72,7 @@ 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}/api/student-health" curl -sS -u "admin:${ACCOUNTS_AUTH_PASSWORD}" "http://127.0.0.1:${APP_PORT:-18080}/admin" ``` @@ -112,7 +112,7 @@ git push origin HEAD:<当前分支> /root/新时空教务管理系统/data/operation_logs.jsonl ``` -普通前端和查询类改动不应该改变这些文件;课程小结查询页是只读功能,也不应该改变这些文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、课时账户编辑、课程小结自动入账或审核批准,先确认自动备份目录: +普通前端和查询类改动不应该改变这些文件;课程小结查询页是只读功能,也不应该改变这些文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、学生档案编辑、课程小结自动入账或审核批准,先确认自动备份目录: ```text /root/新时空教务管理系统/data/backups/ diff --git a/app/README.md b/app/README.md index 6a8a394..12450e7 100644 --- a/app/README.md +++ b/app/README.md @@ -35,7 +35,7 @@ docker compose -f app/docker-compose.yml <命令> ## 后端结构 - `app/main.py`:FastAPI 应用入口,只负责注册路由和全局异常处理。 -- `app/routers/`:按业务入口拆分 API 和页面路由,包括登录页面、课程记录、课时账户、管理后台、课程小结推送和健康检查。 +- `app/routers/`:按业务入口拆分 API 和页面路由,包括登录页面、课程记录、学生档案、管理后台、课程小结推送和健康检查。 - `app/config.py`:路径、环境变量、Cookie 名称和进程内写锁。 - `app/auth.py`:网页登录、Basic Auth、管理后台和课程小结推送鉴权。 - `app/schemas.py`:请求体 Pydantic 模型。 @@ -115,6 +115,8 @@ make migrate-sqlite /root/新时空教务管理系统/archives/text-source-before-sqlite-<时间>.tar.gz.sha256 ``` +学生表包含 `primary_entry_year` 字段,用于维护“小学一年级入学年份”。排课系统只读该字段并按课程日期动态推算年级。管理后台的学生档案编辑页可以维护该字段;运行时 `学生课时账户.md` 缓存会导出为 7 列格式,同时仍兼容旧 6 列缓存导入。 + ## 课程小结推送 VPS 接收接口: @@ -229,12 +231,13 @@ docker compose up -d - `GET /api/health`:数据状态。 - `GET /api/records?q=王鑫鹏5月数学课`:自然语言查询上课记录。 -- `GET /api/accounts`:课时账户列表。 -- `GET /api/accounts?q=王鑫鹏`:按学生筛选账户。 -- `GET /api/accounts?status=欠费`:按账户状态筛选。 -- `GET /api/accounts/王鑫鹏`:单个学生账户。 -- `POST /api/admin/accounts`:管理后台新增课时账户。 -- `PUT /api/admin/accounts/{student_id}`:管理后台修改课时账户。 +- `GET /api/students`:学生档案列表。 +- `GET /api/students?q=王鑫鹏`:按学生姓名或学生ID筛选学生档案。 +- `GET /api/students?status=欠费`:按档案状态筛选。 +- `GET /api/students/王鑫鹏`:单个学生档案。 +- `POST /api/admin/students`:管理后台新增学生档案。 +- `PUT /api/admin/students/{student_id}`:管理后台修改学生档案。 +- 学生档案的剩余课时由系统按缴费记录和上课记录自动计算,管理接口不会接受人工修改余额。 - `GET /api/admin/teachers`:管理后台读取老师档案。 - `POST /api/admin/teachers`:管理后台新增老师档案。 - `PUT /api/admin/teachers/{teacher_id}`:管理后台修改老师档案。 diff --git a/app/app/api_utils.py b/app/app/api_utils.py index 574be15..8f85404 100644 --- a/app/app/api_utils.py +++ b/app/app/api_utils.py @@ -50,7 +50,7 @@ def load_accounts(): if USE_SQLITE_SOURCE: ensure_runtime_cache() if not ACCOUNTS_PATH.exists(): - raise HTTPException(status_code=503, detail=f"课时账户文件不存在: {ACCOUNTS_PATH}") + raise HTTPException(status_code=503, detail=f"学生档案数据文件不存在: {ACCOUNTS_PATH}") return read_accounts(ACCOUNTS_PATH) @@ -79,8 +79,9 @@ def payload_to_account(payload: AccountPayload, student_id: str | None = None) - student_id=student_id if student_id is not None else payload.student_id, student=payload.student, payments=[Payment(date=item.date, hours=item.hours) for item in payload.payments], - remaining=payload.remaining, + remaining=0, account_status=payload.account_status, + primary_entry_year=payload.primary_entry_year, note=payload.note, ) diff --git a/app/app/data.py b/app/app/data.py index 81392cc..11cf92e 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -97,8 +97,9 @@ def format_payment(payment: Payment) -> str: def format_account_row(account: Account) -> str: payments = ",".join(format_payment(payment) for payment in account.payments) + primary_entry_year = str(account.primary_entry_year or "") return ( - f"| {account.student_id} | {account.student} | {payments} | " + f"| {account.student_id} | {account.student} | {primary_entry_year} | {payments} | " f"{format_number(account.remaining)} | {account.account_status} | {account.note} |" ) @@ -141,18 +142,22 @@ def validate_payment(payment: Payment) -> Payment: def validate_account(account: Account) -> Account: student_id = account.student_id.strip() student = canonical_name(account.student) + primary_entry_year = account.primary_entry_year if not re.fullmatch(r"XS\d{3}", student_id): raise ValueError("学生ID格式应为 XS001 这样的三位编号") if not student: raise ValueError("学生姓名不能为空") if account.account_status not in ACCOUNT_STATUSES: - raise ValueError("账户状态必须是 正常、预警、欠费、结课、退费") + raise ValueError("档案状态必须是 正常、预警、欠费、结课、退费") + if primary_entry_year is not None and not 1900 <= int(primary_entry_year) <= 2100: + raise ValueError("入学年份必须是 1900 到 2100 之间的年份") return Account( student_id=student_id, student=student, payments=[validate_payment(payment) for payment in account.payments], remaining=round(float(account.remaining), 2), account_status=account.account_status, + primary_entry_year=int(primary_entry_year) if primary_entry_year is not None else None, note=account.note.strip(), ) @@ -222,18 +227,29 @@ def read_accounts(path: Path) -> list[Account]: parts = [part.strip() for part in line.strip("|").split("|")] if len(parts) < 5: continue + if len(parts) >= 7: + student_id, student, primary_entry_year_text, payments_text, remaining_text, status_text, note_text = parts[:7] + else: + student_id, student, payments_text, remaining_text, status_text = parts[:5] + primary_entry_year_text = "" + note_text = parts[5] if len(parts) > 5 else "" try: - remaining = float(parts[3]) + remaining = float(remaining_text) except ValueError as exc: - raise ValueError(f"{path}:{line_number} 无法解析剩余课时: {parts[3]}") from exc + raise ValueError(f"{path}:{line_number} 无法解析剩余课时: {remaining_text}") from exc + try: + primary_entry_year = int(primary_entry_year_text) if primary_entry_year_text else None + except ValueError as exc: + raise ValueError(f"{path}:{line_number} 无法解析入学年份: {primary_entry_year_text}") from exc accounts.append( Account( - student_id=parts[0], - student=canonical_name(parts[1]), - payments=parse_payments(parts[2]), + student_id=student_id, + student=canonical_name(student), + payments=parse_payments(payments_text), remaining=remaining, - account_status=parts[4], - note=parts[5] if len(parts) > 5 else "", + account_status=status_text, + primary_entry_year=primary_entry_year, + note=note_text, ) ) return accounts @@ -381,7 +397,7 @@ def find_account_index(accounts: list[Account], student: str) -> int: for index, account in enumerate(accounts): if account.student == canonical_student or account.student_id == canonical_student: return index - raise ValueError(f"未找到学生账户: {student}") + raise ValueError(f"未找到学生档案: {student}") def update_account_remaining(account: Account, delta_hours: float) -> Account: @@ -398,6 +414,18 @@ def append_account_payment(account: Account, payment: Payment) -> Account: return replace(updated, account_status=recalc_account_status(updated)) +def used_hours_for_student(records: list[ClassRecord], student: str) -> float: + canonical_student = canonical_name(student) + return round(sum(record.duration_hours for record in records if record.student == canonical_student), 2) + + +def derive_account_remaining(account: Account, records: list[ClassRecord]) -> Account: + paid = round(sum(payment.hours for payment in account.payments), 2) + used = used_hours_for_student(records, account.student) + updated = replace(account, remaining=round(paid - used, 2)) + return replace(updated, account_status=recalc_account_status(updated)) + + def replace_account_lines(original_text: str, accounts_by_id: dict[str, Account]) -> str: lines = original_text.splitlines() output: list[str] = [] @@ -442,7 +470,7 @@ def replace_single_account_line(original_text: str, old_student_id: str, account continue output.append(line) if not replaced: - raise ValueError(f"未找到学生账户: {old_student_id}") + raise ValueError(f"未找到学生档案: {old_student_id}") trailing_newline = "\n" if original_text.endswith("\n") else "" return "\n".join(output) + trailing_newline @@ -597,9 +625,13 @@ def teacher_name_map(teachers: list[Teacher]) -> dict[str, Teacher]: return mapping -def create_account(path: Path, account: Account) -> dict: +def create_account(path: Path, account: Account, classnotes_path: Path | None = None) -> dict: accounts = read_accounts(path) - account = validate_account(replace(account, student_id=next_student_id(accounts))) + records = read_classnotes(classnotes_path) if classnotes_path and classnotes_path.exists() else [] + account = derive_account_remaining( + validate_account(replace(account, student_id=next_student_id(accounts))), + records, + ) if any(item.student_id == account.student_id for item in accounts): raise ValueError(f"学生ID已存在: {account.student_id}") original_accounts = path.read_text(encoding="utf-8") @@ -609,15 +641,16 @@ def create_account(path: Path, account: Account) -> dict: prune_data_backups(backup_dir.parent) except OSError: pass - return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "新增课时账户"} + return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "新增学生档案"} -def update_account(path: Path, old_student_id: str, account: Account) -> dict: +def update_account(path: Path, old_student_id: str, account: Account, classnotes_path: Path | None = None) -> dict: old_student_id = old_student_id.strip() accounts = read_accounts(path) - account = validate_account(account) + records = read_classnotes(classnotes_path) if classnotes_path and classnotes_path.exists() else [] + account = derive_account_remaining(validate_account(account), records) if not any(item.student_id == old_student_id for item in accounts): - raise ValueError(f"未找到学生账户: {old_student_id}") + raise ValueError(f"未找到学生档案: {old_student_id}") if account.student_id != old_student_id and any(item.student_id == account.student_id for item in accounts): raise ValueError(f"学生ID已存在: {account.student_id}") original_accounts = path.read_text(encoding="utf-8") @@ -627,7 +660,7 @@ def update_account(path: Path, old_student_id: str, account: Account) -> dict: prune_data_backups(backup_dir.parent) except OSError: pass - return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "修改课时账户"} + return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "修改学生档案"} def render_accounts_text(path: Path, accounts: list[Account]) -> str: @@ -1755,8 +1788,8 @@ OPERATION_LABELS = { "admin_course_summary_link_existing": "课程小结关联已有记录", "register-class-records": "登记上课记录", "register-payments": "登记缴费记录", - "admin-create-account": "新增课时账户", - "admin-update-account": "修改课时账户", + "admin-create-account": "新增学生档案", + "admin-update-account": "修改学生档案", "admin-create-teacher": "新增老师档案", "admin-update-teacher": "修改老师档案", "admin-approve-correction": "审核批准上课记录纠错", @@ -4771,6 +4804,7 @@ def account_to_dict(account: Account) -> dict: "remaining": account.remaining, "remaining_duration": duration_text_from_hours(account.remaining), "account_status": account.account_status, + "primary_entry_year": account.primary_entry_year, "note": account.note, } @@ -4963,8 +4997,8 @@ def admin_dashboard_summary( daily: defaultdict[str, float] = defaultdict(float) for record in period_records: daily[record.date.replace(".", "-")] += record.duration_hours - active_accounts = [account for account in accounts if account.account_status not in {"结课", "退费"}] - low_remaining = sorted(active_accounts, key=lambda account: (account.remaining, account.student))[:8] + active_students = [account for account in accounts if account.account_status not in {"结课", "退费"}] + low_remaining = sorted(active_students, key=lambda account: (account.remaining, account.student))[:8] logs = list_operation_logs(operation_logs_path, limit=10).get("items", []) if operation_logs_path.exists() else [] return { "period": { @@ -4989,7 +5023,7 @@ def admin_dashboard_summary( "subjects": top_duration_items(summary["subjects"]), "students": top_duration_items(summary["students"]), }, - "accounts": { + "students": { "summary": account_summary(accounts), "low_remaining": [ { diff --git a/app/app/db.py b/app/app/db.py index 3d3528c..fd37da8 100644 --- a/app/app/db.py +++ b/app/app/db.py @@ -8,7 +8,7 @@ from typing import Iterator from .config import SQLITE_DB_PATH -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 def connect(db_path: Path | None = None) -> sqlite3.Connection: @@ -48,6 +48,7 @@ def initialize_schema(conn: sqlite3.Connection) -> None: student_id TEXT PRIMARY KEY, student TEXT NOT NULL UNIQUE, account_status TEXT NOT NULL, + primary_entry_year INTEGER, note TEXT NOT NULL DEFAULT '', source_remaining REAL NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0 @@ -167,12 +168,19 @@ def initialize_schema(conn: sqlite3.Connection) -> None: CREATE INDEX IF NOT EXISTS idx_operation_logs_created_at ON operation_logs(created_at); """ ) + ensure_column(conn, "students", "primary_entry_year", "INTEGER") conn.execute( "INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', ?)", (str(SCHEMA_VERSION),), ) +def ensure_column(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None: + columns = {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})")} + if column not in columns: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}") + + def database_meta(db_path: Path | None = None) -> dict: path = db_path or SQLITE_DB_PATH if not path.exists(): diff --git a/app/app/domain.py b/app/app/domain.py index 71667d1..2979421 100644 --- a/app/app/domain.py +++ b/app/app/domain.py @@ -49,6 +49,7 @@ class Account: payments: list[Payment] remaining: float account_status: str + primary_entry_year: int | None note: str diff --git a/app/app/repository.py b/app/app/repository.py index 20d5db4..812d8b5 100644 --- a/app/app/repository.py +++ b/app/app/repository.py @@ -39,8 +39,8 @@ ACCOUNTS_HEADER = """# 学生课时账户 - 本表由 SQLite 运行时缓存生成,正式事实源为 `/data/xsk_education.db`。 - `剩余课时` 由缴费/退费流水与上课记录实时重算。 -| 学生ID | 学生姓名 | 缴费记录 | 剩余课时 | 账户状态 | 备注 | -|---|---|---|---|---|---| +| 学生ID | 学生姓名 | 入学年份 | 缴费记录 | 剩余课时 | 账户状态 | 备注 | +|---|---|---|---|---|---|---| """ TEACHERS_HEADER = """# 教师档案 @@ -172,10 +172,18 @@ def _insert_sources( for index, account in enumerate(accounts): conn.execute( """ - INSERT INTO students(student_id, student, account_status, note, source_remaining, sort_order) - VALUES(?, ?, ?, ?, ?, ?) + INSERT INTO students(student_id, student, account_status, primary_entry_year, note, source_remaining, sort_order) + VALUES(?, ?, ?, ?, ?, ?, ?) """, - (account.student_id, account.student, account.account_status, account.note, account.remaining, index), + ( + account.student_id, + account.student, + account.account_status, + account.primary_entry_year, + account.note, + account.remaining, + index, + ), ) for payment_index, payment in enumerate(account.payments): conn.execute( @@ -202,7 +210,7 @@ def _insert_sources( seen_record_keys.add(key) account = account_by_student.get(record.student) if account is None: - raise ValueError(f"上课学生没有课时账户: {record.student}") + raise ValueError(f"上课学生没有学生档案: {record.student}") minutes = int(round(record.duration_hours * 60)) used_hours_by_student[record.student] = round(used_hours_by_student[record.student] + record.duration_hours, 2) conn.execute( @@ -457,7 +465,7 @@ def _accounts_from_db(conn: sqlite3.Connection) -> list[Account]: used_map = _used_hours_by_student(conn) accounts: list[Account] = [] rows = conn.execute( - "SELECT student_id, student, account_status, note FROM students ORDER BY sort_order, student_id" + "SELECT student_id, student, account_status, primary_entry_year, note FROM students ORDER BY sort_order, student_id" ).fetchall() for row in rows: student_id = str(row["student_id"]) @@ -472,6 +480,7 @@ def _accounts_from_db(conn: sqlite3.Connection) -> list[Account]: payments=payments, remaining=remaining, account_status=status, + primary_entry_year=row["primary_entry_year"], note=str(row["note"] or ""), ) ) @@ -537,8 +546,9 @@ def _write_accounts(path: Path, accounts: list[Account]) -> None: lines = [ACCOUNTS_HEADER.rstrip("\n")] for account in accounts: payments = ",".join(_format_payment(payment) for payment in account.payments) + primary_entry_year = str(account.primary_entry_year or "") lines.append( - f"| {account.student_id} | {account.student} | {payments} | " + f"| {account.student_id} | {account.student} | {primary_entry_year} | {payments} | " f"{_format_number(account.remaining)} | {account.account_status} | {account.note} |" ) atomic_write_text(path, "\n".join(lines).rstrip() + "\n") diff --git a/app/app/routers/accounts.py b/app/app/routers/accounts.py index 66fcf9a..2ebce42 100644 --- a/app/app/routers/accounts.py +++ b/app/app/routers/accounts.py @@ -120,8 +120,8 @@ async def register_course_summaries(request: Request, _user: str = Depends(verif return {"ok": True, **result} -@router.get("/api/account-health") -def account_health(_user: str = Depends(verify_accounts_auth)): +@router.get("/api/student-health") +def student_health(_user: str = Depends(verify_accounts_auth)): accounts = load_accounts() teachers = load_teachers() records = read_classnotes(CLASSNOTES_PATH) if CLASSNOTES_PATH.exists() else [] @@ -133,7 +133,7 @@ def account_health(_user: str = Depends(verify_accounts_auth)): course_summaries_count = sum(1 for _item in iter_course_summary_markdown(COURSE_SUMMARIES_ROOT)) return { "ok": True, - "accounts": file_meta(ACCOUNTS_PATH), + "students": file_meta(ACCOUNTS_PATH), "teachers": file_meta(TEACHERS_PATH), "classnotes": file_meta(CLASSNOTES_PATH), "course_summaries": file_meta(COURSE_SUMMARIES_ROOT), @@ -142,21 +142,21 @@ def account_health(_user: str = Depends(verify_accounts_auth)): "source_mode": "sqlite" if USE_SQLITE_SOURCE else "text", "text_root": str(LEGACY_TEXT_ROOT), }, - "accounts_count": len(accounts), + "students_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), + "student_summary": account_summary(accounts), } -@router.get("/api/accounts") -def accounts( +@router.get("/api/students") +def students( q: str = Query("", description="学生姓名或学生ID"), - status_filter: str = Query("", alias="status", description="账户状态"), + status_filter: str = Query("", alias="status", description="学生档案状态"), _user: str = Depends(verify_accounts_auth), ): all_accounts = load_accounts() @@ -164,16 +164,16 @@ def accounts( return { "summary": account_summary(all_accounts), "count": len(rows), - "accounts": [account_to_dict(account) for account in rows], + "students": [account_to_dict(account) for account in rows], } -@router.get("/api/accounts/{student}") -def account_detail(student: str, _user: str = Depends(verify_accounts_auth)): +@router.get("/api/students/{student}") +def student_detail(student: str, _user: str = Depends(verify_accounts_auth)): for account in load_accounts(): if account.student == student or account.student_id == student: return account_to_dict(account) - raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}") + raise HTTPException(status_code=404, detail=f"未找到学生档案: {student}") @router.get("/api/admin/statuses") @@ -235,12 +235,12 @@ def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str = return {"ok": True, **result} -@router.post("/api/admin/accounts") -def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_admin_auth)): +@router.post("/api/admin/students") +def admin_create_student(payload: AccountPayload, _user: str = Depends(verify_admin_auth)): try: with write_lock: - result = create_account(ACCOUNTS_PATH, payload_to_account(payload)) - log_operation = str(result.get("operation") or "新增课时账户") + result = create_account(ACCOUNTS_PATH, payload_to_account(payload), CLASSNOTES_PATH) + log_operation = str(result.get("operation") or "新增学生档案") append_operation_log( OPERATION_LOGS_PATH, log_operation, @@ -254,12 +254,12 @@ def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_ad return {"ok": True, **result} -@router.put("/api/admin/accounts/{student_id}") -def admin_update_account(student_id: str, payload: AccountPayload, _user: str = Depends(verify_admin_auth)): +@router.put("/api/admin/students/{student_id}") +def admin_update_student(student_id: str, payload: AccountPayload, _user: str = Depends(verify_admin_auth)): try: with write_lock: - result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload)) - log_operation = str(result.get("operation") or "修改课时账户") + result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload), CLASSNOTES_PATH) + log_operation = str(result.get("operation") or "修改学生档案") append_operation_log( OPERATION_LOGS_PATH, log_operation, diff --git a/app/app/routers/pages.py b/app/app/routers/pages.py index a0bfc4b..d17fe94 100644 --- a/app/app/routers/pages.py +++ b/app/app/routers/pages.py @@ -277,11 +277,6 @@ def logout(): return response -@router.get("/accounts") -def accounts_index(): - return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER) - - @router.get("/admin") def admin_index( request: Request, @@ -333,24 +328,6 @@ def admin_logout(): return response -@router.get("/accounts/login") -def accounts_login_page(): - return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER) - - -@router.post("/accounts/login") -async def accounts_login_submit(request: Request): - return await admin_login_submit(request) - - -@router.get("/accounts/logout") -def accounts_logout(): - response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER) - response.delete_cookie(ACCOUNTS_SESSION_COOKIE) - response.delete_cookie(ADMIN_SESSION_COOKIE) - return response - - @router.get("/static/{asset_path:path}") def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)): target = (STATIC_DIR / asset_path).resolve() diff --git a/app/app/routers/records.py b/app/app/routers/records.py index ee8c908..d264865 100644 --- a/app/app/routers/records.py +++ b/app/app/routers/records.py @@ -4,7 +4,7 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query -from ..api_utils import load_accounts, load_records, load_teachers +from ..api_utils import load_records, load_teachers from ..auth import verify_records_auth from ..config import ( ADMIN_TASKS_PATH, @@ -14,7 +14,6 @@ from ..config import ( write_lock, ) from ..data import ( - account_to_dict, append_operation_log, course_summary_semantic_key, duration_minutes_from_time_range, @@ -53,14 +52,6 @@ def records( ) -@router.get("/api/student-account/{student}") -def record_student_account(student: str, _user: str = Depends(verify_records_auth)): - for account in load_accounts(): - if account.student == student or account.student_id == student: - return account_to_dict(account) - raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}") - - @router.post("/api/corrections") def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(verify_records_auth)): try: diff --git a/app/app/schemas.py b/app/app/schemas.py index f6118ff..b636f60 100644 --- a/app/app/schemas.py +++ b/app/app/schemas.py @@ -25,8 +25,8 @@ class AccountPayload(BaseModel): student_id: str = "" student: str payments: list[PaymentPayload] = Field(default_factory=list) - remaining: float = 0 account_status: str = "正常" + primary_entry_year: int | None = None note: str = "" diff --git a/app/app/static/accounts.html b/app/app/static/accounts.html deleted file mode 100644 index 41a268b..0000000 --- a/app/app/static/accounts.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - 课时账户查询 - - - -
-
-

课时账户查询

-

正在读取账户状态

-
-
- 课程记录 - -
-
- -
-
-
-

课时账户

- -
- -
- -
-
- - - - diff --git a/app/app/static/accounts.js b/app/app/static/accounts.js deleted file mode 100644 index 1b72f96..0000000 --- a/app/app/static/accounts.js +++ /dev/null @@ -1,123 +0,0 @@ -const accountHealthText = document.querySelector("#accountHealthText"); -const refreshBtn = document.querySelector("#refreshBtn"); -const accountForm = document.querySelector("#accountForm"); -const accountQuery = document.querySelector("#accountQuery"); -const accountStatus = document.querySelector("#accountStatus"); -const accountMeta = document.querySelector("#accountMeta"); -const accountRows = document.querySelector("#accountRows"); - -function fmtHours(value) { - const totalMinutes = Math.round(Number(value || 0) * 60); - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - return `${hours}小时${minutes}分`; -} - -function displayHours(value, fallback) { - return fallback || fmtHours(value); -} - -function fmtTime(seconds) { - if (!seconds) return "未知"; - return new Date(seconds * 1000).toLocaleString("zh-CN", { hour12: false }); -} - -function escapeHtml(value) { - return String(value ?? "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -function metric(label, value) { - return `
${escapeHtml(label)}${escapeHtml(value)}
`; -} - -function statusClass(status) { - if (status === "欠费") return "debt"; - if (status === "预警") return "warning"; - if (status === "正常") return "normal"; - return "closed"; -} - -function renderPayments(payments) { - if (!payments.length) return "暂无"; - return payments.map((item) => `${escapeHtml(item.date)}:${escapeHtml(displayHours(item.hours, item.duration))}`).join("
"); -} - -async function fetchJson(url) { - const response = await fetch(url, { cache: "no-store" }); - if (!response.ok) { - let detail = `${response.status} ${response.statusText}`; - try { - const payload = await response.json(); - detail = payload.detail || detail; - } catch (_error) { - detail = response.statusText || detail; - } - throw new Error(detail); - } - return response.json(); -} - -function renderSummary(summary, count) { - accountMeta.innerHTML = [ - metric("当前结果", `${count} 人`), - metric("欠费", summary.debt), - metric("预警", summary.warning), - metric("正常", summary.normal), - ].join(""); -} - -async function loadAccountHealth() { - try { - const data = await fetchJson("/api/account-health"); - accountHealthText.textContent = `账户 ${data.accounts_count} 人;数据更新时间 ${fmtTime(data.accounts.mtime)}`; - } catch (error) { - accountHealthText.textContent = `读取失败:${error.message}`; - } -} - -async function loadAccounts() { - accountRows.innerHTML = `正在读取`; - const params = new URLSearchParams(); - if (accountQuery.value.trim()) params.set("q", accountQuery.value.trim()); - if (accountStatus.value) params.set("status", accountStatus.value); - try { - const data = await fetchJson(`/api/accounts?${params.toString()}`); - renderSummary(data.summary, data.count); - accountRows.innerHTML = data.accounts - .map( - (row) => ` - ${escapeHtml(row.student)}
${escapeHtml(row.student_id)} - ${escapeHtml(row.account_status)} - ${escapeHtml(displayHours(row.remaining, row.remaining_duration))} - ${renderPayments(row.payments)} - ${escapeHtml(row.note || "")} - `, - ) - .join(""); - if (!data.accounts.length) { - accountRows.innerHTML = `没有符合条件的账户`; - } - } catch (error) { - accountRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; - } -} - -accountForm.addEventListener("submit", (event) => { - event.preventDefault(); - loadAccounts(); -}); - -accountStatus.addEventListener("change", loadAccounts); - -refreshBtn.addEventListener("click", () => { - loadAccountHealth(); - loadAccounts(); -}); - -loadAccountHealth(); -loadAccounts(); diff --git a/app/app/static/admin.html b/app/app/static/admin.html index 91f084d..6ad2cf3 100644 --- a/app/app/static/admin.html +++ b/app/app/static/admin.html @@ -22,7 +22,7 @@