feat: 新增大量社区功能与基础组件,优化站点整体体验

- 新增事件总线式toast通知系统
- 新增头像、阅读进度、404/500错误页等基础组件
- 重构页脚、布局、分页、点赞按钮等现有组件
- 新增用户个人页、通知中心、帖子编辑/发布/置顶/推荐管理功能
- 优化登录注册页面样式与交互
- 修复路径类型文件引用,更新站点元信息与字体配置
This commit is contained in:
2026-09-12 04:30:02 +08:00
parent a8f6ce4e4f
commit 1eaf3903d4
31 changed files with 2395 additions and 740 deletions

View File

@@ -1,9 +1,11 @@
import Link from "next/link";
import { Metadata } from "next";
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { PenSquare, MessagesSquare, ChevronRight } from "lucide-react";
import { fetchPosts, fetchBoards } from "@/lib/api";
import Pagination from "@/components/Pagination";
import LikeButton from "@/components/LikeButton";
import PostRow from "@/components/PostRow";
import PinnedPosts from "@/components/PinnedPosts";
interface PageProps {
params: Promise<{ id: string }>;
@@ -15,7 +17,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const { boards } = await fetchBoards();
const board = boards.find((b) => b.id === Number(id));
return {
title: board ? `${board.name} - 姜十三论坛` : "板块 - 姜十三论坛",
title: board ? `${board.name} · 姜十三论坛` : "板块 · 姜十三论坛",
description: board?.description || "板块帖子列表",
};
}
@@ -29,62 +31,111 @@ export default async function BoardPage({ params, searchParams }: PageProps) {
fetchPosts(page, 20, Number(id), "latest", "", cookie),
fetchBoards(),
]);
const board = boardsData.boards.find((b) => b.id === Number(id));
const boardIndex = boardsData.boards.findIndex((b) => b.id === Number(id));
const board = boardIndex >= 0 ? boardsData.boards[boardIndex] : null;
// 第一页:置顶帖独立成区,列表只保留非置顶
const pinned = page === 1 ? postsData.posts.filter((p) => p.pinned > 0) : [];
const rows = page === 1 ? postsData.posts.filter((p) => p.pinned <= 0) : postsData.posts;
return (
<div>
<nav className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
<Link href="/" className="hover:underline">首页</Link>
<span className="mx-2">/</span>
<span>{board?.name || "板块"}</span>
{/* 面包屑 */}
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>{board?.name || "未知板块"}</span>
</nav>
<h1 className="text-2xl font-bold mb-2">{board?.name}</h1>
<p className="mb-6" style={{ color: "var(--text-secondary)" }}>{board?.description}</p>
<div className="space-y-3">
{postsData.posts.map((post) => (
<div
key={post.id}
className="p-4 rounded-lg border hover:shadow-md transition-shadow"
style={{ borderColor: "var(--border)", background: "var(--bg)" }}
>
<div className="flex items-center gap-2 mb-1">
{post.pinned > 0 && (
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700">置顶</span>
<div className="panel p-6 sm:p-7 mb-6 relative overflow-hidden">
<span
aria-hidden
className="absolute -top-16 -right-12 w-48 h-48 rounded-full"
style={{ background: "var(--accent-soft)" }}
/>
<div className="relative flex items-end justify-between gap-4 flex-wrap">
<div className="flex items-center gap-4">
<span
className={`av-${Math.max(0, boardIndex) % 8} w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center text-2xl font-extrabold shrink-0`}
>
{board?.name.slice(0, 1) || "?"}
</span>
<div>
<h1 className="text-2xl sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
{board?.name || "未知板块"}
</h1>
{board?.description && (
<p className="mt-1 text-sm" style={{ color: "var(--ink-2)" }}>
{board.description}
</p>
)}
{post.recommended && (
<span className="text-xs px-2 py-0.5 rounded bg-rose-100 text-rose-700">推荐</span>
)}
</div>
<Link href={`/post/${post.id}`}>
<h2 className="font-semibold mb-1 hover:underline">{post.title}</h2>
</Link>
<div className="flex items-center justify-between gap-4 text-sm" style={{ color: "var(--text-secondary)" }}>
<div className="flex items-center gap-4">
<Link href={`/u/${post.user.id}`} className="hover:underline" style={{ color: "var(--primary)" }}>
{post.user.nickname}
</Link>
<span>{new Date(post.created_at).toLocaleDateString("zh-CN")}</span>
<span>💬 {post.comment_count}</span>
<span>👁 {post.view_count}</span>
</div>
<LikeButton postId={String(post.id)} liked={post.liked} likeCount={post.like_count} variant="compact" />
<p className="meta mt-1.5">{postsData.total} 篇讨论</p>
</div>
</div>
))}
{postsData.posts.length === 0 && (
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>
该板块暂无帖子
</p>
)}
<Link href="/compose" className="btn btn-primary">
<PenSquare size={15} /> 在此板块发帖
</Link>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_290px] gap-6">
<div>
<PinnedPosts posts={pinned} showBoard={false} />
{rows.length > 0 ? (
<div className="panel overflow-hidden">
<div className="px-4 sm:px-5 py-3.5 flex items-center justify-between" style={{ borderBottom: "1px solid var(--line)" }}>
<span className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>全部讨论</span>
<span className="meta text-xs">第 {page} 页</span>
</div>
{rows.map((post) => (
<PostRow key={post.id} post={post} />
))}
</div>
) : (
pinned.length === 0 && (
<div className="panel text-center py-20 px-6">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<MessagesSquare size={26} />
</span>
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>该板块还没有帖子</p>
<p className="meta mt-1.5">来发第一篇,抢占沙发</p>
<Link href="/compose" className="btn btn-primary mt-6">
<PenSquare size={15} /> 写新帖
</Link>
</div>
)
)}
<Pagination total={postsData.total} page={page} size={20} basePath={`/board/${id}`} />
</div>
<aside>
<div className="panel p-5">
<p className="font-bold text-[15px] mb-3.5" style={{ color: "var(--ink)" }}>其他板块</p>
<div className="space-y-1">
{boardsData.boards
.map((b, i) => ({ b, i }))
.filter(({ b }) => b.id !== Number(id))
.map(({ b, i }) => (
<Link
key={b.id}
href={`/board/${b.id}`}
className="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-xl transition-colors hover:bg-[var(--panel-2)]"
>
<span className={`av-${i % 8} w-8 h-8 rounded-xl flex items-center justify-center text-[13px] font-bold shrink-0`}>
{b.name.slice(0, 1)}
</span>
<span className="text-sm font-medium flex-1 truncate group-hover:text-[var(--accent)] transition-colors" style={{ color: "var(--ink)" }}>
{b.name}
</span>
</Link>
))}
</div>
</div>
</aside>
</div>
<Pagination
total={postsData.total}
page={page}
size={20}
basePath={`/board/${id}`}
/>
</div>
);
}