This commit is contained in:
Azmat@qq.com
2026-08-20 12:52:05 +08:00
parent c7520e93b7
commit ba1613f2b5
32 changed files with 336 additions and 139 deletions
+99 -72
View File
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AlertCircle, Boxes, Check, ChevronDown, Info, LogOut, Settings, UsersRound } from "lucide-react";
import { IconKitSvg } from "./IconKitSvg";
import { useBodyScrollLock, useOverlayTransition } from "./overlays";
import { ConfirmModal, useBodyScrollLock, useOverlayTransition } from "./overlays";
import type { Product, Project, Team, User } from "../types";
import type { Notice, Page } from "../routes/route-config";
@@ -165,37 +165,50 @@ export function AccountMenu({ open, anchorRect, onClose, navigate, logout, user,
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
const go = (page: Page) => { onClose(); navigate(page); };
if (!mounted || !rect) return null;
const [confirmLogout, setConfirmLogout] = useState(false);
return createPortal(
<div
ref={menuRef}
className={`shell-account-menu${show ? " show" : ""}`}
id="shell-account-menu"
role="menu"
aria-label="账户菜单"
style={{ left: pos.left, top: pos.top }}
>
<div className="shell-account-head">
<span className="av">{user.avatar_url ? <img src={user.avatar_url} alt="头像" /> : avatar}</span>
<span>
<span className="nm">{team?.name || user.username}</span>
<span className="mail">{user.username}</span>
</span>
</div>
{ACCOUNT_ITEMS.filter((item) => canManageBilling || (item.act !== "team" && item.act !== "account")).map((item) => (
<button key={item.act} type="button" role="menuitem" onClick={() => go(item.act)}>
<IconKitSvg name={item.icon} />
{item.label}
</button>
))}
<div className="sep" />
<button type="button" role="menuitem" onClick={() => { onClose(); logout(); }}>
<LogOut size={14} />
退
</button>
</div>,
document.body
return (
<>
{mounted && rect ? createPortal(
<div
ref={menuRef}
className={`shell-account-menu${show ? " show" : ""}`}
id="shell-account-menu"
role="menu"
aria-label="账户菜单"
style={{ left: pos.left, top: pos.top }}
>
<div className="shell-account-head">
<span className="av">{user.avatar_url ? <img src={user.avatar_url} alt="头像" /> : avatar}</span>
<span>
<span className="nm">{team?.name || user.username}</span>
<span className="mail">{user.username}</span>
</span>
</div>
{ACCOUNT_ITEMS.filter((item) => canManageBilling || (item.act !== "team" && item.act !== "account")).map((item) => (
<button key={item.act} type="button" role="menuitem" onClick={() => go(item.act)}>
<IconKitSvg name={item.icon} />
{item.label}
</button>
))}
<div className="sep" />
<button type="button" role="menuitem" onClick={() => { onClose(); setConfirmLogout(true); }}>
<LogOut size={14} />
退
</button>
</div>,
document.body
) : null}
<ConfirmModal
open={confirmLogout}
title="退出当前账号"
icon={<LogOut size={16} />}
detail="确认后将退出当前设备,再次使用需要重新登录。项目、资产、团队成员与余额数据都会保留。"
confirmText="确认退出"
onCancel={() => setConfirmLogout(false)}
onConfirm={() => { setConfirmLogout(false); logout(); }}
/>
</>
);
}
@@ -262,93 +275,107 @@ const MODE_TABS: { id: TopModule; label: string; page: Page }[] = [
{ id: "video", label: "视频创作", page: "projects" },
];
// 顶栏三按钮滑动黑胶囊 · 对照影擎 .mode-tabs / moveModeIndicator 原样接入:
// clip-path 只露出三个按钮形状,胶囊在缝里看不见;位移走 transform,不在 React state 里改尺寸。
// 顶栏三按钮滑动黑胶囊 · 结构 / 类名 / moveModeIndicator 均按影擎 HTML 原样转写。
export function ModeTabs({ active, navigate }: { active: TopModule | null; navigate: Navigate }) {
const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);
const clipRectsRef = useRef<Array<SVGRectElement | null>>([]);
const rootRef = useRef<HTMLElement>(null);
const indicatorRef = useRef<HTMLSpanElement>(null);
const firstPaint = useRef(true);
const activeRef = useRef(active);
const [visualActive, setVisualActive] = useState(active);
activeRef.current = visualActive ?? active;
useLayoutEffect(() => {
setVisualActive(active);
}, [active]);
const moveModeIndicator = (tab: HTMLButtonElement, animate = true) => {
btnRefs.current.forEach((item, index) => {
const rect = clipRectsRef.current[index];
if (!item || !rect) return;
const root = rootRef.current;
const modeIndicator = indicatorRef.current;
if (!root || !modeIndicator) return;
const modeTabs = [...root.querySelectorAll<HTMLButtonElement>(".mode-tab")];
const modeMaskRects = [...root.querySelectorAll<SVGRectElement>("#modeButtonMask rect")];
modeTabs.forEach((item, index) => {
const rect = modeMaskRects[index];
if (!rect) return;
rect.setAttribute("x", String(item.offsetLeft));
rect.setAttribute("y", "0");
rect.setAttribute("width", String(item.offsetWidth));
rect.setAttribute("height", String(item.offsetHeight));
});
const modeIndicator = indicatorRef.current;
if (!modeIndicator) return;
if (!animate) modeIndicator.style.transition = "none";
else {
modeIndicator.style.transition = "transform 420ms cubic-bezier(0.22, 1, 0.36, 1), width 320ms cubic-bezier(0.22, 1, 0.36, 1)";
}
modeIndicator.style.width = `${tab.offsetWidth}px`;
modeIndicator.style.transform = `translateX(${tab.offsetLeft}px)`;
if (!animate) {
void modeIndicator.offsetWidth;
modeIndicator.style.transition = "";
modeIndicator.style.transition = "transform 420ms cubic-bezier(0.22, 1, 0.36, 1), width 320ms cubic-bezier(0.22, 1, 0.36, 1)";
}
};
const activeButton = () => {
const idx = active ? MODE_TABS.findIndex((tab) => tab.id === active) : -1;
return idx >= 0 ? btnRefs.current[idx] : null;
const tabFor = (id: TopModule | null) => {
const root = rootRef.current;
if (!root || !id) return null;
const idx = MODE_TABS.findIndex((tab) => tab.id === id);
return idx >= 0 ? root.querySelectorAll<HTMLButtonElement>(".mode-tab")[idx] ?? null : null;
};
const firstPaint = useRef(true);
useLayoutEffect(() => {
const tab = activeButton();
const tab = tabFor(active);
if (tab) moveModeIndicator(tab, !firstPaint.current);
firstPaint.current = false;
}, [active]);
useEffect(() => {
const snap = () => {
const tab = activeButton();
const tab = tabFor(activeRef.current);
if (tab) moveModeIndicator(tab, false);
};
window.addEventListener("resize", snap);
void document.fonts?.ready.then(snap);
return () => window.removeEventListener("resize", snap);
}, [active]);
}, []);
return (
<div
className={`yz-mods${active ? "" : " no-active"}`}
role="tablist"
aria-label="作模"
<nav
ref={rootRef}
className={`mode-tabs${visualActive || active ? "" : " no-active"}`}
aria-label="作模"
>
<svg className="yz-mod-clip-defs" width="0" height="0" aria-hidden="true" focusable="false">
{/* clip-path:url(#id) 必须写在文档内 <style> 里,外部 CSS 文件解析不到这个 SVG id */}
<style>{`
.mode-tabs .mode-indicator-mask {
clip-path: url(#modeButtonMask);
-webkit-clip-path: url(#modeButtonMask);
}
`}</style>
<svg className="mode-clip-defs" width="0" height="0" aria-hidden="true" focusable="false">
<defs>
<clipPath id="yzModeButtonMask" clipPathUnits="userSpaceOnUse">
<rect ref={(el) => { clipRectsRef.current[0] = el; }} rx="22" ry="22" x="0" y="0" width="104" height="44" />
<rect ref={(el) => { clipRectsRef.current[1] = el; }} rx="22" ry="22" x="120" y="0" width="110" height="44" />
<rect ref={(el) => { clipRectsRef.current[2] = el; }} rx="22" ry="22" x="246" y="0" width="110" height="44" />
<clipPath id="modeButtonMask" clipPathUnits="userSpaceOnUse">
<rect rx="22" ry="22" x="0" y="0" width="104" height="44" />
<rect rx="22" ry="22" x="120" y="0" width="110" height="44" />
<rect rx="22" ry="22" x="246" y="0" width="110" height="44" />
</clipPath>
</defs>
</svg>
<span className="yz-mod-indicator-mask" aria-hidden="true">
<span
ref={indicatorRef}
className="yz-mod-indicator"
style={{ width: 104, transform: "translateX(0px)" }}
/>
<span className="mode-indicator-mask" aria-hidden="true">
<span ref={indicatorRef} className="mode-indicator" id="modeIndicator" />
</span>
{MODE_TABS.map((tab, index) => (
{MODE_TABS.map((tab) => (
<button
key={tab.id}
ref={(el) => { btnRefs.current[index] = el; }}
className={`yz-mod${active === tab.id ? " active" : ""}`}
className={`mode-tab${(visualActive ?? active) === tab.id ? " active" : ""}`}
type="button"
role="tab"
aria-selected={active === tab.id}
onClick={() => {
const btn = btnRefs.current[index];
if (btn) moveModeIndicator(btn, true);
data-page={tab.id}
onClick={(event) => {
setVisualActive(tab.id);
moveModeIndicator(event.currentTarget, true);
navigate(tab.page);
}}
>{tab.label}</button>
))}
</div>
</nav>
);
}