Compare commits

..

22 Commits

Author SHA1 Message Date
Codex aa6f4a0329 优化课程小结查询展开详情 2026-06-18 09:24:13 +08:00
Codex 59fcee72bb 标记缺失课程小结记录 2026-06-18 04:24:32 +08:00
Codex e5e01b9dbb 优化操作记录详情展开 2026-06-18 04:06:06 +08:00
Codex 9d9500ed36 优化补充课程小结按钮布局 2026-06-18 04:05:16 +08:00
Codex 2c7dfad2b6 完善维护入口并处理课程小结审核 2026-06-18 03:39:25 +08:00
Codex 72184004e3 修复课程小结补充流程 2026-06-18 03:19:34 +08:00
Codex 89d592b0bd 修复登记预览与审批逻辑 2026-06-18 02:30:12 +08:00
Codex baa54def66 精确撤回上课记录并改为脚本登记 2026-06-18 00:58:41 +08:00
Codex 5163714853 调整登记预览解析顺序 2026-06-17 21:23:19 +08:00
Codex ccdcb83b60 固定登记模型并规范老师姓名 2026-06-17 21:14:57 +08:00
Codex 3128c98d58 收紧学科类目范围 2026-06-17 20:47:49 +08:00
Codex 77b36a8dd8 加快登记预览并结构化补项 2026-06-17 20:47:29 +08:00
Codex cc81f82139 调整课程小结登记审核逻辑 2026-06-17 10:52:49 +08:00
Codex 25257b0a83 修正课程小结按钮对齐 2026-06-16 20:12:33 +08:00
Codex 25f32cc44b 完善课程小结审核操作 2026-06-16 20:03:20 +08:00
Codex 5f6bf376b2 新增重复课程小结审核 2026-06-16 17:02:53 +08:00
Codex 69644b4ae8 Add admin operation log rollback 2026-06-16 16:11:20 +08:00
Codex 4875a0ee77 统一汇总时间展示格式 2026-06-16 13:56:30 +08:00
Codex 6c68eb7152 接入 AI 登记预览与追问 2026-06-16 13:47:03 +08:00
Codex 1a9235408c 修正课时时长显示和归一化 2026-06-16 05:09:55 +08:00
Codex c1d3d19dfc 记录原始数据变更操作日志 2026-06-16 04:49:31 +08:00
Codex a1a8466f6b fix: show payment records on separate lines 2026-06-16 04:28:19 +08:00
20 changed files with 3519 additions and 377 deletions
+40
View File
@@ -0,0 +1,40 @@
SHELL := /bin/bash
APP_DIR := app
APP_MAKE := $(MAKE) -C $(APP_DIR) --no-print-directory
COMPOSE := docker compose -f $(APP_DIR)/docker-compose.yml
.PHONY: check smoke data-hash build up ps health deploy logs install-gitea-backup compose-config
check:
$(APP_MAKE) check
smoke:
$(APP_MAKE) smoke
data-hash:
$(APP_MAKE) data-hash
build:
$(APP_MAKE) build
up:
$(APP_MAKE) up
ps:
$(APP_MAKE) ps
health:
$(APP_MAKE) health
deploy:
$(APP_MAKE) deploy
logs:
$(APP_MAKE) logs
install-gitea-backup:
$(APP_MAKE) install-gitea-backup
compose-config:
$(COMPOSE) config
+2
View File
@@ -2,6 +2,8 @@
新时空教务管理系统长期维护时,目标是每次更新都能做到可检查、可部署、可回滚,并且不误改业务数据。 新时空教务管理系统长期维护时,目标是每次更新都能做到可检查、可部署、可回滚,并且不误改业务数据。
以下命令默认在仓库根目录 `/root/新时空教务管理系统` 执行。根目录 `Makefile` 会转发到 `app/` 下的实际应用配置;不要在根目录直接运行裸 `docker compose`,需要直接调用时使用 `docker compose -f app/docker-compose.yml <命令>`
## 日常更新流程 ## 日常更新流程
1. 查看当前改动: 1. 查看当前改动:
+16
View File
@@ -15,6 +15,22 @@
- `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。 - `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。
- `launchd/com.xsk.education-management.sync.plist.template`LaunchAgent 模板。 - `launchd/com.xsk.education-management.sync.plist.template`LaunchAgent 模板。
## 维护入口
推荐在仓库根目录 `/root/新时空教务管理系统` 执行日常维护命令:
```bash
make check
make smoke
make deploy
```
根目录 `Makefile` 会自动转发到 `app/` 下的实际应用配置,避免在错误目录执行 `docker compose``make deploy`。需要直接运行 Docker Compose 时,使用:
```bash
docker compose -f app/docker-compose.yml <命令>
```
## 后端结构 ## 后端结构
- `app/main.py`:FastAPI 应用入口,只负责注册路由和全局异常处理。 - `app/main.py`:FastAPI 应用入口,只负责注册路由和全局异常处理。
+581
View File
@@ -0,0 +1,581 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
import re
import secrets
from typing import Any
from .config import ACCOUNTS_PATH
from .data import (
SUBJECTS,
canonical_teacher_name,
class_record_to_line,
course_summary_to_class_record_line,
extract_course_summary_from_text,
normalize_course_summary,
normalize_lines,
parse_class_record_line,
parse_payment_line,
read_accounts,
)
REGISTER_TYPES = {"class_record", "payment", "course_summary"}
FIELD_LABELS = {
"class_record": {
"student": "学生",
"date": "日期",
"time": "时间",
"teacher": "老师",
"subject": "科目",
"duration": "时长",
},
"payment": {
"student": "学生",
"date": "缴费日期",
"hours": "课时数",
},
"course_summary": {
"student": "学生",
"date": "日期",
"time": "时间",
"teacher": "老师",
"subject": "科目",
"body": "小结正文",
},
}
REQUIRED_FIELDS = {
"class_record": ["student", "date", "time", "teacher", "subject"],
"payment": ["student", "date", "hours"],
"course_summary": ["student", "date", "time", "teacher", "subject", "body"],
}
DATE_RE = re.compile(r"(?P<y>\d{4})[./年-]\s*(?P<m>\d{1,2})[./月-]\s*(?P<d>\d{1,2})")
MONTH_DAY_RE = re.compile(r"(?<!\d)(?P<m>\d{1,2})\s*[月./-]\s*(?P<d>\d{1,2})\s*(?:日|号)?")
TIME_RANGE_FLEX_RE = re.compile(
r"(?P<sh>\d{1,2})\s*(?:[::点.])\s*(?P<sm>\d{1,2})?\s*"
r"(?:-||—||~|至|到)\s*"
r"(?P<eh>\d{1,2})\s*(?:[::点.])\s*(?P<em>\d{1,2})?"
)
TIME_RANGE_HOUR_RE = re.compile(
r"(?<![\d./-])(?P<sh>\d{1,2})\s*(?:-||—||~|至|到)\s*(?P<eh>\d{1,2})(?![\d./-])"
)
TIME_RANGE_COMPACT_RE = re.compile(
r"(?<!\d)(?P<start>\d{3,4})\s*(?:-||—||~|至|到)\s*(?P<end>\d{3,4})(?!\d)"
)
TEACHER_RE = re.compile(r"(?P<teacher>[\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)")
HOURS_RE = re.compile(r"(?P<hours>\d+(?:\.\d+)?)\s*(?:课时|小时)")
MAX_SESSION_AGE_SECONDS = 30 * 60
MAX_SESSIONS = 100
_SESSIONS: dict[str, dict[str, Any]] = {}
def _now() -> datetime:
return datetime.now()
def _cleanup_sessions() -> None:
cutoff = _now() - timedelta(seconds=MAX_SESSION_AGE_SECONDS)
stale = [
session_id
for session_id, session in _SESSIONS.items()
if session.get("updated_at", _now()) < cutoff
]
for session_id in stale:
_SESSIONS.pop(session_id, None)
if len(_SESSIONS) <= MAX_SESSIONS:
return
ordered = sorted(_SESSIONS.items(), key=lambda item: item[1].get("updated_at", _now()))
for session_id, _session in ordered[: len(_SESSIONS) - MAX_SESSIONS]:
_SESSIONS.pop(session_id, None)
def _session_for(conversation_id: str | None, register_type: str, text: str) -> tuple[str, dict[str, Any]]:
_cleanup_sessions()
if conversation_id and conversation_id in _SESSIONS:
session = _SESSIONS[conversation_id]
else:
conversation_id = secrets.token_urlsafe(16)
session = {"type": register_type, "text": text, "answers": {}, "created_at": _now()}
_SESSIONS[conversation_id] = session
session["type"] = register_type
if text:
session["text"] = text
session["updated_at"] = _now()
return conversation_id, session
def normalize_register_type(value: str) -> str:
text = str(value or "").strip()
aliases = {
"class": "class_record",
"class_records": "class_record",
"class-records": "class_record",
"上课记录": "class_record",
"payments": "payment",
"缴费": "payment",
"course-summaries": "course_summary",
"course_summaries": "course_summary",
"课程小结": "course_summary",
}
register_type = aliases.get(text, text)
if register_type not in REGISTER_TYPES:
raise ValueError("不支持的登记类型")
return register_type
def collect_input_lines(
text: str | None = None,
lines: list[str] | None = None,
register_type: str = "",
) -> list[str]:
if lines is not None:
return normalize_lines(lines=lines)
raw = str(text or "")
if register_type in {"class_record", "payment"}:
return normalize_lines(lines=raw.splitlines())
if "\n\n" in raw:
chunks = [item.strip() for item in re.split(r"\n\s*\n", raw) if item.strip()]
if chunks:
return chunks
return normalize_lines(line=raw)
def _field_labels(register_type: str) -> dict[str, str]:
return FIELD_LABELS.get(register_type, {})
def _questions_for_missing(register_type: str, missing_fields: list[str]) -> list[str]:
labels = _field_labels(register_type)
return [f"请补充{labels.get(field, field)}" for field in missing_fields]
def _known_students() -> list[str]:
try:
return [account.student for account in read_accounts(ACCOUNTS_PATH)]
except Exception:
return []
def _normalize_date(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
match = DATE_RE.search(text)
if match:
year = int(match.group("y"))
month = int(match.group("m"))
day = int(match.group("d"))
datetime(year, month, day)
return f"{year:04d}-{month:02d}-{day:02d}"
match = MONTH_DAY_RE.search(text)
if match:
year = date.today().year
month = int(match.group("m"))
day = int(match.group("d"))
datetime(year, month, day)
return f"{year:04d}-{month:02d}-{day:02d}"
return ""
def _record_date(value: str) -> str:
return value.replace("-", ".")
def _weekday(value: str) -> str:
weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
return weekdays[datetime.strptime(value, "%Y-%m-%d").date().weekday()]
def _normalize_time_range(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
standard = re.search(r"\d{1,2}:\d{2}\s*(?:-||—||~|至|到)\s*\d{1,2}:\d{2}", text)
if standard:
raw = re.sub(r"\s*(?:|—||~|至|到)\s*", "-", standard.group(0))
raw = re.sub(r"\s*-\s*", "-", raw)
start, end = raw.split("-", 1)
sh, sm = [int(part) for part in start.split(":", 1)]
eh, em = [int(part) for part in end.split(":", 1)]
else:
match = TIME_RANGE_FLEX_RE.search(text)
if match:
sh = int(match.group("sh"))
sm = int(match.group("sm") or 0)
eh = int(match.group("eh"))
em = int(match.group("em") or 0)
else:
compact = TIME_RANGE_COMPACT_RE.search(text)
if compact:
start = compact.group("start").zfill(4)
end = compact.group("end").zfill(4)
sh = int(start[:-2])
sm = int(start[-2:])
eh = int(end[:-2])
em = int(end[-2:])
else:
hour_only = TIME_RANGE_HOUR_RE.search(text)
if not hour_only:
return ""
sh = int(hour_only.group("sh"))
sm = 0
eh = int(hour_only.group("eh"))
em = 0
if sh > 23 or eh > 23 or sm > 59 or em > 59:
return ""
if eh * 60 + em <= sh * 60 + sm:
return ""
return f"{sh:02d}:{sm:02d}-{eh:02d}:{em:02d}"
def _duration_from_time_range(time_range: str) -> str:
start, end = time_range.split("-", 1)
sh, sm = [int(part) for part in start.split(":", 1)]
eh, em = [int(part) for part in end.split(":", 1)]
minutes = eh * 60 + em - sh * 60 - sm
return f"{minutes // 60}小时{minutes % 60}"
def _duration_from_text(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
if re.fullmatch(r"\d+小时\d+分", text):
return text
match = re.search(r"(?P<h>\d+)\s*小时\s*(?P<m>\d+)?\s*分?", text)
if match:
return f"{int(match.group('h'))}小时{int(match.group('m') or 0)}"
match = re.search(r"(?P<hours>\d+(?:\.\d+)?)\s*(?:课时|小时)", text)
if match:
total = int(round(float(match.group("hours")) * 60))
return f"{total // 60}小时{total % 60}"
return ""
def _extract_student(text: str) -> str:
match = re.search(r"(?:学生|学员)[:\s]*(?P<student>[\u4e00-\u9fa5A-Za-z0-9]{2,8})", text)
if match:
return match.group("student")
for student in _known_students():
if student and student in text:
return student
match = re.search(r"^\s*(?P<student>[\u4e00-\u9fa5]{2,4})(?=\s|[,,。;;:]|\d)", text)
return match.group("student") if match else ""
def _extract_class_record_student(text: str, teacher: str, subject: str) -> str:
student = _extract_student(text)
if student:
return student
remainder = text
for pattern in (DATE_RE, MONTH_DAY_RE, TIME_RANGE_FLEX_RE, TIME_RANGE_COMPACT_RE, TIME_RANGE_HOUR_RE):
remainder = pattern.sub(" ", remainder)
remainder = re.sub(r"\d{1,2}:\d{2}\s*(?:-||—||~|至|到)\s*\d{1,2}:\d{2}", " ", remainder)
remainder = re.sub(r"星期[一二三四五六日]", " ", remainder)
remainder = re.sub(r"\d+(?:\.\d+)?\s*(?:课时|小时)", " ", remainder)
remainder = re.sub(r"\d+\s*小时\s*\d*\s*分?", " ", remainder)
if teacher:
remainder = re.sub(rf"{re.escape(teacher)}(?:老师|教师)?", " ", remainder)
if subject:
remainder = remainder.replace(subject, " ")
remainder = re.sub(r"(?:学生|学员|老师|教师|科目|课程|时间|日期|上课)", " ", remainder)
for candidate in re.findall(r"[\u4e00-\u9fa5]{2,4}", remainder):
if candidate not in SUBJECTS and candidate not in {"老师", "教师", "学生", "学员"}:
return candidate
return ""
def _extract_teacher(text: str) -> str:
match = TEACHER_RE.search(text)
return canonical_teacher_name(match.group("teacher")) if match else ""
def _normalize_teacher_hint(value: object) -> str:
text = canonical_teacher_name(str(value or "").strip())
return re.sub(r"^以下都是", "", text).strip()
def _extract_subject(text: str) -> str:
for subject in SUBJECTS:
if subject in text:
return subject
match = re.search(r"(?:科目|课程)[:\s]*(?P<subject>[\u4e00-\u9fa5A-Za-z0-9]{1,12})", text)
return match.group("subject") if match else ""
def _merge_answers(fields: dict[str, str], answers: dict[str, str]) -> dict[str, str]:
merged = dict(fields)
for key, value in answers.items():
text = str(value or "").strip()
if not text:
continue
if key == "followup":
continue
merged[key] = text
return merged
def _missing_fields(register_type: str, fields: dict[str, str]) -> list[str]:
return [field for field in REQUIRED_FIELDS[register_type] if not str(fields.get(field) or "").strip()]
def _needs_info_response(
register_type: str,
fields: dict[str, str],
missing_fields: list[str],
*,
summary: str = "",
timed_out: bool = False,
error: str = "",
draft_line: str = "",
) -> dict[str, Any]:
return {
"status": "needs_info",
"standard_lines": [],
"questions": _questions_for_missing(register_type, missing_fields) or [_question_for_error(register_type, error or "信息不完整")],
"summary": summary or "请补齐缺失字段后再次生成预览",
"ai_used": False,
"fields": fields,
"missing_fields": missing_fields,
"field_labels": _field_labels(register_type),
"timed_out": timed_out,
"recognition_source": "partial_local",
"error": error,
"draft_line": draft_line,
}
def _standard_from_fields(register_type: str, fields: dict[str, str]) -> str:
if register_type == "class_record":
date_value = _normalize_date(fields.get("date"))
time_range = _normalize_time_range(fields.get("time"))
duration = _duration_from_text(fields.get("duration")) or _duration_from_time_range(time_range)
return (
f"{_record_date(date_value)}-{_weekday(date_value)}-{time_range}-"
f"{fields['student']}-{duration}-{fields['teacher']}-{fields['subject']}"
)
if register_type == "payment":
date_value = _normalize_date(fields.get("date"))
hours = str(fields.get("hours") or "").strip()
hours = re.sub(r"\s*(?:课时|小时)$", "", hours)
return f"{fields['student']}-{date_value}:{hours}"
date_value = _normalize_date(fields.get("date"))
time_range = _normalize_time_range(fields.get("time"))
return "\n".join(
[
f"学生:{fields['student']}",
f"日期:{date_value}",
f"时间:{time_range}",
f"老师:{fields['teacher']}",
f"科目:{fields['subject']}",
"小结:",
str(fields.get("body") or "").strip(),
]
)
def _draft_from_fields(register_type: str, fields: dict[str, str]) -> str:
if register_type == "class_record":
student = fields.get("student") or "【学生】"
date_value = _record_date(_normalize_date(fields.get("date"))) if _normalize_date(fields.get("date")) else "【日期】"
time_range = _normalize_time_range(fields.get("time")) or "【时间】"
teacher = _normalize_teacher_hint(fields.get("teacher")) or "【老师】"
duration = _duration_from_text(fields.get("duration")) or "【时长】"
subject = fields.get("subject") or "【科目】"
return f"{date_value}-{_weekday(_normalize_date(fields.get('date'))) if _normalize_date(fields.get('date')) else '星期?'}-{time_range}-{student}-{duration}-{teacher}-{subject}"
if register_type == "payment":
student = fields.get("student") or "【学生】"
date_value = _normalize_date(fields.get("date")) or "【日期】"
hours = str(fields.get("hours") or "").strip() or "【课时数】"
return f"{student}-{date_value}:{hours}"
student = fields.get("student") or "【学生】"
date_value = _normalize_date(fields.get("date")) or "【日期】"
time_range = _normalize_time_range(fields.get("time")) or "【时间】"
teacher = _normalize_teacher_hint(fields.get("teacher")) or "【老师】"
subject = fields.get("subject") or "【科目】"
body = str(fields.get("body") or "").strip() or "【小结正文】"
return "\n".join(
[
f"学生:{student}",
f"日期:{date_value}",
f"时间:{time_range}",
f"老师:{teacher}",
f"科目:{subject}",
"小结:",
body,
]
)
def _question_for_error(register_type: str, message: str) -> str:
if register_type == "class_record":
return f"请按标准格式补齐或修改上课记录:{message}"
if register_type == "payment":
return f"请按“学生-YYYY-MM-DD:课时”补齐或修改缴费记录:{message}"
return f"请补齐或修改课程小结信息:{message}"
def _extract_class_record_fields(text: str) -> dict[str, str]:
teacher = _extract_teacher(text)
subject = _extract_subject(text)
return {
"student": _extract_class_record_student(text, teacher, subject),
"date": _normalize_date(text),
"time": _normalize_time_range(text),
"teacher": teacher,
"subject": subject,
"duration": _duration_from_text(text),
}
def _extract_payment_fields(text: str) -> dict[str, str]:
hours_match = HOURS_RE.search(text)
return {
"student": _extract_student(text),
"date": _normalize_date(text),
"hours": hours_match.group("hours") if hours_match else "",
}
def _extract_course_summary_fields(text: str) -> dict[str, str]:
try:
raw = extract_course_summary_from_text(text, known_students=_known_students())
except ValueError:
raw = {}
body = str(raw.get("body") or "").strip()
if body == text.strip() and any(label in text for label in ("小结", "正文", "内容")):
body = re.split(r"(?:小结|正文|内容)[:]?", text, maxsplit=1)[-1].strip()
return {
"student": str(raw.get("student") or _extract_student(text)).strip(),
"date": _normalize_date(raw.get("date_iso") or text),
"time": _normalize_time_range(raw.get("time_range") or text),
"teacher": _normalize_teacher_hint(raw.get("teacher") or _extract_teacher(text)),
"subject": str(raw.get("subject") or _extract_subject(text)).strip(),
"body": body or text.strip(),
}
def extract_local_fields(register_type: str, lines: list[str]) -> dict[str, str]:
text = "\n".join(lines).strip()
if register_type == "class_record":
return _extract_class_record_fields(text)
if register_type == "payment":
return _extract_payment_fields(text)
return _extract_course_summary_fields(text)
def fields_preview(register_type: str, fields: dict[str, str]) -> dict[str, Any] | None:
missing = _missing_fields(register_type, fields)
if missing:
return _needs_info_response(register_type, fields, missing, draft_line=_draft_from_fields(register_type, fields))
try:
standard_lines = validate_standard_lines(register_type, [_standard_from_fields(register_type, fields)])
except (ValueError, KeyError) as exc:
return _needs_info_response(
register_type,
fields,
[],
summary="脚本无法生成可校验预览,请修改字段后再试",
error=str(exc),
draft_line=_draft_from_fields(register_type, fields),
)
return {
"status": "ready",
"standard_lines": standard_lines,
"questions": [],
"summary": "已通过本地规则生成预览",
"ai_used": False,
"fields": fields,
"missing_fields": [],
"field_labels": _field_labels(register_type),
"timed_out": False,
"recognition_source": "local",
}
def standard_lines_preview(register_type: str, standard_lines: list[str], summary: str, *, ai_used: bool, source: str) -> dict[str, Any]:
return {
"status": "ready",
"standard_lines": standard_lines,
"questions": [],
"summary": summary,
"ai_used": ai_used,
"fields": {},
"missing_fields": [],
"field_labels": _field_labels(register_type),
"timed_out": False,
"recognition_source": source,
}
def validate_standard_lines(register_type: str, lines: list[str]) -> list[str]:
if not lines:
raise ValueError("标准行不能为空")
standard_lines: list[str] = []
for index, line in enumerate(lines):
text = str(line or "").strip()
if not text:
continue
try:
if register_type == "class_record":
standard_lines.append(class_record_to_line(parse_class_record_line(text)))
elif register_type == "payment":
text = re.sub(r":\s*(\d+(?:\.\d+)?)\s*(?:课时|小时)\s*$", r":\1", text)
student, payment = parse_payment_line(text)
hours = int(payment.hours) if float(payment.hours).is_integer() else payment.hours
standard_lines.append(f"{student}-{payment.date}:{hours}")
else:
raw = extract_course_summary_from_text(text, index)
normalized = normalize_course_summary(raw)
course_summary_to_class_record_line(normalized)
standard_lines.append(text)
except ValueError as exc:
raise ValueError(f"{index + 1} 条校验失败:{exc}") from exc
if not standard_lines:
raise ValueError("标准行不能为空")
return standard_lines
def local_standard_preview(register_type: str, lines: list[str]) -> dict[str, Any] | None:
try:
standard_lines = validate_standard_lines(register_type, lines)
except ValueError:
return None
return standard_lines_preview(register_type, standard_lines, "已按标准格式通过本地校验", ai_used=False, source="local")
def preview_register(
*,
register_type: str,
text: str | None = None,
lines: list[str] | None = None,
answers: dict[str, str] | None = None,
conversation_id: str | None = None,
) -> dict[str, Any]:
normalized_type = normalize_register_type(register_type)
source_lines = collect_input_lines(text=text, lines=lines, register_type=normalized_type)
joined_text = "\n\n".join(source_lines)
conversation_id, session = _session_for(conversation_id, normalized_type, joined_text)
merged_answers = {**dict(session.get("answers") or {}), **(answers or {})}
session["answers"] = merged_answers
local = local_standard_preview(normalized_type, source_lines)
if local is not None:
return {"conversation_id": conversation_id, "type": normalized_type, **local}
local_fields = _merge_answers(extract_local_fields(normalized_type, source_lines), merged_answers)
local_ready = fields_preview(normalized_type, local_fields)
if local_ready is not None:
return {"conversation_id": conversation_id, "type": normalized_type, **local_ready}
missing = _missing_fields(normalized_type, local_fields)
return {
"conversation_id": conversation_id,
"type": normalized_type,
**_needs_info_response(
normalized_type,
local_fields,
missing,
summary="脚本无法补齐全部字段,请补充信息后再次生成预览",
draft_line=_draft_from_fields(normalized_type, local_fields),
),
}
+1170 -72
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import date from datetime import date
SUBJECTS = ["数学", "语文", "英语", "物理", "化学", "生物", "历史", "地理", "政治", "道法"] SUBJECTS = ["语文", "数学", "英语", "物理", "化学", "生物", "历史", "地理", "政治"]
SUBJECT_ALIASES = { SUBJECT_ALIASES = {
"": "数学", "": "数学",
"": "语文", "": "语文",
@@ -29,7 +29,7 @@ UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"}
TEACHER_STATUSES = {"在岗", "离职"} TEACHER_STATUSES = {"在岗", "离职"}
UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"} UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"}
HIGH_CONFIDENCE_VALUES = {"high", "", "高置信", "true", "1", "yes"} HIGH_CONFIDENCE_VALUES = {"high", "", "高置信", "true", "1", "yes"}
AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "manual_admin", "大模型高置信识别", "关键词"} AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "manual_admin", "关键词"}
ROLE_WORDS = { ROLE_WORDS = {
"student": ("学生", "学员", "孩子", "同学"), "student": ("学生", "学员", "孩子", "同学"),
"teacher": ("老师", "教师"), "teacher": ("老师", "教师"),
+2 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from .routers import accounts, admin, health, ingest, pages, records from .routers import accounts, admin, ai_register, health, ingest, pages, records
app = FastAPI(title="新时空教务管理系统", version="1.0.0") app = FastAPI(title="新时空教务管理系统", version="1.0.0")
@@ -19,4 +19,5 @@ app.include_router(health.router)
app.include_router(records.router) app.include_router(records.router)
app.include_router(accounts.router) app.include_router(accounts.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(ai_register.router)
app.include_router(ingest.router) app.include_router(ingest.router)
+58
View File
@@ -20,9 +20,11 @@ from ..data import (
DuplicateRecordError, DuplicateRecordError,
account_summary, account_summary,
account_to_dict, account_to_dict,
append_operation_log,
create_account, create_account,
create_teacher, create_teacher,
filter_accounts, filter_accounts,
parse_class_record_line,
register_class_record_lines, register_class_record_lines,
register_course_summary_texts, register_course_summary_texts,
register_payment_lines, register_payment_lines,
@@ -47,6 +49,16 @@ async def register_class_records(request: Request, _user: str = Depends(verify_a
lines=payload.lines, lines=payload.lines,
line=payload.line, line=payload.line,
) )
for line in result.get("lines", []):
record = parse_class_record_line(str(line))
append_operation_log(
OPERATION_LOGS_PATH,
str(result.get("operation") or "登记上课记录"),
"完成",
student=record.student,
proposed_line=str(line),
backup_id=str(result.get("backup_id") or ""),
)
except DuplicateRecordError as exc: except DuplicateRecordError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc: except ValueError as exc:
@@ -60,6 +72,16 @@ async def register_payments(request: Request, _user: str = Depends(verify_admin_
payload = await read_register_payload(request) payload = await read_register_payload(request)
with write_lock: with write_lock:
result = register_payment_lines(ACCOUNTS_PATH, lines=payload.lines, line=payload.line) result = register_payment_lines(ACCOUNTS_PATH, lines=payload.lines, line=payload.line)
for line in result.get("lines", []):
student = str(line).split("-", 1)[0]
append_operation_log(
OPERATION_LOGS_PATH,
str(result.get("operation") or "登记缴费记录"),
"完成",
student=student,
proposed_line=str(line),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc: 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, **result} return {"ok": True, **result}
@@ -144,6 +166,15 @@ def admin_create_teacher(payload: TeacherPayload, _user: str = Depends(verify_ad
try: try:
with write_lock: with write_lock:
result = create_teacher(TEACHERS_PATH, payload_to_teacher(payload)) result = create_teacher(TEACHERS_PATH, payload_to_teacher(payload))
log_operation = str(result.get("operation") or "新增老师档案")
append_operation_log(
OPERATION_LOGS_PATH,
log_operation,
"完成",
teacher_id=str(result.get("teacher", {}).get("teacher_id") or ""),
teacher=str(result.get("teacher", {}).get("name") or ""),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc: 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, **result} return {"ok": True, **result}
@@ -158,6 +189,15 @@ def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str =
teacher_id, teacher_id,
payload_to_teacher(payload, teacher_id=payload.teacher_id.strip() or teacher_id), payload_to_teacher(payload, teacher_id=payload.teacher_id.strip() or teacher_id),
) )
log_operation = str(result.get("operation") or "修改老师档案")
append_operation_log(
OPERATION_LOGS_PATH,
log_operation,
"完成",
teacher_id=str(result.get("teacher", {}).get("teacher_id") or ""),
teacher=str(result.get("teacher", {}).get("name") or ""),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc: 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, **result} return {"ok": True, **result}
@@ -168,6 +208,15 @@ def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_ad
try: try:
with write_lock: with write_lock:
result = create_account(ACCOUNTS_PATH, payload_to_account(payload)) result = create_account(ACCOUNTS_PATH, payload_to_account(payload))
log_operation = str(result.get("operation") or "新增课时账户")
append_operation_log(
OPERATION_LOGS_PATH,
log_operation,
"完成",
student_id=str(result.get("account", {}).get("student_id") or ""),
student=str(result.get("account", {}).get("student") or ""),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc: 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, **result} return {"ok": True, **result}
@@ -178,6 +227,15 @@ def admin_update_account(student_id: str, payload: AccountPayload, _user: str =
try: try:
with write_lock: with write_lock:
result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload)) result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload))
log_operation = str(result.get("operation") or "修改课时账户")
append_operation_log(
OPERATION_LOGS_PATH,
log_operation,
"完成",
student_id=str(result.get("account", {}).get("student_id") or ""),
student=str(result.get("account", {}).get("student") or ""),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc: 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, **result} return {"ok": True, **result}
+142 -8
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import verify_admin_auth from ..auth import verify_admin_auth
@@ -14,11 +16,17 @@ from ..config import (
from ..data import ( from ..data import (
append_operation_log, append_operation_log,
approve_admin_task, approve_admin_task,
create_course_summary_duplicate_review_tasks,
delete_course_summary, delete_course_summary,
link_existing_course_summary_task,
list_admin_tasks, list_admin_tasks,
list_operation_logs, list_operation_logs,
migrate_operation_log_labels,
query_course_summaries, query_course_summaries,
reject_admin_task, reject_admin_task,
resolve_duplicate_course_summary_task,
rollback_operation_log,
update_course_summary_review_task,
update_course_summary_time, update_course_summary_time,
) )
@@ -26,6 +34,16 @@ from ..data import (
router = APIRouter() router = APIRouter()
def rollback_backup_paths() -> list[Path]:
return [
CLASSNOTES_PATH,
ACCOUNTS_PATH,
ADMIN_TASKS_PATH,
COURSE_SUMMARIES_ROOT,
OPERATION_LOGS_PATH,
]
@router.get("/api/admin/tasks") @router.get("/api/admin/tasks")
def admin_tasks( def admin_tasks(
status_filter: str = Query("", alias="status"), status_filter: str = Query("", alias="status"),
@@ -46,15 +64,39 @@ def admin_operation_logs(
student: str = Query(""), student: str = Query(""),
_user: str = Depends(verify_admin_auth), _user: str = Depends(verify_admin_auth),
): ):
migrate_operation_log_labels(OPERATION_LOGS_PATH)
return list_operation_logs( return list_operation_logs(
OPERATION_LOGS_PATH, OPERATION_LOGS_PATH,
limit=limit, limit=limit,
operation=operation, operation=operation,
status_filter=status_filter, status_filter=status_filter,
student=student, student=student,
backup_paths=rollback_backup_paths(),
) )
@router.post("/api/admin/operation-logs/{log_id}/rollback")
def admin_rollback_operation_log(log_id: str, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = rollback_operation_log(OPERATION_LOGS_PATH, log_id, rollback_backup_paths())
append_operation_log(
OPERATION_LOGS_PATH,
"撤回操作",
"已撤回",
target_log_id=log_id,
target_backup_id=str(result.get("target_backup_id") or ""),
backup_id=str(result.get("backup_id") or ""),
restored_files=result.get("restored_files") or [],
rollback_mode=str(result.get("rollback_mode") or ""),
removed_lines=result.get("removed_lines") or [],
restored_hours=result.get("restored_hours") or "",
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"ok": True, **result}
@router.get("/api/admin/course-summaries") @router.get("/api/admin/course-summaries")
def admin_course_summaries( def admin_course_summaries(
q: str = Query(""), q: str = Query(""),
@@ -91,8 +133,8 @@ def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
task = result.get("task", {}) task = result.get("task", {})
append_operation_log( append_operation_log(
OPERATION_LOGS_PATH, OPERATION_LOGS_PATH,
"admin_task_approve", "审核批准",
"approved", "已批准",
task_id=task_id, task_id=task_id,
task_type=str(task.get("type") or ""), task_type=str(task.get("type") or ""),
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""), student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
@@ -104,6 +146,81 @@ def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
return {"ok": True, **result} return {"ok": True, **result}
@router.post("/api/admin/tasks/{task_id}/resolve-duplicate-summary")
def admin_resolve_duplicate_summary(task_id: int, payload: dict, _user: str = Depends(verify_admin_auth)):
delete_summary_id = str(payload.get("delete_summary_id") or "")
try:
with write_lock:
result = resolve_duplicate_course_summary_task(
ADMIN_TASKS_PATH,
COURSE_SUMMARIES_ROOT,
task_id,
delete_summary_id,
)
task = result.get("task", {})
append_operation_log(
OPERATION_LOGS_PATH,
"重复小结删除",
"已删除",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or ""),
summary_id=str(result.get("deleted_summary_id") or ""),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/admin/tasks/{task_id}/course-summary-review")
def admin_update_course_summary_review(task_id: int, payload: dict, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = update_course_summary_review_task(
ADMIN_TASKS_PATH,
CLASSNOTES_PATH,
ACCOUNTS_PATH,
task_id,
payload,
)
task = result.get("task", {})
append_operation_log(
OPERATION_LOGS_PATH,
"课程小结审核修正",
"已更新",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or ""),
source_id=str(task.get("source_id") or ""),
proposed_line=str(task.get("proposed_line") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/admin/tasks/{task_id}/link-existing-course-summary")
def admin_link_existing_course_summary(task_id: int, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = link_existing_course_summary_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, task_id)
task = result.get("task", {})
append_operation_log(
OPERATION_LOGS_PATH,
"课程小结关联已有记录",
"已批准",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or ""),
source_id=str(task.get("source_id") or ""),
proposed_line=str(result.get("linked_record_line") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/admin/tasks/{task_id}/reject") @router.post("/api/admin/tasks/{task_id}/reject")
def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)): def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
try: try:
@@ -111,8 +228,8 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
task = reject_admin_task(ADMIN_TASKS_PATH, task_id) task = reject_admin_task(ADMIN_TASKS_PATH, task_id)
append_operation_log( append_operation_log(
OPERATION_LOGS_PATH, OPERATION_LOGS_PATH,
"admin_task_reject", "审核驳回",
"rejected", "已驳回",
task_id=task_id, task_id=task_id,
task_type=str(task.get("type") or ""), task_type=str(task.get("type") or ""),
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""), student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
@@ -123,6 +240,23 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
return {"ok": True, "task": task} return {"ok": True, "task": task}
@router.post("/api/admin/course-summaries/duplicate-scan")
def admin_scan_duplicate_course_summaries(_user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = create_course_summary_duplicate_review_tasks(ADMIN_TASKS_PATH, COURSE_SUMMARIES_ROOT)
append_operation_log(
OPERATION_LOGS_PATH,
"重复小结扫描",
"待审核" if result.get("created") else "完成",
scanned_groups=int(result.get("scanned") or 0),
created_tasks=int(result.get("created") or 0),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/admin/course-summaries/{summary_id}/time") @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)): def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)):
try: try:
@@ -130,8 +264,8 @@ def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str
result = update_course_summary_time(COURSE_SUMMARIES_ROOT, summary_id, str(payload.get("time_range") or "")) result = update_course_summary_time(COURSE_SUMMARIES_ROOT, summary_id, str(payload.get("time_range") or ""))
append_operation_log( append_operation_log(
OPERATION_LOGS_PATH, OPERATION_LOGS_PATH,
"admin_course_summary_update_time", "课程小结补齐时间",
"updated", "已更新",
summary_id=summary_id, summary_id=summary_id,
time_range=str(payload.get("time_range") or ""), time_range=str(payload.get("time_range") or ""),
backup_id=str(result.get("backup_id") or ""), backup_id=str(result.get("backup_id") or ""),
@@ -148,8 +282,8 @@ def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_adm
result = delete_course_summary(COURSE_SUMMARIES_ROOT, summary_id) result = delete_course_summary(COURSE_SUMMARIES_ROOT, summary_id)
append_operation_log( append_operation_log(
OPERATION_LOGS_PATH, OPERATION_LOGS_PATH,
"admin_course_summary_delete", "课程小结删除",
"deleted", "已删除",
summary_id=summary_id, summary_id=summary_id,
backup_id=str(result.get("backup_id") or ""), backup_id=str(result.get("backup_id") or ""),
) )
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from ..ai_register import preview_register
from ..auth import verify_admin_auth
from ..schemas import AiRegisterPreviewPayload
router = APIRouter()
@router.post("/api/ai/register/preview")
def ai_register_preview(payload: AiRegisterPreviewPayload, _user: str = Depends(verify_admin_auth)):
try:
return {
"ok": True,
**preview_register(
register_type=payload.type,
text=payload.text,
lines=payload.lines,
answers=payload.answers,
conversation_id=payload.conversation_id,
),
}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+76 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from ..auth import verify_ingest_token from ..auth import verify_ingest_token
from ..ai_register import preview_register
from ..config import ( from ..config import (
ACCOUNTS_PATH, ACCOUNTS_PATH,
ADMIN_TASKS_PATH, ADMIN_TASKS_PATH,
@@ -19,9 +20,83 @@ from ..schemas import CourseSummaryIngestPayload
router = APIRouter() router = APIRouter()
def _summary_text(raw: dict) -> str:
parts = []
mapping = [
("student", "学生"),
("date_iso", "日期"),
("date", "日期"),
("time_range", "时间"),
("raw_time", "时间"),
("teacher", "老师"),
("subject", "科目"),
]
for key, label in mapping:
value = str(raw.get(key) or "").strip()
if value:
parts.append(f"{label}{value}")
body = str(raw.get("body") or raw.get("content") or "").strip()
if body:
parts.append("小结:")
parts.append(body)
return "\n".join(parts)
def _needs_script_summary(raw: dict) -> bool:
required = ["student", "teacher", "subject"]
if any(not str(raw.get(key) or "").strip() for key in required):
return True
if not str(raw.get("date_iso") or raw.get("date") or raw.get("class_date") or "").strip():
return True
if not str(raw.get("time_range") or raw.get("raw_time") or raw.get("time") or "").strip():
return True
return False
def _standard_text_to_raw(text: str, original: dict) -> dict:
from ..data import extract_course_summary_from_text, normalize_course_summary
raw = extract_course_summary_from_text(text, known_students=[])
normalized = normalize_course_summary({**original, **raw})
return {**original, **normalized}
def _preprocess_summaries_locally(summaries: list[dict]) -> list[dict]:
processed: list[dict] = []
for raw in summaries:
if not _needs_script_summary(raw):
processed.append(raw)
continue
try:
preview = preview_register(register_type="course_summary", text=_summary_text(raw))
except ValueError as exc:
processed.append({**raw, "ai_used": False, "ai_questions": [str(exc)]})
continue
if preview.get("status") == "ready" and preview.get("standard_lines"):
try:
updated = _standard_text_to_raw(str(preview["standard_lines"][0]), raw)
processed.append({
**updated,
"ai_used": False,
"ai_summary": str(preview.get("summary") or ""),
})
except ValueError as exc:
processed.append({**raw, "ai_used": False, "ai_questions": [str(exc)]})
continue
questions = [str(item) for item in preview.get("questions") or [] if str(item)]
processed.append({
**raw,
"ai_used": False,
"ai_summary": str(preview.get("summary") or ""),
"ai_questions": questions or ["脚本未能补齐课程小结信息"],
})
return processed
@router.post("/api/ingest/course-summaries") @router.post("/api/ingest/course-summaries")
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)): def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
try: try:
summaries = _preprocess_summaries_locally([item.dict() for item in payload.summaries])
with write_lock: with write_lock:
result = ingest_course_summaries( result = ingest_course_summaries(
classnotes_path=CLASSNOTES_PATH, classnotes_path=CLASSNOTES_PATH,
@@ -33,7 +108,7 @@ def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str
batch_id=payload.batch_id, batch_id=payload.batch_id,
window=payload.window, window=payload.window,
students=payload.students, students=payload.students,
summaries=[item.dict() for item in payload.summaries], summaries=summaries,
) )
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
+104 -2
View File
@@ -1,17 +1,33 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from ..api_utils import load_accounts, load_records, load_teachers 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, COURSE_SUMMARIES_ROOT from ..config import (
ADMIN_TASKS_PATH,
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
OPERATION_LOGS_PATH,
write_lock,
)
from ..data import ( from ..data import (
account_to_dict, account_to_dict,
append_operation_log,
course_summary_semantic_key,
duration_minutes_from_time_range,
find_record_by_identity,
query_public_records, query_public_records,
read_course_summary_state,
save_course_summary_markdown,
sha1_text,
submit_public_correction_tasks, submit_public_correction_tasks,
submit_public_deletion_tasks, submit_public_deletion_tasks,
write_course_summary_state,
) )
from ..schemas import CorrectionSubmitPayload, DeletionSubmitPayload from ..schemas import CorrectionSubmitPayload, CourseSummarySupplementPayload, DeletionSubmitPayload
router = APIRouter() router = APIRouter()
@@ -59,3 +75,89 @@ def submit_deletions(payload: DeletionSubmitPayload, _user: str = Depends(verify
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, **result} return {"ok": True, **result}
@router.post("/api/course-summaries/supplement")
def supplement_course_summary(payload: CourseSummarySupplementPayload, _user: str = Depends(verify_records_auth)):
body = payload.body.strip()
if not body:
raise HTTPException(status_code=400, detail="课程小结正文不能为空")
try:
records = load_records()
record = find_record_by_identity(records, payload.record_id)
date_iso = record.date.replace(".", "-")
source_id = f"supplement:{payload.record_id}:{sha1_text(body, 12)}"
summary = {
"source_id": source_id,
"student": record.student,
"date_iso": date_iso,
"time_range": record.time,
"duration_minutes": duration_minutes_from_time_range(record.time),
"teacher": record.teacher,
"subject": record.subject,
"group": "",
"sender": "课程记录页补充",
"message_time": "",
"body": body,
}
semantic_key = course_summary_semantic_key(summary)
with write_lock:
state = read_course_summary_state(COURSE_SUMMARY_STATE_PATH)
seen_source_ids = set(str(item) for item in state.get("seen_source_ids", []))
seen_semantic_keys = set(str(item) for item in state.get("seen_semantic_keys", []))
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
log_id = append_operation_log(
OPERATION_LOGS_PATH,
"课程小结补充",
"重复",
record_id=payload.record_id,
student=record.student,
teacher=record.teacher,
subject=record.subject,
source_id=source_id,
)
return {"ok": True, "log_id": log_id, "submitted": 0, "status": "duplicate"}
saved = save_course_summary_markdown(COURSE_SUMMARIES_ROOT, summary)
status_value = "完成" if saved.get("added") else "重复"
seen_source_ids.add(source_id)
seen_semantic_keys.add(semantic_key)
state["seen_source_ids"] = sorted(seen_source_ids)
state["seen_semantic_keys"] = sorted(seen_semantic_keys)
state.setdefault("batches", []).append(
{
"batch_id": f"supplement-{payload.record_id}-{sha1_text(body, 8)}",
"received_at": datetime.now().isoformat(timespec="seconds"),
"window": {"source": "records_page_supplement"},
"students": [record.student],
"result": {
"received": 1,
"saved": 1 if saved.get("added") else 0,
"auto_registered": 0,
"review_pending": 0,
"duplicates": 0 if saved.get("added") else 1,
"rejected": 0,
},
}
)
state["batches"] = state["batches"][-200:]
write_course_summary_state(COURSE_SUMMARY_STATE_PATH, state)
log_id = append_operation_log(
OPERATION_LOGS_PATH,
"课程小结补充",
status_value,
record_id=payload.record_id,
student=record.student,
teacher=record.teacher,
subject=record.subject,
source_id=source_id,
saved_path=str(saved.get("path") or ""),
heading=str(saved.get("heading") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"ok": True,
"log_id": log_id,
"submitted": 1 if status_value == "完成" else 0,
"status": "saved" if status_value == "完成" else "duplicate",
}
+14 -1
View File
@@ -8,6 +8,14 @@ class RegisterLinesPayload(BaseModel):
lines: list[str] | None = Field(default=None, description="多条原始登记文本") lines: list[str] | None = Field(default=None, description="多条原始登记文本")
class AiRegisterPreviewPayload(BaseModel):
type: str
text: str | None = None
lines: list[str] | None = None
answers: dict[str, str] = Field(default_factory=dict)
conversation_id: str | None = None
class PaymentPayload(BaseModel): class PaymentPayload(BaseModel):
date: str date: str
hours: float hours: float
@@ -52,9 +60,14 @@ class DeletionSubmitPayload(BaseModel):
items: list[DeletionItemPayload] items: list[DeletionItemPayload]
class CourseSummarySupplementPayload(BaseModel):
record_id: str
body: str
class CourseSummaryPayload(BaseModel): class CourseSummaryPayload(BaseModel):
source_id: str = "" source_id: str = ""
student: str student: str = ""
date_iso: str = "" date_iso: str = ""
date: str = "" date: str = ""
time_range: str = "" time_range: str = ""
+2 -2
View File
@@ -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=20260611-accounts-split" /> <link rel="stylesheet" href="/static/styles.css?v=20260616-duration-text" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -55,6 +55,6 @@
</section> </section>
</main> </main>
<script src="/static/accounts.js?v=20260611-accounts-split"></script> <script src="/static/accounts.js?v=20260616-duration-text"></script>
</body> </body>
</html> </html>
+10 -3
View File
@@ -7,7 +7,14 @@ const accountMeta = document.querySelector("#accountMeta");
const accountRows = document.querySelector("#accountRows"); const accountRows = document.querySelector("#accountRows");
function fmtHours(value) { function fmtHours(value) {
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); const totalMinutes = Math.round(Number(value || 0) * 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}小时${minutes}`;
}
function displayHours(value, fallback) {
return fallback || fmtHours(value);
} }
function fmtTime(seconds) { function fmtTime(seconds) {
@@ -37,7 +44,7 @@ function statusClass(status) {
function renderPayments(payments) { function renderPayments(payments) {
if (!payments.length) return "暂无"; if (!payments.length) return "暂无";
return payments.map((item) => `${escapeHtml(item.date)}${fmtHours(item.hours)} 小时`).join("<br>"); return payments.map((item) => `${escapeHtml(item.date)}${escapeHtml(displayHours(item.hours, item.duration))}`).join("<br>");
} }
async function fetchJson(url) { async function fetchJson(url) {
@@ -86,7 +93,7 @@ async function loadAccounts() {
(row) => `<tr> (row) => `<tr>
<td>${escapeHtml(row.student)}<br><small>${escapeHtml(row.student_id)}</small></td> <td>${escapeHtml(row.student)}<br><small>${escapeHtml(row.student_id)}</small></td>
<td><span class="status ${statusClass(row.account_status)}">${escapeHtml(row.account_status)}</span></td> <td><span class="status ${statusClass(row.account_status)}">${escapeHtml(row.account_status)}</span></td>
<td class="num">${fmtHours(row.remaining)}</td> <td class="num">${escapeHtml(displayHours(row.remaining, row.remaining_duration))}</td>
<td>${renderPayments(row.payments)}</td> <td>${renderPayments(row.payments)}</td>
<td class="note-cell">${escapeHtml(row.note || "")}</td> <td class="note-cell">${escapeHtml(row.note || "")}</td>
</tr>`, </tr>`,
+76 -26
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理后台</title> <title>管理后台</title>
<link rel="stylesheet" href="/static/styles.css?v=20260615-summary-time-review" /> <link rel="stylesheet" href="/static/styles.css?v=20260618-summary-search-expand" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -219,13 +219,16 @@
<section id="summariesPanel" class="panel admin-panel" hidden> <section id="summariesPanel" class="panel admin-panel" hidden>
<div class="section-head"> <div class="section-head">
<h2>课程小结审核</h2> <h2>课程小结审核</h2>
<select id="summaryReviewStatus" aria-label="课程小结审核状态筛选"> <div class="quick-actions">
<option value="pending">待审核</option> <select id="summaryReviewStatus" aria-label="课程小结审核状态筛选">
<option value="conflict">冲突</option> <option value="pending">待审核</option>
<option value="approved">已批准</option> <option value="conflict">冲突</option>
<option value="rejected">驳回</option> <option value="approved">批准</option>
<option value="">全部状态</option> <option value="rejected">已驳回</option>
</select> <option value="">全部状态</option>
</select>
<button id="duplicateSummaryScanBtn" class="chip" type="button">扫描重复小结</button>
</div>
</div> </div>
<div id="summaryReviewMeta" class="summary-grid"></div> <div id="summaryReviewMeta" class="summary-grid"></div>
<div class="table-wrap"> <div class="table-wrap">
@@ -264,6 +267,31 @@
<div class="drawer-label">课程信息</div> <div class="drawer-label">课程信息</div>
<div id="summaryReviewDrawerCourse" class="drawer-text"></div> <div id="summaryReviewDrawerCourse" class="drawer-text"></div>
</div> </div>
<div id="summaryReviewEditSection" class="drawer-section" hidden>
<div class="drawer-label">修正信息</div>
<div class="form-grid compact-grid">
<label>
学生
<input id="summaryReviewEditStudent" autocomplete="off" />
</label>
<label>
日期
<input id="summaryReviewEditDate" type="date" />
</label>
<label>
时间
<input id="summaryReviewEditTime" autocomplete="off" placeholder="08:00-10:00" />
</label>
<label>
老师
<input id="summaryReviewEditTeacher" autocomplete="off" />
</label>
<label>
科目
<input id="summaryReviewEditSubject" autocomplete="off" />
</label>
</div>
</div>
<div class="drawer-section"> <div class="drawer-section">
<div class="drawer-label">建议登记行</div> <div class="drawer-label">建议登记行</div>
<div id="summaryReviewDrawerLine"></div> <div id="summaryReviewDrawerLine"></div>
@@ -282,6 +310,8 @@
</div> </div>
</div> </div>
<div class="drawer-actions"> <div class="drawer-actions">
<button id="summaryReviewDrawerSave" class="secondary-button" type="button">保存修正</button>
<button id="summaryReviewDrawerLink" class="secondary-button" type="button">关联已有记录</button>
<button id="summaryReviewDrawerReject" class="secondary-button" type="button">驳回</button> <button id="summaryReviewDrawerReject" class="secondary-button" type="button">驳回</button>
<button id="summaryReviewDrawerApprove" type="button">批准</button> <button id="summaryReviewDrawerApprove" type="button">批准</button>
</div> </div>
@@ -314,9 +344,6 @@
<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>
@@ -330,17 +357,35 @@
<div class="quick-actions"> <div class="quick-actions">
<select id="logOperation" aria-label="操作类型筛选"> <select id="logOperation" aria-label="操作类型筛选">
<option value="">全部操作</option> <option value="">全部操作</option>
<option value="course_summary_ingest">小结接收</option> <option value="登记上课记录">登记上课记录</option>
<option value="admin_task_approve">审核批准</option> <option value="登记缴费记录">登记缴费记录</option>
<option value="admin_task_reject">审核驳回</option> <option value="新增课时账户">新增课时账户</option>
<option value="修改课时账户">修改课时账户</option>
<option value="新增老师档案">新增老师档案</option>
<option value="修改老师档案">修改老师档案</option>
<option value="课程小结接收">课程小结接收</option>
<option value="课程小结登记">课程小结登记</option>
<option value="课程小结审核修正">课程小结审核修正</option>
<option value="课程小结关联已有记录">课程小结关联已有记录</option>
<option value="重复小结扫描">重复小结扫描</option>
<option value="重复小结删除">重复小结删除</option>
<option value="审核批准">审核批准</option>
<option value="审核驳回">审核驳回</option>
<option value="课程小结补齐时间">课程小结补齐时间</option>
<option value="课程小结删除">课程小结删除</option>
<option value="撤回操作">撤回操作</option>
</select> </select>
<select id="logStatus" aria-label="操作结果筛选"> <select id="logStatus" aria-label="操作结果筛选">
<option value="">全部结果</option> <option value="">全部结果</option>
<option value="auto_registered">自动入账</option> <option value="完成">完成</option>
<option value="review">待审核</option> <option value="自动入账">自动入账</option>
<option value="duplicate">重复</option> <option value="待审核">待审核</option>
<option value="rejected">失败/驳回</option> <option value="重复">重复</option>
<option value="approved">已批准</option> <option value="已驳回">失败/驳回</option>
<option value="已批准">已批准</option>
<option value="已更新">已更新</option>
<option value="已删除">已删除</option>
<option value="已撤回">已撤回</option>
</select> </select>
</div> </div>
</div> </div>
@@ -358,7 +403,7 @@
<th>结果</th> <th>结果</th>
<th>学生</th> <th>学生</th>
<th>批次/任务</th> <th>批次/任务</th>
<th>详情</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody id="logRows"></tbody> <tbody id="logRows"></tbody>
@@ -374,32 +419,37 @@
<form id="classRegisterForm" class="admin-form"> <form id="classRegisterForm" class="admin-form">
<h3>上课记录登记</h3> <h3>上课记录登记</h3>
<textarea id="classRegisterLines" rows="10" placeholder="每行一条上课记录"></textarea> <textarea id="classRegisterLines" rows="10" placeholder="每行一条上课记录"></textarea>
<div id="classRegisterPreview" class="register-preview" hidden></div>
<p id="classRegisterStatus" class="inline-account-loading"></p> <p id="classRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions"> <div class="modal-actions">
<button type="submit">提交上课记录</button> <button type="submit">生成预览</button>
<button id="classRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
</div> </div>
</form> </form>
<form id="paymentRegisterForm" class="admin-form"> <form id="paymentRegisterForm" class="admin-form">
<h3>缴费登记</h3> <h3>缴费登记</h3>
<textarea id="paymentRegisterLines" rows="10" placeholder="每行一条:学生-2026-06-01:53"></textarea> <textarea id="paymentRegisterLines" rows="10" placeholder="每行一条:学生-2026-06-01:53"></textarea>
<div id="paymentRegisterPreview" class="register-preview" hidden></div>
<p id="paymentRegisterStatus" class="inline-account-loading"></p> <p id="paymentRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions"> <div class="modal-actions">
<button type="submit">提交缴费记录</button> <button type="submit">生成预览</button>
<button id="paymentRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
</div> </div>
</form> </form>
<form id="summaryRegisterForm" class="admin-form"> <form id="summaryRegisterForm" class="admin-form">
<h3>课程小结登记</h3> <h3>课程小结登记</h3>
<div id="summaryRegisterItems" class="summary-register-items"></div> <textarea id="summaryRegisterText" rows="12" placeholder="粘贴一条或多条课程小结原文,学生/日期/时间/老师/科目由系统自动提取"></textarea>
<button id="addSummaryRegisterItemBtn" class="secondary-button" type="button">再填一条</button> <div id="summaryRegisterPreview" class="register-preview" hidden></div>
<p id="summaryRegisterStatus" class="inline-account-loading"></p> <p id="summaryRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions"> <div class="modal-actions">
<button type="submit">提交课程小结</button> <button type="submit">生成预览</button>
<button id="summaryRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
</div> </div>
</form> </form>
</div> </div>
</section> </section>
</main> </main>
<script src="/static/admin.js?v=20260615-summary-time-review"></script> <script src="/static/admin.js?v=20260618-summary-search-expand"></script>
</body> </body>
</html> </html>
+620 -204
View File
File diff suppressed because it is too large Load Diff
+162 -14
View File
@@ -29,6 +29,15 @@ const deleteError = document.querySelector("#deleteError");
const closeDeleteBtn = document.querySelector("#closeDeleteBtn"); const closeDeleteBtn = document.querySelector("#closeDeleteBtn");
const cancelDeleteBtn = document.querySelector("#cancelDeleteBtn"); const cancelDeleteBtn = document.querySelector("#cancelDeleteBtn");
const confirmDeleteBtn = document.querySelector("#confirmDeleteBtn"); const confirmDeleteBtn = document.querySelector("#confirmDeleteBtn");
const summarySupplementDialog = document.querySelector("#summarySupplementDialog");
const summarySupplementForm = document.querySelector("#summarySupplementForm");
const summarySupplementOriginal = document.querySelector("#summarySupplementOriginal");
const summarySupplementBody = document.querySelector("#summarySupplementBody");
const summarySupplementError = document.querySelector("#summarySupplementError");
const summarySupplementPreview = document.querySelector("#summarySupplementPreview");
const closeSummarySupplementBtn = document.querySelector("#closeSummarySupplementBtn");
const cancelSummarySupplementBtn = document.querySelector("#cancelSummarySupplementBtn");
const submitSummarySupplementBtn = document.querySelector("#submitSummarySupplementBtn");
const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]; const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
const COPY_LINE_BREAK = "\r\n"; const COPY_LINE_BREAK = "\r\n";
@@ -40,9 +49,17 @@ let correctedRecords = new Map();
let expandedSummaryRecords = new Set(); let expandedSummaryRecords = new Set();
let activeCorrectionKey = ""; let activeCorrectionKey = "";
let activeDeleteKey = ""; let activeDeleteKey = "";
let activeSummarySupplementKey = "";
function fmtHours(value) { function fmtHours(value) {
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); const totalMinutes = Math.round(Number(value || 0) * 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}小时${minutes}`;
}
function displayHours(value, fallback) {
return fallback || fmtHours(value);
} }
function fmtTime(seconds) { function fmtTime(seconds) {
@@ -63,6 +80,10 @@ function metric(label, value) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`; return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
} }
function metricHtml(label, valueHtml) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${valueHtml}</strong></div>`;
}
function makeRecordKey(row, index) { function makeRecordKey(row, index) {
return row.record_id || JSON.stringify([index, row.date, row.time, row.student, row.duration, row.teacher, row.subject]); return row.record_id || JSON.stringify([index, row.date, row.time, row.student, row.duration, row.teacher, row.subject]);
} }
@@ -181,11 +202,11 @@ function renderRecordRow(row) {
const corrected = correctedRecords.get(key); const corrected = correctedRecords.get(key);
const displayRow = getDisplayRecord(row); const displayRow = getDisplayRecord(row);
const correctedClass = corrected ? " corrected-row" : ""; const correctedClass = corrected ? " corrected-row" : "";
const summaryClass = Number(row.summary_count || 0) > 0 ? "" : " missing-summary";
const actionLabel = corrected ? "编辑" : "纠错"; const actionLabel = corrected ? "编辑" : "纠错";
const badge = corrected ? '<span class="correction-badge">已修改</span>' : ""; const badge = corrected ? '<span class="correction-badge">已修改</span>' : "";
const summaryCount = Number(displayRow.summary_count || 0);
const summaryExpanded = expandedSummaryRecords.has(key); const summaryExpanded = expandedSummaryRecords.has(key);
return `<tr class="record-row${correctedClass}"> return `<tr class="record-row${correctedClass}${summaryClass}" data-record-key="${escapeHtml(key)}" tabindex="0" role="button" aria-expanded="${summaryExpanded ? "true" : "false"}">
<td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td> <td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
<td>${escapeHtml(displayRow.time)}</td> <td>${escapeHtml(displayRow.time)}</td>
<td>${escapeHtml(displayRow.student)}</td> <td>${escapeHtml(displayRow.student)}</td>
@@ -194,7 +215,6 @@ 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}
@@ -212,6 +232,17 @@ function renderSummaryEntry(summary) {
</div>`; </div>`;
} }
function renderSummaryMissingRow(key, expanded) {
return `<tr class="summary-collapse-row summary-missing-row" data-summary-row="${escapeHtml(key)}" ${expanded ? "" : "hidden"}>
<td colspan="7">
<div class="summary-missing">
<span>无课程小结</span>
<button class="small-button summary-supplement" type="button" data-record-key="${escapeHtml(key)}">补充课程小结</button>
</div>
</td>
</tr>`;
}
function renderGroupedRecords(records) { function renderGroupedRecords(records) {
currentRecordOrder = []; currentRecordOrder = [];
return groupRecordsByTeacher(records) return groupRecordsByTeacher(records)
@@ -220,7 +251,7 @@ function renderGroupedRecords(records) {
<td colspan="7"> <td colspan="7">
<div class="teacher-group-title"> <div class="teacher-group-title">
<strong>${escapeHtml(group.teacher)}</strong> <strong>${escapeHtml(group.teacher)}</strong>
<span>${group.count} 条记录 · ${fmtHours(group.totalHours)} 小时</span> <span>${group.count} 条记录 · ${displayHours(group.totalHours)}</span>
</div> </div>
</td> </td>
</tr>${sortRecordsForDisplay(group.records) </tr>${sortRecordsForDisplay(group.records)
@@ -231,7 +262,7 @@ function renderGroupedRecords(records) {
? `<tr class="summary-collapse-row" data-summary-row="${escapeHtml(row._recordKey)}" ${expandedSummaryRecords.has(row._recordKey) ? "" : "hidden"}> ? `<tr class="summary-collapse-row" data-summary-row="${escapeHtml(row._recordKey)}" ${expandedSummaryRecords.has(row._recordKey) ? "" : "hidden"}>
<td colspan="7">${(row.summaries || []).map(renderSummaryEntry).join("")}</td> <td colspan="7">${(row.summaries || []).map(renderSummaryEntry).join("")}</td>
</tr>` </tr>`
: ""; : renderSummaryMissingRow(row._recordKey, expandedSummaryRecords.has(row._recordKey));
return `${renderRecordRow(row)}${summaryRow}`; return `${renderRecordRow(row)}${summaryRow}`;
}) })
.join("")}`, .join("")}`,
@@ -248,6 +279,10 @@ function toggleSummaryRow(key) {
renderCurrentRecords(); renderCurrentRecords();
} }
function toggleRecordSummary(key) {
toggleSummaryRow(key);
}
function statusClass(status) { function statusClass(status) {
if (status === "欠费") return "debt"; if (status === "欠费") return "debt";
if (status === "预警") return "warning"; if (status === "预警") return "warning";
@@ -257,7 +292,9 @@ function statusClass(status) {
function renderPayments(payments) { function renderPayments(payments) {
if (!payments.length) return "暂无缴费记录"; if (!payments.length) return "暂无缴费记录";
return payments.map((item) => `${item.date}${fmtHours(item.hours)} 小时`).join(""); return payments
.map((item) => `<span class="payment-line">${escapeHtml(item.date)}${escapeHtml(displayHours(item.hours, item.duration))}</span>`)
.join("");
} }
function renderInlineAccount(account) { function renderInlineAccount(account) {
@@ -270,9 +307,9 @@ function renderInlineAccount(account) {
<span class="status ${statusClass(account.account_status)}">${escapeHtml(account.account_status)}</span> <span class="status ${statusClass(account.account_status)}">${escapeHtml(account.account_status)}</span>
</div> </div>
<div class="inline-account-grid"> <div class="inline-account-grid">
${metric("剩余课时", fmtHours(account.remaining))} ${metric("剩余课时", displayHours(account.remaining, account.remaining_duration))}
${metric("缴费次数", account.payments_count)} ${metric("缴费次数", account.payments_count)}
${metric("缴费记录", renderPayments(account.payments))} ${metricHtml("缴费记录", renderPayments(account.payments))}
${metric("备注", account.note || "无")} ${metric("备注", account.note || "无")}
</div>`; </div>`;
} }
@@ -442,6 +479,92 @@ function closeDeleteDialog() {
setDeleteError(""); setDeleteError("");
} }
function setSummarySupplementError(message) {
summarySupplementError.textContent = message;
summarySupplementError.hidden = !message;
}
function buildSummarySupplementText(record, body) {
return [
`学生:${record.student}`,
`日期:${record.date}`,
`时间:${record.time}`,
`老师:${record.teacher}`,
`科目:${record.subject}`,
"",
body.trim(),
].join("\n");
}
function updateSummarySupplementPreview() {
const original = findCurrentRecord(activeSummarySupplementKey);
if (!original) return;
const body = summarySupplementBody.value.trim();
if (!body) {
summarySupplementPreview.textContent = "提交后会直接保存为这节课的课程小结。";
setSummarySupplementError("");
return;
}
summarySupplementPreview.textContent = buildSummarySupplementText(original, body);
setSummarySupplementError("");
}
function openSummarySupplementDialog(key) {
const original = findCurrentRecord(key);
if (!original) return;
activeSummarySupplementKey = key;
summarySupplementOriginal.textContent = `将补充课程小结:${buildRecordLine(original)}`;
summarySupplementBody.value = "";
summarySupplementPreview.textContent = "提交后会直接保存为这节课的课程小结。";
setSummarySupplementError("");
summarySupplementDialog.hidden = false;
summarySupplementBody.focus();
}
function closeSummarySupplementDialog() {
summarySupplementDialog.hidden = true;
activeSummarySupplementKey = "";
setSummarySupplementError("");
}
async function submitSummarySupplement(event) {
event.preventDefault();
const original = findCurrentRecord(activeSummarySupplementKey);
if (!original) return;
const body = summarySupplementBody.value.trim();
if (!body) {
setSummarySupplementError("课程小结正文不能为空");
return;
}
submitSummarySupplementBtn.disabled = true;
summarySupplementPreview.textContent = "正在提交";
try {
const key = activeSummarySupplementKey;
const data = await fetchJson("/api/course-summaries/supplement", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
record_id: original.record_id,
body,
}),
});
closeSummarySupplementDialog();
await loadHealth();
if (recordQuery.value.trim()) {
await queryRecords(recordQuery.value);
if (key) {
expandedSummaryRecords.add(key);
renderCurrentRecords();
}
}
} catch (error) {
setSummarySupplementError(`提交失败:${error.message}`);
summarySupplementPreview.textContent = "请修正后重新提交";
} finally {
submitSummarySupplementBtn.disabled = false;
}
}
async function submitDeleteRecord() { async function submitDeleteRecord() {
const original = findCurrentRecord(activeDeleteKey); const original = findCurrentRecord(activeDeleteKey);
if (!original) return; if (!original) return;
@@ -584,7 +707,7 @@ async function queryRecords(query) {
recordMeta.innerHTML = [ recordMeta.innerHTML = [
metric("识别日期", data.query.date_range), metric("识别日期", data.query.date_range),
metric("命中记录", `${summary.count}`), metric("命中记录", `${summary.count}`),
metric("总课时", `${fmtHours(summary.total_hours)} 小时`), metric("总课时", displayHours(summary.total_hours, summary.total_duration)),
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`), metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
].join(""); ].join("");
@@ -629,18 +752,33 @@ document.querySelectorAll("[data-query]").forEach((button) => {
}); });
recordRows.addEventListener("click", (event) => { recordRows.addEventListener("click", (event) => {
const summaryButton = event.target.closest(".summary-toggle"); const summarySupplementButton = event.target.closest(".summary-supplement");
const editButton = event.target.closest(".correction-edit"); const editButton = event.target.closest(".correction-edit");
const deleteButton = event.target.closest(".record-delete"); const deleteButton = event.target.closest(".record-delete");
if (summaryButton) { if (summarySupplementButton) {
toggleSummaryRow(summaryButton.dataset.recordKey); openSummarySupplementDialog(summarySupplementButton.dataset.recordKey);
return; return;
} }
if (editButton) { if (editButton) {
openCorrectionDialog(editButton.dataset.recordKey); openCorrectionDialog(editButton.dataset.recordKey);
return; return;
} }
if (deleteButton) openDeleteDialog(deleteButton.dataset.recordKey); if (deleteButton) {
openDeleteDialog(deleteButton.dataset.recordKey);
return;
}
const row = event.target.closest(".record-row");
if (row && !event.target.closest(".record-action-cell")) toggleRecordSummary(row.dataset.recordKey);
});
recordRows.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const row = event.target.closest(".record-row");
if (!row) return;
const target = event.target.closest(".summary-supplement, .correction-edit, .record-delete");
if (target) return;
event.preventDefault();
toggleRecordSummary(row.dataset.recordKey);
}); });
[correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => { [correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => {
@@ -668,6 +806,13 @@ confirmDeleteBtn.addEventListener("click", submitDeleteRecord);
deleteDialog.addEventListener("click", (event) => { deleteDialog.addEventListener("click", (event) => {
if (event.target === deleteDialog) closeDeleteDialog(); if (event.target === deleteDialog) closeDeleteDialog();
}); });
closeSummarySupplementBtn.addEventListener("click", closeSummarySupplementDialog);
cancelSummarySupplementBtn.addEventListener("click", closeSummarySupplementDialog);
summarySupplementDialog.addEventListener("click", (event) => {
if (event.target === summarySupplementDialog) closeSummarySupplementDialog();
});
summarySupplementBody.addEventListener("input", updateSummarySupplementPreview);
summarySupplementForm.addEventListener("submit", submitSummarySupplement);
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !correctionDialog.hidden) { if (event.key === "Escape" && !correctionDialog.hidden) {
closeCorrectionDialog(); closeCorrectionDialog();
@@ -675,6 +820,9 @@ document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !deleteDialog.hidden) { if (event.key === "Escape" && !deleteDialog.hidden) {
closeDeleteDialog(); closeDeleteDialog();
} }
if (event.key === "Escape" && !summarySupplementDialog.hidden) {
closeSummarySupplementDialog();
}
}); });
copyCorrectedBtn.addEventListener("click", copyCorrectedRecords); copyCorrectedBtn.addEventListener("click", copyCorrectedRecords);
+27 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>新时空教务管理系统</title> <title>新时空教务管理系统</title>
<link rel="stylesheet" href="/static/styles.css?v=20260615-record-summary-toggle" /> <link rel="stylesheet" href="/static/styles.css?v=20260618-missing-summary-rail-row" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -140,6 +140,31 @@
</section> </section>
</div> </div>
<script src="/static/app.js?v=20260615-record-summary-toggle"></script> <div id="summarySupplementDialog" class="modal-backdrop" hidden>
<section class="correction-modal summary-supplement-modal" role="dialog" aria-modal="true" aria-labelledby="summarySupplementTitle">
<div class="modal-head">
<h2 id="summarySupplementTitle">补充课程小结</h2>
<button id="closeSummarySupplementBtn" class="modal-close" type="button" aria-label="关闭">关闭</button>
</div>
<form id="summarySupplementForm" class="correction-form">
<p id="summarySupplementOriginal" class="correction-original"></p>
<label class="summary-supplement-body">
小结正文
<textarea id="summarySupplementBody" rows="9" autocomplete="off" placeholder="填写本节课课堂内容、作业、问题和下节安排"></textarea>
</label>
<p id="summarySupplementError" class="correction-error" hidden></p>
<div class="correction-preview">
<span>提交说明</span>
<code id="summarySupplementPreview">提交后会直接保存为这节课的课程小结。</code>
</div>
<div class="modal-actions">
<button id="cancelSummarySupplementBtn" class="secondary-button" type="button">取消</button>
<button id="submitSummarySupplementBtn" type="submit">提交课程小结</button>
</div>
</form>
</section>
</div>
<script src="/static/app.js?v=20260618-record-summary-row"></script>
</body> </body>
</html> </html>
+388 -39
View File
@@ -293,6 +293,10 @@ select:focus {
line-height: 1.2; line-height: 1.2;
} }
.payment-line {
display: block;
}
.inline-account { .inline-account {
padding: 14px 16px; padding: 14px 16px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
@@ -365,6 +369,10 @@ select:focus {
font-weight: 700; font-weight: 700;
} }
.compact-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.span-2 { .span-2 {
grid-column: span 2; grid-column: span 2;
} }
@@ -393,45 +401,155 @@ textarea:focus {
gap: 12px; gap: 12px;
} }
.summary-register-items { .register-preview {
display: grid; display: grid;
gap: 10px;
}
.summary-register-item {
display: grid;
gap: 7px;
}
.summary-register-item.has-missing {
padding: 10px;
border: 1px solid #fecdca;
border-radius: 6px;
background: #fff7f6;
}
.summary-register-item label {
color: #344054;
font-size: 13px;
font-weight: 700;
}
.summary-register-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px; gap: 8px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: #f8fafc;
} }
.summary-register-missing { .register-preview strong {
margin: 0; color: var(--text);
color: var(--danger); font-size: 13px;
}
.register-preview pre {
margin: 0;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: #344054;
font-family: Arial, "Songti SC", SimSun, sans-serif;
font-size: 13px; font-size: 13px;
font-weight: 700;
line-height: 1.45; line-height: 1.45;
} }
.summary-register-remove { .register-preview-head {
justify-self: start; display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.register-preview-head span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.register-line-list {
display: grid;
gap: 6px;
max-height: 220px;
margin: 0;
padding: 0;
overflow: auto;
list-style: none;
}
.register-line-list li {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 8px;
align-items: start;
padding: 8px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
}
.register-line-list span {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 22px;
border-radius: 999px;
background: #e9f5f3;
color: var(--accent-strong);
font-size: 12px;
font-weight: 700;
}
.register-line-list code {
color: #344054;
font-family: Arial, "Songti SC", SimSun, sans-serif;
font-size: 13px;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.register-preview textarea {
min-height: 72px;
}
.register-draft {
display: grid;
gap: 6px;
padding: 8px 10px;
border: 1px dashed var(--line);
border-radius: 6px;
background: #fff;
color: #344054;
font-size: 13px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.register-draft span {
min-height: 22px;
}
.register-draft input,
.register-draft textarea {
width: 100%;
min-width: 0;
padding: 6px 8px;
border: 1px solid var(--line);
border-radius: 4px;
background: #fff;
}
.payment-draft {
grid-template-columns: minmax(90px, 1fr) auto minmax(120px, 1fr) auto minmax(80px, 0.8fr);
align-items: center;
}
.payment-draft b {
color: #667085;
font-weight: 700;
}
.summary-draft pre {
margin: 0;
white-space: pre-wrap;
}
.register-field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.register-field-grid label {
display: grid;
gap: 4px;
color: #52606d;
font-size: 13px;
}
.register-field-grid input,
.register-field-grid textarea {
width: 100%;
box-sizing: border-box;
}
.register-field-grid label:has(textarea) {
grid-column: 1 / -1;
} }
.line-code { .line-code {
@@ -455,7 +573,69 @@ textarea:focus {
} }
.log-detail { .log-detail {
max-width: 460px; max-width: none;
}
.log-row {
cursor: pointer;
}
.log-row:hover td {
background: #f8fafc;
}
.log-row .record-action-cell {
cursor: default;
}
.log-detail-row td {
padding: 0;
background: #fbfcfd;
}
.log-detail-panel {
padding: 14px 16px 16px;
border-top: 1px solid var(--line);
}
.log-detail-group + .log-detail-group {
margin-top: 12px;
}
.log-detail-group h3 {
margin: 0 0 8px;
color: var(--accent-strong);
font-size: 13px;
line-height: 1.4;
}
.log-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
}
.log-detail-field {
display: grid;
gap: 4px;
}
.log-detail-field span {
color: #52606d;
font-size: 12px;
font-weight: 700;
}
.log-detail-field code {
white-space: pre-wrap;
overflow-wrap: anywhere;
color: #344054;
font: inherit;
line-height: 1.5;
}
.log-detail-panel .muted {
color: #98a2b3;
} }
.summary-preview { .summary-preview {
@@ -473,10 +653,62 @@ textarea:focus {
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.summary-toggle { .summary-body + .summary-toggle,
.summary-full + .summary-toggle {
margin-top: 8px; margin-top: 8px;
} }
.summary-search-result-row {
cursor: pointer;
}
.summary-search-result-row:hover td {
background: #f8fafc;
}
.summary-search-detail-row td {
padding: 0;
background: #fbfcfd;
}
.summary-search-detail-panel {
padding: 14px 16px 16px;
border-top: 1px solid var(--line);
}
.summary-search-detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.summary-search-full {
max-width: none;
}
.summary-search-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.summary-search-actions .summary-time-actions {
justify-content: flex-end;
}
.summary-action-note {
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.muted { .muted {
color: var(--muted); color: var(--muted);
} }
@@ -534,8 +766,10 @@ textarea:focus {
.table-wrap { .table-wrap {
max-height: calc(100vh - 292px); max-height: calc(100vh - 292px);
overflow: auto; overflow-x: auto;
overscroll-behavior: contain; overflow-y: auto;
overscroll-behavior-x: contain;
overscroll-behavior-y: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@@ -635,6 +869,18 @@ td {
color: var(--muted); color: var(--muted);
} }
.record-row {
cursor: pointer;
}
.record-row:hover td {
background: #f8fafc;
}
.record-row .record-action-cell {
cursor: default;
}
.corrected-row td { .corrected-row td {
background: #fffaf0; background: #fffaf0;
} }
@@ -680,11 +926,30 @@ td {
background: #fbfcfd; background: #fbfcfd;
} }
.summary-missing-row td {
background: #fcfcfd;
}
.record-summary-item { .record-summary-item {
padding: 12px 16px; padding: 12px 16px;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.record-row.missing-summary td:first-child {
position: relative;
padding-left: 18px;
}
.record-row.missing-summary td:first-child::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 4px;
background: #f59e0b;
}
.record-summary-title { .record-summary-title {
margin-bottom: 8px; margin-bottom: 8px;
color: var(--accent-strong); color: var(--accent-strong);
@@ -706,6 +971,58 @@ td {
font-size: 13px; font-size: 13px;
} }
.summary-missing {
display: grid;
grid-template-columns: repeat(2, 8em);
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 12px 16px 12px 18px;
border-left: 4px solid #f59e0b;
color: var(--muted);
font-size: 13px;
}
.summary-missing > span {
display: inline-flex;
align-items: center;
min-height: 30px;
}
.summary-supplement {
width: 100%;
padding: 0;
}
.summary-supplement-modal {
width: min(100%, 700px);
}
.summary-supplement-body {
display: grid;
gap: 6px;
color: #344054;
font-size: 13px;
font-weight: 700;
}
.summary-supplement-body textarea {
width: 100%;
min-height: 180px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--text);
font: inherit;
resize: vertical;
}
.summary-supplement-body textarea:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15);
}
.correction-badge { .correction-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1011,6 +1328,10 @@ td {
line-height: 1.5; line-height: 1.5;
} }
.summary-supplement-modal .correction-preview code {
font-size: 13px;
}
.modal-actions { .modal-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -1043,6 +1364,9 @@ td {
.table-wrap { .table-wrap {
max-height: none; max-height: none;
overflow-x: auto;
overflow-y: visible;
overscroll-behavior-y: auto;
} }
.inline-account-grid { .inline-account-grid {
@@ -1149,10 +1473,6 @@ td {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.summary-register-fields {
grid-template-columns: 1fr;
}
.admin-tabs { .admin-tabs {
overflow-x: auto; overflow-x: auto;
gap: 6px; gap: 6px;
@@ -1301,4 +1621,33 @@ td {
.admin-tab { .admin-tab {
padding: 0 10px; padding: 0 10px;
} }
.summary-missing {
gap: 8px;
padding: 12px 14px;
grid-template-columns: repeat(2, 8em);
}
.log-detail-panel {
padding: 12px 14px 14px;
}
.summary-search-detail-panel {
padding: 12px 14px 14px;
}
.summary-search-actions,
.summary-search-actions .summary-time-actions {
justify-content: stretch;
}
.summary-search-actions .secondary-button,
.summary-search-actions .summary-time-input {
width: 100%;
}
.log-detail-grid {
grid-template-columns: 1fr;
gap: 10px;
}
} }