Compare commits

..

21 Commits

Author SHA1 Message Date
Codex 33e0c71970 fix: improve mobile responsive layout 2026-06-16 01:33:18 +08:00
Codex 2785f3eb41 Update teacher profiles and record summaries 2026-06-16 01:09:51 +08:00
Codex a7fbe91266 fix: require manual summary fields before registration 2026-06-15 16:35:46 +08:00
Codex 854e4942ba feat: add summary registration and deletion review 2026-06-15 16:23:06 +08:00
Codex cc46f651dc feat: improve summary review layout 2026-06-15 15:11:12 +08:00
Codex 6cd1288b18 fix: preserve source body for missing summaries 2026-06-15 14:53:01 +08:00
Codex e2705c269c docs: rename app directory in docs 2026-06-15 14:21:50 +08:00
Codex d394649c9c docs: update vps root directory name 2026-06-15 14:18:36 +08:00
Codex fccc3585b4 chore: set compose project name 2026-06-15 13:54:46 +08:00
Codex 41452bcd73 chore: unify education management naming 2026-06-15 12:38:25 +08:00
Codex 5112f7ab4e refactor: split app structure 2026-06-15 12:24:49 +08:00
Codex df076f6951 feat: add course summary search 2026-06-15 12:08:04 +08:00
yangdawei 8832a52310 docs: correct course summary import path 2026-06-15 11:28:21 +08:00
yangdawei a39f01b1e3 feat: ingest course summaries on vps 2026-06-15 11:22:52 +08:00
Codex b2913dbd8a chore: rename app to xsk education manager 2026-06-14 21:14:10 +08:00
Codex 6417d2baa9 feat: add admin console and review workflow 2026-06-14 21:13:23 +08:00
Codex 2591549734 chore: add gitea backup hook 2026-06-14 21:10:59 +08:00
Codex 5f90abf6a8 chore: add maintenance workflow 2026-06-13 23:26:51 +08:00
Codex ad77a19fbd feat: add date and time sorting controls 2026-06-13 23:26:17 +08:00
Codex 839149c258 添加数据备份和快捷查询优化 2026-06-12 02:50:15 +08:00
Codex aab9da4f31 初始化新时空数据应用 2026-06-12 02:11:47 +08:00
56 changed files with 5107 additions and 14474 deletions
+17
View File
@@ -0,0 +1,17 @@
APP_PORT=18080
COMPOSE_PROJECT_NAME=xsk-education-management
TZ=Asia/Shanghai
PYTHON_IMAGE=python:3.12-slim
BASIC_AUTH_USERNAME=wolfydw
BASIC_AUTH_PASSWORD=change-me
ADMIN_AUTH_PASSWORD=change-me
INGEST_AUTH_TOKEN=change-this-ingest-token
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
+4 -12
View File
@@ -1,22 +1,14 @@
# Top-level runtime data and backups
data/
archives/
# Local env files
.env .env
.env.* .env.*
!.env.example !.env.example
app/.env *.log
app/.env.* __pycache__/
!app/.env.example
# Python caches and local tooling
**/__pycache__/
*.py[cod] *.py[cod]
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
.ruff_cache/ .ruff_cache/
.venv/ .venv/
venv/ venv/
*.log data/
backups/
.DS_Store .DS_Store
View File
+10 -33
View File
@@ -2,8 +2,6 @@
新时空教务管理系统长期维护时,目标是每次更新都能做到可检查、可部署、可回滚,并且不误改业务数据。 新时空教务管理系统长期维护时,目标是每次更新都能做到可检查、可部署、可回滚,并且不误改业务数据。
以下命令默认在仓库根目录 `/root/新时空教务管理系统` 执行。根目录 `Makefile` 会转发到 `app/` 下的实际应用配置;不要在根目录直接运行裸 `docker compose`,需要直接调用时使用 `docker compose -f app/docker-compose.yml <命令>`
## 日常更新流程 ## 日常更新流程
1. 查看当前改动: 1. 查看当前改动:
@@ -41,7 +39,7 @@
- `make check`:检查前端 JS 语法和后端 Python 编译。 - `make check`:检查前端 JS 语法和后端 Python 编译。
- `make smoke`:运行轻量前端行为烟测,覆盖排序按钮、组内排序和纠错后排序。 - `make smoke`:运行轻量前端行为烟测,覆盖排序按钮、组内排序和纠错后排序。
- `make data-hash`:输出 SQLite 数据库、迁移报告和归档校验文件的 SHA-256。 - `make data-hash`:输出 `classnotes.txt``学生课时账户.md`,以及存在时的 `admin_tasks.json``course_summary_state.json` 的 SHA-256。
- `make build`:构建 Docker 镜像。 - `make build`:构建 Docker 镜像。
- `make up`:重启 Docker Compose 服务。 - `make up`:重启 Docker Compose 服务。
- `make health`:带认证访问 `/api/health` - `make health`:带认证访问 `/api/health`
@@ -49,33 +47,6 @@
- `make logs`:查看最近服务日志。 - `make logs`:查看最近服务日志。
- `make install-gitea-backup`:安装提交后自动推送到 Gitea 的 Git hook。 - `make install-gitea-backup`:安装提交后自动推送到 Gitea 的 Git hook。
## 收尾验证注意事项
收尾阶段要验证“当前运行服务”,不要只验证工作区文件。应用镜像在构建时把源码复制进容器;修改 Python、HTML、JS、CSS 后,如果没有重新执行 `make build``make up``curl` 到的接口和浏览器加载的页面仍可能是旧镜像内容。
- 部署类验证按依赖顺序执行,不要并行运行 `make up``make ps``curl`。先等 `make up` 完成,再看 `make ps`,最后访问接口或页面。
- 如果接口返回旧字段或旧页面,先确认是否已重建并重启容器;不要直接把旧响应判断成代码逻辑错误。
- 前端脚本或样式变更后,记得同步更新 HTML 中对应静态资源的 `?v=` 参数,再构建镜像,避免浏览器继续使用缓存。
- 用 `curl` 做收尾验证时优先使用简单命令。需要检查 HTML 内容时,先直接获取页面;如果要配合 `grep`,先在本地确认引号转义正确,避免把 shell 引号错误误判成服务问题。
- 如果 `curl http://127.0.0.1:<端口>` 连接失败,但 `make ps` 显示容器和端口正常,先区分执行环境网络限制和服务异常;必要时再看 `make logs`
推荐收尾顺序:
```bash
make check
make build
make up
make ps
```
随后再访问具体接口或页面,例如:
```bash
set -a; . ./app/.env
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"
```
## Gitea 自动备份 ## Gitea 自动备份
新时空教务管理系统推荐把 Gitea SSH 仓库配置为 `origin`,并用 `post-commit` hook 在每次提交后自动推送当前分支。 新时空教务管理系统推荐把 Gitea SSH 仓库配置为 `origin`,并用 `post-commit` hook 在每次提交后自动推送当前分支。
@@ -104,10 +75,15 @@ git push origin HEAD:<当前分支>
业务数据文件位于: 业务数据文件位于:
```text ```text
/root/新时空教务管理系统/data/xsk_education.db /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
``` ```
普通前端和查询类改动不应该改变数据库;课程小结查询页是只读功能,也不应该改变数据库。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、学生档案编辑、课程小结自动入账或审核批准,先确认自动备份目录: 普通前端和查询类改动不应该改变这些文件;课程小结查询页是只读功能,也不应该改变这些文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、课时账户编辑、课程小结自动入账或审核批准,先确认自动备份目录:
```text ```text
/root/新时空教务管理系统/data/backups/ /root/新时空教务管理系统/data/backups/
@@ -143,7 +119,8 @@ git push origin HEAD:<当前分支>
## 课程小结迁移维护 ## 课程小结迁移维护
- VPS `/data/xsk_education.db` 唯一正式业务数据源 - VPS `classnotes.txt``学生课时账户.md` 唯一正式写入方
- 本机 `com.xsk.education-management.sync` 常驻同步迁移后应停止,避免旧本地数据覆盖 VPS。
- 本机课程小结采集脚本用 `XSK_INGEST_URL``XSK_INGEST_TOKEN` 推送批次;失败批次保存在本机 `推送失败队列/` - 本机课程小结采集脚本用 `XSK_INGEST_URL``XSK_INGEST_TOKEN` 推送批次;失败批次保存在本机 `推送失败队列/`
- 管理后台的“课程小结审核”处理低置信或冲突小结;“操作记录”追踪接收、自动入账、重复、失败、审核批准和驳回。 - 管理后台的“课程小结审核”处理低置信或冲突小结;“操作记录”追踪接收、自动入账、重复、失败、审核批准和驳回。
- 历史小结导入使用 `scripts/import_course_summaries.py`,历史 `classnotes缺失.txt` 只生成审核任务,不自动扣课时。 - 历史小结导入使用 `scripts/import_course_summaries.py`,历史 `classnotes缺失.txt` 只生成审核任务,不自动扣课时。
+22 -24
View File
@@ -1,46 +1,44 @@
SHELL := /bin/bash SHELL := /bin/bash
APP_DIR := app APP_PORT ?= 18080
APP_MAKE := $(MAKE) -C $(APP_DIR) --no-print-directory DATA_FILES := ../data/classnotes.txt ../data/学生课时账户.md $(wildcard ../data/admin_tasks.json) $(wildcard ../data/course_summary_state.json)
COMPOSE := docker compose -f $(APP_DIR)/docker-compose.yml
.PHONY: check smoke data-hash migrate-sqlite-dry-run migrate-sqlite build up ps health deploy logs install-gitea-backup compose-config .PHONY: check smoke data-hash build up ps health deploy logs install-gitea-backup
check: check:
$(APP_MAKE) check node --check app/static/app.js
node --check app/static/admin.js
python3 -m compileall -q app
smoke: smoke:
$(APP_MAKE) smoke node scripts/smoke_test.js
data-hash: data-hash:
$(APP_MAKE) data-hash sha256sum $(DATA_FILES)
migrate-sqlite-dry-run:
$(APP_MAKE) migrate-sqlite-dry-run
migrate-sqlite:
$(APP_MAKE) migrate-sqlite
build: build:
$(APP_MAKE) build docker compose build
up: up:
$(APP_MAKE) up docker compose up -d --remove-orphans
ps: ps:
$(APP_MAKE) ps docker compose ps
health: health:
$(APP_MAKE) health set -a; . ./.env; curl --fail --silent --show-error --retry 10 --retry-delay 1 --retry-connrefused --retry-all-errors -u "records:$${BASIC_AUTH_PASSWORD}" "http://127.0.0.1:$${APP_PORT:-$(APP_PORT)}/api/health"; echo
deploy: deploy: check smoke
$(APP_MAKE) deploy $(MAKE) --no-print-directory data-hash
$(MAKE) --no-print-directory build
$(MAKE) --no-print-directory up
sleep 2
$(MAKE) --no-print-directory health
$(MAKE) --no-print-directory data-hash
$(MAKE) --no-print-directory ps
logs: logs:
$(APP_MAKE) logs docker compose logs --tail=120 xsk-education-management
install-gitea-backup: install-gitea-backup:
$(APP_MAKE) install-gitea-backup python3 scripts/install_gitea_backup_hook.py
compose-config:
$(COMPOSE) config
+68 -47
View File
@@ -1,6 +1,6 @@
# 新时空教务管理系统 # 新时空教务管理系统
这是一个面向新时空教务业务的综合管理系统。迁移后 VPS 是正式业务数据主机,负责保存 SQLite 数据库 `/data/xsk_education.db`;本机只保留微信群聊天记录采集/识别,并把课程小结批量推送到 VPS。 这是一个面向新时空教务业务的综合管理系统。迁移后 VPS 是正式业务数据主机,负责保存 `classnotes.txt``学生课时账户.md`、课程小结库、审核任务和操作记录;本机只保留微信群聊天记录采集/识别,并把课程小结批量推送到 VPS。
## 目录 ## 目录
@@ -8,31 +8,17 @@
- `MAINTENANCE.md`:日常检查、部署、数据保护和回滚流程。 - `MAINTENANCE.md`:日常检查、部署、数据保护和回滚流程。
- `Makefile`:常用维护命令入口。 - `Makefile`:常用维护命令入口。
- `scripts/deploy_to_vps.py`:部署新时空教务管理系统到 VPS。 - `scripts/deploy_to_vps.py`:部署新时空教务管理系统到 VPS。
- `scripts/migrate_text_to_sqlite.py`:一次性把旧纯文本、JSON、JSONL 和课程小结 Markdown 迁移进 SQLite。
- `scripts/import_course_summaries.py`:一次性导入历史课程小结 Markdown。 - `scripts/import_course_summaries.py`:一次性导入历史课程小结 Markdown。
- `scripts/sync_to_vps.py`:旧版正式数据同步脚本,迁移后不要继续常驻运行。
- `scripts/smoke_test.js`:轻量前端行为烟测。 - `scripts/smoke_test.js`:轻量前端行为烟测。
- `scripts/install_gitea_backup_hook.py`:安装提交后自动推送到 Gitea 的 Git hook。 - `scripts/install_gitea_backup_hook.py`:安装提交后自动推送到 Gitea 的 Git hook。
- `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。
## 维护入口 - `launchd/com.xsk.education-management.sync.plist.template`LaunchAgent 模板。
推荐在仓库根目录 `/root/新时空教务管理系统` 执行日常维护命令:
```bash
make check
make smoke
make deploy
```
根目录 `Makefile` 会自动转发到 `app/` 下的实际应用配置,避免在错误目录执行 `docker compose``make deploy`。需要直接运行 Docker Compose 时,使用:
```bash
docker compose -f app/docker-compose.yml <命令>
```
## 后端结构 ## 后端结构
- `app/main.py`:FastAPI 应用入口,只负责注册路由和全局异常处理。 - `app/main.py`:FastAPI 应用入口,只负责注册路由和全局异常处理。
- `app/routers/`:按业务入口拆分 API 和页面路由,包括登录页面、课程记录、学生档案、管理后台、课程小结推送和健康检查。 - `app/routers/`:按业务入口拆分 API 和页面路由,包括登录页面、课程记录、课时账户、管理后台、课程小结推送和健康检查。
- `app/config.py`:路径、环境变量、Cookie 名称和进程内写锁。 - `app/config.py`:路径、环境变量、Cookie 名称和进程内写锁。
- `app/auth.py`:网页登录、Basic Auth、管理后台和课程小结推送鉴权。 - `app/auth.py`:网页登录、Basic Auth、管理后台和课程小结推送鉴权。
- `app/schemas.py`:请求体 Pydantic 模型。 - `app/schemas.py`:请求体 Pydantic 模型。
@@ -67,32 +53,27 @@ http://121.199.172.246:18080/
XSK_PYTHON_IMAGE='python:3.12-slim' XSK_PYTHON_IMAGE='python:3.12-slim'
``` ```
## SQLite 迁移 ## 手动同步数据
首次迁移先 dry-run 迁移完成后不要再用本机 `classnotes.txt``学生课时账户.md` 覆盖 VPS。下面命令只保留给迁移前或灾难恢复时使用,日常新增课程小结应走 `POST /api/ingest/course-summaries`
```bash ```bash
cd /root/新时空教务管理系统 XSK_USE_SSHPASS=1 \
make migrate-sqlite-dry-run XSK_SSH_PASSWORD='填写SSH密码' \
python3 scripts/sync_to_vps.py --once --use-sshpass
``` ```
确认严格校验通过后执行正式迁移 同步文件
```bash - `/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户/classnotes.txt`
make migrate-sqlite - `/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户/学生课时账户.md`
```
正式迁移会生成 远端数据目录
```text ```text
/root/新时空教务管理系统/data/xsk_education.db /root/新时空教务管理系统/data/
/root/新时空教务管理系统/data/sqlite_migration_report_<时间>.json
/root/新时空教务管理系统/archives/text-source-before-sqlite-<时间>.tar.gz
/root/新时空教务管理系统/archives/text-source-before-sqlite-<时间>.tar.gz.sha256
``` ```
学生表包含 `primary_entry_year` 字段,用于维护“小学一年级入学年份”。排课系统只读该字段并按课程日期动态推算年级。管理后台的学生档案编辑页可以维护该字段;旧 6/7 列 `学生课时账户.md` 仅作为一次性迁移输入兼容。
## 课程小结推送 ## 课程小结推送
VPS 接收接口: VPS 接收接口:
@@ -119,19 +100,31 @@ python3 /Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录
## 历史小结导入 ## 历史小结导入
把本机历史课程小结目录同步或上传到 VPS 后,可在容器内执行一次性导入。历史导入直接写入 SQLite 课程小结表;历史 `classnotes缺失.txt` 默认转为审核任务,不自动扣课时。 把本机历史课程小结目录同步或上传到 VPS 后,可在容器内执行一次性导入。历史导入只重建小结库和状态;历史 `classnotes缺失.txt` 默认转为审核任务,不自动扣课时。
```bash ```bash
cd /root/新时空教务管理系统/app cd /root/新时空教务管理系统/app
docker compose exec xsk-education-management python scripts/import_course_summaries.py \ docker compose exec xsk-education-management python scripts/import_course_summaries.py \
--source /data/import/课程小结采集 \ --source /data/import/课程小结采集 \
--db-path /data/xsk_education.db \ --target /data/course_summaries \
--state /data/course_summary_state.json \
--tasks /data/admin_tasks.json \
--operation-logs /data/operation_logs.jsonl \
--missing-table /data/import/课程小结采集/classnotes缺失.txt --missing-table /data/import/课程小结采集/classnotes缺失.txt
``` ```
## 数据备份 ## 数据备份
通过登记 API、课程小结自动入账或审核批准修改正式课时数据时,服务会以 SQLite 事务写入 `/data/xsk_education.db`。旧纯文本事实源已封存为归档包,不再作为运行时缓存参与读写。 通过登记 API、课程小结自动入账或审核批准修改正式课时数据时,服务会在写入前自动备份本次会改动的业务文件。正式数据和辅助状态位于:
```text
/root/新时空教务管理系统/data/classnotes.txt
/root/新时空教务管理系统/data/学生课时账户.md
/root/新时空教务管理系统/data/教师档案.md
/root/新时空教务管理系统/data/course_summaries/
/root/新时空教务管理系统/data/course_summary_state.json
/root/新时空教务管理系统/data/operation_logs.jsonl
```
备份目录位于: 备份目录位于:
@@ -139,7 +132,7 @@ docker compose exec xsk-education-management python scripts/import_course_summar
/root/新时空教务管理系统/data/backups/ /root/新时空教务管理系统/data/backups/
``` ```
每次写业务数据前生成一个 SQLite 快照备份目录,目录内包含变更前的数据库快照`metadata.json`。系统自动保留最近 50 次备份,超过后删除最旧备份。 每次登记生成一个事务备份目录,目录内包含变更前的业务文件副本`metadata.json`。系统自动保留最近 50 次备份,超过后删除最旧备份。
查看备份: 查看备份:
@@ -147,15 +140,44 @@ docker compose exec xsk-education-management python scripts/import_course_summar
ls -lt /root/新时空教务管理系统/data/backups/ ls -lt /root/新时空教务管理系统/data/backups/
``` ```
恢复某次备份优先使用后台“操作记录”里的撤回按钮。手工恢复时先停止服务,再把对应备份目录里的 `xsk_education.db` 作为数据库恢复源,最后重启服务: 恢复某次备份时,先停止服务,再把对应备份目录里的文件复制回数据目录,最后重启服务:
```bash ```bash
cd /root/新时空教务管理系统/app cd /root/新时空教务管理系统/app
docker compose stop docker compose stop
cp /root/新时空教务管理系统/data/backups/<备份目录>/xsk_education.db /root/新时空教务管理系统/data/xsk_education.db 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
docker compose up -d 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`,然后重启: 登录 VPS 后修改 `/root/新时空教务管理系统/app/.env` 中的 `BASIC_AUTH_PASSWORD`,然后重启:
@@ -171,13 +193,12 @@ docker compose up -d
- `GET /api/health`:数据状态。 - `GET /api/health`:数据状态。
- `GET /api/records?q=王鑫鹏5月数学课`:自然语言查询上课记录。 - `GET /api/records?q=王鑫鹏5月数学课`:自然语言查询上课记录。
- `GET /api/students`学生档案列表。 - `GET /api/accounts`课时账户列表。
- `GET /api/students?q=王鑫鹏`:按学生姓名或学生ID筛选学生档案 - `GET /api/accounts?q=王鑫鹏`:按学生筛选账户
- `GET /api/students?status=欠费`:按档案状态筛选。 - `GET /api/accounts?status=欠费`:按账户状态筛选。
- `GET /api/students/王鑫鹏`:单个学生档案 - `GET /api/accounts/王鑫鹏`:单个学生账户
- `POST /api/admin/students`:管理后台新增学生档案 - `POST /api/admin/accounts`:管理后台新增课时账户
- `PUT /api/admin/students/{student_id}`:管理后台修改学生档案 - `PUT /api/admin/accounts/{student_id}`:管理后台修改课时账户
- 学生档案的剩余课时由系统按缴费记录和上课记录自动计算,管理接口不会接受人工修改余额。
- `GET /api/admin/teachers`:管理后台读取老师档案。 - `GET /api/admin/teachers`:管理后台读取老师档案。
- `POST /api/admin/teachers`:管理后台新增老师档案。 - `POST /api/admin/teachers`:管理后台新增老师档案。
- `PUT /api/admin/teachers/{teacher_id}`:管理后台修改老师档案。 - `PUT /api/admin/teachers/{teacher_id}`:管理后台修改老师档案。
-12
View File
@@ -1,12 +0,0 @@
APP_PORT=18080
COMPOSE_PROJECT_NAME=xsk-education-management
TZ=Asia/Shanghai
PYTHON_IMAGE=python:3.12-slim
BASIC_AUTH_USERNAME=wolfydw
BASIC_AUTH_PASSWORD=change-me
ADMIN_AUTH_PASSWORD=change-me
INGEST_AUTH_TOKEN=change-this-ingest-token
SQLITE_DB_PATH=/data/xsk_education.db
LEGACY_TEXT_ROOT=/data
-14
View File
@@ -1,14 +0,0 @@
.env
.env.*
!.env.example
*.log
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
data/
backups/
.DS_Store
-51
View File
@@ -1,51 +0,0 @@
SHELL := /bin/bash
APP_PORT ?= 18080
DATA_FILES := $(wildcard ../data/xsk_education.db) $(wildcard ../data/sqlite_migration_report_*.json) $(wildcard ../archives/text-source-before-sqlite-*.tar.gz.sha256)
.PHONY: check smoke data-hash migrate-sqlite-dry-run migrate-sqlite build up ps health deploy logs install-gitea-backup
check:
node --check app/static/app.js
node --check app/static/admin.js
python3 -m compileall -q app
smoke:
node scripts/smoke_test.js
python3 scripts/db_smoke_test.py
data-hash:
sha256sum $(DATA_FILES)
migrate-sqlite-dry-run:
python3 scripts/migrate_text_to_sqlite.py --data-root ../data --db-path ../data/xsk_education.db --archives-root ../archives --dry-run
migrate-sqlite:
python3 scripts/migrate_text_to_sqlite.py --data-root ../data --db-path ../data/xsk_education.db --archives-root ../archives
build:
docker compose build
up:
docker compose up -d --remove-orphans
ps:
docker compose ps
health:
set -a; . ./.env; curl --fail --silent --show-error --retry 10 --retry-delay 1 --retry-connrefused --retry-all-errors -u "records:$${BASIC_AUTH_PASSWORD}" "http://127.0.0.1:$${APP_PORT:-$(APP_PORT)}/api/health"; echo
deploy: check smoke
$(MAKE) --no-print-directory data-hash
$(MAKE) --no-print-directory build
$(MAKE) --no-print-directory up
sleep 2
$(MAKE) --no-print-directory health
$(MAKE) --no-print-directory data-hash
$(MAKE) --no-print-directory ps
logs:
docker compose logs --tail=120 xsk-education-management
install-gitea-backup:
python3 scripts/install_gitea_backup_hook.py
+15 -21
View File
@@ -6,11 +6,9 @@ from pathlib import Path
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from pydantic import ValidationError from pydantic import ValidationError
from .data import Account, Payment, Teacher from .config import ACCOUNTS_PATH, CLASSNOTES_PATH, TEACHERS_PATH
from .repository import load_records as load_records_from_db from .data import Account, Payment, Teacher, read_accounts, read_classnotes, read_teachers
from .repository import load_student_profiles as load_student_profiles_from_db from .schemas import AccountPayload, RegisterLinesPayload, TeacherPayload
from .repository import load_teachers as load_teachers_from_db
from .schemas import RegisterLinesPayload, StudentProfilePayload, TeacherPayload
async def read_register_payload(request: Request) -> RegisterLinesPayload: async def read_register_payload(request: Request) -> RegisterLinesPayload:
@@ -40,24 +38,21 @@ async def read_register_payload(request: Request) -> RegisterLinesPayload:
def load_records(): def load_records():
try: if not CLASSNOTES_PATH.exists():
return load_records_from_db() raise HTTPException(status_code=503, detail=f"课程记录文件不存在: {CLASSNOTES_PATH}")
except ValueError as exc: return read_classnotes(CLASSNOTES_PATH)
raise HTTPException(status_code=503, detail=str(exc)) from exc
def load_student_profiles(): def load_accounts():
try: if not ACCOUNTS_PATH.exists():
return load_student_profiles_from_db() raise HTTPException(status_code=503, detail=f"课时账户文件不存在: {ACCOUNTS_PATH}")
except ValueError as exc: return read_accounts(ACCOUNTS_PATH)
raise HTTPException(status_code=503, detail=str(exc)) from exc
def load_teachers(): def load_teachers():
try: if not TEACHERS_PATH.exists():
return load_teachers_from_db() return []
except ValueError as exc: return read_teachers(TEACHERS_PATH)
raise HTTPException(status_code=503, detail=str(exc)) from exc
def file_meta(path: Path) -> dict: def file_meta(path: Path) -> dict:
@@ -72,14 +67,13 @@ def file_meta(path: Path) -> dict:
} }
def payload_to_student_profile(payload: StudentProfilePayload, student_id: str | None = None) -> Account: def payload_to_account(payload: AccountPayload, student_id: str | None = None) -> Account:
return Account( return Account(
student_id=student_id if student_id is not None else payload.student_id, student_id=student_id if student_id is not None else payload.student_id,
student=payload.student, student=payload.student,
payments=[Payment(date=item.date, hours=item.hours) for item in payload.payments], payments=[Payment(date=item.date, hours=item.hours) for item in payload.payments],
remaining=0, remaining=payload.remaining,
account_status=payload.account_status, account_status=payload.account_status,
primary_entry_year=payload.primary_entry_year,
note=payload.note, note=payload.note,
) )
-711
View File
@@ -1,711 +0,0 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
import re
import secrets
from typing import Any
from .data import (
SUBJECTS,
canonical_teacher_name,
class_record_to_line,
course_summary_to_class_record_line,
extract_course_summary_from_text,
normalize_course_summary,
normalize_lines,
parse_class_record_line,
parse_payment_line,
)
from .repository import list_student_names
REGISTER_TYPES = {"class_record", "payment", "course_summary"}
FIELD_LABELS = {
"class_record": {
"student": "学生",
"date": "日期",
"time": "时间",
"teacher": "老师",
"subject": "科目",
"duration": "时长",
},
"payment": {
"student": "学生",
"date": "缴费日期",
"hours": "课时数",
},
"course_summary": {
"student": "学生",
"date": "日期",
"time": "时间",
"teacher": "老师",
"subject": "科目",
"body": "小结正文",
},
}
REQUIRED_FIELDS = {
"class_record": ["student", "date", "time", "teacher", "subject"],
"payment": ["student", "date", "hours"],
"course_summary": ["student", "date", "time", "teacher", "subject", "body"],
}
DATE_RE = re.compile(r"(?P<y>\d{4})[./年-]\s*(?P<m>\d{1,2})[./月-]\s*(?P<d>\d{1,2})")
MONTH_DAY_RE = re.compile(r"(?<!\d)(?P<m>\d{1,2})\s*[月./-]\s*(?P<d>\d{1,2})\s*(?:日|号)?")
TIME_RANGE_FLEX_RE = re.compile(
r"(?P<sh>\d{1,2})\s*(?:[::点.])\s*(?P<sm>\d{1,2})?\s*"
r"(?:-||—||~|至|到)\s*"
r"(?P<eh>\d{1,2})\s*(?:[::点.])\s*(?P<em>\d{1,2})?"
)
TIME_RANGE_HOUR_RE = re.compile(
r"(?<![\d./-])(?P<sh>\d{1,2})\s*(?:-||—||~|至|到)\s*(?P<eh>\d{1,2})(?![\d./-])"
)
TIME_RANGE_COMPACT_RE = re.compile(
r"(?<!\d)(?P<start>\d{3,4})\s*(?:-||—||~|至|到)\s*(?P<end>\d{3,4})(?!\d)"
)
TEACHER_RE = re.compile(r"(?P<teacher>[\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)")
HOURS_RE = re.compile(r"(?P<hours>\d+(?:\.\d+)?)\s*(?:课时|小时)")
MAX_SESSION_AGE_SECONDS = 30 * 60
MAX_SESSIONS = 100
WAITING_PLACEHOLDER = "[待补充]"
_SESSIONS: dict[str, dict[str, Any]] = {}
def _now() -> datetime:
return datetime.now()
def _cleanup_sessions() -> None:
cutoff = _now() - timedelta(seconds=MAX_SESSION_AGE_SECONDS)
stale = [
session_id
for session_id, session in _SESSIONS.items()
if session.get("updated_at", _now()) < cutoff
]
for session_id in stale:
_SESSIONS.pop(session_id, None)
if len(_SESSIONS) <= MAX_SESSIONS:
return
ordered = sorted(_SESSIONS.items(), key=lambda item: item[1].get("updated_at", _now()))
for session_id, _session in ordered[: len(_SESSIONS) - MAX_SESSIONS]:
_SESSIONS.pop(session_id, None)
def _session_for(conversation_id: str | None, register_type: str, text: str) -> tuple[str, dict[str, Any]]:
_cleanup_sessions()
if conversation_id and conversation_id in _SESSIONS:
session = _SESSIONS[conversation_id]
else:
conversation_id = secrets.token_urlsafe(16)
session = {"type": register_type, "text": text, "answers": {}, "created_at": _now()}
_SESSIONS[conversation_id] = session
session["type"] = register_type
if text:
session["text"] = text
session["updated_at"] = _now()
return conversation_id, session
def normalize_register_type(value: str) -> str:
text = str(value or "").strip()
aliases = {
"class": "class_record",
"class_records": "class_record",
"class-records": "class_record",
"上课记录": "class_record",
"payments": "payment",
"缴费": "payment",
"course-summaries": "course_summary",
"course_summaries": "course_summary",
"课程小结": "course_summary",
}
register_type = aliases.get(text, text)
if register_type not in REGISTER_TYPES:
raise ValueError("不支持的登记类型")
return register_type
def collect_input_lines(
text: str | None = None,
lines: list[str] | None = None,
register_type: str = "",
) -> list[str]:
if lines is not None:
return normalize_lines(lines=lines)
raw = str(text or "")
if register_type in {"class_record", "payment"}:
return normalize_lines(lines=raw.splitlines())
if "\n\n" in raw:
chunks = [item.strip() for item in re.split(r"\n\s*\n", raw) if item.strip()]
if chunks:
return chunks
return normalize_lines(line=raw)
def _field_labels(register_type: str) -> dict[str, str]:
return FIELD_LABELS.get(register_type, {})
def _questions_for_missing(register_type: str, missing_fields: list[str]) -> list[str]:
labels = _field_labels(register_type)
return [f"请补充{labels.get(field, field)}" for field in missing_fields]
def _known_students() -> list[str]:
try:
return list_student_names()
except Exception:
return []
def _normalize_date(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
for match in DATE_RE.finditer(text):
year = int(match.group("y"))
month = int(match.group("m"))
day = int(match.group("d"))
try:
datetime(year, month, day)
except ValueError:
continue
return f"{year:04d}-{month:02d}-{day:02d}"
for match in MONTH_DAY_RE.finditer(text):
year = date.today().year
month = int(match.group("m"))
day = int(match.group("d"))
try:
datetime(year, month, day)
except ValueError:
continue
return f"{year:04d}-{month:02d}-{day:02d}"
return ""
def _record_date(value: str) -> str:
return value.replace("-", ".")
def _weekday(value: str) -> str:
weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
return weekdays[datetime.strptime(value, "%Y-%m-%d").date().weekday()]
def _normalize_time_range(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
standard = re.search(r"\d{1,2}:\d{2}\s*(?:-||—||~|至|到)\s*\d{1,2}:\d{2}", text)
if standard:
raw = re.sub(r"\s*(?:|—||~|至|到)\s*", "-", standard.group(0))
raw = re.sub(r"\s*-\s*", "-", raw)
start, end = raw.split("-", 1)
sh, sm = [int(part) for part in start.split(":", 1)]
eh, em = [int(part) for part in end.split(":", 1)]
else:
match = TIME_RANGE_FLEX_RE.search(text)
if match:
sh = int(match.group("sh"))
sm = int(match.group("sm") or 0)
eh = int(match.group("eh"))
em = int(match.group("em") or 0)
else:
compact = TIME_RANGE_COMPACT_RE.search(text)
if compact:
start = compact.group("start").zfill(4)
end = compact.group("end").zfill(4)
sh = int(start[:-2])
sm = int(start[-2:])
eh = int(end[:-2])
em = int(end[-2:])
else:
hour_only = TIME_RANGE_HOUR_RE.search(text)
if not hour_only:
return ""
sh = int(hour_only.group("sh"))
sm = 0
eh = int(hour_only.group("eh"))
em = 0
if sh > 23 or eh > 23 or sm > 59 or em > 59:
return ""
if eh * 60 + em <= sh * 60 + sm:
return ""
return f"{sh:02d}:{sm:02d}-{eh:02d}:{em:02d}"
def _duration_from_time_range(time_range: str) -> str:
start, end = time_range.split("-", 1)
sh, sm = [int(part) for part in start.split(":", 1)]
eh, em = [int(part) for part in end.split(":", 1)]
minutes = eh * 60 + em - sh * 60 - sm
return f"{minutes // 60}小时{minutes % 60}"
def _duration_from_text(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
if re.fullmatch(r"\d+小时\d+分", text):
return text
match = re.search(r"(?P<h>\d+)\s*小时\s*(?P<m>\d+)?\s*分?", text)
if match:
return f"{int(match.group('h'))}小时{int(match.group('m') or 0)}"
match = re.search(r"(?P<hours>\d+(?:\.\d+)?)\s*(?:课时|小时)", text)
if match:
total = int(round(float(match.group("hours")) * 60))
return f"{total // 60}小时{total % 60}"
return ""
def _extract_student(text: str) -> str:
match = re.search(r"(?:学生|学员)[:\s]*(?P<student>[\u4e00-\u9fa5A-Za-z0-9]{2,8})", text)
if match:
return match.group("student")
for student in _known_students():
if student and student in text:
return student
match = re.search(r"^\s*(?P<student>[\u4e00-\u9fa5]{2,4})(?=\s|[,,。;;:]|\d)", text)
return match.group("student") if match else ""
def _extract_class_record_student(text: str, teacher: str, subject: str) -> str:
student = _extract_student(text)
if student:
return student
remainder = text
for pattern in (DATE_RE, MONTH_DAY_RE, TIME_RANGE_FLEX_RE, TIME_RANGE_COMPACT_RE, TIME_RANGE_HOUR_RE):
remainder = pattern.sub(" ", remainder)
remainder = re.sub(r"\d{1,2}:\d{2}\s*(?:-||—||~|至|到)\s*\d{1,2}:\d{2}", " ", remainder)
remainder = re.sub(r"星期[一二三四五六日]", " ", remainder)
remainder = re.sub(r"\d+(?:\.\d+)?\s*(?:课时|小时)", " ", remainder)
remainder = re.sub(r"\d+\s*小时\s*\d*\s*分?", " ", remainder)
if teacher:
remainder = re.sub(rf"{re.escape(teacher)}(?:老师|教师)?", " ", remainder)
if subject:
remainder = remainder.replace(subject, " ")
remainder = re.sub(r"(?:学生|学员|老师|教师|科目|课程|时间|日期|上课)", " ", remainder)
for candidate in re.findall(r"[\u4e00-\u9fa5]{2,4}", remainder):
if candidate not in SUBJECTS and candidate not in {"老师", "教师", "学生", "学员"}:
return candidate
return ""
def _extract_teacher(text: str) -> str:
match = TEACHER_RE.search(text)
return canonical_teacher_name(match.group("teacher")) if match else ""
def _normalize_teacher_hint(value: object) -> str:
text = canonical_teacher_name(str(value or "").strip())
return re.sub(r"^以下都是", "", text).strip()
def _extract_subject(text: str) -> str:
for subject in SUBJECTS:
if subject in text:
return subject
match = re.search(r"(?:科目|课程)[:\s]*(?P<subject>[\u4e00-\u9fa5A-Za-z0-9]{1,12})", text)
return match.group("subject") if match else ""
def _merge_answers(fields: dict[str, str], answers: dict[str, str]) -> dict[str, str]:
merged = dict(fields)
for key, value in answers.items():
text = str(value or "").strip()
if not text:
continue
if key == "followup" or key.startswith("items."):
continue
merged[key] = text
return merged
def _answers_for_item(answers: dict[str, str], index: int, *, include_legacy: bool = False) -> dict[str, str]:
prefix = f"items.{index}."
item_answers = {
key.removeprefix(prefix): value
for key, value in answers.items()
if key.startswith(prefix)
}
if include_legacy:
for key, value in answers.items():
if key == "followup" or key.startswith("items."):
continue
item_answers.setdefault(key, value)
return item_answers
def _field_is_missing(register_type: str, field: str, fields: dict[str, str]) -> bool:
value = str(fields.get(field) or "").strip()
if not value:
return True
if field == "date":
return not _normalize_date(value)
if register_type == "class_record" and field == "time":
return not _normalize_time_range(value)
if register_type == "payment" and field == "hours":
hours = re.sub(r"\s*(?:课时|小时)$", "", value)
try:
return float(hours) <= 0
except ValueError:
return True
return False
def _missing_fields(register_type: str, fields: dict[str, str]) -> list[str]:
return [field for field in REQUIRED_FIELDS[register_type] if _field_is_missing(register_type, field, fields)]
def _needs_info_response(
register_type: str,
fields: dict[str, str],
missing_fields: list[str],
*,
summary: str = "",
timed_out: bool = False,
error: str = "",
draft_line: str = "",
draft_items: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
return {
"status": "needs_info",
"standard_lines": [],
"questions": _questions_for_missing(register_type, missing_fields) or [_question_for_error(register_type, error or "信息不完整")],
"summary": summary or "请补齐缺失字段后再次生成预览",
"ai_used": False,
"fields": fields,
"missing_fields": missing_fields,
"field_labels": _field_labels(register_type),
"timed_out": timed_out,
"recognition_source": "partial_local",
"error": error,
"draft_line": draft_line,
"draft_items": draft_items or [],
}
def _standard_from_fields(register_type: str, fields: dict[str, str]) -> str:
if register_type == "class_record":
date_value = _normalize_date(fields.get("date"))
time_range = _normalize_time_range(fields.get("time"))
if not date_value or not time_range:
raise ValueError("上课记录日期或时间缺失")
duration = _duration_from_text(fields.get("duration")) or _duration_from_time_range(time_range)
return (
f"{_record_date(date_value)}-{_weekday(date_value)}-{time_range}-"
f"{fields['student']}-{duration}-{fields['teacher']}-{fields['subject']}"
)
if register_type == "payment":
date_value = _normalize_date(fields.get("date"))
if not date_value:
raise ValueError("缴费日期缺失")
hours = str(fields.get("hours") or "").strip()
hours = re.sub(r"\s*(?:课时|小时)$", "", hours)
return f"{fields['student']}-{date_value}:{hours}"
date_value = _normalize_date(fields.get("date"))
time_range = _normalize_time_range(fields.get("time"))
return "\n".join(
[
f"学生:{fields['student']}",
f"日期:{date_value}",
f"时间:{time_range}",
f"老师:{fields['teacher']}",
f"科目:{fields['subject']}",
"小结:",
str(fields.get("body") or "").strip(),
]
)
def _draft_from_fields(register_type: str, fields: dict[str, str]) -> str:
if register_type == "class_record":
date_iso = _normalize_date(fields.get("date"))
time_range = _normalize_time_range(fields.get("time"))
student = fields.get("student") or WAITING_PLACEHOLDER
date_value = _record_date(date_iso) if date_iso else WAITING_PLACEHOLDER
weekday = _weekday(date_iso) if date_iso else WAITING_PLACEHOLDER
teacher = _normalize_teacher_hint(fields.get("teacher")) or WAITING_PLACEHOLDER
duration = _duration_from_text(fields.get("duration")) or (
_duration_from_time_range(time_range) if time_range else WAITING_PLACEHOLDER
)
subject = fields.get("subject") or WAITING_PLACEHOLDER
return f"{date_value}-{weekday}-{time_range or WAITING_PLACEHOLDER}-{student}-{duration}-{teacher}-{subject}"
if register_type == "payment":
student = fields.get("student") or WAITING_PLACEHOLDER
date_value = _normalize_date(fields.get("date")) or WAITING_PLACEHOLDER
hours = str(fields.get("hours") or "").strip() or WAITING_PLACEHOLDER
return f"{student}-{date_value}:{hours}"
student = fields.get("student") or "【学生】"
date_value = _normalize_date(fields.get("date")) or "【日期】"
time_range = _normalize_time_range(fields.get("time")) or "【时间】"
teacher = _normalize_teacher_hint(fields.get("teacher")) or "【老师】"
subject = fields.get("subject") or "【科目】"
body = str(fields.get("body") or "").strip() or "【小结正文】"
return "\n".join(
[
f"学生:{student}",
f"日期:{date_value}",
f"时间:{time_range}",
f"老师:{teacher}",
f"科目:{subject}",
"小结:",
body,
]
)
def _question_for_error(register_type: str, message: str) -> str:
if register_type == "class_record":
return f"请按标准格式补齐或修改上课记录:{message}"
if register_type == "payment":
return f"请按“学生-YYYY-MM-DD:课时”补齐或修改缴费记录:{message}"
return f"请补齐或修改课程小结信息:{message}"
def _extract_class_record_fields(text: str) -> dict[str, str]:
teacher = _extract_teacher(text)
subject = _extract_subject(text)
return {
"student": _extract_class_record_student(text, teacher, subject),
"date": _normalize_date(text),
"time": _normalize_time_range(text),
"teacher": teacher,
"subject": subject,
"duration": _duration_from_text(text),
}
def _extract_payment_fields(text: str) -> dict[str, str]:
hours_match = HOURS_RE.search(text)
return {
"student": _extract_student(text),
"date": _normalize_date(text),
"hours": hours_match.group("hours") if hours_match else "",
}
def _extract_course_summary_fields(text: str) -> dict[str, str]:
try:
raw = extract_course_summary_from_text(text, known_students=_known_students())
except ValueError:
raw = {}
body = str(raw.get("body") or "").strip()
if body == text.strip() and any(label in text for label in ("小结", "正文", "内容")):
body = re.split(r"(?:小结|正文|内容)[:]?", text, maxsplit=1)[-1].strip()
return {
"student": str(raw.get("student") or _extract_student(text)).strip(),
"date": _normalize_date(raw.get("date_iso") or text),
"time": _normalize_time_range(raw.get("time_range") or text),
"teacher": _normalize_teacher_hint(raw.get("teacher") or _extract_teacher(text)),
"subject": str(raw.get("subject") or _extract_subject(text)).strip(),
"body": body or text.strip(),
}
def extract_local_fields(register_type: str, lines: list[str]) -> dict[str, str]:
text = "\n".join(lines).strip()
if register_type == "class_record":
return _extract_class_record_fields(text)
if register_type == "payment":
return _extract_payment_fields(text)
return _extract_course_summary_fields(text)
def extract_line_fields(register_type: str, line: str) -> dict[str, str]:
if register_type == "class_record":
return _extract_class_record_fields(line)
if register_type == "payment":
return _extract_payment_fields(line)
return _extract_course_summary_fields(line)
def multi_line_fields_preview(register_type: str, lines: list[str], answers: dict[str, str]) -> dict[str, Any]:
draft_items: list[dict[str, Any]] = []
standard_lines: list[str] = []
all_ready = True
questions: list[str] = []
union_missing: list[str] = []
for index, line in enumerate(lines):
fields = extract_line_fields(register_type, line)
fields = _merge_answers(fields, _answers_for_item(answers, index, include_legacy=len(lines) == 1))
missing = _missing_fields(register_type, fields)
error = ""
standard_line = ""
if missing:
all_ready = False
else:
try:
standard_line = validate_standard_lines(register_type, [_standard_from_fields(register_type, fields)])[0]
standard_lines.append(standard_line)
except (ValueError, KeyError) as exc:
all_ready = False
error = str(exc)
for field in missing:
if field not in union_missing:
union_missing.append(field)
labels = _field_labels(register_type)
questions.extend(f"{index + 1} 条请补充{labels.get(field, field)}" for field in missing)
if error and not missing:
questions.append(f"{index + 1}{_question_for_error(register_type, error)}")
draft_items.append(
{
"index": index,
"source_line": line,
"fields": fields,
"missing_fields": missing,
"questions": _questions_for_missing(register_type, missing),
"draft_line": standard_line or _draft_from_fields(register_type, fields),
"error": error,
}
)
if all_ready:
return {
"status": "ready",
"standard_lines": standard_lines,
"questions": [],
"summary": "已通过本地规则生成预览",
"ai_used": False,
"fields": {},
"missing_fields": [],
"field_labels": _field_labels(register_type),
"timed_out": False,
"recognition_source": "local",
"draft_items": [],
}
return _needs_info_response(
register_type,
draft_items[0]["fields"] if draft_items else {},
union_missing,
summary="请逐条补齐缺失字段后再次生成预览",
error="",
draft_line="\n".join(str(item.get("draft_line") or "") for item in draft_items),
draft_items=draft_items,
)
def fields_preview(register_type: str, fields: dict[str, str]) -> dict[str, Any] | None:
missing = _missing_fields(register_type, fields)
if missing:
return _needs_info_response(register_type, fields, missing, draft_line=_draft_from_fields(register_type, fields))
try:
standard_lines = validate_standard_lines(register_type, [_standard_from_fields(register_type, fields)])
except (ValueError, KeyError) as exc:
return _needs_info_response(
register_type,
fields,
[],
summary="脚本无法生成可校验预览,请修改字段后再试",
error=str(exc),
draft_line=_draft_from_fields(register_type, fields),
)
return {
"status": "ready",
"standard_lines": standard_lines,
"questions": [],
"summary": "已通过本地规则生成预览",
"ai_used": False,
"fields": fields,
"missing_fields": [],
"field_labels": _field_labels(register_type),
"timed_out": False,
"recognition_source": "local",
}
def standard_lines_preview(register_type: str, standard_lines: list[str], summary: str, *, ai_used: bool, source: str) -> dict[str, Any]:
return {
"status": "ready",
"standard_lines": standard_lines,
"questions": [],
"summary": summary,
"ai_used": ai_used,
"fields": {},
"missing_fields": [],
"field_labels": _field_labels(register_type),
"timed_out": False,
"recognition_source": source,
}
def validate_standard_lines(register_type: str, lines: list[str]) -> list[str]:
if not lines:
raise ValueError("标准行不能为空")
standard_lines: list[str] = []
for index, line in enumerate(lines):
text = str(line or "").strip()
if not text:
continue
try:
if register_type == "class_record":
standard_lines.append(class_record_to_line(parse_class_record_line(text)))
elif register_type == "payment":
text = re.sub(r":\s*(\d+(?:\.\d+)?)\s*(?:课时|小时)\s*$", r":\1", text)
student, payment = parse_payment_line(text)
hours = int(payment.hours) if float(payment.hours).is_integer() else payment.hours
standard_lines.append(f"{student}-{payment.date}:{hours}")
else:
raw = extract_course_summary_from_text(text, index)
normalized = normalize_course_summary(raw)
course_summary_to_class_record_line(normalized)
standard_lines.append(text)
except ValueError as exc:
raise ValueError(f"{index + 1} 条校验失败:{exc}") from exc
if not standard_lines:
raise ValueError("标准行不能为空")
return standard_lines
def local_standard_preview(register_type: str, lines: list[str]) -> dict[str, Any] | None:
try:
standard_lines = validate_standard_lines(register_type, lines)
except ValueError:
return None
return standard_lines_preview(register_type, standard_lines, "已按标准格式通过本地校验", ai_used=False, source="local")
def preview_register(
*,
register_type: str,
text: str | None = None,
lines: list[str] | None = None,
answers: dict[str, str] | None = None,
conversation_id: str | None = None,
) -> dict[str, Any]:
normalized_type = normalize_register_type(register_type)
source_lines = collect_input_lines(text=text, lines=lines, register_type=normalized_type)
joined_text = "\n\n".join(source_lines)
conversation_id, session = _session_for(conversation_id, normalized_type, joined_text)
merged_answers = {**dict(session.get("answers") or {}), **(answers or {})}
session["answers"] = merged_answers
local = local_standard_preview(normalized_type, source_lines)
if local is not None:
return {"conversation_id": conversation_id, "type": normalized_type, **local}
if normalized_type in {"class_record", "payment"}:
return {
"conversation_id": conversation_id,
"type": normalized_type,
**multi_line_fields_preview(normalized_type, source_lines, merged_answers),
}
local_fields = _merge_answers(extract_local_fields(normalized_type, source_lines), merged_answers)
local_ready = fields_preview(normalized_type, local_fields)
if local_ready is not None:
return {"conversation_id": conversation_id, "type": normalized_type, **local_ready}
missing = _missing_fields(normalized_type, local_fields)
return {
"conversation_id": conversation_id,
"type": normalized_type,
**_needs_info_response(
normalized_type,
local_fields,
missing,
summary="脚本无法补齐全部字段,请补充信息后再次生成预览",
draft_line=_draft_from_fields(normalized_type, local_fields),
),
}
-4880
View File
File diff suppressed because it is too large Load Diff
-227
View File
@@ -1,227 +0,0 @@
from __future__ import annotations
from contextlib import contextmanager
import sqlite3
from pathlib import Path
from typing import Iterator
from .config import SQLITE_DB_PATH
SCHEMA_VERSION = 3
def connect(db_path: Path | None = None) -> sqlite3.Connection:
path = db_path or SQLITE_DB_PATH
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA busy_timeout = 5000")
conn.execute("PRAGMA journal_mode = WAL")
return conn
@contextmanager
def transaction(db_path: Path | None = None) -> Iterator[sqlite3.Connection]:
conn = connect(db_path)
try:
conn.execute("BEGIN IMMEDIATE")
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def initialize_schema(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS students (
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
);
CREATE TABLE IF NOT EXISTS account_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id TEXT NOT NULL REFERENCES students(student_id) ON DELETE CASCADE,
tx_date TEXT NOT NULL,
hours REAL NOT NULL,
tx_type TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS class_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_key TEXT NOT NULL UNIQUE,
record_date TEXT NOT NULL,
weekday TEXT NOT NULL,
time_range TEXT NOT NULL,
duration_minutes INTEGER NOT NULL,
student_id TEXT REFERENCES students(student_id) ON DELETE SET NULL,
student TEXT NOT NULL,
teacher TEXT NOT NULL,
subject TEXT NOT NULL,
raw_line TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS teachers (
teacher_id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
alias TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS teacher_subjects (
teacher_id TEXT NOT NULL REFERENCES teachers(teacher_id) ON DELETE CASCADE,
subject TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (teacher_id, subject)
);
CREATE TABLE IF NOT EXISTS admin_tasks (
id INTEGER PRIMARY KEY,
task_type TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT '',
student TEXT NOT NULL DEFAULT '',
source_id TEXT NOT NULL DEFAULT '',
payload_json TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS admin_task_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS operation_logs (
log_id TEXT PRIMARY KEY,
created_at TEXT NOT NULL DEFAULT '',
operation TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
student TEXT NOT NULL DEFAULT '',
payload_json TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS course_summaries (
id TEXT PRIMARY KEY,
relative_path TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
raw_body TEXT NOT NULL DEFAULT '',
group_name TEXT NOT NULL DEFAULT '',
student TEXT NOT NULL DEFAULT '',
teacher TEXT NOT NULL DEFAULT '',
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
);
CREATE TABLE IF NOT EXISTS course_summary_seen_keys (
kind TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (kind, value)
);
CREATE TABLE IF NOT EXISTS ingest_batches (
batch_id TEXT PRIMARY KEY,
received_at TEXT NOT NULL DEFAULT '',
payload_json TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS migration_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL
);
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():
return {"exists": False, "path": str(path)}
stat = path.stat()
version = ""
try:
with connect(path) as conn:
initialize_schema(conn)
row = conn.execute("SELECT value FROM metadata WHERE key = 'schema_version'").fetchone()
version = str(row["value"]) if row else ""
except sqlite3.DatabaseError:
version = "unreadable"
return {
"exists": True,
"path": str(path),
"size": stat.st_size,
"mtime": stat.st_mtime,
"schema_version": version,
}
File diff suppressed because it is too large Load Diff
-193
View File
@@ -1,193 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import verify_admin_auth
from ..repository import (
admin_dashboard,
approve_admin_task,
delete_course_summary,
link_existing_course_summary_task,
list_admin_tasks,
list_operation_logs,
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,
update_course_summary_time,
)
router = APIRouter()
@router.get("/api/admin/dashboard")
def admin_dashboard_endpoint(period: str = Query("month"), _user: str = Depends(verify_admin_auth)):
try:
return admin_dashboard(period=period)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/api/admin/tasks")
def admin_tasks(
status_filter: str = Query("", alias="status"),
task_type: str = Query("", alias="type"),
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
_user: str = Depends(verify_admin_auth),
):
return list_admin_tasks(status_filter=status_filter, task_type=task_type, offset=offset, limit=limit)
@router.get("/api/admin/operation-logs")
def admin_operation_logs(
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
operation: str = Query(""),
status_filter: str = Query("", alias="status"),
student: str = Query(""),
_user: str = Depends(verify_admin_auth),
):
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:
result = rollback_operation_log(log_id)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"ok": True, **result}
@router.get("/api/admin/course-summaries")
def admin_course_summaries(
q: str = Query(""),
student: str = Query(""),
teacher: str = Query(""),
subject: str = Query(""),
date_from: str = Query(""),
date_to: str = Query(""),
missing_time: bool = Query(False),
binding_status: str = Query(""),
has_candidate: str = Query(""),
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
_user: str = Depends(verify_admin_auth),
):
try:
return query_course_summaries(
q=q,
student=student,
teacher=teacher,
subject=subject,
date_from=date_from,
date_to=date_to,
missing_time=missing_time,
binding_status=binding_status,
has_candidate=has_candidate,
limit=limit,
offset=offset,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/api/admin/tasks/{task_id}/approve")
def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
try:
result = approve_admin_task(task_id)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"ok": True, **result}
@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)):
try:
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}
@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:
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}
@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:
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}
@router.post("/api/admin/tasks/{task_id}/reject")
def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
try:
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}
@router.post("/api/admin/course-summaries/duplicate-scan")
def admin_scan_duplicate_course_summaries(_user: str = Depends(verify_admin_auth)):
try:
result = scan_duplicate_course_summaries()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@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:
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}
@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:
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}
@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:
result = update_course_summary_identity(
summary_id,
str(payload.get("student") or ""),
str(payload.get("teacher") or ""),
str(payload.get("subject") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.delete("/api/admin/course-summaries/{summary_id}")
def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_admin_auth)):
try:
result = delete_course_summary(summary_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
-27
View File
@@ -1,27 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from ..ai_register import preview_register
from ..auth import verify_admin_auth
from ..schemas import AiRegisterPreviewPayload
router = APIRouter()
@router.post("/api/ai/register/preview")
def ai_register_preview(payload: AiRegisterPreviewPayload, _user: str = Depends(verify_admin_auth)):
try:
return {
"ok": True,
**preview_register(
register_type=payload.type,
text=payload.text,
lines=payload.lines,
answers=payload.answers,
conversation_id=payload.conversation_id,
),
}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
-14
View File
@@ -1,14 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from ..auth import verify_records_auth
from ..repository import health_payload
router = APIRouter()
@router.get("/api/health")
def health(_user: str = Depends(verify_records_auth)):
return health_payload()
-99
View File
@@ -1,99 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from ..auth import verify_ingest_token
from ..ai_register import preview_register
from ..repository import ingest_course_summaries
from ..schemas import CourseSummaryIngestPayload
router = APIRouter()
def _summary_text(raw: dict) -> str:
parts = []
mapping = [
("student", "学生"),
("date_iso", "日期"),
("date", "日期"),
("time_range", "时间"),
("raw_time", "时间"),
("teacher", "老师"),
("subject", "科目"),
]
for key, label in mapping:
value = str(raw.get(key) or "").strip()
if value:
parts.append(f"{label}{value}")
body = str(raw.get("body") or raw.get("content") or "").strip()
if body:
parts.append("小结:")
parts.append(body)
return "\n".join(parts)
def _needs_script_summary(raw: dict) -> bool:
required = ["student", "teacher", "subject"]
if any(not str(raw.get(key) or "").strip() for key in required):
return True
if not str(raw.get("date_iso") or raw.get("date") or raw.get("class_date") or "").strip():
return True
if not str(raw.get("time_range") or raw.get("raw_time") or raw.get("time") or "").strip():
return True
return False
def _standard_text_to_raw(text: str, original: dict) -> dict:
from ..data import extract_course_summary_from_text, normalize_course_summary
raw = extract_course_summary_from_text(text, known_students=[])
normalized = normalize_course_summary({**original, **raw})
return {**original, **normalized}
def _preprocess_summaries_locally(summaries: list[dict]) -> list[dict]:
processed: list[dict] = []
for raw in summaries:
if not _needs_script_summary(raw):
processed.append(raw)
continue
try:
preview = preview_register(register_type="course_summary", text=_summary_text(raw))
except ValueError as exc:
processed.append({**raw, "ai_used": False, "ai_questions": [str(exc)]})
continue
if preview.get("status") == "ready" and preview.get("standard_lines"):
try:
updated = _standard_text_to_raw(str(preview["standard_lines"][0]), raw)
processed.append({
**updated,
"ai_used": False,
"ai_summary": str(preview.get("summary") or ""),
})
except ValueError as exc:
processed.append({**raw, "ai_used": False, "ai_questions": [str(exc)]})
continue
questions = [str(item) for item in preview.get("questions") or [] if str(item)]
processed.append({
**raw,
"ai_used": False,
"ai_summary": str(preview.get("summary") or ""),
"ai_questions": questions or ["脚本未能补齐课程小结信息"],
})
return processed
@router.post("/api/ingest/course-summaries")
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])
result = ingest_course_summaries(
batch_id=payload.batch_id,
window=payload.window,
students=payload.students,
summaries=summaries,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
-54
View File
@@ -1,54 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import verify_records_auth
from ..repository import (
query_public_records,
submit_public_correction_tasks,
submit_public_deletion_tasks,
supplement_course_summary,
)
from ..schemas import CorrectionSubmitPayload, CourseSummarySupplementPayload, DeletionSubmitPayload
router = APIRouter()
@router.get("/api/records")
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),
):
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([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/deletions")
def submit_deletions(payload: DeletionSubmitPayload, _user: str = Depends(verify_records_auth)):
try:
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_endpoint(payload: CourseSummarySupplementPayload, _user: str = Depends(verify_records_auth)):
try:
return supplement_course_summary(payload.record_id, payload.body)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
-128
View File
@@ -1,128 +0,0 @@
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}
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -77,6 +77,13 @@ def is_admin_authenticated(
) or has_valid_basic_auth(credentials, ADMIN_AUTH_PASSWORD) ) 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( def verify_records_auth(
request: Request, request: Request,
credentials: HTTPBasicCredentials | None = Depends(security), credentials: HTTPBasicCredentials | None = Depends(security),
@@ -103,7 +110,7 @@ def verify_admin_auth(
return "admin" return "admin"
def verify_student_profiles_auth( def verify_accounts_auth(
request: Request, request: Request,
credentials: HTTPBasicCredentials | None = Depends(security), credentials: HTTPBasicCredentials | None = Depends(security),
) -> str: ) -> str:
+7 -2
View File
@@ -8,8 +8,13 @@ import threading
APP_DIR = Path(__file__).resolve().parent APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static" STATIC_DIR = APP_DIR / "static"
SQLITE_DB_PATH = Path(os.getenv("SQLITE_DB_PATH", "/data/xsk_education.db")) CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
LEGACY_TEXT_ROOT = Path(os.getenv("LEGACY_TEXT_ROOT", "/data")) 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"))
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "") BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "") ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
+2636
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
services:
xsk-education-management:
build:
context: .
args:
PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.12-slim}
container_name: xsk-education-management
restart: unless-stopped
env_file:
- .env
environment:
TZ: ${TZ:-Asia/Shanghai}
SQLITE_DB_PATH: ${SQLITE_DB_PATH:-/data/xsk_education.db}
LEGACY_TEXT_ROOT: ${LEGACY_TEXT_ROOT:-/data}
INGEST_AUTH_TOKEN: ${INGEST_AUTH_TOKEN:-}
ports:
- "${APP_PORT:-18080}:8000"
volumes:
- ../data:/data
+2 -3
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import date from datetime import date
SUBJECTS = ["语文", "数学", "英语", "物理", "化学", "生物", "历史", "地理", "政治"] SUBJECTS = ["数学", "语文", "英语", "物理", "化学", "生物", "历史", "地理", "政治", "道法"]
SUBJECT_ALIASES = { SUBJECT_ALIASES = {
"": "数学", "": "数学",
"": "语文", "": "语文",
@@ -29,7 +29,7 @@ UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"}
TEACHER_STATUSES = {"在岗", "离职"} TEACHER_STATUSES = {"在岗", "离职"}
UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"} UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"}
HIGH_CONFIDENCE_VALUES = {"high", "", "高置信", "true", "1", "yes"} HIGH_CONFIDENCE_VALUES = {"high", "", "高置信", "true", "1", "yes"}
AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "manual_admin", "关键词"} AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "manual_admin", "大模型高置信识别", "关键词"}
ROLE_WORDS = { ROLE_WORDS = {
"student": ("学生", "学员", "孩子", "同学"), "student": ("学生", "学员", "孩子", "同学"),
"teacher": ("老师", "教师"), "teacher": ("老师", "教师"),
@@ -49,7 +49,6 @@ class Account:
payments: list[Payment] payments: list[Payment]
remaining: float remaining: float
account_status: str account_status: str
primary_entry_year: int | None
note: str note: str
+2 -9
View File
@@ -3,18 +3,12 @@ from __future__ import annotations
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from .repository import initialize_runtime_database from .routers import accounts, admin, health, ingest, pages, records
from .routers import admin, ai_register, health, ingest, pages, records, student_profiles
app = FastAPI(title="新时空教务管理系统", version="1.0.0") app = FastAPI(title="新时空教务管理系统", version="1.0.0")
@app.on_event("startup")
def prepare_sqlite_runtime() -> None:
initialize_runtime_database()
@app.exception_handler(ValueError) @app.exception_handler(ValueError)
async def value_error_handler(_request: Request, exc: ValueError): async def value_error_handler(_request: Request, exc: ValueError):
return JSONResponse(status_code=500, content={"detail": str(exc)}) return JSONResponse(status_code=500, content={"detail": str(exc)})
@@ -23,7 +17,6 @@ async def value_error_handler(_request: Request, exc: ValueError):
app.include_router(pages.router) app.include_router(pages.router)
app.include_router(health.router) app.include_router(health.router)
app.include_router(records.router) app.include_router(records.router)
app.include_router(student_profiles.router) app.include_router(accounts.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(ai_register.router)
app.include_router(ingest.router) app.include_router(ingest.router)
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
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,
OPERATION_LOGS_PATH,
TEACHERS_PATH,
write_lock,
)
from ..data import (
ACCOUNT_STATUSES,
TEACHER_STATUSES,
DuplicateRecordError,
account_summary,
account_to_dict,
create_account,
create_teacher,
filter_accounts,
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()
@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,
)
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)
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()
return {
"ok": True,
"accounts": file_meta(ACCOUNTS_PATH),
"teachers": file_meta(TEACHERS_PATH),
"accounts_count": len(accounts),
"teachers_count": len(teachers),
"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))
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),
)
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))
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))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
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,
OPERATION_LOGS_PATH,
write_lock,
)
from ..data import (
append_operation_log,
approve_admin_task,
delete_course_summary,
list_admin_tasks,
list_operation_logs,
query_course_summaries,
reject_admin_task,
update_course_summary_time,
)
router = APIRouter()
@router.get("/api/admin/tasks")
def admin_tasks(
status_filter: str = Query("", alias="status"),
task_type: str = Query("", alias="type"),
_user: str = Depends(verify_admin_auth),
):
try:
return list_admin_tasks(ADMIN_TASKS_PATH, status_filter=status_filter, task_type=task_type)
except ValueError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.get("/api/admin/operation-logs")
def admin_operation_logs(
limit: int = Query(100, ge=1, le=500),
operation: str = Query(""),
status_filter: str = Query("", alias="status"),
student: str = Query(""),
_user: str = Depends(verify_admin_auth),
):
return list_operation_logs(
OPERATION_LOGS_PATH,
limit=limit,
operation=operation,
status_filter=status_filter,
student=student,
)
@router.get("/api/admin/course-summaries")
def admin_course_summaries(
q: str = Query(""),
student: str = Query(""),
teacher: str = Query(""),
subject: str = Query(""),
date_from: str = Query(""),
date_to: str = Query(""),
missing_time: bool = Query(False),
limit: int = Query(200, ge=1, le=1000),
_user: str = Depends(verify_admin_auth),
):
try:
return query_course_summaries(
COURSE_SUMMARIES_ROOT,
q=q,
student=student,
teacher=teacher,
subject=subject,
date_from=date_from,
date_to=date_to,
missing_time=missing_time,
limit=limit,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@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, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id)
task = result.get("task", {})
append_operation_log(
OPERATION_LOGS_PATH,
"admin_task_approve",
"approved",
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 ""),
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"ok": True, **result}
@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,
"admin_task_reject",
"rejected",
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 ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "task": task}
@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,
"admin_course_summary_update_time",
"updated",
summary_id=summary_id,
time_range=str(payload.get("time_range") 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.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,
"admin_course_summary_delete",
"deleted",
summary_id=summary_id,
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}
+38
View File
@@ -0,0 +1,38 @@
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,
OPERATION_LOGS_PATH,
TEACHERS_PATH,
)
from ..data import account_summary
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),
"records_count": len(records),
"accounts_count": len(accounts),
"teachers_count": len(teachers),
"account_summary": account_summary(accounts),
}
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from ..auth import verify_ingest_token
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 ..schemas import CourseSummaryIngestPayload
router = APIRouter()
@router.post("/api/ingest/course-summaries")
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
try:
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,
summaries=[item.dict() for item in payload.summaries],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -277,6 +277,11 @@ def logout():
return response return response
@router.get("/accounts")
def accounts_index():
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin") @router.get("/admin")
def admin_index( def admin_index(
request: Request, request: Request,
@@ -328,6 +333,24 @@ def admin_logout():
return response return response
@router.get("/accounts/login")
def accounts_login_page():
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/accounts/login")
async def accounts_login_submit(request: Request):
return await admin_login_submit(request)
@router.get("/accounts/logout")
def accounts_logout():
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
response.delete_cookie(ADMIN_SESSION_COOKIE)
return response
@router.get("/static/{asset_path:path}") @router.get("/static/{asset_path:path}")
def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)): def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)):
target = (STATIC_DIR / asset_path).resolve() target = (STATIC_DIR / asset_path).resolve()
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
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
from ..data import (
account_to_dict,
query_public_records,
submit_public_correction_tasks,
submit_public_deletion_tasks,
)
from ..schemas import CorrectionSubmitPayload, DeletionSubmitPayload
router = APIRouter()
@router.get("/api/records")
def records(
q: str = Query(..., min_length=1, description="自然语言查询,例如:王鑫鹏5月数学课"),
limit: int = Query(200, ge=1, le=1000),
_user: str = Depends(verify_records_auth),
):
return query_public_records(load_records(), load_teachers(), q, limit=limit, 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}")
@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],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@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],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
+3 -16
View File
@@ -8,25 +8,17 @@ class RegisterLinesPayload(BaseModel):
lines: list[str] | None = Field(default=None, description="多条原始登记文本") lines: list[str] | None = Field(default=None, description="多条原始登记文本")
class AiRegisterPreviewPayload(BaseModel):
type: str
text: str | None = None
lines: list[str] | None = None
answers: dict[str, str] = Field(default_factory=dict)
conversation_id: str | None = None
class PaymentPayload(BaseModel): class PaymentPayload(BaseModel):
date: str date: str
hours: float hours: float
class StudentProfilePayload(BaseModel): class AccountPayload(BaseModel):
student_id: str = "" student_id: str = ""
student: str student: str
payments: list[PaymentPayload] = Field(default_factory=list) payments: list[PaymentPayload] = Field(default_factory=list)
remaining: float = 0
account_status: str = "正常" account_status: str = "正常"
primary_entry_year: int | None = None
note: str = "" note: str = ""
@@ -60,14 +52,9 @@ class DeletionSubmitPayload(BaseModel):
items: list[DeletionItemPayload] items: list[DeletionItemPayload]
class CourseSummarySupplementPayload(BaseModel):
record_id: str
body: str
class CourseSummaryPayload(BaseModel): class CourseSummaryPayload(BaseModel):
source_id: str = "" source_id: str = ""
student: str = "" student: str
date_iso: str = "" date_iso: str = ""
date: str = "" date: str = ""
time_range: str = "" time_range: str = ""
-72
View File
@@ -1,72 +0,0 @@
#!/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()
-232
View File
@@ -1,232 +0,0 @@
from __future__ import annotations
import argparse
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
import shutil
import sqlite3
import sys
import tarfile
APP_ROOT = Path(__file__).resolve().parents[1]
REPO_ROOT = APP_ROOT.parent
if str(APP_ROOT) not in sys.path:
sys.path.insert(0, str(APP_ROOT))
from app.db import SCHEMA_VERSION, connect, initialize_schema # noqa: E402
from app.repository import replace_database_from_paths, source_file_hashes # noqa: E402
EXPECTED_COUNTS = {
"records": 1548,
"accounts": 45,
"teachers": 13,
}
def sha256_path(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def data_paths(data_root: Path) -> dict[str, Path]:
return {
"classnotes_path": data_root / "classnotes.txt",
"accounts_path": data_root / "学生课时账户.md",
"teachers_path": data_root / "教师档案.md",
"tasks_path": data_root / "admin_tasks.json",
"summaries_root": data_root / "course_summaries",
"state_path": data_root / "course_summary_state.json",
"operation_logs_path": data_root / "operation_logs.jsonl",
}
def validate_counts(summary: dict, *, strict_expected: bool) -> list[str]:
errors: list[str] = []
for key in ["records", "accounts", "teachers", "admin_tasks", "operation_logs", "course_summaries"]:
if int(summary.get(key) or 0) < 0:
errors.append(f"{key} 数量异常")
if strict_expected:
for key, expected in EXPECTED_COUNTS.items():
actual = int(summary.get(key) or 0)
if actual != expected:
errors.append(f"{key} 数量应为 {expected},实际为 {actual}")
mismatch_count = len(summary.get("balance_mismatches") or [])
if mismatch_count != 4:
errors.append(f"余额差异应为 4 个学生,实际为 {mismatch_count}")
return errors
def validate_database(db_path: Path, summary: dict, *, strict_expected: bool) -> dict:
errors = validate_counts(summary, strict_expected=strict_expected)
with connect(db_path) as conn:
table_counts = {
"records": conn.execute("SELECT COUNT(*) FROM class_records").fetchone()[0],
"accounts": conn.execute("SELECT COUNT(*) FROM students").fetchone()[0],
"teachers": conn.execute("SELECT COUNT(*) FROM teachers").fetchone()[0],
"admin_tasks": conn.execute("SELECT COUNT(*) FROM admin_tasks").fetchone()[0],
"operation_logs": conn.execute("SELECT COUNT(*) FROM operation_logs").fetchone()[0],
"course_summaries": conn.execute("SELECT COUNT(*) FROM course_summaries").fetchone()[0],
}
for key, value in table_counts.items():
if int(summary.get(key) or 0) != int(value):
errors.append(f"SQLite {key} 数量不一致: source={summary.get(key)} sqlite={value}")
duplicate_records = conn.execute(
"""
SELECT record_key, COUNT(*) AS c
FROM class_records
GROUP BY record_key
HAVING c > 1
LIMIT 1
"""
).fetchone()
if duplicate_records:
errors.append(f"SQLite 中存在重复课程记录: {duplicate_records['record_key']}")
missing_account = conn.execute(
"""
SELECT student
FROM class_records
WHERE student_id IS NULL
LIMIT 1
"""
).fetchone()
if missing_account:
errors.append(f"SQLite 中存在没有账户的上课学生: {missing_account['student']}")
return {"ok": not errors, "errors": errors, "table_counts": table_counts}
def write_audit(conn: sqlite3.Connection, payload: dict) -> None:
conn.execute(
"INSERT INTO migration_audit(created_at, kind, payload_json) VALUES(?, ?, ?)",
(
datetime.now().isoformat(timespec="seconds"),
"text_to_sqlite",
json.dumps(payload, ensure_ascii=False, sort_keys=True),
),
)
def create_archive(data_root: Path, archives_root: Path, report_path: Path, timestamp: str) -> tuple[Path, Path]:
archives_root.mkdir(parents=True, exist_ok=True)
archive_path = archives_root / f"text-source-before-sqlite-{timestamp}.tar.gz"
with tarfile.open(archive_path, "w:gz") as archive:
for name in [
"classnotes.txt",
"学生课时账户.md",
"教师档案.md",
"admin_tasks.json",
"operation_logs.jsonl",
"course_summary_state.json",
]:
path = data_root / name
if path.exists():
archive.add(path, arcname=name)
summaries_root = data_root / "course_summaries"
if summaries_root.exists():
archive.add(summaries_root, arcname="course_summaries")
archive.add(report_path, arcname=report_path.name)
sha_path = archive_path.with_suffix(archive_path.suffix + ".sha256")
sha_path.write_text(f"{sha256_path(archive_path)} {archive_path.name}\n", encoding="utf-8")
return archive_path, sha_path
def migrate(args: argparse.Namespace) -> dict:
data_root = args.data_root.resolve()
db_path = args.db_path.resolve()
tmp_path = Path(str(db_path) + ".tmp")
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
report_path = data_root / f"sqlite_migration_report_{timestamp}.json"
if tmp_path.exists():
tmp_path.unlink()
if tmp_path.with_suffix(tmp_path.suffix + "-wal").exists():
tmp_path.with_suffix(tmp_path.suffix + "-wal").unlink()
if tmp_path.with_suffix(tmp_path.suffix + "-shm").exists():
tmp_path.with_suffix(tmp_path.suffix + "-shm").unlink()
with connect(tmp_path) as conn:
initialize_schema(conn)
conn.commit()
conn.execute("BEGIN IMMEDIATE")
try:
summary = replace_database_from_paths(
conn,
**data_paths(data_root),
allow_balance_adjustments=False,
)
conn.commit()
except Exception:
conn.rollback()
raise
validation = validate_database(tmp_path, summary, strict_expected=not args.no_strict_expected)
source_hashes = source_file_hashes(
[
data_root / "classnotes.txt",
data_root / "学生课时账户.md",
data_root / "教师档案.md",
data_root / "admin_tasks.json",
data_root / "operation_logs.jsonl",
data_root / "course_summary_state.json",
]
)
report = {
"created_at": datetime.now().isoformat(timespec="seconds"),
"schema_version": SCHEMA_VERSION,
"data_root": str(data_root),
"db_path": str(db_path),
"summary": summary,
"validation": validation,
"source_hashes": source_hashes,
"dry_run": bool(args.dry_run),
}
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if not validation["ok"]:
raise SystemExit("迁移校验失败:\n" + "\n".join(validation["errors"]))
with connect(tmp_path) as conn:
write_audit(conn, {**report, "report_path": str(report_path)})
if args.dry_run:
print(json.dumps({**report, "tmp_db_path": str(tmp_path), "report_path": str(report_path)}, ensure_ascii=False, indent=2))
return report
db_path.parent.mkdir(parents=True, exist_ok=True)
if db_path.exists():
backup_db = db_path.with_name(f"{db_path.name}.before-sqlite-migration-{timestamp}")
shutil.copy2(db_path, backup_db)
report["previous_db_backup"] = str(backup_db)
os.replace(tmp_path, db_path)
for suffix in ["-wal", "-shm"]:
sidecar = Path(str(tmp_path) + suffix)
if sidecar.exists():
os.replace(sidecar, Path(str(db_path) + suffix))
archive_path, sha_path = create_archive(data_root, args.archives_root.resolve(), report_path, timestamp)
report["archive_path"] = str(archive_path)
report["archive_sha256_path"] = str(sha_path)
report["dry_run"] = False
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({**report, "report_path": str(report_path)}, ensure_ascii=False, indent=2))
return report
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="将新时空纯文本教务数据迁移到 SQLite")
parser.add_argument("--data-root", type=Path, default=REPO_ROOT / "data")
parser.add_argument("--db-path", type=Path, default=Path("/data/xsk_education.db"))
parser.add_argument("--archives-root", type=Path, default=REPO_ROOT / "archives")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--no-strict-expected", action="store_true", help="不校验当前生产数据的固定计数")
return parser.parse_args()
if __name__ == "__main__":
migrate(parse_args())
-256
View File
@@ -1,256 +0,0 @@
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const staticDir = path.resolve(__dirname, "../app/static");
function domNode(id, extras = {}) {
let value = "";
return {
id,
textContent: "",
innerHTML: "",
hidden: false,
disabled: false,
dataset: {},
classList: { toggle() {} },
get value() {
return value;
},
set value(nextValue) {
value = String(nextValue ?? "");
},
setAttribute(name, nextValue) {
this[name] = nextValue;
},
addEventListener() {},
focus() {},
...extras,
};
}
const elements = new Map();
function element(id) {
if (!elements.has(id)) {
elements.set(id, domNode(id));
}
return elements.get(id);
}
const documentStub = {
querySelector(selector) {
return element(selector.replace(/^#/, ""));
},
querySelectorAll() {
return [];
},
addEventListener() {},
createElement(tag) {
return element(`created-${tag}-${elements.size}`);
},
body: { appendChild() {} },
execCommand() {
return true;
},
};
const context = {
console,
document: documentStub,
navigator: {},
fetch: async () => ({ ok: true, json: async () => ({ classnotes: { mtime: 0 } }) }),
Date,
JSON,
Map,
Number,
String,
};
context.window = { location: { href: "" } };
context.confirm = () => true;
context.alert = () => {};
function assertEqual(name, actual, expected) {
if (actual !== expected) {
throw new Error(`${name}: got ${actual}, expected ${expected}`);
}
}
function value(expression) {
return vm.runInContext(expression, context);
}
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(staticDir, "app.js"), "utf8"), context);
vm.runInContext(
`
currentRecords = [
{ date: "2026.06.12", weekday: "星期五", time: "10:00-11:00", student: "甲", duration: "1小时0分", duration_hours: 1, teacher: "王老师", subject: "英语", _recordIndex: 0, _recordKey: "a" },
{ date: "2026.06.12", weekday: "星期五", time: "08:00-09:00", student: "乙", duration: "1小时0分", duration_hours: 1, teacher: "王老师", subject: "英语", _recordIndex: 1, _recordKey: "b" },
{ date: "2026.06.13", weekday: "星期六", time: "09:00-10:00", student: "丙", duration: "1小时0分", duration_hours: 1, teacher: "王老师", subject: "英语", _recordIndex: 2, _recordKey: "c" },
{ date: "2026.06.12", weekday: "星期五", time: "07:00-08:00", student: "丁", duration: "1小时0分", duration_hours: 1, teacher: "李老师", subject: "数学", _recordIndex: 3, _recordKey: "d" },
];
renderGroupedRecords(currentRecords);
`,
context,
);
assertEqual("初始日期按钮文案", value("dateSortBtn.textContent"), "日期 升序排列");
assertEqual("初始时间按钮文案", value("timeSortBtn.textContent"), "时间 升序排列");
assertEqual("默认组内排序", value("currentRecordOrder.join('')"), "bacd");
vm.runInContext("toggleRecordSort('date')", context);
assertEqual("日期降序按钮文案", value("dateSortBtn.textContent"), "日期 降序排列");
assertEqual("日期降序组内排序", value("currentRecordOrder.join('')"), "cbad");
vm.runInContext("toggleRecordSort('date'); toggleRecordSort('time')", context);
assertEqual("时间降序按钮文案", value("timeSortBtn.textContent"), "时间 降序排列");
assertEqual("日期升序时间降序组内排序", value("currentRecordOrder.join('')"), "abcd");
vm.runInContext(
`
correctedRecords.set("a", { ...currentRecords[0], date: "2026.06.11", weekday: "星期四", time: "07:00-08:00" });
renderGroupedRecords(currentRecords);
`,
context,
);
assertEqual("纠错后排序仍使用显示记录", value("currentRecordOrder.join('')"), "abcd");
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);
+60
View File
@@ -0,0 +1,60 @@
<!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=20260611-accounts-split" />
</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=20260611-accounts-split"></script>
</body>
</html>
+116
View File
@@ -0,0 +1,116 @@
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) {
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
}
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)}${fmtHours(item.hours)} 小时`).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">${fmtHours(row.remaining)}</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();
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理后台</title> <title>管理后台</title>
<link rel="stylesheet" href="/static/styles.css?v=20260706-sqlite-native" /> <link rel="stylesheet" href="/static/styles.css?v=20260615-summary-time-review" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -21,36 +21,20 @@
<main class="layout account-layout admin-layout"> <main class="layout account-layout admin-layout">
<nav class="admin-tabs" aria-label="管理后台功能"> <nav class="admin-tabs" aria-label="管理后台功能">
<button class="admin-tab is-active" data-admin-tab="dashboard" type="button">仪表盘</button> <button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button>
<button class="admin-tab" data-admin-tab="students" type="button">学生档案</button>
<button class="admin-tab" data-admin-tab="teachers" type="button">老师档案</button> <button class="admin-tab" data-admin-tab="teachers" type="button">老师档案</button>
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button> <button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结</button> <button class="admin-tab" data-admin-tab="summaries" type="button">课程小结审核</button>
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结查询</button>
<button class="admin-tab" data-admin-tab="logs" type="button">操作记录</button> <button class="admin-tab" data-admin-tab="logs" type="button">操作记录</button>
<button class="admin-tab" data-admin-tab="register" type="button">登记</button> <button class="admin-tab" data-admin-tab="register" type="button">登记</button>
</nav> </nav>
<section id="dashboardPanel" class="panel admin-panel"> <section id="accountsPanel" class="panel admin-panel">
<div class="section-head"> <div class="section-head">
<h2>仪表盘</h2> <h2>课时账户</h2>
<div class="quick-actions dashboard-periods" role="group" aria-label="统计周期">
<button class="chip dashboard-period" data-dashboard-period="today" type="button">今日</button>
<button class="chip dashboard-period" data-dashboard-period="7d" type="button">近7日</button>
<button class="chip dashboard-period is-active" data-dashboard-period="month" type="button">本月</button>
<button class="chip dashboard-period" data-dashboard-period="all" type="button">全部</button>
</div>
</div>
<div id="dashboardMeta" class="summary-grid"></div>
<div id="dashboardContent" class="dashboard-content">
<div class="dashboard-loading">正在读取仪表盘数据</div>
</div>
</section>
<section id="studentsPanel" class="panel admin-panel" hidden>
<div class="section-head">
<h2>学生档案</h2>
<div class="quick-actions"> <div class="quick-actions">
<select id="studentStatus" aria-label="学生档案状态筛选"> <select id="accountStatus" aria-label="账户状态筛选">
<option value="">全部状态</option> <option value="">全部状态</option>
<option value="欠费">欠费</option> <option value="欠费">欠费</option>
<option value="预警">预警</option> <option value="预警">预警</option>
@@ -58,20 +42,20 @@
<option value="结课">结课</option> <option value="结课">结课</option>
<option value="退费">退费</option> <option value="退费">退费</option>
</select> </select>
<button id="newStudentBtn" class="chip" type="button">新增学生档案</button> <button id="newAccountBtn" class="chip" type="button">新增账户</button>
</div> </div>
</div> </div>
<form id="studentForm" class="search-row account-search-row"> <form id="accountForm" class="search-row account-search-row">
<input id="studentQuery" autocomplete="off" placeholder="学生姓名或学生ID" /> <input id="accountQuery" autocomplete="off" placeholder="学生姓名或学生ID" />
<button type="submit">查询</button> <button type="submit">查询</button>
</form> </form>
<div id="studentMeta" class="summary-grid"></div> <div id="accountMeta" class="summary-grid"></div>
<div id="studentEditor" class="admin-editor" hidden> <div id="accountEditor" class="admin-editor" hidden>
<div class="section-head compact-head"> <div class="section-head compact-head">
<h2 id="studentEditorTitle">新增学生档案</h2> <h2 id="accountEditorTitle">新增账户</h2>
<button id="cancelStudentEditBtn" class="secondary-button" type="button">取消</button> <button id="cancelAccountEditBtn" class="secondary-button" type="button">取消</button>
</div> </div>
<form id="studentEditForm" class="admin-form"> <form id="accountEditForm" class="admin-form">
<div class="form-grid"> <div class="form-grid">
<label> <label>
学生ID 学生ID
@@ -81,16 +65,12 @@
学生姓名 学生姓名
<input id="editStudent" autocomplete="off" required /> <input id="editStudent" autocomplete="off" required />
</label> </label>
<label>
入学年份
<input id="editPrimaryEntryYear" autocomplete="off" inputmode="numeric" placeholder="小学一年级" />
</label>
<label> <label>
剩余课时 剩余课时
<input id="editRemaining" readonly value="系统自动计算" /> <input id="editRemaining" inputmode="decimal" required />
</label> </label>
<label> <label>
档案状态 账户状态
<select id="editStatus"> <select id="editStatus">
<option value="正常">正常</option> <option value="正常">正常</option>
<option value="预警">预警</option> <option value="预警">预警</option>
@@ -108,9 +88,9 @@
<textarea id="editNote" rows="3"></textarea> <textarea id="editNote" rows="3"></textarea>
</label> </label>
</div> </div>
<p id="studentEditError" class="correction-error" hidden></p> <p id="accountEditError" class="correction-error" hidden></p>
<div class="modal-actions"> <div class="modal-actions">
<button type="submit">保存档案</button> <button type="submit">保存账户</button>
</div> </div>
</form> </form>
</div> </div>
@@ -119,7 +99,6 @@
<thead> <thead>
<tr> <tr>
<th>学生</th> <th>学生</th>
<th>入学年份</th>
<th>状态</th> <th>状态</th>
<th class="num">剩余课时</th> <th class="num">剩余课时</th>
<th>缴费记录</th> <th>缴费记录</th>
@@ -127,7 +106,7 @@
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody id="studentRows"></tbody> <tbody id="accountRows"></tbody>
</table> </table>
</div> </div>
</section> </section>
@@ -235,7 +214,36 @@
<tbody id="reviewRows"></tbody> <tbody id="reviewRows"></tbody>
</table> </table>
</div> </div>
<div id="reviewPager" class="pager" hidden></div> </section>
<section id="summariesPanel" class="panel admin-panel" hidden>
<div class="section-head">
<h2>课程小结审核</h2>
<select id="summaryReviewStatus" aria-label="课程小结审核状态筛选">
<option value="pending">待审核</option>
<option value="conflict">冲突</option>
<option value="approved">已批准</option>
<option value="rejected">已驳回</option>
<option value="">全部状态</option>
</select>
</div>
<div id="summaryReviewMeta" class="summary-grid"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>编号</th>
<th>状态</th>
<th>课程信息</th>
<th>候选记录</th>
<th>小结原文</th>
<th>原因</th>
<th>操作</th>
</tr>
</thead>
<tbody id="summaryReviewRows"></tbody>
</table>
</div>
</section> </section>
<div id="summaryReviewDrawerBackdrop" class="drawer-backdrop" hidden> <div id="summaryReviewDrawerBackdrop" class="drawer-backdrop" hidden>
@@ -256,32 +264,6 @@
<div class="drawer-label">课程信息</div> <div class="drawer-label">课程信息</div>
<div id="summaryReviewDrawerCourse" class="drawer-text"></div> <div id="summaryReviewDrawerCourse" class="drawer-text"></div>
</div> </div>
<div id="summaryReviewEditSection" class="drawer-section" hidden>
<div class="drawer-label">修正信息</div>
<div class="form-grid compact-grid">
<label>
学生
<input id="summaryReviewEditStudent" autocomplete="off" />
</label>
<label>
日期
<input id="summaryReviewEditDate" type="date" />
</label>
<label>
时间
<input id="summaryReviewEditTime" autocomplete="off" placeholder="08:00-10:00" />
</label>
<label>
老师
<input id="summaryReviewEditTeacher" autocomplete="off" />
</label>
<label>
科目
<input id="summaryReviewEditSubject" autocomplete="off" />
</label>
</div>
<div id="summaryReviewEditHint" class="form-hint" hidden></div>
</div>
<div class="drawer-section"> <div class="drawer-section">
<div class="drawer-label">建议登记行</div> <div class="drawer-label">建议登记行</div>
<div id="summaryReviewDrawerLine"></div> <div id="summaryReviewDrawerLine"></div>
@@ -290,10 +272,6 @@
<div class="drawer-label">审核原因</div> <div class="drawer-label">审核原因</div>
<div id="summaryReviewDrawerReasons" class="drawer-text"></div> <div id="summaryReviewDrawerReasons" class="drawer-text"></div>
</div> </div>
<div id="summaryReviewConflictSection" class="drawer-section" hidden>
<div class="drawer-label">冲突信息</div>
<div id="summaryReviewDrawerConflicts" class="summary-conflict-list"></div>
</div>
<div class="drawer-section"> <div class="drawer-section">
<div class="drawer-label">小结原文</div> <div class="drawer-label">小结原文</div>
<div id="summaryReviewDrawerBody" class="drawer-body"></div> <div id="summaryReviewDrawerBody" class="drawer-body"></div>
@@ -304,8 +282,6 @@
</div> </div>
</div> </div>
<div class="drawer-actions"> <div class="drawer-actions">
<button id="summaryReviewDrawerSave" class="secondary-button" type="button">保存修正</button>
<button id="summaryReviewDrawerLink" class="secondary-button" type="button">关联已有记录</button>
<button id="summaryReviewDrawerReject" class="secondary-button" type="button">驳回</button> <button id="summaryReviewDrawerReject" class="secondary-button" type="button">驳回</button>
<button id="summaryReviewDrawerApprove" type="button">批准</button> <button id="summaryReviewDrawerApprove" type="button">批准</button>
</div> </div>
@@ -314,11 +290,7 @@
<section id="summarySearchPanel" class="panel admin-panel" hidden> <section id="summarySearchPanel" class="panel admin-panel" hidden>
<div class="section-head"> <div class="section-head">
<h2>课程小结</h2> <h2>课程小结查询</h2>
<div class="quick-actions">
<button id="quickMismatchBtn" class="chip" type="button">快速处理字段不一致</button>
<button class="chip duplicate-summary-scan" type="button">扫描重复小结</button>
</div>
</div> </div>
<form id="summarySearchForm" class="search-row summary-search-row"> <form id="summarySearchForm" class="search-row summary-search-row">
<input id="summarySearchQuery" autocomplete="off" placeholder="关键词:课堂内容、作业、问题等" /> <input id="summarySearchQuery" autocomplete="off" placeholder="关键词:课堂内容、作业、问题等" />
@@ -327,17 +299,9 @@
<input id="summarySearchSubject" autocomplete="off" placeholder="科目" /> <input id="summarySearchSubject" autocomplete="off" placeholder="科目" />
<input id="summarySearchDateFrom" type="date" aria-label="开始日期" /> <input id="summarySearchDateFrom" type="date" aria-label="开始日期" />
<input id="summarySearchDateTo" type="date" aria-label="结束日期" /> <input id="summarySearchDateTo" type="date" aria-label="结束日期" />
<select id="summarySearchBindingStatus" aria-label="绑定状态筛选"> <select id="summarySearchMissingTime" aria-label="时间状态筛选">
<option value="">全部绑定状态</option> <option value="">全部时间</option>
<option value="matched">已绑定</option> <option value="1">缺少时间</option>
<option value="unmatched">未绑定</option>
<option value="missing_time">缺时间</option>
<option value="mismatch">字段不一致</option>
</select>
<select id="summarySearchHasCandidate" aria-label="候选记录筛选">
<option value="">全部候选状态</option>
<option value="true">有候选记录</option>
<option value="false">无候选记录</option>
</select> </select>
<button type="submit">查询</button> <button type="submit">查询</button>
</form> </form>
@@ -350,45 +314,14 @@
<th>时间</th> <th>时间</th>
<th>学生</th> <th>学生</th>
<th>老师/科目</th> <th>老师/科目</th>
<th>绑定状态</th> <th>标题</th>
<th>小结正文</th>
<th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody id="summarySearchRows"></tbody> <tbody id="summarySearchRows"></tbody>
</table> </table>
</div> </div>
<div id="summarySearchPager" class="pager" hidden></div>
<div class="summary-task-section">
<div class="section-head compact-head">
<h3>待处理任务</h3>
<div class="quick-actions">
<select id="summaryReviewStatus" aria-label="课程小结任务状态筛选">
<option value="pending">待审核</option>
<option value="conflict">冲突</option>
<option value="approved">已批准</option>
<option value="rejected">已驳回</option>
<option value="">全部状态</option>
</select>
</div>
</div>
<div id="summaryReviewMeta" class="summary-grid"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>编号</th>
<th>状态</th>
<th>课程信息</th>
<th>候选记录</th>
<th>小结原文</th>
<th>原因</th>
<th>操作</th>
</tr>
</thead>
<tbody id="summaryReviewRows"></tbody>
</table>
</div>
<div id="summaryReviewPager" class="pager" hidden></div>
</div>
</section> </section>
<section id="logsPanel" class="panel admin-panel" hidden> <section id="logsPanel" class="panel admin-panel" hidden>
@@ -397,37 +330,17 @@
<div class="quick-actions"> <div class="quick-actions">
<select id="logOperation" aria-label="操作类型筛选"> <select id="logOperation" aria-label="操作类型筛选">
<option value="">全部操作</option> <option value="">全部操作</option>
<option value="登记上课记录">登记上课记录</option> <option value="course_summary_ingest">小结接收</option>
<option value="登记缴费记录">登记缴费记录</option> <option value="admin_task_approve">审核批准</option>
<option value="新增学生档案">新增学生档案</option> <option value="admin_task_reject">审核驳回</option>
<option value="修改学生档案">修改学生档案</option>
<option value="新增老师档案">新增老师档案</option>
<option value="修改老师档案">修改老师档案</option>
<option value="课程小结接收">课程小结接收</option>
<option value="课程小结登记">课程小结登记</option>
<option value="课程小结审核修正">课程小结审核修正</option>
<option value="课程小结关联已有记录">课程小结关联已有记录</option>
<option value="课程小结修改归属">课程小结修改归属</option>
<option value="重复小结扫描">重复小结扫描</option>
<option value="重复小结删除">重复小结删除</option>
<option value="审核批准">审核批准</option>
<option value="审核驳回">审核驳回</option>
<option value="课程小结补齐时间">课程小结补齐时间</option>
<option value="课程小结删除">课程小结删除</option>
<option value="撤回操作">撤回操作</option>
</select> </select>
<select id="logStatus" aria-label="操作结果筛选"> <select id="logStatus" aria-label="操作结果筛选">
<option value="">全部结果</option> <option value="">全部结果</option>
<option value="完成">完成</option> <option value="auto_registered">自动入账</option>
<option value="自动入账">自动入账</option> <option value="review">待审核</option>
<option value="自动绑定">自动绑定</option> <option value="duplicate">重复</option>
<option value="待审核">待审核</option> <option value="rejected">失败/驳回</option>
<option value="重复">重复</option> <option value="approved">已批准</option>
<option value="已驳回">失败/驳回</option>
<option value="已批准">已批准</option>
<option value="已更新">已更新</option>
<option value="已删除">已删除</option>
<option value="已撤回">已撤回</option>
</select> </select>
</div> </div>
</div> </div>
@@ -445,13 +358,12 @@
<th>结果</th> <th>结果</th>
<th>学生</th> <th>学生</th>
<th>批次/任务</th> <th>批次/任务</th>
<th>操作</th> <th>详情</th>
</tr> </tr>
</thead> </thead>
<tbody id="logRows"></tbody> <tbody id="logRows"></tbody>
</table> </table>
</div> </div>
<div id="logPager" class="pager" hidden></div>
</section> </section>
<section id="registerPanel" class="panel admin-panel" hidden> <section id="registerPanel" class="panel admin-panel" hidden>
@@ -462,37 +374,32 @@
<form id="classRegisterForm" class="admin-form"> <form id="classRegisterForm" class="admin-form">
<h3>上课记录登记</h3> <h3>上课记录登记</h3>
<textarea id="classRegisterLines" rows="10" placeholder="每行一条上课记录"></textarea> <textarea id="classRegisterLines" rows="10" placeholder="每行一条上课记录"></textarea>
<div id="classRegisterPreview" class="register-preview" hidden></div>
<p id="classRegisterStatus" class="inline-account-loading"></p> <p id="classRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions"> <div class="modal-actions">
<button type="submit">生成预览</button> <button type="submit">提交上课记录</button>
<button id="classRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
</div> </div>
</form> </form>
<form id="paymentRegisterForm" class="admin-form"> <form id="paymentRegisterForm" class="admin-form">
<h3>缴费登记</h3> <h3>缴费登记</h3>
<textarea id="paymentRegisterLines" rows="10" placeholder="每行一条:学生-2026-06-01:53"></textarea> <textarea id="paymentRegisterLines" rows="10" placeholder="每行一条:学生-2026-06-01:53"></textarea>
<div id="paymentRegisterPreview" class="register-preview" hidden></div>
<p id="paymentRegisterStatus" class="inline-account-loading"></p> <p id="paymentRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions"> <div class="modal-actions">
<button type="submit">生成预览</button> <button type="submit">提交缴费记录</button>
<button id="paymentRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
</div> </div>
</form> </form>
<form id="summaryRegisterForm" class="admin-form"> <form id="summaryRegisterForm" class="admin-form">
<h3>课程小结登记</h3> <h3>课程小结登记</h3>
<textarea id="summaryRegisterText" rows="12" placeholder="粘贴一条或多条课程小结原文,学生/日期/时间/老师/科目由系统自动提取"></textarea> <div id="summaryRegisterItems" class="summary-register-items"></div>
<div id="summaryRegisterPreview" class="register-preview" hidden></div> <button id="addSummaryRegisterItemBtn" class="secondary-button" type="button">再填一条</button>
<p id="summaryRegisterStatus" class="inline-account-loading"></p> <p id="summaryRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions"> <div class="modal-actions">
<button type="submit">生成预览</button> <button type="submit">提交课程小结</button>
<button id="summaryRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
</div> </div>
</form> </form>
</div> </div>
</section> </section>
</main> </main>
<script src="/static/admin.js?v=20260706-sqlite-native"></script> <script src="/static/admin.js?v=20260615-summary-time-review"></script>
</body> </body>
</html> </html>
+1007
View File
File diff suppressed because it is too large Load Diff
+65 -290
View File
@@ -4,7 +4,6 @@ const recordForm = document.querySelector("#recordForm");
const recordQuery = document.querySelector("#recordQuery"); const recordQuery = document.querySelector("#recordQuery");
const recordMeta = document.querySelector("#recordMeta"); const recordMeta = document.querySelector("#recordMeta");
const recordRows = document.querySelector("#recordRows"); const recordRows = document.querySelector("#recordRows");
const recordPager = document.querySelector("#recordPager");
const dateSortBtn = document.querySelector("#dateSortBtn"); const dateSortBtn = document.querySelector("#dateSortBtn");
const timeSortBtn = document.querySelector("#timeSortBtn"); const timeSortBtn = document.querySelector("#timeSortBtn");
const inlineAccount = document.querySelector("#inlineAccount"); const inlineAccount = document.querySelector("#inlineAccount");
@@ -30,41 +29,20 @@ const deleteError = document.querySelector("#deleteError");
const closeDeleteBtn = document.querySelector("#closeDeleteBtn"); const closeDeleteBtn = document.querySelector("#closeDeleteBtn");
const cancelDeleteBtn = document.querySelector("#cancelDeleteBtn"); const cancelDeleteBtn = document.querySelector("#cancelDeleteBtn");
const confirmDeleteBtn = document.querySelector("#confirmDeleteBtn"); const confirmDeleteBtn = document.querySelector("#confirmDeleteBtn");
const summarySupplementDialog = document.querySelector("#summarySupplementDialog");
const summarySupplementForm = document.querySelector("#summarySupplementForm");
const summarySupplementOriginal = document.querySelector("#summarySupplementOriginal");
const summarySupplementBody = document.querySelector("#summarySupplementBody");
const summarySupplementError = document.querySelector("#summarySupplementError");
const summarySupplementPreview = document.querySelector("#summarySupplementPreview");
const closeSummarySupplementBtn = document.querySelector("#closeSummarySupplementBtn");
const cancelSummarySupplementBtn = document.querySelector("#cancelSummarySupplementBtn");
const submitSummarySupplementBtn = document.querySelector("#submitSummarySupplementBtn");
const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]; const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
const COPY_LINE_BREAK = "\r\n"; const COPY_LINE_BREAK = "\r\n";
const SORT_DIRECTIONS = { asc: 1, desc: -1 }; const SORT_DIRECTIONS = { asc: 1, desc: -1 };
const RECORD_TABLE_COLUMN_COUNT = 8;
let currentRecords = []; let currentRecords = [];
let recordSort = { date: "asc", time: "asc" }; let recordSort = { date: "asc", time: "asc" };
let currentRecordOrder = []; let currentRecordOrder = [];
let correctedRecords = new Map(); let correctedRecords = new Map();
let correctedRecordOrder = [];
let expandedSummaryRecords = new Set(); let expandedSummaryRecords = new Set();
let collapsedTeacherGroups = new Set();
let currentRecordPage = { query: "", offset: 0, limit: 0, total: 0, shown: 0, hasMore: false };
let activeCorrectionKey = ""; let activeCorrectionKey = "";
let activeDeleteKey = ""; let activeDeleteKey = "";
let activeSummarySupplementKey = "";
function fmtHours(value) { function fmtHours(value) {
const totalMinutes = Math.round(Number(value || 0) * 60); return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}小时${minutes}`;
}
function displayHours(value, fallback) {
return fallback || fmtHours(value);
} }
function fmtTime(seconds) { function fmtTime(seconds) {
@@ -85,17 +63,6 @@ function metric(label, value) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`; return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
} }
function metricHtml(label, valueHtml) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${valueHtml}</strong></div>`;
}
function pageRangeText(state) {
const total = Number(state.total || 0);
const shown = Number(state.shown || 0);
if (!total || !shown) return "0 条";
return `${shown}`;
}
function makeRecordKey(row, index) { function makeRecordKey(row, index) {
return row.record_id || JSON.stringify([index, row.date, row.time, row.student, row.duration, row.teacher, row.subject]); return row.record_id || JSON.stringify([index, row.date, row.time, row.student, row.duration, row.teacher, row.subject]);
} }
@@ -122,13 +89,6 @@ 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) { function getDisplayRecord(row) {
return correctedRecords.get(row._recordKey) || row; return correctedRecords.get(row._recordKey) || row;
} }
@@ -216,25 +176,25 @@ function toggleRecordSort(field) {
renderCurrentRecords(); renderCurrentRecords();
} }
function renderRecordRow(row, rowNumber) { function renderRecordRow(row) {
const key = row._recordKey; const key = row._recordKey;
const corrected = correctedRecords.get(key); const corrected = correctedRecords.get(key);
const displayRow = getDisplayRecord(row); const displayRow = getDisplayRecord(row);
const correctedClass = corrected ? " corrected-row" : ""; const correctedClass = corrected ? " corrected-row" : "";
const summaryClass = Number(row.summary_count || 0) > 0 ? "" : " missing-summary";
const actionLabel = corrected ? "编辑" : "纠错"; const actionLabel = corrected ? "编辑" : "纠错";
const badge = corrected ? '<span class="correction-badge">已修改</span>' : ""; const badge = corrected ? '<span class="correction-badge">已修改</span>' : "";
const summaryCount = Number(displayRow.summary_count || 0);
const summaryExpanded = expandedSummaryRecords.has(key); 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"}"> return `<tr class="record-row${correctedClass}">
<td class="num record-index-cell" data-label="序号">${escapeHtml(rowNumber)}</td> <td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
<td data-label="日期">${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td> <td>${escapeHtml(displayRow.time)}</td>
<td data-label="时间">${escapeHtml(displayRow.time)}</td> <td>${escapeHtml(displayRow.student)}</td>
<td data-label="学生">${escapeHtml(displayRow.student)}</td> <td>${escapeHtml(displayRow.teacher)}</td>
<td data-label="老师">${escapeHtml(displayRow.teacher)}</td> <td>${escapeHtml(displayRow.subject)}</td>
<td data-label="科目">${escapeHtml(displayRow.subject)}</td> <td class="num">${escapeHtml(displayRow.duration)}</td>
<td class="num" data-label="时长">${escapeHtml(displayRow.duration)}</td> <td class="record-action-cell">
<td class="record-action-cell" data-label="操作">
<div class="record-actions"> <div class="record-actions">
<button class="small-button summary-toggle" type="button" data-record-key="${escapeHtml(key)}" ${summaryCount > 0 ? "" : "disabled"}>${summaryExpanded ? "收起小结" : "课程小结"}</button>
<button class="small-button correction-edit" type="button" data-record-key="${escapeHtml(key)}">${actionLabel}</button> <button class="small-button correction-edit" type="button" data-record-key="${escapeHtml(key)}">${actionLabel}</button>
<button class="small-button record-delete" type="button" data-record-key="${escapeHtml(key)}">删除</button> <button class="small-button record-delete" type="button" data-record-key="${escapeHtml(key)}">删除</button>
${badge} ${badge}
@@ -246,61 +206,35 @@ function renderRecordRow(row, rowNumber) {
function renderSummaryEntry(summary) { function renderSummaryEntry(summary) {
const title = summary.title || "课程小结"; const title = summary.title || "课程小结";
const time = summary.time_range ? ` · ${summary.time_range}` : ""; const time = summary.time_range ? ` · ${summary.time_range}` : "";
const meta = [
summary.date_iso || "",
summary.teacher || "",
summary.subject || "",
].filter(Boolean).join(" · ");
const body = summary.body || "暂无正文";
return `<div class="record-summary-item"> return `<div class="record-summary-item">
<div class="record-summary-title">${escapeHtml(title)}${escapeHtml(time)}</div> <div class="record-summary-title">${escapeHtml(title)}${escapeHtml(time)}</div>
${meta ? `<div class="record-summary-meta">${escapeHtml(meta)}</div>` : ""} <div class="summary-body">${escapeHtml(summary.body || "暂无正文")}</div>
<div class="summary-body summary-full">${escapeHtml(body)}</div>
</div>`; </div>`;
} }
function renderSummaryMissingRow(key, expanded) {
return `<tr class="summary-collapse-row summary-missing-row" data-summary-row="${escapeHtml(key)}" ${expanded ? "" : "hidden"}>
<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>
</div>
</td>
</tr>`;
}
function renderGroupedRecords(records) { function renderGroupedRecords(records) {
currentRecordOrder = []; currentRecordOrder = [];
return groupRecordsByTeacher(records) return groupRecordsByTeacher(records)
.map( .map(
(group) => { (group) => `<tr class="teacher-group">
let displayIndex = 0; <td colspan="7">
const collapsed = collapsedTeacherGroups.has(group.teacher); <div class="teacher-group-title">
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="${RECORD_TABLE_COLUMN_COUNT}">${(row.summaries || []).map((summary) => renderSummaryEntry(summary)).join("")}</td>
</tr>`
: renderSummaryMissingRow(row._recordKey, expandedSummaryRecords.has(row._recordKey));
return `${renderRecordRow(row, displayIndex)}${summaryRow}`;
})
.join("");
return `<tr class="teacher-group" data-teacher="${escapeHtml(group.teacher)}">
<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> <strong>${escapeHtml(group.teacher)}</strong>
<span>${group.count} 条记录 · ${displayHours(group.totalHours)} · ${collapsed ? "展开" : "收起"}</span> <span>${group.count} 条记录 · ${fmtHours(group.totalHours)} 小时</span>
</button> </div>
</td> </td>
</tr>${rows}`; </tr>${sortRecordsForDisplay(group.records)
}, .map((row) => {
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(renderSummaryEntry).join("")}</td>
</tr>`
: "";
return `${renderRecordRow(row)}${summaryRow}`;
})
.join("")}`,
) )
.join(""); .join("");
} }
@@ -314,19 +248,6 @@ function toggleSummaryRow(key) {
renderCurrentRecords(); renderCurrentRecords();
} }
function toggleRecordSummary(key) {
toggleSummaryRow(key);
}
function toggleTeacherGroup(teacher) {
if (collapsedTeacherGroups.has(teacher)) {
collapsedTeacherGroups.delete(teacher);
} else {
collapsedTeacherGroups.add(teacher);
}
renderCurrentRecords();
}
function statusClass(status) { function statusClass(status) {
if (status === "欠费") return "debt"; if (status === "欠费") return "debt";
if (status === "预警") return "warning"; if (status === "预警") return "warning";
@@ -336,41 +257,38 @@ function statusClass(status) {
function renderPayments(payments) { function renderPayments(payments) {
if (!payments.length) return "暂无缴费记录"; if (!payments.length) return "暂无缴费记录";
return payments return payments.map((item) => `${item.date}${fmtHours(item.hours)} 小时`).join("");
.map((item) => `<span class="payment-line">${escapeHtml(item.date)}${escapeHtml(displayHours(item.hours, item.duration))}</span>`)
.join("");
} }
function renderInlineStudentProfile(studentProfile) { function renderInlineAccount(account) {
inlineAccount.hidden = false; inlineAccount.hidden = false;
inlineAccount.innerHTML = `<div class="inline-account-head"> inlineAccount.innerHTML = `<div class="inline-account-head">
<div> <div>
<h3>${escapeHtml(studentProfile.student)} 学生档案</h3> <h3>${escapeHtml(account.student)} 课时账户</h3>
<p>${escapeHtml(studentProfile.student_id)}</p> <p>${escapeHtml(account.student_id)}</p>
</div> </div>
<span class="status ${statusClass(studentProfile.account_status)}">${escapeHtml(studentProfile.account_status)}</span> <span class="status ${statusClass(account.account_status)}">${escapeHtml(account.account_status)}</span>
</div> </div>
<div class="inline-account-grid"> <div class="inline-account-grid">
${metric("剩余课时", displayHours(studentProfile.remaining, studentProfile.remaining_duration))} ${metric("剩余课时", fmtHours(account.remaining))}
${metric("入学年份", studentProfile.primary_entry_year || "未填")} ${metric("缴费次数", account.payments_count)}
${metric("缴费次数", studentProfile.payments_count)} ${metric("缴费记录", renderPayments(account.payments))}
${metricHtml("缴费记录", renderPayments(studentProfile.payments))} ${metric("备注", account.note || "无")}
${metric("备注", studentProfile.note || "无")}
</div>`; </div>`;
} }
async function loadInlineStudentProfile(student) { async function loadInlineAccount(student) {
inlineAccount.hidden = false; inlineAccount.hidden = false;
inlineAccount.innerHTML = `<div class="inline-account-loading">正在读取 ${escapeHtml(student)}学生档案</div>`; inlineAccount.innerHTML = `<div class="inline-account-loading">正在读取 ${escapeHtml(student)}课时账户</div>`;
try { try {
const studentProfile = await fetchJson(`/api/students/${encodeURIComponent(student)}`); const account = await fetchJson(`/api/student-account/${encodeURIComponent(student)}`);
renderInlineStudentProfile(studentProfile); renderInlineAccount(account);
} catch (error) { } catch (error) {
inlineAccount.innerHTML = `<div class="inline-account-loading">学生档案读取失败:${escapeHtml(error.message)}</div>`; inlineAccount.innerHTML = `<div class="inline-account-loading">课时账户读取失败:${escapeHtml(error.message)}</div>`;
} }
} }
function clearInlineStudentProfile() { function clearInlineAccount() {
inlineAccount.hidden = true; inlineAccount.hidden = true;
inlineAccount.innerHTML = ""; inlineAccount.innerHTML = "";
} }
@@ -388,10 +306,8 @@ function updateCorrectionToolbar(message = "", isError = false) {
function resetCorrections() { function resetCorrections() {
correctedRecords = new Map(); correctedRecords = new Map();
correctedRecordOrder = [];
currentRecordOrder = []; currentRecordOrder = [];
expandedSummaryRecords = new Set(); expandedSummaryRecords = new Set();
collapsedTeacherGroups = new Set();
activeCorrectionKey = ""; activeCorrectionKey = "";
updateCorrectionToolbar(); updateCorrectionToolbar();
} }
@@ -526,92 +442,6 @@ function closeDeleteDialog() {
setDeleteError(""); setDeleteError("");
} }
function setSummarySupplementError(message) {
summarySupplementError.textContent = message;
summarySupplementError.hidden = !message;
}
function buildSummarySupplementText(record, body) {
return [
`学生:${record.student}`,
`日期:${record.date}`,
`时间:${record.time}`,
`老师:${record.teacher}`,
`科目:${record.subject}`,
"",
body.trim(),
].join("\n");
}
function updateSummarySupplementPreview() {
const original = findCurrentRecord(activeSummarySupplementKey);
if (!original) return;
const body = summarySupplementBody.value.trim();
if (!body) {
summarySupplementPreview.textContent = "提交后会直接保存为这节课的课程小结。";
setSummarySupplementError("");
return;
}
summarySupplementPreview.textContent = buildSummarySupplementText(original, body);
setSummarySupplementError("");
}
function openSummarySupplementDialog(key) {
const original = findCurrentRecord(key);
if (!original) return;
activeSummarySupplementKey = key;
summarySupplementOriginal.textContent = `将补充课程小结:${buildRecordLine(original)}`;
summarySupplementBody.value = "";
summarySupplementPreview.textContent = "提交后会直接保存为这节课的课程小结。";
setSummarySupplementError("");
summarySupplementDialog.hidden = false;
summarySupplementBody.focus();
}
function closeSummarySupplementDialog() {
summarySupplementDialog.hidden = true;
activeSummarySupplementKey = "";
setSummarySupplementError("");
}
async function submitSummarySupplement(event) {
event.preventDefault();
const original = findCurrentRecord(activeSummarySupplementKey);
if (!original) return;
const body = summarySupplementBody.value.trim();
if (!body) {
setSummarySupplementError("课程小结正文不能为空");
return;
}
submitSummarySupplementBtn.disabled = true;
summarySupplementPreview.textContent = "正在提交";
try {
const key = activeSummarySupplementKey;
const data = await fetchJson("/api/course-summaries/supplement", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
record_id: original.record_id,
body,
}),
});
closeSummarySupplementDialog();
await loadHealth();
if (recordQuery.value.trim()) {
await queryRecords(recordQuery.value);
if (key) {
expandedSummaryRecords.add(key);
renderCurrentRecords();
}
}
} catch (error) {
setSummarySupplementError(`提交失败:${error.message}`);
summarySupplementPreview.textContent = "请修正后重新提交";
} finally {
submitSummarySupplementBtn.disabled = false;
}
}
async function submitDeleteRecord() { async function submitDeleteRecord() {
const original = findCurrentRecord(activeDeleteKey); const original = findCurrentRecord(activeDeleteKey);
if (!original) return; if (!original) return;
@@ -637,10 +467,8 @@ function saveCorrection() {
const corrected = buildCorrectedRecord(original); const corrected = buildCorrectedRecord(original);
if (buildRecordLine(corrected) === buildRecordLine(original)) { if (buildRecordLine(corrected) === buildRecordLine(original)) {
correctedRecords.delete(activeCorrectionKey); correctedRecords.delete(activeCorrectionKey);
correctedRecordOrder = correctedRecordOrder.filter((key) => key !== activeCorrectionKey);
} else { } else {
correctedRecords.set(activeCorrectionKey, corrected); correctedRecords.set(activeCorrectionKey, corrected);
if (!correctedRecordOrder.includes(activeCorrectionKey)) correctedRecordOrder.push(activeCorrectionKey);
} }
recordRows.innerHTML = renderGroupedRecords(currentRecords); recordRows.innerHTML = renderGroupedRecords(currentRecords);
closeCorrectionDialog(); closeCorrectionDialog();
@@ -673,7 +501,7 @@ async function copyTextToClipboard(text) {
} }
async function copyCorrectedRecords() { async function copyCorrectedRecords() {
const correctedInPageOrder = correctedRecordOrder const correctedInPageOrder = currentRecordOrder
.map((key) => correctedRecords.get(key)) .map((key) => correctedRecords.get(key))
.filter(Boolean); .filter(Boolean);
if (!correctedInPageOrder.length) return; if (!correctedInPageOrder.length) return;
@@ -687,7 +515,7 @@ async function copyCorrectedRecords() {
} }
async function submitCorrectedRecords() { async function submitCorrectedRecords() {
const items = correctedRecordOrder const items = currentRecordOrder
.map((key) => { .map((key) => {
const corrected = correctedRecords.get(key); const corrected = correctedRecords.get(key);
if (!corrected) return null; if (!corrected) return null;
@@ -735,71 +563,48 @@ async function fetchJson(url, options = {}) {
async function loadHealth() { async function loadHealth() {
try { try {
const data = await fetchJson("/api/health"); const data = await fetchJson("/api/health");
healthText.textContent = `数据更新时间 ${fmtTime(data.updated_at || (data.database || {}).mtime || 0)}`; healthText.textContent = `数据更新时间 ${fmtTime(data.classnotes.mtime)}`;
} catch (error) { } catch (error) {
healthText.textContent = `读取失败:${error.message}`; healthText.textContent = `读取失败:${error.message}`;
} }
} }
async function queryRecords(query, options = {}) { async function queryRecords(query) {
const q = query.trim(); const q = query.trim();
if (!q) return; if (!q) return;
recordRows.innerHTML = `<tr><td colspan="${RECORD_TABLE_COLUMN_COUNT}" class="empty">正在查询</td></tr>`; recordRows.innerHTML = `<tr><td colspan="7" class="empty">正在查询</td></tr>`;
recordMeta.innerHTML = ""; recordMeta.innerHTML = "";
if (recordPager) {
recordPager.hidden = true;
recordPager.innerHTML = "";
}
currentRecords = []; currentRecords = [];
if (options.reset !== false) { resetCorrections();
resetCorrections(); resetRecordSort();
resetRecordSort(); clearInlineAccount();
clearInlineStudentProfile();
} else {
currentRecordOrder = [];
expandedSummaryRecords = new Set();
collapsedTeacherGroups = new Set();
}
try { try {
const params = new URLSearchParams({ q, all: "1" }); const data = await fetchJson(`/api/records?q=${encodeURIComponent(q)}&limit=500`);
const data = await fetchJson(`/api/records?${params.toString()}`);
const summary = data.summary; const summary = data.summary;
currentRecordPage = {
query: q,
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),
};
recordMeta.innerHTML = [ recordMeta.innerHTML = [
metric("识别日期", data.query.date_range), metric("识别日期", data.query.date_range),
metric("命中记录", `${summary.count}`), metric("命中记录", `${summary.count}`),
metric("完整展示", pageRangeText(currentRecordPage)), metric("总课时", `${fmtHours(summary.total_hours)} 小时`),
metric("总课时", displayHours(summary.total_hours, summary.total_duration)),
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`), metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
].join(""); ].join("");
if (options.reset !== false && data.query.students.length === 1) { if (data.query.students.length === 1) {
await loadInlineStudentProfile(data.query.students[0]); await loadInlineAccount(data.query.students[0]);
} }
if (!data.records.length) { if (!data.records.length) {
recordRows.innerHTML = `<tr><td colspan="${RECORD_TABLE_COLUMN_COUNT}" class="empty">未找到符合条件的上课记录</td></tr>`; recordRows.innerHTML = `<tr><td colspan="7" class="empty">未找到符合条件的上课记录</td></tr>`;
return; return;
} }
currentRecords = data.records.map((row, index) => ({ currentRecords = data.records.map((row, index) => ({
...row, ...row,
_recordIndex: currentRecordPage.offset + index, _recordIndex: index,
_recordKey: makeRecordKey(row, currentRecordPage.offset + index), _recordKey: makeRecordKey(row, index),
})); }));
if (options.reset !== false) {
initializeTeacherGroupCollapse(currentRecords);
}
recordRows.innerHTML = renderGroupedRecords(currentRecords); recordRows.innerHTML = renderGroupedRecords(currentRecords);
} catch (error) { } catch (error) {
recordRows.innerHTML = `<tr><td colspan="${RECORD_TABLE_COLUMN_COUNT}" class="empty">查询失败:${escapeHtml(error.message)}</td></tr>`; recordRows.innerHTML = `<tr><td colspan="7" class="empty">查询失败:${escapeHtml(error.message)}</td></tr>`;
} }
} }
@@ -824,38 +629,18 @@ document.querySelectorAll("[data-query]").forEach((button) => {
}); });
recordRows.addEventListener("click", (event) => { recordRows.addEventListener("click", (event) => {
const summarySupplementButton = event.target.closest(".summary-supplement"); const summaryButton = event.target.closest(".summary-toggle");
const editButton = event.target.closest(".correction-edit"); const editButton = event.target.closest(".correction-edit");
const deleteButton = event.target.closest(".record-delete"); const deleteButton = event.target.closest(".record-delete");
const teacherToggle = event.target.closest(".teacher-group-toggle"); if (summaryButton) {
if (summarySupplementButton) { toggleSummaryRow(summaryButton.dataset.recordKey);
openSummarySupplementDialog(summarySupplementButton.dataset.recordKey);
return; return;
} }
if (editButton) { if (editButton) {
openCorrectionDialog(editButton.dataset.recordKey); openCorrectionDialog(editButton.dataset.recordKey);
return; return;
} }
if (deleteButton) { if (deleteButton) openDeleteDialog(deleteButton.dataset.recordKey);
openDeleteDialog(deleteButton.dataset.recordKey);
return;
}
if (teacherToggle) {
toggleTeacherGroup(teacherToggle.dataset.teacher);
return;
}
const row = event.target.closest(".record-row");
if (row && !event.target.closest(".record-action-cell")) toggleRecordSummary(row.dataset.recordKey);
});
recordRows.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const row = event.target.closest(".record-row");
if (!row) return;
const target = event.target.closest(".summary-supplement, .correction-edit, .record-delete");
if (target) return;
event.preventDefault();
toggleRecordSummary(row.dataset.recordKey);
}); });
[correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => { [correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => {
@@ -883,13 +668,6 @@ confirmDeleteBtn.addEventListener("click", submitDeleteRecord);
deleteDialog.addEventListener("click", (event) => { deleteDialog.addEventListener("click", (event) => {
if (event.target === deleteDialog) closeDeleteDialog(); if (event.target === deleteDialog) closeDeleteDialog();
}); });
closeSummarySupplementBtn.addEventListener("click", closeSummarySupplementDialog);
cancelSummarySupplementBtn.addEventListener("click", closeSummarySupplementDialog);
summarySupplementDialog.addEventListener("click", (event) => {
if (event.target === summarySupplementDialog) closeSummarySupplementDialog();
});
summarySupplementBody.addEventListener("input", updateSummarySupplementPreview);
summarySupplementForm.addEventListener("submit", submitSummarySupplement);
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !correctionDialog.hidden) { if (event.key === "Escape" && !correctionDialog.hidden) {
closeCorrectionDialog(); closeCorrectionDialog();
@@ -897,9 +675,6 @@ document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !deleteDialog.hidden) { if (event.key === "Escape" && !deleteDialog.hidden) {
closeDeleteDialog(); closeDeleteDialog();
} }
if (event.key === "Escape" && !summarySupplementDialog.hidden) {
closeSummarySupplementDialog();
}
}); });
copyCorrectedBtn.addEventListener("click", copyCorrectedRecords); copyCorrectedBtn.addEventListener("click", copyCorrectedRecords);
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>新时空教务管理系统</title> <title>新时空教务管理系统</title>
<link rel="stylesheet" href="/static/styles.css?v=20260706-sqlite-native" /> <link rel="stylesheet" href="/static/styles.css?v=20260615-record-summary-toggle" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -53,7 +53,6 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th class="num">序号</th>
<th> <th>
<button id="dateSortBtn" class="sort-header-button" type="button" aria-label="日期排序:升序排列,点击切换为降序排列">日期 升序排列</button> <button id="dateSortBtn" class="sort-header-button" type="button" aria-label="日期排序:升序排列,点击切换为降序排列">日期 升序排列</button>
</th> </th>
@@ -69,12 +68,11 @@
</thead> </thead>
<tbody id="recordRows"> <tbody id="recordRows">
<tr> <tr>
<td colspan="8" class="empty">输入查询条件后显示明细</td> <td colspan="7" class="empty">输入查询条件后显示明细</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div id="recordPager" class="pager" hidden></div>
</section> </section>
</main> </main>
@@ -132,7 +130,7 @@
<p id="deleteError" class="correction-error" hidden></p> <p id="deleteError" class="correction-error" hidden></p>
<div class="correction-preview"> <div class="correction-preview">
<span>审核说明</span> <span>审核说明</span>
<code>确认后会提交后台审核;审核通过才会删除数据库中的上课记录,并恢复对应学生课时。</code> <code>确认后会提交后台审核;审核通过才会删除 classnotes 中的记录,并恢复对应学生课时。</code>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button id="cancelDeleteBtn" class="secondary-button" type="button">取消</button> <button id="cancelDeleteBtn" class="secondary-button" type="button">取消</button>
@@ -142,31 +140,6 @@
</section> </section>
</div> </div>
<div id="summarySupplementDialog" class="modal-backdrop" hidden> <script src="/static/app.js?v=20260615-record-summary-toggle"></script>
<section class="correction-modal summary-supplement-modal" role="dialog" aria-modal="true" aria-labelledby="summarySupplementTitle">
<div class="modal-head">
<h2 id="summarySupplementTitle">补充课程小结</h2>
<button id="closeSummarySupplementBtn" class="modal-close" type="button" aria-label="关闭">关闭</button>
</div>
<form id="summarySupplementForm" class="correction-form">
<p id="summarySupplementOriginal" class="correction-original"></p>
<label class="summary-supplement-body">
小结正文
<textarea id="summarySupplementBody" rows="9" autocomplete="off" placeholder="填写本节课课堂内容、作业、问题和下节安排"></textarea>
</label>
<p id="summarySupplementError" class="correction-error" hidden></p>
<div class="correction-preview">
<span>提交说明</span>
<code id="summarySupplementPreview">提交后会直接保存为这节课的课程小结。</code>
</div>
<div class="modal-actions">
<button id="cancelSummarySupplementBtn" class="secondary-button" type="button">取消</button>
<button id="submitSummarySupplementBtn" type="submit">提交课程小结</button>
</div>
</form>
</section>
</div>
<script src="/static/app.js?v=20260706-sqlite-native"></script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
services:
xsk-education-management:
build:
context: .
args:
PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.12-slim}
container_name: xsk-education-management
restart: unless-stopped
env_file:
- .env
environment:
TZ: ${TZ:-Asia/Shanghai}
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"
volumes:
- ../data:/data
@@ -0,0 +1,26 @@
<?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>
@@ -115,7 +115,8 @@ def write_env(args: argparse.Namespace) -> Path:
f"PYTHON_IMAGE={args.python_image}", f"PYTHON_IMAGE={args.python_image}",
f"BASIC_AUTH_USERNAME={args.auth_user}", f"BASIC_AUTH_USERNAME={args.auth_user}",
f"BASIC_AUTH_PASSWORD={args.auth_password}", f"BASIC_AUTH_PASSWORD={args.auth_password}",
"SQLITE_DB_PATH=/data/xsk_education.db", "CLASSNOTES_PATH=/data/classnotes.txt",
"ACCOUNTS_PATH=/data/学生课时账户.md",
"", "",
] ]
) )
@@ -11,13 +11,17 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from app import repository # noqa: E402
from app.data import ( # noqa: E402 from app.data import ( # noqa: E402
append_operation_log,
course_summary_to_class_record_line, course_summary_to_class_record_line,
course_summary_semantic_key, course_summary_semantic_key,
create_course_summary_review_task,
normalize_course_summary, normalize_course_summary,
read_admin_tasks,
read_course_summary_state,
safe_filename_part, safe_filename_part,
sha1_text, sha1_text,
write_course_summary_state,
) )
@@ -37,13 +41,12 @@ EXCLUDE_PREFIXES = (
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="导入历史课程小结 Markdown 到 SQLite") parser = argparse.ArgumentParser(description="导入历史课程小结 Markdown 到 VPS 数据目录")
parser.add_argument("--source", required=True, type=Path, help="本机历史课程小结采集目录") parser.add_argument("--source", required=True, type=Path, help="本机历史课程小结采集目录")
parser.add_argument("--db-path", default=Path("/data/xsk_education.db"), type=Path, help="SQLite 数据库路径") parser.add_argument("--target", default=Path("/data/course_summaries"), type=Path, help="VPS 课程小结正式目录")
parser.add_argument("--target", type=Path, help=argparse.SUPPRESS) parser.add_argument("--state", default=Path("/data/course_summary_state.json"), type=Path, help="课程小结状态文件")
parser.add_argument("--state", type=Path, help=argparse.SUPPRESS) parser.add_argument("--tasks", default=Path("/data/admin_tasks.json"), type=Path, help="管理任务文件")
parser.add_argument("--tasks", type=Path, help=argparse.SUPPRESS) parser.add_argument("--operation-logs", default=Path("/data/operation_logs.jsonl"), type=Path, help="操作日志文件")
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("--missing-table", type=Path, help="历史 classnotes缺失.txt;不传则尝试 source/classnotes缺失.txt")
parser.add_argument("--dry-run", action="store_true", help="只统计,不写入") parser.add_argument("--dry-run", action="store_true", help="只统计,不写入")
return parser.parse_args() return parser.parse_args()
@@ -289,123 +292,30 @@ def import_missing_tasks(source: Path, missing_table: Path, tasks_path: Path, dr
return created 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: def main() -> None:
args = parse_args() args = parse_args()
source = args.source.resolve() source = args.source.resolve()
repository.SQLITE_DB_PATH = args.db_path.resolve() target = args.target.resolve()
missing_table = args.missing_table or (source / "classnotes缺失.txt") missing_table = args.missing_table or (source / "classnotes缺失.txt")
scanned, entries, skipped = collect_history_entries(source) scanned, copied = copy_markdown_files(source, target, args.dry_run)
missing_tasks = collect_missing_review_tasks(source, missing_table) imported, skipped = rebuild_state_from_markdown(target if target.exists() else source, args.state, args.dry_run)
batch_id = f"history-import-{datetime.now().strftime('%Y%m%d%H%M%S')}" review_tasks = import_missing_tasks(source, missing_table, args.tasks, args.dry_run)
if not args.dry_run: if not args.dry_run:
def work(conn, backup_id): append_operation_log(
imported = 0 args.operation_logs,
duplicates = 0 "history_course_summary_import",
for summary in entries: "completed",
saved = repository._save_course_summary(conn, summary) source=str(source),
repository._add_summary_seen(conn, summary["source_id"], course_summary_semantic_key(summary)) target=str(target),
imported += 1 if saved.get("added") else 0 scanned_files=scanned,
duplicates += 0 if saved.get("added") else 1 copied_files=copied,
created_tasks = 0 indexed_summaries=imported,
for summary, proposed_line in missing_tasks: skipped_summaries=skipped,
repository._create_course_summary_review_task( review_tasks=review_tasks,
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),
scanned_files=scanned,
indexed_summaries=imported,
skipped_summaries=skipped,
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( print(
f"历史小结导入完成:扫描 Markdown {scanned} 个," f"历史小结导入完成:扫描 Markdown {scanned} 个,复制 {copied} 个,"
f"写入/待写入小结 {imported} 条,重复 {duplicates} 条," f"索引小结 {imported} 条,跳过 {skipped} 条,生成审核任务 {review_tasks} 条。"
f"跳过 {skipped} 条,生成审核任务 {review_tasks} 条。"
) )
+64
View File
@@ -0,0 +1,64 @@
#!/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())
+105
View File
@@ -0,0 +1,105 @@
const fs = require("fs");
const vm = require("vm");
const elements = new Map();
function element(id) {
if (!elements.has(id)) {
elements.set(id, {
id,
textContent: "",
innerHTML: "",
hidden: false,
disabled: false,
value: "",
dataset: {},
classList: { toggle() {} },
setAttribute(name, value) {
this[name] = value;
},
addEventListener() {},
focus() {},
});
}
return elements.get(id);
}
const documentStub = {
querySelector(selector) {
return element(selector.replace(/^#/, ""));
},
querySelectorAll() {
return [];
},
addEventListener() {},
createElement(tag) {
return element(`created-${tag}-${elements.size}`);
},
body: { appendChild() {} },
execCommand() {
return true;
},
};
const context = {
console,
document: documentStub,
navigator: {},
fetch: async () => ({ ok: true, json: async () => ({ classnotes: { mtime: 0 } }) }),
Date,
JSON,
Map,
Number,
String,
};
function assertEqual(name, actual, expected) {
if (actual !== expected) {
throw new Error(`${name}: got ${actual}, expected ${expected}`);
}
}
function value(expression) {
return vm.runInContext(expression, context);
}
vm.createContext(context);
vm.runInContext(fs.readFileSync("app/static/app.js", "utf8"), context);
vm.runInContext(
`
currentRecords = [
{ date: "2026.06.12", weekday: "星期五", time: "10:00-11:00", student: "甲", duration: "1小时0分", duration_hours: 1, teacher: "王老师", subject: "英语", _recordIndex: 0, _recordKey: "a" },
{ date: "2026.06.12", weekday: "星期五", time: "08:00-09:00", student: "乙", duration: "1小时0分", duration_hours: 1, teacher: "王老师", subject: "英语", _recordIndex: 1, _recordKey: "b" },
{ date: "2026.06.13", weekday: "星期六", time: "09:00-10:00", student: "丙", duration: "1小时0分", duration_hours: 1, teacher: "王老师", subject: "英语", _recordIndex: 2, _recordKey: "c" },
{ date: "2026.06.12", weekday: "星期五", time: "07:00-08:00", student: "丁", duration: "1小时0分", duration_hours: 1, teacher: "李老师", subject: "数学", _recordIndex: 3, _recordKey: "d" },
];
renderGroupedRecords(currentRecords);
`,
context,
);
assertEqual("初始日期按钮文案", value("dateSortBtn.textContent"), "日期 升序排列");
assertEqual("初始时间按钮文案", value("timeSortBtn.textContent"), "时间 升序排列");
assertEqual("默认组内排序", value("currentRecordOrder.join('')"), "bacd");
vm.runInContext("toggleRecordSort('date')", context);
assertEqual("日期降序按钮文案", value("dateSortBtn.textContent"), "日期 降序排列");
assertEqual("日期降序组内排序", value("currentRecordOrder.join('')"), "cbad");
vm.runInContext("toggleRecordSort('date'); toggleRecordSort('time')", context);
assertEqual("时间降序按钮文案", value("timeSortBtn.textContent"), "时间 降序排列");
assertEqual("日期升序时间降序组内排序", value("currentRecordOrder.join('')"), "abcd");
vm.runInContext(
`
correctedRecords.set("a", { ...currentRecords[0], date: "2026.06.11", weekday: "星期四", time: "07:00-08:00" });
renderGroupedRecords(currentRecords);
`,
context,
);
assertEqual("纠错后排序仍使用显示记录", value("currentRecordOrder.join('')"), "abcd");
vm.runInContext("updateCorrectionToolbar('测试提交按钮')", context);
assertEqual("提交审核按钮文案", value("submitCorrectionsBtn.textContent"), "提交审核 1 条");
console.log("smoke test passed");
+204
View File
@@ -0,0 +1,204 @@
#!/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())