diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 190395a..628f35b 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -21,7 +21,7 @@ import type { } from "./types"; import { publicModelDisplayName } from "./model-display"; import { generationErrorText } from "./generation-error"; -import { AccountMenu, CornerMarks, Decorations, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell"; +import { AccountMenu, CornerMarks, Decorations, ModeTabs, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell"; import { SystemLoading } from "./components/loading"; import { AccountPage, @@ -1132,23 +1132,7 @@ export function App() {
-
- - - -
+
-
+
{notice && } {pipelinePage || renderPage()}
- {accountAnchor && ( - setAccountAnchor(null)} - navigate={navigate} - logout={logout} - user={currentUser} - team={currentTeam} - canManageBilling={isOwner} - /> - )} + setAccountAnchor(null)} + navigate={navigate} + logout={logout} + user={currentUser} + team={currentTeam} + canManageBilling={isOwner} + /> ); } diff --git a/core/frontend/src/account-page.css b/core/frontend/src/account-page.css index a12e8a6..065cba1 100644 --- a/core/frontend/src/account-page.css +++ b/core/frontend/src/account-page.css @@ -1,117 +1,243 @@ -/* 账户页 · 对照影擎页头(返回方钮 + 大标题 + 灰摘要)。仅 scope 在 .account-page,不改共享 .btn/.pill/.input */ +/* 账单库 · 影擎没有独立页,按商品库/团队中心的页头、深色指标卡、data-panel 风格排。不改共享 .btn/.pill/.input */ .account-page { --klein: #002fa7; + --klein-hover: #002680; --st-muted: #6f747c; --st-text: #17181a; --st-black: #101012; --st-line: rgba(34, 42, 54, 0.10); --st-wash: rgba(34, 42, 54, 0.045); --st-shadow: 0 8px 20px rgba(20, 27, 38, 0.09); - max-width: 1140px; - padding-bottom: 44px; + width: 100%; + max-width: none; + box-sizing: border-box; + margin: -24px -28px -60px; + padding: 52px clamp(40px, 3.2vw, 68px) 48px; - .page-head { + .ac-head { + min-height: 76px; display: flex; align-items: flex-start; - justify-content: flex-start; - gap: 14px; - margin-bottom: 22px; - flex-wrap: nowrap; + justify-content: space-between; + gap: 30px; + margin-bottom: 34px; + } + .ac-head h1 { + margin: 0 0 10px; + font-size: 32px; + line-height: 1.2; + font-weight: 600; + letter-spacing: -.02em; + color: var(--accent-black); + } + .ac-head p { + margin: 0; + color: var(--st-muted); + font-size: 15px; } - .page-head h1 { font-size: 28px; } - .page-head .sub { margin-top: 6px; font-size: 15px; color: var(--st-muted); } - .ac-back { - width: 36px; - height: 36px; - flex: 0 0 36px; - margin-top: 4px; + .ac-top { display: grid; - place-items: center; - padding: 0; - border-radius: 8px; - border: 1px solid rgba(28, 34, 43, 0.12); - background: #fff; - color: var(--st-text); - cursor: pointer; + grid-template-columns: minmax(0, 1.2fr) minmax(430px, 0.9fr); + gap: 20px; + margin-bottom: 28px; + align-items: stretch; } - .ac-back:hover { background: rgba(34, 42, 54, 0.05); } - .ac-back:focus-visible { outline: none; box-shadow: 0 0 0 2px #fff, 0 0 0 4px rgba(0, 47, 167, 0.28); } - .ac-back svg { width: 18px; height: 18px; display: block; } + @media (max-width: 1120px) { .ac-top { grid-template-columns: 1fr; } } - .top-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(480px, .85fr); gap: 16px; margin-bottom: 22px; align-items: stretch; } - @media (max-width: 1120px) { .top-grid { grid-template-columns: 1fr; } } - - .balance-banner { - background: #202124; - color: #fff; - padding: 26px 28px; - position: relative; - border: 0; - border-radius: 14px; + .ac-hero { + min-height: 270px; display: flex; flex-direction: column; - gap: 24px; - min-width: 0; - min-height: 246px; + justify-content: space-between; + padding: 28px; + border-radius: 14px; + color: #fff; + background: #202124; box-shadow: 0 14px 30px rgba(16, 16, 18, 0.18); } - .balance-banner::before, .balance-banner::after, - .balance-banner > .corner-tr, .balance-banner > .corner-bl { - content: ''; position: absolute; width: 14px; height: 14px; - background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center; - background-size: contain; pointer-events: none; + .ac-hero > span, + .ac-hero-stats span, + .ac-progress-meta { + color: rgba(255, 255, 255, 0.58); + font-size: 12px; + } + .ac-hero > strong { + display: block; + margin: 8px 0 6px; + font-size: 38px; + font-weight: 600; + letter-spacing: -.02em; + font-variant-numeric: tabular-nums; + line-height: 1.1; + } + .ac-hero > em { + font-style: normal; + color: rgba(255, 255, 255, 0.46); + font-size: 12px; + } + .ac-hero-stats { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 18px; + margin: 22px 0 18px; + } + .ac-hero-stats strong { + display: block; + margin-top: 6px; + font-size: 19px; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .ac-progress { + height: 6px; + overflow: hidden; + border-radius: 99px; + background: rgba(255, 255, 255, 0.15); + } + .ac-progress > span { + display: block; + min-width: 7px; + height: 100%; + background: var(--klein); + } + .ac-progress-meta { + display: flex; + justify-content: space-between; + gap: 14px; + margin-top: 10px; } - .balance-banner::before { top: -7px; left: -7px; } - .balance-banner::after { bottom: -7px; right: -7px; } - .balance-banner > .corner-tr { top: -7px; right: -7px; } - .balance-banner > .corner-bl { bottom: -7px; left: -7px; } - .balance-hero { display: flex; flex-direction: column; gap: 4px; } - .balance-hero .lbl { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.55); letter-spacing: .06em; text-transform: uppercase; } - .balance-hero .v { font-size: 38px; font-weight: 600; letter-spacing: -.02em; font-variant-numeric: tabular-nums; line-height: 1.1; } - .balance-hero .meta { font-size: 11.5px; color: rgba(255,255,255,.5); font-family: var(--font-mono); letter-spacing: .02em; margin-top: 4px; } + .ac-recharge { + padding: 22px; + border: 1px solid var(--st-line); + border-radius: 14px; + background: var(--st-wash); + box-shadow: var(--st-shadow); + display: flex; + flex-direction: column; + } + .ac-recharge h2 { + margin: 0 0 5px; + font-size: 18px; + font-weight: 600; + color: var(--st-text); + } + .ac-recharge > p { + margin: 0 0 18px; + color: var(--st-muted); + font-size: 12px; + } + .ac-recharge-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + } + .ac-option { + min-height: 66px; + padding: 10px; + border: 1px solid rgba(34, 42, 54, 0.10); + border-radius: 10px; + background: rgba(34, 42, 54, 0.035); + cursor: pointer; + text-align: center; + position: relative; + } + .ac-option:hover { background: rgba(34, 42, 54, 0.06); } + .ac-option.active { + border-color: var(--klein); + background: rgba(0, 47, 167, 0.08); + } + .ac-option strong, + .ac-option span { display: block; } + .ac-option strong { + font-size: 16px; + font-weight: 600; + color: var(--st-text); + font-variant-numeric: tabular-nums; + } + .ac-option span { + margin-top: 4px; + color: #16a34a; + font-size: 10px; + } + .ac-option span.plain { color: var(--st-muted); } + .ac-option .ribbon { + position: absolute; + top: 6px; + left: 7px; + font-size: 9px; + padding: 1px 5px; + background: var(--klein); + color: #fff; + font-weight: 600; + border-radius: 4px; + } + .ac-pay-title { + margin: 16px 0 8px; + font-size: 12px; + font-weight: 600; + color: var(--st-text); + } + .ac-recharge .input { + width: 100%; + height: 42px; + margin: 0; + padding: 0 13px; + border: 1px solid rgba(34, 42, 54, 0.11); + border-radius: 10px; + background: #fff; + } + .ac-pay-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 12px; + } + .ac-pay { + height: 42px; + border: 1px solid rgba(28, 34, 43, 0.12); + border-radius: 11px; + background: #fff; + color: var(--st-text); + display: inline-flex; + justify-content: center; + align-items: center; + gap: 8px; + font: inherit; + font-size: 14px; + cursor: pointer; + } + .ac-pay:hover { background: rgba(34, 42, 54, 0.05); } + .ac-pay .pay-logo { + width: 18px; + height: 18px; + border-radius: 6px; + overflow: hidden; + flex: 0 0 18px; + } + .ac-pay .pay-logo img { width: 100%; height: 100%; display: block; object-fit: cover; } + .ac-tip { + margin-top: 14px; + font-size: 12px; + color: var(--st-muted); + line-height: 1.65; + } + .ac-tip strong { color: var(--st-text); font-weight: 600; } + @media (max-width: 720px) { .ac-recharge-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } - .balance-sub { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding: 16px 0 0; border-top: 1px solid rgba(255,255,255,.1); } - .balance-sub .col { min-width: 0; } - .balance-sub .lbl { font-family: var(--font-mono); font-size: 10px; color: rgba(255,255,255,.5); letter-spacing: .06em; text-transform: uppercase; } - .balance-sub .v { font-size: 18px; font-weight: 600; letter-spacing: -.01em; margin-top: 4px; font-variant-numeric: tabular-nums; } - .balance-sub .meta { font-size: 10.5px; color: rgba(255,255,255,.42); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; } + .pane { + background: var(--st-wash); + border: 1px solid var(--st-line); + border-radius: 14px; + padding: 20px; + margin-bottom: 16px; + box-shadow: var(--st-shadow); + } + .pane h3 { font-size: 18px; font-weight: 600; margin: 0 0 6px; color: var(--st-text); } + .pane .desc { font-size: 12px; color: var(--st-muted); margin-bottom: 14px; } - .balance-foot { margin-top: auto; padding-top: 2px; } - .balance-meter { height: 5px; background: rgba(255,255,255,.12); border-radius: var(--r-pill); overflow: hidden; } - .balance-meter > span { display: block; height: 100%; width: 5.4%; background: var(--klein); border-radius: inherit; } - .balance-foot-meta { display: flex; justify-content: space-between; gap: 14px; margin-top: 8px; color: rgba(255,255,255,.46); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; } - - .pane { background: #fff; border: 1px solid var(--st-line); border-radius: 14px; padding: 20px; margin-bottom: 16px; box-shadow: var(--st-shadow); } - .pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 6px; color: var(--st-text); } - .pane .desc { font-size: 11.5px; color: var(--st-muted); margin-bottom: 14px; font-family: var(--font-mono); letter-spacing: .02em; } - .topup-pane { display: flex; flex-direction: column; padding: 20px 22px; margin-bottom: 0; min-height: 246px; } - .topup-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 14px; } - .topup-head h3 { margin-bottom: 5px; } - .topup-head .desc { margin-bottom: 0; } - .topup-selected { font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); white-space: nowrap; padding-top: 2px; } - - .recharge-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } - .recharge-card { min-height: 76px; border: 1px solid var(--st-line); border-radius: 10px; padding: 10px 8px; text-align: center; cursor: pointer; background: #fff; position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base); } - .recharge-card:hover { background: var(--st-wash); border-color: rgba(28, 34, 43, 0.18); } - .recharge-card:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.28); } - .recharge-card.selected { border-color: rgba(0, 47, 167, 0.28); background: rgba(0, 47, 167, 0.07); box-shadow: inset 0 0 0 1px rgba(0, 47, 167, 0.18); } - .recharge-card.selected::after { content: '✓'; position: absolute; top: 6px; right: 7px; width: 15px; height: 15px; border-radius: 50%; display: grid; place-items: center; background: var(--klein); color: #fff; font-size: 10px; line-height: 1; } - .recharge-card .amt { font-size: 17px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.15; color: var(--st-text); } - .recharge-card .gift { font-size: 10px; color: var(--st-muted); margin-top: 4px; font-family: var(--font-mono); white-space: nowrap; } - .recharge-card .gift.bonus { color: var(--accent-forest); font-weight: 600; } - .recharge-card .ribbon { position: absolute; top: 6px; left: 7px; font-family: var(--font-mono); font-size: 9px; padding: 1px 5px; background: var(--klein); color: #fff; letter-spacing: .03em; font-weight: 600; border-radius: var(--r-sm); } - .pay-row { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; padding-top: 0; border-top: 0; } - .pay-title { font-size: 12px; font-weight: 600; color: var(--st-text); line-height: 1.2; } - .pay-row .input { width: 100%; box-sizing: border-box; } - .pay-btn-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } - .pay-method-btn { height: 38px; border-radius: 11px; display: inline-flex; justify-content: center; align-items: center; gap: 8px; } - .pay-logo { width: 18px; height: 18px; border-radius: 6px; display: inline-block; flex: 0 0 18px; overflow: hidden; } - .pay-logo img { width: 100%; height: 100%; display: block; object-fit: cover; border-radius: inherit; } - @media (max-width: 720px) { .recharge-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } } - - .billing-tabs { display: flex; align-items: center; gap: 8px; border-bottom: 0; margin: 8px 0 18px; padding: 0; overflow-x: auto; scrollbar-width: none; } + .billing-tabs { display: flex; align-items: center; gap: 8px; border-bottom: 0; margin: 0 0 18px; padding: 0; overflow-x: auto; scrollbar-width: none; } .billing-tabs::-webkit-scrollbar { display: none; } .ac-tab { display: inline-flex; @@ -143,33 +269,103 @@ .overview-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 16px; align-items: stretch; } @media (max-width: 980px) { .overview-grid { grid-template-columns: 1fr; } } - .trend-pane { padding: 18px 20px 14px; display: flex; flex-direction: column; } - .trend-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 14px; } - .trend-head h3 { margin-bottom: 0; } - .trend-head .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--st-muted); letter-spacing: .02em; } + .trend-pane { padding: 0; display: flex; flex-direction: column; overflow: hidden; } + .trend-head { + min-height: 64px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 20px; + margin-bottom: 0; + border-bottom: 1px solid rgba(34, 42, 54, 0.08); + } + .trend-head h3 { margin-bottom: 0; font-size: 18px; } + .trend-head .sub { font-size: 12px; color: var(--st-muted); } .trend-head .spacer { flex: 1; } .ac-seg { - height: 30px; - padding: 0 12px; + height: 38px; + padding: 0 16px; border: 0; - border-radius: 8px; - color: var(--st-muted); - background: rgba(34, 42, 54, 0.055); + border-radius: 9px; + color: #50555d; + background: transparent; cursor: pointer; font: inherit; - font-size: 12px; + font-size: 14px; } .ac-seg:hover { background: rgba(34, 42, 54, 0.10); color: var(--st-text); } .ac-seg.active { color: #fff; background: var(--st-black); } - .trend-chart { display: grid; grid-template-rows: 1fr auto; gap: 6px; min-height: 170px; flex: 1; padding: 6px 4px 2px; position: relative; } - .trend-chart .bars { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; align-items: end; height: 100%; } - .trend-chart .bar { background: rgba(34, 42, 54, 0.06); border-radius: 2px 2px 0 0; position: relative; transition: background var(--t-base); cursor: pointer; height: 100%; } - .trend-chart .bar > span { position: absolute; left: 0; bottom: 0; display: block; width: 100%; flex: none; min-height: 0; background: var(--klein); border-radius: 2px 2px 0 0; } - .trend-chart .bar:hover > span { background: var(--st-black); } - .trend-chart .bar.peak > span { background: var(--st-black); } - .trend-chart .x-axis { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; font-family: var(--font-mono); font-size: 9.5px; color: var(--st-muted); text-align: center; letter-spacing: .02em; } - .trend-foot { display: flex; gap: 14px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--st-line); font-size: 12px; } + .ac-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + padding: 18px 20px 2px; + } + .ac-summary div { + padding: 14px; + border-radius: 11px; + background: rgba(0, 47, 167, 0.055); + } + .ac-summary span, + .ac-summary strong { display: block; } + .ac-summary span { color: var(--st-muted); font-size: 11px; } + .ac-summary strong { margin-top: 7px; font-size: 16px; font-weight: 600; color: var(--st-text); } + + .trend-chart { + position: relative; + height: 220px; + display: flex; + align-items: flex-end; + gap: 12px; + padding: 30px 28px 22px; + min-height: 0; + } + .trend-chart::after { + content: ""; + position: absolute; + right: 28px; + bottom: 42px; + left: 28px; + height: 1px; + background: rgba(34, 42, 54, 0.12); + } + .ac-bar-group { + position: relative; + z-index: 1; + height: 100%; + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + gap: 9px; + color: var(--st-muted); + font-size: 11px; + min-width: 0; + } + .ac-bar { + width: min(30px, 70%); + min-height: 10px; + border-radius: 7px 7px 3px 3px; + background: var(--klein); + opacity: 0.78; + } + .ac-bar-value { + color: #343a45; + font-size: 10px; + font-weight: 600; + } + .ac-bar-group span { overflow: hidden; text-overflow: ellipsis; max-width: 100%; } + .trend-chart-empty { + width: 100%; + height: 100%; + display: grid; + place-items: center; + color: var(--st-muted); + font-size: 13px; + } + .trend-foot { display: flex; gap: 14px; margin: 0 20px 16px; padding-top: 10px; border-top: 1px solid var(--st-line); font-size: 12px; } .trend-foot .item { display: flex; align-items: baseline; gap: 6px; } .trend-foot .item .k { color: var(--st-muted); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; } .trend-foot .item .v { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--st-text); } diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 2a037d8..29f8d10 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -1,8 +1,8 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { AlertCircle, Boxes, Check, Info, LogOut, Settings, UsersRound } from "lucide-react"; +import { AlertCircle, Boxes, Check, ChevronDown, Info, LogOut, Settings, UsersRound } from "lucide-react"; import { IconKitSvg } from "./IconKitSvg"; -import { useBodyScrollLock } from "./overlays"; +import { useBodyScrollLock, useOverlayTransition } from "./overlays"; import type { Product, Project, Team, User } from "../types"; import type { Notice, Page } from "../routes/route-config"; @@ -18,8 +18,8 @@ const SHELL_COMMANDS: Command[] = [ { id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" }, { id: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" }, { id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" }, - { id: "account", group: "导航", label: "消费", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" }, - { id: "settings", group: "导航", label: "设置", sub: "个人信息、通知、安全、偏好", page: "settings", icon: "settings" }, + { id: "account", group: "导航", label: "账单库", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" }, + { id: "settings", group: "导航", label: "系统设置", sub: "个人信息、通知、安全、偏好", page: "settings", icon: "settings" }, { id: "messages", group: "常用动作", label: "消息中心", sub: "任务提醒、协作评论、系统通知", page: "messages", icon: "bell", key: "M" }, { id: "new-product", group: "常用动作", label: "新建商品", sub: "从商品信息开始生成素材与视频", page: "productCreateUpload", icon: "productPlus" }, { id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" }, @@ -30,6 +30,7 @@ const SHELL_COMMANDS: Command[] = [ function CommandPalette({ open, onClose, navigate, canManageBilling = true }: { open: boolean; onClose: () => void; navigate: Navigate; canManageBilling?: boolean }) { const [query, setQuery] = useState(""); + const { mounted, show } = useOverlayTransition(open, onClose, 220); useBodyScrollLock(open); useEffect(() => { if (open) setQuery(""); }, [open]); const items = useMemo(() => { @@ -40,13 +41,13 @@ function CommandPalette({ open, onClose, navigate, canManageBilling = true }: { .filter((cmd) => !q || [cmd.label, cmd.sub, cmd.group, cmd.id].join(" ").toLowerCase().includes(q)); }, [query, canManageBilling]); const run = (cmd: Command) => { onClose(); navigate(cmd.page); }; - if (!open) return null; + if (!mounted) return null; let lastGroup = ""; return createPortal(
{ if (event.target === event.currentTarget) onClose(); }} >
@@ -108,11 +109,12 @@ 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: "消费与余额" } + { act: "account", icon: "creditCard", label: "账单库" } ]; -export function AccountMenu({ anchorRect, onClose, navigate, logout, user, team, canManageBilling = true }: { - anchorRect: DOMRect; +export function AccountMenu({ open, anchorRect, onClose, navigate, logout, user, team, canManageBilling = true }: { + open: boolean; + anchorRect: DOMRect | null; onClose: () => void; navigate: Navigate; logout: () => void; @@ -120,24 +122,32 @@ export function AccountMenu({ anchorRect, onClose, navigate, logout, user, team, team: Team | null; canManageBilling?: boolean; }) { + const { mounted, show } = useOverlayTransition(open, undefined, 180); const menuRef = useRef(null); - const [pos, setPos] = useState<{ left: number; top: number }>({ left: anchorRect.left, top: anchorRect.bottom + 8 }); + const lastRect = useRef(anchorRect); + if (anchorRect) lastRect.current = anchorRect; + const rect = lastRect.current; + const [pos, setPos] = useState<{ left: number; top: number }>(() => ( + rect ? { left: rect.left, top: rect.bottom + 8 } : { left: 0, top: 0 } + )); // 量出真实菜单尺寸后再定位(右对齐锚点、上下翻转防溢出),与 V1 toggleAccountMenu 一致 useLayoutEffect(() => { + if (!rect) return; 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; + let left = rect.right - width; + if (left < 12) left = rect.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); + let top = rect.bottom + 8; + if (top + height > window.innerHeight - 12) top = Math.max(12, rect.top - height - 8); setPos({ left, top }); - }, [anchorRect]); + }, [rect]); // 点菜单外部 / Esc → 收起(锚点本身由触发器的 toggle 处理,避免点锚点立刻又被关掉) useEffect(() => { + if (!open) return; const onDown = (event: MouseEvent) => { const target = event.target as Node; if (menuRef.current?.contains(target)) return; @@ -151,15 +161,16 @@ export function AccountMenu({ anchorRect, onClose, navigate, logout, user, team, document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; - }, [onClose]); + }, [open, onClose]); const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase(); const go = (page: Page) => { onClose(); navigate(page); }; + if (!mounted || !rect) return null; return createPortal(