commit e689fed71e17e6bb47661ec3d0c9b51fccad0ea2 Author: hz4th_coder Date: Thu Jun 4 23:12:34 2026 +0800 初始化技术博客项目 功能特性: - 前端文章展示 (Markdown渲染、代码高亮) - 后台管理系统 (文章、分类、标签、作者管理) - 图片上传 (点击、拖拽、粘贴) - 网站设置 (Logo、底部宣传图片上传) - 访问统计 (IP记录、趋势图表) - 参考来源、附件支持 - 单端口部署 (16012) 技术栈: - Flask 3.0 + SQLAlchemy - SQLite数据库 - marked.js + highlight.js (Markdown渲染) - Chart.js (统计图表) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07b345b --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +.eggs/ + +# Flask +instance/ +.webassets-cache + +# 数据库 +data/*.db + +# 上传文件(可选,如果想保留上传的图片可以注释掉这行) +# uploads/ + +# 环境变量 +.env + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# 日志 +*.log +logs/ + +# 临时文件 +*.tmp +*.bak \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2399e4 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# 技术博客系统 + +一个基于 Flask 的技术文章展示和管理系统。 + +## 功能特性 + +- **前端展示**:文章列表、文章详情、分类筛选、标签筛选、热门文章 +- **后台管理**:文章管理、分类管理、标签管理、仪表盘统计 +- **架构清晰**:模型、服务、路由分层,便于维护扩展 + +## 项目结构 + +``` +tech-blog/ +├── app/ # 应用核心代码 +│ ├── __init__.py # Flask应用工厂 +│ ├── config.py # 配置文件 +│ ├── models/ # 数据模型 +│ │ ├── article.py # 文章模型 +│ │ ├── category.py # 分类模型 +│ │ ├── tag.py # 标签模型 +│ │ └── admin.py # 管理员模型 +│ ├── routes/ # 路由控制器 +│ │ ├── frontend.py # 前端路由 +│ │ └── admin.py # 后台路由 +│ ├── services/ # 业务服务层 +│ │ ├── article_service.py +│ │ ├── category_service.py +│ │ ├── tag_service.py +│ │ └── admin_service.py +│ ├── templates/ # 模板文件 +│ │ ├── frontend/ # 前端模板 +│ │ └── admin/ # 后台模板 +│ └── static/ # 静态资源 +│ └── css/ +├── migrations/ # 数据库迁移 +├── data/ # 数据库文件目录 +├── tests/ # 测试文件 +├── requirements.txt # 依赖清单 +├── run.py # 启动脚本 +├── .env # 环境变量 +└── README.md +``` + +## 安装部署 + +### 1. 安装依赖 + +```bash +cd works/tech-blog +pip install -r requirements.txt +pip install python-slugify # 额外的slug生成依赖 +``` + +### 2. 初始化数据库 + +```bash +export FLASK_APP=run.py +flask init-db +``` + +### 3. 创建管理员账户 + +```bash +flask create-admin admin your_password +``` + +### 4. 启动服务 + +```bash +python run.py +``` + +服务将在端口 **16012** 上运行。 + +## 访问地址 + +- **前端首页**:http://localhost:16012/ +- **后台登录**:http://localhost:16012/admin/login + +## 使用说明 + +### 后台管理 + +1. 使用创建的管理员账户登录后台 +2. 在分类管理中创建文章分类 +3. 在标签管理中创建标签 +4. 在文章管理中创建和发布文章 + +### 前端展示 + +- 首页显示已发布的文章列表 +- 点击文章标题查看详情 +- 通过分类和标签筛选文章 +- 侧边栏显示热门文章和最新文章 + +## 技术栈 + +- **后端**:Flask 3.0 + SQLAlchemy +- **数据库**:SQLite(可轻松切换到 PostgreSQL) +- **前端**:Jinja2 模板 + CSS +- **部署**:单端口 16012 + +## 扩展建议 + +- 添加 Markdown 渲染支持 +- 添加评论功能 +- 添加搜索功能 +- 添加用户权限管理 +- 添加 API 接口 +- 切换到 PostgreSQL 数据库 \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..ea84d44 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,137 @@ +"""Flask应用工厂""" +from flask import Flask, g, request, send_from_directory +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +import os + +db = SQLAlchemy() +migrate = Migrate() + + +def create_app(config_name='default'): + """创建Flask应用实例""" + from app.config import config_map + + app = Flask(__name__) + app.config.from_object(config_map[config_name]) + + # 初始化扩展 + db.init_app(app) + migrate.init_app(app, db) + + # 确保数据目录和上传目录存在 + data_dir = os.path.join(app.root_path, '..', 'data') + uploads_dir = os.path.join(app.root_path, '..', 'uploads') + os.makedirs(data_dir, exist_ok=True) + os.makedirs(uploads_dir, exist_ok=True) + + # 配置上传目录 + app.config['UPLOAD_FOLDER'] = uploads_dir + + # 注册蓝图 + from app.routes.frontend import frontend_bp + from app.routes.admin import admin_bp + + app.register_blueprint(frontend_bp) + app.register_blueprint(admin_bp, url_prefix='/admin') + + # 注册上传文件路由(在蓝图之前,确保全局可访问) + @app.route('/uploads/') + def serve_upload(filename): + """提供上传文件访问""" + return send_from_directory(uploads_dir, filename) + + # 注册模板过滤器 + register_filters(app) + + # 注册命令 + register_commands(app) + + # 注册请求钩子(访问统计和网站配置) + register_hooks(app) + + return app + + +def register_filters(app): + """注册自定义模板过滤器""" + from datetime import datetime + + @app.template_filter('datetime_format') + def datetime_format(value, format='%Y-%m-%d %H:%M'): + """格式化日期时间""" + if value is None: + return '' + return value.strftime(format) + + @app.template_filter('truncate_content') + def truncate_content(value, length=200): + """截断文章内容用于预览""" + if len(value) <= length: + return value + return value[:length] + '...' + + +def register_commands(app): + """注册自定义命令""" + import click + + @app.cli.command('init-db') + def init_db(): + """初始化数据库""" + db.create_all() + click.echo('数据库初始化完成。') + + @app.cli.command('create-admin') + @click.argument('username') + @click.argument('password') + def create_admin(username, password): + """创建管理员账户""" + from app.models.admin import Admin + from werkzeug.security import generate_password_hash + + admin = Admin.query.filter_by(username=username).first() + if admin: + click.echo(f'管理员 {username} 已存在!') + return + + admin = Admin( + username=username, + password_hash=generate_password_hash(password) + ) + db.session.add(admin) + db.session.commit() + click.echo(f'管理员 {username} 创建成功!') + + +def register_hooks(app): + """注册请求钩子""" + from app.services import SiteConfigService, VisitService + + @app.before_request + def before_request(): + """请求前处理:注入网站配置""" + # 获取网站配置并注入到g对象 + g.site_config = SiteConfigService.get_dict() + + @app.after_request + def after_request(response): + """请求后处理:记录访问日志""" + # 只记录前端请求,不记录静态资源和后台 + if request.path.startswith('/static') or request.path.startswith('/admin'): + return response + + # 只记录成功的请求 + if response.status_code == 200: + # 检查是否是文章详情页 + article_id = None + if hasattr(g, 'current_article_id'): + article_id = g.current_article_id + + # 记录访问 + try: + VisitService.log_visit(request, article_id) + except Exception: + pass # 忽略记录错误,不影响正常响应 + + return response \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..be889b6 --- /dev/null +++ b/app/config.py @@ -0,0 +1,34 @@ +"""应用配置""" +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + + +class Config: + """基础配置""" + SECRET_KEY = os.environ.get('SECRET_KEY') or 'tech-blog-secret-key-change-in-production' + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ + f'sqlite:///{BASE_DIR / "data" / "techblog.db"}' + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # 文章配置 + ARTICLES_PER_PAGE = 10 + ADMIN_ARTICLES_PER_PAGE = 20 + + +class DevelopmentConfig(Config): + """开发环境配置""" + DEBUG = True + + +class ProductionConfig(Config): + """生产环境配置""" + DEBUG = False + + +config_map = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig +} \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..ee375a8 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,10 @@ +"""数据模型模块""" +from app.models.article import Article, article_tags +from app.models.admin import Admin +from app.models.category import Category +from app.models.tag import Tag +from app.models.site_config import SiteConfig +from app.models.visit_log import VisitLog, DailyStats +from app.models.article_ext import ArticleAuthor, Reference, Attachment, UploadedImage + +__all__ = ['Article', 'article_tags', 'Admin', 'Category', 'Tag', 'SiteConfig', 'VisitLog', 'DailyStats', 'ArticleAuthor', 'Reference', 'Attachment', 'UploadedImage'] \ No newline at end of file diff --git a/app/models/admin.py b/app/models/admin.py new file mode 100644 index 0000000..a3d441b --- /dev/null +++ b/app/models/admin.py @@ -0,0 +1,41 @@ +"""管理员模型""" +from datetime import datetime +from app import db +from werkzeug.security import generate_password_hash, check_password_hash + + +class Admin(db.Model): + """管理员账户表""" + __tablename__ = 'admins' + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(50), unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + email = db.Column(db.String(100), unique=True) + is_active = db.Column(db.Boolean, default=True) + last_login = db.Column(db.DateTime) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f'' + + def set_password(self, password): + """设置密码""" + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + """验证密码""" + return check_password_hash(self.password_hash, password) + + def to_dict(self): + """转换为字典(不包含密码)""" + return { + 'id': self.id, + 'username': self.username, + 'email': self.email, + 'is_active': self.is_active, + 'last_login': self.last_login.isoformat() if self.last_login else None, + 'created_at': self.created_at.isoformat() if self.created_at else None + } \ No newline at end of file diff --git a/app/models/article.py b/app/models/article.py new file mode 100644 index 0000000..758e6e4 --- /dev/null +++ b/app/models/article.py @@ -0,0 +1,66 @@ +"""文章模型""" +from datetime import datetime +from app import db + + +class Article(db.Model): + """文章表""" + __tablename__ = 'articles' + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + slug = db.Column(db.String(200), unique=True, nullable=False) + summary = db.Column(db.Text) + content = db.Column(db.Text, nullable=False) + cover_image = db.Column(db.String(500)) + is_published = db.Column(db.Boolean, default=False) + view_count = db.Column(db.Integer, default=0) + + # 作者关联 + author_id = db.Column(db.Integer, db.ForeignKey('article_authors.id')) + author = db.relationship('ArticleAuthor', backref='articles') + + # 分类关联 + category_id = db.Column(db.Integer, db.ForeignKey('categories.id')) + category = db.relationship('Category', back_populates='articles') + + # 标签关联(多对多) + tags = db.relationship('Tag', secondary='article_tags', back_populates='articles') + + # 参考来源和附件(在article_ext.py中定义) + # references = db.relationship('Reference', backref='article') + # attachments = db.relationship('Attachment', backref='article') + + # 时间戳 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + published_at = db.Column(db.DateTime) + + def __repr__(self): + return f'
' + + def to_dict(self): + """转换为字典""" + return { + 'id': self.id, + 'title': self.title, + 'slug': self.slug, + 'summary': self.summary, + 'content': self.content, + 'cover_image': self.cover_image, + 'is_published': self.is_published, + 'view_count': self.view_count, + 'author': self.author.name if self.author else None, + 'category': self.category.name if self.category else None, + 'tags': [tag.name for tag in self.tags], + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + 'published_at': self.published_at.isoformat() if self.published_at else None + } + + +# 文章-标签关联表 +article_tags = db.Table('article_tags', + db.Column('article_id', db.Integer, db.ForeignKey('articles.id'), primary_key=True), + db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True) +) \ No newline at end of file diff --git a/app/models/article_ext.py b/app/models/article_ext.py new file mode 100644 index 0000000..cac04e6 --- /dev/null +++ b/app/models/article_ext.py @@ -0,0 +1,89 @@ +"""文章扩展模型""" +from datetime import datetime +from app import db + + +class ArticleAuthor(db.Model): + """文章作者表""" + __tablename__ = 'article_authors' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + avatar = db.Column(db.String(500)) # 作者头像URL + bio = db.Column(db.String(200)) # 作者简介 + website = db.Column(db.String(500)) # 作者网站/主页 + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def __repr__(self): + return f'' + + +class Reference(db.Model): + """参考来源表""" + __tablename__ = 'references' + + id = db.Column(db.Integer, primary_key=True) + article_id = db.Column(db.Integer, db.ForeignKey('articles.id'), nullable=False) + title = db.Column(db.String(200), nullable=False) # 来源标题 + url = db.Column(db.String(500)) # 来源链接 + description = db.Column(db.String(300)) # 来源描述 + + article = db.relationship('Article', backref=db.backref('references', lazy='dynamic')) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def __repr__(self): + return f'' + + +class Attachment(db.Model): + """附件表""" + __tablename__ = 'attachments' + + id = db.Column(db.Integer, primary_key=True) + article_id = db.Column(db.Integer, db.ForeignKey('articles.id'), nullable=False) + filename = db.Column(db.String(200), nullable=False) # 文件名 + filepath = db.Column(db.String(500), nullable=False) # 文件路径 + filesize = db.Column(db.Integer) # 文件大小(字节) + filetype = db.Column(db.String(50)) # 文件类型 + description = db.Column(db.String(200)) # 文件描述 + + article = db.relationship('Article', backref=db.backref('attachments', lazy='dynamic')) + + upload_time = db.Column(db.DateTime, default=datetime.utcnow) + + def __repr__(self): + return f'' + + def get_filesize_display(self): + """显示友好的文件大小""" + size = self.filesize or 0 + if size < 1024: + return f'{size} B' + elif size < 1024 * 1024: + return f'{size / 1024:.1f} KB' + else: + return f'{size / 1024 / 1024:.1f} MB' + + +class UploadedImage(db.Model): + """上传图片表""" + __tablename__ = 'uploaded_images' + + id = db.Column(db.Integer, primary_key=True) + filename = db.Column(db.String(200), nullable=False) + filepath = db.Column(db.String(500), nullable=False) + article_id = db.Column(db.Integer, db.ForeignKey('articles.id')) # 关联文章(可选) + uploader = db.Column(db.String(50)) # 上传者 + + upload_time = db.Column(db.DateTime, default=datetime.utcnow) + + article = db.relationship('Article', backref=db.backref('uploaded_images', lazy='dynamic')) + + def __repr__(self): + return f'' + + def get_url(self): + """获取图片访问URL""" + return f'/uploads/{self.filename}' \ No newline at end of file diff --git a/app/models/category.py b/app/models/category.py new file mode 100644 index 0000000..1b876fa --- /dev/null +++ b/app/models/category.py @@ -0,0 +1,33 @@ +"""分类模型""" +from datetime import datetime +from app import db + + +class Category(db.Model): + """文章分类表""" + __tablename__ = 'categories' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), unique=True, nullable=False) + slug = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.String(200)) + sort_order = db.Column(db.Integer, default=0) + + # 关联文章 + articles = db.relationship('Article', back_populates='category') + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def __repr__(self): + return f'' + + def to_dict(self): + """转换为字典""" + return { + 'id': self.id, + 'name': self.name, + 'slug': self.slug, + 'description': self.description, + 'sort_order': self.sort_order, + 'article_count': len(self.articles) + } \ No newline at end of file diff --git a/app/models/site_config.py b/app/models/site_config.py new file mode 100644 index 0000000..9e88693 --- /dev/null +++ b/app/models/site_config.py @@ -0,0 +1,59 @@ +"""网站配置模型""" +from datetime import datetime +from app import db + + +class SiteConfig(db.Model): + """网站配置表""" + __tablename__ = 'site_configs' + + id = db.Column(db.Integer, primary_key=True) + + # 基本信息 + site_name = db.Column(db.String(100), default='技术博客') + site_subtitle = db.Column(db.String(200), default='分享技术,记录成长') + site_logo = db.Column(db.String(500)) # Logo图片URL + + # SEO设置 + site_keywords = db.Column(db.String(500)) + site_description = db.Column(db.Text) + + # 底部信息 + footer_text = db.Column(db.String(200), default='© 2024 技术博客') + footer_copyright = db.Column(db.String(200)) + footer_technology = db.Column(db.String(200), default='Powered by Flask') + + # 底部宣传图片 + promo_image_1 = db.Column(db.String(500)) # 宣传图片1 URL + promo_image_1_title = db.Column(db.String(100)) # 图片1标题/说明 + promo_image_1_link = db.Column(db.String(500)) # 图片1点击链接 + promo_image_2 = db.Column(db.String(500)) # 宣传图片2 URL + promo_image_2_title = db.Column(db.String(100)) # 图片2标题/说明 + promo_image_2_link = db.Column(db.String(500)) # 图片2点击链接 + promo_image_3 = db.Column(db.String(500)) # 宣传图片3 URL(如二维码) + promo_image_3_title = db.Column(db.String(100)) # 图片3标题/说明 + + # 联系方式 + contact_email = db.Column(db.String(100)) + contact_github = db.Column(db.String(100)) + + # 其他设置 + articles_per_page = db.Column(db.Integer, default=10) + enable_comments = db.Column(db.Boolean, default=False) + + # 时间戳 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f'' + + @staticmethod + def get_config(): + """获取网站配置(单例)""" + config = SiteConfig.query.first() + if not config: + config = SiteConfig() + db.session.add(config) + db.session.commit() + return config \ No newline at end of file diff --git a/app/models/tag.py b/app/models/tag.py new file mode 100644 index 0000000..40cc134 --- /dev/null +++ b/app/models/tag.py @@ -0,0 +1,32 @@ +"""标签模型""" +from datetime import datetime +from app import db +from app.models.article import article_tags + + +class Tag(db.Model): + """标签表""" + __tablename__ = 'tags' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(30), unique=True, nullable=False) + slug = db.Column(db.String(30), unique=True, nullable=False) + color = db.Column(db.String(7), default='#007bff') # 标签颜色 + + # 关联文章 + articles = db.relationship('Article', secondary=article_tags, back_populates='tags') + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def __repr__(self): + return f'' + + def to_dict(self): + """转换为字典""" + return { + 'id': self.id, + 'name': self.name, + 'slug': self.slug, + 'color': self.color, + 'article_count': len(self.articles) + } \ No newline at end of file diff --git a/app/models/visit_log.py b/app/models/visit_log.py new file mode 100644 index 0000000..27034fc --- /dev/null +++ b/app/models/visit_log.py @@ -0,0 +1,83 @@ +"""访问统计模型""" +from datetime import datetime +from app import db + + +class VisitLog(db.Model): + """访问日志表""" + __tablename__ = 'visit_logs' + + id = db.Column(db.Integer, primary_key=True) + + # 访问信息 + ip_address = db.Column(db.String(50), nullable=False) + user_agent = db.Column(db.String(500)) + referer = db.Column(db.String(500)) + + # 访问路径 + path = db.Column(db.String(500), nullable=False) + method = db.Column(db.String(10), default='GET') + + # 访问者信息(可从IP解析) + country = db.Column(db.String(50)) + city = db.Column(db.String(50)) + + # 文章关联(如果访问的是文章详情页) + article_id = db.Column(db.Integer, db.ForeignKey('articles.id')) + article = db.relationship('Article', backref='visit_logs') + + # 时间戳 + visited_at = db.Column(db.DateTime, default=datetime.utcnow, index=True) + + def __repr__(self): + return f'' + + @staticmethod + def get_today_count(): + """获取今日访问量""" + from datetime import date + today = date.today() + return VisitLog.query.filter( + VisitLog.visited_at >= datetime.combine(today, datetime.min.time()) + ).count() + + @staticmethod + def get_total_count(): + """获取总访问量""" + return VisitLog.query.count() + + @staticmethod + def get_unique_ip_count(): + """获取独立IP数量""" + return db.session.query(db.func.count(db.func.distinct(VisitLog.ip_address))).scalar() + + +class DailyStats(db.Model): + """每日统计汇总表""" + __tablename__ = 'daily_stats' + + id = db.Column(db.Integer, primary_key=True) + date = db.Column(db.Date, unique=True, nullable=False, index=True) + + # 统计数据 + total_visits = db.Column(db.Integer, default=0) + unique_ips = db.Column(db.Integer, default=0) + article_views = db.Column(db.Integer, default=0) + + # 热门文章 + top_article_id = db.Column(db.Integer, db.ForeignKey('articles.id')) + top_article_views = db.Column(db.Integer, default=0) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def __repr__(self): + return f'' + + @staticmethod + def get_stats_list(days=30): + """获取最近N天的统计数据""" + from datetime import date, timedelta + start_date = date.today() - timedelta(days=days) + return DailyStats.query.filter( + DailyStats.date >= start_date + ).order_by(DailyStats.date.desc()).all() \ No newline at end of file diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e2cf145 --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1,5 @@ +"""路由模块""" +from app.routes.frontend import frontend_bp +from app.routes.admin import admin_bp + +__all__ = ['frontend_bp', 'admin_bp'] \ No newline at end of file diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000..dd9edf1 --- /dev/null +++ b/app/routes/admin.py @@ -0,0 +1,673 @@ +"""后台管理路由""" +from flask import Blueprint, render_template, redirect, url_for, request, flash, session, g, jsonify, send_from_directory +from functools import wraps +import os +import json +from app.services import ArticleService, CategoryService, TagService, AdminService, SiteConfigService, VisitService, UploadService, AuthorService, ReferenceService, AttachmentService + +admin_bp = Blueprint('admin', __name__) + + +def login_required(f): + """登录验证装饰器""" + @wraps(f) + def decorated_function(*args, **kwargs): + if 'admin_id' not in session: + flash('请先登录', 'warning') + return redirect(url_for('admin.login')) + return f(*args, **kwargs) + return decorated_function + + +# ==================== 登录登出 ==================== + +@admin_bp.route('/login', methods=['GET', 'POST']) +def login(): + """登录页面""" + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + + admin = AdminService.authenticate(username, password) + if admin: + session['admin_id'] = admin.id + session['admin_username'] = admin.username + flash('登录成功', 'success') + return redirect(url_for('admin.dashboard')) + else: + flash('用户名或密码错误', 'danger') + + return render_template('admin/login.html') + + +@admin_bp.route('/logout') +def logout(): + """登出""" + session.clear() + flash('已退出登录', 'info') + return redirect(url_for('admin.login')) + + +# ==================== 仪表盘 ==================== + +@admin_bp.route('/') +@admin_bp.route('/dashboard') +@login_required +def dashboard(): + """仪表盘""" + # 获取统计数据 + articles = ArticleService.get_all_list(per_page=5) + categories = CategoryService.get_all() + tags = TagService.get_all() + + stats = { + 'total_articles': len(articles.items), + 'published_articles': sum(1 for a in articles.items if a.is_published), + 'total_categories': len(categories), + 'total_tags': len(tags), + 'total_views': sum(a.view_count for a in articles.items) + } + + return render_template('admin/dashboard.html', stats=stats, recent_articles=articles) + + +# ==================== 文章管理 ==================== + +@admin_bp.route('/articles') +@login_required +def articles(): + """文章列表""" + page = request.args.get('page', 1, type=int) + is_published = request.args.get('status') + + if is_published == 'published': + is_published_filter = True + elif is_published == 'draft': + is_published_filter = False + else: + is_published_filter = None + + articles = ArticleService.get_all_list( + page=page, + per_page=20, + is_published=is_published_filter + ) + + categories = CategoryService.get_all() + tags = TagService.get_all() + + return render_template('admin/articles.html', + articles=articles, + categories=categories, + tags=tags, + current_status=is_published) + + +@admin_bp.route('/articles/create', methods=['GET', 'POST']) +@login_required +def article_create(): + """创建文章""" + if request.method == 'POST': + title = request.form.get('title') + content = request.form.get('content') + summary = request.form.get('summary') + author_id = request.form.get('author_id', type=int) + category_id = request.form.get('category_id', type=int) + cover_image = request.form.get('cover_image') + is_published = request.form.get('is_published') == 'on' + tag_ids = request.form.getlist('tags') + tag_ids = [int(t) for t in tag_ids if t] + + # 处理参考来源 + references_json = request.form.get('references', '[]') + references_data = json.loads(references_json) if references_json else [] + + article = ArticleService.create( + title=title, + content=content, + summary=summary, + author_id=author_id, + category_id=category_id, + tag_ids=tag_ids, + cover_image=cover_image, + is_published=is_published + ) + + # 添加参考来源 + for ref in references_data: + if ref.get('title'): + ReferenceService.add(article.id, ref['title'], ref.get('url'), ref.get('description')) + + flash('文章创建成功', 'success') + return redirect(url_for('admin.article_edit', article_id=article.id)) + + categories = CategoryService.get_all() + tags = TagService.get_all() + authors = AuthorService.get_all() + return render_template('admin/article_form.html', + article=None, + categories=categories, + tags=tags, + authors=authors, + references=[], + attachments=[]) + + +@admin_bp.route('/articles//edit', methods=['GET', 'POST']) +@login_required +def article_edit(article_id): + """编辑文章""" + article = ArticleService.get_by_id(article_id) + if not article: + flash('文章不存在', 'danger') + return redirect(url_for('admin.articles')) + + if request.method == 'POST': + title = request.form.get('title') + content = request.form.get('content') + summary = request.form.get('summary') + author_id = request.form.get('author_id', type=int) + category_id = request.form.get('category_id', type=int) + cover_image = request.form.get('cover_image') + is_published = request.form.get('is_published') == 'on' + tag_ids = request.form.getlist('tags') + tag_ids = [int(t) for t in tag_ids if t] + + # 处理参考来源 + references_json = request.form.get('references', '[]') + references_data = json.loads(references_json) if references_json else [] + + ArticleService.update( + article_id, + title=title, + content=content, + summary=summary, + author_id=author_id, + category_id=category_id, + cover_image=cover_image, + is_published=is_published, + tag_ids=tag_ids + ) + + # 更新参考来源 + ReferenceService.update_article_references(article_id, references_data) + + flash('文章更新成功', 'success') + return redirect(url_for('admin.article_edit', article_id=article_id)) + + categories = CategoryService.get_all() + tags = TagService.get_all() + authors = AuthorService.get_all() + references = ReferenceService.get_by_article(article_id) + attachments = AttachmentService.get_by_article(article_id) + return render_template('admin/article_form.html', + article=article, + categories=categories, + tags=tags, + authors=authors, + references=references, + attachments=attachments) + + +@admin_bp.route('/articles//delete', methods=['POST']) +@login_required +def article_delete(article_id): + """删除文章""" + if ArticleService.delete(article_id): + flash('文章已删除', 'success') + else: + flash('删除失败', 'danger') + return redirect(url_for('admin.articles')) + + +@admin_bp.route('/articles//toggle-publish', methods=['POST']) +@login_required +def article_toggle_publish(article_id): + """切换文章发布状态""" + article = ArticleService.get_by_id(article_id) + if article: + ArticleService.update(article_id, is_published=not article.is_published) + status = '发布' if not article.is_published else '取消发布' + flash(f'文章已{status}', 'success') + else: + flash('文章不存在', 'danger') + return redirect(url_for('admin.articles')) + + +# ==================== 分类管理 ==================== + +@admin_bp.route('/categories') +@login_required +def categories(): + """分类列表""" + categories = CategoryService.get_all() + return render_template('admin/categories.html', categories=categories) + + +@admin_bp.route('/categories/create', methods=['GET', 'POST']) +@login_required +def category_create(): + """创建分类""" + if request.method == 'POST': + name = request.form.get('name') + description = request.form.get('description') + sort_order = request.form.get('sort_order', 0, type=int) + + CategoryService.create(name=name, description=description, sort_order=sort_order) + flash('分类创建成功', 'success') + return redirect(url_for('admin.categories')) + + return render_template('admin/category_form.html', category=None) + + +@admin_bp.route('/categories//edit', methods=['GET', 'POST']) +@login_required +def category_edit(category_id): + """编辑分类""" + category = CategoryService.get_by_id(category_id) + if not category: + flash('分类不存在', 'danger') + return redirect(url_for('admin.categories')) + + if request.method == 'POST': + name = request.form.get('name') + description = request.form.get('description') + sort_order = request.form.get('sort_order', 0, type=int) + + CategoryService.update(category_id, name=name, description=description, sort_order=sort_order) + flash('分类更新成功', 'success') + return redirect(url_for('admin.categories')) + + return render_template('admin/category_form.html', category=category) + + +@admin_bp.route('/categories//delete', methods=['POST']) +@login_required +def category_delete(category_id): + """删除分类""" + if CategoryService.delete(category_id): + flash('分类已删除', 'success') + else: + flash('删除失败', 'danger') + return redirect(url_for('admin.categories')) + + +# ==================== 标签管理 ==================== + +@admin_bp.route('/tags') +@login_required +def tags(): + """标签列表""" + tags = TagService.get_all() + return render_template('admin/tags.html', tags=tags) + + +@admin_bp.route('/tags/create', methods=['GET', 'POST']) +@login_required +def tag_create(): + """创建标签""" + if request.method == 'POST': + name = request.form.get('name') + color = request.form.get('color', '#007bff') + + TagService.create(name=name, color=color) + flash('标签创建成功', 'success') + return redirect(url_for('admin.tags')) + + return render_template('admin/tag_form.html', tag=None) + + +@admin_bp.route('/tags//edit', methods=['GET', 'POST']) +@login_required +def tag_edit(tag_id): + """编辑标签""" + tag = TagService.get_by_id(tag_id) + if not tag: + flash('标签不存在', 'danger') + return redirect(url_for('admin.tags')) + + if request.method == 'POST': + name = request.form.get('name') + color = request.form.get('color') + + TagService.update(tag_id, name=name, color=color) + flash('标签更新成功', 'success') + return redirect(url_for('admin.tags')) + + return render_template('admin/tag_form.html', tag=tag) + + +@admin_bp.route('/tags//delete', methods=['POST']) +@login_required +def tag_delete(tag_id): + """删除标签""" + if TagService.delete(tag_id): + flash('标签已删除', 'success') + else: + flash('删除失败', 'danger') + return redirect(url_for('admin.tags')) + + +# ==================== 网站设置 ==================== + +@admin_bp.route('/settings', methods=['GET', 'POST']) +@login_required +def settings(): + """网站设置""" + if request.method == 'POST': + # 处理图片上传 + promo_image_1 = request.form.get('promo_image_1', '') + promo_image_2 = request.form.get('promo_image_2', '') + promo_image_3 = request.form.get('promo_image_3', '') + + # 上传图片1 + if 'promo_image_1_file' in request.files: + file = request.files['promo_image_1_file'] + if file and file.filename: + image, error = UploadService.upload_image(file) + if image: + promo_image_1 = UploadService.get_image_url(image.filename) + + # 上传图片2 + if 'promo_image_2_file' in request.files: + file = request.files['promo_image_2_file'] + if file and file.filename: + image, error = UploadService.upload_image(file) + if image: + promo_image_2 = UploadService.get_image_url(image.filename) + + # 上传图片3 + if 'promo_image_3_file' in request.files: + file = request.files['promo_image_3_file'] + if file and file.filename: + image, error = UploadService.upload_image(file) + if image: + promo_image_3 = UploadService.get_image_url(image.filename) + + # 更新设置 + SiteConfigService.update( + site_name=request.form.get('site_name', '技术博客'), + site_subtitle=request.form.get('site_subtitle', ''), + site_logo=request.form.get('site_logo', ''), + site_keywords=request.form.get('site_keywords', ''), + site_description=request.form.get('site_description', ''), + footer_text=request.form.get('footer_text', ''), + footer_copyright=request.form.get('footer_copyright', ''), + footer_technology=request.form.get('footer_technology', ''), + promo_image_1=promo_image_1, + promo_image_1_title=request.form.get('promo_image_1_title', ''), + promo_image_1_link=request.form.get('promo_image_1_link', ''), + promo_image_2=promo_image_2, + promo_image_2_title=request.form.get('promo_image_2_title', ''), + promo_image_2_link=request.form.get('promo_image_2_link', ''), + promo_image_3=promo_image_3, + promo_image_3_title=request.form.get('promo_image_3_title', ''), + contact_email=request.form.get('contact_email', ''), + contact_github=request.form.get('contact_github', ''), + articles_per_page=request.form.get('articles_per_page', 10, type=int), + ) + flash('网站设置已更新', 'success') + return redirect(url_for('admin.settings')) + + config = SiteConfigService.get_config() + return render_template('admin/settings.html', config=config) + + +# ==================== 访问统计 ==================== + +@admin_bp.route('/stats') +@login_required +def stats(): + """访问统计概览""" + stats_summary = VisitService.get_stats_summary() + top_ips = VisitService.get_top_ips(10) + top_articles = VisitService.get_top_articles(7, 10) + top_paths = VisitService.get_top_paths(20) + + return render_template('admin/stats.html', + stats=stats_summary, + top_ips=top_ips, + top_articles=top_articles, + top_paths=top_paths) + + +@admin_bp.route('/stats/logs') +@login_required +def stats_logs(): + """访问日志列表""" + page = request.args.get('page', 1, type=int) + path_filter = request.args.get('path', '') + + logs = VisitService.get_visit_list(page=page, per_page=50, path_filter=path_filter) + + return render_template('admin/stats_logs.html', + logs=logs, + path_filter=path_filter) + + +@admin_bp.route('/stats/charts') +@login_required +def stats_charts(): + """访问统计图表""" + stats_summary = VisitService.get_stats_summary() + + # 准备图表数据 + daily_data = [] + for stat in stats_summary['daily_stats']: + daily_data.append({ + 'date': stat.date.strftime('%Y-%m-%d'), + 'visits': stat.total_visits, + 'unique_ips': stat.unique_ips, + 'article_views': stat.article_views + }) + + return render_template('admin/stats_charts.html', + daily_data=daily_data, + stats=stats_summary) + + +@admin_bp.route('/stats/clear', methods=['POST']) +@login_required +def stats_clear(): + """清理旧访问日志""" + days = request.form.get('days', 90, type=int) + deleted = VisitService.clear_old_logs(days) + flash(f'已清理 {deleted} 条旧日志记录', 'success') + return redirect(url_for('admin.stats_logs')) + + +# ==================== 图片上传 ==================== + +@admin_bp.route('/upload/image', methods=['POST']) +@login_required +def upload_image(): + """上传图片(支持拖拽、粘贴、选择)""" + from flask import current_app + + article_id = request.form.get('article_id', type=int) + uploader = session.get('admin_username', 'admin') + + # 检查是否有文件上传 + if 'file' not in request.files: + return jsonify({'success': False, 'message': '没有选择文件'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'success': False, 'message': '没有选择文件'}), 400 + + # 上传图片 + image, error = UploadService.upload_image(file, article_id, uploader) + if error: + return jsonify({'success': False, 'message': error}), 400 + + # 返回图片URL + return jsonify({ + 'success': True, + 'url': UploadService.get_image_url(image.filename), + 'filename': image.filename + }) + + +@admin_bp.route('/upload/paste', methods=['POST']) +@login_required +def upload_paste(): + """粘贴图片上传""" + from flask import current_app + import base64 + from io import BytesIO + + article_id = request.form.get('article_id', type=int) + uploader = session.get('admin_username', 'admin') + + # 获取粘贴的图片数据 + image_data = request.form.get('image_data') + if not image_data: + return jsonify({'success': False, 'message': '没有图片数据'}), 400 + + # 解码Base64图片 + try: + # 移除data:image/xxx;base64,前缀 + if 'base64,' in image_data: + image_data = image_data.split('base64,')[1] + + image_bytes = base64.b64decode(image_data) + image_file = BytesIO(image_bytes) + + # 确定图片格式 + format_map = {'png': 'png', 'jpeg': 'jpg', 'jpg': 'jpg', 'gif': 'gif', 'webpo': 'webpo'} + # 从数据头识别格式(简化处理) + ext = 'png' # 默认png + + # 生成文件名 + from datetime import datetime + import uuid + filename = f'{datetime.now().strftime("%Y%m%d")}_{uuid.uuid4().hex[:8]}.{ext}' + + # 保存文件 + upload_folder = UploadService.get_upload_folder() + filepath = os.path.join(upload_folder, filename) + with open(filepath, 'wb') as f: + f.write(image_bytes) + + # 记录到数据库 + from app.models import UploadedImage + image = UploadedImage( + filename=filename, + filepath=filepath, + article_id=article_id, + uploader=uploader + ) + db.session.add(image) + db.session.commit() + + return jsonify({ + 'success': True, + 'url': UploadService.get_image_url(filename), + 'filename': filename + }) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 400 + + +@admin_bp.route('/upload/attachment', methods=['POST']) +@login_required +def upload_attachment(): + """上传附件""" + article_id = request.form.get('article_id', type=int) + description = request.form.get('description', '') + + if 'file' not in request.files: + return jsonify({'success': False, 'message': '没有选择文件'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'success': False, 'message': '没有选择文件'}), 400 + + attachment, error = UploadService.upload_attachment(file, article_id, description) + if error: + return jsonify({'success': False, 'message': error}), 400 + + return jsonify({ + 'success': True, + 'attachment': { + 'id': attachment.id, + 'filename': attachment.filename, + 'filesize': attachment.get_filesize_display(), + 'url': f'/uploads/{attachment.filename}' + } + }) + + +@admin_bp.route('/attachment//delete', methods=['POST']) +@login_required +def delete_attachment(attachment_id): + """删除附件""" + if AttachmentService.delete_attachment(attachment_id): + return jsonify({'success': True}) + return jsonify({'success': False, 'message': '删除失败'}), 400 + + +# ==================== 作者管理 ==================== + +@admin_bp.route('/authors') +@login_required +def authors(): + """作者列表""" + authors = AuthorService.get_all() + return render_template('admin/authors.html', authors=authors) + + +@admin_bp.route('/authors/create', methods=['GET', 'POST']) +@login_required +def author_create(): + """创建作者""" + if request.method == 'POST': + name = request.form.get('name') + avatar = request.form.get('avatar') + bio = request.form.get('bio') + website = request.form.get('website') + + AuthorService.create(name=name, avatar=avatar, bio=bio, website=website) + flash('作者创建成功', 'success') + return redirect(url_for('admin.authors')) + + return render_template('admin/author_form.html', author=None) + + +@admin_bp.route('/authors//edit', methods=['GET', 'POST']) +@login_required +def author_edit(author_id): + """编辑作者""" + author = AuthorService.get_by_id(author_id) + if not author: + flash('作者不存在', 'danger') + return redirect(url_for('admin.authors')) + + if request.method == 'POST': + AuthorService.update(author_id, + name=request.form.get('name'), + avatar=request.form.get('avatar'), + bio=request.form.get('bio'), + website=request.form.get('website') + ) + flash('作者更新成功', 'success') + return redirect(url_for('admin.authors')) + + return render_template('admin/author_form.html', author=author) + + +@admin_bp.route('/authors//delete', methods=['POST']) +@login_required +def author_delete(author_id): + """删除作者""" + if AuthorService.delete(author_id): + flash('作者已删除', 'success') + else: + flash('删除失败', 'danger') + return redirect(url_for('admin.authors')) + + +# ==================== 文件访问 ==================== + +# 注意:文件访问路由已在 app/__init__.py 中注册为全局路由 +# 这里不需要重复注册 \ No newline at end of file diff --git a/app/routes/frontend.py b/app/routes/frontend.py new file mode 100644 index 0000000..995aade --- /dev/null +++ b/app/routes/frontend.py @@ -0,0 +1,131 @@ +"""前端路由""" +from flask import Blueprint, render_template, request, abort +from app.services import ArticleService, CategoryService, TagService + +frontend_bp = Blueprint('frontend', __name__) + + +@frontend_bp.route('/') +def index(): + """首页""" + page = request.args.get('page', 1, type=int) + category_id = request.args.get('category', type=int) + tag_id = request.args.get('tag', type=int) + + # 获取文章列表 + articles = ArticleService.get_published_list( + page=page, + category_id=category_id, + tag_id=tag_id + ) + + # 获取侧边栏数据 + categories = CategoryService.get_all() + tags = TagService.get_all() + hot_articles = ArticleService.get_hot_articles() + recent_articles = ArticleService.get_recent_articles() + + return render_template('frontend/index.html', + articles=articles, + categories=categories, + tags=tags, + hot_articles=hot_articles, + recent_articles=recent_articles, + current_category=category_id, + current_tag=tag_id) + + +@frontend_bp.route('/article/') +def article(slug): + """文章详情页""" + from flask import g + + article = ArticleService.get_by_slug(slug) + if not article: + abort(404) + + # 保存文章ID用于访问统计 + g.current_article_id = article.id + + # 获取侧边栏数据 + categories = CategoryService.get_all() + tags = TagService.get_all() + hot_articles = ArticleService.get_hot_articles() + recent_articles = ArticleService.get_recent_articles() + + return render_template('frontend/article.html', + article=article, + categories=categories, + tags=tags, + hot_articles=hot_articles, + recent_articles=recent_articles) + + +@frontend_bp.route('/category/') +def category(category_id): + """分类文章列表""" + category_obj = CategoryService.get_by_id(category_id) + if not category_obj: + abort(404) + + page = request.args.get('page', 1, type=int) + articles = ArticleService.get_published_list( + page=page, + category_id=category_id + ) + + # 获取侧边栏数据 + categories = CategoryService.get_all() + tags = TagService.get_all() + hot_articles = ArticleService.get_hot_articles() + recent_articles = ArticleService.get_recent_articles() + + return render_template('frontend/index.html', + articles=articles, + categories=categories, + tags=tags, + hot_articles=hot_articles, + recent_articles=recent_articles, + current_category=category_id, + category_title=category_obj.name) + + +@frontend_bp.route('/tag/') +def tag(tag_id): + """标签文章列表""" + tag_obj = TagService.get_by_id(tag_id) + if not tag_obj: + abort(404) + + page = request.args.get('page', 1, type=int) + articles = ArticleService.get_published_list( + page=page, + tag_id=tag_id + ) + + # 获取侧边栏数据 + categories = CategoryService.get_all() + tags = TagService.get_all() + hot_articles = ArticleService.get_hot_articles() + recent_articles = ArticleService.get_recent_articles() + + return render_template('frontend/index.html', + articles=articles, + categories=categories, + tags=tags, + hot_articles=hot_articles, + recent_articles=recent_articles, + current_tag=tag_id, + tag_title=tag_obj.name) + + +@frontend_bp.app_errorhandler(404) +def page_not_found(e): + """404页面""" + return render_template('frontend/404.html'), 404 + + +@frontend_bp.app_errorhandler(500) +def internal_error(e): + """500页面""" + return render_template('frontend/500.html'), 500 \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..95a0e99 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,10 @@ +"""服务层模块""" +from app.services.article_service import ArticleService +from app.services.category_service import CategoryService +from app.services.tag_service import TagService +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 + +__all__ = ['ArticleService', 'CategoryService', 'TagService', 'AdminService', 'SiteConfigService', 'VisitService', 'UploadService', 'AuthorService', 'ReferenceService', 'AttachmentService'] \ No newline at end of file diff --git a/app/services/admin_service.py b/app/services/admin_service.py new file mode 100644 index 0000000..b0a4ea9 --- /dev/null +++ b/app/services/admin_service.py @@ -0,0 +1,48 @@ +"""管理员服务""" +from datetime import datetime +from app import db +from app.models import Admin + + +class AdminService: + """管理员业务逻辑""" + + @staticmethod + def authenticate(username, password): + """验证管理员登录""" + admin = Admin.query.filter_by(username=username).first() + if admin and admin.is_active and admin.check_password(password): + # 更新最后登录时间 + admin.last_login = datetime.utcnow() + db.session.commit() + return admin + return None + + @staticmethod + def get_by_id(admin_id): + """通过ID获取管理员""" + return Admin.query.get(admin_id) + + @staticmethod + def get_by_username(username): + """通过用户名获取管理员""" + return Admin.query.filter_by(username=username).first() + + @staticmethod + def create(username, password, email=None): + """创建管理员""" + admin = Admin(username=username, email=email) + admin.set_password(password) + db.session.add(admin) + db.session.commit() + return admin + + @staticmethod + def update_password(admin_id, old_password, new_password): + """更新密码""" + admin = Admin.query.get(admin_id) + if admin and admin.check_password(old_password): + admin.set_password(new_password) + db.session.commit() + return True + return False \ No newline at end of file diff --git a/app/services/article_service.py b/app/services/article_service.py new file mode 100644 index 0000000..395ec85 --- /dev/null +++ b/app/services/article_service.py @@ -0,0 +1,128 @@ +"""文章服务""" +from datetime import datetime +from slugify import slugify +from app import db +from app.models import Article, Category, Tag + + +class ArticleService: + """文章业务逻辑""" + + @staticmethod + def get_published_list(page=1, per_page=10, category_id=None, tag_id=None): + """获取已发布文章列表""" + query = Article.query.filter_by(is_published=True) + + if category_id: + query = query.filter_by(category_id=category_id) + if tag_id: + query = query.join(Article.tags).filter(Tag.id == tag_id) + + return query.order_by(Article.published_at.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + @staticmethod + def get_by_slug(slug): + """通过slug获取文章""" + article = Article.query.filter_by(slug=slug, is_published=True).first() + if article: + # 增加浏览次数 + article.view_count += 1 + db.session.commit() + return article + + @staticmethod + def get_by_id(article_id): + """通过ID获取文章""" + return Article.query.get(article_id) + + @staticmethod + def get_all_list(page=1, per_page=20, is_published=None): + """获取所有文章列表(后台用)""" + query = Article.query + if is_published is not None: + query = query.filter_by(is_published=is_published) + return query.order_by(Article.updated_at.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + @staticmethod + def create(title, content, summary=None, author_id=None, category_id=None, tag_ids=None, + cover_image=None, is_published=False): + """创建文章""" + # 生成唯一slug + slug = slugify(title) + count = Article.query.filter(Article.slug.like(f'{slug}%')).count() + if count > 0: + slug = f'{slug}-{count + 1}' + + article = Article( + title=title, + slug=slug, + content=content, + summary=summary or content[:200] + '...' if len(content) > 200 else content, + author_id=author_id, + category_id=category_id, + cover_image=cover_image, + is_published=is_published, + published_at=datetime.utcnow() if is_published else None + ) + + # 添加标签 + if tag_ids: + tags = Tag.query.filter(Tag.id.in_(tag_ids)).all() + article.tags = tags + + db.session.add(article) + db.session.commit() + return article + + @staticmethod + def update(article_id, **kwargs): + """更新文章""" + article = Article.query.get(article_id) + if not article: + return None + + # 处理发布状态变化 + if 'is_published' in kwargs and kwargs['is_published'] and not article.is_published: + kwargs['published_at'] = datetime.utcnow() + + # 处理标签 + tag_ids = kwargs.pop('tag_ids', None) + if tag_ids is not None: + tags = Tag.query.filter(Tag.id.in_(tag_ids)).all() + article.tags = tags + + # 更新其他字段 + for key, value in kwargs.items(): + if hasattr(article, key): + setattr(article, key, value) + + db.session.commit() + return article + + @staticmethod + def delete(article_id): + """删除文章""" + article = Article.query.get(article_id) + if article: + db.session.delete(article) + db.session.commit() + return True + return False + + @staticmethod + def get_hot_articles(limit=5): + """获取热门文章""" + return Article.query.filter_by(is_published=True)\ + .order_by(Article.view_count.desc())\ + .limit(limit).all() + + @staticmethod + def get_recent_articles(limit=5): + """获取最新文章""" + return Article.query.filter_by(is_published=True)\ + .order_by(Article.published_at.desc())\ + .limit(limit).all() \ No newline at end of file diff --git a/app/services/category_service.py b/app/services/category_service.py new file mode 100644 index 0000000..93e3647 --- /dev/null +++ b/app/services/category_service.py @@ -0,0 +1,62 @@ +"""分类服务""" +from app import db +from app.models import Category + + +class CategoryService: + """分类业务逻辑""" + + @staticmethod + def get_all(): + """获取所有分类""" + return Category.query.order_by(Category.sort_order).all() + + @staticmethod + def get_by_id(category_id): + """通过ID获取分类""" + return Category.query.get(category_id) + + @staticmethod + def get_by_slug(slug): + """通过slug获取分类""" + return Category.query.filter_by(slug=slug).first() + + @staticmethod + def create(name, slug=None, description=None, sort_order=0): + """创建分类""" + from slugify import slugify + slug = slug or slugify(name) + + category = Category( + name=name, + slug=slug, + description=description, + sort_order=sort_order + ) + db.session.add(category) + db.session.commit() + return category + + @staticmethod + def update(category_id, **kwargs): + """更新分类""" + category = Category.query.get(category_id) + if not category: + return None + + for key, value in kwargs.items(): + if hasattr(category, key): + setattr(category, key, value) + + db.session.commit() + return category + + @staticmethod + def delete(category_id): + """删除分类""" + category = Category.query.get(category_id) + if category: + db.session.delete(category) + db.session.commit() + return True + return False \ No newline at end of file diff --git a/app/services/site_config_service.py b/app/services/site_config_service.py new file mode 100644 index 0000000..8dad8ed --- /dev/null +++ b/app/services/site_config_service.py @@ -0,0 +1,50 @@ +"""网站配置服务""" +from app import db +from app.models import SiteConfig + + +class SiteConfigService: + """网站配置业务逻辑""" + + @staticmethod + def get_config(): + """获取网站配置""" + return SiteConfig.get_config() + + @staticmethod + def update(**kwargs): + """更新网站配置""" + config = SiteConfig.get_config() + + for key, value in kwargs.items(): + if hasattr(config, key): + setattr(config, key, value) + + db.session.commit() + return config + + @staticmethod + def get_dict(): + """获取配置字典(用于模板全局变量)""" + config = SiteConfig.get_config() + return { + 'site_name': config.site_name, + 'site_subtitle': config.site_subtitle, + 'site_logo': config.site_logo, + 'site_keywords': config.site_keywords, + 'site_description': config.site_description, + 'footer_text': config.footer_text, + 'footer_copyright': config.footer_copyright, + 'footer_technology': config.footer_technology, + 'promo_image_1': config.promo_image_1, + 'promo_image_1_title': config.promo_image_1_title, + 'promo_image_1_link': config.promo_image_1_link, + 'promo_image_2': config.promo_image_2, + 'promo_image_2_title': config.promo_image_2_title, + 'promo_image_2_link': config.promo_image_2_link, + 'promo_image_3': config.promo_image_3, + 'promo_image_3_title': config.promo_image_3_title, + 'contact_email': config.contact_email, + 'contact_github': config.contact_github, + 'articles_per_page': config.articles_per_page, + } \ No newline at end of file diff --git a/app/services/tag_service.py b/app/services/tag_service.py new file mode 100644 index 0000000..7dbf7ec --- /dev/null +++ b/app/services/tag_service.py @@ -0,0 +1,57 @@ +"""标签服务""" +from app import db +from app.models import Tag + + +class TagService: + """标签业务逻辑""" + + @staticmethod + def get_all(): + """获取所有标签""" + return Tag.query.order_by(Tag.name).all() + + @staticmethod + def get_by_id(tag_id): + """通过ID获取标签""" + return Tag.query.get(tag_id) + + @staticmethod + def get_by_slug(slug): + """通过slug获取标签""" + return Tag.query.filter_by(slug=slug).first() + + @staticmethod + def create(name, slug=None, color='#007bff'): + """创建标签""" + from slugify import slugify + slug = slug or slugify(name) + + tag = Tag(name=name, slug=slug, color=color) + db.session.add(tag) + db.session.commit() + return tag + + @staticmethod + def update(tag_id, **kwargs): + """更新标签""" + tag = Tag.query.get(tag_id) + if not tag: + return None + + for key, value in kwargs.items(): + if hasattr(tag, key): + setattr(tag, key, value) + + db.session.commit() + return tag + + @staticmethod + def delete(tag_id): + """删除标签""" + tag = Tag.query.get(tag_id) + if tag: + db.session.delete(tag) + db.session.commit() + return True + return False \ No newline at end of file diff --git a/app/services/upload_service.py b/app/services/upload_service.py new file mode 100644 index 0000000..c3b53cc --- /dev/null +++ b/app/services/upload_service.py @@ -0,0 +1,255 @@ +"""文章扩展服务""" +import os +import uuid +from datetime import datetime +from werkzeug.utils import secure_filename +from app import db +from app.models import ArticleAuthor, Reference, Attachment, UploadedImage, Article + + +class UploadService: + """文件上传服务""" + + UPLOAD_FOLDER = 'uploads' + ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webpo', 'svg'} + ALLOWED_FILE_EXTENSIONS = {'pdf', 'doc', 'docx', 'xls', 'xlsx', 'zip', 'rar', 'tar', 'gz', 'txt', 'md'} + MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB + + @staticmethod + def get_upload_folder(): + """获取上传目录路径""" + from flask import current_app + upload_folder = os.path.join(current_app.root_path, '..', UploadService.UPLOAD_FOLDER) + os.makedirs(upload_folder, exist_ok=True) + return upload_folder + + @staticmethod + def allowed_image(filename): + """检查是否是允许的图片格式""" + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in UploadService.ALLOWED_IMAGE_EXTENSIONS + + @staticmethod + def allowed_file(filename): + """检查是否是允许的文件格式""" + ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' + return ext in UploadService.ALLOWED_IMAGE_EXTENSIONS or \ + ext in UploadService.ALLOWED_FILE_EXTENSIONS + + @staticmethod + def upload_image(file, article_id=None, uploader='admin'): + """上传图片""" + if not file or not UploadService.allowed_image(file.filename): + return None, '不支持的图片格式' + + # 检查文件大小 + file.seek(0, os.SEEK_END) + size = file.tell() + file.seek(0) + if size > UploadService.MAX_FILE_SIZE: + return None, '文件大小超过限制(最大10MB)' + + # 生成唯一文件名 + ext = file.filename.rsplit('.', 1)[1].lower() + filename = f'{datetime.now().strftime("%Y%m%d")}_{uuid.uuid4().hex[:8]}.{ext}' + + # 保存文件 + filepath = os.path.join(UploadService.get_upload_folder(), filename) + file.save(filepath) + + # 记录到数据库 + image = UploadedImage( + filename=filename, + filepath=filepath, + article_id=article_id, + uploader=uploader + ) + db.session.add(image) + db.session.commit() + + return image, None + + @staticmethod + def upload_attachment(file, article_id, description=None): + """上传附件""" + if not file or not UploadService.allowed_file(file.filename): + return None, '不支持的文件格式' + + # 检查文件大小 + file.seek(0, os.SEEK_END) + size = file.tell() + file.seek(0) + if size > UploadService.MAX_FILE_SIZE: + return None, '文件大小超过限制(最大10MB)' + + # 生成唯一文件名 + original_name = secure_filename(file.filename) + ext = original_name.rsplit('.', 1)[1].lower() if '.' in original_name else '' + filename = f'{datetime.now().strftime("%Y%m%d")}_{uuid.uuid4().hex[:8]}_{original_name}' + + # 保存文件 + filepath = os.path.join(UploadService.get_upload_folder(), filename) + file.save(filepath) + + # 记录到数据库 + attachment = Attachment( + article_id=article_id, + filename=original_name, + filepath=filepath, + filesize=size, + filetype=ext, + description=description + ) + db.session.add(attachment) + db.session.commit() + + return attachment, None + + @staticmethod + def delete_image(image_id): + """删除图片""" + image = UploadedImage.query.get(image_id) + if image: + # 删除文件 + if os.path.exists(image.filepath): + os.remove(image.filepath) + # 删除记录 + db.session.delete(image) + db.session.commit() + return True + return False + + @staticmethod + def delete_attachment(attachment_id): + """删除附件""" + attachment = Attachment.query.get(attachment_id) + if attachment: + # 删除文件 + if os.path.exists(attachment.filepath): + os.remove(attachment.filepath) + # 删除记录 + db.session.delete(attachment) + db.session.commit() + return True + return False + + @staticmethod + def get_image_url(filename): + """获取图片URL(相对路径)""" + return f'/uploads/{filename}' + + @staticmethod + def get_image_url(filename): + """获取图片URL""" + return f'/uploads/{filename}' + + +class AuthorService: + """作者服务""" + + @staticmethod + def get_all(): + """获取所有作者""" + return ArticleAuthor.query.order_by(ArticleAuthor.name).all() + + @staticmethod + def get_by_id(author_id): + """通过ID获取作者""" + return ArticleAuthor.query.get(author_id) + + @staticmethod + def create(name, avatar=None, bio=None, website=None): + """创建作者""" + author = ArticleAuthor(name=name, avatar=avatar, bio=bio, website=website) + db.session.add(author) + db.session.commit() + return author + + @staticmethod + def update(author_id, **kwargs): + """更新作者""" + author = ArticleAuthor.query.get(author_id) + if author: + for key, value in kwargs.items(): + if hasattr(author, key): + setattr(author, key, value) + db.session.commit() + return author + + @staticmethod + def delete(author_id): + """删除作者""" + author = ArticleAuthor.query.get(author_id) + if author: + db.session.delete(author) + db.session.commit() + return True + return False + + +class ReferenceService: + """参考来源服务""" + + @staticmethod + def get_by_article(article_id): + """获取文章的参考来源""" + return Reference.query.filter_by(article_id=article_id).order_by(Reference.created_at).all() + + @staticmethod + def add(article_id, title, url=None, description=None): + """添加参考来源""" + ref = Reference(article_id=article_id, title=title, url=url, description=description) + db.session.add(ref) + db.session.commit() + return ref + + @staticmethod + def update(reference_id, **kwargs): + """更新参考来源""" + ref = Reference.query.get(reference_id) + if ref: + for key, value in kwargs.items(): + if hasattr(ref, key): + setattr(ref, key, value) + db.session.commit() + return ref + + @staticmethod + def delete(reference_id): + """删除参考来源""" + ref = Reference.query.get(reference_id) + if ref: + db.session.delete(ref) + db.session.commit() + return True + return False + + @staticmethod + def update_article_references(article_id, references_data): + """批量更新文章参考来源""" + # 删除旧的参考来源 + Reference.query.filter_by(article_id=article_id).delete() + + # 添加新的参考来源 + for ref_data in references_data: + if ref_data.get('title'): # 只添加有标题的 + ReferenceService.add( + article_id=article_id, + title=ref_data.get('title'), + url=ref_data.get('url'), + description=ref_data.get('description') + ) + + +class AttachmentService: + """附件服务""" + + @staticmethod + def get_by_article(article_id): + """获取文章附件""" + return Attachment.query.filter_by(article_id=article_id).order_by(Attachment.upload_time).all() + + @staticmethod + def delete_attachment(attachment_id): + """删除附件""" + return UploadService.delete_attachment(attachment_id) \ No newline at end of file diff --git a/app/services/visit_service.py b/app/services/visit_service.py new file mode 100644 index 0000000..e706adc --- /dev/null +++ b/app/services/visit_service.py @@ -0,0 +1,162 @@ +"""访问统计服务""" +from datetime import datetime, date, timedelta +from app import db +from app.models import VisitLog, DailyStats, Article + + +class VisitService: + """访问统计业务逻辑""" + + @staticmethod + def log_visit(request, article_id=None): + """记录访问日志""" + from flask import request as flask_request + + visit = VisitLog( + ip_address=VisitService.get_client_ip(flask_request), + user_agent=flask_request.user_agent.string if flask_request.user_agent else None, + referer=flask_request.referrer, + path=flask_request.path, + method=flask_request.method, + article_id=article_id + ) + + db.session.add(visit) + db.session.commit() + + # 更新每日统计 + VisitService.update_daily_stats() + + return visit + + @staticmethod + def get_client_ip(request): + """获取客户端真实IP""" + # 检查代理头 + if request.headers.get('X-Forwarded-For'): + return request.headers.get('X-Forwarded-For').split(',')[0].strip() + elif request.headers.get('X-Real-IP'): + return request.headers.get('X-Real-IP') + else: + return request.remote_addr or '0.0.0.0' + + @staticmethod + def update_daily_stats(): + """更新每日统计""" + today = date.today() + stats = DailyStats.query.filter_by(date=today).first() + + if not stats: + stats = DailyStats(date=today) + db.session.add(stats) + + # 计算今日数据 + today_start = datetime.combine(today, datetime.min.time()) + today_end = datetime.combine(today, datetime.max.time()) + + stats.total_visits = VisitLog.query.filter( + VisitLog.visited_at >= today_start, + VisitLog.visited_at <= today_end + ).count() + + stats.unique_ips = db.session.query( + db.func.count(db.func.distinct(VisitLog.ip_address)) + ).filter( + VisitLog.visited_at >= today_start, + VisitLog.visited_at <= today_end + ).scalar() + + stats.article_views = VisitLog.query.filter( + VisitLog.visited_at >= today_start, + VisitLog.visited_at <= today_end, + VisitLog.article_id.isnot(None) + ).count() + + # 找出今日最热门文章 + top_article_result = db.session.query( + VisitLog.article_id, + db.func.count(VisitLog.id).label('views') + ).filter( + VisitLog.visited_at >= today_start, + VisitLog.visited_at <= today_end, + VisitLog.article_id.isnot(None) + ).group_by(VisitLog.article_id).order_by(db.desc('views')).first() + + if top_article_result: + stats.top_article_id = top_article_result[0] + stats.top_article_views = top_article_result[1] + + db.session.commit() + return stats + + @staticmethod + def get_stats_summary(): + """获取统计摘要""" + return { + 'total_visits': VisitLog.get_total_count(), + 'today_visits': VisitLog.get_today_count(), + 'unique_ips': VisitLog.get_unique_ip_count(), + 'daily_stats': DailyStats.get_stats_list(30) + } + + @staticmethod + def get_visit_list(page=1, per_page=50, path_filter=None): + """获取访问日志列表""" + query = VisitLog.query + + if path_filter: + query = query.filter(VisitLog.path.like(f'%{path_filter}%')) + + return query.order_by(VisitLog.visited_at.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + @staticmethod + def get_top_ips(limit=10): + """获取访问最多的IP""" + from sqlalchemy import func + + result = db.session.query( + VisitLog.ip_address, + func.count(VisitLog.id).label('visit_count'), + func.min(VisitLog.visited_at).label('first_visit'), + func.max(VisitLog.visited_at).label('last_visit') + ).group_by(VisitLog.ip_address).order_by(db.desc('visit_count')).limit(limit).all() + + return result + + @staticmethod + def get_top_articles(days=7, limit=10): + """获取热门文章""" + start_date = date.today() - timedelta(days=days) + start_datetime = datetime.combine(start_date, datetime.min.time()) + + result = db.session.query( + Article, + db.func.count(VisitLog.id).label('views') + ).join(VisitLog).filter( + VisitLog.visited_at >= start_datetime, + VisitLog.article_id.isnot(None) + ).group_by(Article.id).order_by(db.desc('views')).limit(limit).all() + + return result + + @staticmethod + def get_top_paths(limit=20): + """获取访问最多的路径""" + from sqlalchemy import func + + result = db.session.query( + VisitLog.path, + func.count(VisitLog.id).label('visit_count') + ).group_by(VisitLog.path).order_by(db.desc('visit_count')).limit(limit).all() + + return result + + @staticmethod + def clear_old_logs(days=90): + """清理旧日志""" + cutoff_date = datetime.now() - timedelta(days=days) + deleted = VisitLog.query.filter(VisitLog.visited_at < cutoff_date).delete() + db.session.commit() + return deleted \ No newline at end of file diff --git a/app/static/css/admin.css b/app/static/css/admin.css new file mode 100644 index 0000000..08da903 --- /dev/null +++ b/app/static/css/admin.css @@ -0,0 +1,520 @@ +/* ==================== 管理后台样式 ==================== */ +:root { + --admin-primary: #3498db; + --admin-primary-hover: #2980b9; + --admin-secondary: #6c757d; + --admin-success: #28a745; + --admin-warning: #ffc107; + --admin-danger: #dc3545; + --admin-text: #333; + --admin-text-light: #666; + --admin-border: #e0e0e0; + --admin-bg: #f5f5f5; + --admin-card-bg: #fff; + --admin-sidebar: #2c3e50; + --admin-sidebar-hover: #34495e; + --admin-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: var(--admin-text); + background-color: var(--admin-bg); +} + +/* ==================== 布局 ==================== */ +.admin-wrapper { + display: flex; + min-height: 100vh; +} + +/* ==================== 侧边栏 ==================== */ +.sidebar { + width: 250px; + background: var(--admin-sidebar); + color: #fff; + display: flex; + flex-direction: column; + position: fixed; + height: 100vh; + overflow-y: auto; +} + +.sidebar-header { + padding: 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.sidebar-header h2 { + font-size: 1.2rem; +} + +.sidebar-nav { + flex: 1; + padding: 20px 0; +} + +.nav-item { + display: block; + padding: 12px 20px; + color: rgba(255, 255, 255, 0.8); + transition: all 0.3s; +} + +.nav-item:hover { + background: var(--admin-sidebar-hover); + color: #fff; + text-decoration: none; +} + +.nav-item.active { + background: var(--admin-primary); + color: #fff; +} + +.sidebar-footer { + padding: 20px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.sidebar-footer a { + display: block; + padding: 10px 0; + color: rgba(255, 255, 255, 0.7); + transition: color 0.3s; +} + +.sidebar-footer a:hover { + color: #fff; +} + +/* ==================== 主内容区 ==================== */ +.main-content { + flex: 1; + margin-left: 250px; + background: var(--admin-bg); +} + +.content-header { + background: var(--admin-card-bg); + padding: 20px 30px; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: var(--admin-shadow); +} + +.content-header h1 { + font-size: 1.5rem; +} + +.user-info { + color: var(--admin-text-light); +} + +.content-body { + padding: 30px; +} + +/* ==================== 统计卡片 ==================== */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + margin-bottom: 30px; +} + +.stat-card { + background: var(--admin-card-bg); + border-radius: 8px; + padding: 25px; + display: flex; + align-items: center; + gap: 20px; + box-shadow: var(--admin-shadow); +} + +.stat-icon { + font-size: 2.5rem; +} + +.stat-value { + font-size: 2rem; + font-weight: bold; +} + +.stat-label { + color: var(--admin-text-light); +} + +/* ==================== 工具栏 ==================== */ +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 25px; +} + +.filter-group { + display: flex; + gap: 10px; +} + +/* ==================== 表格 ==================== */ +.data-table { + width: 100%; + background: var(--admin-card-bg); + border-radius: 8px; + overflow: hidden; + box-shadow: var(--admin-shadow); + border-collapse: collapse; +} + +.data-table th, +.data-table td { + padding: 15px; + text-align: left; + border-bottom: 1px solid var(--admin-border); +} + +.data-table th { + background: #f8f9fa; + font-weight: 600; +} + +.data-table tr:hover { + background: #f8f9fa; +} + +.data-table a { + color: var(--admin-primary); +} + +.data-table a:hover { + text-decoration: underline; +} + +/* ==================== 表单 ==================== */ +.form-card { + background: var(--admin-card-bg); + border-radius: 8px; + padding: 30px; + box-shadow: var(--admin-shadow); + max-width: 800px; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 500; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px 15px; + border: 1px solid var(--admin-border); + border-radius: 4px; + font-size: 1rem; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--admin-primary); + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); +} + +.form-row { + display: flex; + gap: 20px; +} + +.form-row .form-group { + flex: 1; +} + +.flex-grow { + flex: 1; +} + +.checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 15px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: auto; +} + +.form-actions { + display: flex; + gap: 15px; + margin-top: 30px; +} + +/* ==================== 文章表单 ==================== */ +.article-form { + background: var(--admin-card-bg); + border-radius: 8px; + padding: 30px; + box-shadow: var(--admin-shadow); +} + +.article-form textarea[name="content"] { + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.95rem; + line-height: 1.5; +} + +.color-input-wrapper { + display: flex; + align-items: center; + gap: 10px; +} + +.color-input-wrapper input[type="text"] { + width: 120px; +} + +/* ==================== 按钮 ==================== */ +.btn { + display: inline-block; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: all 0.3s; + text-decoration: none; +} + +.btn-primary { + background: var(--admin-primary); + color: #fff; +} + +.btn-primary:hover { + background: var(--admin-primary-hover); + color: #fff; +} + +.btn-secondary { + background: var(--admin-secondary); + color: #fff; +} + +.btn-secondary:hover { + opacity: 0.9; + color: #fff; +} + +.btn-danger { + background: var(--admin-danger); + color: #fff; +} + +.btn-danger:hover { + opacity: 0.9; + color: #fff; +} + +.btn-sm { + padding: 5px 12px; + font-size: 0.85rem; +} + +.btn-block { + display: block; + width: 100%; +} + +/* ==================== 徽章 ==================== */ +.badge { + display: inline-block; + padding: 4px 10px; + border-radius: 12px; + font-size: 0.85rem; +} + +.badge-success { + background: var(--admin-success); + color: #fff; +} + +.badge-warning { + background: var(--admin-warning); + color: #333; +} + +/* ==================== 操作按钮 ==================== */ +.action-buttons { + display: flex; + gap: 5px; +} + +/* ==================== 提示框 ==================== */ +.alert { + padding: 15px 20px; + border-radius: 5px; + margin-bottom: 20px; +} + +.alert-success { background: #d4edda; color: #155724; } +.alert-danger { background: #f8d7da; color: #721c24; } +.alert-warning { background: #fff3cd; color: #856404; } +.alert-info { background: #d1ecf1; color: #0c5460; } + +/* ==================== 空状态 ==================== */ +.empty-state { + text-align: center; + padding: 60px 20px; + background: var(--admin-card-bg); + border-radius: 8px; + box-shadow: var(--admin-shadow); +} + +.empty-state p { + color: var(--admin-text-light); + margin-bottom: 20px; +} + +/* ==================== 分页 ==================== */ +.pagination { + display: flex; + justify-content: center; + gap: 5px; + margin-top: 25px; +} + +.page-link { + display: inline-block; + padding: 8px 15px; + background: var(--admin-card-bg); + border: 1px solid var(--admin-border); + border-radius: 4px; + color: var(--admin-text); +} + +.page-link:hover { + background: var(--admin-primary); + color: #fff; + border-color: var(--admin-primary); + text-decoration: none; +} + +.page-link.active { + background: var(--admin-primary); + color: #fff; + border-color: var(--admin-primary); +} + +/* ==================== 最近文章区 ==================== */ +.recent-section { + background: var(--admin-card-bg); + border-radius: 8px; + padding: 25px; + box-shadow: var(--admin-shadow); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.section-header h2 { + font-size: 1.2rem; +} + +/* ==================== 登录页面 ==================== */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.login-card { + background: #fff; + border-radius: 10px; + padding: 40px; + width: 400px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); +} + +.login-card h1 { + text-align: center; + margin-bottom: 30px; + color: var(--admin-text); +} + +.login-form .form-group { + margin-bottom: 20px; +} + +.login-form input { + width: 100%; + padding: 12px 15px; + border: 1px solid var(--admin-border); + border-radius: 4px; + font-size: 1rem; +} + +.login-footer { + text-align: center; + margin-top: 20px; +} + +/* ==================== 响应式 ==================== */ +@media (max-width: 1200px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .sidebar { + width: 200px; + } + + .main-content { + margin-left: 200px; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .toolbar { + flex-direction: column; + gap: 15px; + } + + .form-row { + flex-direction: column; + } +} \ No newline at end of file diff --git a/app/static/css/markdown.css b/app/static/css/markdown.css new file mode 100644 index 0000000..33bf367 --- /dev/null +++ b/app/static/css/markdown.css @@ -0,0 +1,243 @@ +/* ==================== Markdown 渲染样式 ==================== */ + +.markdown-body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + line-height: 1.7; + color: #24292e; + max-width: 100%; +} + +/* 标题 */ +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 1.5em; + margin-bottom: 0.8em; + font-weight: 600; + line-height: 1.25; + color: #24292e; +} + +.markdown-body h1 { + font-size: 2em; + padding-bottom: 0.3em; + border-bottom: 1px solid #eaecef; +} + +.markdown-body h2 { + font-size: 1.5em; + padding-bottom: 0.3em; + border-bottom: 1px solid #eaecef; +} + +.markdown-body h3 { font-size: 1.25em; } +.markdown-body h4 { font-size: 1em; } +.markdown-body h5 { font-size: 0.875em; } +.markdown-body h6 { font-size: 0.85em; color: #6a737d; } + +/* 段落 */ +.markdown-body p { + margin-bottom: 1em; +} + +/* 列表 */ +.markdown-body ul, +.markdown-body ol { + margin-bottom: 1em; + padding-left: 2em; +} + +.markdown-body li { + margin-bottom: 0.25em; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ul, +.markdown-body ol ol { + margin-top: 0.25em; + margin-bottom: 0; +} + +/* 代码块 */ +.markdown-body pre { + margin-bottom: 1em; + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: #f6f8fa; + border-radius: 6px; +} + +.markdown-body code { + padding: 0.2em 0.4em; + margin: 0; + font-size: 85%; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + background-color: rgba(27, 31, 35, 0.05); + border-radius: 3px; +} + +.markdown-body pre code { + padding: 0; + margin: 0; + font-size: 100%; + background: transparent; + border-radius: 0; +} + +/* 高亮代码样式增强 */ +.markdown-body pre code.hljs { + background: #f6f8fa; + padding: 0; +} + +/* 链接 */ +.markdown-body a { + color: #0366d6; + text-decoration: none; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +/* 图片 */ +.markdown-body img { + max-width: 100%; + box-sizing: content-box; + background-color: #fff; + border-radius: 4px; + margin: 1em 0; +} + +/* 引用块 */ +.markdown-body blockquote { + margin: 0 0 1em; + padding: 0 1em; + color: #6a737d; + border-left: 0.25em solid #dfe2e5; +} + +.markdown-body blockquote > :first-child { + margin-top: 0; +} + +.markdown-body blockquote > :last-child { + margin-bottom: 0; +} + +/* 表格 */ +.markdown-body table { + margin-bottom: 1em; + border-spacing: 0; + border-collapse: collapse; + display: block; + width: 100%; + overflow: auto; +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid #dfe2e5; +} + +.markdown-body table th { + font-weight: 600; + background-color: #f6f8fa; +} + +.markdown-body table tr { + background-color: #fff; + border-top: 1px solid #c6cbd1; +} + +.markdown-body table tr:nth-child(2n) { + background-color: #f6f8fa; +} + +/* 分隔线 */ +.markdown-body hr { + height: 0.25em; + padding: 0; + margin: 1.5em 0; + background-color: #e1e4e8; + border: 0; +} + +/* 删除线 */ +.markdown-body del { + text-decoration: line-through; + color: #6a737d; +} + +/* 任务列表 */ +.markdown-body input[type="checkbox"] { + margin: 0 0.2em 0.25em -1.6em; + vertical-align: middle; +} + +/* 警告提示(GitHub 风格) */ +.markdown-body .markdown-alert { + padding: 8px 16px; + margin-bottom: 1em; + border-left: 4px solid; + border-radius: 4px; +} + +.markdown-body .markdown-alert-note { + border-color: #0969da; + background-color: #ddf4ff; +} + +.markdown-body .markdown-alert-tip { + border-color: #1a7f37; + background-color: #d6f5d6; +} + +.markdown-body .markdown-alert-important { + border-color: #8250df; + background-color: #f3e8ff; +} + +.markdown-body .markdown-alert-warning { + border-color: #9a6700; + background-color: #fff8c5; +} + +.markdown-body .markdown-alert-caution { + border-color: #cf222e; + background-color: #ffebe9; +} + +/* 代码语言标签 */ +.markdown-body pre::before { + content: attr(data-lang); + position: absolute; + top: 8px; + right: 12px; + font-size: 0.75em; + color: #6a737d; + font-family: inherit; +} + +/* 响应式 */ +@media (max-width: 768px) { + .markdown-body { + font-size: 0.95em; + } + + .markdown-body pre { + padding: 12px; + } + + .markdown-body table { + display: block; + overflow-x: auto; + } +} \ No newline at end of file diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..3f8bfda --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,693 @@ +/* ==================== 基础样式 ==================== */ +:root { + --primary-color: #3498db; + --primary-hover: #2980b9; + --secondary-color: #6c757d; + --success-color: #28a745; + --warning-color: #ffc107; + --danger-color: #dc3545; + --text-color: #333; + --text-light: #666; + --border-color: #e0e0e0; + --bg-color: #f5f5f5; + --card-bg: #fff; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: var(--text-color); + background-color: var(--bg-color); +} + +a { + color: var(--primary-color); + text-decoration: none; +} + +a:hover { + color: var(--primary-hover); +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; +} + +/* ==================== 导航栏 ==================== */ +.navbar { + background: var(--card-bg); + box-shadow: var(--shadow); + padding: 15px 0; + position: sticky; + top: 0; + z-index: 100; +} + +.navbar .container { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.logo { + font-size: 1.5rem; + font-weight: bold; + color: var(--text-color); + display: flex; + align-items: center; + gap: 10px; +} + +.logo img { + height: 35px; + width: auto; +} + +.site-subtitle { + font-size: 0.9rem; + color: var(--text-light); + font-weight: normal; +} + +.nav-links { + display: flex; + gap: 20px; +} + +.nav-links a { + color: var(--text-color); + padding: 5px 10px; + border-radius: 4px; + transition: all 0.3s; +} + +.nav-links a:hover, +.nav-links a.active { + background: var(--primary-color); + color: #fff; +} + +/* ==================== 主内容 ==================== */ +.main-content { + padding: 30px 0; +} + +.content-wrapper { + display: grid; + grid-template-columns: 1fr 300px; + gap: 30px; +} + +.content-area { + min-width: 0; +} + +/* ==================== 文章卡片 ==================== */ +.page-header { + margin-bottom: 25px; +} + +.page-header h1 { + font-size: 1.8rem; + color: var(--text-color); +} + +.article-card { + background: var(--card-bg); + border-radius: 8px; + overflow: hidden; + box-shadow: var(--shadow); + margin-bottom: 20px; + display: flex; + transition: transform 0.3s; +} + +.article-card:hover { + transform: translateY(-2px); +} + +.article-cover { + width: 200px; + flex-shrink: 0; +} + +.article-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.article-body { + padding: 20px; + flex: 1; +} + +.article-title { + font-size: 1.3rem; + margin-bottom: 10px; +} + +.article-title a { + color: var(--text-color); +} + +.article-title a:hover { + color: var(--primary-color); +} + +.article-meta { + display: flex; + gap: 15px; + margin-bottom: 10px; + color: var(--text-light); + font-size: 0.9rem; +} + +.article-meta .category { + color: var(--primary-color); +} + +.article-meta .author { + display: flex; + align-items: center; + gap: 5px; +} + +.author-avatar { + width: 20px; + height: 20px; + border-radius: 50%; +} + +.author-avatar-lg { + width: 60px; + height: 60px; + border-radius: 50%; +} + +.article-summary { + background: #f8f9fa; + padding: 15px; + border-radius: 5px; + margin-bottom: 20px; + color: var(--text-light); +} + +.author-card { + display: flex; + gap: 20px; + background: #f8f9fa; + padding: 20px; + border-radius: 8px; + margin: 30px 0; +} + +.author-info h4 { + margin-bottom: 5px; +} + +.author-info p { + color: var(--text-light); + margin-bottom: 10px; +} + +.references-section, .attachments-section { + margin: 30px 0; + padding: 20px; + background: #f8f9fa; + border-radius: 8px; +} + +.references-section h3, .attachments-section h3 { + margin-bottom: 15px; + font-size: 1.1rem; +} + +.references-section ul, .attachments-section ul { + list-style: none; +} + +.references-section li, .attachments-section li { + padding: 8px 0; + border-bottom: 1px solid #e0e0e0; +} + +.ref-desc, .att-desc { + color: var(--text-light); + font-size: 0.9em; + margin-left: 10px; +} + +.filesize { + color: var(--text-light); + font-size: 0.85em; + margin-left: 10px; +} + +.article-summary { + color: var(--text-light); + margin-bottom: 15px; +} + +.article-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* ==================== 文章详情 ==================== */ +.article-detail { + background: var(--card-bg); + border-radius: 8px; + padding: 40px; + box-shadow: var(--shadow); +} + +.article-header { + margin-bottom: 30px; +} + +.article-header .article-title { + font-size: 2rem; + margin-bottom: 15px; +} + +.article-header .article-meta { + margin-bottom: 15px; +} + +.article-cover { + margin-bottom: 30px; + border-radius: 8px; + overflow: hidden; +} + +.article-cover img { + width: 100%; + display: block; +} + +.article-content { + font-size: 1.1rem; + line-height: 1.8; +} + +.article-content h1, .article-content h2, .article-content h3 { + margin: 1.5em 0 0.8em; +} + +.article-content p { + margin-bottom: 1em; +} + +.article-content pre { + background: #f4f4f4; + padding: 15px; + border-radius: 5px; + overflow-x: auto; +} + +.article-content code { + background: #f4f4f4; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Consolas', monospace; +} + +.article-content pre code { + background: none; + padding: 0; +} + +.article-footer { + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid var(--border-color); +} + +/* ==================== 侧边栏 ==================== */ +.sidebar { + display: flex; + flex-direction: column; + gap: 25px; +} + +.sidebar-section { + background: var(--card-bg); + border-radius: 8px; + padding: 20px; + box-shadow: var(--shadow); +} + +.sidebar-section h3 { + font-size: 1.1rem; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 2px solid var(--primary-color); +} + +.search-form { + display: flex; + gap: 5px; +} + +.search-form input { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.search-form button { + padding: 8px 12px; + background: var(--primary-color); + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.recent-list, .hot-list { + list-style: none; +} + +.recent-list li, .hot-list li { + padding: 8px 0; + border-bottom: 1px solid var(--border-color); +} + +.recent-list li:last-child, .hot-list li:last-child { + border-bottom: none; +} + +.hot-list a { + display: flex; + align-items: center; + gap: 10px; +} + +.rank { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + background: var(--primary-color); + color: #fff; + border-radius: 50%; + font-size: 0.8rem; +} + +/* ==================== 标签 ==================== */ +.tag { + display: inline-block; + padding: 4px 12px; + background: var(--primary-color); + color: #fff; + border-radius: 15px; + font-size: 0.85rem; + transition: all 0.3s; +} + +.tag:hover { + opacity: 0.8; + color: #fff; +} + +.tag.active { + background: var(--tag-color, var(--primary-color)); +} + +.tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* ==================== 分页 ==================== */ +.pagination { + display: flex; + justify-content: center; + gap: 5px; + margin-top: 30px; +} + +.page-link { + display: inline-block; + padding: 8px 15px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-color); +} + +.page-link:hover { + background: var(--primary-color); + color: #fff; + border-color: var(--primary-color); +} + +.page-link.active { + background: var(--primary-color); + color: #fff; + border-color: var(--primary-color); +} + +.page-link.ellipsis { + border: none; +} + +/* ==================== 按钮 ==================== */ +.btn { + display: inline-block; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: all 0.3s; + text-decoration: none; +} + +.btn-primary { + background: var(--primary-color); + color: #fff; +} + +.btn-primary:hover { + background: var(--primary-hover); + color: #fff; +} + +.btn-secondary { + background: var(--secondary-color); + color: #fff; +} + +.btn-secondary:hover { + opacity: 0.9; + color: #fff; +} + +.btn-danger { + background: var(--danger-color); + color: #fff; +} + +.btn-danger:hover { + opacity: 0.9; + color: #fff; +} + +.btn-sm { + padding: 5px 10px; + font-size: 0.85rem; +} + +.btn-block { + display: block; + width: 100%; +} + +/* ==================== 提示框 ==================== */ +.alert { + padding: 15px 20px; + border-radius: 5px; + margin-bottom: 20px; +} + +.alert-success { background: #d4edda; color: #155724; } +.alert-danger { background: #f8d7da; color: #721c24; } +.alert-warning { background: #fff3cd; color: #856404; } +.alert-info { background: #d1ecf1; color: #0c5460; } + +/* ==================== 空状态 ==================== */ +.empty-state { + text-align: center; + padding: 60px 20px; + background: var(--card-bg); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.empty-state p { + color: var(--text-light); + margin-bottom: 20px; +} + +/* ==================== 错误页面 ==================== */ +.error-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + text-align: center; +} + +.error-page h1 { + font-size: 6rem; + color: var(--primary-color); +} + +.error-page p { + font-size: 1.5rem; + margin: 20px 0 30px; +} + +/* ==================== 页脚 ==================== */ +.footer { + background: var(--card-bg); + padding: 0; + margin-top: 50px; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05); +} + +.footer-content { + display: flex; + justify-content: space-between; + align-items: stretch; + height: 150px; +} + +.footer-main { + flex: 1; + min-width: 200px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 15px 20px; +} + +.footer-main p { + margin: 5px 0; +} + +.footer-main .copyright { + color: var(--text-light); + font-size: 0.9rem; +} + +.footer-main .technology { + color: var(--text-light); + font-size: 0.85rem; +} + +.contact-links { + margin-top: 15px; + display: flex; + gap: 20px; +} + +.contact-links a { + color: var(--text-light); + font-size: 0.9rem; +} + +.contact-links a:hover { + color: var(--primary-color); +} + +.footer-promo { + display: flex; + gap: 5px; + height: 150px; + align-items: stretch; +} + +.promo-item { + display: flex; + flex-direction: column; + flex: 1; + min-width: 100px; + height: 150px; +} + +.promo-link { + position: relative; + display: block; + height: 150px; + overflow: hidden; +} + +.promo-link img { + width: 100%; + height: 150px; + object-fit: cover; + display: block; + transition: transform 0.3s; +} + +.promo-link:hover img { + transform: scale(1.02); +} + +.promo-title-overlay { + display: none; +} + +/* ==================== 响应式 ==================== */ +@media (max-width: 992px) { + .content-wrapper { + grid-template-columns: 1fr; + } + + .sidebar { + display: grid; + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .navbar .container { + flex-direction: column; + gap: 15px; + } + + .article-card { + flex-direction: column; + } + + .article-cover { + width: 100%; + height: 200px; + } + + .sidebar { + grid-template-columns: 1fr; + } + + .article-detail { + padding: 20px; + } +} \ No newline at end of file diff --git a/app/templates/admin/article_form.html b/app/templates/admin/article_form.html new file mode 100644 index 0000000..a9d880a --- /dev/null +++ b/app/templates/admin/article_form.html @@ -0,0 +1,591 @@ +{% extends 'admin/base.html' %} + +{% block title %}{% if article %}编辑文章{% else %}新建文章{% endif %}{% endblock %} +{% block page_title %}{% if article %}编辑文章{% else %}新建文章{% endif %}{% endblock %} + +{% block extra_css %} + + + +{% endblock %} + +{% block extra_js %} + + + + + + +{% endblock %} + +{% block content %} +
+ + +
+
+ + +
+ +
+ + + 管理作者 +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ +

📷 点击、拖拽或粘贴图片到编辑器中上传

+

支持 PNG, JPG, GIF, WebP 格式,最大 10MB

+
+ +
+ + + +
+ + + +
+ + +
+ + + + + + + + + + + + + + +
+ + +
+
+ +
+ +
+

📖 实时预览

+
+
+
+
+ + +
+

📚 参考来源

+
+ +
+ + + {% if article %} +
+

📎 文章附件

+
+ {% for att in attachments %} +
+
+ {{ att.filename }} + {{ att.get_filesize_display() }} +
+
+ 下载 + +
+
+ {% endfor %} +
+ + + + + + +
+ + + {% endif %} + +
+ +
+ {% for tag in tags %} + + {% endfor %} +
+
+ +
+ +
+ +
+ + 取消 + {% if article and article.is_published %} + 查看文章 + {% endif %} +
+ +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/articles.html b/app/templates/admin/articles.html new file mode 100644 index 0000000..433d2d9 --- /dev/null +++ b/app/templates/admin/articles.html @@ -0,0 +1,112 @@ +{% extends 'admin/base.html' %} + +{% block title %}文章管理{% endblock %} +{% block page_title %}文章管理{% endblock %} + +{% block content %} + + +{% if articles.items %} + + + + + + + + + + + + + {% for article in articles.items %} + + + + + + + + + {% endfor %} + +
标题分类状态阅读量更新时间操作
+ + {{ article.title }} + + {{ article.category.name if article.category else '-' }} + {% if article.is_published %} + 已发布 + {% else %} + 草稿 + {% endif %} + {{ article.view_count }}{{ article.updated_at|datetime_format('%Y-%m-%d %H:%M') }} +
+ 编辑 +
+ +
+
+ +
+
+
+ + +{% if articles.pages > 1 %} + +{% endif %} + +{% else %} +
+

暂无文章

+ 创建第一篇文章 +
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/author_form.html b/app/templates/admin/author_form.html new file mode 100644 index 0000000..d4439fa --- /dev/null +++ b/app/templates/admin/author_form.html @@ -0,0 +1,56 @@ +{% extends 'admin/base.html' %} + +{% block title %}{% if author %}编辑作者{% else %}新建作者{% endif %}{% endblock %} +{% block page_title %}{% if author %}编辑作者{% else %}新建作者{% endif %}{% endblock %} + +{% block content %} +
+
+ + +
+ +
+ + + {% if author and author.avatar %} +
+ 头像预览 +
+ {% endif %} +
+ +
+ + +
+ +
+ + +
+ +
+ + 取消 +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/authors.html b/app/templates/admin/authors.html new file mode 100644 index 0000000..c364a75 --- /dev/null +++ b/app/templates/admin/authors.html @@ -0,0 +1,60 @@ +{% extends 'admin/base.html' %} + +{% block title %}作者管理{% endblock %} +{% block page_title %}作者管理{% endblock %} + +{% block content %} + + +{% if authors %} + + + + + + + + + + + + {% for author in authors %} + + + + + + + + {% endfor %} + +
头像姓名简介网站操作
+ {% if author.avatar %} + {{ author.name }} + {% else %} +
👤
+ {% endif %} +
{{ author.name }}{{ author.bio or '-' }} + {% if author.website %} + {{ author.website[:30] }}... + {% else %} + - + {% endif %} + +
+ 编辑 +
+ +
+
+
+{% else %} +
+

暂无作者

+ 创建第一个作者 +
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/base.html b/app/templates/admin/base.html new file mode 100644 index 0000000..4e34795 --- /dev/null +++ b/app/templates/admin/base.html @@ -0,0 +1,71 @@ + + + + + + {% block title %}管理后台{% endblock %} - 技术博客 + + {% block extra_css %}{% endblock %} + + +
+ + + + +
+
+

{% block page_title %}{% endblock %}

+ +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+
+
+ + {% block extra_js %}{% endblock %} + + \ No newline at end of file diff --git a/app/templates/admin/categories.html b/app/templates/admin/categories.html new file mode 100644 index 0000000..44f7b45 --- /dev/null +++ b/app/templates/admin/categories.html @@ -0,0 +1,48 @@ +{% extends 'admin/base.html' %} + +{% block title %}分类管理{% endblock %} +{% block page_title %}分类管理{% endblock %} + +{% block content %} + + +{% if categories %} + + + + + + + + + + + {% for category in categories %} + + + + + + + {% endfor %} + +
名称描述文章数操作
{{ category.name }}{{ category.description or '-' }}{{ category.articles|length }} +
+ 编辑 +
+ +
+
+
+{% else %} +
+

暂无分类

+ 创建第一个分类 +
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/category_form.html b/app/templates/admin/category_form.html new file mode 100644 index 0000000..03d0c0b --- /dev/null +++ b/app/templates/admin/category_form.html @@ -0,0 +1,32 @@ +{% extends 'admin/base.html' %} + +{% block title %}{% if category %}编辑分类{% else %}新建分类{% endif %}{% endblock %} +{% block page_title %}{% if category %}编辑分类{% else %}新建分类{% endif %}{% endblock %} + +{% block content %} +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + 取消 +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..56c67c4 --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,86 @@ +{% extends 'admin/base.html' %} + +{% block title %}仪表盘{% endblock %} +{% block page_title %}仪表盘{% endblock %} + +{% block content %} +
+
+
📄
+
+
{{ stats.total_articles }}
+
文章总数
+
+
+ +
+
+
+
{{ stats.published_articles }}
+
已发布
+
+
+ +
+
📁
+
+
{{ stats.total_categories }}
+
分类数
+
+
+ +
+
👁️
+
+
{{ stats.total_views }}
+
总阅读量
+
+
+
+ +
+
+

最近文章

+ + 新建文章 +
+ + {% if recent_articles.items %} + + + + + + + + + + + + {% for article in recent_articles.items %} + + + + + + + + {% endfor %} + +
标题分类状态阅读量更新时间
+ + {{ article.title }} + + {{ article.category.name if article.category else '-' }} + {% if article.is_published %} + 已发布 + {% else %} + 草稿 + {% endif %} + {{ article.view_count }}{{ article.updated_at|datetime_format('%Y-%m-%d %H:%M') }}
+ {% else %} +
+

暂无文章,创建第一篇文章

+
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/login.html b/app/templates/admin/login.html new file mode 100644 index 0000000..4ebb7d3 --- /dev/null +++ b/app/templates/admin/login.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} + +{% block title %}登录{% endblock %} + +{% block body %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html new file mode 100644 index 0000000..bc87edd --- /dev/null +++ b/app/templates/admin/settings.html @@ -0,0 +1,422 @@ +{% extends 'admin/base.html' %} + +{% block title %}网站设置{% endblock %} +{% block page_title %}网站设置{% endblock %} + +{% block content %} +
+
+

基本信息

+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+
+ {% if config.site_logo %} +
+ Logo预览 +
+ {% endif %} +
+
+ +
+

SEO设置

+ +
+ + +
+ +
+ + +
+
+ +
+

底部信息

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+

底部宣传图片

+ 可在页面底部右侧展示宣传图片、二维码等,支持上传或输入URL + +
+ +
+

图片 1

+
+ +
+ + + {% if config.promo_image_1 %} +
+ 图片1 + +
+ {% else %} +
+ 点击上传图片 +
+ {% endif %} +
+
+
+ + +
+
+ + +
+
+ + +
+

图片 2

+
+ +
+ + + {% if config.promo_image_2 %} +
+ 图片2 + +
+ {% else %} +
+ 点击上传图片 +
+ {% endif %} +
+
+
+ + +
+
+ + +
+
+ + +
+

图片 3(二维码)

+
+ +
+ + + {% if config.promo_image_3 %} +
+ 图片3 + +
+ {% else %} +
+ 点击上传二维码 +
+ {% endif %} +
+
+
+ + +
+
+
+
+ +
+

联系方式

+ +
+
+ + +
+ +
+ + +
+
+
+ +
+

显示设置

+ +
+ + +
+
+ +
+ +
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/stats.html b/app/templates/admin/stats.html new file mode 100644 index 0000000..ad50032 --- /dev/null +++ b/app/templates/admin/stats.html @@ -0,0 +1,158 @@ +{% extends 'admin/base.html' %} + +{% block title %}访问统计{% endblock %} +{% block page_title %}访问统计{% endblock %} + +{% block content %} +
+
+
+
👁️
+
+
{{ stats.total_visits }}
+
总访问量
+
+
+ +
+
📅
+
+
{{ stats.today_visits }}
+
今日访问
+
+
+ +
+
🌐
+
+
{{ stats.unique_ips }}
+
独立访客
+
+
+
+
+ + + +
+ +
+

🔥 访问最多的IP (Top 10)

+ + + + + + + + + + + {% for ip, count, first, last in top_ips %} + + + + + + + {% endfor %} + +
IP地址访问次数首次访问最近访问
{{ ip }}{{ count }}{{ first|datetime_format('%Y-%m-%d %H:%M') }}{{ last|datetime_format('%Y-%m-%d %H:%M') }}
+
+ + +
+

📊 热门文章 (最近7天)

+ + + + + + + + + {% for article, views in top_articles %} + + + + + {% endfor %} + +
文章标题浏览次数
+ + {{ article.title }} + + {{ views }}
+
+ + +
+

📍 访问最多的路径

+ + + + + + + + + {% for path, count in top_paths %} + + + + + {% endfor %} + +
路径访问次数
{{ path }}{{ count }}
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/stats_charts.html b/app/templates/admin/stats_charts.html new file mode 100644 index 0000000..3e20c42 --- /dev/null +++ b/app/templates/admin/stats_charts.html @@ -0,0 +1,149 @@ +{% extends 'admin/base.html' %} + +{% block title %}访问趋势{% endblock %} +{% block page_title %}访问趋势图表{% endblock %} + +{% block content %} + + +
+

📈 最近30天访问趋势

+
+ +
+
+ +
+
+
📊
+
+
{{ stats.total_visits }}
+
30天总访问
+
+
+ +
+
📅
+
+
{{ stats.today_visits }}
+
今日访问
+
+
+ +
+
🌐
+
+
{{ stats.unique_ips }}
+
独立访客
+
+
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/stats_logs.html b/app/templates/admin/stats_logs.html new file mode 100644 index 0000000..34be3d2 --- /dev/null +++ b/app/templates/admin/stats_logs.html @@ -0,0 +1,172 @@ +{% extends 'admin/base.html' %} + +{% block title %}访问日志{% endblock %} +{% block page_title %}访问日志{% endblock %} + +{% block content %} + + +
+
+ + + {% if path_filter %} + 清除 + {% endif %} +
+ +
+ + +
+
+ +{% if logs.items %} + + + + + + + + + + + + + {% for log in logs.items %} + + + + + + + + + {% endfor %} + +
IP地址访问路径User Agent来源页面访问时间文章
{{ log.ip_address }}{{ log.path }} + + {{ (log.user_agent or '-')[:50] }}{% if log.user_agent and len(log.user_agent) > 50 %}...{% endif %} + + + {% if log.referer %} + + {{ log.referer[:30] }}... + + {% else %} + 直接访问 + {% endif %} + {{ log.visited_at|datetime_format('%Y-%m-%d %H:%M:%S') }} + {% if log.article %} + + {{ log.article.title[:20] }}... + + {% else %} + - + {% endif %} +
+ + +{% if logs.pages > 1 %} + +{% endif %} + +{% else %} +
+

暂无访问日志

+
+{% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/tag_form.html b/app/templates/admin/tag_form.html new file mode 100644 index 0000000..895acea --- /dev/null +++ b/app/templates/admin/tag_form.html @@ -0,0 +1,47 @@ +{% extends 'admin/base.html' %} + +{% block title %}{% if tag %}编辑标签{% else %}新建标签{% endif %}{% endblock %} +{% block page_title %}{% if tag %}编辑标签{% else %}新建标签{% endif %}{% endblock %} + +{% block content %} +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + 取消 +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/tags.html b/app/templates/admin/tags.html new file mode 100644 index 0000000..50bd40b --- /dev/null +++ b/app/templates/admin/tags.html @@ -0,0 +1,50 @@ +{% extends 'admin/base.html' %} + +{% block title %}标签管理{% endblock %} +{% block page_title %}标签管理{% endblock %} + +{% block content %} + + +{% if tags %} + + + + + + + + + + + {% for tag in tags %} + + + + + + + {% endfor %} + +
名称颜色文章数操作
+ {{ tag.name }} + {{ tag.color }}{{ tag.articles|length }} +
+ 编辑 +
+ +
+
+
+{% else %} +
+

暂无标签

+ 创建第一个标签 +
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..d159d28 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,14 @@ + + + + + + {% block title %}技术博客{% endblock %} + + {% block extra_css %}{% endblock %} + + + {% block body %}{% endblock %} + {% block extra_js %}{% endblock %} + + \ No newline at end of file diff --git a/app/templates/frontend/404.html b/app/templates/frontend/404.html new file mode 100644 index 0000000..c7f1449 --- /dev/null +++ b/app/templates/frontend/404.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block title %}404 - 页面未找到{% endblock %} + +{% block body %} +
+
+

404

+

页面未找到

+ 返回首页 +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/frontend/500.html b/app/templates/frontend/500.html new file mode 100644 index 0000000..c04c628 --- /dev/null +++ b/app/templates/frontend/500.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block title %}500 - 服务器错误{% endblock %} + +{% block body %} +
+
+

500

+

服务器内部错误

+ 返回首页 +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/frontend/article.html b/app/templates/frontend/article.html new file mode 100644 index 0000000..0e62b20 --- /dev/null +++ b/app/templates/frontend/article.html @@ -0,0 +1,157 @@ +{% extends 'frontend/base.html' %} + +{% block title %}{{ article.title }} - {{ g.site_config.site_name }}{% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block extra_js %} + + + + + + + + + + +{% endblock %} + +{% block content %} +
+
+

{{ article.title }}

+ + + + {% if article.tags %} + + {% endif %} +
+ + {% if article.summary %} +
+ 摘要:{{ article.summary }} +
+ {% endif %} + + {% if article.cover_image %} +
+ {{ article.title }} +
+ {% endif %} + +
+ +
+ + {% if article.author and article.author.bio %} +
+ {% if article.author.avatar %} + {{ article.author.name }} + {% endif %} +
+

{{ article.author.name }}

+

{{ article.author.bio }}

+ {% if article.author.website %} + 个人主页 + {% endif %} +
+
+ {% endif %} + + {% if article.references.all() %} +
+

📚 参考来源

+
    + {% for ref in article.references %} +
  • + {% if ref.url %} + {{ ref.title }} + {% else %} + {{ ref.title }} + {% endif %} + {% if ref.description %} + {{ ref.description }} + {% endif %} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if article.attachments.all() %} +
+

📎 相关附件

+
    + {% for att in article.attachments %} +
  • + + {{ att.filename }} + + {{ att.get_filesize_display() }} + {% if att.description %} + {{ att.description }} + {% endif %} +
  • + {% endfor %} +
+
+ {% endif %} + + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/frontend/base.html b/app/templates/frontend/base.html new file mode 100644 index 0000000..611582a --- /dev/null +++ b/app/templates/frontend/base.html @@ -0,0 +1,163 @@ +{% extends 'base.html' %} + +{% block body %} + + +
+
+
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + +
+
+
+ +
+
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/frontend/index.html b/app/templates/frontend/index.html new file mode 100644 index 0000000..d51c8ff --- /dev/null +++ b/app/templates/frontend/index.html @@ -0,0 +1,97 @@ +{% extends 'frontend/base.html' %} + +{% block title %} +{% if category_title %}{{ category_title }} - 技术博客 +{% elif tag_title %}{{ tag_title }} - 技术博客 +{% else %}首页 - 技术博客{% endif %} +{% endblock %} + +{% block content %} + + +{% if articles.items %} +
+ {% for article in articles.items %} +
+ {% if article.cover_image %} +
+ {{ article.title }} +
+ {% endif %} + +
+

+ + {{ article.title }} + +

+ + + +

{{ article.summary|truncate_content(200) }}

+ + {% if article.tags %} + + {% endif %} +
+
+ {% endfor %} +
+ + +{% if articles.pages > 1 %} + +{% endif %} + +{% else %} +
+

暂无文章

+
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/init_data.py b/init_data.py new file mode 100644 index 0000000..a63fffb --- /dev/null +++ b/init_data.py @@ -0,0 +1,174 @@ +"""初始化示例数据""" +from app import create_app, db +from app.models import Article, Category, Tag, Admin +from app.services import CategoryService, TagService, ArticleService, AdminService +from werkzeug.security import generate_password_hash + +def init_sample_data(): + """创建示例数据""" + app = create_app('development') + + with app.app_context(): + # 创建数据库表 + db.create_all() + + # 创建管理员 + admin = Admin.query.filter_by(username='admin').first() + if not admin: + admin = Admin(username='admin', email='admin@example.com') + admin.set_password('admin123') + db.session.add(admin) + print('创建管理员: admin / admin123') + + # 创建分类 + categories_data = [ + ('Python', 'Python 编程相关技术文章'), + ('JavaScript', 'JavaScript 前端开发技术'), + ('数据库', '数据库技术与实践'), + ('系统架构', '系统设计与架构相关'), + ] + + for name, desc in categories_data: + cat = Category.query.filter_by(name=name).first() + if not cat: + CategoryService.create(name=name, description=desc) + print(f'创建分类: {name}') + + # 创建标签 + tags_data = [ + ('Flask', '#3498db'), + ('Vue', '#42b883'), + ('Django', '#092e20'), + ('React', '#61dafb'), + ('MySQL', '#00758f'), + ('PostgreSQL', '#336791'), + ('微服务', '#ff6b6b'), + ('Docker', '#2496ed'), + ] + + for name, color in tags_data: + tag = Tag.query.filter_by(name=name).first() + if not tag: + TagService.create(name=name, color=color) + print(f'创建标签: {name}') + + # 创建示例文章 + python_cat = Category.query.filter_by(name='Python').first() + flask_tag = Tag.query.filter_by(name='Flask').first() + + articles_data = [ + { + 'title': 'Flask 项目架构最佳实践', + 'content': '''# Flask 项目架构最佳实践 + +## 项目结构 + +合理的项目结构是可维护性的基础: + +``` +project/ +├── app/ +│ ├── models/ +│ ├── routes/ +│ ├── services/ +│ └── templates/ +├── migrations/ +└── tests/ +``` + +## 分层设计 + +- **Models**:数据模型定义 +- **Services**:业务逻辑处理 +- **Routes**:路由控制器 + +## 为什么这样设计? + +1. **关注点分离**:每层只处理自己的职责 +2. **易于测试**:业务逻辑与路由解耦 +3. **便于扩展**:新增功能不会影响现有结构 + +```python +# 示例代码 +class ArticleService: + @staticmethod + def get_published_list(page=1): + return Article.query.filter_by(is_published=True).paginate(page=page) +``` + +## 总结 + +好的架构设计能让代码更清晰、更易维护。 +''', + 'category_id': python_cat.id, + 'is_published': True + }, + { + 'title': 'Python 数据处理技巧', + 'content': '''# Python 数据处理技巧 + +## 列表推导式 + +简洁高效的数据处理方式: + +```python +# 传统方式 +result = [] +for item in items: + result.append(item * 2) + +# 推导式 +result = [item * 2 for item in items] +``` + +## 字典操作技巧 + +```python +# 合并字典 +dict1 = {'a': 1} +dict2 = {'b': 2} +merged = {**dict1, **dict2} + +# defaultdict +from collections import defaultdict +d = defaultdict(list) +d['key'].append('value') +``` + +## 性能优化 + +- 使用生成器节省内存 +- 选择合适的数据结构 +- 避免不必要的循环 + +## 总结 + +掌握这些技巧能大幅提升代码效率。 +''', + 'category_id': python_cat.id, + 'is_published': True + }, + ] + + for article_data in articles_data: + existing = Article.query.filter_by(title=article_data['title']).first() + if not existing: + ArticleService.create( + title=article_data['title'], + content=article_data['content'], + category_id=article_data.get('category_id'), + tag_ids=[flask_tag.id] if flask_tag else [], + is_published=article_data.get('is_published', False) + ) + print(f'创建文章: {article_data["title"]}') + + db.session.commit() + print('\n初始化完成!') + print('访问地址:') + print(' 前端: http://localhost:16012/') + print(' 后台: http://localhost:16012/admin/login') + print(' 账号: admin / admin123') + + +if __name__ == '__main__': + init_sample_data() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9698245 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask==3.0.0 +Flask-SQLAlchemy==3.1.1 +Flask-Migrate==4.0.5 +SQLAlchemy==2.0.23 +Werkzeug==3.0.1 +python-dotenv==1.0.0 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..68817ab --- /dev/null +++ b/run.py @@ -0,0 +1,11 @@ +"""启动脚本""" +import os +from app import create_app + +# 创建应用 +app = create_app(os.environ.get('FLASK_ENV', 'development')) + +if __name__ == '__main__': + # 从环境变量获取端口,默认16012 + port = int(os.environ.get('PORT', 16012)) + app.run(host='0.0.0.0', port=port) \ No newline at end of file diff --git a/uploads/20260604_abc0d28c.png b/uploads/20260604_abc0d28c.png new file mode 100644 index 0000000..55846cd Binary files /dev/null and b/uploads/20260604_abc0d28c.png differ diff --git a/uploads/20260604_be4fde65.png b/uploads/20260604_be4fde65.png new file mode 100644 index 0000000..55846cd Binary files /dev/null and b/uploads/20260604_be4fde65.png differ diff --git a/uploads/20260604_da5e3004.png b/uploads/20260604_da5e3004.png new file mode 100644 index 0000000..f848481 Binary files /dev/null and b/uploads/20260604_da5e3004.png differ