修复登记预览与审批逻辑
This commit is contained in:
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user