From 854e4942ba3c1bad9e712159d1c8ed78eb596b93 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 15 Jun 2026 16:23:06 +0800 Subject: [PATCH] feat: add summary registration and deletion review --- app/data.py | 335 ++++++++++++++++++++++++++++++++++++++++ app/domain.py | 2 +- app/routers/accounts.py | 31 +++- app/routers/records.py | 16 +- app/schemas.py | 8 + app/static/admin.html | 14 +- app/static/admin.js | 96 ++++++++++-- app/static/app.js | 66 +++++++- app/static/index.html | 23 ++- app/static/styles.css | 28 +++- 10 files changed, 597 insertions(+), 22 deletions(-) diff --git a/app/data.py b/app/data.py index 097d3ba..ebdf295 100644 --- a/app/data.py +++ b/app/data.py @@ -42,6 +42,7 @@ PAYMENT_LINE_RE = re.compile(r"^(?P.+?)-(?P\d{4}-\d{2}-\d{2}):(?P TIME_RANGE_RE = re.compile(r"^(?P\d{1,2}):(?P\d{2})-(?P\d{1,2}):(?P\d{2})$") COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P.+)$", re.M) COURSE_SUMMARY_DATE_RE = re.compile(r"(?P<date>\d{4}[.-]\d{1,2}[.-]\d{1,2})") +SUMMARY_FIELD_RE = re.compile(r"^(?P<label>学生|学员|日期|上课日期|时间|上课时间|老师|教师|科目|课程|班级|分组|正文|内容|小结)[::]\s*(?P<value>.*)$") DATE_RANGE_SEPARATOR = r"(?:到|至|-|-|~|—|–)" CHINESE_DATE_RANGE_RE = re.compile( rf"(?:(?P<sy>\d{{4}})\s*年\s*)?" @@ -579,6 +580,35 @@ def submit_correction_tasks(tasks_path: Path, items: list[dict]) -> dict: return {"submitted": len(created), "items": [task_to_dict(task) for task in created]} +def submit_deletion_tasks(tasks_path: Path, items: list[dict]) -> dict: + if not items: + raise ValueError("提交审核的删除记录不能为空") + tasks = read_admin_tasks(tasks_path) + now = datetime.now().isoformat(timespec="seconds") + created: list[dict] = [] + for item in items: + original_line = str(item.get("original_line", "")).strip() + if not original_line: + raise ValueError("删除审核记录缺少原记录") + original = parse_class_record_line(original_line) + task = { + "id": int(tasks["next_id"]), + "type": "class_record_deletion", + "status": "pending", + "created_at": now, + "updated_at": now, + "original_line": class_record_to_line(original), + "original": record_to_dict(original), + "student": original.student, + "reasons": ["申请删除课程记录"], + } + tasks["next_id"] = int(tasks["next_id"]) + 1 + tasks["items"].append(task) + created.append(task) + write_admin_tasks(tasks_path, tasks) + return {"submitted": len(created), "items": [task_to_dict(task) for task in created]} + + def list_admin_tasks(tasks_path: Path, status_filter: str = "", task_type: str = "") -> dict: tasks = read_admin_tasks(tasks_path) items = tasks["items"] @@ -606,6 +636,16 @@ def replace_class_record_line(original_text: str, original_line: str, corrected_ return "\n".join(lines) + trailing_newline +def delete_class_record_line(original_text: str, original_line: str) -> str: + lines = original_text.splitlines() + matched = [index for index, line in enumerate(lines) if line.strip() == original_line] + if not matched: + raise ValueError("原上课记录在正式文件中不存在,可能已被修改") + del lines[matched[0]] + trailing_newline = "\n" if original_text.endswith("\n") and lines else "" + return "\n".join(lines) + trailing_newline + + def mark_admin_task(tasks_path: Path, task_id: int, status: str, message: str = "") -> dict: tasks = read_admin_tasks(tasks_path) task = find_admin_task(tasks, task_id) @@ -680,6 +720,73 @@ def approve_correction_task(tasks_path: Path, classnotes_path: Path, task_id: in return {"task": task_to_dict(task), "backup_id": backup_dir.name} +def approve_deletion_task( + tasks_path: Path, + classnotes_path: Path, + accounts_path: Path, + task_id: int, +) -> dict: + tasks = read_admin_tasks(tasks_path) + task = find_admin_task(tasks, task_id) + if task.get("type") != "class_record_deletion": + raise ValueError("该任务不是上课记录删除") + if task.get("status") not in {"pending", "conflict"}: + raise ValueError("该任务已处理,不能重复批准") + + original_line = str(task.get("original_line", "")).strip() + original = parse_class_record_line(original_line) + + original_classnotes = classnotes_path.read_text(encoding="utf-8") + try: + new_classnotes = delete_class_record_line(original_classnotes, original_line) + accounts = read_accounts(accounts_path) + updated_accounts = list(accounts) + account_index = find_account_index(updated_accounts, original.student) + updated_accounts[account_index] = update_account_remaining(updated_accounts[account_index], original.duration_hours) + except ValueError as exc: + task["status"] = "conflict" + task["updated_at"] = datetime.now().isoformat(timespec="seconds") + task["message"] = str(exc) + write_admin_tasks(tasks_path, tasks) + raise + + original_accounts = accounts_path.read_text(encoding="utf-8") + original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n" + now = datetime.now().isoformat(timespec="seconds") + task["status"] = "approved" + task["updated_at"] = now + task["reviewed_at"] = now + task["deleted_line"] = original_line + task["restored_hours"] = original.duration_hours + updated_accounts_by_id = {updated_accounts[account_index].student_id: updated_accounts[account_index]} + new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id) + new_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n" + + backup_dir = create_data_backup( + "admin-approve-deletion", + { + accounts_path: original_accounts, + classnotes_path: original_classnotes, + tasks_path: original_tasks, + }, + [original_line], + ) + try: + atomic_write_text(accounts_path, new_accounts) + atomic_write_text(classnotes_path, new_classnotes) + atomic_write_text(tasks_path, new_tasks) + except Exception: + atomic_write_text(accounts_path, original_accounts) + atomic_write_text(classnotes_path, original_classnotes) + atomic_write_text(tasks_path, original_tasks) + raise + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return {"task": task_to_dict(task), "backup_id": backup_dir.name} + + def safe_filename_part(value: object) -> str: text = str(value or "").strip() text = re.sub(r"[\\/:*?\"<>|\s]+", "_", text) @@ -755,6 +862,147 @@ def duration_minutes_from_summary(summary: dict) -> int | None: return duration_minutes_from_time_range(str(summary.get("time_range") or "")) +def normalize_date_text(value: str) -> str: + text = value.strip().replace(".", "-").replace("/", "-") + match = re.search(r"(?P<y>\d{4})-(?P<m>\d{1,2})-(?P<d>\d{1,2})", text) + if not match: + raise ValueError(f"无法识别日期: {value}") + return f"{int(match.group('y')):04d}-{int(match.group('m')):02d}-{int(match.group('d')):02d}" + + +def extract_course_summary_from_text(text: str, index: int = 0, known_students: list[str] | None = None) -> dict: + raw = text.strip() + if not raw: + raise ValueError("课程小结内容不能为空") + + fields: dict[str, str] = {} + body_lines: list[str] = [] + in_body = False + label_map = { + "学生": "student", + "学员": "student", + "日期": "date_iso", + "上课日期": "date_iso", + "时间": "time_range", + "上课时间": "time_range", + "老师": "teacher", + "教师": "teacher", + "科目": "subject", + "课程": "subject", + "班级": "group", + "分组": "group", + "正文": "body", + "内容": "body", + "小结": "body", + } + for line in raw.splitlines(): + stripped = line.strip() + if not stripped: + if in_body: + body_lines.append("") + continue + match = SUMMARY_FIELD_RE.match(stripped) + if match: + key = label_map[match.group("label")] + value = match.group("value").strip() + if key == "body": + in_body = True + if value: + body_lines.append(value) + else: + fields[key] = value + in_body = False + continue + if in_body: + body_lines.append(line.rstrip()) + else: + body_lines.append(line.rstrip()) + + first_line = raw.splitlines()[0].strip() + try: + record = parse_class_record_line(first_line) + except ValueError: + record = None + if record: + fields.setdefault("date_iso", record.date.replace(".", "-")) + fields.setdefault("time_range", record.time) + fields.setdefault("student", record.student) + fields.setdefault("duration_minutes", str(int(round(record.duration_hours * 60)))) + fields.setdefault("teacher", record.teacher) + fields.setdefault("subject", record.subject) + + if "date_iso" not in fields: + date_match = COURSE_SUMMARY_DATE_RE.search(raw) + if date_match: + fields["date_iso"] = date_match.group("date") + if "time_range" not in fields: + time_match = TIME_RANGE_RE.search(raw) + if time_match: + fields["time_range"] = time_match.group(0) + if "student" not in fields: + for student in known_students or []: + if student and student in raw: + fields["student"] = student + break + if "subject" not in fields: + for subject in SUBJECTS: + if subject in raw: + fields["subject"] = subject + break + if "teacher" not in fields: + teacher_match = re.search(r"(?P<teacher>[\u4e00-\u9fa5A-Za-z0-9]{1,8}老师)", raw) + if teacher_match: + fields["teacher"] = teacher_match.group("teacher") + + if "date_iso" in fields: + fields["date_iso"] = normalize_date_text(fields["date_iso"]) + if "time_range" in fields: + time_match = TIME_RANGE_RE.search(fields["time_range"]) + if time_match: + fields["time_range"] = time_match.group(0) + + body = "\n".join(body_lines).strip() or raw + source_id = f"manual:{sha1_text(raw, 24)}" + return { + **fields, + "source_id": source_id, + "body": body, + "recognition_source": "manual_admin", + "confidence": "manual", + "teacher_trusted": True, + "sender": "管理后台", + "local_id": str(index + 1), + } + + +def manual_review_summary(raw: dict, error: Exception) -> dict: + body = str(raw.get("body") or raw.get("content") or "").strip() + source_id = str(raw.get("source_id") or "").strip() or f"manual:{sha1_text(body, 24)}" + return { + "source_id": source_id, + "student": canonical_name(str(raw.get("student") or "").strip()) or "待核对学生", + "date_iso": str(raw.get("date_iso") or raw.get("date") or "").strip(), + "time_range": str(raw.get("time_range") or raw.get("time") or "").strip(), + "duration_minutes": raw.get("duration_minutes"), + "duration": str(raw.get("duration") or "").strip(), + "teacher": canonical_name(str(raw.get("teacher") or "").strip()), + "subject": parse_subject_code(str(raw.get("subject") or "").strip()), + "group": str(raw.get("group") or "").strip(), + "sender": str(raw.get("sender") or "管理后台").strip(), + "sender_id": str(raw.get("sender_id") or "").strip(), + "message_time": str(raw.get("message_time") or "").strip(), + "message_date": str(raw.get("message_date") or "").strip(), + "db": str(raw.get("db") or "").strip(), + "local_id": str(raw.get("local_id") or "").strip(), + "title": str(raw.get("title") or "手工登记课程小结").strip(), + "body": body or str(error), + "recognition_source": "manual_admin", + "confidence": "manual_review", + "teacher_trusted": True, + "remark": f"手工登记待审核:{error}", + } + + def normalize_course_summary(raw: dict) -> dict: student = canonical_name(str(raw.get("student") or "").strip()) teacher = canonical_name(str(raw.get("teacher") or "").strip()) @@ -1240,11 +1488,98 @@ def approve_admin_task( task = find_admin_task(read_admin_tasks(tasks_path), task_id) if task.get("type") == "class_record_correction": return approve_correction_task(tasks_path, classnotes_path, task_id) + if task.get("type") == "class_record_deletion": + return approve_deletion_task(tasks_path, classnotes_path, accounts_path, task_id) if task.get("type") == "course_summary_review": return approve_course_summary_task(tasks_path, classnotes_path, accounts_path, task_id) raise ValueError("不支持的审核任务类型") +def register_course_summary_texts( + *, + classnotes_path: Path, + accounts_path: Path, + tasks_path: Path, + summaries_root: Path, + state_path: Path, + operation_logs_path: Path, + lines: list[str] | None = None, + line: str | None = None, +) -> dict: + texts = normalize_lines(lines=lines, line=line) + now = datetime.now().isoformat(timespec="seconds") + accounts = read_accounts(accounts_path) + known_students = [account.student for account in accounts] + summaries: list[dict] = [] + manual_review_items: list[dict] = [] + result = { + "received": len(texts), + "saved": 0, + "auto_registered": 0, + "review_pending": 0, + "duplicates": 0, + "rejected": 0, + "operation_log_ids": [], + "items": [], + } + for index, text in enumerate(texts): + raw = extract_course_summary_from_text(text, index, known_students=known_students) + try: + normalize_course_summary(raw) + summaries.append(raw) + except ValueError as exc: + summary = manual_review_summary(raw, exc) + saved = save_course_summary_markdown(summaries_root, summary) + task = create_course_summary_review_task( + tasks_path, + summary, + "", + [str(exc), "手工登记课程小结需人工补全"], + saved_path=str(saved.get("path") or ""), + ) + log_id = append_operation_log( + operation_logs_path, + "course_summary_ingest", + "review", + batch_id=f"manual-{datetime.now().strftime('%Y%m%d%H%M%S')}", + source_id=summary["source_id"], + student=summary["student"], + reasons=[str(exc), "手工登记课程小结需人工补全"], + task_id=task.get("id"), + saved_path=str(saved.get("path") or ""), + ) + result["saved"] += 1 if saved.get("added") else 0 + result["review_pending"] += 1 + result["operation_log_ids"].append(log_id) + result["items"].append( + { + "source_id": summary["source_id"], + "status": "review", + "task_id": task.get("id"), + "reasons": [str(exc), "手工登记课程小结需人工补全"], + } + ) + if not summaries: + return result + ingest_result = ingest_course_summaries( + classnotes_path=classnotes_path, + accounts_path=accounts_path, + tasks_path=tasks_path, + summaries_root=summaries_root, + state_path=state_path, + operation_logs_path=operation_logs_path, + batch_id=f"manual-{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text('|'.join(texts), 8)}", + window={"source": "admin_register", "submitted_at": now}, + students=[], + summaries=summaries, + ) + for key in ("saved", "auto_registered", "review_pending", "duplicates", "rejected"): + result[key] += int(ingest_result.get(key) or 0) + result["operation_log_ids"].extend(ingest_result.get("operation_log_ids") or []) + result["items"].extend(ingest_result.get("items") or []) + return result + + def ingest_course_summaries( *, classnotes_path: Path, diff --git a/app/domain.py b/app/domain.py index 364dae1..e78517c 100644 --- a/app/domain.py +++ b/app/domain.py @@ -28,7 +28,7 @@ WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", " UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"} UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"} HIGH_CONFIDENCE_VALUES = {"high", "高", "高置信", "true", "1", "yes"} -AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "大模型高置信识别", "关键词"} +AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "manual_admin", "大模型高置信识别", "关键词"} ROLE_WORDS = { "student": ("学生", "学员", "孩子", "同学"), "teacher": ("老师", "教师"), diff --git a/app/routers/accounts.py b/app/routers/accounts.py index 72ccbe6..c7ac811 100644 --- a/app/routers/accounts.py +++ b/app/routers/accounts.py @@ -4,7 +4,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request from ..api_utils import file_meta, load_accounts, payload_to_account, read_register_payload from ..auth import verify_accounts_auth, verify_admin_auth -from ..config import ACCOUNTS_PATH, CLASSNOTES_PATH, write_lock +from ..config import ( + ACCOUNTS_PATH, + ADMIN_TASKS_PATH, + CLASSNOTES_PATH, + COURSE_SUMMARIES_ROOT, + COURSE_SUMMARY_STATE_PATH, + OPERATION_LOGS_PATH, + write_lock, +) from ..data import ( ACCOUNT_STATUSES, DuplicateRecordError, @@ -13,6 +21,7 @@ from ..data import ( create_account, filter_accounts, register_class_record_lines, + register_course_summary_texts, register_payment_lines, update_account, ) @@ -51,6 +60,26 @@ async def register_payments(request: Request, _user: str = Depends(verify_admin_ return {"ok": True, **result} +@router.post("/api/register/course-summaries") +async def register_course_summaries(request: Request, _user: str = Depends(verify_admin_auth)): + try: + payload = await read_register_payload(request) + with write_lock: + result = register_course_summary_texts( + classnotes_path=CLASSNOTES_PATH, + accounts_path=ACCOUNTS_PATH, + tasks_path=ADMIN_TASKS_PATH, + summaries_root=COURSE_SUMMARIES_ROOT, + state_path=COURSE_SUMMARY_STATE_PATH, + operation_logs_path=OPERATION_LOGS_PATH, + lines=payload.lines, + line=payload.line, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} + + @router.get("/api/account-health") def account_health(_user: str = Depends(verify_accounts_auth)): accounts = load_accounts() diff --git a/app/routers/records.py b/app/routers/records.py index c37affd..dad2c4c 100644 --- a/app/routers/records.py +++ b/app/routers/records.py @@ -5,8 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query from ..api_utils import load_accounts, load_records from ..auth import verify_records_auth from ..config import ADMIN_TASKS_PATH -from ..data import account_to_dict, query_records, submit_correction_tasks -from ..schemas import CorrectionSubmitPayload +from ..data import account_to_dict, query_records, submit_correction_tasks, submit_deletion_tasks +from ..schemas import CorrectionSubmitPayload, DeletionSubmitPayload router = APIRouter() @@ -39,3 +39,15 @@ def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(ve except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} + + +@router.post("/api/deletions") +def submit_deletions(payload: DeletionSubmitPayload, _user: str = Depends(verify_records_auth)): + try: + result = submit_deletion_tasks( + ADMIN_TASKS_PATH, + [item.dict() for item in payload.items], + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, **result} diff --git a/app/schemas.py b/app/schemas.py index 02d7662..80ed36d 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -31,6 +31,14 @@ class CorrectionSubmitPayload(BaseModel): items: list[CorrectionItemPayload] +class DeletionItemPayload(BaseModel): + original_line: str + + +class DeletionSubmitPayload(BaseModel): + items: list[DeletionItemPayload] + + class CourseSummaryPayload(BaseModel): source_id: str = "" student: str diff --git a/app/static/admin.html b/app/static/admin.html index 951820f..7b60324 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -128,8 +128,9 @@ <tr> <th>编号</th> <th>状态</th> + <th>类型</th> <th>原记录</th> - <th>修改后</th> + <th>处理内容</th> <th>提交时间</th> <th>操作</th> </tr> @@ -305,10 +306,19 @@ <button type="submit">提交缴费记录</button> </div> </form> + <form id="summaryRegisterForm" class="admin-form"> + <h3>课程小结登记</h3> + <div id="summaryRegisterItems" class="summary-register-items"></div> + <button id="addSummaryRegisterItemBtn" class="secondary-button" type="button">再填一条</button> + <p id="summaryRegisterStatus" class="inline-account-loading"></p> + <div class="modal-actions"> + <button type="submit">提交课程小结</button> + </div> + </form> </div> </section> </main> - <script src="/static/admin.js?v=20260615-summary-review-drawer"></script> + <script src="/static/admin.js?v=20260615-summary-register-delete"></script> </body> </html> diff --git a/app/static/admin.js b/app/static/admin.js index b5c5633..757dd0d 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -64,11 +64,16 @@ const classRegisterStatus = document.querySelector("#classRegisterStatus"); const paymentRegisterForm = document.querySelector("#paymentRegisterForm"); const paymentRegisterLines = document.querySelector("#paymentRegisterLines"); const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus"); +const summaryRegisterForm = document.querySelector("#summaryRegisterForm"); +const summaryRegisterItems = document.querySelector("#summaryRegisterItems"); +const addSummaryRegisterItemBtn = document.querySelector("#addSummaryRegisterItemBtn"); +const summaryRegisterStatus = document.querySelector("#summaryRegisterStatus"); let currentAccounts = []; let editingAccountId = ""; let currentSummaryReviews = []; let activeSummaryReview = null; +let summaryRegisterItemSeq = 0; function fmtHours(value) { return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); @@ -282,21 +287,37 @@ function renderReviewLine(line) { return `<code class="line-code">${escapeHtml(line)}</code>`; } +function reviewTaskTypeLabel(item) { + if (item.type === "class_record_deletion") return "删除"; + return "纠错"; +} + +function renderReviewTarget(item) { + if (item.type === "class_record_deletion") { + const restored = item.original && item.original.duration_hours ? `通过后恢复 ${fmtHours(item.original.duration_hours)} 小时` : "通过后恢复课时"; + return `<span class="status closed">删除</span><br><small>${escapeHtml(restored)}</small>`; + } + return renderReviewLine(item.corrected_line); +} + async function loadReviews() { - reviewRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`; - const params = new URLSearchParams({ type: "class_record_correction" }); - if (reviewStatus.value) params.set("status", reviewStatus.value); + reviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`; try { - const data = await fetchJson(`/api/admin/tasks?${params.toString()}`); - reviewMeta.innerHTML = [metric("当前结果", `${data.count} 条`)].join(""); - reviewRows.innerHTML = data.items + const [correctionData, deletionData] = await Promise.all([ + fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_correction", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`), + fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_deletion", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`), + ]); + const items = [...(correctionData.items || []), ...(deletionData.items || [])].sort((a, b) => Number(b.id || 0) - Number(a.id || 0)); + reviewMeta.innerHTML = [metric("当前结果", `${items.length} 条`)].join(""); + reviewRows.innerHTML = items .map((item) => { const canReview = item.status === "pending" || item.status === "conflict"; return `<tr> <td>#${escapeHtml(item.id)}</td> <td><span class="status ${item.status === "approved" ? "normal" : item.status === "rejected" ? "closed" : item.status === "conflict" ? "debt" : "warning"}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td> + <td>${escapeHtml(reviewTaskTypeLabel(item))}</td> <td>${renderReviewLine(item.original_line)}</td> - <td>${renderReviewLine(item.corrected_line)}</td> + <td>${renderReviewTarget(item)}</td> <td>${escapeHtml(item.created_at || "")}</td> <td class="record-action-cell"> <div class="record-actions"> @@ -307,11 +328,11 @@ async function loadReviews() { </tr>`; }) .join(""); - if (!data.items.length) { - reviewRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的审核项</td></tr>`; + if (!items.length) { + reviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的审核项</td></tr>`; } } catch (error) { - reviewRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`; + reviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`; } } @@ -569,6 +590,47 @@ async function submitRegister(event, textarea, statusNode, url) { } } +function addSummaryRegisterItem(value = "") { + summaryRegisterItemSeq += 1; + const itemId = `summary-register-${summaryRegisterItemSeq}`; + const item = document.createElement("div"); + item.className = "summary-register-item"; + item.innerHTML = `<label for="${itemId}">课程小结</label> + <textarea id="${itemId}" rows="7" placeholder="每个输入框填写一条课程小结">${escapeHtml(value)}</textarea> + <button class="small-button summary-register-remove" type="button">删除本框</button>`; + summaryRegisterItems.appendChild(item); +} + +function summaryRegisterLines() { + return Array.from(summaryRegisterItems.querySelectorAll("textarea")) + .map((textarea) => textarea.value.trim()) + .filter(Boolean); +} + +async function submitSummaryRegister(event) { + event.preventDefault(); + const lines = summaryRegisterLines(); + if (!lines.length) { + summaryRegisterStatus.textContent = "请至少填写一条课程小结"; + return; + } + summaryRegisterStatus.textContent = "正在提交"; + try { + const data = await fetchJson("/api/register/course-summaries", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lines }), + }); + summaryRegisterStatus.textContent = `已接收 ${data.received} 条;自动登记 ${data.auto_registered} 条;待审核 ${data.review_pending} 条;重复 ${data.duplicates} 条;失败 ${data.rejected} 条`; + summaryRegisterItems.innerHTML = ""; + addSummaryRegisterItem(); + await loadAdminHealth(); + await loadAccounts(); + } catch (error) { + summaryRegisterStatus.textContent = `提交失败:${error.message}`; + } +} + document.querySelectorAll("[data-admin-tab]").forEach((button) => { button.addEventListener("click", () => setActiveTab(button.dataset.adminTab)); }); @@ -647,6 +709,19 @@ classRegisterForm.addEventListener("submit", (event) => { paymentRegisterForm.addEventListener("submit", (event) => { submitRegister(event, paymentRegisterLines, paymentRegisterStatus, "/api/register/payments"); }); +addSummaryRegisterItemBtn.addEventListener("click", () => addSummaryRegisterItem()); +summaryRegisterItems.addEventListener("click", (event) => { + const button = event.target.closest(".summary-register-remove"); + if (!button) return; + const items = summaryRegisterItems.querySelectorAll(".summary-register-item"); + if (items.length <= 1) { + const textarea = button.closest(".summary-register-item").querySelector("textarea"); + if (textarea) textarea.value = ""; + return; + } + button.closest(".summary-register-item").remove(); +}); +summaryRegisterForm.addEventListener("submit", submitSummaryRegister); refreshBtn.addEventListener("click", () => { loadAdminHealth(); if (!panels.accounts.hidden) loadAccounts(); @@ -657,4 +732,5 @@ refreshBtn.addEventListener("click", () => { }); loadAdminHealth(); +addSummaryRegisterItem(); loadAccounts(); diff --git a/app/static/app.js b/app/static/app.js index 20b6b86..78c499b 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -23,6 +23,12 @@ const correctionTeacher = document.querySelector("#correctionTeacher"); const correctionSubject = document.querySelector("#correctionSubject"); const closeCorrectionBtn = document.querySelector("#closeCorrectionBtn"); const cancelCorrectionBtn = document.querySelector("#cancelCorrectionBtn"); +const deleteDialog = document.querySelector("#deleteDialog"); +const deleteOriginal = document.querySelector("#deleteOriginal"); +const deleteError = document.querySelector("#deleteError"); +const closeDeleteBtn = document.querySelector("#closeDeleteBtn"); +const cancelDeleteBtn = document.querySelector("#cancelDeleteBtn"); +const confirmDeleteBtn = document.querySelector("#confirmDeleteBtn"); const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]; const COPY_LINE_BREAK = "\r\n"; @@ -32,6 +38,7 @@ let recordSort = { date: "asc", time: "asc" }; let currentRecordOrder = []; let correctedRecords = new Map(); let activeCorrectionKey = ""; +let activeDeleteKey = ""; function fmtHours(value) { return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); @@ -185,6 +192,7 @@ function renderRecordRow(row) { <td class="record-action-cell"> <div class="record-actions"> <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} </div> </td> @@ -385,6 +393,45 @@ function closeCorrectionDialog() { setCorrectionError(""); } +function setDeleteError(message) { + deleteError.textContent = message; + deleteError.hidden = !message; +} + +function openDeleteDialog(key) { + const original = findCurrentRecord(key); + if (!original) return; + activeDeleteKey = key; + deleteOriginal.textContent = `将提交删除审核:${buildRecordLine(original)}`; + setDeleteError(""); + deleteDialog.hidden = false; +} + +function closeDeleteDialog() { + deleteDialog.hidden = true; + activeDeleteKey = ""; + setDeleteError(""); +} + +async function submitDeleteRecord() { + const original = findCurrentRecord(activeDeleteKey); + if (!original) return; + confirmDeleteBtn.disabled = true; + try { + const data = await fetchJson("/api/deletions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: [{ original_line: buildRecordLine(original) }] }), + }); + closeDeleteDialog(); + updateCorrectionToolbar(`已提交 ${data.submitted} 条删除审核`); + } catch (error) { + setDeleteError(`提交失败:${error.message}`); + } finally { + confirmDeleteBtn.disabled = false; + } +} + function saveCorrection() { const original = findCurrentRecord(activeCorrectionKey); if (!original) return; @@ -550,9 +597,13 @@ document.querySelectorAll("[data-query]").forEach((button) => { }); recordRows.addEventListener("click", (event) => { - const button = event.target.closest(".correction-edit"); - if (!button) return; - openCorrectionDialog(button.dataset.recordKey); + const editButton = event.target.closest(".correction-edit"); + const deleteButton = event.target.closest(".record-delete"); + if (editButton) { + openCorrectionDialog(editButton.dataset.recordKey); + return; + } + if (deleteButton) openDeleteDialog(deleteButton.dataset.recordKey); }); [correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => { @@ -574,10 +625,19 @@ cancelCorrectionBtn.addEventListener("click", closeCorrectionDialog); correctionDialog.addEventListener("click", (event) => { if (event.target === correctionDialog) closeCorrectionDialog(); }); +closeDeleteBtn.addEventListener("click", closeDeleteDialog); +cancelDeleteBtn.addEventListener("click", closeDeleteDialog); +confirmDeleteBtn.addEventListener("click", submitDeleteRecord); +deleteDialog.addEventListener("click", (event) => { + if (event.target === deleteDialog) closeDeleteDialog(); +}); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !correctionDialog.hidden) { closeCorrectionDialog(); } + if (event.key === "Escape" && !deleteDialog.hidden) { + closeDeleteDialog(); + } }); copyCorrectedBtn.addEventListener("click", copyCorrectedRecords); diff --git a/app/static/index.html b/app/static/index.html index f7251f5..d526764 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -119,6 +119,27 @@ </section> </div> - <script src="/static/app.js?v=20260613-admin-review"></script> + <div id="deleteDialog" class="modal-backdrop" hidden> + <section class="correction-modal" role="dialog" aria-modal="true" aria-labelledby="deleteTitle"> + <div class="modal-head"> + <h2 id="deleteTitle">删除课程记录</h2> + <button id="closeDeleteBtn" class="modal-close" type="button" aria-label="关闭">关闭</button> + </div> + <div class="correction-form"> + <p id="deleteOriginal" class="correction-original"></p> + <p id="deleteError" class="correction-error" hidden></p> + <div class="correction-preview"> + <span>审核说明</span> + <code>确认后会提交后台审核;审核通过才会删除 classnotes 中的记录,并恢复对应学生课时。</code> + </div> + <div class="modal-actions"> + <button id="cancelDeleteBtn" class="secondary-button" type="button">取消</button> + <button id="confirmDeleteBtn" type="button">确认提交删除审核</button> + </div> + </div> + </section> + </div> + + <script src="/static/app.js?v=20260615-delete-review"></script> </body> </html> diff --git a/app/static/styles.css b/app/static/styles.css index e0d34e2..ddb73ae 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -369,10 +369,30 @@ textarea:focus { .register-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } +.summary-register-items { + display: grid; + gap: 10px; +} + +.summary-register-item { + display: grid; + gap: 7px; +} + +.summary-register-item label { + color: #344054; + font-size: 13px; + font-weight: 700; +} + +.summary-register-remove { + justify-self: start; +} + .line-code { display: block; max-width: 420px; @@ -922,7 +942,7 @@ td { } .register-grid { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -979,6 +999,10 @@ td { grid-template-columns: 1fr; } + .register-grid { + grid-template-columns: 1fr; + } + .admin-tabs { overflow-x: auto; }