Add admin operation log rollback
This commit is contained in:
+210
-9
@@ -28,7 +28,7 @@ from .domain import (
|
|||||||
UNKNOWN_TEACHERS,
|
UNKNOWN_TEACHERS,
|
||||||
WEEKDAYS,
|
WEEKDAYS,
|
||||||
)
|
)
|
||||||
from .storage import atomic_write_text, create_data_backup, prune_data_backups
|
from .storage import BACKUP_DIR_RE, atomic_write_text, create_data_backup, prune_data_backups
|
||||||
|
|
||||||
|
|
||||||
CLASSNOTE_RE = re.compile(
|
CLASSNOTE_RE = re.compile(
|
||||||
@@ -1511,6 +1511,7 @@ OPERATION_LABELS = {
|
|||||||
"admin-approve-deletion": "审核批准上课记录删除",
|
"admin-approve-deletion": "审核批准上课记录删除",
|
||||||
"admin-update-course-summary-time": "课程小结补齐时间",
|
"admin-update-course-summary-time": "课程小结补齐时间",
|
||||||
"admin-delete-course-summary": "课程小结删除",
|
"admin-delete-course-summary": "课程小结删除",
|
||||||
|
"rollback-operation": "撤回操作",
|
||||||
}
|
}
|
||||||
|
|
||||||
STATUS_LABELS = {
|
STATUS_LABELS = {
|
||||||
@@ -1524,6 +1525,7 @@ STATUS_LABELS = {
|
|||||||
"review": "待审核",
|
"review": "待审核",
|
||||||
"conflict": "冲突",
|
"conflict": "冲突",
|
||||||
"pending": "待处理",
|
"pending": "待处理",
|
||||||
|
"rolled_back": "已撤回",
|
||||||
}
|
}
|
||||||
|
|
||||||
TYPE_LABELS = {
|
TYPE_LABELS = {
|
||||||
@@ -1571,7 +1573,7 @@ def migrate_operation_log_labels(path: Path) -> dict:
|
|||||||
return {"updated": changed, "path": str(path)}
|
return {"updated": changed, "path": str(path)}
|
||||||
|
|
||||||
|
|
||||||
def list_operation_logs(path: Path, limit: int = 100, operation: str = "", status_filter: str = "", student: str = "") -> dict:
|
def read_operation_log_rows(path: Path) -> list[dict]:
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
if path.exists():
|
if path.exists():
|
||||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||||
@@ -1581,19 +1583,218 @@ def list_operation_logs(path: Path, limit: int = 100, operation: str = "", statu
|
|||||||
item = json.loads(raw_line)
|
item = json.loads(raw_line)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
item = localize_operation_log_item(item)
|
|
||||||
if operation and item.get("operation") != operation:
|
|
||||||
continue
|
|
||||||
if status_filter and item.get("status") != status_filter:
|
|
||||||
continue
|
|
||||||
if student and student not in str(item.get("student", "")):
|
|
||||||
continue
|
|
||||||
rows.append(item)
|
rows.append(item)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def backup_search_dirs(paths: list[Path]) -> list[Path]:
|
||||||
|
result: list[Path] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
def add(path: Path) -> None:
|
||||||
|
key = str(path)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
result.append(path)
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
if path.name == "backups":
|
||||||
|
add(path)
|
||||||
|
elif path.suffix:
|
||||||
|
add(path.parent / "backups")
|
||||||
|
else:
|
||||||
|
add(path / "backups")
|
||||||
|
if path.exists():
|
||||||
|
for nested in path.rglob("backups"):
|
||||||
|
if nested.is_dir():
|
||||||
|
add(nested)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def backup_context(search_paths: list[Path]) -> tuple[list[Path], list[tuple[Path, dict]]]:
|
||||||
|
backup_dirs = backup_search_dirs(search_paths)
|
||||||
|
metadata_items: list[tuple[Path, dict]] = []
|
||||||
|
for backup_root in backup_dirs:
|
||||||
|
if not backup_root.exists():
|
||||||
|
continue
|
||||||
|
for backup_dir in sorted(
|
||||||
|
path
|
||||||
|
for path in backup_root.iterdir()
|
||||||
|
if path.is_dir() and BACKUP_DIR_RE.match(path.name)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
metadata_items.append((backup_dir, read_backup_metadata(backup_dir)))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return backup_dirs, metadata_items
|
||||||
|
|
||||||
|
|
||||||
|
def find_backup_dir(backup_id: str, search_paths: list[Path] | None = None, backup_dirs: list[Path] | None = None) -> Path:
|
||||||
|
value = backup_id.strip()
|
||||||
|
if not BACKUP_DIR_RE.match(value) or "/" in value or "\\" in value:
|
||||||
|
raise ValueError("备份ID格式错误")
|
||||||
|
for backup_root in backup_dirs if backup_dirs is not None else backup_search_dirs(search_paths or []):
|
||||||
|
candidate = backup_root / value
|
||||||
|
if candidate.is_dir():
|
||||||
|
return candidate
|
||||||
|
raise ValueError(f"备份不存在或已清理: {backup_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def read_backup_metadata(backup_dir: Path) -> dict:
|
||||||
|
metadata_path = backup_dir / "metadata.json"
|
||||||
|
if not metadata_path.exists():
|
||||||
|
raise ValueError(f"备份元数据不存在: {backup_dir.name}")
|
||||||
|
try:
|
||||||
|
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"备份元数据 JSON 格式错误: {backup_dir.name}") from exc
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
raise ValueError("备份元数据必须是 JSON 对象")
|
||||||
|
metadata.setdefault("backup_id", backup_dir.name)
|
||||||
|
metadata.setdefault("files", [])
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def backup_source_path(backup_dir: Path, file_meta: dict) -> Path:
|
||||||
|
raw_path = str(file_meta.get("source_path") or "").strip()
|
||||||
|
if raw_path:
|
||||||
|
path = Path(raw_path)
|
||||||
|
if path.is_absolute():
|
||||||
|
return path
|
||||||
|
return (backup_dir / path).resolve()
|
||||||
|
name = str(file_meta.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("备份文件缺少 source_path/name")
|
||||||
|
return backup_dir.parent.parent / name
|
||||||
|
|
||||||
|
|
||||||
|
def backup_source_paths(backup_dir: Path, metadata: dict) -> set[str]:
|
||||||
|
paths: set[str] = set()
|
||||||
|
for file_meta in metadata.get("files") or []:
|
||||||
|
if isinstance(file_meta, dict):
|
||||||
|
paths.add(str(backup_source_path(backup_dir, file_meta)))
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def operation_log_rollback_state(
|
||||||
|
item: dict,
|
||||||
|
rows: list[dict],
|
||||||
|
search_paths: list[Path],
|
||||||
|
backup_dirs: list[Path] | None = None,
|
||||||
|
metadata_items: list[tuple[Path, dict]] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
backup_id = str(item.get("backup_id") or "").strip()
|
||||||
|
log_id = str(item.get("id") or "").strip()
|
||||||
|
operation = str(item.get("operation") or "")
|
||||||
|
if not backup_id:
|
||||||
|
return {"can_rollback": False, "rollback_block_reason": "没有备份"}
|
||||||
|
if operation == "撤回操作":
|
||||||
|
return {"can_rollback": False, "rollback_block_reason": "撤回记录不能再次撤回"}
|
||||||
|
for row in rows:
|
||||||
|
localized = localize_operation_log_item(row)
|
||||||
|
if localized.get("operation") == "撤回操作" and str(localized.get("target_log_id") or "") == log_id:
|
||||||
|
return {"can_rollback": False, "rollback_block_reason": "已撤回"}
|
||||||
|
try:
|
||||||
|
backup_dir = find_backup_dir(backup_id, search_paths, backup_dirs)
|
||||||
|
metadata = read_backup_metadata(backup_dir)
|
||||||
|
target_sources = backup_source_paths(backup_dir, metadata)
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"can_rollback": False, "rollback_block_reason": str(exc)}
|
||||||
|
if not target_sources:
|
||||||
|
return {"can_rollback": False, "rollback_block_reason": "备份没有文件"}
|
||||||
|
if metadata_items is None:
|
||||||
|
_backup_dirs, metadata_items = backup_context(search_paths)
|
||||||
|
for other_dir, other_metadata in metadata_items:
|
||||||
|
if other_dir.name <= backup_dir.name:
|
||||||
|
continue
|
||||||
|
if target_sources & backup_source_paths(other_dir, other_metadata):
|
||||||
|
return {
|
||||||
|
"can_rollback": False,
|
||||||
|
"rollback_block_reason": f"已有后续备份 {other_dir.name}",
|
||||||
|
}
|
||||||
|
return {"can_rollback": True, "rollback_block_reason": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def list_operation_logs(
|
||||||
|
path: Path,
|
||||||
|
limit: int = 100,
|
||||||
|
operation: str = "",
|
||||||
|
status_filter: str = "",
|
||||||
|
student: str = "",
|
||||||
|
backup_paths: list[Path] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
raw_rows = read_operation_log_rows(path)
|
||||||
|
rows: list[dict] = []
|
||||||
|
backup_dirs: list[Path] | None = None
|
||||||
|
metadata_items: list[tuple[Path, dict]] | None = None
|
||||||
|
if backup_paths is not None:
|
||||||
|
backup_dirs, metadata_items = backup_context(backup_paths)
|
||||||
|
for item in raw_rows:
|
||||||
|
item = localize_operation_log_item(item)
|
||||||
|
if backup_paths is not None:
|
||||||
|
item.update(operation_log_rollback_state(item, raw_rows, backup_paths, backup_dirs, metadata_items))
|
||||||
|
if operation and item.get("operation") != operation:
|
||||||
|
continue
|
||||||
|
if status_filter and item.get("status") != status_filter:
|
||||||
|
continue
|
||||||
|
if student and student not in str(item.get("student", "")):
|
||||||
|
continue
|
||||||
|
rows.append(item)
|
||||||
rows = rows[-limit:]
|
rows = rows[-limit:]
|
||||||
rows.reverse()
|
rows.reverse()
|
||||||
return {"count": len(rows), "items": rows}
|
return {"count": len(rows), "items": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_operation_log(
|
||||||
|
operation_logs_path: Path,
|
||||||
|
log_id: str,
|
||||||
|
backup_paths: list[Path],
|
||||||
|
) -> dict:
|
||||||
|
rows = read_operation_log_rows(operation_logs_path)
|
||||||
|
target: dict | None = None
|
||||||
|
for row in rows:
|
||||||
|
if str(row.get("id") or "") == log_id:
|
||||||
|
target = localize_operation_log_item(row)
|
||||||
|
break
|
||||||
|
if target is None:
|
||||||
|
raise ValueError(f"未找到操作记录: {log_id}")
|
||||||
|
state = operation_log_rollback_state(target, rows, backup_paths)
|
||||||
|
if not state.get("can_rollback"):
|
||||||
|
raise ValueError(str(state.get("rollback_block_reason") or "该记录不能撤回"))
|
||||||
|
|
||||||
|
target_backup_id = str(target.get("backup_id") or "").strip()
|
||||||
|
backup_dir = find_backup_dir(target_backup_id, backup_paths)
|
||||||
|
metadata = read_backup_metadata(backup_dir)
|
||||||
|
file_contents: dict[Path, str] = {}
|
||||||
|
restore_contents: list[tuple[Path, str]] = []
|
||||||
|
for file_meta in metadata.get("files") or []:
|
||||||
|
if not isinstance(file_meta, dict):
|
||||||
|
continue
|
||||||
|
source_path = backup_source_path(backup_dir, file_meta)
|
||||||
|
backup_file = backup_dir / str(file_meta.get("name") or source_path.name)
|
||||||
|
if not backup_file.exists():
|
||||||
|
raise ValueError(f"备份文件不存在: {backup_file.name}")
|
||||||
|
file_contents[source_path] = source_path.read_text(encoding="utf-8") if source_path.exists() else ""
|
||||||
|
restore_contents.append((source_path, backup_file.read_text(encoding="utf-8")))
|
||||||
|
if not restore_contents:
|
||||||
|
raise ValueError("备份没有可恢复文件")
|
||||||
|
|
||||||
|
rollback_backup = create_data_backup(
|
||||||
|
"rollback-operation",
|
||||||
|
file_contents,
|
||||||
|
[log_id, target_backup_id],
|
||||||
|
)
|
||||||
|
for source_path, content in restore_contents:
|
||||||
|
atomic_write_text(source_path, content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"target_log_id": log_id,
|
||||||
|
"target_backup_id": target_backup_id,
|
||||||
|
"backup_id": rollback_backup.name,
|
||||||
|
"restored_files": [str(path) for path, _content in restore_contents],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def normalize_filter_date(value: str) -> str:
|
def normalize_filter_date(value: str) -> str:
|
||||||
text = value.strip().replace(".", "-")
|
text = value.strip().replace(".", "-")
|
||||||
if not text:
|
if not text:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from ..auth import verify_admin_auth
|
from ..auth import verify_admin_auth
|
||||||
@@ -20,6 +22,7 @@ from ..data import (
|
|||||||
migrate_operation_log_labels,
|
migrate_operation_log_labels,
|
||||||
query_course_summaries,
|
query_course_summaries,
|
||||||
reject_admin_task,
|
reject_admin_task,
|
||||||
|
rollback_operation_log,
|
||||||
update_course_summary_time,
|
update_course_summary_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +30,16 @@ from ..data import (
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_backup_paths() -> list[Path]:
|
||||||
|
return [
|
||||||
|
CLASSNOTES_PATH,
|
||||||
|
ACCOUNTS_PATH,
|
||||||
|
ADMIN_TASKS_PATH,
|
||||||
|
COURSE_SUMMARIES_ROOT,
|
||||||
|
OPERATION_LOGS_PATH,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/admin/tasks")
|
@router.get("/api/admin/tasks")
|
||||||
def admin_tasks(
|
def admin_tasks(
|
||||||
status_filter: str = Query("", alias="status"),
|
status_filter: str = Query("", alias="status"),
|
||||||
@@ -54,9 +67,29 @@ def admin_operation_logs(
|
|||||||
operation=operation,
|
operation=operation,
|
||||||
status_filter=status_filter,
|
status_filter=status_filter,
|
||||||
student=student,
|
student=student,
|
||||||
|
backup_paths=rollback_backup_paths(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/admin/operation-logs/{log_id}/rollback")
|
||||||
|
def admin_rollback_operation_log(log_id: str, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
with write_lock:
|
||||||
|
result = rollback_operation_log(OPERATION_LOGS_PATH, log_id, rollback_backup_paths())
|
||||||
|
append_operation_log(
|
||||||
|
OPERATION_LOGS_PATH,
|
||||||
|
"撤回操作",
|
||||||
|
"已撤回",
|
||||||
|
target_log_id=log_id,
|
||||||
|
target_backup_id=str(result.get("target_backup_id") or ""),
|
||||||
|
backup_id=str(result.get("backup_id") or ""),
|
||||||
|
restored_files=result.get("restored_files") or [],
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/admin/course-summaries")
|
@router.get("/api/admin/course-summaries")
|
||||||
def admin_course_summaries(
|
def admin_course_summaries(
|
||||||
q: str = Query(""),
|
q: str = Query(""),
|
||||||
|
|||||||
@@ -342,6 +342,7 @@
|
|||||||
<option value="审核驳回">审核驳回</option>
|
<option value="审核驳回">审核驳回</option>
|
||||||
<option value="课程小结补齐时间">课程小结补齐时间</option>
|
<option value="课程小结补齐时间">课程小结补齐时间</option>
|
||||||
<option value="课程小结删除">课程小结删除</option>
|
<option value="课程小结删除">课程小结删除</option>
|
||||||
|
<option value="撤回操作">撤回操作</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="logStatus" aria-label="操作结果筛选">
|
<select id="logStatus" aria-label="操作结果筛选">
|
||||||
<option value="">全部结果</option>
|
<option value="">全部结果</option>
|
||||||
@@ -353,6 +354,7 @@
|
|||||||
<option value="已批准">已批准</option>
|
<option value="已批准">已批准</option>
|
||||||
<option value="已更新">已更新</option>
|
<option value="已更新">已更新</option>
|
||||||
<option value="已删除">已删除</option>
|
<option value="已删除">已删除</option>
|
||||||
|
<option value="已撤回">已撤回</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,6 +373,7 @@
|
|||||||
<th>学生</th>
|
<th>学生</th>
|
||||||
<th>批次/任务</th>
|
<th>批次/任务</th>
|
||||||
<th>详情</th>
|
<th>详情</th>
|
||||||
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="logRows"></tbody>
|
<tbody id="logRows"></tbody>
|
||||||
@@ -417,6 +420,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/admin.js?v=20260616-duration-text"></script>
|
<script src="/static/admin.js?v=20260616-rollback-log"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+33
-3
@@ -722,12 +722,25 @@ function renderLogDetail(item) {
|
|||||||
if (Array.isArray(item.reasons) && item.reasons.length) details.push(`原因:${item.reasons.join(";")}`);
|
if (Array.isArray(item.reasons) && item.reasons.length) details.push(`原因:${item.reasons.join(";")}`);
|
||||||
if (item.error) details.push(`错误:${item.error}`);
|
if (item.error) details.push(`错误:${item.error}`);
|
||||||
if (item.backup_id) details.push(`备份:${item.backup_id}`);
|
if (item.backup_id) details.push(`备份:${item.backup_id}`);
|
||||||
|
if (item.target_log_id) details.push(`撤回记录:${item.target_log_id}`);
|
||||||
|
if (item.target_backup_id) details.push(`撤回备份:${item.target_backup_id}`);
|
||||||
|
if (Array.isArray(item.restored_files) && item.restored_files.length) details.push(`恢复文件:${item.restored_files.join(";")}`);
|
||||||
if (item.saved_path) details.push(`文件:${item.saved_path}`);
|
if (item.saved_path) details.push(`文件:${item.saved_path}`);
|
||||||
return `<div class="log-detail">${details.map(escapeHtml).join("<br>") || "暂无详情"}</div>`;
|
return `<div class="log-detail">${details.map(escapeHtml).join("<br>") || "暂无详情"}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderLogActions(item) {
|
||||||
|
if (item.can_rollback) {
|
||||||
|
return `<button class="small-button log-rollback" type="button" data-log-id="${escapeHtml(item.id)}">撤回</button>`;
|
||||||
|
}
|
||||||
|
if (item.backup_id && item.rollback_block_reason) {
|
||||||
|
return `<small class="muted">${escapeHtml(item.rollback_block_reason)}</small>`;
|
||||||
|
}
|
||||||
|
return `<span class="muted">-</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOperationLogs() {
|
async function loadOperationLogs() {
|
||||||
logRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
|
logRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
|
||||||
const params = new URLSearchParams({ limit: "200" });
|
const params = new URLSearchParams({ limit: "200" });
|
||||||
if (logOperation.value) params.set("operation", logOperation.value);
|
if (logOperation.value) params.set("operation", logOperation.value);
|
||||||
if (logStatus.value) params.set("status", logStatus.value);
|
if (logStatus.value) params.set("status", logStatus.value);
|
||||||
@@ -743,13 +756,26 @@ async function loadOperationLogs() {
|
|||||||
<td>${escapeHtml(item.student || "")}</td>
|
<td>${escapeHtml(item.student || "")}</td>
|
||||||
<td>${escapeHtml(item.batch_id || "")}${item.task_id ? `<br><small>任务 #${escapeHtml(item.task_id)}</small>` : ""}</td>
|
<td>${escapeHtml(item.batch_id || "")}${item.task_id ? `<br><small>任务 #${escapeHtml(item.task_id)}</small>` : ""}</td>
|
||||||
<td>${renderLogDetail(item)}</td>
|
<td>${renderLogDetail(item)}</td>
|
||||||
|
<td class="record-action-cell">${renderLogActions(item)}</td>
|
||||||
</tr>`)
|
</tr>`)
|
||||||
.join("");
|
.join("");
|
||||||
if (!data.items.length) {
|
if (!data.items.length) {
|
||||||
logRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的操作记录</td></tr>`;
|
logRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的操作记录</td></tr>`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
logRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollbackOperationLog(logId) {
|
||||||
|
if (!confirm("确认撤回这条操作记录?系统只会在没有后续相关改动时恢复备份。")) return;
|
||||||
|
try {
|
||||||
|
await fetchJson(`/api/admin/operation-logs/${encodeURIComponent(logId)}/rollback`, { method: "POST" });
|
||||||
|
await loadOperationLogs();
|
||||||
|
await loadAdminHealth();
|
||||||
|
await loadAccounts();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,6 +1033,10 @@ logFilterForm.addEventListener("submit", (event) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
loadOperationLogs();
|
loadOperationLogs();
|
||||||
});
|
});
|
||||||
|
logRows.addEventListener("click", (event) => {
|
||||||
|
const rollback = event.target.closest(".log-rollback");
|
||||||
|
if (rollback) rollbackOperationLog(rollback.dataset.logId);
|
||||||
|
});
|
||||||
classRegisterForm.addEventListener("submit", (event) => {
|
classRegisterForm.addEventListener("submit", (event) => {
|
||||||
previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus);
|
previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user