feat: 完成头像上传、站点主题配置与UI优化
- 新增附件模型与上传服务,拆分头像上传/管理逻辑 - 重构用户资料接口,移除直接修改头像字段,改用专用上传接口 - 添加站点公开设置API与管理员外观配置页 - 全局替换hover背景为accent-soft,优化交互一致性 - 完善头像组件,支持图片加载失败回退与src更新重置 - 新增用户状态事件总线,解决同页头像更新不及时问题 - 调整依赖包顺序与CI依赖版本 - 重构板块列表接口,新增帖子计数 - 迁移账号设置到用户主页tab,合并重复代码
This commit is contained in:
@@ -332,7 +332,7 @@ export default function AnnouncementAdmin() {
|
||||
<Link
|
||||
href={`/announcement/${a.id}`}
|
||||
target="_blank"
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
title="查看"
|
||||
aria-label="查看公告"
|
||||
@@ -342,7 +342,7 @@ export default function AnnouncementAdmin() {
|
||||
)}
|
||||
<button
|
||||
onClick={() => startEdit(a)}
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
title="编辑"
|
||||
aria-label="编辑公告"
|
||||
|
||||
288
frontend/app/admin/appearance/AppearanceAdmin.tsx
Normal file
288
frontend/app/admin/appearance/AppearanceAdmin.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Palette,
|
||||
Check,
|
||||
Loader2,
|
||||
Save,
|
||||
Undo2,
|
||||
RotateCcw,
|
||||
ChevronRight,
|
||||
Pipette,
|
||||
} from "lucide-react";
|
||||
import { apiUpdateSiteSettings } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
ACCENT_PRESETS,
|
||||
DEFAULT_ACCENT,
|
||||
applyAccentTheme,
|
||||
deriveAccentPair,
|
||||
findPreset,
|
||||
normalizeHex,
|
||||
} from "@/lib/theme";
|
||||
|
||||
// 后台「外观设置」:站点主题色预设 + 自定义取色 + 实时预览。
|
||||
// 修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。
|
||||
export default function AppearanceAdmin({ initialAccent }: { initialAccent: string }) {
|
||||
const router = useRouter();
|
||||
// 已保存值:空串=内置默认(靛蓝)
|
||||
const [savedHex, setSavedHex] = useState(initialAccent || "");
|
||||
const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savedRef = useRef(savedHex);
|
||||
savedRef.current = savedHex;
|
||||
|
||||
const normalized = useMemo(() => normalizeHex(draft), [draft]);
|
||||
const pair = useMemo(
|
||||
() => (normalized ? deriveAccentPair(normalized) : null),
|
||||
[normalized]
|
||||
);
|
||||
const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]);
|
||||
// 保存提交值:默认靛蓝落库为空串(前端不注入覆盖,保持内置令牌单一来源)
|
||||
const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : "";
|
||||
const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
|
||||
const invalid = draft.trim() !== "" && !normalized;
|
||||
|
||||
// 实时预览:合法颜色即应用;离开页面时还原为已保存值,避免未保存预览残留
|
||||
useEffect(() => {
|
||||
if (normalized) applyAccentTheme(normalized);
|
||||
return () => {
|
||||
applyAccentTheme(savedRef.current || null);
|
||||
};
|
||||
}, [normalized]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!normalized || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings(submitHex);
|
||||
const next = (res.accent || "").toLowerCase();
|
||||
setSavedHex(next);
|
||||
applyAccentTheme(next || null);
|
||||
router.refresh();
|
||||
toast(next ? "主题色已保存并对全站生效" : "已恢复默认主题色");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败", "error");
|
||||
applyAccentTheme(savedRef.current || null);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
setDraft(savedHex || DEFAULT_ACCENT);
|
||||
applyAccentTheme(savedHex || null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* 面包屑 */}
|
||||
<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)" }}>外观设置</span>
|
||||
</nav>
|
||||
|
||||
<header className="panel p-6 sm:p-7 mb-5 flex items-center gap-4">
|
||||
<span
|
||||
className="w-12 h-12 rounded-2xl flex items-center justify-center shrink-0"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Palette size={24} />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-xl font-extrabold" style={{ color: "var(--ink)" }}>外观设置</h1>
|
||||
<p className="meta mt-1 text-[13px]">配置站点主题色,全站按钮、链接、选中态会统一联动</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 预设色板 */}
|
||||
<section className="j13-module p-5 sm:p-6 mb-5">
|
||||
<h2 className="text-[15px] font-extrabold mb-1" style={{ color: "var(--ink)" }}>
|
||||
预设主题色
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mb-4">浅/暗两套色板均已调校,保证文字对比度达标</p>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
|
||||
{ACCENT_PRESETS.map((p) => {
|
||||
const active = normalized === p.light;
|
||||
const isDefault = p.light === DEFAULT_ACCENT;
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => setDraft(p.light)}
|
||||
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
|
||||
style={{
|
||||
borderColor: active ? "var(--accent)" : "var(--line)",
|
||||
background: active ? "var(--accent-soft)" : "var(--panel)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
aria-pressed={active}
|
||||
title={isDefault ? `${p.name}(默认)` : p.name}
|
||||
>
|
||||
<span className="relative flex items-center justify-center">
|
||||
{/* 上半圆=浅色模式,下半圆=暗色模式 */}
|
||||
<span className="w-9 h-9 rounded-full overflow-hidden grid grid-rows-2 border-2 border-[var(--panel)] shadow-sm">
|
||||
<span style={{ background: p.light }} />
|
||||
<span style={{ background: p.dark }} />
|
||||
</span>
|
||||
{active && (
|
||||
<span
|
||||
className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center"
|
||||
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
|
||||
>
|
||||
<Check size={11} strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="text-[12px] font-bold leading-none"
|
||||
style={{ color: active ? "var(--accent)" : "var(--ink-2)" }}
|
||||
>
|
||||
{p.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 自定义颜色 */}
|
||||
<section className="j13-module p-5 sm:p-6 mb-5">
|
||||
<h2 className="text-[15px] font-extrabold mb-1 flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
|
||||
<Pipette size={15} /> 自定义颜色
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mb-4">
|
||||
取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* 原生取色器 */}
|
||||
<label
|
||||
className="relative w-12 h-12 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
|
||||
style={{ borderColor: "var(--line-2)" }}
|
||||
title="打开取色器"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 w-[200%] h-[200%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0 bg-transparent"
|
||||
value={normalized || DEFAULT_ACCENT}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label="自定义主题色取色器"
|
||||
/>
|
||||
</label>
|
||||
{/* HEX 文本输入 */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center rounded-[var(--r-sm)] border bg-[var(--panel)] px-3 h-12 font-mono text-[14px] w-40"
|
||||
style={{ borderColor: invalid ? "var(--danger)" : "var(--line-2)" }}
|
||||
>
|
||||
<span style={{ color: "var(--ink-3)" }}>#</span>
|
||||
<input
|
||||
value={draft.startsWith("#") ? draft.slice(1) : draft}
|
||||
onChange={(e) => setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))}
|
||||
className="flex-1 min-w-0 bg-transparent outline-none ml-1 uppercase"
|
||||
style={{ color: "var(--ink)" }}
|
||||
maxLength={7}
|
||||
spellCheck={false}
|
||||
aria-label="主题色 HEX 值"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{pair && (
|
||||
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-3.5 h-3.5 rounded-full border" style={{ background: pair.light, borderColor: "var(--line)" }} />
|
||||
浅色 {pair.light}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-3.5 h-3.5 rounded-full border" style={{ background: pair.dark, borderColor: "var(--line)" }} />
|
||||
暗色 {pair.dark}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{invalid && (
|
||||
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--danger)" }}>
|
||||
颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)
|
||||
</p>
|
||||
)}
|
||||
{pair?.adjusted && (
|
||||
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
|
||||
该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生 {pair.dark})
|
||||
</p>
|
||||
)}
|
||||
{preset && preset.light === DEFAULT_ACCENT && (
|
||||
<p className="mt-2.5 text-[12.5px] meta">当前为内置默认色,保存时会清除自定义配置</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 实时预览(整页已实时换肤,此处展示典型组件) */}
|
||||
<section className="j13-module p-5 sm:p-6 mb-6">
|
||||
<h2 className="text-[15px] font-extrabold mb-4" style={{ color: "var(--ink)" }}>实时预览</h2>
|
||||
<div className="rounded-[var(--r-sm)] border p-4 space-y-4" style={{ borderColor: "var(--line)", background: "var(--bg)" }}>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<button type="button" className="btn btn-primary btn-sm">
|
||||
<Save size={14} /> 主按钮
|
||||
</button>
|
||||
<button type="button" className="btn btn-line btn-sm">次要按钮</button>
|
||||
<a className="text-[13px] font-bold" style={{ color: "var(--accent)" }} href="#preview" onClick={(e) => e.preventDefault()}>
|
||||
文字链接示例
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="j13-tab" aria-selected="false">未选中</span>
|
||||
<span className="j13-tab" aria-selected="true">选中标签</span>
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-3.5 py-2 text-[13.5px] font-semibold"
|
||||
style={{ background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }}
|
||||
>
|
||||
导航选中态
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-w-xs">
|
||||
<div className="j13-bcard" aria-current="page">
|
||||
<span className="j13-bcard-ico" style={{ background: "var(--accent-soft)", color: "var(--accent)" }}>
|
||||
<Palette size={16} />
|
||||
</span>
|
||||
<span className="j13-bcard-name text-[13px] font-bold flex-1">选中的板块卡片</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">
|
||||
<p className="text-[12.5px] meta hidden sm:block">
|
||||
{dirty ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"}
|
||||
</p>
|
||||
<div className="flex items-center gap-2.5 ml-auto">
|
||||
{dirty && (
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={discard}>
|
||||
<Undo2 size={14} /> 撤销改动
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => {
|
||||
setDraft(DEFAULT_ACCENT);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} /> 恢复默认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSave}
|
||||
disabled={!normalized || saving || !dirty}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存并全站生效
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/app/admin/appearance/page.tsx
Normal file
43
frontend/app/admin/appearance/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { cookies } from "next/headers";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import AppearanceAdmin from "./AppearanceAdmin";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "外观设置",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
// 站点外观(主题色)管理:权限以 Go 端 RequireAdmin 为唯一判定,
|
||||
// 非管理员 SSR 直出无权限提示,写接口仍由后端 403 兜底。
|
||||
export default async function AdminAppearancePage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
|
||||
if (me.user?.role !== "admin") {
|
||||
return (
|
||||
<div className="max-w-md mx-auto panel p-8 text-center mt-10">
|
||||
<span
|
||||
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
|
||||
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
||||
>
|
||||
<ShieldAlert size={26} />
|
||||
</span>
|
||||
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||
需要管理员权限
|
||||
</h1>
|
||||
<p className="meta mt-2 text-[13px]">仅站点管理员可以配置站点外观。</p>
|
||||
<Link href="/" className="btn btn-primary mt-6">
|
||||
返回首页
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 主题色为公开设置:当前已保存值随 SSR 直出,避免面板初始闪动
|
||||
const { accent } = await getPublicSettingsCached();
|
||||
return <AppearanceAdmin initialAccent={accent || ""} />;
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export default async function BoardPage({ params, searchParams }: PageProps) {
|
||||
<Link
|
||||
key={b.id}
|
||||
href={`/board/${b.id}`}
|
||||
className="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-xl transition-colors hover:bg-[var(--panel-2)]"
|
||||
className="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-xl transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
>
|
||||
<span className={`av-${i % 8} w-8 h-8 rounded-xl flex items-center justify-center text-[13px] font-bold shrink-0`}>
|
||||
{b.name.slice(0, 1)}
|
||||
|
||||
108
frontend/app/boards/page.tsx
Normal file
108
frontend/app/boards/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { ChevronRight, MessagesSquare, LayoutGrid } from "lucide-react";
|
||||
import { fetchBoards } from "@/lib/api";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "板块目录 · 姜十三论坛",
|
||||
description: "浏览论坛全部板块,找到感兴趣的话题社区",
|
||||
};
|
||||
|
||||
// 板块汇总列表页:首页左栏「板块目录」标题的落地页,
|
||||
// 展示全部板块(介绍 + 讨论量),点击进入板块独立页
|
||||
export default async function BoardsPage() {
|
||||
const { boards } = await fetchBoards();
|
||||
const totalPosts = boards.reduce((sum, b) => sum + (b.post_count || 0), 0);
|
||||
|
||||
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)" }}>板块目录</span>
|
||||
</nav>
|
||||
|
||||
{/* 页头 */}
|
||||
<div className="panel p-6 sm:p-7 mb-6 relative overflow-hidden">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -top-16 -right-12 w-48 h-48 rounded-full"
|
||||
style={{ background: "var(--accent-soft)" }}
|
||||
/>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<span
|
||||
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center shrink-0"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<LayoutGrid size={28} />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
板块目录
|
||||
</h1>
|
||||
<p className="meta mt-1.5">
|
||||
共 {boards.length} 个板块 · {totalPosts} 篇讨论
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 板块卡片网格 */}
|
||||
{boards.length === 0 ? (
|
||||
<div className="panel text-center py-20 px-6">
|
||||
<span
|
||||
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<MessagesSquare size={26} />
|
||||
</span>
|
||||
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>还没有任何板块</p>
|
||||
<p className="meta mt-1.5">板块由管理员创建,敬请期待</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{boards.map((b, i) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={`/board/${b.id}`}
|
||||
className="j13-module group flex items-start gap-3.5 transition-[border-color,background] duration-150 hover:border-[var(--nav-active-line)] hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span className={`av-${i % 8} j13-bcard-ico !w-11 !h-11 !text-lg`} aria-hidden="true">
|
||||
{b.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-[15px] font-extrabold" style={{ color: "var(--ink)" }}>
|
||||
{b.name}
|
||||
</span>
|
||||
<span className="meta shrink-0 text-[11.5px] tabular-nums">{b.post_count} 篇</span>
|
||||
</span>
|
||||
{b.description?.trim() && (
|
||||
<span
|
||||
className="block mt-1 text-[12.5px] leading-relaxed line-clamp-2"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
{b.description.trim()}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="mt-2.5 inline-flex items-center gap-0.5 text-[12px] font-bold transition-colors group-hover:text-[var(--accent)]"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
进入板块
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="transition-transform group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -217,12 +217,15 @@ h1, h2, h3, h4 {
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-on);
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.12), 0 2px 8px rgba(59, 91, 219, 0.22);
|
||||
/* 阴影色跟随动态主题色(color-mix 引用令牌,换肤自动生效) */
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.12),
|
||||
0 2px 8px color-mix(in srgb, var(--accent) 26%, transparent);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hi);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.1), 0 8px 20px rgba(59, 91, 219, 0.3);
|
||||
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.1),
|
||||
0 8px 20px color-mix(in srgb, var(--accent) 34%, transparent);
|
||||
}
|
||||
.btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
@@ -463,11 +466,12 @@ h1, h2, h3, h4 {
|
||||
color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
/* 当前页:与全站选中态一致的主题色淡化底(而非实心填充) */
|
||||
.pager-btn.is-current,
|
||||
.pager-btn.is-current:hover {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-on);
|
||||
background: var(--nav-active-bg);
|
||||
border-color: var(--nav-active-line);
|
||||
color: var(--nav-active-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -559,8 +563,8 @@ h1, h2, h3, h4 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 首页左栏「板块目录」卡片:与面包屑的纯文字位置指引区分,
|
||||
承载板块介绍与讨论量等发现型信息,点击在工作台内筛选帖子流 */
|
||||
/* 首页左栏「全部讨论 / 板块目录」卡片:与面包屑的纯文字位置指引区分,
|
||||
承载讨论量等发现型信息,点击在工作台内筛选帖子流 */
|
||||
.j13-bcard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -580,10 +584,19 @@ h1, h2, h3, h4 {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.j13-bcard[aria-current="page"] {
|
||||
border-color: var(--accent);
|
||||
/* 选中态:全站统一使用主题色淡化底(--nav-active-bg,随站点主题色联动),
|
||||
与 raillink / chip / tab / 顶部导航 pill 的选中表达保持一致 */
|
||||
.j13-bcard[aria-current="page"],
|
||||
.j13-bcard[aria-current="page"]:hover {
|
||||
border-color: var(--nav-active-line);
|
||||
background: var(--nav-active-bg);
|
||||
}
|
||||
.j13-bcard-name {
|
||||
color: var(--ink);
|
||||
}
|
||||
.j13-bcard[aria-current="page"] .j13-bcard-name {
|
||||
color: var(--nav-active-ink);
|
||||
}
|
||||
.j13-bcard-ico {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
@@ -595,16 +608,6 @@ h1, h2, h3, h4 {
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.j13-bcard-desc {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.4;
|
||||
color: var(--ink-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.j13-bcard {
|
||||
transition: none;
|
||||
@@ -762,8 +765,10 @@ h1, h2, h3, h4 {
|
||||
border-radius: 10px;
|
||||
transition: background 0.16s ease;
|
||||
}
|
||||
.j13-rank:hover {
|
||||
background: var(--panel-2);
|
||||
/* 榜单行:无选中态的导航项,悬停/聚焦统一使用主题色淡化底 */
|
||||
.j13-rank:hover,
|
||||
.j13-rank:focus-visible {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.j13-rank .j13-rank-no {
|
||||
width: 18px;
|
||||
|
||||
@@ -6,8 +6,9 @@ import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import Toaster from "@/components/Toaster";
|
||||
import type { User } from "@/lib/api";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
|
||||
import { accentStyleCSS } from "@/lib/theme";
|
||||
|
||||
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
||||
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
|
||||
@@ -58,6 +59,11 @@ export default async function RootLayout({
|
||||
// 旧版 refresh cookie 可能是 Path=/api/auth(页面请求不带),因此只要
|
||||
// token 或 refresh 任一存在就向后端确认一次,避免半登录态(列表点赞态与
|
||||
// Header 游客态不一致),并兼容 access 15 分钟内的自然窗口。
|
||||
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
|
||||
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
|
||||
const { accent } = await getPublicSettingsCached();
|
||||
const accentCSS = accentStyleCSS(accent || "");
|
||||
|
||||
let user: User | null = null;
|
||||
let unread = 0;
|
||||
const hasAuthCookie =
|
||||
@@ -75,6 +81,14 @@ export default async function RootLayout({
|
||||
className={`${theme === "dark" ? "dark" : ""} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`}
|
||||
>
|
||||
<body className="min-h-screen flex flex-col">
|
||||
{accentCSS ? (
|
||||
// id 与 lib/theme 的运行时换肤共用:后台实时预览时直接复用/替换此节点
|
||||
<style
|
||||
id="j13-accent-vars"
|
||||
data-accent-source="ssr"
|
||||
dangerouslySetInnerHTML={{ __html: accentCSS }}
|
||||
/>
|
||||
) : null}
|
||||
<a href="#main" className="skip-link">
|
||||
跳到主要内容
|
||||
</a>
|
||||
|
||||
@@ -69,10 +69,11 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => handleClick(n)}
|
||||
className="w-full text-left px-4 sm:px-5 py-4 flex items-start gap-3.5 transition-colors hover:bg-[var(--panel-2)]"
|
||||
className={`w-full text-left px-4 sm:px-5 py-4 flex items-start gap-3.5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] ${
|
||||
n.is_read ? "" : "bg-[var(--panel-2)]"
|
||||
}`}
|
||||
style={{
|
||||
borderTop: i === 0 ? "none" : "1px solid var(--line)",
|
||||
background: n.is_read ? "transparent" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -147,7 +147,7 @@ function AuthorRailCard({ profile }: { profile: UserProfile }) {
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}>
|
||||
<Avatar name={u.nickname || u.username} size={48} />
|
||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
@@ -198,7 +198,7 @@ function AuthorPosts({ posts }: { posts: PostListItem[] }) {
|
||||
<Link
|
||||
href={`/post/${p.id}`}
|
||||
title={p.title}
|
||||
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--panel-2)]"
|
||||
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
>
|
||||
<span
|
||||
className="block text-[13px] font-semibold leading-snug line-clamp-2"
|
||||
@@ -284,7 +284,7 @@ function AuthorCardStacked({ profile }: { profile: UserProfile }) {
|
||||
</p>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Link href={`/u/${profile.user.id}`} className="shrink-0" aria-label={profile.user.nickname}>
|
||||
<Avatar name={profile.user.nickname || profile.user.username} size={52} />
|
||||
<Avatar name={profile.user.nickname || profile.user.username} src={profile.user.avatar || undefined} size={52} />
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -424,7 +424,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Link href={`/u/${post.user.id}`} className="shrink-0" aria-label={post.user.nickname}>
|
||||
<Avatar name={post.user.nickname || post.user.username} size={40} />
|
||||
<Avatar name={post.user.nickname || post.user.username} src={post.user.avatar || undefined} size={40} />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/u/${post.user.id}`}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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);
|
||||
// 后端已使全部 token 失效并清 cookie,刷新 layout 让 Header 回到游客态
|
||||
router.refresh();
|
||||
setTimeout(() => router.push("/login"), 1500);
|
||||
} else {
|
||||
setError(res.error || "修改失败");
|
||||
}
|
||||
} catch {
|
||||
setError("网络错误,请重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="font-bold text-[15px] mb-3.5" style={{ color: "var(--ink)" }}>修改密码</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="panel p-5 sm:p-6 space-y-4">
|
||||
{success && <div className="alert-ok" role="status">密码修改成功,正在跳转登录页...</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>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || success}
|
||||
className="btn btn-primary w-full justify-center mt-2"
|
||||
>
|
||||
{submitting ? "提交中..." : "修改密码"}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +1,15 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { cookies } from "next/headers";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import ProfileForm from "./ProfileForm";
|
||||
import PasswordForm from "./PasswordForm";
|
||||
|
||||
// SSR 直出账号信息:未登录直接跳登录页,登录后 F5 不再有"正在加载…"闪动
|
||||
export default async function SettingsPage() {
|
||||
// /settings 已融合到用户主页 /u/[id]?tab=settings
|
||||
// 未登录跳登录页,登录后跳自己的主页 settings tab
|
||||
export default async function SettingsRedirect() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
const user = me.user;
|
||||
if (!user) {
|
||||
if (!me.user) {
|
||||
redirect(`/login?redirect=${encodeURIComponent(`/settings`)}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<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)" }}>账号设置</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-7">
|
||||
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
账号设置
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="panel p-5 mb-7 flex items-center gap-4">
|
||||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={46} />
|
||||
<div className="min-w-0">
|
||||
<div className="font-bold truncate" style={{ color: "var(--ink)" }}>
|
||||
{user.nickname}
|
||||
</div>
|
||||
<div className="meta">@{user.username} · {user.role === "admin" ? "管理员" : "普通用户"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-7">
|
||||
<ProfileForm user={user} />
|
||||
<PasswordForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
redirect(`/u/${me.user.id}?tab=settings`);
|
||||
}
|
||||
|
||||
108
frontend/app/u/[id]/PasswordForm.tsx
Normal file
108
frontend/app/u/[id]/PasswordForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,22 @@
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人资料编辑表单:昵称、头像 URL、邮箱、签名。
|
||||
* 提交成功后刷新 layout 使 Header 同步最新昵称/头像,并在页面内显示成功提示。
|
||||
* 个人资料编辑表单:头像(上传/裁剪/历史选择)、昵称、邮箱、签名。
|
||||
* 头像由独立上传链路处理,不随本表单提交;保存成功后 router.refresh()
|
||||
* 让 Header 等 SSR 区域同步最新昵称。
|
||||
*/
|
||||
export default function ProfileForm({ user }: ProfileFormProps) {
|
||||
const router = useRouter();
|
||||
const [nickname, setNickname] = useState(user.nickname || "");
|
||||
const [avatar, setAvatar] = useState(user.avatar || "");
|
||||
const [email, setEmail] = useState(user.email || "");
|
||||
const [signature, setSignature] = useState(user.signature || "");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -45,13 +47,11 @@ export default function ProfileForm({ user }: ProfileFormProps) {
|
||||
try {
|
||||
const res = await apiUpdateProfile({
|
||||
nickname: trimmed,
|
||||
avatar: avatar.trim(),
|
||||
email: email.trim(),
|
||||
signature,
|
||||
});
|
||||
if (res.user) {
|
||||
setSuccess("资料已保存");
|
||||
// 刷新 layout 让 Header 同步最新昵称/头像
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(res.error || "保存失败");
|
||||
@@ -64,15 +64,34 @@ export default function ProfileForm({ user }: ProfileFormProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="font-bold text-[15px] mb-3.5" style={{ color: "var(--ink)" }}>
|
||||
个人资料
|
||||
</p>
|
||||
<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>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="panel p-5 sm:p-6 space-y-5">
|
||||
{success && <div className="alert-ok" role="status">{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
|
||||
@@ -86,21 +105,6 @@ export default function ProfileForm({ user }: ProfileFormProps) {
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="pf-avatar">头像链接</label>
|
||||
<input
|
||||
id="pf-avatar"
|
||||
type="url"
|
||||
value={avatar}
|
||||
onChange={(e) => setAvatar(e.target.value)}
|
||||
maxLength={512}
|
||||
className="field"
|
||||
placeholder="https://example.com/avatar.png"
|
||||
/>
|
||||
<p className="meta mt-2">填写图片 URL,留空则使用默认字母头像</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="pf-email">邮箱</label>
|
||||
<input
|
||||
@@ -113,33 +117,37 @@ export default function ProfileForm({ user }: ProfileFormProps) {
|
||||
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>
|
||||
<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 justify-center mt-1"
|
||||
className="btn btn-primary w-full sm:w-auto"
|
||||
>
|
||||
{submitting ? "保存中..." : "保存资料"}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user