新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
626 lines
23 KiB
TypeScript
626 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { usePathname, useRouter } from "next/navigation";
|
||
import { useState, useEffect, useRef } from "react";
|
||
import {
|
||
LogIn,
|
||
UserPlus,
|
||
LogOut,
|
||
PenSquare,
|
||
Search,
|
||
Settings,
|
||
Bell,
|
||
Home,
|
||
Trophy,
|
||
BookOpen,
|
||
User as UserIcon,
|
||
ChevronDown,
|
||
ShieldCheck,
|
||
MessageSquare,
|
||
X,
|
||
ExternalLink,
|
||
} from "lucide-react";
|
||
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||
import { userPath } from "@/lib/urlStyle";
|
||
import { canAccessAdminMessages, hasAnyChannelPerm, isStaff } from "@/lib/roles";
|
||
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
|
||
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
||
import type { RtChatUnreadData } from "@/lib/realtime";
|
||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||
import NotificationBell from "./NotificationBell";
|
||
import ThemeToggle from "./ThemeToggle";
|
||
import Avatar from "./Avatar";
|
||
import LevelBadge from "./LevelBadge";
|
||
import BadgeChip from "./BadgeChip";
|
||
import BrandLockup from "./BrandLockup";
|
||
import { useAllowMessages } from "./MessagesPolicyProvider";
|
||
import { useSiteBrand, useUrlStyle } from "./SiteBrandProvider";
|
||
import { leaderboardPath, libraryPath } from "@/lib/urlStyle";
|
||
import {
|
||
HEADER_NAV_BUILTIN_LABELS,
|
||
isHeaderNavExternal,
|
||
type HeaderNavBuiltinKey,
|
||
} from "@/lib/headerNav";
|
||
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
|
||
|
||
// 搜索关键词长度上限(与后端 handler maxSearchKeywordRunes 一致);
|
||
// 接近上限时输入框内显示 n/100 计数帮助用户感知
|
||
const SEARCH_MAX_LEN = 100;
|
||
const SEARCH_COUNT_FROM = 80;
|
||
|
||
export default function Header({
|
||
initialUser,
|
||
initialUnread = 0,
|
||
initialChatUnread = 0,
|
||
initialTheme = "light",
|
||
siteName = "姜十三",
|
||
wordmark = "",
|
||
logoLight = "",
|
||
logoDark = "",
|
||
brandMark = "image_text",
|
||
brandLogoSize = "sq",
|
||
brandLogoFit = "contain",
|
||
}: {
|
||
initialUser: User | null;
|
||
initialUnread?: number;
|
||
initialChatUnread?: number;
|
||
initialTheme?: "light" | "dark";
|
||
siteName?: string;
|
||
wordmark?: string;
|
||
logoLight?: string;
|
||
logoDark?: string;
|
||
brandMark?: BrandMark;
|
||
brandLogoSize?: BrandLogoSize;
|
||
brandLogoFit?: BrandLogoFit;
|
||
}) {
|
||
const router = useRouter();
|
||
const pathname = usePathname();
|
||
const urlStyle = useUrlStyle();
|
||
const lbHref = leaderboardPath(urlStyle);
|
||
const libHref = libraryPath(urlStyle);
|
||
// 顶部主导航由后台「基本信息 → 顶部导航」配置(顺序/显隐/自定义链接),
|
||
// 配置为空时整个 nav 不渲染(含左侧分隔线);移动端导航走底部公共菜单
|
||
const headerNav = useSiteBrand().header_nav;
|
||
const builtinNavIcon: Record<HeaderNavBuiltinKey, React.ReactNode> = {
|
||
home: <Home size={15} />,
|
||
leaderboard: <Trophy size={15} />,
|
||
library: <BookOpen size={15} />,
|
||
};
|
||
const builtinNavHref: Record<HeaderNavBuiltinKey, string> = {
|
||
home: "/",
|
||
leaderboard: lbHref,
|
||
library: libHref,
|
||
};
|
||
const isBuiltinNavActive = (key: HeaderNavBuiltinKey): boolean => {
|
||
if (!pathname) return false;
|
||
if (key === "home") return pathname === "/";
|
||
if (key === "leaderboard") return pathname === lbHref || pathname.startsWith(`${lbHref}/`);
|
||
// 书库详情/阅读页标准路径与伪静态(/library-*.html)统一按 /library 前缀高亮
|
||
return pathname.startsWith("/library");
|
||
};
|
||
// 自定义站内菜单高亮:精确或子路径;"/" 仅精确,避免高亮全站
|
||
const isCustomNavActive = (url: string): boolean => {
|
||
if (!pathname || !url.startsWith("/") || url.startsWith("//")) return false;
|
||
if (url === "/") return pathname === "/";
|
||
return pathname === url || pathname.startsWith(`${url}/`);
|
||
};
|
||
const allowMessages = useAllowMessages();
|
||
const hideComposeNav =
|
||
pathname === "/compose" ||
|
||
(pathname?.startsWith("/compose/") ?? false) ||
|
||
/^\/post\/[^/]+\/edit\/?$/.test(pathname || "");
|
||
const [user, setUser] = useState<User | null>(initialUser);
|
||
const [unread, setUnread] = useState(initialUnread);
|
||
const [chatUnread, setChatUnread] = useState(initialChatUnread);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
// 移动端展开式搜索行(导航已交给底部公共菜单,顶部只留搜索/主题/铃铛/头像)
|
||
const [searchOpen, setSearchOpen] = useState(false);
|
||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||
const mobileSearchRef = useRef<HTMLInputElement>(null);
|
||
|
||
// 登录/登出触发 router.refresh() 后,服务端 layout 重新取态并下发新 props
|
||
useEffect(() => {
|
||
setUser(initialUser);
|
||
}, [initialUser]);
|
||
|
||
useEffect(() => {
|
||
setUnread(initialUnread);
|
||
}, [initialUnread]);
|
||
|
||
useEffect(() => {
|
||
setChatUnread(initialChatUnread);
|
||
}, [initialChatUnread]);
|
||
|
||
// 同页内资料变更(如上传/删除头像)立即生效,不依赖 router.refresh 时序
|
||
useEffect(() => onUserUpdate((patch) => {
|
||
setUser((prev) => (prev ? { ...prev, ...patch } : prev));
|
||
}), []);
|
||
|
||
// 强制下线(当前唯一来源:账号被管理员封禁)。
|
||
// 统一复用登出清理:尽力调用 /api/logout 清除 HttpOnly cookie(该端点不要求
|
||
// 有效登录态),本地立即游客化并 router.refresh 校正 SSR 区域,
|
||
// 封禁告知弹窗由 AccountGuard 负责,避免状态与 UI 清理分散
|
||
useEffect(() => onForceLogout(() => {
|
||
apiLogout()
|
||
.catch(() => {})
|
||
.finally(() => {
|
||
setUser(null);
|
||
setUnread(0);
|
||
router.refresh();
|
||
});
|
||
}), [router]);
|
||
|
||
// 挂载后静默校正一次(仅一次):覆盖 SSR 期间后端不可用等边缘情况。
|
||
// 正常 F5 下结果与 SSR 一致,不产生可见闪动;
|
||
// 无可读 CSRF cookie 说明必然是游客,跳过请求
|
||
useEffect(() => {
|
||
if (!hasAuthCookieHint()) return;
|
||
apiMe()
|
||
.then((res) => {
|
||
setUser(res.user);
|
||
if (typeof res.unread_count === "number") setUnread(res.unread_count);
|
||
if (typeof res.chat_unread_count === "number")
|
||
setChatUnread(res.chat_unread_count);
|
||
})
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
// 实时未读:chat:unread 推送给所有成员(含未打开会话页);在当前会话页则忽略
|
||
useEffect(() => {
|
||
if (!user || !allowMessages) return;
|
||
const off = realtime.on<RtChatUnreadData>(RT_CHAT_UNREAD, (data) => {
|
||
if (!data || typeof data.room_id !== "number") return;
|
||
if (pathname === `/chat/${data.room_id}`) return;
|
||
const delta = typeof data.delta === "number" ? data.delta : 1;
|
||
setChatUnread((c) => Math.max(0, c + delta));
|
||
});
|
||
return off;
|
||
}, [user, pathname, allowMessages]);
|
||
|
||
// 会话内已读 / 回前台:合并对账(短时间多次 markRead 只打一次 summary)
|
||
useEffect(() => {
|
||
if (!user || !allowMessages) return;
|
||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||
const pullSummary = () => {
|
||
apiChatUnreadSummary()
|
||
.then((res) => setChatUnread(res.total || 0))
|
||
.catch(() => {});
|
||
};
|
||
const onSync = () => {
|
||
if (debounceTimer) clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(pullSummary, 250);
|
||
};
|
||
window.addEventListener("j13-chat-unread-sync", onSync);
|
||
const offVisible = onDocumentVisible(pullSummary);
|
||
return () => {
|
||
if (debounceTimer) clearTimeout(debounceTimer);
|
||
window.removeEventListener("j13-chat-unread-sync", onSync);
|
||
offVisible();
|
||
};
|
||
}, [user, allowMessages]);
|
||
|
||
const handleSearch = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const q = searchQuery.trim();
|
||
router.push(q ? `/?q=${encodeURIComponent(q)}` : "/");
|
||
setSearchOpen(false);
|
||
searchInputRef.current?.blur();
|
||
mobileSearchRef.current?.blur();
|
||
};
|
||
|
||
useEffect(() => {
|
||
setSearchQuery(new URLSearchParams(window.location.search).get("q")?.trim() || "");
|
||
}, [pathname]);
|
||
|
||
// "/" 快速聚焦搜索
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
const el = e.target as HTMLElement;
|
||
const typing =
|
||
el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable;
|
||
if (e.key === "/" && !typing && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||
e.preventDefault();
|
||
searchInputRef.current?.focus();
|
||
searchInputRef.current?.select();
|
||
}
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, []);
|
||
|
||
const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (e.key === "Escape") {
|
||
setSearchQuery("");
|
||
if (new URLSearchParams(window.location.search).get("q")) router.push("/");
|
||
e.currentTarget.blur();
|
||
}
|
||
};
|
||
|
||
const handleLogout = async () => {
|
||
try {
|
||
await apiLogout();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setUser(null);
|
||
router.refresh();
|
||
};
|
||
|
||
const iconBtn =
|
||
"w-10 h-10 flex items-center justify-center rounded-full transition-colors";
|
||
const iconBtnStyle = {
|
||
border: "1px solid var(--line)",
|
||
background: "var(--panel)",
|
||
color: "var(--ink-2)",
|
||
} as const;
|
||
|
||
return (
|
||
<header
|
||
className="sticky top-0 z-50 border-b"
|
||
style={{
|
||
borderColor: "var(--line)",
|
||
background: "var(--panel)",
|
||
}}
|
||
>
|
||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[var(--header-bar)] flex items-center gap-4 lg:gap-7">
|
||
{/* 左组:品牌 + 主导航 */}
|
||
<div className="flex items-center gap-3 lg:gap-5 shrink-0">
|
||
<Link href="/" className="flex items-center group min-w-0" aria-label={siteName}>
|
||
<BrandLockup
|
||
mode={brandMark}
|
||
size={brandLogoSize}
|
||
fit={brandLogoFit}
|
||
name={siteName}
|
||
wordmark={wordmark}
|
||
logoLight={logoLight}
|
||
logoDark={logoDark}
|
||
/>
|
||
</Link>
|
||
{headerNav.length > 0 && (
|
||
<nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
|
||
{headerNav.map((item, idx) =>
|
||
item.type === "builtin" ? (
|
||
<NavPill
|
||
key={`builtin-${item.key}`}
|
||
href={builtinNavHref[item.key]}
|
||
active={isBuiltinNavActive(item.key)}
|
||
icon={builtinNavIcon[item.key]}
|
||
>
|
||
{HEADER_NAV_BUILTIN_LABELS[item.key]}
|
||
</NavPill>
|
||
) : (
|
||
<NavPill
|
||
key={`custom-${idx}`}
|
||
href={item.url}
|
||
external={isHeaderNavExternal(item.url)}
|
||
newTab={item.new_tab}
|
||
active={!isHeaderNavExternal(item.url) && isCustomNavActive(item.url)}
|
||
>
|
||
{item.label}
|
||
{isHeaderNavExternal(item.url) && item.new_tab ? (
|
||
<ExternalLink size={12} aria-hidden style={{ color: "var(--ink-3)" }} />
|
||
) : null}
|
||
</NavPill>
|
||
),
|
||
)}
|
||
</nav>
|
||
)}
|
||
</div>
|
||
|
||
{/* 中部弹性留白,把搜索与操作区推到右侧 */}
|
||
<div className="hidden lg:block flex-1" aria-hidden />
|
||
|
||
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询)。
|
||
移动端导航交给底部公共菜单,顶部只保留 搜索/主题/铃铛/头像 */}
|
||
<div className="flex items-center gap-2 lg:gap-3 ml-auto lg:ml-0">
|
||
{/* 移动端搜索开关 */}
|
||
<button
|
||
onClick={() => setSearchOpen((v) => !v)}
|
||
aria-label="搜索"
|
||
aria-expanded={searchOpen}
|
||
className={`${iconBtn} lg:hidden`}
|
||
style={iconBtnStyle}
|
||
>
|
||
{searchOpen ? <X size={17} /> : <Search size={17} />}
|
||
</button>
|
||
<form onSubmit={handleSearch} className="hidden lg:block">
|
||
<div className="relative w-[220px] xl:w-[260px] focus-within:w-[268px] xl:focus-within:w-[300px] transition-[width] duration-200">
|
||
<Search
|
||
size={15}
|
||
className="absolute left-4 top-1/2 -translate-y-1/2"
|
||
style={{ color: "var(--ink-3)" }}
|
||
/>
|
||
<input
|
||
ref={searchInputRef}
|
||
type="text"
|
||
value={searchQuery}
|
||
maxLength={SEARCH_MAX_LEN}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
onKeyDown={onSearchKeyDown}
|
||
placeholder="搜索帖子..."
|
||
aria-label="搜索帖子"
|
||
className={`j13-search-field w-full pl-10 py-2.5 text-sm rounded-full ${
|
||
searchQuery.length >= SEARCH_COUNT_FROM ? "pr-16" : "pr-10"
|
||
}`}
|
||
/>
|
||
{searchQuery.length >= SEARCH_COUNT_FROM ? (
|
||
<span
|
||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] tabular-nums pointer-events-none"
|
||
style={{ color: searchQuery.length >= SEARCH_MAX_LEN ? "var(--danger)" : "var(--ink-3)" }}
|
||
>
|
||
{searchQuery.length}/{SEARCH_MAX_LEN}
|
||
</span>
|
||
) : (
|
||
<kbd
|
||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] w-5 h-5 flex items-center justify-center rounded-md pointer-events-none"
|
||
style={{ color: "var(--ink-3)", border: "1px solid var(--line-2)", background: "var(--bg)" }}
|
||
>
|
||
/
|
||
</kbd>
|
||
)}
|
||
</div>
|
||
</form>
|
||
<ThemeToggle initialTheme={initialTheme} className={iconBtn} style={iconBtnStyle} />
|
||
{user && allowMessages && (
|
||
<Link
|
||
href="/chat"
|
||
prefetch={false}
|
||
aria-label="消息"
|
||
data-tip="消息"
|
||
className="relative hidden lg:inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
|
||
style={{
|
||
color: pathname?.startsWith("/chat")
|
||
? "var(--accent)"
|
||
: "var(--ink-2)",
|
||
border: `1px solid ${
|
||
pathname?.startsWith("/chat") ? "var(--accent)" : "var(--line)"
|
||
}`,
|
||
background: pathname?.startsWith("/chat")
|
||
? "var(--accent-soft)"
|
||
: "var(--panel)",
|
||
}}
|
||
>
|
||
<MessageSquare size={17} />
|
||
{chatUnread > 0 && (
|
||
<span
|
||
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-medium flex items-center justify-center rounded-full"
|
||
style={{
|
||
background: "var(--clay)",
|
||
color: "#fff",
|
||
border: "2px solid var(--bg)",
|
||
}}
|
||
>
|
||
{chatUnread > 99 ? "99+" : chatUnread}
|
||
</span>
|
||
)}
|
||
</Link>
|
||
)}
|
||
{user && <NotificationBell initialUnread={unread} />}
|
||
{user ? (
|
||
<>
|
||
{!hideComposeNav && (
|
||
<Link href="/compose" className="btn btn-primary btn-sm hidden lg:inline-flex">
|
||
<PenSquare size={14} /> 发帖
|
||
</Link>
|
||
)}
|
||
<UserMenu user={user} onLogout={handleLogout} />
|
||
</>
|
||
) : (
|
||
<div className="flex items-center gap-2">
|
||
<Link href="/login" className="btn btn-line btn-sm">
|
||
<LogIn size={14} /> 登录
|
||
</Link>
|
||
<Link href="/register" className="btn btn-primary btn-sm hidden sm:inline-flex">
|
||
<UserPlus size={14} /> 注册
|
||
</Link>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 移动端展开式搜索行 */}
|
||
{searchOpen && (
|
||
<div
|
||
className="lg:hidden border-t px-4 py-2.5"
|
||
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
|
||
>
|
||
<form onSubmit={handleSearch} className="relative">
|
||
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2" style={{ color: "var(--ink-3)" }} />
|
||
<input
|
||
ref={mobileSearchRef}
|
||
autoFocus
|
||
type="text"
|
||
value={searchQuery}
|
||
maxLength={SEARCH_MAX_LEN}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") {
|
||
setSearchOpen(false);
|
||
if (new URLSearchParams(window.location.search).get("q")) router.push("/");
|
||
}
|
||
}}
|
||
aria-label="搜索帖子"
|
||
placeholder="搜索帖子..."
|
||
className={`j13-search-field w-full pl-10 py-3 rounded-full text-sm ${
|
||
searchQuery.length >= SEARCH_COUNT_FROM ? "pr-16" : "pr-4"
|
||
}`}
|
||
/>
|
||
{searchQuery.length >= SEARCH_COUNT_FROM && (
|
||
<span
|
||
className="absolute right-4 top-1/2 -translate-y-1/2 text-[11px] tabular-nums pointer-events-none"
|
||
style={{ color: searchQuery.length >= SEARCH_MAX_LEN ? "var(--danger)" : "var(--ink-3)" }}
|
||
>
|
||
{searchQuery.length}/{SEARCH_MAX_LEN}
|
||
</span>
|
||
)}
|
||
</form>
|
||
</div>
|
||
)}
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function NavPill({
|
||
href,
|
||
active,
|
||
icon,
|
||
external,
|
||
newTab,
|
||
children,
|
||
}: {
|
||
href: string;
|
||
active?: boolean;
|
||
icon?: React.ReactNode;
|
||
external?: boolean;
|
||
newTab?: boolean;
|
||
children: React.ReactNode;
|
||
}) {
|
||
const cls =
|
||
"inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[15px] font-semibold transition-colors";
|
||
const style =
|
||
active
|
||
? { background: "transparent", color: "var(--accent)" }
|
||
: { color: "var(--ink)" };
|
||
const target = newTab ? "_blank" : undefined;
|
||
const rel = newTab ? "noopener noreferrer" : undefined;
|
||
// 自定义外链渲染原生 <a>;站内链接(含勾选新标签的)走 Next Link 保留软导航
|
||
if (external) {
|
||
return (
|
||
<a href={href} target={target} rel={rel} className={cls} style={style}>
|
||
{icon}
|
||
{children}
|
||
</a>
|
||
);
|
||
}
|
||
return (
|
||
<Link href={href} target={target} rel={rel} className={cls} style={style}>
|
||
{icon}
|
||
{children}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 用户菜单(两端可用):头像触发器,下拉承载通知中心/个人主页/设置/登出,
|
||
* 把零散的图标按钮收拢为一个语义明确的控件;移动端只显示头像
|
||
*/
|
||
function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||
const urlStyle = useUrlStyle();
|
||
const [open, setOpen] = useState(false);
|
||
const menuRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onDown = (e: MouseEvent) => {
|
||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
|
||
};
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") setOpen(false);
|
||
};
|
||
document.addEventListener("mousedown", onDown);
|
||
document.addEventListener("keydown", onKey);
|
||
return () => {
|
||
document.removeEventListener("mousedown", onDown);
|
||
document.removeEventListener("keydown", onKey);
|
||
};
|
||
}, [open]);
|
||
|
||
const itemCls =
|
||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
|
||
|
||
return (
|
||
<div className="relative" ref={menuRef}>
|
||
<button
|
||
onClick={() => setOpen((v) => !v)}
|
||
aria-haspopup="menu"
|
||
aria-expanded={open}
|
||
aria-label="用户菜单"
|
||
className="flex items-center gap-2 rounded-full p-1 lg:pl-1 lg:pr-2.5 transition-colors"
|
||
style={{
|
||
border: "1px solid " + (open ? "var(--accent)" : "var(--line)"),
|
||
background: open ? "var(--accent-soft)" : "var(--panel)",
|
||
}}
|
||
>
|
||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={28} />
|
||
<span
|
||
className="hidden lg:inline text-[13px] max-w-[90px] truncate"
|
||
style={{ color: open ? "var(--accent)" : "var(--ink)" }}
|
||
>
|
||
{user.nickname}
|
||
</span>
|
||
<ChevronDown
|
||
size={14}
|
||
className="hidden lg:block transition-transform duration-200"
|
||
style={{
|
||
color: "var(--ink-3)",
|
||
transform: open ? "rotate(180deg)" : "none",
|
||
}}
|
||
/>
|
||
</button>
|
||
|
||
{open && (
|
||
<div
|
||
role="menu"
|
||
className="absolute right-0 mt-2.5 w-60 overflow-hidden z-50 j13-toast-in"
|
||
style={{
|
||
background: "var(--panel)",
|
||
border: "1px solid var(--line)",
|
||
borderRadius: "var(--r)",
|
||
boxShadow: "var(--shadow-lg)",
|
||
}}
|
||
>
|
||
<div
|
||
className="flex items-center gap-3 px-4 py-3.5"
|
||
style={{ borderBottom: "1px solid var(--line)" }}
|
||
>
|
||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={38} />
|
||
<div className="min-w-0">
|
||
<p className="text-[14px] truncate flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
|
||
<span className="truncate">{user.nickname}</span>
|
||
<LevelBadge level={user.level} />
|
||
{(user.badges ?? []).slice(0, 2).map((ub) => (
|
||
<BadgeChip key={ub.id} badge={ub.badge} />
|
||
))}
|
||
</p>
|
||
<p className="meta text-xs truncate">@ {user.username}</p>
|
||
</div>
|
||
</div>
|
||
<div className="p-1.5">
|
||
<Link href="/notifications" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||
<Bell size={15} style={{ color: "var(--ink-3)" }} /> 通知中心
|
||
</Link>
|
||
<Link href={userPath(urlStyle, user.id)} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
|
||
</Link>
|
||
<Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
|
||
</Link>
|
||
</div>
|
||
{(isStaff(user.role) || canAccessAdminMessages(user) || hasAnyChannelPerm(user)) && (
|
||
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||
<Link href="/admin" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||
<ShieldCheck size={15} style={{ color: "var(--accent)" }} /> 管理面板
|
||
</Link>
|
||
</div>
|
||
)}
|
||
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||
<button
|
||
onClick={() => {
|
||
setOpen(false);
|
||
onLogout();
|
||
}}
|
||
className={itemCls}
|
||
role="menuitem"
|
||
style={{ color: "var(--danger)" }}
|
||
>
|
||
<LogOut size={15} /> 退出登录
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|