feat: add summary registration and deletion review
This commit is contained in:
+335
@@ -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
@@ -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": ("老师", "教师"),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user