diff --git a/blog/static/blog/css/style.css b/blog/static/blog/css/style.css index 390bba35..cdbd790b 100644 --- a/blog/static/blog/css/style.css +++ b/blog/static/blog/css/style.css @@ -2735,6 +2735,76 @@ li #reply-title { .comment-content .codehilite .nf, .comment-content .codehilite .n, .comment-content .codehilite .p, +.comment-body .codehilite .kt, +.comment-body .codehilite .nf, +.comment-body .codehilite .n, +.comment-body .codehilite .p { + word-wrap: break-word !important; + overflow-wrap: break-word !important; +} + +/* 搜索结果高亮样式 */ +.search-result { + margin-bottom: 30px; + padding: 20px; + border: 1px solid #e1e1e1; + border-radius: 5px; + background: #fff; +} + +.search-result .entry-title { + margin: 0 0 10px 0; + font-size: 1.5em; +} + +.search-result .entry-title a { + color: #2c3e50; + text-decoration: none; +} + +.search-result .entry-title a:hover { + color: #3498db; +} + +.search-result .entry-meta { + color: #7f8c8d; + font-size: 0.9em; + margin-bottom: 15px; +} + +.search-result .entry-meta span { + margin-right: 15px; +} + +.search-excerpt { + line-height: 1.6; + color: #555; +} + +.search-excerpt p { + margin: 10px 0; +} + +/* 搜索关键词高亮 */ +.search-excerpt em, +.search-result .entry-title em { + background-color: #fff3cd; + color: #856404; + font-style: normal; + font-weight: bold; + padding: 2px 4px; + border-radius: 3px; +} + +.more-link { + color: #3498db; + text-decoration: none; + font-weight: bold; +} + +.more-link:hover { + text-decoration: underline; +} .comment-content .codehilite .w, .comment-content .codehilite .o, .comment-body .codehilite .kt, diff --git a/blog/templatetags/blog_tags.py b/blog/templatetags/blog_tags.py index 1f994bcd..024f2c85 100644 --- a/blog/templatetags/blog_tags.py +++ b/blog/templatetags/blog_tags.py @@ -421,3 +421,134 @@ def query(qs, **kwargs): def addstr(arg1, arg2): """concatenate arg1 & arg2""" return str(arg1) + str(arg2) + + +# === 插件系统模板标签 === + +@register.simple_tag(takes_context=True) +def render_plugin_widgets(context, position, **kwargs): + """ + 渲染指定位置的所有插件组件 + + Args: + context: 模板上下文 + position: 位置标识 + **kwargs: 传递给插件的额外参数 + + Returns: + 按优先级排序的所有插件HTML内容 + """ + from djangoblog.plugin_manage.loader import get_loaded_plugins + + widgets = [] + + for plugin in get_loaded_plugins(): + try: + widget_data = plugin.render_position_widget( + position=position, + context=context, + **kwargs + ) + if widget_data: + widgets.append(widget_data) + except Exception as e: + logger.error(f"Error rendering widget from plugin {plugin.PLUGIN_NAME}: {e}") + + # 按优先级排序(数字越小优先级越高) + widgets.sort(key=lambda x: x['priority']) + + # 合并HTML内容 + html_parts = [widget['html'] for widget in widgets] + return mark_safe(''.join(html_parts)) + + +@register.simple_tag(takes_context=True) +def plugin_head_resources(context): + """渲染所有插件的head资源(仅自定义HTML,CSS已集成到压缩系统)""" + from djangoblog.plugin_manage.loader import get_loaded_plugins + + resources = [] + + for plugin in get_loaded_plugins(): + try: + # 只处理自定义head HTML(CSS文件已通过压缩系统处理) + head_html = plugin.get_head_html(context) + if head_html: + resources.append(head_html) + + except Exception as e: + logger.error(f"Error loading head resources from plugin {plugin.PLUGIN_NAME}: {e}") + + return mark_safe('\n'.join(resources)) + + +@register.simple_tag(takes_context=True) +def plugin_body_resources(context): + """渲染所有插件的body资源(仅自定义HTML,JS已集成到压缩系统)""" + from djangoblog.plugin_manage.loader import get_loaded_plugins + + resources = [] + + for plugin in get_loaded_plugins(): + try: + # 只处理自定义body HTML(JS文件已通过压缩系统处理) + body_html = plugin.get_body_html(context) + if body_html: + resources.append(body_html) + + except Exception as e: + logger.error(f"Error loading body resources from plugin {plugin.PLUGIN_NAME}: {e}") + + return mark_safe('\n'.join(resources)) + + +@register.inclusion_tag('plugins/css_includes.html') +def plugin_compressed_css(): + """插件CSS压缩包含模板""" + from djangoblog.plugin_manage.loader import get_loaded_plugins + + css_files = [] + for plugin in get_loaded_plugins(): + for css_file in plugin.get_css_files(): + css_url = plugin.get_static_url(css_file) + css_files.append(css_url) + + return {'css_files': css_files} + + +@register.inclusion_tag('plugins/js_includes.html') +def plugin_compressed_js(): + """插件JS压缩包含模板""" + from djangoblog.plugin_manage.loader import get_loaded_plugins + + js_files = [] + for plugin in get_loaded_plugins(): + for js_file in plugin.get_js_files(): + js_url = plugin.get_static_url(js_file) + js_files.append(js_url) + + return {'js_files': js_files} + + + + +@register.simple_tag(takes_context=True) +def plugin_widget(context, plugin_name, widget_type='default', **kwargs): + """ + 渲染指定插件的组件 + + 使用方式: + {% plugin_widget 'article_recommendation' 'bottom' article=article count=5 %} + """ + from djangoblog.plugin_manage.loader import get_plugin_by_slug + + plugin = get_plugin_by_slug(plugin_name) + if plugin and hasattr(plugin, 'render_template'): + try: + widget_context = {**context.flatten(), **kwargs} + template_name = f"{widget_type}.html" + return mark_safe(plugin.render_template(template_name, widget_context)) + except Exception as e: + logger.error(f"Error rendering plugin widget {plugin_name}.{widget_type}: {e}") + + return "" \ No newline at end of file diff --git a/blog/views.py b/blog/views.py index ace9e636..773bb756 100644 --- a/blog/views.py +++ b/blog/views.py @@ -152,6 +152,11 @@ class ArticleDetailView(DetailView): context = super(ArticleDetailView, self).get_context_data(**kwargs) article = self.object + + # 触发文章详情加载钩子,让插件可以添加额外的上下文数据 + from djangoblog.plugin_manage.hook_constants import ARTICLE_DETAIL_LOAD + hooks.run_action(ARTICLE_DETAIL_LOAD, article=article, context=context, request=self.request) + # Action Hook, 通知插件"文章详情已获取" hooks.run_action('after_article_body_get', article=article, request=self.request) return context diff --git a/djangoblog/plugin_manage/base_plugin.py b/djangoblog/plugin_manage/base_plugin.py index 2b4be5cb..df1ce0b4 100644 --- a/djangoblog/plugin_manage/base_plugin.py +++ b/djangoblog/plugin_manage/base_plugin.py @@ -1,4 +1,8 @@ import logging +from pathlib import Path + +from django.template import TemplateDoesNotExist +from django.template.loader import render_to_string logger = logging.getLogger(__name__) @@ -8,13 +12,34 @@ class BasePlugin: PLUGIN_NAME = None PLUGIN_DESCRIPTION = None PLUGIN_VERSION = None + PLUGIN_AUTHOR = None + + # 插件配置 + SUPPORTED_POSITIONS = [] # 支持的显示位置 + DEFAULT_PRIORITY = 100 # 默认优先级(数字越小优先级越高) + POSITION_PRIORITIES = {} # 各位置的优先级 {'sidebar': 50, 'article_bottom': 80} def __init__(self): if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]): raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.") + + # 设置插件路径 + self.plugin_dir = self._get_plugin_directory() + self.plugin_slug = self._get_plugin_slug() + self.init_plugin() self.register_hooks() + def _get_plugin_directory(self): + """获取插件目录路径""" + import inspect + plugin_file = inspect.getfile(self.__class__) + return Path(plugin_file).parent + + def _get_plugin_slug(self): + """获取插件标识符(目录名)""" + return self.plugin_dir.name + def init_plugin(self): """ 插件初始化逻辑 @@ -29,6 +54,129 @@ class BasePlugin: """ pass + # === 位置渲染系统 === + def render_position_widget(self, position, context, **kwargs): + """ + 根据位置渲染插件组件 + + Args: + position: 位置标识 + context: 模板上下文 + **kwargs: 额外参数 + + Returns: + dict: {'html': 'HTML内容', 'priority': 优先级} 或 None + """ + if position not in self.SUPPORTED_POSITIONS: + return None + + # 检查条件显示 + if not self.should_display(position, context, **kwargs): + return None + + # 调用具体的位置渲染方法 + method_name = f'render_{position}_widget' + if hasattr(self, method_name): + html = getattr(self, method_name)(context, **kwargs) + if html: + priority = self.POSITION_PRIORITIES.get(position, self.DEFAULT_PRIORITY) + return { + 'html': html, + 'priority': priority, + 'plugin_name': self.PLUGIN_NAME + } + + return None + + def should_display(self, position, context, **kwargs): + """ + 判断插件是否应该在指定位置显示 + 子类可重写此方法实现条件显示逻辑 + + Args: + position: 位置标识 + context: 模板上下文 + **kwargs: 额外参数 + + Returns: + bool: 是否显示 + """ + return True + + # === 各位置渲染方法 - 子类重写 === + def render_sidebar_widget(self, context, **kwargs): + """渲染侧边栏组件""" + return None + + def render_article_bottom_widget(self, context, **kwargs): + """渲染文章底部组件""" + return None + + def render_article_top_widget(self, context, **kwargs): + """渲染文章顶部组件""" + return None + + def render_header_widget(self, context, **kwargs): + """渲染页头组件""" + return None + + def render_footer_widget(self, context, **kwargs): + """渲染页脚组件""" + return None + + def render_comment_before_widget(self, context, **kwargs): + """渲染评论前组件""" + return None + + def render_comment_after_widget(self, context, **kwargs): + """渲染评论后组件""" + return None + + # === 模板系统 === + def render_template(self, template_name, context=None): + """ + 渲染插件模板 + + Args: + template_name: 模板文件名 + context: 模板上下文 + + Returns: + HTML字符串 + """ + if context is None: + context = {} + + template_path = f"plugins/{self.plugin_slug}/{template_name}" + + try: + return render_to_string(template_path, context) + except TemplateDoesNotExist: + logger.warning(f"Plugin template not found: {template_path}") + return "" + + # === 静态资源系统 === + def get_static_url(self, static_file): + """获取插件静态文件URL""" + from django.templatetags.static import static + return static(f"{self.plugin_slug}/static/{self.plugin_slug}/{static_file}") + + def get_css_files(self): + """获取插件CSS文件列表""" + return [] + + def get_js_files(self): + """获取插件JavaScript文件列表""" + return [] + + def get_head_html(self, context=None): + """获取需要插入到
中的HTML内容""" + return "" + + def get_body_html(self, context=None): + """获取需要插入到底部的HTML内容""" + return "" + def get_plugin_info(self): """ 获取插件信息 @@ -37,5 +185,10 @@ class BasePlugin: return { 'name': self.PLUGIN_NAME, 'description': self.PLUGIN_DESCRIPTION, - 'version': self.PLUGIN_VERSION + 'version': self.PLUGIN_VERSION, + 'author': self.PLUGIN_AUTHOR, + 'slug': self.plugin_slug, + 'directory': str(self.plugin_dir), + 'supported_positions': self.SUPPORTED_POSITIONS, + 'priorities': self.POSITION_PRIORITIES } diff --git a/djangoblog/plugin_manage/hook_constants.py b/djangoblog/plugin_manage/hook_constants.py index 6685b7ce..8ed4e891 100644 --- a/djangoblog/plugin_manage/hook_constants.py +++ b/djangoblog/plugin_manage/hook_constants.py @@ -5,3 +5,18 @@ ARTICLE_DELETE = 'article_delete' ARTICLE_CONTENT_HOOK_NAME = "the_content" +# 位置钩子常量 +POSITION_HOOKS = { + 'article_top': 'article_top_widgets', + 'article_bottom': 'article_bottom_widgets', + 'sidebar': 'sidebar_widgets', + 'header': 'header_widgets', + 'footer': 'footer_widgets', + 'comment_before': 'comment_before_widgets', + 'comment_after': 'comment_after_widgets', +} + +# 资源注入钩子 +HEAD_RESOURCES_HOOK = 'head_resources' +BODY_RESOURCES_HOOK = 'body_resources' + diff --git a/djangoblog/plugin_manage/loader.py b/djangoblog/plugin_manage/loader.py index 12e824ba..ee750d0d 100644 --- a/djangoblog/plugin_manage/loader.py +++ b/djangoblog/plugin_manage/loader.py @@ -4,16 +4,61 @@ from django.conf import settings logger = logging.getLogger(__name__) +# 全局插件注册表 +_loaded_plugins = [] + def load_plugins(): """ Dynamically loads and initializes plugins from the 'plugins' directory. This function is intended to be called when the Django app registry is ready. """ + global _loaded_plugins + _loaded_plugins = [] + for plugin_name in settings.ACTIVE_PLUGINS: plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name) if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')): try: - __import__(f'plugins.{plugin_name}.plugin') - logger.info(f"Successfully loaded plugin: {plugin_name}") + # 导入插件模块 + plugin_module = __import__(f'plugins.{plugin_name}.plugin', fromlist=['plugin']) + + # 获取插件实例 + if hasattr(plugin_module, 'plugin'): + plugin_instance = plugin_module.plugin + _loaded_plugins.append(plugin_instance) + logger.info(f"Successfully loaded plugin: {plugin_name} - {plugin_instance.PLUGIN_NAME}") + else: + logger.warning(f"Plugin {plugin_name} does not have 'plugin' instance") + except ImportError as e: - logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) \ No newline at end of file + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) + except AttributeError as e: + logger.error(f"Failed to get plugin instance: {plugin_name}", exc_info=e) + except Exception as e: + logger.error(f"Unexpected error loading plugin: {plugin_name}", exc_info=e) + +def get_loaded_plugins(): + """获取所有已加载的插件""" + return _loaded_plugins + +def get_plugin_by_name(plugin_name): + """根据名称获取插件""" + for plugin in _loaded_plugins: + if plugin.plugin_slug == plugin_name: + return plugin + return None + +def get_plugin_by_slug(plugin_slug): + """根据slug获取插件""" + for plugin in _loaded_plugins: + if plugin.plugin_slug == plugin_slug: + return plugin + return None + +def get_plugins_info(): + """获取所有插件的信息""" + return [plugin.get_plugin_info() for plugin in _loaded_plugins] + +def get_plugins_by_position(position): + """获取支持指定位置的插件""" + return [plugin for plugin in _loaded_plugins if position in plugin.SUPPORTED_POSITIONS] \ No newline at end of file diff --git a/djangoblog/settings.py b/djangoblog/settings.py index 6f071cec..0bed20d3 100644 --- a/djangoblog/settings.py +++ b/djangoblog/settings.py @@ -177,6 +177,11 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic') STATIC_URL = '/static/' STATICFILES = os.path.join(BASE_DIR, 'static') +# 添加插件静态文件目录 +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, 'plugins'), # 让Django能找到插件的静态文件 +] + AUTH_USER_MODEL = 'accounts.BlogUser' LOGIN_URL = '/login/' @@ -301,19 +306,57 @@ STATICFILES_FINDERS = ( 'compressor.finders.CompressorFinder', ) COMPRESS_ENABLED = True -# COMPRESS_OFFLINE = True +# 根据环境变量决定是否启用离线压缩 +COMPRESS_OFFLINE = os.environ.get('COMPRESS_OFFLINE', 'False').lower() == 'true' + +# 压缩输出目录 +COMPRESS_OUTPUT_DIR = 'compressed' +# 压缩文件名模板 - 包含哈希值用于缓存破坏 +COMPRESS_CSS_HASHING_METHOD = 'mtime' +COMPRESS_JS_HASHING_METHOD = 'mtime' +# 高级CSS压缩过滤器 COMPRESS_CSS_FILTERS = [ - # creates absolute urls from relative ones + # 创建绝对URL 'compressor.filters.css_default.CssAbsoluteFilter', - # css minimizer - 'compressor.filters.cssmin.CSSMinFilter' + # CSS压缩器 - 高压缩等级 + 'compressor.filters.cssmin.CSSCompressorFilter', ] + +# 高级JS压缩过滤器 COMPRESS_JS_FILTERS = [ - 'compressor.filters.jsmin.JSMinFilter' + # JS压缩器 - 高压缩等级 + 'compressor.filters.jsmin.SlimItFilter', ] +# 压缩缓存配置 +COMPRESS_CACHE_BACKEND = 'default' +COMPRESS_CACHE_KEY_FUNCTION = 'compressor.cache.simple_cachekey' + +# 预压缩配置 +COMPRESS_PRECOMPILERS = ( + # 支持SCSS/SASS + ('text/x-scss', 'django_libsass.SassCompiler'), + ('text/x-sass', 'django_libsass.SassCompiler'), +) + +# 压缩性能优化 +COMPRESS_MINT_DELAY = 30 # 压缩延迟(秒) +COMPRESS_MTIME_DELAY = 10 # 修改时间检查延迟 +COMPRESS_REBUILD_TIMEOUT = 2592000 # 重建超时(30天) + +# 压缩等级配置 +COMPRESS_CSS_COMPRESSOR = 'compressor.css.CssCompressor' +COMPRESS_JS_COMPRESSOR = 'compressor.js.JsCompressor' + +# 静态文件缓存配置 +STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' + +# 浏览器缓存配置(通过中间件或服务器配置) +COMPRESS_URL = STATIC_URL +COMPRESS_ROOT = STATIC_ROOT + MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') MEDIA_URL = '/media/' X_FRAME_OPTIONS = 'SAMEORIGIN' @@ -356,5 +399,6 @@ ACTIVE_PLUGINS = [ 'view_count', 'seo_optimizer', 'image_lazy_loading', + 'article_recommendation', ] diff --git a/plugins/article_recommendation/__init__.py b/plugins/article_recommendation/__init__.py new file mode 100644 index 00000000..951f2ffe --- /dev/null +++ b/plugins/article_recommendation/__init__.py @@ -0,0 +1 @@ +# 文章推荐插件 diff --git a/plugins/article_recommendation/plugin.py b/plugins/article_recommendation/plugin.py new file mode 100644 index 00000000..6656a07c --- /dev/null +++ b/plugins/article_recommendation/plugin.py @@ -0,0 +1,205 @@ +import logging +from djangoblog.plugin_manage.base_plugin import BasePlugin +from djangoblog.plugin_manage import hooks +from djangoblog.plugin_manage.hook_constants import ARTICLE_DETAIL_LOAD +from blog.models import Article + +logger = logging.getLogger(__name__) + + +class ArticleRecommendationPlugin(BasePlugin): + PLUGIN_NAME = '文章推荐' + PLUGIN_DESCRIPTION = '智能文章推荐系统,支持多位置展示' + PLUGIN_VERSION = '1.0.0' + PLUGIN_AUTHOR = 'liangliangyy' + + # 支持的位置 + SUPPORTED_POSITIONS = ['article_bottom'] + + # 各位置优先级 + POSITION_PRIORITIES = { + 'article_bottom': 80, # 文章底部优先级 + } + + # 插件配置 + CONFIG = { + 'article_bottom_count': 8, # 文章底部推荐数量 + 'sidebar_count': 5, # 侧边栏推荐数量 + 'enable_category_fallback': True, # 启用分类回退 + 'enable_popular_fallback': True, # 启用热门文章回退 + } + + def register_hooks(self): + """注册钩子""" + hooks.register(ARTICLE_DETAIL_LOAD, self.on_article_detail_load) + + def on_article_detail_load(self, article, context, request, *args, **kwargs): + """文章详情页加载时的处理""" + # 可以在这里预加载推荐数据到context中 + recommendations = self.get_recommendations(article) + context['article_recommendations'] = recommendations + + def should_display(self, position, context, **kwargs): + """条件显示逻辑""" + # 只在文章详情页底部显示 + if position == 'article_bottom': + article = kwargs.get('article') or context.get('article') + # 检查是否有文章对象,以及是否不是索引页面 + is_index = context.get('isindex', False) if hasattr(context, 'get') else False + return article is not None and not is_index + + return False + + def render_article_bottom_widget(self, context, **kwargs): + """渲染文章底部推荐""" + article = kwargs.get('article') or context.get('article') + if not article: + return None + + # 使用配置的数量,也可以通过kwargs覆盖 + count = kwargs.get('count', self.CONFIG['article_bottom_count']) + recommendations = self.get_recommendations(article, count=count) + if not recommendations: + return None + + # 将RequestContext转换为普通字典 + context_dict = {} + if hasattr(context, 'flatten'): + context_dict = context.flatten() + elif hasattr(context, 'dicts'): + # 合并所有上下文字典 + for d in context.dicts: + context_dict.update(d) + + template_context = { + 'recommendations': recommendations, + 'article': article, + 'title': '相关推荐', + **context_dict + } + + return self.render_template('bottom_widget.html', template_context) + + def render_sidebar_widget(self, context, **kwargs): + """渲染侧边栏推荐""" + article = context.get('article') + + # 使用配置的数量,也可以通过kwargs覆盖 + count = kwargs.get('count', self.CONFIG['sidebar_count']) + + if article: + # 文章页面,显示相关文章 + recommendations = self.get_recommendations(article, count=count) + title = '相关文章' + else: + # 其他页面,显示热门文章 + recommendations = self.get_popular_articles(count=count) + title = '热门推荐' + + if not recommendations: + return None + + # 将RequestContext转换为普通字典 + context_dict = {} + if hasattr(context, 'flatten'): + context_dict = context.flatten() + elif hasattr(context, 'dicts'): + # 合并所有上下文字典 + for d in context.dicts: + context_dict.update(d) + + template_context = { + 'recommendations': recommendations, + 'title': title, + **context_dict + } + + return self.render_template('sidebar_widget.html', template_context) + + def get_css_files(self): + """返回CSS文件""" + return ['css/recommendation.css'] + + def get_js_files(self): + """返回JS文件""" + return ['js/recommendation.js'] + + def get_recommendations(self, article, count=5): + """获取推荐文章""" + if not article: + return [] + + recommendations = [] + + # 1. 基于标签的推荐 + if article.tags.exists(): + tag_ids = list(article.tags.values_list('id', flat=True)) + tag_based = list(Article.objects.filter( + status='p', + tags__id__in=tag_ids + ).exclude( + id=article.id + ).exclude( + title__isnull=True + ).exclude( + title__exact='' + ).distinct().order_by('-views')[:count]) + recommendations.extend(tag_based) + + # 2. 如果数量不够,基于分类推荐 + if len(recommendations) < count and self.CONFIG['enable_category_fallback']: + needed = count - len(recommendations) + existing_ids = [r.id for r in recommendations] + [article.id] + + category_based = list(Article.objects.filter( + status='p', + category=article.category + ).exclude( + id__in=existing_ids + ).exclude( + title__isnull=True + ).exclude( + title__exact='' + ).order_by('-views')[:needed]) + recommendations.extend(category_based) + + # 3. 如果还是不够,推荐热门文章 + if len(recommendations) < count and self.CONFIG['enable_popular_fallback']: + needed = count - len(recommendations) + existing_ids = [r.id for r in recommendations] + [article.id] + + popular_articles = list(Article.objects.filter( + status='p' + ).exclude( + id__in=existing_ids + ).exclude( + title__isnull=True + ).exclude( + title__exact='' + ).order_by('-views')[:needed]) + recommendations.extend(popular_articles) + + # 过滤掉无效的推荐 + valid_recommendations = [] + for rec in recommendations: + if rec.title and len(rec.title.strip()) > 0: + valid_recommendations.append(rec) + else: + logger.warning(f"过滤掉空标题文章: ID={rec.id}, 标题='{rec.title}'") + + # 调试:记录推荐结果 + logger.info(f"原始推荐数量: {len(recommendations)}, 有效推荐数量: {len(valid_recommendations)}") + for i, rec in enumerate(valid_recommendations): + logger.info(f"推荐 {i+1}: ID={rec.id}, 标题='{rec.title}', 长度={len(rec.title)}") + + return valid_recommendations[:count] + + def get_popular_articles(self, count=3): + """获取热门文章""" + return list(Article.objects.filter( + status='p' + ).order_by('-views')[:count]) + + +# 实例化插件 +plugin = ArticleRecommendationPlugin() diff --git a/plugins/article_recommendation/static/article_recommendation/css/recommendation.css b/plugins/article_recommendation/static/article_recommendation/css/recommendation.css new file mode 100644 index 00000000..b223f418 --- /dev/null +++ b/plugins/article_recommendation/static/article_recommendation/css/recommendation.css @@ -0,0 +1,166 @@ +/* 文章推荐插件样式 - 与网站风格保持一致 */ + +/* 文章底部推荐样式 */ +.article-recommendations { + margin: 30px 0; + padding: 20px; + background: #fff; + border: 1px solid #e1e1e1; + border-radius: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.recommendations-title { + margin: 0 0 15px 0; + font-size: 18px; + color: #444; + font-weight: bold; + padding-bottom: 8px; + border-bottom: 2px solid #21759b; + display: inline-block; +} + +.recommendations-icon { + margin-right: 5px; + font-size: 16px; +} + +.recommendations-grid { + display: grid; + gap: 15px; + grid-template-columns: 1fr; + margin-top: 15px; +} + +.recommendation-card { + background: #fff; + border: 1px solid #e1e1e1; + border-radius: 3px; + transition: all 0.2s ease; + overflow: hidden; +} + +.recommendation-card:hover { + border-color: #21759b; + box-shadow: 0 2px 5px rgba(33, 117, 155, 0.1); +} + +.recommendation-link { + display: block; + padding: 15px; + text-decoration: none; + color: inherit; +} + +.recommendation-title { + margin: 0 0 8px 0; + font-size: 15px; + font-weight: normal; + color: #444; + line-height: 1.4; + transition: color 0.2s ease; +} + +.recommendation-card:hover .recommendation-title { + color: #21759b; +} + +.recommendation-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: #757575; +} + +.recommendation-category { + background: #ebebeb; + color: #5e5e5e; + padding: 2px 6px; + border-radius: 2px; + font-size: 11px; + font-weight: normal; +} + +.recommendation-date { + font-weight: normal; + color: #757575; +} + +/* 侧边栏推荐样式 */ +.widget_recommendations { + margin-bottom: 20px; +} + +.widget_recommendations .widget-title { + font-size: 16px; + font-weight: bold; + margin-bottom: 15px; + color: #333; + border-bottom: 2px solid #007cba; + padding-bottom: 5px; +} + +.recommendations-list { + list-style: none; + padding: 0; + margin: 0; +} + +.recommendations-list .recommendation-item { + padding: 8px 0; + border-bottom: 1px solid #eee; + background: none; + border: none; + border-radius: 0; +} + +.recommendations-list .recommendation-item:last-child { + border-bottom: none; +} + +.recommendations-list .recommendation-item a { + color: #333; + text-decoration: none; + font-size: 14px; + line-height: 1.4; + display: block; + margin-bottom: 4px; + transition: color 0.3s ease; +} + +.recommendations-list .recommendation-item a:hover { + color: #007cba; +} + +.recommendations-list .recommendation-meta { + font-size: 11px; + color: #999; + margin: 0; +} + +.recommendations-list .recommendation-meta span { + margin-right: 10px; +} + +/* 响应式设计 - 分栏显示 */ +@media (min-width: 768px) { + .recommendations-grid { + grid-template-columns: repeat(2, 1fr); + gap: 15px; + } +} + +@media (min-width: 1024px) { + .recommendations-grid { + grid-template-columns: repeat(3, 1fr); + gap: 15px; + } +} + +@media (min-width: 1200px) { + .recommendations-grid { + grid-template-columns: repeat(4, 1fr); + gap: 15px; + } +} diff --git a/plugins/article_recommendation/static/article_recommendation/js/recommendation.js b/plugins/article_recommendation/static/article_recommendation/js/recommendation.js new file mode 100644 index 00000000..eb192119 --- /dev/null +++ b/plugins/article_recommendation/static/article_recommendation/js/recommendation.js @@ -0,0 +1,93 @@ +/** + * 文章推荐插件JavaScript + */ + +(function() { + 'use strict'; + + // 等待DOM加载完成 + document.addEventListener('DOMContentLoaded', function() { + initRecommendations(); + }); + + function initRecommendations() { + // 添加点击统计 + trackRecommendationClicks(); + + // 懒加载优化(如果需要) + lazyLoadRecommendations(); + } + + function trackRecommendationClicks() { + const recommendationLinks = document.querySelectorAll('.recommendation-item a'); + + recommendationLinks.forEach(function(link) { + link.addEventListener('click', function(e) { + // 可以在这里添加点击统计逻辑 + const articleTitle = this.textContent.trim(); + const articleUrl = this.href; + + // 发送统计数据到后端(可选) + if (typeof gtag !== 'undefined') { + gtag('event', 'click', { + 'event_category': 'recommendation', + 'event_label': articleTitle, + 'value': 1 + }); + } + + console.log('Recommendation clicked:', articleTitle, articleUrl); + }); + }); + } + + function lazyLoadRecommendations() { + // 如果推荐内容很多,可以实现懒加载 + const recommendationContainer = document.querySelector('.article-recommendations'); + + if (!recommendationContainer) { + return; + } + + // 检查是否在视窗中 + const observer = new IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + if (entry.isIntersecting) { + entry.target.classList.add('loaded'); + observer.unobserve(entry.target); + } + }); + }, { + threshold: 0.1 + }); + + const recommendationItems = document.querySelectorAll('.recommendation-item'); + recommendationItems.forEach(function(item) { + observer.observe(item); + }); + } + + // 添加一些动画效果 + function addAnimations() { + const recommendationItems = document.querySelectorAll('.recommendation-item'); + + recommendationItems.forEach(function(item, index) { + item.style.opacity = '0'; + item.style.transform = 'translateY(20px)'; + item.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; + + setTimeout(function() { + item.style.opacity = '1'; + item.style.transform = 'translateY(0)'; + }, index * 100); + }); + } + + // 如果需要,可以在这里添加更多功能 + window.ArticleRecommendation = { + init: initRecommendations, + track: trackRecommendationClicks, + animate: addAnimations + }; + +})(); diff --git a/templates/blog/tags/article_info.html b/templates/blog/tags/article_info.html index 7af76175..65b45fa2 100644 --- a/templates/blog/tags/article_info.html +++ b/templates/blog/tags/article_info.html @@ -71,4 +71,9 @@ {% load_article_metas article user %} - \ No newline at end of file + + + +{% if not isindex %} + {% render_plugin_widgets 'article_bottom' article=article %} +{% endif %} \ No newline at end of file diff --git a/templates/blog/tags/article_meta_info.html b/templates/blog/tags/article_meta_info.html index cb6111c7..ec8a0f95 100644 --- a/templates/blog/tags/article_meta_info.html +++ b/templates/blog/tags/article_meta_info.html @@ -5,9 +5,6 @@ diff --git a/templates/plugins/article_recommendation/__init__.py b/templates/plugins/article_recommendation/__init__.py new file mode 100644 index 00000000..7d86a997 --- /dev/null +++ b/templates/plugins/article_recommendation/__init__.py @@ -0,0 +1 @@ +# 插件模板目录 diff --git a/templates/plugins/article_recommendation/bottom_widget.html b/templates/plugins/article_recommendation/bottom_widget.html new file mode 100644 index 00000000..829b7b4e --- /dev/null +++ b/templates/plugins/article_recommendation/bottom_widget.html @@ -0,0 +1,23 @@ +{% load i18n %} +