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) => + `
${escapeHtml(line)}${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;
}