Compare commits
62 Commits
33e0c71970
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 73d7d766eb | |||
| 9884e91612 | |||
| bd5fd7815e | |||
| 124ffca03e | |||
| 93cd2e9c75 | |||
| 2fc32db0d4 | |||
| f9050fe40d | |||
| 3035094e22 | |||
| d6a4c95d72 | |||
| b169cb3d7a | |||
| 70a5af3020 | |||
| da368100db | |||
| 253c414e8f | |||
| 6d410efb5c | |||
| 4c10265db9 | |||
| 8ee33b6db9 | |||
| 77e31bd64a | |||
| fb1cfb1bfc | |||
| aa6f4a0329 | |||
| 59fcee72bb | |||
| e5e01b9dbb | |||
| 9d9500ed36 | |||
| 2c7dfad2b6 | |||
| 72184004e3 | |||
| 89d592b0bd | |||
| baa54def66 | |||
| 5163714853 | |||
| ccdcb83b60 | |||
| 3128c98d58 | |||
| 77b36a8dd8 | |||
| cc81f82139 | |||
| 25257b0a83 | |||
| 25f32cc44b | |||
| 5f6bf376b2 | |||
| 69644b4ae8 | |||
| 4875a0ee77 | |||
| 6c68eb7152 | |||
| 1a9235408c | |||
| c1d3d19dfc | |||
| a1a8466f6b | |||
| 0efb2acf89 | |||
| f9a35cbaa9 | |||
| 1682827150 | |||
| ea203c4195 | |||
| 0f854cc185 | |||
| 771220c9d6 | |||
| b3f857d8c0 | |||
| 158a476124 | |||
| c9d4ff88df | |||
| bf38afeb1f | |||
| c6edbcfa80 | |||
| b9468c7485 | |||
| e15c931e91 | |||
| f8af553dd3 | |||
| 21141786ff | |||
| 3d5db45131 | |||
| 5af80a456b | |||
| e83e742ff9 | |||
| 2f4a572d5f | |||
| d0f66c5e50 | |||
| af6cec50cf | |||
| a18ef4fc42 |
@@ -1,17 +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
|
|
||||||
|
|
||||||
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
|
|
||||||
+12
-4
@@ -1,14 +1,22 @@
|
|||||||
|
# Top-level runtime data and backups
|
||||||
|
data/
|
||||||
|
archives/
|
||||||
|
|
||||||
|
# Local env files
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
*.log
|
app/.env
|
||||||
__pycache__/
|
app/.env.*
|
||||||
|
!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/
|
||||||
data/
|
*.log
|
||||||
backups/
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,44 +1,46 @@
|
|||||||
SHELL := /bin/bash
|
SHELL := /bin/bash
|
||||||
|
|
||||||
APP_PORT ?= 18080
|
APP_DIR := app
|
||||||
DATA_FILES := ../data/classnotes.txt ../data/学生课时账户.md $(wildcard ../data/admin_tasks.json) $(wildcard ../data/course_summary_state.json)
|
APP_MAKE := $(MAKE) -C $(APP_DIR) --no-print-directory
|
||||||
|
COMPOSE := docker compose -f $(APP_DIR)/docker-compose.yml
|
||||||
|
|
||||||
.PHONY: check smoke data-hash build up ps health deploy logs install-gitea-backup
|
.PHONY: check smoke data-hash migrate-sqlite-dry-run migrate-sqlite build up ps health deploy logs install-gitea-backup compose-config
|
||||||
|
|
||||||
check:
|
check:
|
||||||
node --check app/static/app.js
|
$(APP_MAKE) check
|
||||||
node --check app/static/admin.js
|
|
||||||
python3 -m compileall -q app
|
|
||||||
|
|
||||||
smoke:
|
smoke:
|
||||||
node scripts/smoke_test.js
|
$(APP_MAKE) smoke
|
||||||
|
|
||||||
data-hash:
|
data-hash:
|
||||||
sha256sum $(DATA_FILES)
|
$(APP_MAKE) data-hash
|
||||||
|
|
||||||
|
migrate-sqlite-dry-run:
|
||||||
|
$(APP_MAKE) migrate-sqlite-dry-run
|
||||||
|
|
||||||
|
migrate-sqlite:
|
||||||
|
$(APP_MAKE) migrate-sqlite
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker compose build
|
$(APP_MAKE) build
|
||||||
|
|
||||||
up:
|
up:
|
||||||
docker compose up -d --remove-orphans
|
$(APP_MAKE) up
|
||||||
|
|
||||||
ps:
|
ps:
|
||||||
docker compose ps
|
$(APP_MAKE) ps
|
||||||
|
|
||||||
health:
|
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
|
$(APP_MAKE) health
|
||||||
|
|
||||||
deploy: check smoke
|
deploy:
|
||||||
$(MAKE) --no-print-directory data-hash
|
$(APP_MAKE) deploy
|
||||||
$(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:
|
||||||
docker compose logs --tail=120 xsk-education-management
|
$(APP_MAKE) logs
|
||||||
|
|
||||||
install-gitea-backup:
|
install-gitea-backup:
|
||||||
python3 scripts/install_gitea_backup_hook.py
|
$(APP_MAKE) install-gitea-backup
|
||||||
|
|
||||||
|
compose-config:
|
||||||
|
$(COMPOSE) config
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
data/
|
||||||
|
backups/
|
||||||
|
.DS_Store
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
新时空教务管理系统长期维护时,目标是每次更新都能做到可检查、可部署、可回滚,并且不误改业务数据。
|
新时空教务管理系统长期维护时,目标是每次更新都能做到可检查、可部署、可回滚,并且不误改业务数据。
|
||||||
|
|
||||||
|
以下命令默认在仓库根目录 `/root/新时空教务管理系统` 执行。根目录 `Makefile` 会转发到 `app/` 下的实际应用配置;不要在根目录直接运行裸 `docker compose`,需要直接调用时使用 `docker compose -f app/docker-compose.yml <命令>`。
|
||||||
|
|
||||||
## 日常更新流程
|
## 日常更新流程
|
||||||
|
|
||||||
1. 查看当前改动:
|
1. 查看当前改动:
|
||||||
@@ -39,7 +41,7 @@
|
|||||||
|
|
||||||
- `make check`:检查前端 JS 语法和后端 Python 编译。
|
- `make check`:检查前端 JS 语法和后端 Python 编译。
|
||||||
- `make smoke`:运行轻量前端行为烟测,覆盖排序按钮、组内排序和纠错后排序。
|
- `make smoke`:运行轻量前端行为烟测,覆盖排序按钮、组内排序和纠错后排序。
|
||||||
- `make data-hash`:输出 `classnotes.txt`、`学生课时账户.md`,以及存在时的 `admin_tasks.json`、`course_summary_state.json` 的 SHA-256。
|
- `make data-hash`:输出 SQLite 数据库、迁移报告和归档校验文件的 SHA-256。
|
||||||
- `make build`:构建 Docker 镜像。
|
- `make build`:构建 Docker 镜像。
|
||||||
- `make up`:重启 Docker Compose 服务。
|
- `make up`:重启 Docker Compose 服务。
|
||||||
- `make health`:带认证访问 `/api/health`。
|
- `make health`:带认证访问 `/api/health`。
|
||||||
@@ -47,6 +49,33 @@
|
|||||||
- `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 在每次提交后自动推送当前分支。
|
||||||
@@ -75,15 +104,10 @@ git push origin HEAD:<当前分支>
|
|||||||
业务数据文件位于:
|
业务数据文件位于:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/root/新时空教务管理系统/data/classnotes.txt
|
/root/新时空教务管理系统/data/xsk_education.db
|
||||||
/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/
|
||||||
@@ -119,8 +143,7 @@ git push origin HEAD:<当前分支>
|
|||||||
|
|
||||||
## 课程小结迁移维护
|
## 课程小结迁移维护
|
||||||
|
|
||||||
- VPS 是 `classnotes.txt` 和 `学生课时账户.md` 的唯一正式写入方。
|
- VPS 的 `/data/xsk_education.db` 是唯一正式业务数据源。
|
||||||
- 本机 `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` 只生成审核任务,不自动扣课时。
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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
|
||||||
+47
-68
@@ -1,6 +1,6 @@
|
|||||||
# 新时空教务管理系统
|
# 新时空教务管理系统
|
||||||
|
|
||||||
这是一个面向新时空教务业务的综合管理系统。迁移后 VPS 是正式业务数据主机,负责保存 `classnotes.txt`、`学生课时账户.md`、课程小结库、审核任务和操作记录;本机只保留微信群聊天记录采集/识别,并把课程小结批量推送到 VPS。
|
这是一个面向新时空教务业务的综合管理系统。迁移后 VPS 是正式业务数据主机,负责保存 SQLite 数据库 `/data/xsk_education.db`;本机只保留微信群聊天记录采集/识别,并把课程小结批量推送到 VPS。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
@@ -8,17 +8,31 @@
|
|||||||
- `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 模型。
|
||||||
@@ -53,27 +67,32 @@ http://121.199.172.246:18080/
|
|||||||
XSK_PYTHON_IMAGE='python:3.12-slim'
|
XSK_PYTHON_IMAGE='python:3.12-slim'
|
||||||
```
|
```
|
||||||
|
|
||||||
## 手动同步数据
|
## SQLite 迁移
|
||||||
|
|
||||||
迁移完成后不要再用本机 `classnotes.txt` 和 `学生课时账户.md` 覆盖 VPS。下面命令只保留给迁移前或灾难恢复时使用,日常新增课程小结应走 `POST /api/ingest/course-summaries`。
|
首次迁移先 dry-run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
XSK_USE_SSHPASS=1 \
|
cd /root/新时空教务管理系统
|
||||||
XSK_SSH_PASSWORD='填写SSH密码' \
|
make migrate-sqlite-dry-run
|
||||||
python3 scripts/sync_to_vps.py --once --use-sshpass
|
|
||||||
```
|
```
|
||||||
|
|
||||||
同步文件:
|
确认严格校验通过后执行正式迁移:
|
||||||
|
|
||||||
- `/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户/classnotes.txt`
|
```bash
|
||||||
- `/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户/学生课时账户.md`
|
make migrate-sqlite
|
||||||
|
```
|
||||||
|
|
||||||
远端数据目录:
|
正式迁移会生成:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/root/新时空教务管理系统/data/
|
/root/新时空教务管理系统/data/xsk_education.db
|
||||||
|
/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 接收接口:
|
||||||
@@ -100,31 +119,19 @@ python3 /Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录
|
|||||||
|
|
||||||
## 历史小结导入
|
## 历史小结导入
|
||||||
|
|
||||||
把本机历史课程小结目录同步或上传到 VPS 后,可在容器内执行一次性导入。历史导入只重建小结库和状态;历史 `classnotes缺失.txt` 默认转为审核任务,不自动扣课时。
|
把本机历史课程小结目录同步或上传到 VPS 后,可在容器内执行一次性导入。历史导入直接写入 SQLite 课程小结表;历史 `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/课程小结采集 \
|
||||||
--target /data/course_summaries \
|
--db-path /data/xsk_education.db \
|
||||||
--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、课程小结自动入账或审核批准修改正式课时数据时,服务会在写入前自动备份本次会改动的业务文件。正式数据和辅助状态位于:
|
通过登记 API、课程小结自动入账或审核批准修改正式课时数据时,服务会以 SQLite 事务写入 `/data/xsk_education.db`。旧纯文本事实源已封存为归档包,不再作为运行时缓存参与读写。
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
备份目录位于:
|
备份目录位于:
|
||||||
|
|
||||||
@@ -132,7 +139,7 @@ docker compose exec xsk-education-management python scripts/import_course_summar
|
|||||||
/root/新时空教务管理系统/data/backups/
|
/root/新时空教务管理系统/data/backups/
|
||||||
```
|
```
|
||||||
|
|
||||||
每次登记生成一个事务备份目录,目录内包含变更前的业务文件副本和 `metadata.json`。系统自动保留最近 50 次备份,超过后删除最旧备份。
|
每次写业务数据前生成一个 SQLite 快照备份目录,目录内包含变更前的数据库快照和 `metadata.json`。系统自动保留最近 50 次备份,超过后删除最旧备份。
|
||||||
|
|
||||||
查看备份:
|
查看备份:
|
||||||
|
|
||||||
@@ -140,44 +147,15 @@ 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/<备份目录>/classnotes.txt /root/新时空教务管理系统/data/classnotes.txt 2>/dev/null || true
|
cp /root/新时空教务管理系统/data/backups/<备份目录>/xsk_education.db /root/新时空教务管理系统/data/xsk_education.db
|
||||||
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`,然后重启:
|
||||||
@@ -193,12 +171,13 @@ docker compose up -d
|
|||||||
|
|
||||||
- `GET /api/health`:数据状态。
|
- `GET /api/health`:数据状态。
|
||||||
- `GET /api/records?q=王鑫鹏5月数学课`:自然语言查询上课记录。
|
- `GET /api/records?q=王鑫鹏5月数学课`:自然语言查询上课记录。
|
||||||
- `GET /api/accounts`:课时账户列表。
|
- `GET /api/students`:学生档案列表。
|
||||||
- `GET /api/accounts?q=王鑫鹏`:按学生筛选账户。
|
- `GET /api/students?q=王鑫鹏`:按学生姓名或学生ID筛选学生档案。
|
||||||
- `GET /api/accounts?status=欠费`:按账户状态筛选。
|
- `GET /api/students?status=欠费`:按档案状态筛选。
|
||||||
- `GET /api/accounts/王鑫鹏`:单个学生账户。
|
- `GET /api/students/王鑫鹏`:单个学生档案。
|
||||||
- `POST /api/admin/accounts`:管理后台新增课时账户。
|
- `POST /api/admin/students`:管理后台新增学生档案。
|
||||||
- `PUT /api/admin/accounts/{student_id}`:管理后台修改课时账户。
|
- `PUT /api/admin/students/{student_id}`:管理后台修改学生档案。
|
||||||
|
- 学生档案的剩余课时由系统按缴费记录和上课记录自动计算,管理接口不会接受人工修改余额。
|
||||||
- `GET /api/admin/teachers`:管理后台读取老师档案。
|
- `GET /api/admin/teachers`:管理后台读取老师档案。
|
||||||
- `POST /api/admin/teachers`:管理后台新增老师档案。
|
- `POST /api/admin/teachers`:管理后台新增老师档案。
|
||||||
- `PUT /api/admin/teachers/{teacher_id}`:管理后台修改老师档案。
|
- `PUT /api/admin/teachers/{teacher_id}`:管理后台修改老师档案。
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
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),
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ from pathlib import Path
|
|||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from .config import ACCOUNTS_PATH, CLASSNOTES_PATH, TEACHERS_PATH
|
from .data import Account, Payment, Teacher
|
||||||
from .data import Account, Payment, Teacher, read_accounts, read_classnotes, read_teachers
|
from .repository import load_records as load_records_from_db
|
||||||
from .schemas import AccountPayload, RegisterLinesPayload, TeacherPayload
|
from .repository import load_student_profiles as load_student_profiles_from_db
|
||||||
|
from .repository import load_teachers as load_teachers_from_db
|
||||||
|
from .schemas import RegisterLinesPayload, StudentProfilePayload, TeacherPayload
|
||||||
|
|
||||||
|
|
||||||
async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
||||||
@@ -38,21 +40,24 @@ async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
|||||||
|
|
||||||
|
|
||||||
def load_records():
|
def load_records():
|
||||||
if not CLASSNOTES_PATH.exists():
|
try:
|
||||||
raise HTTPException(status_code=503, detail=f"课程记录文件不存在: {CLASSNOTES_PATH}")
|
return load_records_from_db()
|
||||||
return read_classnotes(CLASSNOTES_PATH)
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
def load_accounts():
|
def load_student_profiles():
|
||||||
if not ACCOUNTS_PATH.exists():
|
try:
|
||||||
raise HTTPException(status_code=503, detail=f"课时账户文件不存在: {ACCOUNTS_PATH}")
|
return load_student_profiles_from_db()
|
||||||
return read_accounts(ACCOUNTS_PATH)
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
def load_teachers():
|
def load_teachers():
|
||||||
if not TEACHERS_PATH.exists():
|
try:
|
||||||
return []
|
return load_teachers_from_db()
|
||||||
return read_teachers(TEACHERS_PATH)
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
def file_meta(path: Path) -> dict:
|
def file_meta(path: Path) -> dict:
|
||||||
@@ -67,13 +72,14 @@ def file_meta(path: Path) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def payload_to_account(payload: AccountPayload, student_id: str | None = None) -> Account:
|
def payload_to_student_profile(payload: StudentProfilePayload, student_id: str | None = None) -> Account:
|
||||||
return Account(
|
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=payload.remaining,
|
remaining=0,
|
||||||
account_status=payload.account_status,
|
account_status=payload.account_status,
|
||||||
|
primary_entry_year=payload.primary_entry_year,
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -77,13 +77,6 @@ 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),
|
||||||
@@ -110,7 +103,7 @@ def verify_admin_auth(
|
|||||||
return "admin"
|
return "admin"
|
||||||
|
|
||||||
|
|
||||||
def verify_accounts_auth(
|
def verify_student_profiles_auth(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -8,13 +8,8 @@ import threading
|
|||||||
APP_DIR = Path(__file__).resolve().parent
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
STATIC_DIR = APP_DIR / "static"
|
STATIC_DIR = APP_DIR / "static"
|
||||||
|
|
||||||
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
|
SQLITE_DB_PATH = Path(os.getenv("SQLITE_DB_PATH", "/data/xsk_education.db"))
|
||||||
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md"))
|
LEGACY_TEXT_ROOT = Path(os.getenv("LEGACY_TEXT_ROOT", "/data"))
|
||||||
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", "")
|
||||||
+4880
File diff suppressed because it is too large
Load Diff
+227
@@ -0,0 +1,227 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
@@ -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", "model", "model_high_confidence", "manual_admin", "大模型高置信识别", "关键词"}
|
AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "manual_admin", "关键词"}
|
||||||
ROLE_WORDS = {
|
ROLE_WORDS = {
|
||||||
"student": ("学生", "学员", "孩子", "同学"),
|
"student": ("学生", "学员", "孩子", "同学"),
|
||||||
"teacher": ("老师", "教师"),
|
"teacher": ("老师", "教师"),
|
||||||
@@ -49,6 +49,7 @@ class Account:
|
|||||||
payments: list[Payment]
|
payments: list[Payment]
|
||||||
remaining: float
|
remaining: float
|
||||||
account_status: str
|
account_status: str
|
||||||
|
primary_entry_year: int | None
|
||||||
note: str
|
note: str
|
||||||
|
|
||||||
|
|
||||||
@@ -3,12 +3,18 @@ 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 .routers import accounts, admin, health, ingest, pages, records
|
from .repository import initialize_runtime_database
|
||||||
|
from .routers import admin, ai_register, health, ingest, pages, records, student_profiles
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="新时空教务管理系统", version="1.0.0")
|
app = 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)})
|
||||||
@@ -17,6 +23,7 @@ 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(accounts.router)
|
app.include_router(student_profiles.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)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
|||||||
|
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}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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}
|
||||||
@@ -277,11 +277,6 @@ def logout():
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts")
|
|
||||||
def accounts_index():
|
|
||||||
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin")
|
@router.get("/admin")
|
||||||
def admin_index(
|
def admin_index(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -333,24 +328,6 @@ def admin_logout():
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts/login")
|
|
||||||
def accounts_login_page():
|
|
||||||
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/accounts/login")
|
|
||||||
async def accounts_login_submit(request: Request):
|
|
||||||
return await admin_login_submit(request)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts/logout")
|
|
||||||
def accounts_logout():
|
|
||||||
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
|
|
||||||
response.delete_cookie(ADMIN_SESSION_COOKIE)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/static/{asset_path:path}")
|
@router.get("/static/{asset_path:path}")
|
||||||
def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)):
|
def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)):
|
||||||
target = (STATIC_DIR / asset_path).resolve()
|
target = (STATIC_DIR / asset_path).resolve()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
|
||||||
|
from ..api_utils import payload_to_student_profile, payload_to_teacher, read_register_payload
|
||||||
|
from ..auth import verify_admin_auth, verify_any_auth, verify_student_profiles_auth
|
||||||
|
from ..data import ACCOUNT_STATUSES, TEACHER_STATUSES
|
||||||
|
from ..repository import (
|
||||||
|
create_student_profile,
|
||||||
|
create_teacher,
|
||||||
|
health_payload,
|
||||||
|
register_class_record_lines,
|
||||||
|
register_course_summary_texts,
|
||||||
|
register_payment_lines,
|
||||||
|
student_detail_payload,
|
||||||
|
students_payload,
|
||||||
|
teachers_payload,
|
||||||
|
update_student_profile,
|
||||||
|
update_teacher,
|
||||||
|
)
|
||||||
|
from ..schemas import StudentProfilePayload, TeacherPayload
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/register/class-records")
|
||||||
|
async def register_class_records(request: Request, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
payload = await read_register_payload(request)
|
||||||
|
result = register_class_record_lines(lines=payload.lines, line=payload.line)
|
||||||
|
except ValueError as exc:
|
||||||
|
status_code = 409 if exc.__class__.__name__ == "DuplicateRecordError" else 400
|
||||||
|
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/register/payments")
|
||||||
|
async def register_payments(request: Request, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
payload = await read_register_payload(request)
|
||||||
|
result = register_payment_lines(lines=payload.lines, line=payload.line)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/register/course-summaries")
|
||||||
|
async def register_course_summaries(request: Request, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
payload = await read_register_payload(request)
|
||||||
|
result = register_course_summary_texts(lines=payload.lines, line=payload.line)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/student-health")
|
||||||
|
def student_health(_user: str = Depends(verify_student_profiles_auth)):
|
||||||
|
return health_payload()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/students")
|
||||||
|
def students(
|
||||||
|
q: str = Query("", description="学生姓名或学生ID"),
|
||||||
|
status_filter: str = Query("", alias="status", description="学生档案状态"),
|
||||||
|
_user: str = Depends(verify_student_profiles_auth),
|
||||||
|
):
|
||||||
|
return students_payload(q=q, status_filter=status_filter)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/students/{student}")
|
||||||
|
def student_detail(student: str, _user: str = Depends(verify_any_auth)):
|
||||||
|
try:
|
||||||
|
return student_detail_payload(student)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/statuses")
|
||||||
|
def admin_statuses(_user: str = Depends(verify_admin_auth)):
|
||||||
|
return {"account_statuses": sorted(ACCOUNT_STATUSES)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/teacher-statuses")
|
||||||
|
def admin_teacher_statuses(_user: str = Depends(verify_admin_auth)):
|
||||||
|
return {"teacher_statuses": sorted(TEACHER_STATUSES)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/teachers")
|
||||||
|
def admin_teachers(_user: str = Depends(verify_admin_auth)):
|
||||||
|
return teachers_payload()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/admin/teachers")
|
||||||
|
def admin_create_teacher(payload: TeacherPayload, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
result = create_teacher(payload_to_teacher(payload))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/admin/teachers/{teacher_id}")
|
||||||
|
def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
result = update_teacher(teacher_id, payload_to_teacher(payload, teacher_id=payload.teacher_id.strip() or teacher_id))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/admin/students")
|
||||||
|
def admin_create_student(payload: StudentProfilePayload, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
result = create_student_profile(payload_to_student_profile(payload))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/admin/students/{student_id}")
|
||||||
|
def admin_update_student(student_id: str, payload: StudentProfilePayload, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
result = update_student_profile(student_id, payload_to_student_profile(payload))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
@@ -8,17 +8,25 @@ 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 AccountPayload(BaseModel):
|
class StudentProfilePayload(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 = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -52,9 +60,14 @@ 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 = ""
|
||||||
@@ -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=20260615-summary-time-review" />
|
<link rel="stylesheet" href="/static/styles.css?v=20260706-sqlite-native" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -21,20 +21,36 @@
|
|||||||
|
|
||||||
<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="accounts" type="button">课时账户</button>
|
<button class="admin-tab is-active" data-admin-tab="dashboard" 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="summaries" type="button">课程小结审核</button>
|
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结</button>
|
||||||
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结查询</button>
|
|
||||||
<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="accountsPanel" class="panel admin-panel">
|
<section id="dashboardPanel" 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="accountStatus" aria-label="账户状态筛选">
|
<select id="studentStatus" aria-label="学生档案状态筛选">
|
||||||
<option value="">全部状态</option>
|
<option value="">全部状态</option>
|
||||||
<option value="欠费">欠费</option>
|
<option value="欠费">欠费</option>
|
||||||
<option value="预警">预警</option>
|
<option value="预警">预警</option>
|
||||||
@@ -42,20 +58,20 @@
|
|||||||
<option value="结课">结课</option>
|
<option value="结课">结课</option>
|
||||||
<option value="退费">退费</option>
|
<option value="退费">退费</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="newAccountBtn" class="chip" type="button">新增账户</button>
|
<button id="newStudentBtn" class="chip" type="button">新增学生档案</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form id="accountForm" class="search-row account-search-row">
|
<form id="studentForm" class="search-row account-search-row">
|
||||||
<input id="accountQuery" autocomplete="off" placeholder="学生姓名或学生ID" />
|
<input id="studentQuery" autocomplete="off" placeholder="学生姓名或学生ID" />
|
||||||
<button type="submit">查询</button>
|
<button type="submit">查询</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="accountMeta" class="summary-grid"></div>
|
<div id="studentMeta" class="summary-grid"></div>
|
||||||
<div id="accountEditor" class="admin-editor" hidden>
|
<div id="studentEditor" class="admin-editor" hidden>
|
||||||
<div class="section-head compact-head">
|
<div class="section-head compact-head">
|
||||||
<h2 id="accountEditorTitle">新增账户</h2>
|
<h2 id="studentEditorTitle">新增学生档案</h2>
|
||||||
<button id="cancelAccountEditBtn" class="secondary-button" type="button">取消</button>
|
<button id="cancelStudentEditBtn" class="secondary-button" type="button">取消</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="accountEditForm" class="admin-form">
|
<form id="studentEditForm" class="admin-form">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>
|
<label>
|
||||||
学生ID
|
学生ID
|
||||||
@@ -66,11 +82,15 @@
|
|||||||
<input id="editStudent" autocomplete="off" required />
|
<input id="editStudent" autocomplete="off" required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
剩余课时
|
入学年份
|
||||||
<input id="editRemaining" inputmode="decimal" required />
|
<input id="editPrimaryEntryYear" autocomplete="off" inputmode="numeric" placeholder="小学一年级" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
账户状态
|
剩余课时
|
||||||
|
<input id="editRemaining" readonly value="系统自动计算" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
档案状态
|
||||||
<select id="editStatus">
|
<select id="editStatus">
|
||||||
<option value="正常">正常</option>
|
<option value="正常">正常</option>
|
||||||
<option value="预警">预警</option>
|
<option value="预警">预警</option>
|
||||||
@@ -88,9 +108,9 @@
|
|||||||
<textarea id="editNote" rows="3"></textarea>
|
<textarea id="editNote" rows="3"></textarea>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p id="accountEditError" class="correction-error" hidden></p>
|
<p id="studentEditError" class="correction-error" hidden></p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="submit">保存账户</button>
|
<button type="submit">保存档案</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,6 +119,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>学生</th>
|
<th>学生</th>
|
||||||
|
<th>入学年份</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th class="num">剩余课时</th>
|
<th class="num">剩余课时</th>
|
||||||
<th>缴费记录</th>
|
<th>缴费记录</th>
|
||||||
@@ -106,7 +127,7 @@
|
|||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="accountRows"></tbody>
|
<tbody id="studentRows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -214,36 +235,7 @@
|
|||||||
<tbody id="reviewRows"></tbody>
|
<tbody id="reviewRows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div id="reviewPager" class="pager" hidden></div>
|
||||||
|
|
||||||
<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>
|
||||||
@@ -264,6 +256,32 @@
|
|||||||
<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>
|
||||||
@@ -272,6 +290,10 @@
|
|||||||
<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>
|
||||||
@@ -282,6 +304,8 @@
|
|||||||
</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>
|
||||||
@@ -290,7 +314,11 @@
|
|||||||
|
|
||||||
<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="关键词:课堂内容、作业、问题等" />
|
||||||
@@ -299,9 +327,17 @@
|
|||||||
<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="summarySearchMissingTime" aria-label="时间状态筛选">
|
<select id="summarySearchBindingStatus" aria-label="绑定状态筛选">
|
||||||
<option value="">全部时间</option>
|
<option value="">全部绑定状态</option>
|
||||||
<option value="1">缺少时间</option>
|
<option value="matched">已绑定</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>
|
||||||
@@ -314,14 +350,45 @@
|
|||||||
<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>
|
||||||
@@ -330,17 +397,37 @@
|
|||||||
<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="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>
|
||||||
|
<option value="撤回操作">撤回操作</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="logStatus" aria-label="操作结果筛选">
|
<select id="logStatus" aria-label="操作结果筛选">
|
||||||
<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>
|
||||||
|
<option value="已撤回">已撤回</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,12 +445,13 @@
|
|||||||
<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>
|
||||||
@@ -374,32 +462,37 @@
|
|||||||
<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>
|
||||||
<div id="summaryRegisterItems" class="summary-register-items"></div>
|
<textarea id="summaryRegisterText" rows="12" placeholder="粘贴一条或多条课程小结原文,学生/日期/时间/老师/科目由系统自动提取"></textarea>
|
||||||
<button id="addSummaryRegisterItemBtn" class="secondary-button" type="button">再填一条</button>
|
<div id="summaryRegisterPreview" class="register-preview" hidden></div>
|
||||||
<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=20260615-summary-time-review"></script>
|
<script src="/static/admin.js?v=20260706-sqlite-native"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ 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");
|
||||||
@@ -29,20 +30,41 @@ 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) {
|
||||||
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
const totalMinutes = Math.round(Number(value || 0) * 60);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return `${hours}小时${minutes}分`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayHours(value, fallback) {
|
||||||
|
return fallback || fmtHours(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(seconds) {
|
function fmtTime(seconds) {
|
||||||
@@ -63,6 +85,17 @@ 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]);
|
||||||
}
|
}
|
||||||
@@ -89,6 +122,13 @@ function groupRecordsByTeacher(records) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initializeTeacherGroupCollapse(records) {
|
||||||
|
const groups = groupRecordsByTeacher(records);
|
||||||
|
collapsedTeacherGroups = groups.length > 1
|
||||||
|
? new Set(groups.map((group) => group.teacher))
|
||||||
|
: new Set();
|
||||||
|
}
|
||||||
|
|
||||||
function getDisplayRecord(row) {
|
function getDisplayRecord(row) {
|
||||||
return correctedRecords.get(row._recordKey) || row;
|
return correctedRecords.get(row._recordKey) || row;
|
||||||
}
|
}
|
||||||
@@ -176,25 +216,25 @@ function toggleRecordSort(field) {
|
|||||||
renderCurrentRecords();
|
renderCurrentRecords();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRecordRow(row) {
|
function renderRecordRow(row, rowNumber) {
|
||||||
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}">
|
return `<tr class="record-row${correctedClass}${summaryClass}" data-record-key="${escapeHtml(key)}" tabindex="0" role="button" aria-expanded="${summaryExpanded ? "true" : "false"}">
|
||||||
<td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
|
<td class="num record-index-cell" data-label="序号">${escapeHtml(rowNumber)}</td>
|
||||||
<td>${escapeHtml(displayRow.time)}</td>
|
<td data-label="日期">${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
|
||||||
<td>${escapeHtml(displayRow.student)}</td>
|
<td data-label="时间">${escapeHtml(displayRow.time)}</td>
|
||||||
<td>${escapeHtml(displayRow.teacher)}</td>
|
<td data-label="学生">${escapeHtml(displayRow.student)}</td>
|
||||||
<td>${escapeHtml(displayRow.subject)}</td>
|
<td data-label="老师">${escapeHtml(displayRow.teacher)}</td>
|
||||||
<td class="num">${escapeHtml(displayRow.duration)}</td>
|
<td data-label="科目">${escapeHtml(displayRow.subject)}</td>
|
||||||
<td class="record-action-cell">
|
<td class="num" data-label="时长">${escapeHtml(displayRow.duration)}</td>
|
||||||
|
<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}
|
||||||
@@ -206,35 +246,61 @@ function renderRecordRow(row) {
|
|||||||
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>
|
||||||
<div class="summary-body">${escapeHtml(summary.body || "暂无正文")}</div>
|
${meta ? `<div class="record-summary-meta">${escapeHtml(meta)}</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) => `<tr class="teacher-group">
|
(group) => {
|
||||||
<td colspan="7">
|
let displayIndex = 0;
|
||||||
<div class="teacher-group-title">
|
const collapsed = collapsedTeacherGroups.has(group.teacher);
|
||||||
|
const rows = collapsed
|
||||||
|
? ""
|
||||||
|
: sortRecordsForDisplay(group.records)
|
||||||
|
.map((row) => {
|
||||||
|
displayIndex += 1;
|
||||||
|
currentRecordOrder.push(row._recordKey);
|
||||||
|
const summaryCount = Number(row.summary_count || 0);
|
||||||
|
const summaryRow = summaryCount
|
||||||
|
? `<tr class="summary-collapse-row" data-summary-row="${escapeHtml(row._recordKey)}" ${expandedSummaryRecords.has(row._recordKey) ? "" : "hidden"}>
|
||||||
|
<td colspan="${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} 条记录 · ${fmtHours(group.totalHours)} 小时</span>
|
<span>${group.count} 条记录 · ${displayHours(group.totalHours)} · ${collapsed ? "展开" : "收起"}</span>
|
||||||
</div>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>${sortRecordsForDisplay(group.records)
|
</tr>${rows}`;
|
||||||
.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("");
|
||||||
}
|
}
|
||||||
@@ -248,6 +314,19 @@ 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";
|
||||||
@@ -257,38 +336,41 @@ function statusClass(status) {
|
|||||||
|
|
||||||
function renderPayments(payments) {
|
function renderPayments(payments) {
|
||||||
if (!payments.length) return "暂无缴费记录";
|
if (!payments.length) return "暂无缴费记录";
|
||||||
return payments.map((item) => `${item.date}:${fmtHours(item.hours)} 小时`).join(",");
|
return payments
|
||||||
|
.map((item) => `<span class="payment-line">${escapeHtml(item.date)}:${escapeHtml(displayHours(item.hours, item.duration))}</span>`)
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderInlineAccount(account) {
|
function renderInlineStudentProfile(studentProfile) {
|
||||||
inlineAccount.hidden = false;
|
inlineAccount.hidden = false;
|
||||||
inlineAccount.innerHTML = `<div class="inline-account-head">
|
inlineAccount.innerHTML = `<div class="inline-account-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>${escapeHtml(account.student)} 课时账户</h3>
|
<h3>${escapeHtml(studentProfile.student)} 学生档案</h3>
|
||||||
<p>${escapeHtml(account.student_id)}</p>
|
<p>${escapeHtml(studentProfile.student_id)}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="status ${statusClass(account.account_status)}">${escapeHtml(account.account_status)}</span>
|
<span class="status ${statusClass(studentProfile.account_status)}">${escapeHtml(studentProfile.account_status)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-account-grid">
|
<div class="inline-account-grid">
|
||||||
${metric("剩余课时", fmtHours(account.remaining))}
|
${metric("剩余课时", displayHours(studentProfile.remaining, studentProfile.remaining_duration))}
|
||||||
${metric("缴费次数", account.payments_count)}
|
${metric("入学年份", studentProfile.primary_entry_year || "未填")}
|
||||||
${metric("缴费记录", renderPayments(account.payments))}
|
${metric("缴费次数", studentProfile.payments_count)}
|
||||||
${metric("备注", account.note || "无")}
|
${metricHtml("缴费记录", renderPayments(studentProfile.payments))}
|
||||||
|
${metric("备注", studentProfile.note || "无")}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadInlineAccount(student) {
|
async function loadInlineStudentProfile(student) {
|
||||||
inlineAccount.hidden = false;
|
inlineAccount.hidden = false;
|
||||||
inlineAccount.innerHTML = `<div class="inline-account-loading">正在读取 ${escapeHtml(student)} 的课时账户</div>`;
|
inlineAccount.innerHTML = `<div class="inline-account-loading">正在读取 ${escapeHtml(student)} 的学生档案</div>`;
|
||||||
try {
|
try {
|
||||||
const account = await fetchJson(`/api/student-account/${encodeURIComponent(student)}`);
|
const studentProfile = await fetchJson(`/api/students/${encodeURIComponent(student)}`);
|
||||||
renderInlineAccount(account);
|
renderInlineStudentProfile(studentProfile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
inlineAccount.innerHTML = `<div class="inline-account-loading">课时账户读取失败:${escapeHtml(error.message)}</div>`;
|
inlineAccount.innerHTML = `<div class="inline-account-loading">学生档案读取失败:${escapeHtml(error.message)}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearInlineAccount() {
|
function clearInlineStudentProfile() {
|
||||||
inlineAccount.hidden = true;
|
inlineAccount.hidden = true;
|
||||||
inlineAccount.innerHTML = "";
|
inlineAccount.innerHTML = "";
|
||||||
}
|
}
|
||||||
@@ -306,8 +388,10 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -442,6 +526,92 @@ 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;
|
||||||
@@ -467,8 +637,10 @@ 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();
|
||||||
@@ -501,7 +673,7 @@ async function copyTextToClipboard(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyCorrectedRecords() {
|
async function copyCorrectedRecords() {
|
||||||
const correctedInPageOrder = currentRecordOrder
|
const correctedInPageOrder = correctedRecordOrder
|
||||||
.map((key) => correctedRecords.get(key))
|
.map((key) => correctedRecords.get(key))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (!correctedInPageOrder.length) return;
|
if (!correctedInPageOrder.length) return;
|
||||||
@@ -515,7 +687,7 @@ async function copyCorrectedRecords() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitCorrectedRecords() {
|
async function submitCorrectedRecords() {
|
||||||
const items = currentRecordOrder
|
const items = correctedRecordOrder
|
||||||
.map((key) => {
|
.map((key) => {
|
||||||
const corrected = correctedRecords.get(key);
|
const corrected = correctedRecords.get(key);
|
||||||
if (!corrected) return null;
|
if (!corrected) return null;
|
||||||
@@ -563,48 +735,71 @@ 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.classnotes.mtime)}`;
|
healthText.textContent = `数据更新时间 ${fmtTime(data.updated_at || (data.database || {}).mtime || 0)}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
healthText.textContent = `读取失败:${error.message}`;
|
healthText.textContent = `读取失败:${error.message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queryRecords(query) {
|
async function queryRecords(query, options = {}) {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
recordRows.innerHTML = `<tr><td colspan="7" class="empty">正在查询</td></tr>`;
|
recordRows.innerHTML = `<tr><td colspan="${RECORD_TABLE_COLUMN_COUNT}" class="empty">正在查询</td></tr>`;
|
||||||
recordMeta.innerHTML = "";
|
recordMeta.innerHTML = "";
|
||||||
|
if (recordPager) {
|
||||||
|
recordPager.hidden = true;
|
||||||
|
recordPager.innerHTML = "";
|
||||||
|
}
|
||||||
currentRecords = [];
|
currentRecords = [];
|
||||||
resetCorrections();
|
if (options.reset !== false) {
|
||||||
resetRecordSort();
|
resetCorrections();
|
||||||
clearInlineAccount();
|
resetRecordSort();
|
||||||
|
clearInlineStudentProfile();
|
||||||
|
} else {
|
||||||
|
currentRecordOrder = [];
|
||||||
|
expandedSummaryRecords = new Set();
|
||||||
|
collapsedTeacherGroups = new Set();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson(`/api/records?q=${encodeURIComponent(q)}&limit=500`);
|
const params = new URLSearchParams({ q, all: "1" });
|
||||||
|
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("总课时", `${fmtHours(summary.total_hours)} 小时`),
|
metric("完整展示", pageRangeText(currentRecordPage)),
|
||||||
|
metric("总课时", displayHours(summary.total_hours, summary.total_duration)),
|
||||||
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
|
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
|
||||||
].join("");
|
].join("");
|
||||||
|
|
||||||
if (data.query.students.length === 1) {
|
if (options.reset !== false && data.query.students.length === 1) {
|
||||||
await loadInlineAccount(data.query.students[0]);
|
await loadInlineStudentProfile(data.query.students[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.records.length) {
|
if (!data.records.length) {
|
||||||
recordRows.innerHTML = `<tr><td colspan="7" class="empty">未找到符合条件的上课记录</td></tr>`;
|
recordRows.innerHTML = `<tr><td colspan="${RECORD_TABLE_COLUMN_COUNT}" class="empty">未找到符合条件的上课记录</td></tr>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentRecords = data.records.map((row, index) => ({
|
currentRecords = data.records.map((row, index) => ({
|
||||||
...row,
|
...row,
|
||||||
_recordIndex: index,
|
_recordIndex: currentRecordPage.offset + index,
|
||||||
_recordKey: makeRecordKey(row, index),
|
_recordKey: makeRecordKey(row, currentRecordPage.offset + index),
|
||||||
}));
|
}));
|
||||||
|
if (options.reset !== false) {
|
||||||
|
initializeTeacherGroupCollapse(currentRecords);
|
||||||
|
}
|
||||||
recordRows.innerHTML = renderGroupedRecords(currentRecords);
|
recordRows.innerHTML = renderGroupedRecords(currentRecords);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
recordRows.innerHTML = `<tr><td colspan="7" class="empty">查询失败:${escapeHtml(error.message)}</td></tr>`;
|
recordRows.innerHTML = `<tr><td colspan="${RECORD_TABLE_COLUMN_COUNT}" class="empty">查询失败:${escapeHtml(error.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,18 +824,38 @@ document.querySelectorAll("[data-query]").forEach((button) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
recordRows.addEventListener("click", (event) => {
|
recordRows.addEventListener("click", (event) => {
|
||||||
const summaryButton = event.target.closest(".summary-toggle");
|
const summarySupplementButton = event.target.closest(".summary-supplement");
|
||||||
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");
|
||||||
if (summaryButton) {
|
const teacherToggle = event.target.closest(".teacher-group-toggle");
|
||||||
toggleSummaryRow(summaryButton.dataset.recordKey);
|
if (summarySupplementButton) {
|
||||||
|
openSummarySupplementDialog(summarySupplementButton.dataset.recordKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (editButton) {
|
if (editButton) {
|
||||||
openCorrectionDialog(editButton.dataset.recordKey);
|
openCorrectionDialog(editButton.dataset.recordKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (deleteButton) openDeleteDialog(deleteButton.dataset.recordKey);
|
if (deleteButton) {
|
||||||
|
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) => {
|
||||||
@@ -668,6 +883,13 @@ 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();
|
||||||
@@ -675,6 +897,9 @@ 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=20260615-record-summary-toggle" />
|
<link rel="stylesheet" href="/static/styles.css?v=20260706-sqlite-native" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
<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>
|
||||||
@@ -68,11 +69,12 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="recordRows">
|
<tbody id="recordRows">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="empty">输入查询条件后显示明细</td>
|
<td colspan="8" class="empty">输入查询条件后显示明细</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="recordPager" class="pager" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -130,7 +132,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>确认后会提交后台审核;审核通过才会删除 classnotes 中的记录,并恢复对应学生课时。</code>
|
<code>确认后会提交后台审核;审核通过才会删除数据库中的上课记录,并恢复对应学生课时。</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>
|
||||||
@@ -140,6 +142,31 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js?v=20260615-record-summary-toggle"></script>
|
<div id="summarySupplementDialog" class="modal-backdrop" hidden>
|
||||||
|
<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
-2636
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
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}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
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}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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}
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from app import repository
|
||||||
|
from app.domain import Account, Payment, Teacher
|
||||||
|
|
||||||
|
|
||||||
|
def assert_equal(name: str, actual, expected) -> None:
|
||||||
|
if actual != expected:
|
||||||
|
raise AssertionError(f"{name}: got {actual!r}, expected {expected!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
repository.SQLITE_DB_PATH = Path(temp_dir) / "xsk_test.db"
|
||||||
|
repository.initialize_runtime_database()
|
||||||
|
|
||||||
|
student = repository.create_student_profile(
|
||||||
|
Account("", "甲", [Payment("2026-07-01", 10)], 0, "正常", 2020, "")
|
||||||
|
)
|
||||||
|
assert_equal("新增学生", student["account"]["student"], "甲")
|
||||||
|
|
||||||
|
teacher = repository.create_teacher(Teacher("", "王老师", "", ["英语"], "在岗", ""))
|
||||||
|
assert_equal("新增教师", teacher["teacher"]["name"], "王老师")
|
||||||
|
|
||||||
|
record = repository.register_class_record_lines(
|
||||||
|
line="2026.07.06-星期一-10:00-11:00-甲-1小时0分-王老师-英语"
|
||||||
|
)
|
||||||
|
assert_equal("登记上课", record["registered"], 1)
|
||||||
|
|
||||||
|
repository.register_payment_lines(line="甲-2026-07-02:2")
|
||||||
|
students = repository.students_payload()
|
||||||
|
assert_equal("学生数量", students["count"], 1)
|
||||||
|
assert_equal("余额重算", students["students"][0]["remaining"], 11)
|
||||||
|
|
||||||
|
queried = repository.query_public_records("甲7月英语")
|
||||||
|
assert_equal("查询记录", queried["total_records"], 1)
|
||||||
|
record_id = queried["records"][0]["record_id"]
|
||||||
|
|
||||||
|
correction = repository.submit_public_correction_tasks([{"record_id": record_id, "time": "11:00-12:00"}])
|
||||||
|
approved = repository.approve_admin_task(int(correction["items"][0]["id"]))
|
||||||
|
assert_equal("批准纠错", approved["task"]["status"], "approved")
|
||||||
|
assert_equal("纠错生效", repository.query_public_records("甲7月英语")["records"][0]["time"], "11:00-12:00")
|
||||||
|
|
||||||
|
supplement = repository.supplement_course_summary(
|
||||||
|
repository.query_public_records("甲7月英语")["records"][0]["record_id"],
|
||||||
|
"课堂表现很好",
|
||||||
|
)
|
||||||
|
assert_equal("补充小结", supplement["status"], "saved")
|
||||||
|
summaries = repository.query_course_summaries(q="课堂表现")
|
||||||
|
assert_equal("小结查询", summaries["count"], 1)
|
||||||
|
summary_id = summaries["items"][0]["id"]
|
||||||
|
repository.update_course_summary_body(summary_id, "更新后的课堂表现")
|
||||||
|
assert_equal("小结正文更新", repository.query_course_summaries(q="更新后")["count"], 1)
|
||||||
|
|
||||||
|
latest = repository.list_operation_logs()["items"][0]
|
||||||
|
rollback = repository.rollback_operation_log(str(latest["id"]))
|
||||||
|
assert_equal("撤回模式", rollback["rollback_mode"], "sqlite_snapshot")
|
||||||
|
assert_equal("撤回日志", repository.list_operation_logs()["items"][0]["operation"], "撤回操作")
|
||||||
|
|
||||||
|
print("db smoke test passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -115,8 +115,7 @@ 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}",
|
||||||
"CLASSNOTES_PATH=/data/classnotes.txt",
|
"SQLITE_DB_PATH=/data/xsk_education.db",
|
||||||
"ACCOUNTS_PATH=/data/学生课时账户.md",
|
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -11,17 +11,13 @@ 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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -41,12 +37,13 @@ EXCLUDE_PREFIXES = (
|
|||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description="导入历史课程小结 Markdown 到 VPS 数据目录")
|
parser = argparse.ArgumentParser(description="导入历史课程小结 Markdown 到 SQLite")
|
||||||
parser.add_argument("--source", required=True, type=Path, help="本机历史课程小结采集目录")
|
parser.add_argument("--source", required=True, type=Path, help="本机历史课程小结采集目录")
|
||||||
parser.add_argument("--target", default=Path("/data/course_summaries"), type=Path, help="VPS 课程小结正式目录")
|
parser.add_argument("--db-path", default=Path("/data/xsk_education.db"), type=Path, help="SQLite 数据库路径")
|
||||||
parser.add_argument("--state", default=Path("/data/course_summary_state.json"), type=Path, help="课程小结状态文件")
|
parser.add_argument("--target", type=Path, help=argparse.SUPPRESS)
|
||||||
parser.add_argument("--tasks", default=Path("/data/admin_tasks.json"), type=Path, help="管理任务文件")
|
parser.add_argument("--state", type=Path, help=argparse.SUPPRESS)
|
||||||
parser.add_argument("--operation-logs", default=Path("/data/operation_logs.jsonl"), type=Path, help="操作日志文件")
|
parser.add_argument("--tasks", type=Path, help=argparse.SUPPRESS)
|
||||||
|
parser.add_argument("--operation-logs", type=Path, help=argparse.SUPPRESS)
|
||||||
parser.add_argument("--missing-table", type=Path, help="历史 classnotes缺失.txt;不传则尝试 source/classnotes缺失.txt")
|
parser.add_argument("--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()
|
||||||
@@ -292,30 +289,123 @@ 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()
|
||||||
target = args.target.resolve()
|
repository.SQLITE_DB_PATH = args.db_path.resolve()
|
||||||
missing_table = args.missing_table or (source / "classnotes缺失.txt")
|
missing_table = args.missing_table or (source / "classnotes缺失.txt")
|
||||||
scanned, copied = copy_markdown_files(source, target, args.dry_run)
|
scanned, entries, skipped = collect_history_entries(source)
|
||||||
imported, skipped = rebuild_state_from_markdown(target if target.exists() else source, args.state, args.dry_run)
|
missing_tasks = collect_missing_review_tasks(source, missing_table)
|
||||||
review_tasks = import_missing_tasks(source, missing_table, args.tasks, args.dry_run)
|
batch_id = f"history-import-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
append_operation_log(
|
def work(conn, backup_id):
|
||||||
args.operation_logs,
|
imported = 0
|
||||||
"history_course_summary_import",
|
duplicates = 0
|
||||||
"completed",
|
for summary in entries:
|
||||||
source=str(source),
|
saved = repository._save_course_summary(conn, summary)
|
||||||
target=str(target),
|
repository._add_summary_seen(conn, summary["source_id"], course_summary_semantic_key(summary))
|
||||||
scanned_files=scanned,
|
imported += 1 if saved.get("added") else 0
|
||||||
copied_files=copied,
|
duplicates += 0 if saved.get("added") else 1
|
||||||
indexed_summaries=imported,
|
created_tasks = 0
|
||||||
skipped_summaries=skipped,
|
for summary, proposed_line in missing_tasks:
|
||||||
review_tasks=review_tasks,
|
repository._create_course_summary_review_task(
|
||||||
)
|
conn,
|
||||||
|
summary,
|
||||||
|
proposed_line,
|
||||||
|
["历史 classnotes缺失导入,默认只进入审核,不自动扣课时"],
|
||||||
|
saved_path=str(summary.get("remark") or ""),
|
||||||
|
)
|
||||||
|
created_tasks += 1
|
||||||
|
repository._add_ingest_batch(
|
||||||
|
conn,
|
||||||
|
{
|
||||||
|
"batch_id": batch_id,
|
||||||
|
"received_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"window": {"mode": "历史导入", "source": str(source)},
|
||||||
|
"students": [],
|
||||||
|
"result": {
|
||||||
|
"received": len(entries),
|
||||||
|
"saved": imported,
|
||||||
|
"auto_registered": 0,
|
||||||
|
"review_pending": created_tasks,
|
||||||
|
"duplicates": duplicates,
|
||||||
|
"rejected": skipped,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
repository._append_operation_log(
|
||||||
|
conn,
|
||||||
|
"历史课程小结导入",
|
||||||
|
"完成",
|
||||||
|
source=str(source),
|
||||||
|
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} 个,复制 {copied} 个,"
|
f"历史小结导入完成:扫描 Markdown {scanned} 个,"
|
||||||
f"索引小结 {imported} 条,跳过 {skipped} 条,生成审核任务 {review_tasks} 条。"
|
f"写入/待写入小结 {imported} 条,重复 {duplicates} 条,"
|
||||||
|
f"跳过 {skipped} 条,生成审核任务 {review_tasks} 条。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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())
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
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);
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>课时账户查询</title>
|
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=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>
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
const accountHealthText = document.querySelector("#accountHealthText");
|
|
||||||
const refreshBtn = document.querySelector("#refreshBtn");
|
|
||||||
const accountForm = document.querySelector("#accountForm");
|
|
||||||
const accountQuery = document.querySelector("#accountQuery");
|
|
||||||
const accountStatus = document.querySelector("#accountStatus");
|
|
||||||
const accountMeta = document.querySelector("#accountMeta");
|
|
||||||
const accountRows = document.querySelector("#accountRows");
|
|
||||||
|
|
||||||
function fmtHours(value) {
|
|
||||||
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("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function metric(label, value) {
|
|
||||||
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusClass(status) {
|
|
||||||
if (status === "欠费") return "debt";
|
|
||||||
if (status === "预警") return "warning";
|
|
||||||
if (status === "正常") return "normal";
|
|
||||||
return "closed";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPayments(payments) {
|
|
||||||
if (!payments.length) return "暂无";
|
|
||||||
return payments.map((item) => `${escapeHtml(item.date)}:${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();
|
|
||||||
-1007
File diff suppressed because it is too large
Load Diff
@@ -1,24 +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}
|
|
||||||
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
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>com.xsk.education-management.sync</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>/usr/bin/python3</string>
|
|
||||||
<string>/Users/yangdawei/Desktop/新时空业务源数据/tools/xsk-education-management/scripts/sync_to_vps.py</string>
|
|
||||||
</array>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<true/>
|
|
||||||
<key>StandardOutPath</key>
|
|
||||||
<string>/Users/yangdawei/Library/Logs/xsk-education-management/sync.log</string>
|
|
||||||
<key>StandardErrorPath</key>
|
|
||||||
<string>/Users/yangdawei/Library/Logs/xsk-education-management/sync.err.log</string>
|
|
||||||
<key>EnvironmentVariables</key>
|
|
||||||
<dict>
|
|
||||||
<key>PATH</key>
|
|
||||||
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import plistlib
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
|
|
||||||
PROJECT_DIR = Path(__file__).resolve().parents[1]
|
|
||||||
LABEL = "com.xsk.education-management.sync"
|
|
||||||
OLD_LABEL = "com.xsk.records.sync"
|
|
||||||
PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
||||||
OLD_PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{OLD_LABEL}.plist"
|
|
||||||
LOG_DIR = Path.home() / "Library" / "Logs" / "xsk-education-management"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(description="安装新时空教务管理系统同步 LaunchAgent")
|
|
||||||
parser.add_argument("--use-sshpass", action="store_true", default=os.getenv("XSK_USE_SSHPASS", "") == "1")
|
|
||||||
parser.add_argument("--ssh-password", default=os.getenv("XSK_SSH_PASSWORD"))
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_args()
|
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
program_arguments = [
|
|
||||||
"/usr/bin/python3",
|
|
||||||
str(PROJECT_DIR / "scripts" / "sync_to_vps.py"),
|
|
||||||
]
|
|
||||||
if args.use_sshpass:
|
|
||||||
program_arguments.append("--use-sshpass")
|
|
||||||
|
|
||||||
environment = {
|
|
||||||
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
||||||
}
|
|
||||||
if args.use_sshpass and args.ssh_password:
|
|
||||||
environment["XSK_SSH_PASSWORD"] = args.ssh_password
|
|
||||||
|
|
||||||
plist = {
|
|
||||||
"Label": LABEL,
|
|
||||||
"ProgramArguments": program_arguments,
|
|
||||||
"RunAtLoad": True,
|
|
||||||
"KeepAlive": True,
|
|
||||||
"StandardOutPath": str(LOG_DIR / "sync.log"),
|
|
||||||
"StandardErrorPath": str(LOG_DIR / "sync.err.log"),
|
|
||||||
"EnvironmentVariables": environment,
|
|
||||||
}
|
|
||||||
with PLIST_PATH.open("wb") as handle:
|
|
||||||
plistlib.dump(plist, handle)
|
|
||||||
|
|
||||||
subprocess.run(["launchctl", "unload", str(OLD_PLIST_PATH)], check=False, capture_output=True)
|
|
||||||
OLD_PLIST_PATH.unlink(missing_ok=True)
|
|
||||||
subprocess.run(["launchctl", "unload", str(PLIST_PATH)], check=False, capture_output=True)
|
|
||||||
subprocess.run(["launchctl", "load", str(PLIST_PATH)], check=True)
|
|
||||||
print(f"已安装并启动 LaunchAgent: {PLIST_PATH}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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");
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import shlex
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_LOCAL_DIR = Path("/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户")
|
|
||||||
DEFAULT_REMOTE_HOST = "121.199.172.246"
|
|
||||||
DEFAULT_REMOTE_USER = "root"
|
|
||||||
DEFAULT_REMOTE_PORT = 22222
|
|
||||||
DEFAULT_REMOTE_DIR = "/root/新时空教务管理系统"
|
|
||||||
DEFAULT_IDENTITY_FILE = Path.home() / ".ssh" / "xsk_records_vps_ed25519"
|
|
||||||
SYNC_FILES = ("classnotes.txt", "学生课时账户.md")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Config:
|
|
||||||
local_dir: Path
|
|
||||||
remote_host: str
|
|
||||||
remote_user: str
|
|
||||||
remote_port: int
|
|
||||||
remote_dir: str
|
|
||||||
identity_file: Path | None
|
|
||||||
interval: float
|
|
||||||
use_sshpass: bool
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(description="同步新时空课程记录数据到 VPS")
|
|
||||||
parser.add_argument("--once", action="store_true", help="只同步一次后退出")
|
|
||||||
parser.add_argument("--interval", type=float, default=float(os.getenv("XSK_SYNC_INTERVAL", "3")))
|
|
||||||
parser.add_argument("--local-dir", default=os.getenv("XSK_LOCAL_DIR", str(DEFAULT_LOCAL_DIR)))
|
|
||||||
parser.add_argument("--remote-host", default=os.getenv("XSK_REMOTE_HOST", DEFAULT_REMOTE_HOST))
|
|
||||||
parser.add_argument("--remote-user", default=os.getenv("XSK_REMOTE_USER", DEFAULT_REMOTE_USER))
|
|
||||||
parser.add_argument("--remote-port", type=int, default=int(os.getenv("XSK_REMOTE_PORT", str(DEFAULT_REMOTE_PORT))))
|
|
||||||
parser.add_argument("--remote-dir", default=os.getenv("XSK_REMOTE_DIR", DEFAULT_REMOTE_DIR))
|
|
||||||
parser.add_argument(
|
|
||||||
"--identity-file",
|
|
||||||
default=os.getenv("XSK_IDENTITY_FILE", str(DEFAULT_IDENTITY_FILE) if DEFAULT_IDENTITY_FILE.exists() else ""),
|
|
||||||
help="SSH 私钥路径;默认兼容使用历史文件 ~/.ssh/xsk_records_vps_ed25519(如果存在)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--use-sshpass",
|
|
||||||
action="store_true",
|
|
||||||
default=os.getenv("XSK_USE_SSHPASS", "") == "1",
|
|
||||||
help="从 XSK_SSH_PASSWORD 读取密码并通过 sshpass 连接;推荐改用 SSH key",
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def log(message: str) -> None:
|
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
print(f"[{timestamp}] {message}", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
|
|
||||||
env = os.environ.copy()
|
|
||||||
if env.get("XSK_SSH_PASSWORD") and not env.get("SSHPASS"):
|
|
||||||
env["SSHPASS"] = env["XSK_SSH_PASSWORD"]
|
|
||||||
result = subprocess.run(command, text=True, capture_output=True, check=False, env=env)
|
|
||||||
if result.returncode != 0:
|
|
||||||
safe_command = " ".join(shlex.quote(part) for part in command if part != os.getenv("XSK_SSH_PASSWORD", ""))
|
|
||||||
raise RuntimeError(
|
|
||||||
f"命令失败({result.returncode}): {safe_command}\n"
|
|
||||||
f"STDOUT: {result.stdout.strip()}\nSTDERR: {result.stderr.strip()}"
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def base_remote(config: Config) -> str:
|
|
||||||
return f"{config.remote_user}@{config.remote_host}"
|
|
||||||
|
|
||||||
|
|
||||||
def ssh_options(config: Config) -> list[str]:
|
|
||||||
options = [
|
|
||||||
"-p",
|
|
||||||
str(config.remote_port),
|
|
||||||
"-o",
|
|
||||||
"StrictHostKeyChecking=accept-new",
|
|
||||||
]
|
|
||||||
if config.identity_file:
|
|
||||||
options.extend(["-i", str(config.identity_file), "-o", "IdentitiesOnly=yes"])
|
|
||||||
return options
|
|
||||||
|
|
||||||
|
|
||||||
def ssh_prefix(config: Config) -> list[str]:
|
|
||||||
command: list[str] = []
|
|
||||||
if config.use_sshpass:
|
|
||||||
password = os.getenv("XSK_SSH_PASSWORD")
|
|
||||||
if not password:
|
|
||||||
raise RuntimeError("启用 --use-sshpass 时必须设置 XSK_SSH_PASSWORD")
|
|
||||||
command.extend(["sshpass", "-e"])
|
|
||||||
command.extend(["ssh", *ssh_options(config), base_remote(config)])
|
|
||||||
return command
|
|
||||||
|
|
||||||
|
|
||||||
def rsync_prefix(config: Config) -> list[str]:
|
|
||||||
command: list[str] = []
|
|
||||||
if config.use_sshpass:
|
|
||||||
password = os.getenv("XSK_SSH_PASSWORD")
|
|
||||||
if not password:
|
|
||||||
raise RuntimeError("启用 --use-sshpass 时必须设置 XSK_SSH_PASSWORD")
|
|
||||||
command.extend(["sshpass", "-e"])
|
|
||||||
ssh_command = " ".join(shlex.quote(part) for part in ["ssh", *ssh_options(config)])
|
|
||||||
command.extend(
|
|
||||||
[
|
|
||||||
"rsync",
|
|
||||||
"-az",
|
|
||||||
"-e",
|
|
||||||
ssh_command,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return command
|
|
||||||
|
|
||||||
|
|
||||||
def remote_shell_quote(value: str) -> str:
|
|
||||||
return shlex.quote(value)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_remote_dirs(config: Config) -> None:
|
|
||||||
data_dir = f"{config.remote_dir.rstrip('/')}/data"
|
|
||||||
run_command(ssh_prefix(config) + [f"mkdir -p {remote_shell_quote(data_dir)}"])
|
|
||||||
|
|
||||||
|
|
||||||
def sync_once(config: Config) -> None:
|
|
||||||
ensure_remote_dirs(config)
|
|
||||||
data_dir = f"{config.remote_dir.rstrip('/')}/data"
|
|
||||||
for filename in SYNC_FILES:
|
|
||||||
source = config.local_dir / filename
|
|
||||||
if not source.exists():
|
|
||||||
raise FileNotFoundError(f"本地文件不存在: {source}")
|
|
||||||
temp_name = f".{filename}.tmp"
|
|
||||||
remote_temp = f"{base_remote(config)}:{data_dir}/{temp_name}"
|
|
||||||
run_command(rsync_prefix(config) + [str(source), remote_temp])
|
|
||||||
run_command(
|
|
||||||
ssh_prefix(config)
|
|
||||||
+ [
|
|
||||||
"mv "
|
|
||||||
f"{remote_shell_quote(data_dir + '/' + temp_name)} "
|
|
||||||
f"{remote_shell_quote(data_dir + '/' + filename)}"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
log(f"已同步 {source.name}")
|
|
||||||
|
|
||||||
|
|
||||||
def file_signature(path: Path) -> tuple[int, int]:
|
|
||||||
stat = path.stat()
|
|
||||||
return stat.st_mtime_ns, stat.st_size
|
|
||||||
|
|
||||||
|
|
||||||
def current_signatures(local_dir: Path) -> dict[str, tuple[int, int]]:
|
|
||||||
return {filename: file_signature(local_dir / filename) for filename in SYNC_FILES}
|
|
||||||
|
|
||||||
|
|
||||||
def watch(config: Config) -> None:
|
|
||||||
log("启动新时空教务管理系统数据同步监听")
|
|
||||||
signatures: dict[str, tuple[int, int]] = {}
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
next_signatures = current_signatures(config.local_dir)
|
|
||||||
if next_signatures != signatures:
|
|
||||||
time.sleep(0.4)
|
|
||||||
sync_once(config)
|
|
||||||
signatures = current_signatures(config.local_dir)
|
|
||||||
except Exception as exc:
|
|
||||||
log(f"同步失败: {exc}")
|
|
||||||
time.sleep(config.interval)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_args()
|
|
||||||
config = Config(
|
|
||||||
local_dir=Path(args.local_dir).expanduser(),
|
|
||||||
remote_host=args.remote_host,
|
|
||||||
remote_user=args.remote_user,
|
|
||||||
remote_port=args.remote_port,
|
|
||||||
remote_dir=args.remote_dir,
|
|
||||||
identity_file=Path(args.identity_file).expanduser() if args.identity_file else None,
|
|
||||||
interval=args.interval,
|
|
||||||
use_sshpass=args.use_sshpass,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
if args.once:
|
|
||||||
sync_once(config)
|
|
||||||
else:
|
|
||||||
watch(config)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
return 130
|
|
||||||
except Exception as exc:
|
|
||||||
log(f"退出: {exc}")
|
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
Reference in New Issue
Block a user