在博客中添加一个 /board/ 页面,读者可以匿名发布最多 200 字的短消息,支持自定义头像(邮箱 Gravatar 或直链图片),留言以社交动态流的形式展示。数据存储在 Supabase 中,全程客户端原生 JS 实现,不引入额外依赖。
demo:说说
一、Supabase 数据库设置
1. 建表
进入 Supabase 项目的 SQL Editor,执行:
CREATE TABLE messages ( id uuid DEFAULT gen_random_uuid() PRIMARY KEY, content text NOT NULL CHECK (char_length(content) <= 200 AND char_length(content) > 0), nickname text DEFAULT '匿名', avatar text DEFAULT '', fingerprint text NOT NULL, created_at timestamptz DEFAULT now());
-- 启用行级安全策略ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- 公开读取CREATE POLICY "public_select" ON messages FOR SELECT USING (true);
-- 公开写入CREATE POLICY "public_insert" ON messages FOR INSERT WITH CHECK (true);
-- 允许删除(实际所有权校验在客户端通过 fingerprint 完成)CREATE POLICY "owner_delete" ON messages FOR DELETE USING (true);字段说明:
| 字段 | 类型 | 用途 |
|---|---|---|
id | uuid | 主键,自动生成 |
content | text | 留言内容,最长 200 字,数据库层面用 CHECK 约束 |
nickname | text | 昵称,默认”匿名” |
avatar | text | 头像 URL(已解析后的),默认空字符串表示使用首字彩色圆圈 |
fingerprint | text | 浏览器指纹,用于标识匿名用户身份 |
created_at | timestamptz | 创建时间,默认当前时间 |
删除策略用 USING (true) 是因为 RLS 无法直接匹配客户端计算的 fingerprint。实际的”只能删除自己的留言”逻辑在客户端实现:删除请求的 URL 同时携带 id=eq.X 和 fingerprint=eq.Y 两个条件,PostgREST 只删除同时满足两者的行。对于匿名留言板这个安全等级足够。
2. 获取凭据
如果之前做过文章反应功能,Supabase 凭据已经配置好了,跳过此步。
否则进入 Supabase 控制台 → Settings → API,记录 Project URL 和 anon public key,写入 .env:
PUBLIC_SUPABASE_URL=https://xxx.supabase.coPUBLIC_SUPABASE_ANON_KEY=eyJ...部署在 Cloudflare Pages 的话,在 Pages → Settings → Environment variables 中同步添加。
二、创建留言板页面
新建 src/pages/board.astro。
页面采用社交动态流风格,不使用卡片堆叠。结构分为四个区域:标题栏、输入区、消息列表、加载更多。
---import MainGridLayout from "../layouts/MainGridLayout.astro";import { Icon } from "astro-icon/components";---
<MainGridLayout title="说说" description="留言板 - 分享碎片化的想法"> <!-- 页面标题栏 --> <div class="flex items-center gap-3 mb-6 onload-animation" style="animation-delay: var(--content-delay)"> <div class="w-9 h-9 rounded-lg bg-[var(--btn-regular-bg)] flex items-center justify-center"> <Icon name="material-symbols:chat-bubble-outline-rounded" class="text-lg text-[var(--btn-content)]" /> </div> <h1 class="text-2xl font-bold text-90">说说</h1> <span id="board-count" class="text-sm text-50 ml-auto"></span> </div>
<!-- 输入区域 --> <div class="rounded-2xl bg-[var(--card-bg)] transition-all duration-300 ease-out mb-6 onload-animation overflow-hidden" style="animation-delay: calc(var(--content-delay) + 50ms)"> <!-- 收起态:单行提示 --> <div id="compose-collapsed" class="flex items-center gap-3 px-4 py-3 cursor-text group"> <div class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0" style="background: oklch(0.75 0.14 var(--hue) / 0.15)"> <Icon name="material-symbols:edit-rounded" class="text-sm text-[var(--primary)]" /> </div> <div class="text-50 text-sm group-hover:text-[var(--primary)] transition flex-1"> 分享你的想法... </div> <button class="btn-regular rounded-lg h-8 px-3 text-xs font-medium active:scale-95 opacity-50 pointer-events-none"> 发布 </button> </div> <!-- 展开态 --> <div id="compose-expanded" class="compose-panel"> <!-- 昵称 + 头像预览 --> <div class="flex items-center gap-2 mb-3 pb-2 border-b border-dashed border-[var(--line-divider)]"> <div id="compose-avatar-preview" class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 text-xs font-bold text-white overflow-hidden" style="background: oklch(0.75 0.14 var(--hue) / 0.3)"> <Icon name="material-symbols:person-rounded" class="text-sm text-[var(--primary)]" /> </div> <input id="compose-nickname" type="text" maxlength="20" placeholder="昵称 (选填,默认匿名)" class="flex-1 bg-transparent text-90 text-sm outline-none placeholder:text-50 placeholder:text-sm" /> </div> <!-- 头像输入 --> <input id="compose-avatar" type="text" maxlength="200" placeholder="头像:邮箱 或 图片链接 (选填)" class="w-full mb-2 bg-transparent text-90 text-sm outline-none placeholder:text-50 placeholder:text-xs pb-2 border-b border-dashed border-[var(--line-divider)]" /> <!-- 内容输入 --> <textarea id="compose-textarea" maxlength="200" rows="3" placeholder="说点什么吧..." class="w-full min-h-[5rem] bg-transparent text-90 text-sm resize-none outline-none placeholder:text-50 placeholder:text-sm"></textarea> <!-- 底部操作栏 --> <div class="flex items-center justify-between mt-3 pt-3 border-t border-dashed border-[var(--line-divider)]"> <span id="char-count" class="text-30 text-xs">0 / 200</span> <div class="flex items-center gap-2"> <span id="board-error" class="text-sm text-red-500 hidden"></span> <button id="compose-cancel" class="btn-plain rounded-lg h-8 px-3 text-xs font-medium"> 取消 </button> <button id="compose-submit" class="btn-regular rounded-lg h-8 px-4 text-xs font-medium active:scale-95 opacity-50 pointer-events-none"> 发布 </button> </div> </div> </div> </div>
<!-- 消息列表容器,由 JS 动态填充 --> <div id="board-messages" data-initialized="false" data-offset="0"> <div id="board-loading" class="flex flex-col items-center justify-center py-16"> <div class="w-12 h-12 rounded-full bg-[var(--btn-regular-bg)] flex items-center justify-center mb-3 animate-pulse"> <Icon name="material-symbols:progress-activity" class="text-xl text-[var(--btn-content)] animate-spin" /> </div> <div class="text-50 text-sm">加载中...</div> </div> </div>
<!-- 空状态 --> <div id="board-empty" class="hidden flex-col items-center justify-center py-20 text-center"> <div class="w-16 h-16 rounded-full bg-[var(--btn-regular-bg)] flex items-center justify-center mb-4"> <Icon name="material-symbols:chat-bubble-outline-rounded" class="text-3xl text-[var(--btn-content)]" /> </div> <div class="text-75 text-base font-medium mb-2">还没有说说</div> <div class="text-50 text-sm">快来发布第一条想法吧</div> </div>
<!-- 加载更多 --> <div id="board-load-more-area" class="hidden flex-col items-center py-6 gap-3"> <button id="board-load-more" class="btn-regular rounded-lg h-9 px-6 text-sm font-medium active:scale-95"> 加载更多 </button> </div> <div id="board-load-more-loading" class="hidden flex items-center justify-center gap-2 py-6 text-50 text-sm"> <Icon name="material-symbols:progress-activity" class="text-base animate-spin" /> 加载中... </div> <div id="board-no-more" class="hidden text-center py-6 text-30 text-sm"> - 没有更多了 - </div>
<!-- 展开动画和头像预览样式 --> <style is:inline> #compose-collapsed:hover .btn-regular { opacity: 0.85; pointer-events: auto; } .compose-panel { max-height: 0; opacity: 0; overflow: hidden; padding: 0 1rem; transition: max-height 0.35s ease-out, opacity 0.25s ease-out, padding 0.3s ease-out; } .compose-panel.expanded { max-height: 400px; opacity: 1; padding: 1rem; } #compose-avatar-preview img { width: 100%; height: 100%; object-fit: cover; border-radius: 9999px; } </style></MainGridLayout>要点说明:
- 展开动画:收起态和展开态不用
hidden类切换(瞬间跳变),而是用max-height+opacity过渡。.compose-panel默认max-height: 0; opacity: 0,添加.expanded类后过渡到max-height: 400px; opacity: 1,配合padding变化实现平滑展开收起 - 头像预览:
compose-avatar-preview是一个 8×8 的圆形区域,输入头像地址后 JS 会自动更新其内容为<img>标签 - 样式使用
<style is:inline>:与 Reactions 组件一致,避免 Astro 的 scoped style 属性选择器干扰动态 DOM onload-animation:标题栏和输入区域各自带递增的animation-delay,复用 Fuwari 已有的fade-in-up入场动画
三、Layout.astro 添加交互逻辑
在 Layout.astro 的主 <script> 块中,initReactions() 函数之后添加 initBoardMessages()。
1. 辅助函数
相对时间
function timeAgo(dateStr) { const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); if (seconds < 60) return '刚刚'; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}分钟前`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}小时前`; const days = Math.floor(hours / 24); if (days < 30) return `${days}天前`; const months = Math.floor(days / 30); return `${months}个月前`;}XSS 转义
function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML;}所有用户输入(昵称、内容)在插入 innerHTML 前必须经过这个函数。textContent 赋值时浏览器会自动转义 <、>、& 等字符,再通过 innerHTML 读出的就是安全的 HTML 实体。
头像 URL 解析
async function resolveAvatarUrl(raw) { const trimmed = raw.trim(); if (!trimmed) return ''; if (trimmed.includes('@')) { // 邮箱 → loli.net Gravatar 镜像 (SHA-256) const data = new TextEncoder().encode(trimmed.toLowerCase()); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hash = Array.from(new Uint8Array(hashBuffer)) .map(b => b.toString(16).padStart(2, '0')).join(''); return `https://gravatar.loli.net/avatar/${hash}?d=404`; } return trimmed; // 直链直接返回}两种头像来源的判断逻辑:包含 @ 视为邮箱,计算 SHA-256 哈希后拼接 Gravatar 镜像地址;否则视为直链图片 URL。
Gravatar 标准使用 MD5 哈希邮箱,但 gravatar.loli.net 同时支持 SHA-256。使用 SHA-256 的原因是浏览器原生的 SubtleCrypto API 只提供 SHA 系列哈希,无需额外引入 MD5 库。
?d=404 参数表示:如果该邮箱没有设置 Gravatar 头像,返回 404 而非默认图片。前端检测到 404 后会回退显示首字彩色圆圈。
头像 HTML 生成
function getAvatarHtml(avatarUrl, nickname) { const firstChar = (nickname || '匿').charAt(0); const hue = (firstChar.charCodeAt(0) * 37) % 360; if (avatarUrl) { return `<div class="w-10 h-10 rounded-full flex-shrink-0 overflow-hidden bg-[var(--btn-regular-bg)]"> <img src="${escapeHtml(avatarUrl)}" alt="" class="w-full h-full object-cover" onerror="this.parentElement.innerHTML='...'"/> </div>`; } // 无头像:显示首字彩色圆圈 return `<div class="..." style="background-color: oklch(0.65 0.15 ${hue})"> ${escapeHtml(firstChar)} </div>`;}头像颜色由昵称首字的 Unicode 编码确定性生成:charCode * 37 % 360 映射到 oklch 色环的 0-359 度。同一个昵称总是同一个颜色。oklch 的 C(色度)和 L(亮度)固定,只变化 H(色相),保证所有头像的视觉亮度和饱和度一致。
onerror 回退逻辑:<img> 加载失败时(404 或网络错误),把父容器的 innerHTML 替换为首字圆圈。这样即使用户填错了图片地址,也不会显示破碎图片图标。
2. 消息列表渲染
function createMessageEl(msg, animate) { const el = document.createElement('div'); el.className = animate ? 'msg-enter' : ''; el.dataset.msgId = msg.id; const isOwner = msg.fingerprint === fingerprint; el.innerHTML = ` <div class="flex gap-3 py-5 px-1 group"> ${getAvatarHtml(msg.avatar || '', msg.nickname || '匿名')} <div class="flex-1 min-w-0"> <div class="flex items-baseline gap-2 mb-1.5"> <span class="font-bold text-sm text-90"> ${escapeHtml(msg.nickname || '匿名')} </span> <span class="text-30 text-xs"> ${timeAgo(msg.created_at)} </span> ${isOwner ? '<button class="board-delete-btn ..." data-id="...">删除</button>' : ''} </div> <div class="text-75 text-sm leading-relaxed break-words whitespace-pre-wrap"> ${escapeHtml(msg.content)} </div> </div> </div> <div class="border-t border-dashed border-black/10 dark:border-white/[0.15]"></div> `; return el;}要点说明:
- 每条消息是一行 flex 布局:左侧 10×10 圆形头像,右侧昵称 + 时间 + 内容
- 虚线分隔线复用了 Fuwari 的 PostCard 分隔线样式(
border-dashed border-black/10 dark:border-white/[0.15]),自动适配亮暗主题 - 删除按钮用
opacity-0 group-hover:opacity-100实现 hover 显示,不影响默认布局 animate参数控制是否添加msg-enter类(入场动画),初始加载和加载更多用true,后续追加用false
3. 数据获取
async function fetchMessages(offset, limit) { const resp = await fetch( `${supabaseUrl}/rest/v1/messages?` + `select=id,content,nickname,avatar,fingerprint,created_at` + `&order=created_at.desc&limit=${limit}&offset=${offset}`, { headers: { 'apikey': supabaseKey, 'Authorization': `Bearer ${supabaseKey}`, }, signal: controller.signal, } ); if (!resp.ok) throw new Error(`Fetch failed: ${resp.status}`); return await resp.json();}使用 PostgREST 的 order=created_at.desc 实现最新消息在前,limit + offset 实现分页。每次加载 20 条。
4. 输入区域交互
展开收起通过切换 CSS 类实现:
const collapsed = document.getElementById('compose-collapsed');const expanded = document.getElementById('compose-expanded');
function expandCompose() { collapsed.classList.add('hidden'); expanded.classList.add('expanded'); // 触发 CSS 过渡动画 textarea.focus();}
function collapseCompose() { expanded.classList.remove('expanded'); collapsed.classList.remove('hidden'); textarea.value = ''; // 昵称和头像不清除,保留供下次使用}
collapsed.addEventListener('click', expandCompose);cancelBtn.addEventListener('click', collapseCompose);收起时清除内容但保留昵称和头像输入——这两个值已持久化到 localStorage,清除输入框只是视觉层面的,下次展开时会自动恢复。
头像输入带 500ms 防抖预览:
let avatarDebounce;avatarInput.addEventListener('input', () => { clearTimeout(avatarDebounce); avatarDebounce = setTimeout(updateAvatarPreview, 500);});
async function updateAvatarPreview() { const raw = avatarInput.value.trim(); if (!raw) { // 无输入时显示默认 person 图标 avatarPreview.innerHTML = '<svg ...>...</svg>'; return; } const url = await resolveAvatarUrl(raw); avatarPreview.innerHTML = `<img src="${url}" onerror="..." />`;}防抖的原因:邮箱地址需要做 SHA-256 哈希,虽然是异步操作但频繁触发也无意义。500ms 内用户停止输入后才实际解析并加载预览图。
5. 提交留言
submitBtn.addEventListener('click', async () => { const content = textarea.value.trim(); if (!content || content.length > 200) return;
// 关键词过滤 if (content.includes('徐博闻')) { errorEl.textContent = '内容包含不允许的词汇'; errorEl.classList.remove('hidden'); return; }
// 30 秒冷却 const lastSubmit = localStorage.getItem('board_last_submit'); if (lastSubmit) { const elapsed = Date.now() - parseInt(lastSubmit); if (elapsed < 30000) { errorEl.textContent = `操作太频繁,请${Math.ceil((30000 - elapsed) / 1000)}秒后再试`; errorEl.classList.remove('hidden'); return; } }
const nickname = nicknameInput.value.trim() || '匿名'; const avatarRaw = avatarInput.value.trim() || ''; const avatar = avatarRaw ? await resolveAvatarUrl(avatarRaw) : '';
const resp = await fetch(`${supabaseUrl}/rest/v1/messages`, { method: 'POST', headers: { 'apikey': supabaseKey, 'Authorization': `Bearer ${supabaseKey}`, 'Content-Type': 'application/json', 'Prefer': 'return=representation', }, body: JSON.stringify({ content, nickname, avatar, fingerprint }), });
const [newMsg] = await resp.json();
// 持久化用户信息 localStorage.setItem('board_nickname', nickname); if (avatarRaw) localStorage.setItem('board_avatar', avatarRaw); localStorage.setItem('board_last_submit', String(Date.now()));
// 乐观插入到列表顶部 container.prepend(createMessageEl(newMsg, true));});三个防护机制:
- 关键词过滤:客户端
includes()检查,硬编码在代码中。不是安全防线,只是第一层拦截 - 冷却时间:localStorage 记录上次提交时间戳,30 秒内不可重复提交。跨标签页有效(localStorage 共享)
- 数据库约束:
CHECK (char_length(content) <= 200)在数据库层面兜底,即使绕过前端也能阻止超长内容
Prefer: return=representation 让 PostgREST 在 INSERT 后返回完整的插入记录(包括数据库生成的 id 和 created_at),拿到后直接用 createMessageEl 渲染并 prepend 到列表顶部。
头像在提交时先通过 resolveAvatarUrl 解析为最终 URL 再存入数据库,这样读取时不需要再做邮箱→URL 的转换。
6. 删除留言
使用事件委托,监听整个消息容器的 click 事件:
container.addEventListener('click', async (e) => { const target = e.target; if (!target.classList.contains('board-delete-btn')) return; const msgId = target.dataset.id; if (!msgId || !confirm('确定要删除这条留言吗?')) return;
const resp = await fetch( `${supabaseUrl}/rest/v1/messages` + `?id=eq.${encodeURIComponent(msgId)}` + `&fingerprint=eq.${encodeURIComponent(fingerprint)}`, { method: 'DELETE', headers: { ... } } );
if (resp.ok) { const msgEl = target.closest('[data-msg-id]'); // 淡出动画后移除 DOM msgEl.style.transition = 'opacity 0.3s, max-height 0.3s, ...'; msgEl.style.opacity = '0'; requestAnimationFrame(() => { msgEl.style.maxHeight = '0'; msgEl.style.overflow = 'hidden'; }); setTimeout(() => msgEl.remove(), 300); }});删除请求的 URL 同时带 id 和 fingerprint 两个过滤条件,PostgREST 只会删除同时匹配的行,保证用户只能删自己的留言。
移除 DOM 前先播放一个 300ms 的淡出收缩动画:opacity → 0,max-height → 0,视觉上消息”缩回消失”,动画结束后 remove()。
四、接入 Swup 生命周期
Fuwari 使用 Swup 做页面切换。Swup 切页时会替换 #swup-container 内的全部 DOM,留言板页面的 data-initialized 会被重置为 false。
在两个位置调用 initBoardMessages():
// 首次加载function init() { loadTheme(); loadHue(); setTimeout(() => { initTwikoo() }, 100) initReactions() initBoardMessages() // ← 添加}
// swup 切页后window.swup.hooks.on('page:view', () => { // ... initReactions() initBoardMessages() // ← 添加});函数入口的 guard 检查确保只在留言板页面执行:
async function initBoardMessages() { const container = document.getElementById('board-messages'); if (!container || container.dataset.initialized === 'true') return; // ...}其他页面没有 #board-messages 元素,getElementById 返回 null,函数直接退出,零开销。
五、动画
编辑 src/styles/transition.css,追加:
/* 留言条目入场动画 */@keyframes msg-slide-in { 0% { transform: translateY(0.75rem); opacity: 0; } 100% { transform: translateY(0); opacity: 1; }}.msg-enter { animation: 300ms msg-slide-in ease-out; }比 Fuwari 已有的 fade-in-up(位移 2rem)更轻微(位移 0.75rem),适合消息列表中逐条出现的场景,不至于太”跳”。
六、导航链接
编辑 src/config.ts,在 navBarConfig.links 中添加:
{ name: "说说", url: "/board/", external: false,},七、keep-alive
编辑 scripts/keep-alive.mjs,在 methods 数组中添加对 messages 表的定期查询:
{ name: "messages query", fn: async () => { const limit = randomInt(1, 5); const resp = await fetch( `${supabaseUrl}/rest/v1/messages?select=id&limit=${limit}`, { headers: { apikey: supabaseKey, Authorization: `Bearer ${supabaseKey}`, }, } ); if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`); },},Supabase 免费项目 7 天无活动会自动暂停。已有 GitHub Actions 每天两次执行 keep-alive 脚本查询 reactions 表,现在加上 messages 表一起保活。
八、LocalStorage 持久化
留言板用到以下几个 localStorage key:
| Key | 用途 | 生命周期 |
|---|---|---|
reactions_fp | 浏览器指纹 | 永久(复用反应功能的指纹) |
board_nickname | 用户填写的昵称 | 永久 |
board_avatar | 用户填写的头像原始值 | 永久 |
board_last_submit | 上次提交时间戳 | 永久(用于 30 秒冷却判断) |
昵称和头像在每次提交成功后写入 localStorage,页面加载时自动恢复到输入框。用户不需要每次重新填写。头像原始值(邮箱或 URL)存储的是未解析的原始输入,而非解析后的最终 URL——这样邮箱格式的头像在预览时能正确走 Gravatar 解析流程。
九、防刷与安全总结
| 层级 | 机制 | 作用 |
|---|---|---|
| 客户端 | 30 秒冷却(localStorage) | 防止短时间内大量提交 |
| 客户端 | 关键词过滤 | 第一层内容拦截 |
| 客户端 | fingerprint 匹配 | 只能删除自己的留言 |
| 数据库 | CHECK (char_length <= 200) | 内容长度兜底 |
| 数据库 | RLS + DELETE 带 fingerprint 条件 | 即使绕过前端,也无法删别人的留言 |
浏览器指纹不是密码学身份标识,清 localStorage 或换浏览器/设备就能绕过冷却和删除限制。对于个人博客的留言板来说这个安全等级足够。如果需要更严格的限制,可以加 Supabase Edge Function 做 IP 频率限制,或接入 Turnstile 验证码。