diff --git a/.env.example b/.env.example index 27b6947..92931ae 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,8 @@ PYTHON_IMAGE=python:3.12-slim BASIC_AUTH_USERNAME=wolfydw BASIC_AUTH_PASSWORD=change-me +ADMIN_AUTH_PASSWORD=change-me CLASSNOTES_PATH=/data/classnotes.txt ACCOUNTS_PATH=/data/学生课时账户.md +ADMIN_TASKS_PATH=/data/admin_tasks.json diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 9ab923f..81769db 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -39,12 +39,36 @@ - `make check`:检查前端 JS 语法和后端 Python 编译。 - `make smoke`:运行轻量前端行为烟测,覆盖排序按钮、组内排序和纠错后排序。 -- `make data-hash`:输出 `classnotes.txt` 和 `学生课时账户.md` 的 SHA-256。 +- `make data-hash`:输出 `classnotes.txt`、`学生课时账户.md`,以及存在时的 `admin_tasks.json` 的 SHA-256。 - `make build`:构建 Docker 镜像。 - `make up`:重启 Docker Compose 服务。 - `make health`:带认证访问 `/api/health`。 - `make deploy`:按推荐顺序执行检查、构建、重启、健康检查和哈希复核。 - `make logs`:查看最近服务日志。 +- `make install-gitea-backup`:安装提交后自动推送到 Gitea 的 Git hook。 + +## Gitea 自动备份 + +新时空教务管家推荐把 Gitea SSH 仓库配置为 `origin`,并用 `post-commit` hook 在每次提交后自动推送当前分支。 + +首次配置: + +```bash +git remote add origin <你的 Gitea SSH 仓库地址> +make install-gitea-backup +``` + +安装后,每次 `git commit` 成功都会执行: + +```bash +git push origin HEAD:<当前分支> +``` + +注意: + +- 自动备份只推送已提交内容,不会自动提交工作区中的未提交改动。 +- 如果 Gitea 暂时不可用或 SSH key 权限异常,本地 commit 仍会保留,hook 只打印错误提示。 +- 如果当前仓库已有 `.git/hooks/post-commit`,安装脚本会拒绝覆盖;确需覆盖时执行 `python3 scripts/install_gitea_backup_hook.py --force`。 ## 数据保护 @@ -53,9 +77,10 @@ ```text /root/新时空数据/data/classnotes.txt /root/新时空数据/data/学生课时账户.md +/root/新时空数据/data/admin_tasks.json ``` -普通前端和查询类改动不应该改变这两个文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API 或数据写入功能,先确认自动备份目录: +普通前端和查询类改动不应该改变这些文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、课时账户编辑或纠错审核批准,先确认自动备份目录: ```text /root/新时空数据/data/backups/ diff --git a/Makefile b/Makefile index 76035a0..6a58212 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,13 @@ SHELL := /bin/bash APP_PORT ?= 18080 -DATA_FILES := ../data/classnotes.txt ../data/学生课时账户.md +DATA_FILES := ../data/classnotes.txt ../data/学生课时账户.md $(wildcard ../data/admin_tasks.json) .PHONY: check smoke data-hash build up ps health deploy logs install-gitea-backup check: node --check app/static/app.js + node --check app/static/admin.js python3 -m py_compile app/main.py app/data.py smoke: diff --git a/README.md b/README.md index 86c6c11..c9ecc61 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - `scripts/deploy_to_vps.py`:部署网页服务到 VPS。 - `scripts/sync_to_vps.py`:同步正式数据文件到 VPS。 - `scripts/smoke_test.js`:轻量前端行为烟测。 +- `scripts/install_gitea_backup_hook.py`:安装提交后自动推送到 Gitea 的 Git hook。 - `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。 - `launchd/com.xsk.records.sync.plist.template`:LaunchAgent 模板。 @@ -121,6 +122,8 @@ cd /root/新时空数据/app docker compose up -d ``` +管理后台使用 `ADMIN_AUTH_PASSWORD`。为兼容旧部署,如果未配置 `ADMIN_AUTH_PASSWORD`,系统会继续使用原 `ACCOUNTS_AUTH_PASSWORD` 或 `ACCOUNT_AUTH_PASSWORD`。 + ## API - `GET /api/health`:数据状态。 @@ -129,3 +132,9 @@ docker compose up -d - `GET /api/accounts?q=王鑫鹏`:按学生筛选账户。 - `GET /api/accounts?status=欠费`:按账户状态筛选。 - `GET /api/accounts/王鑫鹏`:单个学生账户。 +- `POST /api/admin/accounts`:管理后台新增课时账户。 +- `PUT /api/admin/accounts/{student_id}`:管理后台修改课时账户。 +- `POST /api/corrections`:课程记录页提交纠错审核。 +- `GET /api/admin/tasks`:管理后台查看审核任务。 +- `POST /api/admin/tasks/{task_id}/approve`:批准纠错并写入正式上课记录。 +- `POST /api/admin/tasks/{task_id}/reject`:驳回纠错。 diff --git a/app/data.py b/app/data.py index 3ccee1a..04b035c 100644 --- a/app/data.py +++ b/app/data.py @@ -44,6 +44,7 @@ PAYMENT_LINE_RE = re.compile(r"^(?P.+?)-(?P\d{4}-\d{2}-\d{2}):(?P TIME_RANGE_RE = re.compile(r"^(?P\d{1,2}):(?P\d{2})-(?P\d{1,2}):(?P\d{2})$") BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-") BACKUP_KEEP_COUNT = 50 +ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"} WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] ROLE_WORDS = { "student": ("学生", "学员", "孩子", "同学"), @@ -156,6 +157,33 @@ def parse_payments(text: str) -> list[Payment]: return payments +def validate_payment(payment: Payment) -> Payment: + try: + datetime.strptime(payment.date, "%Y-%m-%d") + except ValueError as exc: + raise ValueError(f"缴费日期不存在: {payment.date}") from exc + return Payment(date=payment.date, hours=round(float(payment.hours), 2)) + + +def validate_account(account: Account) -> Account: + student_id = account.student_id.strip() + student = canonical_name(account.student) + 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("账户状态必须是 正常、预警、欠费、结课、退费") + 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, + note=account.note.strip(), + ) + + def read_classnotes(path: Path) -> list[ClassRecord]: records: list[ClassRecord] = [] with path.open("r", encoding="utf-8") as handle: @@ -334,6 +362,46 @@ def replace_account_lines(original_text: str, accounts_by_id: dict[str, Account] return "\n".join(output) + trailing_newline +def append_account_line(original_text: str, account: Account) -> str: + lines = original_text.splitlines() + 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: + insert_at = index + 1 + lines.insert(insert_at, format_account_row(account)) + trailing_newline = "\n" if original_text.endswith("\n") else "" + return "\n".join(lines) + trailing_newline + + +def replace_single_account_line(original_text: str, old_student_id: str, account: Account) -> str: + lines = original_text.splitlines() + replaced = False + output: list[str] = [] + for line in lines: + stripped = line.strip() + if stripped.startswith("|") and not stripped.startswith("|---") 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)) + replaced = True + continue + output.append(line) + if not replaced: + raise ValueError(f"未找到学生账户: {old_student_id}") + trailing_newline = "\n" if original_text.endswith("\n") else "" + return "\n".join(output) + trailing_newline + + +def next_student_id(accounts: list[Account]) -> str: + values = [] + for account in accounts: + match = re.fullmatch(r"XS(\d{3})", account.student_id) + if match: + values.append(int(match.group(1))) + return f"XS{(max(values) if values else 0) + 1:03d}" + + def atomic_write_text(path: Path, text: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) original_stat = path.stat() if path.exists() else None @@ -457,6 +525,39 @@ def write_accounts(path: Path, accounts: list[Account]) -> None: atomic_write_text(path, replace_account_lines(original_text, accounts_by_id)) +def create_account(path: Path, account: Account) -> dict: + accounts = read_accounts(path) + account = validate_account(replace(account, student_id=next_student_id(accounts))) + 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") + backup_dir = create_data_backup("admin-create-account", {path: original_accounts}, [format_account_row(account)]) + atomic_write_text(path, append_account_line(original_accounts, account)) + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return {"account": account_to_dict(account), "backup_id": backup_dir.name} + + +def update_account(path: Path, old_student_id: str, account: Account) -> dict: + old_student_id = old_student_id.strip() + accounts = read_accounts(path) + account = validate_account(account) + if not any(item.student_id == old_student_id for item in accounts): + 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") + backup_dir = create_data_backup("admin-update-account", {path: original_accounts}, [old_student_id, format_account_row(account)]) + atomic_write_text(path, replace_single_account_line(original_accounts, old_student_id, account)) + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return {"account": account_to_dict(account), "backup_id": backup_dir.name} + + def render_accounts_text(path: Path, accounts: list[Account]) -> str: original_text = path.read_text(encoding="utf-8") accounts_by_id = {account.student_id: account for account in accounts} @@ -575,6 +676,177 @@ def register_payment_lines( return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name} +def default_admin_tasks() -> dict: + return {"version": 1, "next_id": 1, "items": []} + + +def read_admin_tasks(path: Path) -> dict: + if not path.exists(): + return default_admin_tasks() + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"管理任务文件 JSON 格式错误: {path}") from exc + if not isinstance(payload, dict): + raise ValueError("管理任务文件必须是 JSON 对象") + payload.setdefault("version", 1) + payload.setdefault("next_id", 1) + payload.setdefault("items", []) + if not isinstance(payload["items"], list): + raise ValueError("管理任务 items 必须是数组") + return payload + + +def write_admin_tasks(path: Path, tasks: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_text(path, json.dumps(tasks, ensure_ascii=False, indent=2) + "\n") + + +def task_to_dict(task: dict) -> dict: + return dict(task) + + +def find_admin_task(tasks: dict, task_id: int) -> dict: + for task in tasks["items"]: + if int(task.get("id", 0)) == task_id: + return task + raise ValueError(f"未找到管理任务: {task_id}") + + +def submit_correction_tasks(tasks_path: Path, items: list[dict]) -> dict: + if not items: + raise ValueError("提交审核的纠错记录不能为空") + tasks = read_admin_tasks(tasks_path) + now = datetime.now().isoformat(timespec="seconds") + created: list[dict] = [] + for item in items: + original_line = str(item.get("original_line", "")).strip() + corrected_line = str(item.get("corrected_line", "")).strip() + if not original_line or not corrected_line: + raise ValueError("纠错审核记录缺少原记录或修改后记录") + original = parse_class_record_line(original_line) + corrected = parse_class_record_line(corrected_line) + if original_line == corrected_line: + raise ValueError("原记录和修改后记录相同,无需提交审核") + task = { + "id": int(tasks["next_id"]), + "type": "class_record_correction", + "status": "pending", + "created_at": now, + "updated_at": now, + "original_line": class_record_to_line(original), + "corrected_line": class_record_to_line(corrected), + "original": record_to_dict(original), + "corrected": record_to_dict(corrected), + } + tasks["next_id"] = int(tasks["next_id"]) + 1 + tasks["items"].append(task) + created.append(task) + write_admin_tasks(tasks_path, tasks) + return {"submitted": len(created), "items": [task_to_dict(task) for task in created]} + + +def list_admin_tasks(tasks_path: Path, status_filter: str = "", task_type: str = "") -> 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] + 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)], + } + + +def replace_class_record_line(original_text: str, original_line: str, corrected_line: str) -> str: + lines = original_text.splitlines() + matched = [index for index, line in enumerate(lines) if line.strip() == original_line] + if not matched: + raise ValueError("原上课记录在正式文件中不存在,可能已被修改") + if original_line != corrected_line and any(line.strip() == corrected_line for line in lines): + raise ValueError("修改后的上课记录已存在,不能重复写入") + lines[matched[0]] = corrected_line + trailing_newline = "\n" if original_text.endswith("\n") else "" + return "\n".join(lines) + trailing_newline + + +def mark_admin_task(tasks_path: Path, task_id: int, status: str, message: str = "") -> dict: + tasks = read_admin_tasks(tasks_path) + task = find_admin_task(tasks, task_id) + if task.get("status") not in {"pending", "conflict"}: + raise ValueError("该任务已处理,不能重复操作") + now = datetime.now().isoformat(timespec="seconds") + task["status"] = status + task["updated_at"] = now + task["reviewed_at"] = now + if message: + task["message"] = message + write_admin_tasks(tasks_path, tasks) + return task_to_dict(task) + + +def reject_admin_task(tasks_path: Path, task_id: int) -> dict: + return mark_admin_task(tasks_path, task_id, "rejected") + + +def approve_correction_task(tasks_path: Path, classnotes_path: Path, task_id: int) -> dict: + tasks = read_admin_tasks(tasks_path) + task = find_admin_task(tasks, task_id) + if task.get("type") != "class_record_correction": + raise ValueError("该任务不是上课记录纠错") + if task.get("status") not in {"pending", "conflict"}: + raise ValueError("该任务已处理,不能重复批准") + + original_line = str(task.get("original_line", "")).strip() + corrected_line = str(task.get("corrected_line", "")).strip() + parse_class_record_line(original_line) + corrected = parse_class_record_line(corrected_line) + corrected_line = class_record_to_line(corrected) + + original_classnotes = classnotes_path.read_text(encoding="utf-8") + try: + new_classnotes = replace_class_record_line(original_classnotes, original_line, corrected_line) + except ValueError as exc: + task["status"] = "conflict" + task["updated_at"] = datetime.now().isoformat(timespec="seconds") + task["message"] = str(exc) + write_admin_tasks(tasks_path, tasks) + raise + + original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n" + now = datetime.now().isoformat(timespec="seconds") + task["status"] = "approved" + task["updated_at"] = now + task["reviewed_at"] = now + task["corrected_line"] = corrected_line + task["corrected"] = record_to_dict(corrected) + new_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n" + + backup_dir = create_data_backup( + "admin-approve-correction", + { + classnotes_path: original_classnotes, + tasks_path: original_tasks, + }, + [original_line, corrected_line], + ) + try: + atomic_write_text(classnotes_path, new_classnotes) + atomic_write_text(tasks_path, new_tasks) + except Exception: + atomic_write_text(classnotes_path, original_classnotes) + atomic_write_text(tasks_path, original_tasks) + raise + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return {"task": task_to_dict(task), "backup_id": backup_dir.name} + + def parse_record_date(text: str) -> date: return datetime.strptime(text, "%Y.%m.%d").date() diff --git a/app/main.py b/app/main.py index 1884c3e..8281268 100644 --- a/app/main.py +++ b/app/main.py @@ -15,15 +15,24 @@ from fastapi.security import HTTPBasic, HTTPBasicCredentials from pydantic import BaseModel, Field, ValidationError from .data import ( + ACCOUNT_STATUSES, + Account, DuplicateRecordError, + Payment, + approve_correction_task, account_summary, account_to_dict, + create_account, filter_accounts, + list_admin_tasks, query_records, read_accounts, read_classnotes, register_class_record_lines, register_payment_lines, + reject_admin_task, + submit_correction_tasks, + update_account, ) @@ -31,10 +40,13 @@ APP_DIR = Path(__file__).resolve().parent STATIC_DIR = APP_DIR / "static" CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt")) ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md")) +ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json")) BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "") ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "") +ADMIN_AUTH_PASSWORD = os.getenv("ADMIN_AUTH_PASSWORD") or ACCOUNTS_AUTH_PASSWORD RECORDS_SESSION_COOKIE = "xsk_records_session" ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session" +ADMIN_SESSION_COOKIE = "xsk_admin_session" SESSION_MAX_AGE = 60 * 60 * 24 * 30 app = FastAPI(title="新时空课程记录查询", version="1.0.0") @@ -47,6 +59,29 @@ class RegisterLinesPayload(BaseModel): lines: list[str] | None = Field(default=None, description="多条原始登记文本") +class PaymentPayload(BaseModel): + date: str + hours: float + + +class AccountPayload(BaseModel): + student_id: str = "" + student: str + payments: list[PaymentPayload] = Field(default_factory=list) + remaining: float = 0 + account_status: str = "正常" + note: str = "" + + +class CorrectionItemPayload(BaseModel): + original_line: str + corrected_line: str + + +class CorrectionSubmitPayload(BaseModel): + items: list[CorrectionItemPayload] + + async def read_register_payload(request: Request) -> RegisterLinesPayload: body = await request.body() if not body.strip(): @@ -113,16 +148,28 @@ def is_records_authenticated( ) or has_valid_basic_auth(credentials, BASIC_AUTH_PASSWORD) -def is_accounts_authenticated( +def is_admin_authenticated( request: Request, credentials: HTTPBasicCredentials | None = None, ) -> bool: return has_valid_session( + request, + ADMIN_SESSION_COOKIE, + ADMIN_AUTH_PASSWORD, + b"xsk-admin-web-session-v1", + ) or has_valid_session( request, ACCOUNTS_SESSION_COOKIE, - ACCOUNTS_AUTH_PASSWORD, + ADMIN_AUTH_PASSWORD, b"xsk-accounts-web-session-v1", - ) or has_valid_basic_auth(credentials, ACCOUNTS_AUTH_PASSWORD) + ) or has_valid_basic_auth(credentials, ADMIN_AUTH_PASSWORD) + + +def is_accounts_authenticated( + request: Request, + credentials: HTTPBasicCredentials | None = None, +) -> bool: + return is_admin_authenticated(request, credentials) def verify_records_auth( @@ -138,24 +185,31 @@ def verify_records_auth( return "records" +def verify_admin_auth( + request: Request, + credentials: HTTPBasicCredentials | None = Depends(security), +) -> str: + configured_password("管理后台", ADMIN_AUTH_PASSWORD) + if not is_admin_authenticated(request, credentials): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="请先登录管理后台", + ) + return "admin" + + def verify_accounts_auth( request: Request, credentials: HTTPBasicCredentials | None = Depends(security), ) -> str: - configured_password("课时账户", ACCOUNTS_AUTH_PASSWORD) - if not is_accounts_authenticated(request, credentials): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="请先登录课时账户", - ) - return "accounts" + return verify_admin_auth(request, credentials) def verify_any_auth( request: Request, credentials: HTTPBasicCredentials | None = Depends(security), ) -> str: - if is_records_authenticated(request, credentials) or is_accounts_authenticated(request, credentials): + if is_records_authenticated(request, credentials) or is_admin_authenticated(request, credentials): return "authenticated" raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -376,6 +430,17 @@ def file_meta(path: Path) -> dict: } +def payload_to_account(payload: AccountPayload, student_id: str | None = None) -> Account: + return Account( + 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, + account_status=payload.account_status, + note=payload.note, + ) + + @app.exception_handler(ValueError) async def value_error_handler(_request: Request, exc: ValueError): return JSONResponse(status_code=500, content={"detail": str(exc)}) @@ -450,58 +515,84 @@ def logout(): @app.get("/accounts") def accounts_index( +): + return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER) + + +@app.get("/admin") +def admin_index( request: Request, credentials: HTTPBasicCredentials | None = Depends(security), ): - if not is_accounts_authenticated(request, credentials): - return RedirectResponse(url="/accounts/login?next=/accounts", status_code=status.HTTP_303_SEE_OTHER) - return FileResponse(STATIC_DIR / "accounts.html") + if not is_admin_authenticated(request, credentials): + return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER) + return FileResponse(STATIC_DIR / "admin.html") -@app.get("/accounts/login") -def accounts_login_page( +@app.get("/admin/login") +def admin_login_page( request: Request, credentials: HTTPBasicCredentials | None = Depends(security), ): - next_path = safe_next_path(request.query_params.get("next") or "/accounts") + next_path = safe_next_path(request.query_params.get("next") or "/admin") has_error = request.query_params.get("error") == "1" - if is_accounts_authenticated(request, credentials): + if is_admin_authenticated(request, credentials): return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER) return HTMLResponse( render_login_page( - title="课时账户查询", - action="/accounts/login", + title="管理后台", + action="/admin/login", next_path=next_path, has_error=has_error, ) ) -@app.post("/accounts/login") -async def accounts_login_submit(request: Request): +@app.post("/admin/login") +async def admin_login_submit(request: Request): body = (await request.body()).decode("utf-8") form = parse_qs(body, keep_blank_values=True) password = form.get("password", [""])[0] - next_path = safe_next_path(form.get("next", ["/accounts"])[0]) - accounts_password = configured_password("课时账户", ACCOUNTS_AUTH_PASSWORD) - if hmac.compare_digest(password, accounts_password): + next_path = safe_next_path(form.get("next", ["/admin"])[0]) + admin_password = configured_password("管理后台", ADMIN_AUTH_PASSWORD) + if hmac.compare_digest(password, admin_password): response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER) response.set_cookie( - ACCOUNTS_SESSION_COOKIE, - session_token(accounts_password, b"xsk-accounts-web-session-v1"), + ADMIN_SESSION_COOKIE, + session_token(admin_password, b"xsk-admin-web-session-v1"), max_age=SESSION_MAX_AGE, httponly=True, samesite="lax", ) return response - error_url = f"/accounts/login?error=1&next={quote(next_path)}" + error_url = f"/admin/login?error=1&next={quote(next_path)}" return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER) +@app.get("/admin/logout") +def admin_logout(): + response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER) + response.delete_cookie(ADMIN_SESSION_COOKIE) + response.delete_cookie(ACCOUNTS_SESSION_COOKIE) + return response + + +@app.get("/accounts/login") +def accounts_login_page( +): + return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER) + + +@app.post("/accounts/login") +async def accounts_login_submit(request: Request): + return await admin_login_submit(request) + + @app.get("/accounts/logout") def accounts_logout(): - response = RedirectResponse(url="/accounts/login", status_code=status.HTTP_303_SEE_OTHER) + 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 @@ -515,7 +606,7 @@ def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)): @app.post("/api/register/class-records") -async def register_class_records(request: Request, _user: str = Depends(verify_accounts_auth)): +async def register_class_records(request: Request, _user: str = Depends(verify_admin_auth)): try: payload = await read_register_payload(request) with write_lock: @@ -533,7 +624,7 @@ async def register_class_records(request: Request, _user: str = Depends(verify_a @app.post("/api/register/payments") -async def register_payments(request: Request, _user: str = Depends(verify_accounts_auth)): +async def register_payments(request: Request, _user: str = Depends(verify_admin_auth)): try: payload = await read_register_payload(request) with write_lock: @@ -606,3 +697,72 @@ def account_detail(student: str, _user: str = Depends(verify_accounts_auth)): if account.student == student or account.student_id == student: return account_to_dict(account) raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}") + + +@app.get("/api/admin/statuses") +def admin_statuses(_user: str = Depends(verify_admin_auth)): + return {"account_statuses": sorted(ACCOUNT_STATUSES)} + + +@app.post("/api/admin/accounts") +def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + result = create_account(ACCOUNTS_PATH, payload_to_account(payload)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} + + +@app.put("/api/admin/accounts/{student_id}") +def admin_update_account(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)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} + + +@app.post("/api/corrections") +def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(verify_records_auth)): + try: + result = submit_correction_tasks( + ADMIN_TASKS_PATH, + [item.dict() for item in payload.items], + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} + + +@app.get("/api/admin/tasks") +def admin_tasks( + status_filter: str = Query("", alias="status"), + task_type: str = Query("", alias="type"), + _user: str = Depends(verify_admin_auth), +): + try: + return list_admin_tasks(ADMIN_TASKS_PATH, status_filter=status_filter, task_type=task_type) + except ValueError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@app.post("/api/admin/tasks/{task_id}/approve") +def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + result = approve_correction_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, task_id) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return {"ok": True, **result} + + +@app.post("/api/admin/tasks/{task_id}/reject") +def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + task = reject_admin_task(ADMIN_TASKS_PATH, task_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, "task": task} diff --git a/app/static/admin.html b/app/static/admin.html new file mode 100644 index 0000000..d29c6c3 --- /dev/null +++ b/app/static/admin.html @@ -0,0 +1,166 @@ + + + + + + 管理后台 + + + +
+
+

管理后台

+

正在读取管理状态

+
+
+ 课程记录 + 退出 + +
+
+ +
+ + +
+
+

课时账户

+
+ + +
+
+ +
+ + +
+ + + + +
+ + + + diff --git a/app/static/admin.js b/app/static/admin.js new file mode 100644 index 0000000..502cd8b --- /dev/null +++ b/app/static/admin.js @@ -0,0 +1,346 @@ +const adminHealthText = document.querySelector("#adminHealthText"); +const refreshBtn = document.querySelector("#refreshBtn"); +const panels = { + accounts: document.querySelector("#accountsPanel"), + reviews: document.querySelector("#reviewsPanel"), + register: document.querySelector("#registerPanel"), +}; +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"); +const accountEditor = document.querySelector("#accountEditor"); +const accountEditorTitle = document.querySelector("#accountEditorTitle"); +const accountEditForm = document.querySelector("#accountEditForm"); +const accountEditError = document.querySelector("#accountEditError"); +const cancelAccountEditBtn = document.querySelector("#cancelAccountEditBtn"); +const newAccountBtn = document.querySelector("#newAccountBtn"); +const editStudentId = document.querySelector("#editStudentId"); +const editStudent = document.querySelector("#editStudent"); +const editRemaining = document.querySelector("#editRemaining"); +const editStatus = document.querySelector("#editStatus"); +const editPayments = document.querySelector("#editPayments"); +const editNote = document.querySelector("#editNote"); +const reviewStatus = document.querySelector("#reviewStatus"); +const reviewMeta = document.querySelector("#reviewMeta"); +const reviewRows = document.querySelector("#reviewRows"); +const classRegisterForm = document.querySelector("#classRegisterForm"); +const classRegisterLines = document.querySelector("#classRegisterLines"); +const classRegisterStatus = document.querySelector("#classRegisterStatus"); +const paymentRegisterForm = document.querySelector("#paymentRegisterForm"); +const paymentRegisterLines = document.querySelector("#paymentRegisterLines"); +const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus"); + +let currentAccounts = []; +let editingAccountId = ""; + +function fmtHours(value) { + return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); +} + +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)}:${fmtHours(item.hours)} 小时`).join("
"); +} + +async function fetchJson(url, options = {}) { + const response = await fetch(url, { cache: "no-store", ...options }); + 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 setActiveTab(tabName) { + document.querySelectorAll("[data-admin-tab]").forEach((button) => { + button.classList.toggle("is-active", button.dataset.adminTab === tabName); + }); + Object.entries(panels).forEach(([name, panel]) => { + panel.hidden = name !== tabName; + }); + if (tabName === "accounts") loadAccounts(); + if (tabName === "reviews") loadReviews(); +} + +async function loadAdminHealth() { + try { + const data = await fetchJson("/api/account-health"); + adminHealthText.textContent = `账户 ${data.accounts_count} 人;数据更新时间 ${fmtTime(data.accounts.mtime)}`; + } catch (error) { + adminHealthText.textContent = `读取失败:${error.message}`; + } +} + +function renderAccountSummary(summary, count) { + accountMeta.innerHTML = [ + metric("当前结果", `${count} 人`), + metric("欠费", summary.debt), + metric("预警", summary.warning), + metric("正常", summary.normal), + ].join(""); +} + +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()}`); + currentAccounts = data.accounts; + renderAccountSummary(data.summary, data.count); + accountRows.innerHTML = data.accounts + .map( + (row) => ` + ${escapeHtml(row.student)}
${escapeHtml(row.student_id)} + ${escapeHtml(row.account_status)} + ${fmtHours(row.remaining)} + ${renderPayments(row.payments)} + ${escapeHtml(row.note || "")} + + `, + ) + .join(""); + if (!data.accounts.length) { + accountRows.innerHTML = `没有符合条件的账户`; + } + } catch (error) { + accountRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; + } +} + +function paymentsToText(payments) { + return payments.map((item) => `${item.date}:${item.hours}`).join("\n"); +} + +function parsePaymentsInput(value) { + return value + .split(/[\n,]+/) + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => { + const [date, hoursText] = item.split(":"); + if (!date || hoursText === undefined) { + throw new Error(`缴费记录格式错误:${item}`); + } + const hours = Number(hoursText); + if (!Number.isFinite(hours)) { + throw new Error(`缴费课时格式错误:${item}`); + } + return { date: date.trim(), hours }; + }); +} + +function openNewAccountEditor() { + editingAccountId = ""; + accountEditorTitle.textContent = "新增账户"; + editStudentId.value = ""; + editStudentId.placeholder = "保存时自动生成"; + editStudent.value = ""; + editRemaining.value = "0"; + editStatus.value = "正常"; + editPayments.value = ""; + editNote.value = ""; + accountEditError.hidden = true; + accountEditor.hidden = false; + editStudent.focus(); +} + +function openEditAccountEditor(studentId) { + const account = currentAccounts.find((item) => item.student_id === studentId); + if (!account) return; + editingAccountId = account.student_id; + accountEditorTitle.textContent = `编辑账户:${account.student}`; + editStudentId.value = account.student_id; + editStudentId.placeholder = ""; + editStudent.value = account.student; + editRemaining.value = account.remaining; + editStatus.value = account.account_status; + editPayments.value = paymentsToText(account.payments); + editNote.value = account.note || ""; + accountEditError.hidden = true; + accountEditor.hidden = false; + editStudent.focus(); +} + +function buildAccountPayload() { + const remaining = Number(editRemaining.value); + if (!Number.isFinite(remaining)) { + throw new Error("剩余课时必须是数字"); + } + return { + student_id: editStudentId.value.trim(), + student: editStudent.value.trim(), + payments: parsePaymentsInput(editPayments.value), + remaining, + account_status: editStatus.value, + note: editNote.value.trim(), + }; +} + +async function saveAccount(event) { + event.preventDefault(); + try { + const payload = buildAccountPayload(); + const url = editingAccountId + ? `/api/admin/accounts/${encodeURIComponent(editingAccountId)}` + : "/api/admin/accounts"; + const method = editingAccountId ? "PUT" : "POST"; + await fetchJson(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + accountEditor.hidden = true; + await loadAdminHealth(); + await loadAccounts(); + } catch (error) { + accountEditError.textContent = error.message; + accountEditError.hidden = false; + } +} + +function renderReviewLine(line) { + return `${escapeHtml(line)}`; +} + +async function loadReviews() { + reviewRows.innerHTML = `正在读取`; + const params = new URLSearchParams({ type: "class_record_correction" }); + if (reviewStatus.value) params.set("status", reviewStatus.value); + try { + const data = await fetchJson(`/api/admin/tasks?${params.toString()}`); + reviewMeta.innerHTML = [metric("当前结果", `${data.count} 条`)].join(""); + reviewRows.innerHTML = data.items + .map((item) => { + const canReview = item.status === "pending" || item.status === "conflict"; + return ` + #${escapeHtml(item.id)} + ${escapeHtml(item.status)}${item.message ? `
${escapeHtml(item.message)}` : ""} + ${renderReviewLine(item.original_line)} + ${renderReviewLine(item.corrected_line)} + ${escapeHtml(item.created_at || "")} + +
+ + +
+ + `; + }) + .join(""); + if (!data.items.length) { + reviewRows.innerHTML = `没有符合条件的审核项`; + } + } catch (error) { + reviewRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; + } +} + +async function reviewTask(taskId, action) { + try { + await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/${action}`, { method: "POST" }); + } catch (error) { + alert(error.message); + } + loadReviews(); +} + +function linesFromTextarea(textarea) { + return textarea.value + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +async function submitRegister(event, textarea, statusNode, url) { + event.preventDefault(); + statusNode.textContent = "正在提交"; + try { + const data = await fetchJson(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lines: linesFromTextarea(textarea) }), + }); + statusNode.textContent = `已登记 ${data.registered} 条;备份 ${data.backup_id}`; + textarea.value = ""; + await loadAdminHealth(); + await loadAccounts(); + } catch (error) { + statusNode.textContent = `提交失败:${error.message}`; + } +} + +document.querySelectorAll("[data-admin-tab]").forEach((button) => { + button.addEventListener("click", () => setActiveTab(button.dataset.adminTab)); +}); + +accountForm.addEventListener("submit", (event) => { + event.preventDefault(); + loadAccounts(); +}); +accountStatus.addEventListener("change", loadAccounts); +newAccountBtn.addEventListener("click", openNewAccountEditor); +cancelAccountEditBtn.addEventListener("click", () => { + accountEditor.hidden = true; +}); +accountEditForm.addEventListener("submit", saveAccount); +accountRows.addEventListener("click", (event) => { + const button = event.target.closest(".account-edit"); + if (!button) return; + openEditAccountEditor(button.dataset.studentId); +}); +reviewStatus.addEventListener("change", loadReviews); +reviewRows.addEventListener("click", (event) => { + const approve = event.target.closest(".review-approve"); + const reject = event.target.closest(".review-reject"); + if (approve) reviewTask(approve.dataset.taskId, "approve"); + if (reject) reviewTask(reject.dataset.taskId, "reject"); +}); +classRegisterForm.addEventListener("submit", (event) => { + submitRegister(event, classRegisterLines, classRegisterStatus, "/api/register/class-records"); +}); +paymentRegisterForm.addEventListener("submit", (event) => { + submitRegister(event, paymentRegisterLines, paymentRegisterStatus, "/api/register/payments"); +}); +refreshBtn.addEventListener("click", () => { + loadAdminHealth(); + if (!panels.accounts.hidden) loadAccounts(); + if (!panels.reviews.hidden) loadReviews(); +}); + +loadAdminHealth(); +loadAccounts(); diff --git a/app/static/app.js b/app/static/app.js index 2cc6104..20b6b86 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -10,6 +10,7 @@ const inlineAccount = document.querySelector("#inlineAccount"); const correctionToolbar = document.querySelector("#correctionToolbar"); const correctionStatus = document.querySelector("#correctionStatus"); const copyCorrectedBtn = document.querySelector("#copyCorrectedBtn"); +const submitCorrectionsBtn = document.querySelector("#submitCorrectionsBtn"); const correctionDialog = document.querySelector("#correctionDialog"); const correctionForm = document.querySelector("#correctionForm"); const correctionOriginal = document.querySelector("#correctionOriginal"); @@ -260,7 +261,9 @@ function updateCorrectionToolbar(message = "", isError = false) { const count = correctedRecords.size; correctionToolbar.hidden = count === 0; copyCorrectedBtn.disabled = count === 0; + submitCorrectionsBtn.disabled = count === 0; copyCorrectedBtn.textContent = `复制已修改记录 ${count} 条`; + submitCorrectionsBtn.textContent = `提交审核 ${count} 条`; correctionStatus.textContent = message || `已修改 ${count} 条记录`; correctionStatus.classList.toggle("is-error", isError); } @@ -435,8 +438,36 @@ async function copyCorrectedRecords() { } } -async function fetchJson(url) { - const response = await fetch(url, { cache: "no-store" }); +async function submitCorrectedRecords() { + const items = currentRecordOrder + .map((key) => { + const original = findCurrentRecord(key); + const corrected = correctedRecords.get(key); + if (!original || !corrected) return null; + return { + original_line: buildRecordLine(original), + corrected_line: buildRecordLine(corrected), + }; + }) + .filter(Boolean); + if (!items.length) return; + submitCorrectionsBtn.disabled = true; + try { + const data = await fetchJson("/api/corrections", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items }), + }); + updateCorrectionToolbar(`已提交 ${data.submitted} 条纠错审核`); + } catch (error) { + updateCorrectionToolbar(`提交失败:${error.message}`, true); + } finally { + submitCorrectionsBtn.disabled = correctedRecords.size === 0; + } +} + +async function fetchJson(url, options = {}) { + const response = await fetch(url, { cache: "no-store", ...options }); if (!response.ok) { let detail = `${response.status} ${response.statusText}`; try { @@ -550,6 +581,7 @@ document.addEventListener("keydown", (event) => { }); copyCorrectedBtn.addEventListener("click", copyCorrectedRecords); +submitCorrectionsBtn.addEventListener("click", submitCorrectedRecords); updateRecordSortButtons(); loadHealth(); diff --git a/app/static/index.html b/app/static/index.html index 5d8bf24..982c579 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -13,7 +13,7 @@

正在读取数据状态

- 课时账户 + 管理后台 @@ -43,7 +43,10 @@
@@ -116,6 +119,6 @@
- + diff --git a/app/static/styles.css b/app/static/styles.css index 4088cac..5a2b073 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -125,6 +125,10 @@ h2 { grid-template-columns: minmax(0, 1fr); } +.admin-layout { + gap: 12px; +} + .panel { min-width: 0; border: 1px solid var(--line); @@ -150,6 +154,30 @@ h2 { justify-content: flex-end; } +.admin-tabs { + display: flex; + gap: 8px; + padding: 0 0 4px; +} + +.admin-tab { + min-height: 38px; + padding: 0 14px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--text); + cursor: pointer; + font-weight: 700; +} + +.admin-tab.is-active, +.admin-tab:hover { + border-color: var(--accent); + background: #e9f5f3; + color: var(--accent-strong); +} + .chip, .search-row button { border: 0; @@ -280,6 +308,77 @@ select:focus { font-size: 14px; } +.admin-editor { + border-bottom: 1px solid var(--line); + background: #fbfcfd; +} + +.compact-head { + padding: 12px 16px; +} + +.admin-form { + padding: 16px; +} + +.admin-form h3 { + margin: 0 0 10px; + font-size: 16px; + line-height: 1.25; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.form-grid label { + display: grid; + gap: 6px; + color: #344054; + font-size: 13px; + font-weight: 700; +} + +.span-2 { + grid-column: span 2; +} + +textarea { + width: 100%; + min-width: 0; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + color: var(--text); + font: inherit; + resize: vertical; + outline: none; +} + +textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15); +} + +.register-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.line-code { + display: block; + max-width: 420px; + white-space: normal; + overflow-wrap: anywhere; + font-family: Arial, "Songti SC", SimSun, sans-serif; + font-size: 13px; + line-height: 1.4; +} + .correction-toolbar { display: flex; align-items: center; @@ -300,6 +399,12 @@ select:focus { color: var(--danger); } +.toolbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + .correction-toolbar button, .modal-actions button { min-height: 38px; @@ -638,6 +743,10 @@ td { .inline-account-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .register-grid { + grid-template-columns: 1fr; + } } @media (max-width: 640px) { @@ -679,6 +788,11 @@ td { flex-direction: column; } + .toolbar-actions { + width: 100%; + flex-direction: column; + } + .correction-toolbar button { width: 100%; } @@ -687,6 +801,18 @@ td { grid-template-columns: 1fr; } + .admin-tabs { + overflow-x: auto; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .span-2 { + grid-column: auto; + } + .modal-actions { flex-direction: column-reverse; } diff --git a/docker-compose.yml b/docker-compose.yml index 2223c76..997261e 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} + ADMIN_TASKS_PATH: ${ADMIN_TASKS_PATH:-/data/admin_tasks.json} ports: - "${APP_PORT:-18080}:8000" volumes: diff --git a/scripts/smoke_test.js b/scripts/smoke_test.js index f774cd9..4ed60ff 100644 --- a/scripts/smoke_test.js +++ b/scripts/smoke_test.js @@ -99,5 +99,7 @@ renderGroupedRecords(currentRecords); context, ); assertEqual("纠错后排序仍使用显示记录", value("currentRecordOrder.join('')"), "abcd"); +vm.runInContext("updateCorrectionToolbar('测试提交按钮')", context); +assertEqual("提交审核按钮文案", value("submitCorrectionsBtn.textContent"), "提交审核 1 条"); console.log("smoke test passed");