feat: add admin console and review workflow
This commit is contained in:
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 `<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
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user