在每篇文章底部添加一组表情反应按钮(👍❤️😮🎉🤔👀),点击即可投票,再次点击取消。数据存储在 Supabase 的免费 PostgreSQL 数据库中。
一、Supabase 数据库设置
1. 创建项目
前往 Supabase 注册并创建一个免费项目,选择离你最近的区域。
2. 建表
进入项目的 SQL Editor,执行以下 SQL:
-- 反应表:每行记录一个用户对一篇文章的一种 emoji 投票CREATE TABLE reactions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), post_slug text NOT NULL, emoji text NOT NULL, fingerprint text NOT NULL, created_at timestamptz DEFAULT now(), -- 每个用户对每篇文章的每种 emoji 只能投一次 CONSTRAINT reactions_post_slug_emoji_fingerprint_key UNIQUE (post_slug, emoji, fingerprint));
-- 按文章 slug 查询的索引CREATE INDEX idx_reactions_post_slug ON reactions (post_slug);
-- 启用行级安全策略ALTER TABLE reactions ENABLE ROW LEVEL SECURITY;
-- 匿名用户可读(公开数据)CREATE POLICY "Allow public read" ON reactions FOR SELECT USING (true);
-- 匿名用户可写入(投票)CREATE POLICY "Allow public insert" ON reactions FOR INSERT WITH CHECK (true);
-- 匿名用户可删除自己的投票(取消投票)CREATE POLICY "Allow public delete own" ON reactions FOR DELETE USING (true);3. 获取凭据
进入 Supabase 控制台 → Settings → API,记录两个值:
- Project URL:形如
https://xxx.supabase.co - anon public key:以
eyJ开头的长字符串
将它们写入项目根目录的 .env 文件:
PUBLIC_SUPABASE_URL=https://xxx.supabase.coPUBLIC_SUPABASE_ANON_KEY=eyJ...如果部署在 Cloudflare Pages,需要在 Pages → Settings → Environment variables 中同步添加这两个变量。
二、创建 Reactions 组件
新建 src/components/Reactions.astro。这是一个纯静态组件,只负责渲染 HTML 骨架和样式,不包含任何 <script> —— 交互逻辑统一由 Layout.astro 管理。
这种模式和项目中的 MathGraph、Twikoo 一致:组件输出静态 HTML,Layout.astro 的全局脚本通过 swup 的 page:view 钩子负责初始化。
---interface Props { slug: string;}const { slug } = Astro.props;---
<div id="post-reactions" class="post-reactions onload-animation" data-post-slug={slug} data-initialized="false"> <div class="reactions-divider"></div> <div class="reactions-label">这篇文章对你有帮助吗?</div> <div class="reactions-container"> <button class="reaction-btn" data-emoji="👍" disabled> <span class="reaction-emoji">👍</span> <span class="reaction-count">·</span> </button> <button class="reaction-btn" data-emoji="❤️" disabled> <span class="reaction-emoji">❤️</span> <span class="reaction-count">·</span> </button> <button class="reaction-btn" data-emoji="😮" disabled> <span class="reaction-emoji">😮</span> <span class="reaction-count">·</span> </button> <button class="reaction-btn" data-emoji="🎉" disabled> <span class="reaction-emoji">🎉</span> <span class="reaction-count">·</span> </button> <button class="reaction-btn" data-emoji="🤔" disabled> <span class="reaction-emoji">🤔</span> <span class="reaction-count">·</span> </button> <button class="reaction-btn" data-emoji="👀" disabled> <span class="reaction-emoji">👀</span> <span class="reaction-count">·</span> </button> </div></div>
<style is:inline>.post-reactions { padding-top: 1rem; padding-bottom: 0.5rem;}.reactions-divider { border-bottom: 1px dashed var(--line-divider); margin-bottom: 1.25rem;}.reactions-label { font-size: 0.875rem; color: rgba(0, 0, 0, 0.5); margin-bottom: 0.75rem;}html.dark .reactions-label { color: rgba(255, 255, 255, 0.5);}.reactions-container { display: flex; flex-wrap: wrap; gap: 0.5rem;}.reaction-btn { display: flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; border-radius: 9999px; border: 2px solid var(--btn-regular-bg); background: transparent; cursor: pointer; transition: all 0.2s; font-size: 0.875rem; user-select: none; color: inherit;}.reaction-btn:hover:not(:disabled):not(.voted):not(.pending) { background: var(--btn-plain-bg-hover); border-color: var(--primary); color: var(--primary);}.reaction-btn:disabled { opacity: 0.5; cursor: default;}.reaction-btn.pending { pointer-events: none;}.reaction-btn.voted { background: var(--primary); color: white; border-color: var(--primary);}.reaction-btn.voted:hover:not(.pending) { opacity: 0.85;}.reaction-btn:active:not(:disabled):not(.pending) { transform: scale(0.95);}.reaction-count { font-size: 0.75rem; color: rgba(0, 0, 0, 0.5); font-variant-numeric: tabular-nums; min-width: 0.5rem; text-align: center;}html.dark .reaction-count { color: rgba(255, 255, 255, 0.5);}.reaction-btn.voted .reaction-count { color: white;}</style>要点说明:
- 按钮初始状态为
disabled,计数显示·占位符,等 JS 加载数据后替换 data-post-slug存储文章标识(不使用完整 URL,避免 URL 结构变化导致数据丢失)data-initialized用于防止 swup 切页时重复初始化- 样式使用
<style is:inline>而非<style>,因为 Astro 的 scoped style 会添加属性选择器,与动态 DOM 操作不兼容 - 颜色全部引用 Fuwari 的 CSS 变量(
--primary、--btn-regular-bg、--btn-plain-bg-hover),自动适配亮/暗主题
三、在文章页放置组件
编辑 src/pages/posts/[...slug].astro。
在 frontmatter 中添加 import:
import Reactions from "../../components/Reactions.astro";在 #post-container 内部、License 组件之后放置:
{licenseConfig.enable && <License ...></License>}
<Reactions slug={getEntrySlug(entry)} />
</div> <!-- #post-container 的关闭标签 -->四、Layout.astro 添加交互逻辑
这是改动最多的文件,分为三步。
1. 注入 Supabase 凭据
在 Layout.astro 的 <head> 区域,主题加载脚本之后添加一个 <script is:inline define:vars> 块,将 .env 中的变量注入到 window 对象上:
<script is:inline define:vars={{ supabaseUrl: import.meta.env.PUBLIC_SUPABASE_URL, supabaseKey: import.meta.env.PUBLIC_SUPABASE_ANON_KEY}}> window.__SUPABASE_URL = supabaseUrl; window.__SUPABASE_ANON_KEY = supabaseKey;</script>这里用 define:vars 而不是在 JS 中硬编码,是因为 Astro 在构建时会把 import.meta.env.PUBLIC_* 替换为实际值,同时密钥不会出现在源码仓库中。
2. 添加 initReactions() 函数
在 Layout.astro 的主 <script> 块中(initTwikoo() 函数之后),添加完整的反应功能逻辑。核心分为三个部分:
浏览器指纹生成
async function generateFingerprint() { const components = [ navigator.userAgent, screen.width + 'x' + screen.height, String(screen.colorDepth), String(new Date().getTimezoneOffset()), navigator.language, String(navigator.hardwareConcurrency || ''), String((navigator as any).deviceMemory || ''), ]; // Canvas 指纹 try { const canvas = document.createElement('canvas'); canvas.width = 200; canvas.height = 50; const ctx = canvas.getContext('2d'); if (ctx) { ctx.textBaseline = 'top'; ctx.font = '14px Arial'; ctx.fillStyle = '#f60'; ctx.fillRect(125, 1, 62, 20); ctx.fillStyle = '#069'; ctx.fillText('fingerprint', 2, 15); components.push(canvas.toDataURL()); } } catch (e) { /* ignore */ } // WebGL 渲染器信息 try { const gl = document.createElement('canvas').getContext('webgl'); if (gl) { const debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); if (debugInfo) { components.push(String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL))); } } } catch (e) { /* ignore */ } const data = components.join('|||'); const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data)); return Array.from(new Uint8Array(hashBuffer)) .map(b => b.toString(16).padStart(2, '0')) .join('');}
async function getFingerprint() { let fp = localStorage.getItem('reactions_fp'); if (!fp) { fp = await generateFingerprint(); localStorage.setItem('reactions_fp', fp); } return fp;}原理:收集浏览器的 UA、屏幕分辨率、色深、时区、语言、CPU 核数、内存、Canvas 渲染结果、WebGL 渲染器名称,拼接后做 SHA-256 哈希。同一浏览器(相同配置和硬件)会生成相同的哈希值,结果缓存在 localStorage 中只需计算一次。
这个指纹不是密码学级别的身份标识,但足以在”无登录”场景下起到基本的防重复投票作用。无痕模式下 localStorage 隔离,指纹自然独立。
数据获取与渲染
async function initReactions() { const container = document.getElementById('post-reactions'); if (!container || container.dataset.initialized === 'true') return;
// 用 AbortController 取消上一次未完成的请求(应对快速切页) const existingController = (container as any).__abortController; if (existingController) existingController.abort(); const controller = new AbortController(); (container as any).__abortController = controller;
const slug = container.dataset.postSlug; if (!slug) return;
const supabaseUrl = (window as any).__SUPABASE_URL; const supabaseKey = (window as any).__SUPABASE_ANON_KEY; if (!supabaseUrl || !supabaseKey) return;
container.dataset.initialized = 'true';
try { const fingerprint = await getFingerprint();
// 一次请求获取该文章的所有反应记录 const resp = await fetch( `${supabaseUrl}/rest/v1/reactions?post_slug=eq.${encodeURIComponent(slug)}&select=emoji,fingerprint`, { headers: { 'apikey': supabaseKey, 'Authorization': `Bearer ${supabaseKey}`, }, signal: controller.signal, } );
if (!resp.ok) return; const rows = await resp.json();
// 客户端统计计数(数据量极小,无需服务端聚合) const counts = {}; const userVotes = new Set(); for (const row of rows) { counts[row.emoji] = (counts[row.emoji] || 0) + 1; if (row.fingerprint === fingerprint) { userVotes.add(row.emoji); } }
// 启用按钮、显示计数、标记已投票状态 const buttons = container.querySelectorAll('.reaction-btn'); buttons.forEach(btn => { const emoji = btn.dataset.emoji; btn.disabled = false; const countEl = btn.querySelector('.reaction-count'); countEl.textContent = String(counts[emoji] || 0);
if (userVotes.has(emoji)) { btn.classList.add('voted'); }
// 绑定点击事件(见下文) btn.addEventListener('click', () => handleClick(/* ... */)); }); } catch (e) { if (e.name === 'AbortError') return; }}使用 PostgREST 的过滤语法 ?post_slug=eq.xxx&select=emoji,fingerprint 一次性拿到该文章的全部记录,客户端做分组统计。对博客文章来说数据量极小(每篇文章最多 6 种 emoji),不需要服务端聚合。
点击事件:乐观更新 + 切换投票
btn.addEventListener('click', async function handleClick() { if (btn.classList.contains('pending')) return; btn.classList.add('pending'); // 防快速重复点击
const cache = JSON.parse(localStorage.getItem('reactions_votes') || '{}'); const alreadyVoted = cache[slug] && cache[slug][emoji]; const adding = !alreadyVoted; // 切换方向
// 乐观更新 UI(先改界面,再发请求) btn.classList.toggle('voted', adding); const prevText = countEl.textContent; const current = parseInt(prevText) || 0; countEl.textContent = String(Math.max(0, adding ? current + 1 : current - 1));
try { let ok; if (adding) { // 投票:POST 新记录 const r = await fetch(`${supabaseUrl}/rest/v1/reactions`, { method: 'POST', headers: { 'apikey': supabaseKey, 'Authorization': `Bearer ${supabaseKey}`, 'Content-Type': 'application/json', 'Prefer': 'return=minimal', }, body: JSON.stringify({ post_slug: slug, emoji, fingerprint }), signal: controller.signal, }); ok = r.ok || r.status === 409; // 409 = 唯一约束冲突,视为成功 } else { // 取消投票:DELETE 匹配记录 const r = await fetch( `${supabaseUrl}/rest/v1/reactions?post_slug=eq.${encodeURIComponent(slug)}&emoji=eq.${encodeURIComponent(emoji)}&fingerprint=eq.${encodeURIComponent(fingerprint)}`, { method: 'DELETE', headers: { 'apikey': supabaseKey, 'Authorization': `Bearer ${supabaseKey}`, }, signal: controller.signal, } ); ok = r.ok; }
if (ok) { // 更新本地缓存 if (!cache[slug]) cache[slug] = {}; if (adding) cache[slug][emoji] = true; else delete cache[slug][emoji]; localStorage.setItem('reactions_votes', JSON.stringify(cache)); } else { // 请求失败,回滚 UI btn.classList.toggle('voted', !adding); countEl.textContent = prevText; } } catch (e) { if (e.name === 'AbortError') return; btn.classList.toggle('voted', !adding); countEl.textContent = prevText; } finally { btn.classList.remove('pending'); }});三个设计要点:
- 乐观更新:先改 UI 再发请求。网络请求可能需要几百毫秒甚至更长,如果等到响应回来再改界面,用户会感到明显延迟。
- 切换投票:已投票时再点会发送 DELETE 请求删除对应行,同时 UI 回退。
localStorage缓存记录每个 slug+emoji 的投票状态,避免每次都要查数据库。 - 防快速点击:请求期间给按钮添加
pending类(pointer-events: none),阻止重复提交。
3. 注册调用点
在 Layout.astro 中,initReactions() 需要在两个地方被调用:
// 初始页面加载function init() { loadTheme(); loadHue(); setTimeout(() => { initTwikoo() }, 100) initReactions() // ← 添加}
// swup 切页后window.swup.hooks.on('page:view', () => { // ... 其他初始化 ... setTimeout(() => { initTwikoo() }, 100) initReactions() // ← 添加});swup 切页时会替换 #swup-container 内的全部 HTML,新页面的 #post-reactions 元素带有 data-initialized="false",initReactions() 会检测到这个状态并重新执行初始化。旧页面的 AbortController 被 abort,防止过期请求更新错误的 DOM。
五、动画延迟
编辑 src/styles/transition.css,在 #post-container :nth-child(6) 之后添加:
#post-container :nth-child(7) { animation-delay: calc(var(--content-delay) + 400ms) }Fuwari 的文章内容区通过 nth-child 逐子元素设置递增的入场动画延迟,Reactions 作为第 7 个子元素需要补上对应的延迟,否则会没有渐入效果。
六、防刷策略总结
本方案采用三层防护,不依赖用户登录:
| 层级 | 机制 | 作用 |
|---|---|---|
| 客户端 | localStorage 投票缓存 | 同一浏览器即时阻止重复投票,无需查库 |
| 客户端 | 浏览器指纹 SHA-256 | 跨页面、跨 session 识别同一用户 |
| 数据库 | UNIQUE (post_slug, emoji, fingerprint) 约束 | 即使绕过前端,数据库也会拒绝重复插入 |
客户端指纹不是密码学身份标识,清除 localStorage 或换浏览器就能绕过。对于个人博客来说这个防护等级足够。如果需要更严格的限制,可以在 Supabase Edge Function 中添加基于 IP 的频率限制,但需要额外配置。
七、注意事项
- Supabase 的 anon key 设计为公开使用,配合 RLS 策略不会暴露数据。不要使用 service_role key,那个有完全数据库权限。
- 确保
.env中的PUBLIC_SUPABASE_URL不包含/rest/v1/后缀,代码会自动拼接这个路径。 - 如果部署在 Cloudflare Pages,需要在 Pages 的环境变量设置中同步添加 Supabase 凭据,部署后重新构建。
- Supabase 免费项目在 7 天无活动后会自动暂停,首次访问会有一两秒的冷启动延迟。