优化UI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user