From 72184004e34e1e49f8856c57b8fc445147499385 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 18 Jun 2026 03:19:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AF=BE=E7=A8=8B=E5=B0=8F?= =?UTF-8?q?=E7=BB=93=E8=A1=A5=E5=85=85=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/routers/records.py | 106 +++++++++++++++++++++++++- app/app/schemas.py | 5 ++ app/app/static/app.js | 150 +++++++++++++++++++++++++++++++++++-- app/app/static/index.html | 29 ++++++- app/app/static/styles.css | 59 +++++++++++++++ 5 files changed, 337 insertions(+), 12 deletions(-) diff --git a/app/app/routers/records.py b/app/app/routers/records.py index 9e46e17..c3535b8 100644 --- a/app/app/routers/records.py +++ b/app/app/routers/records.py @@ -1,17 +1,33 @@ from __future__ import annotations +from datetime import datetime + from fastapi import APIRouter, Depends, HTTPException, Query from ..api_utils import load_accounts, load_records, load_teachers from ..auth import verify_records_auth -from ..config import ADMIN_TASKS_PATH, COURSE_SUMMARIES_ROOT +from ..config import ( + ADMIN_TASKS_PATH, + COURSE_SUMMARIES_ROOT, + COURSE_SUMMARY_STATE_PATH, + OPERATION_LOGS_PATH, + write_lock, +) from ..data import ( account_to_dict, + append_operation_log, + course_summary_semantic_key, + duration_minutes_from_time_range, + find_record_by_identity, query_public_records, + read_course_summary_state, + save_course_summary_markdown, + sha1_text, submit_public_correction_tasks, submit_public_deletion_tasks, + write_course_summary_state, ) -from ..schemas import CorrectionSubmitPayload, DeletionSubmitPayload +from ..schemas import CorrectionSubmitPayload, CourseSummarySupplementPayload, DeletionSubmitPayload router = APIRouter() @@ -59,3 +75,89 @@ def submit_deletions(payload: DeletionSubmitPayload, _user: str = Depends(verify except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} + + +@router.post("/api/course-summaries/supplement") +def supplement_course_summary(payload: CourseSummarySupplementPayload, _user: str = Depends(verify_records_auth)): + body = payload.body.strip() + if not body: + raise HTTPException(status_code=400, detail="课程小结正文不能为空") + try: + records = load_records() + record = find_record_by_identity(records, payload.record_id) + date_iso = record.date.replace(".", "-") + source_id = f"supplement:{payload.record_id}:{sha1_text(body, 12)}" + summary = { + "source_id": source_id, + "student": record.student, + "date_iso": date_iso, + "time_range": record.time, + "duration_minutes": duration_minutes_from_time_range(record.time), + "teacher": record.teacher, + "subject": record.subject, + "group": "", + "sender": "课程记录页补充", + "message_time": "", + "body": body, + } + semantic_key = course_summary_semantic_key(summary) + with write_lock: + state = read_course_summary_state(COURSE_SUMMARY_STATE_PATH) + seen_source_ids = set(str(item) for item in state.get("seen_source_ids", [])) + seen_semantic_keys = set(str(item) for item in state.get("seen_semantic_keys", [])) + if source_id in seen_source_ids or semantic_key in seen_semantic_keys: + log_id = append_operation_log( + OPERATION_LOGS_PATH, + "课程小结补充", + "重复", + record_id=payload.record_id, + student=record.student, + teacher=record.teacher, + subject=record.subject, + source_id=source_id, + ) + return {"ok": True, "log_id": log_id, "submitted": 0, "status": "duplicate"} + saved = save_course_summary_markdown(COURSE_SUMMARIES_ROOT, summary) + status_value = "完成" if saved.get("added") else "重复" + seen_source_ids.add(source_id) + seen_semantic_keys.add(semantic_key) + state["seen_source_ids"] = sorted(seen_source_ids) + state["seen_semantic_keys"] = sorted(seen_semantic_keys) + state.setdefault("batches", []).append( + { + "batch_id": f"supplement-{payload.record_id}-{sha1_text(body, 8)}", + "received_at": datetime.now().isoformat(timespec="seconds"), + "window": {"source": "records_page_supplement"}, + "students": [record.student], + "result": { + "received": 1, + "saved": 1 if saved.get("added") else 0, + "auto_registered": 0, + "review_pending": 0, + "duplicates": 0 if saved.get("added") else 1, + "rejected": 0, + }, + } + ) + state["batches"] = state["batches"][-200:] + write_course_summary_state(COURSE_SUMMARY_STATE_PATH, state) + log_id = append_operation_log( + OPERATION_LOGS_PATH, + "课程小结补充", + status_value, + record_id=payload.record_id, + student=record.student, + teacher=record.teacher, + subject=record.subject, + source_id=source_id, + saved_path=str(saved.get("path") or ""), + heading=str(saved.get("heading") or ""), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return { + "ok": True, + "log_id": log_id, + "submitted": 1 if status_value == "完成" else 0, + "status": "saved" if status_value == "完成" else "duplicate", + } diff --git a/app/app/schemas.py b/app/app/schemas.py index aab1796..f6118ff 100644 --- a/app/app/schemas.py +++ b/app/app/schemas.py @@ -60,6 +60,11 @@ class DeletionSubmitPayload(BaseModel): items: list[DeletionItemPayload] +class CourseSummarySupplementPayload(BaseModel): + record_id: str + body: str + + class CourseSummaryPayload(BaseModel): source_id: str = "" student: str = "" diff --git a/app/app/static/app.js b/app/app/static/app.js index c388b01..272fbed 100644 --- a/app/app/static/app.js +++ b/app/app/static/app.js @@ -29,6 +29,15 @@ const deleteError = document.querySelector("#deleteError"); const closeDeleteBtn = document.querySelector("#closeDeleteBtn"); const cancelDeleteBtn = document.querySelector("#cancelDeleteBtn"); const confirmDeleteBtn = document.querySelector("#confirmDeleteBtn"); +const summarySupplementDialog = document.querySelector("#summarySupplementDialog"); +const summarySupplementForm = document.querySelector("#summarySupplementForm"); +const summarySupplementOriginal = document.querySelector("#summarySupplementOriginal"); +const summarySupplementBody = document.querySelector("#summarySupplementBody"); +const summarySupplementError = document.querySelector("#summarySupplementError"); +const summarySupplementPreview = document.querySelector("#summarySupplementPreview"); +const closeSummarySupplementBtn = document.querySelector("#closeSummarySupplementBtn"); +const cancelSummarySupplementBtn = document.querySelector("#cancelSummarySupplementBtn"); +const submitSummarySupplementBtn = document.querySelector("#submitSummarySupplementBtn"); const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]; const COPY_LINE_BREAK = "\r\n"; @@ -40,6 +49,7 @@ let correctedRecords = new Map(); let expandedSummaryRecords = new Set(); let activeCorrectionKey = ""; let activeDeleteKey = ""; +let activeSummarySupplementKey = ""; function fmtHours(value) { const totalMinutes = Math.round(Number(value || 0) * 60); @@ -194,9 +204,8 @@ function renderRecordRow(row) { const correctedClass = corrected ? " corrected-row" : ""; const actionLabel = corrected ? "编辑" : "纠错"; const badge = corrected ? '已修改' : ""; - const summaryCount = Number(displayRow.summary_count || 0); const summaryExpanded = expandedSummaryRecords.has(key); - return ` + return ` ${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)} ${escapeHtml(displayRow.time)} ${escapeHtml(displayRow.student)} @@ -205,7 +214,6 @@ function renderRecordRow(row) { ${escapeHtml(displayRow.duration)}
- ${badge} @@ -223,6 +231,17 @@ function renderSummaryEntry(summary) {
`; } +function renderSummaryMissingRow(key, expanded) { + return ` + +
+ 无课程小结 + +
+ + `; +} + function renderGroupedRecords(records) { currentRecordOrder = []; return groupRecordsByTeacher(records) @@ -242,7 +261,7 @@ function renderGroupedRecords(records) { ? ` ${(row.summaries || []).map(renderSummaryEntry).join("")} ` - : ""; + : renderSummaryMissingRow(row._recordKey, expandedSummaryRecords.has(row._recordKey)); return `${renderRecordRow(row)}${summaryRow}`; }) .join("")}`, @@ -259,6 +278,10 @@ function toggleSummaryRow(key) { renderCurrentRecords(); } +function toggleRecordSummary(key) { + toggleSummaryRow(key); +} + function statusClass(status) { if (status === "欠费") return "debt"; if (status === "预警") return "warning"; @@ -455,6 +478,92 @@ function closeDeleteDialog() { setDeleteError(""); } +function setSummarySupplementError(message) { + summarySupplementError.textContent = message; + summarySupplementError.hidden = !message; +} + +function buildSummarySupplementText(record, body) { + return [ + `学生:${record.student}`, + `日期:${record.date}`, + `时间:${record.time}`, + `老师:${record.teacher}`, + `科目:${record.subject}`, + "", + body.trim(), + ].join("\n"); +} + +function updateSummarySupplementPreview() { + const original = findCurrentRecord(activeSummarySupplementKey); + if (!original) return; + const body = summarySupplementBody.value.trim(); + if (!body) { + summarySupplementPreview.textContent = "提交后会直接保存为这节课的课程小结。"; + setSummarySupplementError(""); + return; + } + summarySupplementPreview.textContent = buildSummarySupplementText(original, body); + setSummarySupplementError(""); +} + +function openSummarySupplementDialog(key) { + const original = findCurrentRecord(key); + if (!original) return; + activeSummarySupplementKey = key; + summarySupplementOriginal.textContent = `将补充课程小结:${buildRecordLine(original)}`; + summarySupplementBody.value = ""; + summarySupplementPreview.textContent = "提交后会直接保存为这节课的课程小结。"; + setSummarySupplementError(""); + summarySupplementDialog.hidden = false; + summarySupplementBody.focus(); +} + +function closeSummarySupplementDialog() { + summarySupplementDialog.hidden = true; + activeSummarySupplementKey = ""; + setSummarySupplementError(""); +} + +async function submitSummarySupplement(event) { + event.preventDefault(); + const original = findCurrentRecord(activeSummarySupplementKey); + if (!original) return; + const body = summarySupplementBody.value.trim(); + if (!body) { + setSummarySupplementError("课程小结正文不能为空"); + return; + } + submitSummarySupplementBtn.disabled = true; + summarySupplementPreview.textContent = "正在提交"; + try { + const key = activeSummarySupplementKey; + const data = await fetchJson("/api/course-summaries/supplement", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + record_id: original.record_id, + body, + }), + }); + closeSummarySupplementDialog(); + await loadHealth(); + if (recordQuery.value.trim()) { + await queryRecords(recordQuery.value); + if (key) { + expandedSummaryRecords.add(key); + renderCurrentRecords(); + } + } + } catch (error) { + setSummarySupplementError(`提交失败:${error.message}`); + summarySupplementPreview.textContent = "请修正后重新提交"; + } finally { + submitSummarySupplementBtn.disabled = false; + } +} + async function submitDeleteRecord() { const original = findCurrentRecord(activeDeleteKey); if (!original) return; @@ -642,18 +751,33 @@ document.querySelectorAll("[data-query]").forEach((button) => { }); recordRows.addEventListener("click", (event) => { - const summaryButton = event.target.closest(".summary-toggle"); + const summarySupplementButton = event.target.closest(".summary-supplement"); const editButton = event.target.closest(".correction-edit"); const deleteButton = event.target.closest(".record-delete"); - if (summaryButton) { - toggleSummaryRow(summaryButton.dataset.recordKey); + if (summarySupplementButton) { + openSummarySupplementDialog(summarySupplementButton.dataset.recordKey); return; } if (editButton) { openCorrectionDialog(editButton.dataset.recordKey); return; } - if (deleteButton) openDeleteDialog(deleteButton.dataset.recordKey); + if (deleteButton) { + openDeleteDialog(deleteButton.dataset.recordKey); + return; + } + const row = event.target.closest(".record-row"); + if (row && !event.target.closest(".record-action-cell")) toggleRecordSummary(row.dataset.recordKey); +}); + +recordRows.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + const row = event.target.closest(".record-row"); + if (!row) return; + const target = event.target.closest(".summary-supplement, .correction-edit, .record-delete"); + if (target) return; + event.preventDefault(); + toggleRecordSummary(row.dataset.recordKey); }); [correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => { @@ -681,6 +805,13 @@ confirmDeleteBtn.addEventListener("click", submitDeleteRecord); deleteDialog.addEventListener("click", (event) => { if (event.target === deleteDialog) closeDeleteDialog(); }); +closeSummarySupplementBtn.addEventListener("click", closeSummarySupplementDialog); +cancelSummarySupplementBtn.addEventListener("click", closeSummarySupplementDialog); +summarySupplementDialog.addEventListener("click", (event) => { + if (event.target === summarySupplementDialog) closeSummarySupplementDialog(); +}); +summarySupplementBody.addEventListener("input", updateSummarySupplementPreview); +summarySupplementForm.addEventListener("submit", submitSummarySupplement); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !correctionDialog.hidden) { closeCorrectionDialog(); @@ -688,6 +819,9 @@ document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !deleteDialog.hidden) { closeDeleteDialog(); } + if (event.key === "Escape" && !summarySupplementDialog.hidden) { + closeSummarySupplementDialog(); + } }); copyCorrectedBtn.addEventListener("click", copyCorrectedRecords); diff --git a/app/app/static/index.html b/app/app/static/index.html index fd98ed6..9da3b94 100644 --- a/app/app/static/index.html +++ b/app/app/static/index.html @@ -4,7 +4,7 @@ 新时空教务管理系统 - +
@@ -140,6 +140,31 @@ - + + + diff --git a/app/app/static/styles.css b/app/app/static/styles.css index 751918d..b8967fb 100644 --- a/app/app/static/styles.css +++ b/app/app/static/styles.css @@ -756,6 +756,18 @@ td { color: var(--muted); } +.record-row { + cursor: pointer; +} + +.record-row:hover td { + background: #f8fafc; +} + +.record-row .record-action-cell { + cursor: default; +} + .corrected-row td { background: #fffaf0; } @@ -801,6 +813,10 @@ td { background: #fbfcfd; } +.summary-missing-row td { + background: #fcfcfd; +} + .record-summary-item { padding: 12px 16px; border-top: 1px solid var(--line); @@ -827,6 +843,45 @@ td { font-size: 13px; } +.summary-missing { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + color: var(--muted); + font-size: 13px; +} + +.summary-supplement-modal { + width: min(100%, 700px); +} + +.summary-supplement-body { + display: grid; + gap: 6px; + color: #344054; + font-size: 13px; + font-weight: 700; +} + +.summary-supplement-body textarea { + width: 100%; + min-height: 180px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + font: inherit; + resize: vertical; +} + +.summary-supplement-body textarea:focus { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15); +} + .correction-badge { display: inline-flex; align-items: center; @@ -1132,6 +1187,10 @@ td { line-height: 1.5; } +.summary-supplement-modal .correction-preview code { + font-size: 13px; +} + .modal-actions { display: flex; justify-content: flex-end;