diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 429368c..4dc00b5 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -1,8 +1,10 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { MouseEvent as ReactMouseEvent } from "react"; import { createPortal } from "react-dom"; -import { AlertCircle, Check, Info } from "lucide-react"; +import { AlertCircle, Check, Info, LogOut } from "lucide-react"; import { IconKitSvg } from "./IconKitSvg"; import { useBodyScrollLock } from "./overlays"; +import { api, setToken } from "../api"; import type { Product, Project, Team, User } from "../types"; import type { Notice, Page } from "../routes/route-config"; @@ -96,6 +98,93 @@ function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: ( type Navigate = (page: Page) => void; +// 账户下拉菜单 —— 忠实搬设计稿 shell.js 的 accountMenuHtml + toggleAccountMenu 定位逻辑。 +// 触发器(侧栏 .user / 顶栏头像)传入自己的 DOMRect 当锚点;菜单 fixed 定位、右对齐锚点、 +// 越界自动翻到锚点上方,并钳进视口。点外部 / Esc 收起。各项 navigate,退出走 logout。 +// 认证定调:头像下方第二行展示「用户名」(user.username),本项目不用邮箱。 +const ACCOUNT_ITEMS: { act: Page; icon: string; label: string }[] = [ + { act: "settings", icon: "settings", label: "个人设置" }, + { act: "messages", icon: "bell", label: "消息中心" }, + { act: "team", icon: "users", label: "团队管理" }, + { act: "account", icon: "creditCard", label: "消费与余额" } +]; + +function AccountMenu({ anchorRect, onClose, navigate, logout, user, team }: { + anchorRect: DOMRect; + onClose: () => void; + navigate: Navigate; + logout: () => void; + user: User; + team: Team | null; +}) { + const menuRef = useRef(null); + const [pos, setPos] = useState<{ left: number; top: number }>({ left: anchorRect.left, top: anchorRect.bottom + 8 }); + + // 量出真实菜单尺寸后再定位(右对齐锚点、上下翻转防溢出),与 V1 toggleAccountMenu 一致 + useLayoutEffect(() => { + const menu = menuRef.current; + const width = menu?.offsetWidth || 232; + const height = menu?.offsetHeight || 260; + let left = anchorRect.right - width; + if (left < 12) left = anchorRect.left; + left = Math.min(Math.max(12, left), window.innerWidth - width - 12); + let top = anchorRect.bottom + 8; + if (top + height > window.innerHeight - 12) top = Math.max(12, anchorRect.top - height - 8); + setPos({ left, top }); + }, [anchorRect]); + + // 点菜单外部 / Esc → 收起(锚点本身由触发器的 toggle 处理,避免点锚点立刻又被关掉) + useEffect(() => { + const onDown = (event: MouseEvent) => { + const target = event.target as Node; + if (menuRef.current?.contains(target)) return; + if ((target as Element).closest?.(".user, .topbar-avatar")) return; + onClose(); + }; + const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [onClose]); + + const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase(); + const go = (page: Page) => { onClose(); navigate(page); }; + + return createPortal( +