feat: add summary registration and deletion review

This commit is contained in:
Codex
2026-06-15 16:23:06 +08:00
parent 771220c9d6
commit 0f854cc185
10 changed files with 597 additions and 22 deletions
+335
View File
@@ -42,6 +42,7 @@ PAYMENT_LINE_RE = re.compile(r"^(?P<student>.+?)-(?P<date>\d{4}-\d{2}-\d{2}):(?P
TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$") TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$")
COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P<title>.+)$", re.M) COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P<title>.+)$", re.M)
COURSE_SUMMARY_DATE_RE = re.compile(r"(?P<date>\d{4}[.-]\d{1,2}[.-]\d{1,2})") 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"(?:到|至||-|~|—|)" DATE_RANGE_SEPARATOR = r"(?:到|至||-|~|—|)"
CHINESE_DATE_RANGE_RE = re.compile( CHINESE_DATE_RANGE_RE = re.compile(
rf"(?:(?P<sy>\d{{4}})\s*年\s*)?" 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]} 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: def list_admin_tasks(tasks_path: Path, status_filter: str = "", task_type: str = "") -> dict:
tasks = read_admin_tasks(tasks_path) tasks = read_admin_tasks(tasks_path)
items = tasks["items"] 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 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: def mark_admin_task(tasks_path: Path, task_id: int, status: str, message: str = "") -> 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)
@@ -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} 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: def safe_filename_part(value: object) -> str:
text = str(value or "").strip() text = str(value or "").strip()
text = re.sub(r"[\\/:*?\"<>|\s]+", "_", text) 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 "")) 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: def normalize_course_summary(raw: dict) -> dict:
student = canonical_name(str(raw.get("student") or "").strip()) student = canonical_name(str(raw.get("student") or "").strip())
teacher = canonical_name(str(raw.get("teacher") 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) 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, 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": if task.get("type") == "course_summary_review":
return approve_course_summary_task(tasks_path, classnotes_path, accounts_path, task_id) return approve_course_summary_task(tasks_path, classnotes_path, accounts_path, task_id)
raise ValueError("不支持的审核任务类型") 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( def ingest_course_summaries(
*, *,
classnotes_path: Path, classnotes_path: Path,
+1 -1
View File
@@ -28,7 +28,7 @@ WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "
UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"} UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"}
UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"} UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"}
HIGH_CONFIDENCE_VALUES = {"high", "", "高置信", "true", "1", "yes"} 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 = { ROLE_WORDS = {
"student": ("学生", "学员", "孩子", "同学"), "student": ("学生", "学员", "孩子", "同学"),
"teacher": ("老师", "教师"), "teacher": ("老师", "教师"),
+30 -1
View File
@@ -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 ..api_utils import file_meta, load_accounts, payload_to_account, read_register_payload
from ..auth import verify_accounts_auth, verify_admin_auth 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 ( from ..data import (
ACCOUNT_STATUSES, ACCOUNT_STATUSES,
DuplicateRecordError, DuplicateRecordError,
@@ -13,6 +21,7 @@ from ..data import (
create_account, create_account,
filter_accounts, filter_accounts,
register_class_record_lines, register_class_record_lines,
register_course_summary_texts,
register_payment_lines, register_payment_lines,
update_account, update_account,
) )
@@ -51,6 +60,26 @@ async def register_payments(request: Request, _user: str = Depends(verify_admin_
return {"ok": True, **result} 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") @router.get("/api/account-health")
def account_health(_user: str = Depends(verify_accounts_auth)): def account_health(_user: str = Depends(verify_accounts_auth)):
accounts = load_accounts() accounts = load_accounts()
+14 -2
View File
@@ -5,8 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from ..api_utils import load_accounts, load_records from ..api_utils import load_accounts, load_records
from ..auth import verify_records_auth from ..auth import verify_records_auth
from ..config import ADMIN_TASKS_PATH from ..config import ADMIN_TASKS_PATH
from ..data import account_to_dict, query_records, submit_correction_tasks from ..data import account_to_dict, query_records, submit_correction_tasks, submit_deletion_tasks
from ..schemas import CorrectionSubmitPayload from ..schemas import CorrectionSubmitPayload, DeletionSubmitPayload
router = APIRouter() router = APIRouter()
@@ -39,3 +39,15 @@ def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(ve
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result} 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}
+8
View File
@@ -31,6 +31,14 @@ class CorrectionSubmitPayload(BaseModel):
items: list[CorrectionItemPayload] items: list[CorrectionItemPayload]
class DeletionItemPayload(BaseModel):
original_line: str
class DeletionSubmitPayload(BaseModel):
items: list[DeletionItemPayload]
class CourseSummaryPayload(BaseModel): class CourseSummaryPayload(BaseModel):
source_id: str = "" source_id: str = ""
student: str student: str
+12 -2
View File
@@ -128,8 +128,9 @@
<tr> <tr>
<th>编号</th> <th>编号</th>
<th>状态</th> <th>状态</th>
<th>类型</th>
<th>原记录</th> <th>原记录</th>
<th>修改后</th> <th>处理内容</th>
<th>提交时间</th> <th>提交时间</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
@@ -305,10 +306,19 @@
<button type="submit">提交缴费记录</button> <button type="submit">提交缴费记录</button>
</div> </div>
</form> </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> </div>
</section> </section>
</main> </main>
<script src="/static/admin.js?v=20260615-summary-review-drawer"></script> <script src="/static/admin.js?v=20260615-summary-register-delete"></script>
</body> </body>
</html> </html>
+86 -10
View File
@@ -64,11 +64,16 @@ const classRegisterStatus = document.querySelector("#classRegisterStatus");
const paymentRegisterForm = document.querySelector("#paymentRegisterForm"); const paymentRegisterForm = document.querySelector("#paymentRegisterForm");
const paymentRegisterLines = document.querySelector("#paymentRegisterLines"); const paymentRegisterLines = document.querySelector("#paymentRegisterLines");
const paymentRegisterStatus = document.querySelector("#paymentRegisterStatus"); 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 currentAccounts = [];
let editingAccountId = ""; let editingAccountId = "";
let currentSummaryReviews = []; let currentSummaryReviews = [];
let activeSummaryReview = null; let activeSummaryReview = null;
let summaryRegisterItemSeq = 0;
function fmtHours(value) { function fmtHours(value) {
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
@@ -282,21 +287,37 @@ function renderReviewLine(line) {
return `<code class="line-code">${escapeHtml(line)}</code>`; 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() { async function loadReviews() {
reviewRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`; reviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
const params = new URLSearchParams({ type: "class_record_correction" });
if (reviewStatus.value) params.set("status", reviewStatus.value);
try { try {
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`); const [correctionData, deletionData] = await Promise.all([
reviewMeta.innerHTML = [metric("当前结果", `${data.count}`)].join(""); fetchJson(`/api/admin/tasks?${new URLSearchParams({ type: "class_record_correction", ...(reviewStatus.value ? { status: reviewStatus.value } : {}) }).toString()}`),
reviewRows.innerHTML = data.items 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) => { .map((item) => {
const canReview = item.status === "pending" || item.status === "conflict"; const canReview = item.status === "pending" || item.status === "conflict";
return `<tr> return `<tr>
<td>#${escapeHtml(item.id)}</td> <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><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.original_line)}</td>
<td>${renderReviewLine(item.corrected_line)}</td> <td>${renderReviewTarget(item)}</td>
<td>${escapeHtml(item.created_at || "")}</td> <td>${escapeHtml(item.created_at || "")}</td>
<td class="record-action-cell"> <td class="record-action-cell">
<div class="record-actions"> <div class="record-actions">
@@ -307,11 +328,11 @@ async function loadReviews() {
</tr>`; </tr>`;
}) })
.join(""); .join("");
if (!data.items.length) { if (!items.length) {
reviewRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的审核项</td></tr>`; reviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的审核项</td></tr>`;
} }
} catch (error) { } 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) => { document.querySelectorAll("[data-admin-tab]").forEach((button) => {
button.addEventListener("click", () => setActiveTab(button.dataset.adminTab)); button.addEventListener("click", () => setActiveTab(button.dataset.adminTab));
}); });
@@ -647,6 +709,19 @@ classRegisterForm.addEventListener("submit", (event) => {
paymentRegisterForm.addEventListener("submit", (event) => { paymentRegisterForm.addEventListener("submit", (event) => {
submitRegister(event, paymentRegisterLines, paymentRegisterStatus, "/api/register/payments"); 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", () => { refreshBtn.addEventListener("click", () => {
loadAdminHealth(); loadAdminHealth();
if (!panels.accounts.hidden) loadAccounts(); if (!panels.accounts.hidden) loadAccounts();
@@ -657,4 +732,5 @@ refreshBtn.addEventListener("click", () => {
}); });
loadAdminHealth(); loadAdminHealth();
addSummaryRegisterItem();
loadAccounts(); loadAccounts();
+63 -3
View File
@@ -23,6 +23,12 @@ const correctionTeacher = document.querySelector("#correctionTeacher");
const correctionSubject = document.querySelector("#correctionSubject"); const correctionSubject = document.querySelector("#correctionSubject");
const closeCorrectionBtn = document.querySelector("#closeCorrectionBtn"); const closeCorrectionBtn = document.querySelector("#closeCorrectionBtn");
const cancelCorrectionBtn = document.querySelector("#cancelCorrectionBtn"); 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 WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
const COPY_LINE_BREAK = "\r\n"; const COPY_LINE_BREAK = "\r\n";
@@ -32,6 +38,7 @@ let recordSort = { date: "asc", time: "asc" };
let currentRecordOrder = []; let currentRecordOrder = [];
let correctedRecords = new Map(); let correctedRecords = new Map();
let activeCorrectionKey = ""; let activeCorrectionKey = "";
let activeDeleteKey = "";
function fmtHours(value) { function fmtHours(value) {
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
@@ -185,6 +192,7 @@ function renderRecordRow(row) {
<td class="record-action-cell"> <td class="record-action-cell">
<div class="record-actions"> <div class="record-actions">
<button class="small-button correction-edit" type="button" data-record-key="${escapeHtml(key)}">${actionLabel}</button> <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} ${badge}
</div> </div>
</td> </td>
@@ -385,6 +393,45 @@ function closeCorrectionDialog() {
setCorrectionError(""); 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() { function saveCorrection() {
const original = findCurrentRecord(activeCorrectionKey); const original = findCurrentRecord(activeCorrectionKey);
if (!original) return; if (!original) return;
@@ -550,9 +597,13 @@ document.querySelectorAll("[data-query]").forEach((button) => {
}); });
recordRows.addEventListener("click", (event) => { recordRows.addEventListener("click", (event) => {
const button = event.target.closest(".correction-edit"); const editButton = event.target.closest(".correction-edit");
if (!button) return; const deleteButton = event.target.closest(".record-delete");
openCorrectionDialog(button.dataset.recordKey); if (editButton) {
openCorrectionDialog(editButton.dataset.recordKey);
return;
}
if (deleteButton) openDeleteDialog(deleteButton.dataset.recordKey);
}); });
[correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => { [correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => {
@@ -574,10 +625,19 @@ cancelCorrectionBtn.addEventListener("click", closeCorrectionDialog);
correctionDialog.addEventListener("click", (event) => { correctionDialog.addEventListener("click", (event) => {
if (event.target === correctionDialog) closeCorrectionDialog(); 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) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !correctionDialog.hidden) { if (event.key === "Escape" && !correctionDialog.hidden) {
closeCorrectionDialog(); closeCorrectionDialog();
} }
if (event.key === "Escape" && !deleteDialog.hidden) {
closeDeleteDialog();
}
}); });
copyCorrectedBtn.addEventListener("click", copyCorrectedRecords); copyCorrectedBtn.addEventListener("click", copyCorrectedRecords);
+22 -1
View File
@@ -119,6 +119,27 @@
</section> </section>
</div> </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> </body>
</html> </html>
+26 -2
View File
@@ -369,10 +369,30 @@ textarea:focus {
.register-grid { .register-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px; 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 { .line-code {
display: block; display: block;
max-width: 420px; max-width: 420px;
@@ -922,7 +942,7 @@ td {
} }
.register-grid { .register-grid {
grid-template-columns: 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@@ -979,6 +999,10 @@ td {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.register-grid {
grid-template-columns: 1fr;
}
.admin-tabs { .admin-tabs {
overflow-x: auto; overflow-x: auto;
} }