课程小结支持直接修改正文

This commit is contained in:
Codex
2026-06-18 12:45:21 +08:00
parent 77e31bd64a
commit 8ee33b6db9
4 changed files with 141 additions and 1 deletions
+49
View File
@@ -1628,6 +1628,7 @@ OPERATION_LABELS = {
"admin-approve-correction": "审核批准上课记录纠错", "admin-approve-correction": "审核批准上课记录纠错",
"admin-approve-deletion": "审核批准上课记录删除", "admin-approve-deletion": "审核批准上课记录删除",
"admin-update-course-summary-time": "课程小结补齐时间", "admin-update-course-summary-time": "课程小结补齐时间",
"admin-update-course-summary-body": "课程小结修改正文",
"admin-delete-course-summary": "课程小结删除", "admin-delete-course-summary": "课程小结删除",
"rollback-operation": "撤回操作", "rollback-operation": "撤回操作",
} }
@@ -2481,6 +2482,37 @@ def replace_course_summary_block(root: Path, path: Path, summary_id: str, new_ti
raise ValueError("未找到课程小结") 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<meta>(?:>\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: 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:
@@ -2516,6 +2548,23 @@ def update_course_summary_time(root: Path, summary_id: str, time_range: str) ->
return result 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: 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 ""))
+18
View File
@@ -27,6 +27,7 @@ from ..data import (
reject_admin_task, reject_admin_task,
resolve_duplicate_course_summary_task, resolve_duplicate_course_summary_task,
rollback_operation_log, rollback_operation_log,
update_course_summary_body,
update_course_summary_review_task, update_course_summary_review_task,
update_course_summary_time, 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} 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}") @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:
+55 -1
View File
@@ -112,6 +112,7 @@ let currentSummaryReviews = [];
let activeSummaryReview = null; let activeSummaryReview = null;
let currentSummarySearchItems = []; let currentSummarySearchItems = [];
let expandedSummarySearchId = ""; let expandedSummarySearchId = "";
let editingSummarySearchId = "";
let currentOperationLogs = []; let currentOperationLogs = [];
let expandedOperationLogId = ""; let expandedOperationLogId = "";
const registerPreviewState = { const registerPreviewState = {
@@ -795,6 +796,23 @@ function renderSummaryActions(item) {
</div>`; </div>`;
} }
function renderSummaryBodyEditor(item) {
const body = item.body || item.body_preview || "";
if (String(item.id) !== editingSummarySearchId) {
return `<div class="summary-body summary-search-full">${escapeHtml(body || "暂无正文")}</div>
<div class="summary-edit-actions">
<button class="secondary-button summary-body-edit" type="button" data-summary-id="${escapeHtml(item.id)}">修改课程小结</button>
</div>`;
}
return `<div class="summary-body-edit-panel">
<textarea class="summary-body-editor" data-summary-body-editor="${escapeHtml(item.id)}" rows="10">${escapeHtml(body)}</textarea>
<div class="summary-edit-actions">
<button class="secondary-button summary-body-save" type="button" data-summary-id="${escapeHtml(item.id)}">保存</button>
<button class="secondary-button summary-body-cancel" type="button" data-summary-id="${escapeHtml(item.id)}">取消</button>
</div>
</div>`;
}
function renderMatchedFields(item) { function renderMatchedFields(item) {
const fields = Array.isArray(item.matched_fields) ? item.matched_fields : []; const fields = Array.isArray(item.matched_fields) ? item.matched_fields : [];
return fields.length ? `<br><small>命中:${fields.map(escapeHtml).join("、")}</small>` : ""; return fields.length ? `<br><small>命中:${fields.map(escapeHtml).join("、")}</small>` : "";
@@ -820,7 +838,7 @@ function renderSummarySearchRows(items) {
${renderMatchedFields(item)} ${renderMatchedFields(item)}
</div> </div>
</div> </div>
<div class="summary-body summary-search-full">${escapeHtml(item.body || item.body_preview || "暂无正文")}</div> ${renderSummaryBodyEditor(item)}
<div class="summary-search-actions">${renderSummaryActions(item)}</div> <div class="summary-search-actions">${renderSummaryActions(item)}</div>
</div> </div>
</td> </td>
@@ -844,6 +862,7 @@ async function loadSummarySearch() {
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 = "";
summarySearchMeta.innerHTML = [ summarySearchMeta.innerHTML = [
metric("命中小结", `${data.count}`), metric("命中小结", `${data.count}`),
metric("当前显示", `${data.returned}`), metric("当前显示", `${data.returned}`),
@@ -871,6 +890,18 @@ async function deleteCourseSummary(summaryId) {
await loadSummarySearch(); 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() { async function saveSummaryReviewEdits() {
if (!activeSummaryReview) return; if (!activeSummaryReview) return;
try { try {
@@ -1451,6 +1482,29 @@ summarySearchMissingTime.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 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) { if (saveTime) {
const input = summarySearchRows.querySelector(`[data-summary-time-input='${saveTime.dataset.summaryId}']`); const input = summarySearchRows.querySelector(`[data-summary-time-input='${saveTime.dataset.summaryId}']`);
const timeRange = input ? input.value.trim() : ""; const timeRange = input ? input.value.trim() : "";
+19
View File
@@ -748,6 +748,25 @@ textarea:focus {
max-width: none; 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 { .summary-search-actions {
display: flex; display: flex;
align-items: center; align-items: center;