Skip to content

feat: 添加文章列表页的文章长度和阅读时间显示 - #229

Open
houliabc wants to merge 3 commits into
ZL-Asica:mainfrom
houliabc:main
Open

feat: 添加文章列表页的文章长度和阅读时间显示#229
houliabc wants to merge 3 commits into
ZL-Asica:mainfrom
houliabc:main

Conversation

@houliabc

Copy link
Copy Markdown

📋 Summary

本 PR 为博客文章页新增 阅读量统计点赞功能,并将数据持久化至 Upstash Redis,替换了之前不稳定的第三方计数服务。

✨ 主要改动

1. 文章阅读量统计

  • 新增 PostViewCount 组件,在文章头部(含/不含封面图)显示阅读量。
  • 支持基于 sessionStorage 的会话去重,同一访问者同一文章只计一次。
  • 通过 visitorId (localStorage) 实现 UV 统计基础。
  • 文章列表页(PostList)同步显示每篇文章的阅读量。

2. 点赞功能

  • 新增悬浮爱心按钮(EngagementStats 组件),位于页面右下角。
  • 点击后乐观更新 UI,并通过 /api/likes 接口原子递增 Redis 中的点赞数。
  • 本地防重复:使用 localStorage 记录点赞状态,刷新页面后状态保持。
  • 失败重试机制:请求失败时标记 pending,下次进入页面自动同步。

3. 站点统计展示

  • 在关于页面(/about)新增站点统计卡片,展示:
    • 总访客数 (Site UV)
    • 总访问量 (Site PV)
    • 今日访客 (Today UV)
    • 今日访问量 (Today PV)

4. 文章元信息优化

  • 引入 reading-time 计算文章字数与预计阅读时长。
  • 文章头部统一显示:作者、日期、字数、预计阅读、阅读量。

📦 依赖变更

  • 新增 reading-time@^1.5.0

🔗 API 路由依赖

  • /api/likes – 点赞读写与自增
  • /api/stats/[slug] – 单篇文章阅读量读写
  • /api/stats/site – 站点聚合统计

✅ 自测情况

  • 本地开发环境与 Upstash Redis 连接正常
  • 文章阅读量在详情页与列表页正确显示与累加
  • 点赞后刷新页面数字保持,无痕窗口可见累计总数
  • 关于页统计卡片数据正确更新
  • TypeScript 类型检查通过,无新增 lint 错误

📝 部署备忘

  • 环境变量需包含 KV_REST_API_URLKV_REST_API_TOKEN(或对应的 UPSTASH_ 前缀变量)。

- 在文章列表卡片中添加文章长度(字数)显示
- 在文章列表卡片中添加预计阅读时间显示
- 使用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行错误
Copilot AI review requested due to automatic review settings April 16, 2026 00:19
@vercel

vercel Bot commented Apr 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
suzu-blog Error Error Apr 16, 2026 0:20am

@github-actions

Copy link
Copy Markdown
Contributor

This PR is too large and may need to be broken into smaller pieces.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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} />
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants