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
+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})$")
BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-")
BACKUP_KEEP_COUNT = 50
ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"}
WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
ROLE_WORDS = {
"student": ("学生", "学员", "孩子", "同学"),
@@ -156,6 +157,33 @@ def parse_payments(text: str) -> list[Payment]:
return payments
def validate_payment(payment: Payment) -> Payment:
try:
datetime.strptime(payment.date, "%Y-%m-%d")
except ValueError as exc:
raise ValueError(f"缴费日期不存在: {payment.date}") from exc
return Payment(date=payment.date, hours=round(float(payment.hours), 2))
def validate_account(account: Account) -> Account:
student_id = account.student_id.strip()
student = canonical_name(account.student)
if not re.fullmatch(r"XS\d{3}", student_id):
raise ValueError("学生ID格式应为 XS001 这样的三位编号")
if not student:
raise ValueError("学生姓名不能为空")
if account.account_status not in ACCOUNT_STATUSES:
raise ValueError("账户状态必须是 正常、预警、欠费、结课、退费")
return Account(
student_id=student_id,
student=student,
payments=[validate_payment(payment) for payment in account.payments],
remaining=round(float(account.remaining), 2),
account_status=account.account_status,
note=account.note.strip(),
)
def read_classnotes(path: Path) -> list[ClassRecord]:
records: list[ClassRecord] = []
with path.open("r", encoding="utf-8") as handle:
@@ -334,6 +362,46 @@ def replace_account_lines(original_text: str, accounts_by_id: dict[str, Account]
return "\n".join(output) + trailing_newline
def append_account_line(original_text: str, account: Account) -> str:
lines = original_text.splitlines()
insert_at = len(lines)
for index, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped:
insert_at = index + 1
lines.insert(insert_at, format_account_row(account))
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(lines) + trailing_newline
def replace_single_account_line(original_text: str, old_student_id: str, account: Account) -> str:
lines = original_text.splitlines()
replaced = False
output: list[str] = []
for line in lines:
stripped = line.strip()
if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped:
parts = [part.strip() for part in stripped.strip("|").split("|")]
if parts and parts[0] == old_student_id:
output.append(format_account_row(account))
replaced = True
continue
output.append(line)
if not replaced:
raise ValueError(f"未找到学生账户: {old_student_id}")
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(output) + trailing_newline
def next_student_id(accounts: list[Account]) -> str:
values = []
for account in accounts:
match = re.fullmatch(r"XS(\d{3})", account.student_id)
if match:
values.append(int(match.group(1)))
return f"XS{(max(values) if values else 0) + 1:03d}"
def atomic_write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
original_stat = path.stat() if path.exists() else None
@@ -457,6 +525,39 @@ def write_accounts(path: Path, accounts: list[Account]) -> None:
atomic_write_text(path, replace_account_lines(original_text, accounts_by_id))
def create_account(path: Path, account: Account) -> dict:
accounts = read_accounts(path)
account = validate_account(replace(account, student_id=next_student_id(accounts)))
if any(item.student_id == account.student_id for item in accounts):
raise ValueError(f"学生ID已存在: {account.student_id}")
original_accounts = path.read_text(encoding="utf-8")
backup_dir = create_data_backup("admin-create-account", {path: original_accounts}, [format_account_row(account)])
atomic_write_text(path, append_account_line(original_accounts, account))
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"account": account_to_dict(account), "backup_id": backup_dir.name}
def update_account(path: Path, old_student_id: str, account: Account) -> dict:
old_student_id = old_student_id.strip()
accounts = read_accounts(path)
account = validate_account(account)
if not any(item.student_id == old_student_id for item in accounts):
raise ValueError(f"未找到学生账户: {old_student_id}")
if account.student_id != old_student_id and any(item.student_id == account.student_id for item in accounts):
raise ValueError(f"学生ID已存在: {account.student_id}")
original_accounts = path.read_text(encoding="utf-8")
backup_dir = create_data_backup("admin-update-account", {path: original_accounts}, [old_student_id, format_account_row(account)])
atomic_write_text(path, replace_single_account_line(original_accounts, old_student_id, account))
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"account": account_to_dict(account), "backup_id": backup_dir.name}
def render_accounts_text(path: Path, accounts: list[Account]) -> str:
original_text = path.read_text(encoding="utf-8")
accounts_by_id = {account.student_id: account for account in accounts}
@@ -575,6 +676,177 @@ def register_payment_lines(
return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name}
def default_admin_tasks() -> dict:
return {"version": 1, "next_id": 1, "items": []}
def read_admin_tasks(path: Path) -> dict:
if not path.exists():
return default_admin_tasks()
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"管理任务文件 JSON 格式错误: {path}") from exc
if not isinstance(payload, dict):
raise ValueError("管理任务文件必须是 JSON 对象")
payload.setdefault("version", 1)
payload.setdefault("next_id", 1)
payload.setdefault("items", [])
if not isinstance(payload["items"], list):
raise ValueError("管理任务 items 必须是数组")
return payload
def write_admin_tasks(path: Path, tasks: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_text(path, json.dumps(tasks, ensure_ascii=False, indent=2) + "\n")
def task_to_dict(task: dict) -> dict:
return dict(task)
def find_admin_task(tasks: dict, task_id: int) -> dict:
for task in tasks["items"]:
if int(task.get("id", 0)) == task_id:
return task
raise ValueError(f"未找到管理任务: {task_id}")
def submit_correction_tasks(tasks_path: Path, items: list[dict]) -> dict:
if not items:
raise ValueError("提交审核的纠错记录不能为空")
tasks = read_admin_tasks(tasks_path)
now = datetime.now().isoformat(timespec="seconds")
created: list[dict] = []
for item in items:
original_line = str(item.get("original_line", "")).strip()
corrected_line = str(item.get("corrected_line", "")).strip()
if not original_line or not corrected_line:
raise ValueError("纠错审核记录缺少原记录或修改后记录")
original = parse_class_record_line(original_line)
corrected = parse_class_record_line(corrected_line)
if original_line == corrected_line:
raise ValueError("原记录和修改后记录相同,无需提交审核")
task = {
"id": int(tasks["next_id"]),
"type": "class_record_correction",
"status": "pending",
"created_at": now,
"updated_at": now,
"original_line": class_record_to_line(original),
"corrected_line": class_record_to_line(corrected),
"original": record_to_dict(original),
"corrected": record_to_dict(corrected),
}
tasks["next_id"] = int(tasks["next_id"]) + 1
tasks["items"].append(task)
created.append(task)
write_admin_tasks(tasks_path, tasks)
return {"submitted": len(created), "items": [task_to_dict(task) for task in created]}
def list_admin_tasks(tasks_path: Path, status_filter: str = "", task_type: str = "") -> dict:
tasks = read_admin_tasks(tasks_path)
items = tasks["items"]
if status_filter:
items = [item for item in items if item.get("status") == status_filter]
if task_type:
items = [item for item in items if item.get("type") == task_type]
return {
"version": tasks["version"],
"next_id": tasks["next_id"],
"count": len(items),
"items": [task_to_dict(item) for item in sorted(items, key=lambda item: int(item.get("id", 0)), reverse=True)],
}
def replace_class_record_line(original_text: str, original_line: str, corrected_line: str) -> str:
lines = original_text.splitlines()
matched = [index for index, line in enumerate(lines) if line.strip() == original_line]
if not matched:
raise ValueError("原上课记录在正式文件中不存在,可能已被修改")
if original_line != corrected_line and any(line.strip() == corrected_line for line in lines):
raise ValueError("修改后的上课记录已存在,不能重复写入")
lines[matched[0]] = corrected_line
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(lines) + trailing_newline
def mark_admin_task(tasks_path: Path, task_id: int, status: str, message: str = "") -> dict:
tasks = read_admin_tasks(tasks_path)
task = find_admin_task(tasks, task_id)
if task.get("status") not in {"pending", "conflict"}:
raise ValueError("该任务已处理,不能重复操作")
now = datetime.now().isoformat(timespec="seconds")
task["status"] = status
task["updated_at"] = now
task["reviewed_at"] = now
if message:
task["message"] = message
write_admin_tasks(tasks_path, tasks)
return task_to_dict(task)
def reject_admin_task(tasks_path: Path, task_id: int) -> dict:
return mark_admin_task(tasks_path, task_id, "rejected")
def approve_correction_task(tasks_path: Path, classnotes_path: Path, task_id: int) -> dict:
tasks = read_admin_tasks(tasks_path)
task = find_admin_task(tasks, task_id)
if task.get("type") != "class_record_correction":
raise ValueError("该任务不是上课记录纠错")
if task.get("status") not in {"pending", "conflict"}:
raise ValueError("该任务已处理,不能重复批准")
original_line = str(task.get("original_line", "")).strip()
corrected_line = str(task.get("corrected_line", "")).strip()
parse_class_record_line(original_line)
corrected = parse_class_record_line(corrected_line)
corrected_line = class_record_to_line(corrected)
original_classnotes = classnotes_path.read_text(encoding="utf-8")
try:
new_classnotes = replace_class_record_line(original_classnotes, original_line, corrected_line)
except ValueError as exc:
task["status"] = "conflict"
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
task["message"] = str(exc)
write_admin_tasks(tasks_path, tasks)
raise
original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
now = datetime.now().isoformat(timespec="seconds")
task["status"] = "approved"
task["updated_at"] = now
task["reviewed_at"] = now
task["corrected_line"] = corrected_line
task["corrected"] = record_to_dict(corrected)
new_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
backup_dir = create_data_backup(
"admin-approve-correction",
{
classnotes_path: original_classnotes,
tasks_path: original_tasks,
},
[original_line, corrected_line],
)
try:
atomic_write_text(classnotes_path, new_classnotes)
atomic_write_text(tasks_path, new_tasks)
except Exception:
atomic_write_text(classnotes_path, original_classnotes)
atomic_write_text(tasks_path, original_tasks)
raise
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"task": task_to_dict(task), "backup_id": backup_dir.name}
def parse_record_date(text: str) -> date:
return datetime.strptime(text, "%Y.%m.%d").date()