Compare commits
2 Commits
da368100db
...
b169cb3d7a
| Author | SHA1 | Date | |
|---|---|---|---|
| b169cb3d7a | |||
| 70a5af3020 |
@@ -4,7 +4,7 @@ APP_DIR := app
|
|||||||
APP_MAKE := $(MAKE) -C $(APP_DIR) --no-print-directory
|
APP_MAKE := $(MAKE) -C $(APP_DIR) --no-print-directory
|
||||||
COMPOSE := docker compose -f $(APP_DIR)/docker-compose.yml
|
COMPOSE := docker compose -f $(APP_DIR)/docker-compose.yml
|
||||||
|
|
||||||
.PHONY: check smoke data-hash build up ps health deploy logs install-gitea-backup compose-config
|
.PHONY: check smoke data-hash migrate-sqlite-dry-run migrate-sqlite build up ps health deploy logs install-gitea-backup compose-config
|
||||||
|
|
||||||
check:
|
check:
|
||||||
$(APP_MAKE) check
|
$(APP_MAKE) check
|
||||||
@@ -15,6 +15,12 @@ smoke:
|
|||||||
data-hash:
|
data-hash:
|
||||||
$(APP_MAKE) data-hash
|
$(APP_MAKE) data-hash
|
||||||
|
|
||||||
|
migrate-sqlite-dry-run:
|
||||||
|
$(APP_MAKE) migrate-sqlite-dry-run
|
||||||
|
|
||||||
|
migrate-sqlite:
|
||||||
|
$(APP_MAKE) migrate-sqlite
|
||||||
|
|
||||||
build:
|
build:
|
||||||
$(APP_MAKE) build
|
$(APP_MAKE) build
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ BASIC_AUTH_PASSWORD=change-me
|
|||||||
ADMIN_AUTH_PASSWORD=change-me
|
ADMIN_AUTH_PASSWORD=change-me
|
||||||
INGEST_AUTH_TOKEN=change-this-ingest-token
|
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
|
CLASSNOTES_PATH=/data/classnotes.txt
|
||||||
ACCOUNTS_PATH=/data/学生课时账户.md
|
ACCOUNTS_PATH=/data/学生课时账户.md
|
||||||
TEACHERS_PATH=/data/教师档案.md
|
TEACHERS_PATH=/data/教师档案.md
|
||||||
|
|||||||
+8
-2
@@ -1,9 +1,9 @@
|
|||||||
SHELL := /bin/bash
|
SHELL := /bin/bash
|
||||||
|
|
||||||
APP_PORT ?= 18080
|
APP_PORT ?= 18080
|
||||||
DATA_FILES := ../data/classnotes.txt ../data/学生课时账户.md $(wildcard ../data/admin_tasks.json) $(wildcard ../data/course_summary_state.json)
|
DATA_FILES := $(wildcard ../data/xsk_education.db) $(wildcard ../data/sqlite_migration_report_*.json) $(wildcard ../archives/text-source-before-sqlite-*.tar.gz.sha256)
|
||||||
|
|
||||||
.PHONY: check smoke data-hash build up ps health deploy logs install-gitea-backup
|
.PHONY: check smoke data-hash migrate-sqlite-dry-run migrate-sqlite build up ps health deploy logs install-gitea-backup
|
||||||
|
|
||||||
check:
|
check:
|
||||||
node --check app/static/app.js
|
node --check app/static/app.js
|
||||||
@@ -16,6 +16,12 @@ smoke:
|
|||||||
data-hash:
|
data-hash:
|
||||||
sha256sum $(DATA_FILES)
|
sha256sum $(DATA_FILES)
|
||||||
|
|
||||||
|
migrate-sqlite-dry-run:
|
||||||
|
python3 scripts/migrate_text_to_sqlite.py --data-root ../data --db-path ../data/xsk_education.db --archives-root ../archives --dry-run
|
||||||
|
|
||||||
|
migrate-sqlite:
|
||||||
|
python3 scripts/migrate_text_to_sqlite.py --data-root ../data --db-path ../data/xsk_education.db --archives-root ../archives
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|
||||||
|
|||||||
+30
-10
@@ -1,6 +1,6 @@
|
|||||||
# 新时空教务管理系统
|
# 新时空教务管理系统
|
||||||
|
|
||||||
这是一个面向新时空教务业务的综合管理系统。迁移后 VPS 是正式业务数据主机,负责保存 `classnotes.txt`、`学生课时账户.md`、课程小结库、审核任务和操作记录;本机只保留微信群聊天记录采集/识别,并把课程小结批量推送到 VPS。
|
这是一个面向新时空教务业务的综合管理系统。迁移后 VPS 是正式业务数据主机,负责保存 SQLite 数据库 `/data/xsk_education.db`;本机只保留微信群聊天记录采集/识别,并把课程小结批量推送到 VPS。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
- `MAINTENANCE.md`:日常检查、部署、数据保护和回滚流程。
|
- `MAINTENANCE.md`:日常检查、部署、数据保护和回滚流程。
|
||||||
- `Makefile`:常用维护命令入口。
|
- `Makefile`:常用维护命令入口。
|
||||||
- `scripts/deploy_to_vps.py`:部署新时空教务管理系统到 VPS。
|
- `scripts/deploy_to_vps.py`:部署新时空教务管理系统到 VPS。
|
||||||
|
- `scripts/migrate_text_to_sqlite.py`:一次性把旧纯文本、JSON、JSONL 和课程小结 Markdown 迁移进 SQLite。
|
||||||
- `scripts/import_course_summaries.py`:一次性导入历史课程小结 Markdown。
|
- `scripts/import_course_summaries.py`:一次性导入历史课程小结 Markdown。
|
||||||
- `scripts/sync_to_vps.py`:旧版正式数据同步脚本,迁移后不要继续常驻运行。
|
- `scripts/sync_to_vps.py`:旧版正式数据同步脚本,迁移后不要继续常驻运行。
|
||||||
- `scripts/smoke_test.js`:轻量前端行为烟测。
|
- `scripts/smoke_test.js`:轻量前端行为烟测。
|
||||||
@@ -71,7 +72,7 @@ XSK_PYTHON_IMAGE='python:3.12-slim'
|
|||||||
|
|
||||||
## 手动同步数据
|
## 手动同步数据
|
||||||
|
|
||||||
迁移完成后不要再用本机 `classnotes.txt` 和 `学生课时账户.md` 覆盖 VPS。下面命令只保留给迁移前或灾难恢复时使用,日常新增课程小结应走 `POST /api/ingest/course-summaries`。
|
迁移完成后不要再用本机 `classnotes.txt` 和 `学生课时账户.md` 覆盖 VPS。SQLite 数据库 `/data/xsk_education.db` 是唯一事实源;运行时生成的 `/data/runtime_text_cache/` 只是兼容旧业务逻辑的缓存。下面命令只保留给迁移前或灾难恢复时使用,日常新增课程小结应走 `POST /api/ingest/course-summaries`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
XSK_USE_SSHPASS=1 \
|
XSK_USE_SSHPASS=1 \
|
||||||
@@ -87,7 +88,31 @@ python3 scripts/sync_to_vps.py --once --use-sshpass
|
|||||||
远端数据目录:
|
远端数据目录:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/root/新时空教务管理系统/data/
|
/root/新时空教务管理系统/data/xsk_education.db
|
||||||
|
```
|
||||||
|
|
||||||
|
## SQLite 迁移
|
||||||
|
|
||||||
|
首次迁移先 dry-run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/新时空教务管理系统
|
||||||
|
make migrate-sqlite-dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
确认严格校验通过后执行正式迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make migrate-sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
正式迁移会生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/root/新时空教务管理系统/data/xsk_education.db
|
||||||
|
/root/新时空教务管理系统/data/sqlite_migration_report_<时间>.json
|
||||||
|
/root/新时空教务管理系统/archives/text-source-before-sqlite-<时间>.tar.gz
|
||||||
|
/root/新时空教务管理系统/archives/text-source-before-sqlite-<时间>.tar.gz.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
## 课程小结推送
|
## 课程小结推送
|
||||||
@@ -131,15 +156,10 @@ docker compose exec xsk-education-management python scripts/import_course_summar
|
|||||||
|
|
||||||
## 数据备份
|
## 数据备份
|
||||||
|
|
||||||
通过登记 API、课程小结自动入账或审核批准修改正式课时数据时,服务会在写入前自动备份本次会改动的业务文件。正式数据和辅助状态位于:
|
通过登记 API、课程小结自动入账或审核批准修改正式课时数据时,服务会以 SQLite 事务写入 `/data/xsk_education.db`。旧纯文本事实源已封存为归档包;运行时文本缓存位于:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/root/新时空教务管理系统/data/classnotes.txt
|
/root/新时空教务管理系统/data/runtime_text_cache/
|
||||||
/root/新时空教务管理系统/data/学生课时账户.md
|
|
||||||
/root/新时空教务管理系统/data/教师档案.md
|
|
||||||
/root/新时空教务管理系统/data/course_summaries/
|
|
||||||
/root/新时空教务管理系统/data/course_summary_state.json
|
|
||||||
/root/新时空教务管理系统/data/operation_logs.jsonl
|
|
||||||
```
|
```
|
||||||
|
|
||||||
备份目录位于:
|
备份目录位于:
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ from pathlib import Path
|
|||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from .config import ACCOUNTS_PATH, CLASSNOTES_PATH, TEACHERS_PATH
|
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 .data import Account, Payment, Teacher, read_accounts, read_classnotes, read_teachers
|
||||||
|
from .repository import ensure_runtime_cache
|
||||||
from .schemas import AccountPayload, RegisterLinesPayload, TeacherPayload
|
from .schemas import AccountPayload, RegisterLinesPayload, TeacherPayload
|
||||||
|
|
||||||
|
|
||||||
@@ -38,18 +39,24 @@ async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
|||||||
|
|
||||||
|
|
||||||
def load_records():
|
def load_records():
|
||||||
|
if USE_SQLITE_SOURCE:
|
||||||
|
ensure_runtime_cache()
|
||||||
if not CLASSNOTES_PATH.exists():
|
if not CLASSNOTES_PATH.exists():
|
||||||
raise HTTPException(status_code=503, detail=f"课程记录文件不存在: {CLASSNOTES_PATH}")
|
raise HTTPException(status_code=503, detail=f"课程记录文件不存在: {CLASSNOTES_PATH}")
|
||||||
return read_classnotes(CLASSNOTES_PATH)
|
return read_classnotes(CLASSNOTES_PATH)
|
||||||
|
|
||||||
|
|
||||||
def load_accounts():
|
def load_accounts():
|
||||||
|
if USE_SQLITE_SOURCE:
|
||||||
|
ensure_runtime_cache()
|
||||||
if not ACCOUNTS_PATH.exists():
|
if not ACCOUNTS_PATH.exists():
|
||||||
raise HTTPException(status_code=503, detail=f"课时账户文件不存在: {ACCOUNTS_PATH}")
|
raise HTTPException(status_code=503, detail=f"课时账户文件不存在: {ACCOUNTS_PATH}")
|
||||||
return read_accounts(ACCOUNTS_PATH)
|
return read_accounts(ACCOUNTS_PATH)
|
||||||
|
|
||||||
|
|
||||||
def load_teachers():
|
def load_teachers():
|
||||||
|
if USE_SQLITE_SOURCE:
|
||||||
|
ensure_runtime_cache()
|
||||||
if not TEACHERS_PATH.exists():
|
if not TEACHERS_PATH.exists():
|
||||||
return []
|
return []
|
||||||
return read_teachers(TEACHERS_PATH)
|
return read_teachers(TEACHERS_PATH)
|
||||||
|
|||||||
+56
-8
@@ -8,13 +8,35 @@ import threading
|
|||||||
APP_DIR = Path(__file__).resolve().parent
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
STATIC_DIR = APP_DIR / "static"
|
STATIC_DIR = APP_DIR / "static"
|
||||||
|
|
||||||
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
|
SQLITE_DB_PATH = Path(os.getenv("SQLITE_DB_PATH", "/data/xsk_education.db"))
|
||||||
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md"))
|
RUNTIME_DATA_ROOT = Path(os.getenv("RUNTIME_DATA_ROOT", "/data/runtime_text_cache"))
|
||||||
TEACHERS_PATH = Path(os.getenv("TEACHERS_PATH", "/data/教师档案.md"))
|
LEGACY_TEXT_ROOT = Path(os.getenv("LEGACY_TEXT_ROOT", "/data"))
|
||||||
ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json"))
|
USE_SQLITE_SOURCE = os.getenv("USE_SQLITE_SOURCE", "1").strip().lower() not in {"0", "false", "no"}
|
||||||
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"))
|
if USE_SQLITE_SOURCE:
|
||||||
OPERATION_LOGS_PATH = Path(os.getenv("OPERATION_LOGS_PATH", "/data/operation_logs.jsonl"))
|
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", "")
|
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
|
||||||
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
|
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
|
||||||
@@ -26,4 +48,30 @@ ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session"
|
|||||||
ADMIN_SESSION_COOKIE = "xsk_admin_session"
|
ADMIN_SESSION_COOKIE = "xsk_admin_session"
|
||||||
SESSION_MAX_AGE = 60 * 60 * 24 * 30
|
SESSION_MAX_AGE = 60 * 60 * 24 * 30
|
||||||
|
|
||||||
write_lock = threading.Lock()
|
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()
|
||||||
|
|||||||
+49
-8
@@ -108,6 +108,15 @@ def format_teacher_row(teacher: Teacher) -> str:
|
|||||||
return f"| {teacher.teacher_id} | {teacher.name} | {teacher.alias} | {subjects} | {teacher.status} | {teacher.note} |"
|
return f"| {teacher.teacher_id} | {teacher.name} | {teacher.alias} | {subjects} | {teacher.status} | {teacher.note} |"
|
||||||
|
|
||||||
|
|
||||||
|
def paginate_items(items: list, offset: int = 0, limit: int = 100) -> tuple[list, int, bool]:
|
||||||
|
normalized_offset = max(int(offset or 0), 0)
|
||||||
|
normalized_limit = max(int(limit or 0), 0)
|
||||||
|
if normalized_limit <= 0:
|
||||||
|
return items[normalized_offset:], normalized_offset, False
|
||||||
|
end = normalized_offset + normalized_limit
|
||||||
|
return items[normalized_offset:end], normalized_offset, end < len(items)
|
||||||
|
|
||||||
|
|
||||||
def parse_payments(text: str) -> list[Payment]:
|
def parse_payments(text: str) -> list[Payment]:
|
||||||
payments: list[Payment] = []
|
payments: list[Payment] = []
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
@@ -1073,21 +1082,34 @@ def list_admin_tasks(
|
|||||||
status_filter: str = "",
|
status_filter: str = "",
|
||||||
task_type: str = "",
|
task_type: str = "",
|
||||||
classnotes_path: Path | None = None,
|
classnotes_path: Path | None = None,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 200,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
tasks = read_admin_tasks(tasks_path)
|
tasks = read_admin_tasks(tasks_path)
|
||||||
items = tasks["items"]
|
items = tasks["items"]
|
||||||
if status_filter:
|
if status_filter:
|
||||||
items = [item for item in items if item.get("status") == status_filter]
|
items = [item for item in items if item.get("status") == status_filter]
|
||||||
if task_type:
|
task_types = [item.strip() for item in task_type.split(",") if item.strip()]
|
||||||
items = [item for item in items if item.get("type") == task_type]
|
if task_types:
|
||||||
|
items = [item for item in items if str(item.get("type") or "") in task_types]
|
||||||
|
type_counts: dict[str, int] = defaultdict(int)
|
||||||
|
for item in items:
|
||||||
|
type_counts[str(item.get("type") or "")] += 1
|
||||||
|
sorted_items = sorted(items, key=lambda item: int(item.get("id", 0)), reverse=True)
|
||||||
|
shown, normalized_offset, has_more = paginate_items(sorted_items, offset, limit)
|
||||||
records = read_classnotes(classnotes_path) if classnotes_path is not None and classnotes_path.exists() else None
|
records = read_classnotes(classnotes_path) if classnotes_path is not None and classnotes_path.exists() else None
|
||||||
return {
|
return {
|
||||||
"version": tasks["version"],
|
"version": tasks["version"],
|
||||||
"next_id": tasks["next_id"],
|
"next_id": tasks["next_id"],
|
||||||
"count": len(items),
|
"count": len(items),
|
||||||
|
"returned": len(shown),
|
||||||
|
"offset": normalized_offset,
|
||||||
|
"limit": limit,
|
||||||
|
"has_more": has_more,
|
||||||
|
"type_counts": dict(type_counts),
|
||||||
"items": [
|
"items": [
|
||||||
task_to_dict_with_context(item, records)
|
task_to_dict_with_context(item, records)
|
||||||
for item in sorted(items, key=lambda item: int(item.get("id", 0)), reverse=True)
|
for item in shown
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2001,6 +2023,7 @@ def rollback_class_record_registration(
|
|||||||
def list_operation_logs(
|
def list_operation_logs(
|
||||||
path: Path,
|
path: Path,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
operation: str = "",
|
operation: str = "",
|
||||||
status_filter: str = "",
|
status_filter: str = "",
|
||||||
student: str = "",
|
student: str = "",
|
||||||
@@ -2023,9 +2046,16 @@ def list_operation_logs(
|
|||||||
if student and student not in str(item.get("student", "")):
|
if student and student not in str(item.get("student", "")):
|
||||||
continue
|
continue
|
||||||
rows.append(item)
|
rows.append(item)
|
||||||
rows = rows[-limit:]
|
|
||||||
rows.reverse()
|
rows.reverse()
|
||||||
return {"count": len(rows), "items": rows}
|
shown, normalized_offset, has_more = paginate_items(rows, offset, limit)
|
||||||
|
return {
|
||||||
|
"count": len(rows),
|
||||||
|
"returned": len(shown),
|
||||||
|
"offset": normalized_offset,
|
||||||
|
"limit": limit,
|
||||||
|
"has_more": has_more,
|
||||||
|
"items": shown,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def rollback_operation_log(
|
def rollback_operation_log(
|
||||||
@@ -2734,6 +2764,7 @@ def query_course_summaries(
|
|||||||
binding_status: str = "",
|
binding_status: str = "",
|
||||||
has_candidate: str = "",
|
has_candidate: str = "",
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
|
offset: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
normalized_from = normalize_filter_date(date_from)
|
normalized_from = normalize_filter_date(date_from)
|
||||||
normalized_to = normalize_filter_date(date_to)
|
normalized_to = normalize_filter_date(date_to)
|
||||||
@@ -2802,10 +2833,13 @@ def query_course_summaries(
|
|||||||
),
|
),
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
limited = matched[:limit]
|
limited, normalized_offset, has_more = paginate_items(matched, offset, limit)
|
||||||
return {
|
return {
|
||||||
"count": len(matched),
|
"count": len(matched),
|
||||||
"returned": len(limited),
|
"returned": len(limited),
|
||||||
|
"offset": normalized_offset,
|
||||||
|
"limit": limit,
|
||||||
|
"has_more": has_more,
|
||||||
"items": limited,
|
"items": limited,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4678,7 +4712,7 @@ def has_filter_condition(spec: QuerySpec) -> bool:
|
|||||||
def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> dict:
|
def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> dict:
|
||||||
spec = build_query_spec(query, records)
|
spec = build_query_spec(query, records)
|
||||||
matched = filter_records(records, spec) if has_filter_condition(spec) else []
|
matched = filter_records(records, spec) if has_filter_condition(spec) else []
|
||||||
shown = matched[:limit] if limit > 0 else matched
|
shown, normalized_offset, has_more = paginate_items(matched, 0, limit)
|
||||||
return {
|
return {
|
||||||
"query": {
|
"query": {
|
||||||
"raw_query": spec.raw_query,
|
"raw_query": spec.raw_query,
|
||||||
@@ -4691,6 +4725,9 @@ def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> d
|
|||||||
"records": [record_to_dict(record) for record in shown],
|
"records": [record_to_dict(record) for record in shown],
|
||||||
"total_records": len(matched),
|
"total_records": len(matched),
|
||||||
"shown_records": len(shown),
|
"shown_records": len(shown),
|
||||||
|
"offset": normalized_offset,
|
||||||
|
"limit": limit,
|
||||||
|
"has_more": has_more,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4699,11 +4736,12 @@ def query_public_records(
|
|||||||
teachers: list[Teacher],
|
teachers: list[Teacher],
|
||||||
query: str,
|
query: str,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
|
offset: int = 0,
|
||||||
summaries_root: Path | None = None,
|
summaries_root: Path | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
spec = build_public_query_spec(query, records, teachers)
|
spec = build_public_query_spec(query, records, teachers)
|
||||||
matched = filter_records(records, spec) if has_filter_condition(spec) else []
|
matched = filter_records(records, spec) if has_filter_condition(spec) else []
|
||||||
shown = matched[:limit] if limit > 0 else matched
|
shown, normalized_offset, has_more = paginate_items(matched, offset, limit)
|
||||||
display_names = teacher_alias_map(teachers)
|
display_names = teacher_alias_map(teachers)
|
||||||
summary_index = course_summary_index_for_records(summaries_root, records) if summaries_root is not None else {}
|
summary_index = course_summary_index_for_records(summaries_root, records) if summaries_root is not None else {}
|
||||||
return {
|
return {
|
||||||
@@ -4718,6 +4756,9 @@ def query_public_records(
|
|||||||
"records": [public_record_to_dict(record, teachers, summary_index) for record in shown],
|
"records": [public_record_to_dict(record, teachers, summary_index) for record in shown],
|
||||||
"total_records": len(matched),
|
"total_records": len(matched),
|
||||||
"shown_records": len(shown),
|
"shown_records": len(shown),
|
||||||
|
"offset": normalized_offset,
|
||||||
|
"limit": limit,
|
||||||
|
"has_more": has_more,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
from .config import SQLITE_DB_PATH
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||||
|
path = db_path or SQLITE_DB_PATH
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
conn.execute("PRAGMA journal_mode = WAL")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction(db_path: Path | None = None) -> Iterator[sqlite3.Connection]:
|
||||||
|
conn = connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS metadata (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS students (
|
||||||
|
student_id TEXT PRIMARY KEY,
|
||||||
|
student TEXT NOT NULL UNIQUE,
|
||||||
|
account_status TEXT NOT NULL,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
source_remaining REAL NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS account_transactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
student_id TEXT NOT NULL REFERENCES students(student_id) ON DELETE CASCADE,
|
||||||
|
tx_date TEXT NOT NULL,
|
||||||
|
hours REAL NOT NULL,
|
||||||
|
tx_type TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT '',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS class_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
record_key TEXT NOT NULL UNIQUE,
|
||||||
|
record_date TEXT NOT NULL,
|
||||||
|
weekday TEXT NOT NULL,
|
||||||
|
time_range TEXT NOT NULL,
|
||||||
|
duration_minutes INTEGER NOT NULL,
|
||||||
|
student_id TEXT REFERENCES students(student_id) ON DELETE SET NULL,
|
||||||
|
student TEXT NOT NULL,
|
||||||
|
teacher TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
raw_line TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS teachers (
|
||||||
|
teacher_id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
alias TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS teacher_subjects (
|
||||||
|
teacher_id TEXT NOT NULL REFERENCES teachers(teacher_id) ON DELETE CASCADE,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (teacher_id, subject)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_tasks (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
task_type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT '',
|
||||||
|
student TEXT NOT NULL DEFAULT '',
|
||||||
|
source_id TEXT NOT NULL DEFAULT '',
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_task_state (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operation_logs (
|
||||||
|
log_id TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL DEFAULT '',
|
||||||
|
operation TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
student TEXT NOT NULL DEFAULT '',
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS course_summaries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
relative_path TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
raw_body TEXT NOT NULL DEFAULT '',
|
||||||
|
group_name TEXT NOT NULL DEFAULT '',
|
||||||
|
student TEXT NOT NULL DEFAULT '',
|
||||||
|
teacher TEXT NOT NULL DEFAULT '',
|
||||||
|
subject TEXT NOT NULL DEFAULT '',
|
||||||
|
date_iso TEXT NOT NULL DEFAULT '',
|
||||||
|
time_range TEXT NOT NULL DEFAULT '',
|
||||||
|
message_time TEXT NOT NULL DEFAULT '',
|
||||||
|
sender TEXT NOT NULL DEFAULT '',
|
||||||
|
source_id TEXT NOT NULL DEFAULT '',
|
||||||
|
semantic_key TEXT NOT NULL DEFAULT '',
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS course_summary_seen_keys (
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (kind, value)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ingest_batches (
|
||||||
|
batch_id TEXT PRIMARY KEY,
|
||||||
|
received_at TEXT NOT NULL DEFAULT '',
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_audit (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_class_records_student_date ON class_records(student, record_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_class_records_teacher ON class_records(teacher);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_course_summaries_identity ON course_summaries(student, teacher, subject, date_iso, time_range);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_logs_created_at ON operation_logs(created_at);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', ?)",
|
||||||
|
(str(SCHEMA_VERSION),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def database_meta(db_path: Path | None = None) -> dict:
|
||||||
|
path = db_path or SQLITE_DB_PATH
|
||||||
|
if not path.exists():
|
||||||
|
return {"exists": False, "path": str(path)}
|
||||||
|
stat = path.stat()
|
||||||
|
version = ""
|
||||||
|
try:
|
||||||
|
with connect(path) as conn:
|
||||||
|
initialize_schema(conn)
|
||||||
|
row = conn.execute("SELECT value FROM metadata WHERE key = 'schema_version'").fetchone()
|
||||||
|
version = str(row["value"]) if row else ""
|
||||||
|
except sqlite3.DatabaseError:
|
||||||
|
version = "unreadable"
|
||||||
|
return {
|
||||||
|
"exists": True,
|
||||||
|
"path": str(path),
|
||||||
|
"size": stat.st_size,
|
||||||
|
"mtime": stat.st_mtime,
|
||||||
|
"schema_version": version,
|
||||||
|
}
|
||||||
@@ -3,12 +3,20 @@ from __future__ import annotations
|
|||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from .config import USE_SQLITE_SOURCE
|
||||||
|
from .repository import ensure_runtime_cache
|
||||||
from .routers import accounts, admin, ai_register, health, ingest, pages, records
|
from .routers import accounts, admin, ai_register, health, ingest, pages, records
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="新时空教务管理系统", version="1.0.0")
|
app = FastAPI(title="新时空教务管理系统", version="1.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def prepare_sqlite_runtime_cache() -> None:
|
||||||
|
if USE_SQLITE_SOURCE:
|
||||||
|
ensure_runtime_cache()
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(ValueError)
|
@app.exception_handler(ValueError)
|
||||||
async def value_error_handler(_request: Request, exc: ValueError):
|
async def value_error_handler(_request: Request, exc: ValueError):
|
||||||
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||||
|
|||||||
@@ -0,0 +1,633 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date, datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
ACCOUNTS_PATH,
|
||||||
|
ADMIN_TASKS_PATH,
|
||||||
|
CLASSNOTES_PATH,
|
||||||
|
COURSE_SUMMARIES_ROOT,
|
||||||
|
COURSE_SUMMARY_STATE_PATH,
|
||||||
|
OPERATION_LOGS_PATH,
|
||||||
|
RUNTIME_DATA_ROOT,
|
||||||
|
SQLITE_DB_PATH,
|
||||||
|
TEACHERS_PATH,
|
||||||
|
)
|
||||||
|
from .db import connect, initialize_schema, transaction
|
||||||
|
from .domain import Account, ClassRecord, Payment, Teacher
|
||||||
|
from .storage import atomic_write_text
|
||||||
|
|
||||||
|
|
||||||
|
CLASSNOTES_HEADER = """# 课程记录
|
||||||
|
# 格式:日期-星期-时间段-学生姓名-时长-老师姓名-科目
|
||||||
|
# 示例:2025.09.14-星期日-16:00-18:00-余峻珲-2小时0分-杨达伟-数学
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
ACCOUNTS_HEADER = """# 学生课时账户
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 本表由 SQLite 运行时缓存生成,正式事实源为 `/data/xsk_education.db`。
|
||||||
|
- `剩余课时` 由缴费/退费流水与上课记录实时重算。
|
||||||
|
|
||||||
|
| 学生ID | 学生姓名 | 缴费记录 | 剩余课时 | 账户状态 | 备注 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
"""
|
||||||
|
|
||||||
|
TEACHERS_HEADER = """# 教师档案
|
||||||
|
|
||||||
|
| 教师ID | 教师姓名 | 别名 | 任教学科 | 状态 | 备注 |
|
||||||
|
| ------ | -------- | ---- | -------- | ---- | ---- |
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def sha1_text(text: str, length: int = 40) -> str:
|
||||||
|
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:length]
|
||||||
|
|
||||||
|
|
||||||
|
def _json_dumps(value: object) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_key(record: ClassRecord) -> str:
|
||||||
|
return "|".join(
|
||||||
|
[
|
||||||
|
record.student,
|
||||||
|
record.date,
|
||||||
|
record.time,
|
||||||
|
record.duration,
|
||||||
|
record.teacher,
|
||||||
|
record.subject,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _class_record_to_line(record: ClassRecord) -> str:
|
||||||
|
return f"{record.date}-{record.weekday}-{record.time}-{record.student}-{record.duration}-{record.teacher}-{record.subject}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_number(value: float) -> str:
|
||||||
|
if float(value).is_integer():
|
||||||
|
return str(int(value))
|
||||||
|
return f"{value:.2f}".rstrip("0").rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_payment(payment: Payment) -> str:
|
||||||
|
return f"{payment.date}:{_format_number(payment.hours)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _account_status(stored_status: str, remaining: float) -> str:
|
||||||
|
if stored_status in {"结课", "退费"}:
|
||||||
|
return stored_status
|
||||||
|
if remaining < 0:
|
||||||
|
return "欠费"
|
||||||
|
if remaining < 10:
|
||||||
|
return "预警"
|
||||||
|
return "正常"
|
||||||
|
|
||||||
|
|
||||||
|
def _duration_text_from_minutes(minutes: int) -> str:
|
||||||
|
return f"{minutes // 60}小时{minutes % 60}分"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_course_summary_blocks(root: Path) -> list[dict]:
|
||||||
|
from .data import iter_course_summary_markdown
|
||||||
|
|
||||||
|
if not root.exists():
|
||||||
|
return []
|
||||||
|
return list(iter_course_summary_markdown(root))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_sources(
|
||||||
|
*,
|
||||||
|
classnotes_path: Path,
|
||||||
|
accounts_path: Path,
|
||||||
|
teachers_path: Path,
|
||||||
|
tasks_path: Path,
|
||||||
|
summaries_root: Path,
|
||||||
|
state_path: Path,
|
||||||
|
operation_logs_path: Path,
|
||||||
|
) -> dict:
|
||||||
|
from .data import (
|
||||||
|
read_accounts,
|
||||||
|
read_admin_tasks,
|
||||||
|
read_classnotes,
|
||||||
|
read_course_summary_state,
|
||||||
|
read_operation_log_rows,
|
||||||
|
read_teachers,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"records": read_classnotes(classnotes_path) if classnotes_path.exists() else [],
|
||||||
|
"accounts": read_accounts(accounts_path) if accounts_path.exists() else [],
|
||||||
|
"teachers": read_teachers(teachers_path) if teachers_path.exists() else [],
|
||||||
|
"tasks": read_admin_tasks(tasks_path) if tasks_path.exists() else {"version": 1, "next_id": 1, "items": []},
|
||||||
|
"summaries": _parse_course_summary_blocks(summaries_root),
|
||||||
|
"state": read_course_summary_state(state_path) if state_path.exists() else {"version": 1, "seen_source_ids": [], "seen_semantic_keys": [], "batches": []},
|
||||||
|
"logs": read_operation_log_rows(operation_logs_path) if operation_logs_path.exists() else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_business_tables(conn: sqlite3.Connection) -> None:
|
||||||
|
for table in [
|
||||||
|
"teacher_subjects",
|
||||||
|
"account_transactions",
|
||||||
|
"class_records",
|
||||||
|
"students",
|
||||||
|
"teachers",
|
||||||
|
"admin_tasks",
|
||||||
|
"admin_task_state",
|
||||||
|
"operation_logs",
|
||||||
|
"course_summaries",
|
||||||
|
"course_summary_seen_keys",
|
||||||
|
"ingest_batches",
|
||||||
|
]:
|
||||||
|
conn.execute(f"DELETE FROM {table}")
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_sources(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
sources: dict,
|
||||||
|
*,
|
||||||
|
allow_balance_adjustments: bool,
|
||||||
|
) -> dict:
|
||||||
|
accounts: list[Account] = sources["accounts"]
|
||||||
|
records: list[ClassRecord] = sources["records"]
|
||||||
|
teachers: list[Teacher] = sources["teachers"]
|
||||||
|
tasks: dict = sources["tasks"]
|
||||||
|
summaries: list[dict] = sources["summaries"]
|
||||||
|
state: dict = sources["state"]
|
||||||
|
logs: list[dict] = sources["logs"]
|
||||||
|
|
||||||
|
account_by_student = {account.student: account for account in accounts}
|
||||||
|
for index, account in enumerate(accounts):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO students(student_id, student, account_status, note, source_remaining, sort_order)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(account.student_id, account.student, account.account_status, account.note, account.remaining, index),
|
||||||
|
)
|
||||||
|
for payment_index, payment in enumerate(account.payments):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO account_transactions(student_id, tx_date, hours, tx_type, source, sort_order)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
account.student_id,
|
||||||
|
payment.date,
|
||||||
|
float(payment.hours),
|
||||||
|
"payment" if payment.hours >= 0 else "refund",
|
||||||
|
"account_text",
|
||||||
|
payment_index,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
seen_record_keys: set[str] = set()
|
||||||
|
used_hours_by_student: defaultdict[str, float] = defaultdict(float)
|
||||||
|
for index, record in enumerate(records):
|
||||||
|
key = _record_key(record)
|
||||||
|
if key in seen_record_keys:
|
||||||
|
raise ValueError(f"课程记录重复: {_class_record_to_line(record)}")
|
||||||
|
seen_record_keys.add(key)
|
||||||
|
account = account_by_student.get(record.student)
|
||||||
|
if account is None:
|
||||||
|
raise ValueError(f"上课学生没有课时账户: {record.student}")
|
||||||
|
minutes = int(round(record.duration_hours * 60))
|
||||||
|
used_hours_by_student[record.student] = round(used_hours_by_student[record.student] + record.duration_hours, 2)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO class_records(record_key, record_date, weekday, time_range, duration_minutes,
|
||||||
|
student_id, student, teacher, subject, raw_line, sort_order)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
key,
|
||||||
|
record.date,
|
||||||
|
record.weekday,
|
||||||
|
record.time,
|
||||||
|
minutes,
|
||||||
|
account.student_id,
|
||||||
|
record.student,
|
||||||
|
record.teacher,
|
||||||
|
record.subject,
|
||||||
|
_class_record_to_line(record),
|
||||||
|
index,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
balance_mismatches: list[dict] = []
|
||||||
|
if allow_balance_adjustments:
|
||||||
|
for account in accounts:
|
||||||
|
paid = round(sum(payment.hours for payment in account.payments), 2)
|
||||||
|
used = round(used_hours_by_student.get(account.student, 0.0), 2)
|
||||||
|
derived = round(paid - used, 2)
|
||||||
|
delta = round(account.remaining - derived, 2)
|
||||||
|
if abs(delta) <= 0.011:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO account_transactions(student_id, tx_date, hours, tx_type, source, sort_order)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(account.student_id, date.today().isoformat(), delta, "manual_adjustment", "runtime_account_remaining", 100000),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for account in accounts:
|
||||||
|
paid = round(sum(payment.hours for payment in account.payments), 2)
|
||||||
|
used = round(used_hours_by_student.get(account.student, 0.0), 2)
|
||||||
|
derived = round(paid - used, 2)
|
||||||
|
delta = round(derived - account.remaining, 2)
|
||||||
|
if abs(delta) > 0.011:
|
||||||
|
balance_mismatches.append(
|
||||||
|
{
|
||||||
|
"student": account.student,
|
||||||
|
"student_id": account.student_id,
|
||||||
|
"old_remaining": account.remaining,
|
||||||
|
"recomputed_remaining": derived,
|
||||||
|
"difference": delta,
|
||||||
|
"paid_hours": paid,
|
||||||
|
"used_hours": used,
|
||||||
|
"status": "resolved_by_recompute",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, teacher in enumerate(teachers):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO teachers(teacher_id, name, alias, status, note, sort_order)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(teacher.teacher_id, teacher.name, teacher.alias, teacher.status, teacher.note, index),
|
||||||
|
)
|
||||||
|
for subject_index, subject in enumerate(teacher.subjects):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO teacher_subjects(teacher_id, subject, sort_order) VALUES(?, ?, ?)",
|
||||||
|
(teacher.teacher_id, subject, subject_index),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute("INSERT INTO admin_task_state(key, value) VALUES('version', ?)", (str(tasks.get("version", 1)),))
|
||||||
|
conn.execute("INSERT INTO admin_task_state(key, value) VALUES('next_id', ?)", (str(tasks.get("next_id", 1)),))
|
||||||
|
for index, task in enumerate(tasks.get("items") or []):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO admin_tasks(id, task_type, status, created_at, updated_at, student, source_id, payload_json, sort_order)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
int(task.get("id") or index + 1),
|
||||||
|
str(task.get("type") or ""),
|
||||||
|
str(task.get("status") or ""),
|
||||||
|
str(task.get("created_at") or ""),
|
||||||
|
str(task.get("updated_at") or ""),
|
||||||
|
str(task.get("student") or task.get("corrected", {}).get("student") or ""),
|
||||||
|
str(task.get("source_id") or ""),
|
||||||
|
json.dumps(task, ensure_ascii=False, sort_keys=True),
|
||||||
|
index,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, item in enumerate(logs):
|
||||||
|
log_id = str(item.get("id") or f"legacy-log-{index + 1}")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO operation_logs(log_id, created_at, operation, status, student, payload_json, sort_order)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
log_id,
|
||||||
|
str(item.get("created_at") or ""),
|
||||||
|
str(item.get("operation") or ""),
|
||||||
|
str(item.get("status") or ""),
|
||||||
|
str(item.get("student") or ""),
|
||||||
|
json.dumps({**item, "id": log_id}, ensure_ascii=False, sort_keys=True),
|
||||||
|
index,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, item in enumerate(summaries):
|
||||||
|
item_id = str(item.get("id") or sha1_text(f"{item.get('relative_path')}|{item.get('title')}|{index}", 20))
|
||||||
|
body = str(item.get("body") or "")
|
||||||
|
raw_body = str(item.get("raw_body") or body)
|
||||||
|
content_hash = sha1_text(f"{item.get('relative_path')}|{item.get('title')}|{body}")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO course_summaries(id, relative_path, title, body, raw_body, group_name,
|
||||||
|
student, teacher, subject, date_iso, time_range,
|
||||||
|
message_time, sender, source_id, semantic_key, content_hash, sort_order)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
item_id,
|
||||||
|
str(item.get("relative_path") or ""),
|
||||||
|
str(item.get("title") or ""),
|
||||||
|
body,
|
||||||
|
raw_body,
|
||||||
|
str(item.get("group") or ""),
|
||||||
|
str(item.get("student") or ""),
|
||||||
|
str(item.get("teacher") or ""),
|
||||||
|
str(item.get("subject") or ""),
|
||||||
|
str(item.get("date_iso") or ""),
|
||||||
|
str(item.get("time_range") or ""),
|
||||||
|
str(item.get("message_time") or ""),
|
||||||
|
str(item.get("sender") or ""),
|
||||||
|
str(item.get("source_id") or ""),
|
||||||
|
str(item.get("semantic_key") or ""),
|
||||||
|
content_hash,
|
||||||
|
index,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for value in state.get("seen_source_ids") or []:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO course_summary_seen_keys(kind, value) VALUES('source_id', ?)", (str(value),))
|
||||||
|
for value in state.get("seen_semantic_keys") or []:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO course_summary_seen_keys(kind, value) VALUES('semantic_key', ?)", (str(value),))
|
||||||
|
for index, batch in enumerate(state.get("batches") or []):
|
||||||
|
batch_id = str(batch.get("batch_id") or f"legacy-batch-{index + 1}")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO ingest_batches(batch_id, received_at, payload_json, sort_order)
|
||||||
|
VALUES(?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(batch_id, str(batch.get("received_at") or ""), json.dumps({**batch, "batch_id": batch_id}, ensure_ascii=False, sort_keys=True), index),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"records": len(records),
|
||||||
|
"accounts": len(accounts),
|
||||||
|
"teachers": len(teachers),
|
||||||
|
"admin_tasks": len(tasks.get("items") or []),
|
||||||
|
"operation_logs": len(logs),
|
||||||
|
"course_summaries": len(summaries),
|
||||||
|
"balance_mismatches": balance_mismatches,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def replace_database_from_paths(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
classnotes_path: Path,
|
||||||
|
accounts_path: Path,
|
||||||
|
teachers_path: Path,
|
||||||
|
tasks_path: Path,
|
||||||
|
summaries_root: Path,
|
||||||
|
state_path: Path,
|
||||||
|
operation_logs_path: Path,
|
||||||
|
allow_balance_adjustments: bool,
|
||||||
|
) -> dict:
|
||||||
|
initialize_schema(conn)
|
||||||
|
sources = _read_sources(
|
||||||
|
classnotes_path=classnotes_path,
|
||||||
|
accounts_path=accounts_path,
|
||||||
|
teachers_path=teachers_path,
|
||||||
|
tasks_path=tasks_path,
|
||||||
|
summaries_root=summaries_root,
|
||||||
|
state_path=state_path,
|
||||||
|
operation_logs_path=operation_logs_path,
|
||||||
|
)
|
||||||
|
_clear_business_tables(conn)
|
||||||
|
return _insert_sources(conn, sources, allow_balance_adjustments=allow_balance_adjustments)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_runtime_cache_to_database() -> dict:
|
||||||
|
with transaction(SQLITE_DB_PATH) as conn:
|
||||||
|
return replace_database_from_paths(
|
||||||
|
conn,
|
||||||
|
classnotes_path=CLASSNOTES_PATH,
|
||||||
|
accounts_path=ACCOUNTS_PATH,
|
||||||
|
teachers_path=TEACHERS_PATH,
|
||||||
|
tasks_path=ADMIN_TASKS_PATH,
|
||||||
|
summaries_root=COURSE_SUMMARIES_ROOT,
|
||||||
|
state_path=COURSE_SUMMARY_STATE_PATH,
|
||||||
|
operation_logs_path=OPERATION_LOGS_PATH,
|
||||||
|
allow_balance_adjustments=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _class_records_from_db(conn: sqlite3.Connection) -> list[str]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT raw_line FROM class_records ORDER BY sort_order, id"
|
||||||
|
).fetchall()
|
||||||
|
return [str(row["raw_line"]) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _payments_by_student(conn: sqlite3.Connection) -> dict[str, list[Payment]]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT student_id, tx_date, hours
|
||||||
|
FROM account_transactions
|
||||||
|
ORDER BY student_id, sort_order, id
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
result: dict[str, list[Payment]] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
result[str(row["student_id"])].append(Payment(date=str(row["tx_date"]), hours=float(row["hours"])))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _used_hours_by_student(conn: sqlite3.Connection) -> dict[str, float]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT student_id, duration_minutes
|
||||||
|
FROM class_records
|
||||||
|
WHERE student_id IS NOT NULL
|
||||||
|
ORDER BY sort_order, id
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
result: dict[str, float] = defaultdict(float)
|
||||||
|
for row in rows:
|
||||||
|
student_id = str(row["student_id"])
|
||||||
|
duration_hours = round(int(row["duration_minutes"] or 0) / 60.0, 2)
|
||||||
|
result[student_id] = round(result[student_id] + duration_hours, 2)
|
||||||
|
return dict(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _accounts_from_db(conn: sqlite3.Connection) -> list[Account]:
|
||||||
|
payment_map = _payments_by_student(conn)
|
||||||
|
used_map = _used_hours_by_student(conn)
|
||||||
|
accounts: list[Account] = []
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT student_id, student, account_status, note FROM students ORDER BY sort_order, student_id"
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
student_id = str(row["student_id"])
|
||||||
|
payments = payment_map.get(student_id, [])
|
||||||
|
paid = round(sum(payment.hours for payment in payments), 2)
|
||||||
|
remaining = round(paid - used_map.get(student_id, 0.0), 2)
|
||||||
|
status = _account_status(str(row["account_status"]), remaining)
|
||||||
|
accounts.append(
|
||||||
|
Account(
|
||||||
|
student_id=student_id,
|
||||||
|
student=str(row["student"]),
|
||||||
|
payments=payments,
|
||||||
|
remaining=remaining,
|
||||||
|
account_status=status,
|
||||||
|
note=str(row["note"] or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
def _teachers_from_db(conn: sqlite3.Connection) -> list[Teacher]:
|
||||||
|
subject_rows = conn.execute(
|
||||||
|
"SELECT teacher_id, subject FROM teacher_subjects ORDER BY teacher_id, sort_order"
|
||||||
|
).fetchall()
|
||||||
|
subjects: dict[str, list[str]] = defaultdict(list)
|
||||||
|
for row in subject_rows:
|
||||||
|
subjects[str(row["teacher_id"])].append(str(row["subject"]))
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT teacher_id, name, alias, status, note FROM teachers ORDER BY sort_order, teacher_id"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
Teacher(
|
||||||
|
teacher_id=str(row["teacher_id"]),
|
||||||
|
name=str(row["name"]),
|
||||||
|
alias=str(row["alias"] or ""),
|
||||||
|
subjects=subjects.get(str(row["teacher_id"]), []),
|
||||||
|
status=str(row["status"]),
|
||||||
|
note=str(row["note"] or ""),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _tasks_from_db(conn: sqlite3.Connection) -> dict:
|
||||||
|
state_rows = conn.execute("SELECT key, value FROM admin_task_state").fetchall()
|
||||||
|
state = {str(row["key"]): str(row["value"]) for row in state_rows}
|
||||||
|
task_rows = conn.execute("SELECT payload_json FROM admin_tasks ORDER BY sort_order, id").fetchall()
|
||||||
|
return {
|
||||||
|
"version": int(state.get("version") or 1),
|
||||||
|
"next_id": int(state.get("next_id") or 1),
|
||||||
|
"items": [json.loads(str(row["payload_json"])) for row in task_rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _operation_logs_from_db(conn: sqlite3.Connection) -> list[dict]:
|
||||||
|
rows = conn.execute("SELECT payload_json FROM operation_logs ORDER BY sort_order, created_at, log_id").fetchall()
|
||||||
|
return [json.loads(str(row["payload_json"])) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_state_from_db(conn: sqlite3.Connection) -> dict:
|
||||||
|
source_ids = [
|
||||||
|
str(row["value"])
|
||||||
|
for row in conn.execute("SELECT value FROM course_summary_seen_keys WHERE kind = 'source_id' ORDER BY value")
|
||||||
|
]
|
||||||
|
semantic_keys = [
|
||||||
|
str(row["value"])
|
||||||
|
for row in conn.execute("SELECT value FROM course_summary_seen_keys WHERE kind = 'semantic_key' ORDER BY value")
|
||||||
|
]
|
||||||
|
batches = [
|
||||||
|
json.loads(str(row["payload_json"]))
|
||||||
|
for row in conn.execute("SELECT payload_json FROM ingest_batches ORDER BY sort_order, received_at, batch_id")
|
||||||
|
]
|
||||||
|
return {"version": 1, "seen_source_ids": source_ids, "seen_semantic_keys": semantic_keys, "batches": batches[-200:]}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_accounts(path: Path, accounts: list[Account]) -> None:
|
||||||
|
lines = [ACCOUNTS_HEADER.rstrip("\n")]
|
||||||
|
for account in accounts:
|
||||||
|
payments = ",".join(_format_payment(payment) for payment in account.payments)
|
||||||
|
lines.append(
|
||||||
|
f"| {account.student_id} | {account.student} | {payments} | "
|
||||||
|
f"{_format_number(account.remaining)} | {account.account_status} | {account.note} |"
|
||||||
|
)
|
||||||
|
atomic_write_text(path, "\n".join(lines).rstrip() + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_teachers(path: Path, teachers: list[Teacher]) -> None:
|
||||||
|
lines = [TEACHERS_HEADER.rstrip("\n")]
|
||||||
|
for teacher in teachers:
|
||||||
|
lines.append(
|
||||||
|
f"| {teacher.teacher_id} | {teacher.name} | {teacher.alias} | "
|
||||||
|
f"{'、'.join(teacher.subjects)} | {teacher.status} | {teacher.note} |"
|
||||||
|
)
|
||||||
|
atomic_write_text(path, "\n".join(lines).rstrip() + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_course_summaries(conn: sqlite3.Connection, root: Path) -> None:
|
||||||
|
if root.exists():
|
||||||
|
shutil.rmtree(root)
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
grouped: dict[str, list[sqlite3.Row]] = defaultdict(list)
|
||||||
|
for row in conn.execute("SELECT * FROM course_summaries ORDER BY relative_path, sort_order"):
|
||||||
|
relative_path = str(row["relative_path"] or "未归档/未命名.md")
|
||||||
|
grouped[relative_path].append(row)
|
||||||
|
for relative_path, rows in grouped.items():
|
||||||
|
target = root / relative_path
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
first = rows[0]
|
||||||
|
heading = f"# {first['student']} {first['teacher']}".strip()
|
||||||
|
group = str(first["group_name"] or "")
|
||||||
|
blocks = [heading, ""]
|
||||||
|
if group:
|
||||||
|
blocks.extend([f"## {group}", ""])
|
||||||
|
for row in rows:
|
||||||
|
blocks.append(f"### {row['title']}")
|
||||||
|
blocks.append("")
|
||||||
|
raw_body = str(row["raw_body"] or "").strip()
|
||||||
|
body = str(row["body"] or "").strip()
|
||||||
|
if raw_body:
|
||||||
|
blocks.append(raw_body)
|
||||||
|
else:
|
||||||
|
meta_lines = []
|
||||||
|
if row["source_id"]:
|
||||||
|
meta_lines.append(f"> 来源ID:`{row['source_id']}`")
|
||||||
|
if row["message_time"]:
|
||||||
|
meta_lines.append(f"> 发送时间:`{row['message_time']}`")
|
||||||
|
if row["sender"]:
|
||||||
|
meta_lines.append(f"> 发送者:`{row['sender']}`")
|
||||||
|
if meta_lines:
|
||||||
|
blocks.extend(meta_lines)
|
||||||
|
blocks.append("")
|
||||||
|
blocks.append(body)
|
||||||
|
blocks.append("")
|
||||||
|
atomic_write_text(target, "\n".join(blocks).rstrip() + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def export_database_to_runtime_cache() -> dict:
|
||||||
|
if not SQLITE_DB_PATH.exists():
|
||||||
|
return {"ok": False, "reason": "database_missing", "path": str(SQLITE_DB_PATH)}
|
||||||
|
RUNTIME_DATA_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
with connect(SQLITE_DB_PATH) as conn:
|
||||||
|
initialize_schema(conn)
|
||||||
|
class_lines = _class_records_from_db(conn)
|
||||||
|
atomic_write_text(CLASSNOTES_PATH, CLASSNOTES_HEADER + "\n".join(class_lines).rstrip() + ("\n" if class_lines else ""))
|
||||||
|
_write_accounts(ACCOUNTS_PATH, _accounts_from_db(conn))
|
||||||
|
_write_teachers(TEACHERS_PATH, _teachers_from_db(conn))
|
||||||
|
atomic_write_text(ADMIN_TASKS_PATH, json.dumps(_tasks_from_db(conn), ensure_ascii=False, indent=2) + "\n")
|
||||||
|
logs = _operation_logs_from_db(conn)
|
||||||
|
atomic_write_text(
|
||||||
|
OPERATION_LOGS_PATH,
|
||||||
|
"".join(json.dumps(item, ensure_ascii=False, sort_keys=True) + "\n" for item in logs),
|
||||||
|
)
|
||||||
|
atomic_write_text(COURSE_SUMMARY_STATE_PATH, json.dumps(_summary_state_from_db(conn), ensure_ascii=False, indent=2) + "\n")
|
||||||
|
_write_course_summaries(conn, COURSE_SUMMARIES_ROOT)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"records": len(class_lines),
|
||||||
|
"accounts": len(_accounts_from_db(conn)),
|
||||||
|
"operation_logs": len(logs),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_runtime_cache() -> dict:
|
||||||
|
return export_database_to_runtime_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def source_file_hashes(paths: Iterable[Path]) -> dict[str, str]:
|
||||||
|
hashes: dict[str, str] = {}
|
||||||
|
for path in paths:
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
continue
|
||||||
|
hashes[str(path)] = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
return hashes
|
||||||
@@ -12,10 +12,13 @@ from ..config import (
|
|||||||
CLASSNOTES_PATH,
|
CLASSNOTES_PATH,
|
||||||
COURSE_SUMMARIES_ROOT,
|
COURSE_SUMMARIES_ROOT,
|
||||||
COURSE_SUMMARY_STATE_PATH,
|
COURSE_SUMMARY_STATE_PATH,
|
||||||
|
LEGACY_TEXT_ROOT,
|
||||||
OPERATION_LOGS_PATH,
|
OPERATION_LOGS_PATH,
|
||||||
TEACHERS_PATH,
|
TEACHERS_PATH,
|
||||||
|
USE_SQLITE_SOURCE,
|
||||||
write_lock,
|
write_lock,
|
||||||
)
|
)
|
||||||
|
from ..db import database_meta
|
||||||
from ..data import (
|
from ..data import (
|
||||||
ACCOUNT_STATUSES,
|
ACCOUNT_STATUSES,
|
||||||
TEACHER_STATUSES,
|
TEACHER_STATUSES,
|
||||||
@@ -45,7 +48,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
def compact_duration_text(hours: float) -> str:
|
def compact_duration_text(hours: float) -> str:
|
||||||
text = duration_text_from_hours(hours)
|
text = duration_text_from_hours(hours)
|
||||||
return text.removesuffix("0分")
|
return text[:-2] if text.endswith("小时0分") else text
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/register/class-records")
|
@router.post("/api/register/class-records")
|
||||||
@@ -134,6 +137,11 @@ def account_health(_user: str = Depends(verify_accounts_auth)):
|
|||||||
"teachers": file_meta(TEACHERS_PATH),
|
"teachers": file_meta(TEACHERS_PATH),
|
||||||
"classnotes": file_meta(CLASSNOTES_PATH),
|
"classnotes": file_meta(CLASSNOTES_PATH),
|
||||||
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
|
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
|
||||||
|
"database": database_meta(),
|
||||||
|
"legacy_archive": {
|
||||||
|
"source_mode": "sqlite" if USE_SQLITE_SOURCE else "text",
|
||||||
|
"text_root": str(LEGACY_TEXT_ROOT),
|
||||||
|
},
|
||||||
"accounts_count": len(accounts),
|
"accounts_count": len(accounts),
|
||||||
"teachers_count": len(teachers),
|
"teachers_count": len(teachers),
|
||||||
"active_teachers_count": sum(1 for teacher in teachers if teacher.status == "在岗"),
|
"active_teachers_count": sum(1 for teacher in teachers if teacher.status == "在岗"),
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ def admin_dashboard(period: str = Query("month"), _user: str = Depends(verify_ad
|
|||||||
def admin_tasks(
|
def admin_tasks(
|
||||||
status_filter: str = Query("", alias="status"),
|
status_filter: str = Query("", alias="status"),
|
||||||
task_type: str = Query("", alias="type"),
|
task_type: str = Query("", alias="type"),
|
||||||
|
limit: int = Query(50, ge=1, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
_user: str = Depends(verify_admin_auth),
|
_user: str = Depends(verify_admin_auth),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
@@ -77,6 +79,8 @@ def admin_tasks(
|
|||||||
status_filter=status_filter,
|
status_filter=status_filter,
|
||||||
task_type=task_type,
|
task_type=task_type,
|
||||||
classnotes_path=CLASSNOTES_PATH,
|
classnotes_path=CLASSNOTES_PATH,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
@@ -84,7 +88,8 @@ def admin_tasks(
|
|||||||
|
|
||||||
@router.get("/api/admin/operation-logs")
|
@router.get("/api/admin/operation-logs")
|
||||||
def admin_operation_logs(
|
def admin_operation_logs(
|
||||||
limit: int = Query(100, ge=1, le=500),
|
limit: int = Query(50, ge=1, le=500),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
operation: str = Query(""),
|
operation: str = Query(""),
|
||||||
status_filter: str = Query("", alias="status"),
|
status_filter: str = Query("", alias="status"),
|
||||||
student: str = Query(""),
|
student: str = Query(""),
|
||||||
@@ -94,6 +99,7 @@ def admin_operation_logs(
|
|||||||
return list_operation_logs(
|
return list_operation_logs(
|
||||||
OPERATION_LOGS_PATH,
|
OPERATION_LOGS_PATH,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
operation=operation,
|
operation=operation,
|
||||||
status_filter=status_filter,
|
status_filter=status_filter,
|
||||||
student=student,
|
student=student,
|
||||||
@@ -134,7 +140,8 @@ def admin_course_summaries(
|
|||||||
missing_time: bool = Query(False),
|
missing_time: bool = Query(False),
|
||||||
binding_status: str = Query(""),
|
binding_status: str = Query(""),
|
||||||
has_candidate: str = Query(""),
|
has_candidate: str = Query(""),
|
||||||
limit: int = Query(200, ge=1, le=1000),
|
limit: int = Query(50, ge=1, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
_user: str = Depends(verify_admin_auth),
|
_user: str = Depends(verify_admin_auth),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
@@ -151,6 +158,7 @@ def admin_course_summaries(
|
|||||||
binding_status=binding_status,
|
binding_status=binding_status,
|
||||||
has_candidate=has_candidate,
|
has_candidate=has_candidate,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ from ..config import (
|
|||||||
CLASSNOTES_PATH,
|
CLASSNOTES_PATH,
|
||||||
COURSE_SUMMARIES_ROOT,
|
COURSE_SUMMARIES_ROOT,
|
||||||
COURSE_SUMMARY_STATE_PATH,
|
COURSE_SUMMARY_STATE_PATH,
|
||||||
|
LEGACY_TEXT_ROOT,
|
||||||
OPERATION_LOGS_PATH,
|
OPERATION_LOGS_PATH,
|
||||||
TEACHERS_PATH,
|
TEACHERS_PATH,
|
||||||
|
USE_SQLITE_SOURCE,
|
||||||
)
|
)
|
||||||
|
from ..db import database_meta
|
||||||
from ..data import account_summary
|
from ..data import account_summary
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +34,11 @@ def health(_user: str = Depends(verify_records_auth)):
|
|||||||
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
|
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
|
||||||
"course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH),
|
"course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH),
|
||||||
"operation_logs": file_meta(OPERATION_LOGS_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),
|
"records_count": len(records),
|
||||||
"accounts_count": len(accounts),
|
"accounts_count": len(accounts),
|
||||||
"teachers_count": len(teachers),
|
"teachers_count": len(teachers),
|
||||||
|
|||||||
@@ -36,10 +36,18 @@ router = APIRouter()
|
|||||||
@router.get("/api/records")
|
@router.get("/api/records")
|
||||||
def records(
|
def records(
|
||||||
q: str = Query(..., min_length=1, description="自然语言查询,例如:王鑫鹏5月数学课"),
|
q: str = Query(..., min_length=1, description="自然语言查询,例如:王鑫鹏5月数学课"),
|
||||||
limit: int = Query(200, ge=1, le=1000),
|
limit: int = Query(30, ge=1, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
_user: str = Depends(verify_records_auth),
|
_user: str = Depends(verify_records_auth),
|
||||||
):
|
):
|
||||||
return query_public_records(load_records(), load_teachers(), q, limit=limit, summaries_root=COURSE_SUMMARIES_ROOT)
|
return query_public_records(
|
||||||
|
load_records(),
|
||||||
|
load_teachers(),
|
||||||
|
q,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
summaries_root=COURSE_SUMMARIES_ROOT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/student-account/{student}")
|
@router.get("/api/student-account/{student}")
|
||||||
|
|||||||
@@ -230,6 +230,7 @@
|
|||||||
<tbody id="reviewRows"></tbody>
|
<tbody id="reviewRows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="reviewPager" class="pager" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="summariesPanel" class="panel admin-panel" hidden></section>
|
<section id="summariesPanel" class="panel admin-panel" hidden></section>
|
||||||
@@ -352,6 +353,7 @@
|
|||||||
<tbody id="summarySearchRows"></tbody>
|
<tbody id="summarySearchRows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="summarySearchPager" class="pager" hidden></div>
|
||||||
<div class="summary-task-section">
|
<div class="summary-task-section">
|
||||||
<div class="section-head compact-head">
|
<div class="section-head compact-head">
|
||||||
<h3>待处理任务</h3>
|
<h3>待处理任务</h3>
|
||||||
@@ -382,6 +384,7 @@
|
|||||||
<tbody id="summaryReviewRows"></tbody>
|
<tbody id="summaryReviewRows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="summaryReviewPager" class="pager" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -445,6 +448,7 @@
|
|||||||
<tbody id="logRows"></tbody>
|
<tbody id="logRows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="logPager" class="pager" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="registerPanel" class="panel admin-panel" hidden>
|
<section id="registerPanel" class="panel admin-panel" hidden>
|
||||||
|
|||||||
+198
-71
@@ -50,9 +50,11 @@ const editNote = document.querySelector("#editNote");
|
|||||||
const reviewStatus = document.querySelector("#reviewStatus");
|
const reviewStatus = document.querySelector("#reviewStatus");
|
||||||
const reviewMeta = document.querySelector("#reviewMeta");
|
const reviewMeta = document.querySelector("#reviewMeta");
|
||||||
const reviewRows = document.querySelector("#reviewRows");
|
const reviewRows = document.querySelector("#reviewRows");
|
||||||
|
const reviewPager = document.querySelector("#reviewPager");
|
||||||
const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
|
const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
|
||||||
const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
|
const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
|
||||||
const summaryReviewRows = document.querySelector("#summaryReviewRows");
|
const summaryReviewRows = document.querySelector("#summaryReviewRows");
|
||||||
|
const summaryReviewPager = document.querySelector("#summaryReviewPager");
|
||||||
const duplicateSummaryScanBtns = Array.from(document.querySelectorAll(".duplicate-summary-scan"));
|
const duplicateSummaryScanBtns = Array.from(document.querySelectorAll(".duplicate-summary-scan"));
|
||||||
const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop");
|
const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop");
|
||||||
const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose");
|
const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose");
|
||||||
@@ -88,6 +90,7 @@ const summarySearchBindingStatus = document.querySelector("#summarySearchBinding
|
|||||||
const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate");
|
const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate");
|
||||||
const summarySearchMeta = document.querySelector("#summarySearchMeta");
|
const summarySearchMeta = document.querySelector("#summarySearchMeta");
|
||||||
const summarySearchRows = document.querySelector("#summarySearchRows");
|
const summarySearchRows = document.querySelector("#summarySearchRows");
|
||||||
|
const summarySearchPager = document.querySelector("#summarySearchPager");
|
||||||
const quickMismatchBtn = document.querySelector("#quickMismatchBtn");
|
const quickMismatchBtn = document.querySelector("#quickMismatchBtn");
|
||||||
const logOperation = document.querySelector("#logOperation");
|
const logOperation = document.querySelector("#logOperation");
|
||||||
const logStatus = document.querySelector("#logStatus");
|
const logStatus = document.querySelector("#logStatus");
|
||||||
@@ -95,6 +98,7 @@ const logFilterForm = document.querySelector("#logFilterForm");
|
|||||||
const logStudent = document.querySelector("#logStudent");
|
const logStudent = document.querySelector("#logStudent");
|
||||||
const logMeta = document.querySelector("#logMeta");
|
const logMeta = document.querySelector("#logMeta");
|
||||||
const logRows = document.querySelector("#logRows");
|
const logRows = document.querySelector("#logRows");
|
||||||
|
const logPager = document.querySelector("#logPager");
|
||||||
const classRegisterForm = document.querySelector("#classRegisterForm");
|
const classRegisterForm = document.querySelector("#classRegisterForm");
|
||||||
const classRegisterLines = document.querySelector("#classRegisterLines");
|
const classRegisterLines = document.querySelector("#classRegisterLines");
|
||||||
const classRegisterPreview = document.querySelector("#classRegisterPreview");
|
const classRegisterPreview = document.querySelector("#classRegisterPreview");
|
||||||
@@ -111,20 +115,26 @@ const summaryRegisterPreview = document.querySelector("#summaryRegisterPreview")
|
|||||||
const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus");
|
const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus");
|
||||||
const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm");
|
const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm");
|
||||||
|
|
||||||
|
const ADMIN_PAGE_SIZE = 50;
|
||||||
|
|
||||||
let currentAccounts = [];
|
let currentAccounts = [];
|
||||||
let currentDashboardPeriod = "month";
|
let currentDashboardPeriod = "month";
|
||||||
let editingAccountId = "";
|
let editingAccountId = "";
|
||||||
let currentTeachers = [];
|
let currentTeachers = [];
|
||||||
let editingTeacherId = "";
|
let editingTeacherId = "";
|
||||||
|
let currentReviewPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
|
||||||
let currentSummaryReviews = [];
|
let currentSummaryReviews = [];
|
||||||
|
let currentSummaryReviewPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
|
||||||
let activeSummaryReview = null;
|
let activeSummaryReview = null;
|
||||||
let currentSummarySearchItems = [];
|
let currentSummarySearchItems = [];
|
||||||
|
let currentSummarySearchPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
|
||||||
let expandedSummarySearchId = "";
|
let expandedSummarySearchId = "";
|
||||||
let editingSummarySearchId = "";
|
let editingSummarySearchId = "";
|
||||||
let editingSummaryIdentityId = "";
|
let editingSummaryIdentityId = "";
|
||||||
let quickSummaryMismatchMode = false;
|
let quickSummaryMismatchMode = false;
|
||||||
let quickSummaryProcessing = false;
|
let quickSummaryProcessing = false;
|
||||||
let currentOperationLogs = [];
|
let currentOperationLogs = [];
|
||||||
|
let currentLogPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
|
||||||
let expandedOperationLogId = "";
|
let expandedOperationLogId = "";
|
||||||
const registerPreviewState = {
|
const registerPreviewState = {
|
||||||
class_record: null,
|
class_record: null,
|
||||||
@@ -161,6 +171,49 @@ function metric(label, value) {
|
|||||||
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageRangeText(state) {
|
||||||
|
const total = Number(state.total || 0);
|
||||||
|
const shown = Number(state.shown || 0);
|
||||||
|
const offset = Number(state.offset || 0);
|
||||||
|
if (!total || !shown) return "0 条";
|
||||||
|
return `${offset + 1}-${offset + shown} 条`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPageNumber(state) {
|
||||||
|
return Math.floor((state.offset || 0) / (state.limit || ADMIN_PAGE_SIZE)) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalPageNumber(state) {
|
||||||
|
const limit = state.limit || ADMIN_PAGE_SIZE;
|
||||||
|
return Math.max(1, Math.ceil((state.total || 0) / limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPager(container, state, action) {
|
||||||
|
if (!container) return;
|
||||||
|
const total = Number(state.total || 0);
|
||||||
|
const shown = Number(state.shown || 0);
|
||||||
|
const limit = Number(state.limit || ADMIN_PAGE_SIZE);
|
||||||
|
const offset = Number(state.offset || 0);
|
||||||
|
if (total <= limit && offset === 0) {
|
||||||
|
container.hidden = true;
|
||||||
|
container.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.hidden = false;
|
||||||
|
container.dataset.pagerAction = action;
|
||||||
|
container.innerHTML = `<div class="pager-info">第 ${currentPageNumber(state)} / ${totalPageNumber(state)} 页 · ${pageRangeText(state)} / ${total} 条</div>
|
||||||
|
<div class="pager-actions">
|
||||||
|
<button class="secondary-button pager-prev" type="button" ${offset <= 0 ? "disabled" : ""}>上一页</button>
|
||||||
|
<button class="secondary-button pager-next" type="button" ${state.hasMore ? "" : "disabled"}>下一页</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPagerLoading(container) {
|
||||||
|
if (!container) return;
|
||||||
|
container.hidden = true;
|
||||||
|
container.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
function dashboardPeriodLabel(period) {
|
function dashboardPeriodLabel(period) {
|
||||||
if (period === "today") return "今日";
|
if (period === "today") return "今日";
|
||||||
if (period === "7d") return "近7日";
|
if (period === "7d") return "近7日";
|
||||||
@@ -423,21 +476,25 @@ async function fetchJson(url, options = {}) {
|
|||||||
|
|
||||||
function setActiveTab(tabName) {
|
function setActiveTab(tabName) {
|
||||||
const activeTabName = tabName === "summaries" ? "summarySearch" : tabName;
|
const activeTabName = tabName === "summaries" ? "summarySearch" : tabName;
|
||||||
|
let activeButton = null;
|
||||||
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
||||||
button.classList.toggle("is-active", button.dataset.adminTab === activeTabName);
|
const isActive = button.dataset.adminTab === activeTabName;
|
||||||
|
button.classList.toggle("is-active", isActive);
|
||||||
|
if (isActive) activeButton = button;
|
||||||
});
|
});
|
||||||
|
if (activeButton) activeButton.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
|
||||||
Object.entries(panels).forEach(([name, panel]) => {
|
Object.entries(panels).forEach(([name, panel]) => {
|
||||||
panel.hidden = name !== activeTabName;
|
panel.hidden = name !== activeTabName;
|
||||||
});
|
});
|
||||||
if (activeTabName === "dashboard") loadDashboard();
|
if (activeTabName === "dashboard") loadDashboard();
|
||||||
if (activeTabName === "accounts") loadAccounts();
|
if (activeTabName === "accounts") loadAccounts();
|
||||||
if (activeTabName === "teachers") loadTeachers();
|
if (activeTabName === "teachers") loadTeachers();
|
||||||
if (activeTabName === "reviews") loadReviews();
|
if (activeTabName === "reviews") loadReviews(0);
|
||||||
if (activeTabName === "summarySearch") {
|
if (activeTabName === "summarySearch") {
|
||||||
loadSummarySearch();
|
loadSummarySearch(0);
|
||||||
loadSummaryReviews();
|
loadSummaryReviews(0);
|
||||||
}
|
}
|
||||||
if (activeTabName === "logs") loadOperationLogs();
|
if (activeTabName === "logs") loadOperationLogs(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAdminHealth() {
|
async function loadAdminHealth() {
|
||||||
@@ -734,26 +791,45 @@ function renderReviewTarget(item) {
|
|||||||
return renderReviewLine(item.corrected_line);
|
return renderReviewLine(item.corrected_line);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadReviews() {
|
async function loadReviews(offset = currentReviewPage.offset || 0) {
|
||||||
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
|
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
|
||||||
|
setPagerLoading(reviewPager);
|
||||||
|
const normalizedOffset = Math.max(0, Number(offset || 0));
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: String(ADMIN_PAGE_SIZE),
|
||||||
|
offset: String(normalizedOffset),
|
||||||
|
type: "class_record_correction,class_record_deletion",
|
||||||
|
});
|
||||||
|
if (reviewStatus.value) params.set("status", reviewStatus.value);
|
||||||
try {
|
try {
|
||||||
const [correctionData, deletionData] = await Promise.all([
|
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`);
|
||||||
fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_correction", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`),
|
const items = data.items || [];
|
||||||
fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_deletion", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`),
|
const totalCount = Number(data.count || 0);
|
||||||
]);
|
const typeCounts = data.type_counts || {};
|
||||||
const items = [...(correctionData.items || []), ...(deletionData.items || [])].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
|
currentReviewPage = {
|
||||||
reviewMeta.innerHTML = [metric("当前结果", `${items.length} 条`)].join("");
|
offset: normalizedOffset,
|
||||||
|
limit: Number(data.limit || ADMIN_PAGE_SIZE),
|
||||||
|
total: totalCount,
|
||||||
|
shown: Number(data.returned || items.length),
|
||||||
|
hasMore: Boolean(data.has_more),
|
||||||
|
};
|
||||||
|
reviewMeta.innerHTML = [
|
||||||
|
metric("当前结果", `${totalCount} 条`),
|
||||||
|
metric("当前显示", pageRangeText(currentReviewPage)),
|
||||||
|
metric("纠错", `${typeCounts.class_record_correction || 0} 条`),
|
||||||
|
metric("删除", `${typeCounts.class_record_deletion || 0} 条`),
|
||||||
|
].join("");
|
||||||
reviewRows.innerHTML = items
|
reviewRows.innerHTML = items
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const canReview = item.status === "pending" || item.status === "conflict";
|
const canReview = item.status === "pending" || item.status === "conflict";
|
||||||
return `<tr>
|
return `<tr class="admin-data-row">
|
||||||
<td>#${escapeHtml(item.id)}</td>
|
<td data-label="编号">#${escapeHtml(item.id)}</td>
|
||||||
<td><span class="status ${item.status === "approved" ? "normal" : item.status === "rejected" ? "closed" : item.status === "conflict" ? "debt" : "warning"}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
|
<td data-label="状态"><span class="status ${item.status === "approved" ? "normal" : item.status === "rejected" ? "closed" : item.status === "conflict" ? "debt" : "warning"}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
|
||||||
<td>${escapeHtml(reviewTaskTypeLabel(item))}</td>
|
<td data-label="类型">${escapeHtml(reviewTaskTypeLabel(item))}</td>
|
||||||
<td>${renderReviewLine(item.original_line)}</td>
|
<td data-label="原记录">${renderReviewLine(item.original_line)}</td>
|
||||||
<td>${renderReviewTarget(item)}</td>
|
<td data-label="处理内容">${renderReviewTarget(item)}</td>
|
||||||
<td>${escapeHtml(item.created_at || "")}</td>
|
<td data-label="提交时间">${escapeHtml(item.created_at || "")}</td>
|
||||||
<td class="record-action-cell">
|
<td class="record-action-cell" data-label="操作">
|
||||||
<div class="record-actions">
|
<div class="record-actions">
|
||||||
<button class="small-button review-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>批准</button>
|
<button class="small-button review-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>批准</button>
|
||||||
<button class="small-button review-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
|
<button class="small-button review-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
|
||||||
@@ -765,6 +841,7 @@ async function loadReviews() {
|
|||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的审核项</td></tr>`;
|
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的审核项</td></tr>`;
|
||||||
}
|
}
|
||||||
|
renderPager(reviewPager, currentReviewPage, "reviews");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
@@ -1044,26 +1121,33 @@ function closeSummaryReviewDrawer() {
|
|||||||
summaryReviewDrawerBackdrop.hidden = true;
|
summaryReviewDrawerBackdrop.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSummaryReviews() {
|
async function loadSummaryReviews(offset = currentSummaryReviewPage.offset || 0) {
|
||||||
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
|
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
|
||||||
const reviewParams = new URLSearchParams({ type: "course_summary_review" });
|
setPagerLoading(summaryReviewPager);
|
||||||
const duplicateParams = new URLSearchParams({ type: "course_summary_duplicate_review" });
|
const normalizedOffset = Math.max(0, Number(offset || 0));
|
||||||
if (summaryReviewStatus.value) {
|
const params = new URLSearchParams({
|
||||||
reviewParams.set("status", summaryReviewStatus.value);
|
type: "course_summary_review,course_summary_duplicate_review",
|
||||||
duplicateParams.set("status", summaryReviewStatus.value);
|
limit: String(ADMIN_PAGE_SIZE),
|
||||||
}
|
offset: String(normalizedOffset),
|
||||||
|
});
|
||||||
|
if (summaryReviewStatus.value) params.set("status", summaryReviewStatus.value);
|
||||||
try {
|
try {
|
||||||
const [reviewData, duplicateData] = await Promise.all([
|
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`);
|
||||||
fetchJson(`/api/admin/tasks?${reviewParams.toString()}`),
|
currentSummaryReviews = data.items || [];
|
||||||
fetchJson(`/api/admin/tasks?${duplicateParams.toString()}`),
|
const totalCount = Number(data.count || 0);
|
||||||
]);
|
const typeCounts = data.type_counts || {};
|
||||||
const reviewItems = reviewData.items || [];
|
currentSummaryReviewPage = {
|
||||||
const duplicateItems = duplicateData.items || [];
|
offset: normalizedOffset,
|
||||||
currentSummaryReviews = [...reviewItems, ...duplicateItems].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
|
limit: Number(data.limit || ADMIN_PAGE_SIZE),
|
||||||
|
total: totalCount,
|
||||||
|
shown: Number(data.returned || currentSummaryReviews.length),
|
||||||
|
hasMore: Boolean(data.has_more),
|
||||||
|
};
|
||||||
summaryReviewMeta.innerHTML = [
|
summaryReviewMeta.innerHTML = [
|
||||||
metric("待处理任务", `${currentSummaryReviews.length} 条`),
|
metric("待处理任务", `${totalCount} 条`),
|
||||||
metric("课程小结审核", `${reviewItems.length} 条`),
|
metric("当前显示", pageRangeText(currentSummaryReviewPage)),
|
||||||
metric("重复小结", `${duplicateItems.length} 条`),
|
metric("课程小结审核", `${typeCounts.course_summary_review || 0} 条`),
|
||||||
|
metric("重复小结", `${typeCounts.course_summary_duplicate_review || 0} 条`),
|
||||||
].join("");
|
].join("");
|
||||||
summaryReviewRows.innerHTML = currentSummaryReviews
|
summaryReviewRows.innerHTML = currentSummaryReviews
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
@@ -1074,14 +1158,14 @@ async function loadSummaryReviews() {
|
|||||||
const approveButton = canApproveDirectly
|
const approveButton = canApproveDirectly
|
||||||
? `<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}">批准</button>`
|
? `<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}">批准</button>`
|
||||||
: "";
|
: "";
|
||||||
return `<tr>
|
return `<tr class="admin-data-row">
|
||||||
<td>#${escapeHtml(item.id)}</td>
|
<td data-label="编号">#${escapeHtml(item.id)}</td>
|
||||||
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
|
<td data-label="状态"><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
|
||||||
<td>${renderSummaryInfo(summary, item)}</td>
|
<td data-label="课程信息">${renderSummaryInfo(summary, item)}</td>
|
||||||
<td>${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td>
|
<td data-label="候选记录">${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td>
|
||||||
<td>${renderSummaryPreview(summary)}</td>
|
<td data-label="小结原文">${renderSummaryPreview(summary)}</td>
|
||||||
<td>${renderSummaryReviewReasons(item)}</td>
|
<td data-label="原因">${renderSummaryReviewReasons(item)}</td>
|
||||||
<td class="record-action-cell">
|
<td class="record-action-cell" data-label="操作">
|
||||||
<div class="record-actions">
|
<div class="record-actions">
|
||||||
<button class="small-button summary-detail" type="button" data-task-id="${escapeHtml(item.id)}">${escapeHtml(summaryReviewDetailLabel(item))}</button>
|
<button class="small-button summary-detail" type="button" data-task-id="${escapeHtml(item.id)}">${escapeHtml(summaryReviewDetailLabel(item))}</button>
|
||||||
${approveButton}
|
${approveButton}
|
||||||
@@ -1094,6 +1178,7 @@ async function loadSummaryReviews() {
|
|||||||
if (!currentSummaryReviews.length) {
|
if (!currentSummaryReviews.length) {
|
||||||
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结任务</td></tr>`;
|
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结任务</td></tr>`;
|
||||||
}
|
}
|
||||||
|
renderPager(summaryReviewPager, currentSummaryReviewPage, "summaryReviews");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
currentSummaryReviews = [];
|
currentSummaryReviews = [];
|
||||||
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||||
@@ -1101,7 +1186,7 @@ async function loadSummaryReviews() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function summarySearchParams() {
|
function summarySearchParams() {
|
||||||
const params = new URLSearchParams({ limit: "200" });
|
const params = new URLSearchParams({ limit: String(ADMIN_PAGE_SIZE), offset: String(currentSummarySearchPage.offset || 0) });
|
||||||
if (summarySearchQuery.value.trim()) params.set("q", summarySearchQuery.value.trim());
|
if (summarySearchQuery.value.trim()) params.set("q", summarySearchQuery.value.trim());
|
||||||
if (summarySearchStudent.value.trim()) params.set("student", summarySearchStudent.value.trim());
|
if (summarySearchStudent.value.trim()) params.set("student", summarySearchStudent.value.trim());
|
||||||
if (summarySearchTeacher.value.trim()) params.set("teacher", summarySearchTeacher.value.trim());
|
if (summarySearchTeacher.value.trim()) params.set("teacher", summarySearchTeacher.value.trim());
|
||||||
@@ -1314,11 +1399,11 @@ function renderSummarySearchRows(items) {
|
|||||||
const binding = summaryBinding(item);
|
const binding = summaryBinding(item);
|
||||||
const unmatchedClass = binding.status === "matched" ? "" : " unmatched-summary";
|
const unmatchedClass = binding.status === "matched" ? "" : " unmatched-summary";
|
||||||
const mainRow = `<tr class="summary-search-result-row${unmatchedClass}" data-summary-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}">
|
const mainRow = `<tr class="summary-search-result-row${unmatchedClass}" data-summary-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}">
|
||||||
<td>${escapeHtml(item.date_iso || "未识别")}</td>
|
<td data-label="日期">${escapeHtml(item.date_iso || "未识别")}</td>
|
||||||
<td>${escapeHtml(item.time_range || "缺少时间")}</td>
|
<td data-label="时间">${escapeHtml(item.time_range || "缺少时间")}</td>
|
||||||
<td>${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
|
<td data-label="学生">${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
|
||||||
<td>${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td>
|
<td data-label="老师/科目">${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td>
|
||||||
<td>${renderSummaryBindingStatus(item)}</td>
|
<td data-label="绑定状态">${renderSummaryBindingStatus(item)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
const detailRow = expanded
|
const detailRow = expanded
|
||||||
? `<tr class="summary-search-detail-row" data-summary-detail="${escapeHtml(item.id)}">
|
? `<tr class="summary-search-detail-row" data-summary-detail="${escapeHtml(item.id)}">
|
||||||
@@ -1350,11 +1435,20 @@ function renderCurrentSummarySearch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSummarySearch() {
|
async function loadSummarySearch(offset = currentSummarySearchPage.offset || 0) {
|
||||||
|
currentSummarySearchPage.offset = Math.max(0, Number(offset || 0));
|
||||||
summarySearchRows.innerHTML = `<tr><td colspan="5" class="empty">正在读取</td></tr>`;
|
summarySearchRows.innerHTML = `<tr><td colspan="5" class="empty">正在读取</td></tr>`;
|
||||||
|
setPagerLoading(summarySearchPager);
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`);
|
const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`);
|
||||||
currentSummarySearchItems = data.items || [];
|
currentSummarySearchItems = data.items || [];
|
||||||
|
currentSummarySearchPage = {
|
||||||
|
offset: data.offset || currentSummarySearchPage.offset || 0,
|
||||||
|
limit: data.limit || ADMIN_PAGE_SIZE,
|
||||||
|
total: data.count || 0,
|
||||||
|
shown: data.returned || currentSummarySearchItems.length,
|
||||||
|
hasMore: Boolean(data.has_more),
|
||||||
|
};
|
||||||
if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = "";
|
if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = "";
|
||||||
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = "";
|
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = "";
|
||||||
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummaryIdentityId)) editingSummaryIdentityId = "";
|
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummaryIdentityId)) editingSummaryIdentityId = "";
|
||||||
@@ -1367,12 +1461,13 @@ async function loadSummarySearch() {
|
|||||||
const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems);
|
const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems);
|
||||||
summarySearchMeta.innerHTML = [
|
summarySearchMeta.innerHTML = [
|
||||||
metric("命中小结", `${data.count} 条`),
|
metric("命中小结", `${data.count} 条`),
|
||||||
metric("当前显示", `${data.returned} 条`),
|
metric("当前显示", pageRangeText(currentSummarySearchPage)),
|
||||||
metric("已绑定", `${bindingSummary.matched} 条`),
|
metric("已绑定", `${bindingSummary.matched} 条`),
|
||||||
metric("待处理", `${bindingSummary.pending} 条`),
|
metric("待处理", `${bindingSummary.pending} 条`),
|
||||||
metric("有候选", `${bindingSummary.withCandidate} 条`),
|
metric("有候选", `${bindingSummary.withCandidate} 条`),
|
||||||
].join("");
|
].join("");
|
||||||
renderCurrentSummarySearch();
|
renderCurrentSummarySearch();
|
||||||
|
renderPager(summarySearchPager, currentSummarySearchPage, "summarySearch");
|
||||||
updateQuickMismatchButton();
|
updateQuickMismatchButton();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
currentSummarySearchItems = [];
|
currentSummarySearchItems = [];
|
||||||
@@ -1602,12 +1697,12 @@ function renderOperationLogRows(items) {
|
|||||||
.map((item) => {
|
.map((item) => {
|
||||||
const expanded = String(item.id) === expandedOperationLogId;
|
const expanded = String(item.id) === expandedOperationLogId;
|
||||||
const mainRow = `<tr class="log-row" data-log-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}">
|
const mainRow = `<tr class="log-row" data-log-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}">
|
||||||
<td>${escapeHtml(item.created_at || "")}</td>
|
<td data-label="时间">${escapeHtml(item.created_at || "")}</td>
|
||||||
<td>${escapeHtml(item.operation || "")}</td>
|
<td data-label="操作">${escapeHtml(item.operation || "")}</td>
|
||||||
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span></td>
|
<td data-label="结果"><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span></td>
|
||||||
<td>${escapeHtml(item.student || "")}</td>
|
<td data-label="学生">${escapeHtml(item.student || "")}</td>
|
||||||
<td>${renderLogAssociation(item)}</td>
|
<td data-label="批次/任务">${renderLogAssociation(item)}</td>
|
||||||
<td class="record-action-cell">${renderLogActions(item)}</td>
|
<td class="record-action-cell" data-label="操作">${renderLogActions(item)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
const detailRow = expanded
|
const detailRow = expanded
|
||||||
? `<tr class="log-detail-row" data-log-detail="${escapeHtml(item.id)}">
|
? `<tr class="log-detail-row" data-log-detail="${escapeHtml(item.id)}">
|
||||||
@@ -1688,18 +1783,31 @@ function renderLogActions(item) {
|
|||||||
return `<span class="muted">-</span>`;
|
return `<span class="muted">-</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOperationLogs() {
|
async function loadOperationLogs(offset = currentLogPage.offset || 0) {
|
||||||
|
currentLogPage.offset = Math.max(0, Number(offset || 0));
|
||||||
logRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
|
logRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
|
||||||
const params = new URLSearchParams({ limit: "200" });
|
setPagerLoading(logPager);
|
||||||
|
const params = new URLSearchParams({ limit: String(ADMIN_PAGE_SIZE), offset: String(currentLogPage.offset || 0) });
|
||||||
if (logOperation.value) params.set("operation", logOperation.value);
|
if (logOperation.value) params.set("operation", logOperation.value);
|
||||||
if (logStatus.value) params.set("status", logStatus.value);
|
if (logStatus.value) params.set("status", logStatus.value);
|
||||||
if (logStudent.value.trim()) params.set("student", logStudent.value.trim());
|
if (logStudent.value.trim()) params.set("student", logStudent.value.trim());
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson(`/api/admin/operation-logs?${params.toString()}`);
|
const data = await fetchJson(`/api/admin/operation-logs?${params.toString()}`);
|
||||||
currentOperationLogs = data.items || [];
|
currentOperationLogs = data.items || [];
|
||||||
|
currentLogPage = {
|
||||||
|
offset: data.offset || currentLogPage.offset || 0,
|
||||||
|
limit: data.limit || ADMIN_PAGE_SIZE,
|
||||||
|
total: data.count || 0,
|
||||||
|
shown: data.returned || currentOperationLogs.length,
|
||||||
|
hasMore: Boolean(data.has_more),
|
||||||
|
};
|
||||||
if (!currentOperationLogs.some((item) => String(item.id) === expandedOperationLogId)) expandedOperationLogId = "";
|
if (!currentOperationLogs.some((item) => String(item.id) === expandedOperationLogId)) expandedOperationLogId = "";
|
||||||
logMeta.innerHTML = [metric("当前结果", `${data.count} 条`)].join("");
|
logMeta.innerHTML = [
|
||||||
|
metric("当前结果", `${data.count} 条`),
|
||||||
|
metric("当前显示", pageRangeText(currentLogPage)),
|
||||||
|
].join("");
|
||||||
renderCurrentOperationLogs();
|
renderCurrentOperationLogs();
|
||||||
|
renderPager(logPager, currentLogPage, "logs");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
currentOperationLogs = [];
|
currentOperationLogs = [];
|
||||||
logRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
logRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||||
@@ -2113,14 +2221,14 @@ teacherRows.addEventListener("click", (event) => {
|
|||||||
if (!button) return;
|
if (!button) return;
|
||||||
openEditTeacherEditor(button.dataset.teacherId);
|
openEditTeacherEditor(button.dataset.teacherId);
|
||||||
});
|
});
|
||||||
reviewStatus.addEventListener("change", loadReviews);
|
reviewStatus.addEventListener("change", () => loadReviews(0));
|
||||||
reviewRows.addEventListener("click", (event) => {
|
reviewRows.addEventListener("click", (event) => {
|
||||||
const approve = event.target.closest(".review-approve");
|
const approve = event.target.closest(".review-approve");
|
||||||
const reject = event.target.closest(".review-reject");
|
const reject = event.target.closest(".review-reject");
|
||||||
if (approve) reviewTask(approve.dataset.taskId, "approve");
|
if (approve) reviewTask(approve.dataset.taskId, "approve");
|
||||||
if (reject) reviewTask(reject.dataset.taskId, "reject");
|
if (reject) reviewTask(reject.dataset.taskId, "reject");
|
||||||
});
|
});
|
||||||
summaryReviewStatus.addEventListener("change", loadSummaryReviews);
|
summaryReviewStatus.addEventListener("change", () => loadSummaryReviews(0));
|
||||||
duplicateSummaryScanBtns.forEach((button) => {
|
duplicateSummaryScanBtns.forEach((button) => {
|
||||||
button.addEventListener("click", scanDuplicateSummaries);
|
button.addEventListener("click", scanDuplicateSummaries);
|
||||||
});
|
});
|
||||||
@@ -2167,15 +2275,15 @@ document.addEventListener("keydown", (event) => {
|
|||||||
summarySearchForm.addEventListener("submit", (event) => {
|
summarySearchForm.addEventListener("submit", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
quickSummaryMismatchMode = false;
|
quickSummaryMismatchMode = false;
|
||||||
loadSummarySearch();
|
loadSummarySearch(0);
|
||||||
});
|
});
|
||||||
summarySearchBindingStatus.addEventListener("change", () => {
|
summarySearchBindingStatus.addEventListener("change", () => {
|
||||||
quickSummaryMismatchMode = false;
|
quickSummaryMismatchMode = false;
|
||||||
loadSummarySearch();
|
loadSummarySearch(0);
|
||||||
});
|
});
|
||||||
summarySearchHasCandidate.addEventListener("change", () => {
|
summarySearchHasCandidate.addEventListener("change", () => {
|
||||||
quickSummaryMismatchMode = false;
|
quickSummaryMismatchMode = false;
|
||||||
loadSummarySearch();
|
loadSummarySearch(0);
|
||||||
});
|
});
|
||||||
quickMismatchBtn.addEventListener("click", () => {
|
quickMismatchBtn.addEventListener("click", () => {
|
||||||
processQuickMismatchItem().catch((error) => {
|
processQuickMismatchItem().catch((error) => {
|
||||||
@@ -2278,11 +2386,11 @@ summarySearchRows.addEventListener("keydown", (event) => {
|
|||||||
expandedSummarySearchId = expandedSummarySearchId === row.dataset.summaryId ? "" : row.dataset.summaryId;
|
expandedSummarySearchId = expandedSummarySearchId === row.dataset.summaryId ? "" : row.dataset.summaryId;
|
||||||
renderCurrentSummarySearch();
|
renderCurrentSummarySearch();
|
||||||
});
|
});
|
||||||
logOperation.addEventListener("change", loadOperationLogs);
|
logOperation.addEventListener("change", () => loadOperationLogs(0));
|
||||||
logStatus.addEventListener("change", loadOperationLogs);
|
logStatus.addEventListener("change", () => loadOperationLogs(0));
|
||||||
logFilterForm.addEventListener("submit", (event) => {
|
logFilterForm.addEventListener("submit", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
loadOperationLogs();
|
loadOperationLogs(0);
|
||||||
});
|
});
|
||||||
logRows.addEventListener("click", (event) => {
|
logRows.addEventListener("click", (event) => {
|
||||||
const rollback = event.target.closest(".log-rollback");
|
const rollback = event.target.closest(".log-rollback");
|
||||||
@@ -2305,6 +2413,25 @@ logRows.addEventListener("keydown", (event) => {
|
|||||||
expandedOperationLogId = expandedOperationLogId === row.dataset.logId ? "" : row.dataset.logId;
|
expandedOperationLogId = expandedOperationLogId === row.dataset.logId ? "" : row.dataset.logId;
|
||||||
renderCurrentOperationLogs();
|
renderCurrentOperationLogs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function bindPager(container, getState, loadPage) {
|
||||||
|
if (!container) return;
|
||||||
|
container.addEventListener("click", (event) => {
|
||||||
|
const previous = event.target.closest(".pager-prev");
|
||||||
|
const next = event.target.closest(".pager-next");
|
||||||
|
if (!previous && !next) return;
|
||||||
|
const state = getState();
|
||||||
|
const limit = state.limit || ADMIN_PAGE_SIZE;
|
||||||
|
const nextOffset = Math.max(0, (state.offset || 0) + (previous ? -limit : limit));
|
||||||
|
loadPage(nextOffset);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindPager(summarySearchPager, () => currentSummarySearchPage, loadSummarySearch);
|
||||||
|
bindPager(summaryReviewPager, () => currentSummaryReviewPage, loadSummaryReviews);
|
||||||
|
bindPager(reviewPager, () => currentReviewPage, loadReviews);
|
||||||
|
bindPager(logPager, () => currentLogPage, loadOperationLogs);
|
||||||
|
|
||||||
classRegisterForm.addEventListener("submit", (event) => {
|
classRegisterForm.addEventListener("submit", (event) => {
|
||||||
previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus);
|
previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus);
|
||||||
});
|
});
|
||||||
@@ -2335,7 +2462,7 @@ refreshBtn.addEventListener("click", () => {
|
|||||||
if (!panels.accounts.hidden) loadAccounts();
|
if (!panels.accounts.hidden) loadAccounts();
|
||||||
if (!panels.reviews.hidden) loadReviews();
|
if (!panels.reviews.hidden) loadReviews();
|
||||||
if (!panels.summarySearch.hidden) refreshSummaryWorkspace({ logs: false });
|
if (!panels.summarySearch.hidden) refreshSummaryWorkspace({ logs: false });
|
||||||
if (!panels.logs.hidden) loadOperationLogs();
|
if (!panels.logs.hidden) loadOperationLogs(currentLogPage.offset || 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
loadAdminHealth();
|
loadAdminHealth();
|
||||||
|
|||||||
+158
-26
@@ -4,6 +4,7 @@ const recordForm = document.querySelector("#recordForm");
|
|||||||
const recordQuery = document.querySelector("#recordQuery");
|
const recordQuery = document.querySelector("#recordQuery");
|
||||||
const recordMeta = document.querySelector("#recordMeta");
|
const recordMeta = document.querySelector("#recordMeta");
|
||||||
const recordRows = document.querySelector("#recordRows");
|
const recordRows = document.querySelector("#recordRows");
|
||||||
|
const recordPager = document.querySelector("#recordPager");
|
||||||
const dateSortBtn = document.querySelector("#dateSortBtn");
|
const dateSortBtn = document.querySelector("#dateSortBtn");
|
||||||
const timeSortBtn = document.querySelector("#timeSortBtn");
|
const timeSortBtn = document.querySelector("#timeSortBtn");
|
||||||
const inlineAccount = document.querySelector("#inlineAccount");
|
const inlineAccount = document.querySelector("#inlineAccount");
|
||||||
@@ -42,11 +43,16 @@ const submitSummarySupplementBtn = document.querySelector("#submitSummarySupplem
|
|||||||
const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
|
const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
|
||||||
const COPY_LINE_BREAK = "\r\n";
|
const COPY_LINE_BREAK = "\r\n";
|
||||||
const SORT_DIRECTIONS = { asc: 1, desc: -1 };
|
const SORT_DIRECTIONS = { asc: 1, desc: -1 };
|
||||||
|
const RECORD_PAGE_SIZE = 30;
|
||||||
let currentRecords = [];
|
let currentRecords = [];
|
||||||
let recordSort = { date: "asc", time: "asc" };
|
let recordSort = { date: "asc", time: "asc" };
|
||||||
let currentRecordOrder = [];
|
let currentRecordOrder = [];
|
||||||
let correctedRecords = new Map();
|
let correctedRecords = new Map();
|
||||||
|
let correctedRecordOrder = [];
|
||||||
let expandedSummaryRecords = new Set();
|
let expandedSummaryRecords = new Set();
|
||||||
|
let expandedSummaryBodies = new Set();
|
||||||
|
let collapsedTeacherGroups = new Set();
|
||||||
|
let currentRecordPage = { query: "", offset: 0, limit: RECORD_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
|
||||||
let activeCorrectionKey = "";
|
let activeCorrectionKey = "";
|
||||||
let activeDeleteKey = "";
|
let activeDeleteKey = "";
|
||||||
let activeSummarySupplementKey = "";
|
let activeSummarySupplementKey = "";
|
||||||
@@ -84,6 +90,44 @@ function metricHtml(label, valueHtml) {
|
|||||||
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${valueHtml}</strong></div>`;
|
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${valueHtml}</strong></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentPageNumber(state) {
|
||||||
|
return Math.floor((state.offset || 0) / (state.limit || RECORD_PAGE_SIZE)) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalPageNumber(state) {
|
||||||
|
const limit = state.limit || RECORD_PAGE_SIZE;
|
||||||
|
return Math.max(1, Math.ceil((state.total || 0) / limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPager(container, state) {
|
||||||
|
if (!container) return;
|
||||||
|
const total = Number(state.total || 0);
|
||||||
|
const shown = Number(state.shown || 0);
|
||||||
|
const limit = Number(state.limit || RECORD_PAGE_SIZE);
|
||||||
|
const offset = Number(state.offset || 0);
|
||||||
|
if (total <= limit && offset === 0) {
|
||||||
|
container.hidden = true;
|
||||||
|
container.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = shown ? offset + 1 : 0;
|
||||||
|
const end = offset + shown;
|
||||||
|
container.hidden = false;
|
||||||
|
container.innerHTML = `<div class="pager-info">第 ${currentPageNumber(state)} / ${totalPageNumber(state)} 页 · ${start}-${end} / ${total} 条</div>
|
||||||
|
<div class="pager-actions">
|
||||||
|
<button class="secondary-button pager-prev" type="button" ${offset <= 0 ? "disabled" : ""}>上一页</button>
|
||||||
|
<button class="secondary-button pager-next" type="button" ${state.hasMore ? "" : "disabled"}>下一页</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageRangeText(state) {
|
||||||
|
const total = Number(state.total || 0);
|
||||||
|
const shown = Number(state.shown || 0);
|
||||||
|
const offset = Number(state.offset || 0);
|
||||||
|
if (!total || !shown) return "0 条";
|
||||||
|
return `${offset + 1}-${offset + shown} 条`;
|
||||||
|
}
|
||||||
|
|
||||||
function makeRecordKey(row, index) {
|
function makeRecordKey(row, index) {
|
||||||
return row.record_id || JSON.stringify([index, row.date, row.time, row.student, row.duration, row.teacher, row.subject]);
|
return row.record_id || JSON.stringify([index, row.date, row.time, row.student, row.duration, row.teacher, row.subject]);
|
||||||
}
|
}
|
||||||
@@ -207,13 +251,13 @@ function renderRecordRow(row) {
|
|||||||
const badge = corrected ? '<span class="correction-badge">已修改</span>' : "";
|
const badge = corrected ? '<span class="correction-badge">已修改</span>' : "";
|
||||||
const summaryExpanded = expandedSummaryRecords.has(key);
|
const summaryExpanded = expandedSummaryRecords.has(key);
|
||||||
return `<tr class="record-row${correctedClass}${summaryClass}" data-record-key="${escapeHtml(key)}" tabindex="0" role="button" aria-expanded="${summaryExpanded ? "true" : "false"}">
|
return `<tr class="record-row${correctedClass}${summaryClass}" data-record-key="${escapeHtml(key)}" tabindex="0" role="button" aria-expanded="${summaryExpanded ? "true" : "false"}">
|
||||||
<td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
|
<td data-label="日期">${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
|
||||||
<td>${escapeHtml(displayRow.time)}</td>
|
<td data-label="时间">${escapeHtml(displayRow.time)}</td>
|
||||||
<td>${escapeHtml(displayRow.student)}</td>
|
<td data-label="学生">${escapeHtml(displayRow.student)}</td>
|
||||||
<td>${escapeHtml(displayRow.teacher)}</td>
|
<td data-label="老师">${escapeHtml(displayRow.teacher)}</td>
|
||||||
<td>${escapeHtml(displayRow.subject)}</td>
|
<td data-label="科目">${escapeHtml(displayRow.subject)}</td>
|
||||||
<td class="num">${escapeHtml(displayRow.duration)}</td>
|
<td class="num" data-label="时长">${escapeHtml(displayRow.duration)}</td>
|
||||||
<td class="record-action-cell">
|
<td class="record-action-cell" data-label="操作">
|
||||||
<div class="record-actions">
|
<div class="record-actions">
|
||||||
<button class="small-button correction-edit" type="button" data-record-key="${escapeHtml(key)}">${actionLabel}</button>
|
<button class="small-button correction-edit" type="button" data-record-key="${escapeHtml(key)}">${actionLabel}</button>
|
||||||
<button class="small-button record-delete" type="button" data-record-key="${escapeHtml(key)}">删除</button>
|
<button class="small-button record-delete" type="button" data-record-key="${escapeHtml(key)}">删除</button>
|
||||||
@@ -223,7 +267,16 @@ function renderRecordRow(row) {
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSummaryEntry(summary) {
|
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) {
|
||||||
const title = summary.title || "课程小结";
|
const title = summary.title || "课程小结";
|
||||||
const time = summary.time_range ? ` · ${summary.time_range}` : "";
|
const time = summary.time_range ? ` · ${summary.time_range}` : "";
|
||||||
const meta = [
|
const meta = [
|
||||||
@@ -231,10 +284,15 @@ function renderSummaryEntry(summary) {
|
|||||||
summary.teacher || "",
|
summary.teacher || "",
|
||||||
summary.subject || "",
|
summary.subject || "",
|
||||||
].filter(Boolean).join(" · ");
|
].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">
|
return `<div class="record-summary-item">
|
||||||
<div class="record-summary-title">${escapeHtml(title)}${escapeHtml(time)}</div>
|
<div class="record-summary-title">${escapeHtml(title)}${escapeHtml(time)}</div>
|
||||||
${meta ? `<div class="record-summary-meta">${escapeHtml(meta)}</div>` : ""}
|
${meta ? `<div class="record-summary-meta">${escapeHtml(meta)}</div>` : ""}
|
||||||
<div class="summary-body">${escapeHtml(summary.body || "暂无正文")}</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>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,25 +311,31 @@ function renderGroupedRecords(records) {
|
|||||||
currentRecordOrder = [];
|
currentRecordOrder = [];
|
||||||
return groupRecordsByTeacher(records)
|
return groupRecordsByTeacher(records)
|
||||||
.map(
|
.map(
|
||||||
(group) => `<tr class="teacher-group">
|
(group) => {
|
||||||
<td colspan="7">
|
const collapsed = collapsedTeacherGroups.has(group.teacher);
|
||||||
<div class="teacher-group-title">
|
const rows = collapsed
|
||||||
<strong>${escapeHtml(group.teacher)}</strong>
|
? ""
|
||||||
<span>${group.count} 条记录 · ${displayHours(group.totalHours)}</span>
|
: sortRecordsForDisplay(group.records)
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>${sortRecordsForDisplay(group.records)
|
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
currentRecordOrder.push(row._recordKey);
|
currentRecordOrder.push(row._recordKey);
|
||||||
const summaryCount = Number(row.summary_count || 0);
|
const summaryCount = Number(row.summary_count || 0);
|
||||||
const summaryRow = summaryCount
|
const summaryRow = summaryCount
|
||||||
? `<tr class="summary-collapse-row" data-summary-row="${escapeHtml(row._recordKey)}" ${expandedSummaryRecords.has(row._recordKey) ? "" : "hidden"}>
|
? `<tr class="summary-collapse-row" data-summary-row="${escapeHtml(row._recordKey)}" ${expandedSummaryRecords.has(row._recordKey) ? "" : "hidden"}>
|
||||||
<td colspan="7">${(row.summaries || []).map(renderSummaryEntry).join("")}</td>
|
<td colspan="7">${(row.summaries || []).map((summary, index) => renderSummaryEntry(summary, row._recordKey, index)).join("")}</td>
|
||||||
</tr>`
|
</tr>`
|
||||||
: renderSummaryMissingRow(row._recordKey, expandedSummaryRecords.has(row._recordKey));
|
: renderSummaryMissingRow(row._recordKey, expandedSummaryRecords.has(row._recordKey));
|
||||||
return `${renderRecordRow(row)}${summaryRow}`;
|
return `${renderRecordRow(row)}${summaryRow}`;
|
||||||
})
|
})
|
||||||
.join("")}`,
|
.join("");
|
||||||
|
return `<tr class="teacher-group" data-teacher="${escapeHtml(group.teacher)}">
|
||||||
|
<td colspan="7">
|
||||||
|
<button class="teacher-group-toggle" type="button" data-teacher="${escapeHtml(group.teacher)}" aria-expanded="${collapsed ? "false" : "true"}">
|
||||||
|
<strong>${escapeHtml(group.teacher)}</strong>
|
||||||
|
<span>${group.count} 条记录 · ${displayHours(group.totalHours)} · ${collapsed ? "展开" : "收起"}</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>${rows}`;
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
@@ -289,6 +353,24 @@ function toggleRecordSummary(key) {
|
|||||||
toggleSummaryRow(key);
|
toggleSummaryRow(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleTeacherGroup(teacher) {
|
||||||
|
if (collapsedTeacherGroups.has(teacher)) {
|
||||||
|
collapsedTeacherGroups.delete(teacher);
|
||||||
|
} else {
|
||||||
|
collapsedTeacherGroups.add(teacher);
|
||||||
|
}
|
||||||
|
renderCurrentRecords();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSummaryBody(key) {
|
||||||
|
if (expandedSummaryBodies.has(key)) {
|
||||||
|
expandedSummaryBodies.delete(key);
|
||||||
|
} else {
|
||||||
|
expandedSummaryBodies.add(key);
|
||||||
|
}
|
||||||
|
renderCurrentRecords();
|
||||||
|
}
|
||||||
|
|
||||||
function statusClass(status) {
|
function statusClass(status) {
|
||||||
if (status === "欠费") return "debt";
|
if (status === "欠费") return "debt";
|
||||||
if (status === "预警") return "warning";
|
if (status === "预警") return "warning";
|
||||||
@@ -349,8 +431,11 @@ function updateCorrectionToolbar(message = "", isError = false) {
|
|||||||
|
|
||||||
function resetCorrections() {
|
function resetCorrections() {
|
||||||
correctedRecords = new Map();
|
correctedRecords = new Map();
|
||||||
|
correctedRecordOrder = [];
|
||||||
currentRecordOrder = [];
|
currentRecordOrder = [];
|
||||||
expandedSummaryRecords = new Set();
|
expandedSummaryRecords = new Set();
|
||||||
|
expandedSummaryBodies = new Set();
|
||||||
|
collapsedTeacherGroups = new Set();
|
||||||
activeCorrectionKey = "";
|
activeCorrectionKey = "";
|
||||||
updateCorrectionToolbar();
|
updateCorrectionToolbar();
|
||||||
}
|
}
|
||||||
@@ -596,8 +681,10 @@ function saveCorrection() {
|
|||||||
const corrected = buildCorrectedRecord(original);
|
const corrected = buildCorrectedRecord(original);
|
||||||
if (buildRecordLine(corrected) === buildRecordLine(original)) {
|
if (buildRecordLine(corrected) === buildRecordLine(original)) {
|
||||||
correctedRecords.delete(activeCorrectionKey);
|
correctedRecords.delete(activeCorrectionKey);
|
||||||
|
correctedRecordOrder = correctedRecordOrder.filter((key) => key !== activeCorrectionKey);
|
||||||
} else {
|
} else {
|
||||||
correctedRecords.set(activeCorrectionKey, corrected);
|
correctedRecords.set(activeCorrectionKey, corrected);
|
||||||
|
if (!correctedRecordOrder.includes(activeCorrectionKey)) correctedRecordOrder.push(activeCorrectionKey);
|
||||||
}
|
}
|
||||||
recordRows.innerHTML = renderGroupedRecords(currentRecords);
|
recordRows.innerHTML = renderGroupedRecords(currentRecords);
|
||||||
closeCorrectionDialog();
|
closeCorrectionDialog();
|
||||||
@@ -630,7 +717,7 @@ async function copyTextToClipboard(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyCorrectedRecords() {
|
async function copyCorrectedRecords() {
|
||||||
const correctedInPageOrder = currentRecordOrder
|
const correctedInPageOrder = correctedRecordOrder
|
||||||
.map((key) => correctedRecords.get(key))
|
.map((key) => correctedRecords.get(key))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (!correctedInPageOrder.length) return;
|
if (!correctedInPageOrder.length) return;
|
||||||
@@ -644,7 +731,7 @@ async function copyCorrectedRecords() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitCorrectedRecords() {
|
async function submitCorrectedRecords() {
|
||||||
const items = currentRecordOrder
|
const items = correctedRecordOrder
|
||||||
.map((key) => {
|
.map((key) => {
|
||||||
const corrected = correctedRecords.get(key);
|
const corrected = correctedRecords.get(key);
|
||||||
if (!corrected) return null;
|
if (!corrected) return null;
|
||||||
@@ -698,40 +785,64 @@ async function loadHealth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queryRecords(query) {
|
async function queryRecords(query, options = {}) {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
|
const offset = Number(options.offset || 0);
|
||||||
recordRows.innerHTML = `<tr><td colspan="7" class="empty">正在查询</td></tr>`;
|
recordRows.innerHTML = `<tr><td colspan="7" class="empty">正在查询</td></tr>`;
|
||||||
recordMeta.innerHTML = "";
|
recordMeta.innerHTML = "";
|
||||||
|
if (recordPager) {
|
||||||
|
recordPager.hidden = true;
|
||||||
|
recordPager.innerHTML = "";
|
||||||
|
}
|
||||||
currentRecords = [];
|
currentRecords = [];
|
||||||
|
if (options.reset !== false) {
|
||||||
resetCorrections();
|
resetCorrections();
|
||||||
resetRecordSort();
|
resetRecordSort();
|
||||||
clearInlineAccount();
|
clearInlineAccount();
|
||||||
|
} else {
|
||||||
|
currentRecordOrder = [];
|
||||||
|
expandedSummaryRecords = new Set();
|
||||||
|
expandedSummaryBodies = new Set();
|
||||||
|
collapsedTeacherGroups = new Set();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson(`/api/records?q=${encodeURIComponent(q)}&limit=500`);
|
const params = new URLSearchParams({ q, limit: String(RECORD_PAGE_SIZE), offset: String(offset) });
|
||||||
|
const data = await fetchJson(`/api/records?${params.toString()}`);
|
||||||
const summary = data.summary;
|
const summary = data.summary;
|
||||||
|
currentRecordPage = {
|
||||||
|
query: q,
|
||||||
|
offset: data.offset || offset,
|
||||||
|
limit: data.limit || RECORD_PAGE_SIZE,
|
||||||
|
total: data.total_records || 0,
|
||||||
|
shown: data.shown_records || (data.records || []).length,
|
||||||
|
hasMore: Boolean(data.has_more),
|
||||||
|
};
|
||||||
recordMeta.innerHTML = [
|
recordMeta.innerHTML = [
|
||||||
metric("识别日期", data.query.date_range),
|
metric("识别日期", data.query.date_range),
|
||||||
metric("命中记录", `${summary.count} 条`),
|
metric("命中记录", `${summary.count} 条`),
|
||||||
|
metric("当前显示", pageRangeText(currentRecordPage)),
|
||||||
metric("总课时", displayHours(summary.total_hours, summary.total_duration)),
|
metric("总课时", displayHours(summary.total_hours, summary.total_duration)),
|
||||||
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
|
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
|
||||||
].join("");
|
].join("");
|
||||||
|
|
||||||
if (data.query.students.length === 1) {
|
if (options.reset !== false && data.query.students.length === 1) {
|
||||||
await loadInlineAccount(data.query.students[0]);
|
await loadInlineAccount(data.query.students[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.records.length) {
|
if (!data.records.length) {
|
||||||
recordRows.innerHTML = `<tr><td colspan="7" class="empty">未找到符合条件的上课记录</td></tr>`;
|
recordRows.innerHTML = `<tr><td colspan="7" class="empty">未找到符合条件的上课记录</td></tr>`;
|
||||||
|
renderPager(recordPager, currentRecordPage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentRecords = data.records.map((row, index) => ({
|
currentRecords = data.records.map((row, index) => ({
|
||||||
...row,
|
...row,
|
||||||
_recordIndex: index,
|
_recordIndex: currentRecordPage.offset + index,
|
||||||
_recordKey: makeRecordKey(row, index),
|
_recordKey: makeRecordKey(row, currentRecordPage.offset + index),
|
||||||
}));
|
}));
|
||||||
recordRows.innerHTML = renderGroupedRecords(currentRecords);
|
recordRows.innerHTML = renderGroupedRecords(currentRecords);
|
||||||
|
renderPager(recordPager, currentRecordPage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
recordRows.innerHTML = `<tr><td colspan="7" class="empty">查询失败:${escapeHtml(error.message)}</td></tr>`;
|
recordRows.innerHTML = `<tr><td colspan="7" class="empty">查询失败:${escapeHtml(error.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
@@ -761,6 +872,8 @@ recordRows.addEventListener("click", (event) => {
|
|||||||
const summarySupplementButton = event.target.closest(".summary-supplement");
|
const summarySupplementButton = event.target.closest(".summary-supplement");
|
||||||
const editButton = event.target.closest(".correction-edit");
|
const editButton = event.target.closest(".correction-edit");
|
||||||
const deleteButton = event.target.closest(".record-delete");
|
const deleteButton = event.target.closest(".record-delete");
|
||||||
|
const teacherToggle = event.target.closest(".teacher-group-toggle");
|
||||||
|
const summaryToggle = event.target.closest(".summary-toggle");
|
||||||
if (summarySupplementButton) {
|
if (summarySupplementButton) {
|
||||||
openSummarySupplementDialog(summarySupplementButton.dataset.recordKey);
|
openSummarySupplementDialog(summarySupplementButton.dataset.recordKey);
|
||||||
return;
|
return;
|
||||||
@@ -773,6 +886,14 @@ recordRows.addEventListener("click", (event) => {
|
|||||||
openDeleteDialog(deleteButton.dataset.recordKey);
|
openDeleteDialog(deleteButton.dataset.recordKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (teacherToggle) {
|
||||||
|
toggleTeacherGroup(teacherToggle.dataset.teacher);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (summaryToggle) {
|
||||||
|
toggleSummaryBody(summaryToggle.dataset.summaryBodyKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const row = event.target.closest(".record-row");
|
const row = event.target.closest(".record-row");
|
||||||
if (row && !event.target.closest(".record-action-cell")) toggleRecordSummary(row.dataset.recordKey);
|
if (row && !event.target.closest(".record-action-cell")) toggleRecordSummary(row.dataset.recordKey);
|
||||||
});
|
});
|
||||||
@@ -787,6 +908,17 @@ recordRows.addEventListener("keydown", (event) => {
|
|||||||
toggleRecordSummary(row.dataset.recordKey);
|
toggleRecordSummary(row.dataset.recordKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (recordPager) {
|
||||||
|
recordPager.addEventListener("click", (event) => {
|
||||||
|
const previous = event.target.closest(".pager-prev");
|
||||||
|
const next = event.target.closest(".pager-next");
|
||||||
|
if (!previous && !next) return;
|
||||||
|
const delta = previous ? -currentRecordPage.limit : currentRecordPage.limit;
|
||||||
|
const nextOffset = Math.max(0, currentRecordPage.offset + delta);
|
||||||
|
queryRecords(currentRecordPage.query || recordQuery.value, { offset: nextOffset, reset: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => {
|
[correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => {
|
||||||
input.addEventListener("input", updateCorrectionPreview);
|
input.addEventListener("input", updateCorrectionPreview);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="recordPager" class="pager" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+223
-1
@@ -938,6 +938,22 @@ textarea:focus {
|
|||||||
margin-top: 8px;
|
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 {
|
.summary-conflict-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -1234,6 +1250,35 @@ textarea:focus {
|
|||||||
max-height: calc(100vh - 262px);
|
max-height: calc(100vh - 262px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: #fbfcfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-info {
|
||||||
|
min-width: 0;
|
||||||
|
color: #344054;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-actions .secondary-button {
|
||||||
|
min-width: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 720px;
|
min-width: 720px;
|
||||||
@@ -1310,18 +1355,39 @@ td {
|
|||||||
color: var(--accent-strong);
|
color: var(--accent-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teacher-group-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.teacher-group-title strong {
|
.teacher-group-title strong {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teacher-group-title span {
|
.teacher-group-title span,
|
||||||
|
.teacher-group-toggle span {
|
||||||
color: #344054;
|
color: #344054;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teacher-group-toggle strong {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
.record-row td:nth-child(4) {
|
.record-row td:nth-child(4) {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -1833,6 +1899,130 @@ td {
|
|||||||
overscroll-behavior-y: auto;
|
overscroll-behavior-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.records-panel .table-wrap,
|
||||||
|
#reviewsPanel .table-wrap,
|
||||||
|
#summarySearchPanel .table-wrap,
|
||||||
|
#logsPanel .table-wrap {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-panel table,
|
||||||
|
#reviewsPanel table,
|
||||||
|
#summarySearchPanel table,
|
||||||
|
#logsPanel table {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-panel thead,
|
||||||
|
#reviewsPanel thead,
|
||||||
|
#summarySearchPanel thead,
|
||||||
|
#logsPanel thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-panel tbody,
|
||||||
|
.records-panel tr,
|
||||||
|
.records-panel td,
|
||||||
|
#reviewsPanel tbody,
|
||||||
|
#reviewsPanel tr,
|
||||||
|
#reviewsPanel td,
|
||||||
|
#summarySearchPanel tbody,
|
||||||
|
#summarySearchPanel tr,
|
||||||
|
#summarySearchPanel td,
|
||||||
|
#logsPanel tbody,
|
||||||
|
#logsPanel tr,
|
||||||
|
#logsPanel td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-row,
|
||||||
|
.admin-data-row,
|
||||||
|
.summary-search-result-row,
|
||||||
|
.log-row {
|
||||||
|
margin: 10px 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-row td,
|
||||||
|
.admin-data-row td,
|
||||||
|
.summary-search-result-row td,
|
||||||
|
.log-row td {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 82px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-bottom: 1px solid #eef2f6;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-row td::before,
|
||||||
|
.admin-data-row td::before,
|
||||||
|
.summary-search-result-row td::before,
|
||||||
|
.log-row td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-row td:last-child,
|
||||||
|
.admin-data-row td:last-child,
|
||||||
|
.summary-search-result-row td:last-child,
|
||||||
|
.log-row td:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-row .num {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-row .record-action-cell,
|
||||||
|
.admin-data-row .record-action-cell,
|
||||||
|
.log-row .record-action-cell {
|
||||||
|
grid-template-columns: 82px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-group,
|
||||||
|
.summary-collapse-row,
|
||||||
|
.summary-search-detail-row,
|
||||||
|
.log-detail-row {
|
||||||
|
display: block;
|
||||||
|
margin: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-group td,
|
||||||
|
.summary-collapse-row td,
|
||||||
|
.summary-search-detail-row td,
|
||||||
|
.log-detail-row td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-group {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-group td {
|
||||||
|
border: 1px solid #b7d8d4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-collapse-row td,
|
||||||
|
.summary-search-detail-row td,
|
||||||
|
.log-detail-row td {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.inline-account-grid {
|
.inline-account-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -1966,6 +2156,15 @@ td {
|
|||||||
.admin-tabs {
|
.admin-tabs {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
margin: 0 -12px;
|
||||||
|
padding: 0 12px 8px;
|
||||||
|
scroll-padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs::after {
|
||||||
|
content: "";
|
||||||
|
flex: 0 0 18px;
|
||||||
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-tab {
|
.admin-tab {
|
||||||
@@ -2070,9 +2269,32 @@ td {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teacher-group-toggle {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.teacher-group-title span {
|
.teacher-group-title span {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teacher-group-toggle span {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-actions .secondary-button {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
TZ: ${TZ:-Asia/Shanghai}
|
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}
|
CLASSNOTES_PATH: ${CLASSNOTES_PATH:-/data/classnotes.txt}
|
||||||
ACCOUNTS_PATH: ${ACCOUNTS_PATH:-/data/学生课时账户.md}
|
ACCOUNTS_PATH: ${ACCOUNTS_PATH:-/data/学生课时账户.md}
|
||||||
TEACHERS_PATH: ${TEACHERS_PATH:-/data/教师档案.md}
|
TEACHERS_PATH: ${TEACHERS_PATH:-/data/教师档案.md}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
REPO_ROOT = APP_ROOT.parent
|
||||||
|
if str(APP_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(APP_ROOT))
|
||||||
|
|
||||||
|
from app.db import SCHEMA_VERSION, connect, initialize_schema # noqa: E402
|
||||||
|
from app.repository import replace_database_from_paths, source_file_hashes # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_COUNTS = {
|
||||||
|
"records": 1548,
|
||||||
|
"accounts": 45,
|
||||||
|
"teachers": 13,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_path(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def data_paths(data_root: Path) -> dict[str, Path]:
|
||||||
|
return {
|
||||||
|
"classnotes_path": data_root / "classnotes.txt",
|
||||||
|
"accounts_path": data_root / "学生课时账户.md",
|
||||||
|
"teachers_path": data_root / "教师档案.md",
|
||||||
|
"tasks_path": data_root / "admin_tasks.json",
|
||||||
|
"summaries_root": data_root / "course_summaries",
|
||||||
|
"state_path": data_root / "course_summary_state.json",
|
||||||
|
"operation_logs_path": data_root / "operation_logs.jsonl",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_counts(summary: dict, *, strict_expected: bool) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
for key in ["records", "accounts", "teachers", "admin_tasks", "operation_logs", "course_summaries"]:
|
||||||
|
if int(summary.get(key) or 0) < 0:
|
||||||
|
errors.append(f"{key} 数量异常")
|
||||||
|
if strict_expected:
|
||||||
|
for key, expected in EXPECTED_COUNTS.items():
|
||||||
|
actual = int(summary.get(key) or 0)
|
||||||
|
if actual != expected:
|
||||||
|
errors.append(f"{key} 数量应为 {expected},实际为 {actual}")
|
||||||
|
mismatch_count = len(summary.get("balance_mismatches") or [])
|
||||||
|
if mismatch_count != 4:
|
||||||
|
errors.append(f"余额差异应为 4 个学生,实际为 {mismatch_count}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_database(db_path: Path, summary: dict, *, strict_expected: bool) -> dict:
|
||||||
|
errors = validate_counts(summary, strict_expected=strict_expected)
|
||||||
|
with connect(db_path) as conn:
|
||||||
|
table_counts = {
|
||||||
|
"records": conn.execute("SELECT COUNT(*) FROM class_records").fetchone()[0],
|
||||||
|
"accounts": conn.execute("SELECT COUNT(*) FROM students").fetchone()[0],
|
||||||
|
"teachers": conn.execute("SELECT COUNT(*) FROM teachers").fetchone()[0],
|
||||||
|
"admin_tasks": conn.execute("SELECT COUNT(*) FROM admin_tasks").fetchone()[0],
|
||||||
|
"operation_logs": conn.execute("SELECT COUNT(*) FROM operation_logs").fetchone()[0],
|
||||||
|
"course_summaries": conn.execute("SELECT COUNT(*) FROM course_summaries").fetchone()[0],
|
||||||
|
}
|
||||||
|
for key, value in table_counts.items():
|
||||||
|
if int(summary.get(key) or 0) != int(value):
|
||||||
|
errors.append(f"SQLite {key} 数量不一致: source={summary.get(key)} sqlite={value}")
|
||||||
|
duplicate_records = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT record_key, COUNT(*) AS c
|
||||||
|
FROM class_records
|
||||||
|
GROUP BY record_key
|
||||||
|
HAVING c > 1
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
if duplicate_records:
|
||||||
|
errors.append(f"SQLite 中存在重复课程记录: {duplicate_records['record_key']}")
|
||||||
|
missing_account = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT student
|
||||||
|
FROM class_records
|
||||||
|
WHERE student_id IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
if missing_account:
|
||||||
|
errors.append(f"SQLite 中存在没有账户的上课学生: {missing_account['student']}")
|
||||||
|
return {"ok": not errors, "errors": errors, "table_counts": table_counts}
|
||||||
|
|
||||||
|
|
||||||
|
def write_audit(conn: sqlite3.Connection, payload: dict) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO migration_audit(created_at, kind, payload_json) VALUES(?, ?, ?)",
|
||||||
|
(
|
||||||
|
datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"text_to_sqlite",
|
||||||
|
json.dumps(payload, ensure_ascii=False, sort_keys=True),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_archive(data_root: Path, archives_root: Path, report_path: Path, timestamp: str) -> tuple[Path, Path]:
|
||||||
|
archives_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
archive_path = archives_root / f"text-source-before-sqlite-{timestamp}.tar.gz"
|
||||||
|
with tarfile.open(archive_path, "w:gz") as archive:
|
||||||
|
for name in [
|
||||||
|
"classnotes.txt",
|
||||||
|
"学生课时账户.md",
|
||||||
|
"教师档案.md",
|
||||||
|
"admin_tasks.json",
|
||||||
|
"operation_logs.jsonl",
|
||||||
|
"course_summary_state.json",
|
||||||
|
]:
|
||||||
|
path = data_root / name
|
||||||
|
if path.exists():
|
||||||
|
archive.add(path, arcname=name)
|
||||||
|
summaries_root = data_root / "course_summaries"
|
||||||
|
if summaries_root.exists():
|
||||||
|
archive.add(summaries_root, arcname="course_summaries")
|
||||||
|
archive.add(report_path, arcname=report_path.name)
|
||||||
|
sha_path = archive_path.with_suffix(archive_path.suffix + ".sha256")
|
||||||
|
sha_path.write_text(f"{sha256_path(archive_path)} {archive_path.name}\n", encoding="utf-8")
|
||||||
|
return archive_path, sha_path
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(args: argparse.Namespace) -> dict:
|
||||||
|
data_root = args.data_root.resolve()
|
||||||
|
db_path = args.db_path.resolve()
|
||||||
|
tmp_path = Path(str(db_path) + ".tmp")
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
report_path = data_root / f"sqlite_migration_report_{timestamp}.json"
|
||||||
|
|
||||||
|
if tmp_path.exists():
|
||||||
|
tmp_path.unlink()
|
||||||
|
if tmp_path.with_suffix(tmp_path.suffix + "-wal").exists():
|
||||||
|
tmp_path.with_suffix(tmp_path.suffix + "-wal").unlink()
|
||||||
|
if tmp_path.with_suffix(tmp_path.suffix + "-shm").exists():
|
||||||
|
tmp_path.with_suffix(tmp_path.suffix + "-shm").unlink()
|
||||||
|
|
||||||
|
with connect(tmp_path) as conn:
|
||||||
|
initialize_schema(conn)
|
||||||
|
conn.commit()
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
summary = replace_database_from_paths(
|
||||||
|
conn,
|
||||||
|
**data_paths(data_root),
|
||||||
|
allow_balance_adjustments=False,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
validation = validate_database(tmp_path, summary, strict_expected=not args.no_strict_expected)
|
||||||
|
source_hashes = source_file_hashes(
|
||||||
|
[
|
||||||
|
data_root / "classnotes.txt",
|
||||||
|
data_root / "学生课时账户.md",
|
||||||
|
data_root / "教师档案.md",
|
||||||
|
data_root / "admin_tasks.json",
|
||||||
|
data_root / "operation_logs.jsonl",
|
||||||
|
data_root / "course_summary_state.json",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
report = {
|
||||||
|
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"data_root": str(data_root),
|
||||||
|
"db_path": str(db_path),
|
||||||
|
"summary": summary,
|
||||||
|
"validation": validation,
|
||||||
|
"source_hashes": source_hashes,
|
||||||
|
"dry_run": bool(args.dry_run),
|
||||||
|
}
|
||||||
|
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
if not validation["ok"]:
|
||||||
|
raise SystemExit("迁移校验失败:\n" + "\n".join(validation["errors"]))
|
||||||
|
|
||||||
|
with connect(tmp_path) as conn:
|
||||||
|
write_audit(conn, {**report, "report_path": str(report_path)})
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(json.dumps({**report, "tmp_db_path": str(tmp_path), "report_path": str(report_path)}, ensure_ascii=False, indent=2))
|
||||||
|
return report
|
||||||
|
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if db_path.exists():
|
||||||
|
backup_db = db_path.with_name(f"{db_path.name}.before-sqlite-migration-{timestamp}")
|
||||||
|
shutil.copy2(db_path, backup_db)
|
||||||
|
report["previous_db_backup"] = str(backup_db)
|
||||||
|
os.replace(tmp_path, db_path)
|
||||||
|
for suffix in ["-wal", "-shm"]:
|
||||||
|
sidecar = Path(str(tmp_path) + suffix)
|
||||||
|
if sidecar.exists():
|
||||||
|
os.replace(sidecar, Path(str(db_path) + suffix))
|
||||||
|
|
||||||
|
archive_path, sha_path = create_archive(data_root, args.archives_root.resolve(), report_path, timestamp)
|
||||||
|
report["archive_path"] = str(archive_path)
|
||||||
|
report["archive_sha256_path"] = str(sha_path)
|
||||||
|
report["dry_run"] = False
|
||||||
|
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(json.dumps({**report, "report_path": str(report_path)}, ensure_ascii=False, indent=2))
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="将新时空纯文本教务数据迁移到 SQLite")
|
||||||
|
parser.add_argument("--data-root", type=Path, default=REPO_ROOT / "data")
|
||||||
|
parser.add_argument("--db-path", type=Path, default=Path("/data/xsk_education.db"))
|
||||||
|
parser.add_argument("--archives-root", type=Path, default=REPO_ROOT / "archives")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("--no-strict-expected", action="store_true", help="不校验当前生产数据的固定计数")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate(parse_args())
|
||||||
Reference in New Issue
Block a user