- {post.pinned && (
-
-
- 置顶
-
- )}
- {post.tags.slice(0, 2).map((tag) => (
-
{tag}
- ))}
-
-
- {formatDate(post.createdAt)}
-
+ )}
+ {imageMode === "top" && (
+
+
+
-
- {post.title}
-
-
- {post.excerpt}
-
-
-
-
- {post.category || "未分类"}
-
-
- 阅读全文
-
-
+
{body}
+
+ )}
+ {imageMode === "thumbnail" && (
+
+
{body}
+
+
-
+ )}
+ {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..6645837
--- /dev/null
+++ b/client/src/lib/card-layout.ts
@@ -0,0 +1,41 @@
+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 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..5a60677 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"
/>