修复课程小结补充流程

This commit is contained in:
Codex
2026-06-18 03:19:34 +08:00
parent 89d592b0bd
commit 72184004e3
5 changed files with 337 additions and 12 deletions
+104 -2
View File
@@ -1,17 +1,33 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from ..api_utils import load_accounts, load_records, load_teachers from ..api_utils import load_accounts, load_records, load_teachers
from ..auth import verify_records_auth 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 ( from ..data import (
account_to_dict, account_to_dict,
append_operation_log,
course_summary_semantic_key,
duration_minutes_from_time_range,
find_record_by_identity,
query_public_records, query_public_records,
read_course_summary_state,
save_course_summary_markdown,
sha1_text,
submit_public_correction_tasks, submit_public_correction_tasks,
submit_public_deletion_tasks, submit_public_deletion_tasks,
write_course_summary_state,
) )
from ..schemas import CorrectionSubmitPayload, DeletionSubmitPayload from ..schemas import CorrectionSubmitPayload, CourseSummarySupplementPayload, DeletionSubmitPayload
router = APIRouter() router = APIRouter()
@@ -59,3 +75,89 @@ def submit_deletions(payload: DeletionSubmitPayload, _user: str = Depends(verify
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result} 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",
}
+5
View File
@@ -60,6 +60,11 @@ class DeletionSubmitPayload(BaseModel):
items: list[DeletionItemPayload] items: list[DeletionItemPayload]
class CourseSummarySupplementPayload(BaseModel):
record_id: str
body: str
class CourseSummaryPayload(BaseModel): class CourseSummaryPayload(BaseModel):
source_id: str = "" source_id: str = ""
student: str = "" student: str = ""
+142 -8
View File
@@ -29,6 +29,15 @@ const deleteError = document.querySelector("#deleteError");
const closeDeleteBtn = document.querySelector("#closeDeleteBtn"); const closeDeleteBtn = document.querySelector("#closeDeleteBtn");
const cancelDeleteBtn = document.querySelector("#cancelDeleteBtn"); const cancelDeleteBtn = document.querySelector("#cancelDeleteBtn");
const confirmDeleteBtn = document.querySelector("#confirmDeleteBtn"); 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 WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
const COPY_LINE_BREAK = "\r\n"; const COPY_LINE_BREAK = "\r\n";
@@ -40,6 +49,7 @@ let correctedRecords = new Map();
let expandedSummaryRecords = new Set(); let expandedSummaryRecords = new Set();
let activeCorrectionKey = ""; let activeCorrectionKey = "";
let activeDeleteKey = ""; let activeDeleteKey = "";
let activeSummarySupplementKey = "";
function fmtHours(value) { function fmtHours(value) {
const totalMinutes = Math.round(Number(value || 0) * 60); const totalMinutes = Math.round(Number(value || 0) * 60);
@@ -194,9 +204,8 @@ function renderRecordRow(row) {
const correctedClass = corrected ? " corrected-row" : ""; const correctedClass = corrected ? " corrected-row" : "";
const actionLabel = corrected ? "编辑" : "纠错"; const actionLabel = corrected ? "编辑" : "纠错";
const badge = corrected ? '<span class="correction-badge">已修改</span>' : ""; const badge = corrected ? '<span class="correction-badge">已修改</span>' : "";
const summaryCount = Number(displayRow.summary_count || 0);
const summaryExpanded = expandedSummaryRecords.has(key); const summaryExpanded = expandedSummaryRecords.has(key);
return `<tr class="record-row${correctedClass}"> return `<tr class="record-row${correctedClass}" data-record-key="${escapeHtml(key)}" tabindex="0" role="button" aria-expanded="${summaryExpanded ? "true" : "false"}">
<td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td> <td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
<td>${escapeHtml(displayRow.time)}</td> <td>${escapeHtml(displayRow.time)}</td>
<td>${escapeHtml(displayRow.student)}</td> <td>${escapeHtml(displayRow.student)}</td>
@@ -205,7 +214,6 @@ function renderRecordRow(row) {
<td class="num">${escapeHtml(displayRow.duration)}</td> <td class="num">${escapeHtml(displayRow.duration)}</td>
<td class="record-action-cell"> <td class="record-action-cell">
<div class="record-actions"> <div class="record-actions">
<button class="small-button summary-toggle" type="button" data-record-key="${escapeHtml(key)}" ${summaryCount > 0 ? "" : "disabled"}>${summaryExpanded ? "收起小结" : "课程小结"}</button>
<button class="small-button correction-edit" type="button" data-record-key="${escapeHtml(key)}">${actionLabel}</button> <button class="small-button correction-edit" type="button" data-record-key="${escapeHtml(key)}">${actionLabel}</button>
<button class="small-button record-delete" type="button" data-record-key="${escapeHtml(key)}">删除</button> <button class="small-button record-delete" type="button" data-record-key="${escapeHtml(key)}">删除</button>
${badge} ${badge}
@@ -223,6 +231,17 @@ function renderSummaryEntry(summary) {
</div>`; </div>`;
} }
function renderSummaryMissingRow(key, expanded) {
return `<tr class="summary-collapse-row summary-missing-row" data-summary-row="${escapeHtml(key)}" ${expanded ? "" : "hidden"}>
<td colspan="7">
<div class="summary-missing">
<span>无课程小结</span>
<button class="small-button summary-supplement" type="button" data-record-key="${escapeHtml(key)}">补充课程小结</button>
</div>
</td>
</tr>`;
}
function renderGroupedRecords(records) { function renderGroupedRecords(records) {
currentRecordOrder = []; currentRecordOrder = [];
return groupRecordsByTeacher(records) return groupRecordsByTeacher(records)
@@ -242,7 +261,7 @@ function renderGroupedRecords(records) {
? `<tr class="summary-collapse-row" data-summary-row="${escapeHtml(row._recordKey)}" ${expandedSummaryRecords.has(row._recordKey) ? "" : "hidden"}> ? `<tr class="summary-collapse-row" data-summary-row="${escapeHtml(row._recordKey)}" ${expandedSummaryRecords.has(row._recordKey) ? "" : "hidden"}>
<td colspan="7">${(row.summaries || []).map(renderSummaryEntry).join("")}</td> <td colspan="7">${(row.summaries || []).map(renderSummaryEntry).join("")}</td>
</tr>` </tr>`
: ""; : renderSummaryMissingRow(row._recordKey, expandedSummaryRecords.has(row._recordKey));
return `${renderRecordRow(row)}${summaryRow}`; return `${renderRecordRow(row)}${summaryRow}`;
}) })
.join("")}`, .join("")}`,
@@ -259,6 +278,10 @@ function toggleSummaryRow(key) {
renderCurrentRecords(); renderCurrentRecords();
} }
function toggleRecordSummary(key) {
toggleSummaryRow(key);
}
function statusClass(status) { function statusClass(status) {
if (status === "欠费") return "debt"; if (status === "欠费") return "debt";
if (status === "预警") return "warning"; if (status === "预警") return "warning";
@@ -455,6 +478,92 @@ function closeDeleteDialog() {
setDeleteError(""); 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() { async function submitDeleteRecord() {
const original = findCurrentRecord(activeDeleteKey); const original = findCurrentRecord(activeDeleteKey);
if (!original) return; if (!original) return;
@@ -642,18 +751,33 @@ document.querySelectorAll("[data-query]").forEach((button) => {
}); });
recordRows.addEventListener("click", (event) => { 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 editButton = event.target.closest(".correction-edit");
const deleteButton = event.target.closest(".record-delete"); const deleteButton = event.target.closest(".record-delete");
if (summaryButton) { if (summarySupplementButton) {
toggleSummaryRow(summaryButton.dataset.recordKey); openSummarySupplementDialog(summarySupplementButton.dataset.recordKey);
return; return;
} }
if (editButton) { if (editButton) {
openCorrectionDialog(editButton.dataset.recordKey); openCorrectionDialog(editButton.dataset.recordKey);
return; 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) => { [correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => {
@@ -681,6 +805,13 @@ confirmDeleteBtn.addEventListener("click", submitDeleteRecord);
deleteDialog.addEventListener("click", (event) => { deleteDialog.addEventListener("click", (event) => {
if (event.target === deleteDialog) closeDeleteDialog(); 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) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !correctionDialog.hidden) { if (event.key === "Escape" && !correctionDialog.hidden) {
closeCorrectionDialog(); closeCorrectionDialog();
@@ -688,6 +819,9 @@ document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !deleteDialog.hidden) { if (event.key === "Escape" && !deleteDialog.hidden) {
closeDeleteDialog(); closeDeleteDialog();
} }
if (event.key === "Escape" && !summarySupplementDialog.hidden) {
closeSummarySupplementDialog();
}
}); });
copyCorrectedBtn.addEventListener("click", copyCorrectedRecords); copyCorrectedBtn.addEventListener("click", copyCorrectedRecords);
+27 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>新时空教务管理系统</title> <title>新时空教务管理系统</title>
<link rel="stylesheet" href="/static/styles.css?v=20260616-duration-text" /> <link rel="stylesheet" href="/static/styles.css?v=20260618-record-summary-row" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -140,6 +140,31 @@
</section> </section>
</div> </div>
<script src="/static/app.js?v=20260616-duration-text"></script> <div id="summarySupplementDialog" class="modal-backdrop" hidden>
<section class="correction-modal summary-supplement-modal" role="dialog" aria-modal="true" aria-labelledby="summarySupplementTitle">
<div class="modal-head">
<h2 id="summarySupplementTitle">补充课程小结</h2>
<button id="closeSummarySupplementBtn" class="modal-close" type="button" aria-label="关闭">关闭</button>
</div>
<form id="summarySupplementForm" class="correction-form">
<p id="summarySupplementOriginal" class="correction-original"></p>
<label class="summary-supplement-body">
小结正文
<textarea id="summarySupplementBody" rows="9" autocomplete="off" placeholder="填写本节课课堂内容、作业、问题和下节安排"></textarea>
</label>
<p id="summarySupplementError" class="correction-error" hidden></p>
<div class="correction-preview">
<span>提交说明</span>
<code id="summarySupplementPreview">提交后会直接保存为这节课的课程小结。</code>
</div>
<div class="modal-actions">
<button id="cancelSummarySupplementBtn" class="secondary-button" type="button">取消</button>
<button id="submitSummarySupplementBtn" type="submit">提交课程小结</button>
</div>
</form>
</section>
</div>
<script src="/static/app.js?v=20260618-record-summary-row"></script>
</body> </body>
</html> </html>
+59
View File
@@ -756,6 +756,18 @@ td {
color: var(--muted); color: var(--muted);
} }
.record-row {
cursor: pointer;
}
.record-row:hover td {
background: #f8fafc;
}
.record-row .record-action-cell {
cursor: default;
}
.corrected-row td { .corrected-row td {
background: #fffaf0; background: #fffaf0;
} }
@@ -801,6 +813,10 @@ td {
background: #fbfcfd; background: #fbfcfd;
} }
.summary-missing-row td {
background: #fcfcfd;
}
.record-summary-item { .record-summary-item {
padding: 12px 16px; padding: 12px 16px;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
@@ -827,6 +843,45 @@ td {
font-size: 13px; 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 { .correction-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1132,6 +1187,10 @@ td {
line-height: 1.5; line-height: 1.5;
} }
.summary-supplement-modal .correction-preview code {
font-size: 13px;
}
.modal-actions { .modal-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;