完善课程小结工作区与后台统计

This commit is contained in:
Codex
2026-06-20 19:28:26 +08:00
parent 4c10265db9
commit 6d410efb5c
9 changed files with 1150 additions and 113 deletions
+27
View File
@@ -49,6 +49,33 @@
- `make logs`:查看最近服务日志。 - `make logs`:查看最近服务日志。
- `make install-gitea-backup`:安装提交后自动推送到 Gitea 的 Git hook。 - `make install-gitea-backup`:安装提交后自动推送到 Gitea 的 Git hook。
## 收尾验证注意事项
收尾阶段要验证“当前运行服务”,不要只验证工作区文件。应用镜像在构建时把源码复制进容器;修改 Python、HTML、JS、CSS 后,如果没有重新执行 `make build` 和 `make up``curl` 到的接口和浏览器加载的页面仍可能是旧镜像内容。
- 部署类验证按依赖顺序执行,不要并行运行 `make up`、`make ps`、`curl`。先等 `make up` 完成,再看 `make ps`,最后访问接口或页面。
- 如果接口返回旧字段或旧页面,先确认是否已重建并重启容器;不要直接把旧响应判断成代码逻辑错误。
- 前端脚本或样式变更后,记得同步更新 HTML 中对应静态资源的 `?v=` 参数,再构建镜像,避免浏览器继续使用缓存。
- 用 `curl` 做收尾验证时优先使用简单命令。需要检查 HTML 内容时,先直接获取页面;如果要配合 `grep`,先在本地确认引号转义正确,避免把 shell 引号错误误判成服务问题。
- 如果 `curl http://127.0.0.1:<端口>` 连接失败,但 `make ps` 显示容器和端口正常,先区分执行环境网络限制和服务异常;必要时再看 `make logs`。
推荐收尾顺序:
```bash
make check
make build
make up
make ps
```
随后再访问具体接口或页面,例如:
```bash
set -a; . ./app/.env
curl -sS -u "admin:${ACCOUNTS_AUTH_PASSWORD}" "http://127.0.0.1:${APP_PORT:-18080}/api/account-health"
curl -sS -u "admin:${ACCOUNTS_AUTH_PASSWORD}" "http://127.0.0.1:${APP_PORT:-18080}/admin"
```
## Gitea 自动备份 ## Gitea 自动备份
新时空教务管理系统推荐把 Gitea SSH 仓库配置为 `origin`,并用 `post-commit` hook 在每次提交后自动推送当前分支。 新时空教务管理系统推荐把 Gitea SSH 仓库配置为 `origin`,并用 `post-commit` hook 在每次提交后自动推送当前分支。
+444 -17
View File
@@ -45,7 +45,8 @@ TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<
COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P<title>.+)$", re.M) COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P<title>.+)$", re.M)
COURSE_SUMMARY_DATE_RE = re.compile(r"(?P<date>\d{4}[.-]\d{1,2}[.-]\d{1,2})") COURSE_SUMMARY_DATE_RE = re.compile(r"(?P<date>\d{4}[.-]\d{1,2}[.-]\d{1,2})")
SUMMARY_FIELD_RE = re.compile(r"^(?P<label>学生|学员|日期|上课日期|时间|上课时间|老师|教师|科目|课程|班级|分组|正文|内容|小结)[::]\s*(?P<value>.*)$") SUMMARY_FIELD_RE = re.compile(r"^(?P<label>学生|学员|日期|上课日期|时间|上课时间|老师|教师|科目|课程|班级|分组|正文|内容|小结)[::]\s*(?P<value>.*)$")
DATE_RANGE_SEPARATOR = r"(?:到|至||-|~|—|)" TIME_RANGE_SEPARATOR = r"[--–—~到至‐‑‒]"
DATE_RANGE_SEPARATOR = r"(?:到|至||-|~|—||||)"
CHINESE_DATE_RANGE_RE = re.compile( CHINESE_DATE_RANGE_RE = re.compile(
rf"(?:(?P<sy>\d{{4}})\s*年\s*)?" rf"(?:(?P<sy>\d{{4}})\s*年\s*)?"
rf"(?P<sm>\d{{1,2}})\s*月\s*(?P<sd>\d{{1,2}})\s*[日号]?\s*" rf"(?P<sm>\d{{1,2}})\s*月\s*(?P<sd>\d{{1,2}})\s*[日号]?\s*"
@@ -874,7 +875,47 @@ def write_admin_tasks(path: Path, tasks: dict) -> None:
def task_to_dict(task: dict) -> dict: def task_to_dict(task: dict) -> dict:
return dict(task) item = dict(task)
if item.get("type") == "course_summary_review":
summary = item.get("summary") or {}
if isinstance(summary, dict) and not summary.get("time_range"):
suggested_time = parse_course_summary_time_text(
"\n".join(
str(value or "")
for value in (summary.get("title"), summary.get("body"))
if value
)
)
if suggested_time:
item["suggested_time_range"] = suggested_time
minutes = duration_minutes_from_time_range(suggested_time)
if minutes is not None:
item["suggested_duration"] = duration_text_from_minutes(minutes)
return item
def course_summary_duplicate_candidates_with_binding(
candidates: list[dict],
records: list[ClassRecord],
) -> list[dict]:
record_keys = {class_record_binding_key(record): record for record in records}
return [
{
**candidate,
"binding": course_summary_binding_status(candidate, record_keys, records),
}
for candidate in candidates
]
def task_to_dict_with_context(task: dict, records: list[ClassRecord] | None = None) -> dict:
item = task_to_dict(task)
if records is not None and item.get("type") == "course_summary_duplicate_review":
item["duplicate_candidates"] = course_summary_duplicate_candidates_with_binding(
item.get("duplicate_candidates") or [],
records,
)
return item
def find_admin_task(tasks: dict, task_id: int) -> dict: def find_admin_task(tasks: dict, task_id: int) -> dict:
@@ -962,6 +1003,22 @@ def resolve_teacher_input(value: str, teachers: list[Teacher]) -> str:
return canonical_name(text) return canonical_name(text)
def resolve_existing_teacher_input(value: str, teachers: list[Teacher]) -> str:
text = value.strip()
if not text:
raise ValueError("老师不能为空")
matches = [
teacher
for teacher in teachers
if text in {teacher.teacher_id, teacher.name, teacher.alias}
]
if len(matches) == 1:
return matches[0].name
if len(matches) > 1:
raise ValueError(f"老师别名不唯一,请在后台修正别名: {text}")
raise ValueError(f"老师不存在,请先在教师档案中维护: {text}")
def class_record_from_public_item(original: ClassRecord, item: dict, teachers: list[Teacher]) -> ClassRecord: def class_record_from_public_item(original: ClassRecord, item: dict, teachers: list[Teacher]) -> ClassRecord:
date_text = str(item.get("date") or original.date) date_text = str(item.get("date") or original.date)
time_text = str(item.get("time") or original.time) time_text = str(item.get("time") or original.time)
@@ -1011,18 +1068,27 @@ def submit_public_deletion_tasks(tasks_path: Path, records: list[ClassRecord], i
return submit_deletion_tasks(tasks_path, internal_items) return submit_deletion_tasks(tasks_path, internal_items)
def list_admin_tasks(tasks_path: Path, status_filter: str = "", task_type: str = "") -> dict: def list_admin_tasks(
tasks_path: Path,
status_filter: str = "",
task_type: str = "",
classnotes_path: Path | None = None,
) -> dict:
tasks = read_admin_tasks(tasks_path) tasks = read_admin_tasks(tasks_path)
items = tasks["items"] items = tasks["items"]
if status_filter: if status_filter:
items = [item for item in items if item.get("status") == status_filter] items = [item for item in items if item.get("status") == status_filter]
if task_type: if task_type:
items = [item for item in items if item.get("type") == task_type] items = [item for item in items if item.get("type") == task_type]
records = read_classnotes(classnotes_path) if classnotes_path is not None and classnotes_path.exists() else None
return { return {
"version": tasks["version"], "version": tasks["version"],
"next_id": tasks["next_id"], "next_id": tasks["next_id"],
"count": len(items), "count": len(items),
"items": [task_to_dict(item) for item in sorted(items, key=lambda item: int(item.get("id", 0)), reverse=True)], "items": [
task_to_dict_with_context(item, records)
for item in sorted(items, key=lambda item: int(item.get("id", 0)), reverse=True)
],
} }
@@ -1057,6 +1123,7 @@ def duplicate_review_task_from_group(task_id: int, group: dict, now: str) -> dic
def create_course_summary_duplicate_review_tasks( def create_course_summary_duplicate_review_tasks(
tasks_path: Path, tasks_path: Path,
summaries_root: Path, summaries_root: Path,
classnotes_path: Path | None = None,
target_key: tuple[str, str, str, str, str] | None = None, target_key: tuple[str, str, str, str, str] | None = None,
) -> dict: ) -> dict:
tasks = read_admin_tasks(tasks_path) tasks = read_admin_tasks(tasks_path)
@@ -1100,11 +1167,12 @@ def create_course_summary_duplicate_review_tasks(
created.append(task) created.append(task)
if created or changed: if created or changed:
write_admin_tasks(tasks_path, tasks) write_admin_tasks(tasks_path, tasks)
records = read_classnotes(classnotes_path) if classnotes_path is not None and classnotes_path.exists() else None
return { return {
"scanned": len(groups), "scanned": len(groups),
"created": len(created), "created": len(created),
"refreshed": refreshed, "refreshed": refreshed,
"items": [task_to_dict(task) for task in created], "items": [task_to_dict_with_context(task, records) for task in created],
} }
@@ -1329,6 +1397,9 @@ def normalize_time_range_text(value: object) -> str:
text = str(value or "").strip() text = str(value or "").strip()
if not text: if not text:
return "" return ""
text = re.sub(r"[.]", ":", text)
text = re.sub(TIME_RANGE_SEPARATOR, "-", text)
text = re.sub(r"\s+", "", text)
match = TIME_RANGE_RE.fullmatch(text) match = TIME_RANGE_RE.fullmatch(text)
if not match: if not match:
raise ValueError(f"课程小结时间段格式错误: {text}") raise ValueError(f"课程小结时间段格式错误: {text}")
@@ -1629,6 +1700,7 @@ OPERATION_LABELS = {
"admin-approve-deletion": "审核批准上课记录删除", "admin-approve-deletion": "审核批准上课记录删除",
"admin-update-course-summary-time": "课程小结补齐时间", "admin-update-course-summary-time": "课程小结补齐时间",
"admin-update-course-summary-body": "课程小结修改正文", "admin-update-course-summary-body": "课程小结修改正文",
"admin-update-course-summary-identity": "课程小结修改归属",
"admin-delete-course-summary": "课程小结删除", "admin-delete-course-summary": "课程小结删除",
"rollback-operation": "撤回操作", "rollback-operation": "撤回操作",
} }
@@ -2042,9 +2114,8 @@ def parse_course_summary_title_date(title: str) -> str:
def parse_course_summary_time_text(text: str) -> str: def parse_course_summary_time_text(text: str) -> str:
separators = r"[--–—~到至]"
colon_match = re.search( colon_match = re.search(
rf"(\d{{1,2}})\s*[:.]\s*(\d{{1,2}})\d*\s*{separators}\s*(\d{{1,2}})\s*[:.]\s*(\d{{1,2}})\d*", rf"(\d{{1,2}})\s*[:.]\s*(\d{{1,2}})\d*\s*{TIME_RANGE_SEPARATOR}\s*(\d{{1,2}})\s*[:.]\s*(\d{{1,2}})\d*",
text, text,
) )
if colon_match: if colon_match:
@@ -2052,7 +2123,7 @@ def parse_course_summary_time_text(text: str) -> str:
candidate = f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}" candidate = f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}"
else: else:
point_match = re.search( point_match = re.search(
rf"(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?\s*{separators}\s*(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?", rf"(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?\s*{TIME_RANGE_SEPARATOR}\s*(\d{{1,2}})\s*点\s*(\d{{1,2}})?(?:分)?",
text, text,
) )
if not point_match: if not point_match:
@@ -2406,6 +2477,150 @@ def course_summary_index_for_records(root: Path) -> dict[tuple[str, str, str, st
return index return index
def class_record_binding_key(record: ClassRecord) -> tuple[str, str, str, str, str]:
return course_summary_record_key(
record.student,
record.teacher,
record.subject,
record.date.replace(".", "-"),
record.time,
)
def course_summary_record_snapshot(record: ClassRecord) -> dict:
return {
"record_id": record_identity(record),
"date": record.date,
"date_iso": record.date.replace(".", "-"),
"weekday": record.weekday,
"time": record.time,
"student": record.student,
"duration": record.duration,
"duration_hours": record.duration_hours,
"teacher": record.teacher,
"subject": record.subject,
}
def course_summary_binding_diff(item: dict, record: ClassRecord) -> list[str]:
differences: list[str] = []
item_key = course_summary_record_key(
str(item.get("student") or ""),
str(item.get("teacher") or ""),
str(item.get("subject") or ""),
str(item.get("date_iso") or ""),
str(item.get("time_range") or ""),
)
record_key = class_record_binding_key(record)
labels = ["学生", "老师", "科目", "日期", "时间"]
for label, item_value, record_value in zip(labels, item_key, record_key):
if item_value != record_value:
differences.append(f"{label}不一致")
return differences
def course_summary_candidate_score(item: dict, record: ClassRecord) -> tuple[int, int, int, int, int, str, str]:
item_student = canonical_name(str(item.get("student") or ""))
item_teacher = canonical_teacher_name(str(item.get("teacher") or ""))
item_subject = normalize_subject(str(item.get("subject") or ""))
item_date = str(item.get("date_iso") or "")
item_time = normalize_time_range_text(str(item.get("time_range") or "")) if item.get("time_range") else ""
record_date = record.date.replace(".", "-")
record_subject = normalize_subject(record.subject)
score = 0
score += 40 if item_student and item_student == record.student else 0
score += 25 if item_date and item_date == record_date else 0
score += 15 if item_teacher and item_teacher == record.teacher else 0
score += 12 if item_subject and item_subject == record_subject else 0
score += 8 if item_time and item_time == record.time else 0
same_day_student = 1 if item_student and item_date and item_student == record.student and item_date == record_date else 0
same_day = 1 if item_date and item_date == record_date else 0
same_student = 1 if item_student and item_student == record.student else 0
return (score, same_day_student, same_day, same_student, -abs(len(record.subject) - len(item_subject)), record.date, record.time)
def course_summary_binding_reason(item: dict, candidates: list[dict]) -> str:
if not str(item.get("date_iso") or ""):
return "课程小结缺少可识别日期"
if not str(item.get("time_range") or ""):
return "课程小结缺少时间,无法完成五元组绑定"
if not str(item.get("student") or ""):
return "课程小结缺少学生"
if not str(item.get("teacher") or ""):
return "课程小结缺少老师"
if not str(item.get("subject") or ""):
return "课程小结缺少科目"
if candidates:
return "未找到完全一致的上课记录,可按候选记录修正小结字段"
return "未找到相同学生和日期的上课记录"
def course_summary_binding_status(item: dict, record_keys: dict[tuple[str, str, str, str, str], ClassRecord], records: list[ClassRecord]) -> dict:
try:
summary_key = course_summary_duplicate_key(item)
except ValueError:
summary_key = ("", "", "", "", "")
if summary_key and all(summary_key) and summary_key in record_keys:
record = record_keys[summary_key]
return {
"status": "matched",
"label": "已绑定",
"record": course_summary_record_snapshot(record),
"candidates": [],
"reason": "",
"differences": [],
}
item_student = canonical_name(str(item.get("student") or ""))
item_date = str(item.get("date_iso") or "")
same_student_date = [
record
for record in records
if item_student and item_date and record.student == item_student and record.date.replace(".", "-") == item_date
]
same_date = [
record
for record in records
if item_date and record.date.replace(".", "-") == item_date
]
same_student = [
record
for record in records
if item_student and record.student == item_student
]
candidate_pool = same_student_date or same_date or same_student
scored: list[tuple[tuple[int, int, int, int, int, str, str], ClassRecord]] = []
for record in candidate_pool:
score = course_summary_candidate_score(item, record)
if score[0] <= 0:
continue
scored.append((score, record))
scored.sort(key=lambda pair: pair[0], reverse=True)
candidates = [
{
**course_summary_record_snapshot(record),
"differences": course_summary_binding_diff(item, record),
}
for _score, record in scored[:3]
]
status = "missing_time" if not str(item.get("time_range") or "") else "unmatched"
if candidates and str(item.get("time_range") or ""):
status = "mismatch"
labels = {
"missing_time": "缺时间",
"mismatch": "字段不一致",
"unmatched": "未绑定",
}
return {
"status": status,
"label": labels[status],
"record": None,
"candidates": candidates,
"reason": course_summary_binding_reason(item, candidates),
"differences": candidates[0]["differences"] if candidates else [],
}
def query_course_summaries( def query_course_summaries(
root: Path, root: Path,
classnotes_path: Path | None = None, classnotes_path: Path | None = None,
@@ -2416,12 +2631,21 @@ def query_course_summaries(
date_from: str = "", date_from: str = "",
date_to: str = "", date_to: str = "",
missing_time: bool = False, missing_time: bool = False,
binding_status: str = "",
has_candidate: str = "",
limit: int = 200, limit: int = 200,
) -> dict: ) -> dict:
normalized_from = normalize_filter_date(date_from) normalized_from = normalize_filter_date(date_from)
normalized_to = normalize_filter_date(date_to) normalized_to = normalize_filter_date(date_to)
if normalized_from and normalized_to and normalized_from > normalized_to: if normalized_from and normalized_to and normalized_from > normalized_to:
raise ValueError("开始日期不能晚于结束日期") raise ValueError("开始日期不能晚于结束日期")
normalized_binding_status = binding_status.strip()
allowed_binding_statuses = {"", "matched", "unmatched", "missing_time", "mismatch"}
if normalized_binding_status not in allowed_binding_statuses:
raise ValueError("绑定状态筛选无效")
normalized_has_candidate = has_candidate.strip().lower()
if normalized_has_candidate not in {"", "true", "false"}:
raise ValueError("候选记录筛选无效")
keyword = q.strip() keyword = q.strip()
matched = [ matched = [
item item
@@ -2440,17 +2664,34 @@ def query_course_summaries(
for item in matched: for item in matched:
item["matched_fields"] = course_summary_matched_fields(item, keyword) item["matched_fields"] = course_summary_matched_fields(item, keyword)
item["matched_record"] = False item["matched_record"] = False
if classnotes_path is not None and classnotes_path.exists(): item["binding"] = {
record_keys = { "status": "unchecked",
course_summary_record_key(record.student, record.teacher, record.subject, record.date.replace(".", "-"), record.time) "label": "未检查",
for record in read_classnotes(classnotes_path) "record": None,
"candidates": [],
"reason": "未读取上课记录",
"differences": [],
} }
if classnotes_path is not None and classnotes_path.exists():
records = read_classnotes(classnotes_path)
record_keys = {class_record_binding_key(record): record for record in records}
for item in matched: for item in matched:
try: binding = course_summary_binding_status(item, record_keys, records)
key = course_summary_duplicate_key(item) item["binding"] = binding
except ValueError: item["matched_record"] = binding["status"] == "matched"
key = ("", "", "", "", "") if normalized_binding_status:
item["matched_record"] = bool(key and all(key) and key in record_keys) matched = [
item
for item in matched
if str((item.get("binding") or {}).get("status") or "") == normalized_binding_status
]
if normalized_has_candidate:
expect_candidate = normalized_has_candidate == "true"
matched = [
item
for item in matched
if bool((item.get("binding") or {}).get("candidates") or []) == expect_candidate
]
matched.sort( matched.sort(
key=lambda item: ( key=lambda item: (
str(item.get("date_iso") or "0000-00-00"), str(item.get("date_iso") or "0000-00-00"),
@@ -2526,6 +2767,75 @@ def replace_course_summary_body(root: Path, path: Path, summary_id: str, new_bod
raise ValueError("未找到课程小结") raise ValueError("未找到课程小结")
def course_summary_block_payload(root: Path, path: Path, summary_id: str) -> dict:
text = path.read_text(encoding="utf-8")
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text))
identity = parse_course_summary_file_identity(root, path)
group = ""
if matches:
group_match = re.search(r"^##\s+(.+)$", text[: matches[0].start()], flags=re.M)
if group_match:
group = group_match.group(1).strip()
for index, match in enumerate(matches):
title = match.group("title").strip()
start = match.start()
body_start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
raw_body = text[body_start:end].strip()
body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", raw_body).strip()
body_text = body_without_meta or raw_body
item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20)
if item_id != summary_id:
continue
return {
**identity,
"id": item_id,
"title": title,
"index": index,
"start": start,
"end": end,
"group": group,
"raw_body": raw_body,
"body": body_text,
"date_iso": parse_course_summary_title_date(title),
"time_range": parse_course_summary_time_text(f"{title}\n{body_text}"),
"source_id": source_match.group(1) if (source_match := re.search(r"来源ID`([^`]+)`", raw_body)) else "",
"message_time": sent_at_match.group(1) if (sent_at_match := re.search(r"发送时间:`([^`]+)`", raw_body)) else "",
"sender": sender_match.group(1) if (sender_match := re.search(r"发送者:`([^`]+)`", raw_body)) else "",
}
raise ValueError("未找到课程小结")
def remove_course_summary_block_text(text: str, block: dict) -> str:
start = int(block["start"])
end = int(block["end"])
return (text[:start].rstrip() + "\n\n" + text[end:].lstrip()).rstrip() + "\n"
def course_summary_identity_markdown_text(root: Path, existing: str, path: Path, item: dict) -> tuple[str, str, str]:
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(existing))
existing_headings = {match.group("title").strip() for match in matches}
title = str(item.get("title") or "").strip()
if not title:
raise ValueError("课程小结标题不能为空")
heading = title if title not in existing_headings else f"{title}{sha1_text(str(item.get('source_id') or '') + str(item.get('body') or ''), 8)}"
group_name = str(item.get("group") or item.get("student") or "").strip()
raw_body = str(item.get("raw_body") or item.get("body") or "").strip()
lines: list[str] = []
if not existing.strip():
lines.extend([f"# {path.stem}", "", f"## {group_name}", ""])
elif group_name and f"## {group_name}" not in existing:
lines.extend(["", f"## {group_name}", ""])
lines.extend([f"### {heading}", "", raw_body, ""])
prefix = existing
if existing and not existing.endswith("\n"):
prefix += "\n"
new_text = prefix + "\n".join(lines).rstrip() + "\n"
body_text = re.sub(r"^(?:>\s+.*\n)+\s*", "", raw_body).strip() or raw_body
relative_path = str(path.relative_to(root))
return new_text, heading, sha1_text(f"{relative_path}|{heading}|{len(matches)}|{body_text[:200]}", 20)
def find_course_summary_item(root: Path, summary_id: str) -> dict: def find_course_summary_item(root: Path, summary_id: str) -> dict:
for item in iter_course_summary_markdown(root): for item in iter_course_summary_markdown(root):
if str(item.get("id") or "") == summary_id: if str(item.get("id") or "") == summary_id:
@@ -2578,6 +2888,123 @@ def update_course_summary_body(root: Path, summary_id: str, body: str) -> dict:
return result return result
def update_course_summary_identity(
root: Path,
state_path: Path,
teachers_path: Path,
summary_id: str,
student: str,
teacher: str,
subject: str = "",
) -> dict:
new_student = canonical_name(str(student or "").strip())
if not new_student:
raise ValueError("学生不能为空")
new_teacher = resolve_existing_teacher_input(str(teacher or ""), read_teachers(teachers_path))
item = find_course_summary_item(root, summary_id)
new_subject = normalize_subject(str(subject or "").strip()) or normalize_subject(str(item.get("subject") or ""))
if not new_subject:
raise ValueError("科目不能为空")
source_path = Path(str(item.get("source_path") or ""))
block = course_summary_block_payload(root, source_path, summary_id)
old_student = str(item.get("student") or "")
old_teacher = str(item.get("teacher") or "")
old_subject = normalize_subject(str(item.get("subject") or ""))
if new_student == old_student and new_teacher == old_teacher and new_subject == old_subject:
raise ValueError("学生、老师和科目没有变化")
title = str(block.get("title") or "")
if new_subject != old_subject:
updated_title = title.replace(old_subject, new_subject, 1) if old_subject else title
if updated_title == title:
time_range = str(item.get("time_range") or "")
date_iso = str(item.get("date_iso") or "")
updated_title = f"{date_iso} {time_range + ' ' if time_range else ''}{new_subject}课堂小结".strip()
block = {**block, "title": updated_title}
moved_item = {
**block,
"student": new_student,
"teacher": new_teacher,
"subject": new_subject,
"date_iso": str(item.get("date_iso") or ""),
"time_range": str(item.get("time_range") or ""),
"duration_minutes": duration_minutes_from_time_range(str(item.get("time_range") or "")),
}
target_path = course_summary_path(root, moved_item)
source_text = source_path.read_text(encoding="utf-8")
target_exists = target_path.exists()
target_text = target_path.read_text(encoding="utf-8") if target_exists else ""
new_source_text = remove_course_summary_block_text(source_text, block)
target_base_text = new_source_text if target_path == source_path else target_text
new_target_text, heading, new_id = course_summary_identity_markdown_text(root, target_base_text, target_path, moved_item)
state = read_course_summary_state(state_path)
original_state_text = state_path.read_text(encoding="utf-8") if state_path.exists() else json.dumps(default_course_summary_state(), ensure_ascii=False, indent=2) + "\n"
semantic_keys = {str(value) for value in state.get("seen_semantic_keys", [])}
old_key_item = {
**moved_item,
"student": old_student,
"teacher": old_teacher,
"subject": old_subject,
}
semantic_keys.discard(course_summary_semantic_key(old_key_item))
semantic_keys.discard(course_summary_semantic_key({
**item,
"duration_minutes": duration_minutes_from_time_range(str(item.get("time_range") or "")),
}))
semantic_keys.add(course_summary_semantic_key(moved_item))
state["seen_semantic_keys"] = sorted(semantic_keys)
new_state_text = json.dumps(state, ensure_ascii=False, indent=2) + "\n"
backup_contents = {
source_path: source_text,
state_path: original_state_text,
}
if target_path != source_path:
backup_contents[target_path] = target_text
backup_dir = create_data_backup(
"admin-update-course-summary-identity",
backup_contents,
[summary_id, old_student, old_teacher, old_subject, new_student, new_teacher, new_subject],
)
try:
if target_path == source_path:
atomic_write_text(source_path, new_target_text)
else:
atomic_write_text(source_path, new_source_text)
target_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_text(target_path, new_target_text)
atomic_write_text(state_path, new_state_text)
except Exception:
atomic_write_text(source_path, source_text)
if target_path != source_path:
if target_exists:
atomic_write_text(target_path, target_text)
elif target_path.exists():
target_path.unlink()
atomic_write_text(state_path, original_state_text)
raise
try:
prune_data_backups(backup_dir.parent)
except OSError:
pass
return {
"id": summary_id,
"new_id": new_id,
"old_student": old_student,
"old_teacher": old_teacher,
"old_subject": old_subject,
"student": new_student,
"teacher": new_teacher,
"subject": new_subject,
"old_path": str(source_path),
"new_path": str(target_path),
"heading": heading,
"backup_id": backup_dir.name,
}
def delete_course_summary(root: Path, summary_id: str) -> dict: def delete_course_summary(root: Path, summary_id: str) -> dict:
item = find_course_summary_item(root, summary_id) item = find_course_summary_item(root, summary_id)
path = Path(str(item.get("source_path") or "")) path = Path(str(item.get("source_path") or ""))
+24
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ..api_utils import file_meta, load_accounts, load_teachers, payload_to_account, payload_to_teacher, read_register_payload from ..api_utils import file_meta, load_accounts, load_teachers, payload_to_account, payload_to_teacher, read_register_payload
@@ -23,8 +25,11 @@ from ..data import (
append_operation_log, append_operation_log,
create_account, create_account,
create_teacher, create_teacher,
duration_text_from_hours,
filter_accounts, filter_accounts,
iter_course_summary_markdown,
parse_class_record_line, parse_class_record_line,
read_classnotes,
register_class_record_lines, register_class_record_lines,
register_course_summary_texts, register_course_summary_texts,
register_payment_lines, register_payment_lines,
@@ -38,6 +43,11 @@ from ..schemas import AccountPayload, TeacherPayload
router = APIRouter() router = APIRouter()
def compact_duration_text(hours: float) -> str:
text = duration_text_from_hours(hours)
return text.removesuffix("0分")
@router.post("/api/register/class-records") @router.post("/api/register/class-records")
async def register_class_records(request: Request, _user: str = Depends(verify_admin_auth)): async def register_class_records(request: Request, _user: str = Depends(verify_admin_auth)):
try: try:
@@ -111,12 +121,26 @@ async def register_course_summaries(request: Request, _user: str = Depends(verif
def account_health(_user: str = Depends(verify_accounts_auth)): def account_health(_user: str = Depends(verify_accounts_auth)):
accounts = load_accounts() accounts = load_accounts()
teachers = load_teachers() teachers = load_teachers()
records = read_classnotes(CLASSNOTES_PATH) if CLASSNOTES_PATH.exists() else []
current_month_prefix = date.today().strftime("%Y.%m.")
current_month_hours = round(
sum(record.duration_hours for record in records if record.date.startswith(current_month_prefix)),
2,
)
course_summaries_count = sum(1 for _item in iter_course_summary_markdown(COURSE_SUMMARIES_ROOT))
return { return {
"ok": True, "ok": True,
"accounts": file_meta(ACCOUNTS_PATH), "accounts": file_meta(ACCOUNTS_PATH),
"teachers": file_meta(TEACHERS_PATH), "teachers": file_meta(TEACHERS_PATH),
"classnotes": file_meta(CLASSNOTES_PATH),
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
"accounts_count": len(accounts), "accounts_count": len(accounts),
"teachers_count": len(teachers), "teachers_count": len(teachers),
"active_teachers_count": sum(1 for teacher in teachers if teacher.status == "在岗"),
"records_count": len(records),
"course_summaries_count": course_summaries_count,
"current_month_hours": current_month_hours,
"current_month_duration": compact_duration_text(current_month_hours),
"account_summary": account_summary(accounts), "account_summary": account_summary(accounts),
} }
+52 -2
View File
@@ -12,6 +12,7 @@ from ..config import (
COURSE_SUMMARIES_ROOT, COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH, COURSE_SUMMARY_STATE_PATH,
OPERATION_LOGS_PATH, OPERATION_LOGS_PATH,
TEACHERS_PATH,
write_lock, write_lock,
) )
from ..data import ( from ..data import (
@@ -28,6 +29,7 @@ from ..data import (
resolve_duplicate_course_summary_task, resolve_duplicate_course_summary_task,
rollback_operation_log, rollback_operation_log,
update_course_summary_body, update_course_summary_body,
update_course_summary_identity,
update_course_summary_review_task, update_course_summary_review_task,
update_course_summary_time, update_course_summary_time,
) )
@@ -53,7 +55,12 @@ def admin_tasks(
_user: str = Depends(verify_admin_auth), _user: str = Depends(verify_admin_auth),
): ):
try: try:
return list_admin_tasks(ADMIN_TASKS_PATH, status_filter=status_filter, task_type=task_type) return list_admin_tasks(
ADMIN_TASKS_PATH,
status_filter=status_filter,
task_type=task_type,
classnotes_path=CLASSNOTES_PATH,
)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
@@ -108,6 +115,8 @@ def admin_course_summaries(
date_from: str = Query(""), date_from: str = Query(""),
date_to: str = Query(""), date_to: str = Query(""),
missing_time: bool = Query(False), missing_time: bool = Query(False),
binding_status: str = Query(""),
has_candidate: str = Query(""),
limit: int = Query(200, ge=1, le=1000), limit: int = Query(200, ge=1, le=1000),
_user: str = Depends(verify_admin_auth), _user: str = Depends(verify_admin_auth),
): ):
@@ -122,6 +131,8 @@ def admin_course_summaries(
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
missing_time=missing_time, missing_time=missing_time,
binding_status=binding_status,
has_candidate=has_candidate,
limit=limit, limit=limit,
) )
except ValueError as exc: except ValueError as exc:
@@ -250,7 +261,11 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
def admin_scan_duplicate_course_summaries(_user: str = Depends(verify_admin_auth)): def admin_scan_duplicate_course_summaries(_user: str = Depends(verify_admin_auth)):
try: try:
with write_lock: with write_lock:
result = create_course_summary_duplicate_review_tasks(ADMIN_TASKS_PATH, COURSE_SUMMARIES_ROOT) result = create_course_summary_duplicate_review_tasks(
ADMIN_TASKS_PATH,
COURSE_SUMMARIES_ROOT,
classnotes_path=CLASSNOTES_PATH,
)
append_operation_log( append_operation_log(
OPERATION_LOGS_PATH, OPERATION_LOGS_PATH,
"重复小结扫描", "重复小结扫描",
@@ -298,6 +313,41 @@ def admin_update_course_summary_body(summary_id: str, payload: dict, _user: str
return {"ok": True, **result} return {"ok": True, **result}
@router.post("/api/admin/course-summaries/{summary_id}/identity")
def admin_update_course_summary_identity(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = update_course_summary_identity(
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
TEACHERS_PATH,
summary_id,
str(payload.get("student") or ""),
str(payload.get("teacher") or ""),
str(payload.get("subject") or ""),
)
append_operation_log(
OPERATION_LOGS_PATH,
"课程小结修改归属",
"已更新",
summary_id=summary_id,
new_summary_id=str(result.get("new_id") or ""),
student=str(result.get("student") or ""),
old_student=str(result.get("old_student") or ""),
teacher=str(result.get("teacher") or ""),
old_teacher=str(result.get("old_teacher") or ""),
subject=str(result.get("subject") or ""),
old_subject=str(result.get("old_subject") or ""),
old_path=str(result.get("old_path") or ""),
new_path=str(result.get("new_path") or ""),
heading=str(result.get("heading") or ""),
backup_id=str(result.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.delete("/api/admin/course-summaries/{summary_id}") @router.delete("/api/admin/course-summaries/{summary_id}")
def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_admin_auth)): def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_admin_auth)):
try: try:
+51 -38
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理后台</title> <title>管理后台</title>
<link rel="stylesheet" href="/static/styles.css?v=20260618-summary-duplicate-review" /> <link rel="stylesheet" href="/static/styles.css?v=20260620-summary-workspace" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -24,7 +24,6 @@
<button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button> <button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button>
<button class="admin-tab" data-admin-tab="teachers" type="button">老师档案</button> <button class="admin-tab" data-admin-tab="teachers" type="button">老师档案</button>
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button> <button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
<button class="admin-tab" data-admin-tab="summaries" type="button">课程小结审核</button>
<button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结查询</button> <button class="admin-tab" data-admin-tab="summarySearch" type="button">课程小结查询</button>
<button class="admin-tab" data-admin-tab="logs" type="button">操作记录</button> <button class="admin-tab" data-admin-tab="logs" type="button">操作记录</button>
<button class="admin-tab" data-admin-tab="register" type="button">登记</button> <button class="admin-tab" data-admin-tab="register" type="button">登记</button>
@@ -216,38 +215,7 @@
</div> </div>
</section> </section>
<section id="summariesPanel" class="panel admin-panel" hidden> <section id="summariesPanel" class="panel admin-panel" hidden></section>
<div class="section-head">
<h2>课程小结审核</h2>
<div class="quick-actions">
<select id="summaryReviewStatus" aria-label="课程小结审核状态筛选">
<option value="pending">待审核</option>
<option value="conflict">冲突</option>
<option value="approved">已批准</option>
<option value="rejected">已驳回</option>
<option value="">全部状态</option>
</select>
<button id="duplicateSummaryScanBtn" class="chip" type="button">扫描重复小结</button>
</div>
</div>
<div id="summaryReviewMeta" class="summary-grid"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>编号</th>
<th>状态</th>
<th>课程信息</th>
<th>候选记录</th>
<th>小结原文</th>
<th>原因</th>
<th>操作</th>
</tr>
</thead>
<tbody id="summaryReviewRows"></tbody>
</table>
</div>
</section>
<div id="summaryReviewDrawerBackdrop" class="drawer-backdrop" hidden> <div id="summaryReviewDrawerBackdrop" class="drawer-backdrop" hidden>
<aside class="summary-review-drawer" role="dialog" aria-modal="true" aria-labelledby="summaryReviewDrawerTitle"> <aside class="summary-review-drawer" role="dialog" aria-modal="true" aria-labelledby="summaryReviewDrawerTitle">
@@ -291,6 +259,7 @@
<input id="summaryReviewEditSubject" autocomplete="off" /> <input id="summaryReviewEditSubject" autocomplete="off" />
</label> </label>
</div> </div>
<div id="summaryReviewEditHint" class="form-hint" hidden></div>
</div> </div>
<div class="drawer-section"> <div class="drawer-section">
<div class="drawer-label">建议登记行</div> <div class="drawer-label">建议登记行</div>
@@ -325,6 +294,9 @@
<section id="summarySearchPanel" class="panel admin-panel" hidden> <section id="summarySearchPanel" class="panel admin-panel" hidden>
<div class="section-head"> <div class="section-head">
<h2>课程小结查询</h2> <h2>课程小结查询</h2>
<div class="quick-actions">
<button class="chip duplicate-summary-scan" type="button">扫描重复小结</button>
</div>
</div> </div>
<form id="summarySearchForm" class="search-row summary-search-row"> <form id="summarySearchForm" class="search-row summary-search-row">
<input id="summarySearchQuery" autocomplete="off" placeholder="关键词:课堂内容、作业、问题等" /> <input id="summarySearchQuery" autocomplete="off" placeholder="关键词:课堂内容、作业、问题等" />
@@ -333,9 +305,17 @@
<input id="summarySearchSubject" autocomplete="off" placeholder="科目" /> <input id="summarySearchSubject" autocomplete="off" placeholder="科目" />
<input id="summarySearchDateFrom" type="date" aria-label="开始日期" /> <input id="summarySearchDateFrom" type="date" aria-label="开始日期" />
<input id="summarySearchDateTo" type="date" aria-label="结束日期" /> <input id="summarySearchDateTo" type="date" aria-label="结束日期" />
<select id="summarySearchMissingTime" aria-label="时间状态筛选"> <select id="summarySearchBindingStatus" aria-label="绑定状态筛选">
<option value="">全部时间</option> <option value="">全部绑定状态</option>
<option value="1">缺少时间</option> <option value="matched">已绑定</option>
<option value="unmatched">未绑定</option>
<option value="missing_time">缺时间</option>
<option value="mismatch">字段不一致</option>
</select>
<select id="summarySearchHasCandidate" aria-label="候选记录筛选">
<option value="">全部候选状态</option>
<option value="true">有候选记录</option>
<option value="false">无候选记录</option>
</select> </select>
<button type="submit">查询</button> <button type="submit">查询</button>
</form> </form>
@@ -348,11 +328,43 @@
<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>
</table> </table>
</div> </div>
<div class="summary-task-section">
<div class="section-head compact-head">
<h3>待处理任务</h3>
<div class="quick-actions">
<select id="summaryReviewStatus" aria-label="课程小结任务状态筛选">
<option value="pending">待审核</option>
<option value="conflict">冲突</option>
<option value="approved">已批准</option>
<option value="rejected">已驳回</option>
<option value="">全部状态</option>
</select>
</div>
</div>
<div id="summaryReviewMeta" class="summary-grid"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>编号</th>
<th>状态</th>
<th>课程信息</th>
<th>候选记录</th>
<th>小结原文</th>
<th>原因</th>
<th>操作</th>
</tr>
</thead>
<tbody id="summaryReviewRows"></tbody>
</table>
</div>
</div>
</section> </section>
<section id="logsPanel" class="panel admin-panel" hidden> <section id="logsPanel" class="panel admin-panel" hidden>
@@ -371,6 +383,7 @@
<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>
@@ -454,6 +467,6 @@
</section> </section>
</main> </main>
<script src="/static/admin.js?v=20260618-summary-duplicate-review"></script> <script src="/static/admin.js?v=20260620-admin-health-summary"></script>
</body> </body>
</html> </html>
+386 -53
View File
@@ -49,7 +49,7 @@ const reviewRows = document.querySelector("#reviewRows");
const summaryReviewStatus = document.querySelector("#summaryReviewStatus"); const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
const summaryReviewMeta = document.querySelector("#summaryReviewMeta"); const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
const summaryReviewRows = document.querySelector("#summaryReviewRows"); const summaryReviewRows = document.querySelector("#summaryReviewRows");
const duplicateSummaryScanBtn = document.querySelector("#duplicateSummaryScanBtn"); const duplicateSummaryScanBtns = Array.from(document.querySelectorAll(".duplicate-summary-scan"));
const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop"); const summaryReviewDrawerBackdrop = document.querySelector("#summaryReviewDrawerBackdrop");
const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose"); const summaryReviewDrawerClose = document.querySelector("#summaryReviewDrawerClose");
const summaryReviewDrawerTitle = document.querySelector("#summaryReviewDrawerTitle"); const summaryReviewDrawerTitle = document.querySelector("#summaryReviewDrawerTitle");
@@ -62,6 +62,7 @@ const summaryReviewEditDate = document.querySelector("#summaryReviewEditDate");
const summaryReviewEditTime = document.querySelector("#summaryReviewEditTime"); const summaryReviewEditTime = document.querySelector("#summaryReviewEditTime");
const summaryReviewEditTeacher = document.querySelector("#summaryReviewEditTeacher"); const summaryReviewEditTeacher = document.querySelector("#summaryReviewEditTeacher");
const summaryReviewEditSubject = document.querySelector("#summaryReviewEditSubject"); const summaryReviewEditSubject = document.querySelector("#summaryReviewEditSubject");
const summaryReviewEditHint = document.querySelector("#summaryReviewEditHint");
const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine"); const summaryReviewDrawerLine = document.querySelector("#summaryReviewDrawerLine");
const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons"); const summaryReviewDrawerReasons = document.querySelector("#summaryReviewDrawerReasons");
const summaryReviewConflictSection = document.querySelector("#summaryReviewConflictSection"); const summaryReviewConflictSection = document.querySelector("#summaryReviewConflictSection");
@@ -79,7 +80,8 @@ const summarySearchTeacher = document.querySelector("#summarySearchTeacher");
const summarySearchSubject = document.querySelector("#summarySearchSubject"); const summarySearchSubject = document.querySelector("#summarySearchSubject");
const summarySearchDateFrom = document.querySelector("#summarySearchDateFrom"); const summarySearchDateFrom = document.querySelector("#summarySearchDateFrom");
const summarySearchDateTo = document.querySelector("#summarySearchDateTo"); const summarySearchDateTo = document.querySelector("#summarySearchDateTo");
const summarySearchMissingTime = document.querySelector("#summarySearchMissingTime"); const summarySearchBindingStatus = document.querySelector("#summarySearchBindingStatus");
const summarySearchHasCandidate = document.querySelector("#summarySearchHasCandidate");
const summarySearchMeta = document.querySelector("#summarySearchMeta"); const summarySearchMeta = document.querySelector("#summarySearchMeta");
const summarySearchRows = document.querySelector("#summarySearchRows"); const summarySearchRows = document.querySelector("#summarySearchRows");
const logOperation = document.querySelector("#logOperation"); const logOperation = document.querySelector("#logOperation");
@@ -113,6 +115,7 @@ let activeSummaryReview = null;
let currentSummarySearchItems = []; let currentSummarySearchItems = [];
let expandedSummarySearchId = ""; let expandedSummarySearchId = "";
let editingSummarySearchId = ""; let editingSummarySearchId = "";
let editingSummaryIdentityId = "";
let currentOperationLogs = []; let currentOperationLogs = [];
let expandedOperationLogId = ""; let expandedOperationLogId = "";
const registerPreviewState = { const registerPreviewState = {
@@ -185,25 +188,33 @@ async function fetchJson(url, options = {}) {
} }
function setActiveTab(tabName) { function setActiveTab(tabName) {
const activeTabName = tabName === "summaries" ? "summarySearch" : tabName;
document.querySelectorAll("[data-admin-tab]").forEach((button) => { document.querySelectorAll("[data-admin-tab]").forEach((button) => {
button.classList.toggle("is-active", button.dataset.adminTab === tabName); button.classList.toggle("is-active", button.dataset.adminTab === activeTabName);
}); });
Object.entries(panels).forEach(([name, panel]) => { Object.entries(panels).forEach(([name, panel]) => {
panel.hidden = name !== tabName; panel.hidden = name !== activeTabName;
}); });
if (tabName === "accounts") loadAccounts(); if (activeTabName === "accounts") loadAccounts();
if (tabName === "teachers") loadTeachers(); if (activeTabName === "teachers") loadTeachers();
if (tabName === "reviews") loadReviews(); if (activeTabName === "reviews") loadReviews();
if (tabName === "summaries") loadSummaryReviews(); if (activeTabName === "summarySearch") {
if (tabName === "summarySearch") loadSummarySearch(); loadSummarySearch();
if (tabName === "logs") loadOperationLogs(); loadSummaryReviews();
}
if (activeTabName === "logs") loadOperationLogs();
} }
async function loadAdminHealth() { async function loadAdminHealth() {
try { try {
const data = await fetchJson("/api/account-health"); const data = await fetchJson("/api/account-health");
const teacherText = data.teachers_count === undefined ? "" : `;老师 ${data.teachers_count}`; adminHealthText.textContent = [
adminHealthText.textContent = `账户 ${data.accounts_count}${teacherText};数据更新时间 ${fmtTime(data.accounts.mtime)}`; `账户 ${data.accounts_count || 0}`,
`在岗老师 ${data.active_teachers_count || 0}`,
`课程记录 ${data.records_count || 0}`,
`小结 ${data.course_summaries_count || 0}`,
`本月已上 ${data.current_month_duration || fmtHours(data.current_month_hours || 0)}`,
].join("");
} catch (error) { } catch (error) {
adminHealthText.textContent = `读取失败:${error.message}`; adminHealthText.textContent = `读取失败:${error.message}`;
} }
@@ -544,11 +555,14 @@ function renderSummaryInfo(summary, item = {}) {
const parts = [ const parts = [
summary.student, summary.student,
summary.date_iso, summary.date_iso,
summary.time_range, summary.time_range || item.suggested_time_range,
summary.teacher, summary.teacher,
summary.subject, summary.subject,
].filter(Boolean); ].filter(Boolean);
return `${renderReviewTag(summaryReviewKind(item))}${parts.map(escapeHtml).join("<br>")}<br><small>${escapeHtml(summary.group || "")}</small>`; const suggestedTime = !summary.time_range && item.suggested_time_range
? `<br><small class="summary-suggested">原文时间,待保存</small>`
: "";
return `${renderReviewTag(summaryReviewKind(item))}${parts.map(escapeHtml).join("<br>")}${suggestedTime}<br><small>${escapeHtml(summary.group || "")}</small>`;
} }
function renderSummaryPreview(summary) { function renderSummaryPreview(summary) {
@@ -576,7 +590,14 @@ function hasDuplicateReviewContext(item) {
function renderDuplicateCandidate(candidate, canReview, options = {}) { function renderDuplicateCandidate(candidate, canReview, options = {}) {
const showDelete = options.showDelete !== false; const showDelete = options.showDelete !== false;
const binding = summaryBinding(candidate);
const bindingDetail = binding.record
? `绑定记录:${renderBindingRecordLine(binding.record)}`
: (Array.isArray(binding.candidates) && binding.candidates.length
? `候选记录:${binding.candidates.length}`
: (binding.reason || ""));
const source = [ const source = [
bindingDetail,
candidate.source_id ? `来源ID${candidate.source_id}` : "", candidate.source_id ? `来源ID${candidate.source_id}` : "",
candidate.message_time ? `发送时间:${candidate.message_time}` : "", candidate.message_time ? `发送时间:${candidate.message_time}` : "",
candidate.sender ? `发送者:${candidate.sender}` : "", candidate.sender ? `发送者:${candidate.sender}` : "",
@@ -584,7 +605,10 @@ function renderDuplicateCandidate(candidate, canReview, options = {}) {
candidate.source_path && !candidate.relative_path ? `文件:${candidate.source_path}` : "", candidate.source_path && !candidate.relative_path ? `文件:${candidate.source_path}` : "",
].filter(Boolean); ].filter(Boolean);
return `<div class="record-summary-item duplicate-summary-candidate"> return `<div class="record-summary-item duplicate-summary-candidate">
<div class="record-summary-title">${escapeHtml(candidate.title || "课程小结")}</div> <div class="duplicate-summary-title-row">
<div class="record-summary-title">${escapeHtml(candidate.title || "课程小结")}</div>
${renderBindingBadge(candidate)}
</div>
<div class="drawer-text">${source.map(escapeHtml).join("<br>") || "暂无来源信息"}</div> <div class="drawer-text">${source.map(escapeHtml).join("<br>") || "暂无来源信息"}</div>
<div class="summary-body">${escapeHtml(candidate.body || candidate.body_preview || "暂无正文")}</div> <div class="summary-body">${escapeHtml(candidate.body || candidate.body_preview || "暂无正文")}</div>
${showDelete ? `<button class="small-button duplicate-summary-delete" type="button" data-summary-id="${escapeHtml(candidate.id)}" ${canReview ? "" : "disabled"}>删除这一条</button>` : ""} ${showDelete ? `<button class="small-button duplicate-summary-delete" type="button" data-summary-id="${escapeHtml(candidate.id)}" ${canReview ? "" : "disabled"}>删除这一条</button>` : ""}
@@ -638,22 +662,77 @@ function canLinkExistingSummary(item) {
return Boolean(item && item.proposed_line && reasons.includes("classnotes 已存在同一条上课记录")); return Boolean(item && item.proposed_line && reasons.includes("classnotes 已存在同一条上课记录"));
} }
function courseInfoLines(summary) { function canApproveSummaryDirectly(item) {
return Boolean(
canReviewSummary(item)
&& !isDuplicateSummaryReview(item)
&& item.proposed_line
&& !canLinkExistingSummary(item)
);
}
function isMissingTimeReason(reason) {
const text = String(reason || "");
return (
text.includes("时间段缺失")
|| text.includes("时长缺失")
|| text.includes("课程小结缺少时长")
|| text.includes("请补充时间")
);
}
function summaryReviewReasonLines(item) {
const reasons = Array.isArray(item && item.reasons) ? item.reasons : [];
const lines = [];
if (reasons.some(isMissingTimeReason)) {
const suggestion = item.suggested_time_range
? `:原文疑似 ${item.suggested_time_range}${item.suggested_duration ? `${item.suggested_duration}` : ""}`
: "";
lines.push(`待补时间${suggestion}`);
}
reasons.forEach((reason) => {
if (isMissingTimeReason(reason)) return;
if (!lines.includes(reason)) lines.push(reason);
});
return lines.length ? lines : ["待人工复核"];
}
function renderSummaryReviewReasons(item) {
return `<div class="summary-review-reasons">${summaryReviewReasonLines(item)
.map((reason) => `<span>${escapeHtml(reason)}</span>`)
.join("")}</div>`;
}
function summaryReviewDetailLabel(item) {
if (isDuplicateSummaryReview(item)) return "处理重复";
if (canLinkExistingSummary(item)) return "关联记录";
if (!item.proposed_line) return item.suggested_time_range ? "补时间" : "修正";
return "查看详情";
}
function courseInfoLines(summary, item = {}) {
return [ return [
summary.student ? `学生:${summary.student}` : "", summary.student ? `学生:${summary.student}` : "",
summary.date_iso ? `日期:${summary.date_iso}` : "", summary.date_iso ? `日期:${summary.date_iso}` : "",
summary.time_range ? `时间:${summary.time_range}` : "", summary.time_range ? `时间:${summary.time_range}` : "",
!summary.time_range && item.suggested_time_range ? `原文时间:${item.suggested_time_range}` : "",
summary.teacher || summary.subject ? `老师/科目:${summary.teacher || ""} ${summary.subject || ""}`.trim() : "", summary.teacher || summary.subject ? `老师/科目:${summary.teacher || ""} ${summary.subject || ""}`.trim() : "",
summary.group ? `班级/分组:${summary.group}` : "", summary.group ? `班级/分组:${summary.group}` : "",
].filter(Boolean); ].filter(Boolean);
} }
function fillSummaryReviewEditor(summary) { function fillSummaryReviewEditor(summary, item = {}) {
summaryReviewEditStudent.value = summary.student || ""; summaryReviewEditStudent.value = summary.student || "";
summaryReviewEditDate.value = summary.date_iso || ""; summaryReviewEditDate.value = summary.date_iso || "";
summaryReviewEditTime.value = summary.time_range || ""; summaryReviewEditTime.value = summary.time_range || item.suggested_time_range || "";
summaryReviewEditTeacher.value = summary.teacher || ""; summaryReviewEditTeacher.value = summary.teacher || "";
summaryReviewEditSubject.value = summary.subject || ""; summaryReviewEditSubject.value = summary.subject || "";
const hasSuggestedTime = Boolean(!summary.time_range && item.suggested_time_range);
summaryReviewEditTime.classList.toggle("suggested-input", hasSuggestedTime);
summaryReviewEditHint.hidden = !hasSuggestedTime;
summaryReviewEditHint.textContent = hasSuggestedTime
? `已从原文识别到 ${item.suggested_time_range}${item.suggested_duration ? `${item.suggested_duration}` : ""},保存后会生成建议登记行。`
: "";
} }
function summaryReviewEditorPayload() { function summaryReviewEditorPayload() {
@@ -676,6 +755,7 @@ function openSummaryReviewDrawer(taskId) {
const duplicateReview = isDuplicateSummaryReview(item); const duplicateReview = isDuplicateSummaryReview(item);
const duplicateContext = hasDuplicateReviewContext(item); const duplicateContext = hasDuplicateReviewContext(item);
const pendingSummarySave = Boolean(item.pending_summary_save); const pendingSummarySave = Boolean(item.pending_summary_save);
const canApproveDirectly = canApproveSummaryDirectly(item);
activeSummaryReview = item; activeSummaryReview = item;
summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`; summaryReviewDrawerTitle.textContent = `课程小结详情 #${item.id}`;
@@ -684,13 +764,13 @@ function openSummaryReviewDrawer(taskId) {
kind ? renderReviewTag(kind) : "", kind ? renderReviewTag(kind) : "",
].filter(Boolean).join(" "); ].filter(Boolean).join(" ");
summaryReviewDrawerStatus.innerHTML = `<span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span>${item.message ? ` <small>${escapeHtml(item.message)}</small>` : ""}`; summaryReviewDrawerStatus.innerHTML = `<span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span>${item.message ? ` <small>${escapeHtml(item.message)}</small>` : ""}`;
summaryReviewDrawerCourse.innerHTML = courseInfoLines(summary).map(escapeHtml).join("<br>") || "暂无课程信息"; summaryReviewDrawerCourse.innerHTML = courseInfoLines(summary, item).map(escapeHtml).join("<br>") || "暂无课程信息";
summaryReviewEditSection.hidden = duplicateReview; summaryReviewEditSection.hidden = duplicateReview;
if (!duplicateReview) fillSummaryReviewEditor(summary); if (!duplicateReview) fillSummaryReviewEditor(summary, item);
summaryReviewDrawerLine.innerHTML = duplicateReview summaryReviewDrawerLine.innerHTML = duplicateReview
? "<span class=\"muted\">请选择下方重复小结删除</span>" ? "<span class=\"muted\">请选择下方重复小结删除</span>"
: item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"; : item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">保存修正后生成</span>";
summaryReviewDrawerReasons.innerHTML = reasons.length ? reasons.map(escapeHtml).join("<br>") : "待人工复核"; summaryReviewDrawerReasons.innerHTML = renderSummaryReviewReasons(item);
if (duplicateReview) { if (duplicateReview) {
summaryReviewDrawerBody.innerHTML = renderDuplicateCandidates(item, canReview); summaryReviewDrawerBody.innerHTML = renderDuplicateCandidates(item, canReview);
} else { } else {
@@ -705,10 +785,11 @@ function openSummaryReviewDrawer(taskId) {
].filter(Boolean).join("<br>") || "暂无来源信息"; ].filter(Boolean).join("<br>") || "暂无来源信息";
summaryReviewDrawerSave.hidden = duplicateReview; summaryReviewDrawerSave.hidden = duplicateReview;
summaryReviewDrawerSave.disabled = !canReview || duplicateReview; summaryReviewDrawerSave.disabled = !canReview || duplicateReview;
summaryReviewDrawerSave.textContent = !summary.time_range && item.suggested_time_range ? "保存时间修正" : "保存修正";
summaryReviewDrawerLink.hidden = duplicateReview || !canLinkExistingSummary(item); summaryReviewDrawerLink.hidden = duplicateReview || !canLinkExistingSummary(item);
summaryReviewDrawerLink.disabled = !canReview || duplicateReview || !canLinkExistingSummary(item); summaryReviewDrawerLink.disabled = !canReview || duplicateReview || !canLinkExistingSummary(item);
summaryReviewDrawerApprove.hidden = duplicateReview; summaryReviewDrawerApprove.hidden = duplicateReview || !item.proposed_line || canLinkExistingSummary(item);
summaryReviewDrawerApprove.disabled = !canReview || duplicateReview; summaryReviewDrawerApprove.disabled = !canApproveDirectly;
summaryReviewDrawerReject.disabled = !canReview; summaryReviewDrawerReject.disabled = !canReview;
summaryReviewDrawerBackdrop.hidden = false; summaryReviewDrawerBackdrop.hidden = false;
} }
@@ -717,10 +798,14 @@ function closeSummaryReviewDrawer() {
activeSummaryReview = null; activeSummaryReview = null;
summaryReviewEditSection.hidden = true; summaryReviewEditSection.hidden = true;
summaryReviewDrawerSave.hidden = false; summaryReviewDrawerSave.hidden = false;
summaryReviewDrawerSave.textContent = "保存修正";
summaryReviewDrawerLink.hidden = true; summaryReviewDrawerLink.hidden = true;
summaryReviewDrawerApprove.hidden = false; summaryReviewDrawerApprove.hidden = false;
summaryReviewConflictSection.hidden = true; summaryReviewConflictSection.hidden = true;
summaryReviewDrawerConflicts.innerHTML = ""; summaryReviewDrawerConflicts.innerHTML = "";
summaryReviewEditHint.hidden = true;
summaryReviewEditHint.textContent = "";
summaryReviewEditTime.classList.remove("suggested-input");
summaryReviewDrawerBackdrop.hidden = true; summaryReviewDrawerBackdrop.hidden = true;
} }
@@ -737,26 +822,34 @@ async function loadSummaryReviews() {
fetchJson(`/api/admin/tasks?${reviewParams.toString()}`), fetchJson(`/api/admin/tasks?${reviewParams.toString()}`),
fetchJson(`/api/admin/tasks?${duplicateParams.toString()}`), fetchJson(`/api/admin/tasks?${duplicateParams.toString()}`),
]); ]);
currentSummaryReviews = [...(reviewData.items || []), ...(duplicateData.items || [])].sort((a, b) => Number(b.id || 0) - Number(a.id || 0)); const reviewItems = reviewData.items || [];
summaryReviewMeta.innerHTML = [metric("当前结果", `${currentSummaryReviews.length}`)].join(""); const duplicateItems = duplicateData.items || [];
currentSummaryReviews = [...reviewItems, ...duplicateItems].sort((a, b) => Number(b.id || 0) - Number(a.id || 0));
summaryReviewMeta.innerHTML = [
metric("待处理任务", `${currentSummaryReviews.length}`),
metric("课程小结审核", `${reviewItems.length}`),
metric("重复小结", `${duplicateItems.length}`),
].join("");
summaryReviewRows.innerHTML = currentSummaryReviews summaryReviewRows.innerHTML = currentSummaryReviews
.map((item) => { .map((item) => {
const canReview = canReviewSummary(item); const canReview = canReviewSummary(item);
const duplicateReview = isDuplicateSummaryReview(item); const duplicateReview = isDuplicateSummaryReview(item);
const canApproveDirectly = canApproveSummaryDirectly(item);
const summary = item.summary || {}; const summary = item.summary || {};
const reasons = Array.isArray(item.reasons) ? item.reasons : []; const approveButton = canApproveDirectly
const approveLabel = duplicateReview ? "详情选择" : "批准"; ? `<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}">批准</button>`
: "";
return `<tr> return `<tr>
<td>#${escapeHtml(item.id)}</td> <td>#${escapeHtml(item.id)}</td>
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td> <td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td>${renderSummaryInfo(summary, item)}</td> <td>${renderSummaryInfo(summary, item)}</td>
<td>${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td> <td>${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td>
<td>${renderSummaryPreview(summary)}</td> <td>${renderSummaryPreview(summary)}</td>
<td>${reasons.map(escapeHtml).join("<br>") || "待人工复核"}</td> <td>${renderSummaryReviewReasons(item)}</td>
<td class="record-action-cell"> <td class="record-action-cell">
<div class="record-actions"> <div class="record-actions">
<button class="small-button summary-detail" type="button" data-task-id="${escapeHtml(item.id)}">查看详情</button> <button class="small-button summary-detail" type="button" data-task-id="${escapeHtml(item.id)}">${escapeHtml(summaryReviewDetailLabel(item))}</button>
<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview && !duplicateReview ? "" : "disabled"}>${approveLabel}</button> ${approveButton}
<button class="small-button summary-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button> <button class="small-button summary-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
</div> </div>
</td> </td>
@@ -764,7 +857,7 @@ async function loadSummaryReviews() {
}) })
.join(""); .join("");
if (!currentSummaryReviews.length) { if (!currentSummaryReviews.length) {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结审核项</td></tr>`; summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结任务</td></tr>`;
} }
} catch (error) { } catch (error) {
currentSummaryReviews = []; currentSummaryReviews = [];
@@ -780,25 +873,53 @@ function summarySearchParams() {
if (summarySearchSubject.value.trim()) params.set("subject", summarySearchSubject.value.trim()); if (summarySearchSubject.value.trim()) params.set("subject", summarySearchSubject.value.trim());
if (summarySearchDateFrom.value) params.set("date_from", summarySearchDateFrom.value); if (summarySearchDateFrom.value) params.set("date_from", summarySearchDateFrom.value);
if (summarySearchDateTo.value) params.set("date_to", summarySearchDateTo.value); if (summarySearchDateTo.value) params.set("date_to", summarySearchDateTo.value);
if (summarySearchMissingTime.value) params.set("missing_time", "true"); if (summarySearchBindingStatus.value) params.set("binding_status", summarySearchBindingStatus.value);
if (summarySearchHasCandidate.value) params.set("has_candidate", summarySearchHasCandidate.value);
return params; return params;
} }
function renderSummaryActions(item) { function renderSummaryActions(item) {
const identityButton = `<button class="secondary-button summary-identity-edit" type="button" data-summary-id="${escapeHtml(item.id)}">修改绑定字段</button>`;
const editButton = `<button class="secondary-button summary-body-edit" type="button" data-summary-id="${escapeHtml(item.id)}">修改课程小结</button>`; const editButton = `<button class="secondary-button summary-body-edit" type="button" data-summary-id="${escapeHtml(item.id)}">修改课程小结</button>`;
const deleteButton = `<button class="secondary-button summary-delete" type="button" data-summary-id="${escapeHtml(item.id)}">删除课程小结</button>`; const deleteButton = `<button class="secondary-button summary-delete" type="button" data-summary-id="${escapeHtml(item.id)}">删除课程小结</button>`;
if (item.time_range) { if (item.time_range) {
return `<div class="summary-time-actions"><span class="summary-action-note">时间已完整</span>${editButton}${deleteButton}</div>`; return `<div class="summary-time-actions"><span class="summary-action-note">时间已完整</span>${identityButton}${editButton}${deleteButton}</div>`;
} }
const inputId = `summary-time-${item.id}`; const inputId = `summary-time-${item.id}`;
return `<div class="summary-time-actions"> return `<div class="summary-time-actions">
<input id="${escapeHtml(inputId)}" class="summary-time-input" data-summary-time-input="${escapeHtml(item.id)}" autocomplete="off" placeholder="08:00-10:00" /> <input id="${escapeHtml(inputId)}" class="summary-time-input" data-summary-time-input="${escapeHtml(item.id)}" autocomplete="off" placeholder="08:00-10:00" />
<button class="secondary-button summary-time-save" type="button" data-summary-id="${escapeHtml(item.id)}">补齐时间</button> <button class="secondary-button summary-time-save" type="button" data-summary-id="${escapeHtml(item.id)}">补齐时间</button>
${identityButton}
${editButton} ${editButton}
${deleteButton} ${deleteButton}
</div>`; </div>`;
} }
function renderSummaryIdentityEditor(item) {
if (String(item.id) !== editingSummaryIdentityId) return "";
return `<div class="summary-identity-edit-panel">
<div class="summary-identity-grid">
<label>
<span>学生</span>
<input data-summary-identity-student="${escapeHtml(item.id)}" autocomplete="off" value="${escapeHtml(item.student || "")}" />
</label>
<label>
<span>老师</span>
<input data-summary-identity-teacher="${escapeHtml(item.id)}" autocomplete="off" value="${escapeHtml(item.teacher || "")}" />
</label>
<label>
<span>科目</span>
<input data-summary-identity-subject="${escapeHtml(item.id)}" autocomplete="off" value="${escapeHtml(item.subject || "")}" />
</label>
</div>
<div class="form-hint">只修改课程小结的学生老师和科目不同步修改上课记录和课时账户老师必须已在教师档案中维护</div>
<div class="summary-edit-actions">
<button class="secondary-button summary-identity-save" type="button" data-summary-id="${escapeHtml(item.id)}">保存绑定字段</button>
<button class="secondary-button summary-identity-cancel" type="button" data-summary-id="${escapeHtml(item.id)}">取消</button>
</div>
</div>`;
}
function renderSummaryBodyEditor(item) { function renderSummaryBodyEditor(item) {
const body = item.body || item.body_preview || ""; const body = item.body || item.body_preview || "";
if (String(item.id) !== editingSummarySearchId) { if (String(item.id) !== editingSummarySearchId) {
@@ -818,20 +939,117 @@ function renderMatchedFields(item) {
return fields.length ? `<br><small>命中:${fields.map(escapeHtml).join("、")}</small>` : ""; return fields.length ? `<br><small>命中:${fields.map(escapeHtml).join("、")}</small>` : "";
} }
function summaryBinding(item) {
return item.binding || {
status: item.matched_record ? "matched" : "unmatched",
label: item.matched_record ? "已绑定" : "未绑定",
record: null,
candidates: [],
reason: "",
differences: [],
};
}
function bindingStatusClass(status) {
if (status === "matched") return "normal";
if (status === "missing_time" || status === "mismatch") return "warning";
return "debt";
}
function renderBindingBadge(item) {
const binding = summaryBinding(item);
const label = binding.label || (binding.status === "matched" ? "已绑定" : "未绑定");
return `<span class="status ${bindingStatusClass(binding.status)} summary-binding-badge">${escapeHtml(label)}</span>`;
}
function renderBindingRecordLine(record) {
if (!record) return "";
const pieces = [
`${record.date || record.date_iso || ""} ${record.weekday || ""}`.trim(),
record.time || "",
record.student || "",
record.teacher || "",
record.subject || "",
record.duration || "",
].filter(Boolean);
return pieces.map(escapeHtml).join(" · ");
}
function renderSummaryBindingStatus(item) {
const binding = summaryBinding(item);
const detail = binding.record ? renderBindingRecordLine(binding.record) : (binding.reason || "");
const candidateCount = Array.isArray(binding.candidates) ? binding.candidates.length : 0;
const suffix = !binding.record && candidateCount ? `候选 ${candidateCount}` : detail;
return `<div class="summary-binding-status">
${renderBindingBadge(item)}
${suffix ? `<small>${escapeHtml(suffix)}</small>` : ""}
</div>`;
}
function summarizeSummaryBindings(items) {
return items.reduce(
(summary, item) => {
const binding = summaryBinding(item);
if (binding.status === "matched") summary.matched += 1;
if (binding.status !== "matched") summary.pending += 1;
if (Array.isArray(binding.candidates) && binding.candidates.length) summary.withCandidate += 1;
return summary;
},
{ matched: 0, pending: 0, withCandidate: 0 },
);
}
function renderSummaryBindingDetail(item) {
const binding = summaryBinding(item);
if (binding.status === "matched" && binding.record) {
return `<div class="summary-binding-panel matched">
<div class="summary-binding-title">已绑定上课记录</div>
<div class="summary-binding-record">${renderBindingRecordLine(binding.record)}</div>
</div>`;
}
const candidates = Array.isArray(binding.candidates) ? binding.candidates : [];
const candidateRows = candidates
.map((record) => {
const differences = Array.isArray(record.differences) && record.differences.length
? record.differences.map(escapeHtml).join("、")
: "可直接匹配";
return `<div class="summary-binding-candidate">
<div>
<div class="summary-binding-record">${renderBindingRecordLine(record)}</div>
<small>${differences}</small>
</div>
<button class="secondary-button summary-apply-candidate" type="button"
data-summary-id="${escapeHtml(item.id)}"
data-student="${escapeHtml(record.student || "")}"
data-teacher="${escapeHtml(record.teacher || "")}"
data-time="${escapeHtml(record.time || "")}"
data-subject="${escapeHtml(record.subject || "")}"
data-differences="${escapeHtml((record.differences || []).join(","))}">按此记录修正</button>
</div>`;
})
.join("");
return `<div class="summary-binding-panel">
<div class="summary-binding-title">${escapeHtml(binding.reason || "未绑定到上课记录")}</div>
${candidateRows || '<div class="form-hint">没有找到可参考的上课记录。</div>'}
</div>`;
}
function renderSummarySearchRows(items) { function renderSummarySearchRows(items) {
return items return items
.map((item) => { .map((item) => {
const expanded = String(item.id) === expandedSummarySearchId; const expanded = String(item.id) === expandedSummarySearchId;
const unmatchedClass = item.matched_record ? "" : " unmatched-summary"; const binding = summaryBinding(item);
const unmatchedClass = binding.status === "matched" ? "" : " unmatched-summary";
const mainRow = `<tr class="summary-search-result-row${unmatchedClass}" data-summary-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}"> const mainRow = `<tr class="summary-search-result-row${unmatchedClass}" data-summary-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}">
<td>${escapeHtml(item.date_iso || "未识别")}</td> <td>${escapeHtml(item.date_iso || "未识别")}</td>
<td>${escapeHtml(item.time_range || "缺少时间")}</td> <td>${escapeHtml(item.time_range || "缺少时间")}</td>
<td>${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td> <td>${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
<td>${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td> <td>${escapeHtml(item.teacher || "待核对老师")}<br><small>${escapeHtml(item.subject || "待核对科目")}</small></td>
<td>${renderSummaryBindingStatus(item)}</td>
</tr>`; </tr>`;
const detailRow = expanded const detailRow = expanded
? `<tr class="summary-search-detail-row" data-summary-detail="${escapeHtml(item.id)}"> ? `<tr class="summary-search-detail-row" data-summary-detail="${escapeHtml(item.id)}">
<td colspan="4"> <td colspan="5">
<div class="summary-search-detail-panel${unmatchedClass}"> <div class="summary-search-detail-panel${unmatchedClass}">
<div class="summary-search-detail-head"> <div class="summary-search-detail-head">
<div> <div>
@@ -839,6 +1057,8 @@ function renderSummarySearchRows(items) {
${renderMatchedFields(item)} ${renderMatchedFields(item)}
</div> </div>
</div> </div>
${renderSummaryBindingDetail(item)}
${renderSummaryIdentityEditor(item)}
${renderSummaryBodyEditor(item)} ${renderSummaryBodyEditor(item)}
<div class="summary-search-actions">${renderSummaryActions(item)}</div> <div class="summary-search-actions">${renderSummaryActions(item)}</div>
</div> </div>
@@ -853,28 +1073,38 @@ function renderSummarySearchRows(items) {
function renderCurrentSummarySearch() { function renderCurrentSummarySearch() {
summarySearchRows.innerHTML = renderSummarySearchRows(currentSummarySearchItems); summarySearchRows.innerHTML = renderSummarySearchRows(currentSummarySearchItems);
if (!currentSummarySearchItems.length) { if (!currentSummarySearchItems.length) {
summarySearchRows.innerHTML = `<tr><td colspan="4" class="empty">没有符合条件的课程小结</td></tr>`; summarySearchRows.innerHTML = `<tr><td colspan="5" class="empty">没有符合条件的课程小结</td></tr>`;
} }
} }
async function loadSummarySearch() { async function loadSummarySearch() {
summarySearchRows.innerHTML = `<tr><td colspan="4" class="empty">正在读取</td></tr>`; summarySearchRows.innerHTML = `<tr><td colspan="5" class="empty">正在读取</td></tr>`;
try { try {
const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`); const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`);
currentSummarySearchItems = data.items || []; currentSummarySearchItems = data.items || [];
if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = ""; if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = "";
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = ""; if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = "";
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummaryIdentityId)) editingSummaryIdentityId = "";
const bindingSummary = summarizeSummaryBindings(currentSummarySearchItems);
summarySearchMeta.innerHTML = [ summarySearchMeta.innerHTML = [
metric("命中小结", `${data.count}`), metric("命中小结", `${data.count}`),
metric("当前显示", `${data.returned}`), metric("当前显示", `${data.returned}`),
metric("已绑定", `${bindingSummary.matched}`),
metric("待处理", `${bindingSummary.pending}`),
metric("有候选", `${bindingSummary.withCandidate}`),
].join(""); ].join("");
renderCurrentSummarySearch(); renderCurrentSummarySearch();
} catch (error) { } catch (error) {
currentSummarySearchItems = []; currentSummarySearchItems = [];
summarySearchRows.innerHTML = `<tr><td colspan="4" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`; summarySearchRows.innerHTML = `<tr><td colspan="5" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
} }
} }
async function refreshSummaryWorkspace({ logs = true } = {}) {
await Promise.all([loadSummarySearch(), loadSummaryReviews()]);
if (logs) await loadOperationLogs();
}
async function updateCourseSummaryTime(summaryId, timeRange) { async function updateCourseSummaryTime(summaryId, timeRange) {
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/time`, { await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/time`, {
method: "POST", method: "POST",
@@ -903,6 +1133,58 @@ async function updateCourseSummaryBody(summaryId, body) {
await loadOperationLogs(); await loadOperationLogs();
} }
async function updateCourseSummaryIdentity(summaryId, payload) {
const result = await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/identity`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
editingSummaryIdentityId = "";
editingSummarySearchId = "";
expandedSummarySearchId = result.new_id || "";
await loadSummarySearch();
await loadOperationLogs();
}
function findCurrentSummary(summaryId) {
return currentSummarySearchItems.find((item) => String(item.id) === String(summaryId));
}
async function applySummaryBindingCandidate(summaryId, record) {
const item = findCurrentSummary(summaryId);
if (!item) throw new Error("未找到当前课程小结");
let activeSummaryId = summaryId;
const targetStudent = String(record.student || "").trim();
const targetTeacher = String(record.teacher || "").trim();
const targetSubject = String(record.subject || "").trim();
const targetTime = String(record.time || "").trim();
const needsIdentity = targetStudent && targetTeacher && targetSubject && (
targetStudent !== String(item.student || "")
|| targetTeacher !== String(item.teacher || "")
|| targetSubject !== String(item.subject || "")
);
if (needsIdentity) {
const result = await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(activeSummaryId)}/identity`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ student: targetStudent, teacher: targetTeacher, subject: targetSubject }),
});
activeSummaryId = result.new_id || activeSummaryId;
}
if (targetTime && targetTime !== String(item.time_range || "")) {
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(activeSummaryId)}/time`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ time_range: targetTime }),
});
}
editingSummaryIdentityId = "";
editingSummarySearchId = "";
expandedSummarySearchId = activeSummaryId;
await loadSummarySearch();
await loadOperationLogs();
}
async function saveSummaryReviewEdits() { async function saveSummaryReviewEdits() {
if (!activeSummaryReview) return; if (!activeSummaryReview) return;
try { try {
@@ -912,9 +1194,8 @@ async function saveSummaryReviewEdits() {
body: JSON.stringify(summaryReviewEditorPayload()), body: JSON.stringify(summaryReviewEditorPayload()),
}); });
activeSummaryReview = result.task; activeSummaryReview = result.task;
await loadSummaryReviews(); await refreshSummaryWorkspace();
openSummaryReviewDrawer(activeSummaryReview.id); openSummaryReviewDrawer(activeSummaryReview.id);
await loadOperationLogs();
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
} }
@@ -928,8 +1209,7 @@ async function linkExistingSummaryReview() {
method: "POST", method: "POST",
}); });
closeSummaryReviewDrawer(); closeSummaryReviewDrawer();
await loadSummaryReviews(); await refreshSummaryWorkspace();
await loadOperationLogs();
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
} }
@@ -943,21 +1223,25 @@ async function resolveDuplicateSummary(taskId, summaryId) {
body: JSON.stringify({ delete_summary_id: summaryId }), body: JSON.stringify({ delete_summary_id: summaryId }),
}); });
closeSummaryReviewDrawer(); closeSummaryReviewDrawer();
await loadSummaryReviews(); await refreshSummaryWorkspace();
await loadOperationLogs();
} }
async function scanDuplicateSummaries() { async function scanDuplicateSummaries() {
duplicateSummaryScanBtn.disabled = true; duplicateSummaryScanBtns.forEach((button) => {
button.disabled = true;
});
try { try {
const result = await fetchJson("/api/admin/course-summaries/duplicate-scan", { method: "POST" }); const result = await fetchJson("/api/admin/course-summaries/duplicate-scan", { method: "POST" });
alert(`扫描完成:发现 ${result.scanned || 0} 组重复,新增 ${result.created || 0} 条审核任务`); alert(`扫描完成:发现 ${result.scanned || 0} 组重复,新增 ${result.created || 0} 条审核任务`);
await loadSummaryReviews(); summaryReviewStatus.value = "pending";
setActiveTab("summarySearch");
await loadOperationLogs(); await loadOperationLogs();
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
} finally { } finally {
duplicateSummaryScanBtn.disabled = false; duplicateSummaryScanBtns.forEach((button) => {
button.disabled = false;
});
} }
} }
@@ -1035,17 +1319,23 @@ function renderLogDetail(item) {
logField("来源ID", item.source_id), logField("来源ID", item.source_id),
logField("记录ID", item.record_id), logField("记录ID", item.record_id),
logField("课程小结ID", item.summary_id), logField("课程小结ID", item.summary_id),
logField("新课程小结ID", item.new_summary_id),
logField("批次", item.batch_id), logField("批次", item.batch_id),
]), ]),
renderLogDetailGroup("课程信息", [ renderLogDetailGroup("课程信息", [
logField("老师", item.teacher), logField("老师", item.teacher),
logField("原老师", item.old_teacher),
logField("原学生", item.old_student),
logField("教师ID", item.teacher_id), logField("教师ID", item.teacher_id),
logField("科目", item.subject), logField("科目", item.subject),
logField("原科目", item.old_subject),
logField("时间段", item.time_range), logField("时间段", item.time_range),
logField("上课记录", item.proposed_line), logField("上课记录", item.proposed_line),
]), ]),
renderLogDetailGroup("文件与备份", [ renderLogDetailGroup("文件与备份", [
logField("保存文件", item.saved_path), logField("保存文件", item.saved_path),
logField("原文件", item.old_path),
logField("新文件", item.new_path),
logField("备份ID", item.backup_id), logField("备份ID", item.backup_id),
logField("撤回记录", item.target_log_id), logField("撤回记录", item.target_log_id),
logField("撤回备份", item.target_backup_id), logField("撤回备份", item.target_backup_id),
@@ -1119,7 +1409,8 @@ async function reviewTask(taskId, action) {
} }
async function summaryReviewTask(taskId, action) { async function summaryReviewTask(taskId, action) {
await runTaskAction(taskId, action, loadSummaryReviews); await runTaskAction(taskId, action, () => refreshSummaryWorkspace({ logs: false }));
await loadOperationLogs();
closeSummaryReviewDrawer(); closeSummaryReviewDrawer();
} }
@@ -1434,7 +1725,9 @@ reviewRows.addEventListener("click", (event) => {
if (reject) reviewTask(reject.dataset.taskId, "reject"); if (reject) reviewTask(reject.dataset.taskId, "reject");
}); });
summaryReviewStatus.addEventListener("change", loadSummaryReviews); summaryReviewStatus.addEventListener("change", loadSummaryReviews);
duplicateSummaryScanBtn.addEventListener("click", scanDuplicateSummaries); duplicateSummaryScanBtns.forEach((button) => {
button.addEventListener("click", scanDuplicateSummaries);
});
summaryReviewRows.addEventListener("click", (event) => { summaryReviewRows.addEventListener("click", (event) => {
const detail = event.target.closest(".summary-detail"); const detail = event.target.closest(".summary-detail");
const approve = event.target.closest(".summary-approve"); const approve = event.target.closest(".summary-approve");
@@ -1479,15 +1772,56 @@ summarySearchForm.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
loadSummarySearch(); loadSummarySearch();
}); });
summarySearchMissingTime.addEventListener("change", loadSummarySearch); summarySearchBindingStatus.addEventListener("change", loadSummarySearch);
summarySearchHasCandidate.addEventListener("change", loadSummarySearch);
summarySearchRows.addEventListener("click", (event) => { summarySearchRows.addEventListener("click", (event) => {
const saveTime = event.target.closest(".summary-time-save"); const saveTime = event.target.closest(".summary-time-save");
const deleteSummary = event.target.closest(".summary-delete"); const deleteSummary = event.target.closest(".summary-delete");
const editIdentity = event.target.closest(".summary-identity-edit");
const saveIdentity = event.target.closest(".summary-identity-save");
const cancelIdentity = event.target.closest(".summary-identity-cancel");
const editSummary = event.target.closest(".summary-body-edit"); const editSummary = event.target.closest(".summary-body-edit");
const saveSummaryBody = event.target.closest(".summary-body-save"); const saveSummaryBody = event.target.closest(".summary-body-save");
const cancelSummaryBody = event.target.closest(".summary-body-cancel"); const cancelSummaryBody = event.target.closest(".summary-body-cancel");
const applyCandidate = event.target.closest(".summary-apply-candidate");
if (applyCandidate) {
if (!confirm("确认按候选上课记录修正课程小结的学生、老师、科目和时间?系统不会修改上课记录和课时账户。")) return;
applySummaryBindingCandidate(applyCandidate.dataset.summaryId, {
student: applyCandidate.dataset.student,
teacher: applyCandidate.dataset.teacher,
time: applyCandidate.dataset.time,
subject: applyCandidate.dataset.subject,
}).catch((error) => alert(error.message));
return;
}
if (editIdentity) {
editingSummaryIdentityId = editIdentity.dataset.summaryId;
editingSummarySearchId = "";
renderCurrentSummarySearch();
return;
}
if (cancelIdentity) {
editingSummaryIdentityId = "";
renderCurrentSummarySearch();
return;
}
if (saveIdentity) {
const studentInput = summarySearchRows.querySelector(`[data-summary-identity-student='${saveIdentity.dataset.summaryId}']`);
const teacherInput = summarySearchRows.querySelector(`[data-summary-identity-teacher='${saveIdentity.dataset.summaryId}']`);
const subjectInput = summarySearchRows.querySelector(`[data-summary-identity-subject='${saveIdentity.dataset.summaryId}']`);
const student = studentInput ? studentInput.value.trim() : "";
const teacher = teacherInput ? teacherInput.value.trim() : "";
const subject = subjectInput ? subjectInput.value.trim() : "";
if (!student || !teacher || !subject) {
alert("学生、老师和科目不能为空");
return;
}
updateCourseSummaryIdentity(saveIdentity.dataset.summaryId, { student, teacher, subject }).catch((error) => alert(error.message));
return;
}
if (editSummary) { if (editSummary) {
editingSummarySearchId = editSummary.dataset.summaryId; editingSummarySearchId = editSummary.dataset.summaryId;
editingSummaryIdentityId = "";
renderCurrentSummarySearch(); renderCurrentSummarySearch();
return; return;
} }
@@ -1589,8 +1923,7 @@ refreshBtn.addEventListener("click", () => {
loadAdminHealth(); loadAdminHealth();
if (!panels.accounts.hidden) loadAccounts(); if (!panels.accounts.hidden) loadAccounts();
if (!panels.reviews.hidden) loadReviews(); if (!panels.reviews.hidden) loadReviews();
if (!panels.summaries.hidden) loadSummaryReviews(); if (!panels.summarySearch.hidden) refreshSummaryWorkspace({ logs: false });
if (!panels.summarySearch.hidden) loadSummarySearch();
if (!panels.logs.hidden) loadOperationLogs(); if (!panels.logs.hidden) loadOperationLogs();
}); });
+6
View File
@@ -226,8 +226,14 @@ function renderRecordRow(row) {
function renderSummaryEntry(summary) { function renderSummaryEntry(summary) {
const title = summary.title || "课程小结"; const title = summary.title || "课程小结";
const time = summary.time_range ? ` · ${summary.time_range}` : ""; const time = summary.time_range ? ` · ${summary.time_range}` : "";
const meta = [
summary.date_iso || "",
summary.teacher || "",
summary.subject || "",
].filter(Boolean).join(" · ");
return `<div class="record-summary-item"> return `<div class="record-summary-item">
<div class="record-summary-title">${escapeHtml(title)}${escapeHtml(time)}</div> <div class="record-summary-title">${escapeHtml(title)}${escapeHtml(time)}</div>
${meta ? `<div class="record-summary-meta">${escapeHtml(meta)}</div>` : ""}
<div class="summary-body">${escapeHtml(summary.body || "暂无正文")}</div> <div class="summary-body">${escapeHtml(summary.body || "暂无正文")}</div>
</div>`; </div>`;
} }
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>新时空教务管理系统</title> <title>新时空教务管理系统</title>
<link rel="stylesheet" href="/static/styles.css?v=20260618-missing-summary-rail-row" /> <link rel="stylesheet" href="/static/styles.css?v=20260620-summary-binding" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -165,6 +165,6 @@
</section> </section>
</div> </div>
<script src="/static/app.js?v=20260618-record-summary-row"></script> <script src="/static/app.js?v=20260620-summary-binding"></script>
</body> </body>
</html> </html>
+158 -1
View File
@@ -230,7 +230,7 @@ h2 {
} }
.summary-search-row { .summary-search-row {
grid-template-columns: minmax(180px, 1.4fr) repeat(6, minmax(100px, 0.8fr)) 88px; grid-template-columns: minmax(180px, 1.4fr) repeat(7, minmax(104px, 0.8fr)) 88px;
} }
input, input,
@@ -373,6 +373,18 @@ select:focus {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.form-hint {
grid-column: 1 / -1;
color: #475467;
font-size: 13px;
line-height: 1.45;
}
.suggested-input {
border-color: #f59e0b;
background: #fffbeb;
}
.span-2 { .span-2 {
grid-column: span 2; grid-column: span 2;
} }
@@ -676,6 +688,34 @@ textarea:focus {
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
} }
.summary-suggested {
color: var(--warn);
font-weight: 700;
}
.summary-review-reasons {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 6px;
}
.summary-review-reasons span {
max-width: 260px;
padding: 3px 8px;
border-radius: 999px;
background: #f2f4f7;
color: #475467;
font-size: 12px;
font-weight: 700;
line-height: 1.35;
}
.summary-review-reasons span:first-child {
background: #fef0c7;
color: var(--warn);
}
.summary-full { .summary-full {
max-width: min(720px, 72vw); max-width: min(720px, 72vw);
margin-top: 8px; margin-top: 8px;
@@ -706,6 +746,13 @@ textarea:focus {
max-width: none; max-width: none;
} }
.duplicate-summary-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.summary-conflict-heading { .summary-conflict-heading {
color: var(--accent-strong); color: var(--accent-strong);
font-size: 13px; font-size: 13px;
@@ -774,6 +821,68 @@ textarea:focus {
margin-bottom: 8px; margin-bottom: 8px;
} }
.summary-binding-status {
display: grid;
gap: 4px;
align-items: start;
}
.summary-binding-badge {
width: max-content;
}
.summary-binding-status small {
color: var(--muted);
line-height: 1.4;
}
.summary-binding-panel {
display: grid;
gap: 10px;
margin-bottom: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
}
.summary-binding-panel.matched {
border-color: #bbf7d0;
background: #f0fdf4;
}
.summary-binding-title {
color: #344054;
font-size: 13px;
font-weight: 700;
}
.summary-binding-record {
color: var(--text);
font-size: 14px;
line-height: 1.5;
}
.summary-binding-candidate {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.summary-binding-candidate small {
color: var(--muted);
line-height: 1.4;
}
.summary-task-section {
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
.summary-search-full { .summary-search-full {
max-width: none; max-width: none;
} }
@@ -783,6 +892,35 @@ textarea:focus {
gap: 10px; gap: 10px;
} }
.summary-identity-edit-panel {
display: grid;
gap: 10px;
margin-bottom: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
}
.summary-identity-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.summary-identity-grid label {
display: grid;
gap: 6px;
color: #344054;
font-size: 13px;
font-weight: 700;
}
.summary-identity-grid input {
width: 100%;
min-width: 0;
}
.summary-body-editor { .summary-body-editor {
min-height: 220px; min-height: 220px;
font-family: Arial, "Songti SC", SimSun, sans-serif; font-family: Arial, "Songti SC", SimSun, sans-serif;
@@ -1044,6 +1182,13 @@ td {
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.record-summary-meta {
margin-top: 4px;
color: var(--muted);
font-size: 13px;
line-height: 1.4;
}
.record-row.missing-summary td:first-child { .record-row.missing-summary td:first-child {
position: relative; position: relative;
padding-left: 18px; padding-left: 18px;
@@ -1750,11 +1895,23 @@ td {
justify-content: flex-start; justify-content: flex-start;
} }
.summary-identity-grid {
grid-template-columns: 1fr;
}
.summary-search-actions .secondary-button, .summary-search-actions .secondary-button,
.summary-search-actions .summary-time-input { .summary-search-actions .summary-time-input {
width: 100%; width: 100%;
} }
.summary-binding-candidate {
grid-template-columns: 1fr;
}
.summary-binding-candidate .secondary-button {
width: 100%;
}
.log-detail-grid { .log-detail-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 10px; gap: 10px;