完善课程小结工作区与后台统计
This commit is contained in:
@@ -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
@@ -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 ""))
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user