feat: 帖子收藏与用户关注

- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件
- 新增关注功能:follow 服务与 handler、FollowButton 组件
- 用户主页适配关注/粉丝与收藏展示
- flash 消息工具、广告与侧边栏组件微调
This commit is contained in:
2026-09-26 03:02:40 +08:00
parent 31f5360c26
commit d7c6b3a961
40 changed files with 1528 additions and 268 deletions

View File

@@ -24,16 +24,18 @@ import {
import { defaultSidebarConfig, sidebarScopeLists } from "@/lib/sidebarMeta";
import SidebarRenderer, { type SidebarBlockProps } from "@/components/sidebar/SidebarRenderer";
import { postViewerCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { getMeCached, getPublicSettingsCached, getBoardsCached } from "@/lib/serverData";
import CommentSection from "@/components/CommentSection";
import PostActions from "@/components/PostActions";
import LikeButton from "@/components/LikeButton";
import FavoriteButton from "@/components/FavoriteButton";
import { SiteDocBreadcrumb } from "@/components/site-doc";
import MobilePostBar from "@/components/MobilePostBar";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import { boardColor } from "@/components/BoardIcon";
import MarkdownBody from "@/components/MarkdownBody";
import { publicMarkdownForSeo } from "@/lib/hideBlocks";
import PostAttachments from "@/components/PostAttachments";
@@ -160,9 +162,14 @@ function RelatedPosts({
function AuthorCardStacked({
profile,
allowComments,
boardNames,
boardColors,
}: {
profile: UserProfile;
allowComments: boolean;
/** 板块 id → 名称/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色 */
boardNames?: Record<number, string>;
boardColors?: Record<number, number>;
}) {
const u = profile.user;
const signature = (u.signature || "").trim();
@@ -192,7 +199,9 @@ function AuthorCardStacked({
{(u.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
{u.role !== "user" && <RoleBadge role={u.role} boardIds={u.board_ids} />}
{u.role !== "user" && (
<RoleBadge role={u.role} boardIds={u.board_ids} boardNames={boardNames} boardColors={boardColors} />
)}
</div>
<p className="meta mt-1 flex items-center gap-1.5 flex-wrap">
<span>@{u.username}</span>
@@ -307,13 +316,22 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
};
const meFirst = await getMeCached(cookie || undefined);
const canFetchComments = allowComments && (!commentsRequireLogin || !!meFirst.user);
const [commentsData, profile, relatedData, sidebarData] = await Promise.all([
const [commentsData, profile, relatedData, sidebarData, boardsData] = await Promise.all([
canFetchComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments),
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
// 侧栏布局配置失败不阻塞页面,回落默认布局
fetchSidebar(cookie).catch(() => null),
// 板块名/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色
getBoardsCached().catch(() => null),
]);
// 板块 id → 名称 / 配色下标(与板块列表、头像底色同一套 av-N 色板)
const boardNames: Record<number, string> = {};
const boardColors: Record<number, number> = {};
for (const b of boardsData?.boards ?? []) {
boardNames[b.id] = b.name;
boardColors[b.id] = boardColor(b);
}
const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
const me = meFirst;
const comments = commentsData.comments;
@@ -336,6 +354,8 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
authorProfile: profile ?? undefined,
authorPosts,
toc,
boardNames,
boardColors,
};
// 三档网格退化:双栏 / 仅左 / 仅右 / 单栏
const railGridClass = !hasLeftRail && !hasRightRail
@@ -462,6 +482,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
postId={id}
liked={post.liked}
likeCount={post.like_count}
favorited={post.favorited === true}
allowComments={allowComments}
/>
@@ -550,6 +571,23 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
>
{post.user.nickname}
</Link>
{/* 等级 + 管理员颁布徽章 + 站点角色徽章收拢为一组(详情接口已 Preload,随 post.user 返回) */}
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<LevelBadge level={post.user.level} className="shrink-0" />
{(post.user.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} className="shrink-0" />
))}
{post.user.role !== "user" && (
<RoleBadge
role={post.user.role}
// board_ids 仅 /api/profile 返回;profile 拉取失败回落纯角色徽章
boardIds={profile?.user.board_ids}
boardNames={boardNames}
boardColors={boardColors}
className="shrink-0"
/>
)}
</div>
</div>
{/* 管理操作已收进单个下拉按钮;普通读者此处不渲染内容 */}
<PostActions
@@ -650,9 +688,9 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<QuestionStatusCard postId={post.id} state={questionState} />
) : null}
{/* 文末点赞:分隔线后居中,唯一的点赞入口(作者行不再放置) */}
{/* 文末点赞/收藏:分隔线后居中,唯一的互动入口(作者行不再放置) */}
<div
className="mt-9 pt-7 flex justify-center"
className="mt-9 pt-7 flex justify-center gap-3"
style={{ borderTop: "1px solid var(--line)" }}
>
<LikeButton
@@ -661,13 +699,19 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
likeCount={post.like_count}
variant="article"
/>
<FavoriteButton postId={id} favorited={post.favorited === true} variant="article" />
</div>
</div>
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */}
{profile && (
<div className="lg:hidden mt-6">
<AuthorCardStacked profile={profile} allowComments={allowComments} />
<AuthorCardStacked
profile={profile}
allowComments={allowComments}
boardNames={boardNames}
boardColors={boardColors}
/>
</div>
)}