Files
jiang13-bbs/frontend/app/u/[id]/page.tsx
freefire 40bbad2c82 公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 00:11:24 +08:00

528 lines
22 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 {
ChevronRight, FileText, MessageCircle, Flame, Calendar, Hash, Shield,
Settings, KeyRound, Info, PenLine,
} from "lucide-react";
import {
fetchUserProfile, fetchUserComments, fetchLoginDevices,
type UserProfile, type UserCommentsResponse, type LoginDevice,
} from "@/lib/api";
import { authCookieHeader, sessionCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } 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 { roleMeta } from "@/lib/roles";
import StartDMButton from "./StartDMButton";
import StaffUserMenu from "@/components/StaffUserMenu";
import { requestCanonical } from "@/lib/canonicalMeta";
import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl";
import { profileJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
interface UserPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string; tab?: string }>;
}
export type UserTab = "posts" | "comments" | "settings" | "security";
export async function generateMetadata({ params, searchParams }: UserPageProps): Promise<Metadata> {
const { id } = await params;
const sp = await searchParams;
const rawTab = sp.tab;
const tab: UserTab =
rawTab === "comments" ? "comments" :
rawTab === "settings" ? "settings" :
rawTab === "security" ? "security" : "posts";
try {
const data = await fetchUserProfile(id, 1);
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
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";
const page = canonicalPage(sp.page);
if (page) query.page = page;
return {
title,
description,
...(await requestCanonical(`/u/${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 rawTab = sp.tab;
const tab: UserTab =
rawTab === "comments" ? "comments" :
rawTab === "settings" ? "settings" :
rawTab === "security" ? "security" : "posts";
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
const settings = await getPublicSettingsCached();
const allowComments = settings.allow_comments !== false;
const allowMessages = settings.allow_messages !== false;
let data: UserProfile;
try {
// 带 cookie:超管/站长才能拿到 banned 字段
data = await fetchUserProfile(id, page, cookie || undefined);
} 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>
);
}
// 判断是否本人(决定能否看到 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 (!allowComments && effectiveTab === "comments") effectiveTab = "posts";
// 评论 tab:SSR 直出对应分页
let commentData: UserCommentsResponse | null = null;
let commentError = false;
if (allowComments && effectiveTab === "comments") {
try {
commentData = await fetchUserComments(id, page);
} catch {
commentError = 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 navItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode; ownerOnly?: boolean }[] = [
{ key: "posts", label: "发帖", count: data.stats.post_count, icon: <FileText size={15} /> },
...(allowComments
? [{ key: "comments" as const, label: "评论", count: data.stats.comment_count, icon: <MessageCircle size={15} /> }]
: []),
{ key: "settings", label: "资料设置", icon: <Settings size={15} />, ownerOnly: true },
{ key: "security", label: "安全设置", icon: <KeyRound size={15} />, ownerOnly: true },
];
const visibleNav = navItems.filter((t) => !t.ownerOnly || isOwner);
const tabHref = (t: UserTab) => (t === "posts" ? `/u/${id}` : `/u/${id}?tab=${t}`);
const profileQuery: Record<string, string | number> = {};
if (allowComments && effectiveTab === "comments") profileQuery.tab = "comments";
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(`/u/${u.id}`, profileQuery),
});
/** 本页卡片统一阴影;暗色随 color-mix 自动加深 */
const cardShadow = "0 1px 3px color-mix(in srgb, #000 18%, transparent)";
return (
<div>
<JsonLd data={profileLd} />
<nav className="meta -mt-4 sm:-mt-6 mb-1.5 flex items-center gap-1 text-[12.5px] leading-none">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>{u.nickname || u.username}</span>
</nav>
{/* ═══ 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={`/u/${id}?tab=settings`} className="btn btn-line btn-sm">
<PenLine size={13.5} />
<span className="hidden sm:inline">编辑资料</span>
</Link>
<Link
href={`/u/${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">
<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>
{u.role !== "user" && (
<RoleBadge role={u.role} boardIds={u.board_ids} />
)}
{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>
<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>
{allowComments && (
<>
<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">
{visibleNav.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>
</section>
</aside>
{/* 中栏:Tab 内容 */}
<div className="min-w-0">
{effectiveTab === "posts" || effectiveTab === "comments" ? (
<UserProfileContent
userId={id}
profile={data}
tab={effectiveTab}
commentData={commentData}
commentError={commentError}
page={page}
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">
<div>
<p className="text-[11px] leading-none" style={{ color: "var(--ink-3)" }}>用户名</p>
<p className="text-[13px] font-semibold mt-1" style={{ color: "var(--ink)" }}>
{u.username}
</p>
</div>
<div>
<p className="text-[11px] leading-none flex items-center gap-1" style={{ color: "var(--ink-3)" }}>
<Calendar size={11} /> 注册时间
</p>
<p className="text-[13px] font-semibold mt-1 tabular-nums" style={{ color: "var(--ink)" }}>
{joinDate}
</p>
</div>
<div>
<p className="text-[11px] leading-none flex items-center gap-1" style={{ color: "var(--ink-3)" }}>
<Shield size={11} /> 角色
</p>
<p className="text-[13px] font-semibold mt-1" style={{ color: "var(--ink)" }}>
{roleMeta(u.role).label}
</p>
</div>
<div>
<p className="text-[11px] leading-none" style={{ color: "var(--ink-3)" }}>用户 ID</p>
<p className="text-[13px] font-semibold mt-1 tabular-nums" style={{ color: "var(--ink)" }}>
#{u.id}
</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>
);
}