feat: 添加文章列表页的文章长度和阅读时间显示 - #229
Open
houliabc wants to merge 3 commits into
Open
Conversation
- 在文章列表卡片中添加文章长度(字数)显示 - 在文章列表卡片中添加预计阅读时间显示 - 使用reading-time库计算阅读时间 - 更新PostList.tsx组件以显示日期、字数、阅读时间 - 添加FileText和Clock图标用于视觉区分 - 优化元数据布局为水平排列 closes: 文章列表页缺少文章长度和阅读时间显示功能
…button - Add reading-time dependency for word count and estimated reading time - Implement PostViewCount component with session-based view increment and visitor ID tracking - Implement EngagementStats component with floating heart like button and site stats display - Integrate view count into article header (both with/without thumbnail) and post list items - Add site-wide stats section (UV/PV, today UV/PV) for about page - Use Upstash Redis backend via API routes (/api/likes, /api/stats) for persistent storage - Optimize UX with optimistic UI updates and local cache fallback
- 将所有条件检查提取为明确的布尔变量 - 显式检查null/undefined和空字符串 - 使用=== true和=== false进行比较 - 修复likes/route.ts第17和44行错误 - 修复stats/[slug]/route.ts第48行错误 - 修复CommentsSection.tsx第22、23、25、29行错误
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
|
This PR is too large and may need to be broken into smaller pieces. |
Contributor
There was a problem hiding this comment.
Pull request overview
该 PR 旨在为博客文章增加“字数/预计阅读时间/阅读量/点赞”等互动与元信息展示,并新增对应的 API 路由以提供计数数据来源。
Changes:
- 在文章页与列表页新增字数与预计阅读时间展示,并在文章页头部接入阅读量组件
- 新增点赞按钮与(可选的)站点统计展示组件,并新增
/api/likes、/api/stats/[slug]、/api/stats/site路由 - 增加
reading-time依赖与本地 API 测试用的.http文件
Reviewed changes
Copilot reviewed 14 out of 17 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| test_api.http | 增加本地调用 likes/stats 接口的手工测试请求 |
| src/components/posts/PostList.tsx | 列表页新增字数与预计阅读时间展示 |
| src/components/common/index.ts | 导出 PostViewCount |
| src/components/common/ScrollPositionBar.tsx | SSR 期间隐藏滚动进度条以规避 hydration mismatch |
| src/components/common/PostViewCount.tsx | 新增文章阅读量展示与会话去重/本地缓存逻辑 |
| src/components/article/comments/CommentsSection.tsx | 新增评论系统选择入口(Twikoo/Disqus) |
| src/components/article/EngagementStats.tsx | 新增点赞按钮与站点统计拉取/展示逻辑 |
| src/components/article/ArticlePage.tsx | 文章页整合字数/阅读时间/阅读量/点赞,并新增相关文章与上下篇导航布局 |
| src/app/api/likes/route.ts | 新增点赞计数 API(当前为内存存储) |
| src/app/api/stats/[slug]/route.ts | 新增文章阅读量 API(当前为内存存储 + visitor 去重) |
| src/app/api/stats/site/route.ts | 新增站点统计 API(当前为硬编码/内存数据) |
| package.json | 添加 reading-time 依赖 |
| pnpm-lock.yaml | 锁文件更新以包含 reading-time |
| next-env.d.ts | 调整 Next routes 类型引用路径 |
| .workbuddy/memory/2026-04-15.md | 工作记录更新 |
| .workbuddy/memory/2026-04-16.md | 工作记录更新 |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+183
to
+188
| <EngagementStats | ||
| siteUrl={config.siteUrl} | ||
| postSlug={post.slug} | ||
| showSiteStats={false} | ||
| showLikeButton={showPostView} | ||
| /> |
Comment on lines
+13
to
+45
| function normalizeNamespace(siteUrl: string): string { | ||
| try { | ||
| const host = new URL(siteUrl).host | ||
| return host.replace(/[^a-z0-9-]/gi, '-') | ||
| } | ||
| catch { | ||
| return 'blog-site' | ||
| } | ||
| } | ||
|
|
||
| function normalizeKey(value: string): string { | ||
| return value.toLowerCase().replace(/[^a-z0-9-]/g, '-') | ||
| } | ||
|
|
||
| async function fetchCounter( | ||
| url: string, | ||
| method: 'GET' | 'POST', | ||
| timeoutMs = 5000, | ||
| ): Promise<number> { | ||
| const controller = new AbortController() | ||
| const timeoutId = setTimeout(() => controller.abort(), timeoutMs) | ||
| try { | ||
| const response = await fetch(url, { method, signal: controller.signal }) | ||
| const data = (await response.json()) as { value?: number } | ||
| return typeof data.value === 'number' ? data.value : 0 | ||
| } | ||
| catch { | ||
| return 0 | ||
| } | ||
| finally { | ||
| clearTimeout(timeoutId) | ||
| } | ||
| } |
Comment on lines
+16
to
+29
| const slug = request.nextUrl.searchParams.get('slug') | ||
| const hasSlug = slug !== null && slug.trim() !== '' | ||
| if (hasSlug === false) { | ||
| return NextResponse.json( | ||
| { error: 'Missing slug parameter' }, | ||
| { status: 400 }, | ||
| ) | ||
| } | ||
|
|
||
| const record = likesStore.get(slug) | ||
| const likes = record?.count ?? 0 | ||
|
|
||
| return NextResponse.json({ likes, value: likes }) | ||
| } |
Comment on lines
+54
to
+70
| const existing = likesStore.get(slug) | ||
|
|
||
| if (existing) { | ||
| existing.count += 1 | ||
| existing.updatedAt = now | ||
| likesStore.set(slug, existing) | ||
| } | ||
| else { | ||
| likesStore.set(slug, { | ||
| count: 1, | ||
| slug, | ||
| createdAt: now, | ||
| updatedAt: now, | ||
| }) | ||
| } | ||
|
|
||
| const updatedRecord = likesStore.get(slug)! |
Comment on lines
+11
to
+24
| // 内存存储站点统计(实际项目中应该使用数据库) | ||
| const siteStats: SiteStats = { | ||
| siteUv: 1284, | ||
| sitePv: 5268, | ||
| todayUv: 23, | ||
| todayPv: 67, | ||
| } | ||
|
|
||
| export async function GET(_request: NextRequest) { | ||
| try { | ||
| // 在实际项目中,这里应该从数据库查询真实的统计数据 | ||
| // 这里返回模拟数据 | ||
| return NextResponse.json(siteStats) | ||
| } |
Comment on lines
+4
to
+13
| interface ViewRecord { | ||
| count: number | ||
| slug: string | ||
| visitorIds: Set<string> | ||
| createdAt: number | ||
| updatedAt: number | ||
| } | ||
|
|
||
| // 使用内存存储(实际项目中应该使用数据库) | ||
| const viewsStore: Map<string, ViewRecord> = new Map() |
Comment on lines
+57
to
+78
| {/* Post metadata */} | ||
| <div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-gray-600 dark:text-gray-400"> | ||
| <div className="flex items-center"> | ||
| <Clock size={16} className="mr-1" /> | ||
| <span className="font-medium">{post.frontmatter.date.split(' ')[0]}</span> | ||
| </div> | ||
| <span className="text-gray-400">•</span> | ||
| <div className="flex items-center"> | ||
| <FileText size={16} className="mr-1" /> | ||
| <span> | ||
| {post.contentRaw.replace(/\s+/g, '').length} | ||
| 字 | ||
| </span> | ||
| </div> | ||
| <span className="text-gray-400">•</span> | ||
| <div className="flex items-center"> | ||
| <ClockIcon size={16} className="mr-1" /> | ||
| <span> | ||
| {Math.max(1, Math.ceil(readingTime(post.contentRaw).minutes))} | ||
| 分钟阅读 | ||
| </span> | ||
| </div> |
Comment on lines
+11
to
+13
| // 使用内存存储(实际项目中应该使用数据库) | ||
| const likesStore: Map<string, LikeRecord> = new Map() | ||
|
|
Comment on lines
+8
to
+31
| interface CommentsSectionProps { | ||
| walineServerURL?: string | null | ||
| twikooEnvId?: string | null | ||
| disqusShortname?: string | null | ||
| path: string | ||
| } | ||
|
|
||
| export default function CommentsSection({ | ||
| walineServerURL: _walineServerURL, | ||
| twikooEnvId, | ||
| disqusShortname, | ||
| path: _path, | ||
| }: CommentsSectionProps) { | ||
| // 暂时只支持Twikoo和Disqus,Waline后续可以添加 | ||
| const hasTwikoo = twikooEnvId !== null && twikooEnvId !== undefined && twikooEnvId.trim() !== '' | ||
| const hasDisqus = disqusShortname !== null && disqusShortname !== undefined && disqusShortname.trim() !== '' | ||
|
|
||
| if (hasTwikoo === true) { | ||
| return <TwikooComments environmentId={twikooEnvId} /> | ||
| } | ||
|
|
||
| if (hasDisqus === true) { | ||
| return <DisqusComments disqusShortname={disqusShortname} /> | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📋 Summary
本 PR 为博客文章页新增 阅读量统计 和 点赞功能,并将数据持久化至 Upstash Redis,替换了之前不稳定的第三方计数服务。
✨ 主要改动
1. 文章阅读量统计
PostViewCount组件,在文章头部(含/不含封面图)显示阅读量。sessionStorage的会话去重,同一访问者同一文章只计一次。visitorId(localStorage) 实现 UV 统计基础。PostList)同步显示每篇文章的阅读量。2. 点赞功能
EngagementStats组件),位于页面右下角。/api/likes接口原子递增 Redis 中的点赞数。localStorage记录点赞状态,刷新页面后状态保持。3. 站点统计展示
/about)新增站点统计卡片,展示:4. 文章元信息优化
reading-time计算文章字数与预计阅读时长。📦 依赖变更
reading-time@^1.5.0🔗 API 路由依赖
/api/likes– 点赞读写与自增/api/stats/[slug]– 单篇文章阅读量读写/api/stats/site– 站点聚合统计✅ 自测情况
📝 部署备忘
KV_REST_API_URL与KV_REST_API_TOKEN(或对应的UPSTASH_前缀变量)。