Compare commits

...

9 Commits

Author SHA1 Message Date
Codex 73d7d766eb 改造为 SQLite 原生读写 2026-07-06 16:51:14 +08:00
Codex 9884e91612 fix: show full student summaries 2026-07-06 13:35:55 +08:00
Codex bd5fd7815e 清理历史兼容代码和废弃同步入口 2026-06-30 12:11:24 +08:00
Codex 124ffca03e 升级学生档案管理 2026-06-30 11:35:25 +08:00
Codex 93cd2e9c75 按老师数量设置查询结果默认折叠 2026-06-26 17:30:44 +08:00
Codex 2fc32db0d4 上课记录查询完整展示结果 2026-06-26 17:25:44 +08:00
Codex f9050fe40d 修复课程小结失败回滚和日志迁移副作用 2026-06-26 16:19:33 +08:00
Codex 3035094e22 为上课记录表添加分组序号列 2026-06-26 15:11:25 +08:00
Codex d6a4c95d72 Fix review task persistence for corrections 2026-06-26 15:00:04 +08:00
36 changed files with 3631 additions and 2091 deletions
-10
View File
@@ -8,15 +8,5 @@ BASIC_AUTH_PASSWORD=change-me
ADMIN_AUTH_PASSWORD=change-me
INGEST_AUTH_TOKEN=change-this-ingest-token
USE_SQLITE_SOURCE=1
SQLITE_DB_PATH=/data/xsk_education.db
RUNTIME_DATA_ROOT=/data/runtime_text_cache
LEGACY_TEXT_ROOT=/data
CLASSNOTES_PATH=/data/classnotes.txt
ACCOUNTS_PATH=/data/学生课时账户.md
TEACHERS_PATH=/data/教师档案.md
ADMIN_TASKS_PATH=/data/admin_tasks.json
COURSE_SUMMARIES_ROOT=/data/course_summaries
COURSE_SUMMARY_STATE_PATH=/data/course_summary_state.json
OPERATION_LOGS_PATH=/data/operation_logs.jsonl
+5 -11
View File
@@ -41,7 +41,7 @@
- `make check`:检查前端 JS 语法和后端 Python 编译。
- `make smoke`:运行轻量前端行为烟测,覆盖排序按钮、组内排序和纠错后排序。
- `make data-hash`:输出 `classnotes.txt`、`学生课时账户.md`,以及存在时的 `admin_tasks.json`、`course_summary_state.json` 的 SHA-256。
- `make data-hash`:输出 SQLite 数据库、迁移报告和归档校验文件的 SHA-256。
- `make build`:构建 Docker 镜像。
- `make up`:重启 Docker Compose 服务。
- `make health`:带认证访问 `/api/health`。
@@ -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"
```
@@ -104,15 +104,10 @@ git push origin HEAD:<当前分支>
业务数据文件位于:
```text
/root/新时空教务管理系统/data/classnotes.txt
/root/新时空教务管理系统/data/学生课时账户.md
/root/新时空教务管理系统/data/admin_tasks.json
/root/新时空教务管理系统/data/course_summaries/
/root/新时空教务管理系统/data/course_summary_state.json
/root/新时空教务管理系统/data/operation_logs.jsonl
/root/新时空教务管理系统/data/xsk_education.db
```
普通前端和查询类改动不应该改变这些文件;课程小结查询页是只读功能,也不应该改变这些文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、课时账户编辑、课程小结自动入账或审核批准,先确认自动备份目录:
普通前端和查询类改动不应该改变数据库;课程小结查询页是只读功能,也不应该改变数据库。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、学生档案编辑、课程小结自动入账或审核批准,先确认自动备份目录:
```text
/root/新时空教务管理系统/data/backups/
@@ -148,8 +143,7 @@ git push origin HEAD:<当前分支>
## 课程小结迁移维护
- VPS `classnotes.txt` 和 `学生课时账户.md` 唯一正式写入方
- 本机 `com.xsk.education-management.sync` 常驻同步迁移后应停止,避免旧本地数据覆盖 VPS。
- VPS `/data/xsk_education.db` 唯一正式业务数据源
- 本机课程小结采集脚本用 `XSK_INGEST_URL` 和 `XSK_INGEST_TOKEN` 推送批次;失败批次保存在本机 `推送失败队列/`。
- 管理后台的“课程小结审核”处理低置信或冲突小结;“操作记录”追踪接收、自动入账、重复、失败、审核批准和驳回。
- 历史小结导入使用 `scripts/import_course_summaries.py`,历史 `classnotes缺失.txt` 只生成审核任务,不自动扣课时。
+1
View File
@@ -12,6 +12,7 @@ check:
smoke:
node scripts/smoke_test.js
python3 scripts/db_smoke_test.py
data-hash:
sha256sum $(DATA_FILES)
+16 -73
View File
@@ -10,11 +10,8 @@
- `scripts/deploy_to_vps.py`:部署新时空教务管理系统到 VPS。
- `scripts/migrate_text_to_sqlite.py`:一次性把旧纯文本、JSON、JSONL 和课程小结 Markdown 迁移进 SQLite。
- `scripts/import_course_summaries.py`:一次性导入历史课程小结 Markdown。
- `scripts/sync_to_vps.py`:旧版正式数据同步脚本,迁移后不要继续常驻运行。
- `scripts/smoke_test.js`:轻量前端行为烟测。
- `scripts/install_gitea_backup_hook.py`:安装提交后自动推送到 Gitea 的 Git hook。
- `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。
- `launchd/com.xsk.education-management.sync.plist.template`LaunchAgent 模板。
## 维护入口
@@ -35,7 +32,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 模型。
@@ -70,27 +67,6 @@ http://121.199.172.246:18080/
XSK_PYTHON_IMAGE='python:3.12-slim'
```
## 手动同步数据
迁移完成后不要再用本机 `classnotes.txt``学生课时账户.md` 覆盖 VPS。SQLite 数据库 `/data/xsk_education.db` 是唯一事实源;运行时生成的 `/data/runtime_text_cache/` 只是兼容旧业务逻辑的缓存。下面命令只保留给迁移前或灾难恢复时使用,日常新增课程小结应走 `POST /api/ingest/course-summaries`
```bash
XSK_USE_SSHPASS=1 \
XSK_SSH_PASSWORD='填写SSH密码' \
python3 scripts/sync_to_vps.py --once --use-sshpass
```
同步文件:
- `/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户/classnotes.txt`
- `/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户/学生课时账户.md`
远端数据目录:
```text
/root/新时空教务管理系统/data/xsk_education.db
```
## SQLite 迁移
首次迁移先 dry-run
@@ -115,6 +91,8 @@ make migrate-sqlite
/root/新时空教务管理系统/archives/text-source-before-sqlite-<时间>.tar.gz.sha256
```
学生表包含 `primary_entry_year` 字段,用于维护“小学一年级入学年份”。排课系统只读该字段并按课程日期动态推算年级。管理后台的学生档案编辑页可以维护该字段;旧 6/7 列 `学生课时账户.md` 仅作为一次性迁移输入兼容。
## 课程小结推送
VPS 接收接口:
@@ -141,26 +119,19 @@ python3 /Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录
## 历史小结导入
把本机历史课程小结目录同步或上传到 VPS 后,可在容器内执行一次性导入。历史导入只重建小结库和状态;历史 `classnotes缺失.txt` 默认转为审核任务,不自动扣课时。
把本机历史课程小结目录同步或上传到 VPS 后,可在容器内执行一次性导入。历史导入直接写入 SQLite 课程小结表;历史 `classnotes缺失.txt` 默认转为审核任务,不自动扣课时。
```bash
cd /root/新时空教务管理系统/app
docker compose exec xsk-education-management python scripts/import_course_summaries.py \
--source /data/import/课程小结采集 \
--target /data/course_summaries \
--state /data/course_summary_state.json \
--tasks /data/admin_tasks.json \
--operation-logs /data/operation_logs.jsonl \
--db-path /data/xsk_education.db \
--missing-table /data/import/课程小结采集/classnotes缺失.txt
```
## 数据备份
通过登记 API、课程小结自动入账或审核批准修改正式课时数据时,服务会以 SQLite 事务写入 `/data/xsk_education.db`。旧纯文本事实源已封存为归档包;运行时文本缓存位于:
```text
/root/新时空教务管理系统/data/runtime_text_cache/
```
通过登记 API、课程小结自动入账或审核批准修改正式课时数据时,服务会以 SQLite 事务写入 `/data/xsk_education.db`。旧纯文本事实源已封存为归档包,不再作为运行时缓存参与读写。
备份目录位于:
@@ -168,7 +139,7 @@ docker compose exec xsk-education-management python scripts/import_course_summar
/root/新时空教务管理系统/data/backups/
```
每次登记生成一个事务备份目录,目录内包含变更前的业务文件副本`metadata.json`。系统自动保留最近 50 次备份,超过后删除最旧备份。
每次写业务数据前生成一个 SQLite 快照备份目录,目录内包含变更前的数据库快照`metadata.json`。系统自动保留最近 50 次备份,超过后删除最旧备份。
查看备份:
@@ -176,44 +147,15 @@ docker compose exec xsk-education-management python scripts/import_course_summar
ls -lt /root/新时空教务管理系统/data/backups/
```
恢复某次备份时,先停止服务,再把对应备份目录里的文件复制回数据目录,最后重启服务:
恢复某次备份优先使用后台“操作记录”里的撤回按钮。手工恢复时先停止服务,再把对应备份目录里的 `xsk_education.db` 作为数据库恢复源,最后重启服务:
```bash
cd /root/新时空教务管理系统/app
docker compose stop
cp /root/新时空教务管理系统/data/backups/<备份目录>/classnotes.txt /root/新时空教务管理系统/data/classnotes.txt 2>/dev/null || true
cp /root/新时空教务管理系统/data/backups/<备份目录>/学生课时账户.md /root/新时空教务管理系统/data/学生课时账户.md 2>/dev/null || true
cp /root/新时空教务管理系统/data/backups/<备份目录>/xsk_education.db /root/新时空教务管理系统/data/xsk_education.db
docker compose up -d
```
## 安装自动同步
```bash
XSK_USE_SSHPASS=1 \
XSK_SSH_PASSWORD='填写SSH密码' \
python3 scripts/install_launch_agent.py --use-sshpass
```
日志位置:
```text
~/Library/Logs/xsk-education-management/sync.log
~/Library/Logs/xsk-education-management/sync.err.log
```
查看任务:
```bash
launchctl list | grep com.xsk.education-management.sync
```
卸载任务:
```bash
launchctl unload ~/Library/LaunchAgents/com.xsk.education-management.sync.plist
rm ~/Library/LaunchAgents/com.xsk.education-management.sync.plist
```
## 更换网页访问密码
登录 VPS 后修改 `/root/新时空教务管理系统/app/.env` 中的 `BASIC_AUTH_PASSWORD`,然后重启:
@@ -229,12 +171,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}`:管理后台修改老师档案。
+2 -3
View File
@@ -5,7 +5,6 @@ import re
import secrets
from typing import Any
from .config import ACCOUNTS_PATH
from .data import (
SUBJECTS,
canonical_teacher_name,
@@ -16,8 +15,8 @@ from .data import (
normalize_lines,
parse_class_record_line,
parse_payment_line,
read_accounts,
)
from .repository import list_student_names
REGISTER_TYPES = {"class_record", "payment", "course_summary"}
@@ -152,7 +151,7 @@ def _questions_for_missing(register_type: str, missing_fields: list[str]) -> lis
def _known_students() -> list[str]:
try:
return [account.student for account in read_accounts(ACCOUNTS_PATH)]
return list_student_names()
except Exception:
return []
+21 -22
View File
@@ -6,10 +6,11 @@ from pathlib import Path
from fastapi import HTTPException, Request
from pydantic import ValidationError
from .config import ACCOUNTS_PATH, CLASSNOTES_PATH, TEACHERS_PATH, USE_SQLITE_SOURCE
from .data import Account, Payment, Teacher, read_accounts, read_classnotes, read_teachers
from .repository import ensure_runtime_cache
from .schemas import AccountPayload, RegisterLinesPayload, TeacherPayload
from .data import Account, Payment, Teacher
from .repository import load_records as load_records_from_db
from .repository import load_student_profiles as load_student_profiles_from_db
from .repository import load_teachers as load_teachers_from_db
from .schemas import RegisterLinesPayload, StudentProfilePayload, TeacherPayload
async def read_register_payload(request: Request) -> RegisterLinesPayload:
@@ -39,27 +40,24 @@ async def read_register_payload(request: Request) -> RegisterLinesPayload:
def load_records():
if USE_SQLITE_SOURCE:
ensure_runtime_cache()
if not CLASSNOTES_PATH.exists():
raise HTTPException(status_code=503, detail=f"课程记录文件不存在: {CLASSNOTES_PATH}")
return read_classnotes(CLASSNOTES_PATH)
try:
return load_records_from_db()
except ValueError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
def load_accounts():
if USE_SQLITE_SOURCE:
ensure_runtime_cache()
if not ACCOUNTS_PATH.exists():
raise HTTPException(status_code=503, detail=f"课时账户文件不存在: {ACCOUNTS_PATH}")
return read_accounts(ACCOUNTS_PATH)
def load_student_profiles():
try:
return load_student_profiles_from_db()
except ValueError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
def load_teachers():
if USE_SQLITE_SOURCE:
ensure_runtime_cache()
if not TEACHERS_PATH.exists():
return []
return read_teachers(TEACHERS_PATH)
try:
return load_teachers_from_db()
except ValueError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
def file_meta(path: Path) -> dict:
@@ -74,13 +72,14 @@ def file_meta(path: Path) -> dict:
}
def payload_to_account(payload: AccountPayload, student_id: str | None = None) -> Account:
def payload_to_student_profile(payload: StudentProfilePayload, student_id: str | None = None) -> Account:
return Account(
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,
)
+1 -8
View File
@@ -77,13 +77,6 @@ def is_admin_authenticated(
) or has_valid_basic_auth(credentials, ADMIN_AUTH_PASSWORD)
def is_accounts_authenticated(
request: Request,
credentials: HTTPBasicCredentials | None = None,
) -> bool:
return is_admin_authenticated(request, credentials)
def verify_records_auth(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
@@ -110,7 +103,7 @@ def verify_admin_auth(
return "admin"
def verify_accounts_auth(
def verify_student_profiles_auth(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
) -> str:
+1 -54
View File
@@ -9,34 +9,7 @@ APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static"
SQLITE_DB_PATH = Path(os.getenv("SQLITE_DB_PATH", "/data/xsk_education.db"))
RUNTIME_DATA_ROOT = Path(os.getenv("RUNTIME_DATA_ROOT", "/data/runtime_text_cache"))
LEGACY_TEXT_ROOT = Path(os.getenv("LEGACY_TEXT_ROOT", "/data"))
USE_SQLITE_SOURCE = os.getenv("USE_SQLITE_SOURCE", "1").strip().lower() not in {"0", "false", "no"}
if USE_SQLITE_SOURCE:
CLASSNOTES_PATH = RUNTIME_DATA_ROOT / "classnotes.txt"
ACCOUNTS_PATH = RUNTIME_DATA_ROOT / "学生课时账户.md"
TEACHERS_PATH = RUNTIME_DATA_ROOT / "教师档案.md"
ADMIN_TASKS_PATH = RUNTIME_DATA_ROOT / "admin_tasks.json"
COURSE_SUMMARIES_ROOT = RUNTIME_DATA_ROOT / "course_summaries"
COURSE_SUMMARY_STATE_PATH = RUNTIME_DATA_ROOT / "course_summary_state.json"
OPERATION_LOGS_PATH = RUNTIME_DATA_ROOT / "operation_logs.jsonl"
else:
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md"))
TEACHERS_PATH = Path(os.getenv("TEACHERS_PATH", "/data/教师档案.md"))
ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json"))
COURSE_SUMMARIES_ROOT = Path(os.getenv("COURSE_SUMMARIES_ROOT", "/data/course_summaries"))
COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/course_summary_state.json"))
OPERATION_LOGS_PATH = Path(os.getenv("OPERATION_LOGS_PATH", "/data/operation_logs.jsonl"))
LEGACY_CLASSNOTES_PATH = Path(os.getenv("LEGACY_CLASSNOTES_PATH", str(LEGACY_TEXT_ROOT / "classnotes.txt")))
LEGACY_ACCOUNTS_PATH = Path(os.getenv("LEGACY_ACCOUNTS_PATH", str(LEGACY_TEXT_ROOT / "学生课时账户.md")))
LEGACY_TEACHERS_PATH = Path(os.getenv("LEGACY_TEACHERS_PATH", str(LEGACY_TEXT_ROOT / "教师档案.md")))
LEGACY_ADMIN_TASKS_PATH = Path(os.getenv("LEGACY_ADMIN_TASKS_PATH", str(LEGACY_TEXT_ROOT / "admin_tasks.json")))
LEGACY_COURSE_SUMMARIES_ROOT = Path(os.getenv("LEGACY_COURSE_SUMMARIES_ROOT", str(LEGACY_TEXT_ROOT / "course_summaries")))
LEGACY_COURSE_SUMMARY_STATE_PATH = Path(os.getenv("LEGACY_COURSE_SUMMARY_STATE_PATH", str(LEGACY_TEXT_ROOT / "course_summary_state.json")))
LEGACY_OPERATION_LOGS_PATH = Path(os.getenv("LEGACY_OPERATION_LOGS_PATH", str(LEGACY_TEXT_ROOT / "operation_logs.jsonl")))
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
@@ -48,30 +21,4 @@ ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session"
ADMIN_SESSION_COOKIE = "xsk_admin_session"
SESSION_MAX_AGE = 60 * 60 * 24 * 30
class SQLiteBackedWriteLock:
def __init__(self) -> None:
self._lock = threading.Lock()
def __enter__(self):
self._lock.acquire()
if USE_SQLITE_SOURCE:
from .repository import export_database_to_runtime_cache
export_database_to_runtime_cache()
return self
def __exit__(self, exc_type, exc, tb) -> bool:
try:
if USE_SQLITE_SOURCE:
from .repository import export_database_to_runtime_cache, sync_runtime_cache_to_database
if exc_type is None:
sync_runtime_cache_to_database()
else:
export_database_to_runtime_cache()
finally:
self._lock.release()
return False
write_lock = SQLiteBackedWriteLock() if USE_SQLITE_SOURCE else threading.Lock()
write_lock = threading.Lock()
+132 -208
View File
@@ -97,8 +97,9 @@ def format_payment(payment: Payment) -> str:
def format_account_row(account: Account) -> str:
payments = ",".join(format_payment(payment) for payment in account.payments)
primary_entry_year = str(account.primary_entry_year or "")
return (
f"| {account.student_id} | {account.student} | {payments} | "
f"| {account.student_id} | {account.student} | {primary_entry_year} | {payments} | "
f"{format_number(account.remaining)} | {account.account_status} | {account.note} |"
)
@@ -141,18 +142,22 @@ def validate_payment(payment: Payment) -> Payment:
def validate_account(account: Account) -> Account:
student_id = account.student_id.strip()
student = canonical_name(account.student)
primary_entry_year = account.primary_entry_year
if not re.fullmatch(r"XS\d{3}", student_id):
raise ValueError("学生ID格式应为 XS001 这样的三位编号")
if not student:
raise ValueError("学生姓名不能为空")
if account.account_status not in ACCOUNT_STATUSES:
raise ValueError("账户状态必须是 正常、预警、欠费、结课、退费")
raise ValueError("档案状态必须是 正常、预警、欠费、结课、退费")
if primary_entry_year is not None and not 1900 <= int(primary_entry_year) <= 2100:
raise ValueError("入学年份必须是 1900 到 2100 之间的年份")
return Account(
student_id=student_id,
student=student,
payments=[validate_payment(payment) for payment in account.payments],
remaining=round(float(account.remaining), 2),
account_status=account.account_status,
primary_entry_year=int(primary_entry_year) if primary_entry_year is not None else None,
note=account.note.strip(),
)
@@ -222,18 +227,29 @@ def read_accounts(path: Path) -> list[Account]:
parts = [part.strip() for part in line.strip("|").split("|")]
if len(parts) < 5:
continue
if len(parts) >= 7:
student_id, student, primary_entry_year_text, payments_text, remaining_text, status_text, note_text = parts[:7]
else:
student_id, student, payments_text, remaining_text, status_text = parts[:5]
primary_entry_year_text = ""
note_text = parts[5] if len(parts) > 5 else ""
try:
remaining = float(parts[3])
remaining = float(remaining_text)
except ValueError as exc:
raise ValueError(f"{path}:{line_number} 无法解析剩余课时: {parts[3]}") from exc
raise ValueError(f"{path}:{line_number} 无法解析剩余课时: {remaining_text}") from exc
try:
primary_entry_year = int(primary_entry_year_text) if primary_entry_year_text else None
except ValueError as exc:
raise ValueError(f"{path}:{line_number} 无法解析入学年份: {primary_entry_year_text}") from exc
accounts.append(
Account(
student_id=parts[0],
student=canonical_name(parts[1]),
payments=parse_payments(parts[2]),
student_id=student_id,
student=canonical_name(student),
payments=parse_payments(payments_text),
remaining=remaining,
account_status=parts[4],
note=parts[5] if len(parts) > 5 else "",
account_status=status_text,
primary_entry_year=primary_entry_year,
note=note_text,
)
)
return accounts
@@ -381,7 +397,7 @@ def find_account_index(accounts: list[Account], student: str) -> int:
for index, account in enumerate(accounts):
if account.student == canonical_student or account.student_id == canonical_student:
return index
raise ValueError(f"未找到学生账户: {student}")
raise ValueError(f"未找到学生档案: {student}")
def update_account_remaining(account: Account, delta_hours: float) -> Account:
@@ -398,6 +414,18 @@ def append_account_payment(account: Account, payment: Payment) -> Account:
return replace(updated, account_status=recalc_account_status(updated))
def used_hours_for_student(records: list[ClassRecord], student: str) -> float:
canonical_student = canonical_name(student)
return round(sum(record.duration_hours for record in records if record.student == canonical_student), 2)
def derive_account_remaining(account: Account, records: list[ClassRecord]) -> Account:
paid = round(sum(payment.hours for payment in account.payments), 2)
used = used_hours_for_student(records, account.student)
updated = replace(account, remaining=round(paid - used, 2))
return replace(updated, account_status=recalc_account_status(updated))
def replace_account_lines(original_text: str, accounts_by_id: dict[str, Account]) -> str:
lines = original_text.splitlines()
output: list[str] = []
@@ -442,7 +470,7 @@ def replace_single_account_line(original_text: str, old_student_id: str, account
continue
output.append(line)
if not replaced:
raise ValueError(f"未找到学生账户: {old_student_id}")
raise ValueError(f"未找到学生档案: {old_student_id}")
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(output) + trailing_newline
@@ -456,12 +484,6 @@ def next_student_id(accounts: list[Account]) -> str:
return f"XS{(max(values) if values else 0) + 1:03d}"
def write_accounts(path: Path, accounts: list[Account]) -> None:
original_text = path.read_text(encoding="utf-8")
accounts_by_id = {account.student_id: account for account in accounts}
atomic_write_text(path, replace_account_lines(original_text, accounts_by_id))
def next_teacher_id(teachers: list[Teacher]) -> str:
values = []
for teacher in teachers:
@@ -471,22 +493,6 @@ def next_teacher_id(teachers: list[Teacher]) -> str:
return f"T{(max(values) if values else 0) + 1:03d}"
def replace_teacher_lines(original_text: str, teachers_by_id: dict[str, Teacher]) -> str:
lines = original_text.splitlines()
output: list[str] = []
for line in lines:
stripped = line.strip()
compact = stripped.strip("|").replace(" ", "").replace("|", "")
if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped:
parts = [part.strip() for part in stripped.strip("|").split("|")]
if parts and parts[0] in teachers_by_id:
output.append(format_teacher_row(teachers_by_id[parts[0]]))
continue
output.append(line)
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(output) + trailing_newline
def append_teacher_line(original_text: str, teacher: Teacher) -> str:
if not original_text.strip():
original_text = (
@@ -525,12 +531,6 @@ def replace_single_teacher_line(original_text: str, old_teacher_id: str, teacher
return "\n".join(output) + trailing_newline
def write_teachers(path: Path, teachers: list[Teacher]) -> None:
original_text = path.read_text(encoding="utf-8") if path.exists() else ""
teachers_by_id = {teacher.teacher_id: teacher for teacher in teachers}
atomic_write_text(path, replace_teacher_lines(original_text, teachers_by_id))
def create_teacher(path: Path, teacher: Teacher) -> dict:
teachers = read_teachers(path)
teacher = validate_teacher(replace(teacher, teacher_id=next_teacher_id(teachers)))
@@ -587,19 +587,13 @@ def teacher_alias_map(teachers: list[Teacher]) -> dict[str, str]:
return mapping
def teacher_name_map(teachers: list[Teacher]) -> dict[str, Teacher]:
mapping: dict[str, Teacher] = {}
for teacher in teachers:
mapping[teacher.name] = teacher
if teacher.alias:
mapping[teacher.alias] = teacher
mapping[teacher.teacher_id] = 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 +603,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,18 +622,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": "修改课时账户"}
def render_accounts_text(path: Path, accounts: list[Account]) -> str:
original_text = path.read_text(encoding="utf-8")
accounts_by_id = {account.student_id: account for account in accounts}
return replace_account_lines(original_text, accounts_by_id)
def render_accounts_text_from_text(original_text: str, accounts: list[Account]) -> str:
accounts_by_id = {account.student_id: account for account in accounts}
return replace_account_lines(original_text, accounts_by_id)
return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "修改学生档案"}
def normalize_lines(lines: list[str] | None = None, line: str | None = None) -> list[str]:
@@ -748,115 +732,6 @@ def register_payment_lines(
return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name, "operation": "登记缴费记录"}
def normalize_classnote_durations_and_accounts(
classnotes_path: Path,
accounts_path: Path,
operation_logs_path: Path | None = None,
) -> dict:
original_classnotes = classnotes_path.read_text(encoding="utf-8")
original_accounts = accounts_path.read_text(encoding="utf-8")
accounts = read_accounts(accounts_path)
updated_accounts = list(accounts)
account_deltas: defaultdict[str, float] = defaultdict(float)
changed_lines: list[str] = []
output_lines: list[str] = []
skipped_lines: list[str] = []
for line_number, raw_line in enumerate(original_classnotes.splitlines(), start=1):
stripped = raw_line.strip()
if not stripped or stripped.startswith("#"):
output_lines.append(raw_line)
continue
match = CLASSNOTE_RE.fullmatch(stripped)
if not match:
output_lines.append(raw_line)
skipped_lines.append(f"{line_number}: {stripped}")
continue
old_minutes = int(round(parse_hours_text(match.group("duration")) * 60))
true_minutes = parse_time_range_minutes(match.group("time"))
true_duration = duration_text_from_minutes(true_minutes)
if true_minutes == old_minutes and match.group("duration") == true_duration:
output_lines.append(raw_line)
continue
student = canonical_name(match.group("student"))
account_index = find_account_index(updated_accounts, student)
delta_hours = round((old_minutes - true_minutes) / 60.0, 2)
if delta_hours:
updated_accounts[account_index] = update_account_remaining(updated_accounts[account_index], delta_hours)
account_deltas[updated_accounts[account_index].student_id] += delta_hours
new_line = (
f"{match.group('date')}-{match.group('weekday')}-{match.group('time')}-"
f"{student}-{true_duration}-{match.group('teacher').strip()}-{match.group('subject').strip()}"
)
output_lines.append(new_line)
changed_lines.append(f"{line_number}: {stripped} => {new_line}")
if not changed_lines:
return {
"updated_records": 0,
"updated_accounts": 0,
"account_deltas": {},
"skipped_lines": skipped_lines,
"backup_id": "",
}
trailing_newline = "\n" if original_classnotes.endswith("\n") else ""
new_classnotes = "\n".join(output_lines) + trailing_newline
changed_account_ids = {account_id for account_id, delta in account_deltas.items() if round(delta, 2)}
updated_accounts_by_id = {
account.student_id: account
for account in updated_accounts
if account.student_id in changed_account_ids
}
new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id)
backup_dir = create_data_backup(
"normalize-classnote-durations",
{
accounts_path: original_accounts,
classnotes_path: original_classnotes,
},
changed_lines,
)
try:
atomic_write_text(classnotes_path, new_classnotes)
atomic_write_text(accounts_path, new_accounts)
except Exception:
atomic_write_text(classnotes_path, original_classnotes)
atomic_write_text(accounts_path, original_accounts)
raise
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
result = {
"updated_records": len(changed_lines),
"updated_accounts": len(updated_accounts_by_id),
"account_deltas": {
account_id: round(delta, 2)
for account_id, delta in sorted(account_deltas.items())
if round(delta, 2)
},
"skipped_lines": skipped_lines,
"backup_id": backup_dir.name,
}
if operation_logs_path is not None:
append_operation_log(
operation_logs_path,
"历史上课记录真实时长迁移",
"完成",
backup_id=backup_dir.name,
updated_records=result["updated_records"],
updated_accounts=result["updated_accounts"],
account_deltas=result["account_deltas"],
skipped_lines_count=len(skipped_lines),
)
return result
def default_admin_tasks() -> dict:
return {"version": 1, "next_id": 1, "items": []}
@@ -1398,6 +1273,24 @@ def sha1_text(value: str, length: int = 16) -> str:
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:length]
def snapshot_text_files(paths: Iterable[Path]) -> dict[Path, str | None]:
snapshots: dict[Path, str | None] = {}
for path in paths:
if path in snapshots:
continue
snapshots[path] = path.read_text(encoding="utf-8") if path.exists() else None
return snapshots
def restore_text_file_snapshots(snapshots: dict[Path, str | None]) -> None:
for path, original_text in snapshots.items():
if original_text is None:
if path.exists():
path.unlink()
continue
atomic_write_text(path, original_text)
def payload_bool(value: object) -> bool:
if isinstance(value, bool):
return value
@@ -1737,8 +1630,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": "审核批准上课记录纠错",
@@ -3739,6 +3632,24 @@ def approve_admin_task(
raise ValueError("不支持的审核任务类型")
COURSE_SUMMARY_RESULT_COUNTER_KEYS = ("saved", "auto_registered", "review_pending", "duplicates", "rejected")
def snapshot_course_summary_result(result: dict) -> dict:
return {
**{key: result[key] for key in COURSE_SUMMARY_RESULT_COUNTER_KEYS},
"operation_log_ids": list(result["operation_log_ids"]),
"items": list(result["items"]),
}
def restore_course_summary_result(result: dict, snapshot: dict) -> None:
for key in COURSE_SUMMARY_RESULT_COUNTER_KEYS:
result[key] = snapshot[key]
result["operation_log_ids"] = list(snapshot["operation_log_ids"])
result["items"] = list(snapshot["items"])
def register_course_summary_texts(
*,
classnotes_path: Path,
@@ -3767,14 +3678,25 @@ def register_course_summary_texts(
for index, text in enumerate(texts):
raw: dict | None = None
normalized: dict | None = None
file_snapshots: dict[Path, str | None] | None = None
result_snapshot = snapshot_course_summary_result(result)
try:
raw = extract_course_summary_from_text(text, index, known_students=known_students)
normalized = normalize_course_summary(raw)
source_id = normalized["source_id"]
semantic_key = course_summary_semantic_key(normalized)
summary_path = course_summary_path(summaries_root, normalized)
state = read_course_summary_state(state_path)
seen_source_ids = set(str(item) for item in state.get("seen_source_ids", []))
seen_semantic_keys = set(str(item) for item in state.get("seen_semantic_keys", []))
file_snapshots = snapshot_text_files([
classnotes_path,
accounts_path,
tasks_path,
state_path,
operation_logs_path,
summary_path,
])
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
duplicate_reasons, duplicate_conflicts, source_id_duplicate, semantic_duplicate = course_summary_duplicate_review_context(
summaries_root,
@@ -3794,7 +3716,7 @@ def register_course_summary_texts(
normalized,
proposed_line,
review_reasons,
str(course_summary_path(summaries_root, normalized)),
str(summary_path),
extra_fields={
"duplicate_source": summary_as_duplicate_candidate(normalized),
"duplicate_reasons": duplicate_reasons,
@@ -3838,7 +3760,7 @@ def register_course_summary_texts(
proposed_line=proposed_line,
reasons=review_reasons,
task_id=str(task.get("id") or ""),
saved_path=str(course_summary_path(summaries_root, normalized)),
saved_path=str(summary_path),
duplicate_source_id=source_id_duplicate,
duplicate_semantic_key=semantic_duplicate,
)
@@ -4027,7 +3949,12 @@ def register_course_summary_texts(
"backup_id": str(register_result.get("backup_id") or ""),
}
)
except ValueError as exc:
except Exception as exc:
if file_snapshots is not None:
restore_text_file_snapshots(file_snapshots)
restore_course_summary_result(result, result_snapshot)
if not isinstance(exc, ValueError):
raise
source_id = str((normalized or raw or {}).get("source_id") or f"manual:{sha1_text(text, 24)}")
student = str((normalized or raw or {}).get("student") or "")
log_id = append_operation_log(
@@ -4080,6 +4007,10 @@ def ingest_course_summaries(
for raw in summaries:
normalized: dict | None = None
backup_id = ""
file_snapshots: dict[Path, str | None] | None = None
result_snapshot = snapshot_course_summary_result(result)
seen_source_ids_before = set(seen_source_ids)
seen_semantic_keys_before = set(seen_semantic_keys)
try:
normalized = normalize_course_summary(raw)
ai_questions = [
@@ -4089,6 +4020,15 @@ def ingest_course_summaries(
]
source_id = normalized["source_id"]
semantic_key = course_summary_semantic_key(normalized)
summary_path = course_summary_path(summaries_root, normalized)
file_snapshots = snapshot_text_files([
classnotes_path,
accounts_path,
tasks_path,
state_path,
operation_logs_path,
summary_path,
])
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
duplicate_reasons, duplicate_conflicts, source_id_duplicate, semantic_duplicate = course_summary_duplicate_review_context(
summaries_root,
@@ -4108,7 +4048,7 @@ def ingest_course_summaries(
normalized,
proposed_line,
review_reasons,
str(course_summary_path(summaries_root, normalized)),
str(summary_path),
extra_fields={
"duplicate_source": summary_as_duplicate_candidate(normalized),
"duplicate_reasons": duplicate_reasons,
@@ -4135,7 +4075,7 @@ def ingest_course_summaries(
proposed_line=proposed_line,
reasons=review_reasons,
task_id=task.get("id"),
saved_path=str(course_summary_path(summaries_root, normalized)),
saved_path=str(summary_path),
duplicate_source_id=source_id_duplicate,
duplicate_semantic_key=semantic_duplicate,
)
@@ -4242,6 +4182,11 @@ def ingest_course_summaries(
}
)
except Exception as exc:
if file_snapshots is not None:
restore_text_file_snapshots(file_snapshots)
seen_source_ids = seen_source_ids_before
seen_semantic_keys = seen_semantic_keys_before
restore_course_summary_result(result, result_snapshot)
ai_questions = [
str(item).strip()
for item in ((raw if isinstance(raw, dict) else {}).get("ai_questions") or [])
@@ -4701,6 +4646,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,
}
@@ -4709,28 +4655,6 @@ def has_filter_condition(spec: QuerySpec) -> bool:
return bool(spec.start_date or spec.end_date or spec.students or spec.teachers or spec.subjects)
def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> dict:
spec = build_query_spec(query, records)
matched = filter_records(records, spec) if has_filter_condition(spec) else []
shown, normalized_offset, has_more = paginate_items(matched, 0, limit)
return {
"query": {
"raw_query": spec.raw_query,
"date_range": format_date_range(spec),
"students": spec.students,
"teachers": spec.teachers,
"subjects": spec.subjects,
},
"summary": summarize_records(matched),
"records": [record_to_dict(record) for record in shown],
"total_records": len(matched),
"shown_records": len(shown),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
}
def query_public_records(
records: list[ClassRecord],
teachers: list[Teacher],
@@ -4893,8 +4817,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": {
@@ -4919,7 +4843,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": [
{
+33 -1
View File
@@ -8,7 +8,7 @@ from typing import Iterator
from .config import SQLITE_DB_PATH
SCHEMA_VERSION = 1
SCHEMA_VERSION = 3
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
@@ -133,11 +134,21 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
subject TEXT NOT NULL DEFAULT '',
date_iso TEXT NOT NULL DEFAULT '',
time_range TEXT NOT NULL DEFAULT '',
duration_minutes INTEGER,
message_time TEXT NOT NULL DEFAULT '',
message_date TEXT NOT NULL DEFAULT '',
sender TEXT NOT NULL DEFAULT '',
sender_id TEXT NOT NULL DEFAULT '',
source_id TEXT NOT NULL DEFAULT '',
source_db TEXT NOT NULL DEFAULT '',
local_id TEXT NOT NULL DEFAULT '',
recognition_source TEXT NOT NULL DEFAULT '',
confidence TEXT NOT NULL DEFAULT '',
teacher_trusted INTEGER NOT NULL DEFAULT 0,
remark TEXT NOT NULL DEFAULT '',
semantic_key TEXT NOT NULL DEFAULT '',
content_hash TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
sort_order INTEGER NOT NULL DEFAULT 0
);
@@ -163,16 +174,37 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
CREATE INDEX IF NOT EXISTS idx_class_records_student_date ON class_records(student, record_date);
CREATE INDEX IF NOT EXISTS idx_class_records_teacher ON class_records(teacher);
CREATE INDEX IF NOT EXISTS idx_class_records_record_date ON class_records(record_date);
CREATE INDEX IF NOT EXISTS idx_course_summaries_identity ON course_summaries(student, teacher, subject, date_iso, time_range);
CREATE INDEX IF NOT EXISTS idx_course_summaries_source_id ON course_summaries(source_id);
CREATE INDEX IF NOT EXISTS idx_course_summaries_semantic_key ON course_summaries(semantic_key);
CREATE INDEX IF NOT EXISTS idx_admin_tasks_status_type ON admin_tasks(status, task_type);
CREATE INDEX IF NOT EXISTS idx_operation_logs_created_at ON operation_logs(created_at);
"""
)
ensure_column(conn, "students", "primary_entry_year", "INTEGER")
ensure_column(conn, "course_summaries", "duration_minutes", "INTEGER")
ensure_column(conn, "course_summaries", "message_date", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "course_summaries", "sender_id", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "course_summaries", "source_db", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "course_summaries", "local_id", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "course_summaries", "recognition_source", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "course_summaries", "confidence", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "course_summaries", "teacher_trusted", "INTEGER NOT NULL DEFAULT 0")
ensure_column(conn, "course_summaries", "remark", "TEXT NOT NULL DEFAULT ''")
ensure_column(conn, "course_summaries", "payload_json", "TEXT NOT NULL DEFAULT '{}'")
conn.execute(
"INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', ?)",
(str(SCHEMA_VERSION),),
)
def ensure_column(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None:
columns = {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})")}
if column not in columns:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
def database_meta(db_path: Path | None = None) -> dict:
path = db_path or SQLITE_DB_PATH
if not path.exists():
+1
View File
@@ -49,6 +49,7 @@ class Account:
payments: list[Payment]
remaining: float
account_status: str
primary_entry_year: int | None
note: str
+5 -7
View File
@@ -3,18 +3,16 @@ from __future__ import annotations
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from .config import USE_SQLITE_SOURCE
from .repository import ensure_runtime_cache
from .routers import accounts, admin, ai_register, health, ingest, pages, records
from .repository import initialize_runtime_database
from .routers import admin, ai_register, health, ingest, pages, records, student_profiles
app = FastAPI(title="新时空教务管理系统", version="1.0.0")
@app.on_event("startup")
def prepare_sqlite_runtime_cache() -> None:
if USE_SQLITE_SOURCE:
ensure_runtime_cache()
def prepare_sqlite_runtime() -> None:
initialize_runtime_database()
@app.exception_handler(ValueError)
@@ -25,7 +23,7 @@ async def value_error_handler(_request: Request, exc: ValueError):
app.include_router(pages.router)
app.include_router(health.router)
app.include_router(records.router)
app.include_router(accounts.router)
app.include_router(student_profiles.router)
app.include_router(admin.router)
app.include_router(ai_register.router)
app.include_router(ingest.router)
+2677 -185
View File
File diff suppressed because it is too large Load Diff
-273
View File
@@ -1,273 +0,0 @@
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ..api_utils import file_meta, load_accounts, load_teachers, payload_to_account, payload_to_teacher, read_register_payload
from ..auth import verify_accounts_auth, verify_admin_auth
from ..config import (
ACCOUNTS_PATH,
ADMIN_TASKS_PATH,
CLASSNOTES_PATH,
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
LEGACY_TEXT_ROOT,
OPERATION_LOGS_PATH,
TEACHERS_PATH,
USE_SQLITE_SOURCE,
write_lock,
)
from ..db import database_meta
from ..data import (
ACCOUNT_STATUSES,
TEACHER_STATUSES,
DuplicateRecordError,
account_summary,
account_to_dict,
append_operation_log,
create_account,
create_teacher,
duration_text_from_hours,
filter_accounts,
iter_course_summary_markdown,
parse_class_record_line,
read_classnotes,
register_class_record_lines,
register_course_summary_texts,
register_payment_lines,
teacher_to_dict,
update_account,
update_teacher,
)
from ..schemas import AccountPayload, TeacherPayload
router = APIRouter()
def compact_duration_text(hours: float) -> str:
text = duration_text_from_hours(hours)
return text[:-2] if text.endswith("小时0分") else text
@router.post("/api/register/class-records")
async def register_class_records(request: Request, _user: str = Depends(verify_admin_auth)):
try:
payload = await read_register_payload(request)
with write_lock:
result = register_class_record_lines(
CLASSNOTES_PATH,
ACCOUNTS_PATH,
lines=payload.lines,
line=payload.line,
)
for line in result.get("lines", []):
record = parse_class_record_line(str(line))
append_operation_log(
OPERATION_LOGS_PATH,
str(result.get("operation") or "登记上课记录"),
"完成",
student=record.student,
proposed_line=str(line),
backup_id=str(result.get("backup_id") or ""),
)
except DuplicateRecordError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/register/payments")
async def register_payments(request: Request, _user: str = Depends(verify_admin_auth)):
try:
payload = await read_register_payload(request)
with write_lock:
result = register_payment_lines(ACCOUNTS_PATH, lines=payload.lines, line=payload.line)
for line in result.get("lines", []):
student = str(line).split("-", 1)[0]
append_operation_log(
OPERATION_LOGS_PATH,
str(result.get("operation") or "登记缴费记录"),
"完成",
student=student,
proposed_line=str(line),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/register/course-summaries")
async def register_course_summaries(request: Request, _user: str = Depends(verify_admin_auth)):
try:
payload = await read_register_payload(request)
with write_lock:
result = register_course_summary_texts(
classnotes_path=CLASSNOTES_PATH,
accounts_path=ACCOUNTS_PATH,
tasks_path=ADMIN_TASKS_PATH,
summaries_root=COURSE_SUMMARIES_ROOT,
state_path=COURSE_SUMMARY_STATE_PATH,
operation_logs_path=OPERATION_LOGS_PATH,
lines=payload.lines,
line=payload.line,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.get("/api/account-health")
def account_health(_user: str = Depends(verify_accounts_auth)):
accounts = load_accounts()
teachers = load_teachers()
records = read_classnotes(CLASSNOTES_PATH) if CLASSNOTES_PATH.exists() else []
current_month_prefix = date.today().strftime("%Y.%m.")
current_month_hours = round(
sum(record.duration_hours for record in records if record.date.startswith(current_month_prefix)),
2,
)
course_summaries_count = sum(1 for _item in iter_course_summary_markdown(COURSE_SUMMARIES_ROOT))
return {
"ok": True,
"accounts": file_meta(ACCOUNTS_PATH),
"teachers": file_meta(TEACHERS_PATH),
"classnotes": file_meta(CLASSNOTES_PATH),
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
"database": database_meta(),
"legacy_archive": {
"source_mode": "sqlite" if USE_SQLITE_SOURCE else "text",
"text_root": str(LEGACY_TEXT_ROOT),
},
"accounts_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),
}
@router.get("/api/accounts")
def accounts(
q: str = Query("", description="学生姓名或学生ID"),
status_filter: str = Query("", alias="status", description="账户状态"),
_user: str = Depends(verify_accounts_auth),
):
all_accounts = load_accounts()
rows = filter_accounts(all_accounts, keyword=q, status=status_filter)
return {
"summary": account_summary(all_accounts),
"count": len(rows),
"accounts": [account_to_dict(account) for account in rows],
}
@router.get("/api/accounts/{student}")
def account_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}")
@router.get("/api/admin/statuses")
def admin_statuses(_user: str = Depends(verify_admin_auth)):
return {"account_statuses": sorted(ACCOUNT_STATUSES)}
@router.get("/api/admin/teacher-statuses")
def admin_teacher_statuses(_user: str = Depends(verify_admin_auth)):
return {"teacher_statuses": sorted(TEACHER_STATUSES)}
@router.get("/api/admin/teachers")
def admin_teachers(_user: str = Depends(verify_admin_auth)):
return {
"teachers": [teacher_to_dict(teacher) for teacher in load_teachers()],
}
@router.post("/api/admin/teachers")
def admin_create_teacher(payload: TeacherPayload, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = create_teacher(TEACHERS_PATH, payload_to_teacher(payload))
log_operation = str(result.get("operation") or "新增老师档案")
append_operation_log(
OPERATION_LOGS_PATH,
log_operation,
"完成",
teacher_id=str(result.get("teacher", {}).get("teacher_id") or ""),
teacher=str(result.get("teacher", {}).get("name") or ""),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.put("/api/admin/teachers/{teacher_id}")
def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = update_teacher(
TEACHERS_PATH,
teacher_id,
payload_to_teacher(payload, teacher_id=payload.teacher_id.strip() or teacher_id),
)
log_operation = str(result.get("operation") or "修改老师档案")
append_operation_log(
OPERATION_LOGS_PATH,
log_operation,
"完成",
teacher_id=str(result.get("teacher", {}).get("teacher_id") or ""),
teacher=str(result.get("teacher", {}).get("name") or ""),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/admin/accounts")
def admin_create_account(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 "新增课时账户")
append_operation_log(
OPERATION_LOGS_PATH,
log_operation,
"完成",
student_id=str(result.get("account", {}).get("student_id") or ""),
student=str(result.get("account", {}).get("student") or ""),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
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)):
try:
with write_lock:
result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload))
log_operation = str(result.get("operation") or "修改课时账户")
append_operation_log(
OPERATION_LOGS_PATH,
log_operation,
"完成",
student_id=str(result.get("account", {}).get("student_id") or ""),
student=str(result.get("account", {}).get("student") or ""),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
+17 -214
View File
@@ -1,34 +1,20 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import verify_admin_auth
from ..config import (
ACCOUNTS_PATH,
ADMIN_TASKS_PATH,
CLASSNOTES_PATH,
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
OPERATION_LOGS_PATH,
TEACHERS_PATH,
write_lock,
)
from ..data import (
admin_dashboard_summary,
append_operation_log,
from ..repository import (
admin_dashboard,
approve_admin_task,
create_course_summary_duplicate_review_tasks,
delete_course_summary,
link_existing_course_summary_task,
list_admin_tasks,
list_operation_logs,
migrate_operation_log_labels,
query_course_summaries,
reject_admin_task,
resolve_duplicate_course_summary_task,
rollback_operation_log,
scan_duplicate_course_summaries,
update_course_summary_body,
update_course_summary_identity,
update_course_summary_review_task,
@@ -39,28 +25,10 @@ from ..data import (
router = APIRouter()
def rollback_backup_paths() -> list[Path]:
return [
CLASSNOTES_PATH,
ACCOUNTS_PATH,
ADMIN_TASKS_PATH,
COURSE_SUMMARIES_ROOT,
OPERATION_LOGS_PATH,
]
@router.get("/api/admin/dashboard")
def admin_dashboard(period: str = Query("month"), _user: str = Depends(verify_admin_auth)):
def admin_dashboard_endpoint(period: str = Query("month"), _user: str = Depends(verify_admin_auth)):
try:
return admin_dashboard_summary(
classnotes_path=CLASSNOTES_PATH,
accounts_path=ACCOUNTS_PATH,
teachers_path=TEACHERS_PATH,
tasks_path=ADMIN_TASKS_PATH,
summaries_root=COURSE_SUMMARIES_ROOT,
operation_logs_path=OPERATION_LOGS_PATH,
period=period,
)
return admin_dashboard(period=period)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -73,17 +41,7 @@ def admin_tasks(
offset: int = Query(0, ge=0),
_user: str = Depends(verify_admin_auth),
):
try:
return list_admin_tasks(
ADMIN_TASKS_PATH,
status_filter=status_filter,
task_type=task_type,
classnotes_path=CLASSNOTES_PATH,
offset=offset,
limit=limit,
)
except ValueError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return list_admin_tasks(status_filter=status_filter, task_type=task_type, offset=offset, limit=limit)
@router.get("/api/admin/operation-logs")
@@ -95,35 +53,13 @@ def admin_operation_logs(
student: str = Query(""),
_user: str = Depends(verify_admin_auth),
):
migrate_operation_log_labels(OPERATION_LOGS_PATH)
return list_operation_logs(
OPERATION_LOGS_PATH,
limit=limit,
offset=offset,
operation=operation,
status_filter=status_filter,
student=student,
backup_paths=rollback_backup_paths(),
)
return list_operation_logs(limit=limit, offset=offset, operation=operation, status_filter=status_filter, student=student)
@router.post("/api/admin/operation-logs/{log_id}/rollback")
def admin_rollback_operation_log(log_id: str, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = rollback_operation_log(OPERATION_LOGS_PATH, log_id, rollback_backup_paths())
append_operation_log(
OPERATION_LOGS_PATH,
"撤回操作",
"已撤回",
target_log_id=log_id,
target_backup_id=str(result.get("target_backup_id") or ""),
backup_id=str(result.get("backup_id") or ""),
restored_files=result.get("restored_files") or [],
rollback_mode=str(result.get("rollback_mode") or ""),
removed_lines=result.get("removed_lines") or [],
restored_hours=result.get("restored_hours") or "",
)
result = rollback_operation_log(log_id)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -146,8 +82,6 @@ def admin_course_summaries(
):
try:
return query_course_summaries(
COURSE_SUMMARIES_ROOT,
CLASSNOTES_PATH,
q=q,
student=student,
teacher=teacher,
@@ -167,20 +101,7 @@ def admin_course_summaries(
@router.post("/api/admin/tasks/{task_id}/approve")
def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = approve_admin_task(ADMIN_TASKS_PATH, COURSE_SUMMARIES_ROOT, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id)
task = result.get("task", {})
append_operation_log(
OPERATION_LOGS_PATH,
"审核批准",
"已批准",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
source_id=str(task.get("source_id") or ""),
backup_id=str(result.get("backup_id") or task.get("backup_id") or ""),
saved_path=str(task.get("saved_path") or ""),
)
result = approve_admin_task(task_id)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -188,26 +109,8 @@ def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
@router.post("/api/admin/tasks/{task_id}/resolve-duplicate-summary")
def admin_resolve_duplicate_summary(task_id: int, payload: dict, _user: str = Depends(verify_admin_auth)):
delete_summary_id = str(payload.get("delete_summary_id") or "")
try:
with write_lock:
result = resolve_duplicate_course_summary_task(
ADMIN_TASKS_PATH,
COURSE_SUMMARIES_ROOT,
task_id,
delete_summary_id,
)
task = result.get("task", {})
append_operation_log(
OPERATION_LOGS_PATH,
"重复小结删除",
"已删除",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or ""),
summary_id=str(result.get("deleted_summary_id") or ""),
backup_id=str(result.get("backup_id") or ""),
)
result = resolve_duplicate_course_summary_task(task_id, str(payload.get("delete_summary_id") or ""))
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -216,27 +119,7 @@ def admin_resolve_duplicate_summary(task_id: int, payload: dict, _user: str = De
@router.post("/api/admin/tasks/{task_id}/course-summary-review")
def admin_update_course_summary_review(task_id: int, payload: dict, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = update_course_summary_review_task(
ADMIN_TASKS_PATH,
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
CLASSNOTES_PATH,
ACCOUNTS_PATH,
task_id,
payload,
)
task = result.get("task", {})
append_operation_log(
OPERATION_LOGS_PATH,
"课程小结审核修正",
"已更新",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or ""),
source_id=str(task.get("source_id") or ""),
proposed_line=str(task.get("proposed_line") or ""),
)
result = update_course_summary_review_task(task_id, payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -245,19 +128,7 @@ def admin_update_course_summary_review(task_id: int, payload: dict, _user: str =
@router.post("/api/admin/tasks/{task_id}/link-existing-course-summary")
def admin_link_existing_course_summary(task_id: int, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = link_existing_course_summary_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, task_id)
task = result.get("task", {})
append_operation_log(
OPERATION_LOGS_PATH,
"课程小结关联已有记录",
"已批准",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or ""),
source_id=str(task.get("source_id") or ""),
proposed_line=str(result.get("linked_record_line") or ""),
)
result = link_existing_course_summary_task(task_id)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -266,17 +137,7 @@ def admin_link_existing_course_summary(task_id: int, _user: str = Depends(verify
@router.post("/api/admin/tasks/{task_id}/reject")
def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
task = reject_admin_task(ADMIN_TASKS_PATH, task_id)
append_operation_log(
OPERATION_LOGS_PATH,
"审核驳回",
"已驳回",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
source_id=str(task.get("source_id") or ""),
)
task = reject_admin_task(task_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "task": task}
@@ -285,19 +146,7 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
@router.post("/api/admin/course-summaries/duplicate-scan")
def admin_scan_duplicate_course_summaries(_user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = create_course_summary_duplicate_review_tasks(
ADMIN_TASKS_PATH,
COURSE_SUMMARIES_ROOT,
classnotes_path=CLASSNOTES_PATH,
)
append_operation_log(
OPERATION_LOGS_PATH,
"重复小结扫描",
"待审核" if result.get("created") else "完成",
scanned_groups=int(result.get("scanned") or 0),
created_tasks=int(result.get("created") or 0),
)
result = scan_duplicate_course_summaries()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -306,16 +155,7 @@ def admin_scan_duplicate_course_summaries(_user: str = Depends(verify_admin_auth
@router.post("/api/admin/course-summaries/{summary_id}/time")
def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = update_course_summary_time(COURSE_SUMMARIES_ROOT, summary_id, str(payload.get("time_range") or ""))
append_operation_log(
OPERATION_LOGS_PATH,
"课程小结补齐时间",
"已更新",
summary_id=summary_id,
time_range=str(payload.get("time_range") or ""),
backup_id=str(result.get("backup_id") or ""),
)
result = update_course_summary_time(summary_id, str(payload.get("time_range") or ""))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -324,15 +164,7 @@ def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str
@router.post("/api/admin/course-summaries/{summary_id}/body")
def admin_update_course_summary_body(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = update_course_summary_body(COURSE_SUMMARIES_ROOT, summary_id, str(payload.get("body") or ""))
append_operation_log(
OPERATION_LOGS_PATH,
"课程小结修改正文",
"已更新",
summary_id=summary_id,
backup_id=str(result.get("backup_id") or ""),
)
result = update_course_summary_body(summary_id, str(payload.get("body") or ""))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -341,33 +173,12 @@ def admin_update_course_summary_body(summary_id: str, payload: dict, _user: str
@router.post("/api/admin/course-summaries/{summary_id}/identity")
def admin_update_course_summary_identity(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = update_course_summary_identity(
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
TEACHERS_PATH,
summary_id,
str(payload.get("student") or ""),
str(payload.get("teacher") or ""),
str(payload.get("subject") or ""),
)
append_operation_log(
OPERATION_LOGS_PATH,
"课程小结修改归属",
"已更新",
summary_id=summary_id,
new_summary_id=str(result.get("new_id") or ""),
student=str(result.get("student") or ""),
old_student=str(result.get("old_student") or ""),
teacher=str(result.get("teacher") or ""),
old_teacher=str(result.get("old_teacher") or ""),
subject=str(result.get("subject") or ""),
old_subject=str(result.get("old_subject") or ""),
old_path=str(result.get("old_path") or ""),
new_path=str(result.get("new_path") or ""),
heading=str(result.get("heading") or ""),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -376,15 +187,7 @@ def admin_update_course_summary_identity(summary_id: str, payload: dict, _user:
@router.delete("/api/admin/course-summaries/{summary_id}")
def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = delete_course_summary(COURSE_SUMMARIES_ROOT, summary_id)
append_operation_log(
OPERATION_LOGS_PATH,
"课程小结删除",
"已删除",
summary_id=summary_id,
backup_id=str(result.get("backup_id") or ""),
)
result = delete_course_summary(summary_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
+2 -34
View File
@@ -2,20 +2,8 @@ from __future__ import annotations
from fastapi import APIRouter, Depends
from ..api_utils import file_meta, load_accounts, load_records, load_teachers
from ..auth import verify_records_auth
from ..config import (
ACCOUNTS_PATH,
CLASSNOTES_PATH,
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
LEGACY_TEXT_ROOT,
OPERATION_LOGS_PATH,
TEACHERS_PATH,
USE_SQLITE_SOURCE,
)
from ..db import database_meta
from ..data import account_summary
from ..repository import health_payload
router = APIRouter()
@@ -23,24 +11,4 @@ router = APIRouter()
@router.get("/api/health")
def health(_user: str = Depends(verify_records_auth)):
records = load_records()
accounts = load_accounts()
teachers = load_teachers()
return {
"ok": True,
"classnotes": file_meta(CLASSNOTES_PATH),
"accounts": file_meta(ACCOUNTS_PATH),
"teachers": file_meta(TEACHERS_PATH),
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
"course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH),
"operation_logs": file_meta(OPERATION_LOGS_PATH),
"database": database_meta(),
"legacy_archive": {
"source_mode": "sqlite" if USE_SQLITE_SOURCE else "text",
"text_root": str(LEGACY_TEXT_ROOT),
},
"records_count": len(records),
"accounts_count": len(accounts),
"teachers_count": len(teachers),
"account_summary": account_summary(accounts),
}
return health_payload()
+1 -17
View File
@@ -4,16 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
from ..auth import verify_ingest_token
from ..ai_register import preview_register
from ..config import (
ACCOUNTS_PATH,
ADMIN_TASKS_PATH,
CLASSNOTES_PATH,
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
OPERATION_LOGS_PATH,
write_lock,
)
from ..data import ingest_course_summaries
from ..repository import ingest_course_summaries
from ..schemas import CourseSummaryIngestPayload
@@ -97,14 +88,7 @@ def _preprocess_summaries_locally(summaries: list[dict]) -> list[dict]:
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
try:
summaries = _preprocess_summaries_locally([item.dict() for item in payload.summaries])
with write_lock:
result = ingest_course_summaries(
classnotes_path=CLASSNOTES_PATH,
accounts_path=ACCOUNTS_PATH,
tasks_path=ADMIN_TASKS_PATH,
summaries_root=COURSE_SUMMARIES_ROOT,
state_path=COURSE_SUMMARY_STATE_PATH,
operation_logs_path=OPERATION_LOGS_PATH,
batch_id=payload.batch_id,
window=payload.window,
students=payload.students,
-23
View File
@@ -277,11 +277,6 @@ def logout():
return response
@router.get("/accounts")
def accounts_index():
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin")
def admin_index(
request: Request,
@@ -333,24 +328,6 @@ def admin_logout():
return response
@router.get("/accounts/login")
def accounts_login_page():
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/accounts/login")
async def accounts_login_submit(request: Request):
return await admin_login_submit(request)
@router.get("/accounts/logout")
def accounts_logout():
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
response.delete_cookie(ADMIN_SESSION_COOKIE)
return response
@router.get("/static/{asset_path:path}")
def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)):
target = (STATIC_DIR / asset_path).resolve()
+10 -127
View File
@@ -1,31 +1,13 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from ..api_utils import load_accounts, load_records, load_teachers
from ..auth import verify_records_auth
from ..config import (
ADMIN_TASKS_PATH,
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
OPERATION_LOGS_PATH,
write_lock,
)
from ..data import (
account_to_dict,
append_operation_log,
course_summary_semantic_key,
duration_minutes_from_time_range,
find_record_by_identity,
from ..repository import (
query_public_records,
read_course_summary_state,
save_course_summary_markdown,
sha1_text,
submit_public_correction_tasks,
submit_public_deletion_tasks,
write_course_summary_state,
supplement_course_summary,
)
from ..schemas import CorrectionSubmitPayload, CourseSummarySupplementPayload, DeletionSubmitPayload
@@ -38,35 +20,18 @@ def records(
q: str = Query(..., min_length=1, description="自然语言查询,例如:王鑫鹏5月数学课"),
limit: int = Query(30, ge=1, le=1000),
offset: int = Query(0, ge=0),
show_all: bool = Query(False, alias="all"),
_user: str = Depends(verify_records_auth),
):
return query_public_records(
load_records(),
load_teachers(),
q,
limit=limit,
offset=offset,
summaries_root=COURSE_SUMMARIES_ROOT,
)
@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}")
effective_limit = 0 if show_all else limit
effective_offset = 0 if show_all else offset
return query_public_records(q, limit=effective_limit, offset=effective_offset)
@router.post("/api/corrections")
def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(verify_records_auth)):
try:
result = submit_public_correction_tasks(
ADMIN_TASKS_PATH,
load_records(),
load_teachers(),
[item.dict() for item in payload.items],
)
result = submit_public_correction_tasks([item.dict() for item in payload.items])
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -75,97 +40,15 @@ def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(ve
@router.post("/api/deletions")
def submit_deletions(payload: DeletionSubmitPayload, _user: str = Depends(verify_records_auth)):
try:
result = submit_public_deletion_tasks(
ADMIN_TASKS_PATH,
load_records(),
[item.dict() for item in payload.items],
)
result = submit_public_deletion_tasks([item.dict() for item in payload.items])
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/course-summaries/supplement")
def supplement_course_summary(payload: CourseSummarySupplementPayload, _user: str = Depends(verify_records_auth)):
body = payload.body.strip()
if not body:
raise HTTPException(status_code=400, detail="课程小结正文不能为空")
def supplement_course_summary_endpoint(payload: CourseSummarySupplementPayload, _user: str = Depends(verify_records_auth)):
try:
records = load_records()
record = find_record_by_identity(records, payload.record_id)
date_iso = record.date.replace(".", "-")
source_id = f"supplement:{payload.record_id}:{sha1_text(body, 12)}"
summary = {
"source_id": source_id,
"student": record.student,
"date_iso": date_iso,
"time_range": record.time,
"duration_minutes": duration_minutes_from_time_range(record.time),
"teacher": record.teacher,
"subject": record.subject,
"group": "",
"sender": "课程记录页补充",
"message_time": "",
"body": body,
}
semantic_key = course_summary_semantic_key(summary)
with write_lock:
state = read_course_summary_state(COURSE_SUMMARY_STATE_PATH)
seen_source_ids = set(str(item) for item in state.get("seen_source_ids", []))
seen_semantic_keys = set(str(item) for item in state.get("seen_semantic_keys", []))
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
log_id = append_operation_log(
OPERATION_LOGS_PATH,
"课程小结补充",
"重复",
record_id=payload.record_id,
student=record.student,
teacher=record.teacher,
subject=record.subject,
source_id=source_id,
)
return {"ok": True, "log_id": log_id, "submitted": 0, "status": "duplicate"}
saved = save_course_summary_markdown(COURSE_SUMMARIES_ROOT, summary)
status_value = "完成" if saved.get("added") else "重复"
seen_source_ids.add(source_id)
seen_semantic_keys.add(semantic_key)
state["seen_source_ids"] = sorted(seen_source_ids)
state["seen_semantic_keys"] = sorted(seen_semantic_keys)
state.setdefault("batches", []).append(
{
"batch_id": f"supplement-{payload.record_id}-{sha1_text(body, 8)}",
"received_at": datetime.now().isoformat(timespec="seconds"),
"window": {"source": "records_page_supplement"},
"students": [record.student],
"result": {
"received": 1,
"saved": 1 if saved.get("added") else 0,
"auto_registered": 0,
"review_pending": 0,
"duplicates": 0 if saved.get("added") else 1,
"rejected": 0,
},
}
)
state["batches"] = state["batches"][-200:]
write_course_summary_state(COURSE_SUMMARY_STATE_PATH, state)
log_id = append_operation_log(
OPERATION_LOGS_PATH,
"课程小结补充",
status_value,
record_id=payload.record_id,
student=record.student,
teacher=record.teacher,
subject=record.subject,
source_id=source_id,
saved_path=str(saved.get("path") or ""),
heading=str(saved.get("heading") or ""),
)
return supplement_course_summary(payload.record_id, payload.body)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"ok": True,
"log_id": log_id,
"submitted": 1 if status_value == "完成" else 0,
"status": "saved" if status_value == "完成" else "duplicate",
}
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ..api_utils import payload_to_student_profile, payload_to_teacher, read_register_payload
from ..auth import verify_admin_auth, verify_any_auth, verify_student_profiles_auth
from ..data import ACCOUNT_STATUSES, TEACHER_STATUSES
from ..repository import (
create_student_profile,
create_teacher,
health_payload,
register_class_record_lines,
register_course_summary_texts,
register_payment_lines,
student_detail_payload,
students_payload,
teachers_payload,
update_student_profile,
update_teacher,
)
from ..schemas import StudentProfilePayload, TeacherPayload
router = APIRouter()
@router.post("/api/register/class-records")
async def register_class_records(request: Request, _user: str = Depends(verify_admin_auth)):
try:
payload = await read_register_payload(request)
result = register_class_record_lines(lines=payload.lines, line=payload.line)
except ValueError as exc:
status_code = 409 if exc.__class__.__name__ == "DuplicateRecordError" else 400
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/register/payments")
async def register_payments(request: Request, _user: str = Depends(verify_admin_auth)):
try:
payload = await read_register_payload(request)
result = register_payment_lines(lines=payload.lines, line=payload.line)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/register/course-summaries")
async def register_course_summaries(request: Request, _user: str = Depends(verify_admin_auth)):
try:
payload = await read_register_payload(request)
result = register_course_summary_texts(lines=payload.lines, line=payload.line)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.get("/api/student-health")
def student_health(_user: str = Depends(verify_student_profiles_auth)):
return health_payload()
@router.get("/api/students")
def students(
q: str = Query("", description="学生姓名或学生ID"),
status_filter: str = Query("", alias="status", description="学生档案状态"),
_user: str = Depends(verify_student_profiles_auth),
):
return students_payload(q=q, status_filter=status_filter)
@router.get("/api/students/{student}")
def student_detail(student: str, _user: str = Depends(verify_any_auth)):
try:
return student_detail_payload(student)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/api/admin/statuses")
def admin_statuses(_user: str = Depends(verify_admin_auth)):
return {"account_statuses": sorted(ACCOUNT_STATUSES)}
@router.get("/api/admin/teacher-statuses")
def admin_teacher_statuses(_user: str = Depends(verify_admin_auth)):
return {"teacher_statuses": sorted(TEACHER_STATUSES)}
@router.get("/api/admin/teachers")
def admin_teachers(_user: str = Depends(verify_admin_auth)):
return teachers_payload()
@router.post("/api/admin/teachers")
def admin_create_teacher(payload: TeacherPayload, _user: str = Depends(verify_admin_auth)):
try:
result = create_teacher(payload_to_teacher(payload))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.put("/api/admin/teachers/{teacher_id}")
def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str = Depends(verify_admin_auth)):
try:
result = update_teacher(teacher_id, payload_to_teacher(payload, teacher_id=payload.teacher_id.strip() or teacher_id))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/admin/students")
def admin_create_student(payload: StudentProfilePayload, _user: str = Depends(verify_admin_auth)):
try:
result = create_student_profile(payload_to_student_profile(payload))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.put("/api/admin/students/{student_id}")
def admin_update_student(student_id: str, payload: StudentProfilePayload, _user: str = Depends(verify_admin_auth)):
try:
result = update_student_profile(student_id, payload_to_student_profile(payload))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
+2 -2
View File
@@ -21,12 +21,12 @@ class PaymentPayload(BaseModel):
hours: float
class AccountPayload(BaseModel):
class StudentProfilePayload(BaseModel):
student_id: str = ""
student: str
payments: list[PaymentPayload] = Field(default_factory=list)
remaining: float = 0
account_status: str = "正常"
primary_entry_year: int | None = None
note: str = ""
-60
View File
@@ -1,60 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>课时账户查询</title>
<link rel="stylesheet" href="/static/styles.css?v=20260616-duration-text" />
</head>
<body>
<header class="topbar">
<div>
<h1>课时账户查询</h1>
<p id="accountHealthText">正在读取账户状态</p>
</div>
<div class="top-actions">
<a class="nav-button" href="/">课程记录</a>
<button id="refreshBtn" class="icon-button" title="刷新账户" type="button" aria-label="刷新账户">
</button>
</div>
</header>
<main class="layout account-layout">
<section class="panel accounts-panel">
<div class="section-head">
<h2>课时账户</h2>
<select id="accountStatus" aria-label="账户状态筛选">
<option value="">全部状态</option>
<option value="欠费">欠费</option>
<option value="预警">预警</option>
<option value="正常">正常</option>
<option value="结课">结课</option>
<option value="退费">退费</option>
</select>
</div>
<form id="accountForm" class="search-row account-search-row">
<input id="accountQuery" autocomplete="off" placeholder="学生姓名或学生ID" />
<button type="submit">查询</button>
</form>
<div id="accountMeta" class="summary-grid"></div>
<div class="table-wrap account-table-wrap">
<table>
<thead>
<tr>
<th>学生</th>
<th>状态</th>
<th class="num">剩余课时</th>
<th>缴费记录</th>
<th>备注</th>
</tr>
</thead>
<tbody id="accountRows"></tbody>
</table>
</div>
</section>
</main>
<script src="/static/accounts.js?v=20260616-duration-text"></script>
</body>
</html>
-123
View File
@@ -1,123 +0,0 @@
const accountHealthText = document.querySelector("#accountHealthText");
const refreshBtn = document.querySelector("#refreshBtn");
const accountForm = document.querySelector("#accountForm");
const accountQuery = document.querySelector("#accountQuery");
const accountStatus = document.querySelector("#accountStatus");
const accountMeta = document.querySelector("#accountMeta");
const accountRows = document.querySelector("#accountRows");
function fmtHours(value) {
const totalMinutes = Math.round(Number(value || 0) * 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}小时${minutes}`;
}
function displayHours(value, fallback) {
return fallback || fmtHours(value);
}
function fmtTime(seconds) {
if (!seconds) return "未知";
return new Date(seconds * 1000).toLocaleString("zh-CN", { hour12: false });
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function metric(label, value) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
}
function statusClass(status) {
if (status === "欠费") return "debt";
if (status === "预警") return "warning";
if (status === "正常") return "normal";
return "closed";
}
function renderPayments(payments) {
if (!payments.length) return "暂无";
return payments.map((item) => `${escapeHtml(item.date)}${escapeHtml(displayHours(item.hours, item.duration))}`).join("<br>");
}
async function fetchJson(url) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
const payload = await response.json();
detail = payload.detail || detail;
} catch (_error) {
detail = response.statusText || detail;
}
throw new Error(detail);
}
return response.json();
}
function renderSummary(summary, count) {
accountMeta.innerHTML = [
metric("当前结果", `${count}`),
metric("欠费", summary.debt),
metric("预警", summary.warning),
metric("正常", summary.normal),
].join("");
}
async function loadAccountHealth() {
try {
const data = await fetchJson("/api/account-health");
accountHealthText.textContent = `账户 ${data.accounts_count} 人;数据更新时间 ${fmtTime(data.accounts.mtime)}`;
} catch (error) {
accountHealthText.textContent = `读取失败:${error.message}`;
}
}
async function loadAccounts() {
accountRows.innerHTML = `<tr><td colspan="5" class="empty">正在读取</td></tr>`;
const params = new URLSearchParams();
if (accountQuery.value.trim()) params.set("q", accountQuery.value.trim());
if (accountStatus.value) params.set("status", accountStatus.value);
try {
const data = await fetchJson(`/api/accounts?${params.toString()}`);
renderSummary(data.summary, data.count);
accountRows.innerHTML = data.accounts
.map(
(row) => `<tr>
<td>${escapeHtml(row.student)}<br><small>${escapeHtml(row.student_id)}</small></td>
<td><span class="status ${statusClass(row.account_status)}">${escapeHtml(row.account_status)}</span></td>
<td class="num">${escapeHtml(displayHours(row.remaining, row.remaining_duration))}</td>
<td>${renderPayments(row.payments)}</td>
<td class="note-cell">${escapeHtml(row.note || "")}</td>
</tr>`,
)
.join("");
if (!data.accounts.length) {
accountRows.innerHTML = `<tr><td colspan="5" class="empty">没有符合条件的账户</td></tr>`;
}
} catch (error) {
accountRows.innerHTML = `<tr><td colspan="5" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
accountForm.addEventListener("submit", (event) => {
event.preventDefault();
loadAccounts();
});
accountStatus.addEventListener("change", loadAccounts);
refreshBtn.addEventListener("click", () => {
loadAccountHealth();
loadAccounts();
});
loadAccountHealth();
loadAccounts();
+27 -24
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理后台</title>
<link rel="stylesheet" href="/static/styles.css?v=20260620-dashboard" />
<link rel="stylesheet" href="/static/styles.css?v=20260706-sqlite-native" />
</head>
<body>
<header class="topbar">
@@ -22,7 +22,7 @@
<main class="layout account-layout admin-layout">
<nav class="admin-tabs" aria-label="管理后台功能">
<button class="admin-tab is-active" data-admin-tab="dashboard" type="button">仪表盘</button>
<button class="admin-tab" data-admin-tab="accounts" type="button">课时账户</button>
<button class="admin-tab" data-admin-tab="students" type="button">学生档案</button>
<button class="admin-tab" data-admin-tab="teachers" type="button">老师档案</button>
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结</button>
@@ -46,11 +46,11 @@
</div>
</section>
<section id="accountsPanel" class="panel admin-panel" hidden>
<section id="studentsPanel" class="panel admin-panel" hidden>
<div class="section-head">
<h2>课时账户</h2>
<h2>学生档案</h2>
<div class="quick-actions">
<select id="accountStatus" aria-label="账户状态筛选">
<select id="studentStatus" aria-label="学生档案状态筛选">
<option value="">全部状态</option>
<option value="欠费">欠费</option>
<option value="预警">预警</option>
@@ -58,20 +58,20 @@
<option value="结课">结课</option>
<option value="退费">退费</option>
</select>
<button id="newAccountBtn" class="chip" type="button">新增账户</button>
<button id="newStudentBtn" class="chip" type="button">新增学生档案</button>
</div>
</div>
<form id="accountForm" class="search-row account-search-row">
<input id="accountQuery" autocomplete="off" placeholder="学生姓名或学生ID" />
<form id="studentForm" class="search-row account-search-row">
<input id="studentQuery" autocomplete="off" placeholder="学生姓名或学生ID" />
<button type="submit">查询</button>
</form>
<div id="accountMeta" class="summary-grid"></div>
<div id="accountEditor" class="admin-editor" hidden>
<div id="studentMeta" class="summary-grid"></div>
<div id="studentEditor" class="admin-editor" hidden>
<div class="section-head compact-head">
<h2 id="accountEditorTitle">新增账户</h2>
<button id="cancelAccountEditBtn" class="secondary-button" type="button">取消</button>
<h2 id="studentEditorTitle">新增学生档案</h2>
<button id="cancelStudentEditBtn" class="secondary-button" type="button">取消</button>
</div>
<form id="accountEditForm" class="admin-form">
<form id="studentEditForm" class="admin-form">
<div class="form-grid">
<label>
学生ID
@@ -82,11 +82,15 @@
<input id="editStudent" autocomplete="off" required />
</label>
<label>
剩余课时
<input id="editRemaining" inputmode="decimal" required />
入学年份
<input id="editPrimaryEntryYear" autocomplete="off" inputmode="numeric" placeholder="小学一年级" />
</label>
<label>
账户状态
剩余课时
<input id="editRemaining" readonly value="系统自动计算" />
</label>
<label>
档案状态
<select id="editStatus">
<option value="正常">正常</option>
<option value="预警">预警</option>
@@ -104,9 +108,9 @@
<textarea id="editNote" rows="3"></textarea>
</label>
</div>
<p id="accountEditError" class="correction-error" hidden></p>
<p id="studentEditError" class="correction-error" hidden></p>
<div class="modal-actions">
<button type="submit">保存账户</button>
<button type="submit">保存档案</button>
</div>
</form>
</div>
@@ -115,6 +119,7 @@
<thead>
<tr>
<th>学生</th>
<th>入学年份</th>
<th>状态</th>
<th class="num">剩余课时</th>
<th>缴费记录</th>
@@ -122,7 +127,7 @@
<th>操作</th>
</tr>
</thead>
<tbody id="accountRows"></tbody>
<tbody id="studentRows"></tbody>
</table>
</div>
</section>
@@ -233,8 +238,6 @@
<div id="reviewPager" class="pager" hidden></div>
</section>
<section id="summariesPanel" class="panel admin-panel" hidden></section>
<div id="summaryReviewDrawerBackdrop" class="drawer-backdrop" hidden>
<aside class="summary-review-drawer" role="dialog" aria-modal="true" aria-labelledby="summaryReviewDrawerTitle">
<div class="drawer-head">
@@ -396,8 +399,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>
@@ -490,6 +493,6 @@
</section>
</main>
<script src="/static/admin.js?v=20260620-dashboard"></script>
<script src="/static/admin.js?v=20260706-sqlite-native"></script>
</body>
</html>
+120 -115
View File
@@ -2,10 +2,9 @@ const adminHealthText = document.querySelector("#adminHealthText");
const refreshBtn = document.querySelector("#refreshBtn");
const panels = {
dashboard: document.querySelector("#dashboardPanel"),
accounts: document.querySelector("#accountsPanel"),
students: document.querySelector("#studentsPanel"),
teachers: document.querySelector("#teachersPanel"),
reviews: document.querySelector("#reviewsPanel"),
summaries: document.querySelector("#summariesPanel"),
summarySearch: document.querySelector("#summarySearchPanel"),
logs: document.querySelector("#logsPanel"),
register: document.querySelector("#registerPanel"),
@@ -13,17 +12,17 @@ const panels = {
const dashboardMeta = document.querySelector("#dashboardMeta");
const dashboardContent = document.querySelector("#dashboardContent");
const dashboardPeriodBtns = Array.from(document.querySelectorAll(".dashboard-period"));
const accountForm = document.querySelector("#accountForm");
const accountQuery = document.querySelector("#accountQuery");
const accountStatus = document.querySelector("#accountStatus");
const accountMeta = document.querySelector("#accountMeta");
const accountRows = document.querySelector("#accountRows");
const accountEditor = document.querySelector("#accountEditor");
const accountEditorTitle = document.querySelector("#accountEditorTitle");
const accountEditForm = document.querySelector("#accountEditForm");
const accountEditError = document.querySelector("#accountEditError");
const cancelAccountEditBtn = document.querySelector("#cancelAccountEditBtn");
const newAccountBtn = document.querySelector("#newAccountBtn");
const studentForm = document.querySelector("#studentForm");
const studentQuery = document.querySelector("#studentQuery");
const studentStatus = document.querySelector("#studentStatus");
const studentMeta = document.querySelector("#studentMeta");
const studentRows = document.querySelector("#studentRows");
const studentEditor = document.querySelector("#studentEditor");
const studentEditorTitle = document.querySelector("#studentEditorTitle");
const studentEditForm = document.querySelector("#studentEditForm");
const studentEditError = document.querySelector("#studentEditError");
const cancelStudentEditBtn = document.querySelector("#cancelStudentEditBtn");
const newStudentBtn = document.querySelector("#newStudentBtn");
const teacherForm = document.querySelector("#teacherForm");
const teacherQuery = document.querySelector("#teacherQuery");
const teacherStatus = document.querySelector("#teacherStatus");
@@ -43,6 +42,7 @@ const editTeacherSubjects = document.querySelector("#editTeacherSubjects");
const editTeacherNote = document.querySelector("#editTeacherNote");
const editStudentId = document.querySelector("#editStudentId");
const editStudent = document.querySelector("#editStudent");
const editPrimaryEntryYear = document.querySelector("#editPrimaryEntryYear");
const editRemaining = document.querySelector("#editRemaining");
const editStatus = document.querySelector("#editStatus");
const editPayments = document.querySelector("#editPayments");
@@ -117,9 +117,9 @@ const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm")
const ADMIN_PAGE_SIZE = 50;
let currentAccounts = [];
let currentStudents = [];
let currentDashboardPeriod = "month";
let editingAccountId = "";
let editingStudentId = "";
let currentTeachers = [];
let editingTeacherId = "";
let currentReviewPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
@@ -287,8 +287,8 @@ function dashboardAction(label, attrs = {}) {
function renderDashboard(data) {
const overview = data.overview || {};
const accounts = data.accounts || {};
const accountSummary = accounts.summary || {};
const students = data.students || {};
const studentSummary = students.summary || {};
const teachers = data.teachers || {};
const summaries = data.course_summaries || {};
const tasks = data.tasks || {};
@@ -333,34 +333,34 @@ function renderDashboard(data) {
</section>
<section class="dashboard-section">
<div class="dashboard-section-head">
<h3>账户与老师</h3>
<h3>学生档案与老师</h3>
${renderDashboardActions([
dashboardAction("欠费账户", { "data-dashboard-tab": "accounts", "data-account-status": "欠费" }),
dashboardAction("预警账户", { "data-dashboard-tab": "accounts", "data-account-status": "预警" }),
dashboardAction("欠费学生", { "data-dashboard-tab": "students", "data-student-status": "欠费" }),
dashboardAction("预警学生", { "data-dashboard-tab": "students", "data-student-status": "预警" }),
dashboardAction("老师档案", { "data-dashboard-tab": "teachers" }),
])}
</div>
<div class="dashboard-grid two">
<article class="dashboard-card dashboard-metrics-card">
<h4>账户概况</h4>
<h4>学生档案概况</h4>
<div class="dashboard-mini-metrics">
${metric("总账户", `${accountSummary.total || 0}`)}
${metric("正常", accountSummary.normal || 0)}
${metric("预警", accountSummary.warning || 0)}
${metric("欠费", accountSummary.debt || 0)}
${metric("结课", accountSummary.completed || 0)}
${metric("退费", accountSummary.refunded || 0)}
${metric("学生档案", `${studentSummary.total || 0}`)}
${metric("正常", studentSummary.normal || 0)}
${metric("预警", studentSummary.warning || 0)}
${metric("欠费", studentSummary.debt || 0)}
${metric("结课", studentSummary.completed || 0)}
${metric("退费", studentSummary.refunded || 0)}
</div>
</article>
<article class="dashboard-card">
<h4>低剩余课时</h4>
${renderDashboardList(accounts.low_remaining, (item) => `
<button class="dashboard-list-row" type="button" data-dashboard-tab="accounts" data-account-query="${escapeHtml(item.student || "")}">
${renderDashboardList(students.low_remaining, (item) => `
<button class="dashboard-list-row" type="button" data-dashboard-tab="students" data-student-query="${escapeHtml(item.student || "")}">
<span><strong>${escapeHtml(item.student || "")}</strong><small>${escapeHtml(item.student_id || "")}</small></span>
<span class="dashboard-list-value">${escapeHtml(item.remaining_duration || fmtHours(item.remaining || 0))}</span>
<span class="status ${statusClass(item.status)}">${escapeHtml(item.status || "")}</span>
</button>
`, "没有需要重点关注的账户")}
`, "没有需要重点关注的学生")}
</article>
<article class="dashboard-card dashboard-metrics-card">
<h4>老师概况</h4>
@@ -475,7 +475,7 @@ async function fetchJson(url, options = {}) {
}
function setActiveTab(tabName) {
const activeTabName = tabName === "summaries" ? "summarySearch" : tabName;
const activeTabName = tabName;
let activeButton = null;
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
const isActive = button.dataset.adminTab === activeTabName;
@@ -487,7 +487,7 @@ function setActiveTab(tabName) {
panel.hidden = name !== activeTabName;
});
if (activeTabName === "dashboard") loadDashboard();
if (activeTabName === "accounts") loadAccounts();
if (activeTabName === "students") loadStudents();
if (activeTabName === "teachers") loadTeachers();
if (activeTabName === "reviews") loadReviews(0);
if (activeTabName === "summarySearch") {
@@ -499,9 +499,9 @@ function setActiveTab(tabName) {
async function loadAdminHealth() {
try {
const data = await fetchJson("/api/account-health");
const data = await fetchJson("/api/student-health");
adminHealthText.textContent = [
`账户 ${data.accounts_count || 0}`,
`学生档案 ${data.students_count || 0}`,
`在岗老师 ${data.active_teachers_count || 0}`,
`课程记录 ${data.records_count || 0}`,
`小结 ${data.course_summaries_count || 0}`,
@@ -512,8 +512,8 @@ async function loadAdminHealth() {
}
}
function renderAccountSummary(summary, count) {
accountMeta.innerHTML = [
function renderStudentSummary(summary, count) {
studentMeta.innerHTML = [
metric("当前结果", `${count}`),
metric("欠费", summary.debt),
metric("预警", summary.warning),
@@ -521,32 +521,34 @@ function renderAccountSummary(summary, count) {
].join("");
}
async function loadAccounts() {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
async function loadStudents() {
studentRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
const params = new URLSearchParams();
if (accountQuery.value.trim()) params.set("q", accountQuery.value.trim());
if (accountStatus.value) params.set("status", accountStatus.value);
if (studentQuery.value.trim()) params.set("q", studentQuery.value.trim());
if (studentStatus.value) params.set("status", studentStatus.value);
try {
const data = await fetchJson(`/api/accounts?${params.toString()}`);
currentAccounts = data.accounts;
renderAccountSummary(data.summary, data.count);
accountRows.innerHTML = data.accounts
const data = await fetchJson(`/api/students?${params.toString()}`);
const students = data.students || [];
currentStudents = students;
renderStudentSummary(data.summary, data.count);
studentRows.innerHTML = students
.map(
(row) => `<tr>
<td>${escapeHtml(row.student)}<br><small>${escapeHtml(row.student_id)}</small></td>
<td>${escapeHtml(row.primary_entry_year || "")}</td>
<td><span class="status ${statusClass(row.account_status)}">${escapeHtml(row.account_status)}</span></td>
<td class="num">${escapeHtml(displayHours(row.remaining, row.remaining_duration))}</td>
<td>${renderPayments(row.payments)}</td>
<td class="note-cell">${escapeHtml(row.note || "")}</td>
<td><button class="small-button account-edit" type="button" data-student-id="${escapeHtml(row.student_id)}">编辑</button></td>
<td><button class="small-button student-edit" type="button" data-student-id="${escapeHtml(row.student_id)}">编辑</button></td>
</tr>`,
)
.join("");
if (!data.accounts.length) {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的账户</td></tr>`;
if (!students.length) {
studentRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的学生档案</td></tr>`;
}
} catch (error) {
accountRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
studentRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
@@ -581,72 +583,75 @@ function parsePaymentsInput(value) {
});
}
function openNewAccountEditor() {
editingAccountId = "";
accountEditorTitle.textContent = "新增账户";
function openNewStudentEditor() {
editingStudentId = "";
studentEditorTitle.textContent = "新增学生档案";
editStudentId.value = "";
editStudentId.placeholder = "保存时自动生成";
editStudent.value = "";
editRemaining.value = "0";
editPrimaryEntryYear.value = "";
editRemaining.value = "保存后由系统自动计算";
editStatus.value = "正常";
editPayments.value = "";
editNote.value = "";
accountEditError.hidden = true;
accountEditor.hidden = false;
studentEditError.hidden = true;
studentEditor.hidden = false;
editStudent.focus();
}
function openEditAccountEditor(studentId) {
const account = currentAccounts.find((item) => item.student_id === studentId);
if (!account) return;
editingAccountId = account.student_id;
accountEditorTitle.textContent = `编辑账户${account.student}`;
editStudentId.value = account.student_id;
function openEditStudentEditor(studentId) {
const studentProfile = currentStudents.find((item) => item.student_id === studentId);
if (!studentProfile) return;
editingStudentId = studentProfile.student_id;
studentEditorTitle.textContent = `编辑学生档案${studentProfile.student}`;
editStudentId.value = studentProfile.student_id;
editStudentId.placeholder = "";
editStudent.value = account.student;
editRemaining.value = account.remaining;
editStatus.value = account.account_status;
editPayments.value = paymentsToText(account.payments);
editNote.value = account.note || "";
accountEditError.hidden = true;
accountEditor.hidden = false;
editStudent.value = studentProfile.student;
editPrimaryEntryYear.value = studentProfile.primary_entry_year || "";
editRemaining.value = displayHours(studentProfile.remaining, studentProfile.remaining_duration);
editStatus.value = studentProfile.account_status;
editPayments.value = paymentsToText(studentProfile.payments);
editNote.value = studentProfile.note || "";
studentEditError.hidden = true;
studentEditor.hidden = false;
editStudent.focus();
}
function buildAccountPayload() {
const remaining = Number(editRemaining.value);
if (!Number.isFinite(remaining)) {
throw new Error("剩余课时必须是数字");
function buildStudentPayload() {
const primaryEntryYearText = editPrimaryEntryYear.value.trim();
const primaryEntryYear = primaryEntryYearText ? Number(primaryEntryYearText) : null;
if (primaryEntryYearText && (!Number.isInteger(primaryEntryYear) || primaryEntryYear < 1900 || primaryEntryYear > 2100)) {
throw new Error("入学年份必须是 1900 到 2100 之间的年份");
}
return {
student_id: editStudentId.value.trim(),
student: editStudent.value.trim(),
payments: parsePaymentsInput(editPayments.value),
remaining,
account_status: editStatus.value,
primary_entry_year: primaryEntryYear,
note: editNote.value.trim(),
};
}
async function saveAccount(event) {
async function saveStudent(event) {
event.preventDefault();
try {
const payload = buildAccountPayload();
const url = editingAccountId
? `/api/admin/accounts/${encodeURIComponent(editingAccountId)}`
: "/api/admin/accounts";
const method = editingAccountId ? "PUT" : "POST";
const payload = buildStudentPayload();
const url = editingStudentId
? `/api/admin/students/${encodeURIComponent(editingStudentId)}`
: "/api/admin/students";
const method = editingStudentId ? "PUT" : "POST";
await fetchJson(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
accountEditor.hidden = true;
studentEditor.hidden = true;
await loadAdminHealth();
await loadAccounts();
await loadStudents();
} catch (error) {
accountEditError.textContent = error.message;
accountEditError.hidden = false;
studentEditError.textContent = error.message;
studentEditError.hidden = false;
}
}
@@ -913,8 +918,8 @@ function renderDuplicateCandidate(candidate, canReview, options = {}) {
candidate.source_id ? `来源ID${candidate.source_id}` : "",
candidate.message_time ? `发送时间:${candidate.message_time}` : "",
candidate.sender ? `发送者:${candidate.sender}` : "",
candidate.relative_path ? `文件${candidate.relative_path}` : "",
candidate.source_path && !candidate.relative_path ? `文件${candidate.source_path}` : "",
candidate.storage_key || candidate.relative_path ? `存储标识${candidate.storage_key || candidate.relative_path}` : "",
candidate.id ? `小结ID${candidate.id}` : "",
].filter(Boolean);
return `<div class="record-summary-item duplicate-summary-candidate">
<div class="duplicate-summary-title-row">
@@ -956,7 +961,7 @@ function renderSummaryDuplicateConflicts(item) {
parts.push(`<div class="summary-conflict-heading">命中的已有小结</div>`);
parts.push(conflicts.map((candidate) => renderDuplicateCandidate(candidate, false, { showDelete: false })).join(""));
} else {
parts.push("<span class=\"muted\">状态文件命中重复,但正式小结未找到对应小结。</span>");
parts.push("<span class=\"muted\">状态命中重复,但课程小结未找到对应小结。</span>");
}
return parts.join("");
}
@@ -1092,7 +1097,7 @@ function openSummaryReviewDrawer(taskId) {
summaryReviewDrawerConflicts.innerHTML = duplicateContext ? renderSummaryDuplicateConflicts(item) : "";
summaryReviewDrawerSource.innerHTML = [
item.source_id ? `来源ID${escapeHtml(item.source_id)}` : "",
item.saved_path ? `${duplicateContext || pendingSummarySave ? "目标文件" : "保存文件"}${escapeHtml(item.saved_path)}` : "",
(item.storage_key || item.saved_path) ? `${duplicateContext || pendingSummarySave ? "目标标识" : "存储标识"}${escapeHtml(item.storage_key || item.saved_path)}` : "",
item.batch_id ? `批次:${escapeHtml(item.batch_id)}` : "",
].filter(Boolean).join("<br>") || "暂无来源信息";
summaryReviewDrawerSave.hidden = duplicateReview;
@@ -1232,7 +1237,7 @@ function renderSummaryIdentityEditor(item) {
<input data-summary-identity-subject="${escapeHtml(item.id)}" autocomplete="off" value="${escapeHtml(item.subject || "")}" />
</label>
</div>
<div class="form-hint">只修改课程小结的学生老师和科目不同步修改上课记录和课时账户老师必须已在教师档案中维护</div>
<div class="form-hint">只修改课程小结的学生老师和科目不同步修改上课记录学生档案或课时余额老师必须已在教师档案中维护</div>
<div class="summary-edit-actions">
<button class="secondary-button summary-identity-save" type="button" data-summary-id="${escapeHtml(item.id)}">保存绑定字段</button>
<button class="secondary-button summary-identity-cancel" type="button" data-summary-id="${escapeHtml(item.id)}">取消</button>
@@ -1634,7 +1639,7 @@ async function linkExistingSummaryReview() {
}
async function resolveDuplicateSummary(taskId, summaryId) {
if (!confirm("确认删除这条重复课程小结?系统会先备份来源文件。")) return;
if (!confirm("确认删除这条重复课程小结?系统会先备份数据库快照。")) return;
await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/resolve-duplicate-summary`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1750,15 +1755,15 @@ function renderLogDetail(item) {
logField("时间段", item.time_range),
logField("上课记录", item.proposed_line),
]),
renderLogDetailGroup("文件与备份", [
logField("保存文件", item.saved_path),
logField("原文件", item.old_path),
logField("新文件", item.new_path),
renderLogDetailGroup("存储与备份", [
logField("存储标识", item.storage_key),
logField("小结ID", item.summary_id),
logField("新小结ID", item.new_summary_id),
logField("备份ID", item.backup_id),
logField("撤回记录", item.target_log_id),
logField("撤回备份", item.target_backup_id),
logField("撤回方式", item.rollback_mode),
logArrayField("恢复文件", item.restored_files),
logArrayField("恢复", item.restored_tables),
]),
renderLogDetailGroup("结果与原因", [
logArrayField("原因", item.reasons),
@@ -1820,7 +1825,7 @@ async function rollbackOperationLog(logId) {
await fetchJson(`/api/admin/operation-logs/${encodeURIComponent(logId)}/rollback`, { method: "POST" });
await loadOperationLogs();
await loadAdminHealth();
await loadAccounts();
await loadStudents();
} catch (error) {
alert(error.message);
}
@@ -2108,15 +2113,15 @@ async function confirmRegister(type, statusNode, url, onSuccess) {
clearRegisterPreview(type);
onSuccess();
await loadAdminHealth();
await loadAccounts();
await loadStudents();
} catch (error) {
statusNode.textContent = `写入失败:${error.message}`;
}
}
function resetAccountFilters() {
accountQuery.value = "";
accountStatus.value = "";
function resetStudentFilters() {
studentQuery.value = "";
studentStatus.value = "";
}
function resetSummarySearchFilters() {
@@ -2133,11 +2138,11 @@ function resetSummarySearchFilters() {
function openDashboardTarget(button) {
const targetTab = button.dataset.dashboardTab;
if (targetTab === "accounts") {
resetAccountFilters();
if (button.dataset.accountStatus !== undefined) accountStatus.value = button.dataset.accountStatus;
if (button.dataset.accountQuery !== undefined) accountQuery.value = button.dataset.accountQuery;
setActiveTab("accounts");
if (targetTab === "students") {
resetStudentFilters();
if (button.dataset.studentStatus !== undefined) studentStatus.value = button.dataset.studentStatus;
if (button.dataset.studentQuery !== undefined) studentQuery.value = button.dataset.studentQuery;
setActiveTab("students");
return;
}
if (targetTab === "summarySearch") {
@@ -2191,20 +2196,20 @@ dashboardContent.addEventListener("click", (event) => {
openDashboardTarget(jumpButton);
});
accountForm.addEventListener("submit", (event) => {
studentForm.addEventListener("submit", (event) => {
event.preventDefault();
loadAccounts();
loadStudents();
});
accountStatus.addEventListener("change", loadAccounts);
newAccountBtn.addEventListener("click", openNewAccountEditor);
cancelAccountEditBtn.addEventListener("click", () => {
accountEditor.hidden = true;
studentStatus.addEventListener("change", loadStudents);
newStudentBtn.addEventListener("click", openNewStudentEditor);
cancelStudentEditBtn.addEventListener("click", () => {
studentEditor.hidden = true;
});
accountEditForm.addEventListener("submit", saveAccount);
accountRows.addEventListener("click", (event) => {
const button = event.target.closest(".account-edit");
studentEditForm.addEventListener("submit", saveStudent);
studentRows.addEventListener("click", (event) => {
const button = event.target.closest(".student-edit");
if (!button) return;
openEditAccountEditor(button.dataset.studentId);
openEditStudentEditor(button.dataset.studentId);
});
teacherForm.addEventListener("submit", (event) => {
event.preventDefault();
@@ -2303,7 +2308,7 @@ summarySearchRows.addEventListener("click", (event) => {
const cancelSummaryBody = event.target.closest(".summary-body-cancel");
const applyCandidate = event.target.closest(".summary-apply-candidate");
if (applyCandidate) {
if (!confirm("确认按候选上课记录修正课程小结的学生、老师、科目和时间?系统不会修改上课记录和课时账户。")) return;
if (!confirm("确认按候选上课记录修正课程小结的学生、老师、科目和时间?系统不会修改上课记录、学生档案或课时余额。")) return;
applySummaryBindingCandidate(applyCandidate.dataset.summaryId, {
student: applyCandidate.dataset.student,
teacher: applyCandidate.dataset.teacher,
@@ -2459,7 +2464,7 @@ summaryRegisterConfirm.addEventListener("click", () => {
refreshBtn.addEventListener("click", () => {
loadAdminHealth();
if (!panels.dashboard.hidden) loadDashboard();
if (!panels.accounts.hidden) loadAccounts();
if (!panels.students.hidden) loadStudents();
if (!panels.reviews.hidden) loadReviews();
if (!panels.summarySearch.hidden) refreshSummaryWorkspace({ logs: false });
if (!panels.logs.hidden) loadOperationLogs(currentLogPage.offset || 0);
+48 -109
View File
@@ -43,16 +43,15 @@ const submitSummarySupplementBtn = document.querySelector("#submitSummarySupplem
const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
const COPY_LINE_BREAK = "\r\n";
const SORT_DIRECTIONS = { asc: 1, desc: -1 };
const RECORD_PAGE_SIZE = 30;
const RECORD_TABLE_COLUMN_COUNT = 8;
let currentRecords = [];
let recordSort = { date: "asc", time: "asc" };
let currentRecordOrder = [];
let correctedRecords = new Map();
let correctedRecordOrder = [];
let expandedSummaryRecords = new Set();
let expandedSummaryBodies = new Set();
let collapsedTeacherGroups = new Set();
let currentRecordPage = { query: "", offset: 0, limit: RECORD_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
let currentRecordPage = { query: "", offset: 0, limit: 0, total: 0, shown: 0, hasMore: false };
let activeCorrectionKey = "";
let activeDeleteKey = "";
let activeSummarySupplementKey = "";
@@ -90,42 +89,11 @@ function metricHtml(label, valueHtml) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${valueHtml}</strong></div>`;
}
function currentPageNumber(state) {
return Math.floor((state.offset || 0) / (state.limit || RECORD_PAGE_SIZE)) + 1;
}
function totalPageNumber(state) {
const limit = state.limit || RECORD_PAGE_SIZE;
return Math.max(1, Math.ceil((state.total || 0) / limit));
}
function renderPager(container, state) {
if (!container) return;
const total = Number(state.total || 0);
const shown = Number(state.shown || 0);
const limit = Number(state.limit || RECORD_PAGE_SIZE);
const offset = Number(state.offset || 0);
if (total <= limit && offset === 0) {
container.hidden = true;
container.innerHTML = "";
return;
}
const start = shown ? offset + 1 : 0;
const end = offset + shown;
container.hidden = false;
container.innerHTML = `<div class="pager-info">第 ${currentPageNumber(state)} / ${totalPageNumber(state)} 页 · ${start}-${end} / ${total} 条</div>
<div class="pager-actions">
<button class="secondary-button pager-prev" type="button" ${offset <= 0 ? "disabled" : ""}>上一页</button>
<button class="secondary-button pager-next" type="button" ${state.hasMore ? "" : "disabled"}>下一页</button>
</div>`;
}
function pageRangeText(state) {
const total = Number(state.total || 0);
const shown = Number(state.shown || 0);
const offset = Number(state.offset || 0);
if (!total || !shown) return "0 条";
return `${offset + 1}-${offset + shown}`;
return `${shown}`;
}
function makeRecordKey(row, index) {
@@ -154,6 +122,13 @@ function groupRecordsByTeacher(records) {
});
}
function initializeTeacherGroupCollapse(records) {
const groups = groupRecordsByTeacher(records);
collapsedTeacherGroups = groups.length > 1
? new Set(groups.map((group) => group.teacher))
: new Set();
}
function getDisplayRecord(row) {
return correctedRecords.get(row._recordKey) || row;
}
@@ -241,7 +216,7 @@ function toggleRecordSort(field) {
renderCurrentRecords();
}
function renderRecordRow(row) {
function renderRecordRow(row, rowNumber) {
const key = row._recordKey;
const corrected = correctedRecords.get(key);
const displayRow = getDisplayRecord(row);
@@ -251,6 +226,7 @@ function renderRecordRow(row) {
const badge = corrected ? '<span class="correction-badge">已修改</span>' : "";
const summaryExpanded = expandedSummaryRecords.has(key);
return `<tr class="record-row${correctedClass}${summaryClass}" data-record-key="${escapeHtml(key)}" tabindex="0" role="button" aria-expanded="${summaryExpanded ? "true" : "false"}">
<td class="num record-index-cell" data-label="序号">${escapeHtml(rowNumber)}</td>
<td data-label="日期">${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
<td data-label="时间">${escapeHtml(displayRow.time)}</td>
<td data-label="学生">${escapeHtml(displayRow.student)}</td>
@@ -267,16 +243,7 @@ function renderRecordRow(row) {
</tr>`;
}
function summaryBodyKey(recordKey, index) {
return `${recordKey}:${index}`;
}
function summaryPreviewText(value) {
const text = String(value || "暂无正文");
return text.length > 120 ? `${text.slice(0, 120)}...` : text;
}
function renderSummaryEntry(summary, recordKey, index) {
function renderSummaryEntry(summary) {
const title = summary.title || "课程小结";
const time = summary.time_range ? ` · ${summary.time_range}` : "";
const meta = [
@@ -284,21 +251,17 @@ function renderSummaryEntry(summary, recordKey, index) {
summary.teacher || "",
summary.subject || "",
].filter(Boolean).join(" · ");
const bodyKey = summaryBodyKey(recordKey, index);
const body = summary.body || "暂无正文";
const expanded = expandedSummaryBodies.has(bodyKey);
const canToggle = body.length > 120;
return `<div class="record-summary-item">
<div class="record-summary-title">${escapeHtml(title)}${escapeHtml(time)}</div>
${meta ? `<div class="record-summary-meta">${escapeHtml(meta)}</div>` : ""}
<div class="summary-body${expanded ? " summary-full" : " summary-preview"}">${escapeHtml(expanded ? body : summaryPreviewText(body))}</div>
${canToggle ? `<button class="summary-toggle" type="button" data-summary-body-key="${escapeHtml(bodyKey)}">${expanded ? "收起全文" : "展开全文"}</button>` : ""}
<div class="summary-body summary-full">${escapeHtml(body)}</div>
</div>`;
}
function renderSummaryMissingRow(key, expanded) {
return `<tr class="summary-collapse-row summary-missing-row" data-summary-row="${escapeHtml(key)}" ${expanded ? "" : "hidden"}>
<td colspan="7">
<td colspan="${RECORD_TABLE_COLUMN_COUNT}">
<div class="summary-missing">
<span>无课程小结</span>
<button class="small-button summary-supplement" type="button" data-record-key="${escapeHtml(key)}">补充课程小结</button>
@@ -312,23 +275,25 @@ function renderGroupedRecords(records) {
return groupRecordsByTeacher(records)
.map(
(group) => {
let displayIndex = 0;
const collapsed = collapsedTeacherGroups.has(group.teacher);
const rows = collapsed
? ""
: sortRecordsForDisplay(group.records)
.map((row) => {
displayIndex += 1;
currentRecordOrder.push(row._recordKey);
const summaryCount = Number(row.summary_count || 0);
const summaryRow = summaryCount
? `<tr class="summary-collapse-row" data-summary-row="${escapeHtml(row._recordKey)}" ${expandedSummaryRecords.has(row._recordKey) ? "" : "hidden"}>
<td colspan="7">${(row.summaries || []).map((summary, index) => renderSummaryEntry(summary, row._recordKey, index)).join("")}</td>
<td colspan="${RECORD_TABLE_COLUMN_COUNT}">${(row.summaries || []).map((summary) => renderSummaryEntry(summary)).join("")}</td>
</tr>`
: renderSummaryMissingRow(row._recordKey, expandedSummaryRecords.has(row._recordKey));
return `${renderRecordRow(row)}${summaryRow}`;
return `${renderRecordRow(row, displayIndex)}${summaryRow}`;
})
.join("");
return `<tr class="teacher-group" data-teacher="${escapeHtml(group.teacher)}">
<td colspan="7">
<td colspan="${RECORD_TABLE_COLUMN_COUNT}">
<button class="teacher-group-toggle" type="button" data-teacher="${escapeHtml(group.teacher)}" aria-expanded="${collapsed ? "false" : "true"}">
<strong>${escapeHtml(group.teacher)}</strong>
<span>${group.count} 条记录 · ${displayHours(group.totalHours)} · ${collapsed ? "展开" : "收起"}</span>
@@ -362,15 +327,6 @@ function toggleTeacherGroup(teacher) {
renderCurrentRecords();
}
function toggleSummaryBody(key) {
if (expandedSummaryBodies.has(key)) {
expandedSummaryBodies.delete(key);
} else {
expandedSummaryBodies.add(key);
}
renderCurrentRecords();
}
function statusClass(status) {
if (status === "欠费") return "debt";
if (status === "预警") return "warning";
@@ -385,35 +341,36 @@ function renderPayments(payments) {
.join("");
}
function renderInlineAccount(account) {
function renderInlineStudentProfile(studentProfile) {
inlineAccount.hidden = false;
inlineAccount.innerHTML = `<div class="inline-account-head">
<div>
<h3>${escapeHtml(account.student)} 课时账户</h3>
<p>${escapeHtml(account.student_id)}</p>
<h3>${escapeHtml(studentProfile.student)} 学生档案</h3>
<p>${escapeHtml(studentProfile.student_id)}</p>
</div>
<span class="status ${statusClass(account.account_status)}">${escapeHtml(account.account_status)}</span>
<span class="status ${statusClass(studentProfile.account_status)}">${escapeHtml(studentProfile.account_status)}</span>
</div>
<div class="inline-account-grid">
${metric("剩余课时", displayHours(account.remaining, account.remaining_duration))}
${metric("缴费次数", account.payments_count)}
${metricHtml("缴费记录", renderPayments(account.payments))}
${metric("备注", account.note || "无")}
${metric("剩余课时", displayHours(studentProfile.remaining, studentProfile.remaining_duration))}
${metric("入学年份", studentProfile.primary_entry_year || "未填")}
${metric("缴费次数", studentProfile.payments_count)}
${metricHtml("缴费记录", renderPayments(studentProfile.payments))}
${metric("备注", studentProfile.note || "无")}
</div>`;
}
async function loadInlineAccount(student) {
async function loadInlineStudentProfile(student) {
inlineAccount.hidden = false;
inlineAccount.innerHTML = `<div class="inline-account-loading">正在读取 ${escapeHtml(student)}课时账户</div>`;
inlineAccount.innerHTML = `<div class="inline-account-loading">正在读取 ${escapeHtml(student)}学生档案</div>`;
try {
const account = await fetchJson(`/api/student-account/${encodeURIComponent(student)}`);
renderInlineAccount(account);
const studentProfile = await fetchJson(`/api/students/${encodeURIComponent(student)}`);
renderInlineStudentProfile(studentProfile);
} catch (error) {
inlineAccount.innerHTML = `<div class="inline-account-loading">课时账户读取失败:${escapeHtml(error.message)}</div>`;
inlineAccount.innerHTML = `<div class="inline-account-loading">学生档案读取失败:${escapeHtml(error.message)}</div>`;
}
}
function clearInlineAccount() {
function clearInlineStudentProfile() {
inlineAccount.hidden = true;
inlineAccount.innerHTML = "";
}
@@ -434,7 +391,6 @@ function resetCorrections() {
correctedRecordOrder = [];
currentRecordOrder = [];
expandedSummaryRecords = new Set();
expandedSummaryBodies = new Set();
collapsedTeacherGroups = new Set();
activeCorrectionKey = "";
updateCorrectionToolbar();
@@ -779,7 +735,7 @@ async function fetchJson(url, options = {}) {
async function loadHealth() {
try {
const data = await fetchJson("/api/health");
healthText.textContent = `数据更新时间 ${fmtTime(data.classnotes.mtime)}`;
healthText.textContent = `数据更新时间 ${fmtTime(data.updated_at || (data.database || {}).mtime || 0)}`;
} catch (error) {
healthText.textContent = `读取失败:${error.message}`;
}
@@ -788,8 +744,7 @@ async function loadHealth() {
async function queryRecords(query, options = {}) {
const q = query.trim();
if (!q) return;
const offset = Number(options.offset || 0);
recordRows.innerHTML = `<tr><td colspan="7" class="empty">正在查询</td></tr>`;
recordRows.innerHTML = `<tr><td colspan="${RECORD_TABLE_COLUMN_COUNT}" class="empty">正在查询</td></tr>`;
recordMeta.innerHTML = "";
if (recordPager) {
recordPager.hidden = true;
@@ -799,21 +754,20 @@ async function queryRecords(query, options = {}) {
if (options.reset !== false) {
resetCorrections();
resetRecordSort();
clearInlineAccount();
clearInlineStudentProfile();
} else {
currentRecordOrder = [];
expandedSummaryRecords = new Set();
expandedSummaryBodies = new Set();
collapsedTeacherGroups = new Set();
}
try {
const params = new URLSearchParams({ q, limit: String(RECORD_PAGE_SIZE), offset: String(offset) });
const params = new URLSearchParams({ q, all: "1" });
const data = await fetchJson(`/api/records?${params.toString()}`);
const summary = data.summary;
currentRecordPage = {
query: q,
offset: data.offset || offset,
limit: data.limit || RECORD_PAGE_SIZE,
offset: data.offset || 0,
limit: data.limit || 0,
total: data.total_records || 0,
shown: data.shown_records || (data.records || []).length,
hasMore: Boolean(data.has_more),
@@ -821,18 +775,17 @@ async function queryRecords(query, options = {}) {
recordMeta.innerHTML = [
metric("识别日期", data.query.date_range),
metric("命中记录", `${summary.count}`),
metric("当前显示", pageRangeText(currentRecordPage)),
metric("完整展示", pageRangeText(currentRecordPage)),
metric("总课时", displayHours(summary.total_hours, summary.total_duration)),
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
].join("");
if (options.reset !== false && data.query.students.length === 1) {
await loadInlineAccount(data.query.students[0]);
await loadInlineStudentProfile(data.query.students[0]);
}
if (!data.records.length) {
recordRows.innerHTML = `<tr><td colspan="7" class="empty">未找到符合条件的上课记录</td></tr>`;
renderPager(recordPager, currentRecordPage);
recordRows.innerHTML = `<tr><td colspan="${RECORD_TABLE_COLUMN_COUNT}" class="empty">未找到符合条件的上课记录</td></tr>`;
return;
}
@@ -841,10 +794,12 @@ async function queryRecords(query, options = {}) {
_recordIndex: currentRecordPage.offset + index,
_recordKey: makeRecordKey(row, currentRecordPage.offset + index),
}));
if (options.reset !== false) {
initializeTeacherGroupCollapse(currentRecords);
}
recordRows.innerHTML = renderGroupedRecords(currentRecords);
renderPager(recordPager, currentRecordPage);
} catch (error) {
recordRows.innerHTML = `<tr><td colspan="7" class="empty">查询失败:${escapeHtml(error.message)}</td></tr>`;
recordRows.innerHTML = `<tr><td colspan="${RECORD_TABLE_COLUMN_COUNT}" class="empty">查询失败:${escapeHtml(error.message)}</td></tr>`;
}
}
@@ -873,7 +828,6 @@ recordRows.addEventListener("click", (event) => {
const editButton = event.target.closest(".correction-edit");
const deleteButton = event.target.closest(".record-delete");
const teacherToggle = event.target.closest(".teacher-group-toggle");
const summaryToggle = event.target.closest(".summary-toggle");
if (summarySupplementButton) {
openSummarySupplementDialog(summarySupplementButton.dataset.recordKey);
return;
@@ -890,10 +844,6 @@ recordRows.addEventListener("click", (event) => {
toggleTeacherGroup(teacherToggle.dataset.teacher);
return;
}
if (summaryToggle) {
toggleSummaryBody(summaryToggle.dataset.summaryBodyKey);
return;
}
const row = event.target.closest(".record-row");
if (row && !event.target.closest(".record-action-cell")) toggleRecordSummary(row.dataset.recordKey);
});
@@ -908,17 +858,6 @@ recordRows.addEventListener("keydown", (event) => {
toggleRecordSummary(row.dataset.recordKey);
});
if (recordPager) {
recordPager.addEventListener("click", (event) => {
const previous = event.target.closest(".pager-prev");
const next = event.target.closest(".pager-next");
if (!previous && !next) return;
const delta = previous ? -currentRecordPage.limit : currentRecordPage.limit;
const nextOffset = Math.max(0, currentRecordPage.offset + delta);
queryRecords(currentRecordPage.query || recordQuery.value, { offset: nextOffset, reset: false });
});
}
[correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => {
input.addEventListener("input", updateCorrectionPreview);
});
+5 -4
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>新时空教务管理系统</title>
<link rel="stylesheet" href="/static/styles.css?v=20260620-summary-binding" />
<link rel="stylesheet" href="/static/styles.css?v=20260706-sqlite-native" />
</head>
<body>
<header class="topbar">
@@ -53,6 +53,7 @@
<table>
<thead>
<tr>
<th class="num">序号</th>
<th>
<button id="dateSortBtn" class="sort-header-button" type="button" aria-label="日期排序:升序排列,点击切换为降序排列">日期 升序排列</button>
</th>
@@ -68,7 +69,7 @@
</thead>
<tbody id="recordRows">
<tr>
<td colspan="7" class="empty">输入查询条件后显示明细</td>
<td colspan="8" class="empty">输入查询条件后显示明细</td>
</tr>
</tbody>
</table>
@@ -131,7 +132,7 @@
<p id="deleteError" class="correction-error" hidden></p>
<div class="correction-preview">
<span>审核说明</span>
<code>确认后会提交后台审核;审核通过才会删除 classnotes 中的记录,并恢复对应学生课时。</code>
<code>确认后会提交后台审核;审核通过才会删除数据库中的上课记录,并恢复对应学生课时。</code>
</div>
<div class="modal-actions">
<button id="cancelDeleteBtn" class="secondary-button" type="button">取消</button>
@@ -166,6 +167,6 @@
</section>
</div>
<script src="/static/app.js?v=20260620-summary-binding"></script>
<script src="/static/app.js?v=20260706-sqlite-native"></script>
</body>
</html>
+5 -22
View File
@@ -933,27 +933,6 @@ textarea:focus {
border-top: 1px solid var(--line);
}
.summary-body + .summary-toggle,
.summary-full + .summary-toggle {
margin-top: 8px;
}
.summary-toggle {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--accent-strong);
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
.summary-toggle:hover {
border-color: var(--accent);
}
.summary-conflict-list {
display: grid;
gap: 10px;
@@ -1286,7 +1265,6 @@ table {
}
.admin-panel table,
.accounts-panel table,
.records-panel table {
min-width: 820px;
}
@@ -1392,6 +1370,11 @@ td {
color: var(--muted);
}
.record-index-cell {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.record-row {
cursor: pointer;
}
-9
View File
@@ -10,17 +10,8 @@ services:
- .env
environment:
TZ: ${TZ:-Asia/Shanghai}
USE_SQLITE_SOURCE: ${USE_SQLITE_SOURCE:-1}
SQLITE_DB_PATH: ${SQLITE_DB_PATH:-/data/xsk_education.db}
RUNTIME_DATA_ROOT: ${RUNTIME_DATA_ROOT:-/data/runtime_text_cache}
LEGACY_TEXT_ROOT: ${LEGACY_TEXT_ROOT:-/data}
CLASSNOTES_PATH: ${CLASSNOTES_PATH:-/data/classnotes.txt}
ACCOUNTS_PATH: ${ACCOUNTS_PATH:-/data/学生课时账户.md}
TEACHERS_PATH: ${TEACHERS_PATH:-/data/教师档案.md}
ADMIN_TASKS_PATH: ${ADMIN_TASKS_PATH:-/data/admin_tasks.json}
COURSE_SUMMARIES_ROOT: ${COURSE_SUMMARIES_ROOT:-/data/course_summaries}
COURSE_SUMMARY_STATE_PATH: ${COURSE_SUMMARY_STATE_PATH:-/data/course_summary_state.json}
OPERATION_LOGS_PATH: ${OPERATION_LOGS_PATH:-/data/operation_logs.jsonl}
INGEST_AUTH_TOKEN: ${INGEST_AUTH_TOKEN:-}
ports:
- "${APP_PORT:-18080}:8000"
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.xsk.education-management.sync</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/yangdawei/Desktop/新时空业务源数据/tools/xsk-education-management/scripts/sync_to_vps.py</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/yangdawei/Library/Logs/xsk-education-management/sync.log</string>
<key>StandardErrorPath</key>
<string>/Users/yangdawei/Library/Logs/xsk-education-management/sync.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app import repository
from app.domain import Account, Payment, Teacher
def assert_equal(name: str, actual, expected) -> None:
if actual != expected:
raise AssertionError(f"{name}: got {actual!r}, expected {expected!r}")
def main() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
repository.SQLITE_DB_PATH = Path(temp_dir) / "xsk_test.db"
repository.initialize_runtime_database()
student = repository.create_student_profile(
Account("", "", [Payment("2026-07-01", 10)], 0, "正常", 2020, "")
)
assert_equal("新增学生", student["account"]["student"], "")
teacher = repository.create_teacher(Teacher("", "王老师", "", ["英语"], "在岗", ""))
assert_equal("新增教师", teacher["teacher"]["name"], "王老师")
record = repository.register_class_record_lines(
line="2026.07.06-星期一-10:00-11:00-甲-1小时0分-王老师-英语"
)
assert_equal("登记上课", record["registered"], 1)
repository.register_payment_lines(line="甲-2026-07-02:2")
students = repository.students_payload()
assert_equal("学生数量", students["count"], 1)
assert_equal("余额重算", students["students"][0]["remaining"], 11)
queried = repository.query_public_records("甲7月英语")
assert_equal("查询记录", queried["total_records"], 1)
record_id = queried["records"][0]["record_id"]
correction = repository.submit_public_correction_tasks([{"record_id": record_id, "time": "11:00-12:00"}])
approved = repository.approve_admin_task(int(correction["items"][0]["id"]))
assert_equal("批准纠错", approved["task"]["status"], "approved")
assert_equal("纠错生效", repository.query_public_records("甲7月英语")["records"][0]["time"], "11:00-12:00")
supplement = repository.supplement_course_summary(
repository.query_public_records("甲7月英语")["records"][0]["record_id"],
"课堂表现很好",
)
assert_equal("补充小结", supplement["status"], "saved")
summaries = repository.query_course_summaries(q="课堂表现")
assert_equal("小结查询", summaries["count"], 1)
summary_id = summaries["items"][0]["id"]
repository.update_course_summary_body(summary_id, "更新后的课堂表现")
assert_equal("小结正文更新", repository.query_course_summaries(q="更新后")["count"], 1)
latest = repository.list_operation_logs()["items"][0]
rollback = repository.rollback_operation_log(str(latest["id"]))
assert_equal("撤回模式", rollback["rollback_mode"], "sqlite_snapshot")
assert_equal("撤回日志", repository.list_operation_logs()["items"][0]["operation"], "撤回操作")
print("db smoke test passed")
if __name__ == "__main__":
main()
+1 -2
View File
@@ -115,8 +115,7 @@ def write_env(args: argparse.Namespace) -> Path:
f"PYTHON_IMAGE={args.python_image}",
f"BASIC_AUTH_USERNAME={args.auth_user}",
f"BASIC_AUTH_PASSWORD={args.auth_password}",
"CLASSNOTES_PATH=/data/classnotes.txt",
"ACCOUNTS_PATH=/data/学生课时账户.md",
"SQLITE_DB_PATH=/data/xsk_education.db",
"",
]
)
+113 -23
View File
@@ -11,17 +11,13 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app import repository # noqa: E402
from app.data import ( # noqa: E402
append_operation_log,
course_summary_to_class_record_line,
course_summary_semantic_key,
create_course_summary_review_task,
normalize_course_summary,
read_admin_tasks,
read_course_summary_state,
safe_filename_part,
sha1_text,
write_course_summary_state,
)
@@ -41,12 +37,13 @@ EXCLUDE_PREFIXES = (
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="导入历史课程小结 Markdown 到 VPS 数据目录")
parser = argparse.ArgumentParser(description="导入历史课程小结 Markdown 到 SQLite")
parser.add_argument("--source", required=True, type=Path, help="本机历史课程小结采集目录")
parser.add_argument("--target", default=Path("/data/course_summaries"), type=Path, help="VPS 课程小结正式目录")
parser.add_argument("--state", default=Path("/data/course_summary_state.json"), type=Path, help="课程小结状态文件")
parser.add_argument("--tasks", default=Path("/data/admin_tasks.json"), type=Path, help="管理任务文件")
parser.add_argument("--operation-logs", default=Path("/data/operation_logs.jsonl"), type=Path, help="操作日志文件")
parser.add_argument("--db-path", default=Path("/data/xsk_education.db"), type=Path, help="SQLite 数据库路径")
parser.add_argument("--target", type=Path, help=argparse.SUPPRESS)
parser.add_argument("--state", type=Path, help=argparse.SUPPRESS)
parser.add_argument("--tasks", type=Path, help=argparse.SUPPRESS)
parser.add_argument("--operation-logs", type=Path, help=argparse.SUPPRESS)
parser.add_argument("--missing-table", type=Path, help="历史 classnotes缺失.txt;不传则尝试 source/classnotes缺失.txt")
parser.add_argument("--dry-run", action="store_true", help="只统计,不写入")
return parser.parse_args()
@@ -292,30 +289,123 @@ def import_missing_tasks(source: Path, missing_table: Path, tasks_path: Path, dr
return created
def collect_history_entries(source: Path) -> tuple[int, list[dict], int]:
scanned = 0
skipped = 0
entries: list[dict] = []
for path in sorted(source.rglob("*.md")):
if not should_import_markdown(path, source):
continue
scanned += 1
for entry in iter_markdown_entries(path):
try:
entries.append(normalize_course_summary(entry))
except ValueError:
skipped += 1
return scanned, entries, skipped
def collect_missing_review_tasks(source: Path, missing_table: Path) -> list[tuple[dict, str]]:
rows = parse_missing_table(missing_table)
tasks: list[tuple[dict, str]] = []
for row in rows:
source_seed = "|".join(str(row.get(key, "")) for key in ("日期", "学生", "老师", "科目", "来源文件", "备注"))
source_id = f"history-missing:{sha1_text(source_seed, 20)}"
summary = {
"source_id": source_id,
"student": row.get("学生", ""),
"date_iso": str(row.get("日期", "")).replace(".", "-"),
"time_range": row.get("时间段", ""),
"duration": row.get("时长", ""),
"teacher": row.get("老师", ""),
"subject": row.get("科目", ""),
"group": row.get("群聊", ""),
"title": "历史 classnotes 缺失",
"body": f"历史 classnotes 缺失项:{source_seed}",
"recognition_source": "history_missing_table",
"confidence": "review",
"teacher_trusted": False,
"remark": row.get("备注", ""),
}
summary.update({key: value for key, value in source_summary_for_missing_row(source, row).items() if value not in ("", None)})
proposed_line = ""
try:
proposed_line = course_summary_to_class_record_line(summary)
except ValueError:
pass
tasks.append((summary, proposed_line))
return tasks
def main() -> None:
args = parse_args()
source = args.source.resolve()
target = args.target.resolve()
repository.SQLITE_DB_PATH = args.db_path.resolve()
missing_table = args.missing_table or (source / "classnotes缺失.txt")
scanned, copied = copy_markdown_files(source, target, args.dry_run)
imported, skipped = rebuild_state_from_markdown(target if target.exists() else source, args.state, args.dry_run)
review_tasks = import_missing_tasks(source, missing_table, args.tasks, args.dry_run)
scanned, entries, skipped = collect_history_entries(source)
missing_tasks = collect_missing_review_tasks(source, missing_table)
batch_id = f"history-import-{datetime.now().strftime('%Y%m%d%H%M%S')}"
if not args.dry_run:
append_operation_log(
args.operation_logs,
"history_course_summary_import",
"completed",
def work(conn, backup_id):
imported = 0
duplicates = 0
for summary in entries:
saved = repository._save_course_summary(conn, summary)
repository._add_summary_seen(conn, summary["source_id"], course_summary_semantic_key(summary))
imported += 1 if saved.get("added") else 0
duplicates += 0 if saved.get("added") else 1
created_tasks = 0
for summary, proposed_line in missing_tasks:
repository._create_course_summary_review_task(
conn,
summary,
proposed_line,
["历史 classnotes缺失导入,默认只进入审核,不自动扣课时"],
saved_path=str(summary.get("remark") or ""),
)
created_tasks += 1
repository._add_ingest_batch(
conn,
{
"batch_id": batch_id,
"received_at": datetime.now().isoformat(timespec="seconds"),
"window": {"mode": "历史导入", "source": str(source)},
"students": [],
"result": {
"received": len(entries),
"saved": imported,
"auto_registered": 0,
"review_pending": created_tasks,
"duplicates": duplicates,
"rejected": skipped,
},
},
)
repository._append_operation_log(
conn,
"历史课程小结导入",
"完成",
source=str(source),
target=str(target),
scanned_files=scanned,
copied_files=copied,
indexed_summaries=imported,
skipped_summaries=skipped,
review_tasks=review_tasks,
review_tasks=created_tasks,
backup_id=backup_id,
)
return {"imported": imported, "duplicates": duplicates, "review_tasks": created_tasks}
result = repository._write_transaction("history-course-summary-import", [str(source), batch_id], work)
imported = int(result.get("imported") or 0)
duplicates = int(result.get("duplicates") or 0)
review_tasks = int(result.get("review_tasks") or 0)
else:
imported = len(entries)
duplicates = 0
review_tasks = len(missing_tasks)
print(
f"历史小结导入完成:扫描 Markdown {scanned} 个,复制 {copied} 个,"
f"索引小结 {imported} 条,跳过 {skipped} 条,生成审核任务 {review_tasks} 条。"
f"历史小结导入完成:扫描 Markdown {scanned} 个,"
f"写入/待写入小结 {imported} 条,重复 {duplicates} 条,"
f"跳过 {skipped} 条,生成审核任务 {review_tasks} 条。"
)
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
from pathlib import Path
import plistlib
import subprocess
PROJECT_DIR = Path(__file__).resolve().parents[1]
LABEL = "com.xsk.education-management.sync"
OLD_LABEL = "com.xsk.records.sync"
PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
OLD_PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{OLD_LABEL}.plist"
LOG_DIR = Path.home() / "Library" / "Logs" / "xsk-education-management"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="安装新时空教务管理系统同步 LaunchAgent")
parser.add_argument("--use-sshpass", action="store_true", default=os.getenv("XSK_USE_SSHPASS", "") == "1")
parser.add_argument("--ssh-password", default=os.getenv("XSK_SSH_PASSWORD"))
return parser.parse_args()
def main() -> int:
args = parse_args()
LOG_DIR.mkdir(parents=True, exist_ok=True)
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
program_arguments = [
"/usr/bin/python3",
str(PROJECT_DIR / "scripts" / "sync_to_vps.py"),
]
if args.use_sshpass:
program_arguments.append("--use-sshpass")
environment = {
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
}
if args.use_sshpass and args.ssh_password:
environment["XSK_SSH_PASSWORD"] = args.ssh_password
plist = {
"Label": LABEL,
"ProgramArguments": program_arguments,
"RunAtLoad": True,
"KeepAlive": True,
"StandardOutPath": str(LOG_DIR / "sync.log"),
"StandardErrorPath": str(LOG_DIR / "sync.err.log"),
"EnvironmentVariables": environment,
}
with PLIST_PATH.open("wb") as handle:
plistlib.dump(plist, handle)
subprocess.run(["launchctl", "unload", str(OLD_PLIST_PATH)], check=False, capture_output=True)
OLD_PLIST_PATH.unlink(missing_ok=True)
subprocess.run(["launchctl", "unload", str(PLIST_PATH)], check=False, capture_output=True)
subprocess.run(["launchctl", "load", str(PLIST_PATH)], check=True)
print(f"已安装并启动 LaunchAgent: {PLIST_PATH}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+160 -9
View File
@@ -1,25 +1,39 @@
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const elements = new Map();
const staticDir = path.resolve(__dirname, "../app/static");
function element(id) {
if (!elements.has(id)) {
elements.set(id, {
function domNode(id, extras = {}) {
let value = "";
return {
id,
textContent: "",
innerHTML: "",
hidden: false,
disabled: false,
value: "",
dataset: {},
classList: { toggle() {} },
setAttribute(name, value) {
this[name] = value;
get value() {
return value;
},
set value(nextValue) {
value = String(nextValue ?? "");
},
setAttribute(name, nextValue) {
this[name] = nextValue;
},
addEventListener() {},
focus() {},
});
...extras,
};
}
const elements = new Map();
function element(id) {
if (!elements.has(id)) {
elements.set(id, domNode(id));
}
return elements.get(id);
}
@@ -52,6 +66,9 @@ const context = {
Number,
String,
};
context.window = { location: { href: "" } };
context.confirm = () => true;
context.alert = () => {};
function assertEqual(name, actual, expected) {
if (actual !== expected) {
@@ -64,7 +81,7 @@ function value(expression) {
}
vm.createContext(context);
vm.runInContext(fs.readFileSync("app/static/app.js", "utf8"), context);
vm.runInContext(fs.readFileSync(path.join(staticDir, "app.js"), "utf8"), context);
vm.runInContext(
`
@@ -102,4 +119,138 @@ assertEqual("纠错后排序仍使用显示记录", value("currentRecordOrder.jo
vm.runInContext("updateCorrectionToolbar('测试提交按钮')", context);
assertEqual("提交审核按钮文案", value("submitCorrectionsBtn.textContent"), "提交审核 1 条");
vm.runInContext(
`
const longSummaryBody = "完整课程小结内容".repeat(20);
currentRecords = [
{
date: "2026.06.14",
weekday: "星期日",
time: "10:00-11:00",
student: "甲",
duration: "1小时0分",
duration_hours: 1,
teacher: "王老师",
subject: "英语",
_recordIndex: 0,
_recordKey: "summary-test",
summary_count: 1,
summaries: [{ title: "课程小结", body: longSummaryBody, teacher: "王老师", subject: "英语" }],
},
];
expandedSummaryRecords = new Set(["summary-test"]);
collapsedTeacherGroups = new Set();
const summaryHtml = renderGroupedRecords(currentRecords);
if (!summaryHtml.includes(longSummaryBody)) {
throw new Error("课程小结展开后应直接显示完整正文");
}
if (summaryHtml.includes("展开全文") || summaryHtml.includes("summary-toggle") || summaryHtml.includes("summary-preview")) {
throw new Error("课程小结展开后不应再渲染展开全文按钮或预览裁切");
}
`,
context,
);
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);
-204
View File
@@ -1,204 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from dataclasses import dataclass
from datetime import datetime
import os
from pathlib import Path
import shlex
import subprocess
import sys
import time
DEFAULT_LOCAL_DIR = Path("/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户")
DEFAULT_REMOTE_HOST = "121.199.172.246"
DEFAULT_REMOTE_USER = "root"
DEFAULT_REMOTE_PORT = 22222
DEFAULT_REMOTE_DIR = "/root/新时空教务管理系统"
DEFAULT_IDENTITY_FILE = Path.home() / ".ssh" / "xsk_records_vps_ed25519"
SYNC_FILES = ("classnotes.txt", "学生课时账户.md")
@dataclass(frozen=True)
class Config:
local_dir: Path
remote_host: str
remote_user: str
remote_port: int
remote_dir: str
identity_file: Path | None
interval: float
use_sshpass: bool
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="同步新时空课程记录数据到 VPS")
parser.add_argument("--once", action="store_true", help="只同步一次后退出")
parser.add_argument("--interval", type=float, default=float(os.getenv("XSK_SYNC_INTERVAL", "3")))
parser.add_argument("--local-dir", default=os.getenv("XSK_LOCAL_DIR", str(DEFAULT_LOCAL_DIR)))
parser.add_argument("--remote-host", default=os.getenv("XSK_REMOTE_HOST", DEFAULT_REMOTE_HOST))
parser.add_argument("--remote-user", default=os.getenv("XSK_REMOTE_USER", DEFAULT_REMOTE_USER))
parser.add_argument("--remote-port", type=int, default=int(os.getenv("XSK_REMOTE_PORT", str(DEFAULT_REMOTE_PORT))))
parser.add_argument("--remote-dir", default=os.getenv("XSK_REMOTE_DIR", DEFAULT_REMOTE_DIR))
parser.add_argument(
"--identity-file",
default=os.getenv("XSK_IDENTITY_FILE", str(DEFAULT_IDENTITY_FILE) if DEFAULT_IDENTITY_FILE.exists() else ""),
help="SSH 私钥路径;默认兼容使用历史文件 ~/.ssh/xsk_records_vps_ed25519(如果存在)",
)
parser.add_argument(
"--use-sshpass",
action="store_true",
default=os.getenv("XSK_USE_SSHPASS", "") == "1",
help="从 XSK_SSH_PASSWORD 读取密码并通过 sshpass 连接;推荐改用 SSH key",
)
return parser.parse_args()
def log(message: str) -> None:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] {message}", flush=True)
def run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
if env.get("XSK_SSH_PASSWORD") and not env.get("SSHPASS"):
env["SSHPASS"] = env["XSK_SSH_PASSWORD"]
result = subprocess.run(command, text=True, capture_output=True, check=False, env=env)
if result.returncode != 0:
safe_command = " ".join(shlex.quote(part) for part in command if part != os.getenv("XSK_SSH_PASSWORD", ""))
raise RuntimeError(
f"命令失败({result.returncode}): {safe_command}\n"
f"STDOUT: {result.stdout.strip()}\nSTDERR: {result.stderr.strip()}"
)
return result
def base_remote(config: Config) -> str:
return f"{config.remote_user}@{config.remote_host}"
def ssh_options(config: Config) -> list[str]:
options = [
"-p",
str(config.remote_port),
"-o",
"StrictHostKeyChecking=accept-new",
]
if config.identity_file:
options.extend(["-i", str(config.identity_file), "-o", "IdentitiesOnly=yes"])
return options
def ssh_prefix(config: Config) -> list[str]:
command: list[str] = []
if config.use_sshpass:
password = os.getenv("XSK_SSH_PASSWORD")
if not password:
raise RuntimeError("启用 --use-sshpass 时必须设置 XSK_SSH_PASSWORD")
command.extend(["sshpass", "-e"])
command.extend(["ssh", *ssh_options(config), base_remote(config)])
return command
def rsync_prefix(config: Config) -> list[str]:
command: list[str] = []
if config.use_sshpass:
password = os.getenv("XSK_SSH_PASSWORD")
if not password:
raise RuntimeError("启用 --use-sshpass 时必须设置 XSK_SSH_PASSWORD")
command.extend(["sshpass", "-e"])
ssh_command = " ".join(shlex.quote(part) for part in ["ssh", *ssh_options(config)])
command.extend(
[
"rsync",
"-az",
"-e",
ssh_command,
]
)
return command
def remote_shell_quote(value: str) -> str:
return shlex.quote(value)
def ensure_remote_dirs(config: Config) -> None:
data_dir = f"{config.remote_dir.rstrip('/')}/data"
run_command(ssh_prefix(config) + [f"mkdir -p {remote_shell_quote(data_dir)}"])
def sync_once(config: Config) -> None:
ensure_remote_dirs(config)
data_dir = f"{config.remote_dir.rstrip('/')}/data"
for filename in SYNC_FILES:
source = config.local_dir / filename
if not source.exists():
raise FileNotFoundError(f"本地文件不存在: {source}")
temp_name = f".{filename}.tmp"
remote_temp = f"{base_remote(config)}:{data_dir}/{temp_name}"
run_command(rsync_prefix(config) + [str(source), remote_temp])
run_command(
ssh_prefix(config)
+ [
"mv "
f"{remote_shell_quote(data_dir + '/' + temp_name)} "
f"{remote_shell_quote(data_dir + '/' + filename)}"
]
)
log(f"已同步 {source.name}")
def file_signature(path: Path) -> tuple[int, int]:
stat = path.stat()
return stat.st_mtime_ns, stat.st_size
def current_signatures(local_dir: Path) -> dict[str, tuple[int, int]]:
return {filename: file_signature(local_dir / filename) for filename in SYNC_FILES}
def watch(config: Config) -> None:
log("启动新时空教务管理系统数据同步监听")
signatures: dict[str, tuple[int, int]] = {}
while True:
try:
next_signatures = current_signatures(config.local_dir)
if next_signatures != signatures:
time.sleep(0.4)
sync_once(config)
signatures = current_signatures(config.local_dir)
except Exception as exc:
log(f"同步失败: {exc}")
time.sleep(config.interval)
def main() -> int:
args = parse_args()
config = Config(
local_dir=Path(args.local_dir).expanduser(),
remote_host=args.remote_host,
remote_user=args.remote_user,
remote_port=args.remote_port,
remote_dir=args.remote_dir,
identity_file=Path(args.identity_file).expanduser() if args.identity_file else None,
interval=args.interval,
use_sshpass=args.use_sshpass,
)
try:
if args.once:
sync_once(config)
else:
watch(config)
except KeyboardInterrupt:
return 130
except Exception as exc:
log(f"退出: {exc}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())