前言
最近把博客的评论系统从 Giscus 迁移到了自建的留言板。主要原因是想要一个更轻量、无需 GitHub 登录、且能深度集成到博客主题中的评论系统。同时,为了配合“繁琐小事”(Thoughts)页面的需求,开发了一套基于 Vercel KV (Redis) 的留言板方案。
技术选型
- 框架: Astro (主框架,使用
static输出模式配合 Serverless API) - UI 组件: Svelte (处理前端交互,如表单提交、即时渲染)
- 数据存储: Vercel KV (基于 Redis),同时通过
ioredis兼容标准 Redis 协议 - 样式: TailwindCSS
- Markdown 渲染: markdown-it + sanitize-html (支持部分 Markdown 语法并防止 XSS)
核心实现
1. 混合渲染架构 (Hybrid Rendering)
博客整体依然保持 静态生成 (Static Site Generation) 以确保最佳的访问速度和 SEO。但是留言板需要动态交互,因此我们利用 Astro 的 Server Endpoints 功能,实现了一个运行在 Vercel Serverless Function 上的 API。
在 astro.config.mjs 中,我们配置了 Vercel 适配器:
import vercel from "@astrojs/vercel";
export default defineConfig({
// ...
output: "static", // 默认为静态
adapter: vercel(),
// ...
});而在 API 文件 src/pages/api/messages.ts 中,我们强制开启服务端渲染:
export const prerender = false; // 声明此文件为动态路由
export const GET: APIRoute = async ({ request }) => {
// ...
}2. 数据存储层设计
为了兼容 Vercel 的 Serverless 环境以及本地开发,封装了一个统一的数据库工具 src/utils/local-db.ts。它会自动根据环境变量切换存储策略:
- 生产环境 (Vercel): 优先检测
KV_REST_API_URL(Vercel KV),如果不存在则检测REDIS_URL(使用ioredis连接)。 - 本地开发: 如果没有配置 Redis 环境变量,则自动降级为本地 JSON 文件存储 (
data/messages.json)。
// src/utils/local-db.ts
import { createClient } from "@vercel/kv";
import Redis from "ioredis";
// 自动检测客户端
const USE_VERCEL_KV = !!(process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN);
const USE_REDIS_URL = !USE_VERCEL_KV && !!process.env.REDIS_URL;
export async function getMessages(slug?: string) {
// ... 从 Redis 或 JSON 读取 ...
// 目前采用简单的单 Key 存储所有留言,通过 slug 过滤
if (slug) {
return messages.filter((m) => m.slug === slug);
}
return messages;
}3. 前端组件与交互 (Svelte)
前端主要由三个组件构成:
MessageBoard.svelte: 容器组件,负责获取数据。MessageList.svelte/MessageItem.svelte: 负责递归渲染留言及回复。MessageEditor.svelte: 负责表单提交及头像预览。
性能优化:Markdown 渲染单例化
在 MessageItem.svelte 中,每条留言都需要渲染 Markdown。如果直接在组件内 import 并初始化 markdown-it,会导致每条留言都创建新的解析器实例,极大地消耗内存并阻塞主线程。
我们利用 Svelte 的 <script context="module"> 特性,确保整个页面只初始化一次解析器:
<!-- src/components/MessageItem.svelte -->
<script context="module" lang="ts">
import MarkdownIt from "markdown-it";
import sanitizeHtml from "sanitize-html";
// 全局单例,复用配置
const md = new MarkdownIt({ html: true, breaks: true, linkify: true });
// 严格的 XSS 过滤配置
const sanitizeOptions = {
allowedTags: ["p", "b", "i", "em", "strong", "a", "code", "pre", "span", "img", "details", "summary"],
allowedAttributes: {
img: ["src", "alt", "title"],
a: ["href", "title", "target"],
span: ["class"],
},
allowedClasses: {
span: ["spoiler"], // 允许自定义的防剧透样式
}
};
function renderContent(text: string) {
// 自定义语法支持:||黑幕||
const withSpoilers = text.replace(/\|\|(.*?)\|\|/g, '<span class="spoiler">$1</span>');
return sanitizeHtml(md.render(withSpoilers), sanitizeOptions);
}
</script>
<script lang="ts">
export let message;
// 直接调用模块级函数
$: htmlContent = renderContent(message.content);
</script>交互优化:延迟加载与防抖
为了不影响页面的 LCP (Largest Contentful Paint),留言数据的请求被特意延迟了 100ms:
// src/components/MessageBoard.svelte
onMount(() => {
// 避免阻塞首屏 Hydration
const timer = setTimeout(() => {
fetchMessages();
}, 100);
return () => clearTimeout(timer);
});此外,MessageEditor 会将用户的昵称、邮箱、网址缓存在 localStorage 中,提升用户体验。
4. 路由与复用
为了让“繁琐小事”页面也能使用留言板,我们将 MessageBoard 封装进了 Comment.astro 组件。它会自动获取当前页面的 URL 路径作为 slug:
---
// src/components/Comment.astro
interface Props {
slug?: string;
}
const { slug: propSlug } = Astro.props;
// 默认使用 pathname,去除末尾斜杠
let slug = propSlug || Astro.url.pathname.replace(/\/$/, "");
---
<div class="mt-8 mb-8">
<MessageBoard client:visible {slug} />
</div>在 src/pages/thoughts/[...page].astro 中,我们进一步使用 <details> 标签包裹留言板,默认折叠,保持页面清爽:
<details class="group">
<summary class="...">
<span>展开评论区</span>
</summary>
<div class="mt-6 animate-fade-in-down">
<Comment slug="thoughts" />
</div>
</details>5. 获取设备信息 (Device Info)
为了让留言板更有趣,我们增加了一个小功能:显示发送者的设备信息(操作系统、浏览器、设备型号)。
首先安装 ua-parser-js:
pnpm add ua-parser-js
pnpm add -D @types/ua-parser-js然后在后端 API (src/pages/api/messages.ts) 中解析 User-Agent:
import { UAParser } from "ua-parser-js";
export const POST: APIRoute = async ({ request }) => {
// ...
const uaString = request.headers.get("user-agent") || "";
const parser = new UAParser(uaString);
// 解析结果
const browser = parser.getBrowser();
const os = parser.getOS();
const device = parser.getDevice();
// ... 保存到数据库
}最后在前端 (src/components/MessageItem.svelte) 展示即可。
部署配置
在 Vercel 面板中,需要配置以下环境变量之一:
- KV_REST_API_URL & KV_REST_API_TOKEN: 如果使用 Vercel Storage KV。
- REDIS_URL: 如果使用 Upstash 或其他标准 Redis 服务。
结语
这就完成了一个麻雀虽小五脏俱全的留言板系统。相比于 Giscus,它完全由我们自己掌控数据,并且可以根据需求随意定制样式和功能(比如未来可以加入邮件通知、后台管理等)。
附录:完整源代码
以下是本系统实现的核心文件源码。
src/types/message.ts (类型定义)
export interface Message {
id: string;
slug?: string;
parentId?: string;
nickname: string;
content: string;
email?: string;
website?: string;
avatar: string;
createdAt: number;
replies?: Message[];
os?: string;
browser?: string;
device?: string;
}src/utils/local-db.ts (数据库工具)
import fs from "node:fs";
import path from "node:path";
import { createClient } from "@vercel/kv";
import Redis from "ioredis";
import type { Message } from "../types/message";
const DB_PATH = path.join(process.cwd(), "data", "messages.json");
// Determine which client to use
const USE_VERCEL_KV = !!(process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN);
const USE_REDIS_URL = !USE_VERCEL_KV && !!process.env.REDIS_URL;
const IS_REMOTE = USE_VERCEL_KV || USE_REDIS_URL;
// Initialize clients
let kvClient: ReturnType<typeof createClient> | null = null;
let redisClient: Redis | null = null;
if (USE_VERCEL_KV) {
kvClient = createClient({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
});
} else if (USE_REDIS_URL) {
// Use ioredis for standard connection strings (e.g. redis://...)
// This works for Vercel Redis integrations that only provide REDIS_URL
try {
redisClient = new Redis(process.env.REDIS_URL!);
redisClient.on("error", (err) => {
console.error("Redis connection error:", err);
});
} catch (e) {
console.error("Failed to initialize Redis client:", e);
}
}
// Ensure data directory exists for local dev (fallback)
if (!IS_REMOTE && !fs.existsSync(path.dirname(DB_PATH))) {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
}
// Initialize DB file if not exists for local dev (fallback)
if (!IS_REMOTE && !fs.existsSync(DB_PATH)) {
fs.writeFileSync(DB_PATH, JSON.stringify([]));
}
export async function getMessages(slug?: string): Promise<Message[]> {
try {
let messages: Message[] = [];
if (USE_VERCEL_KV && kvClient) {
messages = (await kvClient.get<Message[]>("messages")) || [];
} else if (USE_REDIS_URL && redisClient) {
const raw = await redisClient.get("messages");
messages = raw ? JSON.parse(raw) : [];
} else if (fs.existsSync(DB_PATH)) {
const data = fs.readFileSync(DB_PATH, "utf-8");
messages = JSON.parse(data);
}
if (slug) {
return messages.filter((m) => m.slug === slug);
}
return messages;
} catch (error) {
console.error("Failed to get messages:", error);
return [];
}
}
export async function addMessage(
message: Omit<Message, "id" | "createdAt">,
): Promise<Message> {
// Always get ALL messages when adding
const messages = await getMessages();
const newMessage: Message = {
...message,
id: Math.random().toString(36).substring(2, 9),
createdAt: Date.now(),
};
messages.unshift(newMessage); // Add to top
// Limit to 2000 messages total for now to prevent infinite growth
if (messages.length > 2000) {
messages.pop();
}
if (USE_VERCEL_KV && kvClient) {
await kvClient.set("messages", messages);
} else if (USE_REDIS_URL && redisClient) {
await redisClient.set("messages", JSON.stringify(messages));
} else {
fs.writeFileSync(DB_PATH, JSON.stringify(messages, null, 2));
}
return newMessage;
}src/pages/api/messages.ts (后端 API)
import type { APIRoute } from "astro";
import { UAParser } from "ua-parser-js";
import { addMessage, getMessages } from "../../utils/local-db";
export const prerender = false;
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const slug = url.searchParams.get("slug") || undefined;
const messages = await getMessages(slug);
return new Response(JSON.stringify(messages), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
};
export const POST: APIRoute = async ({ request }) => {
console.log("POST /api/messages called");
try {
const body = await request.json();
console.log("Request body:", body);
const { nickname, content, email, website, parentId, slug } = body;
// Parse User Agent
const uaString = request.headers.get("user-agent") || "";
const parser = new UAParser(uaString);
const browser = parser.getBrowser();
const os = parser.getOS();
const device = parser.getDevice();
const browserName = browser.name
? `${browser.name} ${browser.major || browser.version || ""}`.trim()
: undefined;
const osName = os.name
? `${os.name} ${os.version || ""}`.trim()
: undefined;
const deviceName = device.model
? `${device.vendor || ""} ${device.model}`.trim()
: undefined;
if (!nickname || !content) {
return new Response(JSON.stringify({ error: "昵称和内容不能为空" }), {
status: 400,
});
}
// Determine avatar
let avatar = `https://api.dicebear.com/7.x/identicon/svg?seed=${nickname}`;
if (email) {
// Check if email is a QQ number or QQ email
const qqMatch = email.match(/^(\d{5,11})(@qq\.com)?$/);
if (qqMatch) {
const qq = qqMatch[1];
avatar = `https://q1.qlogo.cn/g?b=qq&nk=${qq}&s=100`;
}
}
let finalWebsite = website ? website.slice(0, 100) : undefined;
if (
finalWebsite &&
!finalWebsite.startsWith("http://") &&
!finalWebsite.startsWith("https://")
) {
finalWebsite = `https://${finalWebsite}`;
}
const newMessage = await addMessage({
nickname: nickname.slice(0, 20), // Limit nickname length
content: content.slice(0, 500), // Limit content length
email: email ? email.slice(0, 50) : undefined,
website: finalWebsite,
avatar,
parentId,
slug,
os: osName,
browser: browserName,
device: deviceName,
});
return new Response(JSON.stringify(newMessage), {
status: 201,
headers: {
"Content-Type": "application/json",
},
});
} catch (error) {
console.error("Error in POST /api/messages:", error);
return new Response(JSON.stringify({ error: "服务器内部错误" }), {
status: 500,
});
}
};src/components/Comment.astro (Astro 包装组件)
---
import MessageBoard from "./MessageBoard.svelte";
interface Props {
slug?: string;
}
const { slug: propSlug } = Astro.props;
// Use Astro.url.pathname as the default slug
// Remove trailing slash for consistency, unless it's root
let slug = propSlug || Astro.url.pathname;
if (slug.length > 1 && slug.endsWith('/')) {
slug = slug.slice(0, -1);
}
---
<div class="mt-8 mb-8">
<MessageBoard client:visible {slug} />
</div>src/components/MessageBoard.svelte (主组件)
<script lang="ts">
import { onMount } from "svelte";
import Icon from "@iconify/svelte";
import MessageEditor from "./MessageEditor.svelte";
import MessageItem from "./MessageItem.svelte";
import type { Message } from "../types/message";
export let slug = "message-board";
let messages: Message[] = [];
let loading = true;
let showSuccess = false;
async function fetchMessages() {
loading = true;
try {
const res = await fetch(`/api/messages/?slug=${slug}`);
if (res.ok) {
const rawMessages: Message[] = await res.json();
// Build tree
const messageMap = new Map<string, Message>();
rawMessages.forEach(m => {
m.replies = [];
messageMap.set(m.id, m);
});
const rootMessages: Message[] = [];
rawMessages.forEach(m => {
if (m.parentId && messageMap.has(m.parentId)) {
messageMap.get(m.parentId)!.replies!.push(m);
} else {
rootMessages.push(m);
}
});
// Sort root messages by newest first
rootMessages.sort((a, b) => b.createdAt - a.createdAt);
// Sort replies by oldest first (chronological conversation)
rawMessages.forEach(m => {
if (m.replies) {
m.replies.sort((a, b) => a.createdAt - b.createdAt);
}
});
messages = rootMessages;
}
} catch (e) {
console.error("Failed to fetch messages", e);
} finally {
loading = false;
}
}
function handleSuccess(e: CustomEvent) {
// Add the new message to the list or refresh
// If it's a root message (no parentId), we can prepend it.
// If it's a reply, it's complicated to update locally without re-fetching or traversing.
// Re-fetching is safer and easier.
// Show success toast
showSuccess = true;
setTimeout(() => {
showSuccess = false;
}, 3000);
fetchMessages();
}
onMount(() => {
// Delay fetch to avoid blocking initial render and interactions
const timer = setTimeout(() => {
fetchMessages();
}, 100);
return () => clearTimeout(timer);
});
</script>
<div class="flex flex-col gap-8">
<!-- Success Notification -->
{#if showSuccess}
<div class="fixed top-20 right-4 z-50 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg flex items-center gap-2 animate-bounce-in">
<Icon icon="fa6-solid:check" />
<span>发送成功!</span>
</div>
{/if}
<!-- Input Area -->
<div class="card-base p-6 border border-neutral-100 dark:border-neutral-800">
<div class="flex items-center gap-2 mb-4 font-bold text-lg text-neutral-900 dark:text-neutral-100">
<Icon icon="fa6-solid:pen-to-square" class="text-[var(--primary)]" />
留下你的足迹
</div>
<MessageEditor on:success={handleSuccess} {slug} />
</div>
<!-- Message List -->
<div class="flex flex-col gap-4">
{#if loading}
<div class="flex justify-center py-10">
<Icon icon="eos-icons:loading" class="text-3xl text-neutral-400" />
</div>
{:else if messages.length === 0}
<div class="flex flex-col items-center justify-center py-10 text-neutral-400 gap-2">
<Icon icon="fa6-solid:comment-slash" class="text-3xl" />
<span>还没有留言,快来抢沙发吧!</span>
</div>
{:else}
{#each messages as msg (msg.id)}
<MessageItem message={msg} on:replySuccess={handleSuccess} {slug} />
{/each}
{/if}
</div>
</div>
<style>
.card-base {
border-radius: var(--radius-large);
background-color: var(--card-bg);
}
@keyframes bounce-in {
0% { transform: translateY(-20px); opacity: 0; }
50% { transform: translateY(5px); }
100% { transform: translateY(0); opacity: 1; }
}
.animate-bounce-in {
animation: bounce-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
/* Markdown Styles */
:global(.markdown-content p) {
margin-bottom: 0.5em;
}
:global(.markdown-content p:last-child) {
margin-bottom: 0;
}
:global(.markdown-content a) {
color: var(--primary);
text-decoration: underline;
}
:global(.markdown-content ul), :global(.markdown-content ol) {
margin-left: 1.5em;
margin-bottom: 0.5em;
}
:global(.markdown-content ul) {
list-style-type: disc;
}
:global(.markdown-content ol) {
list-style-type: decimal;
}
:global(.markdown-content blockquote) {
border-left: 3px solid var(--primary);
padding-left: 1em;
margin-left: 0;
margin-bottom: 0.5em;
color: #666;
}
:global(.dark .markdown-content blockquote) {
color: #aaa;
}
:global(.markdown-content code) {
background-color: rgba(0, 0, 0, 0.1);
padding: 0.2em 0.4em;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
:global(.dark .markdown-content code) {
background-color: rgba(255, 255, 255, 0.1);
}
:global(.markdown-content pre) {
background-color: #f5f5f5;
padding: 1em;
border-radius: 8px;
overflow-x: auto;
margin-bottom: 0.5em;
}
:global(.dark .markdown-content pre) {
background-color: #1a1a1a;
}
:global(.markdown-content pre code) {
background-color: transparent;
padding: 0;
}
:global(.markdown-content .spoiler) {
background-color: #000;
color: #000;
padding: 0 4px;
border-radius: 4px;
cursor: pointer;
transition: color 0.3s;
}
:global(.dark .markdown-content .spoiler) {
background-color: #fff;
color: #fff;
}
:global(.markdown-content .spoiler:hover) {
color: #fff;
}
:global(.dark .markdown-content .spoiler:hover) {
color: #000;
}
</style>src/components/MessageItem.svelte (留言项组件)
<script context="module" lang="ts">
import MarkdownIt from "markdown-it";
import sanitizeHtml from "sanitize-html";
const md = new MarkdownIt({
html: true,
breaks: true,
linkify: true,
});
const sanitizeOptions = {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img", "details", "summary", "span", "h1", "h2"]),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ["src", "alt", "title"],
span: ["class"],
},
allowedClasses: {
span: ["spoiler"],
},
};
function renderContent(text: string) {
// Process spoilers: ||text|| -> <span class="spoiler">text</span>
const withSpoilers = text.replace(/\|\|(.*?)\|\|/g, '<span class="spoiler">$1</span>');
const rawHtml = md.render(withSpoilers);
return sanitizeHtml(rawHtml, sanitizeOptions);
}
</script>
<script lang="ts">
import Icon from "@iconify/svelte";
import type { Message } from "../types/message";
import MessageEditor from "./MessageEditor.svelte";
import { createEventDispatcher } from "svelte";
export let message: Message;
export let depth = 0;
export let slug = "message-board";
const dispatch = createEventDispatcher();
let showReply = false;
function timeAgo(timestamp: number) {
const seconds = Math.floor((Date.now() - timestamp) / 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);
return `${days}天前`;
}
function handleReplySuccess(e: CustomEvent) {
showReply = false;
dispatch("replySuccess", e.detail);
}
</script>
<div class="flex flex-col gap-4">
<div class="card-base p-4 border border-neutral-100 dark:border-neutral-800 transition-all hover:shadow-md">
<div class="flex gap-4">
<div class="w-10 h-10 rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden flex-shrink-0 mt-1">
<img src={message.avatar} alt={message.nickname} class="w-full h-full object-cover" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<div class="font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
{#if message.website}
<a href={message.website} target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors flex items-center gap-1 group">
{message.nickname}
<Icon icon="fa6-solid:link" class="text-xs opacity-0 group-hover:opacity-100 transition-opacity text-neutral-400" />
</a>
{:else}
{message.nickname}
{/if}
<span class="text-[10px] px-1.5 py-0.5 rounded-md bg-neutral-200/50 dark:bg-neutral-700/50 text-neutral-500">
Lv.1
</span>
</div>
<div class="flex items-center gap-3">
{#if message.os || message.browser}
<div class="hidden sm:flex items-center gap-1.5 text-[11px] text-neutral-400/80 bg-neutral-100 dark:bg-neutral-800/50 px-2 py-0.5 rounded-md mr-1">
{#if message.device}
<span class="flex items-center gap-1">
<Icon icon="fa6-solid:mobile" class="text-[10px]" />
{message.device}
</span>
{/if}
{#if message.os}
<span class="flex items-center gap-1">
{#if !message.device}
<Icon icon="fa6-solid:laptop" class="text-[10px]" />
{/if}
{message.os}
</span>
{/if}
{#if message.browser}
<span class="flex items-center gap-1">
<Icon icon="fa6-solid:globe" class="text-[10px]" />
{message.browser}
</span>
{/if}
</div>
{/if}
<div class="text-xs text-neutral-400">
{timeAgo(message.createdAt)}
</div>
<button
class="text-xs text-neutral-400 hover:text-[var(--primary)] transition-colors flex items-center gap-1"
on:click={() => showReply = !showReply}
>
<Icon icon="fa6-solid:reply" />
{showReply ? '取消回复' : '回复'}
</button>
</div>
</div>
<div class="mt-2 text-neutral-800 dark:text-neutral-200 text-sm leading-relaxed custom-md">
{@html renderContent(message.content)}
</div>
<!-- Reply Editor -->
{#if showReply}
<div class="mt-4 pt-4 border-t border-neutral-100 dark:border-neutral-800 animate-fade-in">
<MessageEditor
parentId={message.id}
placeholder={`回复 @${message.nickname}...`}
autofocus={true}
on:success={handleReplySuccess}
{slug}
/>
</div>
{/if}
</div>
</div>
</div>
<!-- Nested Replies -->
{#if message.replies && message.replies.length > 0}
<div class="flex flex-col gap-4 pl-8 border-l-2 border-neutral-100 dark:border-neutral-800 ml-4">
{#each message.replies as reply (reply.id)}
<svelte:self message={reply} depth={depth + 1} on:replySuccess {slug} />
{/each}
</div>
{/if}
</div>
<style>
.card-base {
border-radius: var(--radius-large);
background-color: var(--card-bg);
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>src/components/MessageEditor.svelte (编辑器组件)
<script lang="ts">
import { onMount, createEventDispatcher } from "svelte";
import Icon from "@iconify/svelte";
export let parentId: string | undefined = undefined;
export let placeholder = "说点什么吧... (支持 Markdown 和 ||隐藏内容||)";
export let autofocus = false;
export let slug = "message-board";
const dispatch = createEventDispatcher();
let nickname = "";
let email = "";
let website = "";
let content = "";
let submitting = false;
let contentTextarea: HTMLTextAreaElement;
// Reactive avatar preview
$: avatarPreview = email && (email.match(/^\d{5,11}$/) || email.match(/^\d{5,11}@qq\.com$/))
? `https://q1.qlogo.cn/g?b=qq&nk=${email.replace(/@qq\.com$/, '')}&s=100`
: `https://api.dicebear.com/7.x/identicon/svg?seed=${nickname || 'anonymous'}`;
onMount(() => {
nickname = localStorage.getItem("message_nickname") || "";
email = localStorage.getItem("message_email") || "";
website = localStorage.getItem("message_website") || "";
if (autofocus && contentTextarea) {
contentTextarea.focus();
}
});
$: if (typeof localStorage !== 'undefined') {
if (nickname) localStorage.setItem("message_nickname", nickname);
if (email) localStorage.setItem("message_email", email);
if (website) localStorage.setItem("message_website", website);
}
async function handleSubmit() {
if (!nickname.trim() || !content.trim()) return;
submitting = true;
try {
const res = await fetch("/api/messages/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nickname,
content,
email,
website,
parentId,
slug
}),
});
const data = await res.json();
if (res.ok) {
content = ""; // Clear content
dispatch("success", data);
} else {
alert(data.error || "发送失败,请重试");
}
} catch (e) {
console.error("Failed to post message", e);
alert("发送失败,请检查网络连接");
} finally {
submitting = false;
}
}
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row gap-4">
<div class="w-16 h-16 rounded-xl bg-neutral-100 dark:bg-neutral-800 overflow-hidden flex-shrink-0 border border-neutral-200 dark:border-neutral-700 shadow-sm self-start">
<img
src={avatarPreview}
alt="Avatar"
class="w-full h-full object-cover transition-all duration-500"
/>
</div>
<div class="flex-1 flex flex-col gap-3">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<input
type="text"
bind:value={nickname}
placeholder="昵称 (必填)"
class="w-full px-4 py-2 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-700 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:bg-white dark:focus:bg-black transition-all text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400"
maxlength="20"
/>
<input
type="text"
bind:value={email}
placeholder="QQ号 / 邮箱 (可选)"
class="w-full px-4 py-2 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-700 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:bg-white dark:focus:bg-black transition-all text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400"
maxlength="50"
/>
<input
type="text"
bind:value={website}
placeholder="个人网站 (可选)"
class="w-full px-4 py-2 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-700 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:bg-white dark:focus:bg-black transition-all text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400"
maxlength="100"
/>
</div>
<textarea
bind:this={contentTextarea}
bind:value={content}
{placeholder}
rows="3"
class="w-full px-4 py-2 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-700 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:bg-white dark:focus:bg-black transition-all resize-none text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400"
maxlength="500"
></textarea>
</div>
</div>
<div class="flex justify-end">
<button
on:click={handleSubmit}
disabled={submitting || !nickname || !content}
class="btn-primary px-6 py-2 rounded-lg flex items-center gap-2 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if submitting}
<Icon icon="eos-icons:loading" />
<span>发送中...</span>
{:else}
<Icon icon="fa6-solid:paper-plane" />
<span>发送</span>
{/if}
</button>
</div>
</div>
<style>
.btn-primary {
background-color: var(--primary);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-hover);
}
</style>