修复登记预览与审批逻辑

This commit is contained in:
Codex
2026-06-18 02:30:12 +08:00
parent baa54def66
commit 89d592b0bd
4 changed files with 174 additions and 23 deletions
+8 -2
View File
@@ -123,10 +123,16 @@ def normalize_register_type(value: str) -> str:
return register_type 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: if lines is not None:
return normalize_lines(lines=lines) return normalize_lines(lines=lines)
raw = str(text or "") raw = str(text or "")
if register_type in {"class_record", "payment"}:
return normalize_lines(lines=raw.splitlines())
if "\n\n" in raw: if "\n\n" in raw:
chunks = [item.strip() for item in re.split(r"\n\s*\n", raw) if item.strip()] chunks = [item.strip() for item in re.split(r"\n\s*\n", raw) if item.strip()]
if chunks: if chunks:
@@ -547,7 +553,7 @@ def preview_register(
conversation_id: str | None = None, conversation_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
normalized_type = normalize_register_type(register_type) 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) joined_text = "\n\n".join(source_lines)
conversation_id, session = _session_for(conversation_id, normalized_type, joined_text) conversation_id, session = _session_for(conversation_id, normalized_type, joined_text)
merged_answers = {**dict(session.get("answers") or {}), **(answers or {})} merged_answers = {**dict(session.get("answers") or {}), **(answers or {})}
+93 -18
View File
@@ -1149,7 +1149,7 @@ def reject_admin_task(tasks_path: Path, task_id: int) -> dict:
return mark_admin_task(tasks_path, task_id, "rejected") 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) tasks = read_admin_tasks(tasks_path)
task = find_admin_task(tasks, task_id) task = find_admin_task(tasks, task_id)
if task.get("type") != "class_record_correction": 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() original_line = str(task.get("original_line", "")).strip()
corrected_line = str(task.get("corrected_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 = parse_class_record_line(corrected_line)
corrected_line = class_record_to_line(corrected) corrected_line = class_record_to_line(corrected)
original_classnotes = classnotes_path.read_text(encoding="utf-8") original_classnotes = classnotes_path.read_text(encoding="utf-8")
try: try:
new_classnotes = replace_class_record_line(original_classnotes, original_line, corrected_line) 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: except ValueError as exc:
task["status"] = "conflict" task["status"] = "conflict"
task["updated_at"] = datetime.now().isoformat(timespec="seconds") 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) write_admin_tasks(tasks_path, tasks)
raise raise
original_accounts = accounts_path.read_text(encoding="utf-8")
original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n" original_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
now = datetime.now().isoformat(timespec="seconds") now = datetime.now().isoformat(timespec="seconds")
task["status"] = "approved" task["status"] = "approved"
task["updated_at"] = now task["updated_at"] = now
task["reviewed_at"] = now task["reviewed_at"] = now
task["original"] = record_to_dict(original)
task["corrected_line"] = corrected_line task["corrected_line"] = corrected_line
task["corrected"] = record_to_dict(corrected) 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" new_tasks = json.dumps(tasks, ensure_ascii=False, indent=2) + "\n"
backup_dir = create_data_backup( backup_dir = create_data_backup(
"admin-approve-correction", "admin-approve-correction",
{ {
accounts_path: original_accounts,
classnotes_path: original_classnotes, classnotes_path: original_classnotes,
tasks_path: original_tasks, tasks_path: original_tasks,
}, },
[original_line, corrected_line], [original_line, corrected_line],
) )
try: try:
atomic_write_text(accounts_path, new_accounts)
atomic_write_text(classnotes_path, new_classnotes) atomic_write_text(classnotes_path, new_classnotes)
atomic_write_text(tasks_path, new_tasks) atomic_write_text(tasks_path, new_tasks)
except Exception: except Exception:
atomic_write_text(accounts_path, original_accounts)
atomic_write_text(classnotes_path, original_classnotes) atomic_write_text(classnotes_path, original_classnotes)
atomic_write_text(tasks_path, original_tasks) atomic_write_text(tasks_path, original_tasks)
raise 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: def save_course_summary_markdown(root: Path, summary: dict) -> dict:
path = course_summary_path(root, summary) path = course_summary_path(root, summary)
existing = path.read_text(encoding="utf-8") if path.exists() else "" existing = path.read_text(encoding="utf-8") if path.exists() else ""
existing_headings = set(re.findall(r"^###\s+(.+)$", existing, flags=re.M)) matches = list(COURSE_SUMMARY_HEADING_RE.finditer(existing))
existing_compact = re.sub(r"\s+", "", existing) existing_headings = {match.group("title").strip() for match in matches}
body = str(summary.get("body") or "").rstrip() 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"]) group_name = str(summary.get("group") or summary["student"])
heading = course_summary_heading(summary, existing_headings) 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] = [] lines: list[str] = []
if not existing.strip(): if not existing.strip():
lines.extend([f"# {path.stem}", "", f"## {group_name}", ""]) lines.extend([f"# {path.stem}", "", f"## {group_name}", ""])
@@ -2733,7 +2768,50 @@ def approve_course_summary_task(
raise ValueError("课程小结信息未补齐,不能直接批准入账") raise ValueError("课程小结信息未补齐,不能直接批准入账")
try: 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: except ValueError as exc:
task["status"] = "conflict" task["status"] = "conflict"
task["updated_at"] = datetime.now().isoformat(timespec="seconds") task["updated_at"] = datetime.now().isoformat(timespec="seconds")
@@ -2741,14 +2819,11 @@ def approve_course_summary_task(
write_admin_tasks(tasks_path, tasks) write_admin_tasks(tasks_path, tasks)
raise raise
task["status"] = "approved" try:
task["updated_at"] = datetime.now().isoformat(timespec="seconds") prune_data_backups(backup_dir.parent)
task["reviewed_at"] = task["updated_at"] except OSError:
task["proposed_line"] = proposed_line pass
task["registered_line"] = proposed_line return {"task": task_to_dict(task), "backup_id": backup_dir.name}
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", "")}
def approve_admin_task( def approve_admin_task(
@@ -2759,7 +2834,7 @@ def approve_admin_task(
) -> dict: ) -> dict:
task = find_admin_task(read_admin_tasks(tasks_path), task_id) task = find_admin_task(read_admin_tasks(tasks_path), task_id)
if task.get("type") == "class_record_correction": 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": if task.get("type") == "class_record_deletion":
return approve_deletion_task(tasks_path, classnotes_path, accounts_path, task_id) return approve_deletion_task(tasks_path, classnotes_path, accounts_path, task_id)
if task.get("type") == "course_summary_review": if task.get("type") == "course_summary_review":
+17 -3
View File
@@ -971,6 +971,20 @@ function clearRegisterPreview(type, resetState = true) {
if (confirmButton) confirmButton.hidden = 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) =>
`<li><span>${index + 1}</span><code>${escapeHtml(line)}</code></li>`
)
.join("");
return `<div class="register-preview-head"><strong>标准登记内容</strong><span>共 ${standardLines.length} 条</span></div><ol class="register-line-list">${rows}</ol>`;
}
return `<strong>标准登记内容</strong><pre>${escapeHtml(standardLines.join("\n\n"))}</pre>`;
}
function renderRegisterPreview(type, data, statusNode) { function renderRegisterPreview(type, data, statusNode) {
const previewNode = { const previewNode = {
class_record: classRegisterPreview, class_record: classRegisterPreview,
@@ -985,11 +999,12 @@ function renderRegisterPreview(type, data, statusNode) {
previewNode.hidden = false; previewNode.hidden = false;
if (data.status === "ready") { if (data.status === "ready") {
const warning = data.warning ? `<p>${escapeHtml(data.warning)}</p>` : ""; const warning = data.warning ? `<p>${escapeHtml(data.warning)}</p>` : "";
previewNode.innerHTML = `${warning}<strong>标准登记内容</strong><pre>${escapeHtml((data.standard_lines || []).join("\n\n"))}</pre>`; const standardLines = data.standard_lines || [];
previewNode.innerHTML = `${warning}${renderStandardLinesPreview(type, standardLines)}`;
confirmButton.hidden = false; confirmButton.hidden = false;
registerPreviewState[type] = { registerPreviewState[type] = {
conversationId: data.conversation_id, conversationId: data.conversation_id,
standardLines: data.standard_lines || [], standardLines,
}; };
statusNode.textContent = "已通过脚本生成预览,请确认后写入"; statusNode.textContent = "已通过脚本生成预览,请确认后写入";
return; return;
@@ -1289,5 +1304,4 @@ refreshBtn.addEventListener("click", () => {
}); });
loadAdminHealth(); loadAdminHealth();
addSummaryRegisterItem();
loadAccounts(); loadAccounts();
+56
View File
@@ -427,6 +427,62 @@ textarea:focus {
line-height: 1.45; 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 { .register-preview textarea {
min-height: 72px; min-height: 72px;
} }