Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa6f4a0329 | |||
| 59fcee72bb | |||
| e5e01b9dbb | |||
| 9d9500ed36 | |||
| 2c7dfad2b6 | |||
| 72184004e3 | |||
| 89d592b0bd | |||
| baa54def66 | |||
| 5163714853 | |||
| ccdcb83b60 | |||
| 3128c98d58 | |||
| 77b36a8dd8 | |||
| cc81f82139 | |||
| 25257b0a83 | |||
| 25f32cc44b | |||
| 5f6bf376b2 | |||
| 69644b4ae8 | |||
| 4875a0ee77 | |||
| 6c68eb7152 | |||
| 1a9235408c | |||
| c1d3d19dfc | |||
| a1a8466f6b |
@@ -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,6 +2,8 @@
|
|||||||
|
|
||||||
新时空教务管理系统长期维护时,目标是每次更新都能做到可检查、可部署、可回滚,并且不误改业务数据。
|
新时空教务管理系统长期维护时,目标是每次更新都能做到可检查、可部署、可回滚,并且不误改业务数据。
|
||||||
|
|
||||||
|
以下命令默认在仓库根目录 `/root/新时空教务管理系统` 执行。根目录 `Makefile` 会转发到 `app/` 下的实际应用配置;不要在根目录直接运行裸 `docker compose`,需要直接调用时使用 `docker compose -f app/docker-compose.yml <命令>`。
|
||||||
|
|
||||||
## 日常更新流程
|
## 日常更新流程
|
||||||
|
|
||||||
1. 查看当前改动:
|
1. 查看当前改动:
|
||||||
|
|||||||
@@ -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 应用入口,只负责注册路由和全局异常处理。
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
}
|
||||||
+1164
-66
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 ""),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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 = ""
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>`,
|
||||||
|
|||||||
+69
-19
@@ -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,6 +219,7 @@
|
|||||||
<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>
|
||||||
|
<div class="quick-actions">
|
||||||
<select id="summaryReviewStatus" aria-label="课程小结审核状态筛选">
|
<select id="summaryReviewStatus" aria-label="课程小结审核状态筛选">
|
||||||
<option value="pending">待审核</option>
|
<option value="pending">待审核</option>
|
||||||
<option value="conflict">冲突</option>
|
<option value="conflict">冲突</option>
|
||||||
@@ -226,6 +227,8 @@
|
|||||||
<option value="rejected">已驳回</option>
|
<option value="rejected">已驳回</option>
|
||||||
<option value="">全部状态</option>
|
<option value="">全部状态</option>
|
||||||
</select>
|
</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>
|
||||||
|
|||||||
+616
-200
File diff suppressed because it is too large
Load Diff
+162
-14
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user