这两个功能已经想了很久了,但是一直没有去动手实现(AI驱动),直到今天才操作了一下,其中数据库的创建数据表,以及最后的vercel创建环境变量需要手动去操作,其他的基本上可以使用AI辅助来编写相关的代码了。对与使用WordPress和动态的博客来说,实现访问量和喜欢简直是小菜一碟,因为大部分都会集成了类似的功能,可以信手捏来。但是对于静态博客来说,就需要自己动手丰衣足食了。如果你也在使用静态博客,碰巧也要添加这类功能,请把这些喂给AI,让他帮你实现。
具体功能效果请参考页面上的访问量展示和喜欢功能
本篇文章将详细讲解如何在碎言博客项目中实现文章阅读量和喜欢功能。这个方案基于 Next.js 和 Supabase,实现了无需登录的匿名用户统计,通过浏览器指纹防止重复点赞,并采用了原子操作确保数据一致性。
┌─────────────────┐ ┌─────────────┐ ┌─────────────────┐
│ Next.js 页面 │────▶│ API Routes │────▶│ Supabase Admin │
│ (Pages Router) │◄────│ /api/stats │◄────│ (绕过 RLS) │
└─────────────────┘ └─────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ PostgreSQL 数据库 │
│ article_stats │
│ user_likes │
└─────────────────┘
在项目中已经安装了必要的依赖:
npm install @supabase/supabase-js @supabase/ssr @fingerprintjs/fingerprintjs
检查 package.json 确认依赖已安装:
{
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.93.3"
}
}
登录 Supabase Dashboard,进入 SQL Editor,执行以下 SQL 语句:
-- 1. 文章统计表
create table article_stats (
id bigint generated always as identity primary key,
slug text not null unique, -- 文章唯一标识(如:/blog/20260130103859)
view_count bigint default 0, -- 阅读量
like_count bigint default 0, -- 喜欢数
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- 2. 用户喜欢记录表(防止重复喜欢)
create table user_likes (
id bigint generated always as identity primary key,
slug text not null,
fingerprint text not null, -- 浏览器指纹
created_at timestamptz default now(),
unique(slug, fingerprint) -- 联合唯一约束,防止同一用户重复喜欢
);
-- 3. 创建索引(提升查询性能)
create index idx_article_stats_slug on article_stats(slug);
create index idx_user_likes_slug on user_likes(slug);
-- 4. 行级安全策略(RLS)
alter table article_stats enable row level security;
alter table user_likes enable row level security;
-- 5. 允许匿名读取统计信息
create policy "Allow public read stats"
on article_stats for select
using (true);
-- 6. 允许匿名更新阅读量
create policy "Allow public update views"
on article_stats for update
using (true)
with check (true);
-- 7. 允许匿名插入喜欢记录
create policy "Allow public insert likes"
on user_likes for insert
with check (true);
-- 8. 允许匿名删除喜欢记录(取消喜欢)
create policy "Allow public delete likes"
on user_likes for delete
using (true);
为了确保数据一致性和原子性操作,我们需要创建数据库函数。下面是详细的创建步骤:
打开 SQL Editor:
粘贴 SQL 代码:
-- 原子性处理喜欢(喜欢/取消喜欢)
create or replace function toggle_like(
article_slug text,
user_fingerprint text,
out liked boolean,
out current_likes bigint
)
as $$
declare
like_exists boolean;
begin
-- 检查是否已喜欢
select exists(
select 1 from user_likes
where slug = article_slug and fingerprint = user_fingerprint
) into like_exists;
if like_exists then
-- 取消喜欢
delete from user_likes
where slug = article_slug and fingerprint = user_fingerprint;
update article_stats
set like_count = like_count - 1,
updated_at = now()
where slug = article_slug
returning like_count into current_likes;
liked := false;
else
-- 添加喜欢
insert into user_likes (slug, fingerprint)
values (article_slug, user_fingerprint);
insert into article_stats (slug, like_count, view_count)
values (article_slug, 1, 0)
on conflict (slug)
do update set
like_count = article_stats.like_count + 1,
updated_at = now()
returning like_count into current_likes;
liked := true;
end if;
end;
$$ language plpgsql security definer;
-- 授予执行权限
grant execute on function toggle_like to anon;
执行 SQL:
验证函数创建:
toggle_like 函数如果你安装了 Supabase CLI,也可以通过命令行创建:
supabase migration new create_toggle_like_function
# 打开生成的迁移文件
supabase migrations/$(ls -t supabase/migrations | head -1)
粘贴 SQL 代码: 将上面的 SQL 代码粘贴到迁移文件中。
应用迁移:
supabase db push
参数解释:
article_slug text - 文章的唯一标识符(如:/blog/20260130103859)user_fingerprint text - 用户的浏览器指纹out liked boolean - 输出参数,返回操作后的喜欢状态out current_likes bigint - 输出参数,返回当前的喜欢总数工作原理:
on conflict 处理文章统计记录不存在的情况关键字段说明:
security definer - 确保函数以数据库所有者权限执行,绕过 RLS 限制out 参数 - 直接返回结果,避免额外的查询请求$$ - PostgreSQL 的美元引号,用于函数体定义在 SQL Editor 中执行以下测试代码:
-- 测试函数:模拟用户喜欢文章
select * from toggle_like(
article_slug := '/blog/test-post',
user_fingerprint := 'test-fingerprint-123'
);
-- 结果应该显示:
-- liked: true(首次喜欢)
-- current_likes: 1
-- 再次执行(取消喜欢)
select * from toggle_like(
article_slug := '/blog/test-post',
user_fingerprint := 'test-fingerprint-123'
);
-- 结果应该显示:
-- liked: false(已取消喜欢)
-- current_likes: 0
-- 测试不同用户喜欢同一篇文章
select * from toggle_like(
article_slug := '/blog/test-post',
user_fingerprint := 'test-fingerprint-456'
);
-- 结果应该显示:
-- liked: true(另一个用户喜欢)
-- current_likes: 1
-- 查看数据库记录
select * from article_stats where slug = '/blog/test-post';
select * from user_likes where slug = '/blog/test-post';
-- 清理测试数据
delete from user_likes where slug = '/blog/test-post';
delete from article_stats where slug = '/blog/test-post';
问题 1:函数创建失败
article_stats 和 user_likes 表问题 2:权限不足
grant execute on function toggle_like to anon问题 3:函数调用失败
在 Supabase Dashboard 中:
项目 URL:
Project Settings > APIProject URLAnon Key(公开密钥):
anon public keyService Role Key(服务密钥):
service_role key在项目根目录创建 .env.local 文件:
# Supabase 配置
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
文件说明:
.env.local - 本地开发使用,不会被提交到 GitNEXT_PUBLIC_ 前缀 - 表示可以暴露给前端NEXT_PUBLIC_ 前缀 - 只能在服务端使用确保 .gitignore 文件包含:
.env.local
.env*.local
创建 src/lib/supabase.ts:
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
// 公开客户端(前端使用)
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
// 管理员客户端(后端 API 使用,绕过 RLS)
export const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
关键点:
supabase - 使用 anon key,受 RLS 限制,用于前端supabaseAdmin - 使用 service role key,绕过 RLS,用于 API 路由创建 src/lib/fingerprint.ts:
import FingerprintJS from '@fingerprintjs/fingerprintjs'
let fpPromise: Promise<any> | null = null
/**
* 获取浏览器指纹
* 使用 FingerprintJS 库生成稳定的设备标识
*/
export async function getFingerprint(): Promise<string> {
if (!fpPromise) {
fpPromise = FingerprintJS.load().then(fp => fp.get())
}
const result = await fpPromise
return result.visitorId
}
/**
* 备用指纹方案
* 如果 FingerprintJS 加载失败,使用简单的浏览器特征哈希
*/
export function getSimpleFingerprint(): string {
const seed = navigator.userAgent + navigator.language + screen.width + screen.height
return btoa(seed).slice(0, 32)
}
说明:
创建 src/pages/api/stats.ts:
import type { NextApiRequest, NextApiResponse } from 'next'
import { supabaseAdmin } from '@/lib/supabase'
/**
* GET /api/stats?slug=/blog/20260130103859
* 获取文章的阅读量和喜欢数
*
* POST /api/stats
* 增加文章阅读量
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const { slug } = req.query
if (!slug || typeof slug !== 'string') {
return res.status(400).json({ error: 'Slug required' })
}
const { data, error } = await supabaseAdmin
.from('article_stats')
.select('view_count, like_count')
.eq('slug', slug)
.single()
// PGRST116 = not found(记录不存在是正常情况)
if (error && error.code !== 'PGRST116') {
return res.status(500).json({ error: error.message })
}
return res.json({
views: data?.view_count || 0,
likes: data?.like_count || 0
})
}
if (req.method === 'POST') {
const { slug } = req.body
if (!slug) {
return res.status(400).json({ error: 'Slug required' })
}
try {
// 先检查记录是否存在
const { existingData } = await supabaseAdmin
.from('article_stats')
.select('view_count')
.eq('slug', slug)
.maybeSingle()
if (existingData) {
// 记录存在,更新阅读量
const { error: updateError } = await supabaseAdmin
.from('article_stats')
.update({
view_count: existingData.view_count + 1,
updated_at: new Date().toISOString()
})
.eq('slug', slug)
if (updateError) {
console.error('Update error:', updateError)
return res.status(500).json({ error: updateError.message })
}
} else {
// 记录不存在,插入新记录
const { error: insertError } = await supabaseAdmin
.from('article_stats')
.insert({
slug,
view_count: 1,
like_count: 0
})
if (insertError) {
console.error('Insert error:', insertError)
return res.status(500).json({ error: insertError.message })
}
}
return res.json({ success: true })
} catch (error) {
console.error('Unexpected error:', error)
return res.status(500).json({ error: 'Internal server error' })
}
}
res.setHeader('Allow', ['GET', 'POST'])
return res.status(405).json({ error: 'Method not allowed' })
}
创建 src/pages/api/stats/like.ts:
import type { NextApiRequest, NextApiResponse } from 'next'
import { supabaseAdmin } from '@/lib/supabase'
/**
* POST /api/stats/like
* 切换喜欢状态(喜欢/取消喜欢)
* Body: { slug: string, fingerprint: string }
*
* GET /api/stats/like?slug=/blog/xxx&fingerprint=xxx
* 检查用户是否喜欢该文章
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
const { slug, fingerprint } = req.body
if (!slug || !fingerprint) {
return res.status(400).json({ error: 'Slug and fingerprint required' })
}
// 调用数据库函数进行原子操作
const { data, error } = await supabaseAdmin.rpc('toggle_like', {
article_slug: slug,
user_fingerprint: fingerprint
})
if (error) {
return res.status(500).json({ error: error.message })
}
return res.json({
liked: data.liked,
likes: data.current_likes
})
}
if (req.method === 'GET') {
const { slug, fingerprint } = req.query
if (!slug || !fingerprint || typeof slug !== 'string' || typeof fingerprint !== 'string') {
return res.status(400).json({ error: 'Slug and fingerprint required' })
}
// 并行查询:当前用户是否喜欢 + 文章总喜欢数
const [userLikeData, statsData] = await Promise.all([
supabaseAdmin
.from('user_likes')
.select('id')
.eq('slug', slug)
.eq('fingerprint', fingerprint)
.maybeSingle(),
supabaseAdmin
.from('article_stats')
.select('like_count')
.eq('slug', slug)
.maybeSingle()
])
return res.json({
liked: !!userLikeData.data,
likes: statsData.data?.like_count || 0
})
}
res.setHeader('Allow', ['GET', 'POST'])
return res.status(405).json({ error: 'Method not allowed' })
}
创建 src/components/ArticleStats.tsx:
'use client'
import { useEffect, useState, useCallback } from 'react'
import { getFingerprint, getSimpleFingerprint } from '@/lib/fingerprint'
interface ArticleStatsProps {
slug: string
mode?: 'like' | 'views' | 'both'
}
interface Stats {
views: number
likes: number
liked: boolean
}
/**
* 文章统计组件
* 支持三种模式:
* - like: 只显示喜欢按钮
* - views: 只显示阅读量
* - both: 显示阅读量和喜欢按钮
*/
export default function ArticleStats({ slug, mode = 'like' }: ArticleStatsProps) {
const [stats, setStats] = useState<Stats>({ views: 0, likes: 0, liked: false })
const [fingerprint, setFingerprint] = useState<string>('')
const [loading, setLoading] = useState(true)
// 初始化:获取指纹和统计数据
useEffect(() => {
const init = async () => {
try {
// 获取指纹,失败时使用备用方案
const fp = await getFingerprint().catch(() => getSimpleFingerprint())
setFingerprint(fp)
// 并行获取统计数据和喜欢状态
const [statsRes, likeRes] = await Promise.all([
fetch(`/api/stats?slug=${encodeURIComponent(slug)}`),
fetch(`/api/stats/like?slug=${encodeURIComponent(slug)}&fingerprint=${fp}`)
])
const statsData = await statsRes.json()
const likeData = await likeRes.json()
setStats({
views: statsData.views,
likes: statsData.likes,
liked: likeData.liked
})
// 增加阅读量(只在首次加载时)
await fetch('/api/stats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug })
})
// 乐观更新阅读量显示
setStats(prev => ({ ...prev, views: prev.views + 1 }))
} catch (error) {
console.error('Failed to load stats:', error)
} finally {
setLoading(false)
}
}
init()
}, [slug])
// 处理喜欢点击
const handleLike = useCallback(async () => {
if (!fingerprint) return
// 乐观更新 UI
setStats(prev => ({
...prev,
liked: !prev.liked,
likes: prev.liked ? prev.likes - 1 : prev.likes + 1
}))
try {
const res = await fetch('/api/stats/like', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, fingerprint })
})
const data = await res.json()
// 同步服务器返回的真实数据
setStats(prev => ({
...prev,
liked: data.liked,
likes: data.likes
}))
} catch (error) {
// 失败时回滚
setStats(prev => ({
...prev,
liked: !prev.liked,
likes: prev.liked ? prev.likes + 1 : prev.likes - 1
}))
console.error('Failed to toggle like:', error)
}
}, [slug, fingerprint])
if (loading) {
return null
}
// 只显示阅读量
if (mode === 'views') {
return (
<div className="flex items-center gap-2 text-text-secondary">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span>{stats.views.toLocaleString()} 阅读</span>
</div>
)
}
// 只显示喜欢按钮
if (mode === 'like') {
return (
<div className="relative group inline-block">
<button
onClick={handleLike}
className={`transition-all duration-200 ${
stats.liked
? 'text-red-500'
: 'text-text-secondary hover:text-text-dark'
}`}
aria-label={stats.liked ? '取消喜欢' : '喜欢'}
>
<svg
className={`w-7 h-7 transition-transform duration-200 ${stats.liked ? 'scale-110' : ''}`}
fill={stats.liked ? 'currentColor' : 'none'}
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
{/* 喜欢数量徽标 */}
{stats.likes > 0 && (
<span className="absolute -top-1 -right-2 flex items-center justify-center min-w-[18px] h-[18px] px-1 bg-red-500 text-white text-xs font-bold rounded-full">
{stats.likes > 99 ? '99+' : stats.likes}
</span>
)}
{/* Hover 提示 */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1 bg-text-primary text-bg-content text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
{stats.liked ? '已喜欢' : '喜欢这篇文章'}
</div>
</div>
)
}
// 显示完整统计(阅读量 + 喜欢按钮)
return (
<div className="flex items-center gap-6">
{/* 阅读量 */}
<div className="flex items-center gap-2 text-text-secondary">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span>{stats.views.toLocaleString()} 阅读</span>
</div>
{/* 喜欢按钮 */}
<div className="relative group inline-block">
<button
onClick={handleLike}
className={`transition-all duration-200 ${
stats.liked
? 'text-red-500'
: 'text-text-secondary hover:text-text-dark'
}`}
aria-label={stats.liked ? '取消喜欢' : '喜欢'}
>
<svg
className={`w-7 h-7 transition-transform duration-200 ${stats.liked ? 'scale-110' : ''}`}
fill={stats.liked ? 'currentColor' : 'none'}
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
{/* 喜欢数量徽标 */}
{stats.likes > 0 && (
<span className="absolute -top-1 -right-2 flex items-center justify-center min-w-[18px] h-[18px] px-1 bg-red-500 text-white text-xs font-bold rounded-full">
{stats.likes > 99 ? '99+' : stats.likes}
</span>
)}
{/* Hover 提示 */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1 bg-text-primary text-bg-content text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
{stats.liked ? '已喜欢' : '喜欢这篇文章'}
</div>
</div>
</div>
)
}
组件特性:
在 src/pages/blog/[id].tsx 中集成组件:
import ArticleStats from '@/components/ArticleStats'
// 在文章标题下方显示阅读量
<div className="flex flex-wrap items-center gap-4 text-sm text-text-secondary">
<div className="flex items-center gap-2">
<span>作者: {post.author}</span>
</div>
<div className="flex items-center gap-2">
<span>{stats.words} 字</span>
<span>·</span>
<span>预计阅读 {stats.text}</span>
</div>
<div className="flex items-center gap-2">
<time dateTime={post.time}>
{formatDate(post.time || '')}
</time>
</div>
{/* 阅读量显示 */}
<div className="flex items-center gap-2">
<ArticleStats slug={`/blog/${post.id}`} mode="views" />
</div>
</div>
// 在文章底部显示喜欢按钮
<div className="flex items-center justify-between">
{/* 左侧:评论和喜欢按钮 */}
<div className="flex items-center justify-center gap-6">
{/* 评论按钮 */}
<div className="relative group inline-block">
<button
onClick={() => setShowComments(!showComments)}
className="text-text-secondary hover:text-text-dark"
>
{/* 评论图标 */}
</button>
</div>
{/* 喜欢按钮 */}
<ArticleStats slug={`/blog/${post.id}`} />
</div>
{/* 右侧:赞赏按钮 */}
{/* ... */}
</div>
使用说明:
slug 参数使用文章的路由路径(如 /blog/20260130103859)mode="views" 只显示阅读量,放在文章元数据区域mode 默认显示喜欢按钮,放在文章底部导入项目:
配置环境变量:
| 名称 | 值 | 环境 |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | https://your-project.supabase.co | Production, Preview, Development |
NEXT_PUBLIC_SUPABASE_ANON_KEY | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | Production, Preview, Development |
SUPABASE_SERVICE_ROLE_KEY | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | Production, Preview, Development |
部署配置:
npm run build(或 pnpm build).next⚠️ 重要提醒:
Service Role Key 保护:
SUPABASE_SERVICE_ROLE_KEY 绝不能暴露给前端NEXT_PUBLIC_SUPABASE_ANON_KEY环境隔离:
.env.localGit 安全:
.gitignore 包含 .env.local.env 文件提交到 Git.env.example 模板(只包含变量名)部署完成后,进行以下测试:
访问网站:
# 访问任意文章页面
https://your-domain.com/blog/20260130103859
检查功能:
检查数据库:
-- 在 Supabase SQL Editor 中执行
SELECT * FROM article_stats ORDER BY updated_at DESC LIMIT 10;
SELECT * FROM user_likes ORDER BY created_at DESC LIMIT 10;
启动开发服务器:
npm run dev
# 或
pnpm dev
测试步骤:
http://localhost:3000/blog/20260130103859问题 1:阅读量不增加
问题 2:喜欢按钮无响应
toggle_like 是否存在问题 3:喜欢状态不持久
user_likes 表是否有数据缓存优化:
// 在 API 路由中添加缓存头
res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate')
批量查询:
// 在首页批量获取多篇文章的统计
const slugs = posts.map(p => `/blog/${p.id}`)
const { data } = await supabaseAdmin
.from('article_stats')
.select('slug, view_count, like_count')
.in('slug', slugs)
速率限制:
// 使用 IP 限制频繁请求
import { Ratelimit } from '@upstash/ratelimit'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s')
})
数据验证:
// 验证 slug 格式
if (!slug.match(/^\/blog\/\d{14}$/)) {
return res.status(400).json({ error: 'Invalid slug format' })
}
热门文章榜单:
// 获取最受欢迎的文章
const { data } = await supabaseAdmin
.from('article_stats')
.select('slug, view_count, like_count')
.order('like_count', { ascending: false })
.limit(10)
阅读进度追踪:
// 记录用户阅读进度
const handleScroll = () => {
const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight)
if (progress > 0.8) {
// 标记为已读
}
}
这套文章阅读量和喜欢功能方案具有以下特点:
supabaseAdmin 绕过 RLS 限制希望这篇文章能帮助你理解并实现博客的文章阅读量和喜欢功能!如果有任何问题,欢迎在评论区交流讨论。
这两个功能已经想了很久了,但是一直没有去动手实现(AI驱动),直到今天才操作了一下,其中数据库的创建数据表,以及最后的vercel创建环境变量需要手动去操作,其他的基本上可以使用AI辅助来编写相关的代码了。对与使用WordPress和动态的博客来说,实现访问量和喜欢简直是小菜一碟,因为大部分都会集成了类似的功能,可以信手捏来。但是对于静态博客来说,就需要自己动手丰衣足食了。如果你也在使用静态博客,碰巧也要添加这类功能,请把这些喂给AI,让他帮你实现。
具体功能效果请参考页面上的访问量展示和喜欢功能
本篇文章将详细讲解如何在碎言博客项目中实现文章阅读量和喜欢功能。这个方案基于 Next.js 和 Supabase,实现了无需登录的匿名用户统计,通过浏览器指纹防止重复点赞,并采用了原子操作确保数据一致性。
┌─────────────────┐ ┌─────────────┐ ┌─────────────────┐
│ Next.js 页面 │────▶│ API Routes │────▶│ Supabase Admin │
│ (Pages Router) │◄────│ /api/stats │◄────│ (绕过 RLS) │
└─────────────────┘ └─────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ PostgreSQL 数据库 │
│ article_stats │
│ user_likes │
└─────────────────┘
在项目中已经安装了必要的依赖:
npm install @supabase/supabase-js @supabase/ssr @fingerprintjs/fingerprintjs
检查 package.json 确认依赖已安装:
{
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.93.3"
}
}
登录 Supabase Dashboard,进入 SQL Editor,执行以下 SQL 语句:
-- 1. 文章统计表
create table article_stats (
id bigint generated always as identity primary key,
slug text not null unique, -- 文章唯一标识(如:/blog/20260130103859)
view_count bigint default 0, -- 阅读量
like_count bigint default 0, -- 喜欢数
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- 2. 用户喜欢记录表(防止重复喜欢)
create table user_likes (
id bigint generated always as identity primary key,
slug text not null,
fingerprint text not null, -- 浏览器指纹
created_at timestamptz default now(),
unique(slug, fingerprint) -- 联合唯一约束,防止同一用户重复喜欢
);
-- 3. 创建索引(提升查询性能)
create index idx_article_stats_slug on article_stats(slug);
create index idx_user_likes_slug on user_likes(slug);
-- 4. 行级安全策略(RLS)
alter table article_stats enable row level security;
alter table user_likes enable row level security;
-- 5. 允许匿名读取统计信息
create policy "Allow public read stats"
on article_stats for select
using (true);
-- 6. 允许匿名更新阅读量
create policy "Allow public update views"
on article_stats for update
using (true)
with check (true);
-- 7. 允许匿名插入喜欢记录
create policy "Allow public insert likes"
on user_likes for insert
with check (true);
-- 8. 允许匿名删除喜欢记录(取消喜欢)
create policy "Allow public delete likes"
on user_likes for delete
using (true);
为了确保数据一致性和原子性操作,我们需要创建数据库函数。下面是详细的创建步骤:
打开 SQL Editor:
粘贴 SQL 代码:
-- 原子性处理喜欢(喜欢/取消喜欢)
create or replace function toggle_like(
article_slug text,
user_fingerprint text,
out liked boolean,
out current_likes bigint
)
as $$
declare
like_exists boolean;
begin
-- 检查是否已喜欢
select exists(
select 1 from user_likes
where slug = article_slug and fingerprint = user_fingerprint
) into like_exists;
if like_exists then
-- 取消喜欢
delete from user_likes
where slug = article_slug and fingerprint = user_fingerprint;
update article_stats
set like_count = like_count - 1,
updated_at = now()
where slug = article_slug
returning like_count into current_likes;
liked := false;
else
-- 添加喜欢
insert into user_likes (slug, fingerprint)
values (article_slug, user_fingerprint);
insert into article_stats (slug, like_count, view_count)
values (article_slug, 1, 0)
on conflict (slug)
do update set
like_count = article_stats.like_count + 1,
updated_at = now()
returning like_count into current_likes;
liked := true;
end if;
end;
$$ language plpgsql security definer;
-- 授予执行权限
grant execute on function toggle_like to anon;
执行 SQL:
验证函数创建:
toggle_like 函数如果你安装了 Supabase CLI,也可以通过命令行创建:
supabase migration new create_toggle_like_function
# 打开生成的迁移文件
supabase migrations/$(ls -t supabase/migrations | head -1)
粘贴 SQL 代码: 将上面的 SQL 代码粘贴到迁移文件中。
应用迁移:
supabase db push
参数解释:
article_slug text - 文章的唯一标识符(如:/blog/20260130103859)user_fingerprint text - 用户的浏览器指纹out liked boolean - 输出参数,返回操作后的喜欢状态out current_likes bigint - 输出参数,返回当前的喜欢总数工作原理:
on conflict 处理文章统计记录不存在的情况关键字段说明:
security definer - 确保函数以数据库所有者权限执行,绕过 RLS 限制out 参数 - 直接返回结果,避免额外的查询请求$$ - PostgreSQL 的美元引号,用于函数体定义在 SQL Editor 中执行以下测试代码:
-- 测试函数:模拟用户喜欢文章
select * from toggle_like(
article_slug := '/blog/test-post',
user_fingerprint := 'test-fingerprint-123'
);
-- 结果应该显示:
-- liked: true(首次喜欢)
-- current_likes: 1
-- 再次执行(取消喜欢)
select * from toggle_like(
article_slug := '/blog/test-post',
user_fingerprint := 'test-fingerprint-123'
);
-- 结果应该显示:
-- liked: false(已取消喜欢)
-- current_likes: 0
-- 测试不同用户喜欢同一篇文章
select * from toggle_like(
article_slug := '/blog/test-post',
user_fingerprint := 'test-fingerprint-456'
);
-- 结果应该显示:
-- liked: true(另一个用户喜欢)
-- current_likes: 1
-- 查看数据库记录
select * from article_stats where slug = '/blog/test-post';
select * from user_likes where slug = '/blog/test-post';
-- 清理测试数据
delete from user_likes where slug = '/blog/test-post';
delete from article_stats where slug = '/blog/test-post';
问题 1:函数创建失败
article_stats 和 user_likes 表问题 2:权限不足
grant execute on function toggle_like to anon问题 3:函数调用失败
在 Supabase Dashboard 中:
项目 URL:
Project Settings > APIProject URLAnon Key(公开密钥):
anon public keyService Role Key(服务密钥):
service_role key在项目根目录创建 .env.local 文件:
# Supabase 配置
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
文件说明:
.env.local - 本地开发使用,不会被提交到 GitNEXT_PUBLIC_ 前缀 - 表示可以暴露给前端NEXT_PUBLIC_ 前缀 - 只能在服务端使用确保 .gitignore 文件包含:
.env.local
.env*.local
创建 src/lib/supabase.ts:
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
// 公开客户端(前端使用)
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
// 管理员客户端(后端 API 使用,绕过 RLS)
export const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
关键点:
supabase - 使用 anon key,受 RLS 限制,用于前端supabaseAdmin - 使用 service role key,绕过 RLS,用于 API 路由创建 src/lib/fingerprint.ts:
import FingerprintJS from '@fingerprintjs/fingerprintjs'
let fpPromise: Promise<any> | null = null
/**
* 获取浏览器指纹
* 使用 FingerprintJS 库生成稳定的设备标识
*/
export async function getFingerprint(): Promise<string> {
if (!fpPromise) {
fpPromise = FingerprintJS.load().then(fp => fp.get())
}
const result = await fpPromise
return result.visitorId
}
/**
* 备用指纹方案
* 如果 FingerprintJS 加载失败,使用简单的浏览器特征哈希
*/
export function getSimpleFingerprint(): string {
const seed = navigator.userAgent + navigator.language + screen.width + screen.height
return btoa(seed).slice(0, 32)
}
说明:
创建 src/pages/api/stats.ts:
import type { NextApiRequest, NextApiResponse } from 'next'
import { supabaseAdmin } from '@/lib/supabase'
/**
* GET /api/stats?slug=/blog/20260130103859
* 获取文章的阅读量和喜欢数
*
* POST /api/stats
* 增加文章阅读量
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const { slug } = req.query
if (!slug || typeof slug !== 'string') {
return res.status(400).json({ error: 'Slug required' })
}
const { data, error } = await supabaseAdmin
.from('article_stats')
.select('view_count, like_count')
.eq('slug', slug)
.single()
// PGRST116 = not found(记录不存在是正常情况)
if (error && error.code !== 'PGRST116') {
return res.status(500).json({ error: error.message })
}
return res.json({
views: data?.view_count || 0,
likes: data?.like_count || 0
})
}
if (req.method === 'POST') {
const { slug } = req.body
if (!slug) {
return res.status(400).json({ error: 'Slug required' })
}
try {
// 先检查记录是否存在
const { existingData } = await supabaseAdmin
.from('article_stats')
.select('view_count')
.eq('slug', slug)
.maybeSingle()
if (existingData) {
// 记录存在,更新阅读量
const { error: updateError } = await supabaseAdmin
.from('article_stats')
.update({
view_count: existingData.view_count + 1,
updated_at: new Date().toISOString()
})
.eq('slug', slug)
if (updateError) {
console.error('Update error:', updateError)
return res.status(500).json({ error: updateError.message })
}
} else {
// 记录不存在,插入新记录
const { error: insertError } = await supabaseAdmin
.from('article_stats')
.insert({
slug,
view_count: 1,
like_count: 0
})
if (insertError) {
console.error('Insert error:', insertError)
return res.status(500).json({ error: insertError.message })
}
}
return res.json({ success: true })
} catch (error) {
console.error('Unexpected error:', error)
return res.status(500).json({ error: 'Internal server error' })
}
}
res.setHeader('Allow', ['GET', 'POST'])
return res.status(405).json({ error: 'Method not allowed' })
}
创建 src/pages/api/stats/like.ts:
import type { NextApiRequest, NextApiResponse } from 'next'
import { supabaseAdmin } from '@/lib/supabase'
/**
* POST /api/stats/like
* 切换喜欢状态(喜欢/取消喜欢)
* Body: { slug: string, fingerprint: string }
*
* GET /api/stats/like?slug=/blog/xxx&fingerprint=xxx
* 检查用户是否喜欢该文章
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
const { slug, fingerprint } = req.body
if (!slug || !fingerprint) {
return res.status(400).json({ error: 'Slug and fingerprint required' })
}
// 调用数据库函数进行原子操作
const { data, error } = await supabaseAdmin.rpc('toggle_like', {
article_slug: slug,
user_fingerprint: fingerprint
})
if (error) {
return res.status(500).json({ error: error.message })
}
return res.json({
liked: data.liked,
likes: data.current_likes
})
}
if (req.method === 'GET') {
const { slug, fingerprint } = req.query
if (!slug || !fingerprint || typeof slug !== 'string' || typeof fingerprint !== 'string') {
return res.status(400).json({ error: 'Slug and fingerprint required' })
}
// 并行查询:当前用户是否喜欢 + 文章总喜欢数
const [userLikeData, statsData] = await Promise.all([
supabaseAdmin
.from('user_likes')
.select('id')
.eq('slug', slug)
.eq('fingerprint', fingerprint)
.maybeSingle(),
supabaseAdmin
.from('article_stats')
.select('like_count')
.eq('slug', slug)
.maybeSingle()
])
return res.json({
liked: !!userLikeData.data,
likes: statsData.data?.like_count || 0
})
}
res.setHeader('Allow', ['GET', 'POST'])
return res.status(405).json({ error: 'Method not allowed' })
}
创建 src/components/ArticleStats.tsx:
'use client'
import { useEffect, useState, useCallback } from 'react'
import { getFingerprint, getSimpleFingerprint } from '@/lib/fingerprint'
interface ArticleStatsProps {
slug: string
mode?: 'like' | 'views' | 'both'
}
interface Stats {
views: number
likes: number
liked: boolean
}
/**
* 文章统计组件
* 支持三种模式:
* - like: 只显示喜欢按钮
* - views: 只显示阅读量
* - both: 显示阅读量和喜欢按钮
*/
export default function ArticleStats({ slug, mode = 'like' }: ArticleStatsProps) {
const [stats, setStats] = useState<Stats>({ views: 0, likes: 0, liked: false })
const [fingerprint, setFingerprint] = useState<string>('')
const [loading, setLoading] = useState(true)
// 初始化:获取指纹和统计数据
useEffect(() => {
const init = async () => {
try {
// 获取指纹,失败时使用备用方案
const fp = await getFingerprint().catch(() => getSimpleFingerprint())
setFingerprint(fp)
// 并行获取统计数据和喜欢状态
const [statsRes, likeRes] = await Promise.all([
fetch(`/api/stats?slug=${encodeURIComponent(slug)}`),
fetch(`/api/stats/like?slug=${encodeURIComponent(slug)}&fingerprint=${fp}`)
])
const statsData = await statsRes.json()
const likeData = await likeRes.json()
setStats({
views: statsData.views,
likes: statsData.likes,
liked: likeData.liked
})
// 增加阅读量(只在首次加载时)
await fetch('/api/stats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug })
})
// 乐观更新阅读量显示
setStats(prev => ({ ...prev, views: prev.views + 1 }))
} catch (error) {
console.error('Failed to load stats:', error)
} finally {
setLoading(false)
}
}
init()
}, [slug])
// 处理喜欢点击
const handleLike = useCallback(async () => {
if (!fingerprint) return
// 乐观更新 UI
setStats(prev => ({
...prev,
liked: !prev.liked,
likes: prev.liked ? prev.likes - 1 : prev.likes + 1
}))
try {
const res = await fetch('/api/stats/like', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, fingerprint })
})
const data = await res.json()
// 同步服务器返回的真实数据
setStats(prev => ({
...prev,
liked: data.liked,
likes: data.likes
}))
} catch (error) {
// 失败时回滚
setStats(prev => ({
...prev,
liked: !prev.liked,
likes: prev.liked ? prev.likes + 1 : prev.likes - 1
}))
console.error('Failed to toggle like:', error)
}
}, [slug, fingerprint])
if (loading) {
return null
}
// 只显示阅读量
if (mode === 'views') {
return (
<div className="flex items-center gap-2 text-text-secondary">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span>{stats.views.toLocaleString()} 阅读</span>
</div>
)
}
// 只显示喜欢按钮
if (mode === 'like') {
return (
<div className="relative group inline-block">
<button
onClick={handleLike}
className={`transition-all duration-200 ${
stats.liked
? 'text-red-500'
: 'text-text-secondary hover:text-text-dark'
}`}
aria-label={stats.liked ? '取消喜欢' : '喜欢'}
>
<svg
className={`w-7 h-7 transition-transform duration-200 ${stats.liked ? 'scale-110' : ''}`}
fill={stats.liked ? 'currentColor' : 'none'}
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
{/* 喜欢数量徽标 */}
{stats.likes > 0 && (
<span className="absolute -top-1 -right-2 flex items-center justify-center min-w-[18px] h-[18px] px-1 bg-red-500 text-white text-xs font-bold rounded-full">
{stats.likes > 99 ? '99+' : stats.likes}
</span>
)}
{/* Hover 提示 */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1 bg-text-primary text-bg-content text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
{stats.liked ? '已喜欢' : '喜欢这篇文章'}
</div>
</div>
)
}
// 显示完整统计(阅读量 + 喜欢按钮)
return (
<div className="flex items-center gap-6">
{/* 阅读量 */}
<div className="flex items-center gap-2 text-text-secondary">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span>{stats.views.toLocaleString()} 阅读</span>
</div>
{/* 喜欢按钮 */}
<div className="relative group inline-block">
<button
onClick={handleLike}
className={`transition-all duration-200 ${
stats.liked
? 'text-red-500'
: 'text-text-secondary hover:text-text-dark'
}`}
aria-label={stats.liked ? '取消喜欢' : '喜欢'}
>
<svg
className={`w-7 h-7 transition-transform duration-200 ${stats.liked ? 'scale-110' : ''}`}
fill={stats.liked ? 'currentColor' : 'none'}
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
{/* 喜欢数量徽标 */}
{stats.likes > 0 && (
<span className="absolute -top-1 -right-2 flex items-center justify-center min-w-[18px] h-[18px] px-1 bg-red-500 text-white text-xs font-bold rounded-full">
{stats.likes > 99 ? '99+' : stats.likes}
</span>
)}
{/* Hover 提示 */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1 bg-text-primary text-bg-content text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
{stats.liked ? '已喜欢' : '喜欢这篇文章'}
</div>
</div>
</div>
)
}
组件特性:
在 src/pages/blog/[id].tsx 中集成组件:
import ArticleStats from '@/components/ArticleStats'
// 在文章标题下方显示阅读量
<div className="flex flex-wrap items-center gap-4 text-sm text-text-secondary">
<div className="flex items-center gap-2">
<span>作者: {post.author}</span>
</div>
<div className="flex items-center gap-2">
<span>{stats.words} 字</span>
<span>·</span>
<span>预计阅读 {stats.text}</span>
</div>
<div className="flex items-center gap-2">
<time dateTime={post.time}>
{formatDate(post.time || '')}
</time>
</div>
{/* 阅读量显示 */}
<div className="flex items-center gap-2">
<ArticleStats slug={`/blog/${post.id}`} mode="views" />
</div>
</div>
// 在文章底部显示喜欢按钮
<div className="flex items-center justify-between">
{/* 左侧:评论和喜欢按钮 */}
<div className="flex items-center justify-center gap-6">
{/* 评论按钮 */}
<div className="relative group inline-block">
<button
onClick={() => setShowComments(!showComments)}
className="text-text-secondary hover:text-text-dark"
>
{/* 评论图标 */}
</button>
</div>
{/* 喜欢按钮 */}
<ArticleStats slug={`/blog/${post.id}`} />
</div>
{/* 右侧:赞赏按钮 */}
{/* ... */}
</div>
使用说明:
slug 参数使用文章的路由路径(如 /blog/20260130103859)mode="views" 只显示阅读量,放在文章元数据区域mode 默认显示喜欢按钮,放在文章底部导入项目:
配置环境变量:
| 名称 | 值 | 环境 |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | https://your-project.supabase.co | Production, Preview, Development |
NEXT_PUBLIC_SUPABASE_ANON_KEY | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | Production, Preview, Development |
SUPABASE_SERVICE_ROLE_KEY | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | Production, Preview, Development |
部署配置:
npm run build(或 pnpm build).next⚠️ 重要提醒:
Service Role Key 保护:
SUPABASE_SERVICE_ROLE_KEY 绝不能暴露给前端NEXT_PUBLIC_SUPABASE_ANON_KEY环境隔离:
.env.localGit 安全:
.gitignore 包含 .env.local.env 文件提交到 Git.env.example 模板(只包含变量名)部署完成后,进行以下测试:
访问网站:
# 访问任意文章页面
https://your-domain.com/blog/20260130103859
检查功能:
检查数据库:
-- 在 Supabase SQL Editor 中执行
SELECT * FROM article_stats ORDER BY updated_at DESC LIMIT 10;
SELECT * FROM user_likes ORDER BY created_at DESC LIMIT 10;
启动开发服务器:
npm run dev
# 或
pnpm dev
测试步骤:
http://localhost:3000/blog/20260130103859问题 1:阅读量不增加
问题 2:喜欢按钮无响应
toggle_like 是否存在问题 3:喜欢状态不持久
user_likes 表是否有数据缓存优化:
// 在 API 路由中添加缓存头
res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate')
批量查询:
// 在首页批量获取多篇文章的统计
const slugs = posts.map(p => `/blog/${p.id}`)
const { data } = await supabaseAdmin
.from('article_stats')
.select('slug, view_count, like_count')
.in('slug', slugs)
速率限制:
// 使用 IP 限制频繁请求
import { Ratelimit } from '@upstash/ratelimit'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s')
})
数据验证:
// 验证 slug 格式
if (!slug.match(/^\/blog\/\d{14}$/)) {
return res.status(400).json({ error: 'Invalid slug format' })
}
热门文章榜单:
// 获取最受欢迎的文章
const { data } = await supabaseAdmin
.from('article_stats')
.select('slug, view_count, like_count')
.order('like_count', { ascending: false })
.limit(10)
阅读进度追踪:
// 记录用户阅读进度
const handleScroll = () => {
const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight)
if (progress > 0.8) {
// 标记为已读
}
}
这套文章阅读量和喜欢功能方案具有以下特点:
supabaseAdmin 绕过 RLS 限制希望这篇文章能帮助你理解并实现博客的文章阅读量和喜欢功能!如果有任何问题,欢迎在评论区交流讨论。