- {/* 装饰光斑 */}
-
-
-
+
);
}
diff --git a/frontend/src/app/(admin)/admin/page.tsx b/frontend/src/app/(admin)/admin/page.tsx
index 06fa9eb..1a9789a 100644
--- a/frontend/src/app/(admin)/admin/page.tsx
+++ b/frontend/src/app/(admin)/admin/page.tsx
@@ -1,314 +1,233 @@
"use client";
-import { useEffect, useState, useTransition } from "react";
-import { api } from "@/lib/api";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { FileText, Users, MessageSquare, Eye } from "lucide-react";
-import { StatsCard } from "@/components/dashboard/stats-card";
-import { QuickActions } from "@/components/dashboard/quick-actions";
-import { RecentActivities } from "@/components/dashboard/recent-activities";
-import { TopArticles } from "@/components/dashboard/top-articles";
-import { TodoList } from "@/components/dashboard/todo-list";
-import { SkeletonChart } from "@/components/ui/skeleton-chart";
-import { EmptyState } from "@/components/ui/empty-state";
-import { ErrorBoundary } from "@/components/error-boundary";
+import { useEffect, useState } from "react";
+import Link from "next/link";
import {
- PieChart,
- Pie,
- Cell,
- LineChart,
- Line,
- XAxis,
- YAxis,
- CartesianGrid,
- Tooltip,
- ResponsiveContainer,
- Legend,
-} from "recharts";
-
-/* ==============================
- * 数据类型定义
- * ============================== */
+ FileText,
+ Users,
+ MessageSquare,
+ Eye,
+ Plus,
+ List,
+ FolderTree,
+ Tags,
+ Mail,
+ MessageCircle,
+ Camera,
+ ArrowRight,
+ Bell,
+ Check,
+ Trash2,
+} from "lucide-react";
+import { api } from "@/lib/api";
+import { cn } from "@/lib/utils";
+import { toast } from "sonner";
interface DashboardData {
articleCount: number;
userCount: number;
messageCount: number;
viewCount: number;
- categoryList: { name: string; value: number }[];
- viewList: { date: string; count: number }[];
}
-interface TopArticle {
+interface Notification {
id: number;
title: string;
- viewCount: number;
- commentCount: number;
- likeCount: number;
+ content: string;
+ read: boolean;
+ createTime: string;
}
-interface TodoItem {
- type: "draft" | "comment" | "message";
- count: number;
- label: string;
- link: string;
-}
-
-const CHART_COLORS = [
- "var(--color-chart-1)",
- "var(--color-chart-2)",
- "var(--color-chart-3)",
- "var(--color-chart-4)",
- "var(--color-chart-5)",
-];
-
-const TIME_RANGES = [
- { label: "7天", value: 7 },
- { label: "30天", value: 30 },
- { label: "90天", value: 90 },
-] as const;
-
-/* ==============================
- * 仪表盘页面
- * ============================== */
-
export default function DashboardPage() {
const [data, setData] = useState
(null);
const [loading, setLoading] = useState(true);
- const [timeRange, setTimeRange] = useState(7);
- const [, startTransition] = useTransition();
-
- // Extended data (may not be available from backend)
- const [topArticles, setTopArticles] = useState([]);
- const [todos, setTodos] = useState([]);
- const [extLoading, setExtLoading] = useState(true);
+ const [notifications, setNotifications] = useState([]);
+ const [tab, setTab] = useState<"all" | "unread" | "read">("all");
useEffect(() => {
let cancelled = false;
-
- // Fetch dashboard data
- startTransition(() => setLoading(true));
api
.get("/admin")
.then((res) => {
- if (!cancelled && res.flag) {
- startTransition(() => setData(res.data));
- }
- })
- .finally(() => {
- if (!cancelled) startTransition(() => setLoading(false));
- });
-
- // Fetch extended data
- startTransition(() => setExtLoading(true));
- api
- .get<{ records: TopArticle[] }>("/admin/articles?page=1&size=5&sort=viewCount,desc")
- .then((res) => {
- if (!cancelled && res.flag && res.data?.records) {
- startTransition(() => setTopArticles(res.data.records));
- }
- })
- .catch((err) => {
- console.warn("Failed to fetch top articles:", err);
- });
-
- Promise.all([
- api.get<{ records: { id: number; status: number }[] }>("/admin/articles?page=1&size=1&status=3"),
- api.get<{ records: { id: number }[] }>("/admin/comments?page=1&size=1"),
- api.get<{ records: { id: number }[] }>("/admin/messages?page=1&size=1"),
- ])
- .then(([draftRes, commentRes, messageRes]) => {
- if (cancelled) return;
- const items: TodoItem[] = [];
- if (draftRes.flag && draftRes.data?.records) {
- items.push({
- type: "draft",
- count: draftRes.data.records.length,
- label: "草稿文章",
- link: "/admin/articles?status=3",
- });
- }
- if (commentRes.flag && commentRes.data?.records) {
- items.push({
- type: "comment",
- count: commentRes.data.records.length,
- label: "待审核评论",
- link: "/admin/comments",
- });
- }
- if (messageRes.flag && messageRes.data?.records) {
- items.push({
- type: "message",
- count: messageRes.data.records.length,
- label: "未读留言",
- link: "/admin/messages",
- });
- }
- startTransition(() => setTodos(items));
- })
- .catch((err) => {
- console.warn("Failed to fetch todo data:", err);
+ if (!cancelled && res.flag) setData(res.data);
})
+ .catch(() => {})
.finally(() => {
- if (!cancelled) startTransition(() => setExtLoading(false));
+ if (!cancelled) setLoading(false);
});
-
return () => {
cancelled = true;
};
}, []);
+ // 占位通知
+ useEffect(() => {
+ setNotifications([
+ {
+ id: 1,
+ title: "欢迎使用博客后台",
+ content: "在这里你可以管理文章、评论、用户等所有内容",
+ read: false,
+ createTime: new Date().toISOString(),
+ },
+ ]);
+ }, []);
+
const stats = [
- { title: "文章数", value: data?.articleCount ?? 0, icon: FileText },
- { title: "用户数", value: data?.userCount ?? 0, icon: Users },
- { title: "留言数", value: data?.messageCount ?? 0, icon: MessageSquare },
- { title: "访问量", value: data?.viewCount ?? 0, icon: Eye },
+ { label: "总文章数", value: data?.articleCount ?? 0, icon: FileText },
+ { label: "总用户数", value: data?.userCount ?? 0, icon: Users },
+ { label: "总评论数", value: data?.messageCount ?? 0, icon: MessageSquare },
+ { label: "总访问量", value: data?.viewCount ?? 0, icon: Eye },
];
- const viewTrend =
- data?.viewList && data.viewList.length >= 2
- ? (() => {
- const mid = Math.floor(data.viewList.length / 2);
- const recent = data.viewList.slice(mid).reduce((s, v) => s + v.count, 0);
- const previous = data.viewList.slice(0, mid).reduce((s, v) => s + v.count, 0);
- return previous > 0 ? (recent - previous) / previous : undefined;
- })()
- : undefined;
+ const quickActions = [
+ { title: "发布文章", description: "写一篇新文章", icon: Plus, href: "/admin/articles/editor" },
+ { title: "文章列表", description: "管理现有文章", icon: List, href: "/admin/articles" },
+ { title: "分类管理", description: "管理文章分类", icon: FolderTree, href: "/admin/categories" },
+ { title: "标签管理", description: "管理文章标签", icon: Tags, href: "/admin/tags" },
+ { title: "评论管理", description: "审核评论", icon: MessageCircle, href: "/admin/comments" },
+ { title: "留言管理", description: "查看留言", icon: Mail, href: "/admin/messages" },
+ ];
- const filteredViewList = data?.viewList?.slice(-timeRange) ?? [];
+ const filteredNotifications = notifications.filter((n) => {
+ if (tab === "unread") return !n.read;
+ if (tab === "read") return n.read;
+ return true;
+ });
return (
- {/* 统计卡片 */}
-
- {stats.map((stat, index) => (
-
+ {/* 统计卡片(spec §5.4.1) */}
+
+ {stats.map((stat) => (
+
+
+
+
{stat.label}
+
+ {loading ? "—" : stat.value}
+
+
+
+
+
+
+
))}
- {/* 快捷操作 */}
-
-
- {/* 双栏:最近动态 + 热门文章 */}
- {/* TODO: 接入真实的最近活动数据(如操作日志 API) */}
-
- {/*
-
- */}
-
-
-
+ {/* 快捷操作(spec §5.4.2) */}
+
+
+ 快捷操作
+
+
+ {quickActions.map((a) => (
+
+
+
+
{a.title}
+
{a.description}
+
+
+
+ ))}
+
- {/* 访问趋势 + 时间范围 */}
-
-
-
- 访问趋势
-
- {TIME_RANGES.map((range) => (
-
- ))}
-
-
-
- {loading ? (
-
- ) : filteredViewList.length > 0 ? (
-
-
-
-
-
-
-
-
-
-
- ) : (
-
- )}
-
-
-
-
- {/* 分类统计 */}
-
-
-
- 分类文章统计
-
-
- {loading ? (
-
- ) : data?.categoryList && data.categoryList.length > 0 ? (
-
-
-
- `${name ?? ""} ${((percent ?? 0) * 100).toFixed(0)}%`
- }
- >
- {data.categoryList.map((_, index) => (
- |
- ))}
-
-
-
-
- ) : (
-
- )}
-
-
-
-
- {/* 待办提醒 */}
-
-
-
+ {/* 通知面板(spec §5.4.3) */}
+
+
+
+
+ 通知
+
+
+ {[
+ { key: "all" as const, label: "全部" },
+ { key: "unread" as const, label: "未读" },
+ { key: "read" as const, label: "已读" },
+ ].map((t) => (
+
+ ))}
+
+
+
+
+ {filteredNotifications.length === 0 ? (
+ - 暂无通知
+ ) : (
+ filteredNotifications.map((n) => (
+ -
+
+
+
+ {n.title}
+
+
{n.content}
+
+
+ {!n.read && (
+
+ )}
+
+
+
+
+ ))
+ )}
+
+
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx
new file mode 100644
index 0000000..9563e41
--- /dev/null
+++ b/frontend/src/app/(auth)/forgot-password/page.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { ForgotPasswordForm } from "@/components/auth/ForgotPasswordForm";
+
+export default function ForgotPasswordPage() {
+ const router = useRouter();
+ return (
+
router.push("/login")} />
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/app/(auth)/layout.tsx b/frontend/src/app/(auth)/layout.tsx
new file mode 100644
index 0000000..0e20f8a
--- /dev/null
+++ b/frontend/src/app/(auth)/layout.tsx
@@ -0,0 +1,9 @@
+import AuthLayout from "@/components/auth/AuthLayout";
+
+export default function AuthLayoutWrapper({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return {children};
+}
\ No newline at end of file
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx
new file mode 100644
index 0000000..db68cc0
--- /dev/null
+++ b/frontend/src/app/(auth)/login/page.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { LoginForm } from "@/components/auth/LoginForm";
+
+export default function LoginPage() {
+ const router = useRouter();
+ return (
+ router.push("/register")}
+ onSwitchToForgot={() => router.push("/forgot-password")}
+ />
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx
new file mode 100644
index 0000000..cc382bd
--- /dev/null
+++ b/frontend/src/app/(auth)/register/page.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { RegisterForm } from "@/components/auth/RegisterForm";
+
+export default function RegisterPage() {
+ const router = useRouter();
+ return (
+ router.push("/login")} />
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/app/(blog)/ArticleList.tsx b/frontend/src/app/(blog)/ArticleList.tsx
index 979b9c1..ae27e2d 100644
--- a/frontend/src/app/(blog)/ArticleList.tsx
+++ b/frontend/src/app/(blog)/ArticleList.tsx
@@ -1,296 +1,230 @@
"use client";
import { useEffect, useRef, useState } from "react";
-import Link from "next/link";
-import { Skeleton } from "@/components/ui/skeleton";
-import { Button } from "@/components/ui/button";
-import Image from "next/image";
-import { Calendar, Tag, Folder } from "lucide-react";
+import { useSearchParams, useRouter, usePathname } from "next/navigation";
+import { ArticleCard, ArticleListPagination, ArticleListSkeleton, ArticleEmpty, ArticleError, type ArticleCardData } from "@/components/blog/ArticleCard";
+import { CategorySidebar, TagSidebar, RecommendedArticles } from "@/components/blog/Sidebars";
import { api } from "@/lib/api";
-
-interface TagBrief {
- id: number;
- tagName: string;
-}
-
-interface Article {
- id: number;
- articleCover: string;
- articleTitle: string;
- articleContent: string;
- isTop: boolean;
- type: number;
- createTime: string;
- categoryName: string;
- tagVOList: TagBrief[];
-}
-
-interface PageResult {
- records: Article[];
+import { cn, stripMarkdown } from "@/lib/utils";
+
+interface PageData {
+ records: {
+ id: number;
+ articleTitle: string;
+ articleCover?: string;
+ articleContent?: string;
+ isTop?: boolean;
+ createTime?: string;
+ categoryName?: string;
+ viewCount?: number;
+ tagVOList?: { id: number; tagName: string }[];
+ }[];
count: number;
}
-interface ArticleListProps {
- categoryId?: string | null;
- tagId?: string | null;
-}
-
const PAGE_SIZE = 10;
-function stripMarkdown(s: string): string {
- return (s || "")
- .replace(/[#*`>\-\[\]()_~]/g, "")
- .replace(/\s+/g, " ")
- .trim();
-}
+export default function ArticleList() {
+ const params = useSearchParams();
+ const router = useRouter();
+ const pathname = usePathname();
+ const categoryId = params.get("category");
+ const tagId = params.get("tag");
+ const hasFilter = !!(categoryId || tagId);
-export default function ArticleList({
- categoryId,
- tagId,
-}: ArticleListProps = {}) {
- const [articles, setArticles] = useState([]);
+ const [articles, setArticles] = useState([]);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
- const [showCover, setShowCover] = useState(true);
+ const [showImages, setShowImages] = useState(true);
const requestIdRef = useRef(0);
+ const holdHeightRef = useRef(null);
useEffect(() => {
setCurrent(1);
- fetchArticles(1);
+ // 记录旧高度,避免布局抖动
+ holdHeightRef.current = document.getElementById("article-list-wrap")?.offsetHeight ?? null;
+ fetch(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categoryId, tagId]);
- function fetchArticles(page: number) {
- const thisRequest = ++requestIdRef.current;
-
+ function fetch(page: number) {
+ const thisReq = ++requestIdRef.current;
setLoading(true);
setError("");
- const hasFilter = categoryId || tagId;
- const params = new URLSearchParams({
+ const qs = new URLSearchParams({
current: String(page),
size: String(PAGE_SIZE),
});
- if (categoryId) params.set("categoryId", categoryId);
- if (tagId) params.set("tagId", tagId);
+ if (categoryId) qs.set("categoryId", categoryId);
+ if (tagId) qs.set("tagId", tagId);
const endpoint = hasFilter
- ? `/articles/condition?${params.toString()}`
- : `/articles?${params.toString()}`;
+ ? `/articles/condition?${qs.toString()}`
+ : `/articles?${qs.toString()}`;
api
- .get(endpoint)
+ .get(endpoint)
.then((res) => {
- if (thisRequest !== requestIdRef.current) return;
+ if (thisReq !== requestIdRef.current) return;
if (res.flag && res.data) {
- setArticles(res.data.records || []);
+ const list = (res.data.records || []).map((a) => ({
+ id: a.id,
+ title: a.articleTitle,
+ cover: a.articleCover,
+ content: a.articleContent,
+ isTop: a.isTop,
+ categoryName: a.categoryName,
+ publishTime: a.createTime,
+ viewCount: a.viewCount,
+ tags: a.tagVOList?.map((t) => ({ id: t.id, name: t.tagName })),
+ }));
+ setArticles(list);
setTotal(res.data.count ?? 0);
}
})
.catch((err: unknown) => {
- if (thisRequest !== requestIdRef.current) return;
+ if (thisReq !== requestIdRef.current) return;
setError(err instanceof Error ? err.message : "加载失败");
})
.finally(() => {
- if (thisRequest === requestIdRef.current) {
+ if (thisReq === requestIdRef.current) {
setLoading(false);
+ holdHeightRef.current = null;
}
});
}
- if (loading) {
- return (
-
-
-
-
-
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
-
- );
- }
-
- if (error) {
- return (
-
- );
- }
-
- if (articles.length === 0) {
- return (
- 暂无文章
- );
+ function setFilter(key: "category" | "tag", id: number | null) {
+ const next = new URLSearchParams(params.toString());
+ if (id === null) next.delete(key);
+ else next.set(key, String(id));
+ router.replace(`${pathname}?${next.toString()}`);
}
const totalPages = Math.ceil(total / PAGE_SIZE);
return (
-
- {/* 列表头:标题 + 显示图片开关 */}
-
-
- {categoryId || tagId ? "文章筛选" : "最新推荐"}
-
-
-
-
-
- {articles.map((article) => {
- const coverUrl = showCover ? article.articleCover : "";
- return (
-
-
- {/* 左侧封面 */}
- {coverUrl ? (
-
-
-
- ) : (
-
+
+ {/* 主列表 */}
+
+ {/* 工具栏 */}
+
+
+ {hasFilter ? "文章筛选" : "最新推荐"}
+
+
+ {/* 图片开关 */}
+
+
+ {(categoryId || tagId) && (
+
+ )}
+
+
- {/* 分页 */}
- {totalPages > 1 && (
-
-
-
- {current} / {totalPages}
-
-
+ {/* 内容区 */}
+
+ {loading ? (
+
+ ) : error ? (
+
fetch(current)} />
+ ) : articles.length === 0 ? (
+
+ ) : (
+ <>
+ {/* 桌面 3 列网格 / 移动横排 */}
+
+ {articles.map((a) => (
+
+ ))}
+
+
+ {articles.map((a) => (
+
+ ))}
+
+
+ {
+ setCurrent(p);
+ fetch(p);
+ }}
+ />
+ >
+ )}
- )}
+
+
+ {/* 侧栏 */}
+
+
+ {/* 移动端:筛选在底部 */}
+
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/app/(blog)/articles/[id]/page.tsx b/frontend/src/app/(blog)/articles/[id]/page.tsx
index 9fe132d..77e3b08 100644
--- a/frontend/src/app/(blog)/articles/[id]/page.tsx
+++ b/frontend/src/app/(blog)/articles/[id]/page.tsx
@@ -1,34 +1,30 @@
"use client";
-import { useEffect, useState, useMemo } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
-import Image from "next/image";
import Link from "next/link";
+import Image from "next/image";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
-import { Badge } from "@/components/ui/badge";
-import { Skeleton } from "@/components/ui/skeleton";
-import { ReadingProgress } from "@/components/blog/ReadingProgress";
+import { Calendar, Eye, ChevronUp, Lock, ChevronLeft, ChevronRight } from "lucide-react";
import { TableOfContents } from "@/components/blog/TableOfContents";
-import { ArticleNav } from "@/components/blog/ArticleNav";
+import { CommentSection, type CommentNode } from "@/components/blog/CommentSection";
import { api } from "@/lib/api";
-import "highlight.js/styles/github-dark.css";
-
-interface TagBrief {
- id: number;
- tagName: string;
-}
+import { cn, extractHeadings, formatViews } from "@/lib/utils";
+import { toast } from "sonner";
interface ArticleDetail {
id: number;
- articleCover: string;
articleTitle: string;
articleContent: string;
- categoryName: string;
+ articleCover?: string;
+ categoryName?: string;
+ categoryId?: number;
createTime: string;
- updateTime: string;
- tagVOList: TagBrief[];
+ viewCount?: number;
+ tagVOList?: { id: number; tagName: string }[];
+ isPassword?: boolean;
}
interface NavItem {
@@ -36,31 +32,8 @@ interface NavItem {
articleTitle: string;
}
-/** 从 Markdown 文本中提取 h2/h3 标题,生成 TOC */
-function extractHeadings(markdown: string) {
- const headingRegex = /^(#{2,3})\s+(.+)$/gm;
- const headings: { level: number; text: string; id: string }[] = [];
- let match: RegExpExecArray | null;
-
- while ((match = headingRegex.exec(markdown)) !== null) {
- const text = match[2].trim();
- const id = text
- .toLowerCase()
- .replace(/[^\w\u4e00-\u9fa5]+/g, "-")
- .replace(/^-|-$/g, "");
- headings.push({ level: match[1].length, text, id });
- }
-
- return headings;
-}
-
-// 错误分类:
-// notFound — 业务侧明确告知资源不存在(后端 code=40004 或 data 为空)
-// server — 业务返回失败(flag=false 且非 404)
-// network — fetch 抛异常(超时、断网、CORS 等)
type ErrorKind = "notFound" | "server" | "network";
-// 与后端 handler/response.go 保持一致
const BACKEND_CODE_NOT_FOUND = 40004;
export default function ArticleDetailPage() {
@@ -74,31 +47,30 @@ export default function ArticleDetailPage() {
const [errorKind, setErrorKind] = useState(null);
const [errorMessage, setErrorMessage] = useState("");
const [reloadTick, setReloadTick] = useState(0);
+ const [showBackTop, setShowBackTop] = useState(false);
+ const [password, setPassword] = useState("");
+ const [comments, setComments] = useState([]);
- const loadArticle = () => {
+ useEffect(() => {
if (!id) return;
setLoading(true);
setErrorKind(null);
- setErrorMessage("");
-
api
.get(`/articles/${id}`)
.then(async (res) => {
if (res.flag && res.data) {
setArticle(res.data);
- // 从首页列表里找出当前文章的上一篇 /下一篇
+ // 上下篇
try {
- const list = await api.get<
- { records: NavItem[]; count: number } | null
- >(`/articles?current=1&size=200`);
+ const list = await api.get<{ records: NavItem[]; count: number }>(
+ "/articles?current=1&size=200",
+ );
const records = list.data?.records ?? [];
const idx = records.findIndex((r) => String(r.id) === String(id));
if (idx > 0) setPrev(records[idx - 1]);
- if (idx >= 0 && idx < records.length - 1) {
- setNext(records[idx + 1]);
- }
+ if (idx >= 0 && idx < records.length - 1) setNext(records[idx + 1]);
} catch {
- /* 上下篇是增强体验,失败不影响主流程 */
+ /* 上下篇是增强体验 */
}
return;
}
@@ -114,143 +86,215 @@ export default function ArticleDetailPage() {
setErrorMessage(err instanceof Error ? err.message : "网络异常");
})
.finally(() => setLoading(false));
- };
-
- useEffect(() => {
- loadArticle();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, reloadTick]);
+ // 回到顶部按钮
+ useEffect(() => {
+ const onScroll = () => setShowBackTop(window.scrollY > 300);
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, []);
+
const headings = useMemo(
() => (article ? extractHeadings(article.articleContent) : []),
[article],
);
+ const handleSubmitComment = async (_content: string) => {
+ toast.info("评论功能开发中");
+ };
+
+ const handleReply = async (_id: number, _content: string) => {
+ toast.info("评论功能开发中");
+ };
+
if (loading) {
return (
- <>
-
-
-
-
-
-
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
- >
+
);
}
if (errorKind === "notFound") {
return (
-
+
文章不存在或已被删除
-
+
);
}
- if (errorKind === "server") {
+ if (errorKind === "server" || errorKind === "network") {
return (
-
-
服务器错误,请稍后重试{errorMessage ? `(${errorMessage})` : ""}
+
+
+ {errorKind === "server" ? "服务器错误" : "网络连接失败"}
+ {errorMessage ? `(${errorMessage})` : ""}
+
);
}
- if (errorKind === "network") {
+ if (!article) {
return (
-
-
网络连接失败{errorMessage ? `(${errorMessage})` : ""}
-
+
+ 文章不存在或已被删除
);
}
- if (!article) {
+ // 密码保护(spec §4.6)
+ if (article.isPassword) {
return (
-
- 文章不存在或已被删除
-
+
+
+ 受密码保护
+
+
+ 这篇文章需要输入密码后才能查看
+
+
setPassword(e.target.value)}
+ placeholder="请输入密码"
+ className="mb-4 w-full appearance-none rounded border border-gray-300 px-3 py-2 text-gray-700 shadow focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
+ />
+
+
);
}
return (
- <>
-
-
- {/* 正文区域 */}
-
- {/* 分类面包屑 */}
- {article.categoryName && (
-
-
- {article.categoryName}
-
- )}
+
+ {/* 封面(spec §4.6) */}
+ {article.articleCover && (
+
+
+
+
+
+ {article.categoryName && (
+
+ {article.categoryName}
+
+ )}
+
+ {article.articleTitle}
+
+
+
+
+ {article.createTime}
+
+ {article.viewCount !== undefined && (
+
+
+ {formatViews(article.viewCount)}
+
+ )}
+
+
+
+
+ )}
- {/* 标题 */}
-
- {article.articleTitle}
-
+ {/* 三栏布局 */}
+
+ {/* 左侧 TOC */}
+
- {/* 元信息 */}
-
-
- {article.tagVOList?.map((tag) => (
-
- {tag.tagName}
-
- ))}
-
+ {/* 中间内容 */}
+
+ {/* 如果没有封面则在文章卡片内显示标题 */}
+ {!article.articleCover && (
+ <>
+ {article.categoryName && (
+
+ {article.categoryName}
+
+ )}
+
+ {article.articleTitle}
+
+
+
+
+ {article.createTime}
+
+ {article.viewCount !== undefined && (
+
+
+ {formatViews(article.viewCount)}
+
+ )}
+
+ >
+ )}
- {/* 封面 */}
- {article.articleCover && (
-
-
+ {/* 标签 */}
+ {article.tagVOList && article.tagVOList.length > 0 && (
+
+ {article.tagVOList.map((t) => (
+
+ #{t.tagName}
+
+ ))}
)}
- {/* Markdown 内容 */}
-
+ prose-h2:border-b prose-h2:border-gray-200 prose-h2:pb-2
+ dark:prose-h2:border-gray-700
+ prose-h2:text-2xl prose-h3:text-xl">
- {children}
-
- );
+ return {children}
;
},
h3: ({ children, ...props }) => {
const text = String(children);
const id = text
.toLowerCase()
- .replace(/[^\w\u4e00-\u9fa5]+/g, "-")
+ .replace(/[^\w一-龥]+/g, "-")
.replace(/^-|-$/g, "");
- return (
-
- {children}
-
- );
+ return {children}
;
},
}}
>
@@ -285,13 +321,89 @@ export default function ArticleDetailPage() {
- {/* 上一篇 / 下一篇 */}
-
+ {/* 上下篇(spec §4.6) */}
+
+
+ {/* 评论 */}
+
{}}
+ />
- {/* 侧边 TOC 目录 */}
-
+ {/* 右侧 meta */}
+
- >
+
+ {/* 回到顶部(spec §4.6) */}
+
+
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/app/(blog)/layout.tsx b/frontend/src/app/(blog)/layout.tsx
index 40f36b3..15eb5f0 100644
--- a/frontend/src/app/(blog)/layout.tsx
+++ b/frontend/src/app/(blog)/layout.tsx
@@ -1,4 +1,4 @@
-import TopNav from "@/components/layout/TopNav";
+import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
export default function BlogLayout({
@@ -7,12 +7,12 @@ export default function BlogLayout({
children: React.ReactNode;
}) {
return (
-
-
-
- {children}
+
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/app/(blog)/page.tsx b/frontend/src/app/(blog)/page.tsx
index 06a9ec5..307c885 100644
--- a/frontend/src/app/(blog)/page.tsx
+++ b/frontend/src/app/(blog)/page.tsx
@@ -1,20 +1,14 @@
import { Suspense } from "react";
import ArticleList from "./ArticleList";
import { Hero, type HeroPinned } from "@/components/blog/Hero";
-import { FeaturedCard } from "@/components/blog/FeaturedCard";
-import { CategoryList } from "@/components/blog/CategoryList";
-import { TagCloud } from "@/components/blog/TagCloud";
-import { Skeleton } from "@/components/ui/skeleton";
interface HomePageProps {
- searchParams: Promise<{ categoryId?: string; tagId?: string }>;
+ searchParams: Promise<{ category?: string; tag?: string }>;
}
export default async function HomePage({ searchParams }: HomePageProps) {
const params = await searchParams;
- const categoryId = params.categoryId ?? null;
- const tagId = params.tagId ?? null;
- const hasFilter = !!(categoryId || tagId);
+ const hasFilter = !!(params.category || params.tag);
// 仅在无筛选时拉侧边数据(服务端组件)
const [config, featured] = hasFilter
@@ -30,41 +24,15 @@ export default async function HomePage({ searchParams }: HomePageProps) {
hasFilter={hasFilter}
/>
-
- {/* 左栏:文章列表 */}
-
-
-
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
-
- }
- >
-
-
-
-
- {/* 右栏:3 块卡片 */}
- {!hasFilter && (
-
- )}
-
+
+ 加载中...
+
+ }
+ >
+
+
);
}
@@ -115,10 +83,9 @@ async function fetchFeatured(): Promise
{
const json = await res.json();
type Row = FeaturedArticle & { isTop?: boolean };
const list: Row[] = json?.data?.records ?? [];
- // 优先取 isTop=true 的;否则取第一条
const picked = list.find((a) => a.isTop) ?? list[0];
return picked ?? null;
} catch {
return null;
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 4204eeb..036f73d 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -25,7 +25,7 @@
--font-mono: "Geist Mono", ui-monospace, monospace;
--font-heading: "Geist", ui-sans-serif, system-ui, sans-serif;
- /* ===== 品牌色 token(蓝色,对齐 dim_stack) ===== */
+ /* ===== 品牌色 token(蓝色,对齐 uiux-spec) ===== */
--color-brand-50: var(--brand-50);
--color-brand-100: var(--brand-100);
--color-brand-200: var(--brand-200);
@@ -81,48 +81,47 @@
}
:root {
- --background: oklch(1 0 0);
- --foreground: oklch(0.145 0 0);
- --card: oklch(1 0 0);
- --card-foreground: oklch(0.145 0 0);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.145 0 0);
-
- /* ===== 品牌色(蓝色系,对齐 dim_stack) ===== */
- --brand-50: oklch(0.97 0.014 250);
- --brand-100: oklch(0.93 0.030 250);
- --brand-200: oklch(0.87 0.060 250);
- --brand-300: oklch(0.78 0.115 250);
- --brand-400: oklch(0.70 0.160 250);
- --brand-500: oklch(0.62 0.195 255);
- --brand-600: oklch(0.55 0.220 258);
- --brand-700: oklch(0.48 0.225 262);
- --brand-800: oklch(0.42 0.215 265);
- --brand-900: oklch(0.36 0.190 268);
-
- /* primary 改为品牌蓝(覆盖原中性近黑灰) */
+ --background: #ffffff;
+ --foreground: #111827;
+ --card: #ffffff;
+ --card-foreground: #111827;
+ --popover: #ffffff;
+ --popover-foreground: #111827;
+
+ /* ===== 品牌色(蓝色系,对齐 uiux-spec) ===== */
+ --brand-50: #eff6ff;
+ --brand-100: #dbeafe;
+ --brand-200: #bfdbfe;
+ --brand-300: #93c5fd;
+ --brand-400: #60a5fa;
+ --brand-500: #3b82f6;
+ --brand-600: #2563eb;
+ --brand-700: #1d4ed8;
+ --brand-800: #1e40af;
+ --brand-900: #1e3a8a;
+
+ /* primary 改为品牌蓝 */
--primary: var(--brand-500);
- --primary-foreground: oklch(0.985 0 0);
+ --primary-foreground: #ffffff;
- /* 浅色选中态:品牌浅蓝 */
- --accent: oklch(0.96 0.025 250);
+ --accent: var(--brand-50);
--accent-foreground: var(--brand-700);
- --secondary: oklch(0.97 0 0);
- --secondary-foreground: oklch(0.205 0 0);
- --muted: oklch(0.97 0 0);
- --muted-foreground: oklch(0.556 0 0);
- --destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.922 0 0);
- --input: oklch(0.922 0 0);
- --ring: oklch(0.708 0 0);
+ --secondary: #f9fafb;
+ --secondary-foreground: #111827;
+ --muted: #f9fafb;
+ --muted-foreground: #6b7280;
+ --destructive: #ef4444;
+ --border: #e5e7eb;
+ --input: #e5e7eb;
+ --ring: var(--brand-500);
/* chart 改为品牌蓝色阶 */
- --chart-1: oklch(0.62 0.195 255);
- --chart-2: oklch(0.55 0.220 258);
- --chart-3: oklch(0.70 0.160 250);
- --chart-4: oklch(0.78 0.115 250);
- --chart-5: oklch(0.48 0.225 262);
+ --chart-1: #3b82f6;
+ --chart-2: #2563eb;
+ --chart-3: #60a5fa;
+ --chart-4: #93c5fd;
+ --chart-5: #1d4ed8;
--radius: 0.625rem;
@@ -131,71 +130,71 @@
--shadow-card-hover-value: 0 10px 25px -3px rgb(0 0 0 / 0.08), 0 4px 10px -4px rgb(0 0 0 / 0.04);
--shadow-brand-glow-value: 0 0 0 3px rgb(59 130 246 / 0.18);
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar: #ffffff;
+ --sidebar-foreground: #111827;
--sidebar-primary: var(--brand-500);
- --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-primary-foreground: #ffffff;
--sidebar-accent: var(--brand-50);
--sidebar-accent-foreground: var(--brand-700);
- --sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
+ --sidebar-border: #e5e7eb;
+ --sidebar-ring: var(--brand-500);
}
.dark {
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
-
- /* 暗色模式品牌色略亮,保持对比度 */
- --brand-50: oklch(0.27 0.040 250);
- --brand-100: oklch(0.30 0.060 250);
- --brand-200: oklch(0.35 0.090 250);
- --brand-300: oklch(0.45 0.130 250);
- --brand-400: oklch(0.65 0.180 250);
- --brand-500: oklch(0.72 0.185 250);
- --brand-600: oklch(0.78 0.160 250);
- --brand-700: oklch(0.85 0.130 250);
- --brand-800: oklch(0.90 0.090 250);
- --brand-900: oklch(0.95 0.050 250);
+ --background: #111827;
+ --foreground: #f9fafb;
+ --card: #1f2937;
+ --card-foreground: #f9fafb;
+ --popover: #1f2937;
+ --popover-foreground: #f9fafb;
+
+ /* 暗色模式品牌色略亮 */
+ --brand-50: rgba(59, 130, 246, 0.12);
+ --brand-100: #1e3a8a;
+ --brand-200: #1e40af;
+ --brand-300: #2563eb;
+ --brand-400: #60a5fa;
+ --brand-500: #3b82f6;
+ --brand-600: #93c5fd;
+ --brand-700: #bfdbfe;
+ --brand-800: #dbeafe;
+ --brand-900: #eff6ff;
--primary: var(--brand-500);
- --primary-foreground: oklch(0.145 0 0);
+ --primary-foreground: #ffffff;
- --accent: oklch(0.27 0.05 250);
- --accent-foreground: var(--brand-300);
+ --accent: #1e3a8a;
+ --accent-foreground: #93c5fd;
- --secondary: oklch(0.269 0 0);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --destructive: oklch(0.704 0.191 22.216);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0 0);
+ --secondary: #1f2937;
+ --secondary-foreground: #f9fafb;
+ --muted: #1f2937;
+ --muted-foreground: #9ca3af;
+ --destructive: #ef4444;
+ --border: #374151;
+ --input: #4b5563;
+ --ring: var(--brand-500);
/* 暗色图表:使用品牌色 */
- --chart-1: oklch(0.72 0.185 250);
- --chart-2: oklch(0.65 0.180 250);
- --chart-3: oklch(0.78 0.160 250);
- --chart-4: oklch(0.85 0.130 250);
- --chart-5: oklch(0.55 0.200 258);
+ --chart-1: #60a5fa;
+ --chart-2: #3b82f6;
+ --chart-3: #93c5fd;
+ --chart-4: #bfdbfe;
+ --chart-5: #1d4ed8;
/* 暗色模式阴影加深 */
--shadow-card-value: 0 1px 3px 0 rgb(0 0 0 / 0.4), 0 1px 2px -1px rgb(0 0 0 / 0.3);
--shadow-card-hover-value: 0 10px 30px -3px rgb(0 0 0 / 0.5), 0 4px 12px -4px rgb(0 0 0 / 0.3);
--shadow-brand-glow-value: 0 0 0 3px rgb(96 165 250 / 0.25);
- --sidebar: oklch(0.205 0 0);
- --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar: #1f2937;
+ --sidebar-foreground: #f9fafb;
--sidebar-primary: var(--brand-500);
- --sidebar-primary-foreground: oklch(0.145 0 0);
- --sidebar-accent: oklch(0.27 0.05 250);
- --sidebar-accent-foreground: var(--brand-300);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.556 0 0);
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: #1e3a8a;
+ --sidebar-accent-foreground: #93c5fd;
+ --sidebar-border: #374151;
+ --sidebar-ring: var(--brand-500);
}
@layer base {
@@ -210,7 +209,135 @@
}
}
-/* Shimmer animation for skeleton loading */
+/* ===== 滚动条(参考规范 hide-scrollbar) ===== */
+.hide-scrollbar {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+.hide-scrollbar::-webkit-scrollbar {
+ display: none;
+}
+.scrollbar-thin {
+ scrollbar-width: thin;
+}
+.scrollbar-thin::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+.dark .scrollbar-thin::-webkit-scrollbar-thumb {
+ background-color: #6b7280;
+}
+.scrollbar-thin::-webkit-scrollbar-thumb {
+ background-color: #d1d5db;
+ border-radius: 3px;
+}
+.scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background-color: #9ca3af;
+}
+
+/* ===== 渐隐遮罩(参考规范 4.5.2) ===== */
+.fade-mask-top,
+.fade-mask-bottom {
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 2.5rem;
+ pointer-events: none;
+ z-index: 10;
+ transition: opacity 200ms ease;
+}
+.fade-mask-top {
+ top: 0;
+ background: linear-gradient(to bottom, var(--background), transparent);
+}
+.fade-mask-bottom {
+ bottom: 0;
+ background: linear-gradient(to top, var(--background), transparent);
+}
+
+/* ===== 玻璃态(spec §1.5) ===== */
+.glass-panel {
+ background: rgba(255, 255, 255, 0.85);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 1px solid rgba(229, 231, 235, 0.5);
+}
+.dark .glass-panel {
+ background: rgba(31, 41, 55, 0.85);
+ border-color: rgba(75, 85, 99, 0.5);
+}
+
+/* ===== 音乐播放器(spec §6.6) ===== */
+.music-player-panel {
+ background: rgba(255, 255, 255, 0.85);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 1px solid rgba(229, 231, 235, 0.5);
+ box-shadow: 4px 4px 24px rgba(0, 0, 0, 0.08), 2px 2px 8px rgba(0, 0, 0, 0.04);
+}
+.dark .music-player-panel {
+ background: rgba(31, 41, 55, 0.88);
+ border-color: rgba(75, 85, 99, 0.5);
+ box-shadow: 4px 4px 24px rgba(0, 0, 0, 0.3);
+}
+.music-progress-track {
+ background: #e5e7eb;
+ height: 4px;
+ border-radius: 9999px;
+ position: relative;
+ cursor: pointer;
+}
+.dark .music-progress-track {
+ background: #374151;
+}
+.music-progress-fill {
+ background: linear-gradient(90deg, #3b82f6, #60a5fa);
+ height: 100%;
+ border-radius: 9999px;
+ transition: width 100ms linear;
+}
+.music-playlist-active {
+ background-color: rgba(59, 130, 246, 0.1);
+ color: #1d4ed8;
+}
+.dark .music-playlist-active {
+ background-color: rgba(59, 130, 246, 0.2);
+ color: #93c5fd;
+}
+
+/* ===== 主题切换涟漪(View Transitions) ===== */
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation-duration: 0.5s;
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+}
+::view-transition-old(root) {
+ animation-name: theme-fade-out;
+}
+::view-transition-new(root) {
+ animation-name: theme-ripple-in;
+ clip-path: circle(0 at var(--theme-ripple-x, 50%) var(--theme-ripple-y, 50%));
+}
+
+@keyframes theme-fade-out {
+ to { opacity: 0; }
+}
+@keyframes theme-ripple-in {
+ to {
+ clip-path: circle(
+ calc(200vmax) at var(--theme-ripple-x, 50%) var(--theme-ripple-y, 50%)
+ );
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ ::view-transition-old(root),
+ ::view-transition-new(root) {
+ animation-duration: 0s;
+ }
+}
+
+/* ===== Shimmer(spec §11.1 卡片骨架) ===== */
@keyframes shimmer {
0% {
transform: translateX(-100%);
@@ -232,12 +359,20 @@
background: linear-gradient(
90deg,
transparent,
- hsl(var(--muted) / 0.3),
+ rgba(0, 0, 0, 0.06),
transparent
);
animation: shimmer 2s infinite;
pointer-events: none;
}
+.dark .skeleton-shimmer::after {
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.06),
+ transparent
+ );
+}
@media (prefers-reduced-motion: reduce) {
.skeleton-shimmer::after {
@@ -245,94 +380,264 @@
}
}
-/* ===== 品牌视觉增强层 ===== */
-
-@layer components {
- /* 博客卡片:默认带阴影,hover 升级并轻微浮起 */
- .blog-card {
- @apply shadow-(--shadow-card) transition-all duration-200;
- }
- .blog-card:hover {
- @apply -translate-y-0.5 shadow-(--shadow-card-hover);
- }
+/* ===== Toast 玻璃态(spec §6.1) ===== */
+.toast-glass {
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+}
- /* 品牌链接:默认 muted-foreground,hover 变品牌色 */
- .link-brand {
- @apply text-muted-foreground transition-colors hover:text-brand-600 dark:hover:text-brand-400;
+/* ===== 上传进度面板(spec §6.5) ===== */
+.upload-progress-panel {
+ animation: upload-progress-slide-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+}
+@keyframes upload-progress-slide-in {
+ from {
+ transform: translateX(calc(100% + 48px));
}
-
- /* 玻璃拟态面板 */
- .glass-panel {
- @apply bg-background/70 backdrop-blur-xl border border-border/40;
+ to {
+ transform: translateX(0);
}
-
- /* 渐进式横向渐变(用于装饰) */
- .gradient-brand {
- background-image: linear-gradient(
- 135deg,
- var(--color-brand-500) 0%,
- oklch(0.65 0.22 290) 100%
- );
+}
+.upload-progress-item {
+ animation: upload-progress-item-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
+}
+@keyframes upload-progress-item-in {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
}
-
- /* 阅读进度条根 */
- .reading-progress-bar {
- @apply fixed top-0 left-0 right-0 z-60 h-0.5 origin-left;
- background: linear-gradient(
- 90deg,
- var(--color-brand-400),
- var(--color-brand-600)
- );
- transform: scaleX(var(--progress, 0));
- transition: transform 100ms linear;
+ to {
+ transform: translateX(0);
+ opacity: 1;
}
}
-
-/* View Transition 主题切换涟漪效果 */
-::view-transition-old(root),
-::view-transition-new(root) {
- animation-duration: 0.5s;
- animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+.upload-progress-track {
+ background: #f3f4f6;
+ height: 4px;
+ border-radius: 9999px;
+ overflow: hidden;
+ position: relative;
}
-::view-transition-old(root) {
- animation-name: theme-fade-out;
+.dark .upload-progress-track {
+ background: #374151;
}
-::view-transition-new(root) {
- animation-name: theme-fade-in;
- clip-path: circle(0 at var(--theme-ripple-x, 50%) var(--theme-ripple-y, 50%));
- animation-name: theme-ripple-in;
+.upload-progress-fill-shimmer {
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.4),
+ transparent
+ );
+ animation: shimmer 1.5s infinite;
+ height: 100%;
}
-
-@keyframes theme-fade-out {
- to { opacity: 0; }
+.upload-progress-processing-text {
+ animation: pulse-text 1.5s ease-in-out infinite;
}
-@keyframes theme-ripple-in {
+@keyframes pulse-text {
+ 0%, 100% { opacity: 0.5; }
+ 50% { opacity: 1; }
+}
+.upload-progress-item-leaving-done {
+ animation: upload-progress-out 0.3s ease 1.1s forwards;
+}
+.upload-progress-item-leaving-error {
+ animation: upload-progress-out 0.3s ease 3.6s forwards;
+}
+@keyframes upload-progress-out {
to {
- clip-path: circle(
- calc(200vmax) at var(--theme-ripple-x, 50%) var(--theme-ripple-y, 50%)
- );
+ opacity: 0;
+ transform: translateX(100%);
}
}
-@media (prefers-reduced-motion: reduce) {
- ::view-transition-old(root),
- ::view-transition-new(root) {
- animation-duration: 0s;
- }
+/* ===== 博客卡片(spec §4.3) ===== */
+.article-card {
+ background: #ffffff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.04);
+ transition: all 200ms ease;
+ cursor: pointer;
+}
+.article-card:hover {
+ box-shadow: 0 10px 25px -3px rgb(0 0 0 / 0.08), 0 4px 10px -4px rgb(0 0 0 / 0.04);
+ transform: translateY(-2px);
+}
+.dark .article-card {
+ background: #1f2937;
+ border-color: #374151;
}
-/* 文章正文 prose 内的品牌色微调 */
+.article-tag {
+ background: #f3f4f6;
+ color: #1f2937;
+ font-size: 0.75rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ transition: background-color 200ms ease;
+}
+.article-tag:hover {
+ background: #e5e7eb;
+}
+.dark .article-tag {
+ background: #374151;
+ color: #f9fafb;
+}
+
+/* ===== 文章正文 prose 微调 ===== */
.prose {
- --tw-prose-links: var(--color-brand-600);
- --tw-prose-headings: var(--color-foreground);
+ --tw-prose-links: #2563eb;
+ --tw-prose-headings: #111827;
+ color: #1f2937;
}
.dark .prose {
- --tw-prose-links: var(--color-brand-400);
+ --tw-prose-links: #60a5fa;
+ --tw-prose-headings: #f9fafb;
+ color: #e5e7eb;
}
.prose a {
- text-decoration-color: color-mix(in oklch, var(--color-brand-500) 40%, transparent);
+ text-decoration-color: rgba(59, 130, 246, 0.4);
text-underline-offset: 3px;
}
.prose a:hover {
- text-decoration-color: var(--color-brand-500);
+ text-decoration-color: #3b82f6;
+}
+.prose pre {
+ background: #1f2937 !important;
+ color: #f9fafb !important;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ overflow-x: auto;
+}
+.dark .prose pre {
+ background: #111827 !important;
+}
+.prose code {
+ background: rgba(59, 130, 246, 0.08);
+ color: #1d4ed8;
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.25rem;
+ font-size: 0.875em;
+}
+.dark .prose code {
+ background: rgba(96, 165, 250, 0.15);
+ color: #93c5fd;
+}
+.prose code::before,
+.prose code::after {
+ content: none !important;
+}
+
+/* ===== 公告弹窗正文 ===== */
+.announcement-body img {
+ max-width: 100%;
+ height: auto;
+ margin: 0.75em 0;
+ border-radius: 4px;
+}
+
+/* ===== 表单错误条(spec §4.10) ===== */
+.form-error-bar {
+ background: #fef2f2;
+ color: #991b1b;
+ padding: 0.75rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ border: 1px solid #fecaca;
+ margin-bottom: 1rem;
+}
+.dark .form-error-bar {
+ background: rgba(239, 68, 68, 0.12);
+ color: #fca5a5;
+ border-color: rgba(239, 68, 68, 0.3);
+}
+
+/* ===== DataTable 状态徽章(spec §5.13) ===== */
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.125rem 0.5rem;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ border: 1px solid transparent;
+}
+.status-badge-success {
+ background: #ecfdf5;
+ color: #047857;
+ border-color: #a7f3d0;
+}
+.status-badge-info {
+ background: #eff6ff;
+ color: #1d4ed8;
+ border-color: #bfdbfe;
+}
+.status-badge-warning {
+ background: #fff7ed;
+ color: #c2410c;
+ border-color: #fed7aa;
+}
+.status-badge-danger {
+ background: #fef2f2;
+ color: #b91c1c;
+ border-color: #fecaca;
+}
+.status-badge-default {
+ background: #f3f4f6;
+ color: #1f2937;
+ border-color: #e5e7eb;
+}
+
+/* ===== 分类树(spec §5.10.1) ===== */
+.category-tree-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ background: #ffffff;
+ border-bottom: 1px solid #e5e7eb;
+ transition: background-color 150ms ease;
+}
+.category-tree-row:hover {
+ background: #f9fafb;
+}
+.dark .category-tree-row {
+ background: #1f2937;
+ border-color: #374151;
+}
+.dark .category-tree-row:hover {
+ background: #111827;
+}
+.category-tree-child {
+ margin-left: 2rem;
+ position: relative;
+}
+.category-tree-child::before {
+ content: '';
+ position: absolute;
+ left: -1rem;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background: #e5e7eb;
+}
+.dark .category-tree-child::before {
+ background: #374151;
+}
+
+/* ===== 进度条(spec §6.5) ===== */
+.progress-track {
+ background: #f3f4f6;
+ height: 4px;
+ border-radius: 9999px;
+ overflow: hidden;
+}
+.dark .progress-track {
+ background: #374151;
+}
+.progress-fill {
+ background: linear-gradient(90deg, #3b82f6, #60a5fa);
+ height: 100%;
+ transition: width 200ms ease;
}
\ No newline at end of file
diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx
new file mode 100644
index 0000000..72112b8
--- /dev/null
+++ b/frontend/src/app/not-found.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import Link from "next/link";
+
+/** 404 页面(spec §4.13) */
+export default function NotFound() {
+ return (
+
+
+
404
+
+ 页面走丢了
+
+
+ 你访问的页面不存在或已被移除
+
+
+ 返回首页
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/albums/album-card.test.tsx b/frontend/src/components/albums/album-card.test.tsx
deleted file mode 100644
index 98d5626..0000000
--- a/frontend/src/components/albums/album-card.test.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import { describe, it, expect, vi } from "vitest";
-import { render, screen, fireEvent } from "@testing-library/react";
-import { AlbumCard } from "./album-card";
-
-const mockAlbum = {
- id: 1,
- albumName: "旅行相册",
- albumDesc: "2024年旅行记录",
- albumCover: "https://example.com/cover.jpg",
- photoCount: 24,
- status: 1,
- createTime: "2024-01-01",
-};
-
-describe("AlbumCard", () => {
- it("renders album name and photo count", () => {
- const onClick = vi.fn();
- render();
-
- expect(screen.getByText("旅行相册")).toBeInTheDocument();
- expect(screen.getByText("24 张图片")).toBeInTheDocument();
- });
-
- it("renders cover image when albumCover exists", () => {
- render();
-
- const img = screen.getByRole("img");
- expect(img).toHaveAttribute("src", expect.stringContaining("cover.jpg"));
- });
-
- it("renders placeholder when no cover", () => {
- const albumWithoutCover = { ...mockAlbum, albumCover: "" };
- render();
-
- expect(screen.queryByRole("img")).not.toBeInTheDocument();
- expect(document.querySelector("svg")).toBeInTheDocument();
- });
-
- it("calls onClick when card clicked", () => {
- const onClick = vi.fn();
- render();
-
- const card = screen.getByRole("button");
- fireEvent.click(card);
-
- expect(onClick).toHaveBeenCalledWith(mockAlbum);
- });
-
- it("shows action buttons on hover", () => {
- const onEdit = vi.fn();
- const onDelete = vi.fn();
- render(
-
- );
-
- const cards = screen.getAllByRole("button");
- const card = cards[0];
- fireEvent.mouseEnter(card);
-
- expect(screen.getByLabelText("编辑")).toBeInTheDocument();
- expect(screen.getByLabelText("删除")).toBeInTheDocument();
- });
-
- it("calls onEdit when edit button clicked", () => {
- const onEdit = vi.fn();
- render();
-
- const cards = screen.getAllByRole("button");
- const card = cards[0];
- fireEvent.mouseEnter(card);
-
- const editBtn = screen.getByLabelText("编辑");
- fireEvent.click(editBtn);
-
- expect(onEdit).toHaveBeenCalledWith(mockAlbum);
- });
-
- it("calls onDelete when delete button clicked", () => {
- const onDelete = vi.fn();
- render();
-
- const cards = screen.getAllByRole("button");
- const card = cards[0];
- fireEvent.mouseEnter(card);
-
- const deleteBtn = screen.getByLabelText("删除");
- fireEvent.click(deleteBtn);
-
- expect(onDelete).toHaveBeenCalledWith(mockAlbum);
- });
-
- it("shows checkbox in selection mode", () => {
- render(
-
- );
-
- const checkbox = screen.getByRole("checkbox");
- expect(checkbox).toBeChecked();
- });
-
- it("calls onSelect when checkbox clicked", () => {
- const onSelect = vi.fn();
- render(
-
- );
-
- const checkbox = screen.getByRole("checkbox");
- fireEvent.click(checkbox);
-
- expect(onSelect).toHaveBeenCalledWith(mockAlbum.id, true);
- });
-});
diff --git a/frontend/src/components/albums/album-grid.test.tsx b/frontend/src/components/albums/album-grid.test.tsx
deleted file mode 100644
index ef5d9c6..0000000
--- a/frontend/src/components/albums/album-grid.test.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import { describe, it, expect, vi } from "vitest";
-import { render, screen, fireEvent } from "@testing-library/react";
-import { AlbumGrid } from "./album-grid";
-
-const mockAlbums = [
- {
- id: 1,
- albumName: "旅行相册",
- albumDesc: "2024年旅行记录",
- albumCover: "https://example.com/cover1.jpg",
- photoCount: 24,
- status: 1,
- createTime: "2024-01-01",
- },
- {
- id: 2,
- albumName: "美食记录",
- albumDesc: "",
- albumCover: "",
- photoCount: 56,
- status: 2,
- createTime: "2024-02-01",
- },
-];
-
-describe("AlbumGrid", () => {
- it("renders all album cards in grid", () => {
- render();
-
- expect(screen.getByText("旅行相册")).toBeInTheDocument();
- expect(screen.getByText("美食记录")).toBeInTheDocument();
- });
-
- it("renders empty state when no albums", () => {
- render();
-
- expect(screen.getByText("暂无相册")).toBeInTheDocument();
- });
-
- it("uses responsive grid classes", () => {
- const { container } = render();
-
- const grid = container.querySelector(".grid");
- expect(grid).toHaveClass("grid-cols-1");
- expect(grid).toHaveClass("sm:grid-cols-2");
- expect(grid).toHaveClass("lg:grid-cols-4");
- });
-
- it("calls onAlbumClick when card clicked", () => {
- const onAlbumClick = vi.fn();
- render();
-
- const cards = screen.getAllByRole("button");
- fireEvent.click(cards[0]);
-
- expect(onAlbumClick).toHaveBeenCalledWith(mockAlbums[0]);
- });
-
- it("passes selection props to cards", () => {
- render(
-
- );
-
- const checkboxes = screen.getAllByRole("checkbox");
- expect(checkboxes[0]).toBeChecked();
- expect(checkboxes[1]).not.toBeChecked();
- });
-});
diff --git a/frontend/src/components/albums/view-toggle.test.tsx b/frontend/src/components/albums/view-toggle.test.tsx
deleted file mode 100644
index c4b75e7..0000000
--- a/frontend/src/components/albums/view-toggle.test.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { describe, it, expect, vi } from "vitest";
-import { render, screen, fireEvent } from "@testing-library/react";
-import { ViewToggle } from "./view-toggle";
-
-describe("ViewToggle", () => {
- it("renders grid and list buttons", () => {
- render();
-
- expect(screen.getByLabelText("网格视图")).toBeInTheDocument();
- expect(screen.getByLabelText("列表视图")).toBeInTheDocument();
- });
-
- it("shows grid button as active when view is grid", () => {
- render();
-
- const gridBtn = screen.getByLabelText("网格视图");
- expect(gridBtn).toHaveAttribute("data-active", "true");
- });
-
- it("shows list button as active when view is list", () => {
- render();
-
- const listBtn = screen.getByLabelText("列表视图");
- expect(listBtn).toHaveAttribute("data-active", "true");
- });
-
- it("calls onChange when clicking inactive button", () => {
- const onChange = vi.fn();
- render();
-
- const listBtn = screen.getByLabelText("列表视图");
- fireEvent.click(listBtn);
-
- expect(onChange).toHaveBeenCalledWith("list");
- });
-
- it("persists view mode to localStorage", () => {
- const onChange = vi.fn();
- render();
-
- const listBtn = screen.getByLabelText("列表视图");
- fireEvent.click(listBtn);
-
- expect(localStorage.getItem("album-view-mode")).toBe("list");
- });
-
- it("reads initial view from localStorage", () => {
- localStorage.setItem("album-view-mode", "list");
-
- const onChange = vi.fn();
- render();
-
- const listBtn = screen.getByLabelText("列表视图");
- expect(listBtn).toHaveAttribute("data-active", "true");
- });
-});
diff --git a/frontend/src/components/auth/AuthLayout.tsx b/frontend/src/components/auth/AuthLayout.tsx
new file mode 100644
index 0000000..52f3f35
--- /dev/null
+++ b/frontend/src/components/auth/AuthLayout.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { Moon, Sun } from "lucide-react";
+import { useSiteConfig } from "@/hooks/useSiteConfig";
+
+/** 主题切换(极简版) */
+function ThemeToggle() {
+ const [mounted, setMounted] = useState(false);
+ const [isDark, setIsDark] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ const stored = localStorage.getItem("theme");
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+ setIsDark(stored === "dark" || (stored === null && prefersDark));
+ }, []);
+
+ useEffect(() => {
+ document.documentElement.classList.toggle("dark", isDark);
+ }, [isDark]);
+
+ const toggle = () => {
+ const next = !isDark;
+ setIsDark(next);
+ localStorage.setItem("theme", next ? "dark" : "light");
+ };
+
+ if (!mounted) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+/** 鉴权页面外壳:顶部站点名 + 主题切换,下方内容居中 */
+export default function AuthLayout({ children }: { children: React.ReactNode }) {
+ const siteConfig = useSiteConfig();
+ const siteName = siteConfig?.name || "Moon";
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/auth/CaptchaBlock.tsx b/frontend/src/components/auth/CaptchaBlock.tsx
new file mode 100644
index 0000000..53b10e6
--- /dev/null
+++ b/frontend/src/components/auth/CaptchaBlock.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { ArrowRight, RefreshCw } from "lucide-react";
+import { useState } from "react";
+
+interface CaptchaBlockProps {
+ value: string;
+ onChange: (v: string) => void;
+ src?: string;
+ onRefresh?: () => void;
+}
+
+/** 验证码:输入 + 简易刷新按钮(可被后端图片替换) */
+export function CaptchaBlock({ value, onChange, src, onRefresh }: CaptchaBlockProps) {
+ const [refreshKey, setRefreshKey] = useState(0);
+
+ const handleRefresh = () => {
+ setRefreshKey((k) => k + 1);
+ onRefresh?.();
+ };
+
+ return (
+
+
+
+
onChange(e.target.value)}
+ placeholder="请输入验证码"
+ maxLength={6}
+ className="block w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 transition-colors placeholder:text-gray-400 focus:border-gray-900 focus:ring-0 focus:outline-none dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100 dark:placeholder:text-gray-600 dark:focus:border-gray-100"
+ />
+
+
+
+ );
+}
+
+/** 页脚切换链接(右箭头样式) */
+export function SwitchLink({
+ label,
+ cta,
+ onClick,
+}: {
+ label: string;
+ cta: string;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/auth/ErrorBar.tsx b/frontend/src/components/auth/ErrorBar.tsx
new file mode 100644
index 0000000..fcf2ffe
--- /dev/null
+++ b/frontend/src/components/auth/ErrorBar.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+interface ErrorBarProps {
+ message: string | null;
+ /** 自动消失时间(毫秒),0 表示不自动消失 */
+ autoDismissMs?: number;
+ onDismiss?: () => void;
+}
+
+/** 极简错误条:细条、淡红背景、自动 5 秒消失 */
+export function ErrorBar({ message, autoDismissMs = 5000, onDismiss }: ErrorBarProps) {
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ if (!message) {
+ setVisible(false);
+ return;
+ }
+ setVisible(true);
+ if (autoDismissMs > 0) {
+ const t = setTimeout(() => {
+ setVisible(false);
+ onDismiss?.();
+ }, autoDismissMs);
+ return () => clearTimeout(t);
+ }
+ }, [message, autoDismissMs, onDismiss]);
+
+ if (!message || !visible) return null;
+
+ return (
+
+ {message}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/auth/FieldInput.tsx b/frontend/src/components/auth/FieldInput.tsx
new file mode 100644
index 0000000..edd89fa
--- /dev/null
+++ b/frontend/src/components/auth/FieldInput.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { forwardRef, useId } from "react";
+import { cn } from "@/lib/utils";
+
+interface FieldInputProps extends React.InputHTMLAttributes {
+ label: string;
+ error?: string;
+ hint?: string;
+}
+
+/** 极简表单输入:标签在上、细边框、无图标 */
+export const FieldInput = forwardRef(
+ function FieldInput({ label, error, hint, className, id, ...props }, ref) {
+ const autoId = useId();
+ const inputId = id ?? autoId;
+
+ return (
+
+
+
+ {(error || hint) && (
+
+ {error || hint}
+
+ )}
+
+ );
+ },
+);
\ No newline at end of file
diff --git a/frontend/src/components/auth/ForgotPasswordForm.tsx b/frontend/src/components/auth/ForgotPasswordForm.tsx
new file mode 100644
index 0000000..5ba11c7
--- /dev/null
+++ b/frontend/src/components/auth/ForgotPasswordForm.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { useState } from "react";
+import { toast } from "sonner";
+import { api } from "@/lib/api";
+import { FieldInput } from "./FieldInput";
+import { ErrorBar } from "./ErrorBar";
+import { SwitchLink } from "./CaptchaBlock";
+
+export function ForgotPasswordForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
+ const [email, setEmail] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [sent, setSent] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!email.trim()) {
+ setError("请输入邮箱");
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await api.post("/forgot-password", { email });
+ if (res.flag) {
+ setSent(true);
+ toast.success("重置链接已发送");
+ } else {
+ setError(res.message || "发送失败");
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "网络错误");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {sent ? (
+
+
+ 如果 {email}{" "}
+ 已被注册,链接将在 10 分钟内到达。
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx
new file mode 100644
index 0000000..1e4e96c
--- /dev/null
+++ b/frontend/src/components/auth/LoginForm.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { api } from "@/lib/api";
+import { useAuthStore } from "@/stores/auth";
+import { FieldInput } from "./FieldInput";
+import { ErrorBar } from "./ErrorBar";
+import { SwitchLink } from "./CaptchaBlock";
+
+interface LoginResponse {
+ token: string;
+ userInfo: {
+ userId: number;
+ nickname: string;
+ avatar: string;
+ intro: string;
+ email: string;
+ loginType: number;
+ };
+}
+
+export function LoginForm({
+ onSwitchToRegister,
+ onSwitchToForgot,
+}: {
+ onSwitchToRegister: () => void;
+ onSwitchToForgot: () => void;
+}) {
+ const router = useRouter();
+ const setAuth = useAuthStore((s) => s.setAuth);
+
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [remember, setRemember] = useState(true);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!email.trim() || !password.trim()) {
+ setError("请输入邮箱和密码");
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await api.post("/login", {
+ username: email,
+ password,
+ });
+ if (res.flag) {
+ setAuth(res.data.token, res.data.userInfo);
+ toast.success("已登录");
+ // 按角色分流
+ router.replace(res.data.userInfo.loginType === 1 ? "/admin" : "/");
+ } else {
+ setError(res.message || "登录失败");
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "网络错误");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx
new file mode 100644
index 0000000..47ab4dd
--- /dev/null
+++ b/frontend/src/components/auth/RegisterForm.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { useState } from "react";
+import { toast } from "sonner";
+import { api } from "@/lib/api";
+import { FieldInput } from "./FieldInput";
+import { ErrorBar } from "./ErrorBar";
+import { CaptchaBlock, SwitchLink } from "./CaptchaBlock";
+
+export function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
+ const [nickname, setNickname] = useState("");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [confirm, setConfirm] = useState("");
+ const [captcha, setCaptcha] = useState("");
+ const [agree, setAgree] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!nickname.trim() || !email.trim() || !password.trim()) {
+ setError("请填写完整信息");
+ return;
+ }
+ if (password !== confirm) {
+ setError("两次输入的密码不一致");
+ return;
+ }
+ if (!captcha.trim()) {
+ setError("请输入验证码");
+ return;
+ }
+ if (!agree) {
+ setError("请先同意服务条款");
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await api.post("/register", {
+ nickname,
+ username: email,
+ email,
+ password,
+ captcha,
+ });
+ if (res.flag) {
+ toast.success("注册成功,请登录");
+ onSwitchToLogin();
+ } else {
+ setError(res.message || "注册失败");
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "网络错误");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/blog/ArticleCard.tsx b/frontend/src/components/blog/ArticleCard.tsx
new file mode 100644
index 0000000..b2e662c
--- /dev/null
+++ b/frontend/src/components/blog/ArticleCard.tsx
@@ -0,0 +1,326 @@
+"use client";
+
+import Link from "next/link";
+import Image from "next/image";
+import { Calendar, Eye, ChevronRight } from "lucide-react";
+import { cn, formatViews, safeTruncate, stripMarkdown } from "@/lib/utils";
+
+export interface ArticleCardData {
+ id: number;
+ title: string;
+ cover?: string;
+ summary?: string;
+ content?: string;
+ categoryName?: string;
+ tags?: { id: number; name: string }[];
+ author?: { name: string; avatar?: string };
+ publishTime?: string;
+ viewCount?: number;
+ isTop?: boolean;
+}
+
+interface ArticleCardProps {
+ article: ArticleCardData;
+ /** 移动端横排布局(强制) */
+ forceMobile?: boolean;
+ /** 隐藏封面图 */
+ hideCover?: boolean;
+ className?: string;
+}
+
+/**
+ * 文章卡片(spec §4.3)
+ * - 桌面:3 列网格中的纵向卡片,封面 16:9
+ * - 移动:固定 h-40 横排布局,封面占 1/3
+ */
+export function ArticleCard({
+ article,
+ forceMobile = false,
+ hideCover = false,
+ className,
+}: ArticleCardProps) {
+ const summary =
+ article.summary ?? stripMarkdown(article.content ?? "").slice(0, 140);
+
+ if (forceMobile) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {/* 封面 */}
+ {!hideCover && article.cover ? (
+
+
+ {article.isTop && (
+
+ 置顶
+
+ )}
+
+ ) : (
+ !hideCover && (
+
+ 暂无封面
+
+ )
+ )}
+
+ {/* 内容 */}
+
+ {/* 标题 */}
+
+ {safeTruncate(article.title, 30)}
+
+
+ {/* 摘要 */}
+
+ {summary}
+
+
+ {/* 标签 */}
+ {article.tags && article.tags.length > 0 && (
+
+ {article.tags.slice(0, 3).map((t) => (
+
+ {t.name}
+
+ ))}
+
+ )}
+
+ {/* meta */}
+
+
+ {article.author?.avatar ? (
+
+ ) : (
+
+ {article.author?.name?.[0] || "U"}
+
+ )}
+
+ {article.author?.name && (
+
+ {article.author.name}
+
+ )}
+ {article.publishTime && (
+
+
+ {article.publishTime}
+
+ )}
+
+
+ {article.viewCount !== undefined && (
+
+
+ {formatViews(article.viewCount)}
+
+ )}
+
+
+
+
+ );
+}
+
+function MobileCard({
+ article,
+ summary,
+ hideCover,
+ className,
+}: {
+ article: ArticleCardData;
+ summary: string;
+ hideCover: boolean;
+ className?: string;
+}) {
+ return (
+
+
+ {!hideCover && article.cover ? (
+
+
+
+ ) : (
+ !hideCover && (
+
+ 无封面
+
+ )
+ )}
+
+
+
+
+ {article.isTop && (
+
+ 置顶
+
+ )}
+ {article.categoryName && (
+
+ {article.categoryName}
+
+ )}
+
+
+ {article.title}
+
+
+ {summary}
+
+
+
+
+ {article.tags?.slice(0, 3).map((t) => (
+
+ {t.name}
+
+ ))}
+
+ {article.author?.avatar ? (
+
+ ) : (
+
+ {article.author?.name?.[0] || "U"}
+
+ )}
+ {article.publishTime}
+
+
+
+
+
+ );
+}
+
+export function ArticleListPagination({
+ current,
+ totalPages,
+ onChange,
+}: {
+ current: number;
+ totalPages: number;
+ onChange: (page: number) => void;
+}) {
+ if (totalPages <= 1) return null;
+ return (
+
+
+
+ {current} / {totalPages}
+
+
+
+ );
+}
+
+export function ArticleEmpty({ hint }: { hint?: string }) {
+ return (
+
+ );
+}
+
+export function ArticleError({ message, onRetry }: { message: string; onRetry?: () => void }) {
+ return (
+
+
{message}
+ {onRetry && (
+
+ )}
+
+ );
+}
+
+export function ArticleListSkeleton({ count = 5 }: { count?: number }) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+export { ChevronRight };
\ No newline at end of file
diff --git a/frontend/src/components/blog/ArticleNav.tsx b/frontend/src/components/blog/ArticleNav.tsx
deleted file mode 100644
index 9438fe3..0000000
--- a/frontend/src/components/blog/ArticleNav.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { ChevronLeft, ChevronRight } from "lucide-react";
-import { Card } from "@/components/ui/card";
-
-interface ArticleNavItem {
- id: number;
- articleTitle: string;
- articleCover?: string;
-}
-
-interface Props {
- prev: ArticleNavItem | null;
- next: ArticleNavItem | null;
-}
-
-export function ArticleNav({ prev, next }: Props) {
- if (!prev && !next) return null;
-
- return (
-
- );
-}
diff --git a/frontend/src/components/blog/CategoryList.tsx b/frontend/src/components/blog/CategoryList.tsx
deleted file mode 100644
index 5cde707..0000000
--- a/frontend/src/components/blog/CategoryList.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { useEffect, useState } from "react";
-import { Skeleton } from "@/components/ui/skeleton";
-import { api } from "@/lib/api";
-import { SideTitle } from "./FeaturedCard";
-
-interface Category {
- id: number;
- categoryName: string;
- articleCount: number;
-}
-
-/**
- * 右栏:文章分类(list,每项带左侧品牌色细条)
- */
-export function CategoryList() {
- const [list, setList] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState("");
-
- useEffect(() => {
- api
- .get("/categories")
- .then((res) => {
- if (res.flag && res.data) setList(res.data);
- })
- .catch((err) => setError(err instanceof Error ? err.message : "加载失败"))
- .finally(() => setLoading(false));
- }, []);
-
- return (
-
- );
-}
diff --git a/frontend/src/components/blog/CommentSection.tsx b/frontend/src/components/blog/CommentSection.tsx
new file mode 100644
index 0000000..17076af
--- /dev/null
+++ b/frontend/src/components/blog/CommentSection.tsx
@@ -0,0 +1,264 @@
+"use client";
+
+import { useState } from "react";
+import Image from "next/image";
+import { Heart, Reply, Trash2, Send } from "lucide-react";
+import { cn, formatTime } from "@/lib/utils";
+
+export interface CommentNode {
+ id: number;
+ nickname: string;
+ avatar?: string;
+ content: string;
+ createTime: string;
+ likeCount?: number;
+ children?: CommentNode[];
+}
+
+/**
+ * 评论区(spec §4.7)
+ * - 嵌套评论(最多一级回复)
+ * - Enter 发送 / Shift+Enter 换行
+ * - 时间显示为「几分钟前」
+ */
+export function CommentSection({
+ comments,
+ onSubmit,
+ onReply,
+ onLike,
+ onDelete,
+ currentUser,
+}: {
+ comments: CommentNode[];
+ onSubmit: (content: string) => Promise | void;
+ onReply: (parentId: number, content: string) => Promise | void;
+ onLike: (commentId: number) => Promise | void;
+ onDelete?: (commentId: number) => Promise | void;
+ currentUser?: { nickname: string; avatar?: string };
+}) {
+ const [content, setContent] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSubmit = async () => {
+ const v = content.trim();
+ if (!v || submitting) return;
+ setSubmitting(true);
+ try {
+ await onSubmit(v);
+ setContent("");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ 评论
+
+
+ {/* 输入区 */}
+
+
+
+ {/* 列表 */}
+
+ {comments.length === 0 && (
+ -
+ 还没有评论,来抢沙发吧~
+
+ )}
+ {comments.map((c) => (
+
+ ))}
+
+
+ );
+}
+
+function CommentItem({
+ comment,
+ onReply,
+ onLike,
+ onDelete,
+ depth = 0,
+}: {
+ comment: CommentNode;
+ onReply: (parentId: number, content: string) => Promise | void;
+ onLike: (commentId: number) => Promise | void;
+ onDelete?: (commentId: number) => Promise | void;
+ depth?: number;
+}) {
+ const [replyOpen, setReplyOpen] = useState(false);
+ const [replyText, setReplyText] = useState("");
+ const [liked, setLiked] = useState(false);
+ const [likeCount, setLikeCount] = useState(comment.likeCount ?? 0);
+
+ const submitReply = async () => {
+ const v = replyText.trim();
+ if (!v) return;
+ await onReply(comment.id, v);
+ setReplyText("");
+ setReplyOpen(false);
+ };
+
+ return (
+
+
+
+ {comment.avatar ? (
+
+ ) : (
+
+ {comment.nickname?.[0] || "U"}
+
+ )}
+
+
+
+
+ {comment.nickname}
+
+ • {formatTime(comment.createTime)}
+
+
+ {comment.content}
+
+
+
+
+ {onDelete && (
+
+ )}
+
+
+ {replyOpen && (
+
+
+ )}
+
+ {comment.children && comment.children.length > 0 && (
+
+ {comment.children.map((child) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/blog/FeaturedCard.tsx b/frontend/src/components/blog/FeaturedCard.tsx
deleted file mode 100644
index 5f056bf..0000000
--- a/frontend/src/components/blog/FeaturedCard.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import Image from "next/image";
-
-export interface FeaturedArticle {
- id: number;
- articleTitle: string;
- articleCover?: string;
- categoryName?: string;
-}
-
-/**
- * 右栏:今日推荐
- * 单张大幅封面 + 底部渐变 + 标题叠在封面上
- */
-export function FeaturedCard({ article }: { article: FeaturedArticle | null }) {
- if (!article) {
- return (
-
- );
- }
-
- return (
-
- );
-}
-
-export function SideTitle({ children }: { children: React.ReactNode }) {
- return (
-
-
- {children}
-
- );
-}
diff --git a/frontend/src/components/blog/Hero.tsx b/frontend/src/components/blog/Hero.tsx
index 2713e13..74e3c40 100644
--- a/frontend/src/components/blog/Hero.tsx
+++ b/frontend/src/components/blog/Hero.tsx
@@ -2,46 +2,44 @@
import Link from "next/link";
import Image from "next/image";
-import { ArrowRight, Sparkles } from "lucide-react";
+import { ArrowRight } from "lucide-react";
export interface HeroPinned {
id: number;
articleTitle: string;
articleCover?: string;
categoryName?: string;
- articleContent?: string;
- createTime?: string;
}
-interface HeroProps {
+/**
+ * Hero 头图(spec §4.2)
+ */
+export function Hero({
+ siteName,
+ slogan,
+ pinned,
+ hasFilter,
+}: {
siteName?: string;
slogan?: string;
pinned?: HeroPinned | null;
hasFilter?: boolean;
-}
-
-export function Hero({ siteName, slogan, pinned, hasFilter }: HeroProps) {
- // 筛选态:只显示一行提示(保留原行为)
+}) {
if (hasFilter) {
return (
-
-
文章筛选
-
- 按条件筛选的文章列表
-
+
);
}
const title = siteName || "我的博客";
- const subtitle =
- slogan || "记录技术、分享生活。Stay hungry, stay foolish.";
- // 优先用置顶文章封面,没有则用本地 Hero.jpg
+ const subtitle = slogan || "记录技术、分享生活。Stay hungry, stay foolish.";
const bgCover = "/Hero.jpg";
return (
-
- {/* 背景层 */}
+
- {/* 左侧加深蒙版,保证左下文字可读;右侧稍亮,给推荐卡留呼吸感 */}
-
-
+
-
- {/* 内容层 */}
-
- {/* 左下:玻璃面板 */}
-
- {/* 顶部小条:玻璃风格统一 */}
-
-
-
- Welcome
+
+
+ {pinned?.categoryName && (
+
+ {pinned.categoryName}
- {pinned?.categoryName && (
-
- {pinned.categoryName}
-
- )}
-
-
- {/* 主标题 + 副标题:玻璃面板(统一样式) */}
-
-
- 欢迎来到
-
- {title}
-
-
-
-
- {subtitle}
-
-
-
- {/* CTA */}
-
- 开始阅读
-
-
+ )}
+
+ {title}
+
+ {subtitle &&
{subtitle}
}
+ {pinned && (
+
+ 阅读推荐
+
+
+ )}
-
- {/* 右下空间(当前留空,文字面板独占视觉中心) */}
);
diff --git a/frontend/src/components/blog/ReadingProgress.tsx b/frontend/src/components/blog/ReadingProgress.tsx
deleted file mode 100644
index 988ef9b..0000000
--- a/frontend/src/components/blog/ReadingProgress.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-
-/**
- * 顶部固定阅读进度条(蓝色,0.5px 高)
- * 通过 --progress CSS 变量驱动 transform: scaleX
- */
-export function ReadingProgress() {
- const [progress, setProgress] = useState(0);
-
- useEffect(() => {
- const update = () => {
- const h = document.documentElement;
- const scrolled = h.scrollTop || document.body.scrollTop;
- const max = h.scrollHeight - h.clientHeight;
- const ratio = max > 0 ? scrolled / max : 0;
- setProgress(Math.min(Math.max(ratio, 0), 1));
- };
-
- update();
- window.addEventListener("scroll", update, { passive: true });
- window.addEventListener("resize", update);
- return () => {
- window.removeEventListener("scroll", update);
- window.removeEventListener("resize", update);
- };
- }, []);
-
- return (
-
- );
-}
diff --git a/frontend/src/components/blog/SearchPopover.tsx b/frontend/src/components/blog/SearchPopover.tsx
new file mode 100644
index 0000000..abe3a2f
--- /dev/null
+++ b/frontend/src/components/blog/SearchPopover.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import Image from "next/image";
+import { Search, X } from "lucide-react";
+import { cn, debounce, stripMarkdown } from "@/lib/utils";
+import { api } from "@/lib/api";
+
+interface SearchResult {
+ id: number;
+ articleTitle: string;
+ articleContent?: string;
+ articleCover?: string;
+}
+
+interface SearchPopoverProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+/** 搜索弹窗(spec §4.8) */
+export function SearchPopover({ open, onClose }: SearchPopoverProps) {
+ const [query, setQuery] = useState("");
+ const [results, setResults] = useState
([]);
+ const [loading, setLoading] = useState(false);
+ const inputRef = useRef(null);
+
+ // 防抖搜索
+ useEffect(() => {
+ if (!open) return;
+ const trigger = debounce(async (q: string) => {
+ if (!q.trim()) {
+ setResults([]);
+ return;
+ }
+ setLoading(true);
+ try {
+ const res = await api.get<{ records: SearchResult[] }>(
+ `/articles/condition?current=1&size=10&keyword=${encodeURIComponent(q)}`,
+ );
+ if (res.flag && res.data) setResults(res.data.records || []);
+ } catch {
+ setResults([]);
+ } finally {
+ setLoading(false);
+ }
+ }, 300);
+ trigger(query);
+ }, [query, open]);
+
+ // 自动聚焦
+ useEffect(() => {
+ if (open) {
+ const t = setTimeout(() => inputRef.current?.focus(), 100);
+ return () => clearTimeout(t);
+ }
+ }, [open]);
+
+ // ESC 关闭
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [open, onClose]);
+
+ // body overflow
+ useEffect(() => {
+ if (!open) return;
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = prev;
+ };
+ }, [open]);
+
+ if (!open) return null;
+
+ return (
+ <>
+
+
+
+ {/* 输入区 */}
+
+
+ setQuery(e.target.value)}
+ placeholder="搜索文章..."
+ className="w-full rounded-xl border-0 bg-white/80 px-5 py-3 pl-12 text-base text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-800/80 dark:text-gray-100"
+ />
+
+
+
+ {/* 结果区 */}
+
+ {loading ? (
+
+
+ 搜索中...
+
+ ) : query.trim() && results.length === 0 ? (
+
+ ) : results.length > 0 ? (
+ results.map((r) => (
+
+
+ {r.articleCover && (
+
+
+
+ )}
+
+
+ {r.articleTitle}
+
+
+ {stripMarkdown(r.articleContent || "").slice(0, 80)}
+
+
+
+
+ ))
+ ) : (
+
+ 输入关键词开始搜索
+
+ )}
+
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/blog/SidebarCard.tsx b/frontend/src/components/blog/SidebarCard.tsx
new file mode 100644
index 0000000..598101a
--- /dev/null
+++ b/frontend/src/components/blog/SidebarCard.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { cn } from "@/lib/utils";
+
+/** 侧栏三件套统一外壳(spec §4.5.1) */
+export function SidebarCard({
+ title,
+ icon,
+ children,
+ className,
+}: {
+ title: string;
+ icon?: React.ReactNode;
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+
+ {icon && (
+
+ {icon}
+
+ )}
+ {title}
+
+ {children}
+
+ );
+}
+
+/** 带渐隐滚动效果的滚动容器(spec §4.5.2) */
+export function FadeScrollContainer({
+ children,
+ maxHeight = "15rem",
+ className,
+}: {
+ children: React.ReactNode;
+ maxHeight?: string;
+ className?: string;
+}) {
+ const ref = useRef(null);
+ const [showTop, setShowTop] = useState(false);
+ const [showBottom, setShowBottom] = useState(true);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const update = () => {
+ setShowTop(el.scrollTop > 8);
+ setShowBottom(el.scrollHeight - el.scrollTop - el.clientHeight > 8);
+ };
+ update();
+ el.addEventListener("scroll", update, { passive: true });
+ const ro = new ResizeObserver(update);
+ ro.observe(el);
+ return () => {
+ el.removeEventListener("scroll", update);
+ ro.disconnect();
+ };
+ }, []);
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/blog/Sidebars.tsx b/frontend/src/components/blog/Sidebars.tsx
new file mode 100644
index 0000000..a2c4050
--- /dev/null
+++ b/frontend/src/components/blog/Sidebars.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { useSearchParams } from "next/navigation";
+import { Eye, Calendar, ChevronRight, Tag as TagIcon, FolderTree, Flame } from "lucide-react";
+import { SidebarCard, FadeScrollContainer } from "./SidebarCard";
+import { api } from "@/lib/api";
+import { cn, formatViews } from "@/lib/utils";
+
+/** 分类侧栏(spec §4.5.2) */
+export function CategorySidebar({
+ activeId,
+ onSelect,
+}: {
+ activeId?: string | null;
+ onSelect?: (id: number | null) => void;
+}) {
+ const [list, setList] = useState<{ id: number; categoryName: string; articleCount: number }[]>([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ api
+ .get<{ id: number; categoryName: string; articleCount: number }[]>("/categories")
+ .then((res) => {
+ if (res.flag && res.data) setList(res.data);
+ })
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, []);
+
+ return (
+ }>
+ {loading ? (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ ) : (
+
+
+ -
+
+
+ {list.map((cat) => (
+ -
+
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+/** 标签侧栏(spec §4.5.3) */
+export function TagSidebar({
+ activeId,
+ onSelect,
+}: {
+ activeId?: string | null;
+ onSelect?: (id: number | null) => void;
+}) {
+ const [list, setList] = useState<{ id: number; tagName: string; articleCount: number }[]>([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ api
+ .get<{ id: number; tagName: string; articleCount: number }[]>("/tags")
+ .then((res) => {
+ if (res.flag && res.data) setList(res.data);
+ })
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, []);
+
+ return (
+ }>
+ {loading ? (
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+ ) : list.length === 0 ? (
+ 暂无标签
+ ) : (
+
+
+ {list.map((t) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+/** 热门文章(spec §4.5.4) */
+export function RecommendedArticles() {
+ const [list, setList] = useState<
+ { id: number; articleTitle: string; viewCount?: number; createTime: string; categoryName?: string }[]
+ >([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ api
+ .get<{
+ records: { id: number; articleTitle: string; viewCount?: number; createTime: string; categoryName?: string }[];
+ }>("/articles?current=1&size=8")
+ .then((res) => {
+ if (res.flag && res.data) setList(res.data.records);
+ })
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, []);
+
+ return (
+ }>
+ {loading ? (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ ) : list.length === 0 ? (
+ 暂无推荐
+ ) : (
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/blog/TableOfContents.tsx b/frontend/src/components/blog/TableOfContents.tsx
index d86f73d..f570b19 100644
--- a/frontend/src/components/blog/TableOfContents.tsx
+++ b/frontend/src/components/blog/TableOfContents.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
+import { cn } from "@/lib/utils";
interface Heading {
level: number;
@@ -8,15 +9,10 @@ interface Heading {
id: string;
}
-/**
- * 文章右侧 sticky 目录
- * - IntersectionObserver 监听 h2/h3 进入视口,高亮当前章节
- * - 点击平滑滚动 + offsetTop 修正(避免被 sticky header 遮挡)
- */
+/** 目录组件(spec §4.6 TOC) */
export function TableOfContents({ headings }: { headings: Heading[] }) {
- const [activeId, setActiveId] = useState(
- headings[0]?.id ?? null,
- );
+ const [activeId, setActiveId] = useState(headings[0]?.id ?? null);
+ const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
if (headings.length === 0) return;
@@ -36,14 +32,9 @@ export function TableOfContents({ headings }: { headings: Heading[] }) {
a.target.getBoundingClientRect().top -
b.target.getBoundingClientRect().top,
);
- if (visible[0]) {
- setActiveId(visible[0].target.id);
- }
- },
- {
- rootMargin: "-80px 0px -70% 0px",
- threshold: [0, 1],
+ if (visible[0]) setActiveId(visible[0].target.id);
},
+ { rootMargin: "-80px 0px -70% 0px", threshold: [0, 1] },
);
elements.forEach((el) => observer.observe(el));
@@ -53,48 +44,66 @@ export function TableOfContents({ headings }: { headings: Heading[] }) {
if (headings.length === 0) return null;
return (
-