Files
jiang13-bbs/frontend/app/u/[id]/UserProfileContent.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

360 lines
14 KiB
TypeScript
Raw 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, Pin, Star,
} 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";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
/** 本页卡片统一阴影;用 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: "精华" },
];
/** 帖子卡(发帖列表与收藏列表共用) */
async function PostCardItem({ post, allowComments }: { post: PostListItem; allowComments?: boolean }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
const pinned = post.pinned > 0;
return (
<Link
href={postPath(urlStyle, 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="badge-pin shrink-0 mt-0.5"
title="置顶"
aria-label="置顶"
>
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</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 mt-0.5" title="推荐" aria-label="推荐">
<Star size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</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 async 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 urlStyle = (await getPublicSettingsCached()).url_style;
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" ? userPath(urlStyle, Number(userId)) : `${userPath(urlStyle, Number(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={userPath(urlStyle, Number(userId))}
query={paginationQuery}
/>
</div>
) : tab === "favorites" ? (
favoriteError ? (
<ErrorState retryHref={`${userPath(urlStyle, Number(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={userPath(urlStyle, Number(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={`${userPath(urlStyle, Number(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={userPath(urlStyle, 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={userPath(urlStyle, Number(userId))}
query={paginationQuery}
/>
</div>
) : (
<EmptyState
icon={<UserPlus size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />}
text="暂无关注"
/>
)
) : commentError ? (
<ErrorState retryHref={`${userPath(urlStyle, Number(userId))}?tab=comments`} />
) : commentData && (commentData.comments?.length ?? 0) > 0 ? (
<div>
<div className="panel overflow-hidden">
{(commentData.comments ?? []).map((c) => (
<Link
key={c.id}
href={`${postPath(urlStyle, c.post_id)}${commentAnchor(c.floor, c.is_root, 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={userPath(urlStyle, Number(userId))}
query={paginationQuery}
/>
</div>
) : (
<EmptyState
icon={<MessageCircle size={26} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />}
text="暂无评论"
/>
)}
</div>
);
}