基于 Next.js + Supabase 的文章阅读量和喜欢功能

作者: J.sky
5116·预计阅读 26 min read

这两个功能已经想了很久了,但是一直没有去动手实现(AI驱动),直到今天才操作了一下,其中数据库的创建数据表,以及最后的vercel创建环境变量需要手动去操作,其他的基本上可以使用AI辅助来编写相关的代码了。对与使用WordPress和动态的博客来说,实现访问量和喜欢简直是小菜一碟,因为大部分都会集成了类似的功能,可以信手捏来。但是对于静态博客来说,就需要自己动手丰衣足食了。如果你也在使用静态博客,碰巧也要添加这类功能,请把这些喂给AI,让他帮你实现。

具体功能效果请参考页面上的访问量展示和喜欢功能

本篇文章将详细讲解如何在碎言博客项目中实现文章阅读量和喜欢功能。这个方案基于 Next.js 和 Supabase,实现了无需登录的匿名用户统计,通过浏览器指纹防止重复点赞,并采用了原子操作确保数据一致性。

一、项目架构概览

┌─────────────────┐     ┌─────────────┐     ┌─────────────────┐
│   Next.js 页面   │────▶│  API Routes │────▶│  Supabase Admin │
│  (Pages Router) │◄────│  /api/stats │◄────│  (绕过 RLS)     │
└─────────────────┘     └─────────────┘     └─────────────────┘
                                                      │
                                                      ▼
                                          ┌─────────────────┐
                                          │ PostgreSQL 数据库 │
                                          │ article_stats    │
                                          │ user_likes       │
                                          └─────────────────┘

项目使用的技术栈

  • Next.js 16 - React 框架,使用 Pages Router
  • Supabase - 后端即服务(BaaS),提供 PostgreSQL 数据库和 API
  • FingerprintJS - 浏览器指纹库,用于识别用户
  • TypeScript - 类型安全的 JavaScript
  • Tailwind CSS - CSS 框架

二、环境准备

2.1 安装依赖

在项目中已经安装了必要的依赖:

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 数据库设置

3.1 创建 Supabase 项目

  1. 访问 Supabase 官网
  2. 点击 "Start your project"
  3. 使用 GitHub 账号登录
  4. 创建新项目,选择组织(或创建新组织)
  5. 设置项目名称、数据库密码、区域
  6. 等待项目创建完成(约 2 分钟)

3.2 创建数据表

登录 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);

3.3 创建数据库函数

为了确保数据一致性和原子性操作,我们需要创建数据库函数。下面是详细的创建步骤:

方式一:通过 Supabase Dashboard 创建(推荐)

  1. 打开 SQL Editor

    • 登录 Supabase Dashboard
    • 点击左侧菜单栏的 SQL Editor 图标
    • 点击 "New query" 创建新查询
  2. 粘贴 SQL 代码

    • 将以下完整的 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;
  1. 执行 SQL

    • 点击编辑器右上角的 RUN 按钮
    • 等待执行完成,底部会显示 "Success" 消息
  2. 验证函数创建

    • 在左侧菜单栏找到 Database 图标
    • 展开 Functions 目录
    • 你应该能看到 toggle_like 函数

方式二:通过 Supabase CLI 创建

如果你安装了 Supabase CLI,也可以通过命令行创建:

  1. 创建迁移文件
supabase migration new create_toggle_like_function
  1. 编辑迁移文件
# 打开生成的迁移文件
supabase migrations/$(ls -t supabase/migrations | head -1)
  1. 粘贴 SQL 代码: 将上面的 SQL 代码粘贴到迁移文件中。

  2. 应用迁移

supabase db push

函数说明

参数解释

  • article_slug text - 文章的唯一标识符(如:/blog/20260130103859
  • user_fingerprint text - 用户的浏览器指纹
  • out liked boolean - 输出参数,返回操作后的喜欢状态
  • out current_likes bigint - 输出参数,返回当前的喜欢总数

工作原理

  1. 检查用户是否已经喜欢过该文章
  2. 如果已喜欢,则删除喜欢记录并减少喜欢数
  3. 如果未喜欢,则添加喜欢记录并增加喜欢数
  4. 使用 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_statsuser_likes
  • 检查 SQL 语法是否正确(括号、逗号等)
  • 查看 Supabase 的错误日志

问题 2:权限不足

  • 确保执行了 grant execute on function toggle_like to anon
  • 在 Project Settings > Database > Connection String 中确认权限配置

问题 3:函数调用失败

  • 检查函数名称是否拼写正确
  • 确认参数数量和类型是否正确
  • 查看 API 路由中的调用代码是否正确

四、环境变量配置

4.1 获取 Supabase 凭证

在 Supabase Dashboard 中:

  1. 项目 URL

    • 点击左侧 Project Settings > API
    • 复制 Project URL
  2. Anon Key(公开密钥)

    • 在同一页面找到 anon public key
    • 这个密钥可以暴露在前端
  3. Service Role Key(服务密钥)

    • 在同一页面找到 service_role key
    • ⚠️ 重要:这个密钥只能在后端使用,绝不能暴露给前端

4.2 本地开发配置

在项目根目录创建 .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 - 本地开发使用,不会被提交到 Git
  • NEXT_PUBLIC_ 前缀 - 表示可以暴露给前端
  • 没有 NEXT_PUBLIC_ 前缀 - 只能在服务端使用

4.3 Git 忽略配置

确保 .gitignore 文件包含:

.env.local
.env*.local

五、代码实现

5.1 Supabase 客户端配置

创建 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 路由

5.2 浏览器指纹工具

创建 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)
}

说明

  • FingerprintJS 生成的指纹在 90% 以上的情况下保持稳定
  • 提供备用方案确保即使主库加载失败也能正常工作
  • 指纹用于识别用户,防止重复点赞

5.3 API 路由实现

5.3.1 统计数据 API

创建 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' })
}

5.3.2 喜欢功能 API

创建 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' })
}

5.4 React 组件实现

创建 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>
  )
}

组件特性

  • 支持三种显示模式,灵活适应不同场景
  • 乐观更新 UI,提升用户体验
  • 错误回滚机制,确保数据一致性
  • 加载状态处理,避免闪烁
  • 辅助功能(aria-label)支持

5.5 在文章页面中使用

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 默认显示喜欢按钮,放在文章底部

六、Vercel 部署配置

6.1 Vercel 项目设置

  1. 导入项目

  2. 配置环境变量

    • 在项目设置中找到 "Environment Variables"
    • 添加以下变量:
    名称环境
    NEXT_PUBLIC_SUPABASE_URLhttps://your-project.supabase.coProduction, Preview, Development
    NEXT_PUBLIC_SUPABASE_ANON_KEYeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Production, Preview, Development
    SUPABASE_SERVICE_ROLE_KEYeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Production, Preview, Development
  3. 部署配置

    • Framework Preset: Next.js
    • Build Command: npm run build(或 pnpm build
    • Output Directory: .next

6.2 环境变量安全注意事项

⚠️ 重要提醒

  1. Service Role Key 保护

    • SUPABASE_SERVICE_ROLE_KEY 绝不能暴露给前端
    • 只在 API 路由中使用(服务端执行)
    • 前端使用 NEXT_PUBLIC_SUPABASE_ANON_KEY
  2. 环境隔离

    • 开发环境:使用 .env.local
    • 生产环境:在 Vercel Dashboard 中配置
    • 建议为开发和生产使用不同的 Supabase 项目
  3. Git 安全

    • 确保 .gitignore 包含 .env.local
    • 不要将 .env 文件提交到 Git
    • 使用 .env.example 模板(只包含变量名)

6.3 部署验证

部署完成后,进行以下测试:

  1. 访问网站

    # 访问任意文章页面
    https://your-domain.com/blog/20260130103859
    
  2. 检查功能

    • ✅ 阅读量正确显示
    • ✅ 点击喜欢按钮,喜欢数增加,图标变红
    • ✅ 再次点击,喜欢数减少,图标恢复
    • ✅ 刷新页面,喜欢状态保持
    • ✅ 不同浏览器/设备可以独立喜欢
  3. 检查数据库

    -- 在 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;
    

七、测试与调试

7.1 本地测试

启动开发服务器:

npm run dev
# 或
pnpm dev

测试步骤:

  1. 访问 http://localhost:3000/blog/20260130103859
  2. 打开浏览器开发者工具(F12)
  3. 查看 Network 标签,确认 API 请求正常
  4. 点击喜欢按钮,观察乐观更新效果

7.2 常见问题排查

问题 1:阅读量不增加

  • 检查 API 路由是否正确返回
  • 查看 Supabase 日志
  • 确认 Service Role Key 配置正确

问题 2:喜欢按钮无响应

  • 检查控制台是否有 JavaScript 错误
  • 确认指纹生成是否成功
  • 验证数据库函数 toggle_like 是否存在

问题 3:喜欢状态不持久

  • 检查 user_likes 表是否有数据
  • 确认指纹是否稳定(同一设备多次访问应该相同)
  • 验证 RLS 策略是否正确

八、优化建议

8.1 性能优化

  1. 缓存优化

    // 在 API 路由中添加缓存头
    res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate')
    
  2. 批量查询

    // 在首页批量获取多篇文章的统计
    const slugs = posts.map(p => `/blog/${p.id}`)
    const { data } = await supabaseAdmin
      .from('article_stats')
      .select('slug, view_count, like_count')
      .in('slug', slugs)
    

8.2 安全增强

  1. 速率限制

    // 使用 IP 限制频繁请求
    import { Ratelimit } from '@upstash/ratelimit'
    
    const ratelimit = new Ratelimit({
      redis: Redis.fromEnv(),
      limiter: Ratelimit.slidingWindow(10, '10 s')
    })
    
  2. 数据验证

    // 验证 slug 格式
    if (!slug.match(/^\/blog\/\d{14}$/)) {
      return res.status(400).json({ error: 'Invalid slug format' })
    }
    

8.3 功能扩展

  1. 热门文章榜单

    // 获取最受欢迎的文章
    const { data } = await supabaseAdmin
      .from('article_stats')
      .select('slug, view_count, like_count')
      .order('like_count', { ascending: false })
      .limit(10)
    
  2. 阅读进度追踪

    // 记录用户阅读进度
    const handleScroll = () => {
      const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight)
      if (progress > 0.8) {
        // 标记为已读
      }
    }
    

九、总结

这套文章阅读量和喜欢功能方案具有以下特点:

优势

  • 无需登录:基于浏览器指纹,用户体验流畅
  • 原子操作:数据库函数确保数据一致性
  • 防重复:数据库唯一约束防止重复点赞
  • 乐观更新:UI 即时响应,提升体验
  • 静态兼容:与 Next.js SSG 完美配合
  • 类型安全:TypeScript 提供完整的类型支持
  • 易于维护:代码结构清晰,职责分明

技术要点

  • 使用 supabaseAdmin 绕过 RLS 限制
  • 数据库函数实现原子操作
  • 乐观更新 + 错误回滚机制
  • 浏览器指纹识别用户
  • 环境变量区分前后端密钥

适用场景

  • 个人博客
  • 文档网站
  • 内容展示平台
  • 无需用户登录的互动功能

十、参考资料


希望这篇文章能帮助你理解并实现博客的文章阅读量和喜欢功能!如果有任何问题,欢迎在评论区交流讨论。

本文为原创文章,遵循: CC BY-NC-SA 4.0版权协议。

本文链接:https://www.suiyan.cc/blog/20260130103859

英雄请留步!欢迎点击图标,留言交流!
赞赏作者

相关文章

那年今日