diff --git a/client/src/app.tsx b/client/src/app.tsx index 577d9bd..a1dc097 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -15,6 +15,7 @@ const PostPage = lazy(() => import("@/pages/post").then((m) => ({ default: m.Pos const ArchivePage = lazy(() => import("@/pages/archive").then((m) => ({ default: m.ArchivePage }))); const AboutPage = lazy(() => import("@/pages/about").then((m) => ({ default: m.AboutPage }))); const FriendsPage = lazy(() => import("@/pages/friends").then((m) => ({ default: m.FriendsPage }))); +const GuestbookPage = lazy(() => import("@/pages/guestbook").then((m) => ({ default: m.GuestbookPage }))); const AdminLogin = lazy(() => import("@/pages/admin/login").then((m) => ({ default: m.AdminLogin }))); const AdminDashboard = lazy(() => import("@/pages/admin/dashboard").then((m) => ({ default: m.AdminDashboard }))); const AdminEditor = lazy(() => import("@/pages/admin/editor").then((m) => ({ default: m.AdminEditor }))); @@ -23,6 +24,7 @@ const AdminBackup = lazy(() => import("@/pages/admin/backup").then((m) => ({ def const AdminPages = lazy(() => import("@/pages/admin/pages").then((m) => ({ default: m.AdminPages }))); const AdminComments = lazy(() => import("@/pages/admin/comments").then((m) => ({ default: m.AdminComments }))); const AdminFriends = lazy(() => import("@/pages/admin/friends").then((m) => ({ default: m.AdminFriends }))); +const AdminGuestbook = lazy(() => import("@/pages/admin/guestbook").then((m) => ({ default: m.AdminGuestbook }))); const AdminMedia = lazy(() => import("@/pages/admin/media").then((m) => ({ default: m.AdminMedia }))); const AdminAnalytics = lazy(() => import("@/pages/admin/analytics").then((m) => ({ default: m.AdminAnalytics }))); const AdminSeo = lazy(() => import("@/pages/admin/seo").then((m) => ({ default: m.AdminSeo }))); @@ -169,6 +171,7 @@ export function App() { + @@ -219,6 +222,7 @@ export function App() { + diff --git a/client/src/components/admin-layout.tsx b/client/src/components/admin-layout.tsx index 87f13ac..4788ae3 100644 --- a/client/src/components/admin-layout.tsx +++ b/client/src/components/admin-layout.tsx @@ -31,6 +31,7 @@ const NAV_GROUPS = [ { href: "/admin/pages", icon: StickyNote, label: "页面管理" }, { href: "/admin/comments", icon: MessageCircle, label: "互动审核" }, { href: "/admin/friends", icon: Link2, label: "友链管理" }, + { href: "/admin/guestbook", icon: MessageCircle, label: "留言板" }, ], }, { diff --git a/client/src/components/article-card.tsx b/client/src/components/article-card.tsx index 2374abb..7d881cb 100644 --- a/client/src/components/article-card.tsx +++ b/client/src/components/article-card.tsx @@ -1,87 +1,110 @@ import { Link } from "wouter"; import { Badge } from "@/components/ui/badge"; import type { PostMeta } from "@/lib/api"; +import { clampCardHeight, clampCardWidth, getArticleCardGridClass, getArticleCardImageMode } from "@/lib/card-layout"; import { ArrowRight, CalendarDays, FolderOpen, Pin } from "lucide-react"; +import type { CSSProperties } from "react"; function formatDate(dateStr: string): string { const date = new Date(dateStr); return date.toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" }); } -/** 取标题首字作为无图占位 */ -function getInitial(title: string): string { - if (!title) return "·"; - const ch = title.trim().charAt(0); - return ch || "·"; -} - export function ArticleCard({ post }: { post: PostMeta }) { - const cover = post.coverImage || ""; - const gradient = post.coverColor || "from-gray-500/20 to-gray-600/20"; + const width = clampCardWidth(post.cardWidth); + const height = clampCardHeight(post.cardHeight); + const cover = post.coverImage?.trim() || ""; + const imageMode = getArticleCardImageMode(width, height, Boolean(cover)); + const isBackground = imageMode === "background"; + const compact = height < 190; + const style = { + "--article-card-height": `${height}px`, + } as CSSProperties; + const gridClass = getArticleCardGridClass(width); + const titleClass = "line-clamp-2 font-heading text-[20px] font-semibold leading-snug tracking-[-0.018em] text-foreground transition-colors duration-200 group-hover:text-foreground/90 lg:text-[23px]"; + const excerptClass = compact + ? "mt-[8px] line-clamp-1 text-[13px] leading-[1.65] text-muted-foreground" + : "mt-[10px] line-clamp-2 text-[14px] leading-[1.75] text-muted-foreground"; + + const meta = ( +
+ {post.pinned && ( + + + 置顶 + + )} + {post.tags.slice(0, 2).map((tag) => ( + {tag} + ))} + + + {formatDate(post.createdAt)} + +
+ ); + + const body = ( + <> + {meta} +

{post.title}

+

{post.excerpt}

+
+ + + {post.category || "未分类"} + + + 阅读全文 + + +
+ + ); return ( -
-
- {/* 封面区 */} -
-
- {cover ? ( - {post.title} - ) : ( -
- - {getInitial(post.title)} - -
- )} +
+ {imageMode === "background" && ( + <> + +
+
{body}
+ + )} + {imageMode === "side" && ( +
+
{body}
+
+ {post.title}
- - {/* 内容区 */} -
-
- {post.pinned && ( - - - 置顶 - - )} - {post.tags.slice(0, 2).map((tag) => ( - {tag} - ))} - - - {formatDate(post.createdAt)} - + )} + {imageMode === "top" && ( +
+
+ {post.title}
-

- {post.title} -

-

- {post.excerpt} -

-
- - - {post.category || "未分类"} - - - 阅读全文 - - +
{body}
+
+ )} + {imageMode === "thumbnail" && ( +
+
{body}
+
+ {post.title}
-
+ )} + {imageMode === "text" && ( +
{body}
+ )}
); diff --git a/client/src/components/navbar.tsx b/client/src/components/navbar.tsx index 92da4f9..7bb4bb5 100644 --- a/client/src/components/navbar.tsx +++ b/client/src/components/navbar.tsx @@ -13,6 +13,7 @@ const fixedStart = [{ href: "/", label: "首页" }]; const fixedEnd = [ { href: "/archive", label: "归档" }, { href: "/friends", label: "友链" }, + { href: "/guestbook", label: "留言" }, { href: "/about", label: "关于" }, ]; diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index b48ddc8..258b143 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -48,6 +48,8 @@ export type PostMeta = { excerpt: string | null; coverColor: string | null; coverImage: string | null; + cardWidth: number; + cardHeight: number; createdAt: string; tags: string[]; pinned: boolean; @@ -242,6 +244,9 @@ export async function createPost(data: { content: string; excerpt?: string; coverColor?: string; + coverImage?: string; + cardWidth?: number; + cardHeight?: number; published?: boolean; tags?: string[]; pinned?: boolean; @@ -551,6 +556,20 @@ export type AdminComment = CommentData & { postTitle: string; }; +export type GuestbookMessage = { + id: number; + authorName: string; + authorEmail?: string; + content: string; + approved: boolean; + createdAt: string; +}; + +export type GuestbookPage = { + items: GuestbookMessage[]; + nextCursor: number | null; +}; + export async function fetchComments(slug: string): Promise { const res = await fetch(`${API_BASE}/api/posts/${slug}/comments`); if (!res.ok) throw new Error("获取评论失败"); @@ -571,6 +590,25 @@ export async function submitComment(slug: string, data: { return res.json(); } +export async function fetchGuestbookMessages(before?: number): Promise { + const query = before ? `?before=${before}` : ""; + return fetchJsonWithCache(`/api/guestbook${query}`, 60_000); +} + +export async function submitGuestbookMessage(data: { + authorName: string; + authorEmail?: string; + content: string; + _hp?: string; +}): Promise<{ success: boolean; message?: string; error?: string }> { + const res = await fetch(`${API_BASE}/api/guestbook`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return res.json(); +} + export async function fetchAdminComments(): Promise { const res = await fetch(`${API_BASE}/api/admin/comments`, { headers: authHeaders(), @@ -579,6 +617,15 @@ export async function fetchAdminComments(): Promise { return res.json(); } +export async function fetchAdminGuestbookMessages(before?: number): Promise { + const query = before ? `?before=${before}` : ""; + const res = await fetch(`${API_BASE}/api/admin/guestbook${query}`, { + headers: authHeaders(), + }); + if (!res.ok) throw new Error("获取留言失败"); + return res.json(); +} + export async function fetchAdminFriends(): Promise { const res = await fetch(`${API_BASE}/api/admin/friends`, { headers: authHeaders(), @@ -656,6 +703,22 @@ export async function deleteComment(id: number): Promise { if (!res.ok) throw new Error("删除失败"); } +export async function approveGuestbookMessage(id: number): Promise { + const res = await fetch(`${API_BASE}/api/admin/guestbook/${id}/approve`, { + method: "POST", + headers: authHeaders(), + }); + if (!res.ok) throw new Error(await readError(res, "审核失败")); +} + +export async function deleteGuestbookMessage(id: number): Promise { + const res = await fetch(`${API_BASE}/api/admin/guestbook/${id}`, { + method: "DELETE", + headers: authHeaders(), + }); + if (!res.ok) throw new Error(await readError(res, "删除失败")); +} + /* ── 媒体管理 ──────────────────────────────── */ export type MediaItem = { key: string; diff --git a/client/src/lib/card-layout.ts b/client/src/lib/card-layout.ts new file mode 100644 index 0000000..49adcf4 --- /dev/null +++ b/client/src/lib/card-layout.ts @@ -0,0 +1,48 @@ +export type CardImageMode = "text" | "background" | "side" | "top" | "thumbnail"; +export type CardGridSize = "full" | "half" | "third"; + +export const CARD_IMAGE_MODE_LABEL: Record = { + text: "纯文字", + background: "背景图", + side: "侧图", + top: "顶图", + thumbnail: "缩略图", +}; + +export function clampCardWidth(value: number | null | undefined) { + const next = Number.isFinite(value) ? Number(value) : 100; + return Math.min(100, Math.max(42, Math.round(next))); +} + +export function clampCardHeight(value: number | null | undefined) { + const next = Number.isFinite(value) ? Number(value) : 220; + return Math.min(420, Math.max(156, Math.round(next))); +} + +export function getCardGridSize(width: number): CardGridSize { + const normalized = clampCardWidth(width); + if (normalized >= 84) return "full"; + if (normalized >= 58) return "half"; + return "third"; +} + +export const CARD_GRID_SIZE_LABEL: Record = { + full: "整行", + half: "双列", + third: "三列", +}; + +export function getArticleCardGridClass(width: number): string { + const gridSize = getCardGridSize(width); + if (gridSize === "full") return "sm:col-span-6 md:col-span-12"; + if (gridSize === "half") return "sm:col-span-3 md:col-span-6"; + return "sm:col-span-2 md:col-span-4"; +} + +export function getArticleCardImageMode(width: number, height: number, hasCover: boolean): CardImageMode { + if (!hasCover) return "text"; + if (width >= 82 && height >= 260) return "background"; + if (width >= 68 && height >= 190) return "side"; + if (height >= 250) return "top"; + return "thumbnail"; +} diff --git a/client/src/pages/admin/editor.tsx b/client/src/pages/admin/editor.tsx index 5fd9c0a..bbc5c81 100644 --- a/client/src/pages/admin/editor.tsx +++ b/client/src/pages/admin/editor.tsx @@ -1,8 +1,9 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useParams, useLocation } from "wouter"; import { fetchPost, createPost, updatePost, uploadImage, localizePostImages, fetchPostVersions, restorePostVersion, type PostVersion } from "@/lib/api"; +import { CARD_GRID_SIZE_LABEL, CARD_IMAGE_MODE_LABEL, clampCardHeight, clampCardWidth, getArticleCardImageMode, getCardGridSize } from "@/lib/card-layout"; import { renderMarkdown } from "@/lib/markdown"; -import { Save, Eye, EyeOff, Upload, Image, ChevronDown, ChevronUp, Bold, Italic, Heading2, Heading3, Link2, Code, Quote, List, ListOrdered, Minus, Maximize2, Minimize2, Table, CheckSquare, FileCode, ImageDown, History, Check, X, ArrowDownUp, PanelRightClose, PanelRight, ArrowLeft } from "lucide-react"; +import { Save, Eye, EyeOff, Upload, Image, ChevronDown, ChevronUp, Bold, Italic, Heading2, Heading3, Link2, Code, Quote, List, ListOrdered, Minus, Maximize2, Minimize2, Table, CheckSquare, FileCode, ImageDown, History, Check, X, ArrowDownUp, PanelRightClose, PanelRight, ArrowLeft, RotateCcw, SlidersHorizontal } from "lucide-react"; import { Link } from "wouter"; import Editor, { type Monaco } from "@monaco-editor/react"; import type * as MonacoTypes from "monaco-editor"; @@ -101,6 +102,13 @@ function clearDraft(slug: string) { try { localStorage.removeItem(`${DRAFT_KEY}_${slug || "new"}`); } catch { /* 忽略 */ } } +const CARD_LAYOUT_PRESETS = [ + { label: "三列紧凑", width: 48, height: 168 }, + { label: "双列标准", width: 72, height: 220 }, + { label: "横幅", width: 100, height: 292 }, + { label: "整行画报", width: 86, height: 360 }, +]; + export function AdminEditor() { const params = useParams<{ slug?: string }>(); const [, setLocation] = useLocation(); @@ -115,6 +123,8 @@ export function AdminEditor() { excerpt: "", coverColor: "from-zinc-500/20 to-slate-500/20", coverImage: "", + cardWidth: 100, + cardHeight: 220, tags: "", published: true, pinned: false, @@ -140,6 +150,12 @@ export function AdminEditor() { const [syncScroll, setSyncScroll] = useState(true); const syncScrollRef = useRef(true); const previewRef = useRef(null); + const hasCardCover = Boolean(form.coverImage.trim()); + const cardImageMode = getArticleCardImageMode(form.cardWidth, form.cardHeight, hasCardCover); + const cardImageModeLabel = CARD_IMAGE_MODE_LABEL[cardImageMode]; + const cardDensity = CARD_GRID_SIZE_LABEL[getCardGridSize(form.cardWidth)]; + const previewCardWidth = Math.min(228, Math.max(112, Math.round(form.cardWidth * 2.2))); + const previewCardHeight = Math.min(154, Math.max(72, Math.round(form.cardHeight / 2.35))); // 保持 ref 与 state 同步(避免 onMount 闭包陷阱) useEffect(() => { syncScrollRef.current = syncScroll; }, [syncScroll]); @@ -162,6 +178,8 @@ export function AdminEditor() { excerpt: post.excerpt || "", coverColor: post.coverColor || "", coverImage: post.coverImage || "", + cardWidth: post.cardWidth ?? 100, + cardHeight: post.cardHeight ?? 220, tags: post.tags.join(", "), published: post.published, pinned: post.pinned, @@ -230,6 +248,8 @@ export function AdminEditor() { excerpt: form.excerpt, coverColor: form.coverColor, coverImage: form.coverImage, + cardWidth: form.cardWidth, + cardHeight: form.cardHeight, published: form.published, tags: tagsList, pinned: form.pinned, @@ -360,7 +380,7 @@ export function AdminEditor() { } }, []); - const updateField = (key: keyof typeof form, value: string | boolean) => { + const updateField = (key: K, value: (typeof form)[K]) => { setForm((prev) => { const next = { ...prev, [key]: value }; // 标题变化时自动更新 Slug @@ -664,6 +684,184 @@ export function AdminEditor() { className="h-[34px] w-full rounded-md border border-border/25 bg-background/28 px-[10px] text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/50 focus:border-foreground/25" />
+
+
+
+
+
+ + + +
+

展示块

+

+ {cardDensity} / {form.cardHeight}px · {cardImageModeLabel} +

+
+
+ +
+ +
+ {CARD_LAYOUT_PRESETS.map((preset) => { + const active = form.cardWidth === preset.width && form.cardHeight === preset.height; + return ( + + ); + })} +
+ +
+
+ 列表密度 + {cardDensity} +
+
+ 自动图位 + {cardImageModeLabel} +
+
+ 封面状态 + {hasCardCover ? "已设置" : "未设置"} +
+
+ +
+
+
+ +
+ updateField("cardWidth", clampCardWidth(Number(e.target.value)))} + className="h-[30px] w-[64px] rounded-md border border-border/25 bg-background/28 px-[8px] text-right font-mono text-[11px] text-foreground outline-none focus:border-foreground/25" + /> + % +
+
+ updateField("cardWidth", Number(e.target.value))} + className="w-full accent-foreground" + /> +
+ 42 + 100 +
+
+ +
+
+ +
+ updateField("cardHeight", clampCardHeight(Number(e.target.value)))} + className="h-[30px] w-[72px] rounded-md border border-border/25 bg-background/28 px-[8px] text-right font-mono text-[11px] text-foreground outline-none focus:border-foreground/25" + /> + px +
+
+ updateField("cardHeight", Number(e.target.value))} + className="w-full accent-foreground" + /> +
+ 156 + 420 +
+
+
+
+ +
+
+ 实时预览 + + {cardImageModeLabel} + +
+
+
+
+ {cardImageMode === "background" && ( + + )} + {cardImageMode === "top" && ( + + )} +
+ {(cardImageMode === "side" || cardImageMode === "thumbnail") && ( + + )} +
+
+
+
+
+
+
+
+ {cardDensity} + {form.cardHeight}px + {cardImageModeLabel} +
+
+
+
+
updateField("title", e.target.value)} @@ -681,12 +879,12 @@ export function AdminEditor() { )} {/* ─── 编辑器 + 预览 ─── */} -
+
{/* 左侧 Monaco 编辑器 */}
{/* 工具栏 */}
-
+
{toolbarActions.map((item) => { if (!item.icon) return
; const Icon = item.icon; diff --git a/client/src/pages/admin/guestbook.tsx b/client/src/pages/admin/guestbook.tsx new file mode 100644 index 0000000..1ea656a --- /dev/null +++ b/client/src/pages/admin/guestbook.tsx @@ -0,0 +1,253 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Check, + CheckCircle2, + Clock, + Mail, + MessageCircle, + Trash2, +} from "lucide-react"; +import { + approveGuestbookMessage, + deleteGuestbookMessage, + fetchAdminGuestbookMessages, + type GuestbookMessage, +} from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; + +type FilterType = "all" | "pending" | "approved"; + +function formatDate(value: string) { + return new Date(value).toLocaleDateString("zh-CN", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function AdminGuestbook() { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [nextCursor, setNextCursor] = useState(null); + const [filter, setFilter] = useState("all"); + const [processing, setProcessing] = useState(null); + const [notice, setNotice] = useState<{ type: "success" | "error"; text: string } | null>(null); + + useEffect(() => { + document.title = "留言板 | Monolith"; + fetchAdminGuestbookMessages() + .then((page) => { + setMessages(page.items); + setNextCursor(page.nextCursor); + setNotice(null); + }) + .catch(() => { + setMessages([]); + setNotice({ type: "error", text: "留言加载失败,请稍后重试。" }); + }) + .finally(() => setLoading(false)); + }, []); + + const handleLoadMore = async () => { + if (!nextCursor || loadingMore) return; + setLoadingMore(true); + try { + const page = await fetchAdminGuestbookMessages(nextCursor); + setMessages((current) => [...current, ...page.items]); + setNextCursor(page.nextCursor); + setNotice(null); + } catch (error) { + setNotice({ type: "error", text: error instanceof Error ? error.message : "更多留言加载失败" }); + } finally { + setLoadingMore(false); + } + }; + + const counts = useMemo(() => ({ + all: messages.length, + pending: messages.filter((message) => !message.approved).length, + approved: messages.filter((message) => message.approved).length, + }), [messages]); + + const filteredMessages = useMemo(() => { + const list = messages.filter((message) => { + if (filter === "pending") return !message.approved; + if (filter === "approved") return message.approved; + return true; + }); + return [...list].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + }, [filter, messages]); + + const handleApprove = async (id: number) => { + setProcessing(id); + try { + await approveGuestbookMessage(id); + setMessages((prev) => prev.map((message) => ( + message.id === id ? { ...message, approved: true } : message + ))); + setNotice({ type: "success", text: "留言已通过" }); + } catch (error) { + setNotice({ type: "error", text: error instanceof Error ? error.message : "审核失败" }); + } finally { + setProcessing(null); + } + }; + + const handleDelete = async (id: number) => { + if (!confirm("确定删除这条留言?此操作不可撤销。")) return; + setProcessing(id); + try { + await deleteGuestbookMessage(id); + setMessages((prev) => prev.filter((message) => message.id !== id)); + setNotice({ type: "success", text: "留言已删除" }); + } catch (error) { + setNotice({ type: "error", text: error instanceof Error ? error.message : "删除失败" }); + } finally { + setProcessing(null); + } + }; + + const filterButtons: { key: FilterType; label: string; icon: typeof MessageCircle }[] = [ + { key: "all", label: "全部", icon: MessageCircle }, + { key: "pending", label: "待审核", icon: Clock }, + { key: "approved", label: "已通过", icon: CheckCircle2 }, + ]; + + return ( +
+
+
+

留言板

+

审核公开留言,处理访客反馈

+
+ {notice && ( + + {notice.text} + + )} +
+ +
+ {filterButtons.map((item) => { + const Icon = item.icon; + const active = filter === item.key; + return ( + + ); + })} +
+ +
+

+ {filter === "all" ? "所有留言" : filter === "pending" ? "待审核" : "已通过"} +

+ {filteredMessages.length} 条 +
+ + {loading ? ( +
+ {[1, 2, 3].map((item) => ( +
+ ))} +
+ ) : filteredMessages.length === 0 ? ( +
+ +

+ {filter === "pending" ? "没有待审核留言" : filter === "approved" ? "没有已通过留言" : "还没有留言"} +

+
+ ) : ( +
+ {filteredMessages.map((message, index) => ( +
+
+
+
+
+ {message.authorName} + {message.authorEmail && ( + + + {message.authorEmail} + + )} + + {message.approved ? "已通过" : "待审核"} + +
+

+ {message.content} +

+ {formatDate(message.createdAt)} +
+
+ {!message.approved && ( + + )} + +
+
+
+ ))} +
+ )} + {!loading && nextCursor && ( + + )} +
+ ); +} diff --git a/client/src/pages/guestbook.tsx b/client/src/pages/guestbook.tsx new file mode 100644 index 0000000..27a0f0e --- /dev/null +++ b/client/src/pages/guestbook.tsx @@ -0,0 +1,251 @@ +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { MessageCircle, Send, Sparkles } from "lucide-react"; +import { SeoHead } from "@/components/seo-head"; +import { + fetchGuestbookMessages, + submitGuestbookMessage, + type GuestbookMessage, +} from "@/lib/api"; + +type FormState = { + authorName: string; + authorEmail: string; + content: string; + hp: string; +}; + +const EMPTY_FORM: FormState = { + authorName: "", + authorEmail: "", + content: "", + hp: "", +}; + +const inputClass = "h-[42px] w-full rounded-md border border-border/25 bg-background/35 px-[12px] text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-foreground/25"; + +function formatDate(value: string) { + return new Date(value).toLocaleDateString("zh-CN", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function getInitial(name: string) { + return name.trim().slice(0, 1).toUpperCase() || "M"; +} + +export function GuestbookPage() { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [loadError, setLoadError] = useState(false); + const [nextCursor, setNextCursor] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [submitting, setSubmitting] = useState(false); + const [notice, setNotice] = useState<{ type: "success" | "error"; text: string } | null>(null); + + const loadMessages = useCallback(async (before?: number) => { + before ? setLoadingMore(true) : setLoading(true); + setLoadError(false); + try { + const page = await fetchGuestbookMessages(before); + setMessages((current) => before ? [...current, ...page.items] : page.items); + setNextCursor(page.nextCursor); + } catch { + setLoadError(true); + } finally { + before ? setLoadingMore(false) : setLoading(false); + } + }, []); + + useEffect(() => { + document.title = "留言板 | Monolith"; + void loadMessages(); + }, [loadMessages]); + + const updateField = (key: keyof FormState, value: string) => { + setForm((prev) => ({ ...prev, [key]: value })); + }; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setNotice(null); + + if (!form.authorName.trim() || !form.content.trim()) { + setNotice({ type: "error", text: "请填写昵称和留言内容" }); + return; + } + + setSubmitting(true); + try { + const result = await submitGuestbookMessage({ + authorName: form.authorName.trim(), + authorEmail: form.authorEmail.trim() || undefined, + content: form.content.trim(), + _hp: form.hp, + }); + + if (result.success) { + setForm(EMPTY_FORM); + setNotice({ type: "success", text: result.message || "留言已提交,等待审核" }); + } else { + setNotice({ type: "error", text: result.error || "提交失败" }); + } + } catch { + setNotice({ type: "error", text: "网络错误,请稍后重试" }); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ + +
+
+ + Guestbook +
+

留言板

+

+ 可以在这里留下问候、反馈或想交流的话。留言会先进入审核,公开展示时不会暴露邮箱。 +

+
+ +
+
+
+

公开留言

+ 已加载 {messages.length} 条 +
+ + {loading ? ( +
+ {[1, 2, 3].map((item) => ( +
+ ))} +
+ ) : loadError && messages.length === 0 ? ( +
+ +

留言加载失败,请检查网络后重试

+ +
+ ) : messages.length === 0 ? ( +
+ +

暂时还没有公开留言

+
+ ) : ( +
+ {messages.map((message) => ( +
+
+
+ {getInitial(message.authorName)} +
+
+
+

{message.authorName}

+ {formatDate(message.createdAt)} +
+

+ {message.content} +

+
+
+
+ ))} + {nextCursor && ( + + )} + {loadError && messages.length > 0 && ( +

更多留言加载失败,请重试

+ )} +
+ )} +
+ +