feat: add admin console and review workflow

This commit is contained in:
Codex
2026-06-14 21:13:23 +08:00
parent 2591549734
commit 6417d2baa9
13 changed files with 1184 additions and 39 deletions
+2
View File
@@ -4,6 +4,8 @@ PYTHON_IMAGE=python:3.12-slim
BASIC_AUTH_USERNAME=wolfydw BASIC_AUTH_USERNAME=wolfydw
BASIC_AUTH_PASSWORD=change-me BASIC_AUTH_PASSWORD=change-me
ADMIN_AUTH_PASSWORD=change-me
CLASSNOTES_PATH=/data/classnotes.txt CLASSNOTES_PATH=/data/classnotes.txt
ACCOUNTS_PATH=/data/学生课时账户.md ACCOUNTS_PATH=/data/学生课时账户.md
ADMIN_TASKS_PATH=/data/admin_tasks.json
+27 -2
View File
@@ -39,12 +39,36 @@
- `make check`:检查前端 JS 语法和后端 Python 编译。 - `make check`:检查前端 JS 语法和后端 Python 编译。
- `make smoke`:运行轻量前端行为烟测,覆盖排序按钮、组内排序和纠错后排序。 - `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 build`:构建 Docker 镜像。
- `make up`:重启 Docker Compose 服务。 - `make up`:重启 Docker Compose 服务。
- `make health`:带认证访问 `/api/health`。 - `make health`:带认证访问 `/api/health`。
- `make deploy`:按推荐顺序执行检查、构建、重启、健康检查和哈希复核。 - `make deploy`:按推荐顺序执行检查、构建、重启、健康检查和哈希复核。
- `make logs`:查看最近服务日志。 - `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 ```text
/root/新时空数据/data/classnotes.txt /root/新时空数据/data/classnotes.txt
/root/新时空数据/data/学生课时账户.md /root/新时空数据/data/学生课时账户.md
/root/新时空数据/data/admin_tasks.json
``` ```
普通前端和查询类改动不应该改变这两个文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API 或数据写入功能,先确认自动备份目录: 普通前端和查询类改动不应该改变这文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、课时账户编辑或纠错审核批准,先确认自动备份目录:
```text ```text
/root/新时空数据/data/backups/ /root/新时空数据/data/backups/
+2 -1
View File
@@ -1,12 +1,13 @@
SHELL := /bin/bash SHELL := /bin/bash
APP_PORT ?= 18080 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 .PHONY: check smoke data-hash build up ps health deploy logs install-gitea-backup
check: check:
node --check app/static/app.js node --check app/static/app.js
node --check app/static/admin.js
python3 -m py_compile app/main.py app/data.py python3 -m py_compile app/main.py app/data.py
smoke: smoke:
+9
View File
@@ -10,6 +10,7 @@
- `scripts/deploy_to_vps.py`:部署网页服务到 VPS。 - `scripts/deploy_to_vps.py`:部署网页服务到 VPS。
- `scripts/sync_to_vps.py`:同步正式数据文件到 VPS。 - `scripts/sync_to_vps.py`:同步正式数据文件到 VPS。
- `scripts/smoke_test.js`:轻量前端行为烟测。 - `scripts/smoke_test.js`:轻量前端行为烟测。
- `scripts/install_gitea_backup_hook.py`:安装提交后自动推送到 Gitea 的 Git hook。
- `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。 - `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。
- `launchd/com.xsk.records.sync.plist.template`LaunchAgent 模板。 - `launchd/com.xsk.records.sync.plist.template`LaunchAgent 模板。
@@ -121,6 +122,8 @@ cd /root/新时空数据/app
docker compose up -d docker compose up -d
``` ```
管理后台使用 `ADMIN_AUTH_PASSWORD`。为兼容旧部署,如果未配置 `ADMIN_AUTH_PASSWORD`,系统会继续使用原 `ACCOUNTS_AUTH_PASSWORD``ACCOUNT_AUTH_PASSWORD`
## API ## API
- `GET /api/health`:数据状态。 - `GET /api/health`:数据状态。
@@ -129,3 +132,9 @@ docker compose up -d
- `GET /api/accounts?q=王鑫鹏`:按学生筛选账户。 - `GET /api/accounts?q=王鑫鹏`:按学生筛选账户。
- `GET /api/accounts?status=欠费`:按账户状态筛选。 - `GET /api/accounts?status=欠费`:按账户状态筛选。
- `GET /api/accounts/王鑫鹏`:单个学生账户。 - `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`:驳回纠错。
+272
View File
@@ -44,6 +44,7 @@ PAYMENT_LINE_RE = re.compile(r"^(?P<student>.+?)-(?P<date>\d{4}-\d{2}-\d{2}):(?P
TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$") TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$")
BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-") BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-")
BACKUP_KEEP_COUNT = 50 BACKUP_KEEP_COUNT = 50
ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"}
WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
ROLE_WORDS = { ROLE_WORDS = {
"student": ("学生", "学员", "孩子", "同学"), "student": ("学生", "学员", "孩子", "同学"),
@@ -156,6 +157,33 @@ def parse_payments(text: str) -> list[Payment]:
return payments 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]: def read_classnotes(path: Path) -> list[ClassRecord]:
records: list[ClassRecord] = [] records: list[ClassRecord] = []
with path.open("r", encoding="utf-8") as handle: 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 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: def atomic_write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
original_stat = path.stat() if path.exists() else None 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)) 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: def render_accounts_text(path: Path, accounts: list[Account]) -> str:
original_text = path.read_text(encoding="utf-8") original_text = path.read_text(encoding="utf-8")
accounts_by_id = {account.student_id: account for account in accounts} 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} 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: def parse_record_date(text: str) -> date:
return datetime.strptime(text, "%Y.%m.%d").date() return datetime.strptime(text, "%Y.%m.%d").date()
+191 -31
View File
@@ -15,15 +15,24 @@ from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from .data import ( from .data import (
ACCOUNT_STATUSES,
Account,
DuplicateRecordError, DuplicateRecordError,
Payment,
approve_correction_task,
account_summary, account_summary,
account_to_dict, account_to_dict,
create_account,
filter_accounts, filter_accounts,
list_admin_tasks,
query_records, query_records,
read_accounts, read_accounts,
read_classnotes, read_classnotes,
register_class_record_lines, register_class_record_lines,
register_payment_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" STATIC_DIR = APP_DIR / "static"
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt")) CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md")) 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", "") BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_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" RECORDS_SESSION_COOKIE = "xsk_records_session"
ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session" ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session"
ADMIN_SESSION_COOKIE = "xsk_admin_session"
SESSION_MAX_AGE = 60 * 60 * 24 * 30 SESSION_MAX_AGE = 60 * 60 * 24 * 30
app = FastAPI(title="新时空课程记录查询", version="1.0.0") app = FastAPI(title="新时空课程记录查询", version="1.0.0")
@@ -47,6 +59,29 @@ class RegisterLinesPayload(BaseModel):
lines: list[str] | None = Field(default=None, description="多条原始登记文本") 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: async def read_register_payload(request: Request) -> RegisterLinesPayload:
body = await request.body() body = await request.body()
if not body.strip(): if not body.strip():
@@ -113,16 +148,28 @@ def is_records_authenticated(
) or has_valid_basic_auth(credentials, BASIC_AUTH_PASSWORD) ) or has_valid_basic_auth(credentials, BASIC_AUTH_PASSWORD)
def is_accounts_authenticated( def is_admin_authenticated(
request: Request, request: Request,
credentials: HTTPBasicCredentials | None = None, credentials: HTTPBasicCredentials | None = None,
) -> bool: ) -> bool:
return has_valid_session( return has_valid_session(
request,
ADMIN_SESSION_COOKIE,
ADMIN_AUTH_PASSWORD,
b"xsk-admin-web-session-v1",
) or has_valid_session(
request, request,
ACCOUNTS_SESSION_COOKIE, ACCOUNTS_SESSION_COOKIE,
ACCOUNTS_AUTH_PASSWORD, ADMIN_AUTH_PASSWORD,
b"xsk-accounts-web-session-v1", 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( def verify_records_auth(
@@ -138,24 +185,31 @@ def verify_records_auth(
return "records" 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( def verify_accounts_auth(
request: Request, request: Request,
credentials: HTTPBasicCredentials | None = Depends(security), credentials: HTTPBasicCredentials | None = Depends(security),
) -> str: ) -> str:
configured_password("课时账户", ACCOUNTS_AUTH_PASSWORD) return verify_admin_auth(request, credentials)
if not is_accounts_authenticated(request, credentials):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录课时账户",
)
return "accounts"
def verify_any_auth( def verify_any_auth(
request: Request, request: Request,
credentials: HTTPBasicCredentials | None = Depends(security), credentials: HTTPBasicCredentials | None = Depends(security),
) -> str: ) -> 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" return "authenticated"
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, 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) @app.exception_handler(ValueError)
async def value_error_handler(_request: Request, exc: ValueError): async def value_error_handler(_request: Request, exc: ValueError):
return JSONResponse(status_code=500, content={"detail": str(exc)}) return JSONResponse(status_code=500, content={"detail": str(exc)})
@@ -450,58 +515,84 @@ def logout():
@app.get("/accounts") @app.get("/accounts")
def accounts_index( def accounts_index(
):
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/admin")
def admin_index(
request: Request, request: Request,
credentials: HTTPBasicCredentials | None = Depends(security), credentials: HTTPBasicCredentials | None = Depends(security),
): ):
if not is_accounts_authenticated(request, credentials): if not is_admin_authenticated(request, credentials):
return RedirectResponse(url="/accounts/login?next=/accounts", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
return FileResponse(STATIC_DIR / "accounts.html") return FileResponse(STATIC_DIR / "admin.html")
@app.get("/accounts/login") @app.get("/admin/login")
def accounts_login_page( def admin_login_page(
request: Request, request: Request,
credentials: HTTPBasicCredentials | None = Depends(security), 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" 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 RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
return HTMLResponse( return HTMLResponse(
render_login_page( render_login_page(
title="课时账户查询", title="管理后台",
action="/accounts/login", action="/admin/login",
next_path=next_path, next_path=next_path,
has_error=has_error, has_error=has_error,
) )
) )
@app.post("/accounts/login") @app.post("/admin/login")
async def accounts_login_submit(request: Request): async def admin_login_submit(request: Request):
body = (await request.body()).decode("utf-8") body = (await request.body()).decode("utf-8")
form = parse_qs(body, keep_blank_values=True) form = parse_qs(body, keep_blank_values=True)
password = form.get("password", [""])[0] password = form.get("password", [""])[0]
next_path = safe_next_path(form.get("next", ["/accounts"])[0]) next_path = safe_next_path(form.get("next", ["/admin"])[0])
accounts_password = configured_password("课时账户", ACCOUNTS_AUTH_PASSWORD) admin_password = configured_password("管理后台", ADMIN_AUTH_PASSWORD)
if hmac.compare_digest(password, accounts_password): if hmac.compare_digest(password, admin_password):
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER) response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie( response.set_cookie(
ACCOUNTS_SESSION_COOKIE, ADMIN_SESSION_COOKIE,
session_token(accounts_password, b"xsk-accounts-web-session-v1"), session_token(admin_password, b"xsk-admin-web-session-v1"),
max_age=SESSION_MAX_AGE, max_age=SESSION_MAX_AGE,
httponly=True, httponly=True,
samesite="lax", samesite="lax",
) )
return response 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) 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") @app.get("/accounts/logout")
def 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(ACCOUNTS_SESSION_COOKIE)
response.delete_cookie(ADMIN_SESSION_COOKIE)
return response return response
@@ -515,7 +606,7 @@ def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)):
@app.post("/api/register/class-records") @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: try:
payload = await read_register_payload(request) payload = await read_register_payload(request)
with write_lock: with write_lock:
@@ -533,7 +624,7 @@ async def register_class_records(request: Request, _user: str = Depends(verify_a
@app.post("/api/register/payments") @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: try:
payload = await read_register_payload(request) payload = await read_register_payload(request)
with write_lock: 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: if account.student == student or account.student_id == student:
return account_to_dict(account) return account_to_dict(account)
raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}") 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}
+166
View File
@@ -0,0 +1,166 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理后台</title>
<link rel="stylesheet" href="/static/styles.css?v=20260613-admin-review" />
</head>
<body>
<header class="topbar">
<div>
<h1>管理后台</h1>
<p id="adminHealthText">正在读取管理状态</p>
</div>
<div class="top-actions">
<a class="nav-button" href="/">课程记录</a>
<a class="nav-button" href="/admin/logout">退出</a>
<button id="refreshBtn" class="icon-button" title="刷新" type="button" aria-label="刷新"></button>
</div>
</header>
<main class="layout account-layout admin-layout">
<nav class="admin-tabs" aria-label="管理后台功能">
<button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button>
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
<button class="admin-tab" data-admin-tab="register" type="button">登记</button>
</nav>
<section id="accountsPanel" class="panel admin-panel">
<div class="section-head">
<h2>课时账户</h2>
<div class="quick-actions">
<select id="accountStatus" aria-label="账户状态筛选">
<option value="">全部状态</option>
<option value="欠费">欠费</option>
<option value="预警">预警</option>
<option value="正常">正常</option>
<option value="结课">结课</option>
<option value="退费">退费</option>
</select>
<button id="newAccountBtn" class="chip" type="button">新增账户</button>
</div>
</div>
<form id="accountForm" class="search-row account-search-row">
<input id="accountQuery" autocomplete="off" placeholder="学生姓名或学生ID" />
<button type="submit">查询</button>
</form>
<div id="accountMeta" class="summary-grid"></div>
<div id="accountEditor" class="admin-editor" hidden>
<div class="section-head compact-head">
<h2 id="accountEditorTitle">新增账户</h2>
<button id="cancelAccountEditBtn" class="secondary-button" type="button">取消</button>
</div>
<form id="accountEditForm" class="admin-form">
<div class="form-grid">
<label>
学生ID
<input id="editStudentId" autocomplete="off" placeholder="新增时自动生成" />
</label>
<label>
学生姓名
<input id="editStudent" autocomplete="off" required />
</label>
<label>
剩余课时
<input id="editRemaining" inputmode="decimal" required />
</label>
<label>
账户状态
<select id="editStatus">
<option value="正常">正常</option>
<option value="预警">预警</option>
<option value="欠费">欠费</option>
<option value="结课">结课</option>
<option value="退费">退费</option>
</select>
</label>
<label class="span-2">
缴费记录
<textarea id="editPayments" rows="4" placeholder="每行一条:2026-06-01:53"></textarea>
</label>
<label class="span-2">
备注
<textarea id="editNote" rows="3"></textarea>
</label>
</div>
<p id="accountEditError" class="correction-error" hidden></p>
<div class="modal-actions">
<button type="submit">保存账户</button>
</div>
</form>
</div>
<div class="table-wrap account-table-wrap">
<table>
<thead>
<tr>
<th>学生</th>
<th>状态</th>
<th class="num">剩余课时</th>
<th>缴费记录</th>
<th>备注</th>
<th>操作</th>
</tr>
</thead>
<tbody id="accountRows"></tbody>
</table>
</div>
</section>
<section id="reviewsPanel" class="panel admin-panel" hidden>
<div class="section-head">
<h2>纠错审核</h2>
<select id="reviewStatus" aria-label="审核状态筛选">
<option value="pending">待审核</option>
<option value="conflict">冲突</option>
<option value="approved">已批准</option>
<option value="rejected">已驳回</option>
<option value="">全部状态</option>
</select>
</div>
<div id="reviewMeta" class="summary-grid"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>编号</th>
<th>状态</th>
<th>原记录</th>
<th>修改后</th>
<th>提交时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="reviewRows"></tbody>
</table>
</div>
</section>
<section id="registerPanel" class="panel admin-panel" hidden>
<div class="section-head">
<h2>登记</h2>
</div>
<div class="register-grid">
<form id="classRegisterForm" class="admin-form">
<h3>上课记录登记</h3>
<textarea id="classRegisterLines" rows="10" placeholder="每行一条上课记录"></textarea>
<p id="classRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions">
<button type="submit">提交上课记录</button>
</div>
</form>
<form id="paymentRegisterForm" class="admin-form">
<h3>缴费登记</h3>
<textarea id="paymentRegisterLines" rows="10" placeholder="每行一条:学生-2026-06-01:53"></textarea>
<p id="paymentRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions">
<button type="submit">提交缴费记录</button>
</div>
</form>
</div>
</section>
</main>
<script src="/static/admin.js?v=20260613-admin-review"></script>
</body>
</html>
+346
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function metric(label, value) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
}
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("<br>");
}
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 = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
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) => `<tr>
<td>${escapeHtml(row.student)}<br><small>${escapeHtml(row.student_id)}</small></td>
<td><span class="status ${statusClass(row.account_status)}">${escapeHtml(row.account_status)}</span></td>
<td class="num">${fmtHours(row.remaining)}</td>
<td>${renderPayments(row.payments)}</td>
<td class="note-cell">${escapeHtml(row.note || "")}</td>
<td><button class="small-button account-edit" type="button" data-student-id="${escapeHtml(row.student_id)}">编辑</button></td>
</tr>`,
)
.join("");
if (!data.accounts.length) {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的账户</td></tr>`;
}
} catch (error) {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
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 `<code class="line-code">${escapeHtml(line)}</code>`;
}
async function loadReviews() {
reviewRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
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 `<tr>
<td>#${escapeHtml(item.id)}</td>
<td><span class="status ${item.status === "approved" ? "normal" : item.status === "rejected" ? "closed" : item.status === "conflict" ? "debt" : "warning"}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td>${renderReviewLine(item.original_line)}</td>
<td>${renderReviewLine(item.corrected_line)}</td>
<td>${escapeHtml(item.created_at || "")}</td>
<td class="record-action-cell">
<div class="record-actions">
<button class="small-button review-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>批准</button>
<button class="small-button review-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
</div>
</td>
</tr>`;
})
.join("");
if (!data.items.length) {
reviewRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的审核项</td></tr>`;
}
} catch (error) {
reviewRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
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();
+34 -2
View File
@@ -10,6 +10,7 @@ const inlineAccount = document.querySelector("#inlineAccount");
const correctionToolbar = document.querySelector("#correctionToolbar"); const correctionToolbar = document.querySelector("#correctionToolbar");
const correctionStatus = document.querySelector("#correctionStatus"); const correctionStatus = document.querySelector("#correctionStatus");
const copyCorrectedBtn = document.querySelector("#copyCorrectedBtn"); const copyCorrectedBtn = document.querySelector("#copyCorrectedBtn");
const submitCorrectionsBtn = document.querySelector("#submitCorrectionsBtn");
const correctionDialog = document.querySelector("#correctionDialog"); const correctionDialog = document.querySelector("#correctionDialog");
const correctionForm = document.querySelector("#correctionForm"); const correctionForm = document.querySelector("#correctionForm");
const correctionOriginal = document.querySelector("#correctionOriginal"); const correctionOriginal = document.querySelector("#correctionOriginal");
@@ -260,7 +261,9 @@ function updateCorrectionToolbar(message = "", isError = false) {
const count = correctedRecords.size; const count = correctedRecords.size;
correctionToolbar.hidden = count === 0; correctionToolbar.hidden = count === 0;
copyCorrectedBtn.disabled = count === 0; copyCorrectedBtn.disabled = count === 0;
submitCorrectionsBtn.disabled = count === 0;
copyCorrectedBtn.textContent = `复制已修改记录 ${count}`; copyCorrectedBtn.textContent = `复制已修改记录 ${count}`;
submitCorrectionsBtn.textContent = `提交审核 ${count}`;
correctionStatus.textContent = message || `已修改 ${count} 条记录`; correctionStatus.textContent = message || `已修改 ${count} 条记录`;
correctionStatus.classList.toggle("is-error", isError); correctionStatus.classList.toggle("is-error", isError);
} }
@@ -435,8 +438,36 @@ async function copyCorrectedRecords() {
} }
} }
async function fetchJson(url) { async function submitCorrectedRecords() {
const response = await fetch(url, { cache: "no-store" }); 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) { if (!response.ok) {
let detail = `${response.status} ${response.statusText}`; let detail = `${response.status} ${response.statusText}`;
try { try {
@@ -550,6 +581,7 @@ document.addEventListener("keydown", (event) => {
}); });
copyCorrectedBtn.addEventListener("click", copyCorrectedRecords); copyCorrectedBtn.addEventListener("click", copyCorrectedRecords);
submitCorrectionsBtn.addEventListener("click", submitCorrectedRecords);
updateRecordSortButtons(); updateRecordSortButtons();
loadHealth(); loadHealth();
+6 -3
View File
@@ -13,7 +13,7 @@
<p id="healthText">正在读取数据状态</p> <p id="healthText">正在读取数据状态</p>
</div> </div>
<div class="top-actions"> <div class="top-actions">
<a class="nav-button" href="/accounts">课时账户</a> <a class="nav-button" href="/admin">管理后台</a>
<button id="refreshBtn" class="icon-button" title="刷新数据" type="button" aria-label="刷新数据"> <button id="refreshBtn" class="icon-button" title="刷新数据" type="button" aria-label="刷新数据">
</button> </button>
@@ -43,7 +43,10 @@
<div id="recordMeta" class="summary-grid"></div> <div id="recordMeta" class="summary-grid"></div>
<div id="correctionToolbar" class="correction-toolbar" hidden> <div id="correctionToolbar" class="correction-toolbar" hidden>
<span id="correctionStatus">已修改 0 条记录</span> <span id="correctionStatus">已修改 0 条记录</span>
<button id="copyCorrectedBtn" type="button">复制已修改记录</button> <div class="toolbar-actions">
<button id="copyCorrectedBtn" type="button">复制已修改记录</button>
<button id="submitCorrectionsBtn" type="button">提交审核</button>
</div>
</div> </div>
<div id="inlineAccount" class="inline-account" hidden></div> <div id="inlineAccount" class="inline-account" hidden></div>
<div class="table-wrap"> <div class="table-wrap">
@@ -116,6 +119,6 @@
</section> </section>
</div> </div>
<script src="/static/app.js?v=20260613-sort-labels"></script> <script src="/static/app.js?v=20260613-admin-review"></script>
</body> </body>
</html> </html>
+126
View File
@@ -125,6 +125,10 @@ h2 {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.admin-layout {
gap: 12px;
}
.panel { .panel {
min-width: 0; min-width: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -150,6 +154,30 @@ h2 {
justify-content: flex-end; 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, .chip,
.search-row button { .search-row button {
border: 0; border: 0;
@@ -280,6 +308,77 @@ select:focus {
font-size: 14px; 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 { .correction-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -300,6 +399,12 @@ select:focus {
color: var(--danger); color: var(--danger);
} }
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.correction-toolbar button, .correction-toolbar button,
.modal-actions button { .modal-actions button {
min-height: 38px; min-height: 38px;
@@ -638,6 +743,10 @@ td {
.inline-account-grid { .inline-account-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.register-grid {
grid-template-columns: 1fr;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -679,6 +788,11 @@ td {
flex-direction: column; flex-direction: column;
} }
.toolbar-actions {
width: 100%;
flex-direction: column;
}
.correction-toolbar button { .correction-toolbar button {
width: 100%; width: 100%;
} }
@@ -687,6 +801,18 @@ td {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.admin-tabs {
overflow-x: auto;
}
.form-grid {
grid-template-columns: 1fr;
}
.span-2 {
grid-column: auto;
}
.modal-actions { .modal-actions {
flex-direction: column-reverse; flex-direction: column-reverse;
} }
+1
View File
@@ -12,6 +12,7 @@ services:
TZ: ${TZ:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai}
CLASSNOTES_PATH: ${CLASSNOTES_PATH:-/data/classnotes.txt} CLASSNOTES_PATH: ${CLASSNOTES_PATH:-/data/classnotes.txt}
ACCOUNTS_PATH: ${ACCOUNTS_PATH:-/data/学生课时账户.md} ACCOUNTS_PATH: ${ACCOUNTS_PATH:-/data/学生课时账户.md}
ADMIN_TASKS_PATH: ${ADMIN_TASKS_PATH:-/data/admin_tasks.json}
ports: ports:
- "${APP_PORT:-18080}:8000" - "${APP_PORT:-18080}:8000"
volumes: volumes:
+2
View File
@@ -99,5 +99,7 @@ renderGroupedRecords(currentRecords);
context, context,
); );
assertEqual("纠错后排序仍使用显示记录", value("currentRecordOrder.join('')"), "abcd"); assertEqual("纠错后排序仍使用显示记录", value("currentRecordOrder.join('')"), "abcd");
vm.runInContext("updateCorrectionToolbar('测试提交按钮')", context);
assertEqual("提交审核按钮文案", value("submitCorrectionsBtn.textContent"), "提交审核 1 条");
console.log("smoke test passed"); console.log("smoke test passed");