Compare commits

...

2 Commits

Author SHA1 Message Date
Codex b169cb3d7a 优化记录与后台列表分页展示 2026-06-25 11:22:31 +08:00
Codex 70a5af3020 Migrate education data to SQLite 2026-06-24 17:27:50 +08:00
21 changed files with 1865 additions and 142 deletions
+7 -1
View File
@@ -4,7 +4,7 @@ APP_DIR := app
APP_MAKE := $(MAKE) -C $(APP_DIR) --no-print-directory
COMPOSE := docker compose -f $(APP_DIR)/docker-compose.yml
.PHONY: check smoke data-hash build up ps health deploy logs install-gitea-backup 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:
$(APP_MAKE) check
@@ -15,6 +15,12 @@ smoke:
data-hash:
$(APP_MAKE) data-hash
migrate-sqlite-dry-run:
$(APP_MAKE) migrate-sqlite-dry-run
migrate-sqlite:
$(APP_MAKE) migrate-sqlite
build:
$(APP_MAKE) build
+5
View File
@@ -8,6 +8,11 @@ BASIC_AUTH_PASSWORD=change-me
ADMIN_AUTH_PASSWORD=change-me
INGEST_AUTH_TOKEN=change-this-ingest-token
USE_SQLITE_SOURCE=1
SQLITE_DB_PATH=/data/xsk_education.db
RUNTIME_DATA_ROOT=/data/runtime_text_cache
LEGACY_TEXT_ROOT=/data
CLASSNOTES_PATH=/data/classnotes.txt
ACCOUNTS_PATH=/data/学生课时账户.md
TEACHERS_PATH=/data/教师档案.md
+8 -2
View File
@@ -1,9 +1,9 @@
SHELL := /bin/bash
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:
node --check app/static/app.js
@@ -16,6 +16,12 @@ smoke:
data-hash:
sha256sum $(DATA_FILES)
migrate-sqlite-dry-run:
python3 scripts/migrate_text_to_sqlite.py --data-root ../data --db-path ../data/xsk_education.db --archives-root ../archives --dry-run
migrate-sqlite:
python3 scripts/migrate_text_to_sqlite.py --data-root ../data --db-path ../data/xsk_education.db --archives-root ../archives
build:
docker compose build
+30 -10
View File
@@ -1,6 +1,6 @@
# 新时空教务管理系统
这是一个面向新时空教务业务的综合管理系统。迁移后 VPS 是正式业务数据主机,负责保存 `classnotes.txt``学生课时账户.md`、课程小结库、审核任务和操作记录;本机只保留微信群聊天记录采集/识别,并把课程小结批量推送到 VPS。
这是一个面向新时空教务业务的综合管理系统。迁移后 VPS 是正式业务数据主机,负责保存 SQLite 数据库 `/data/xsk_education.db`;本机只保留微信群聊天记录采集/识别,并把课程小结批量推送到 VPS。
## 目录
@@ -8,6 +8,7 @@
- `MAINTENANCE.md`:日常检查、部署、数据保护和回滚流程。
- `Makefile`:常用维护命令入口。
- `scripts/deploy_to_vps.py`:部署新时空教务管理系统到 VPS。
- `scripts/migrate_text_to_sqlite.py`:一次性把旧纯文本、JSON、JSONL 和课程小结 Markdown 迁移进 SQLite。
- `scripts/import_course_summaries.py`:一次性导入历史课程小结 Markdown。
- `scripts/sync_to_vps.py`:旧版正式数据同步脚本,迁移后不要继续常驻运行。
- `scripts/smoke_test.js`:轻量前端行为烟测。
@@ -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
XSK_USE_SSHPASS=1 \
@@ -87,7 +88,31 @@ python3 scripts/sync_to_vps.py --once --use-sshpass
远端数据目录:
```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
/root/新时空教务管理系统/data/classnotes.txt
/root/新时空教务管理系统/data/学生课时账户.md
/root/新时空教务管理系统/data/教师档案.md
/root/新时空教务管理系统/data/course_summaries/
/root/新时空教务管理系统/data/course_summary_state.json
/root/新时空教务管理系统/data/operation_logs.jsonl
/root/新时空教务管理系统/data/runtime_text_cache/
```
备份目录位于:
+8 -1
View File
@@ -6,8 +6,9 @@ from pathlib import Path
from fastapi import HTTPException, Request
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 .repository import ensure_runtime_cache
from .schemas import AccountPayload, RegisterLinesPayload, TeacherPayload
@@ -38,18 +39,24 @@ async def read_register_payload(request: Request) -> RegisterLinesPayload:
def load_records():
if USE_SQLITE_SOURCE:
ensure_runtime_cache()
if not CLASSNOTES_PATH.exists():
raise HTTPException(status_code=503, detail=f"课程记录文件不存在: {CLASSNOTES_PATH}")
return read_classnotes(CLASSNOTES_PATH)
def load_accounts():
if USE_SQLITE_SOURCE:
ensure_runtime_cache()
if not ACCOUNTS_PATH.exists():
raise HTTPException(status_code=503, detail=f"课时账户文件不存在: {ACCOUNTS_PATH}")
return read_accounts(ACCOUNTS_PATH)
def load_teachers():
if USE_SQLITE_SOURCE:
ensure_runtime_cache()
if not TEACHERS_PATH.exists():
return []
return read_teachers(TEACHERS_PATH)
+56 -8
View File
@@ -8,13 +8,35 @@ import threading
APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static"
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"))
SQLITE_DB_PATH = Path(os.getenv("SQLITE_DB_PATH", "/data/xsk_education.db"))
RUNTIME_DATA_ROOT = Path(os.getenv("RUNTIME_DATA_ROOT", "/data/runtime_text_cache"))
LEGACY_TEXT_ROOT = Path(os.getenv("LEGACY_TEXT_ROOT", "/data"))
USE_SQLITE_SOURCE = os.getenv("USE_SQLITE_SOURCE", "1").strip().lower() not in {"0", "false", "no"}
if USE_SQLITE_SOURCE:
CLASSNOTES_PATH = RUNTIME_DATA_ROOT / "classnotes.txt"
ACCOUNTS_PATH = RUNTIME_DATA_ROOT / "学生课时账户.md"
TEACHERS_PATH = RUNTIME_DATA_ROOT / "教师档案.md"
ADMIN_TASKS_PATH = RUNTIME_DATA_ROOT / "admin_tasks.json"
COURSE_SUMMARIES_ROOT = RUNTIME_DATA_ROOT / "course_summaries"
COURSE_SUMMARY_STATE_PATH = RUNTIME_DATA_ROOT / "course_summary_state.json"
OPERATION_LOGS_PATH = RUNTIME_DATA_ROOT / "operation_logs.jsonl"
else:
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md"))
TEACHERS_PATH = Path(os.getenv("TEACHERS_PATH", "/data/教师档案.md"))
ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json"))
COURSE_SUMMARIES_ROOT = Path(os.getenv("COURSE_SUMMARIES_ROOT", "/data/course_summaries"))
COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/course_summary_state.json"))
OPERATION_LOGS_PATH = Path(os.getenv("OPERATION_LOGS_PATH", "/data/operation_logs.jsonl"))
LEGACY_CLASSNOTES_PATH = Path(os.getenv("LEGACY_CLASSNOTES_PATH", str(LEGACY_TEXT_ROOT / "classnotes.txt")))
LEGACY_ACCOUNTS_PATH = Path(os.getenv("LEGACY_ACCOUNTS_PATH", str(LEGACY_TEXT_ROOT / "学生课时账户.md")))
LEGACY_TEACHERS_PATH = Path(os.getenv("LEGACY_TEACHERS_PATH", str(LEGACY_TEXT_ROOT / "教师档案.md")))
LEGACY_ADMIN_TASKS_PATH = Path(os.getenv("LEGACY_ADMIN_TASKS_PATH", str(LEGACY_TEXT_ROOT / "admin_tasks.json")))
LEGACY_COURSE_SUMMARIES_ROOT = Path(os.getenv("LEGACY_COURSE_SUMMARIES_ROOT", str(LEGACY_TEXT_ROOT / "course_summaries")))
LEGACY_COURSE_SUMMARY_STATE_PATH = Path(os.getenv("LEGACY_COURSE_SUMMARY_STATE_PATH", str(LEGACY_TEXT_ROOT / "course_summary_state.json")))
LEGACY_OPERATION_LOGS_PATH = Path(os.getenv("LEGACY_OPERATION_LOGS_PATH", str(LEGACY_TEXT_ROOT / "operation_logs.jsonl")))
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
@@ -26,4 +48,30 @@ ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session"
ADMIN_SESSION_COOKIE = "xsk_admin_session"
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
View File
@@ -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} |"
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]:
payments: list[Payment] = []
if not text.strip():
@@ -1073,21 +1082,34 @@ def list_admin_tasks(
status_filter: str = "",
task_type: str = "",
classnotes_path: Path | None = None,
offset: int = 0,
limit: int = 200,
) -> dict:
tasks = read_admin_tasks(tasks_path)
items = tasks["items"]
if status_filter:
items = [item for item in items if item.get("status") == status_filter]
if task_type:
items = [item for item in items if item.get("type") == task_type]
task_types = [item.strip() for item in task_type.split(",") if item.strip()]
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
return {
"version": tasks["version"],
"next_id": tasks["next_id"],
"count": len(items),
"returned": len(shown),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
"type_counts": dict(type_counts),
"items": [
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(
path: Path,
limit: int = 100,
offset: int = 0,
operation: str = "",
status_filter: str = "",
student: str = "",
@@ -2023,9 +2046,16 @@ def list_operation_logs(
if student and student not in str(item.get("student", "")):
continue
rows.append(item)
rows = rows[-limit:]
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(
@@ -2734,6 +2764,7 @@ def query_course_summaries(
binding_status: str = "",
has_candidate: str = "",
limit: int = 200,
offset: int = 0,
) -> dict:
normalized_from = normalize_filter_date(date_from)
normalized_to = normalize_filter_date(date_to)
@@ -2802,10 +2833,13 @@ def query_course_summaries(
),
reverse=True,
)
limited = matched[:limit]
limited, normalized_offset, has_more = paginate_items(matched, offset, limit)
return {
"count": len(matched),
"returned": len(limited),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
"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:
spec = build_query_spec(query, records)
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 {
"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],
"total_records": len(matched),
"shown_records": len(shown),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
}
@@ -4699,11 +4736,12 @@ def query_public_records(
teachers: list[Teacher],
query: str,
limit: int = 200,
offset: int = 0,
summaries_root: Path | None = None,
) -> dict:
spec = build_public_query_spec(query, records, teachers)
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)
summary_index = course_summary_index_for_records(summaries_root, records) if summaries_root is not None else {}
return {
@@ -4718,6 +4756,9 @@ def query_public_records(
"records": [public_record_to_dict(record, teachers, summary_index) for record in shown],
"total_records": len(matched),
"shown_records": len(shown),
"offset": normalized_offset,
"limit": limit,
"has_more": has_more,
}
+195
View File
@@ -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,
}
+8
View File
@@ -3,12 +3,20 @@ from __future__ import annotations
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from .config import USE_SQLITE_SOURCE
from .repository import ensure_runtime_cache
from .routers import accounts, admin, ai_register, health, ingest, pages, records
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)
async def value_error_handler(_request: Request, exc: ValueError):
return JSONResponse(status_code=500, content={"detail": str(exc)})
+633
View File
@@ -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
+9 -1
View File
@@ -12,10 +12,13 @@ from ..config import (
CLASSNOTES_PATH,
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
LEGACY_TEXT_ROOT,
OPERATION_LOGS_PATH,
TEACHERS_PATH,
USE_SQLITE_SOURCE,
write_lock,
)
from ..db import database_meta
from ..data import (
ACCOUNT_STATUSES,
TEACHER_STATUSES,
@@ -45,7 +48,7 @@ router = APIRouter()
def compact_duration_text(hours: float) -> str:
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")
@@ -134,6 +137,11 @@ def account_health(_user: str = Depends(verify_accounts_auth)):
"teachers": file_meta(TEACHERS_PATH),
"classnotes": file_meta(CLASSNOTES_PATH),
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
"database": database_meta(),
"legacy_archive": {
"source_mode": "sqlite" if USE_SQLITE_SOURCE else "text",
"text_root": str(LEGACY_TEXT_ROOT),
},
"accounts_count": len(accounts),
"teachers_count": len(teachers),
"active_teachers_count": sum(1 for teacher in teachers if teacher.status == "在岗"),
+10 -2
View File
@@ -69,6 +69,8 @@ def admin_dashboard(period: str = Query("month"), _user: str = Depends(verify_ad
def admin_tasks(
status_filter: str = Query("", alias="status"),
task_type: str = Query("", alias="type"),
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
_user: str = Depends(verify_admin_auth),
):
try:
@@ -77,6 +79,8 @@ def admin_tasks(
status_filter=status_filter,
task_type=task_type,
classnotes_path=CLASSNOTES_PATH,
offset=offset,
limit=limit,
)
except ValueError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@@ -84,7 +88,8 @@ def admin_tasks(
@router.get("/api/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(""),
status_filter: str = Query("", alias="status"),
student: str = Query(""),
@@ -94,6 +99,7 @@ def admin_operation_logs(
return list_operation_logs(
OPERATION_LOGS_PATH,
limit=limit,
offset=offset,
operation=operation,
status_filter=status_filter,
student=student,
@@ -134,7 +140,8 @@ def admin_course_summaries(
missing_time: bool = Query(False),
binding_status: 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),
):
try:
@@ -151,6 +158,7 @@ def admin_course_summaries(
binding_status=binding_status,
has_candidate=has_candidate,
limit=limit,
offset=offset,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+8
View File
@@ -9,9 +9,12 @@ from ..config import (
CLASSNOTES_PATH,
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
LEGACY_TEXT_ROOT,
OPERATION_LOGS_PATH,
TEACHERS_PATH,
USE_SQLITE_SOURCE,
)
from ..db import database_meta
from ..data import account_summary
@@ -31,6 +34,11 @@ def health(_user: str = Depends(verify_records_auth)):
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
"course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH),
"operation_logs": file_meta(OPERATION_LOGS_PATH),
"database": database_meta(),
"legacy_archive": {
"source_mode": "sqlite" if USE_SQLITE_SOURCE else "text",
"text_root": str(LEGACY_TEXT_ROOT),
},
"records_count": len(records),
"accounts_count": len(accounts),
"teachers_count": len(teachers),
+10 -2
View File
@@ -36,10 +36,18 @@ router = APIRouter()
@router.get("/api/records")
def records(
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),
):
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}")
+4
View File
@@ -230,6 +230,7 @@
<tbody id="reviewRows"></tbody>
</table>
</div>
<div id="reviewPager" class="pager" hidden></div>
</section>
<section id="summariesPanel" class="panel admin-panel" hidden></section>
@@ -352,6 +353,7 @@
<tbody id="summarySearchRows"></tbody>
</table>
</div>
<div id="summarySearchPager" class="pager" hidden></div>
<div class="summary-task-section">
<div class="section-head compact-head">
<h3>待处理任务</h3>
@@ -382,6 +384,7 @@
<tbody id="summaryReviewRows"></tbody>
</table>
</div>
<div id="summaryReviewPager" class="pager" hidden></div>
</div>
</section>
@@ -445,6 +448,7 @@
<tbody id="logRows"></tbody>
</table>
</div>
<div id="logPager" class="pager" hidden></div>
</section>
<section id="registerPanel" class="panel admin-panel" hidden>
+198 -71
View File
@@ -50,9 +50,11 @@ const editNote = document.querySelector("#editNote");
const reviewStatus = document.querySelector("#reviewStatus");
const reviewMeta = document.querySelector("#reviewMeta");
const reviewRows = document.querySelector("#reviewRows");
const reviewPager = document.querySelector("#reviewPager");
const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
const summaryReviewRows = document.querySelector("#summaryReviewRows");
const summaryReviewPager = document.querySelector("#summaryReviewPager");
const duplicateSummaryScanBtns = Array.from(document.querySelectorAll(".duplicate-summary-scan"));
const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop");
const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose");
@@ -88,6 +90,7 @@ const summarySearchBindingStatus = document.querySelector("#summarySearchBinding
const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate");
const summarySearchMeta = document.querySelector("#summarySearchMeta");
const summarySearchRows = document.querySelector("#summarySearchRows");
const summarySearchPager = document.querySelector("#summarySearchPager");
const quickMismatchBtn = document.querySelector("#quickMismatchBtn");
const logOperation = document.querySelector("#logOperation");
const logStatus = document.querySelector("#logStatus");
@@ -95,6 +98,7 @@ const logFilterForm = document.querySelector("#logFilterForm");
const logStudent = document.querySelector("#logStudent");
const logMeta = document.querySelector("#logMeta");
const logRows = document.querySelector("#logRows");
const logPager = document.querySelector("#logPager");
const classRegisterForm = document.querySelector("#classRegisterForm");
const classRegisterLines = document.querySelector("#classRegisterLines");
const classRegisterPreview = document.querySelector("#classRegisterPreview");
@@ -111,20 +115,26 @@ const summaryRegisterPreview = document.querySelector("#summaryRegisterPreview")
const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus");
const summaryRegisterConfirm = document.querySelector("#summaryRegisterConfirm");
const ADMIN_PAGE_SIZE = 50;
let currentAccounts = [];
let currentDashboardPeriod = "month";
let editingAccountId = "";
let currentTeachers = [];
let editingTeacherId = "";
let currentReviewPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
let currentSummaryReviews = [];
let currentSummaryReviewPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
let activeSummaryReview = null;
let currentSummarySearchItems = [];
let currentSummarySearchPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
let expandedSummarySearchId = "";
let editingSummarySearchId = "";
let editingSummaryIdentityId = "";
let quickSummaryMismatchMode = false;
let quickSummaryProcessing = false;
let currentOperationLogs = [];
let currentLogPage = { offset: 0, limit: ADMIN_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
let expandedOperationLogId = "";
const registerPreviewState = {
class_record: null,
@@ -161,6 +171,49 @@ function metric(label, value) {
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) {
if (period === "today") return "今日";
if (period === "7d") return "近7日";
@@ -423,21 +476,25 @@ async function fetchJson(url, options = {}) {
function setActiveTab(tabName) {
const activeTabName = tabName === "summaries" ? "summarySearch" : tabName;
let activeButton = null;
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]) => {
panel.hidden = name !== activeTabName;
});
if (activeTabName === "dashboard") loadDashboard();
if (activeTabName === "accounts") loadAccounts();
if (activeTabName === "teachers") loadTeachers();
if (activeTabName === "reviews") loadReviews();
if (activeTabName === "reviews") loadReviews(0);
if (activeTabName === "summarySearch") {
loadSummarySearch();
loadSummaryReviews();
loadSummarySearch(0);
loadSummaryReviews(0);
}
if (activeTabName === "logs") loadOperationLogs();
if (activeTabName === "logs") loadOperationLogs(0);
}
async function loadAdminHealth() {
@@ -734,26 +791,45 @@ function renderReviewTarget(item) {
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>`;
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 {
const [correctionData, deletionData] = await Promise.all([
fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_correction", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`),
fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_deletion", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`),
]);
const items = [...(correctionData.items || []), ...(deletionData.items || [])].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
reviewMeta.innerHTML = [metric("当前结果", `${items.length}`)].join("");
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`);
const items = data.items || [];
const totalCount = Number(data.count || 0);
const typeCounts = data.type_counts || {};
currentReviewPage = {
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
.map((item) => {
const canReview = item.status === "pending" || item.status === "conflict";
return `<tr>
<td>#${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>${escapeHtml(reviewTaskTypeLabel(item))}</td>
<td>${renderReviewLine(item.original_line)}</td>
<td>${renderReviewTarget(item)}</td>
<td>${escapeHtml(item.created_at || "")}</td>
<td class="record-action-cell">
return `<tr class="admin-data-row">
<td data-label="编号">#${escapeHtml(item.id)}</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 data-label="类型">${escapeHtml(reviewTaskTypeLabel(item))}</td>
<td data-label="原记录">${renderReviewLine(item.original_line)}</td>
<td data-label="处理内容">${renderReviewTarget(item)}</td>
<td data-label="提交时间">${escapeHtml(item.created_at || "")}</td>
<td class="record-action-cell" data-label="操作">
<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-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
@@ -765,6 +841,7 @@ async function loadReviews() {
if (!items.length) {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的审核项</td></tr>`;
}
renderPager(reviewPager, currentReviewPage, "reviews");
} catch (error) {
reviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
@@ -1044,26 +1121,33 @@ function closeSummaryReviewDrawer() {
summaryReviewDrawerBackdrop.hidden = true;
}
async function loadSummaryReviews() {
async function loadSummaryReviews(offset = currentSummaryReviewPage.offset || 0) {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
const reviewParams = new URLSearchParams({ type: "course_summary_review" });
const duplicateParams = new URLSearchParams({ type: "course_summary_duplicate_review" });
if (summaryReviewStatus.value) {
reviewParams.set("status", summaryReviewStatus.value);
duplicateParams.set("status", summaryReviewStatus.value);
}
setPagerLoading(summaryReviewPager);
const normalizedOffset = Math.max(0, Number(offset || 0));
const params = new URLSearchParams({
type: "course_summary_review,course_summary_duplicate_review",
limit: String(ADMIN_PAGE_SIZE),
offset: String(normalizedOffset),
});
if (summaryReviewStatus.value) params.set("status", summaryReviewStatus.value);
try {
const [reviewData, duplicateData] = await Promise.all([
fetchJson(`/api/admin/tasks?${reviewParams.toString()}`),
fetchJson(`/api/admin/tasks?${duplicateParams.toString()}`),
]);
const reviewItems = reviewData.items || [];
const duplicateItems = duplicateData.items || [];
currentSummaryReviews = [...reviewItems, ...duplicateItems].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`);
currentSummaryReviews = data.items || [];
const totalCount = Number(data.count || 0);
const typeCounts = data.type_counts || {};
currentSummaryReviewPage = {
offset: normalizedOffset,
limit: Number(data.limit || ADMIN_PAGE_SIZE),
total: totalCount,
shown: Number(data.returned || currentSummaryReviews.length),
hasMore: Boolean(data.has_more),
};
summaryReviewMeta.innerHTML = [
metric("待处理任务", `${currentSummaryReviews.length}`),
metric("课程小结审核", `${reviewItems.length}`),
metric("重复小结", `${duplicateItems.length}`),
metric("待处理任务", `${totalCount}`),
metric("当前显示", pageRangeText(currentSummaryReviewPage)),
metric("课程小结审核", `${typeCounts.course_summary_review || 0}`),
metric("重复小结", `${typeCounts.course_summary_duplicate_review || 0}`),
].join("");
summaryReviewRows.innerHTML = currentSummaryReviews
.map((item) => {
@@ -1074,14 +1158,14 @@ async function loadSummaryReviews() {
const approveButton = canApproveDirectly
? `<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}">批准</button>`
: "";
return `<tr>
<td>#${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>${renderSummaryInfo(summary, item)}</td>
<td>${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td>
<td>${renderSummaryPreview(summary)}</td>
<td>${renderSummaryReviewReasons(item)}</td>
<td class="record-action-cell">
return `<tr class="admin-data-row">
<td data-label="编号">#${escapeHtml(item.id)}</td>
<td data-label="状态"><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td data-label="课程信息">${renderSummaryInfo(summary, item)}</td>
<td data-label="候选记录">${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td>
<td data-label="小结原文">${renderSummaryPreview(summary)}</td>
<td data-label="原因">${renderSummaryReviewReasons(item)}</td>
<td class="record-action-cell" data-label="操作">
<div class="record-actions">
<button class="small-button summary-detail" type="button" data-task-id="${escapeHtml(item.id)}">${escapeHtml(summaryReviewDetailLabel(item))}</button>
${approveButton}
@@ -1094,6 +1178,7 @@ async function loadSummaryReviews() {
if (!currentSummaryReviews.length) {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结任务</td></tr>`;
}
renderPager(summaryReviewPager, currentSummaryReviewPage, "summaryReviews");
} catch (error) {
currentSummaryReviews = [];
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
@@ -1101,7 +1186,7 @@ async function loadSummaryReviews() {
}
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 (summarySearchStudent.value.trim()) params.set("student", summarySearchStudent.value.trim());
if (summarySearchTeacher.value.trim()) params.set("teacher", summarySearchTeacher.value.trim());
@@ -1314,11 +1399,11 @@ function renderSummarySearchRows(items) {
const binding = summaryBinding(item);
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"}">
<td>${escapeHtml(item.date_iso || "未识别")}</td>
<td>${escapeHtml(item.time_range || "缺少时间")}</td>
<td>${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
<td>${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td>
<td>${renderSummaryBindingStatus(item)}</td>
<td data-label="日期">${escapeHtml(item.date_iso || "未识别")}</td>
<td data-label="时间">${escapeHtml(item.time_range || "缺少时间")}</td>
<td data-label="学生">${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
<td data-label="老师/科目">${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td>
<td data-label="绑定状态">${renderSummaryBindingStatus(item)}</td>
</tr>`;
const detailRow = expanded
? `<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>`;
setPagerLoading(summarySearchPager);
try {
const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`);
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) === editingSummarySearchId)) editingSummarySearchId = "";
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummaryIdentityId)) editingSummaryIdentityId = "";
@@ -1367,12 +1461,13 @@ async function loadSummarySearch() {
const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems);
summarySearchMeta.innerHTML = [
metric("命中小结", `${data.count}`),
metric("当前显示", `${data.returned}`),
metric("当前显示", pageRangeText(currentSummarySearchPage)),
metric("已绑定", `${bindingSummary.matched}`),
metric("待处理", `${bindingSummary.pending}`),
metric("有候选", `${bindingSummary.withCandidate}`),
].join("");
renderCurrentSummarySearch();
renderPager(summarySearchPager, currentSummarySearchPage, "summarySearch");
updateQuickMismatchButton();
} catch (error) {
currentSummarySearchItems = [];
@@ -1602,12 +1697,12 @@ function renderOperationLogRows(items) {
.map((item) => {
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"}">
<td>${escapeHtml(item.created_at || "")}</td>
<td>${escapeHtml(item.operation || "")}</td>
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span></td>
<td>${escapeHtml(item.student || "")}</td>
<td>${renderLogAssociation(item)}</td>
<td class="record-action-cell">${renderLogActions(item)}</td>
<td data-label="时间">${escapeHtml(item.created_at || "")}</td>
<td data-label="操作">${escapeHtml(item.operation || "")}</td>
<td data-label="结果"><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span></td>
<td data-label="学生">${escapeHtml(item.student || "")}</td>
<td data-label="批次/任务">${renderLogAssociation(item)}</td>
<td class="record-action-cell" data-label="操作">${renderLogActions(item)}</td>
</tr>`;
const detailRow = expanded
? `<tr class="log-detail-row" data-log-detail="${escapeHtml(item.id)}">
@@ -1688,18 +1783,31 @@ function renderLogActions(item) {
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>`;
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 (logStatus.value) params.set("status", logStatus.value);
if (logStudent.value.trim()) params.set("student", logStudent.value.trim());
try {
const data = await fetchJson(`/api/admin/operation-logs?${params.toString()}`);
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 = "";
logMeta.innerHTML = [metric("当前结果", `${data.count}`)].join("");
logMeta.innerHTML = [
metric("当前结果", `${data.count}`),
metric("当前显示", pageRangeText(currentLogPage)),
].join("");
renderCurrentOperationLogs();
renderPager(logPager, currentLogPage, "logs");
} catch (error) {
currentOperationLogs = [];
logRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
@@ -2113,14 +2221,14 @@ teacherRows.addEventListener("click", (event) => {
if (!button) return;
openEditTeacherEditor(button.dataset.teacherId);
});
reviewStatus.addEventListener("change", loadReviews);
reviewStatus.addEventListener("change", () => loadReviews(0));
reviewRows.addEventListener("click", (event) => {
const approve = event.target.closest(".review-approve");
const reject = event.target.closest(".review-reject");
if (approve) reviewTask(approve.dataset.taskId, "approve");
if (reject) reviewTask(reject.dataset.taskId, "reject");
});
summaryReviewStatus.addEventListener("change", loadSummaryReviews);
summaryReviewStatus.addEventListener("change", () => loadSummaryReviews(0));
duplicateSummaryScanBtns.forEach((button) => {
button.addEventListener("click", scanDuplicateSummaries);
});
@@ -2167,15 +2275,15 @@ document.addEventListener("keydown", (event) => {
summarySearchForm.addEventListener("submit", (event) => {
event.preventDefault();
quickSummaryMismatchMode = false;
loadSummarySearch();
loadSummarySearch(0);
});
summarySearchBindingStatus.addEventListener("change", () => {
quickSummaryMismatchMode = false;
loadSummarySearch();
loadSummarySearch(0);
});
summarySearchHasCandidate.addEventListener("change", () => {
quickSummaryMismatchMode = false;
loadSummarySearch();
loadSummarySearch(0);
});
quickMismatchBtn.addEventListener("click", () => {
processQuickMismatchItem().catch((error) => {
@@ -2278,11 +2386,11 @@ summarySearchRows.addEventListener("keydown", (event) => {
expandedSummarySearchId = expandedSummarySearchId === row.dataset.summaryId ? "" : row.dataset.summaryId;
renderCurrentSummarySearch();
});
logOperation.addEventListener("change", loadOperationLogs);
logStatus.addEventListener("change", loadOperationLogs);
logOperation.addEventListener("change", () => loadOperationLogs(0));
logStatus.addEventListener("change", () => loadOperationLogs(0));
logFilterForm.addEventListener("submit", (event) => {
event.preventDefault();
loadOperationLogs();
loadOperationLogs(0);
});
logRows.addEventListener("click", (event) => {
const rollback = event.target.closest(".log-rollback");
@@ -2305,6 +2413,25 @@ logRows.addEventListener("keydown", (event) => {
expandedOperationLogId = expandedOperationLogId === row.dataset.logId ? "" : row.dataset.logId;
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) => {
previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus);
});
@@ -2335,7 +2462,7 @@ refreshBtn.addEventListener("click", () => {
if (!panels.accounts.hidden) loadAccounts();
if (!panels.reviews.hidden) loadReviews();
if (!panels.summarySearch.hidden) refreshSummaryWorkspace({ logs: false });
if (!panels.logs.hidden) loadOperationLogs();
if (!panels.logs.hidden) loadOperationLogs(currentLogPage.offset || 0);
});
loadAdminHealth();
+167 -35
View File
@@ -4,6 +4,7 @@ const recordForm = document.querySelector("#recordForm");
const recordQuery = document.querySelector("#recordQuery");
const recordMeta = document.querySelector("#recordMeta");
const recordRows = document.querySelector("#recordRows");
const recordPager = document.querySelector("#recordPager");
const dateSortBtn = document.querySelector("#dateSortBtn");
const timeSortBtn = document.querySelector("#timeSortBtn");
const inlineAccount = document.querySelector("#inlineAccount");
@@ -42,11 +43,16 @@ const submitSummarySupplementBtn = document.querySelector("#submitSummarySupplem
const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
const COPY_LINE_BREAK = "\r\n";
const SORT_DIRECTIONS = { asc: 1, desc: -1 };
const RECORD_PAGE_SIZE = 30;
let currentRecords = [];
let recordSort = { date: "asc", time: "asc" };
let currentRecordOrder = [];
let correctedRecords = new Map();
let correctedRecordOrder = [];
let expandedSummaryRecords = new Set();
let expandedSummaryBodies = new Set();
let collapsedTeacherGroups = new Set();
let currentRecordPage = { query: "", offset: 0, limit: RECORD_PAGE_SIZE, total: 0, shown: 0, hasMore: false };
let activeCorrectionKey = "";
let activeDeleteKey = "";
let activeSummarySupplementKey = "";
@@ -84,6 +90,44 @@ function metricHtml(label, valueHtml) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${valueHtml}</strong></div>`;
}
function currentPageNumber(state) {
return Math.floor((state.offset || 0) / (state.limit || RECORD_PAGE_SIZE)) + 1;
}
function totalPageNumber(state) {
const limit = state.limit || RECORD_PAGE_SIZE;
return Math.max(1, Math.ceil((state.total || 0) / limit));
}
function renderPager(container, state) {
if (!container) return;
const total = Number(state.total || 0);
const shown = Number(state.shown || 0);
const limit = Number(state.limit || RECORD_PAGE_SIZE);
const offset = Number(state.offset || 0);
if (total <= limit && offset === 0) {
container.hidden = true;
container.innerHTML = "";
return;
}
const start = shown ? offset + 1 : 0;
const end = offset + shown;
container.hidden = false;
container.innerHTML = `<div class="pager-info">第 ${currentPageNumber(state)} / ${totalPageNumber(state)} 页 · ${start}-${end} / ${total} 条</div>
<div class="pager-actions">
<button class="secondary-button pager-prev" type="button" ${offset <= 0 ? "disabled" : ""}>上一页</button>
<button class="secondary-button pager-next" type="button" ${state.hasMore ? "" : "disabled"}>下一页</button>
</div>`;
}
function pageRangeText(state) {
const total = Number(state.total || 0);
const shown = Number(state.shown || 0);
const offset = Number(state.offset || 0);
if (!total || !shown) return "0 条";
return `${offset + 1}-${offset + shown}`;
}
function makeRecordKey(row, index) {
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 summaryExpanded = expandedSummaryRecords.has(key);
return `<tr class="record-row${correctedClass}${summaryClass}" data-record-key="${escapeHtml(key)}" tabindex="0" role="button" aria-expanded="${summaryExpanded ? "true" : "false"}">
<td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
<td>${escapeHtml(displayRow.time)}</td>
<td>${escapeHtml(displayRow.student)}</td>
<td>${escapeHtml(displayRow.teacher)}</td>
<td>${escapeHtml(displayRow.subject)}</td>
<td class="num">${escapeHtml(displayRow.duration)}</td>
<td class="record-action-cell">
<td data-label="日期">${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
<td data-label="时间">${escapeHtml(displayRow.time)}</td>
<td data-label="学生">${escapeHtml(displayRow.student)}</td>
<td data-label="老师">${escapeHtml(displayRow.teacher)}</td>
<td data-label="科目">${escapeHtml(displayRow.subject)}</td>
<td class="num" data-label="时长">${escapeHtml(displayRow.duration)}</td>
<td class="record-action-cell" data-label="操作">
<div class="record-actions">
<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>
@@ -223,7 +267,16 @@ function renderRecordRow(row) {
</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 time = summary.time_range ? ` · ${summary.time_range}` : "";
const meta = [
@@ -231,10 +284,15 @@ function renderSummaryEntry(summary) {
summary.teacher || "",
summary.subject || "",
].filter(Boolean).join(" · ");
const bodyKey = summaryBodyKey(recordKey, index);
const body = summary.body || "暂无正文";
const expanded = expandedSummaryBodies.has(bodyKey);
const canToggle = body.length > 120;
return `<div class="record-summary-item">
<div class="record-summary-title">${escapeHtml(title)}${escapeHtml(time)}</div>
${meta ? `<div class="record-summary-meta">${escapeHtml(meta)}</div>` : ""}
<div class="summary-body">${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>`;
}
@@ -253,25 +311,31 @@ function renderGroupedRecords(records) {
currentRecordOrder = [];
return groupRecordsByTeacher(records)
.map(
(group) => `<tr class="teacher-group">
(group) => {
const collapsed = collapsedTeacherGroups.has(group.teacher);
const rows = collapsed
? ""
: sortRecordsForDisplay(group.records)
.map((row) => {
currentRecordOrder.push(row._recordKey);
const summaryCount = Number(row.summary_count || 0);
const summaryRow = summaryCount
? `<tr class="summary-collapse-row" data-summary-row="${escapeHtml(row._recordKey)}" ${expandedSummaryRecords.has(row._recordKey) ? "" : "hidden"}>
<td colspan="7">${(row.summaries || []).map((summary, index) => renderSummaryEntry(summary, row._recordKey, index)).join("")}</td>
</tr>`
: renderSummaryMissingRow(row._recordKey, expandedSummaryRecords.has(row._recordKey));
return `${renderRecordRow(row)}${summaryRow}`;
})
.join("");
return `<tr class="teacher-group" data-teacher="${escapeHtml(group.teacher)}">
<td colspan="7">
<div class="teacher-group-title">
<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)}</span>
</div>
<span>${group.count} 条记录 · ${displayHours(group.totalHours)} · ${collapsed ? "展开" : "收起"}</span>
</button>
</td>
</tr>${sortRecordsForDisplay(group.records)
.map((row) => {
currentRecordOrder.push(row._recordKey);
const summaryCount = Number(row.summary_count || 0);
const summaryRow = summaryCount
? `<tr class="summary-collapse-row" data-summary-row="${escapeHtml(row._recordKey)}" ${expandedSummaryRecords.has(row._recordKey) ? "" : "hidden"}>
<td colspan="7">${(row.summaries || []).map(renderSummaryEntry).join("")}</td>
</tr>`
: renderSummaryMissingRow(row._recordKey, expandedSummaryRecords.has(row._recordKey));
return `${renderRecordRow(row)}${summaryRow}`;
})
.join("")}`,
</tr>${rows}`;
},
)
.join("");
}
@@ -289,6 +353,24 @@ function toggleRecordSummary(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) {
if (status === "欠费") return "debt";
if (status === "预警") return "warning";
@@ -349,8 +431,11 @@ function updateCorrectionToolbar(message = "", isError = false) {
function resetCorrections() {
correctedRecords = new Map();
correctedRecordOrder = [];
currentRecordOrder = [];
expandedSummaryRecords = new Set();
expandedSummaryBodies = new Set();
collapsedTeacherGroups = new Set();
activeCorrectionKey = "";
updateCorrectionToolbar();
}
@@ -596,8 +681,10 @@ function saveCorrection() {
const corrected = buildCorrectedRecord(original);
if (buildRecordLine(corrected) === buildRecordLine(original)) {
correctedRecords.delete(activeCorrectionKey);
correctedRecordOrder = correctedRecordOrder.filter((key) => key !== activeCorrectionKey);
} else {
correctedRecords.set(activeCorrectionKey, corrected);
if (!correctedRecordOrder.includes(activeCorrectionKey)) correctedRecordOrder.push(activeCorrectionKey);
}
recordRows.innerHTML = renderGroupedRecords(currentRecords);
closeCorrectionDialog();
@@ -630,7 +717,7 @@ async function copyTextToClipboard(text) {
}
async function copyCorrectedRecords() {
const correctedInPageOrder = currentRecordOrder
const correctedInPageOrder = correctedRecordOrder
.map((key) => correctedRecords.get(key))
.filter(Boolean);
if (!correctedInPageOrder.length) return;
@@ -644,7 +731,7 @@ async function copyCorrectedRecords() {
}
async function submitCorrectedRecords() {
const items = currentRecordOrder
const items = correctedRecordOrder
.map((key) => {
const corrected = correctedRecords.get(key);
if (!corrected) return null;
@@ -698,40 +785,64 @@ async function loadHealth() {
}
}
async function queryRecords(query) {
async function queryRecords(query, options = {}) {
const q = query.trim();
if (!q) return;
const offset = Number(options.offset || 0);
recordRows.innerHTML = `<tr><td colspan="7" class="empty">正在查询</td></tr>`;
recordMeta.innerHTML = "";
if (recordPager) {
recordPager.hidden = true;
recordPager.innerHTML = "";
}
currentRecords = [];
resetCorrections();
resetRecordSort();
clearInlineAccount();
if (options.reset !== false) {
resetCorrections();
resetRecordSort();
clearInlineAccount();
} else {
currentRecordOrder = [];
expandedSummaryRecords = new Set();
expandedSummaryBodies = new Set();
collapsedTeacherGroups = new Set();
}
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;
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 = [
metric("识别日期", data.query.date_range),
metric("命中记录", `${summary.count}`),
metric("当前显示", pageRangeText(currentRecordPage)),
metric("总课时", displayHours(summary.total_hours, summary.total_duration)),
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
].join("");
if (data.query.students.length === 1) {
if (options.reset !== false && data.query.students.length === 1) {
await loadInlineAccount(data.query.students[0]);
}
if (!data.records.length) {
recordRows.innerHTML = `<tr><td colspan="7" class="empty">未找到符合条件的上课记录</td></tr>`;
renderPager(recordPager, currentRecordPage);
return;
}
currentRecords = data.records.map((row, index) => ({
...row,
_recordIndex: index,
_recordKey: makeRecordKey(row, index),
_recordIndex: currentRecordPage.offset + index,
_recordKey: makeRecordKey(row, currentRecordPage.offset + index),
}));
recordRows.innerHTML = renderGroupedRecords(currentRecords);
renderPager(recordPager, currentRecordPage);
} catch (error) {
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 editButton = event.target.closest(".correction-edit");
const deleteButton = event.target.closest(".record-delete");
const teacherToggle = event.target.closest(".teacher-group-toggle");
const summaryToggle = event.target.closest(".summary-toggle");
if (summarySupplementButton) {
openSummarySupplementDialog(summarySupplementButton.dataset.recordKey);
return;
@@ -773,6 +886,14 @@ recordRows.addEventListener("click", (event) => {
openDeleteDialog(deleteButton.dataset.recordKey);
return;
}
if (teacherToggle) {
toggleTeacherGroup(teacherToggle.dataset.teacher);
return;
}
if (summaryToggle) {
toggleSummaryBody(summaryToggle.dataset.summaryBodyKey);
return;
}
const row = event.target.closest(".record-row");
if (row && !event.target.closest(".record-action-cell")) toggleRecordSummary(row.dataset.recordKey);
});
@@ -787,6 +908,17 @@ recordRows.addEventListener("keydown", (event) => {
toggleRecordSummary(row.dataset.recordKey);
});
if (recordPager) {
recordPager.addEventListener("click", (event) => {
const previous = event.target.closest(".pager-prev");
const next = event.target.closest(".pager-next");
if (!previous && !next) return;
const delta = previous ? -currentRecordPage.limit : currentRecordPage.limit;
const nextOffset = Math.max(0, currentRecordPage.offset + delta);
queryRecords(currentRecordPage.query || recordQuery.value, { offset: nextOffset, reset: false });
});
}
[correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => {
input.addEventListener("input", updateCorrectionPreview);
});
+1
View File
@@ -73,6 +73,7 @@
</tbody>
</table>
</div>
<div id="recordPager" class="pager" hidden></div>
</section>
</main>
+223 -1
View File
@@ -938,6 +938,22 @@ textarea:focus {
margin-top: 8px;
}
.summary-toggle {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--accent-strong);
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
.summary-toggle:hover {
border-color: var(--accent);
}
.summary-conflict-list {
display: grid;
gap: 10px;
@@ -1234,6 +1250,35 @@ textarea:focus {
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 {
width: 100%;
min-width: 720px;
@@ -1310,18 +1355,39 @@ td {
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 {
font-size: 15px;
line-height: 1.25;
}
.teacher-group-title span {
.teacher-group-title span,
.teacher-group-toggle span {
color: #344054;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.teacher-group-toggle strong {
font-size: 15px;
line-height: 1.25;
}
.record-row td:nth-child(4) {
color: var(--muted);
}
@@ -1833,6 +1899,130 @@ td {
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 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1966,6 +2156,15 @@ td {
.admin-tabs {
overflow-x: auto;
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 {
@@ -2070,9 +2269,32 @@ td {
gap: 4px;
}
.teacher-group-toggle {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.teacher-group-title span {
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) {
+4
View File
@@ -10,6 +10,10 @@ services:
- .env
environment:
TZ: ${TZ:-Asia/Shanghai}
USE_SQLITE_SOURCE: ${USE_SQLITE_SOURCE:-1}
SQLITE_DB_PATH: ${SQLITE_DB_PATH:-/data/xsk_education.db}
RUNTIME_DATA_ROOT: ${RUNTIME_DATA_ROOT:-/data/runtime_text_cache}
LEGACY_TEXT_ROOT: ${LEGACY_TEXT_ROOT:-/data}
CLASSNOTES_PATH: ${CLASSNOTES_PATH:-/data/classnotes.txt}
ACCOUNTS_PATH: ${ACCOUNTS_PATH:-/data/学生课时账户.md}
TEACHERS_PATH: ${TEACHERS_PATH:-/data/教师档案.md}
+232
View File
@@ -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())