Compare commits
3 Commits
124ffca03e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 73d7d766eb | |||
| 9884e91612 | |||
| bd5fd7815e |
@@ -8,15 +8,5 @@ BASIC_AUTH_PASSWORD=change-me
|
||||
ADMIN_AUTH_PASSWORD=change-me
|
||||
INGEST_AUTH_TOKEN=change-this-ingest-token
|
||||
|
||||
USE_SQLITE_SOURCE=1
|
||||
SQLITE_DB_PATH=/data/xsk_education.db
|
||||
RUNTIME_DATA_ROOT=/data/runtime_text_cache
|
||||
LEGACY_TEXT_ROOT=/data
|
||||
|
||||
CLASSNOTES_PATH=/data/classnotes.txt
|
||||
ACCOUNTS_PATH=/data/学生课时账户.md
|
||||
TEACHERS_PATH=/data/教师档案.md
|
||||
ADMIN_TASKS_PATH=/data/admin_tasks.json
|
||||
COURSE_SUMMARIES_ROOT=/data/course_summaries
|
||||
COURSE_SUMMARY_STATE_PATH=/data/course_summary_state.json
|
||||
OPERATION_LOGS_PATH=/data/operation_logs.jsonl
|
||||
|
||||
+4
-10
@@ -41,7 +41,7 @@
|
||||
|
||||
- `make check`:检查前端 JS 语法和后端 Python 编译。
|
||||
- `make smoke`:运行轻量前端行为烟测,覆盖排序按钮、组内排序和纠错后排序。
|
||||
- `make data-hash`:输出 `classnotes.txt`、`学生课时账户.md`,以及存在时的 `admin_tasks.json`、`course_summary_state.json` 的 SHA-256。
|
||||
- `make data-hash`:输出 SQLite 数据库、迁移报告和归档校验文件的 SHA-256。
|
||||
- `make build`:构建 Docker 镜像。
|
||||
- `make up`:重启 Docker Compose 服务。
|
||||
- `make health`:带认证访问 `/api/health`。
|
||||
@@ -104,15 +104,10 @@ git push origin HEAD:<当前分支>
|
||||
业务数据文件位于:
|
||||
|
||||
```text
|
||||
/root/新时空教务管理系统/data/classnotes.txt
|
||||
/root/新时空教务管理系统/data/学生课时账户.md
|
||||
/root/新时空教务管理系统/data/admin_tasks.json
|
||||
/root/新时空教务管理系统/data/course_summaries/
|
||||
/root/新时空教务管理系统/data/course_summary_state.json
|
||||
/root/新时空教务管理系统/data/operation_logs.jsonl
|
||||
/root/新时空教务管理系统/data/xsk_education.db
|
||||
```
|
||||
|
||||
普通前端和查询类改动不应该改变这些文件;课程小结查询页是只读功能,也不应该改变这些文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、学生档案编辑、课程小结自动入账或审核批准,先确认自动备份目录:
|
||||
普通前端和查询类改动不应该改变数据库;课程小结查询页是只读功能,也不应该改变数据库。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、学生档案编辑、课程小结自动入账或审核批准,先确认自动备份目录:
|
||||
|
||||
```text
|
||||
/root/新时空教务管理系统/data/backups/
|
||||
@@ -148,8 +143,7 @@ git push origin HEAD:<当前分支>
|
||||
|
||||
## 课程小结迁移维护
|
||||
|
||||
- VPS 是 `classnotes.txt` 和 `学生课时账户.md` 的唯一正式写入方。
|
||||
- 本机 `com.xsk.education-management.sync` 常驻同步迁移后应停止,避免旧本地数据覆盖 VPS。
|
||||
- VPS 的 `/data/xsk_education.db` 是唯一正式业务数据源。
|
||||
- 本机课程小结采集脚本用 `XSK_INGEST_URL` 和 `XSK_INGEST_TOKEN` 推送批次;失败批次保存在本机 `推送失败队列/`。
|
||||
- 管理后台的“课程小结审核”处理低置信或冲突小结;“操作记录”追踪接收、自动入账、重复、失败、审核批准和驳回。
|
||||
- 历史小结导入使用 `scripts/import_course_summaries.py`,历史 `classnotes缺失.txt` 只生成审核任务,不自动扣课时。
|
||||
|
||||
@@ -12,6 +12,7 @@ check:
|
||||
|
||||
smoke:
|
||||
node scripts/smoke_test.js
|
||||
python3 scripts/db_smoke_test.py
|
||||
|
||||
data-hash:
|
||||
sha256sum $(DATA_FILES)
|
||||
|
||||
+7
-67
@@ -10,11 +10,8 @@
|
||||
- `scripts/deploy_to_vps.py`:部署新时空教务管理系统到 VPS。
|
||||
- `scripts/migrate_text_to_sqlite.py`:一次性把旧纯文本、JSON、JSONL 和课程小结 Markdown 迁移进 SQLite。
|
||||
- `scripts/import_course_summaries.py`:一次性导入历史课程小结 Markdown。
|
||||
- `scripts/sync_to_vps.py`:旧版正式数据同步脚本,迁移后不要继续常驻运行。
|
||||
- `scripts/smoke_test.js`:轻量前端行为烟测。
|
||||
- `scripts/install_gitea_backup_hook.py`:安装提交后自动推送到 Gitea 的 Git hook。
|
||||
- `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。
|
||||
- `launchd/com.xsk.education-management.sync.plist.template`:LaunchAgent 模板。
|
||||
|
||||
## 维护入口
|
||||
|
||||
@@ -70,27 +67,6 @@ http://121.199.172.246:18080/
|
||||
XSK_PYTHON_IMAGE='python:3.12-slim'
|
||||
```
|
||||
|
||||
## 手动同步数据
|
||||
|
||||
迁移完成后不要再用本机 `classnotes.txt` 和 `学生课时账户.md` 覆盖 VPS。SQLite 数据库 `/data/xsk_education.db` 是唯一事实源;运行时生成的 `/data/runtime_text_cache/` 只是兼容旧业务逻辑的缓存。下面命令只保留给迁移前或灾难恢复时使用,日常新增课程小结应走 `POST /api/ingest/course-summaries`。
|
||||
|
||||
```bash
|
||||
XSK_USE_SSHPASS=1 \
|
||||
XSK_SSH_PASSWORD='填写SSH密码' \
|
||||
python3 scripts/sync_to_vps.py --once --use-sshpass
|
||||
```
|
||||
|
||||
同步文件:
|
||||
|
||||
- `/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户/classnotes.txt`
|
||||
- `/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户/学生课时账户.md`
|
||||
|
||||
远端数据目录:
|
||||
|
||||
```text
|
||||
/root/新时空教务管理系统/data/xsk_education.db
|
||||
```
|
||||
|
||||
## SQLite 迁移
|
||||
|
||||
首次迁移先 dry-run:
|
||||
@@ -115,7 +91,7 @@ make migrate-sqlite
|
||||
/root/新时空教务管理系统/archives/text-source-before-sqlite-<时间>.tar.gz.sha256
|
||||
```
|
||||
|
||||
学生表包含 `primary_entry_year` 字段,用于维护“小学一年级入学年份”。排课系统只读该字段并按课程日期动态推算年级。管理后台的学生档案编辑页可以维护该字段;运行时 `学生课时账户.md` 缓存会导出为 7 列格式,同时仍兼容旧 6 列缓存导入。
|
||||
学生表包含 `primary_entry_year` 字段,用于维护“小学一年级入学年份”。排课系统只读该字段并按课程日期动态推算年级。管理后台的学生档案编辑页可以维护该字段;旧 6/7 列 `学生课时账户.md` 仅作为一次性迁移输入兼容。
|
||||
|
||||
## 课程小结推送
|
||||
|
||||
@@ -143,26 +119,19 @@ python3 /Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录
|
||||
|
||||
## 历史小结导入
|
||||
|
||||
把本机历史课程小结目录同步或上传到 VPS 后,可在容器内执行一次性导入。历史导入只重建小结库和状态;历史 `classnotes缺失.txt` 默认转为审核任务,不自动扣课时。
|
||||
把本机历史课程小结目录同步或上传到 VPS 后,可在容器内执行一次性导入。历史导入直接写入 SQLite 课程小结表;历史 `classnotes缺失.txt` 默认转为审核任务,不自动扣课时。
|
||||
|
||||
```bash
|
||||
cd /root/新时空教务管理系统/app
|
||||
docker compose exec xsk-education-management python scripts/import_course_summaries.py \
|
||||
--source /data/import/课程小结采集 \
|
||||
--target /data/course_summaries \
|
||||
--state /data/course_summary_state.json \
|
||||
--tasks /data/admin_tasks.json \
|
||||
--operation-logs /data/operation_logs.jsonl \
|
||||
--db-path /data/xsk_education.db \
|
||||
--missing-table /data/import/课程小结采集/classnotes缺失.txt
|
||||
```
|
||||
|
||||
## 数据备份
|
||||
|
||||
通过登记 API、课程小结自动入账或审核批准修改正式课时数据时,服务会以 SQLite 事务写入 `/data/xsk_education.db`。旧纯文本事实源已封存为归档包;运行时文本缓存位于:
|
||||
|
||||
```text
|
||||
/root/新时空教务管理系统/data/runtime_text_cache/
|
||||
```
|
||||
通过登记 API、课程小结自动入账或审核批准修改正式课时数据时,服务会以 SQLite 事务写入 `/data/xsk_education.db`。旧纯文本事实源已封存为归档包,不再作为运行时缓存参与读写。
|
||||
|
||||
备份目录位于:
|
||||
|
||||
@@ -170,7 +139,7 @@ docker compose exec xsk-education-management python scripts/import_course_summar
|
||||
/root/新时空教务管理系统/data/backups/
|
||||
```
|
||||
|
||||
每次登记生成一个事务备份目录,目录内包含变更前的业务文件副本和 `metadata.json`。系统自动保留最近 50 次备份,超过后删除最旧备份。
|
||||
每次写业务数据前生成一个 SQLite 快照备份目录,目录内包含变更前的数据库快照和 `metadata.json`。系统自动保留最近 50 次备份,超过后删除最旧备份。
|
||||
|
||||
查看备份:
|
||||
|
||||
@@ -178,44 +147,15 @@ docker compose exec xsk-education-management python scripts/import_course_summar
|
||||
ls -lt /root/新时空教务管理系统/data/backups/
|
||||
```
|
||||
|
||||
恢复某次备份时,先停止服务,再把对应备份目录里的文件复制回数据目录,最后重启服务:
|
||||
恢复某次备份优先使用后台“操作记录”里的撤回按钮。手工恢复时先停止服务,再把对应备份目录里的 `xsk_education.db` 作为数据库恢复源,最后重启服务:
|
||||
|
||||
```bash
|
||||
cd /root/新时空教务管理系统/app
|
||||
docker compose stop
|
||||
cp /root/新时空教务管理系统/data/backups/<备份目录>/classnotes.txt /root/新时空教务管理系统/data/classnotes.txt 2>/dev/null || true
|
||||
cp /root/新时空教务管理系统/data/backups/<备份目录>/学生课时账户.md /root/新时空教务管理系统/data/学生课时账户.md 2>/dev/null || true
|
||||
cp /root/新时空教务管理系统/data/backups/<备份目录>/xsk_education.db /root/新时空教务管理系统/data/xsk_education.db
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 安装自动同步
|
||||
|
||||
```bash
|
||||
XSK_USE_SSHPASS=1 \
|
||||
XSK_SSH_PASSWORD='填写SSH密码' \
|
||||
python3 scripts/install_launch_agent.py --use-sshpass
|
||||
```
|
||||
|
||||
日志位置:
|
||||
|
||||
```text
|
||||
~/Library/Logs/xsk-education-management/sync.log
|
||||
~/Library/Logs/xsk-education-management/sync.err.log
|
||||
```
|
||||
|
||||
查看任务:
|
||||
|
||||
```bash
|
||||
launchctl list | grep com.xsk.education-management.sync
|
||||
```
|
||||
|
||||
卸载任务:
|
||||
|
||||
```bash
|
||||
launchctl unload ~/Library/LaunchAgents/com.xsk.education-management.sync.plist
|
||||
rm ~/Library/LaunchAgents/com.xsk.education-management.sync.plist
|
||||
```
|
||||
|
||||
## 更换网页访问密码
|
||||
|
||||
登录 VPS 后修改 `/root/新时空教务管理系统/app/.env` 中的 `BASIC_AUTH_PASSWORD`,然后重启:
|
||||
|
||||
@@ -5,7 +5,6 @@ import re
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from .config import ACCOUNTS_PATH
|
||||
from .data import (
|
||||
SUBJECTS,
|
||||
canonical_teacher_name,
|
||||
@@ -16,8 +15,8 @@ from .data import (
|
||||
normalize_lines,
|
||||
parse_class_record_line,
|
||||
parse_payment_line,
|
||||
read_accounts,
|
||||
)
|
||||
from .repository import list_student_names
|
||||
|
||||
|
||||
REGISTER_TYPES = {"class_record", "payment", "course_summary"}
|
||||
@@ -152,7 +151,7 @@ def _questions_for_missing(register_type: str, missing_fields: list[str]) -> lis
|
||||
|
||||
def _known_students() -> list[str]:
|
||||
try:
|
||||
return [account.student for account in read_accounts(ACCOUNTS_PATH)]
|
||||
return list_student_names()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
+19
-21
@@ -6,10 +6,11 @@ from pathlib import Path
|
||||
from fastapi import HTTPException, Request
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .config import ACCOUNTS_PATH, CLASSNOTES_PATH, TEACHERS_PATH, USE_SQLITE_SOURCE
|
||||
from .data import Account, Payment, Teacher, read_accounts, read_classnotes, read_teachers
|
||||
from .repository import ensure_runtime_cache
|
||||
from .schemas import AccountPayload, RegisterLinesPayload, TeacherPayload
|
||||
from .data import Account, Payment, Teacher
|
||||
from .repository import load_records as load_records_from_db
|
||||
from .repository import load_student_profiles as load_student_profiles_from_db
|
||||
from .repository import load_teachers as load_teachers_from_db
|
||||
from .schemas import RegisterLinesPayload, StudentProfilePayload, TeacherPayload
|
||||
|
||||
|
||||
async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
||||
@@ -39,27 +40,24 @@ async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
||||
|
||||
|
||||
def load_records():
|
||||
if USE_SQLITE_SOURCE:
|
||||
ensure_runtime_cache()
|
||||
if not CLASSNOTES_PATH.exists():
|
||||
raise HTTPException(status_code=503, detail=f"课程记录文件不存在: {CLASSNOTES_PATH}")
|
||||
return read_classnotes(CLASSNOTES_PATH)
|
||||
try:
|
||||
return load_records_from_db()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def load_accounts():
|
||||
if USE_SQLITE_SOURCE:
|
||||
ensure_runtime_cache()
|
||||
if not ACCOUNTS_PATH.exists():
|
||||
raise HTTPException(status_code=503, detail=f"学生档案数据文件不存在: {ACCOUNTS_PATH}")
|
||||
return read_accounts(ACCOUNTS_PATH)
|
||||
def load_student_profiles():
|
||||
try:
|
||||
return load_student_profiles_from_db()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def load_teachers():
|
||||
if USE_SQLITE_SOURCE:
|
||||
ensure_runtime_cache()
|
||||
if not TEACHERS_PATH.exists():
|
||||
return []
|
||||
return read_teachers(TEACHERS_PATH)
|
||||
try:
|
||||
return load_teachers_from_db()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def file_meta(path: Path) -> dict:
|
||||
@@ -74,7 +72,7 @@ def file_meta(path: Path) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def payload_to_account(payload: AccountPayload, student_id: str | None = None) -> Account:
|
||||
def payload_to_student_profile(payload: StudentProfilePayload, student_id: str | None = None) -> Account:
|
||||
return Account(
|
||||
student_id=student_id if student_id is not None else payload.student_id,
|
||||
student=payload.student,
|
||||
|
||||
+1
-8
@@ -77,13 +77,6 @@ def is_admin_authenticated(
|
||||
) or has_valid_basic_auth(credentials, ADMIN_AUTH_PASSWORD)
|
||||
|
||||
|
||||
def is_accounts_authenticated(
|
||||
request: Request,
|
||||
credentials: HTTPBasicCredentials | None = None,
|
||||
) -> bool:
|
||||
return is_admin_authenticated(request, credentials)
|
||||
|
||||
|
||||
def verify_records_auth(
|
||||
request: Request,
|
||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||
@@ -110,7 +103,7 @@ def verify_admin_auth(
|
||||
return "admin"
|
||||
|
||||
|
||||
def verify_accounts_auth(
|
||||
def verify_student_profiles_auth(
|
||||
request: Request,
|
||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||
) -> str:
|
||||
|
||||
+1
-54
@@ -9,34 +9,7 @@ APP_DIR = Path(__file__).resolve().parent
|
||||
STATIC_DIR = APP_DIR / "static"
|
||||
|
||||
SQLITE_DB_PATH = Path(os.getenv("SQLITE_DB_PATH", "/data/xsk_education.db"))
|
||||
RUNTIME_DATA_ROOT = Path(os.getenv("RUNTIME_DATA_ROOT", "/data/runtime_text_cache"))
|
||||
LEGACY_TEXT_ROOT = Path(os.getenv("LEGACY_TEXT_ROOT", "/data"))
|
||||
USE_SQLITE_SOURCE = os.getenv("USE_SQLITE_SOURCE", "1").strip().lower() not in {"0", "false", "no"}
|
||||
|
||||
if USE_SQLITE_SOURCE:
|
||||
CLASSNOTES_PATH = RUNTIME_DATA_ROOT / "classnotes.txt"
|
||||
ACCOUNTS_PATH = RUNTIME_DATA_ROOT / "学生课时账户.md"
|
||||
TEACHERS_PATH = RUNTIME_DATA_ROOT / "教师档案.md"
|
||||
ADMIN_TASKS_PATH = RUNTIME_DATA_ROOT / "admin_tasks.json"
|
||||
COURSE_SUMMARIES_ROOT = RUNTIME_DATA_ROOT / "course_summaries"
|
||||
COURSE_SUMMARY_STATE_PATH = RUNTIME_DATA_ROOT / "course_summary_state.json"
|
||||
OPERATION_LOGS_PATH = RUNTIME_DATA_ROOT / "operation_logs.jsonl"
|
||||
else:
|
||||
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
|
||||
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md"))
|
||||
TEACHERS_PATH = Path(os.getenv("TEACHERS_PATH", "/data/教师档案.md"))
|
||||
ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json"))
|
||||
COURSE_SUMMARIES_ROOT = Path(os.getenv("COURSE_SUMMARIES_ROOT", "/data/course_summaries"))
|
||||
COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/course_summary_state.json"))
|
||||
OPERATION_LOGS_PATH = Path(os.getenv("OPERATION_LOGS_PATH", "/data/operation_logs.jsonl"))
|
||||
|
||||
LEGACY_CLASSNOTES_PATH = Path(os.getenv("LEGACY_CLASSNOTES_PATH", str(LEGACY_TEXT_ROOT / "classnotes.txt")))
|
||||
LEGACY_ACCOUNTS_PATH = Path(os.getenv("LEGACY_ACCOUNTS_PATH", str(LEGACY_TEXT_ROOT / "学生课时账户.md")))
|
||||
LEGACY_TEACHERS_PATH = Path(os.getenv("LEGACY_TEACHERS_PATH", str(LEGACY_TEXT_ROOT / "教师档案.md")))
|
||||
LEGACY_ADMIN_TASKS_PATH = Path(os.getenv("LEGACY_ADMIN_TASKS_PATH", str(LEGACY_TEXT_ROOT / "admin_tasks.json")))
|
||||
LEGACY_COURSE_SUMMARIES_ROOT = Path(os.getenv("LEGACY_COURSE_SUMMARIES_ROOT", str(LEGACY_TEXT_ROOT / "course_summaries")))
|
||||
LEGACY_COURSE_SUMMARY_STATE_PATH = Path(os.getenv("LEGACY_COURSE_SUMMARY_STATE_PATH", str(LEGACY_TEXT_ROOT / "course_summary_state.json")))
|
||||
LEGACY_OPERATION_LOGS_PATH = Path(os.getenv("LEGACY_OPERATION_LOGS_PATH", str(LEGACY_TEXT_ROOT / "operation_logs.jsonl")))
|
||||
|
||||
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
|
||||
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
|
||||
@@ -48,30 +21,4 @@ ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session"
|
||||
ADMIN_SESSION_COOKIE = "xsk_admin_session"
|
||||
SESSION_MAX_AGE = 60 * 60 * 24 * 30
|
||||
|
||||
class SQLiteBackedWriteLock:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def __enter__(self):
|
||||
self._lock.acquire()
|
||||
if USE_SQLITE_SOURCE:
|
||||
from .repository import export_database_to_runtime_cache
|
||||
|
||||
export_database_to_runtime_cache()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
try:
|
||||
if USE_SQLITE_SOURCE:
|
||||
from .repository import export_database_to_runtime_cache, sync_runtime_cache_to_database
|
||||
|
||||
if exc_type is None:
|
||||
sync_runtime_cache_to_database()
|
||||
else:
|
||||
export_database_to_runtime_cache()
|
||||
finally:
|
||||
self._lock.release()
|
||||
return False
|
||||
|
||||
|
||||
write_lock = SQLiteBackedWriteLock() if USE_SQLITE_SOURCE else threading.Lock()
|
||||
write_lock = threading.Lock()
|
||||
|
||||
-180
@@ -484,12 +484,6 @@ def next_student_id(accounts: list[Account]) -> str:
|
||||
return f"XS{(max(values) if values else 0) + 1:03d}"
|
||||
|
||||
|
||||
def write_accounts(path: Path, accounts: list[Account]) -> None:
|
||||
original_text = path.read_text(encoding="utf-8")
|
||||
accounts_by_id = {account.student_id: account for account in accounts}
|
||||
atomic_write_text(path, replace_account_lines(original_text, accounts_by_id))
|
||||
|
||||
|
||||
def next_teacher_id(teachers: list[Teacher]) -> str:
|
||||
values = []
|
||||
for teacher in teachers:
|
||||
@@ -499,22 +493,6 @@ def next_teacher_id(teachers: list[Teacher]) -> str:
|
||||
return f"T{(max(values) if values else 0) + 1:03d}"
|
||||
|
||||
|
||||
def replace_teacher_lines(original_text: str, teachers_by_id: dict[str, Teacher]) -> str:
|
||||
lines = original_text.splitlines()
|
||||
output: list[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
compact = stripped.strip("|").replace(" ", "").replace("|", "")
|
||||
if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped:
|
||||
parts = [part.strip() for part in stripped.strip("|").split("|")]
|
||||
if parts and parts[0] in teachers_by_id:
|
||||
output.append(format_teacher_row(teachers_by_id[parts[0]]))
|
||||
continue
|
||||
output.append(line)
|
||||
trailing_newline = "\n" if original_text.endswith("\n") else ""
|
||||
return "\n".join(output) + trailing_newline
|
||||
|
||||
|
||||
def append_teacher_line(original_text: str, teacher: Teacher) -> str:
|
||||
if not original_text.strip():
|
||||
original_text = (
|
||||
@@ -553,12 +531,6 @@ def replace_single_teacher_line(original_text: str, old_teacher_id: str, teacher
|
||||
return "\n".join(output) + trailing_newline
|
||||
|
||||
|
||||
def write_teachers(path: Path, teachers: list[Teacher]) -> None:
|
||||
original_text = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
teachers_by_id = {teacher.teacher_id: teacher for teacher in teachers}
|
||||
atomic_write_text(path, replace_teacher_lines(original_text, teachers_by_id))
|
||||
|
||||
|
||||
def create_teacher(path: Path, teacher: Teacher) -> dict:
|
||||
teachers = read_teachers(path)
|
||||
teacher = validate_teacher(replace(teacher, teacher_id=next_teacher_id(teachers)))
|
||||
@@ -615,16 +587,6 @@ def teacher_alias_map(teachers: list[Teacher]) -> dict[str, str]:
|
||||
return mapping
|
||||
|
||||
|
||||
def teacher_name_map(teachers: list[Teacher]) -> dict[str, Teacher]:
|
||||
mapping: dict[str, Teacher] = {}
|
||||
for teacher in teachers:
|
||||
mapping[teacher.name] = teacher
|
||||
if teacher.alias:
|
||||
mapping[teacher.alias] = teacher
|
||||
mapping[teacher.teacher_id] = teacher
|
||||
return mapping
|
||||
|
||||
|
||||
def create_account(path: Path, account: Account, classnotes_path: Path | None = None) -> dict:
|
||||
accounts = read_accounts(path)
|
||||
records = read_classnotes(classnotes_path) if classnotes_path and classnotes_path.exists() else []
|
||||
@@ -663,17 +625,6 @@ def update_account(path: Path, old_student_id: str, account: Account, classnotes
|
||||
return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "修改学生档案"}
|
||||
|
||||
|
||||
def render_accounts_text(path: Path, accounts: list[Account]) -> str:
|
||||
original_text = path.read_text(encoding="utf-8")
|
||||
accounts_by_id = {account.student_id: account for account in accounts}
|
||||
return replace_account_lines(original_text, accounts_by_id)
|
||||
|
||||
|
||||
def render_accounts_text_from_text(original_text: str, accounts: list[Account]) -> str:
|
||||
accounts_by_id = {account.student_id: account for account in accounts}
|
||||
return replace_account_lines(original_text, accounts_by_id)
|
||||
|
||||
|
||||
def normalize_lines(lines: list[str] | None = None, line: str | None = None) -> list[str]:
|
||||
values: list[str] = []
|
||||
if line is not None:
|
||||
@@ -781,115 +732,6 @@ def register_payment_lines(
|
||||
return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name, "operation": "登记缴费记录"}
|
||||
|
||||
|
||||
def normalize_classnote_durations_and_accounts(
|
||||
classnotes_path: Path,
|
||||
accounts_path: Path,
|
||||
operation_logs_path: Path | None = None,
|
||||
) -> dict:
|
||||
original_classnotes = classnotes_path.read_text(encoding="utf-8")
|
||||
original_accounts = accounts_path.read_text(encoding="utf-8")
|
||||
accounts = read_accounts(accounts_path)
|
||||
updated_accounts = list(accounts)
|
||||
account_deltas: defaultdict[str, float] = defaultdict(float)
|
||||
changed_lines: list[str] = []
|
||||
output_lines: list[str] = []
|
||||
skipped_lines: list[str] = []
|
||||
|
||||
for line_number, raw_line in enumerate(original_classnotes.splitlines(), start=1):
|
||||
stripped = raw_line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
output_lines.append(raw_line)
|
||||
continue
|
||||
match = CLASSNOTE_RE.fullmatch(stripped)
|
||||
if not match:
|
||||
output_lines.append(raw_line)
|
||||
skipped_lines.append(f"{line_number}: {stripped}")
|
||||
continue
|
||||
|
||||
old_minutes = int(round(parse_hours_text(match.group("duration")) * 60))
|
||||
true_minutes = parse_time_range_minutes(match.group("time"))
|
||||
true_duration = duration_text_from_minutes(true_minutes)
|
||||
if true_minutes == old_minutes and match.group("duration") == true_duration:
|
||||
output_lines.append(raw_line)
|
||||
continue
|
||||
|
||||
student = canonical_name(match.group("student"))
|
||||
account_index = find_account_index(updated_accounts, student)
|
||||
delta_hours = round((old_minutes - true_minutes) / 60.0, 2)
|
||||
if delta_hours:
|
||||
updated_accounts[account_index] = update_account_remaining(updated_accounts[account_index], delta_hours)
|
||||
account_deltas[updated_accounts[account_index].student_id] += delta_hours
|
||||
|
||||
new_line = (
|
||||
f"{match.group('date')}-{match.group('weekday')}-{match.group('time')}-"
|
||||
f"{student}-{true_duration}-{match.group('teacher').strip()}-{match.group('subject').strip()}"
|
||||
)
|
||||
output_lines.append(new_line)
|
||||
changed_lines.append(f"{line_number}: {stripped} => {new_line}")
|
||||
|
||||
if not changed_lines:
|
||||
return {
|
||||
"updated_records": 0,
|
||||
"updated_accounts": 0,
|
||||
"account_deltas": {},
|
||||
"skipped_lines": skipped_lines,
|
||||
"backup_id": "",
|
||||
}
|
||||
|
||||
trailing_newline = "\n" if original_classnotes.endswith("\n") else ""
|
||||
new_classnotes = "\n".join(output_lines) + trailing_newline
|
||||
changed_account_ids = {account_id for account_id, delta in account_deltas.items() if round(delta, 2)}
|
||||
updated_accounts_by_id = {
|
||||
account.student_id: account
|
||||
for account in updated_accounts
|
||||
if account.student_id in changed_account_ids
|
||||
}
|
||||
new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id)
|
||||
backup_dir = create_data_backup(
|
||||
"normalize-classnote-durations",
|
||||
{
|
||||
accounts_path: original_accounts,
|
||||
classnotes_path: original_classnotes,
|
||||
},
|
||||
changed_lines,
|
||||
)
|
||||
try:
|
||||
atomic_write_text(classnotes_path, new_classnotes)
|
||||
atomic_write_text(accounts_path, new_accounts)
|
||||
except Exception:
|
||||
atomic_write_text(classnotes_path, original_classnotes)
|
||||
atomic_write_text(accounts_path, original_accounts)
|
||||
raise
|
||||
try:
|
||||
prune_data_backups(backup_dir.parent)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
result = {
|
||||
"updated_records": len(changed_lines),
|
||||
"updated_accounts": len(updated_accounts_by_id),
|
||||
"account_deltas": {
|
||||
account_id: round(delta, 2)
|
||||
for account_id, delta in sorted(account_deltas.items())
|
||||
if round(delta, 2)
|
||||
},
|
||||
"skipped_lines": skipped_lines,
|
||||
"backup_id": backup_dir.name,
|
||||
}
|
||||
if operation_logs_path is not None:
|
||||
append_operation_log(
|
||||
operation_logs_path,
|
||||
"历史上课记录真实时长迁移",
|
||||
"完成",
|
||||
backup_id=backup_dir.name,
|
||||
updated_records=result["updated_records"],
|
||||
updated_accounts=result["updated_accounts"],
|
||||
account_deltas=result["account_deltas"],
|
||||
skipped_lines_count=len(skipped_lines),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def default_admin_tasks() -> dict:
|
||||
return {"version": 1, "next_id": 1, "items": []}
|
||||
|
||||
@@ -4813,28 +4655,6 @@ def has_filter_condition(spec: QuerySpec) -> bool:
|
||||
return bool(spec.start_date or spec.end_date or spec.students or spec.teachers or spec.subjects)
|
||||
|
||||
|
||||
def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> dict:
|
||||
spec = build_query_spec(query, records)
|
||||
matched = filter_records(records, spec) if has_filter_condition(spec) else []
|
||||
shown, normalized_offset, has_more = paginate_items(matched, 0, limit)
|
||||
return {
|
||||
"query": {
|
||||
"raw_query": spec.raw_query,
|
||||
"date_range": format_date_range(spec),
|
||||
"students": spec.students,
|
||||
"teachers": spec.teachers,
|
||||
"subjects": spec.subjects,
|
||||
},
|
||||
"summary": summarize_records(matched),
|
||||
"records": [record_to_dict(record) for record in shown],
|
||||
"total_records": len(matched),
|
||||
"shown_records": len(shown),
|
||||
"offset": normalized_offset,
|
||||
"limit": limit,
|
||||
"has_more": has_more,
|
||||
}
|
||||
|
||||
|
||||
def query_public_records(
|
||||
records: list[ClassRecord],
|
||||
teachers: list[Teacher],
|
||||
|
||||
+25
-1
@@ -8,7 +8,7 @@ from typing import Iterator
|
||||
from .config import SQLITE_DB_PATH
|
||||
|
||||
|
||||
SCHEMA_VERSION = 2
|
||||
SCHEMA_VERSION = 3
|
||||
|
||||
|
||||
def connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||
@@ -134,11 +134,21 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
date_iso TEXT NOT NULL DEFAULT '',
|
||||
time_range TEXT NOT NULL DEFAULT '',
|
||||
duration_minutes INTEGER,
|
||||
message_time TEXT NOT NULL DEFAULT '',
|
||||
message_date TEXT NOT NULL DEFAULT '',
|
||||
sender TEXT NOT NULL DEFAULT '',
|
||||
sender_id TEXT NOT NULL DEFAULT '',
|
||||
source_id TEXT NOT NULL DEFAULT '',
|
||||
source_db TEXT NOT NULL DEFAULT '',
|
||||
local_id TEXT NOT NULL DEFAULT '',
|
||||
recognition_source TEXT NOT NULL DEFAULT '',
|
||||
confidence TEXT NOT NULL DEFAULT '',
|
||||
teacher_trusted INTEGER NOT NULL DEFAULT 0,
|
||||
remark TEXT NOT NULL DEFAULT '',
|
||||
semantic_key TEXT NOT NULL DEFAULT '',
|
||||
content_hash TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
@@ -164,11 +174,25 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_class_records_student_date ON class_records(student, record_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_class_records_teacher ON class_records(teacher);
|
||||
CREATE INDEX IF NOT EXISTS idx_class_records_record_date ON class_records(record_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_course_summaries_identity ON course_summaries(student, teacher, subject, date_iso, time_range);
|
||||
CREATE INDEX IF NOT EXISTS idx_course_summaries_source_id ON course_summaries(source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_course_summaries_semantic_key ON course_summaries(semantic_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_tasks_status_type ON admin_tasks(status, task_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_operation_logs_created_at ON operation_logs(created_at);
|
||||
"""
|
||||
)
|
||||
ensure_column(conn, "students", "primary_entry_year", "INTEGER")
|
||||
ensure_column(conn, "course_summaries", "duration_minutes", "INTEGER")
|
||||
ensure_column(conn, "course_summaries", "message_date", "TEXT NOT NULL DEFAULT ''")
|
||||
ensure_column(conn, "course_summaries", "sender_id", "TEXT NOT NULL DEFAULT ''")
|
||||
ensure_column(conn, "course_summaries", "source_db", "TEXT NOT NULL DEFAULT ''")
|
||||
ensure_column(conn, "course_summaries", "local_id", "TEXT NOT NULL DEFAULT ''")
|
||||
ensure_column(conn, "course_summaries", "recognition_source", "TEXT NOT NULL DEFAULT ''")
|
||||
ensure_column(conn, "course_summaries", "confidence", "TEXT NOT NULL DEFAULT ''")
|
||||
ensure_column(conn, "course_summaries", "teacher_trusted", "INTEGER NOT NULL DEFAULT 0")
|
||||
ensure_column(conn, "course_summaries", "remark", "TEXT NOT NULL DEFAULT ''")
|
||||
ensure_column(conn, "course_summaries", "payload_json", "TEXT NOT NULL DEFAULT '{}'")
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', ?)",
|
||||
(str(SCHEMA_VERSION),),
|
||||
|
||||
+5
-10
@@ -3,21 +3,16 @@ from __future__ import annotations
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .config import OPERATION_LOGS_PATH, USE_SQLITE_SOURCE, write_lock
|
||||
from .data import migrate_operation_log_labels
|
||||
from .repository import ensure_runtime_cache
|
||||
from .routers import accounts, admin, ai_register, health, ingest, pages, records
|
||||
from .repository import initialize_runtime_database
|
||||
from .routers import admin, ai_register, health, ingest, pages, records, student_profiles
|
||||
|
||||
|
||||
app = FastAPI(title="新时空教务管理系统", version="1.0.0")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def prepare_sqlite_runtime_cache() -> None:
|
||||
if USE_SQLITE_SOURCE:
|
||||
ensure_runtime_cache()
|
||||
with write_lock:
|
||||
migrate_operation_log_labels(OPERATION_LOGS_PATH)
|
||||
def prepare_sqlite_runtime() -> None:
|
||||
initialize_runtime_database()
|
||||
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
@@ -28,7 +23,7 @@ async def value_error_handler(_request: Request, exc: ValueError):
|
||||
app.include_router(pages.router)
|
||||
app.include_router(health.router)
|
||||
app.include_router(records.router)
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(student_profiles.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(ai_register.router)
|
||||
app.include_router(ingest.router)
|
||||
|
||||
+2661
-179
File diff suppressed because it is too large
Load Diff
@@ -1,273 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from ..api_utils import file_meta, load_accounts, load_teachers, payload_to_account, payload_to_teacher, read_register_payload
|
||||
from ..auth import verify_accounts_auth, verify_admin_auth
|
||||
from ..config import (
|
||||
ACCOUNTS_PATH,
|
||||
ADMIN_TASKS_PATH,
|
||||
CLASSNOTES_PATH,
|
||||
COURSE_SUMMARIES_ROOT,
|
||||
COURSE_SUMMARY_STATE_PATH,
|
||||
LEGACY_TEXT_ROOT,
|
||||
OPERATION_LOGS_PATH,
|
||||
TEACHERS_PATH,
|
||||
USE_SQLITE_SOURCE,
|
||||
write_lock,
|
||||
)
|
||||
from ..db import database_meta
|
||||
from ..data import (
|
||||
ACCOUNT_STATUSES,
|
||||
TEACHER_STATUSES,
|
||||
DuplicateRecordError,
|
||||
account_summary,
|
||||
account_to_dict,
|
||||
append_operation_log,
|
||||
create_account,
|
||||
create_teacher,
|
||||
duration_text_from_hours,
|
||||
filter_accounts,
|
||||
iter_course_summary_markdown,
|
||||
parse_class_record_line,
|
||||
read_classnotes,
|
||||
register_class_record_lines,
|
||||
register_course_summary_texts,
|
||||
register_payment_lines,
|
||||
teacher_to_dict,
|
||||
update_account,
|
||||
update_teacher,
|
||||
)
|
||||
from ..schemas import AccountPayload, TeacherPayload
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def compact_duration_text(hours: float) -> str:
|
||||
text = duration_text_from_hours(hours)
|
||||
return text[:-2] if text.endswith("小时0分") else text
|
||||
|
||||
|
||||
@router.post("/api/register/class-records")
|
||||
async def register_class_records(request: Request, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
payload = await read_register_payload(request)
|
||||
with write_lock:
|
||||
result = register_class_record_lines(
|
||||
CLASSNOTES_PATH,
|
||||
ACCOUNTS_PATH,
|
||||
lines=payload.lines,
|
||||
line=payload.line,
|
||||
)
|
||||
for line in result.get("lines", []):
|
||||
record = parse_class_record_line(str(line))
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
str(result.get("operation") or "登记上课记录"),
|
||||
"完成",
|
||||
student=record.student,
|
||||
proposed_line=str(line),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
except DuplicateRecordError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@router.post("/api/register/payments")
|
||||
async def register_payments(request: Request, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
payload = await read_register_payload(request)
|
||||
with write_lock:
|
||||
result = register_payment_lines(ACCOUNTS_PATH, lines=payload.lines, line=payload.line)
|
||||
for line in result.get("lines", []):
|
||||
student = str(line).split("-", 1)[0]
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
str(result.get("operation") or "登记缴费记录"),
|
||||
"完成",
|
||||
student=student,
|
||||
proposed_line=str(line),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@router.post("/api/register/course-summaries")
|
||||
async def register_course_summaries(request: Request, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
payload = await read_register_payload(request)
|
||||
with write_lock:
|
||||
result = register_course_summary_texts(
|
||||
classnotes_path=CLASSNOTES_PATH,
|
||||
accounts_path=ACCOUNTS_PATH,
|
||||
tasks_path=ADMIN_TASKS_PATH,
|
||||
summaries_root=COURSE_SUMMARIES_ROOT,
|
||||
state_path=COURSE_SUMMARY_STATE_PATH,
|
||||
operation_logs_path=OPERATION_LOGS_PATH,
|
||||
lines=payload.lines,
|
||||
line=payload.line,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@router.get("/api/student-health")
|
||||
def student_health(_user: str = Depends(verify_accounts_auth)):
|
||||
accounts = load_accounts()
|
||||
teachers = load_teachers()
|
||||
records = read_classnotes(CLASSNOTES_PATH) if CLASSNOTES_PATH.exists() else []
|
||||
current_month_prefix = date.today().strftime("%Y.%m.")
|
||||
current_month_hours = round(
|
||||
sum(record.duration_hours for record in records if record.date.startswith(current_month_prefix)),
|
||||
2,
|
||||
)
|
||||
course_summaries_count = sum(1 for _item in iter_course_summary_markdown(COURSE_SUMMARIES_ROOT))
|
||||
return {
|
||||
"ok": True,
|
||||
"students": file_meta(ACCOUNTS_PATH),
|
||||
"teachers": file_meta(TEACHERS_PATH),
|
||||
"classnotes": file_meta(CLASSNOTES_PATH),
|
||||
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
|
||||
"database": database_meta(),
|
||||
"legacy_archive": {
|
||||
"source_mode": "sqlite" if USE_SQLITE_SOURCE else "text",
|
||||
"text_root": str(LEGACY_TEXT_ROOT),
|
||||
},
|
||||
"students_count": len(accounts),
|
||||
"teachers_count": len(teachers),
|
||||
"active_teachers_count": sum(1 for teacher in teachers if teacher.status == "在岗"),
|
||||
"records_count": len(records),
|
||||
"course_summaries_count": course_summaries_count,
|
||||
"current_month_hours": current_month_hours,
|
||||
"current_month_duration": compact_duration_text(current_month_hours),
|
||||
"student_summary": account_summary(accounts),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/students")
|
||||
def students(
|
||||
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),
|
||||
"students": [account_to_dict(account) for account in rows],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/students/{student}")
|
||||
def student_detail(student: str, _user: str = Depends(verify_accounts_auth)):
|
||||
for account in load_accounts():
|
||||
if account.student == student or account.student_id == student:
|
||||
return account_to_dict(account)
|
||||
raise HTTPException(status_code=404, detail=f"未找到学生档案: {student}")
|
||||
|
||||
|
||||
@router.get("/api/admin/statuses")
|
||||
def admin_statuses(_user: str = Depends(verify_admin_auth)):
|
||||
return {"account_statuses": sorted(ACCOUNT_STATUSES)}
|
||||
|
||||
|
||||
@router.get("/api/admin/teacher-statuses")
|
||||
def admin_teacher_statuses(_user: str = Depends(verify_admin_auth)):
|
||||
return {"teacher_statuses": sorted(TEACHER_STATUSES)}
|
||||
|
||||
|
||||
@router.get("/api/admin/teachers")
|
||||
def admin_teachers(_user: str = Depends(verify_admin_auth)):
|
||||
return {
|
||||
"teachers": [teacher_to_dict(teacher) for teacher in load_teachers()],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/admin/teachers")
|
||||
def admin_create_teacher(payload: TeacherPayload, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = create_teacher(TEACHERS_PATH, payload_to_teacher(payload))
|
||||
log_operation = str(result.get("operation") or "新增老师档案")
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
log_operation,
|
||||
"完成",
|
||||
teacher_id=str(result.get("teacher", {}).get("teacher_id") or ""),
|
||||
teacher=str(result.get("teacher", {}).get("name") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@router.put("/api/admin/teachers/{teacher_id}")
|
||||
def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = update_teacher(
|
||||
TEACHERS_PATH,
|
||||
teacher_id,
|
||||
payload_to_teacher(payload, teacher_id=payload.teacher_id.strip() or teacher_id),
|
||||
)
|
||||
log_operation = str(result.get("operation") or "修改老师档案")
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
log_operation,
|
||||
"完成",
|
||||
teacher_id=str(result.get("teacher", {}).get("teacher_id") or ""),
|
||||
teacher=str(result.get("teacher", {}).get("name") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@router.post("/api/admin/students")
|
||||
def admin_create_student(payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = create_account(ACCOUNTS_PATH, payload_to_account(payload), CLASSNOTES_PATH)
|
||||
log_operation = str(result.get("operation") or "新增学生档案")
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
log_operation,
|
||||
"完成",
|
||||
student_id=str(result.get("account", {}).get("student_id") or ""),
|
||||
student=str(result.get("account", {}).get("student") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@router.put("/api/admin/students/{student_id}")
|
||||
def admin_update_student(student_id: str, payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload), CLASSNOTES_PATH)
|
||||
log_operation = str(result.get("operation") or "修改学生档案")
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
log_operation,
|
||||
"完成",
|
||||
student_id=str(result.get("account", {}).get("student_id") or ""),
|
||||
student=str(result.get("account", {}).get("student") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
+23
-218
@@ -1,25 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ..auth import verify_admin_auth
|
||||
from ..config import (
|
||||
ACCOUNTS_PATH,
|
||||
ADMIN_TASKS_PATH,
|
||||
CLASSNOTES_PATH,
|
||||
COURSE_SUMMARIES_ROOT,
|
||||
COURSE_SUMMARY_STATE_PATH,
|
||||
OPERATION_LOGS_PATH,
|
||||
TEACHERS_PATH,
|
||||
write_lock,
|
||||
)
|
||||
from ..data import (
|
||||
admin_dashboard_summary,
|
||||
append_operation_log,
|
||||
from ..repository import (
|
||||
admin_dashboard,
|
||||
approve_admin_task,
|
||||
create_course_summary_duplicate_review_tasks,
|
||||
delete_course_summary,
|
||||
link_existing_course_summary_task,
|
||||
list_admin_tasks,
|
||||
@@ -28,6 +14,7 @@ from ..data import (
|
||||
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,
|
||||
@@ -38,28 +25,10 @@ from ..data import (
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def rollback_backup_paths() -> list[Path]:
|
||||
return [
|
||||
CLASSNOTES_PATH,
|
||||
ACCOUNTS_PATH,
|
||||
ADMIN_TASKS_PATH,
|
||||
COURSE_SUMMARIES_ROOT,
|
||||
OPERATION_LOGS_PATH,
|
||||
]
|
||||
|
||||
|
||||
@router.get("/api/admin/dashboard")
|
||||
def admin_dashboard(period: str = Query("month"), _user: str = Depends(verify_admin_auth)):
|
||||
def admin_dashboard_endpoint(period: str = Query("month"), _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
return admin_dashboard_summary(
|
||||
classnotes_path=CLASSNOTES_PATH,
|
||||
accounts_path=ACCOUNTS_PATH,
|
||||
teachers_path=TEACHERS_PATH,
|
||||
tasks_path=ADMIN_TASKS_PATH,
|
||||
summaries_root=COURSE_SUMMARIES_ROOT,
|
||||
operation_logs_path=OPERATION_LOGS_PATH,
|
||||
period=period,
|
||||
)
|
||||
return admin_dashboard(period=period)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@@ -72,17 +41,7 @@ def admin_tasks(
|
||||
offset: int = Query(0, ge=0),
|
||||
_user: str = Depends(verify_admin_auth),
|
||||
):
|
||||
try:
|
||||
return list_admin_tasks(
|
||||
ADMIN_TASKS_PATH,
|
||||
status_filter=status_filter,
|
||||
task_type=task_type,
|
||||
classnotes_path=CLASSNOTES_PATH,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return list_admin_tasks(status_filter=status_filter, task_type=task_type, offset=offset, limit=limit)
|
||||
|
||||
|
||||
@router.get("/api/admin/operation-logs")
|
||||
@@ -94,34 +53,13 @@ def admin_operation_logs(
|
||||
student: str = Query(""),
|
||||
_user: str = Depends(verify_admin_auth),
|
||||
):
|
||||
return list_operation_logs(
|
||||
OPERATION_LOGS_PATH,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
operation=operation,
|
||||
status_filter=status_filter,
|
||||
student=student,
|
||||
backup_paths=rollback_backup_paths(),
|
||||
)
|
||||
return list_operation_logs(limit=limit, offset=offset, operation=operation, status_filter=status_filter, student=student)
|
||||
|
||||
|
||||
@router.post("/api/admin/operation-logs/{log_id}/rollback")
|
||||
def admin_rollback_operation_log(log_id: str, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = rollback_operation_log(OPERATION_LOGS_PATH, log_id, rollback_backup_paths())
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"撤回操作",
|
||||
"已撤回",
|
||||
target_log_id=log_id,
|
||||
target_backup_id=str(result.get("target_backup_id") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
restored_files=result.get("restored_files") or [],
|
||||
rollback_mode=str(result.get("rollback_mode") or ""),
|
||||
removed_lines=result.get("removed_lines") or [],
|
||||
restored_hours=result.get("restored_hours") or "",
|
||||
)
|
||||
result = rollback_operation_log(log_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -144,8 +82,6 @@ def admin_course_summaries(
|
||||
):
|
||||
try:
|
||||
return query_course_summaries(
|
||||
COURSE_SUMMARIES_ROOT,
|
||||
CLASSNOTES_PATH,
|
||||
q=q,
|
||||
student=student,
|
||||
teacher=teacher,
|
||||
@@ -165,20 +101,7 @@ def admin_course_summaries(
|
||||
@router.post("/api/admin/tasks/{task_id}/approve")
|
||||
def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = approve_admin_task(ADMIN_TASKS_PATH, COURSE_SUMMARIES_ROOT, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id)
|
||||
task = result.get("task", {})
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"审核批准",
|
||||
"已批准",
|
||||
task_id=task_id,
|
||||
task_type=str(task.get("type") or ""),
|
||||
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
|
||||
source_id=str(task.get("source_id") or ""),
|
||||
backup_id=str(result.get("backup_id") or task.get("backup_id") or ""),
|
||||
saved_path=str(task.get("saved_path") or ""),
|
||||
)
|
||||
result = approve_admin_task(task_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -186,26 +109,8 @@ def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
||||
|
||||
@router.post("/api/admin/tasks/{task_id}/resolve-duplicate-summary")
|
||||
def admin_resolve_duplicate_summary(task_id: int, payload: dict, _user: str = Depends(verify_admin_auth)):
|
||||
delete_summary_id = str(payload.get("delete_summary_id") or "")
|
||||
try:
|
||||
with write_lock:
|
||||
result = resolve_duplicate_course_summary_task(
|
||||
ADMIN_TASKS_PATH,
|
||||
COURSE_SUMMARIES_ROOT,
|
||||
task_id,
|
||||
delete_summary_id,
|
||||
)
|
||||
task = result.get("task", {})
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"重复小结删除",
|
||||
"已删除",
|
||||
task_id=task_id,
|
||||
task_type=str(task.get("type") or ""),
|
||||
student=str(task.get("student") or ""),
|
||||
summary_id=str(result.get("deleted_summary_id") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
result = resolve_duplicate_course_summary_task(task_id, str(payload.get("delete_summary_id") or ""))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -214,27 +119,7 @@ def admin_resolve_duplicate_summary(task_id: int, payload: dict, _user: str = De
|
||||
@router.post("/api/admin/tasks/{task_id}/course-summary-review")
|
||||
def admin_update_course_summary_review(task_id: int, payload: dict, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = update_course_summary_review_task(
|
||||
ADMIN_TASKS_PATH,
|
||||
COURSE_SUMMARIES_ROOT,
|
||||
COURSE_SUMMARY_STATE_PATH,
|
||||
CLASSNOTES_PATH,
|
||||
ACCOUNTS_PATH,
|
||||
task_id,
|
||||
payload,
|
||||
)
|
||||
task = result.get("task", {})
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"课程小结审核修正",
|
||||
"已更新",
|
||||
task_id=task_id,
|
||||
task_type=str(task.get("type") or ""),
|
||||
student=str(task.get("student") or ""),
|
||||
source_id=str(task.get("source_id") or ""),
|
||||
proposed_line=str(task.get("proposed_line") or ""),
|
||||
)
|
||||
result = update_course_summary_review_task(task_id, payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -243,19 +128,7 @@ def admin_update_course_summary_review(task_id: int, payload: dict, _user: str =
|
||||
@router.post("/api/admin/tasks/{task_id}/link-existing-course-summary")
|
||||
def admin_link_existing_course_summary(task_id: int, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = link_existing_course_summary_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, task_id)
|
||||
task = result.get("task", {})
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"课程小结关联已有记录",
|
||||
"已批准",
|
||||
task_id=task_id,
|
||||
task_type=str(task.get("type") or ""),
|
||||
student=str(task.get("student") or ""),
|
||||
source_id=str(task.get("source_id") or ""),
|
||||
proposed_line=str(result.get("linked_record_line") or ""),
|
||||
)
|
||||
result = link_existing_course_summary_task(task_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -264,17 +137,7 @@ def admin_link_existing_course_summary(task_id: int, _user: str = Depends(verify
|
||||
@router.post("/api/admin/tasks/{task_id}/reject")
|
||||
def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
task = reject_admin_task(ADMIN_TASKS_PATH, task_id)
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"审核驳回",
|
||||
"已驳回",
|
||||
task_id=task_id,
|
||||
task_type=str(task.get("type") or ""),
|
||||
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
|
||||
source_id=str(task.get("source_id") or ""),
|
||||
)
|
||||
task = reject_admin_task(task_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, "task": task}
|
||||
@@ -283,19 +146,7 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
||||
@router.post("/api/admin/course-summaries/duplicate-scan")
|
||||
def admin_scan_duplicate_course_summaries(_user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = create_course_summary_duplicate_review_tasks(
|
||||
ADMIN_TASKS_PATH,
|
||||
COURSE_SUMMARIES_ROOT,
|
||||
classnotes_path=CLASSNOTES_PATH,
|
||||
)
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"重复小结扫描",
|
||||
"待审核" if result.get("created") else "完成",
|
||||
scanned_groups=int(result.get("scanned") or 0),
|
||||
created_tasks=int(result.get("created") or 0),
|
||||
)
|
||||
result = scan_duplicate_course_summaries()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -304,16 +155,7 @@ def admin_scan_duplicate_course_summaries(_user: str = Depends(verify_admin_auth
|
||||
@router.post("/api/admin/course-summaries/{summary_id}/time")
|
||||
def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = update_course_summary_time(COURSE_SUMMARIES_ROOT, summary_id, str(payload.get("time_range") or ""))
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"课程小结补齐时间",
|
||||
"已更新",
|
||||
summary_id=summary_id,
|
||||
time_range=str(payload.get("time_range") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
result = update_course_summary_time(summary_id, str(payload.get("time_range") or ""))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -322,15 +164,7 @@ def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str
|
||||
@router.post("/api/admin/course-summaries/{summary_id}/body")
|
||||
def admin_update_course_summary_body(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = update_course_summary_body(COURSE_SUMMARIES_ROOT, summary_id, str(payload.get("body") or ""))
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"课程小结修改正文",
|
||||
"已更新",
|
||||
summary_id=summary_id,
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
result = update_course_summary_body(summary_id, str(payload.get("body") or ""))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -339,33 +173,12 @@ def admin_update_course_summary_body(summary_id: str, payload: dict, _user: str
|
||||
@router.post("/api/admin/course-summaries/{summary_id}/identity")
|
||||
def admin_update_course_summary_identity(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = update_course_summary_identity(
|
||||
COURSE_SUMMARIES_ROOT,
|
||||
COURSE_SUMMARY_STATE_PATH,
|
||||
TEACHERS_PATH,
|
||||
summary_id,
|
||||
str(payload.get("student") or ""),
|
||||
str(payload.get("teacher") or ""),
|
||||
str(payload.get("subject") or ""),
|
||||
)
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"课程小结修改归属",
|
||||
"已更新",
|
||||
summary_id=summary_id,
|
||||
new_summary_id=str(result.get("new_id") or ""),
|
||||
student=str(result.get("student") or ""),
|
||||
old_student=str(result.get("old_student") or ""),
|
||||
teacher=str(result.get("teacher") or ""),
|
||||
old_teacher=str(result.get("old_teacher") or ""),
|
||||
subject=str(result.get("subject") or ""),
|
||||
old_subject=str(result.get("old_subject") or ""),
|
||||
old_path=str(result.get("old_path") or ""),
|
||||
new_path=str(result.get("new_path") or ""),
|
||||
heading=str(result.get("heading") or ""),
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
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}
|
||||
@@ -374,15 +187,7 @@ def admin_update_course_summary_identity(summary_id: str, payload: dict, _user:
|
||||
@router.delete("/api/admin/course-summaries/{summary_id}")
|
||||
def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_admin_auth)):
|
||||
try:
|
||||
with write_lock:
|
||||
result = delete_course_summary(COURSE_SUMMARIES_ROOT, summary_id)
|
||||
append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"课程小结删除",
|
||||
"已删除",
|
||||
summary_id=summary_id,
|
||||
backup_id=str(result.get("backup_id") or ""),
|
||||
)
|
||||
result = delete_course_summary(summary_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
|
||||
@@ -2,20 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..api_utils import file_meta, load_accounts, load_records, load_teachers
|
||||
from ..auth import verify_records_auth
|
||||
from ..config import (
|
||||
ACCOUNTS_PATH,
|
||||
CLASSNOTES_PATH,
|
||||
COURSE_SUMMARIES_ROOT,
|
||||
COURSE_SUMMARY_STATE_PATH,
|
||||
LEGACY_TEXT_ROOT,
|
||||
OPERATION_LOGS_PATH,
|
||||
TEACHERS_PATH,
|
||||
USE_SQLITE_SOURCE,
|
||||
)
|
||||
from ..db import database_meta
|
||||
from ..data import account_summary
|
||||
from ..repository import health_payload
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
@@ -23,24 +11,4 @@ router = APIRouter()
|
||||
|
||||
@router.get("/api/health")
|
||||
def health(_user: str = Depends(verify_records_auth)):
|
||||
records = load_records()
|
||||
accounts = load_accounts()
|
||||
teachers = load_teachers()
|
||||
return {
|
||||
"ok": True,
|
||||
"classnotes": file_meta(CLASSNOTES_PATH),
|
||||
"accounts": file_meta(ACCOUNTS_PATH),
|
||||
"teachers": file_meta(TEACHERS_PATH),
|
||||
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
|
||||
"course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH),
|
||||
"operation_logs": file_meta(OPERATION_LOGS_PATH),
|
||||
"database": database_meta(),
|
||||
"legacy_archive": {
|
||||
"source_mode": "sqlite" if USE_SQLITE_SOURCE else "text",
|
||||
"text_root": str(LEGACY_TEXT_ROOT),
|
||||
},
|
||||
"records_count": len(records),
|
||||
"accounts_count": len(accounts),
|
||||
"teachers_count": len(teachers),
|
||||
"account_summary": account_summary(accounts),
|
||||
}
|
||||
return health_payload()
|
||||
|
||||
@@ -4,16 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..auth import verify_ingest_token
|
||||
from ..ai_register import preview_register
|
||||
from ..config import (
|
||||
ACCOUNTS_PATH,
|
||||
ADMIN_TASKS_PATH,
|
||||
CLASSNOTES_PATH,
|
||||
COURSE_SUMMARIES_ROOT,
|
||||
COURSE_SUMMARY_STATE_PATH,
|
||||
OPERATION_LOGS_PATH,
|
||||
write_lock,
|
||||
)
|
||||
from ..data import ingest_course_summaries
|
||||
from ..repository import ingest_course_summaries
|
||||
from ..schemas import CourseSummaryIngestPayload
|
||||
|
||||
|
||||
@@ -97,19 +88,12 @@ def _preprocess_summaries_locally(summaries: list[dict]) -> list[dict]:
|
||||
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
|
||||
try:
|
||||
summaries = _preprocess_summaries_locally([item.dict() for item in payload.summaries])
|
||||
with write_lock:
|
||||
result = ingest_course_summaries(
|
||||
classnotes_path=CLASSNOTES_PATH,
|
||||
accounts_path=ACCOUNTS_PATH,
|
||||
tasks_path=ADMIN_TASKS_PATH,
|
||||
summaries_root=COURSE_SUMMARIES_ROOT,
|
||||
state_path=COURSE_SUMMARY_STATE_PATH,
|
||||
operation_logs_path=OPERATION_LOGS_PATH,
|
||||
batch_id=payload.batch_id,
|
||||
window=payload.window,
|
||||
students=payload.students,
|
||||
summaries=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}
|
||||
|
||||
+7
-123
@@ -1,30 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ..api_utils import load_records, load_teachers
|
||||
from ..auth import verify_records_auth
|
||||
from ..config import (
|
||||
ADMIN_TASKS_PATH,
|
||||
COURSE_SUMMARIES_ROOT,
|
||||
COURSE_SUMMARY_STATE_PATH,
|
||||
OPERATION_LOGS_PATH,
|
||||
write_lock,
|
||||
)
|
||||
from ..data import (
|
||||
append_operation_log,
|
||||
course_summary_semantic_key,
|
||||
duration_minutes_from_time_range,
|
||||
find_record_by_identity,
|
||||
from ..repository import (
|
||||
query_public_records,
|
||||
read_course_summary_state,
|
||||
save_course_summary_markdown,
|
||||
sha1_text,
|
||||
submit_public_correction_tasks,
|
||||
submit_public_deletion_tasks,
|
||||
write_course_summary_state,
|
||||
supplement_course_summary,
|
||||
)
|
||||
from ..schemas import CorrectionSubmitPayload, CourseSummarySupplementPayload, DeletionSubmitPayload
|
||||
|
||||
@@ -42,28 +25,13 @@ def records(
|
||||
):
|
||||
effective_limit = 0 if show_all else limit
|
||||
effective_offset = 0 if show_all else offset
|
||||
return query_public_records(
|
||||
load_records(),
|
||||
load_teachers(),
|
||||
q,
|
||||
limit=effective_limit,
|
||||
offset=effective_offset,
|
||||
summaries_root=COURSE_SUMMARIES_ROOT,
|
||||
)
|
||||
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:
|
||||
records = load_records()
|
||||
teachers = load_teachers()
|
||||
with write_lock:
|
||||
result = submit_public_correction_tasks(
|
||||
ADMIN_TASKS_PATH,
|
||||
records,
|
||||
teachers,
|
||||
[item.dict() for item in payload.items],
|
||||
)
|
||||
result = submit_public_correction_tasks([item.dict() for item in payload.items])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
@@ -72,99 +40,15 @@ def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(ve
|
||||
@router.post("/api/deletions")
|
||||
def submit_deletions(payload: DeletionSubmitPayload, _user: str = Depends(verify_records_auth)):
|
||||
try:
|
||||
records = load_records()
|
||||
with write_lock:
|
||||
result = submit_public_deletion_tasks(
|
||||
ADMIN_TASKS_PATH,
|
||||
records,
|
||||
[item.dict() for item in payload.items],
|
||||
)
|
||||
result = submit_public_deletion_tasks([item.dict() for item in payload.items])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@router.post("/api/course-summaries/supplement")
|
||||
def supplement_course_summary(payload: CourseSummarySupplementPayload, _user: str = Depends(verify_records_auth)):
|
||||
body = payload.body.strip()
|
||||
if not body:
|
||||
raise HTTPException(status_code=400, detail="课程小结正文不能为空")
|
||||
def supplement_course_summary_endpoint(payload: CourseSummarySupplementPayload, _user: str = Depends(verify_records_auth)):
|
||||
try:
|
||||
records = load_records()
|
||||
record = find_record_by_identity(records, payload.record_id)
|
||||
date_iso = record.date.replace(".", "-")
|
||||
source_id = f"supplement:{payload.record_id}:{sha1_text(body, 12)}"
|
||||
summary = {
|
||||
"source_id": source_id,
|
||||
"student": record.student,
|
||||
"date_iso": date_iso,
|
||||
"time_range": record.time,
|
||||
"duration_minutes": duration_minutes_from_time_range(record.time),
|
||||
"teacher": record.teacher,
|
||||
"subject": record.subject,
|
||||
"group": "",
|
||||
"sender": "课程记录页补充",
|
||||
"message_time": "",
|
||||
"body": body,
|
||||
}
|
||||
semantic_key = course_summary_semantic_key(summary)
|
||||
with write_lock:
|
||||
state = read_course_summary_state(COURSE_SUMMARY_STATE_PATH)
|
||||
seen_source_ids = set(str(item) for item in state.get("seen_source_ids", []))
|
||||
seen_semantic_keys = set(str(item) for item in state.get("seen_semantic_keys", []))
|
||||
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
|
||||
log_id = append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"课程小结补充",
|
||||
"重复",
|
||||
record_id=payload.record_id,
|
||||
student=record.student,
|
||||
teacher=record.teacher,
|
||||
subject=record.subject,
|
||||
source_id=source_id,
|
||||
)
|
||||
return {"ok": True, "log_id": log_id, "submitted": 0, "status": "duplicate"}
|
||||
saved = save_course_summary_markdown(COURSE_SUMMARIES_ROOT, summary)
|
||||
status_value = "完成" if saved.get("added") else "重复"
|
||||
seen_source_ids.add(source_id)
|
||||
seen_semantic_keys.add(semantic_key)
|
||||
state["seen_source_ids"] = sorted(seen_source_ids)
|
||||
state["seen_semantic_keys"] = sorted(seen_semantic_keys)
|
||||
state.setdefault("batches", []).append(
|
||||
{
|
||||
"batch_id": f"supplement-{payload.record_id}-{sha1_text(body, 8)}",
|
||||
"received_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"window": {"source": "records_page_supplement"},
|
||||
"students": [record.student],
|
||||
"result": {
|
||||
"received": 1,
|
||||
"saved": 1 if saved.get("added") else 0,
|
||||
"auto_registered": 0,
|
||||
"review_pending": 0,
|
||||
"duplicates": 0 if saved.get("added") else 1,
|
||||
"rejected": 0,
|
||||
},
|
||||
}
|
||||
)
|
||||
state["batches"] = state["batches"][-200:]
|
||||
write_course_summary_state(COURSE_SUMMARY_STATE_PATH, state)
|
||||
log_id = append_operation_log(
|
||||
OPERATION_LOGS_PATH,
|
||||
"课程小结补充",
|
||||
status_value,
|
||||
record_id=payload.record_id,
|
||||
student=record.student,
|
||||
teacher=record.teacher,
|
||||
subject=record.subject,
|
||||
source_id=source_id,
|
||||
saved_path=str(saved.get("path") or ""),
|
||||
heading=str(saved.get("heading") or ""),
|
||||
)
|
||||
return supplement_course_summary(payload.record_id, payload.body)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {
|
||||
"ok": True,
|
||||
"log_id": log_id,
|
||||
"submitted": 1 if status_value == "完成" else 0,
|
||||
"status": "saved" if status_value == "完成" else "duplicate",
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
+1
-1
@@ -21,7 +21,7 @@ class PaymentPayload(BaseModel):
|
||||
hours: float
|
||||
|
||||
|
||||
class AccountPayload(BaseModel):
|
||||
class StudentProfilePayload(BaseModel):
|
||||
student_id: str = ""
|
||||
student: str
|
||||
payments: list[PaymentPayload] = Field(default_factory=list)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>管理后台</title>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20260620-dashboard" />
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20260706-sqlite-native" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -238,8 +238,6 @@
|
||||
<div id="reviewPager" class="pager" hidden></div>
|
||||
</section>
|
||||
|
||||
<section id="summariesPanel" class="panel admin-panel" hidden></section>
|
||||
|
||||
<div id="summaryReviewDrawerBackdrop" class="drawer-backdrop" hidden>
|
||||
<aside class="summary-review-drawer" role="dialog" aria-modal="true" aria-labelledby="summaryReviewDrawerTitle">
|
||||
<div class="drawer-head">
|
||||
@@ -495,6 +493,6 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/admin.js?v=20260620-dashboard"></script>
|
||||
<script src="/static/admin.js?v=20260706-sqlite-native"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-12
@@ -5,7 +5,6 @@ const panels = {
|
||||
students: document.querySelector("#studentsPanel"),
|
||||
teachers: document.querySelector("#teachersPanel"),
|
||||
reviews: document.querySelector("#reviewsPanel"),
|
||||
summaries: document.querySelector("#summariesPanel"),
|
||||
summarySearch: document.querySelector("#summarySearchPanel"),
|
||||
logs: document.querySelector("#logsPanel"),
|
||||
register: document.querySelector("#registerPanel"),
|
||||
@@ -476,7 +475,7 @@ async function fetchJson(url, options = {}) {
|
||||
}
|
||||
|
||||
function setActiveTab(tabName) {
|
||||
const activeTabName = tabName === "summaries" ? "summarySearch" : tabName;
|
||||
const activeTabName = tabName;
|
||||
let activeButton = null;
|
||||
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
||||
const isActive = button.dataset.adminTab === activeTabName;
|
||||
@@ -919,8 +918,8 @@ function renderDuplicateCandidate(candidate, canReview, options = {}) {
|
||||
candidate.source_id ? `来源ID:${candidate.source_id}` : "",
|
||||
candidate.message_time ? `发送时间:${candidate.message_time}` : "",
|
||||
candidate.sender ? `发送者:${candidate.sender}` : "",
|
||||
candidate.relative_path ? `文件:${candidate.relative_path}` : "",
|
||||
candidate.source_path && !candidate.relative_path ? `文件:${candidate.source_path}` : "",
|
||||
candidate.storage_key || candidate.relative_path ? `存储标识:${candidate.storage_key || candidate.relative_path}` : "",
|
||||
candidate.id ? `小结ID:${candidate.id}` : "",
|
||||
].filter(Boolean);
|
||||
return `<div class="record-summary-item duplicate-summary-candidate">
|
||||
<div class="duplicate-summary-title-row">
|
||||
@@ -962,7 +961,7 @@ function renderSummaryDuplicateConflicts(item) {
|
||||
parts.push(`<div class="summary-conflict-heading">命中的已有小结</div>`);
|
||||
parts.push(conflicts.map((candidate) => renderDuplicateCandidate(candidate, false, { showDelete: false })).join(""));
|
||||
} else {
|
||||
parts.push("<span class=\"muted\">状态文件命中重复,但正式小结库未找到对应小结。</span>");
|
||||
parts.push("<span class=\"muted\">状态表命中重复,但课程小结表未找到对应小结。</span>");
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
@@ -1098,7 +1097,7 @@ function openSummaryReviewDrawer(taskId) {
|
||||
summaryReviewDrawerConflicts.innerHTML = duplicateContext ? renderSummaryDuplicateConflicts(item) : "";
|
||||
summaryReviewDrawerSource.innerHTML = [
|
||||
item.source_id ? `来源ID:${escapeHtml(item.source_id)}` : "",
|
||||
item.saved_path ? `${duplicateContext || pendingSummarySave ? "目标文件" : "保存文件"}:${escapeHtml(item.saved_path)}` : "",
|
||||
(item.storage_key || item.saved_path) ? `${duplicateContext || pendingSummarySave ? "目标标识" : "存储标识"}:${escapeHtml(item.storage_key || item.saved_path)}` : "",
|
||||
item.batch_id ? `批次:${escapeHtml(item.batch_id)}` : "",
|
||||
].filter(Boolean).join("<br>") || "暂无来源信息";
|
||||
summaryReviewDrawerSave.hidden = duplicateReview;
|
||||
@@ -1640,7 +1639,7 @@ async function linkExistingSummaryReview() {
|
||||
}
|
||||
|
||||
async function resolveDuplicateSummary(taskId, summaryId) {
|
||||
if (!confirm("确认删除这条重复课程小结?系统会先备份来源文件。")) return;
|
||||
if (!confirm("确认删除这条重复课程小结?系统会先备份数据库快照。")) return;
|
||||
await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/resolve-duplicate-summary`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -1756,15 +1755,15 @@ function renderLogDetail(item) {
|
||||
logField("时间段", item.time_range),
|
||||
logField("上课记录", item.proposed_line),
|
||||
]),
|
||||
renderLogDetailGroup("文件与备份", [
|
||||
logField("保存文件", item.saved_path),
|
||||
logField("原文件", item.old_path),
|
||||
logField("新文件", item.new_path),
|
||||
renderLogDetailGroup("存储与备份", [
|
||||
logField("存储标识", item.storage_key),
|
||||
logField("小结ID", item.summary_id),
|
||||
logField("新小结ID", item.new_summary_id),
|
||||
logField("备份ID", item.backup_id),
|
||||
logField("撤回记录", item.target_log_id),
|
||||
logField("撤回备份", item.target_backup_id),
|
||||
logField("撤回方式", item.rollback_mode),
|
||||
logArrayField("恢复文件", item.restored_files),
|
||||
logArrayField("恢复表", item.restored_tables),
|
||||
]),
|
||||
renderLogDetailGroup("结果与原因", [
|
||||
logArrayField("原因", item.reasons),
|
||||
|
||||
+4
-34
@@ -50,7 +50,6 @@ let currentRecordOrder = [];
|
||||
let correctedRecords = new Map();
|
||||
let correctedRecordOrder = [];
|
||||
let expandedSummaryRecords = new Set();
|
||||
let expandedSummaryBodies = new Set();
|
||||
let collapsedTeacherGroups = new Set();
|
||||
let currentRecordPage = { query: "", offset: 0, limit: 0, total: 0, shown: 0, hasMore: false };
|
||||
let activeCorrectionKey = "";
|
||||
@@ -244,16 +243,7 @@ function renderRecordRow(row, rowNumber) {
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function summaryBodyKey(recordKey, index) {
|
||||
return `${recordKey}:${index}`;
|
||||
}
|
||||
|
||||
function summaryPreviewText(value) {
|
||||
const text = String(value || "暂无正文");
|
||||
return text.length > 120 ? `${text.slice(0, 120)}...` : text;
|
||||
}
|
||||
|
||||
function renderSummaryEntry(summary, recordKey, index) {
|
||||
function renderSummaryEntry(summary) {
|
||||
const title = summary.title || "课程小结";
|
||||
const time = summary.time_range ? ` · ${summary.time_range}` : "";
|
||||
const meta = [
|
||||
@@ -261,15 +251,11 @@ function renderSummaryEntry(summary, recordKey, index) {
|
||||
summary.teacher || "",
|
||||
summary.subject || "",
|
||||
].filter(Boolean).join(" · ");
|
||||
const bodyKey = summaryBodyKey(recordKey, index);
|
||||
const body = summary.body || "暂无正文";
|
||||
const expanded = expandedSummaryBodies.has(bodyKey);
|
||||
const canToggle = body.length > 120;
|
||||
return `<div class="record-summary-item">
|
||||
<div class="record-summary-title">${escapeHtml(title)}${escapeHtml(time)}</div>
|
||||
${meta ? `<div class="record-summary-meta">${escapeHtml(meta)}</div>` : ""}
|
||||
<div class="summary-body${expanded ? " summary-full" : " summary-preview"}">${escapeHtml(expanded ? body : summaryPreviewText(body))}</div>
|
||||
${canToggle ? `<button class="summary-toggle" type="button" data-summary-body-key="${escapeHtml(bodyKey)}">${expanded ? "收起全文" : "展开全文"}</button>` : ""}
|
||||
<div class="summary-body summary-full">${escapeHtml(body)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -300,7 +286,7 @@ function renderGroupedRecords(records) {
|
||||
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, index) => renderSummaryEntry(summary, row._recordKey, index)).join("")}</td>
|
||||
<td colspan="${RECORD_TABLE_COLUMN_COUNT}">${(row.summaries || []).map((summary) => renderSummaryEntry(summary)).join("")}</td>
|
||||
</tr>`
|
||||
: renderSummaryMissingRow(row._recordKey, expandedSummaryRecords.has(row._recordKey));
|
||||
return `${renderRecordRow(row, displayIndex)}${summaryRow}`;
|
||||
@@ -341,15 +327,6 @@ function toggleTeacherGroup(teacher) {
|
||||
renderCurrentRecords();
|
||||
}
|
||||
|
||||
function toggleSummaryBody(key) {
|
||||
if (expandedSummaryBodies.has(key)) {
|
||||
expandedSummaryBodies.delete(key);
|
||||
} else {
|
||||
expandedSummaryBodies.add(key);
|
||||
}
|
||||
renderCurrentRecords();
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === "欠费") return "debt";
|
||||
if (status === "预警") return "warning";
|
||||
@@ -414,7 +391,6 @@ function resetCorrections() {
|
||||
correctedRecordOrder = [];
|
||||
currentRecordOrder = [];
|
||||
expandedSummaryRecords = new Set();
|
||||
expandedSummaryBodies = new Set();
|
||||
collapsedTeacherGroups = new Set();
|
||||
activeCorrectionKey = "";
|
||||
updateCorrectionToolbar();
|
||||
@@ -759,7 +735,7 @@ async function fetchJson(url, options = {}) {
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const data = await fetchJson("/api/health");
|
||||
healthText.textContent = `数据更新时间 ${fmtTime(data.classnotes.mtime)}`;
|
||||
healthText.textContent = `数据更新时间 ${fmtTime(data.updated_at || (data.database || {}).mtime || 0)}`;
|
||||
} catch (error) {
|
||||
healthText.textContent = `读取失败:${error.message}`;
|
||||
}
|
||||
@@ -782,7 +758,6 @@ async function queryRecords(query, options = {}) {
|
||||
} else {
|
||||
currentRecordOrder = [];
|
||||
expandedSummaryRecords = new Set();
|
||||
expandedSummaryBodies = new Set();
|
||||
collapsedTeacherGroups = new Set();
|
||||
}
|
||||
try {
|
||||
@@ -853,7 +828,6 @@ recordRows.addEventListener("click", (event) => {
|
||||
const editButton = event.target.closest(".correction-edit");
|
||||
const deleteButton = event.target.closest(".record-delete");
|
||||
const teacherToggle = event.target.closest(".teacher-group-toggle");
|
||||
const summaryToggle = event.target.closest(".summary-toggle");
|
||||
if (summarySupplementButton) {
|
||||
openSummarySupplementDialog(summarySupplementButton.dataset.recordKey);
|
||||
return;
|
||||
@@ -870,10 +844,6 @@ recordRows.addEventListener("click", (event) => {
|
||||
toggleTeacherGroup(teacherToggle.dataset.teacher);
|
||||
return;
|
||||
}
|
||||
if (summaryToggle) {
|
||||
toggleSummaryBody(summaryToggle.dataset.summaryBodyKey);
|
||||
return;
|
||||
}
|
||||
const row = event.target.closest(".record-row");
|
||||
if (row && !event.target.closest(".record-action-cell")) toggleRecordSummary(row.dataset.recordKey);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>新时空教务管理系统</title>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20260626-record-index" />
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20260706-sqlite-native" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -132,7 +132,7 @@
|
||||
<p id="deleteError" class="correction-error" hidden></p>
|
||||
<div class="correction-preview">
|
||||
<span>审核说明</span>
|
||||
<code>确认后会提交后台审核;审核通过才会删除 classnotes 中的记录,并恢复对应学生课时。</code>
|
||||
<code>确认后会提交后台审核;审核通过才会删除数据库中的上课记录,并恢复对应学生课时。</code>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="cancelDeleteBtn" class="secondary-button" type="button">取消</button>
|
||||
@@ -167,6 +167,6 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js?v=20260626-record-index"></script>
|
||||
<script src="/static/app.js?v=20260706-sqlite-native"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -933,27 +933,6 @@ textarea:focus {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.summary-body + .summary-toggle,
|
||||
.summary-full + .summary-toggle {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.summary-toggle {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--accent-strong);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-toggle:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.summary-conflict-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1286,7 +1265,6 @@ table {
|
||||
}
|
||||
|
||||
.admin-panel table,
|
||||
.accounts-panel table,
|
||||
.records-panel table {
|
||||
min-width: 820px;
|
||||
}
|
||||
|
||||
@@ -10,17 +10,8 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
USE_SQLITE_SOURCE: ${USE_SQLITE_SOURCE:-1}
|
||||
SQLITE_DB_PATH: ${SQLITE_DB_PATH:-/data/xsk_education.db}
|
||||
RUNTIME_DATA_ROOT: ${RUNTIME_DATA_ROOT:-/data/runtime_text_cache}
|
||||
LEGACY_TEXT_ROOT: ${LEGACY_TEXT_ROOT:-/data}
|
||||
CLASSNOTES_PATH: ${CLASSNOTES_PATH:-/data/classnotes.txt}
|
||||
ACCOUNTS_PATH: ${ACCOUNTS_PATH:-/data/学生课时账户.md}
|
||||
TEACHERS_PATH: ${TEACHERS_PATH:-/data/教师档案.md}
|
||||
ADMIN_TASKS_PATH: ${ADMIN_TASKS_PATH:-/data/admin_tasks.json}
|
||||
COURSE_SUMMARIES_ROOT: ${COURSE_SUMMARIES_ROOT:-/data/course_summaries}
|
||||
COURSE_SUMMARY_STATE_PATH: ${COURSE_SUMMARY_STATE_PATH:-/data/course_summary_state.json}
|
||||
OPERATION_LOGS_PATH: ${OPERATION_LOGS_PATH:-/data/operation_logs.jsonl}
|
||||
INGEST_AUTH_TOKEN: ${INGEST_AUTH_TOKEN:-}
|
||||
ports:
|
||||
- "${APP_PORT:-18080}:8000"
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.xsk.education-management.sync</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/python3</string>
|
||||
<string>/Users/yangdawei/Desktop/新时空业务源数据/tools/xsk-education-management/scripts/sync_to_vps.py</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/yangdawei/Library/Logs/xsk-education-management/sync.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/yangdawei/Library/Logs/xsk-education-management/sync.err.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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"BASIC_AUTH_USERNAME={args.auth_user}",
|
||||
f"BASIC_AUTH_PASSWORD={args.auth_password}",
|
||||
"CLASSNOTES_PATH=/data/classnotes.txt",
|
||||
"ACCOUNTS_PATH=/data/学生课时账户.md",
|
||||
"SQLITE_DB_PATH=/data/xsk_education.db",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -11,17 +11,13 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app import repository # noqa: E402
|
||||
from app.data import ( # noqa: E402
|
||||
append_operation_log,
|
||||
course_summary_to_class_record_line,
|
||||
course_summary_semantic_key,
|
||||
create_course_summary_review_task,
|
||||
normalize_course_summary,
|
||||
read_admin_tasks,
|
||||
read_course_summary_state,
|
||||
safe_filename_part,
|
||||
sha1_text,
|
||||
write_course_summary_state,
|
||||
)
|
||||
|
||||
|
||||
@@ -41,12 +37,13 @@ EXCLUDE_PREFIXES = (
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="导入历史课程小结 Markdown 到 VPS 数据目录")
|
||||
parser = argparse.ArgumentParser(description="导入历史课程小结 Markdown 到 SQLite")
|
||||
parser.add_argument("--source", required=True, type=Path, help="本机历史课程小结采集目录")
|
||||
parser.add_argument("--target", default=Path("/data/course_summaries"), type=Path, help="VPS 课程小结正式目录")
|
||||
parser.add_argument("--state", default=Path("/data/course_summary_state.json"), type=Path, help="课程小结状态文件")
|
||||
parser.add_argument("--tasks", default=Path("/data/admin_tasks.json"), type=Path, help="管理任务文件")
|
||||
parser.add_argument("--operation-logs", default=Path("/data/operation_logs.jsonl"), type=Path, help="操作日志文件")
|
||||
parser.add_argument("--db-path", default=Path("/data/xsk_education.db"), type=Path, help="SQLite 数据库路径")
|
||||
parser.add_argument("--target", type=Path, help=argparse.SUPPRESS)
|
||||
parser.add_argument("--state", type=Path, help=argparse.SUPPRESS)
|
||||
parser.add_argument("--tasks", type=Path, help=argparse.SUPPRESS)
|
||||
parser.add_argument("--operation-logs", type=Path, help=argparse.SUPPRESS)
|
||||
parser.add_argument("--missing-table", type=Path, help="历史 classnotes缺失.txt;不传则尝试 source/classnotes缺失.txt")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只统计,不写入")
|
||||
return parser.parse_args()
|
||||
@@ -292,30 +289,123 @@ def import_missing_tasks(source: Path, missing_table: Path, tasks_path: Path, dr
|
||||
return created
|
||||
|
||||
|
||||
def collect_history_entries(source: Path) -> tuple[int, list[dict], int]:
|
||||
scanned = 0
|
||||
skipped = 0
|
||||
entries: list[dict] = []
|
||||
for path in sorted(source.rglob("*.md")):
|
||||
if not should_import_markdown(path, source):
|
||||
continue
|
||||
scanned += 1
|
||||
for entry in iter_markdown_entries(path):
|
||||
try:
|
||||
entries.append(normalize_course_summary(entry))
|
||||
except ValueError:
|
||||
skipped += 1
|
||||
return scanned, entries, skipped
|
||||
|
||||
|
||||
def collect_missing_review_tasks(source: Path, missing_table: Path) -> list[tuple[dict, str]]:
|
||||
rows = parse_missing_table(missing_table)
|
||||
tasks: list[tuple[dict, str]] = []
|
||||
for row in rows:
|
||||
source_seed = "|".join(str(row.get(key, "")) for key in ("日期", "学生", "老师", "科目", "来源文件", "备注"))
|
||||
source_id = f"history-missing:{sha1_text(source_seed, 20)}"
|
||||
summary = {
|
||||
"source_id": source_id,
|
||||
"student": row.get("学生", ""),
|
||||
"date_iso": str(row.get("日期", "")).replace(".", "-"),
|
||||
"time_range": row.get("时间段", ""),
|
||||
"duration": row.get("时长", ""),
|
||||
"teacher": row.get("老师", ""),
|
||||
"subject": row.get("科目", ""),
|
||||
"group": row.get("群聊", ""),
|
||||
"title": "历史 classnotes 缺失",
|
||||
"body": f"历史 classnotes 缺失项:{source_seed}",
|
||||
"recognition_source": "history_missing_table",
|
||||
"confidence": "review",
|
||||
"teacher_trusted": False,
|
||||
"remark": row.get("备注", ""),
|
||||
}
|
||||
summary.update({key: value for key, value in source_summary_for_missing_row(source, row).items() if value not in ("", None)})
|
||||
proposed_line = ""
|
||||
try:
|
||||
proposed_line = course_summary_to_class_record_line(summary)
|
||||
except ValueError:
|
||||
pass
|
||||
tasks.append((summary, proposed_line))
|
||||
return tasks
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
source = args.source.resolve()
|
||||
target = args.target.resolve()
|
||||
repository.SQLITE_DB_PATH = args.db_path.resolve()
|
||||
missing_table = args.missing_table or (source / "classnotes缺失.txt")
|
||||
scanned, copied = copy_markdown_files(source, target, args.dry_run)
|
||||
imported, skipped = rebuild_state_from_markdown(target if target.exists() else source, args.state, args.dry_run)
|
||||
review_tasks = import_missing_tasks(source, missing_table, args.tasks, args.dry_run)
|
||||
scanned, entries, skipped = collect_history_entries(source)
|
||||
missing_tasks = collect_missing_review_tasks(source, missing_table)
|
||||
batch_id = f"history-import-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
if not args.dry_run:
|
||||
append_operation_log(
|
||||
args.operation_logs,
|
||||
"history_course_summary_import",
|
||||
"completed",
|
||||
source=str(source),
|
||||
target=str(target),
|
||||
scanned_files=scanned,
|
||||
copied_files=copied,
|
||||
indexed_summaries=imported,
|
||||
skipped_summaries=skipped,
|
||||
review_tasks=review_tasks,
|
||||
)
|
||||
def work(conn, backup_id):
|
||||
imported = 0
|
||||
duplicates = 0
|
||||
for summary in entries:
|
||||
saved = repository._save_course_summary(conn, summary)
|
||||
repository._add_summary_seen(conn, summary["source_id"], course_summary_semantic_key(summary))
|
||||
imported += 1 if saved.get("added") else 0
|
||||
duplicates += 0 if saved.get("added") else 1
|
||||
created_tasks = 0
|
||||
for summary, proposed_line in missing_tasks:
|
||||
repository._create_course_summary_review_task(
|
||||
conn,
|
||||
summary,
|
||||
proposed_line,
|
||||
["历史 classnotes缺失导入,默认只进入审核,不自动扣课时"],
|
||||
saved_path=str(summary.get("remark") or ""),
|
||||
)
|
||||
created_tasks += 1
|
||||
repository._add_ingest_batch(
|
||||
conn,
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"received_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"window": {"mode": "历史导入", "source": str(source)},
|
||||
"students": [],
|
||||
"result": {
|
||||
"received": len(entries),
|
||||
"saved": imported,
|
||||
"auto_registered": 0,
|
||||
"review_pending": created_tasks,
|
||||
"duplicates": duplicates,
|
||||
"rejected": skipped,
|
||||
},
|
||||
},
|
||||
)
|
||||
repository._append_operation_log(
|
||||
conn,
|
||||
"历史课程小结导入",
|
||||
"完成",
|
||||
source=str(source),
|
||||
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(
|
||||
f"历史小结导入完成:扫描 Markdown {scanned} 个,复制 {copied} 个,"
|
||||
f"索引小结 {imported} 条,跳过 {skipped} 条,生成审核任务 {review_tasks} 条。"
|
||||
f"历史小结导入完成:扫描 Markdown {scanned} 个,"
|
||||
f"写入/待写入小结 {imported} 条,重复 {duplicates} 条,"
|
||||
f"跳过 {skipped} 条,生成审核任务 {review_tasks} 条。"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -119,6 +119,38 @@ assertEqual("纠错后排序仍使用显示记录", value("currentRecordOrder.jo
|
||||
vm.runInContext("updateCorrectionToolbar('测试提交按钮')", context);
|
||||
assertEqual("提交审核按钮文案", value("submitCorrectionsBtn.textContent"), "提交审核 1 条");
|
||||
|
||||
vm.runInContext(
|
||||
`
|
||||
const longSummaryBody = "完整课程小结内容".repeat(20);
|
||||
currentRecords = [
|
||||
{
|
||||
date: "2026.06.14",
|
||||
weekday: "星期日",
|
||||
time: "10:00-11:00",
|
||||
student: "甲",
|
||||
duration: "1小时0分",
|
||||
duration_hours: 1,
|
||||
teacher: "王老师",
|
||||
subject: "英语",
|
||||
_recordIndex: 0,
|
||||
_recordKey: "summary-test",
|
||||
summary_count: 1,
|
||||
summaries: [{ title: "课程小结", body: longSummaryBody, teacher: "王老师", subject: "英语" }],
|
||||
},
|
||||
];
|
||||
expandedSummaryRecords = new Set(["summary-test"]);
|
||||
collapsedTeacherGroups = new Set();
|
||||
const summaryHtml = renderGroupedRecords(currentRecords);
|
||||
if (!summaryHtml.includes(longSummaryBody)) {
|
||||
throw new Error("课程小结展开后应直接显示完整正文");
|
||||
}
|
||||
if (summaryHtml.includes("展开全文") || summaryHtml.includes("summary-toggle") || summaryHtml.includes("summary-preview")) {
|
||||
throw new Error("课程小结展开后不应再渲染展开全文按钮或预览裁切");
|
||||
}
|
||||
`,
|
||||
context,
|
||||
);
|
||||
|
||||
const adminElements = new Map();
|
||||
function adminElement(id) {
|
||||
if (!adminElements.has(id)) {
|
||||
|
||||
@@ -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