From e15c931e9110e7b6e865c31aa544eacb2bbda977 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 15 Jun 2026 12:08:04 +0800 Subject: [PATCH] feat: add course summary search --- app/MAINTENANCE.md | 2 +- app/README.md | 1 + app/app/data.py | 165 ++++++++++++++++++++++++++++++++++++++ app/app/main.py | 27 +++++++ app/app/static/admin.html | 32 ++++++++ app/app/static/admin.js | 74 +++++++++++++++++ app/app/static/styles.css | 18 ++++- 7 files changed, 317 insertions(+), 2 deletions(-) diff --git a/app/MAINTENANCE.md b/app/MAINTENANCE.md index ce5e140..09417d8 100644 --- a/app/MAINTENANCE.md +++ b/app/MAINTENANCE.md @@ -83,7 +83,7 @@ git push origin HEAD:<当前分支> /root/新时空数据/data/operation_logs.jsonl ``` -普通前端和查询类改动不应该改变这些文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、课时账户编辑、课程小结自动入账或审核批准,先确认自动备份目录: +普通前端和查询类改动不应该改变这些文件;课程小结查询页是只读功能,也不应该改变这些文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、课时账户编辑、课程小结自动入账或审核批准,先确认自动备份目录: ```text /root/新时空数据/data/backups/ diff --git a/app/README.md b/app/README.md index 7882b4c..17af266 100644 --- a/app/README.md +++ b/app/README.md @@ -191,4 +191,5 @@ docker compose up -d - `POST /api/admin/tasks/{task_id}/approve`:批准纠错并写入正式上课记录。 - `POST /api/admin/tasks/{task_id}/reject`:驳回纠错。 - `POST /api/ingest/course-summaries`:本机采集脚本批量推送课程小结,使用 `X-INGEST-TOKEN` 鉴权。 +- `GET /api/admin/course-summaries`:管理后台只读查询正式课程小结库。 - `GET /api/admin/operation-logs`:管理后台读取小结接收、自动入账、审核批准和驳回记录。 diff --git a/app/app/data.py b/app/app/data.py index 94db064..12036c3 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -42,6 +42,8 @@ CLASSNOTE_RE = re.compile( ) PAYMENT_LINE_RE = re.compile(r"^(?P.+?)-(?P\d{4}-\d{2}-\d{2}):(?P\d+(?:\.\d+)?)$") TIME_RANGE_RE = re.compile(r"^(?P\d{1,2}):(?P\d{2})-(?P\d{1,2}):(?P\d{2})$") +COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P.+)$", re.M) +COURSE_SUMMARY_DATE_RE = re.compile(r"(?P<date>\d{4}[.-]\d{1,2}[.-]\d{1,2})") BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-") BACKUP_KEEP_COUNT = 50 ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"} @@ -1059,6 +1061,169 @@ def list_operation_logs(path: Path, limit: int = 100, operation: str = "", statu return {"count": len(rows), "items": rows} +def normalize_filter_date(value: str) -> str: + text = value.strip().replace(".", "-") + if not text: + return "" + parts = text.split("-") + if len(parts) == 3: + text = f"{int(parts[0]):04d}-{int(parts[1]):02d}-{int(parts[2]):02d}" + return date.fromisoformat(text).isoformat() + + +def parse_course_summary_file_identity(root: Path, path: Path) -> dict: + relative = path.relative_to(root) + student = canonical_name(relative.parts[0]) if relative.parts else "" + stem = path.stem + parts = stem.split("_") + teacher = "" + subject = "" + if len(parts) >= 3: + student = canonical_name(parts[0]) + teacher = canonical_name("_".join(parts[1:-1])) + subject = parts[-1] + return { + "student": student, + "teacher": teacher, + "subject": normalize_subject(subject), + "source_path": str(path), + "relative_path": str(relative), + } + + +def parse_course_summary_title_date(title: str) -> str: + match = COURSE_SUMMARY_DATE_RE.search(title) + if not match: + return "" + raw = match.group("date").replace(".", "-") + parts = raw.split("-") + if len(parts) != 3: + return "" + normalized = f"{int(parts[0]):04d}-{int(parts[1]):02d}-{int(parts[2]):02d}" + try: + return date.fromisoformat(normalized).isoformat() + except ValueError: + return "" + + +def iter_course_summary_markdown(root: Path) -> Iterable[dict]: + if not root.exists(): + return + for path in sorted(root.rglob("*.md")): + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8") + matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text)) + if not matches: + continue + identity = parse_course_summary_file_identity(root, path) + group = "" + 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.end() + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + body = text[start:end].strip() + body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", body).strip() + body_text = body_without_meta or body + item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20) + yield { + **identity, + "id": item_id, + "title": title, + "date_iso": parse_course_summary_title_date(title), + "group": group, + "body": body_text, + "body_preview": body_text[:260] + ("..." if len(body_text) > 260 else ""), + } + + +def course_summary_matches(item: dict, q: str, student: str, teacher: str, subject: str, date_from: str, date_to: str) -> bool: + if student and student not in str(item.get("student", "")): + return False + if teacher and teacher not in str(item.get("teacher", "")): + return False + if subject and normalize_subject(subject) not in str(item.get("subject", "")): + return False + item_date = str(item.get("date_iso") or "") + if date_from and (not item_date or item_date < date_from): + return False + if date_to and (not item_date or item_date > date_to): + return False + if q: + haystack = "\n".join( + str(item.get(key, "")) + for key in ("student", "teacher", "subject", "title", "group", "body", "relative_path") + ) + return q in haystack + return True + + +def course_summary_matched_fields(item: dict, q: str) -> list[str]: + if not q: + return [] + labels = { + "student": "学生", + "teacher": "老师", + "subject": "科目", + "title": "标题", + "group": "群名", + "body": "正文", + "relative_path": "来源", + } + return [label for key, label in labels.items() if q in str(item.get(key, ""))] + + +def query_course_summaries( + root: Path, + q: str = "", + student: str = "", + teacher: str = "", + subject: str = "", + date_from: str = "", + date_to: str = "", + limit: int = 200, +) -> dict: + normalized_from = normalize_filter_date(date_from) + normalized_to = normalize_filter_date(date_to) + if normalized_from and normalized_to and normalized_from > normalized_to: + raise ValueError("开始日期不能晚于结束日期") + keyword = q.strip() + matched = [ + item + for item in iter_course_summary_markdown(root) + if course_summary_matches( + item, + keyword, + canonical_name(student.strip()), + canonical_name(teacher.strip()), + subject.strip(), + normalized_from, + normalized_to, + ) + ] + for item in matched: + item["matched_fields"] = course_summary_matched_fields(item, keyword) + matched.sort( + key=lambda item: ( + str(item.get("date_iso") or "0000-00-00"), + str(item.get("student") or ""), + str(item.get("teacher") or ""), + str(item.get("subject") or ""), + str(item.get("title") or ""), + ), + reverse=True, + ) + limited = matched[:limit] + return { + "count": len(matched), + "returned": len(limited), + "items": limited, + } + + def course_summary_path(root: Path, summary: dict) -> Path: student = safe_filename_part(summary["student"]) teacher = safe_filename_part(summary["teacher"] or "待核对老师") diff --git a/app/app/main.py b/app/app/main.py index e7c7e87..493453b 100644 --- a/app/app/main.py +++ b/app/app/main.py @@ -28,6 +28,7 @@ from .data import ( ingest_course_summaries, list_operation_logs, list_admin_tasks, + query_course_summaries, query_records, read_accounts, read_classnotes, @@ -842,6 +843,32 @@ def admin_operation_logs( ) +@app.get("/api/admin/course-summaries") +def admin_course_summaries( + q: str = Query(""), + student: str = Query(""), + teacher: str = Query(""), + subject: str = Query(""), + date_from: str = Query(""), + date_to: str = Query(""), + limit: int = Query(200, ge=1, le=1000), + _user: str = Depends(verify_admin_auth), +): + try: + return query_course_summaries( + COURSE_SUMMARIES_ROOT, + q=q, + student=student, + teacher=teacher, + subject=subject, + date_from=date_from, + date_to=date_to, + limit=limit, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/admin/tasks/{task_id}/approve") def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)): try: diff --git a/app/app/static/admin.html b/app/app/static/admin.html index 6defd7f..ba4bf34 100644 --- a/app/app/static/admin.html +++ b/app/app/static/admin.html @@ -24,6 +24,7 @@ <button class="admin-tab is-active" data-admin-tab="accounts" 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="logs" type="button">操作记录</button> <button class="admin-tab" data-admin-tab="register" type="button">登记</button> </nav> @@ -168,6 +169,37 @@ </div> </section> + <section id="summarySearchPanel" class="panel admin-panel" hidden> + <div class="section-head"> + <h2>课程小结查询</h2> + </div> + <form id="summarySearchForm" class="search-row summary-search-row"> + <input id="summarySearchQuery" autocomplete="off" placeholder="关键词:课堂内容、作业、问题等" /> + <input id="summarySearchStudent" autocomplete="off" placeholder="学生" /> + <input id="summarySearchTeacher" autocomplete="off" placeholder="老师" /> + <input id="summarySearchSubject" autocomplete="off" placeholder="科目" /> + <input id="summarySearchDateFrom" type="date" aria-label="开始日期" /> + <input id="summarySearchDateTo" type="date" aria-label="结束日期" /> + <button type="submit">查询</button> + </form> + <div id="summarySearchMeta" class="summary-grid"></div> + <div class="table-wrap"> + <table> + <thead> + <tr> + <th>日期</th> + <th>学生</th> + <th>老师/科目</th> + <th>标题</th> + <th>小结正文</th> + <th>来源</th> + </tr> + </thead> + <tbody id="summarySearchRows"></tbody> + </table> + </div> + </section> + <section id="logsPanel" class="panel admin-panel" hidden> <div class="section-head"> <h2>操作记录</h2> diff --git a/app/app/static/admin.js b/app/app/static/admin.js index 0107591..f08edbf 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -4,6 +4,7 @@ const panels = { accounts: document.querySelector("#accountsPanel"), reviews: document.querySelector("#reviewsPanel"), summaries: document.querySelector("#summariesPanel"), + summarySearch: document.querySelector("#summarySearchPanel"), logs: document.querySelector("#logsPanel"), register: document.querySelector("#registerPanel"), }; @@ -30,6 +31,15 @@ const reviewRows = document.querySelector("#reviewRows"); const summaryReviewStatus = document.querySelector("#summaryReviewStatus"); const summaryReviewMeta = document.querySelector("#summaryReviewMeta"); const summaryReviewRows = document.querySelector("#summaryReviewRows"); +const summarySearchForm = document.querySelector("#summarySearchForm"); +const summarySearchQuery = document.querySelector("#summarySearchQuery"); +const summarySearchStudent = document.querySelector("#summarySearchStudent"); +const summarySearchTeacher = document.querySelector("#summarySearchTeacher"); +const summarySearchSubject = document.querySelector("#summarySearchSubject"); +const summarySearchDateFrom = document.querySelector("#summarySearchDateFrom"); +const summarySearchDateTo = document.querySelector("#summarySearchDateTo"); +const summarySearchMeta = document.querySelector("#summarySearchMeta"); +const summarySearchRows = document.querySelector("#summarySearchRows"); const logOperation = document.querySelector("#logOperation"); const logStatus = document.querySelector("#logStatus"); const logFilterForm = document.querySelector("#logFilterForm"); @@ -112,6 +122,7 @@ function setActiveTab(tabName) { if (tabName === "accounts") loadAccounts(); if (tabName === "reviews") loadReviews(); if (tabName === "summaries") loadSummaryReviews(); + if (tabName === "summarySearch") loadSummarySearch(); if (tabName === "logs") loadOperationLogs(); } @@ -343,6 +354,56 @@ async function loadSummaryReviews() { } } +function summarySearchParams() { + const params = new URLSearchParams({ limit: "200" }); + if (summarySearchQuery.value.trim()) params.set("q", summarySearchQuery.value.trim()); + if (summarySearchStudent.value.trim()) params.set("student", summarySearchStudent.value.trim()); + if (summarySearchTeacher.value.trim()) params.set("teacher", summarySearchTeacher.value.trim()); + if (summarySearchSubject.value.trim()) params.set("subject", summarySearchSubject.value.trim()); + if (summarySearchDateFrom.value) params.set("date_from", summarySearchDateFrom.value); + if (summarySearchDateTo.value) params.set("date_to", summarySearchDateTo.value); + return params; +} + +function renderSummarySearchBody(item) { + const fullId = `summary-full-${item.id}`; + const hasFull = String(item.body || "") !== String(item.body_preview || ""); + return `<div class="summary-body">${escapeHtml(item.body_preview || "")}</div> + <div id="${escapeHtml(fullId)}" class="summary-body summary-full" hidden>${escapeHtml(item.body || "")}</div> + <button class="small-button summary-toggle" type="button" data-target="${escapeHtml(fullId)}" ${hasFull ? "" : "hidden"}>展开</button>`; +} + +function renderMatchedFields(item) { + const fields = Array.isArray(item.matched_fields) ? item.matched_fields : []; + return fields.length ? `<br><small>命中:${fields.map(escapeHtml).join("、")}</small>` : ""; +} + +async function loadSummarySearch() { + summarySearchRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`; + try { + const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`); + summarySearchMeta.innerHTML = [ + metric("命中小结", `${data.count} 条`), + metric("当前显示", `${data.returned} 条`), + ].join(""); + summarySearchRows.innerHTML = data.items + .map((item) => `<tr> + <td>${escapeHtml(item.date_iso || "未识别")}</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.title || "")}${renderMatchedFields(item)}</td> + <td>${renderSummarySearchBody(item)}</td> + <td><code class="line-code">${escapeHtml(item.relative_path || "")}</code></td> + </tr>`) + .join(""); + if (!data.items.length) { + summarySearchRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的课程小结</td></tr>`; + } + } catch (error) { + summarySearchRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`; + } +} + function renderLogDetail(item) { const details = []; if (item.source_id) details.push(`来源:${item.source_id}`); @@ -457,6 +518,18 @@ summaryReviewRows.addEventListener("click", (event) => { if (approve) summaryReviewTask(approve.dataset.taskId, "approve"); if (reject) summaryReviewTask(reject.dataset.taskId, "reject"); }); +summarySearchForm.addEventListener("submit", (event) => { + event.preventDefault(); + loadSummarySearch(); +}); +summarySearchRows.addEventListener("click", (event) => { + const button = event.target.closest(".summary-toggle"); + if (!button) return; + const target = document.getElementById(button.dataset.target); + if (!target) return; + target.hidden = !target.hidden; + button.textContent = target.hidden ? "展开" : "收起"; +}); logOperation.addEventListener("change", loadOperationLogs); logStatus.addEventListener("change", loadOperationLogs); logFilterForm.addEventListener("submit", (event) => { @@ -474,6 +547,7 @@ refreshBtn.addEventListener("click", () => { if (!panels.accounts.hidden) loadAccounts(); if (!panels.reviews.hidden) loadReviews(); if (!panels.summaries.hidden) loadSummaryReviews(); + if (!panels.summarySearch.hidden) loadSummarySearch(); if (!panels.logs.hidden) loadOperationLogs(); }); diff --git a/app/app/static/styles.css b/app/app/static/styles.css index 84c1af1..30417e0 100644 --- a/app/app/static/styles.css +++ b/app/app/static/styles.css @@ -210,6 +210,10 @@ h2 { grid-template-columns: minmax(0, 1fr) 76px; } +.summary-search-row { + grid-template-columns: minmax(180px, 1.4fr) repeat(5, minmax(110px, 0.8fr)) 88px; +} + input, select { min-width: 0; @@ -389,6 +393,17 @@ textarea:focus { line-height: 1.45; } +.summary-full { + max-width: min(720px, 72vw); + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--line); +} + +.summary-toggle { + margin-top: 8px; +} + .muted { color: var(--muted); } @@ -789,7 +804,8 @@ td { .search-row, .search-row.compact, - .account-search-row { + .account-search-row, + .summary-search-row { grid-template-columns: 1fr; }