Files
jiang13-bbs/frontend/app/u/[id]/UserProfileContent.tsx
freefire d7c6b3a961 feat: 帖子收藏与用户关注
- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件
- 新增关注功能:follow 服务与 handler、FollowButton 组件
- 用户主页适配关注/粉丝与收藏展示
- flash 消息工具、广告与侧边栏组件微调
2026-09-26 03:02:40 +08:00

351 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import {
FileText, MessageCircle, AlertCircle, Bookmark, UserPlus,
} from "lucide-react";
import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow";
import { IconComment, IconEye, IconHeart } from "@/components/Icons";
import Avatar from "@/components/Avatar";
import LevelBadge from "@/components/LevelBadge";
import FollowButton from "@/components/FollowButton";
import { formatRelative } from "@/lib/format";
import type {
UserProfile, UserCommentsResponse, UserFavoritesResponse, UserFollowingResponse, PostListItem,
} from "@/lib/api";
/** 本页卡片统一阴影;用 color-mix 让暗色主题自动加深,避免写死浅色阴影 */
const CARD_SHADOW = "0 1px 3px color-mix(in srgb, #000 18%, transparent)";
const ITEM_BASE =
"group block rounded-xl px-6 py-5 border transition-[background-color,box-shadow,border-color] duration-150";
const SORTS: { key: string; label: string }[] = [
{ key: "new", label: "最新" },
{ key: "hot", label: "热门" },
{ key: "essence", label: "精华" },
];
/** 帖子卡(发帖列表与收藏列表共用) */
function PostCardItem({ post, allowComments }: { post: PostListItem; allowComments?: boolean }) {
const pinned = post.pinned > 0;
return (
<Link
href={`/post/${post.id}`}
className={`${ITEM_BASE} ${
pinned
? "bg-[color-mix(in_srgb,#fde68a_22%,var(--panel))] border-[color-mix(in_srgb,#fde68a_70%,var(--line))] hover:bg-[color-mix(in_srgb,#fde68a_30%,var(--panel))]"
: "bg-[var(--panel)] border-[var(--line)] hover:bg-[color-mix(in_srgb,var(--ink)_3.5%,var(--panel))]"
}`}
style={{ boxShadow: CARD_SHADOW }}
>
<div className="flex items-start gap-2">
{pinned && (
<span
className="mt-0.5 inline-flex shrink-0 items-center rounded px-1.5 py-px text-[11px] font-semibold leading-[1.5] text-white"
style={{ background: "#f59e0b" }}
>
置顶
</span>
)}
<h2
className="min-w-0 flex-1 text-[18px] font-semibold leading-snug transition-colors duration-150 group-hover:text-[var(--accent)]"
style={{ color: "var(--ink)" }}
>
{post.title}
</h2>
{post.recommended && <span className="badge-rec shrink-0">推荐</span>}
</div>
<div
className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[12px]"
style={{ color: "var(--ink-3)" }}
>
<span
className="inline-flex items-center rounded px-1.5 py-px text-[11px] font-medium leading-[1.5]"
style={{
background: "var(--panel-2)",
color: "var(--ink)",
}}
>
{post.board.name}
</span>
<span className="tabular-nums">{formatRelative(post.created_at)}</span>
{allowComments ? (
<span className="inline-flex items-center gap-1">
<IconComment size={12} />
<span className="inline-flex items-center gap-1 tabular-nums">
{post.comment_count}
<span>评论</span>
</span>
</span>
) : null}
<span className="inline-flex items-center gap-1">
<IconEye size={12} />
<span className="inline-flex items-center gap-1 tabular-nums">
{post.view_count}
<span>浏览</span>
</span>
</span>
<span className="inline-flex items-center gap-1">
<IconHeart size={12} />
<span className="inline-flex items-center gap-1 tabular-nums">
{post.like_count}
<span>点赞</span>
</span>
</span>
</div>
</Link>
);
}
/** 列表区块统一空态 */
function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div
className="rounded-xl text-center py-16 px-6"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
boxShadow: CARD_SHADOW,
}}
>
{icon}
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>{text}</p>
</div>
);
}
/** 列表加载失败统一态 */
function ErrorState({ retryHref }: { retryHref: string }) {
return (
<div
className="rounded-xl text-center py-16 px-6"
role="alert"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
boxShadow: CARD_SHADOW,
}}
>
<AlertCircle size={26} className="mx-auto mb-3" style={{ color: "var(--danger)" }} />
<p className="text-sm font-semibold" style={{ color: "var(--ink-2)" }}>加载失败</p>
<p className="meta mt-1.5 mb-4">网络异常,请稍后重试</p>
<Link href={retryHref} className="btn btn-line btn-sm">重新加载</Link>
</div>
);
}
/**
* 用户主页内容区(纯服务端渲染):
* tab 与分页全部走 URL(?tab=xxx&page=N&sort=xxx),刷新/后退/分享链接均保持一致。
* Tab 切换栏已移至 page.tsx 统一渲染(含 settings / security),此组件仅负责列表类 tab。
*/
export default function UserProfileContent({
userId,
profile,
tab,
commentData,
commentError,
favoriteData,
favoriteError,
followingData,
followingError,
page,
sort = "new",
isOwner,
allowComments = true,
}: {
userId: string;
profile: UserProfile;
tab: "posts" | "comments" | "favorites" | "following";
commentData: UserCommentsResponse | null;
commentError: boolean;
favoriteData: UserFavoritesResponse | null;
favoriteError: boolean;
followingData: UserFollowingResponse | null;
followingError: boolean;
page: number;
sort?: string;
isOwner: boolean;
allowComments?: boolean;
}) {
const displayName = profile.user.nickname || profile.user.username;
const paginationQuery: Record<string, string> =
tab === "comments" ? { tab: "comments" }
: tab === "favorites" ? { tab: "favorites" }
: tab === "following" ? { tab: "following" }
: sort !== "new" ? { sort }
: {};
return (
<div>
{tab === "posts" ? (
<div>
{/* 标题 + 排序 chips(最新/热门/精华),与参考图一致 */}
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-[17px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
{isOwner ? "我的帖子" : `${displayName} 的帖子`}
</h2>
<div className="j13-chips">
{SORTS.map((s) => (
<Link
key={s.key}
href={s.key === "new" ? `/u/${userId}` : `/u/${userId}?sort=${s.key}`}
className="j13-chip"
aria-current={sort === s.key ? "page" : undefined}
>
{s.label}
</Link>
))}
</div>
</div>
{Array.isArray(profile.posts) && profile.posts.length > 0 ? (
/* 与首页信息流一致的行式列表 */
<div className="panel overflow-hidden">
{profile.posts.map((post, i) => (
<PostRow key={post.id} post={post} showBoard index={i} showLike={false} />
))}
</div>
) : (
<EmptyState
icon={<FileText size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />}
text="暂无发帖"
/>
)}
<Pagination
total={profile.posts_total}
page={page}
size={20}
basePath={`/u/${userId}`}
query={paginationQuery}
/>
</div>
) : tab === "favorites" ? (
favoriteError ? (
<ErrorState retryHref={`/u/${userId}?tab=favorites`} />
) : favoriteData && favoriteData.posts.length > 0 ? (
<div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-[17px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
{isOwner ? "我的收藏" : `${displayName} 的收藏`}
</h2>
</div>
<div className="flex flex-col gap-2">
{favoriteData.posts.map((post) => (
<PostCardItem key={post.id} post={post} allowComments={allowComments} />
))}
</div>
<Pagination
total={favoriteData.total}
page={favoriteData.page}
size={20}
basePath={`/u/${userId}`}
query={paginationQuery}
/>
</div>
) : (
<EmptyState
icon={<Bookmark size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />}
text="暂无收藏"
/>
)
) : tab === "following" ? (
followingError ? (
<ErrorState retryHref={`/u/${userId}?tab=following`} />
) : followingData && followingData.users.length > 0 ? (
<div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-[17px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
{isOwner ? "我的关注" : `${displayName} 的关注`}
</h2>
</div>
<div className="flex flex-col gap-2">
{followingData.users.map((fu) => (
<div
key={fu.id}
className="group flex items-center gap-3 rounded-xl border px-5 py-4 transition-[background-color,box-shadow,border-color] duration-150 bg-[var(--panel)] border-[var(--line)] hover:bg-[color-mix(in_srgb,var(--ink)_3.5%,var(--panel))]"
style={{ boxShadow: CARD_SHADOW }}
>
<Link href={`/u/${fu.id}`} className="flex min-w-0 flex-1 items-center gap-3">
<Avatar name={fu.nickname || fu.username} src={fu.avatar || undefined} size={40} />
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span
className="truncate text-[15px] font-semibold transition-colors duration-150 group-hover:text-[var(--accent)]"
style={{ color: "var(--ink)" }}
>
{fu.nickname || fu.username}
</span>
<LevelBadge level={fu.level ?? 0} />
</div>
<p className="mt-0.5 truncate text-[12px]" style={{ color: "var(--ink-3)" }}>
@{fu.username}
{fu.signature ? ` · ${fu.signature}` : ""}
</p>
</div>
</Link>
{isOwner && <FollowButton userId={fu.id} following size="sm" />}
</div>
))}
</div>
<Pagination
total={followingData.total}
page={followingData.page}
size={20}
basePath={`/u/${userId}`}
query={paginationQuery}
/>
</div>
) : (
<EmptyState
icon={<UserPlus size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />}
text="暂无关注"
/>
)
) : commentError ? (
<ErrorState retryHref={`/u/${userId}?tab=comments`} />
) : commentData && (commentData.comments?.length ?? 0) > 0 ? (
<div>
<div className="panel overflow-hidden">
{(commentData.comments ?? []).map((c) => (
<Link
key={c.id}
href={`/post/${c.post_id}#comment-${c.id}`}
className="group block px-4 sm:px-5 py-4 border-t transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
style={{ borderColor: "var(--line)" }}
>
<div className="meta text-[12.5px] flex items-center gap-1 flex-wrap">
<MessageCircle size={12} />
<span>
评论于《<span className="font-medium" style={{ color: "var(--accent)" }}>{c.post_title}</span>》
</span>
</div>
<p
className="mt-1.5 text-[14px] leading-relaxed line-clamp-2 group-hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{c.content}
</p>
<div className="meta text-[12px] mt-1.5 tabular-nums">
{formatRelative(c.created_at)}
</div>
</Link>
))}
</div>
<Pagination
total={commentData.total}
page={commentData.page}
size={20}
basePath={`/u/${userId}`}
query={paginationQuery}
/>
</div>
) : (
<EmptyState
icon={<MessageCircle size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />}
text="暂无评论"
/>
)}
</div>
);
}