3577 字
18 分钟
Fuwari 匿名留言板功能
2026-05-23
Purely By AI

在博客中添加一个 /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);

字段说明:

字段类型用途
iduuid主键,自动生成
contenttext留言内容,最长 200 字,数据库层面用 CHECK 约束
nicknametext昵称,默认”匿名”
avatartext头像 URL(已解析后的),默认空字符串表示使用首字彩色圆圈
fingerprinttext浏览器指纹,用于标识匿名用户身份
created_attimestamptz创建时间,默认当前时间

删除策略用 USING (true) 是因为 RLS 无法直接匹配客户端计算的 fingerprint。实际的”只能删除自己的留言”逻辑在客户端实现:删除请求的 URL 同时携带 id=eq.Xfingerprint=eq.Y 两个条件,PostgREST 只删除同时满足两者的行。对于匿名留言板这个安全等级足够。

2. 获取凭据#

如果之前做过文章反应功能,Supabase 凭据已经配置好了,跳过此步。

否则进入 Supabase 控制台 → Settings → API,记录 Project URLanon public key,写入 .env

PUBLIC_SUPABASE_URL=https://xxx.supabase.co
PUBLIC_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));
});

三个防护机制:

  1. 关键词过滤:客户端 includes() 检查,硬编码在代码中。不是安全防线,只是第一层拦截
  2. 冷却时间:localStorage 记录上次提交时间戳,30 秒内不可重复提交。跨标签页有效(localStorage 共享)
  3. 数据库约束CHECK (char_length(content) <= 200) 在数据库层面兜底,即使绕过前端也能阻止超长内容

Prefer: return=representation 让 PostgREST 在 INSERT 后返回完整的插入记录(包括数据库生成的 idcreated_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 同时带 idfingerprint 两个过滤条件,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 验证码。

这篇文章对你有帮助吗?