From 8ee33b6db96006d4549c9f2e1cbd71ebed6e6b3f Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 18 Jun 2026 12:45:21 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=BE=E7=A8=8B=E5=B0=8F=E7=BB=93=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=9B=B4=E6=8E=A5=E4=BF=AE=E6=94=B9=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/data.py | 49 ++++++++++++++++++++++++++++++++++ app/app/routers/admin.py | 18 +++++++++++++ app/app/static/admin.js | 56 ++++++++++++++++++++++++++++++++++++++- app/app/static/styles.css | 19 +++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) diff --git a/app/app/data.py b/app/app/data.py index a8a7bcf..ad80098 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -1628,6 +1628,7 @@ OPERATION_LABELS = { "admin-approve-correction": "审核批准上课记录纠错", "admin-approve-deletion": "审核批准上课记录删除", "admin-update-course-summary-time": "课程小结补齐时间", + "admin-update-course-summary-body": "课程小结修改正文", "admin-delete-course-summary": "课程小结删除", "rollback-operation": "撤回操作", } @@ -2481,6 +2482,37 @@ def replace_course_summary_block(root: Path, path: Path, summary_id: str, new_ti raise ValueError("未找到课程小结") +def replace_course_summary_body(root: Path, path: Path, summary_id: str, new_body: 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) + for index, match in enumerate(matches): + title = match.group("title").strip() + body_start = match.end() + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + body = text[body_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) + if item_id != summary_id: + continue + updated_body = new_body.strip() + meta_match = re.match(r"(?P(?:>\s+.*(?:\n|$))+)\s*", body) + metadata = meta_match.group("meta").rstrip() if meta_match else "" + updated_block = f"{metadata}\n\n{updated_body}" if metadata else updated_body + new_text = f"{text[:body_start].rstrip()}\n\n{updated_block}\n\n{text[end:].lstrip()}" + atomic_write_text(path, new_text.rstrip() + "\n") + new_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{updated_body[:200]}", 20) + return { + "id": summary_id, + "new_id": new_id, + "title": title, + "path": str(path), + "body": updated_body, + } + raise ValueError("未找到课程小结") + + def find_course_summary_item(root: Path, summary_id: str) -> dict: for item in iter_course_summary_markdown(root): if str(item.get("id") or "") == summary_id: @@ -2516,6 +2548,23 @@ def update_course_summary_time(root: Path, summary_id: str, time_range: str) -> return result +def update_course_summary_body(root: Path, summary_id: str, body: str) -> dict: + new_body = body.strip() + if not new_body: + raise ValueError("课程小结正文不能为空") + item = find_course_summary_item(root, summary_id) + path = Path(str(item.get("source_path") or "")) + original = path.read_text(encoding="utf-8") + backup_dir = create_data_backup("admin-update-course-summary-body", {path: original}, [summary_id]) + result = replace_course_summary_body(root, path, summary_id, new_body) + result["backup_id"] = backup_dir.name + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return result + + def delete_course_summary(root: Path, summary_id: str) -> dict: item = find_course_summary_item(root, summary_id) path = Path(str(item.get("source_path") or "")) diff --git a/app/app/routers/admin.py b/app/app/routers/admin.py index 491564b..e800110 100644 --- a/app/app/routers/admin.py +++ b/app/app/routers/admin.py @@ -27,6 +27,7 @@ from ..data import ( reject_admin_task, resolve_duplicate_course_summary_task, rollback_operation_log, + update_course_summary_body, update_course_summary_review_task, update_course_summary_time, ) @@ -279,6 +280,23 @@ def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str return {"ok": True, **result} +@router.post("/api/admin/course-summaries/{summary_id}/body") +def admin_update_course_summary_body(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + result = update_course_summary_body(COURSE_SUMMARIES_ROOT, summary_id, str(payload.get("body") or "")) + append_operation_log( + OPERATION_LOGS_PATH, + "课程小结修改正文", + "已更新", + summary_id=summary_id, + 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}") def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_admin_auth)): try: diff --git a/app/app/static/admin.js b/app/app/static/admin.js index eafe8fa..038e557 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -112,6 +112,7 @@ let currentSummaryReviews = []; let activeSummaryReview = null; let currentSummarySearchItems = []; let expandedSummarySearchId = ""; +let editingSummarySearchId = ""; let currentOperationLogs = []; let expandedOperationLogId = ""; const registerPreviewState = { @@ -795,6 +796,23 @@ function renderSummaryActions(item) { `; } +function renderSummaryBodyEditor(item) { + const body = item.body || item.body_preview || ""; + if (String(item.id) !== editingSummarySearchId) { + return `
${escapeHtml(body || "暂无正文")}
+
+ +
`; + } + return `
+ +
+ + +
+
`; +} + function renderMatchedFields(item) { const fields = Array.isArray(item.matched_fields) ? item.matched_fields : []; return fields.length ? `
命中:${fields.map(escapeHtml).join("、")}` : ""; @@ -820,7 +838,7 @@ function renderSummarySearchRows(items) { ${renderMatchedFields(item)} -
${escapeHtml(item.body || item.body_preview || "暂无正文")}
+ ${renderSummaryBodyEditor(item)}
${renderSummaryActions(item)}
@@ -844,6 +862,7 @@ async function loadSummarySearch() { const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`); currentSummarySearchItems = data.items || []; if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = ""; + if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = ""; summarySearchMeta.innerHTML = [ metric("命中小结", `${data.count} 条`), metric("当前显示", `${data.returned} 条`), @@ -871,6 +890,18 @@ async function deleteCourseSummary(summaryId) { await loadSummarySearch(); } +async function updateCourseSummaryBody(summaryId, body) { + await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/body`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body }), + }); + editingSummarySearchId = ""; + expandedSummarySearchId = ""; + await loadSummarySearch(); + await loadOperationLogs(); +} + async function saveSummaryReviewEdits() { if (!activeSummaryReview) return; try { @@ -1451,6 +1482,29 @@ summarySearchMissingTime.addEventListener("change", loadSummarySearch); summarySearchRows.addEventListener("click", (event) => { const saveTime = event.target.closest(".summary-time-save"); const deleteSummary = event.target.closest(".summary-delete"); + const editSummary = event.target.closest(".summary-body-edit"); + const saveSummaryBody = event.target.closest(".summary-body-save"); + const cancelSummaryBody = event.target.closest(".summary-body-cancel"); + if (editSummary) { + editingSummarySearchId = editSummary.dataset.summaryId; + renderCurrentSummarySearch(); + return; + } + if (cancelSummaryBody) { + editingSummarySearchId = ""; + renderCurrentSummarySearch(); + return; + } + if (saveSummaryBody) { + const textarea = summarySearchRows.querySelector(`[data-summary-body-editor='${saveSummaryBody.dataset.summaryId}']`); + const body = textarea ? textarea.value.trim() : ""; + if (!body) { + alert("课程小结正文不能为空"); + return; + } + updateCourseSummaryBody(saveSummaryBody.dataset.summaryId, body).catch((error) => alert(error.message)); + return; + } if (saveTime) { const input = summarySearchRows.querySelector(`[data-summary-time-input='${saveTime.dataset.summaryId}']`); const timeRange = input ? input.value.trim() : ""; diff --git a/app/app/static/styles.css b/app/app/static/styles.css index 6380111..50123f1 100644 --- a/app/app/static/styles.css +++ b/app/app/static/styles.css @@ -748,6 +748,25 @@ textarea:focus { max-width: none; } +.summary-body-edit-panel { + display: grid; + gap: 10px; +} + +.summary-body-editor { + min-height: 220px; + font-family: Arial, "Songti SC", SimSun, sans-serif; + line-height: 1.5; +} + +.summary-edit-actions { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} + .summary-search-actions { display: flex; align-items: center;