2188 字
11 分钟

我为 Astro Firefly 主题构建了一款智能文章推荐的组件

Firefly 主题是我现在正在使用的一款基于模块化设计的美观、轻盈的 Astro 主题,最近在使用的过程中发现个问题:文章页太单薄。用户看完一篇文章大概率就走了,页面之间没什么连接,“文章”成了孤岛,跳出率肯定高。

网上看到一位博主在分享他自己博客网站的文章推荐的思路,我借鉴了一些想法,和 Claude Code 整出了一个智能推荐组件。基于标签匹配和时间权重,给用户推相关性高的文章。

它长这样:

Dark 模式

组件的功能特性#

  • 智能标签匹配:基于文章标签自动推荐相关内容
  • 时间权重算法:优先推荐较新的文章(6 个月内权重更高)
  • 随机补充机制:当标签匹配不足时,智能补充随机推荐
  • 综合评分系统:匹配度 + 时间新鲜度双重评分
  • 精美 UI 设计:卡片式布局,序号样式区分推荐类型,完美适配 Firefly 主题配色
  • 深色模式支持:自动适配明暗主题,统一使用主题色背景
  • 响应式设计:完美支持移动端和桌面端
  • SEO 友好:服务端预计算推荐结果,搜索引擎可直接抓取推荐链接
  • 性能优化:减少 HTML 体积,提升页面加载速度

组件的推荐逻辑#

默认推荐 6 篇文章,70% 来自标签匹配(最多 4 篇)高相关性,最新文章优先展示;30% 随机推荐(最多 2 篇)作为兜底和补充。理论上不会出现无相关推荐的尴尬时刻。

推荐列表的去重与排序:

按标题去重,按标签匹配优先 > 同类型按评分排序,取前 6 篇。


如何安装使用#

如果你也喜欢这个组件,恰巧也正在使用 Astro 框架 + Firefly 主题,可以直接复制我的代码按下面的步骤集成到你的网站。

创建组件#

将下面完整代码保存到 src/components/features/Recommender.astro

src/components/features/RecommenderSEO.astro
---
// SEO 优化版:服务端预计算推荐结果
import { getCollection } from "astro:content";
import { removeFileExtension } from "@utils/url-utils";
interface Props {
post: {
id: string;
data: {
title: string;
tags?: string[];
published?: string | Date;
};
};
}
const { post } = Astro.props;
// 获取所有文章
const allPosts = (await getCollection("posts"))
.sort((a, b) => {
const dateA = new Date(a.data.published);
const dateB = new Date(b.data.published);
return dateB - dateA;
})
.map((post) => {
const slug = removeFileExtension(post.id);
return {
title: post.data.title,
slug: slug,
tags: post.data.tags || [],
published: post.data.published,
};
});
// 当前文章
const currentPostSlug = removeFileExtension(post.id);
const currentPost = {
title: post.data.title,
slug: currentPostSlug,
tags: post.data.tags || [],
published: post.data.published,
};
// ========== 服务端计算推荐结果 ==========
const maxRecommandItemCount = 6;
const recommendations = [];
// 排除当前文章
const otherPosts = allPosts.filter((article) => article.slug !== currentPostSlug);
// 1. 标签匹配推荐(70% 权重)
let tagMatches = [];
if (currentPost.tags && currentPost.tags.length > 0) {
const currentTags = new Set(currentPost.tags);
const now = new Date();
otherPosts.forEach(article => {
const articleTags = new Set(article.tags || []);
const intersection = new Set([...currentTags].filter(tag => articleTags.has(tag)));
if (intersection.size > 0) {
// 计算时间权重(最近6个月的文章权重更高)
const articleDate = new Date(article.published);
const daysDiff = (now - articleDate) / (1000 * 60 * 60 * 24);
const timeWeight = Math.max(0, 1 - daysDiff / 180); // 180天 = 6个月
tagMatches.push({
title: article.title,
slug: article.slug,
url: `/posts/${article.slug}/`,
type: 'tag-match',
matchingTags: [...intersection],
score: intersection.size * 10 + timeWeight * 5,
published: article.published
});
}
});
tagMatches.sort((a, b) => b.score - a.score);
}
// 2. 随机推荐(30% 权重)
const randomPool = otherPosts
.filter(article => {
return !tagMatches.some(match => match.slug === article.slug);
})
.map(article => ({
title: article.title,
slug: article.slug,
url: `/posts/${article.slug}/`,
type: 'random',
tags: article.tags || [],
published: article.published,
score: ((article.tags || []).length > 0 ? 3 : 0)
}))
.sort((a, b) => b.score - a.score || Math.random() - 0.5)
.slice(0, Math.max(2, maxRecommandItemCount - tagMatches.length));
// 3. 智能混合
const tagMatchCount = Math.min(4, tagMatches.length);
const randomCount = Math.min(2, randomPool.length);
recommendations.push(...tagMatches.slice(0, tagMatchCount));
recommendations.push(...randomPool.slice(0, randomCount));
// 4. 去重并排序
const finalResults = recommendations
.filter((v, i, a) => a.findIndex((t) => t.title === v.title) === i)
.sort((a, b) => {
if (a.type === 'tag-match' && b.type === 'random') return -1;
if (a.type === 'random' && b.type === 'tag-match') return 1;
if (a.score !== undefined && b.score !== undefined) return b.score - a.score;
return Math.random() - 0.5;
})
.slice(0, maxRecommandItemCount);
// 5. Fallback
if (finalResults.length === 0) {
const fallbackPosts = otherPosts.slice(0, maxRecommandItemCount);
finalResults.push(
...fallbackPosts.map((article) => ({
title: article.title,
url: `/posts/${article.slug}/`,
type: 'fallback',
published: article.published
}))
);
}
// ========== 辅助函数 ==========
const formatTime = (dateStr: string | Date) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return '今天';
if (diffDays === 1) return '昨天';
if (diffDays < 7) return `${diffDays} 天前`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} 周前`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} 月前`;
return `${Math.floor(diffDays / 365)} 年前`;
};
const getTagColor = () => {
return 'bg-[var(--primary)]/10 text-[var(--primary)] border-[var(--primary)]/20';
};
const getNumberStyle = (index: number, article: any) => {
if (article.type === 'tag-match') {
return 'bg-[var(--primary)] shadow-lg shadow-[var(--primary)]/30';
} else {
return 'bg-gradient-to-br from-[var(--primary)] to-[var(--primary)]/80 shadow-lg shadow-[var(--primary)]/30';
}
};
---
<div class="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<div class="mb-6 onload-animation">
<div class="flex items-center justify-between mb-6">
<h3 class="text-2xl font-bold text-black/90 dark:text-white/90">推荐文章</h3>
<span class="text-sm text-gray-500 dark:text-gray-400">基于标签匹配 · 智能推荐</span>
</div>
{finalResults.length > 0 ? (
<div class="space-y-4">
{finalResults.map((article, index) => {
const timeAgo = formatTime(article.published);
const numberStyle = getNumberStyle(index, article);
const tagColor = getTagColor();
return (
<a
href={article.url}
class="group block relative overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-700/50 dark:bg-[var(--primary)]/15 hover:border-[var(--primary)] hover:shadow-xl hover:shadow-[var(--primary)]/15 hover:-translate-y-1 transition-all duration-300"
>
<div class="absolute inset-0 bg-gradient-to-r from-[var(--primary)]/0 via-[var(--primary)]/0 to-[var(--primary)]/0 group-hover:from-[var(--primary)]/5 group-hover:via-[var(--primary)]/10 group-hover:to-[var(--primary)]/5 transition-all duration-500"></div>
<div class="relative p-5 md:p-6">
<div class="flex items-start gap-4">
{/* 序号徽章 */}
<div class="flex-shrink-0">
<div class={`w-12 h-12 rounded-xl ${numberStyle} flex items-center justify-center group-hover:scale-110 group-hover:rotate-3 transition-all duration-300`}>
<span class="text-white font-bold text-lg">{index + 1}</span>
</div>
</div>
{/* 内容区域 */}
<div class="flex-1 min-w-0">
<div class="font-semibold text-black/90 dark:text-white/90 group-hover:text-[var(--primary)] transition-colors mb-2" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; line-height: 1.5; min-height: 3rem;">
{article.title}
</div>
<div class="flex items-center gap-2">
{timeAgo && (
<div class="flex items-center text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{timeAgo}
</div>
)}
<div class="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
{(article as any).matchingTags && (article as any).matchingTags.length > 0 ? (
<div class="flex items-center gap-1.5">
{(article as any).matchingTags.length > 0 && (
<span class={`inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium ${tagColor} border hover:scale-105 transition-transform flex-shrink-0`}>
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>
{(article as any).matchingTags[0]}
</span>
)}
{(article as any).matchingTags.length > 1 && (
<span class={`inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium ${tagColor} border hover:scale-105 transition-transform flex-shrink-0 hidden md:inline-flex`}>
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>
{(article as any).matchingTags[1]}
</span>
)}
{(article as any).matchingTags.length > 2 && (
<span class={`inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium ${tagColor} border hover:scale-105 transition-transform flex-shrink-0 hidden lg:inline-flex`}>
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>
{(article as any).matchingTags[2]}
</span>
)}
{/* 移动端和平板:+{length-1} */}
{(article as any).matchingTags.length > 1 && (
<span class={`inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium ${tagColor} border flex-shrink-0 lg:hidden`}>
+{(article as any).matchingTags.length - 1}
</span>
)}
{/* 桌面端:+{length-3} */}
{(article as any).matchingTags.length > 3 && (
<span class={`inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium ${tagColor} border flex-shrink-0 hidden lg:inline-flex`}>
+{(article as any).matchingTags.length - 3}
</span>
)}
</div>
) : article.tags && article.tags.length > 0 ? (
<div class="flex items-center gap-1.5">
{article.tags.length > 0 && (
<span class={`inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium ${tagColor} border flex-shrink-0`}>
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>
{article.tags[0]}
</span>
)}
{article.tags.length > 1 && (
<span class={`inline-flex items-center px-2 py-1 rounded-lg text-xs font-medium ${tagColor} border flex-shrink-0 hidden md:inline-flex`}>
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>
{article.tags[1]}
</span>
)}
</div>
) : null}
</div>
</div>
</div>
{/* 箭头图标 */}
<div class="flex-shrink-0 self-center">
<div class="w-9 h-9 rounded-xl bg-[var(--primary)]/5 border border-[var(--primary)]/10 flex items-center justify-center opacity-0 group-hover:opacity-100 group-hover:translate-x-0 -translate-x-3 group-hover:bg-[var(--primary)]/10 transition-all duration-300">
<svg class="w-5 h-5 text-[var(--primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</div>
</div>
</div>
</div>
</a>
);
})}
</div>
) : (
<div class="text-center py-16 rounded-2xl border-2 border-dashed border-gray-300 dark:border-gray-700 bg-gradient-to-br from-gray-50 to-white dark:from-gray-800/50 dark:to-gray-900/50">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
<h4 class="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">暂无相关推荐</h4>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">试试浏览其他文章分类</p>
<a href="/posts/" class="inline-flex items-center px-6 py-2.5 rounded-xl bg-[var(--primary)] text-white font-medium hover:bg-[var(--primary)]/90 transition-colors">
浏览所有文章
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</a>
</div>
)}
</div>
</div>

在文章页面中调用#

src/pages/posts/[...slug].astro 文件中增加下面代码(⚠️注意是增加不是覆盖)导入并使用组件:

---
import Recommender from "@components/features/Recommender.astro";
---
<!-- 在文章内容后面添加推荐模块 -->
<div class="mb-8">
<Recommender post={entry} />
</div>

推荐放在文章内容和评论之间,效果最佳。

另外,有需要调整配置的话,可以直接改代码里的参数。maxRecommandItemCount 改推荐数量,timeWeight 计算里的 180 改时间权重周期(默认 180 天 = 6 个月)。文章路径不是 /posts/ 的话,把 url 那行改成你的路径。


写在最后#

整体下来,这个组件的 Vibe-Coding (与 Claude Code 交互) 的过程很轻松、很愉快,调样式花的时间最多,其他逻辑沟通挺顺畅。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
我为 Astro Firefly 主题构建了一款智能文章推荐的组件
https://blog.moewah.com/posts/astro-firefly-smart-article-recommendation-component/
作者
GoWah
发布于
2026-01-28
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
GoWah
Hello, I'm GoWah.
分类
标签
站点统计
文章
160
分类
9
标签
350
总字数
301,106
运行时长
0
最后活动
0 天前

目录