Files
jiang13-bbs/frontend/app/u/[id]/page.tsx

445 lines
18 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 } 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, type UserProfile, type UserCommentsResponse } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import UserProfileContent from "./UserProfileContent";
import ProfileForm from "./ProfileForm";
import PasswordForm from "./PasswordForm";
import ProfileHeroAvatar from "./ProfileHeroAvatar";
import RoleBadge from "@/components/RoleBadge";
import { roleMeta } from "@/lib/roles";
import StartDMButton from "./StartDMButton";
import StaffUserMenu from "@/components/StaffUserMenu";
interface UserPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string; tab?: string }>;
}
export type UserTab = "posts" | "comments" | "settings" | "security";
export async function generateMetadata({ params }: UserPageProps): Promise<Metadata> {
const { id } = await params;
try {
const data = await fetchUserProfile(id, 1);
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
return {
title: data.user.nickname || data.user.username,
description: allowComments
? `查看 ${data.user.nickname} 的资料、发帖与评论`
: `查看 ${data.user.nickname} 的资料与发帖`,
};
} 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;
}
}
const u = data.user;
const joinDate = new Date(u.created_at).toLocaleDateString("zh-CN");
const showPoints = data.stats.points > 0 || isOwner;
// 导航项(桌面左栏 / 移动 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 heroStats = [
{ label: "发帖", value: data.stats.post_count },
...(allowComments ? [{ label: "评论", value: data.stats.comment_count }] : []),
{ label: "积分", value: data.stats.points, foot: data.stats.streak > 0 ? `连签 ${data.stats.streak} 天` : undefined },
];
return (
<div>
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<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:石墨面板 + 淡靛几何装饰 + 身份信息 + 统计条 ═══ */}
<section className="panel relative mb-6">
{/* 装饰层单独裁圆角,避免 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 sm:top-4 sm:right-4 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 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 sm:top-4 sm:right-4 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,
}}
/>
{allowMessages ? <StartDMButton userId={data.user.id} /> : null}
</div>
) : null}
{/* 身份信息:纯文档流,头像 + 右侧文字 baseline 对齐 */}
<div className="relative z-10 px-5 sm:px-7 pt-5 sm:pt-6">
<div className="flex items-center gap-4">
<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-[19px] sm:text-[22px] 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="meta mt-1 flex items-center gap-1.5 text-[12.5px] flex-wrap">
<span>@{u.username}</span>
<span aria-hidden>·</span>
<Calendar size={12} />
<span>{joinDate} 加入</span>
</p>
</div>
</div>
{u.signature && (
<p
className="mt-3.5 text-[13.5px] leading-relaxed"
style={{ color: "var(--ink-2)" }}
>
{u.signature}
</p>
)}
</div>
{/* 统计条 */}
<div
className={`relative z-10 grid mt-4 border-t ${heroStats.length === 3 ? "grid-cols-3" : "grid-cols-2"}`}
style={{ borderColor: "var(--line)" }}
>
{heroStats.map((s, i) => (
<div
key={s.label}
className="px-5 sm:px-7 py-3.5"
style={i > 0 ? { borderLeft: "1px solid var(--line)" } : undefined}
>
<p
className="text-[19px] sm:text-[21px] font-extrabold leading-none tabular-nums"
style={{ color: "var(--ink)" }}
>
{s.value}
</p>
<p className="mt-1.5 text-[11.5px] flex items-center gap-1" style={{ color: "var(--ink-3)" }}>
{s.label}
{s.foot && (
<span className="hidden sm:inline-flex items-center gap-0.5 font-semibold" style={{ color: "var(--gold)" }}>
<Flame size={10.5} /> {s.foot}
</span>
)}
</p>
</div>
))}
</div>
</section>
{/* 移动端 Tab chips(lg 以下) */}
<div className="lg: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>
{/* ═══ 三栏:左导航 / 中内容 / 右信息 ═══ */}
<div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
{/* 左栏:Tab 导航 */}
<aside className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-24 flex-col gap-4">
<section className="j13-module !p-2">
<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 />
</div>
) : null}
</div>
{/* 右栏:账号信息 + 积分 + 安全提示 */}
<aside className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden">
<section className="j13-module">
<p className="j13-module-title mb-3">
<Hash size={15} style={{ color: "var(--accent)" }} /> 账号信息
</p>
<div className="space-y-3">
<div>
<p className="j13-pulse-lab">用户名</p>
<p className="text-[13px] font-semibold mt-0.5" style={{ color: "var(--ink)" }}>
{u.username}
</p>
</div>
<div className="pt-3 border-t" style={{ borderColor: "var(--line)" }}>
<p className="j13-pulse-lab">
<Calendar size={11.5} /> 注册时间
</p>
<p className="text-[13px] font-semibold mt-0.5 tabular-nums" style={{ color: "var(--ink)" }}>
{joinDate}
</p>
</div>
<div className="pt-3 border-t" style={{ borderColor: "var(--line)" }}>
<p className="j13-pulse-lab">
<Shield size={11.5} /> 角色
</p>
<p className="text-[13px] font-semibold mt-0.5" style={{ color: "var(--ink)" }}>
{roleMeta(u.role).label}
</p>
</div>
<div className="pt-3 border-t" style={{ borderColor: "var(--line)" }}>
<p className="j13-pulse-lab">用户 ID</p>
<p className="text-[13px] font-semibold mt-0.5 tabular-nums" style={{ color: "var(--ink)" }}>
#{u.id}
</p>
</div>
</div>
</section>
{showPoints && (
<section className="j13-module">
<div className="flex items-center justify-between">
<p className="j13-module-title">
<Flame size={15} style={{ color: "var(--clay)" }} /> 积分
</p>
{data.stats.streak > 0 && (
<span
className="inline-flex items-center gap-1 text-[11.5px] font-bold tabular-nums"
style={{ color: "var(--gold)" }}
>
<Flame size={11.5} /> {data.stats.streak} 天连签
</span>
)}
</div>
<p
className="mt-2 text-[26px] font-extrabold 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-[12.5px] font-semibold hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink-2)" }}
>
查看流水 →
</Link>
)}
</section>
)}
{isOwner && (effectiveTab === "settings" || effectiveTab === "security") && (
<section className="j13-module">
<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>
</ul>
</section>
)}
</aside>
</div>
</div>
);
}