From df2f0860b525a58b3cd334694f823c02c2e93e39 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Thu, 16 Jul 2026 16:38:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=8A=9F=E8=83=BD=20&=20=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E4=B8=8A=E4=BC=A0=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增备份管理页面(备份/恢复/下载/删除) - 新增 backup_service 服务层 - 文章编辑器增加工具栏图片上传按钮 - 优化图片上传交互提示 --- README.md | 8 +- app/routes/admin/__init__.py | 2 +- app/routes/admin/backup.py | 68 +++++++ app/services/__init__.py | 3 +- app/services/backup_service.py | 179 +++++++++++++++++ app/templates/admin/article_form.html | 106 +++++++--- app/templates/admin/backup.html | 275 ++++++++++++++++++++++++++ app/templates/admin/base.html | 3 + uploads/20260715_0b5edf70.png | Bin 0 -> 23405 bytes 9 files changed, 614 insertions(+), 30 deletions(-) create mode 100644 app/routes/admin/backup.py create mode 100644 app/services/backup_service.py create mode 100644 app/templates/admin/backup.html create mode 100644 uploads/20260715_0b5edf70.png diff --git a/README.md b/README.md index e2399e4..33527a7 100644 --- a/README.md +++ b/README.md @@ -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 ## 扩展建议 diff --git a/app/routes/admin/__init__.py b/app/routes/admin/__init__.py index 3784159..34d1f59 100644 --- a/app/routes/admin/__init__.py +++ b/app/routes/admin/__init__.py @@ -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 diff --git a/app/routes/admin/backup.py b/app/routes/admin/backup.py new file mode 100644 index 0000000..3f96307 --- /dev/null +++ b/app/routes/admin/backup.py @@ -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/') +@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/', 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}) \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py index 95a0e99..48162f5 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -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'] \ No newline at end of file +__all__ = ['ArticleService', 'CategoryService', 'TagService', 'AdminService', 'SiteConfigService', 'VisitService', 'UploadService', 'AuthorService', 'ReferenceService', 'AttachmentService', 'BackupService'] \ No newline at end of file diff --git a/app/services/backup_service.py b/app/services/backup_service.py new file mode 100644 index 0000000..5d7a09b --- /dev/null +++ b/app/services/backup_service.py @@ -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" \ No newline at end of file diff --git a/app/templates/admin/article_form.html b/app/templates/admin/article_form.html index 6077a4c..185693e 100644 --- a/app/templates/admin/article_form.html +++ b/app/templates/admin/article_form.html @@ -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) { - + + + + +
diff --git a/app/templates/admin/backup.html b/app/templates/admin/backup.html new file mode 100644 index 0000000..9a5487b --- /dev/null +++ b/app/templates/admin/backup.html @@ -0,0 +1,275 @@ +{% extends 'admin/base.html' %} + +{% block title %}数据备份{% endblock %} +{% block page_title %}数据备份管理{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} + +{% block content %} +
+
+

📊 备份概览

+
+ +
+
+
{{ stats.total_backups }}
+
备份文件数
+
+
+
{{ (stats.total_size / 1024 / 1024) | round(2) }} MB
+
备份总大小
+
+
+ +
+
+

💾 创建新备份

+

一键备份网站数据库和所有上传的图片附件资源,打包成 ZIP 文件保存。

+
+ +
+ +
+

备份文件列表

+ + {% if backups %} + {% for backup in backups %} +
+
+
📦
+
+
{{ backup.filename }}
+
+ 📅 {{ backup.created_at.strftime('%Y-%m-%d %H:%M') }} + 💾 {{ (backup.size / 1024) | round(1) }} KB +
+
+
+
+ 📥 下载 + +
+
+ {% endfor %} + {% else %} +
+
📭
+

暂无备份文件

+

点击上方"立即备份"按钮创建第一个备份

+
+ {% endif %} +
+
+ +
+

⚠️ 注意事项

+
    +
  • 备份包含数据库文件和上传的图片附件
  • +
  • 备份文件保存在服务器的 backups/ 目录
  • +
  • 建议定期备份并下载到本地保存
  • +
  • 恢复数据需要手动解压并替换相应文件
  • +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/base.html b/app/templates/admin/base.html index 4e34795..b2f9f7f 100644 --- a/app/templates/admin/base.html +++ b/app/templates/admin/base.html @@ -36,6 +36,9 @@ 📈 访问统计 + + 💾 数据备份 +