From 89d592b0bd4f7dc836b43c48f00fc1f49268294d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 18 Jun 2026 02:30:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=99=BB=E8=AE=B0=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E4=B8=8E=E5=AE=A1=E6=89=B9=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/ai_register.py | 10 +++- app/app/data.py | 111 +++++++++++++++++++++++++++++++------- app/app/static/admin.js | 20 +++++-- app/app/static/styles.css | 56 +++++++++++++++++++ 4 files changed, 174 insertions(+), 23 deletions(-) diff --git a/app/app/ai_register.py b/app/app/ai_register.py index 85b084d..ca1432d 100644 --- a/app/app/ai_register.py +++ b/app/app/ai_register.py @@ -123,10 +123,16 @@ def normalize_register_type(value: str) -> str: return register_type -def collect_input_lines(text: str | None = None, lines: list[str] | None = None) -> list[str]: +def collect_input_lines( + text: str | None = None, + lines: list[str] | None = None, + register_type: str = "", +) -> list[str]: if lines is not None: return normalize_lines(lines=lines) raw = str(text or "") + if register_type in {"class_record", "payment"}: + return normalize_lines(lines=raw.splitlines()) if "\n\n" in raw: chunks = [item.strip() for item in re.split(r"\n\s*\n", raw) if item.strip()] if chunks: @@ -547,7 +553,7 @@ def preview_register( conversation_id: str | None = None, ) -> dict[str, Any]: normalized_type = normalize_register_type(register_type) - source_lines = collect_input_lines(text=text, lines=lines) + source_lines = collect_input_lines(text=text, lines=lines, register_type=normalized_type) joined_text = "\n\n".join(source_lines) conversation_id, session = _session_for(conversation_id, normalized_type, joined_text) merged_answers = {**dict(session.get("answers") or {}), **(answers or {})} diff --git a/app/app/data.py b/app/app/data.py index cde652d..c010d2d 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -1149,7 +1149,7 @@ def reject_admin_task(tasks_path: Path, task_id: int) -> dict: return mark_admin_task(tasks_path, task_id, "rejected") -def approve_correction_task(tasks_path: Path, classnotes_path: Path, task_id: int) -> dict: +def approve_correction_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_correction": @@ -1159,13 +1159,25 @@ def approve_correction_task(tasks_path: Path, classnotes_path: Path, task_id: in original_line = str(task.get("original_line", "")).strip() corrected_line = str(task.get("corrected_line", "")).strip() - parse_class_record_line(original_line) + original = parse_class_record_line(original_line) corrected = parse_class_record_line(corrected_line) corrected_line = class_record_to_line(corrected) original_classnotes = classnotes_path.read_text(encoding="utf-8") try: new_classnotes = replace_class_record_line(original_classnotes, original_line, corrected_line) + accounts = read_accounts(accounts_path) + updated_accounts = list(accounts) + original_account_index = find_account_index(updated_accounts, original.student) + updated_accounts[original_account_index] = update_account_remaining( + updated_accounts[original_account_index], + original.duration_hours, + ) + corrected_account_index = find_account_index(updated_accounts, corrected.student) + updated_accounts[corrected_account_index] = update_account_remaining( + updated_accounts[corrected_account_index], + -corrected.duration_hours, + ) except ValueError as exc: task["status"] = "conflict" task["updated_at"] = datetime.now().isoformat(timespec="seconds") @@ -1173,27 +1185,42 @@ def approve_correction_task(tasks_path: Path, classnotes_path: Path, task_id: in 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["original"] = record_to_dict(original) task["corrected_line"] = corrected_line task["corrected"] = record_to_dict(corrected) + changed_account_ids = { + updated_accounts[original_account_index].student_id, + updated_accounts[corrected_account_index].student_id, + } + updated_accounts_by_id = { + account.student_id: account + for account in updated_accounts + if account.student_id in changed_account_ids + } + 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-correction", { + accounts_path: original_accounts, classnotes_path: original_classnotes, tasks_path: original_tasks, }, [original_line, corrected_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 @@ -2483,15 +2510,23 @@ def course_summary_heading(summary: dict, existing_headings: set[str]) -> str: def save_course_summary_markdown(root: Path, summary: dict) -> dict: path = course_summary_path(root, summary) existing = path.read_text(encoding="utf-8") if path.exists() else "" - existing_headings = set(re.findall(r"^###\s+(.+)$", existing, flags=re.M)) - existing_compact = re.sub(r"\s+", "", existing) + matches = list(COURSE_SUMMARY_HEADING_RE.finditer(existing)) + existing_headings = {match.group("title").strip() for match in matches} body = str(summary.get("body") or "").rstrip() - body_compact = re.sub(r"\s+", "", body) - if body_compact and body_compact in existing_compact: - return {"path": str(path), "added": False} - group_name = str(summary.get("group") or summary["student"]) heading = course_summary_heading(summary, existing_headings) + source_id = str(summary.get("source_id") or "").strip() + if source_id and f"来源ID:`{source_id}`" in existing: + return {"path": str(path), "added": False} + existing_blocks: set[tuple[str, str]] = set() + for index, match in enumerate(matches): + start = match.end() + end = matches[index + 1].start() if index + 1 < len(matches) else len(existing) + block_body = existing[start:end].strip() + block_body = re.sub(r"^(?:>\s+.*\n)+\s*", "", block_body).strip() + existing_blocks.add((match.group("title").strip(), re.sub(r"\s+", "", block_body))) + if (heading, re.sub(r"\s+", "", body)) in existing_blocks: + return {"path": str(path), "added": False} lines: list[str] = [] if not existing.strip(): lines.extend([f"# {path.stem}", "", f"## {group_name}", ""]) @@ -2733,7 +2768,50 @@ def approve_course_summary_task( raise ValueError("课程小结信息未补齐,不能直接批准入账") try: - result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line) + original_classnotes = classnotes_path.read_text(encoding="utf-8") + original_accounts = accounts_path.read_text(encoding="utf-8") + original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n" + record = parse_class_record_line(proposed_line) + record_line = class_record_to_line(record) + existing_lines = {raw.strip() for raw in original_classnotes.splitlines()} + if record_line in existing_lines: + raise ValueError(f"上课记录已存在: {record_line}") + accounts = read_accounts(accounts_path) + updated_accounts = list(accounts) + account_index = find_account_index(updated_accounts, record.student) + updated_accounts[account_index] = update_account_remaining( + updated_accounts[account_index], + -record.duration_hours, + ) + separator = "" if not original_classnotes or original_classnotes.endswith("\n") else "\n" + new_classnotes = f"{original_classnotes}{separator}{record_line}\n" + 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) + task["status"] = "approved" + task["updated_at"] = datetime.now().isoformat(timespec="seconds") + task["reviewed_at"] = task["updated_at"] + task["proposed_line"] = record_line + task["registered_line"] = record_line + backup_dir = create_data_backup( + "admin-approve-course-summary", + { + accounts_path: original_accounts, + classnotes_path: original_classnotes, + tasks_path: original_tasks, + }, + [record_line], + ) + task["backup_id"] = backup_dir.name + new_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n" + 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 except ValueError as exc: task["status"] = "conflict" task["updated_at"] = datetime.now().isoformat(timespec="seconds") @@ -2741,14 +2819,11 @@ def approve_course_summary_task( write_admin_tasks(tasks_path, tasks) raise - task["status"] = "approved" - task["updated_at"] = datetime.now().isoformat(timespec="seconds") - task["reviewed_at"] = task["updated_at"] - task["proposed_line"] = proposed_line - task["registered_line"] = proposed_line - task["backup_id"] = result.get("backup_id", "") - write_admin_tasks(tasks_path, tasks) - return {"task": task_to_dict(task), "backup_id": result.get("backup_id", "")} + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return {"task": task_to_dict(task), "backup_id": backup_dir.name} def approve_admin_task( @@ -2759,7 +2834,7 @@ def approve_admin_task( ) -> dict: 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) + return approve_correction_task(tasks_path, classnotes_path, accounts_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": diff --git a/app/app/static/admin.js b/app/app/static/admin.js index 15663f2..f2cd094 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -971,6 +971,20 @@ function clearRegisterPreview(type, resetState = true) { if (confirmButton) confirmButton.hidden = true; } +function renderStandardLinesPreview(type, lines) { + const standardLines = Array.isArray(lines) ? lines : []; + if (type === "class_record") { + const rows = standardLines + .map( + (line, index) => + `
  • ${index + 1}${escapeHtml(line)}
  • ` + ) + .join(""); + return `
    标准登记内容共 ${standardLines.length} 条
      ${rows}
    `; + } + return `标准登记内容
    ${escapeHtml(standardLines.join("\n\n"))}
    `; +} + function renderRegisterPreview(type, data, statusNode) { const previewNode = { class_record: classRegisterPreview, @@ -985,11 +999,12 @@ function renderRegisterPreview(type, data, statusNode) { previewNode.hidden = false; if (data.status === "ready") { const warning = data.warning ? `

    ${escapeHtml(data.warning)}

    ` : ""; - previewNode.innerHTML = `${warning}标准登记内容
    ${escapeHtml((data.standard_lines || []).join("\n\n"))}
    `; + const standardLines = data.standard_lines || []; + previewNode.innerHTML = `${warning}${renderStandardLinesPreview(type, standardLines)}`; confirmButton.hidden = false; registerPreviewState[type] = { conversationId: data.conversation_id, - standardLines: data.standard_lines || [], + standardLines, }; statusNode.textContent = "已通过脚本生成预览,请确认后写入"; return; @@ -1289,5 +1304,4 @@ refreshBtn.addEventListener("click", () => { }); loadAdminHealth(); -addSummaryRegisterItem(); loadAccounts(); diff --git a/app/app/static/styles.css b/app/app/static/styles.css index 010549d..751918d 100644 --- a/app/app/static/styles.css +++ b/app/app/static/styles.css @@ -427,6 +427,62 @@ textarea:focus { line-height: 1.45; } +.register-preview-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.register-preview-head span { + color: var(--muted); + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.register-line-list { + display: grid; + gap: 6px; + max-height: 220px; + margin: 0; + padding: 0; + overflow: auto; + list-style: none; +} + +.register-line-list li { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 8px; + align-items: start; + padding: 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; +} + +.register-line-list span { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 22px; + border-radius: 999px; + background: #e9f5f3; + color: var(--accent-strong); + font-size: 12px; + font-weight: 700; +} + +.register-line-list code { + color: #344054; + font-family: Arial, "Songti SC", SimSun, sans-serif; + font-size: 13px; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + .register-preview textarea { min-height: 72px; }