feat: 完成头像上传、站点主题配置与UI优化

- 新增附件模型与上传服务,拆分头像上传/管理逻辑
- 重构用户资料接口,移除直接修改头像字段,改用专用上传接口
- 添加站点公开设置API与管理员外观配置页
- 全局替换hover背景为accent-soft,优化交互一致性
- 完善头像组件,支持图片加载失败回退与src更新重置
- 新增用户状态事件总线,解决同页头像更新不及时问题
- 调整依赖包顺序与CI依赖版本
- 重构板块列表接口,新增帖子计数
- 迁移账号设置到用户主页tab,合并重复代码
This commit is contained in:
2026-09-15 01:06:12 +08:00
parent f58b2af993
commit 24c6dea38c
48 changed files with 2513 additions and 432 deletions

View File

@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import { apiChangePassword } from "@/lib/api";
/**
* 修改密码表单:提交成功后后端已递增 TokenVersion 并清除全部登录 cookie,
* 刷新 layout 使 Header 回到游客态,随后跳转登录页重新登录。
*/
export default function PasswordForm() {
const router = useRouter();
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (newPassword.length < 6) {
setError("新密码至少 6 位");
return;
}
if (newPassword !== confirmPassword) {
setError("两次输入的新密码不一致");
return;
}
setSubmitting(true);
try {
const res = await apiChangePassword(oldPassword, newPassword);
if (res.message) {
setSuccess(true);
router.refresh();
setTimeout(() => router.push("/login"), 1500);
} else {
setError(res.error || "修改失败");
}
} catch {
setError("网络错误,请重试");
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="panel p-5 sm:p-6 space-y-4">
{success && (
<div className="alert-ok flex items-center gap-2" role="status">
<Check size={15} /> 密码修改成功,正在跳转登录页...
</div>
)}
{error && <div className="alert-error" role="alert">{error}</div>}
<div>
<label className="field-label" htmlFor="pwd-old">当前密码</label>
<input
id="pwd-old"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
required
autoComplete="current-password"
className="field"
/>
</div>
<div>
<label className="field-label" htmlFor="pwd-new">新密码(至少 6 位)</label>
<input
id="pwd-new"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
className="field"
/>
</div>
<div>
<label className="field-label" htmlFor="pwd-confirm">确认新密码</label>
<input
id="pwd-confirm"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
className="field"
/>
</div>
<div className="flex justify-end pt-1 border-t" style={{ borderColor: "var(--line)" }}>
<button
type="submit"
disabled={submitting || success}
className="btn btn-primary w-full sm:w-auto"
>
{submitting ? "提交中..." : "修改密码"}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import { apiUpdateProfile, type User } from "@/lib/api";
import AvatarPicker from "@/components/AvatarPicker";
interface ProfileFormProps {
user: User;
}
/**
* 个人资料编辑表单:头像(上传/裁剪/历史选择)、昵称、邮箱、签名。
* 头像由独立上传链路处理,不随本表单提交;保存成功后 router.refresh()
* 让 Header 等 SSR 区域同步最新昵称。
*/
export default function ProfileForm({ user }: ProfileFormProps) {
const router = useRouter();
const [nickname, setNickname] = useState(user.nickname || "");
const [email, setEmail] = useState(user.email || "");
const [signature, setSignature] = useState(user.signature || "");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setSuccess("");
const trimmed = nickname.trim();
if (!trimmed) {
setError("昵称不能为空");
return;
}
if (trimmed.length > 64) {
setError("昵称不能超过 64 个字符");
return;
}
if (signature.length > 255) {
setError("签名不能超过 255 个字符");
return;
}
setSubmitting(true);
try {
const res = await apiUpdateProfile({
nickname: trimmed,
email: email.trim(),
signature,
});
if (res.user) {
setSuccess("资料已保存");
router.refresh();
} else {
setError(res.error || "保存失败");
}
} catch {
setError("网络错误,请重试");
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="panel p-5 sm:p-7 space-y-5">
{success && (
<div className="alert-ok flex items-center gap-2" role="status">
<Check size={15} /> {success}
</div>
)}
{error && <div className="alert-error" role="alert">{error}</div>}
{/* 头像:点击上传/裁剪,或从历史图片中选择 */}
<div
className="flex items-center gap-4 p-4 rounded-xl"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<AvatarPicker
name={nickname || user.username}
src={user.avatar || ""}
size={56}
/>
<div className="flex-1 min-w-0">
<p className="text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>头像</p>
<p className="text-[12px] mt-1" style={{ color: "var(--ink-3)" }}>
点击头像上传新图片,支持拖动裁剪;统一转为 WebP,裁剪后不超过 2MB
</p>
</div>
</div>
{/* 昵称 + 邮箱:桌面双列高密度排布 */}
<div className="grid sm:grid-cols-2 gap-5">
<div>
<label className="field-label" htmlFor="pf-nickname">昵称</label>
<input
id="pf-nickname"
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
required
maxLength={64}
className="field"
placeholder="请输入昵称"
/>
</div>
<div>
<label className="field-label" htmlFor="pf-email">邮箱</label>
<input
id="pf-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
maxLength={128}
className="field"
placeholder="your@email.com"
/>
</div>
</div>
{/* 个性签名 */}
<div>
<label className="field-label" htmlFor="pf-signature">
个性签名
<span className="font-normal ml-1.5" style={{ color: "var(--ink-3)" }}>
({signature.length}/255)
</span>
</label>
<textarea
id="pf-signature"
value={signature}
onChange={(e) => setSignature(e.target.value)}
maxLength={255}
rows={3}
className="field"
placeholder="介绍一下自己吧"
/>
</div>
{/* 操作区:桌面右对齐 */}
<div className="flex justify-end pt-1 border-t" style={{ borderColor: "var(--line)" }}>
<button
type="submit"
disabled={submitting}
className="btn btn-primary w-full sm:w-auto"
>
{submitting ? "保存中..." : "保存资料"}
</button>
</div>
</form>
);
}

View File

@@ -7,6 +7,7 @@ import type { UserProfile, UserCommentsResponse } from "@/lib/api";
/**
* 用户主页内容区(纯服务端渲染):
* tab 与分页全部走 URL(?tab=comments&page=N),刷新/后退/分享链接均保持一致。
* Tab 切换栏已移至 page.tsx 统一渲染(含 settings / security),此组件仅负责 posts / comments 列表。
*/
export default function UserProfileContent({
userId,
@@ -23,47 +24,8 @@ export default function UserProfileContent({
commentError: boolean;
page: number;
}) {
const tabLink = (t: "posts" | "comments", label: string, count: number, icon: React.ReactNode) => {
const active = tab === t;
const href = t === "posts" ? `/u/${userId}` : `/u/${userId}?tab=comments`;
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`inline-flex items-center justify-center gap-1.5 px-4 py-2 rounded-full text-sm font-semibold transition-all flex-1 sm:flex-none ${
active ? "" : "hover:text-[var(--ink)]"
}`}
style={{
background: active ? "var(--panel)" : "transparent",
color: active ? "var(--accent)" : "var(--ink-3)",
boxShadow: active ? "var(--shadow-sm)" : "none",
}}
>
{icon}
{label}
<span
className="text-[11px] font-bold px-1.5 py-px rounded-full tabular-nums"
style={{
background: active ? "var(--accent-soft)" : "var(--tint)",
color: active ? "var(--accent)" : "var(--ink-3)",
}}
>
{count}
</span>
</Link>
);
};
return (
<div>
<div
className="inline-flex w-full sm:w-auto items-center gap-1 mb-6 p-1 rounded-full"
style={{ background: "var(--panel-2)" }}
>
{tabLink("posts", "发帖", profile.stats.post_count, <FileText size={15} />)}
{tabLink("comments", "评论", profile.stats.comment_count, <MessageCircle size={15} />)}
</div>
{tab === "posts" ? (
<div>
{profile.posts.length > 0 ? (
@@ -72,7 +34,7 @@ export default function UserProfileContent({
<Link
key={post.id}
href={`/post/${post.id}`}
className="group block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--panel-2)]"
className="group block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
>
<div className="flex items-center gap-2 mb-2 flex-wrap">
@@ -128,7 +90,7 @@ export default function UserProfileContent({
<Link
key={c.id}
href={`/post/${c.post_id}`}
className="block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--panel-2)]"
className="block px-4 sm:px-5 py-4 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
>
<div className="meta text-xs mb-2">

View File

@@ -1,8 +1,16 @@
import Link from "next/link";
import { cookies } from "next/headers";
import { Metadata } from "next";
import { ChevronRight, FileText, MessageCircle, Flame, AlertCircle } from "lucide-react";
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";
interface UserPageProps {
@@ -10,6 +18,8 @@ interface UserPageProps {
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 {
@@ -23,11 +33,19 @@ export async function generateMetadata({ params }: UserPageProps): Promise<Metad
}
}
// 装饰几何色:全部由主题变量派生,浅色为淡靛、暗色自动提亮
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: "posts" | "comments" = sp.tab === "comments" ? "comments" : "posts";
const rawTab = sp.tab;
const tab: UserTab =
rawTab === "comments" ? "comments" :
rawTab === "settings" ? "settings" :
rawTab === "security" ? "security" : "posts";
let data: UserProfile;
try {
@@ -46,10 +64,18 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
);
}
// 评论 tab:SSR 直出对应分页,URL(?tab=comments&page=N)与内容严格一致
// 判断是否本人(决定能否看到 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 (tab === "comments") {
if (effectiveTab === "comments") {
try {
commentData = await fetchUserComments(id, page);
} catch {
@@ -58,91 +84,324 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
}
const u = data.user;
const stats = [
{ label: "发帖", value: data.stats.post_count, icon: <FileText size={15} /> },
{ label: "评论", value: data.stats.comment_count, icon: <MessageCircle size={15} /> },
];
const isAdmin = u.role === "admin";
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 className="max-w-4xl mx-auto">
<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>
{/* 用户信息卡片 */}
<section className="panel relative overflow-hidden p-6 sm:p-8 mb-6">
<span
aria-hidden
className="absolute -top-20 -right-16 w-60 h-60 rounded-full"
style={{ background: "var(--accent-soft)" }}
/>
<div className="relative flex items-start gap-5 sm:gap-6">
<Avatar
name={u.nickname || u.username}
src={u.avatar || undefined}
size={88}
{/* ═══ 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%)" }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-[24px] sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
{u.nickname || u.username}
</h1>
{u.role === "admin" && (
<span
className="text-[11.5px] font-bold px-2.5 py-1 rounded-full"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
<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>
)}
{/* 身份信息:纯文档流,头像 + 右侧文字 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)" }}
>
管理员
</span>
)}
</div>
<p className="meta mt-1.5 text-[13px]">
@{u.username} · {new Date(u.created_at).toLocaleDateString("zh-CN")} 加入
</p>
{u.signature && (
<p className="mt-3 text-[14px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
{u.signature}
{u.nickname || u.username}
</h1>
{isAdmin && (
<span
className="text-[11px] font-bold px-2 py-0.5 rounded-full"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
管理员
</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 className="flex gap-2.5 mt-4 flex-wrap">
{stats.map((s) => (
<span
key={s.label}
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-full text-[13px] font-semibold"
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
>
<span style={{ color: "var(--accent)" }}>{s.icon}</span>
<span className="tabular-nums">{s.value}</span> {s.label}
</span>
))}
{showPoints && (
<span
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-full text-[13px] font-semibold"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
title={`连续签到 ${data.stats.streak} 天`}
>
<Flame size={15} />
<span className="tabular-nums">{data.stats.points}</span> 积分
{data.stats.streak > 0 && <span className="font-normal opacity-80">· 连签 {data.stats.streak} 天</span>}
</span>
)}
</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>
{/* 发帖 / 评论 切换(URL 驱动,SSR 直出) */}
<UserProfileContent
userId={id}
profile={data}
tab={tab}
commentData={commentData}
commentError={commentError}
page={page}
/>
{/* 移动端 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)" }}>
{isAdmin ? "管理员" : "普通用户"}
</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>
);
}