修复课程小结补充流程
This commit is contained in:
+104
-2
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
+142
-8
@@ -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 ? '<span class="correction-badge">已修改</span>' : "";
|
||||
const summaryCount = Number(displayRow.summary_count || 0);
|
||||
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.time)}</td>
|
||||
<td>${escapeHtml(displayRow.student)}</td>
|
||||
@@ -205,7 +214,6 @@ function renderRecordRow(row) {
|
||||
<td class="num">${escapeHtml(displayRow.duration)}</td>
|
||||
<td class="record-action-cell">
|
||||
<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 record-delete" type="button" data-record-key="${escapeHtml(key)}">删除</button>
|
||||
${badge}
|
||||
@@ -223,6 +231,17 @@ function renderSummaryEntry(summary) {
|
||||
</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) {
|
||||
currentRecordOrder = [];
|
||||
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"}>
|
||||
<td colspan="7">${(row.summaries || []).map(renderSummaryEntry).join("")}</td>
|
||||
</tr>`
|
||||
: "";
|
||||
: 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);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -140,6 +140,31 @@
|
||||
</section>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user