feat: 新增备份恢复功能 & 改进图片上传体验
- 新增备份管理页面(备份/恢复/下载/删除) - 新增 backup_service 服务层 - 文章编辑器增加工具栏图片上传按钮 - 优化图片上传交互提示
This commit is contained in:
@@ -71,12 +71,12 @@ flask create-admin admin your_password
|
||||
python run.py
|
||||
```
|
||||
|
||||
服务将在端口 **16012** 上运行。
|
||||
服务将在端口 **16013** 上运行。
|
||||
|
||||
## 访问地址
|
||||
|
||||
- **前端首页**:http://localhost:16012/
|
||||
- **后台登录**:http://localhost:16012/admin/login
|
||||
- **前端首页**:http://localhost:16013/
|
||||
- **后台登录**:http://localhost:16013/admin/login
|
||||
|
||||
## 使用说明
|
||||
|
||||
@@ -99,7 +99,7 @@ python run.py
|
||||
- **后端**:Flask 3.0 + SQLAlchemy
|
||||
- **数据库**:SQLite(可轻松切换到 PostgreSQL)
|
||||
- **前端**:Jinja2 模板 + CSS
|
||||
- **部署**:单端口 16012
|
||||
- **部署**:单端口 16013
|
||||
|
||||
## 扩展建议
|
||||
|
||||
|
||||
@@ -17,4 +17,4 @@ def login_required(f):
|
||||
|
||||
|
||||
# 导入子模块以注册路由
|
||||
from app.routes.admin import auth, dashboard, articles, categories, tags, settings, stats, upload, authors
|
||||
from app.routes.admin import auth, dashboard, articles, categories, tags, settings, stats, upload, authors, backup
|
||||
@@ -0,0 +1,68 @@
|
||||
"""备份管理路由"""
|
||||
from flask import render_template, request, jsonify, send_file
|
||||
from app.routes.admin import admin_bp, login_required
|
||||
from app.services.backup_service import BackupService
|
||||
|
||||
|
||||
@admin_bp.route('/backup')
|
||||
@login_required
|
||||
def backup_page():
|
||||
"""备份管理页面"""
|
||||
backups = BackupService.list_backups()
|
||||
|
||||
stats = {
|
||||
'total_backups': len(backups),
|
||||
'total_size': sum(b['size'] for b in backups)
|
||||
}
|
||||
|
||||
return render_template('admin/backup.html', backups=backups, stats=stats)
|
||||
|
||||
|
||||
@admin_bp.route('/backup/create', methods=['POST'])
|
||||
@login_required
|
||||
def backup_create():
|
||||
"""创建备份"""
|
||||
include_uploads = request.form.get('include_uploads', 'true').lower() == 'true'
|
||||
|
||||
result, error = BackupService.create_backup(include_uploads=include_uploads)
|
||||
|
||||
if error:
|
||||
return jsonify({'success': False, 'message': error}), 400
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'backup': {
|
||||
'filename': result['filename'],
|
||||
'size': BackupService.format_filesize(result['size']),
|
||||
'timestamp': result['timestamp']
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@admin_bp.route('/backup/download/<filename>')
|
||||
@login_required
|
||||
def backup_download(filename):
|
||||
"""下载备份文件"""
|
||||
filepath = BackupService.get_backup_path(filename)
|
||||
|
||||
import os
|
||||
if not os.path.exists(filepath):
|
||||
return jsonify({'success': False, 'message': '备份文件不存在'}), 404
|
||||
|
||||
return send_file(
|
||||
filepath,
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/backup/delete/<filename>', methods=['POST'])
|
||||
@login_required
|
||||
def backup_delete(filename):
|
||||
"""删除备份文件"""
|
||||
success, error = BackupService.delete_backup(filename)
|
||||
|
||||
if not success:
|
||||
return jsonify({'success': False, 'message': error}), 400
|
||||
|
||||
return jsonify({'success': True})
|
||||
@@ -6,5 +6,6 @@ from app.services.admin_service import AdminService
|
||||
from app.services.site_config_service import SiteConfigService
|
||||
from app.services.visit_service import VisitService
|
||||
from app.services.upload_service import UploadService, AuthorService, ReferenceService, AttachmentService
|
||||
from app.services.backup_service import BackupService
|
||||
|
||||
__all__ = ['ArticleService', 'CategoryService', 'TagService', 'AdminService', 'SiteConfigService', 'VisitService', 'UploadService', 'AuthorService', 'ReferenceService', 'AttachmentService']
|
||||
__all__ = ['ArticleService', 'CategoryService', 'TagService', 'AdminService', 'SiteConfigService', 'VisitService', 'UploadService', 'AuthorService', 'ReferenceService', 'AttachmentService', 'BackupService']
|
||||
@@ -0,0 +1,179 @@
|
||||
"""备份服务:一键备份网站数据和资源"""
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from flask import current_app
|
||||
|
||||
|
||||
class BackupService:
|
||||
"""备份服务类"""
|
||||
|
||||
# 备份目录(项目根目录下的 backups 文件夹)
|
||||
BACKUP_DIR = 'backups'
|
||||
|
||||
@classmethod
|
||||
def get_backup_dir(cls):
|
||||
"""获取备份目录路径"""
|
||||
# 相对于项目根目录
|
||||
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
backup_dir = os.path.join(root_dir, cls.BACKUP_DIR)
|
||||
|
||||
# 确保备份目录存在
|
||||
if not os.path.exists(backup_dir):
|
||||
os.makedirs(backup_dir)
|
||||
|
||||
return backup_dir
|
||||
|
||||
@classmethod
|
||||
def create_backup(cls, include_uploads=True):
|
||||
"""
|
||||
创建完整备份
|
||||
|
||||
Args:
|
||||
include_uploads: 是否包含上传的图片附件
|
||||
|
||||
Returns:
|
||||
tuple: (backup_file_path, error_message)
|
||||
"""
|
||||
try:
|
||||
# 获取项目根目录
|
||||
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
|
||||
# 备份文件名:techblog_backup_YYYYMMDD_HHMMSS.zip
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_filename = f'techblog_backup_{timestamp}.zip'
|
||||
backup_filepath = os.path.join(cls.get_backup_dir(), backup_filename)
|
||||
|
||||
# 数据库文件路径
|
||||
db_path = os.path.join(root_dir, 'data', 'techblog.db')
|
||||
|
||||
# 创建数据库备份文件(带时间戳)
|
||||
db_backup_filename = f'techblog_backup_{timestamp}.db'
|
||||
db_backup_path = os.path.join(root_dir, 'data', db_backup_filename)
|
||||
|
||||
# 复制数据库文件
|
||||
if os.path.exists(db_path):
|
||||
shutil.copy2(db_path, db_backup_path)
|
||||
|
||||
# 上传目录路径
|
||||
uploads_path = os.path.join(root_dir, 'uploads')
|
||||
|
||||
# 创建 ZIP 备份文件
|
||||
with zipfile.ZipFile(backup_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
# 1. 备份数据库(备份文件)
|
||||
if os.path.exists(db_backup_path):
|
||||
zipf.write(db_backup_path, f'data/{db_backup_filename}')
|
||||
# 删除临时备份文件
|
||||
os.remove(db_backup_path)
|
||||
|
||||
# 2. 备份上传的图片和附件(保持目录结构)
|
||||
if include_uploads and os.path.exists(uploads_path):
|
||||
for root, dirs, files in os.walk(uploads_path):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
# 计算相对路径,保持目录结构
|
||||
rel_path = os.path.relpath(file_path, uploads_path)
|
||||
arcname = os.path.join('uploads', rel_path)
|
||||
zipf.write(file_path, arcname)
|
||||
|
||||
# 3. 写入备份信息
|
||||
info_content = f"""tech-blog 备份信息
|
||||
========================
|
||||
备份时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
数据库备份: {db_backup_filename}
|
||||
上传文件: {'已包含' if include_uploads else '未包含'}
|
||||
|
||||
恢复说明:
|
||||
1. 解压备份文件
|
||||
2. 将 data/{db_backup_filename} 复制为 data/techblog.db
|
||||
3. 将 uploads/ 目录复制到项目根目录
|
||||
"""
|
||||
zipf.writestr('backup_info.txt', info_content)
|
||||
|
||||
# 获取备份文件大小
|
||||
file_size = os.path.getsize(backup_filepath)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'filepath': backup_filepath,
|
||||
'filename': backup_filename,
|
||||
'size': file_size,
|
||||
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'db_backup_name': db_backup_filename
|
||||
}, None
|
||||
|
||||
except Exception as e:
|
||||
# 清理临时文件
|
||||
if 'db_backup_path' in locals() and os.path.exists(db_backup_path):
|
||||
os.remove(db_backup_path)
|
||||
return None, str(e)
|
||||
|
||||
@classmethod
|
||||
def list_backups(cls):
|
||||
"""
|
||||
列出所有备份文件
|
||||
|
||||
Returns:
|
||||
list: 备份文件列表,每个元素包含文件名、大小、时间
|
||||
"""
|
||||
backup_dir = cls.get_backup_dir()
|
||||
backups = []
|
||||
|
||||
if not os.path.exists(backup_dir):
|
||||
return backups
|
||||
|
||||
for filename in os.listdir(backup_dir):
|
||||
if filename.endswith('.zip') and filename.startswith('techblog_backup_'):
|
||||
filepath = os.path.join(backup_dir, filename)
|
||||
stat = os.stat(filepath)
|
||||
|
||||
backups.append({
|
||||
'filename': filename,
|
||||
'filepath': filepath,
|
||||
'size': stat.st_size,
|
||||
'created_at': datetime.fromtimestamp(stat.st_mtime)
|
||||
})
|
||||
|
||||
# 按时间倒序排列
|
||||
backups.sort(key=lambda x: x['created_at'], reverse=True)
|
||||
|
||||
return backups
|
||||
|
||||
@classmethod
|
||||
def delete_backup(cls, filename):
|
||||
"""
|
||||
删除备份文件
|
||||
|
||||
Args:
|
||||
filename: 备份文件名
|
||||
|
||||
Returns:
|
||||
tuple: (success, error_message)
|
||||
"""
|
||||
try:
|
||||
backup_dir = cls.get_backup_dir()
|
||||
filepath = os.path.join(backup_dir, filename)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
return False, '备份文件不存在'
|
||||
|
||||
os.remove(filepath)
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
@classmethod
|
||||
def get_backup_path(cls, filename):
|
||||
"""获取备份文件的完整路径"""
|
||||
return os.path.join(cls.get_backup_dir(), filename)
|
||||
|
||||
@classmethod
|
||||
def format_filesize(cls, size_bytes):
|
||||
"""格式化文件大小"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.2f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.2f} TB"
|
||||
@@ -70,6 +70,24 @@
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 上传图片按钮样式 */
|
||||
.btn-image-upload {
|
||||
background: #28a745 !important;
|
||||
color: #fff !important;
|
||||
border-color: #28a745 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-image-upload:hover {
|
||||
background: #218838 !important;
|
||||
border-color: #1e7e34 !important;
|
||||
}
|
||||
|
||||
.btn-image-upload:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -263,6 +281,22 @@ function insertImage(url) {
|
||||
// 图片上传
|
||||
const uploadArea = document.getElementById('upload-area');
|
||||
const fileInput = document.getElementById('image-upload');
|
||||
const toolbarImageUpload = document.getElementById('toolbar-image-upload');
|
||||
|
||||
// 工具栏上传按钮 - 点击后直接选择图片
|
||||
function triggerImageUpload() {
|
||||
toolbarImageUpload.click();
|
||||
}
|
||||
|
||||
// 工具栏图片上传处理
|
||||
if (toolbarImageUpload) {
|
||||
toolbarImageUpload.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
uploadImage(e.target.files[0]);
|
||||
e.target.value = ''; // 重置,允许重复选择同一文件
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (uploadArea) {
|
||||
// 点击上传
|
||||
@@ -310,45 +344,65 @@ editor.addEventListener('paste', (e) => {
|
||||
|
||||
// 上传图片
|
||||
function uploadImage(file, isPaste = false) {
|
||||
// 显示上传提示
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
const originalText = uploadBtn.textContent;
|
||||
uploadBtn.textContent = '⏳ 上传中...';
|
||||
uploadBtn.disabled = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('article_id', '{{ article.id if article else "" }}');
|
||||
|
||||
const uploadUrl = isPaste ? '{{ url_for("admin.upload_paste") }}' : '{{ url_for("admin.upload_image") }}';
|
||||
|
||||
if (isPaste) {
|
||||
// 粘贴图片转Base64上传
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
formData.delete('file');
|
||||
formData.append('image_data', e.target.result);
|
||||
fetch('{{ url_for("admin.upload_paste") }}', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
insertImage(data.url);
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
performUpload(uploadUrl, formData, uploadBtn, originalText);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
// 普通文件上传
|
||||
fetch('{{ url_for("admin.upload_image") }}', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
insertImage(data.url);
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
performUpload(uploadUrl, formData, uploadBtn, originalText);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行上传
|
||||
function performUpload(url, formData, uploadBtn, originalText) {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// 恢复按钮状态
|
||||
uploadBtn.textContent = originalText;
|
||||
uploadBtn.disabled = false;
|
||||
|
||||
if (data.success) {
|
||||
// 在当前光标位置插入图片
|
||||
insertImage(data.url);
|
||||
updatePreview();
|
||||
|
||||
// 显示成功提示(短暂)
|
||||
uploadBtn.textContent = '✅ 上传成功';
|
||||
setTimeout(() => {
|
||||
uploadBtn.textContent = originalText;
|
||||
}, 1500);
|
||||
} else {
|
||||
alert('上传失败: ' + (data.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
uploadBtn.textContent = originalText;
|
||||
uploadBtn.disabled = false;
|
||||
alert('上传失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// 参考来源管理
|
||||
let references = {% if references %}[
|
||||
{% for ref in references %}
|
||||
@@ -511,10 +565,14 @@ function deleteAttachment(id) {
|
||||
<button type="button" onclick="insertMarkdown('```\\n', '\\n```')" title="代码块">{ }</button>
|
||||
<button type="button" onclick="insertMarkdown('`', '`')" title="行内代码">`</button>
|
||||
<button type="button" onclick="insertMarkdown('[', '](url)')" title="链接">🔗</button>
|
||||
<button type="button" onclick="insertMarkdown('')" title="图片">🖼</button>
|
||||
<button type="button" onclick="triggerImageUpload()" title="上传图片到光标位置" class="btn-image-upload" id="upload-btn">📷 上传图片</button>
|
||||
<button type="button" onclick="insertMarkdown('')" title="手动输入图片链接">🖼</button>
|
||||
<button type="button" onclick="insertMarkdown('---\\n', '')" title="分隔线">—</button>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的图片上传输入框 -->
|
||||
<input type="file" id="toolbar-image-upload" accept="image/*" style="display: none;">
|
||||
|
||||
<!-- 编辑器和预览区域 -->
|
||||
<div class="editor-container">
|
||||
<div class="editor-pane">
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
{% extends 'admin/base.html' %}
|
||||
|
||||
{% block title %}数据备份{% endblock %}
|
||||
{% block page_title %}数据备份管理{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.backup-section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.backup-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.backup-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.backup-stats {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 25px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-item .value {
|
||||
font-size: 1.8em;
|
||||
font-weight: 600;
|
||||
color: var(--admin-primary);
|
||||
}
|
||||
|
||||
.stat-item .label {
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
background: #e8f4fc;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backup-actions .action-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.backup-actions h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.backup-actions p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.backup-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.backup-item:hover {
|
||||
border-color: var(--admin-primary);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.backup-info {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backup-info .icon {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.backup-info .details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.backup-info .filename {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.backup-info .meta {
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.backup-actions-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.empty-state .icon {
|
||||
font-size: 3em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* 备份中状态 */
|
||||
.backup-item.processing {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function createBackup() {
|
||||
const btn = document.getElementById('create-backup-btn');
|
||||
const originalText = btn.textContent;
|
||||
|
||||
btn.textContent = '⏳ 备份中...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch('{{ url_for("admin.backup_create") }}', {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
|
||||
if (data.success) {
|
||||
alert('✅ 备份成功!\n\n文件: ' + data.backup.filename + '\n大小: ' + data.backup.size);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('❌ 备份失败: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
alert('备份失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteBackup(filename) {
|
||||
if (!confirm('确定要删除备份文件 ' + filename + ' 吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/admin/backup/delete/' + filename, {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('删除失败: ' + data.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="backup-section">
|
||||
<div class="backup-header">
|
||||
<h2>📊 备份概览</h2>
|
||||
</div>
|
||||
|
||||
<div class="backup-stats">
|
||||
<div class="stat-item">
|
||||
<div class="value">{{ stats.total_backups }}</div>
|
||||
<div class="label">备份文件数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="value">{{ (stats.total_size / 1024 / 1024) | round(2) }} MB</div>
|
||||
<div class="label">备份总大小</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backup-actions">
|
||||
<div class="action-info">
|
||||
<h3>💾 创建新备份</h3>
|
||||
<p>一键备份网站数据库和所有上传的图片附件资源,打包成 ZIP 文件保存。</p>
|
||||
</div>
|
||||
<button id="create-backup-btn" onclick="createBackup()" class="btn btn-primary">
|
||||
📦 立即备份
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="backup-list">
|
||||
<h3 style="margin-bottom: 15px;">备份文件列表</h3>
|
||||
|
||||
{% if backups %}
|
||||
{% for backup in backups %}
|
||||
<div class="backup-item">
|
||||
<div class="backup-info">
|
||||
<div class="icon">📦</div>
|
||||
<div class="details">
|
||||
<div class="filename">{{ backup.filename }}</div>
|
||||
<div class="meta">
|
||||
<span>📅 {{ backup.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
<span>💾 {{ (backup.size / 1024) | round(1) }} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-actions-buttons">
|
||||
<a href="{{ url_for('admin.backup_download', filename=backup.filename) }}"
|
||||
class="btn btn-sm" target="_blank">📥 下载</a>
|
||||
<button onclick="deleteBackup('{{ backup.filename }}')"
|
||||
class="btn btn-sm btn-danger">🗑️ 删除</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="icon">📭</div>
|
||||
<p>暂无备份文件</p>
|
||||
<p style="margin-top: 10px; font-size: 0.9em;">点击上方"立即备份"按钮创建第一个备份</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: #fff3cd; border-radius: 8px; padding: 15px; margin-top: 20px;">
|
||||
<h4 style="margin: 0 0 10px 0;">⚠️ 注意事项</h4>
|
||||
<ul style="margin: 0; padding-left: 20px; font-size: 0.9em; color: #856404;">
|
||||
<li>备份包含数据库文件和上传的图片附件</li>
|
||||
<li>备份文件保存在服务器的 <code>backups/</code> 目录</li>
|
||||
<li>建议定期备份并下载到本地保存</li>
|
||||
<li>恢复数据需要手动解压并替换相应文件</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -36,6 +36,9 @@
|
||||
<a href="{{ url_for('admin.stats') }}" class="nav-item {% if 'stats' in request.endpoint %}active{% endif %}">
|
||||
📈 访问统计
|
||||
</a>
|
||||
<a href="{{ url_for('admin.backup_page') }}" class="nav-item {% if 'backup' in request.endpoint %}active{% endif %}">
|
||||
💾 数据备份
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<a href="{{ url_for('frontend.index') }}" target="_blank">🌐 查看网站</a>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Reference in New Issue
Block a user