升级学生档案管理

This commit is contained in:
Codex
2026-06-30 11:35:25 +08:00
parent 93cd2e9c75
commit 124ffca03e
17 changed files with 409 additions and 436 deletions
+2 -2
View File
@@ -72,7 +72,7 @@ make ps
```bash
set -a; . ./app/.env
curl -sS -u "admin:${ACCOUNTS_AUTH_PASSWORD}" "http://127.0.0.1:${APP_PORT:-18080}/api/account-health"
curl -sS -u "admin:${ACCOUNTS_AUTH_PASSWORD}" "http://127.0.0.1:${APP_PORT:-18080}/api/student-health"
curl -sS -u "admin:${ACCOUNTS_AUTH_PASSWORD}" "http://127.0.0.1:${APP_PORT:-18080}/admin"
```
@@ -112,7 +112,7 @@ git push origin HEAD:<当前分支>
/root/新时空教务管理系统/data/operation_logs.jsonl
```
普通前端和查询类改动不应该改变这些文件;课程小结查询页是只读功能,也不应该改变这些文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、课时账户编辑、课程小结自动入账或审核批准,先确认自动备份目录:
普通前端和查询类改动不应该改变这些文件;课程小结查询页是只读功能,也不应该改变这些文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、学生档案编辑、课程小结自动入账或审核批准,先确认自动备份目录:
```text
/root/新时空教务管理系统/data/backups/
+10 -7
View File
@@ -35,7 +35,7 @@ docker compose -f app/docker-compose.yml <命令>
## 后端结构
- `app/main.py`:FastAPI 应用入口,只负责注册路由和全局异常处理。
- `app/routers/`:按业务入口拆分 API 和页面路由,包括登录页面、课程记录、课时账户、管理后台、课程小结推送和健康检查。
- `app/routers/`:按业务入口拆分 API 和页面路由,包括登录页面、课程记录、学生档案、管理后台、课程小结推送和健康检查。
- `app/config.py`:路径、环境变量、Cookie 名称和进程内写锁。
- `app/auth.py`:网页登录、Basic Auth、管理后台和课程小结推送鉴权。
- `app/schemas.py`:请求体 Pydantic 模型。
@@ -115,6 +115,8 @@ make migrate-sqlite
/root/新时空教务管理系统/archives/text-source-before-sqlite-<时间>.tar.gz.sha256
```
学生表包含 `primary_entry_year` 字段,用于维护“小学一年级入学年份”。排课系统只读该字段并按课程日期动态推算年级。管理后台的学生档案编辑页可以维护该字段;运行时 `学生课时账户.md` 缓存会导出为 7 列格式,同时仍兼容旧 6 列缓存导入。
## 课程小结推送
VPS 接收接口:
@@ -229,12 +231,13 @@ docker compose up -d
- `GET /api/health`:数据状态。
- `GET /api/records?q=王鑫鹏5月数学课`:自然语言查询上课记录。
- `GET /api/accounts`课时账户列表。
- `GET /api/accounts?q=王鑫鹏`:按学生筛选账户
- `GET /api/accounts?status=欠费`:按账户状态筛选。
- `GET /api/accounts/王鑫鹏`:单个学生账户
- `POST /api/admin/accounts`:管理后台新增课时账户
- `PUT /api/admin/accounts/{student_id}`:管理后台修改课时账户
- `GET /api/students`学生档案列表。
- `GET /api/students?q=王鑫鹏`:按学生姓名或学生ID筛选学生档案
- `GET /api/students?status=欠费`:按档案状态筛选。
- `GET /api/students/王鑫鹏`:单个学生档案
- `POST /api/admin/students`:管理后台新增学生档案
- `PUT /api/admin/students/{student_id}`:管理后台修改学生档案
- 学生档案的剩余课时由系统按缴费记录和上课记录自动计算,管理接口不会接受人工修改余额。
- `GET /api/admin/teachers`:管理后台读取老师档案。
- `POST /api/admin/teachers`:管理后台新增老师档案。
- `PUT /api/admin/teachers/{teacher_id}`:管理后台修改老师档案。
+3 -2
View File
@@ -50,7 +50,7 @@ def load_accounts():
if USE_SQLITE_SOURCE:
ensure_runtime_cache()
if not ACCOUNTS_PATH.exists():
raise HTTPException(status_code=503, detail=f"课时账户文件不存在: {ACCOUNTS_PATH}")
raise HTTPException(status_code=503, detail=f"学生档案数据文件不存在: {ACCOUNTS_PATH}")
return read_accounts(ACCOUNTS_PATH)
@@ -79,8 +79,9 @@ def payload_to_account(payload: AccountPayload, student_id: str | None = None) -
student_id=student_id if student_id is not None else payload.student_id,
student=payload.student,
payments=[Payment(date=item.date, hours=item.hours) for item in payload.payments],
remaining=payload.remaining,
remaining=0,
account_status=payload.account_status,
primary_entry_year=payload.primary_entry_year,
note=payload.note,
)
+57 -23
View File
@@ -97,8 +97,9 @@ def format_payment(payment: Payment) -> str:
def format_account_row(account: Account) -> str:
payments = ",".join(format_payment(payment) for payment in account.payments)
primary_entry_year = str(account.primary_entry_year or "")
return (
f"| {account.student_id} | {account.student} | {payments} | "
f"| {account.student_id} | {account.student} | {primary_entry_year} | {payments} | "
f"{format_number(account.remaining)} | {account.account_status} | {account.note} |"
)
@@ -141,18 +142,22 @@ def validate_payment(payment: Payment) -> Payment:
def validate_account(account: Account) -> Account:
student_id = account.student_id.strip()
student = canonical_name(account.student)
primary_entry_year = account.primary_entry_year
if not re.fullmatch(r"XS\d{3}", student_id):
raise ValueError("学生ID格式应为 XS001 这样的三位编号")
if not student:
raise ValueError("学生姓名不能为空")
if account.account_status not in ACCOUNT_STATUSES:
raise ValueError("账户状态必须是 正常、预警、欠费、结课、退费")
raise ValueError("档案状态必须是 正常、预警、欠费、结课、退费")
if primary_entry_year is not None and not 1900 <= int(primary_entry_year) <= 2100:
raise ValueError("入学年份必须是 1900 到 2100 之间的年份")
return Account(
student_id=student_id,
student=student,
payments=[validate_payment(payment) for payment in account.payments],
remaining=round(float(account.remaining), 2),
account_status=account.account_status,
primary_entry_year=int(primary_entry_year) if primary_entry_year is not None else None,
note=account.note.strip(),
)
@@ -222,18 +227,29 @@ def read_accounts(path: Path) -> list[Account]:
parts = [part.strip() for part in line.strip("|").split("|")]
if len(parts) < 5:
continue
if len(parts) >= 7:
student_id, student, primary_entry_year_text, payments_text, remaining_text, status_text, note_text = parts[:7]
else:
student_id, student, payments_text, remaining_text, status_text = parts[:5]
primary_entry_year_text = ""
note_text = parts[5] if len(parts) > 5 else ""
try:
remaining = float(parts[3])
remaining = float(remaining_text)
except ValueError as exc:
raise ValueError(f"{path}:{line_number} 无法解析剩余课时: {parts[3]}") from exc
raise ValueError(f"{path}:{line_number} 无法解析剩余课时: {remaining_text}") from exc
try:
primary_entry_year = int(primary_entry_year_text) if primary_entry_year_text else None
except ValueError as exc:
raise ValueError(f"{path}:{line_number} 无法解析入学年份: {primary_entry_year_text}") from exc
accounts.append(
Account(
student_id=parts[0],
student=canonical_name(parts[1]),
payments=parse_payments(parts[2]),
student_id=student_id,
student=canonical_name(student),
payments=parse_payments(payments_text),
remaining=remaining,
account_status=parts[4],
note=parts[5] if len(parts) > 5 else "",
account_status=status_text,
primary_entry_year=primary_entry_year,
note=note_text,
)
)
return accounts
@@ -381,7 +397,7 @@ def find_account_index(accounts: list[Account], student: str) -> int:
for index, account in enumerate(accounts):
if account.student == canonical_student or account.student_id == canonical_student:
return index
raise ValueError(f"未找到学生账户: {student}")
raise ValueError(f"未找到学生档案: {student}")
def update_account_remaining(account: Account, delta_hours: float) -> Account:
@@ -398,6 +414,18 @@ def append_account_payment(account: Account, payment: Payment) -> Account:
return replace(updated, account_status=recalc_account_status(updated))
def used_hours_for_student(records: list[ClassRecord], student: str) -> float:
canonical_student = canonical_name(student)
return round(sum(record.duration_hours for record in records if record.student == canonical_student), 2)
def derive_account_remaining(account: Account, records: list[ClassRecord]) -> Account:
paid = round(sum(payment.hours for payment in account.payments), 2)
used = used_hours_for_student(records, account.student)
updated = replace(account, remaining=round(paid - used, 2))
return replace(updated, account_status=recalc_account_status(updated))
def replace_account_lines(original_text: str, accounts_by_id: dict[str, Account]) -> str:
lines = original_text.splitlines()
output: list[str] = []
@@ -442,7 +470,7 @@ def replace_single_account_line(original_text: str, old_student_id: str, account
continue
output.append(line)
if not replaced:
raise ValueError(f"未找到学生账户: {old_student_id}")
raise ValueError(f"未找到学生档案: {old_student_id}")
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(output) + trailing_newline
@@ -597,9 +625,13 @@ def teacher_name_map(teachers: list[Teacher]) -> dict[str, Teacher]:
return mapping
def create_account(path: Path, account: Account) -> dict:
def create_account(path: Path, account: Account, classnotes_path: Path | None = None) -> dict:
accounts = read_accounts(path)
account = validate_account(replace(account, student_id=next_student_id(accounts)))
records = read_classnotes(classnotes_path) if classnotes_path and classnotes_path.exists() else []
account = derive_account_remaining(
validate_account(replace(account, student_id=next_student_id(accounts))),
records,
)
if any(item.student_id == account.student_id for item in accounts):
raise ValueError(f"学生ID已存在: {account.student_id}")
original_accounts = path.read_text(encoding="utf-8")
@@ -609,15 +641,16 @@ def create_account(path: Path, account: Account) -> dict:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "新增课时账户"}
return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "新增学生档案"}
def update_account(path: Path, old_student_id: str, account: Account) -> dict:
def update_account(path: Path, old_student_id: str, account: Account, classnotes_path: Path | None = None) -> dict:
old_student_id = old_student_id.strip()
accounts = read_accounts(path)
account = validate_account(account)
records = read_classnotes(classnotes_path) if classnotes_path and classnotes_path.exists() else []
account = derive_account_remaining(validate_account(account), records)
if not any(item.student_id == old_student_id for item in accounts):
raise ValueError(f"未找到学生账户: {old_student_id}")
raise ValueError(f"未找到学生档案: {old_student_id}")
if account.student_id != old_student_id and any(item.student_id == account.student_id for item in accounts):
raise ValueError(f"学生ID已存在: {account.student_id}")
original_accounts = path.read_text(encoding="utf-8")
@@ -627,7 +660,7 @@ def update_account(path: Path, old_student_id: str, account: Account) -> dict:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "修改课时账户"}
return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "修改学生档案"}
def render_accounts_text(path: Path, accounts: list[Account]) -> str:
@@ -1755,8 +1788,8 @@ OPERATION_LABELS = {
"admin_course_summary_link_existing": "课程小结关联已有记录",
"register-class-records": "登记上课记录",
"register-payments": "登记缴费记录",
"admin-create-account": "新增课时账户",
"admin-update-account": "修改课时账户",
"admin-create-account": "新增学生档案",
"admin-update-account": "修改学生档案",
"admin-create-teacher": "新增老师档案",
"admin-update-teacher": "修改老师档案",
"admin-approve-correction": "审核批准上课记录纠错",
@@ -4771,6 +4804,7 @@ def account_to_dict(account: Account) -> dict:
"remaining": account.remaining,
"remaining_duration": duration_text_from_hours(account.remaining),
"account_status": account.account_status,
"primary_entry_year": account.primary_entry_year,
"note": account.note,
}
@@ -4963,8 +4997,8 @@ def admin_dashboard_summary(
daily: defaultdict[str, float] = defaultdict(float)
for record in period_records:
daily[record.date.replace(".", "-")] += record.duration_hours
active_accounts = [account for account in accounts if account.account_status not in {"结课", "退费"}]
low_remaining = sorted(active_accounts, key=lambda account: (account.remaining, account.student))[:8]
active_students = [account for account in accounts if account.account_status not in {"结课", "退费"}]
low_remaining = sorted(active_students, key=lambda account: (account.remaining, account.student))[:8]
logs = list_operation_logs(operation_logs_path, limit=10).get("items", []) if operation_logs_path.exists() else []
return {
"period": {
@@ -4989,7 +5023,7 @@ def admin_dashboard_summary(
"subjects": top_duration_items(summary["subjects"]),
"students": top_duration_items(summary["students"]),
},
"accounts": {
"students": {
"summary": account_summary(accounts),
"low_remaining": [
{
+9 -1
View File
@@ -8,7 +8,7 @@ from typing import Iterator
from .config import SQLITE_DB_PATH
SCHEMA_VERSION = 1
SCHEMA_VERSION = 2
def connect(db_path: Path | None = None) -> sqlite3.Connection:
@@ -48,6 +48,7 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
student_id TEXT PRIMARY KEY,
student TEXT NOT NULL UNIQUE,
account_status TEXT NOT NULL,
primary_entry_year INTEGER,
note TEXT NOT NULL DEFAULT '',
source_remaining REAL NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
@@ -167,12 +168,19 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
CREATE INDEX IF NOT EXISTS idx_operation_logs_created_at ON operation_logs(created_at);
"""
)
ensure_column(conn, "students", "primary_entry_year", "INTEGER")
conn.execute(
"INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', ?)",
(str(SCHEMA_VERSION),),
)
def ensure_column(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None:
columns = {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})")}
if column not in columns:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
def database_meta(db_path: Path | None = None) -> dict:
path = db_path or SQLITE_DB_PATH
if not path.exists():
+1
View File
@@ -49,6 +49,7 @@ class Account:
payments: list[Payment]
remaining: float
account_status: str
primary_entry_year: int | None
note: str
+18 -8
View File
@@ -39,8 +39,8 @@ ACCOUNTS_HEADER = """# 学生课时账户
- 本表由 SQLite 运行时缓存生成正式事实源为 `/data/xsk_education.db`
- `剩余课时` 由缴费/退费流水与上课记录实时重算
| 学生ID | 学生姓名 | 缴费记录 | 剩余课时 | 账户状态 | 备注 |
|---|---|---|---|---|---|
| 学生ID | 学生姓名 | 入学年份 | 缴费记录 | 剩余课时 | 账户状态 | 备注 |
|---|---|---|---|---|---|---|
"""
TEACHERS_HEADER = """# 教师档案
@@ -172,10 +172,18 @@ def _insert_sources(
for index, account in enumerate(accounts):
conn.execute(
"""
INSERT INTO students(student_id, student, account_status, note, source_remaining, sort_order)
VALUES(?, ?, ?, ?, ?, ?)
INSERT INTO students(student_id, student, account_status, primary_entry_year, note, source_remaining, sort_order)
VALUES(?, ?, ?, ?, ?, ?, ?)
""",
(account.student_id, account.student, account.account_status, account.note, account.remaining, index),
(
account.student_id,
account.student,
account.account_status,
account.primary_entry_year,
account.note,
account.remaining,
index,
),
)
for payment_index, payment in enumerate(account.payments):
conn.execute(
@@ -202,7 +210,7 @@ def _insert_sources(
seen_record_keys.add(key)
account = account_by_student.get(record.student)
if account is None:
raise ValueError(f"上课学生没有课时账户: {record.student}")
raise ValueError(f"上课学生没有学生档案: {record.student}")
minutes = int(round(record.duration_hours * 60))
used_hours_by_student[record.student] = round(used_hours_by_student[record.student] + record.duration_hours, 2)
conn.execute(
@@ -457,7 +465,7 @@ def _accounts_from_db(conn: sqlite3.Connection) -> list[Account]:
used_map = _used_hours_by_student(conn)
accounts: list[Account] = []
rows = conn.execute(
"SELECT student_id, student, account_status, note FROM students ORDER BY sort_order, student_id"
"SELECT student_id, student, account_status, primary_entry_year, note FROM students ORDER BY sort_order, student_id"
).fetchall()
for row in rows:
student_id = str(row["student_id"])
@@ -472,6 +480,7 @@ def _accounts_from_db(conn: sqlite3.Connection) -> list[Account]:
payments=payments,
remaining=remaining,
account_status=status,
primary_entry_year=row["primary_entry_year"],
note=str(row["note"] or ""),
)
)
@@ -537,8 +546,9 @@ def _write_accounts(path: Path, accounts: list[Account]) -> None:
lines = [ACCOUNTS_HEADER.rstrip("\n")]
for account in accounts:
payments = ",".join(_format_payment(payment) for payment in account.payments)
primary_entry_year = str(account.primary_entry_year or "")
lines.append(
f"| {account.student_id} | {account.student} | {payments} | "
f"| {account.student_id} | {account.student} | {primary_entry_year} | {payments} | "
f"{_format_number(account.remaining)} | {account.account_status} | {account.note} |"
)
atomic_write_text(path, "\n".join(lines).rstrip() + "\n")
+20 -20
View File
@@ -120,8 +120,8 @@ async def register_course_summaries(request: Request, _user: str = Depends(verif
return {"ok": True, **result}
@router.get("/api/account-health")
def account_health(_user: str = Depends(verify_accounts_auth)):
@router.get("/api/student-health")
def student_health(_user: str = Depends(verify_accounts_auth)):
accounts = load_accounts()
teachers = load_teachers()
records = read_classnotes(CLASSNOTES_PATH) if CLASSNOTES_PATH.exists() else []
@@ -133,7 +133,7 @@ def account_health(_user: str = Depends(verify_accounts_auth)):
course_summaries_count = sum(1 for _item in iter_course_summary_markdown(COURSE_SUMMARIES_ROOT))
return {
"ok": True,
"accounts": file_meta(ACCOUNTS_PATH),
"students": file_meta(ACCOUNTS_PATH),
"teachers": file_meta(TEACHERS_PATH),
"classnotes": file_meta(CLASSNOTES_PATH),
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
@@ -142,21 +142,21 @@ def account_health(_user: str = Depends(verify_accounts_auth)):
"source_mode": "sqlite" if USE_SQLITE_SOURCE else "text",
"text_root": str(LEGACY_TEXT_ROOT),
},
"accounts_count": len(accounts),
"students_count": len(accounts),
"teachers_count": len(teachers),
"active_teachers_count": sum(1 for teacher in teachers if teacher.status == "在岗"),
"records_count": len(records),
"course_summaries_count": course_summaries_count,
"current_month_hours": current_month_hours,
"current_month_duration": compact_duration_text(current_month_hours),
"account_summary": account_summary(accounts),
"student_summary": account_summary(accounts),
}
@router.get("/api/accounts")
def accounts(
@router.get("/api/students")
def students(
q: str = Query("", description="学生姓名或学生ID"),
status_filter: str = Query("", alias="status", description="账户状态"),
status_filter: str = Query("", alias="status", description="学生档案状态"),
_user: str = Depends(verify_accounts_auth),
):
all_accounts = load_accounts()
@@ -164,16 +164,16 @@ def accounts(
return {
"summary": account_summary(all_accounts),
"count": len(rows),
"accounts": [account_to_dict(account) for account in rows],
"students": [account_to_dict(account) for account in rows],
}
@router.get("/api/accounts/{student}")
def account_detail(student: str, _user: str = Depends(verify_accounts_auth)):
@router.get("/api/students/{student}")
def student_detail(student: str, _user: str = Depends(verify_accounts_auth)):
for account in load_accounts():
if account.student == student or account.student_id == student:
return account_to_dict(account)
raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}")
raise HTTPException(status_code=404, detail=f"未找到学生档案: {student}")
@router.get("/api/admin/statuses")
@@ -235,12 +235,12 @@ def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str =
return {"ok": True, **result}
@router.post("/api/admin/accounts")
def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
@router.post("/api/admin/students")
def admin_create_student(payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = create_account(ACCOUNTS_PATH, payload_to_account(payload))
log_operation = str(result.get("operation") or "新增课时账户")
result = create_account(ACCOUNTS_PATH, payload_to_account(payload), CLASSNOTES_PATH)
log_operation = str(result.get("operation") or "新增学生档案")
append_operation_log(
OPERATION_LOGS_PATH,
log_operation,
@@ -254,12 +254,12 @@ def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_ad
return {"ok": True, **result}
@router.put("/api/admin/accounts/{student_id}")
def admin_update_account(student_id: str, payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
@router.put("/api/admin/students/{student_id}")
def admin_update_student(student_id: str, payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload))
log_operation = str(result.get("operation") or "修改课时账户")
result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload), CLASSNOTES_PATH)
log_operation = str(result.get("operation") or "修改学生档案")
append_operation_log(
OPERATION_LOGS_PATH,
log_operation,
-23
View File
@@ -277,11 +277,6 @@ def logout():
return response
@router.get("/accounts")
def accounts_index():
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin")
def admin_index(
request: Request,
@@ -333,24 +328,6 @@ def admin_logout():
return response
@router.get("/accounts/login")
def accounts_login_page():
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/accounts/login")
async def accounts_login_submit(request: Request):
return await admin_login_submit(request)
@router.get("/accounts/logout")
def accounts_logout():
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
response.delete_cookie(ADMIN_SESSION_COOKIE)
return response
@router.get("/static/{asset_path:path}")
def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)):
target = (STATIC_DIR / asset_path).resolve()
+1 -10
View File
@@ -4,7 +4,7 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from ..api_utils import load_accounts, load_records, load_teachers
from ..api_utils import load_records, load_teachers
from ..auth import verify_records_auth
from ..config import (
ADMIN_TASKS_PATH,
@@ -14,7 +14,6 @@ from ..config import (
write_lock,
)
from ..data import (
account_to_dict,
append_operation_log,
course_summary_semantic_key,
duration_minutes_from_time_range,
@@ -53,14 +52,6 @@ def records(
)
@router.get("/api/student-account/{student}")
def record_student_account(student: str, _user: str = Depends(verify_records_auth)):
for account in load_accounts():
if account.student == student or account.student_id == student:
return account_to_dict(account)
raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}")
@router.post("/api/corrections")
def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(verify_records_auth)):
try:
+1 -1
View File
@@ -25,8 +25,8 @@ class AccountPayload(BaseModel):
student_id: str = ""
student: str
payments: list[PaymentPayload] = Field(default_factory=list)
remaining: float = 0
account_status: str = "正常"
primary_entry_year: int | None = None
note: str = ""
-60
View File
@@ -1,60 +0,0 @@
<!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=20260616-duration-text" />
</head>
<body>
<header class="topbar">
<div>
<h1>课时账户查询</h1>
<p id="accountHealthText">正在读取账户状态</p>
</div>
<div class="top-actions">
<a class="nav-button" href="/">课程记录</a>
<button id="refreshBtn" class="icon-button" title="刷新账户" type="button" aria-label="刷新账户">
</button>
</div>
</header>
<main class="layout account-layout">
<section class="panel accounts-panel">
<div class="section-head">
<h2>课时账户</h2>
<select id="accountStatus" aria-label="账户状态筛选">
<option value="">全部状态</option>
<option value="欠费">欠费</option>
<option value="预警">预警</option>
<option value="正常">正常</option>
<option value="结课">结课</option>
<option value="退费">退费</option>
</select>
</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 class="table-wrap account-table-wrap">
<table>
<thead>
<tr>
<th>学生</th>
<th>状态</th>
<th class="num">剩余课时</th>
<th>缴费记录</th>
<th>备注</th>
</tr>
</thead>
<tbody id="accountRows"></tbody>
</table>
</div>
</section>
</main>
<script src="/static/accounts.js?v=20260616-duration-text"></script>
</body>
</html>
-123
View File
@@ -1,123 +0,0 @@
const accountHealthText = document.querySelector("#accountHealthText");
const refreshBtn = document.querySelector("#refreshBtn");
const accountForm = document.querySelector("#accountForm");
const accountQuery = document.querySelector("#accountQuery");
const accountStatus = document.querySelector("#accountStatus");
const accountMeta = document.querySelector("#accountMeta");
const accountRows = document.querySelector("#accountRows");
function fmtHours(value) {
const totalMinutes = Math.round(Number(value || 0) * 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}小时${minutes}`;
}
function displayHours(value, fallback) {
return fallback || fmtHours(value);
}
function fmtTime(seconds) {
if (!seconds) return "未知";
return new Date(seconds * 1000).toLocaleString("zh-CN", { hour12: false });
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&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)}${escapeHtml(displayHours(item.hours, item.duration))}`).join("<br>");
}
async function fetchJson(url) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
const payload = await response.json();
detail = payload.detail || detail;
} catch (_error) {
detail = response.statusText || detail;
}
throw new Error(detail);
}
return response.json();
}
function renderSummary(summary, count) {
accountMeta.innerHTML = [
metric("当前结果", `${count}`),
metric("欠费", summary.debt),
metric("预警", summary.warning),
metric("正常", summary.normal),
].join("");
}
async function loadAccountHealth() {
try {
const data = await fetchJson("/api/account-health");
accountHealthText.textContent = `账户 ${data.accounts_count} 人;数据更新时间 ${fmtTime(data.accounts.mtime)}`;
} catch (error) {
accountHealthText.textContent = `读取失败:${error.message}`;
}
}
async function loadAccounts() {
accountRows.innerHTML = `<tr><td colspan="5" 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()}`);
renderSummary(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">${escapeHtml(displayHours(row.remaining, row.remaining_duration))}</td>
<td>${renderPayments(row.payments)}</td>
<td class="note-cell">${escapeHtml(row.note || "")}</td>
</tr>`,
)
.join("");
if (!data.accounts.length) {
accountRows.innerHTML = `<tr><td colspan="5" class="empty">没有符合条件的账户</td></tr>`;
}
} catch (error) {
accountRows.innerHTML = `<tr><td colspan="5" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
accountForm.addEventListener("submit", (event) => {
event.preventDefault();
loadAccounts();
});
accountStatus.addEventListener("change", loadAccounts);
refreshBtn.addEventListener("click", () => {
loadAccountHealth();
loadAccounts();
});
loadAccountHealth();
loadAccounts();
+25 -20
View File
@@ -22,7 +22,7 @@
<main class="layout account-layout admin-layout">
<nav class="admin-tabs" aria-label="管理后台功能">
<button class="admin-tab is-active" data-admin-tab="dashboard" type="button">仪表盘</button>
<button class="admin-tab" data-admin-tab="accounts" type="button">课时账户</button>
<button class="admin-tab" data-admin-tab="students" type="button">学生档案</button>
<button class="admin-tab" data-admin-tab="teachers" type="button">老师档案</button>
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结</button>
@@ -46,11 +46,11 @@
</div>
</section>
<section id="accountsPanel" class="panel admin-panel" hidden>
<section id="studentsPanel" class="panel admin-panel" hidden>
<div class="section-head">
<h2>课时账户</h2>
<h2>学生档案</h2>
<div class="quick-actions">
<select id="accountStatus" aria-label="账户状态筛选">
<select id="studentStatus" aria-label="学生档案状态筛选">
<option value="">全部状态</option>
<option value="欠费">欠费</option>
<option value="预警">预警</option>
@@ -58,20 +58,20 @@
<option value="结课">结课</option>
<option value="退费">退费</option>
</select>
<button id="newAccountBtn" class="chip" type="button">新增账户</button>
<button id="newStudentBtn" class="chip" type="button">新增学生档案</button>
</div>
</div>
<form id="accountForm" class="search-row account-search-row">
<input id="accountQuery" autocomplete="off" placeholder="学生姓名或学生ID" />
<form id="studentForm" class="search-row account-search-row">
<input id="studentQuery" autocomplete="off" placeholder="学生姓名或学生ID" />
<button type="submit">查询</button>
</form>
<div id="accountMeta" class="summary-grid"></div>
<div id="accountEditor" class="admin-editor" hidden>
<div id="studentMeta" class="summary-grid"></div>
<div id="studentEditor" class="admin-editor" hidden>
<div class="section-head compact-head">
<h2 id="accountEditorTitle">新增账户</h2>
<button id="cancelAccountEditBtn" class="secondary-button" type="button">取消</button>
<h2 id="studentEditorTitle">新增学生档案</h2>
<button id="cancelStudentEditBtn" class="secondary-button" type="button">取消</button>
</div>
<form id="accountEditForm" class="admin-form">
<form id="studentEditForm" class="admin-form">
<div class="form-grid">
<label>
学生ID
@@ -82,11 +82,15 @@
<input id="editStudent" autocomplete="off" required />
</label>
<label>
剩余课时
<input id="editRemaining" inputmode="decimal" required />
入学年份
<input id="editPrimaryEntryYear" autocomplete="off" inputmode="numeric" placeholder="小学一年级" />
</label>
<label>
账户状态
剩余课时
<input id="editRemaining" readonly value="系统自动计算" />
</label>
<label>
档案状态
<select id="editStatus">
<option value="正常">正常</option>
<option value="预警">预警</option>
@@ -104,9 +108,9 @@
<textarea id="editNote" rows="3"></textarea>
</label>
</div>
<p id="accountEditError" class="correction-error" hidden></p>
<p id="studentEditError" class="correction-error" hidden></p>
<div class="modal-actions">
<button type="submit">保存账户</button>
<button type="submit">保存档案</button>
</div>
</form>
</div>
@@ -115,6 +119,7 @@
<thead>
<tr>
<th>学生</th>
<th>入学年份</th>
<th>状态</th>
<th class="num">剩余课时</th>
<th>缴费记录</th>
@@ -122,7 +127,7 @@
<th>操作</th>
</tr>
</thead>
<tbody id="accountRows"></tbody>
<tbody id="studentRows"></tbody>
</table>
</div>
</section>
@@ -396,8 +401,8 @@
<option value="">全部操作</option>
<option value="登记上课记录">登记上课记录</option>
<option value="登记缴费记录">登记缴费记录</option>
<option value="新增课时账户">新增课时账户</option>
<option value="修改课时账户">修改课时账户</option>
<option value="新增学生档案">新增学生档案</option>
<option value="修改学生档案">修改学生档案</option>
<option value="新增老师档案">新增老师档案</option>
<option value="修改老师档案">修改老师档案</option>
<option value="课程小结接收">课程小结接收</option>
+109 -103
View File
@@ -2,7 +2,7 @@ const adminHealthText = document.querySelector("#adminHealthText");
const refreshBtn = document.querySelector("#refreshBtn");
const panels = {
dashboard: document.querySelector("#dashboardPanel"),
accounts: document.querySelector("#accountsPanel"),
students: document.querySelector("#studentsPanel"),
teachers: document.querySelector("#teachersPanel"),
reviews: document.querySelector("#reviewsPanel"),
summaries: document.querySelector("#summariesPanel"),
@@ -13,17 +13,17 @@ const panels = {
const dashboardMeta = document.querySelector("#dashboardMeta");
const dashboardContent = document.querySelector("#dashboardContent");
const dashboardPeriodBtns = Array.from(document.querySelectorAll(".dashboard-period"));
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 studentForm = document.querySelector("#studentForm");
const studentQuery = document.querySelector("#studentQuery");
const studentStatus = document.querySelector("#studentStatus");
const studentMeta = document.querySelector("#studentMeta");
const studentRows = document.querySelector("#studentRows");
const studentEditor = document.querySelector("#studentEditor");
const studentEditorTitle = document.querySelector("#studentEditorTitle");
const studentEditForm = document.querySelector("#studentEditForm");
const studentEditError = document.querySelector("#studentEditError");
const cancelStudentEditBtn = document.querySelector("#cancelStudentEditBtn");
const newStudentBtn = document.querySelector("#newStudentBtn");
const teacherForm = document.querySelector("#teacherForm");
const teacherQuery = document.querySelector("#teacherQuery");
const teacherStatus = document.querySelector("#teacherStatus");
@@ -43,6 +43,7 @@ const editTeacherSubjects = document.querySelector("#editTeacherSubjects");
const editTeacherNote = document.querySelector("#editTeacherNote");
const editStudentId = document.querySelector("#editStudentId");
const editStudent = document.querySelector("#editStudent");
const editPrimaryEntryYear = document.querySelector("#editPrimaryEntryYear");
const editRemaining = document.querySelector("#editRemaining");
const editStatus = document.querySelector("#editStatus");
const editPayments = document.querySelector("#editPayments");
@@ -117,9 +118,9 @@ const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm")
const ADMIN_PAGE_SIZE = 50;
let currentAccounts = [];
let currentStudents = [];
let currentDashboardPeriod = "month";
let editingAccountId = "";
let editingStudentId = "";
let currentTeachers = [];
let editingTeacherId = "";
let currentReviewPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
@@ -287,8 +288,8 @@ function dashboardAction(label, attrs = {}) {
function renderDashboard(data) {
const overview = data.overview || {};
const accounts = data.accounts || {};
const accountSummary = accounts.summary || {};
const students = data.students || {};
const studentSummary = students.summary || {};
const teachers = data.teachers || {};
const summaries = data.course_summaries || {};
const tasks = data.tasks || {};
@@ -333,34 +334,34 @@ function renderDashboard(data) {
</section>
<section class="dashboard-section">
<div class="dashboard-section-head">
<h3>账户与老师</h3>
<h3>学生档案与老师</h3>
${renderDashboardActions([
dashboardAction("欠费账户", { "data-dashboard-tab": "accounts", "data-account-status": "欠费" }),
dashboardAction("预警账户", { "data-dashboard-tab": "accounts", "data-account-status": "预警" }),
dashboardAction("欠费学生", { "data-dashboard-tab": "students", "data-student-status": "欠费" }),
dashboardAction("预警学生", { "data-dashboard-tab": "students", "data-student-status": "预警" }),
dashboardAction("老师档案", { "data-dashboard-tab": "teachers" }),
])}
</div>
<div class="dashboard-grid two">
<article class="dashboard-card dashboard-metrics-card">
<h4>账户概况</h4>
<h4>学生档案概况</h4>
<div class="dashboard-mini-metrics">
${metric("总账户", `${accountSummary.total || 0}`)}
${metric("正常", accountSummary.normal || 0)}
${metric("预警", accountSummary.warning || 0)}
${metric("欠费", accountSummary.debt || 0)}
${metric("结课", accountSummary.completed || 0)}
${metric("退费", accountSummary.refunded || 0)}
${metric("学生档案", `${studentSummary.total || 0}`)}
${metric("正常", studentSummary.normal || 0)}
${metric("预警", studentSummary.warning || 0)}
${metric("欠费", studentSummary.debt || 0)}
${metric("结课", studentSummary.completed || 0)}
${metric("退费", studentSummary.refunded || 0)}
</div>
</article>
<article class="dashboard-card">
<h4>低剩余课时</h4>
${renderDashboardList(accounts.low_remaining, (item) => `
<button class="dashboard-list-row" type="button" data-dashboard-tab="accounts" data-account-query="${escapeHtml(item.student || "")}">
${renderDashboardList(students.low_remaining, (item) => `
<button class="dashboard-list-row" type="button" data-dashboard-tab="students" data-student-query="${escapeHtml(item.student || "")}">
<span><strong>${escapeHtml(item.student || "")}</strong><small>${escapeHtml(item.student_id || "")}</small></span>
<span class="dashboard-list-value">${escapeHtml(item.remaining_duration || fmtHours(item.remaining || 0))}</span>
<span class="status ${statusClass(item.status)}">${escapeHtml(item.status || "")}</span>
</button>
`, "没有需要重点关注的账户")}
`, "没有需要重点关注的学生")}
</article>
<article class="dashboard-card dashboard-metrics-card">
<h4>老师概况</h4>
@@ -487,7 +488,7 @@ function setActiveTab(tabName) {
panel.hidden = name !== activeTabName;
});
if (activeTabName === "dashboard") loadDashboard();
if (activeTabName === "accounts") loadAccounts();
if (activeTabName === "students") loadStudents();
if (activeTabName === "teachers") loadTeachers();
if (activeTabName === "reviews") loadReviews(0);
if (activeTabName === "summarySearch") {
@@ -499,9 +500,9 @@ function setActiveTab(tabName) {
async function loadAdminHealth() {
try {
const data = await fetchJson("/api/account-health");
const data = await fetchJson("/api/student-health");
adminHealthText.textContent = [
`账户 ${data.accounts_count || 0}`,
`学生档案 ${data.students_count || 0}`,
`在岗老师 ${data.active_teachers_count || 0}`,
`课程记录 ${data.records_count || 0}`,
`小结 ${data.course_summaries_count || 0}`,
@@ -512,8 +513,8 @@ async function loadAdminHealth() {
}
}
function renderAccountSummary(summary, count) {
accountMeta.innerHTML = [
function renderStudentSummary(summary, count) {
studentMeta.innerHTML = [
metric("当前结果", `${count}`),
metric("欠费", summary.debt),
metric("预警", summary.warning),
@@ -521,32 +522,34 @@ function renderAccountSummary(summary, count) {
].join("");
}
async function loadAccounts() {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
async function loadStudents() {
studentRows.innerHTML = `<tr><td colspan="7" 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);
if (studentQuery.value.trim()) params.set("q", studentQuery.value.trim());
if (studentStatus.value) params.set("status", studentStatus.value);
try {
const data = await fetchJson(`/api/accounts?${params.toString()}`);
currentAccounts = data.accounts;
renderAccountSummary(data.summary, data.count);
accountRows.innerHTML = data.accounts
const data = await fetchJson(`/api/students?${params.toString()}`);
const students = data.students || [];
currentStudents = students;
renderStudentSummary(data.summary, data.count);
studentRows.innerHTML = students
.map(
(row) => `<tr>
<td>${escapeHtml(row.student)}<br><small>${escapeHtml(row.student_id)}</small></td>
<td>${escapeHtml(row.primary_entry_year || "")}</td>
<td><span class="status ${statusClass(row.account_status)}">${escapeHtml(row.account_status)}</span></td>
<td class="num">${escapeHtml(displayHours(row.remaining, row.remaining_duration))}</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>
<td><button class="small-button student-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>`;
if (!students.length) {
studentRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的学生档案</td></tr>`;
}
} catch (error) {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
studentRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
@@ -581,72 +584,75 @@ function parsePaymentsInput(value) {
});
}
function openNewAccountEditor() {
editingAccountId = "";
accountEditorTitle.textContent = "新增账户";
function openNewStudentEditor() {
editingStudentId = "";
studentEditorTitle.textContent = "新增学生档案";
editStudentId.value = "";
editStudentId.placeholder = "保存时自动生成";
editStudent.value = "";
editRemaining.value = "0";
editPrimaryEntryYear.value = "";
editRemaining.value = "保存后由系统自动计算";
editStatus.value = "正常";
editPayments.value = "";
editNote.value = "";
accountEditError.hidden = true;
accountEditor.hidden = false;
studentEditError.hidden = true;
studentEditor.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;
function openEditStudentEditor(studentId) {
const studentProfile = currentStudents.find((item) => item.student_id === studentId);
if (!studentProfile) return;
editingStudentId = studentProfile.student_id;
studentEditorTitle.textContent = `编辑学生档案${studentProfile.student}`;
editStudentId.value = studentProfile.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.value = studentProfile.student;
editPrimaryEntryYear.value = studentProfile.primary_entry_year || "";
editRemaining.value = displayHours(studentProfile.remaining, studentProfile.remaining_duration);
editStatus.value = studentProfile.account_status;
editPayments.value = paymentsToText(studentProfile.payments);
editNote.value = studentProfile.note || "";
studentEditError.hidden = true;
studentEditor.hidden = false;
editStudent.focus();
}
function buildAccountPayload() {
const remaining = Number(editRemaining.value);
if (!Number.isFinite(remaining)) {
throw new Error("剩余课时必须是数字");
function buildStudentPayload() {
const primaryEntryYearText = editPrimaryEntryYear.value.trim();
const primaryEntryYear = primaryEntryYearText ? Number(primaryEntryYearText) : null;
if (primaryEntryYearText && (!Number.isInteger(primaryEntryYear) || primaryEntryYear < 1900 || primaryEntryYear > 2100)) {
throw new Error("入学年份必须是 1900 到 2100 之间的年份");
}
return {
student_id: editStudentId.value.trim(),
student: editStudent.value.trim(),
payments: parsePaymentsInput(editPayments.value),
remaining,
account_status: editStatus.value,
primary_entry_year: primaryEntryYear,
note: editNote.value.trim(),
};
}
async function saveAccount(event) {
async function saveStudent(event) {
event.preventDefault();
try {
const payload = buildAccountPayload();
const url = editingAccountId
? `/api/admin/accounts/${encodeURIComponent(editingAccountId)}`
: "/api/admin/accounts";
const method = editingAccountId ? "PUT" : "POST";
const payload = buildStudentPayload();
const url = editingStudentId
? `/api/admin/students/${encodeURIComponent(editingStudentId)}`
: "/api/admin/students";
const method = editingStudentId ? "PUT" : "POST";
await fetchJson(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
accountEditor.hidden = true;
studentEditor.hidden = true;
await loadAdminHealth();
await loadAccounts();
await loadStudents();
} catch (error) {
accountEditError.textContent = error.message;
accountEditError.hidden = false;
studentEditError.textContent = error.message;
studentEditError.hidden = false;
}
}
@@ -1232,7 +1238,7 @@ function renderSummaryIdentityEditor(item) {
<input data-summary-identity-subject="${escapeHtml(item.id)}" autocomplete="off" value="${escapeHtml(item.subject || "")}" />
</label>
</div>
<div class="form-hint">只修改课程小结的学生老师和科目不同步修改上课记录和课时账户老师必须已在教师档案中维护</div>
<div class="form-hint">只修改课程小结的学生老师和科目不同步修改上课记录学生档案或课时余额老师必须已在教师档案中维护</div>
<div class="summary-edit-actions">
<button class="secondary-button summary-identity-save" type="button" data-summary-id="${escapeHtml(item.id)}">保存绑定字段</button>
<button class="secondary-button summary-identity-cancel" type="button" data-summary-id="${escapeHtml(item.id)}">取消</button>
@@ -1820,7 +1826,7 @@ async function rollbackOperationLog(logId) {
await fetchJson(`/api/admin/operation-logs/${encodeURIComponent(logId)}/rollback`, { method: "POST" });
await loadOperationLogs();
await loadAdminHealth();
await loadAccounts();
await loadStudents();
} catch (error) {
alert(error.message);
}
@@ -2108,15 +2114,15 @@ async function confirmRegister(type, statusNode, url, onSuccess) {
clearRegisterPreview(type);
onSuccess();
await loadAdminHealth();
await loadAccounts();
await loadStudents();
} catch (error) {
statusNode.textContent = `写入失败:${error.message}`;
}
}
function resetAccountFilters() {
accountQuery.value = "";
accountStatus.value = "";
function resetStudentFilters() {
studentQuery.value = "";
studentStatus.value = "";
}
function resetSummarySearchFilters() {
@@ -2133,11 +2139,11 @@ function resetSummarySearchFilters() {
function openDashboardTarget(button) {
const targetTab = button.dataset.dashboardTab;
if (targetTab === "accounts") {
resetAccountFilters();
if (button.dataset.accountStatus !== undefined) accountStatus.value = button.dataset.accountStatus;
if (button.dataset.accountQuery !== undefined) accountQuery.value = button.dataset.accountQuery;
setActiveTab("accounts");
if (targetTab === "students") {
resetStudentFilters();
if (button.dataset.studentStatus !== undefined) studentStatus.value = button.dataset.studentStatus;
if (button.dataset.studentQuery !== undefined) studentQuery.value = button.dataset.studentQuery;
setActiveTab("students");
return;
}
if (targetTab === "summarySearch") {
@@ -2191,20 +2197,20 @@ dashboardContent.addEventListener("click", (event) => {
openDashboardTarget(jumpButton);
});
accountForm.addEventListener("submit", (event) => {
studentForm.addEventListener("submit", (event) => {
event.preventDefault();
loadAccounts();
loadStudents();
});
accountStatus.addEventListener("change", loadAccounts);
newAccountBtn.addEventListener("click", openNewAccountEditor);
cancelAccountEditBtn.addEventListener("click", () => {
accountEditor.hidden = true;
studentStatus.addEventListener("change", loadStudents);
newStudentBtn.addEventListener("click", openNewStudentEditor);
cancelStudentEditBtn.addEventListener("click", () => {
studentEditor.hidden = true;
});
accountEditForm.addEventListener("submit", saveAccount);
accountRows.addEventListener("click", (event) => {
const button = event.target.closest(".account-edit");
studentEditForm.addEventListener("submit", saveStudent);
studentRows.addEventListener("click", (event) => {
const button = event.target.closest(".student-edit");
if (!button) return;
openEditAccountEditor(button.dataset.studentId);
openEditStudentEditor(button.dataset.studentId);
});
teacherForm.addEventListener("submit", (event) => {
event.preventDefault();
@@ -2303,7 +2309,7 @@ summarySearchRows.addEventListener("click", (event) => {
const cancelSummaryBody = event.target.closest(".summary-body-cancel");
const applyCandidate = event.target.closest(".summary-apply-candidate");
if (applyCandidate) {
if (!confirm("确认按候选上课记录修正课程小结的学生、老师、科目和时间?系统不会修改上课记录和课时账户。")) return;
if (!confirm("确认按候选上课记录修正课程小结的学生、老师、科目和时间?系统不会修改上课记录、学生档案或课时余额。")) return;
applySummaryBindingCandidate(applyCandidate.dataset.summaryId, {
student: applyCandidate.dataset.student,
teacher: applyCandidate.dataset.teacher,
@@ -2459,7 +2465,7 @@ summaryRegisterConfirm.addEventListener("click", () => {
refreshBtn.addEventListener("click", () => {
loadAdminHealth();
if (!panels.dashboard.hidden) loadDashboard();
if (!panels.accounts.hidden) loadAccounts();
if (!panels.students.hidden) loadStudents();
if (!panels.reviews.hidden) loadReviews();
if (!panels.summarySearch.hidden) refreshSummaryWorkspace({ logs: false });
if (!panels.logs.hidden) loadOperationLogs(currentLogPage.offset || 0);
+17 -16
View File
@@ -364,35 +364,36 @@ function renderPayments(payments) {
.join("");
}
function renderInlineAccount(account) {
function renderInlineStudentProfile(studentProfile) {
inlineAccount.hidden = false;
inlineAccount.innerHTML = `<div class="inline-account-head">
<div>
<h3>${escapeHtml(account.student)} 课时账户</h3>
<p>${escapeHtml(account.student_id)}</p>
<h3>${escapeHtml(studentProfile.student)} 学生档案</h3>
<p>${escapeHtml(studentProfile.student_id)}</p>
</div>
<span class="status ${statusClass(account.account_status)}">${escapeHtml(account.account_status)}</span>
<span class="status ${statusClass(studentProfile.account_status)}">${escapeHtml(studentProfile.account_status)}</span>
</div>
<div class="inline-account-grid">
${metric("剩余课时", displayHours(account.remaining, account.remaining_duration))}
${metric("缴费次数", account.payments_count)}
${metricHtml("缴费记录", renderPayments(account.payments))}
${metric("备注", account.note || "无")}
${metric("剩余课时", displayHours(studentProfile.remaining, studentProfile.remaining_duration))}
${metric("入学年份", studentProfile.primary_entry_year || "未填")}
${metric("缴费次数", studentProfile.payments_count)}
${metricHtml("缴费记录", renderPayments(studentProfile.payments))}
${metric("备注", studentProfile.note || "无")}
</div>`;
}
async function loadInlineAccount(student) {
async function loadInlineStudentProfile(student) {
inlineAccount.hidden = false;
inlineAccount.innerHTML = `<div class="inline-account-loading">正在读取 ${escapeHtml(student)}课时账户</div>`;
inlineAccount.innerHTML = `<div class="inline-account-loading">正在读取 ${escapeHtml(student)}学生档案</div>`;
try {
const account = await fetchJson(`/api/student-account/${encodeURIComponent(student)}`);
renderInlineAccount(account);
const studentProfile = await fetchJson(`/api/students/${encodeURIComponent(student)}`);
renderInlineStudentProfile(studentProfile);
} catch (error) {
inlineAccount.innerHTML = `<div class="inline-account-loading">课时账户读取失败:${escapeHtml(error.message)}</div>`;
inlineAccount.innerHTML = `<div class="inline-account-loading">学生档案读取失败:${escapeHtml(error.message)}</div>`;
}
}
function clearInlineAccount() {
function clearInlineStudentProfile() {
inlineAccount.hidden = true;
inlineAccount.innerHTML = "";
}
@@ -777,7 +778,7 @@ async function queryRecords(query, options = {}) {
if (options.reset !== false) {
resetCorrections();
resetRecordSort();
clearInlineAccount();
clearInlineStudentProfile();
} else {
currentRecordOrder = [];
expandedSummaryRecords = new Set();
@@ -805,7 +806,7 @@ async function queryRecords(query, options = {}) {
].join("");
if (options.reset !== false && data.query.students.length === 1) {
await loadInlineAccount(data.query.students[0]);
await loadInlineStudentProfile(data.query.students[0]);
}
if (!data.records.length) {
+129 -10
View File
@@ -1,25 +1,39 @@
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const elements = new Map();
const staticDir = path.resolve(__dirname, "../app/static");
function element(id) {
if (!elements.has(id)) {
elements.set(id, {
function domNode(id, extras = {}) {
let value = "";
return {
id,
textContent: "",
innerHTML: "",
hidden: false,
disabled: false,
value: "",
dataset: {},
classList: { toggle() {} },
setAttribute(name, value) {
this[name] = value;
get value() {
return value;
},
set value(nextValue) {
value = String(nextValue ?? "");
},
setAttribute(name, nextValue) {
this[name] = nextValue;
},
addEventListener() {},
focus() {},
});
...extras,
};
}
const elements = new Map();
function element(id) {
if (!elements.has(id)) {
elements.set(id, domNode(id));
}
return elements.get(id);
}
@@ -52,6 +66,9 @@ const context = {
Number,
String,
};
context.window = { location: { href: "" } };
context.confirm = () => true;
context.alert = () => {};
function assertEqual(name, actual, expected) {
if (actual !== expected) {
@@ -64,7 +81,7 @@ function value(expression) {
}
vm.createContext(context);
vm.runInContext(fs.readFileSync("app/static/app.js", "utf8"), context);
vm.runInContext(fs.readFileSync(path.join(staticDir, "app.js"), "utf8"), context);
vm.runInContext(
`
@@ -102,4 +119,106 @@ assertEqual("纠错后排序仍使用显示记录", value("currentRecordOrder.jo
vm.runInContext("updateCorrectionToolbar('测试提交按钮')", context);
assertEqual("提交审核按钮文案", value("submitCorrectionsBtn.textContent"), "提交审核 1 条");
console.log("smoke test passed");
const adminElements = new Map();
function adminElement(id) {
if (!adminElements.has(id)) {
adminElements.set(id, domNode(id, {
scrollIntoView() {},
}));
}
return adminElements.get(id);
}
const adminFetchUrls = [];
const adminContext = {
console,
navigator: {},
Date,
JSON,
Map,
Number,
String,
URLSearchParams,
window: { location: { href: "" } },
confirm: () => true,
alert: () => {},
document: {
querySelector(selector) {
return adminElement(selector.replace(/^#/, ""));
},
querySelectorAll() {
return [];
},
addEventListener() {},
createElement(tag) {
return adminElement(`created-${tag}-${adminElements.size}`);
},
body: { appendChild() {} },
execCommand() {
return true;
},
},
fetch: async (url) => {
adminFetchUrls.push(String(url));
if (String(url).startsWith("/api/students")) {
return {
ok: true,
json: async () => ({
summary: { total: 1, debt: 0, warning: 0, normal: 1 },
count: 1,
students: [
{
student_id: "XS001",
student: "甲",
primary_entry_year: 2020,
account_status: "正常",
remaining: 12,
remaining_duration: "12小时",
payments: [{ date: "2026-06-01", hours: 12, duration: "12小时" }],
note: "备注",
},
],
}),
};
}
return {
ok: true,
json: async () => ({
ok: true,
students_count: 1,
active_teachers_count: 0,
records_count: 0,
course_summaries_count: 0,
current_month_hours: 0,
current_month_duration: "0小时",
overview: {},
students: { summary: {}, low_remaining: [] },
teachers: {},
course_summaries: {},
tasks: {},
teaching: {},
period: {},
}),
};
},
};
vm.createContext(adminContext);
vm.runInContext(fs.readFileSync(path.join(staticDir, "admin.js"), "utf8"), adminContext);
vm.runInContext("loadStudents()", adminContext);
setTimeout(() => {
if (!adminFetchUrls.some((url) => url.startsWith("/api/students"))) {
throw new Error(`学生档案列表未调用 /api/students${adminFetchUrls.join(",")}`);
}
const rowsHtml = adminElements.get("studentRows").innerHTML;
if (!rowsHtml.includes("XS001") || !rowsHtml.includes("2020")) {
throw new Error("学生档案列表未渲染学生ID和入学年份");
}
vm.runInContext('openEditStudentEditor("XS001")', adminContext);
const payload = vm.runInContext("buildStudentPayload()", adminContext);
if (Object.prototype.hasOwnProperty.call(payload, "remaining")) {
throw new Error("学生档案保存 payload 不应包含 remaining");
}
assertEqual("学生档案剩余课时只读展示", adminElements.get("editRemaining").value, "12小时");
console.log("smoke test passed");
}, 0);