diff --git a/app/MAINTENANCE.md b/app/MAINTENANCE.md
index e9e03fc..05683d6 100644
--- a/app/MAINTENANCE.md
+++ b/app/MAINTENANCE.md
@@ -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/
diff --git a/app/README.md b/app/README.md
index 6a8a394..12450e7 100644
--- a/app/README.md
+++ b/app/README.md
@@ -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}`:管理后台修改老师档案。
diff --git a/app/app/api_utils.py b/app/app/api_utils.py
index 574be15..8f85404 100644
--- a/app/app/api_utils.py
+++ b/app/app/api_utils.py
@@ -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,
)
diff --git a/app/app/data.py b/app/app/data.py
index 81392cc..11cf92e 100644
--- a/app/app/data.py
+++ b/app/app/data.py
@@ -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": [
{
diff --git a/app/app/db.py b/app/app/db.py
index 3d3528c..fd37da8 100644
--- a/app/app/db.py
+++ b/app/app/db.py
@@ -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():
diff --git a/app/app/domain.py b/app/app/domain.py
index 71667d1..2979421 100644
--- a/app/app/domain.py
+++ b/app/app/domain.py
@@ -49,6 +49,7 @@ class Account:
payments: list[Payment]
remaining: float
account_status: str
+ primary_entry_year: int | None
note: str
diff --git a/app/app/repository.py b/app/app/repository.py
index 20d5db4..812d8b5 100644
--- a/app/app/repository.py
+++ b/app/app/repository.py
@@ -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")
diff --git a/app/app/routers/accounts.py b/app/app/routers/accounts.py
index 66fcf9a..2ebce42 100644
--- a/app/app/routers/accounts.py
+++ b/app/app/routers/accounts.py
@@ -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,
diff --git a/app/app/routers/pages.py b/app/app/routers/pages.py
index a0bfc4b..d17fe94 100644
--- a/app/app/routers/pages.py
+++ b/app/app/routers/pages.py
@@ -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()
diff --git a/app/app/routers/records.py b/app/app/routers/records.py
index ee8c908..d264865 100644
--- a/app/app/routers/records.py
+++ b/app/app/routers/records.py
@@ -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:
diff --git a/app/app/schemas.py b/app/app/schemas.py
index f6118ff..b636f60 100644
--- a/app/app/schemas.py
+++ b/app/app/schemas.py
@@ -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 = ""
diff --git a/app/app/static/accounts.html b/app/app/static/accounts.html
deleted file mode 100644
index 41a268b..0000000
--- a/app/app/static/accounts.html
+++ /dev/null
@@ -1,60 +0,0 @@
-
-
-
-
-
- 课时账户查询
-
-
-
-
-
-
-
-
-
课时账户
-
-
-
-
-
-
-
-
- | 学生 |
- 状态 |
- 剩余课时 |
- 缴费记录 |
- 备注 |
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/app/static/accounts.js b/app/app/static/accounts.js
deleted file mode 100644
index 1b72f96..0000000
--- a/app/app/static/accounts.js
+++ /dev/null
@@ -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 `${escapeHtml(label)}${escapeHtml(value)}
`;
-}
-
-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("
");
-}
-
-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 = `| 正在读取 |
`;
- 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) => `
- ${escapeHtml(row.student)} ${escapeHtml(row.student_id)} |
- ${escapeHtml(row.account_status)} |
- ${escapeHtml(displayHours(row.remaining, row.remaining_duration))} |
- ${renderPayments(row.payments)} |
- ${escapeHtml(row.note || "")} |
-
`,
- )
- .join("");
- if (!data.accounts.length) {
- accountRows.innerHTML = `| 没有符合条件的账户 |
`;
- }
- } catch (error) {
- accountRows.innerHTML = `| 读取失败:${escapeHtml(error.message)} |
`;
- }
-}
-
-accountForm.addEventListener("submit", (event) => {
- event.preventDefault();
- loadAccounts();
-});
-
-accountStatus.addEventListener("change", loadAccounts);
-
-refreshBtn.addEventListener("click", () => {
- loadAccountHealth();
- loadAccounts();
-});
-
-loadAccountHealth();
-loadAccounts();
diff --git a/app/app/static/admin.html b/app/app/static/admin.html
index 91f084d..6ad2cf3 100644
--- a/app/app/static/admin.html
+++ b/app/app/static/admin.html
@@ -22,7 +22,7 @@