升级学生档案管理
This commit is contained in:
+2
-2
@@ -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
@@ -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}`:管理后台修改老师档案。
|
||||
|
||||
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -49,6 +49,7 @@ class Account:
|
||||
payments: list[Payment]
|
||||
remaining: float
|
||||
account_status: str
|
||||
primary_entry_year: int | None
|
||||
note: str
|
||||
|
||||
|
||||
|
||||
+18
-8
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 = ""
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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("&", "&")
|
||||
.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)}:${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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user