feat: 新增备份恢复功能 & 改进图片上传体验

- 新增备份管理页面(备份/恢复/下载/删除)
- 新增 backup_service 服务层
- 文章编辑器增加工具栏图片上传按钮
- 优化图片上传交互提示
This commit is contained in:
2026-07-16 16:38:38 +08:00
parent 503a9780b2
commit df2f0860b5
9 changed files with 614 additions and 30 deletions
+4 -4
View File
@@ -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
## 扩展建议
+1 -1
View File
@@ -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
+68
View File
@@ -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})
+2 -1
View File
@@ -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']
+179
View File
@@ -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"
+73 -15
View File
@@ -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,43 +344,63 @@ 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") }}', {
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);
});
}
}
// 参考来源管理
@@ -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('![alt](', ')')" title="图片">🖼</button>
<button type="button" onclick="triggerImageUpload()" title="上传图片到光标位置" class="btn-image-upload" id="upload-btn">📷 上传图片</button>
<button type="button" onclick="insertMarkdown('![alt](', ')')" 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">
+275
View File
@@ -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 %}
+3
View File
@@ -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