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. 查看当前改动:
+16
View File
@@ -15,6 +15,22 @@
- `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。
- `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 应用入口,只负责注册路由和全局异常处理。
+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
SUBJECTS = ["数学", "语文", "英语", "物理", "化学", "生物", "历史", "地理", "政治", "道法"]
SUBJECTS = ["语文", "数学", "英语", "物理", "化学", "生物", "历史", "地理", "政治"]
SUBJECT_ALIASES = {
"": "数学",
"": "语文",
@@ -29,7 +29,7 @@ UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"}
TEACHER_STATUSES = {"在岗", "离职"}
UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"}
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 = {
"student": ("学生", "学员", "孩子", "同学"),
"teacher": ("老师", "教师"),
+2 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi import FastAPI, Request
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")
@@ -19,4 +19,5 @@ app.include_router(health.router)
app.include_router(records.router)
app.include_router(accounts.router)
app.include_router(admin.router)
app.include_router(ai_register.router)
app.include_router(ingest.router)
+58
View File
@@ -20,9 +20,11 @@ from ..data import (
DuplicateRecordError,
account_summary,
account_to_dict,
append_operation_log,
create_account,
create_teacher,
filter_accounts,
parse_class_record_line,
register_class_record_lines,
register_course_summary_texts,
register_payment_lines,
@@ -47,6 +49,16 @@ async def register_class_records(request: Request, _user: str = Depends(verify_a
lines=payload.lines,
line=payload.line,
)
for line in result.get("lines", []):
record = parse_class_record_line(str(line))
append_operation_log(
OPERATION_LOGS_PATH,
str(result.get("operation") or "登记上课记录"),
"完成",
student=record.student,
proposed_line=str(line),
backup_id=str(result.get("backup_id") or ""),
)
except DuplicateRecordError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
@@ -60,6 +72,16 @@ async def register_payments(request: Request, _user: str = Depends(verify_admin_
payload = await read_register_payload(request)
with write_lock:
result = register_payment_lines(ACCOUNTS_PATH, lines=payload.lines, line=payload.line)
for line in result.get("lines", []):
student = str(line).split("-", 1)[0]
append_operation_log(
OPERATION_LOGS_PATH,
str(result.get("operation") or "登记缴费记录"),
"完成",
student=student,
proposed_line=str(line),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -144,6 +166,15 @@ def admin_create_teacher(payload: TeacherPayload, _user: str = Depends(verify_ad
try:
with write_lock:
result = create_teacher(TEACHERS_PATH, payload_to_teacher(payload))
log_operation = str(result.get("operation") or "新增老师档案")
append_operation_log(
OPERATION_LOGS_PATH,
log_operation,
"完成",
teacher_id=str(result.get("teacher", {}).get("teacher_id") or ""),
teacher=str(result.get("teacher", {}).get("name") or ""),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -158,6 +189,15 @@ def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str =
teacher_id,
payload_to_teacher(payload, teacher_id=payload.teacher_id.strip() or teacher_id),
)
log_operation = str(result.get("operation") or "修改老师档案")
append_operation_log(
OPERATION_LOGS_PATH,
log_operation,
"完成",
teacher_id=str(result.get("teacher", {}).get("teacher_id") or ""),
teacher=str(result.get("teacher", {}).get("name") or ""),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -168,6 +208,15 @@ def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_ad
try:
with write_lock:
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:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -178,6 +227,15 @@ def admin_update_account(student_id: str, payload: AccountPayload, _user: str =
try:
with write_lock:
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:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
+142 -8
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import verify_admin_auth
@@ -14,11 +16,17 @@ from ..config import (
from ..data import (
append_operation_log,
approve_admin_task,
create_course_summary_duplicate_review_tasks,
delete_course_summary,
link_existing_course_summary_task,
list_admin_tasks,
list_operation_logs,
migrate_operation_log_labels,
query_course_summaries,
reject_admin_task,
resolve_duplicate_course_summary_task,
rollback_operation_log,
update_course_summary_review_task,
update_course_summary_time,
)
@@ -26,6 +34,16 @@ from ..data import (
router = APIRouter()
def rollback_backup_paths() -> list[Path]:
return [
CLASSNOTES_PATH,
ACCOUNTS_PATH,
ADMIN_TASKS_PATH,
COURSE_SUMMARIES_ROOT,
OPERATION_LOGS_PATH,
]
@router.get("/api/admin/tasks")
def admin_tasks(
status_filter: str = Query("", alias="status"),
@@ -46,15 +64,39 @@ def admin_operation_logs(
student: str = Query(""),
_user: str = Depends(verify_admin_auth),
):
migrate_operation_log_labels(OPERATION_LOGS_PATH)
return list_operation_logs(
OPERATION_LOGS_PATH,
limit=limit,
operation=operation,
status_filter=status_filter,
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")
def admin_course_summaries(
q: str = Query(""),
@@ -91,8 +133,8 @@ def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
task = result.get("task", {})
append_operation_log(
OPERATION_LOGS_PATH,
"admin_task_approve",
"approved",
"审核批准",
"已批准",
task_id=task_id,
task_type=str(task.get("type") 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}
@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")
def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
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)
append_operation_log(
OPERATION_LOGS_PATH,
"admin_task_reject",
"rejected",
"审核驳回",
"已驳回",
task_id=task_id,
task_type=str(task.get("type") 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}
@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")
def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)):
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 ""))
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 ""),
@@ -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)
append_operation_log(
OPERATION_LOGS_PATH,
"admin_course_summary_delete",
"deleted",
"课程小结删除",
"已删除",
summary_id=summary_id,
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 ..auth import verify_ingest_token
from ..ai_register import preview_register
from ..config import (
ACCOUNTS_PATH,
ADMIN_TASKS_PATH,
@@ -19,9 +20,83 @@ from ..schemas import CourseSummaryIngestPayload
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")
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
try:
summaries = _preprocess_summaries_locally([item.dict() for item in payload.summaries])
with write_lock:
result = ingest_course_summaries(
classnotes_path=CLASSNOTES_PATH,
@@ -33,7 +108,7 @@ def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str
batch_id=payload.batch_id,
window=payload.window,
students=payload.students,
summaries=[item.dict() for item in payload.summaries],
summaries=summaries,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+104 -2
View File
@@ -1,17 +1,33 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from ..api_utils import load_accounts, load_records, load_teachers
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 (
account_to_dict,
append_operation_log,
course_summary_semantic_key,
duration_minutes_from_time_range,
find_record_by_identity,
query_public_records,
read_course_summary_state,
save_course_summary_markdown,
sha1_text,
submit_public_correction_tasks,
submit_public_deletion_tasks,
write_course_summary_state,
)
from ..schemas import CorrectionSubmitPayload, DeletionSubmitPayload
from ..schemas import CorrectionSubmitPayload, CourseSummarySupplementPayload, DeletionSubmitPayload
router = APIRouter()
@@ -59,3 +75,89 @@ def submit_deletions(payload: DeletionSubmitPayload, _user: str = Depends(verify
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/course-summaries/supplement")
def supplement_course_summary(payload: CourseSummarySupplementPayload, _user: str = Depends(verify_records_auth)):
body = payload.body.strip()
if not body:
raise HTTPException(status_code=400, detail="课程小结正文不能为空")
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="多条原始登记文本")
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):
date: str
hours: float
@@ -52,9 +60,14 @@ class DeletionSubmitPayload(BaseModel):
items: list[DeletionItemPayload]
class CourseSummarySupplementPayload(BaseModel):
record_id: str
body: str
class CourseSummaryPayload(BaseModel):
source_id: str = ""
student: str
student: str = ""
date_iso: str = ""
date: str = ""
time_range: str = ""
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>课时账户查询</title>
<link rel="stylesheet" href="/static/styles.css?v=20260611-accounts-split" />
<link rel="stylesheet" href="/static/styles.css?v=20260616-duration-text" />
</head>
<body>
<header class="topbar">
@@ -55,6 +55,6 @@
</section>
</main>
<script src="/static/accounts.js?v=20260611-accounts-split"></script>
<script src="/static/accounts.js?v=20260616-duration-text"></script>
</body>
</html>
+10 -3
View File
@@ -7,7 +7,14 @@ const accountMeta = document.querySelector("#accountMeta");
const accountRows = document.querySelector("#accountRows");
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) {
@@ -37,7 +44,7 @@ function statusClass(status) {
function renderPayments(payments) {
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) {
@@ -86,7 +93,7 @@ async function loadAccounts() {
(row) => `<tr>
<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 class="num">${fmtHours(row.remaining)}</td>
<td class="num">${escapeHtml(displayHours(row.remaining, row.remaining_duration))}</td>
<td>${renderPayments(row.payments)}</td>
<td class="note-cell">${escapeHtml(row.note || "")}</td>
</tr>`,
+76 -26
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理后台</title>
<link rel="stylesheet" href="/static/styles.css?v=20260615-summary-time-review" />
<link rel="stylesheet" href="/static/styles.css?v=20260618-summary-search-expand" />
</head>
<body>
<header class="topbar">
@@ -219,13 +219,16 @@
<section id="summariesPanel" class="panel admin-panel" hidden>
<div class="section-head">
<h2>课程小结审核</h2>
<select id="summaryReviewStatus" aria-label="课程小结审核状态筛选">
<option value="pending">待审核</option>
<option value="conflict">冲突</option>
<option value="approved">已批准</option>
<option value="rejected">驳回</option>
<option value="">全部状态</option>
</select>
<div class="quick-actions">
<select id="summaryReviewStatus" aria-label="课程小结审核状态筛选">
<option value="pending">待审核</option>
<option value="conflict">冲突</option>
<option value="approved">批准</option>
<option value="rejected">已驳回</option>
<option value="">全部状态</option>
</select>
<button id="duplicateSummaryScanBtn" class="chip" type="button">扫描重复小结</button>
</div>
</div>
<div id="summaryReviewMeta" class="summary-grid"></div>
<div class="table-wrap">
@@ -264,6 +267,31 @@
<div class="drawer-label">课程信息</div>
<div id="summaryReviewDrawerCourse" class="drawer-text"></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-label">建议登记行</div>
<div id="summaryReviewDrawerLine"></div>
@@ -282,6 +310,8 @@
</div>
</div>
<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="summaryReviewDrawerApprove" type="button">批准</button>
</div>
@@ -314,9 +344,6 @@
<th>时间</th>
<th>学生</th>
<th>老师/科目</th>
<th>标题</th>
<th>小结正文</th>
<th>操作</th>
</tr>
</thead>
<tbody id="summarySearchRows"></tbody>
@@ -330,17 +357,35 @@
<div class="quick-actions">
<select id="logOperation" aria-label="操作类型筛选">
<option value="">全部操作</option>
<option value="course_summary_ingest">小结接收</option>
<option value="admin_task_approve">审核批准</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>
<option value="课程小结删除">课程小结删除</option>
<option value="撤回操作">撤回操作</option>
</select>
<select id="logStatus" aria-label="操作结果筛选">
<option value="">全部结果</option>
<option value="auto_registered">自动入账</option>
<option value="review">待审核</option>
<option value="duplicate">重复</option>
<option value="rejected">失败/驳回</option>
<option value="approved">已批准</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>
</div>
</div>
@@ -358,7 +403,7 @@
<th>结果</th>
<th>学生</th>
<th>批次/任务</th>
<th>详情</th>
<th>操作</th>
</tr>
</thead>
<tbody id="logRows"></tbody>
@@ -374,32 +419,37 @@
<form id="classRegisterForm" class="admin-form">
<h3>上课记录登记</h3>
<textarea id="classRegisterLines" rows="10" placeholder="每行一条上课记录"></textarea>
<div id="classRegisterPreview" class="register-preview" hidden></div>
<p id="classRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions">
<button type="submit">提交上课记录</button>
<button type="submit">生成预览</button>
<button id="classRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
</div>
</form>
<form id="paymentRegisterForm" class="admin-form">
<h3>缴费登记</h3>
<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>
<div class="modal-actions">
<button type="submit">提交缴费记录</button>
<button type="submit">生成预览</button>
<button id="paymentRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
</div>
</form>
<form id="summaryRegisterForm" class="admin-form">
<h3>课程小结登记</h3>
<div id="summaryRegisterItems" class="summary-register-items"></div>
<button id="addSummaryRegisterItemBtn" class="secondary-button" type="button">再填一条</button>
<textarea id="summaryRegisterText" rows="12" placeholder="粘贴一条或多条课程小结原文,学生/日期/时间/老师/科目由系统自动提取"></textarea>
<div id="summaryRegisterPreview" class="register-preview" hidden></div>
<p id="summaryRegisterStatus" class="inline-account-loading"></p>
<div class="modal-actions">
<button type="submit">提交课程小结</button>
<button type="submit">生成预览</button>
<button id="summaryRegisterConfirm" class="secondary-button" type="button" hidden>确认写入</button>
</div>
</form>
</div>
</section>
</main>
<script src="/static/admin.js?v=20260615-summary-time-review"></script>
<script src="/static/admin.js?v=20260618-summary-search-expand"></script>
</body>
</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 cancelDeleteBtn = document.querySelector("#cancelDeleteBtn");
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 COPY_LINE_BREAK = "\r\n";
@@ -40,9 +49,17 @@ let correctedRecords = new Map();
let expandedSummaryRecords = new Set();
let activeCorrectionKey = "";
let activeDeleteKey = "";
let activeSummarySupplementKey = "";
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) {
@@ -63,6 +80,10 @@ function metric(label, value) {
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) {
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 displayRow = getDisplayRecord(row);
const correctedClass = corrected ? " corrected-row" : "";
const summaryClass = Number(row.summary_count || 0) > 0 ? "" : " missing-summary";
const actionLabel = corrected ? "编辑" : "纠错";
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}${summaryClass}" data-record-key="${escapeHtml(key)}" tabindex="0" role="button" aria-expanded="${summaryExpanded ? "true" : "false"}">
<td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
<td>${escapeHtml(displayRow.time)}</td>
<td>${escapeHtml(displayRow.student)}</td>
@@ -194,7 +215,6 @@ function renderRecordRow(row) {
<td class="num">${escapeHtml(displayRow.duration)}</td>
<td class="record-action-cell">
<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 record-delete" type="button" data-record-key="${escapeHtml(key)}">删除</button>
${badge}
@@ -212,6 +232,17 @@ function renderSummaryEntry(summary) {
</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) {
currentRecordOrder = [];
return groupRecordsByTeacher(records)
@@ -220,7 +251,7 @@ function renderGroupedRecords(records) {
<td colspan="7">
<div class="teacher-group-title">
<strong>${escapeHtml(group.teacher)}</strong>
<span>${group.count} 条记录 · ${fmtHours(group.totalHours)} 小时</span>
<span>${group.count} 条记录 · ${displayHours(group.totalHours)}</span>
</div>
</td>
</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"}>
<td colspan="7">${(row.summaries || []).map(renderSummaryEntry).join("")}</td>
</tr>`
: "";
: renderSummaryMissingRow(row._recordKey, expandedSummaryRecords.has(row._recordKey));
return `${renderRecordRow(row)}${summaryRow}`;
})
.join("")}`,
@@ -248,6 +279,10 @@ function toggleSummaryRow(key) {
renderCurrentRecords();
}
function toggleRecordSummary(key) {
toggleSummaryRow(key);
}
function statusClass(status) {
if (status === "欠费") return "debt";
if (status === "预警") return "warning";
@@ -257,7 +292,9 @@ function statusClass(status) {
function renderPayments(payments) {
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) {
@@ -270,9 +307,9 @@ function renderInlineAccount(account) {
<span class="status ${statusClass(account.account_status)}">${escapeHtml(account.account_status)}</span>
</div>
<div class="inline-account-grid">
${metric("剩余课时", fmtHours(account.remaining))}
${metric("剩余课时", displayHours(account.remaining, account.remaining_duration))}
${metric("缴费次数", account.payments_count)}
${metric("缴费记录", renderPayments(account.payments))}
${metricHtml("缴费记录", renderPayments(account.payments))}
${metric("备注", account.note || "无")}
</div>`;
}
@@ -442,6 +479,92 @@ function closeDeleteDialog() {
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() {
const original = findCurrentRecord(activeDeleteKey);
if (!original) return;
@@ -584,7 +707,7 @@ async function queryRecords(query) {
recordMeta.innerHTML = [
metric("识别日期", data.query.date_range),
metric("命中记录", `${summary.count}`),
metric("总课时", `${fmtHours(summary.total_hours)} 小时`),
metric("总课时", displayHours(summary.total_hours, summary.total_duration)),
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
].join("");
@@ -629,18 +752,33 @@ document.querySelectorAll("[data-query]").forEach((button) => {
});
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 deleteButton = event.target.closest(".record-delete");
if (summaryButton) {
toggleSummaryRow(summaryButton.dataset.recordKey);
if (summarySupplementButton) {
openSummarySupplementDialog(summarySupplementButton.dataset.recordKey);
return;
}
if (editButton) {
openCorrectionDialog(editButton.dataset.recordKey);
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) => {
@@ -668,6 +806,13 @@ confirmDeleteBtn.addEventListener("click", submitDeleteRecord);
deleteDialog.addEventListener("click", (event) => {
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) => {
if (event.key === "Escape" && !correctionDialog.hidden) {
closeCorrectionDialog();
@@ -675,6 +820,9 @@ document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !deleteDialog.hidden) {
closeDeleteDialog();
}
if (event.key === "Escape" && !summarySupplementDialog.hidden) {
closeSummarySupplementDialog();
}
});
copyCorrectedBtn.addEventListener("click", copyCorrectedRecords);
+27 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>新时空教务管理系统</title>
<link rel="stylesheet" href="/static/styles.css?v=20260615-record-summary-toggle" />
<link rel="stylesheet" href="/static/styles.css?v=20260618-missing-summary-rail-row" />
</head>
<body>
<header class="topbar">
@@ -140,6 +140,31 @@
</section>
</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>
</html>
+388 -39
View File
@@ -293,6 +293,10 @@ select:focus {
line-height: 1.2;
}
.payment-line {
display: block;
}
.inline-account {
padding: 14px 16px;
border-bottom: 1px solid var(--line);
@@ -365,6 +369,10 @@ select:focus {
font-weight: 700;
}
.compact-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.span-2 {
grid-column: span 2;
}
@@ -393,45 +401,155 @@ textarea:focus {
gap: 12px;
}
.summary-register-items {
.register-preview {
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;
padding: 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: #f8fafc;
}
.summary-register-missing {
margin: 0;
color: var(--danger);
.register-preview strong {
color: var(--text);
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-weight: 700;
line-height: 1.45;
}
.summary-register-remove {
justify-self: start;
.register-preview-head {
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 {
@@ -455,7 +573,69 @@ textarea:focus {
}
.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 {
@@ -473,10 +653,62 @@ textarea:focus {
border-top: 1px solid var(--line);
}
.summary-toggle {
.summary-body + .summary-toggle,
.summary-full + .summary-toggle {
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 {
color: var(--muted);
}
@@ -534,8 +766,10 @@ textarea:focus {
.table-wrap {
max-height: calc(100vh - 292px);
overflow: auto;
overscroll-behavior: contain;
overflow-x: auto;
overflow-y: auto;
overscroll-behavior-x: contain;
overscroll-behavior-y: auto;
-webkit-overflow-scrolling: touch;
}
@@ -635,6 +869,18 @@ td {
color: var(--muted);
}
.record-row {
cursor: pointer;
}
.record-row:hover td {
background: #f8fafc;
}
.record-row .record-action-cell {
cursor: default;
}
.corrected-row td {
background: #fffaf0;
}
@@ -680,11 +926,30 @@ td {
background: #fbfcfd;
}
.summary-missing-row td {
background: #fcfcfd;
}
.record-summary-item {
padding: 12px 16px;
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 {
margin-bottom: 8px;
color: var(--accent-strong);
@@ -706,6 +971,58 @@ td {
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 {
display: inline-flex;
align-items: center;
@@ -1011,6 +1328,10 @@ td {
line-height: 1.5;
}
.summary-supplement-modal .correction-preview code {
font-size: 13px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
@@ -1043,6 +1364,9 @@ td {
.table-wrap {
max-height: none;
overflow-x: auto;
overflow-y: visible;
overscroll-behavior-y: auto;
}
.inline-account-grid {
@@ -1149,10 +1473,6 @@ td {
grid-template-columns: 1fr;
}
.summary-register-fields {
grid-template-columns: 1fr;
}
.admin-tabs {
overflow-x: auto;
gap: 6px;
@@ -1301,4 +1621,33 @@ td {
.admin-tab {
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;
}
}