Update teacher profiles and record summaries
This commit is contained in:
@@ -10,6 +10,7 @@ INGEST_AUTH_TOKEN=change-this-ingest-token
|
|||||||
|
|
||||||
CLASSNOTES_PATH=/data/classnotes.txt
|
CLASSNOTES_PATH=/data/classnotes.txt
|
||||||
ACCOUNTS_PATH=/data/学生课时账户.md
|
ACCOUNTS_PATH=/data/学生课时账户.md
|
||||||
|
TEACHERS_PATH=/data/教师档案.md
|
||||||
ADMIN_TASKS_PATH=/data/admin_tasks.json
|
ADMIN_TASKS_PATH=/data/admin_tasks.json
|
||||||
COURSE_SUMMARIES_ROOT=/data/course_summaries
|
COURSE_SUMMARIES_ROOT=/data/course_summaries
|
||||||
COURSE_SUMMARY_STATE_PATH=/data/course_summary_state.json
|
COURSE_SUMMARY_STATE_PATH=/data/course_summary_state.json
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ docker compose exec xsk-education-management python scripts/import_course_summar
|
|||||||
```text
|
```text
|
||||||
/root/新时空教务管理系统/data/classnotes.txt
|
/root/新时空教务管理系统/data/classnotes.txt
|
||||||
/root/新时空教务管理系统/data/学生课时账户.md
|
/root/新时空教务管理系统/data/学生课时账户.md
|
||||||
|
/root/新时空教务管理系统/data/教师档案.md
|
||||||
/root/新时空教务管理系统/data/course_summaries/
|
/root/新时空教务管理系统/data/course_summaries/
|
||||||
/root/新时空教务管理系统/data/course_summary_state.json
|
/root/新时空教务管理系统/data/course_summary_state.json
|
||||||
/root/新时空教务管理系统/data/operation_logs.jsonl
|
/root/新时空教务管理系统/data/operation_logs.jsonl
|
||||||
@@ -198,6 +199,9 @@ docker compose up -d
|
|||||||
- `GET /api/accounts/王鑫鹏`:单个学生账户。
|
- `GET /api/accounts/王鑫鹏`:单个学生账户。
|
||||||
- `POST /api/admin/accounts`:管理后台新增课时账户。
|
- `POST /api/admin/accounts`:管理后台新增课时账户。
|
||||||
- `PUT /api/admin/accounts/{student_id}`:管理后台修改课时账户。
|
- `PUT /api/admin/accounts/{student_id}`:管理后台修改课时账户。
|
||||||
|
- `GET /api/admin/teachers`:管理后台读取老师档案。
|
||||||
|
- `POST /api/admin/teachers`:管理后台新增老师档案。
|
||||||
|
- `PUT /api/admin/teachers/{teacher_id}`:管理后台修改老师档案。
|
||||||
- `POST /api/corrections`:课程记录页提交纠错审核。
|
- `POST /api/corrections`:课程记录页提交纠错审核。
|
||||||
- `GET /api/admin/tasks`:管理后台查看审核任务。
|
- `GET /api/admin/tasks`:管理后台查看审核任务。
|
||||||
- `POST /api/admin/tasks/{task_id}/approve`:批准纠错并写入正式上课记录。
|
- `POST /api/admin/tasks/{task_id}/approve`:批准纠错并写入正式上课记录。
|
||||||
|
|||||||
+20
-3
@@ -6,9 +6,9 @@ from pathlib import Path
|
|||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from .config import ACCOUNTS_PATH, CLASSNOTES_PATH
|
from .config import ACCOUNTS_PATH, CLASSNOTES_PATH, TEACHERS_PATH
|
||||||
from .data import Account, Payment, read_accounts, read_classnotes
|
from .data import Account, Payment, Teacher, read_accounts, read_classnotes, read_teachers
|
||||||
from .schemas import AccountPayload, RegisterLinesPayload
|
from .schemas import AccountPayload, RegisterLinesPayload, TeacherPayload
|
||||||
|
|
||||||
|
|
||||||
async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
||||||
@@ -49,6 +49,12 @@ def load_accounts():
|
|||||||
return read_accounts(ACCOUNTS_PATH)
|
return read_accounts(ACCOUNTS_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def load_teachers():
|
||||||
|
if not TEACHERS_PATH.exists():
|
||||||
|
return []
|
||||||
|
return read_teachers(TEACHERS_PATH)
|
||||||
|
|
||||||
|
|
||||||
def file_meta(path: Path) -> dict:
|
def file_meta(path: Path) -> dict:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return {"exists": False, "path": str(path)}
|
return {"exists": False, "path": str(path)}
|
||||||
@@ -70,3 +76,14 @@ def payload_to_account(payload: AccountPayload, student_id: str | None = None) -
|
|||||||
account_status=payload.account_status,
|
account_status=payload.account_status,
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def payload_to_teacher(payload: TeacherPayload, teacher_id: str | None = None) -> Teacher:
|
||||||
|
return Teacher(
|
||||||
|
teacher_id=teacher_id if teacher_id is not None else payload.teacher_id,
|
||||||
|
name=payload.name,
|
||||||
|
alias=payload.alias,
|
||||||
|
subjects=payload.subjects,
|
||||||
|
status=payload.status,
|
||||||
|
note=payload.note,
|
||||||
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ STATIC_DIR = APP_DIR / "static"
|
|||||||
|
|
||||||
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
|
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
|
||||||
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md"))
|
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"))
|
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_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"))
|
COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/course_summary_state.json"))
|
||||||
|
|||||||
+539
-5
@@ -22,6 +22,8 @@ from .domain import (
|
|||||||
ROLE_WORDS,
|
ROLE_WORDS,
|
||||||
SUBJECTS,
|
SUBJECTS,
|
||||||
SUBJECT_ALIASES,
|
SUBJECT_ALIASES,
|
||||||
|
TEACHER_STATUSES,
|
||||||
|
Teacher,
|
||||||
UNKNOWN_SUBJECTS,
|
UNKNOWN_SUBJECTS,
|
||||||
UNKNOWN_TEACHERS,
|
UNKNOWN_TEACHERS,
|
||||||
WEEKDAYS,
|
WEEKDAYS,
|
||||||
@@ -95,6 +97,11 @@ def format_account_row(account: Account) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_teacher_row(teacher: Teacher) -> str:
|
||||||
|
subjects = "、".join(teacher.subjects)
|
||||||
|
return f"| {teacher.teacher_id} | {teacher.name} | {teacher.alias} | {subjects} | {teacher.status} | {teacher.note} |"
|
||||||
|
|
||||||
|
|
||||||
def parse_payments(text: str) -> list[Payment]:
|
def parse_payments(text: str) -> list[Payment]:
|
||||||
payments: list[Payment] = []
|
payments: list[Payment] = []
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
@@ -135,6 +142,34 @@ def validate_account(account: Account) -> Account:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_teacher(teacher: Teacher) -> Teacher:
|
||||||
|
teacher_id = teacher.teacher_id.strip()
|
||||||
|
name = canonical_name(teacher.name)
|
||||||
|
alias = teacher.alias.strip()
|
||||||
|
subjects = [normalize_subject(subject) for subject in teacher.subjects if normalize_subject(subject)]
|
||||||
|
deduped_subjects = list(dict.fromkeys(subjects))
|
||||||
|
status = teacher.status.strip()
|
||||||
|
note = teacher.note.strip()
|
||||||
|
if not re.fullmatch(r"T\d{3}", teacher_id):
|
||||||
|
raise ValueError("教师ID格式应为 T001 这样的三位编号")
|
||||||
|
if not name:
|
||||||
|
raise ValueError("教师姓名不能为空")
|
||||||
|
if "|" in name or "|" in alias or "|" in note:
|
||||||
|
raise ValueError("教师姓名、别名和备注不能包含 |")
|
||||||
|
if not deduped_subjects:
|
||||||
|
raise ValueError("任教学科不能为空")
|
||||||
|
if status not in TEACHER_STATUSES:
|
||||||
|
raise ValueError("教师状态必须是 在岗 或 离职")
|
||||||
|
return Teacher(
|
||||||
|
teacher_id=teacher_id,
|
||||||
|
name=name,
|
||||||
|
alias=alias,
|
||||||
|
subjects=deduped_subjects,
|
||||||
|
status=status,
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def read_classnotes(path: Path) -> list[ClassRecord]:
|
def read_classnotes(path: Path) -> list[ClassRecord]:
|
||||||
records: list[ClassRecord] = []
|
records: list[ClassRecord] = []
|
||||||
with path.open("r", encoding="utf-8") as handle:
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
@@ -166,7 +201,8 @@ def read_accounts(path: Path) -> list[Account]:
|
|||||||
with path.open("r", encoding="utf-8") as handle:
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
for line_number, raw_line in enumerate(handle, start=1):
|
for line_number, raw_line in enumerate(handle, start=1):
|
||||||
line = raw_line.strip()
|
line = raw_line.strip()
|
||||||
if not line.startswith("|") or line.startswith("|---") or "学生ID" in line:
|
compact = line.strip("|").replace(" ", "").replace("|", "")
|
||||||
|
if not line.startswith("|") or set(compact) <= {"-"} or "学生ID" in line:
|
||||||
continue
|
continue
|
||||||
parts = [part.strip() for part in line.strip("|").split("|")]
|
parts = [part.strip() for part in line.strip("|").split("|")]
|
||||||
if len(parts) < 5:
|
if len(parts) < 5:
|
||||||
@@ -188,6 +224,51 @@ def read_accounts(path: Path) -> list[Account]:
|
|||||||
return accounts
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
def parse_teacher_subjects(text: str) -> list[str]:
|
||||||
|
subjects: list[str] = []
|
||||||
|
for item in re.split(r"[、,,\s]+", text.strip()):
|
||||||
|
subject = normalize_subject(item)
|
||||||
|
if subject and subject not in subjects:
|
||||||
|
subjects.append(subject)
|
||||||
|
return subjects
|
||||||
|
|
||||||
|
|
||||||
|
def read_teachers(path: Path) -> list[Teacher]:
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
teachers: list[Teacher] = []
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
for line_number, raw_line in enumerate(handle, start=1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
compact = line.strip("|").replace(" ", "").replace("|", "")
|
||||||
|
if not line.startswith("|") or set(compact) <= {"-"} or "教师ID" in line:
|
||||||
|
continue
|
||||||
|
parts = [part.strip() for part in line.strip("|").split("|")]
|
||||||
|
if len(parts) == 5:
|
||||||
|
teacher_id, name, subjects, status, note = parts
|
||||||
|
alias = ""
|
||||||
|
elif len(parts) >= 6:
|
||||||
|
teacher_id, name, alias, subjects, status, note = parts[:6]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
teachers.append(
|
||||||
|
validate_teacher(
|
||||||
|
Teacher(
|
||||||
|
teacher_id=teacher_id,
|
||||||
|
name=canonical_name(name),
|
||||||
|
alias=alias.strip(),
|
||||||
|
subjects=parse_teacher_subjects(subjects),
|
||||||
|
status=status,
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{path}:{line_number} 教师档案错误: {exc}") from exc
|
||||||
|
return teachers
|
||||||
|
|
||||||
|
|
||||||
def parse_class_record_line(line: str) -> ClassRecord:
|
def parse_class_record_line(line: str) -> ClassRecord:
|
||||||
raw = line.strip()
|
raw = line.strip()
|
||||||
match = CLASSNOTE_RE.fullmatch(raw)
|
match = CLASSNOTE_RE.fullmatch(raw)
|
||||||
@@ -303,7 +384,8 @@ def replace_account_lines(original_text: str, accounts_by_id: dict[str, Account]
|
|||||||
output: list[str] = []
|
output: list[str] = []
|
||||||
for line in lines:
|
for line in lines:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped:
|
compact = stripped.strip("|").replace(" ", "").replace("|", "")
|
||||||
|
if stripped.startswith("|") and not set(compact) <= {"-"} and "学生ID" not in stripped:
|
||||||
parts = [part.strip() for part in stripped.strip("|").split("|")]
|
parts = [part.strip() for part in stripped.strip("|").split("|")]
|
||||||
if parts and parts[0] in accounts_by_id:
|
if parts and parts[0] in accounts_by_id:
|
||||||
output.append(format_account_row(accounts_by_id[parts[0]]))
|
output.append(format_account_row(accounts_by_id[parts[0]]))
|
||||||
@@ -318,7 +400,8 @@ def append_account_line(original_text: str, account: Account) -> str:
|
|||||||
insert_at = len(lines)
|
insert_at = len(lines)
|
||||||
for index, line in enumerate(lines):
|
for index, line in enumerate(lines):
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped:
|
compact = stripped.strip("|").replace(" ", "").replace("|", "")
|
||||||
|
if stripped.startswith("|") and not set(compact) <= {"-"} and "学生ID" not in stripped:
|
||||||
insert_at = index + 1
|
insert_at = index + 1
|
||||||
lines.insert(insert_at, format_account_row(account))
|
lines.insert(insert_at, format_account_row(account))
|
||||||
trailing_newline = "\n" if original_text.endswith("\n") else ""
|
trailing_newline = "\n" if original_text.endswith("\n") else ""
|
||||||
@@ -331,7 +414,8 @@ def replace_single_account_line(original_text: str, old_student_id: str, account
|
|||||||
output: list[str] = []
|
output: list[str] = []
|
||||||
for line in lines:
|
for line in lines:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped:
|
compact = stripped.strip("|").replace(" ", "").replace("|", "")
|
||||||
|
if stripped.startswith("|") and not set(compact) <= {"-"} and "学生ID" not in stripped:
|
||||||
parts = [part.strip() for part in stripped.strip("|").split("|")]
|
parts = [part.strip() for part in stripped.strip("|").split("|")]
|
||||||
if parts and parts[0] == old_student_id:
|
if parts and parts[0] == old_student_id:
|
||||||
output.append(format_account_row(account))
|
output.append(format_account_row(account))
|
||||||
@@ -359,6 +443,141 @@ def write_accounts(path: Path, accounts: list[Account]) -> None:
|
|||||||
atomic_write_text(path, replace_account_lines(original_text, accounts_by_id))
|
atomic_write_text(path, replace_account_lines(original_text, accounts_by_id))
|
||||||
|
|
||||||
|
|
||||||
|
def next_teacher_id(teachers: list[Teacher]) -> str:
|
||||||
|
values = []
|
||||||
|
for teacher in teachers:
|
||||||
|
match = re.fullmatch(r"T(\d{3})", teacher.teacher_id)
|
||||||
|
if match:
|
||||||
|
values.append(int(match.group(1)))
|
||||||
|
return f"T{(max(values) if values else 0) + 1:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def replace_teacher_lines(original_text: str, teachers_by_id: dict[str, Teacher]) -> str:
|
||||||
|
lines = original_text.splitlines()
|
||||||
|
output: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
compact = stripped.strip("|").replace(" ", "").replace("|", "")
|
||||||
|
if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped:
|
||||||
|
parts = [part.strip() for part in stripped.strip("|").split("|")]
|
||||||
|
if parts and parts[0] in teachers_by_id:
|
||||||
|
output.append(format_teacher_row(teachers_by_id[parts[0]]))
|
||||||
|
continue
|
||||||
|
output.append(line)
|
||||||
|
trailing_newline = "\n" if original_text.endswith("\n") else ""
|
||||||
|
return "\n".join(output) + trailing_newline
|
||||||
|
|
||||||
|
|
||||||
|
def append_teacher_line(original_text: str, teacher: Teacher) -> str:
|
||||||
|
if not original_text.strip():
|
||||||
|
original_text = (
|
||||||
|
"| 教师ID | 教师姓名 | 别名 | 任教学科 | 状态 | 备注 |\n"
|
||||||
|
"| ------ | -------- | ---- | -------- | ------ | ---- |\n"
|
||||||
|
)
|
||||||
|
lines = original_text.splitlines()
|
||||||
|
insert_at = len(lines)
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
compact = stripped.strip("|").replace(" ", "").replace("|", "")
|
||||||
|
if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped:
|
||||||
|
insert_at = index + 1
|
||||||
|
lines.insert(insert_at, format_teacher_row(teacher))
|
||||||
|
trailing_newline = "\n" if original_text.endswith("\n") else ""
|
||||||
|
return "\n".join(lines) + trailing_newline
|
||||||
|
|
||||||
|
|
||||||
|
def replace_single_teacher_line(original_text: str, old_teacher_id: str, teacher: Teacher) -> str:
|
||||||
|
lines = original_text.splitlines()
|
||||||
|
replaced = False
|
||||||
|
output: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
compact = stripped.strip("|").replace(" ", "").replace("|", "")
|
||||||
|
if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped:
|
||||||
|
parts = [part.strip() for part in stripped.strip("|").split("|")]
|
||||||
|
if parts and parts[0] == old_teacher_id:
|
||||||
|
output.append(format_teacher_row(teacher))
|
||||||
|
replaced = True
|
||||||
|
continue
|
||||||
|
output.append(line)
|
||||||
|
if not replaced:
|
||||||
|
raise ValueError(f"未找到教师档案: {old_teacher_id}")
|
||||||
|
trailing_newline = "\n" if original_text.endswith("\n") else ""
|
||||||
|
return "\n".join(output) + trailing_newline
|
||||||
|
|
||||||
|
|
||||||
|
def write_teachers(path: Path, teachers: list[Teacher]) -> None:
|
||||||
|
original_text = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||||
|
teachers_by_id = {teacher.teacher_id: teacher for teacher in teachers}
|
||||||
|
atomic_write_text(path, replace_teacher_lines(original_text, teachers_by_id))
|
||||||
|
|
||||||
|
|
||||||
|
def create_teacher(path: Path, teacher: Teacher) -> dict:
|
||||||
|
teachers = read_teachers(path)
|
||||||
|
teacher = validate_teacher(replace(teacher, teacher_id=next_teacher_id(teachers)))
|
||||||
|
if any(item.teacher_id == teacher.teacher_id for item in teachers):
|
||||||
|
raise ValueError(f"教师ID已存在: {teacher.teacher_id}")
|
||||||
|
original_text = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||||
|
backup_dir = create_data_backup("admin-create-teacher", {path: original_text}, [format_teacher_row(teacher)])
|
||||||
|
atomic_write_text(path, append_teacher_line(original_text, teacher))
|
||||||
|
try:
|
||||||
|
prune_data_backups(backup_dir.parent)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return {"teacher": teacher_to_dict(teacher), "backup_id": backup_dir.name}
|
||||||
|
|
||||||
|
|
||||||
|
def update_teacher(path: Path, old_teacher_id: str, teacher: Teacher) -> dict:
|
||||||
|
old_teacher_id = old_teacher_id.strip()
|
||||||
|
teachers = read_teachers(path)
|
||||||
|
teacher = validate_teacher(teacher)
|
||||||
|
if not any(item.teacher_id == old_teacher_id for item in teachers):
|
||||||
|
raise ValueError(f"未找到教师档案: {old_teacher_id}")
|
||||||
|
if teacher.teacher_id != old_teacher_id and any(item.teacher_id == teacher.teacher_id for item in teachers):
|
||||||
|
raise ValueError(f"教师ID已存在: {teacher.teacher_id}")
|
||||||
|
original_text = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||||
|
backup_dir = create_data_backup("admin-update-teacher", {path: original_text}, [old_teacher_id, format_teacher_row(teacher)])
|
||||||
|
atomic_write_text(path, replace_single_teacher_line(original_text, old_teacher_id, teacher))
|
||||||
|
try:
|
||||||
|
prune_data_backups(backup_dir.parent)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return {"teacher": teacher_to_dict(teacher), "backup_id": backup_dir.name}
|
||||||
|
|
||||||
|
|
||||||
|
def teacher_to_dict(teacher: Teacher) -> dict:
|
||||||
|
return {
|
||||||
|
"teacher_id": teacher.teacher_id,
|
||||||
|
"name": teacher.name,
|
||||||
|
"alias": teacher.alias,
|
||||||
|
"display_name": teacher.alias or teacher.name,
|
||||||
|
"subjects": teacher.subjects,
|
||||||
|
"status": teacher.status,
|
||||||
|
"note": teacher.note,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def teacher_alias_map(teachers: list[Teacher]) -> dict[str, str]:
|
||||||
|
mapping: dict[str, str] = {}
|
||||||
|
for teacher in teachers:
|
||||||
|
display = teacher.alias or teacher.name
|
||||||
|
mapping[teacher.name] = display
|
||||||
|
if teacher.alias:
|
||||||
|
mapping[teacher.alias] = display
|
||||||
|
mapping[teacher.teacher_id] = display
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def teacher_name_map(teachers: list[Teacher]) -> dict[str, Teacher]:
|
||||||
|
mapping: dict[str, Teacher] = {}
|
||||||
|
for teacher in teachers:
|
||||||
|
mapping[teacher.name] = teacher
|
||||||
|
if teacher.alias:
|
||||||
|
mapping[teacher.alias] = teacher
|
||||||
|
mapping[teacher.teacher_id] = teacher
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
def create_account(path: Path, account: Account) -> dict:
|
def create_account(path: Path, account: Account) -> dict:
|
||||||
accounts = read_accounts(path)
|
accounts = read_accounts(path)
|
||||||
account = validate_account(replace(account, student_id=next_student_id(accounts)))
|
account = validate_account(replace(account, student_id=next_student_id(accounts)))
|
||||||
@@ -609,6 +828,71 @@ def submit_deletion_tasks(tasks_path: Path, items: list[dict]) -> dict:
|
|||||||
return {"submitted": len(created), "items": [task_to_dict(task) for task in created]}
|
return {"submitted": len(created), "items": [task_to_dict(task) for task in created]}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_teacher_input(value: str, teachers: list[Teacher]) -> str:
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("老师不能为空")
|
||||||
|
matches = [
|
||||||
|
teacher
|
||||||
|
for teacher in teachers
|
||||||
|
if text in {teacher.teacher_id, teacher.name, teacher.alias}
|
||||||
|
]
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0].name
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise ValueError(f"老师别名不唯一,请在后台修正别名: {text}")
|
||||||
|
return canonical_name(text)
|
||||||
|
|
||||||
|
|
||||||
|
def class_record_from_public_item(original: ClassRecord, item: dict, teachers: list[Teacher]) -> ClassRecord:
|
||||||
|
date_text = str(item.get("date") or original.date)
|
||||||
|
time_text = str(item.get("time") or original.time)
|
||||||
|
student = canonical_name(str(item.get("student") or original.student))
|
||||||
|
teacher = resolve_teacher_input(str(item.get("teacher") or original.teacher), teachers)
|
||||||
|
subject = normalize_subject(str(item.get("subject") or original.subject))
|
||||||
|
record_date = parse_record_date(date_text)
|
||||||
|
weekday = WEEKDAYS[record_date.weekday()]
|
||||||
|
duration_hours = parse_time_range_hours(time_text)
|
||||||
|
minutes = int(round(duration_hours * 60))
|
||||||
|
duration = duration_text_from_minutes(minutes)
|
||||||
|
return ClassRecord(
|
||||||
|
date=record_date.strftime("%Y.%m.%d"),
|
||||||
|
weekday=weekday,
|
||||||
|
time=normalize_time_range_text(time_text),
|
||||||
|
student=student,
|
||||||
|
duration=duration,
|
||||||
|
duration_hours=duration_hours,
|
||||||
|
teacher=teacher,
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def submit_public_correction_tasks(tasks_path: Path, records: list[ClassRecord], teachers: list[Teacher], items: list[dict]) -> dict:
|
||||||
|
if not items:
|
||||||
|
raise ValueError("提交审核的纠错记录不能为空")
|
||||||
|
internal_items: list[dict] = []
|
||||||
|
for item in items:
|
||||||
|
original = find_record_by_identity(records, str(item.get("record_id") or ""))
|
||||||
|
corrected = class_record_from_public_item(original, item, teachers)
|
||||||
|
internal_items.append(
|
||||||
|
{
|
||||||
|
"original_line": class_record_to_line(original),
|
||||||
|
"corrected_line": class_record_to_line(corrected),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return submit_correction_tasks(tasks_path, internal_items)
|
||||||
|
|
||||||
|
|
||||||
|
def submit_public_deletion_tasks(tasks_path: Path, records: list[ClassRecord], items: list[dict]) -> dict:
|
||||||
|
if not items:
|
||||||
|
raise ValueError("提交审核的删除记录不能为空")
|
||||||
|
internal_items: list[dict] = []
|
||||||
|
for item in items:
|
||||||
|
original = find_record_by_identity(records, str(item.get("record_id") or ""))
|
||||||
|
internal_items.append({"original_line": class_record_to_line(original)})
|
||||||
|
return submit_deletion_tasks(tasks_path, internal_items)
|
||||||
|
|
||||||
|
|
||||||
def list_admin_tasks(tasks_path: Path, status_filter: str = "", task_type: str = "") -> dict:
|
def list_admin_tasks(tasks_path: Path, status_filter: str = "", task_type: str = "") -> dict:
|
||||||
tasks = read_admin_tasks(tasks_path)
|
tasks = read_admin_tasks(tasks_path)
|
||||||
items = tasks["items"]
|
items = tasks["items"]
|
||||||
@@ -1153,12 +1437,41 @@ def parse_course_summary_title_date(title: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_course_summary_time_text(text: str) -> str:
|
||||||
|
separators = r"[--–—~到至]"
|
||||||
|
colon_match = re.search(
|
||||||
|
rf"(\d{{1,2}})\s*[::..]\s*(\d{{1,2}})\d*\s*{separators}\s*(\d{{1,2}})\s*[::..]\s*(\d{{1,2}})\d*",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
if colon_match:
|
||||||
|
start_hour, start_minute, end_hour, end_minute = (int(value) for value in colon_match.groups())
|
||||||
|
candidate = f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}"
|
||||||
|
else:
|
||||||
|
point_match = re.search(
|
||||||
|
rf"(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?\s*{separators}\s*(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
if not point_match:
|
||||||
|
return ""
|
||||||
|
start_hour = int(point_match.group(1))
|
||||||
|
start_minute = int(point_match.group(2) or 0)
|
||||||
|
end_hour = int(point_match.group(3))
|
||||||
|
end_minute = int(point_match.group(4) or 0)
|
||||||
|
candidate = f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}"
|
||||||
|
try:
|
||||||
|
return normalize_time_range_text(candidate)
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def iter_course_summary_markdown(root: Path) -> Iterable[dict]:
|
def iter_course_summary_markdown(root: Path) -> Iterable[dict]:
|
||||||
if not root.exists():
|
if not root.exists():
|
||||||
return
|
return
|
||||||
for path in sorted(root.rglob("*.md")):
|
for path in sorted(root.rglob("*.md")):
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
continue
|
continue
|
||||||
|
if "backups" in path.relative_to(root).parts:
|
||||||
|
continue
|
||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text))
|
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text))
|
||||||
if not matches:
|
if not matches:
|
||||||
@@ -1181,6 +1494,7 @@ def iter_course_summary_markdown(root: Path) -> Iterable[dict]:
|
|||||||
"id": item_id,
|
"id": item_id,
|
||||||
"title": title,
|
"title": title,
|
||||||
"date_iso": parse_course_summary_title_date(title),
|
"date_iso": parse_course_summary_title_date(title),
|
||||||
|
"time_range": parse_course_summary_time_text(f"{title}\n{body_text}"),
|
||||||
"group": group,
|
"group": group,
|
||||||
"body": body_text,
|
"body": body_text,
|
||||||
"body_preview": body_text[:260] + ("..." if len(body_text) > 260 else ""),
|
"body_preview": body_text[:260] + ("..." if len(body_text) > 260 else ""),
|
||||||
@@ -1223,6 +1537,55 @@ def course_summary_matched_fields(item: dict, q: str) -> list[str]:
|
|||||||
return [label for key, label in labels.items() if q in str(item.get(key, ""))]
|
return [label for key, label in labels.items() if q in str(item.get(key, ""))]
|
||||||
|
|
||||||
|
|
||||||
|
def course_summary_record_key(student: str, teacher: str, subject: str, date_iso: str, time_range: str) -> tuple[str, str, str, str, str]:
|
||||||
|
return (
|
||||||
|
canonical_name(student.strip()),
|
||||||
|
canonical_name(teacher.strip()),
|
||||||
|
normalize_subject(subject.strip()),
|
||||||
|
date_iso.strip(),
|
||||||
|
normalize_time_range_text(time_range) if time_range else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def course_summary_to_public(item: dict, teachers: list[Teacher]) -> dict:
|
||||||
|
display_names = teacher_alias_map(teachers)
|
||||||
|
teacher = str(item.get("teacher") or "")
|
||||||
|
body = str(item.get("body") or "")
|
||||||
|
title = str(item.get("title") or "")
|
||||||
|
display_teacher = display_names.get(teacher, teacher)
|
||||||
|
if teacher and display_teacher != teacher:
|
||||||
|
body = body.replace(teacher, display_teacher)
|
||||||
|
title = title.replace(teacher, display_teacher)
|
||||||
|
return {
|
||||||
|
"id": str(item.get("id") or ""),
|
||||||
|
"title": title,
|
||||||
|
"date_iso": str(item.get("date_iso") or ""),
|
||||||
|
"time_range": str(item.get("time_range") or ""),
|
||||||
|
"teacher": display_teacher,
|
||||||
|
"subject": str(item.get("subject") or ""),
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def course_summary_index_for_records(root: Path) -> dict[tuple[str, str, str, str, str], list[dict]]:
|
||||||
|
index: dict[tuple[str, str, str, str, str], list[dict]] = defaultdict(list)
|
||||||
|
for item in iter_course_summary_markdown(root):
|
||||||
|
time_range = str(item.get("time_range") or "")
|
||||||
|
if not time_range:
|
||||||
|
continue
|
||||||
|
key = course_summary_record_key(
|
||||||
|
str(item.get("student") or ""),
|
||||||
|
str(item.get("teacher") or ""),
|
||||||
|
str(item.get("subject") or ""),
|
||||||
|
str(item.get("date_iso") or ""),
|
||||||
|
time_range,
|
||||||
|
)
|
||||||
|
index[key].append(item)
|
||||||
|
for values in index.values():
|
||||||
|
values.sort(key=lambda item: (str(item.get("title") or ""), str(item.get("id") or "")))
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
def query_course_summaries(
|
def query_course_summaries(
|
||||||
root: Path,
|
root: Path,
|
||||||
q: str = "",
|
q: str = "",
|
||||||
@@ -1231,6 +1594,7 @@ def query_course_summaries(
|
|||||||
subject: str = "",
|
subject: str = "",
|
||||||
date_from: str = "",
|
date_from: str = "",
|
||||||
date_to: str = "",
|
date_to: str = "",
|
||||||
|
missing_time: bool = False,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
normalized_from = normalize_filter_date(date_from)
|
normalized_from = normalize_filter_date(date_from)
|
||||||
@@ -1250,6 +1614,7 @@ def query_course_summaries(
|
|||||||
normalized_from,
|
normalized_from,
|
||||||
normalized_to,
|
normalized_to,
|
||||||
)
|
)
|
||||||
|
and (not missing_time or not str(item.get("time_range") or ""))
|
||||||
]
|
]
|
||||||
for item in matched:
|
for item in matched:
|
||||||
item["matched_fields"] = course_summary_matched_fields(item, keyword)
|
item["matched_fields"] = course_summary_matched_fields(item, keyword)
|
||||||
@@ -1271,6 +1636,81 @@ def query_course_summaries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def replace_course_summary_block(root: Path, path: Path, summary_id: str, new_title: str | None = None, delete: bool = False) -> dict:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text))
|
||||||
|
identity = parse_course_summary_file_identity(root, path)
|
||||||
|
for index, match in enumerate(matches):
|
||||||
|
title = match.group("title").strip()
|
||||||
|
start = match.start()
|
||||||
|
body_start = match.end()
|
||||||
|
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||||||
|
body = text[body_start:end].strip()
|
||||||
|
body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", body).strip()
|
||||||
|
body_text = body_without_meta or body
|
||||||
|
item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20)
|
||||||
|
if item_id != summary_id:
|
||||||
|
continue
|
||||||
|
if delete:
|
||||||
|
new_text = text[:start].rstrip() + "\n\n" + text[end:].lstrip()
|
||||||
|
else:
|
||||||
|
if not new_title:
|
||||||
|
raise ValueError("课程小结标题不能为空")
|
||||||
|
new_text = f"{text[:match.start('title')]}{new_title}{text[match.end('title'):]}"
|
||||||
|
atomic_write_text(path, new_text.rstrip() + "\n")
|
||||||
|
return {"id": summary_id, "title": title, "path": str(path), "deleted": delete, "new_title": new_title or ""}
|
||||||
|
raise ValueError("未找到课程小结")
|
||||||
|
|
||||||
|
|
||||||
|
def find_course_summary_item(root: Path, summary_id: str) -> dict:
|
||||||
|
for item in iter_course_summary_markdown(root):
|
||||||
|
if str(item.get("id") or "") == summary_id:
|
||||||
|
return item
|
||||||
|
raise ValueError("未找到课程小结")
|
||||||
|
|
||||||
|
|
||||||
|
def update_course_summary_time(root: Path, summary_id: str, time_range: str) -> dict:
|
||||||
|
item = find_course_summary_item(root, summary_id)
|
||||||
|
normalized_time = normalize_time_range_text(time_range)
|
||||||
|
path = Path(str(item.get("source_path") or ""))
|
||||||
|
subject = normalize_subject(str(item.get("subject") or "待核对科目")) or "待核对科目"
|
||||||
|
date_iso = str(item.get("date_iso") or "")
|
||||||
|
if not date_iso:
|
||||||
|
raise ValueError("课程小结缺少日期,不能补齐时间")
|
||||||
|
old_title = str(item.get("title") or "")
|
||||||
|
date_prefix = date_iso
|
||||||
|
new_title = re.sub(
|
||||||
|
r"^\d{4}[.-]\d{1,2}[.-]\d{1,2}(?:\s+\d{1,2}:\d{2}-\d{1,2}:\d{2})?",
|
||||||
|
f"{date_prefix} {normalized_time}",
|
||||||
|
old_title,
|
||||||
|
)
|
||||||
|
if new_title == old_title:
|
||||||
|
new_title = f"{date_prefix} {normalized_time} {subject}课堂小结"
|
||||||
|
original = path.read_text(encoding="utf-8")
|
||||||
|
backup_dir = create_data_backup("admin-update-course-summary-time", {path: original}, [summary_id, new_title])
|
||||||
|
result = replace_course_summary_block(root, path, summary_id, new_title=new_title)
|
||||||
|
result["backup_id"] = backup_dir.name
|
||||||
|
try:
|
||||||
|
prune_data_backups(backup_dir.parent)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def delete_course_summary(root: Path, summary_id: str) -> dict:
|
||||||
|
item = find_course_summary_item(root, summary_id)
|
||||||
|
path = Path(str(item.get("source_path") or ""))
|
||||||
|
original = path.read_text(encoding="utf-8")
|
||||||
|
backup_dir = create_data_backup("admin-delete-course-summary", {path: original}, [summary_id])
|
||||||
|
result = replace_course_summary_block(root, path, summary_id, delete=True)
|
||||||
|
result["backup_id"] = backup_dir.name
|
||||||
|
try:
|
||||||
|
prune_data_backups(backup_dir.parent)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def course_summary_path(root: Path, summary: dict) -> Path:
|
def course_summary_path(root: Path, summary: dict) -> Path:
|
||||||
student = safe_filename_part(summary["student"])
|
student = safe_filename_part(summary["student"])
|
||||||
teacher = safe_filename_part(summary["teacher"] or "待核对老师")
|
teacher = safe_filename_part(summary["teacher"] or "待核对老师")
|
||||||
@@ -1445,6 +1885,7 @@ def approve_course_summary_task(
|
|||||||
task["status"] = "approved"
|
task["status"] = "approved"
|
||||||
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
|
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
|
||||||
task["reviewed_at"] = task["updated_at"]
|
task["reviewed_at"] = task["updated_at"]
|
||||||
|
task["proposed_line"] = proposed_line
|
||||||
task["registered_line"] = proposed_line
|
task["registered_line"] = proposed_line
|
||||||
task["backup_id"] = result.get("backup_id", "")
|
task["backup_id"] = result.get("backup_id", "")
|
||||||
write_admin_tasks(tasks_path, tasks)
|
write_admin_tasks(tasks_path, tasks)
|
||||||
@@ -1651,7 +2092,6 @@ def ingest_course_summaries(
|
|||||||
result["review_pending"] += 1
|
result["review_pending"] += 1
|
||||||
status_value = "review"
|
status_value = "review"
|
||||||
task_id = task.get("id")
|
task_id = task.get("id")
|
||||||
backup_id = ""
|
|
||||||
else:
|
else:
|
||||||
register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
|
register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
|
||||||
result["auto_registered"] += 1
|
result["auto_registered"] += 1
|
||||||
@@ -1967,6 +2407,11 @@ def build_query_spec(query: str, records: list[ClassRecord], today: date | None
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_public_query_spec(query: str, records: list[ClassRecord], teachers: list[Teacher], today: date | None = None) -> QuerySpec:
|
||||||
|
spec = build_query_spec(query, records, today=today)
|
||||||
|
return replace(spec, raw_query=query)
|
||||||
|
|
||||||
|
|
||||||
def filter_records(records: list[ClassRecord], spec: QuerySpec) -> list[ClassRecord]:
|
def filter_records(records: list[ClassRecord], spec: QuerySpec) -> list[ClassRecord]:
|
||||||
matched: list[ClassRecord] = []
|
matched: list[ClassRecord] = []
|
||||||
for record in records:
|
for record in records:
|
||||||
@@ -2014,6 +2459,28 @@ def summarize_records(records: list[ClassRecord]) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_public_records(records: list[ClassRecord], teachers: list[Teacher]) -> dict:
|
||||||
|
display_names = teacher_alias_map(teachers)
|
||||||
|
by_student: defaultdict[str, float] = defaultdict(float)
|
||||||
|
by_teacher: defaultdict[str, float] = defaultdict(float)
|
||||||
|
by_subject: defaultdict[str, float] = defaultdict(float)
|
||||||
|
for record in records:
|
||||||
|
by_student[record.student] += record.duration_hours
|
||||||
|
by_teacher[display_names.get(record.teacher, record.teacher)] += record.duration_hours
|
||||||
|
by_subject[record.subject] += record.duration_hours
|
||||||
|
return {
|
||||||
|
"count": len(records),
|
||||||
|
"total_hours": round(sum(record.duration_hours for record in records), 2),
|
||||||
|
"students": dict(sorted((key, round(value, 2)) for key, value in by_student.items())),
|
||||||
|
"teachers": dict(sorted((key, round(value, 2)) for key, value in by_teacher.items())),
|
||||||
|
"subjects": dict(sorted((key, round(value, 2)) for key, value in by_subject.items())),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def record_identity(record: ClassRecord) -> str:
|
||||||
|
return sha1_text(class_record_to_line(record), 20)
|
||||||
|
|
||||||
|
|
||||||
def record_to_dict(record: ClassRecord) -> dict:
|
def record_to_dict(record: ClassRecord) -> dict:
|
||||||
return {
|
return {
|
||||||
"date": record.date,
|
"date": record.date,
|
||||||
@@ -2027,6 +2494,38 @@ def record_to_dict(record: ClassRecord) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def public_record_to_dict(
|
||||||
|
record: ClassRecord,
|
||||||
|
teachers: list[Teacher],
|
||||||
|
summary_index: dict[tuple[str, str, str, str, str], list[dict]] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
display_names = teacher_alias_map(teachers)
|
||||||
|
summary_key = course_summary_record_key(
|
||||||
|
record.student,
|
||||||
|
record.teacher,
|
||||||
|
record.subject,
|
||||||
|
record.date.replace(".", "-"),
|
||||||
|
record.time,
|
||||||
|
)
|
||||||
|
summaries = [
|
||||||
|
course_summary_to_public(item, teachers)
|
||||||
|
for item in (summary_index or {}).get(summary_key, [])
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"record_id": record_identity(record),
|
||||||
|
"date": record.date,
|
||||||
|
"weekday": record.weekday,
|
||||||
|
"time": record.time,
|
||||||
|
"student": record.student,
|
||||||
|
"duration": record.duration,
|
||||||
|
"duration_hours": record.duration_hours,
|
||||||
|
"teacher": display_names.get(record.teacher, record.teacher),
|
||||||
|
"subject": record.subject,
|
||||||
|
"summary_count": len(summaries),
|
||||||
|
"summaries": summaries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def account_to_dict(account: Account) -> dict:
|
def account_to_dict(account: Account) -> dict:
|
||||||
return {
|
return {
|
||||||
"student_id": account.student_id,
|
"student_id": account.student_id,
|
||||||
@@ -2062,6 +2561,41 @@ def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> d
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def query_public_records(
|
||||||
|
records: list[ClassRecord],
|
||||||
|
teachers: list[Teacher],
|
||||||
|
query: str,
|
||||||
|
limit: int = 200,
|
||||||
|
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
|
||||||
|
display_names = teacher_alias_map(teachers)
|
||||||
|
summary_index = course_summary_index_for_records(summaries_root) if summaries_root is not None else {}
|
||||||
|
return {
|
||||||
|
"query": {
|
||||||
|
"raw_query": spec.raw_query,
|
||||||
|
"date_range": format_date_range(spec),
|
||||||
|
"students": spec.students,
|
||||||
|
"teachers": [display_names.get(teacher, teacher) for teacher in spec.teachers],
|
||||||
|
"subjects": spec.subjects,
|
||||||
|
},
|
||||||
|
"summary": summarize_public_records(matched, teachers),
|
||||||
|
"records": [public_record_to_dict(record, teachers, summary_index) for record in shown],
|
||||||
|
"total_records": len(matched),
|
||||||
|
"shown_records": len(shown),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_record_by_identity(records: list[ClassRecord], record_id: str) -> ClassRecord:
|
||||||
|
target = record_id.strip()
|
||||||
|
for record in records:
|
||||||
|
if record_identity(record) == target:
|
||||||
|
return record
|
||||||
|
raise ValueError(f"未找到上课记录: {record_id}")
|
||||||
|
|
||||||
|
|
||||||
def account_summary(accounts: list[Account]) -> dict:
|
def account_summary(accounts: list[Account]) -> dict:
|
||||||
result = {
|
result = {
|
||||||
"total": len(accounts),
|
"total": len(accounts),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ FALLBACK_ALIASES = {
|
|||||||
ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"}
|
ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"}
|
||||||
WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
|
WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
|
||||||
UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"}
|
UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"}
|
||||||
|
TEACHER_STATUSES = {"在岗", "离职"}
|
||||||
UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"}
|
UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"}
|
||||||
HIGH_CONFIDENCE_VALUES = {"high", "高", "高置信", "true", "1", "yes"}
|
HIGH_CONFIDENCE_VALUES = {"high", "高", "高置信", "true", "1", "yes"}
|
||||||
AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "manual_admin", "大模型高置信识别", "关键词"}
|
AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "manual_admin", "大模型高置信识别", "关键词"}
|
||||||
@@ -63,6 +64,16 @@ class ClassRecord:
|
|||||||
subject: str
|
subject: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Teacher:
|
||||||
|
teacher_id: str
|
||||||
|
name: str
|
||||||
|
alias: str
|
||||||
|
subjects: list[str]
|
||||||
|
status: str
|
||||||
|
note: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class QuerySpec:
|
class QuerySpec:
|
||||||
raw_query: str
|
raw_query: str
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
|
||||||
from ..api_utils import file_meta, load_accounts, payload_to_account, read_register_payload
|
from ..api_utils import file_meta, load_accounts, load_teachers, payload_to_account, payload_to_teacher, read_register_payload
|
||||||
from ..auth import verify_accounts_auth, verify_admin_auth
|
from ..auth import verify_accounts_auth, verify_admin_auth
|
||||||
from ..config import (
|
from ..config import (
|
||||||
ACCOUNTS_PATH,
|
ACCOUNTS_PATH,
|
||||||
@@ -11,21 +11,26 @@ from ..config import (
|
|||||||
COURSE_SUMMARIES_ROOT,
|
COURSE_SUMMARIES_ROOT,
|
||||||
COURSE_SUMMARY_STATE_PATH,
|
COURSE_SUMMARY_STATE_PATH,
|
||||||
OPERATION_LOGS_PATH,
|
OPERATION_LOGS_PATH,
|
||||||
|
TEACHERS_PATH,
|
||||||
write_lock,
|
write_lock,
|
||||||
)
|
)
|
||||||
from ..data import (
|
from ..data import (
|
||||||
ACCOUNT_STATUSES,
|
ACCOUNT_STATUSES,
|
||||||
|
TEACHER_STATUSES,
|
||||||
DuplicateRecordError,
|
DuplicateRecordError,
|
||||||
account_summary,
|
account_summary,
|
||||||
account_to_dict,
|
account_to_dict,
|
||||||
create_account,
|
create_account,
|
||||||
|
create_teacher,
|
||||||
filter_accounts,
|
filter_accounts,
|
||||||
register_class_record_lines,
|
register_class_record_lines,
|
||||||
register_course_summary_texts,
|
register_course_summary_texts,
|
||||||
register_payment_lines,
|
register_payment_lines,
|
||||||
|
teacher_to_dict,
|
||||||
update_account,
|
update_account,
|
||||||
|
update_teacher,
|
||||||
)
|
)
|
||||||
from ..schemas import AccountPayload
|
from ..schemas import AccountPayload, TeacherPayload
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -83,10 +88,13 @@ async def register_course_summaries(request: Request, _user: str = Depends(verif
|
|||||||
@router.get("/api/account-health")
|
@router.get("/api/account-health")
|
||||||
def account_health(_user: str = Depends(verify_accounts_auth)):
|
def account_health(_user: str = Depends(verify_accounts_auth)):
|
||||||
accounts = load_accounts()
|
accounts = load_accounts()
|
||||||
|
teachers = load_teachers()
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"accounts": file_meta(ACCOUNTS_PATH),
|
"accounts": file_meta(ACCOUNTS_PATH),
|
||||||
|
"teachers": file_meta(TEACHERS_PATH),
|
||||||
"accounts_count": len(accounts),
|
"accounts_count": len(accounts),
|
||||||
|
"teachers_count": len(teachers),
|
||||||
"account_summary": account_summary(accounts),
|
"account_summary": account_summary(accounts),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +127,42 @@ def admin_statuses(_user: str = Depends(verify_admin_auth)):
|
|||||||
return {"account_statuses": sorted(ACCOUNT_STATUSES)}
|
return {"account_statuses": sorted(ACCOUNT_STATUSES)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/teacher-statuses")
|
||||||
|
def admin_teacher_statuses(_user: str = Depends(verify_admin_auth)):
|
||||||
|
return {"teacher_statuses": sorted(TEACHER_STATUSES)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/teachers")
|
||||||
|
def admin_teachers(_user: str = Depends(verify_admin_auth)):
|
||||||
|
return {
|
||||||
|
"teachers": [teacher_to_dict(teacher) for teacher in load_teachers()],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/admin/teachers")
|
||||||
|
def admin_create_teacher(payload: TeacherPayload, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
with write_lock:
|
||||||
|
result = create_teacher(TEACHERS_PATH, payload_to_teacher(payload))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/admin/teachers/{teacher_id}")
|
||||||
|
def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
with write_lock:
|
||||||
|
result = update_teacher(
|
||||||
|
TEACHERS_PATH,
|
||||||
|
teacher_id,
|
||||||
|
payload_to_teacher(payload, teacher_id=payload.teacher_id.strip() or teacher_id),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/admin/accounts")
|
@router.post("/api/admin/accounts")
|
||||||
def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
|
def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ from ..config import (
|
|||||||
from ..data import (
|
from ..data import (
|
||||||
append_operation_log,
|
append_operation_log,
|
||||||
approve_admin_task,
|
approve_admin_task,
|
||||||
|
delete_course_summary,
|
||||||
list_admin_tasks,
|
list_admin_tasks,
|
||||||
list_operation_logs,
|
list_operation_logs,
|
||||||
query_course_summaries,
|
query_course_summaries,
|
||||||
reject_admin_task,
|
reject_admin_task,
|
||||||
|
update_course_summary_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -61,6 +63,7 @@ def admin_course_summaries(
|
|||||||
subject: str = Query(""),
|
subject: str = Query(""),
|
||||||
date_from: str = Query(""),
|
date_from: str = Query(""),
|
||||||
date_to: str = Query(""),
|
date_to: str = Query(""),
|
||||||
|
missing_time: bool = Query(False),
|
||||||
limit: int = Query(200, ge=1, le=1000),
|
limit: int = Query(200, ge=1, le=1000),
|
||||||
_user: str = Depends(verify_admin_auth),
|
_user: str = Depends(verify_admin_auth),
|
||||||
):
|
):
|
||||||
@@ -73,6 +76,7 @@ def admin_course_summaries(
|
|||||||
subject=subject,
|
subject=subject,
|
||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
|
missing_time=missing_time,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -117,3 +121,38 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
return {"ok": True, "task": task}
|
return {"ok": True, "task": task}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/admin/course-summaries/{summary_id}/time")
|
||||||
|
def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
with write_lock:
|
||||||
|
result = update_course_summary_time(COURSE_SUMMARIES_ROOT, summary_id, str(payload.get("time_range") or ""))
|
||||||
|
append_operation_log(
|
||||||
|
OPERATION_LOGS_PATH,
|
||||||
|
"admin_course_summary_update_time",
|
||||||
|
"updated",
|
||||||
|
summary_id=summary_id,
|
||||||
|
time_range=str(payload.get("time_range") or ""),
|
||||||
|
backup_id=str(result.get("backup_id") or ""),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/admin/course-summaries/{summary_id}")
|
||||||
|
def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
with write_lock:
|
||||||
|
result = delete_course_summary(COURSE_SUMMARIES_ROOT, summary_id)
|
||||||
|
append_operation_log(
|
||||||
|
OPERATION_LOGS_PATH,
|
||||||
|
"admin_course_summary_delete",
|
||||||
|
"deleted",
|
||||||
|
summary_id=summary_id,
|
||||||
|
backup_id=str(result.get("backup_id") or ""),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from ..api_utils import file_meta, load_accounts, load_records
|
from ..api_utils import file_meta, load_accounts, load_records, load_teachers
|
||||||
from ..auth import verify_records_auth
|
from ..auth import verify_records_auth
|
||||||
from ..config import (
|
from ..config import (
|
||||||
ACCOUNTS_PATH,
|
ACCOUNTS_PATH,
|
||||||
@@ -10,6 +10,7 @@ from ..config import (
|
|||||||
COURSE_SUMMARIES_ROOT,
|
COURSE_SUMMARIES_ROOT,
|
||||||
COURSE_SUMMARY_STATE_PATH,
|
COURSE_SUMMARY_STATE_PATH,
|
||||||
OPERATION_LOGS_PATH,
|
OPERATION_LOGS_PATH,
|
||||||
|
TEACHERS_PATH,
|
||||||
)
|
)
|
||||||
from ..data import account_summary
|
from ..data import account_summary
|
||||||
|
|
||||||
@@ -21,14 +22,17 @@ router = APIRouter()
|
|||||||
def health(_user: str = Depends(verify_records_auth)):
|
def health(_user: str = Depends(verify_records_auth)):
|
||||||
records = load_records()
|
records = load_records()
|
||||||
accounts = load_accounts()
|
accounts = load_accounts()
|
||||||
|
teachers = load_teachers()
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"classnotes": file_meta(CLASSNOTES_PATH),
|
"classnotes": file_meta(CLASSNOTES_PATH),
|
||||||
"accounts": file_meta(ACCOUNTS_PATH),
|
"accounts": file_meta(ACCOUNTS_PATH),
|
||||||
|
"teachers": file_meta(TEACHERS_PATH),
|
||||||
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
|
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
|
||||||
"course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH),
|
"course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH),
|
||||||
"operation_logs": file_meta(OPERATION_LOGS_PATH),
|
"operation_logs": file_meta(OPERATION_LOGS_PATH),
|
||||||
"records_count": len(records),
|
"records_count": len(records),
|
||||||
"accounts_count": len(accounts),
|
"accounts_count": len(accounts),
|
||||||
|
"teachers_count": len(teachers),
|
||||||
"account_summary": account_summary(accounts),
|
"account_summary": account_summary(accounts),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from ..api_utils import load_accounts, load_records
|
from ..api_utils import load_accounts, load_records, load_teachers
|
||||||
from ..auth import verify_records_auth
|
from ..auth import verify_records_auth
|
||||||
from ..config import ADMIN_TASKS_PATH
|
from ..config import ADMIN_TASKS_PATH, COURSE_SUMMARIES_ROOT
|
||||||
from ..data import account_to_dict, query_records, submit_correction_tasks, submit_deletion_tasks
|
from ..data import (
|
||||||
|
account_to_dict,
|
||||||
|
query_public_records,
|
||||||
|
submit_public_correction_tasks,
|
||||||
|
submit_public_deletion_tasks,
|
||||||
|
)
|
||||||
from ..schemas import CorrectionSubmitPayload, DeletionSubmitPayload
|
from ..schemas import CorrectionSubmitPayload, DeletionSubmitPayload
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +23,7 @@ def records(
|
|||||||
limit: int = Query(200, ge=1, le=1000),
|
limit: int = Query(200, ge=1, le=1000),
|
||||||
_user: str = Depends(verify_records_auth),
|
_user: str = Depends(verify_records_auth),
|
||||||
):
|
):
|
||||||
return query_records(load_records(), q, limit=limit)
|
return query_public_records(load_records(), load_teachers(), q, limit=limit, summaries_root=COURSE_SUMMARIES_ROOT)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/student-account/{student}")
|
@router.get("/api/student-account/{student}")
|
||||||
@@ -32,8 +37,10 @@ def record_student_account(student: str, _user: str = Depends(verify_records_aut
|
|||||||
@router.post("/api/corrections")
|
@router.post("/api/corrections")
|
||||||
def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(verify_records_auth)):
|
def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(verify_records_auth)):
|
||||||
try:
|
try:
|
||||||
result = submit_correction_tasks(
|
result = submit_public_correction_tasks(
|
||||||
ADMIN_TASKS_PATH,
|
ADMIN_TASKS_PATH,
|
||||||
|
load_records(),
|
||||||
|
load_teachers(),
|
||||||
[item.dict() for item in payload.items],
|
[item.dict() for item in payload.items],
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -44,8 +51,9 @@ def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(ve
|
|||||||
@router.post("/api/deletions")
|
@router.post("/api/deletions")
|
||||||
def submit_deletions(payload: DeletionSubmitPayload, _user: str = Depends(verify_records_auth)):
|
def submit_deletions(payload: DeletionSubmitPayload, _user: str = Depends(verify_records_auth)):
|
||||||
try:
|
try:
|
||||||
result = submit_deletion_tasks(
|
result = submit_public_deletion_tasks(
|
||||||
ADMIN_TASKS_PATH,
|
ADMIN_TASKS_PATH,
|
||||||
|
load_records(),
|
||||||
[item.dict() for item in payload.items],
|
[item.dict() for item in payload.items],
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
+16
-3
@@ -22,9 +22,22 @@ class AccountPayload(BaseModel):
|
|||||||
note: str = ""
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class TeacherPayload(BaseModel):
|
||||||
|
teacher_id: str = ""
|
||||||
|
name: str
|
||||||
|
alias: str = ""
|
||||||
|
subjects: list[str] = Field(default_factory=list)
|
||||||
|
status: str = "在岗"
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
class CorrectionItemPayload(BaseModel):
|
class CorrectionItemPayload(BaseModel):
|
||||||
original_line: str
|
record_id: str
|
||||||
corrected_line: str
|
date: str
|
||||||
|
time: str
|
||||||
|
student: str
|
||||||
|
teacher: str
|
||||||
|
subject: str
|
||||||
|
|
||||||
|
|
||||||
class CorrectionSubmitPayload(BaseModel):
|
class CorrectionSubmitPayload(BaseModel):
|
||||||
@@ -32,7 +45,7 @@ class CorrectionSubmitPayload(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class DeletionItemPayload(BaseModel):
|
class DeletionItemPayload(BaseModel):
|
||||||
original_line: str
|
record_id: str
|
||||||
|
|
||||||
|
|
||||||
class DeletionSubmitPayload(BaseModel):
|
class DeletionSubmitPayload(BaseModel):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>管理后台</title>
|
<title>管理后台</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=20260615-summary-review-drawer" />
|
<link rel="stylesheet" href="/static/styles.css?v=20260615-summary-time-review" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
<main class="layout account-layout admin-layout">
|
<main class="layout account-layout admin-layout">
|
||||||
<nav class="admin-tabs" aria-label="管理后台功能">
|
<nav class="admin-tabs" aria-label="管理后台功能">
|
||||||
<button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button>
|
<button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button>
|
||||||
|
<button class="admin-tab" data-admin-tab="teachers" type="button">老师档案</button>
|
||||||
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
|
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
|
||||||
<button class="admin-tab" data-admin-tab="summaries" type="button">课程小结审核</button>
|
<button class="admin-tab" data-admin-tab="summaries" type="button">课程小结审核</button>
|
||||||
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结查询</button>
|
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结查询</button>
|
||||||
@@ -110,6 +111,81 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="teachersPanel" class="panel admin-panel" hidden>
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>老师档案</h2>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<select id="teacherStatus" aria-label="老师状态筛选">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="在岗">在岗</option>
|
||||||
|
<option value="离职">离职</option>
|
||||||
|
</select>
|
||||||
|
<button id="newTeacherBtn" class="chip" type="button">新增老师</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="teacherForm" class="search-row account-search-row">
|
||||||
|
<input id="teacherQuery" autocomplete="off" placeholder="教师姓名、别名、教师ID或学科" />
|
||||||
|
<button type="submit">查询</button>
|
||||||
|
</form>
|
||||||
|
<div id="teacherMeta" class="summary-grid"></div>
|
||||||
|
<div id="teacherEditor" class="admin-editor" hidden>
|
||||||
|
<div class="section-head compact-head">
|
||||||
|
<h2 id="teacherEditorTitle">新增老师</h2>
|
||||||
|
<button id="cancelTeacherEditBtn" class="secondary-button" type="button">取消</button>
|
||||||
|
</div>
|
||||||
|
<form id="teacherEditForm" class="admin-form">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
教师ID
|
||||||
|
<input id="editTeacherId" autocomplete="off" placeholder="新增时自动生成" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
教师姓名
|
||||||
|
<input id="editTeacherName" autocomplete="off" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
别名
|
||||||
|
<input id="editTeacherAlias" autocomplete="off" placeholder="对外展示名称;空则显示教师姓名" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
状态
|
||||||
|
<select id="editTeacherStatus">
|
||||||
|
<option value="在岗">在岗</option>
|
||||||
|
<option value="离职">离职</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="span-2">
|
||||||
|
任教学科
|
||||||
|
<input id="editTeacherSubjects" autocomplete="off" placeholder="数学、物理、生物" required />
|
||||||
|
</label>
|
||||||
|
<label class="span-2">
|
||||||
|
备注
|
||||||
|
<textarea id="editTeacherNote" rows="3"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p id="teacherEditError" class="correction-error" hidden></p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit">保存老师档案</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap account-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>教师</th>
|
||||||
|
<th>别名</th>
|
||||||
|
<th>任教学科</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>备注</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="teacherRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="reviewsPanel" class="panel admin-panel" hidden>
|
<section id="reviewsPanel" class="panel admin-panel" hidden>
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>纠错审核</h2>
|
<h2>纠错审核</h2>
|
||||||
@@ -223,6 +299,10 @@
|
|||||||
<input id="summarySearchSubject" autocomplete="off" placeholder="科目" />
|
<input id="summarySearchSubject" autocomplete="off" placeholder="科目" />
|
||||||
<input id="summarySearchDateFrom" type="date" aria-label="开始日期" />
|
<input id="summarySearchDateFrom" type="date" aria-label="开始日期" />
|
||||||
<input id="summarySearchDateTo" type="date" aria-label="结束日期" />
|
<input id="summarySearchDateTo" type="date" aria-label="结束日期" />
|
||||||
|
<select id="summarySearchMissingTime" aria-label="时间状态筛选">
|
||||||
|
<option value="">全部时间</option>
|
||||||
|
<option value="1">缺少时间</option>
|
||||||
|
</select>
|
||||||
<button type="submit">查询</button>
|
<button type="submit">查询</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="summarySearchMeta" class="summary-grid"></div>
|
<div id="summarySearchMeta" class="summary-grid"></div>
|
||||||
@@ -231,11 +311,12 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>日期</th>
|
<th>日期</th>
|
||||||
|
<th>时间</th>
|
||||||
<th>学生</th>
|
<th>学生</th>
|
||||||
<th>老师/科目</th>
|
<th>老师/科目</th>
|
||||||
<th>标题</th>
|
<th>标题</th>
|
||||||
<th>小结正文</th>
|
<th>小结正文</th>
|
||||||
<th>来源</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="summarySearchRows"></tbody>
|
<tbody id="summarySearchRows"></tbody>
|
||||||
@@ -319,6 +400,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/admin.js?v=20260615-summary-register-complete"></script>
|
<script src="/static/admin.js?v=20260615-summary-time-review"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+215
-5
@@ -2,6 +2,7 @@ const adminHealthText = document.querySelector("#adminHealthText");
|
|||||||
const refreshBtn = document.querySelector("#refreshBtn");
|
const refreshBtn = document.querySelector("#refreshBtn");
|
||||||
const panels = {
|
const panels = {
|
||||||
accounts: document.querySelector("#accountsPanel"),
|
accounts: document.querySelector("#accountsPanel"),
|
||||||
|
teachers: document.querySelector("#teachersPanel"),
|
||||||
reviews: document.querySelector("#reviewsPanel"),
|
reviews: document.querySelector("#reviewsPanel"),
|
||||||
summaries: document.querySelector("#summariesPanel"),
|
summaries: document.querySelector("#summariesPanel"),
|
||||||
summarySearch: document.querySelector("#summarySearchPanel"),
|
summarySearch: document.querySelector("#summarySearchPanel"),
|
||||||
@@ -19,6 +20,23 @@ const accountEditForm = document.querySelector("#accountEditForm");
|
|||||||
const accountEditError = document.querySelector("#accountEditError");
|
const accountEditError = document.querySelector("#accountEditError");
|
||||||
const cancelAccountEditBtn = document.querySelector("#cancelAccountEditBtn");
|
const cancelAccountEditBtn = document.querySelector("#cancelAccountEditBtn");
|
||||||
const newAccountBtn = document.querySelector("#newAccountBtn");
|
const newAccountBtn = document.querySelector("#newAccountBtn");
|
||||||
|
const teacherForm = document.querySelector("#teacherForm");
|
||||||
|
const teacherQuery = document.querySelector("#teacherQuery");
|
||||||
|
const teacherStatus = document.querySelector("#teacherStatus");
|
||||||
|
const teacherMeta = document.querySelector("#teacherMeta");
|
||||||
|
const teacherRows = document.querySelector("#teacherRows");
|
||||||
|
const teacherEditor = document.querySelector("#teacherEditor");
|
||||||
|
const teacherEditorTitle = document.querySelector("#teacherEditorTitle");
|
||||||
|
const teacherEditForm = document.querySelector("#teacherEditForm");
|
||||||
|
const teacherEditError = document.querySelector("#teacherEditError");
|
||||||
|
const cancelTeacherEditBtn = document.querySelector("#cancelTeacherEditBtn");
|
||||||
|
const newTeacherBtn = document.querySelector("#newTeacherBtn");
|
||||||
|
const editTeacherId = document.querySelector("#editTeacherId");
|
||||||
|
const editTeacherName = document.querySelector("#editTeacherName");
|
||||||
|
const editTeacherAlias = document.querySelector("#editTeacherAlias");
|
||||||
|
const editTeacherStatus = document.querySelector("#editTeacherStatus");
|
||||||
|
const editTeacherSubjects = document.querySelector("#editTeacherSubjects");
|
||||||
|
const editTeacherNote = document.querySelector("#editTeacherNote");
|
||||||
const editStudentId = document.querySelector("#editStudentId");
|
const editStudentId = document.querySelector("#editStudentId");
|
||||||
const editStudent = document.querySelector("#editStudent");
|
const editStudent = document.querySelector("#editStudent");
|
||||||
const editRemaining = document.querySelector("#editRemaining");
|
const editRemaining = document.querySelector("#editRemaining");
|
||||||
@@ -50,6 +68,7 @@ const summarySearchTeacher = document.querySelector("#summarySearchTeacher");
|
|||||||
const summarySearchSubject = document.querySelector("#summarySearchSubject");
|
const summarySearchSubject = document.querySelector("#summarySearchSubject");
|
||||||
const summarySearchDateFrom = document.querySelector("#summarySearchDateFrom");
|
const summarySearchDateFrom = document.querySelector("#summarySearchDateFrom");
|
||||||
const summarySearchDateTo = document.querySelector("#summarySearchDateTo");
|
const summarySearchDateTo = document.querySelector("#summarySearchDateTo");
|
||||||
|
const summarySearchMissingTime = document.querySelector("#summarySearchMissingTime");
|
||||||
const summarySearchMeta = document.querySelector("#summarySearchMeta");
|
const summarySearchMeta = document.querySelector("#summarySearchMeta");
|
||||||
const summarySearchRows = document.querySelector("#summarySearchRows");
|
const summarySearchRows = document.querySelector("#summarySearchRows");
|
||||||
const logOperation = document.querySelector("#logOperation");
|
const logOperation = document.querySelector("#logOperation");
|
||||||
@@ -71,6 +90,8 @@ const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus");
|
|||||||
|
|
||||||
let currentAccounts = [];
|
let currentAccounts = [];
|
||||||
let editingAccountId = "";
|
let editingAccountId = "";
|
||||||
|
let currentTeachers = [];
|
||||||
|
let editingTeacherId = "";
|
||||||
let currentSummaryReviews = [];
|
let currentSummaryReviews = [];
|
||||||
let activeSummaryReview = null;
|
let activeSummaryReview = null;
|
||||||
let summaryRegisterItemSeq = 0;
|
let summaryRegisterItemSeq = 0;
|
||||||
@@ -147,6 +168,7 @@ function setActiveTab(tabName) {
|
|||||||
panel.hidden = name !== tabName;
|
panel.hidden = name !== tabName;
|
||||||
});
|
});
|
||||||
if (tabName === "accounts") loadAccounts();
|
if (tabName === "accounts") loadAccounts();
|
||||||
|
if (tabName === "teachers") loadTeachers();
|
||||||
if (tabName === "reviews") loadReviews();
|
if (tabName === "reviews") loadReviews();
|
||||||
if (tabName === "summaries") loadSummaryReviews();
|
if (tabName === "summaries") loadSummaryReviews();
|
||||||
if (tabName === "summarySearch") loadSummarySearch();
|
if (tabName === "summarySearch") loadSummarySearch();
|
||||||
@@ -156,7 +178,8 @@ function setActiveTab(tabName) {
|
|||||||
async function loadAdminHealth() {
|
async function loadAdminHealth() {
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson("/api/account-health");
|
const data = await fetchJson("/api/account-health");
|
||||||
adminHealthText.textContent = `账户 ${data.accounts_count} 人;数据更新时间 ${fmtTime(data.accounts.mtime)}`;
|
const teacherText = data.teachers_count === undefined ? "" : `;老师 ${data.teachers_count} 位`;
|
||||||
|
adminHealthText.textContent = `账户 ${data.accounts_count} 人${teacherText};数据更新时间 ${fmtTime(data.accounts.mtime)}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
adminHealthText.textContent = `读取失败:${error.message}`;
|
adminHealthText.textContent = `读取失败:${error.message}`;
|
||||||
}
|
}
|
||||||
@@ -291,6 +314,130 @@ async function saveAccount(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTeacherSummary(rows) {
|
||||||
|
const activeCount = rows.filter((item) => item.status === "在岗").length;
|
||||||
|
const aliasCount = rows.filter((item) => String(item.alias || "").trim()).length;
|
||||||
|
teacherMeta.innerHTML = [
|
||||||
|
metric("当前结果", `${rows.length} 位`),
|
||||||
|
metric("在岗", activeCount),
|
||||||
|
metric("已配置别名", aliasCount),
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function teacherMatches(row) {
|
||||||
|
const keyword = teacherQuery.value.trim();
|
||||||
|
if (teacherStatus.value && row.status !== teacherStatus.value) return false;
|
||||||
|
if (!keyword) return true;
|
||||||
|
const haystack = [
|
||||||
|
row.teacher_id,
|
||||||
|
row.name,
|
||||||
|
row.alias,
|
||||||
|
row.display_name,
|
||||||
|
...(row.subjects || []),
|
||||||
|
row.status,
|
||||||
|
row.note,
|
||||||
|
].join("\n");
|
||||||
|
return haystack.includes(keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTeachers() {
|
||||||
|
teacherRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
|
||||||
|
try {
|
||||||
|
const data = await fetchJson("/api/admin/teachers");
|
||||||
|
currentTeachers = data.teachers || [];
|
||||||
|
const rows = currentTeachers.filter(teacherMatches);
|
||||||
|
renderTeacherSummary(rows);
|
||||||
|
teacherRows.innerHTML = rows
|
||||||
|
.map(
|
||||||
|
(row) => `<tr>
|
||||||
|
<td>${escapeHtml(row.name)}<br><small>${escapeHtml(row.teacher_id)}</small></td>
|
||||||
|
<td>${escapeHtml(row.alias || "")}</td>
|
||||||
|
<td>${escapeHtml((row.subjects || []).join("、"))}</td>
|
||||||
|
<td><span class="status ${row.status === "在岗" ? "normal" : "closed"}">${escapeHtml(row.status)}</span></td>
|
||||||
|
<td class="note-cell">${escapeHtml(row.note || "")}</td>
|
||||||
|
<td><button class="small-button teacher-edit" type="button" data-teacher-id="${escapeHtml(row.teacher_id)}">编辑</button></td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
if (!rows.length) {
|
||||||
|
teacherRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的老师档案</td></tr>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
teacherRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewTeacherEditor() {
|
||||||
|
editingTeacherId = "";
|
||||||
|
teacherEditorTitle.textContent = "新增老师";
|
||||||
|
editTeacherId.value = "";
|
||||||
|
editTeacherId.placeholder = "保存时自动生成";
|
||||||
|
editTeacherName.value = "";
|
||||||
|
editTeacherAlias.value = "";
|
||||||
|
editTeacherSubjects.value = "";
|
||||||
|
editTeacherStatus.value = "在岗";
|
||||||
|
editTeacherNote.value = "";
|
||||||
|
teacherEditError.hidden = true;
|
||||||
|
teacherEditor.hidden = false;
|
||||||
|
editTeacherName.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditTeacherEditor(teacherId) {
|
||||||
|
const teacher = currentTeachers.find((item) => item.teacher_id === teacherId);
|
||||||
|
if (!teacher) return;
|
||||||
|
editingTeacherId = teacher.teacher_id;
|
||||||
|
teacherEditorTitle.textContent = `编辑老师:${teacher.name}`;
|
||||||
|
editTeacherId.value = teacher.teacher_id;
|
||||||
|
editTeacherId.placeholder = "";
|
||||||
|
editTeacherName.value = teacher.name;
|
||||||
|
editTeacherAlias.value = teacher.alias || "";
|
||||||
|
editTeacherSubjects.value = (teacher.subjects || []).join("、");
|
||||||
|
editTeacherStatus.value = teacher.status;
|
||||||
|
editTeacherNote.value = teacher.note || "";
|
||||||
|
teacherEditError.hidden = true;
|
||||||
|
teacherEditor.hidden = false;
|
||||||
|
editTeacherName.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTeacherSubjects(value) {
|
||||||
|
return value
|
||||||
|
.split(/[、,,\s]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTeacherPayload() {
|
||||||
|
return {
|
||||||
|
teacher_id: editTeacherId.value.trim(),
|
||||||
|
name: editTeacherName.value.trim(),
|
||||||
|
alias: editTeacherAlias.value.trim(),
|
||||||
|
subjects: parseTeacherSubjects(editTeacherSubjects.value),
|
||||||
|
status: editTeacherStatus.value,
|
||||||
|
note: editTeacherNote.value.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTeacher(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const payload = buildTeacherPayload();
|
||||||
|
const url = editingTeacherId
|
||||||
|
? `/api/admin/teachers/${encodeURIComponent(editingTeacherId)}`
|
||||||
|
: "/api/admin/teachers";
|
||||||
|
const method = editingTeacherId ? "PUT" : "POST";
|
||||||
|
await fetchJson(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
teacherEditor.hidden = true;
|
||||||
|
await loadTeachers();
|
||||||
|
} catch (error) {
|
||||||
|
teacherEditError.textContent = error.message;
|
||||||
|
teacherEditError.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderReviewLine(line) {
|
function renderReviewLine(line) {
|
||||||
return `<code class="line-code">${escapeHtml(line)}</code>`;
|
return `<code class="line-code">${escapeHtml(line)}</code>`;
|
||||||
}
|
}
|
||||||
@@ -474,6 +621,7 @@ function summarySearchParams() {
|
|||||||
if (summarySearchSubject.value.trim()) params.set("subject", summarySearchSubject.value.trim());
|
if (summarySearchSubject.value.trim()) params.set("subject", summarySearchSubject.value.trim());
|
||||||
if (summarySearchDateFrom.value) params.set("date_from", summarySearchDateFrom.value);
|
if (summarySearchDateFrom.value) params.set("date_from", summarySearchDateFrom.value);
|
||||||
if (summarySearchDateTo.value) params.set("date_to", summarySearchDateTo.value);
|
if (summarySearchDateTo.value) params.set("date_to", summarySearchDateTo.value);
|
||||||
|
if (summarySearchMissingTime.value) params.set("missing_time", "true");
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,13 +633,25 @@ function renderSummarySearchBody(item) {
|
|||||||
<button class="small-button summary-toggle" type="button" data-target="${escapeHtml(fullId)}" ${hasFull ? "" : "hidden"}>展开</button>`;
|
<button class="small-button summary-toggle" type="button" data-target="${escapeHtml(fullId)}" ${hasFull ? "" : "hidden"}>展开</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSummaryActions(item) {
|
||||||
|
if (item.time_range) {
|
||||||
|
return `<span class="muted">已完整</span>`;
|
||||||
|
}
|
||||||
|
const inputId = `summary-time-${item.id}`;
|
||||||
|
return `<div class="summary-time-actions">
|
||||||
|
<input id="${escapeHtml(inputId)}" class="summary-time-input" data-summary-time-input="${escapeHtml(item.id)}" autocomplete="off" placeholder="08:00-10:00" />
|
||||||
|
<button class="small-button summary-time-save" type="button" data-summary-id="${escapeHtml(item.id)}">补齐时间</button>
|
||||||
|
<button class="small-button summary-delete" type="button" data-summary-id="${escapeHtml(item.id)}">删除</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderMatchedFields(item) {
|
function renderMatchedFields(item) {
|
||||||
const fields = Array.isArray(item.matched_fields) ? item.matched_fields : [];
|
const fields = Array.isArray(item.matched_fields) ? item.matched_fields : [];
|
||||||
return fields.length ? `<br><small>命中:${fields.map(escapeHtml).join("、")}</small>` : "";
|
return fields.length ? `<br><small>命中:${fields.map(escapeHtml).join("、")}</small>` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSummarySearch() {
|
async function loadSummarySearch() {
|
||||||
summarySearchRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
|
summarySearchRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`);
|
const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`);
|
||||||
summarySearchMeta.innerHTML = [
|
summarySearchMeta.innerHTML = [
|
||||||
@@ -501,21 +661,38 @@ async function loadSummarySearch() {
|
|||||||
summarySearchRows.innerHTML = data.items
|
summarySearchRows.innerHTML = data.items
|
||||||
.map((item) => `<tr>
|
.map((item) => `<tr>
|
||||||
<td>${escapeHtml(item.date_iso || "未识别")}</td>
|
<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.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
|
||||||
<td>${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td>
|
<td>${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td>
|
||||||
<td>${escapeHtml(item.title || "")}${renderMatchedFields(item)}</td>
|
<td>${escapeHtml(item.title || "")}${renderMatchedFields(item)}</td>
|
||||||
<td>${renderSummarySearchBody(item)}</td>
|
<td>${renderSummarySearchBody(item)}</td>
|
||||||
<td><code class="line-code">${escapeHtml(item.relative_path || "")}</code></td>
|
<td>${renderSummaryActions(item)}</td>
|
||||||
</tr>`)
|
</tr>`)
|
||||||
.join("");
|
.join("");
|
||||||
if (!data.items.length) {
|
if (!data.items.length) {
|
||||||
summarySearchRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的课程小结</td></tr>`;
|
summarySearchRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结</td></tr>`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
summarySearchRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
summarySearchRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateCourseSummaryTime(summaryId, timeRange) {
|
||||||
|
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/time`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ time_range: timeRange }),
|
||||||
|
});
|
||||||
|
await loadSummarySearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCourseSummary(summaryId) {
|
||||||
|
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
await loadSummarySearch();
|
||||||
|
}
|
||||||
|
|
||||||
function renderLogDetail(item) {
|
function renderLogDetail(item) {
|
||||||
const details = [];
|
const details = [];
|
||||||
if (item.source_id) details.push(`来源:${item.source_id}`);
|
if (item.source_id) details.push(`来源:${item.source_id}`);
|
||||||
@@ -711,6 +888,21 @@ accountRows.addEventListener("click", (event) => {
|
|||||||
if (!button) return;
|
if (!button) return;
|
||||||
openEditAccountEditor(button.dataset.studentId);
|
openEditAccountEditor(button.dataset.studentId);
|
||||||
});
|
});
|
||||||
|
teacherForm.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
loadTeachers();
|
||||||
|
});
|
||||||
|
teacherStatus.addEventListener("change", loadTeachers);
|
||||||
|
newTeacherBtn.addEventListener("click", openNewTeacherEditor);
|
||||||
|
cancelTeacherEditBtn.addEventListener("click", () => {
|
||||||
|
teacherEditor.hidden = true;
|
||||||
|
});
|
||||||
|
teacherEditForm.addEventListener("submit", saveTeacher);
|
||||||
|
teacherRows.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest(".teacher-edit");
|
||||||
|
if (!button) return;
|
||||||
|
openEditTeacherEditor(button.dataset.teacherId);
|
||||||
|
});
|
||||||
reviewStatus.addEventListener("change", loadReviews);
|
reviewStatus.addEventListener("change", loadReviews);
|
||||||
reviewRows.addEventListener("click", (event) => {
|
reviewRows.addEventListener("click", (event) => {
|
||||||
const approve = event.target.closest(".review-approve");
|
const approve = event.target.closest(".review-approve");
|
||||||
@@ -750,8 +942,26 @@ summarySearchForm.addEventListener("submit", (event) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
loadSummarySearch();
|
loadSummarySearch();
|
||||||
});
|
});
|
||||||
|
summarySearchMissingTime.addEventListener("change", loadSummarySearch);
|
||||||
summarySearchRows.addEventListener("click", (event) => {
|
summarySearchRows.addEventListener("click", (event) => {
|
||||||
const button = event.target.closest(".summary-toggle");
|
const button = event.target.closest(".summary-toggle");
|
||||||
|
const saveTime = event.target.closest(".summary-time-save");
|
||||||
|
const deleteSummary = event.target.closest(".summary-delete");
|
||||||
|
if (saveTime) {
|
||||||
|
const input = summarySearchRows.querySelector(`[data-summary-time-input='${saveTime.dataset.summaryId}']`);
|
||||||
|
const timeRange = input ? input.value.trim() : "";
|
||||||
|
if (!timeRange) {
|
||||||
|
alert("请输入时间段");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateCourseSummaryTime(saveTime.dataset.summaryId, timeRange).catch((error) => alert(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (deleteSummary) {
|
||||||
|
if (!confirm("确认删除这条课程小结?")) return;
|
||||||
|
deleteCourseSummary(deleteSummary.dataset.summaryId).catch((error) => alert(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
const target = document.getElementById(button.dataset.target);
|
const target = document.getElementById(button.dataset.target);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|||||||
+44
-7
@@ -37,6 +37,7 @@ let currentRecords = [];
|
|||||||
let recordSort = { date: "asc", time: "asc" };
|
let recordSort = { date: "asc", time: "asc" };
|
||||||
let currentRecordOrder = [];
|
let currentRecordOrder = [];
|
||||||
let correctedRecords = new Map();
|
let correctedRecords = new Map();
|
||||||
|
let expandedSummaryRecords = new Set();
|
||||||
let activeCorrectionKey = "";
|
let activeCorrectionKey = "";
|
||||||
let activeDeleteKey = "";
|
let activeDeleteKey = "";
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ function metric(label, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeRecordKey(row, index) {
|
function makeRecordKey(row, index) {
|
||||||
return JSON.stringify([index, row.date, row.time, row.student, row.duration, row.teacher, row.subject]);
|
return row.record_id || JSON.stringify([index, row.date, row.time, row.student, row.duration, row.teacher, row.subject]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRecordLine(row) {
|
function buildRecordLine(row) {
|
||||||
@@ -182,6 +183,8 @@ function renderRecordRow(row) {
|
|||||||
const correctedClass = corrected ? " corrected-row" : "";
|
const correctedClass = corrected ? " corrected-row" : "";
|
||||||
const actionLabel = corrected ? "编辑" : "纠错";
|
const actionLabel = corrected ? "编辑" : "纠错";
|
||||||
const badge = corrected ? '<span class="correction-badge">已修改</span>' : "";
|
const badge = corrected ? '<span class="correction-badge">已修改</span>' : "";
|
||||||
|
const summaryCount = Number(displayRow.summary_count || 0);
|
||||||
|
const summaryExpanded = expandedSummaryRecords.has(key);
|
||||||
return `<tr class="record-row${correctedClass}">
|
return `<tr class="record-row${correctedClass}">
|
||||||
<td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
|
<td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
|
||||||
<td>${escapeHtml(displayRow.time)}</td>
|
<td>${escapeHtml(displayRow.time)}</td>
|
||||||
@@ -191,6 +194,7 @@ function renderRecordRow(row) {
|
|||||||
<td class="num">${escapeHtml(displayRow.duration)}</td>
|
<td class="num">${escapeHtml(displayRow.duration)}</td>
|
||||||
<td class="record-action-cell">
|
<td class="record-action-cell">
|
||||||
<div class="record-actions">
|
<div class="record-actions">
|
||||||
|
<button class="small-button summary-toggle" type="button" data-record-key="${escapeHtml(key)}" ${summaryCount > 0 ? "" : "disabled"}>${summaryExpanded ? "收起小结" : "课程小结"}</button>
|
||||||
<button class="small-button correction-edit" type="button" data-record-key="${escapeHtml(key)}">${actionLabel}</button>
|
<button class="small-button correction-edit" type="button" data-record-key="${escapeHtml(key)}">${actionLabel}</button>
|
||||||
<button class="small-button record-delete" type="button" data-record-key="${escapeHtml(key)}">删除</button>
|
<button class="small-button record-delete" type="button" data-record-key="${escapeHtml(key)}">删除</button>
|
||||||
${badge}
|
${badge}
|
||||||
@@ -199,6 +203,15 @@ function renderRecordRow(row) {
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSummaryEntry(summary) {
|
||||||
|
const title = summary.title || "课程小结";
|
||||||
|
const time = summary.time_range ? ` · ${summary.time_range}` : "";
|
||||||
|
return `<div class="record-summary-item">
|
||||||
|
<div class="record-summary-title">${escapeHtml(title)}${escapeHtml(time)}</div>
|
||||||
|
<div class="summary-body">${escapeHtml(summary.body || "暂无正文")}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderGroupedRecords(records) {
|
function renderGroupedRecords(records) {
|
||||||
currentRecordOrder = [];
|
currentRecordOrder = [];
|
||||||
return groupRecordsByTeacher(records)
|
return groupRecordsByTeacher(records)
|
||||||
@@ -213,13 +226,28 @@ function renderGroupedRecords(records) {
|
|||||||
</tr>${sortRecordsForDisplay(group.records)
|
</tr>${sortRecordsForDisplay(group.records)
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
currentRecordOrder.push(row._recordKey);
|
currentRecordOrder.push(row._recordKey);
|
||||||
return renderRecordRow(row);
|
const summaryCount = Number(row.summary_count || 0);
|
||||||
|
const summaryRow = summaryCount
|
||||||
|
? `<tr class="summary-collapse-row" data-summary-row="${escapeHtml(row._recordKey)}" ${expandedSummaryRecords.has(row._recordKey) ? "" : "hidden"}>
|
||||||
|
<td colspan="7">${(row.summaries || []).map(renderSummaryEntry).join("")}</td>
|
||||||
|
</tr>`
|
||||||
|
: "";
|
||||||
|
return `${renderRecordRow(row)}${summaryRow}`;
|
||||||
})
|
})
|
||||||
.join("")}`,
|
.join("")}`,
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSummaryRow(key) {
|
||||||
|
if (expandedSummaryRecords.has(key)) {
|
||||||
|
expandedSummaryRecords.delete(key);
|
||||||
|
} else {
|
||||||
|
expandedSummaryRecords.add(key);
|
||||||
|
}
|
||||||
|
renderCurrentRecords();
|
||||||
|
}
|
||||||
|
|
||||||
function statusClass(status) {
|
function statusClass(status) {
|
||||||
if (status === "欠费") return "debt";
|
if (status === "欠费") return "debt";
|
||||||
if (status === "预警") return "warning";
|
if (status === "预警") return "warning";
|
||||||
@@ -279,6 +307,7 @@ function updateCorrectionToolbar(message = "", isError = false) {
|
|||||||
function resetCorrections() {
|
function resetCorrections() {
|
||||||
correctedRecords = new Map();
|
correctedRecords = new Map();
|
||||||
currentRecordOrder = [];
|
currentRecordOrder = [];
|
||||||
|
expandedSummaryRecords = new Set();
|
||||||
activeCorrectionKey = "";
|
activeCorrectionKey = "";
|
||||||
updateCorrectionToolbar();
|
updateCorrectionToolbar();
|
||||||
}
|
}
|
||||||
@@ -421,7 +450,7 @@ async function submitDeleteRecord() {
|
|||||||
const data = await fetchJson("/api/deletions", {
|
const data = await fetchJson("/api/deletions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ items: [{ original_line: buildRecordLine(original) }] }),
|
body: JSON.stringify({ items: [{ record_id: original.record_id }] }),
|
||||||
});
|
});
|
||||||
closeDeleteDialog();
|
closeDeleteDialog();
|
||||||
updateCorrectionToolbar(`已提交 ${data.submitted} 条删除审核`);
|
updateCorrectionToolbar(`已提交 ${data.submitted} 条删除审核`);
|
||||||
@@ -488,12 +517,15 @@ async function copyCorrectedRecords() {
|
|||||||
async function submitCorrectedRecords() {
|
async function submitCorrectedRecords() {
|
||||||
const items = currentRecordOrder
|
const items = currentRecordOrder
|
||||||
.map((key) => {
|
.map((key) => {
|
||||||
const original = findCurrentRecord(key);
|
|
||||||
const corrected = correctedRecords.get(key);
|
const corrected = correctedRecords.get(key);
|
||||||
if (!original || !corrected) return null;
|
if (!corrected) return null;
|
||||||
return {
|
return {
|
||||||
original_line: buildRecordLine(original),
|
record_id: corrected.record_id,
|
||||||
corrected_line: buildRecordLine(corrected),
|
date: corrected.date,
|
||||||
|
time: corrected.time,
|
||||||
|
student: corrected.student,
|
||||||
|
teacher: corrected.teacher,
|
||||||
|
subject: corrected.subject,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -597,8 +629,13 @@ document.querySelectorAll("[data-query]").forEach((button) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
recordRows.addEventListener("click", (event) => {
|
recordRows.addEventListener("click", (event) => {
|
||||||
|
const summaryButton = event.target.closest(".summary-toggle");
|
||||||
const editButton = event.target.closest(".correction-edit");
|
const editButton = event.target.closest(".correction-edit");
|
||||||
const deleteButton = event.target.closest(".record-delete");
|
const deleteButton = event.target.closest(".record-delete");
|
||||||
|
if (summaryButton) {
|
||||||
|
toggleSummaryRow(summaryButton.dataset.recordKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (editButton) {
|
if (editButton) {
|
||||||
openCorrectionDialog(editButton.dataset.recordKey);
|
openCorrectionDialog(editButton.dataset.recordKey);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>新时空教务管理系统</title>
|
<title>新时空教务管理系统</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=20260613-date-time-sort" />
|
<link rel="stylesheet" href="/static/styles.css?v=20260615-record-summary-toggle" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -140,6 +140,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js?v=20260615-delete-review"></script>
|
<script src="/static/app.js?v=20260615-record-summary-toggle"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-search-row {
|
.summary-search-row {
|
||||||
grid-template-columns: minmax(180px, 1.4fr) repeat(5, minmax(110px, 0.8fr)) 88px;
|
grid-template-columns: minmax(180px, 1.4fr) repeat(6, minmax(100px, 0.8fr)) 88px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
@@ -630,6 +630,49 @@ td {
|
|||||||
color: var(--accent-strong);
|
color: var(--accent-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.small-button:disabled {
|
||||||
|
border-color: #e4e7ec;
|
||||||
|
background: #f2f4f7;
|
||||||
|
color: #98a2b3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-button:disabled:hover {
|
||||||
|
border-color: #e4e7ec;
|
||||||
|
color: #98a2b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-collapse-row td {
|
||||||
|
padding: 0;
|
||||||
|
background: #fbfcfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-summary-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-summary-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-time-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-time-input {
|
||||||
|
width: 120px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.correction-badge {
|
.correction-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+2
-2
@@ -100,13 +100,13 @@ def create_data_backup(
|
|||||||
target_path = backup_dir / source_path.name
|
target_path = backup_dir / source_path.name
|
||||||
atomic_write_text(target_path, content)
|
atomic_write_text(target_path, content)
|
||||||
apply_source_permissions(target_path, source_path)
|
apply_source_permissions(target_path, source_path)
|
||||||
source_stat = source_path.stat()
|
source_stat = source_path.stat() if source_path.exists() else None
|
||||||
metadata["files"].append(
|
metadata["files"].append(
|
||||||
{
|
{
|
||||||
"name": source_path.name,
|
"name": source_path.name,
|
||||||
"source_path": str(source_path),
|
"source_path": str(source_path),
|
||||||
"size": len(content.encode("utf-8")),
|
"size": len(content.encode("utf-8")),
|
||||||
"mtime": source_stat.st_mtime,
|
"mtime": source_stat.st_mtime if source_stat is not None else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
metadata_path = backup_dir / "metadata.json"
|
metadata_path = backup_dir / "metadata.json"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ services:
|
|||||||
TZ: ${TZ:-Asia/Shanghai}
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
CLASSNOTES_PATH: ${CLASSNOTES_PATH:-/data/classnotes.txt}
|
CLASSNOTES_PATH: ${CLASSNOTES_PATH:-/data/classnotes.txt}
|
||||||
ACCOUNTS_PATH: ${ACCOUNTS_PATH:-/data/学生课时账户.md}
|
ACCOUNTS_PATH: ${ACCOUNTS_PATH:-/data/学生课时账户.md}
|
||||||
|
TEACHERS_PATH: ${TEACHERS_PATH:-/data/教师档案.md}
|
||||||
ADMIN_TASKS_PATH: ${ADMIN_TASKS_PATH:-/data/admin_tasks.json}
|
ADMIN_TASKS_PATH: ${ADMIN_TASKS_PATH:-/data/admin_tasks.json}
|
||||||
COURSE_SUMMARIES_ROOT: ${COURSE_SUMMARIES_ROOT:-/data/course_summaries}
|
COURSE_SUMMARIES_ROOT: ${COURSE_SUMMARIES_ROOT:-/data/course_summaries}
|
||||||
COURSE_SUMMARY_STATE_PATH: ${COURSE_SUMMARY_STATE_PATH:-/data/course_summary_state.json}
|
COURSE_SUMMARY_STATE_PATH: ${COURSE_SUMMARY_STATE_PATH:-/data/course_summary_state.json}
|
||||||
|
|||||||
Reference in New Issue
Block a user