Files
jiang13-bbs/frontend/app/u/[id]/page.tsx
freefire 42c395a864 feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 22:58:16 +08:00

409 lines
16 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 } from "@/lib/serverData";
import UserProfileContent from "./UserProfileContent";
import ProfileForm from "./ProfileForm";
import PasswordForm from "./PasswordForm";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import { roleMeta } from "@/lib/roles";
import StartDMButton from "./StartDMButton";
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);
return {
title: data.user.nickname || data.user.username,
description: `查看 ${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";
let data: UserProfile;
try {
data = await fetchUserProfile(id, page);
} 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 cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
const isOwner = me.user?.id === data.user.id;
const currentUser = me.user;
const effectiveTab: UserTab =
(tab === "settings" || tab === "security") && !isOwner ? "posts" : tab;
// 评论 tab:SSR 直出对应分页
let commentData: UserCommentsResponse | null = null;
let commentError = false;
if (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;
// 导航项(桌面左栏 / 移动 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} /> },
{ key: "comments", 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 },
{ 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="安全设置"
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">
<StartDMButton userId={data.user.id} />
</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">
<span
className="inline-flex items-center justify-center shrink-0 rounded-full p-0"
style={{
width: 80,
height: 80,
lineHeight: 0,
boxShadow:
"0 0 0 4px var(--panel), 0 0 0 6px color-mix(in srgb, var(--accent) 38%, transparent)",
}}
>
<Avatar
name={u.nickname || u.username}
src={u.avatar || undefined}
size={80}
/>
</span>
<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} />
)}
</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 grid-cols-3 mt-4 border-t" 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}
/>
) : 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>
</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>昵称和头像会公开展示在帖子、评论和个人主页。</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>
);
}