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 @@
-
+
+
+