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