feat: add database checkpoint management system

Features:
- Create manual database checkpoints with descriptions
- List all available checkpoints with statistics
- Restore database from checkpoints (with dangerous operation warning)
- Delete unwanted checkpoints
- Frontend UI integrated into database admin panel
- JSON-based checkpoint storage in data/checkpoints/

Backend Changes:
- Added checkpoint management functions to licensing_repo.py:
  * create_checkpoint() - backup all tables to JSON
  * list_checkpoints() - enumerate checkpoint files
  * restore_checkpoint() - restore from checkpoint
  * delete_checkpoint() - remove checkpoint file
- Added 4 new API endpoints to v2.py:
  * GET /admin/checkpoints - list checkpoints
  * POST /admin/checkpoints - create checkpoint
  * POST /admin/checkpoints/{id}/restore - restore checkpoint
  * DELETE /admin/checkpoints/{id} - delete checkpoint

Frontend Changes (db_admin.html):
- Added step 5 "检查点管理" to navigation
- Created checkpoint management UI with forms and lists
- Added dangerous operation confirmation modal
- Integrated into existing breadcrumb navigation system

Safety Features:
- All dangerous operations require explicit confirmation
- Restore operations show warning about data loss
- Checkpoints include row counts and table statistics
- Timestamped checkpoint IDs for easy identification

Note: Checkpoint files are stored in data/checkpoints/ directory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Codex Agent 2025-10-30 10:33:35 +08:00
parent 506ea5ce98
commit 9530eabac8
3 changed files with 755 additions and 4 deletions

View File

@ -11,6 +11,10 @@ from lawrisk.services.licensing_repo import (
load_permits_and_risks, load_permits_and_risks,
list_region_theme_options, list_region_theme_options,
load_theme_payload, load_theme_payload,
create_checkpoint,
list_checkpoints,
restore_checkpoint,
delete_checkpoint,
) )
from lawrisk.services.lawrisk_service import suggest_questions_embed from lawrisk.services.lawrisk_service import suggest_questions_embed
@ -250,3 +254,57 @@ def admin_permit_details():
except Exception as exc: except Exception as exc:
print(f"admin_permit_details error: {exc}") print(f"admin_permit_details error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500 return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/checkpoints', methods=['GET'])
def admin_list_checkpoints():
"""List all available checkpoints."""
try:
checkpoints = list_checkpoints()
return jsonify({"success": True, "data": {"checkpoints": checkpoints}})
except Exception as exc:
print(f"admin_list_checkpoints error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/checkpoints', methods=['POST'])
def admin_create_checkpoint():
"""Create a new checkpoint."""
if request.is_json:
payload = request.get_json(silent=True) or {}
else:
payload = request.form.to_dict(flat=True) if request.form else {}
description = payload.get("description", "")
try:
checkpoint = create_checkpoint(description)
return jsonify({"success": True, "data": checkpoint})
except Exception as exc:
print(f"admin_create_checkpoint error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/checkpoints/<checkpoint_id>/restore', methods=['POST'])
def admin_restore_checkpoint(checkpoint_id):
"""Restore database from a checkpoint. DANGEROUS OPERATION!"""
try:
restore_summary = restore_checkpoint(checkpoint_id)
return jsonify({"success": True, "data": restore_summary})
except Exception as exc:
print(f"admin_restore_checkpoint error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500
@v2_bp.route('/admin/checkpoints/<checkpoint_id>', methods=['DELETE'])
def admin_delete_checkpoint(checkpoint_id):
"""Delete a checkpoint."""
try:
success = delete_checkpoint(checkpoint_id)
if success:
return jsonify({"success": True, "message": "Checkpoint deleted"})
else:
return jsonify({"success": False, "message": "Checkpoint not found"}), 404
except Exception as exc:
print(f"admin_delete_checkpoint error: {exc}")
return jsonify({"success": False, "message": str(exc)}), 500

View File

@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
import re import re
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import pg8000.dbapi as pg import pg8000.dbapi as pg
@ -325,3 +327,202 @@ def load_theme_payload(region_id: str, theme_id: str) -> Dict[str, object]:
"theme": {"id": str(theme_uuid), "name": str(theme_name)}, "theme": {"id": str(theme_uuid), "name": str(theme_name)},
"permits": permits, "permits": permits,
} }
def _get_checkpoints_dir() -> str:
"""Get the directory for storing checkpoint files."""
base_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data")
checkpoints_dir = os.path.join(base_dir, "checkpoints")
os.makedirs(checkpoints_dir, exist_ok=True)
return checkpoints_dir
def _get_all_tables() -> List[str]:
"""Get list of all tables in the licensing_risks database."""
sql = """
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
"""
with _lic_pg_conn() as conn:
cur = conn.cursor()
cur.execute(sql)
return [row[0] for row in cur.fetchall()]
def _backup_table(conn: pg.Connection, table_name: str) -> Tuple[List[Dict[str, Any]], int]:
"""Backup a single table and return its data and row count."""
sql = f"SELECT * FROM {table_name}"
cur = conn.cursor()
cur.execute(sql)
rows = cur.fetchall()
colnames = [desc[0] for desc in cur.description]
data = []
for row in rows:
row_dict = {}
for i, col in enumerate(colnames):
value = row[i]
if value is not None:
# Convert UUID and other non-serializable types to strings
if hasattr(value, 'isoformat'): # UUID, datetime, etc.
row_dict[col] = str(value)
else:
row_dict[col] = value
data.append(row_dict)
return data, len(data)
def _restore_table(conn: pg.Connection, table_name: str, data: List[Dict[str, Any]]) -> int:
"""Restore a table from backup data. Returns number of rows restored."""
if not data:
return 0
conn.autocommit = False
try:
cur = conn.cursor()
delete_sql = f"DELETE FROM {table_name}"
cur.execute(delete_sql)
if data:
columns = list(data[0].keys())
placeholders = ", ".join(["%s"] * len(columns))
insert_sql = f"INSERT INTO {table_name} ({', '.join(columns)}) VALUES ({placeholders})"
for row in data:
values = [row.get(col) for col in columns]
cur.execute(insert_sql, values)
conn.commit()
return len(data)
except Exception as e:
conn.rollback()
raise e
finally:
conn.autocommit = False
def create_checkpoint(description: str = "") -> Dict[str, Any]:
"""Create a checkpoint by backing up all tables."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
checkpoint_id = f"checkpoint_{timestamp}"
tables = _get_all_tables()
checkpoint_data = {
"checkpoint_id": checkpoint_id,
"timestamp": timestamp,
"description": description,
"tables": {}
}
total_rows = 0
table_counts = {}
with _lic_pg_conn() as conn:
for table in tables:
data, row_count = _backup_table(conn, table)
checkpoint_data["tables"][table] = data
table_counts[table] = row_count
total_rows += row_count
checkpoint_data["table_counts"] = table_counts
checkpoint_data["total_rows"] = total_rows
checkpoints_dir = _get_checkpoints_dir()
checkpoint_file = os.path.join(checkpoints_dir, f"{checkpoint_id}.json")
def json_serializer(obj):
"""Convert non-JSON serializable objects to strings."""
if hasattr(obj, 'isoformat'): # UUID, datetime, etc.
return str(obj)
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
with open(checkpoint_file, "w", encoding="utf-8") as f:
json.dump(checkpoint_data, f, ensure_ascii=False, indent=2, default=json_serializer)
return {
"checkpoint_id": checkpoint_id,
"timestamp": timestamp,
"description": description,
"total_rows": total_rows,
"table_counts": table_counts
}
def list_checkpoints() -> List[Dict[str, Any]]:
"""List all available checkpoints."""
checkpoints_dir = _get_checkpoints_dir()
checkpoints = []
if not os.path.exists(checkpoints_dir):
return checkpoints
for filename in os.listdir(checkpoints_dir):
if filename.endswith(".json"):
filepath = os.path.join(checkpoints_dir, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
checkpoints.append({
"checkpoint_id": data["checkpoint_id"],
"timestamp": data["timestamp"],
"description": data.get("description", ""),
"total_rows": data.get("total_rows", 0),
"table_counts": data.get("table_counts", {}),
"filename": filename
})
except Exception as e:
print(f"Error reading checkpoint {filename}: {e}")
return sorted(checkpoints, key=lambda x: x["timestamp"], reverse=True)
def restore_checkpoint(checkpoint_id: str) -> Dict[str, Any]:
"""Restore database from a checkpoint. This is a destructive operation!"""
checkpoints_dir = _get_checkpoints_dir()
checkpoint_file = os.path.join(checkpoints_dir, f"{checkpoint_id}.json")
if not os.path.exists(checkpoint_file):
raise ValueError(f"Checkpoint {checkpoint_id} not found")
with open(checkpoint_file, "r", encoding="utf-8") as f:
checkpoint_data = json.load(f)
tables = checkpoint_data.get("tables", {})
restore_summary = {
"checkpoint_id": checkpoint_id,
"tables_restored": 0,
"total_rows_restored": 0,
"table_details": {}
}
with _lic_pg_conn(autocommit=False) as conn:
try:
for table_name, data in tables.items():
rows_restored = _restore_table(conn, table_name, data)
restore_summary["tables_restored"] += 1
restore_summary["total_rows_restored"] += rows_restored
restore_summary["table_details"][table_name] = rows_restored
conn.commit()
except Exception as e:
conn.rollback()
raise e
return restore_summary
def delete_checkpoint(checkpoint_id: str) -> bool:
"""Delete a checkpoint file."""
checkpoints_dir = _get_checkpoints_dir()
checkpoint_file = os.path.join(checkpoints_dir, f"{checkpoint_id}.json")
if os.path.exists(checkpoint_file):
os.remove(checkpoint_file)
return True
return False

View File

@ -264,6 +264,259 @@
color: white; color: white;
} }
.checkpoint-nav-item {
background: #fff3cd;
border: 2px solid #ffc107;
}
.checkpoint-nav-item:hover {
background: #ffe69c;
border-color: #ff9800;
}
.checkpoint-section {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid #e0e0e0;
}
.checkpoint-section h3 {
color: #667eea;
font-size: 18px;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.checkpoint-section h3::before {
content: '🔒';
font-size: 20px;
}
.checkpoint-form {
margin-bottom: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 500;
}
.form-group input[type="text"] {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
}
.form-group input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #5568d3;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c82333;
}
.btn-warning {
background: #ffc107;
color: #333;
}
.btn-warning:hover:not(:disabled) {
background: #e0a800;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.checkpoint-list {
margin-top: 20px;
}
.checkpoint-item {
background: #f8f9fa;
padding: 15px;
margin-bottom: 12px;
border-radius: 6px;
border: 1px solid #e0e0e0;
transition: all 0.3s;
}
.checkpoint-item:hover {
background: #e9ecef;
border-color: #667eea;
}
.checkpoint-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.checkpoint-id {
font-weight: bold;
color: #333;
font-size: 15px;
}
.checkpoint-timestamp {
color: #666;
font-size: 13px;
}
.checkpoint-description {
color: #555;
margin-bottom: 10px;
line-height: 1.6;
}
.checkpoint-stats {
display: flex;
gap: 15px;
margin-bottom: 10px;
font-size: 13px;
color: #666;
}
.checkpoint-stats span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.checkpoint-actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.show {
display: flex;
}
.modal-content {
background: white;
border-radius: 12px;
padding: 30px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
animation: modalFadeIn 0.3s;
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
text-align: center;
margin-bottom: 20px;
}
.modal-header h3 {
color: #dc3545;
font-size: 20px;
margin-bottom: 10px;
}
.modal-header .warning-icon {
font-size: 48px;
margin-bottom: 15px;
}
.modal-body {
margin-bottom: 20px;
}
.modal-body p {
color: #555;
line-height: 1.6;
margin-bottom: 10px;
}
.modal-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.danger-text {
color: #dc3545;
font-weight: bold;
}
.success-text {
color: #28a745;
font-weight: bold;
}
.details-area { .details-area {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
@ -457,6 +710,11 @@
<div class="step-number">4</div> <div class="step-number">4</div>
<div class="step-label">查看详情</div> <div class="step-label">查看详情</div>
</div> </div>
<div class="arrow"></div>
<div class="step" id="step5">
<div class="step-number">5</div>
<div class="step-label">检查点</div>
</div>
</div> </div>
<div class="content-area"> <div class="content-area">
@ -469,7 +727,12 @@
</h2> </h2>
<div class="breadcrumb" id="breadcrumb"></div> <div class="breadcrumb" id="breadcrumb"></div>
<div class="selection-area"> <div class="selection-area">
<div id="navList" class="item-list"></div> <div id="navList" class="item-list">
<li onclick="quickJump(5)" class="checkpoint-nav-item">
<span class="item-name">🔒 检查点管理</span>
<span class="item-count">备份/恢复</span>
</li>
</div>
</div> </div>
</div> </div>
@ -487,20 +750,40 @@
</div> </div>
</div> </div>
<!-- 危险操作确认模态框 -->
<div class="modal" id="dangerModal">
<div class="modal-content">
<div class="modal-header">
<div class="warning-icon">⚠️</div>
<h3 id="dangerModalTitle">危险操作确认</h3>
</div>
<div class="modal-body">
<p id="dangerModalMessage"></p>
<p class="danger-text" id="dangerModalWarning"></p>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeDangerModal()">取消</button>
<button class="btn btn-danger" id="dangerModalConfirmBtn" onclick="confirmDangerOperation()">确认执行</button>
</div>
</div>
</div>
<script> <script>
// 导航状态管理 // 导航状态管理
let currentStep = 1; // 1=区域, 2=主题, 3=许可, 4=详情 let currentStep = 1; // 1=区域, 2=主题, 3=许可, 4=详情, 5=检查点
let historyStack = []; // 历史记录栈 let historyStack = []; // 历史记录栈
let currentRegion = null; let currentRegion = null;
let currentTheme = null; let currentTheme = null;
let currentPermit = null; let currentPermit = null;
let pendingDangerOperation = null; // 待执行的危险操作
// 步骤配置 // 步骤配置
const steps = { const steps = {
1: { title: '选择区域', loadData: loadRegions }, 1: { title: '选择区域', loadData: loadRegions },
2: { title: '选择主题', loadData: (region) => loadThemes(region.id, region.name) }, 2: { title: '选择主题', loadData: (region) => loadThemes(region.id, region.name) },
3: { title: '选择许可', loadData: (theme) => loadPermits(theme.id, theme.name) }, 3: { title: '选择许可', loadData: (theme) => loadPermits(theme.id, theme.name) },
4: { title: '许可详情', loadData: null } 4: { title: '许可详情', loadData: null },
5: { title: '检查点管理', loadData: loadCheckpoints }
}; };
// 加载地区列表 // 加载地区列表
@ -685,6 +968,8 @@
await loadPermits(currentTheme.id, currentTheme.name); await loadPermits(currentTheme.id, currentTheme.name);
} else if (step === 4) { } else if (step === 4) {
await showPermitDetails(); await showPermitDetails();
} else if (step === 5) {
await loadCheckpoints();
} }
} }
@ -877,7 +1162,7 @@
// 更新步骤指示器 // 更新步骤指示器
function updateStepIndicator(step) { function updateStepIndicator(step) {
for (let i = 1; i <= 4; i++) { for (let i = 1; i <= 5; i++) {
const stepElement = document.getElementById(`step${i}`); const stepElement = document.getElementById(`step${i}`);
if (i <= step) { if (i <= step) {
stepElement.classList.add('active'); stepElement.classList.add('active');
@ -887,6 +1172,213 @@
} }
} }
// ================ 检查点管理功能 ================
// 加载检查点列表
async function loadCheckpoints() {
const detailsArea = document.getElementById('detailsArea');
detailsArea.innerHTML = '<div class="loading"></div>加载检查点列表...';
try {
const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints');
const data = await response.json();
if (data.success) {
renderCheckpointManager(data.data.checkpoints);
} else {
detailsArea.innerHTML = `<div class="error">加载检查点失败:${data.message}</div>`;
}
} catch (error) {
detailsArea.innerHTML = `<div class="error">网络错误:${error.message}</div>`;
}
}
// 渲染检查点管理界面
function renderCheckpointManager(checkpoints) {
const detailsArea = document.getElementById('detailsArea');
let html = '<div class="details-content">';
// 创建检查点表单
html += `
<div class="checkpoint-section">
<h3>创建新检查点</h3>
<div class="checkpoint-form">
<div class="form-group">
<label for="checkpointDescription">检查点描述(可选):</label>
<input type="text" id="checkpointDescription" placeholder="例如:修改风险提示前的备份">
</div>
<button class="btn btn-primary" onclick="createCheckpoint()">
<span>📸</span> 创建检查点
</button>
</div>
</div>
`;
// 检查点列表
html += '<div class="checkpoint-section">';
html += '<h3>已有检查点</h3>';
if (checkpoints.length === 0) {
html += '<p style="color: #999; text-align: center; padding: 20px;">暂无检查点</p>';
} else {
html += '<div class="checkpoint-list">';
checkpoints.forEach(cp => {
const formattedTime = formatTimestamp(cp.timestamp);
html += `
<div class="checkpoint-item">
<div class="checkpoint-header">
<span class="checkpoint-id">${cp.checkpoint_id}</span>
<span class="checkpoint-timestamp">${formattedTime}</span>
</div>
${cp.description ? `<div class="checkpoint-description">${cp.description}</div>` : ''}
<div class="checkpoint-stats">
<span>📊 总行数:<strong>${cp.total_rows}</strong></span>
<span>📋 表数:<strong>${Object.keys(cp.table_counts).length}</strong></span>
</div>
<div class="checkpoint-actions">
<button class="btn btn-danger btn-sm" onclick="confirmRestoreCheckpoint('${cp.checkpoint_id}')">
<span>🔄</span> 恢复
</button>
<button class="btn btn-warning btn-sm" onclick="deleteCheckpoint('${cp.checkpoint_id}')">
<span>🗑️</span> 删除
</button>
</div>
</div>
`;
});
html += '</div>';
}
html += '</div>';
html += '</div>';
detailsArea.innerHTML = html;
}
// 创建检查点
async function createCheckpoint() {
const description = document.getElementById('checkpointDescription').value;
const btn = event.target;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<div class="loading"></div>创建中...';
try {
const response = await fetch('/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ description })
});
const data = await response.json();
if (data.success) {
alert('检查点创建成功!');
document.getElementById('checkpointDescription').value = '';
await loadCheckpoints();
} else {
alert(`创建失败:${data.message}`);
}
} catch (error) {
alert(`网络错误:${error.message}`);
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
// 确认恢复检查点
function confirmRestoreCheckpoint(checkpointId) {
showDangerModal(
'恢复检查点',
`您确定要恢复检查点 "${checkpointId}" 吗?`,
'此操作将覆盖当前数据库中的所有数据,且无法撤销!请确保您已经创建了新的检查点作为备份。',
() => restoreCheckpoint(checkpointId)
);
}
// 恢复检查点
async function restoreCheckpoint(checkpointId) {
closeDangerModal();
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints/${checkpointId}/restore`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
alert(`检查点恢复成功!\n恢复了 ${data.data.total_rows_restored} 行数据,覆盖了 ${data.data.tables_restored} 个表。`);
await loadCheckpoints();
} else {
alert(`恢复失败:${data.message}`);
}
} catch (error) {
alert(`网络错误:${error.message}`);
}
}
// 删除检查点
async function deleteCheckpoint(checkpointId) {
if (!confirm(`确定要删除检查点 "${checkpointId}" 吗?此操作无法撤销。`)) {
return;
}
try {
const response = await fetch(`/fs-ai-asistant/api/workflow/lawrisk/admin/checkpoints/${checkpointId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
alert('检查点已删除');
await loadCheckpoints();
} else {
alert(`删除失败:${data.message}`);
}
} catch (error) {
alert(`网络错误:${error.message}`);
}
}
// 格式化时间戳
function formatTimestamp(timestamp) {
if (!timestamp) return '';
const year = timestamp.substring(0, 4);
const month = timestamp.substring(4, 6);
const day = timestamp.substring(6, 8);
const hour = timestamp.substring(9, 11);
const minute = timestamp.substring(11, 13);
const second = timestamp.substring(13, 15);
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
// ================ 危险操作确认模态框 ================
// 显示危险操作确认模态框
function showDangerModal(title, message, warning, callback) {
pendingDangerOperation = callback;
document.getElementById('dangerModalTitle').textContent = title;
document.getElementById('dangerModalMessage').textContent = message;
document.getElementById('dangerModalWarning').textContent = warning;
document.getElementById('dangerModal').classList.add('show');
}
// 关闭危险操作确认模态框
function closeDangerModal() {
document.getElementById('dangerModal').classList.remove('show');
pendingDangerOperation = null;
}
// 确认执行危险操作
function confirmDangerOperation() {
if (pendingDangerOperation && typeof pendingDangerOperation === 'function') {
pendingDangerOperation();
}
closeDangerModal();
}
// 页面加载时初始化 // 页面加载时初始化
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
goToStep(1); goToStep(1);