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:
parent
506ea5ce98
commit
9530eabac8
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue