Files
jiang13-bbs/frontend/app/u/[id]/page.tsx
freefire 7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00

644 lines
27 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 { cookies, headers } from "next/headers";
import { Metadata } from "next";
import {
FileText, MessageCircle, Flame, Calendar, Hash, Shield,
Settings, KeyRound, Info, PenLine, Bookmark, UserPlus, Mail,
} from "lucide-react";
import {
fetchUserProfile, fetchUserComments, fetchLoginDevices,
fetchUserFavorites, fetchUserFollowing,
type UserProfile, type UserCommentsResponse, type LoginDevice,
type UserFavoritesResponse, type UserFollowingResponse,
} from "@/lib/api";
import { authCookieHeader, sessionCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached, getBoardsCached } from "@/lib/serverData";
import UserProfileContent from "./UserProfileContent";
import ProfileForm from "./ProfileForm";
import PasswordForm from "./PasswordForm";
import LoginDevices from "./LoginDevices";
import ProfileHeroAvatar from "./ProfileHeroAvatar";
import RoleBadge from "@/components/RoleBadge";
import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import { boardColor } from "@/components/BoardIcon";
import { roleMeta } from "@/lib/roles";
import StartDMButton from "./StartDMButton";
import FollowButton from "@/components/FollowButton";
import StaffUserMenu from "@/components/StaffUserMenu";
import { requestCanonical } from "@/lib/canonicalMeta";
import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl";
import { userPath } from "@/lib/urlStyle";
import { profileJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
interface UserPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string; tab?: string; sort?: string }>;
}
export type UserTab = "posts" | "comments" | "favorites" | "following" | "settings" | "security";
function parseTab(rawTab?: string): UserTab {
return rawTab === "comments" ? "comments" :
rawTab === "favorites" ? "favorites" :
rawTab === "following" ? "following" :
rawTab === "settings" ? "settings" :
rawTab === "security" ? "security" : "posts";
}
export async function generateMetadata({ params, searchParams }: UserPageProps): Promise<Metadata> {
const { id } = await params;
const sp = await searchParams;
const tab = parseTab(sp.tab);
try {
const data = await fetchUserProfile(id, 1);
const pub = await getPublicSettingsCached();
const allowComments = pub.allow_comments !== false;
const urlStyle = pub.url_style;
const title = data.user.nickname || data.user.username;
const description = allowComments
? `查看 ${data.user.nickname} 的资料、发帖与评论`
: `查看 ${data.user.nickname} 的资料与发帖`;
// 资料/安全 tab 仅本人可见,不声明 Canonical,避免被当成公开资料页或继承首页
if (tab === "settings" || tab === "security") {
return { title, description };
}
const query: Record<string, string | number> = {};
if (allowComments && tab === "comments") query.tab = "comments";
if (tab === "favorites") query.tab = "favorites";
if (tab === "following") query.tab = "following";
if (tab === "posts" && (sp.sort === "hot" || sp.sort === "essence")) query.sort = sp.sort;
const page = canonicalPage(sp.page);
if (page) query.page = page;
return {
title,
description,
...(await requestCanonical(userPath(urlStyle, data.user.id), query)),
};
} catch {
return { title: "用户不存在" };
}
}
// 装饰几何色:全部由主题变量派生,浅色为淡靛、暗色自动提亮
const ACCENT_RING_1 = "color-mix(in srgb, var(--accent) 14%, transparent)";
const ACCENT_RING_2 = "color-mix(in srgb, var(--accent) 9%, transparent)";
export default async function UserPage({ params, searchParams }: UserPageProps) {
const { id } = await params;
const sp = await searchParams;
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
const tab = parseTab(sp.tab);
const sort = sp.sort === "hot" || sp.sort === "essence" ? sp.sort : "new";
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
const settings = await getPublicSettingsCached();
const allowComments = settings.allow_comments !== false;
const urlStyle = settings.url_style;
const commentsRequireLogin = settings.comments_require_login === true;
const allowMessages = settings.allow_messages !== false;
const canReadComments = allowComments && (!commentsRequireLogin || !!me.user);
let data: UserProfile;
try {
// 带 cookie:超管/站长才能拿到 banned 字段
data = await fetchUserProfile(id, page, cookie || undefined, sort);
} catch {
return (
<div className="max-w-md mx-auto text-center py-24">
<p className="text-lg font-extrabold mb-1.5" style={{ color: "var(--ink)" }}>
用户不存在
</p>
<p className="meta mb-5">该用户可能已注销或链接有误</p>
<Link href="/" className="btn btn-line btn-sm">
返回首页
</Link>
</div>
);
}
// 板块 id → 名称/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色
const boardNames: Record<number, string> = {};
const boardColors: Record<number, number> = {};
try {
for (const b of (await getBoardsCached()).boards) {
boardNames[b.id] = b.name;
boardColors[b.id] = boardColor(b);
}
} catch {
/* 板块列表失败时回落通用角色徽章 */
}
// 判断是否本人(决定能否看到 settings / security tab)
const isOwner = me.user?.id === data.user.id;
const currentUser = me.user;
let effectiveTab: UserTab =
(tab === "settings" || tab === "security") && !isOwner ? "posts" : tab;
if (!canReadComments && effectiveTab === "comments") effectiveTab = "posts";
// 评论 tab:SSR 直出对应分页
let commentData: UserCommentsResponse | null = null;
let commentError = false;
if (canReadComments && effectiveTab === "comments") {
try {
commentData = await fetchUserComments(id, page, cookie || undefined);
} catch {
commentError = true;
}
}
// 收藏 tab:SSR 直出对应分页(公开可见)
let favoriteData: UserFavoritesResponse | null = null;
let favoriteError = false;
if (effectiveTab === "favorites") {
try {
favoriteData = await fetchUserFavorites(id, page);
} catch {
favoriteError = true;
}
}
// 关注 tab:SSR 直出对应分页(公开可见)
let followingData: UserFollowingResponse | null = null;
let followingError = false;
if (effectiveTab === "following") {
try {
followingData = await fetchUserFollowing(id, page);
} catch {
followingError = true;
}
}
// 安全设置:仍有效的登录会话(仅本人)
// SSR 直连后端时必须转发 refresh cookie + 浏览器 UA/IP,才能标「当前在线」并避免把 Node fetch 当成一台设备
let loginDevices: LoginDevice[] = [];
if (effectiveTab === "security" && isOwner) {
try {
const hdrs = await headers();
const sessionCookie = sessionCookieHeader(await cookies());
loginDevices = await fetchLoginDevices(sessionCookie || cookie || undefined, {
userAgent: hdrs.get("user-agent") || undefined,
clientIP: hdrs.get("x-forwarded-for")?.split(",")[0]?.trim()
|| hdrs.get("x-real-ip")
|| undefined,
});
} catch {
loginDevices = [];
}
}
const u = data.user;
const joinDate = new Date(u.created_at).toLocaleDateString("zh-CN");
const showPoints = data.stats.points > 0 || isOwner;
const hasHeroActions = isOwner || !!currentUser;
// 导航项(桌面左栏 / 移动 chips 共用定义):「个人主页」+「设置」两组,与参考图一致
const mainNavItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode }[] = [
{ key: "posts", label: "发帖", count: data.stats.post_count, icon: <FileText size={15} /> },
...(canReadComments
? [{ key: "comments" as const, label: "回帖", count: data.stats.comment_count, icon: <MessageCircle size={15} /> }]
: []),
{ key: "favorites", label: "收藏", count: data.stats.favorite_count, icon: <Bookmark size={15} /> },
{ key: "following", label: "关注", count: data.stats.following_count, icon: <UserPlus size={15} /> },
];
const settingsNavItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode }[] = isOwner
? [
{ key: "settings", label: "资料设置", icon: <Settings size={15} /> },
{ key: "security", label: "安全设置", icon: <KeyRound size={15} /> },
]
: [];
const visibleNav = [...mainNavItems, ...settingsNavItems];
const tabHref = (t: UserTab) =>
t === "posts" ? userPath(urlStyle, Number(id)) : `${userPath(urlStyle, Number(id))}?tab=${t}`;
const profileQuery: Record<string, string | number> = {};
if (canReadComments && effectiveTab === "comments") profileQuery.tab = "comments";
if (effectiveTab === "favorites") profileQuery.tab = "favorites";
if (effectiveTab === "following") profileQuery.tab = "following";
if (effectiveTab === "posts" && sort !== "new") profileQuery.sort = sort;
if (page > 1) profileQuery.page = page;
const profileLd =
effectiveTab === "settings" || effectiveTab === "security"
? null
: profileJsonLd({
id: u.id,
name: u.nickname || u.username,
username: u.username,
signature: (u.signature || "").trim() || undefined,
pageUrl: absoluteCanonical(userPath(urlStyle, u.id), profileQuery),
urlStyle,
});
/** 本页卡片统一阴影;暗色随 color-mix 自动加深 */
const cardShadow = "0 1px 3px color-mix(in srgb, #000 18%, transparent)";
return (
<div>
<JsonLd data={profileLd} />
{/* ═══ Hero:头像 + 昵称 + 简介 + 行内统计,约 120px 高 ═══ */}
<section
className="panel relative mb-5 transition-[background-color,box-shadow] duration-150"
style={{ boxShadow: cardShadow }}
>
{/* 装饰层单独裁圆角,避免 overflow-hidden 裁掉管理下拉菜单 */}
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<span
className="absolute -top-28 -right-24 w-[440px] h-[440px] rounded-full"
style={{ background: "radial-gradient(circle, var(--accent-soft) 0%, transparent 68%)" }}
/>
<span
className="absolute -top-[150px] -right-[52px] w-[390px] h-[390px] rounded-full border"
style={{ borderColor: ACCENT_RING_1 }}
/>
<span
className="absolute -top-[96px] -right-[8px] w-[290px] h-[290px] rounded-full border"
style={{ borderColor: ACCENT_RING_2 }}
/>
</div>
{/* 本人操作按钮 / 访客私聊:绝对定位在卡片右上角 */}
{isOwner ? (
<div className="absolute top-3 right-3 z-20 flex items-center gap-2">
<Link href={`${userPath(urlStyle, Number(id))}?tab=settings`} className="btn btn-line btn-sm">
<PenLine size={13.5} />
<span className="hidden sm:inline">编辑资料</span>
</Link>
<Link
href={`${userPath(urlStyle, Number(id))}?tab=security`}
aria-label="安全设置"
data-tip="安全设置"
className="inline-flex w-8 h-8 items-center justify-center rounded-full border transition-colors duration-150 hover:border-[var(--accent)] hover:text-[var(--accent)]"
style={{ background: "var(--panel)", borderColor: "var(--line-2)", color: "var(--ink-2)" }}
>
<Shield size={14} />
</Link>
</div>
) : currentUser ? (
<div className="absolute top-3 right-3 z-20 flex items-center gap-2">
<FollowButton userId={u.id} following={u.is_following === true} />
<StaffUserMenu
target={{
id: u.id,
username: u.username,
nickname: u.nickname,
role: u.role,
board_ids: u.board_ids,
banned: u.banned,
avatar: u.avatar,
created_at: u.created_at,
points: data.stats.points,
}}
/>
{allowMessages ? <StartDMButton userId={data.user.id} /> : null}
</div>
) : null}
{/* 身份 + 行内统计:垂直居中,高度约 120px */}
<div
className={`relative z-10 flex min-h-[120px] items-center gap-3.5 px-5 sm:px-6 py-3.5 ${
hasHeroActions ? "pr-24 sm:pr-32" : ""
}`}
>
<ProfileHeroAvatar
name={u.nickname || u.username}
src={u.avatar || undefined}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1
className="text-[18px] sm:text-[20px] font-extrabold tracking-tight leading-tight truncate"
style={{ color: "var(--ink)" }}
>
{u.nickname || u.username}
</h1>
<LevelBadge level={u.level} />
{u.role !== "user" && (
<RoleBadge
role={u.role}
boardIds={u.board_ids}
boardNames={boardNames}
boardColors={boardColors}
/>
)}
{u.banned === true && (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
已封禁
</span>
)}
</div>
{/* 徽章墙:管理员颁布的全部徽章,悬浮显示授予时间 */}
{(u.badges ?? []).length > 0 && (
<div className="mt-1.5 flex items-center gap-1.5 flex-wrap">
{(u.badges ?? []).map((ub) => (
<BadgeChip
key={ub.id}
badge={ub.badge}
tip={`${ub.badge.name} · ${new Date(ub.awarded_at).toLocaleDateString("zh-CN")} 颁布`}
/>
))}
</div>
)}
<p className="mt-1.5 text-[14px] leading-snug flex flex-wrap items-center" style={{ color: "var(--ink-2)" }}>
<span>
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>{data.stats.post_count}</span>
{" "}帖子
</span>
{canReadComments && (
<>
<span aria-hidden className="mx-1.5">·</span>
<span>
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>{data.stats.comment_count}</span>
{" "}评论
</span>
</>
)}
<span aria-hidden className="mx-1.5">·</span>
<span>
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>{data.stats.points}</span>
{" "}积分
</span>
{data.stats.streak > 0 && (
<>
<span aria-hidden className="mx-1.5">·</span>
<span className="inline-flex items-center gap-0.5 font-semibold" style={{ color: "var(--gold)" }}>
<Flame size={13} />
{data.stats.streak}天连签
</span>
</>
)}
</p>
{u.signature && (
<p
className="mt-1 text-[13px] leading-snug line-clamp-1"
style={{ color: "var(--ink-2)" }}
>
{u.signature}
</p>
)}
</div>
</div>
</section>
{/* 移动端 Tab chips(md 以下,约 768px) */}
<div className="md:hidden -mx-4 sm:-mx-6 px-4 sm:px-6 mb-5">
<div className="j13-chips">
{visibleNav.map((t) => (
<Link
key={t.key}
href={tabHref(t.key)}
className="j13-chip"
aria-current={effectiveTab === t.key ? "page" : undefined}
>
{t.icon}
{t.label}
{t.count !== undefined && (
<span className="tabular-nums opacity-70">{t.count}</span>
)}
</Link>
))}
</div>
</div>
{/* ═══ 三栏:左 240 / 中自适应 / 右 300;md 以下侧栏折叠 ═══ */}
<div className="grid grid-cols-1 items-start md:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
{/* 左栏:Tab 导航 */}
<aside className="hidden md:flex w-[240px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4">
<section
className="j13-module !p-2 transition-[background-color,box-shadow] duration-150"
style={{ boxShadow: cardShadow }}
>
<p
className="px-2.5 pt-1.5 pb-2 text-[11.5px] font-bold tracking-[0.08em] uppercase"
style={{ color: "var(--ink-3)" }}
>
个人主页
</p>
<div className="space-y-0.5">
{mainNavItems.map((t) => (
<Link
key={t.key}
href={tabHref(t.key)}
className="j13-raillink"
aria-current={effectiveTab === t.key ? "page" : undefined}
>
{t.icon}
<span className="flex-1 truncate">{t.label}</span>
{t.count !== undefined && (
<span className="text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{t.count}
</span>
)}
</Link>
))}
</div>
{settingsNavItems.length > 0 && (
<>
<p
className="px-2.5 pt-3 pb-2 text-[11.5px] font-bold tracking-[0.08em] uppercase"
style={{ color: "var(--ink-3)" }}
>
设置
</p>
<div className="space-y-0.5">
{settingsNavItems.map((t) => (
<Link
key={t.key}
href={tabHref(t.key)}
className="j13-raillink"
aria-current={effectiveTab === t.key ? "page" : undefined}
>
{t.icon}
<span className="flex-1 truncate">{t.label}</span>
</Link>
))}
</div>
</>
)}
</section>
</aside>
{/* 中栏:Tab 内容 */}
<div className="min-w-0">
{effectiveTab === "posts" ||
effectiveTab === "comments" ||
effectiveTab === "favorites" ||
effectiveTab === "following" ? (
<UserProfileContent
userId={id}
profile={data}
tab={effectiveTab}
commentData={commentData}
commentError={commentError}
favoriteData={favoriteData}
favoriteError={favoriteError}
followingData={followingData}
followingError={followingError}
page={page}
sort={sort}
isOwner={isOwner}
allowComments={allowComments}
/>
) : effectiveTab === "settings" && isOwner && currentUser ? (
<div>
<div className="mb-4">
<h2 className="text-[18px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
编辑个人资料
</h2>
<p className="meta mt-1 text-[13px]">昵称、头像和签名会公开展示,邮箱仅你自己可见</p>
</div>
<ProfileForm user={currentUser} />
</div>
) : effectiveTab === "security" && isOwner ? (
<div>
<div className="mb-4">
<h2 className="text-[18px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
修改密码
</h2>
<p className="meta mt-1 text-[13px]">修改成功后将在所有设备登出,需要使用新密码重新登录</p>
</div>
<PasswordForm />
<LoginDevices devices={loginDevices} />
</div>
) : null}
</div>
{/* 右栏:账号信息 + 积分 + 安全提示 */}
<aside className="hidden md:flex w-[300px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden">
<section
className="j13-module !p-4 transition-[background-color,box-shadow] duration-150 hover:bg-[color-mix(in_srgb,var(--ink)_2%,var(--panel))]"
style={{ boxShadow: cardShadow }}
>
<p className="j13-module-title mb-2.5">
<Hash size={15} style={{ color: "var(--accent)" }} /> 账号信息
</p>
<div className="flex flex-col gap-2.5">
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] leading-none shrink-0" style={{ color: "var(--ink-3)" }}>用户名</p>
<p className="text-[13px] font-semibold truncate text-right" style={{ color: "var(--ink)" }}>
{u.username}
</p>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] leading-none flex items-center gap-1 shrink-0" style={{ color: "var(--ink-3)" }}>
<Calendar size={11} /> 注册时间
</p>
<p className="text-[13px] font-semibold tabular-nums text-right" style={{ color: "var(--ink)" }}>
{joinDate}
</p>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] leading-none flex items-center gap-1 shrink-0" style={{ color: "var(--ink-3)" }}>
<Shield size={11} /> 角色
</p>
<p className="text-[13px] font-semibold text-right" style={{ color: "var(--ink)" }}>
{roleMeta(u.role).label}
</p>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] leading-none shrink-0" style={{ color: "var(--ink-3)" }}>用户 ID</p>
<p className="text-[13px] font-semibold tabular-nums text-right" style={{ color: "var(--ink)" }}>
#{u.id}
</p>
</div>
{u.email && (
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] leading-none flex items-center gap-1 shrink-0" style={{ color: "var(--ink-3)" }}>
<Mail size={11} /> 邮箱
</p>
<p className="text-[13px] font-semibold truncate text-right" style={{ color: "var(--ink)" }}>
{u.email}
</p>
</div>
)}
</div>
</section>
{showPoints && (
<section
className="j13-module !p-4 transition-[background-color,box-shadow] duration-150"
style={{
boxShadow: cardShadow,
background: "color-mix(in srgb, var(--accent) 7%, var(--panel))",
}}
>
<div className="flex items-center justify-between gap-2">
<p className="j13-module-title">
<Flame size={15} style={{ color: "var(--clay)" }} /> 积分
</p>
{data.stats.streak > 0 && (
<span
className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[11px] font-semibold tabular-nums"
style={{
background: "color-mix(in srgb, var(--gold) 16%, var(--panel))",
color: "var(--gold)",
}}
>
<Flame size={11} />
{data.stats.streak}天连签
</span>
)}
</div>
<p
className="mt-2.5 text-[28px] font-bold tabular-nums leading-none"
style={{ color: "var(--accent)" }}
>
{data.stats.points}
</p>
<p className="meta text-[11px] mt-1.5">可用余额</p>
{isOwner && (
<Link
href="/points"
className="inline-flex mt-3 text-[13px] font-semibold transition-colors duration-150 hover:text-[var(--accent-hi)]"
style={{ color: "var(--accent)" }}
>
查看流水 →
</Link>
)}
</section>
)}
{isOwner && (effectiveTab === "settings" || effectiveTab === "security") && (
<section
className="j13-module !p-4 transition-[background-color,box-shadow] duration-150 hover:bg-[color-mix(in_srgb,var(--ink)_2%,var(--panel))]"
style={{ boxShadow: cardShadow }}
>
<p className="j13-module-title mb-3">
<Info size={15} style={{ color: "var(--accent)" }} /> 安全提示
</p>
<ul className="space-y-2.5 text-[12.5px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
<li className="flex gap-2">
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
<span>修改密码后会立即在所有设备登出,需要重新登录。</span>
</li>
<li className="flex gap-2">
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
<span>
{allowComments
? "昵称和头像会公开展示在帖子、评论和个人主页。"
: "昵称和头像会公开展示在帖子和个人主页。"}
</span>
</li>
<li className="flex gap-2">
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
<span>邮箱仅你自己可见,用于安全通知。</span>
</li>
{effectiveTab === "security" && (
<li className="flex gap-2">
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
<span>「当前在线」是你正在看的这台设备;其它仍有效的登录标「在线」,可剔除后需重新输入密码。</span>
</li>
)}
</ul>
</section>
)}
</aside>
</div>
</div>
);
}