refactor: extract Topbar and LocalAvatar components from App.tsx
This commit is contained in:
+33
-126
@@ -4,19 +4,16 @@ import {
|
|||||||
CheckCircleFilled,
|
CheckCircleFilled,
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
IdcardOutlined,
|
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
LoginOutlined,
|
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MailOutlined,
|
MailOutlined,
|
||||||
MobileOutlined,
|
MobileOutlined,
|
||||||
PictureOutlined,
|
|
||||||
SafetyOutlined,
|
SafetyOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
VideoCameraOutlined,
|
|
||||||
WalletOutlined,
|
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
|
import { LocalAvatar } from "./components/LocalAvatar";
|
||||||
|
import { Topbar } from "./components/Topbar";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import ToastContainer from "./components/toast/ToastContainer";
|
import ToastContainer from "./components/toast/ToastContainer";
|
||||||
import { toast } from "./components/toast/toastStore";
|
import { toast } from "./components/toast/toastStore";
|
||||||
@@ -40,6 +37,9 @@ const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
|
|||||||
|
|
||||||
type AuthMode = "login" | "register";
|
type AuthMode = "login" | "register";
|
||||||
type AuthMethod = "account" | "email" | "phone";
|
type AuthMethod = "account" | "email" | "phone";
|
||||||
|
type WorkspaceChromeState = {
|
||||||
|
isToolPage: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
interface LocalProfilePageProps {
|
interface LocalProfilePageProps {
|
||||||
session: WebUserSession;
|
session: WebUserSession;
|
||||||
@@ -51,17 +51,6 @@ interface LocalProfilePageProps {
|
|||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LocalAvatar({ session, size = "md" }: { session: WebUserSession; size?: "sm" | "md" | "lg" }) {
|
|
||||||
const displayName = session.user.displayName || session.user.username || "用户";
|
|
||||||
const label = displayName.trim().slice(0, 1).toUpperCase() || "用";
|
|
||||||
const avatarUrl = session.user.avatarUrl;
|
|
||||||
return (
|
|
||||||
<span className={`local-user-avatar local-user-avatar--${size}`}>
|
|
||||||
{avatarUrl ? <img src={avatarUrl} alt={displayName} /> : <span>{label}</span>}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, onBugFeedback, onLogout }: LocalProfilePageProps) {
|
function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, onBugFeedback, onLogout }: LocalProfilePageProps) {
|
||||||
const displayName = session.user.displayName || session.user.username || "用户";
|
const displayName = session.user.displayName || session.user.username || "用户";
|
||||||
const workCount = Math.max(imageCount + videoCount, 0);
|
const workCount = Math.max(imageCount + videoCount, 0);
|
||||||
@@ -166,6 +155,9 @@ function App() {
|
|||||||
const [sessionNotice, setSessionNotice] = useState<string | null>(null);
|
const [sessionNotice, setSessionNotice] = useState<string | null>(null);
|
||||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace");
|
const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace");
|
||||||
|
const [workspaceChrome, setWorkspaceChrome] = useState<WorkspaceChromeState>({
|
||||||
|
isToolPage: false,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadDarkGreenTheme();
|
void loadDarkGreenTheme();
|
||||||
@@ -318,20 +310,6 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const balance = Math.max(usage.balanceCents, 0) / 100;
|
const balance = Math.max(usage.balanceCents, 0) / 100;
|
||||||
const displayName = session?.user.displayName || session?.user.username || "用户";
|
|
||||||
const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0);
|
|
||||||
const shownWorkCount = actualWorkCount;
|
|
||||||
|
|
||||||
const avatarMenuStats = useMemo(
|
|
||||||
() => [
|
|
||||||
{ icon: <IdcardOutlined />, label: "UID", value: session?.user.id ?? "-" },
|
|
||||||
{ icon: <WalletOutlined />, label: "积分", value: `${balance.toFixed(2)} 积分` },
|
|
||||||
{ icon: <PictureOutlined />, label: "图片", value: usage.imageUsed },
|
|
||||||
{ icon: <VideoCameraOutlined />, label: "视频", value: usage.videoUsed },
|
|
||||||
{ icon: <PictureOutlined />, label: "作品", value: shownWorkCount },
|
|
||||||
],
|
|
||||||
[balance, session?.user.id, shownWorkCount, usage.imageUsed, usage.videoUsed],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpenProfile = () => {
|
const handleOpenProfile = () => {
|
||||||
setProfileMenuOpen(false);
|
setProfileMenuOpen(false);
|
||||||
@@ -349,106 +327,31 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ecommerce-standalone web-shell" data-theme="dark" data-ui-theme="dark-green" data-view="ecommerce">
|
<div
|
||||||
<header
|
className="ecommerce-standalone web-shell"
|
||||||
className="ecommerce-standalone__topbar"
|
data-theme="dark"
|
||||||
style={{
|
data-ui-theme="dark-green"
|
||||||
position: "fixed",
|
data-view="ecommerce"
|
||||||
top: 0,
|
data-workspace-tool-page={workspaceChrome.isToolPage ? "true" : "false"}
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 1000,
|
|
||||||
pointerEvents: "none",
|
|
||||||
background: "transparent !important",
|
|
||||||
border: "none !important",
|
|
||||||
boxShadow: "none !important",
|
|
||||||
backdropFilter: "none !important",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<button
|
<Topbar
|
||||||
type="button"
|
session={session}
|
||||||
className="ecommerce-standalone__brand"
|
usage={usage}
|
||||||
style={{ pointerEvents: "auto" }}
|
profileMenuOpen={profileMenuOpen}
|
||||||
onClick={handleOpenWorkspace}
|
onProfileMenuOpenChange={setProfileMenuOpen}
|
||||||
>
|
onOpenWorkspace={handleOpenWorkspace}
|
||||||
<span className="ecommerce-standalone__logo" aria-hidden="true">
|
onOpenProfile={handleOpenProfile}
|
||||||
<img src="https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png" alt="" />
|
onOpenAuth={openAuth}
|
||||||
</span>
|
onLogout={handleLogout}
|
||||||
<strong>OmniAI 电商智能体</strong>
|
onBugFeedback={handleBugFeedback}
|
||||||
</button>
|
|
||||||
<div className="ecommerce-standalone__account">
|
|
||||||
{session ? (
|
|
||||||
<div className="ecommerce-profile-menu">
|
|
||||||
<span className="ecommerce-standalone__credits">
|
|
||||||
{(Math.max(usage.balanceCents, 0) / 100).toFixed(2)} 积分
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ecommerce-profile-menu__trigger"
|
|
||||||
style={{ background: "transparent !important", border: "none !important", boxShadow: "none !important", pointerEvents: "auto" }}
|
|
||||||
onClick={() => setProfileMenuOpen((open) => !open)}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-expanded={profileMenuOpen}
|
|
||||||
>
|
|
||||||
<LocalAvatar session={session} size="sm" />
|
|
||||||
<span>{displayName}</span>
|
|
||||||
</button>
|
|
||||||
{profileMenuOpen ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ecommerce-profile-popover__backdrop"
|
|
||||||
aria-label="关闭账户信息"
|
|
||||||
onClick={() => setProfileMenuOpen(false)}
|
|
||||||
/>
|
/>
|
||||||
<section className="ecommerce-profile-popover" role="dialog" aria-label="账户信息">
|
|
||||||
<div className="ecommerce-profile-popover__head">
|
|
||||||
<LocalAvatar session={session} size="md" />
|
|
||||||
<div>
|
|
||||||
<strong>{displayName}</strong>
|
|
||||||
<span>{session.user.username}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="ecommerce-profile-popover__stats">
|
|
||||||
{avatarMenuStats.map((item) => (
|
|
||||||
<div key={item.label}>
|
|
||||||
<dt>{item.icon}{item.label}</dt>
|
|
||||||
<dd>{item.value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="ecommerce-profile-popover__actions">
|
|
||||||
<button type="button" className="is-primary" onClick={handleOpenProfile}>
|
|
||||||
<UserOutlined />
|
|
||||||
个人中心
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={handleBugFeedback}>
|
|
||||||
<BugOutlined />
|
|
||||||
Bug 反馈
|
|
||||||
</button>
|
|
||||||
<button type="button" className="is-danger" onClick={handleLogout}>
|
|
||||||
<LogoutOutlined />
|
|
||||||
退出
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button type="button" onClick={() => openAuth("login")}>
|
|
||||||
<LoginOutlined />
|
|
||||||
<span>登录 / 注册</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="ecommerce-standalone__content">
|
<main className="ecommerce-standalone__content">
|
||||||
{session ? (
|
{session ? (
|
||||||
<div className="ecommerce-standalone__page" hidden={currentPage !== "profile"}>
|
<div
|
||||||
|
className="ecommerce-standalone__page ecommerce-standalone__page--profile"
|
||||||
|
hidden={currentPage !== "profile"}
|
||||||
|
>
|
||||||
<LocalProfilePage
|
<LocalProfilePage
|
||||||
session={session}
|
session={session}
|
||||||
balance={balance}
|
balance={balance}
|
||||||
@@ -462,7 +365,10 @@ function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
{/* 工作台常驻挂载,仅用 hidden 切换。切到个人中心时不卸载,
|
{/* 工作台常驻挂载,仅用 hidden 切换。切到个人中心时不卸载,
|
||||||
生成任务、进度动画、已上传图片等本地状态全部保留,切回即继续。 */}
|
生成任务、进度动画、已上传图片等本地状态全部保留,切回即继续。 */}
|
||||||
<div className="ecommerce-standalone__page" hidden={Boolean(session) && currentPage === "profile"}>
|
<div
|
||||||
|
className="ecommerce-standalone__page ecommerce-standalone__page--workspace"
|
||||||
|
hidden={Boolean(session) && currentPage === "profile"}
|
||||||
|
>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
@@ -475,6 +381,7 @@ function App() {
|
|||||||
<EcommercePage
|
<EcommercePage
|
||||||
projects={[]}
|
projects={[]}
|
||||||
isAuthenticated={Boolean(session)}
|
isAuthenticated={Boolean(session)}
|
||||||
|
onWorkspaceChromeChange={setWorkspaceChrome}
|
||||||
onStartCreate={() => undefined}
|
onStartCreate={() => undefined}
|
||||||
onOpenProject={() => undefined}
|
onOpenProject={() => undefined}
|
||||||
onDeleteProject={() => undefined}
|
onDeleteProject={() => undefined}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { WebUserSession } from "../types";
|
||||||
|
|
||||||
|
interface LocalAvatarProps {
|
||||||
|
session: WebUserSession;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalAvatar({ session, size = "md" }: LocalAvatarProps) {
|
||||||
|
const displayName = session.user.displayName || session.user.username || "用户";
|
||||||
|
const label = displayName.trim().slice(0, 1).toUpperCase() || "用";
|
||||||
|
const avatarUrl = session.user.avatarUrl;
|
||||||
|
return (
|
||||||
|
<span className={`local-user-avatar local-user-avatar--${size}`}>
|
||||||
|
{avatarUrl ? <img src={avatarUrl} alt={displayName} /> : <span>{label}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
BugOutlined,
|
||||||
|
IdcardOutlined,
|
||||||
|
LoginOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
WalletOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { LocalAvatar } from "./LocalAvatar";
|
||||||
|
import type { WebUserSession } from "../types";
|
||||||
|
|
||||||
|
interface TopbarProps {
|
||||||
|
session: WebUserSession | null;
|
||||||
|
usage: { balanceCents: number; imageUsed: number; videoUsed: number };
|
||||||
|
profileMenuOpen: boolean;
|
||||||
|
onProfileMenuOpenChange: (open: boolean) => void;
|
||||||
|
onOpenWorkspace: () => void;
|
||||||
|
onOpenProfile: () => void;
|
||||||
|
onOpenAuth: (mode: "login" | "register") => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
onBugFeedback: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Topbar({
|
||||||
|
session,
|
||||||
|
usage,
|
||||||
|
profileMenuOpen,
|
||||||
|
onProfileMenuOpenChange,
|
||||||
|
onOpenWorkspace,
|
||||||
|
onOpenProfile,
|
||||||
|
onOpenAuth,
|
||||||
|
onLogout,
|
||||||
|
onBugFeedback,
|
||||||
|
}: TopbarProps) {
|
||||||
|
const [isTopbarHidden, setIsTopbarHidden] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let restoreTimer: number | undefined;
|
||||||
|
let cleanupScrollTarget: (() => void) | undefined;
|
||||||
|
|
||||||
|
const bindScrollTarget = () => {
|
||||||
|
cleanupScrollTarget?.();
|
||||||
|
const target = document.querySelector<HTMLElement>(
|
||||||
|
".ecommerce-standalone__page--workspace:not([hidden]) .clone-ai-preview",
|
||||||
|
);
|
||||||
|
if (!target) {
|
||||||
|
cleanupScrollTarget = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (profileMenuOpen) return;
|
||||||
|
setIsTopbarHidden(true);
|
||||||
|
if (restoreTimer) window.clearTimeout(restoreTimer);
|
||||||
|
restoreTimer = window.setTimeout(() => {
|
||||||
|
setIsTopbarHidden(false);
|
||||||
|
}, 240);
|
||||||
|
};
|
||||||
|
|
||||||
|
target.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
cleanupScrollTarget = () => target.removeEventListener("scroll", handleScroll);
|
||||||
|
};
|
||||||
|
|
||||||
|
bindScrollTarget();
|
||||||
|
const observer = new MutationObserver(bindScrollTarget);
|
||||||
|
observer.observe(document.body, { attributes: true, childList: true, subtree: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanupScrollTarget?.();
|
||||||
|
observer.disconnect();
|
||||||
|
if (restoreTimer) window.clearTimeout(restoreTimer);
|
||||||
|
};
|
||||||
|
}, [profileMenuOpen]);
|
||||||
|
|
||||||
|
const balance = Math.max(usage.balanceCents, 0) / 100;
|
||||||
|
const displayName = session?.user.displayName || session?.user.username || "用户";
|
||||||
|
const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0);
|
||||||
|
const shownWorkCount = actualWorkCount;
|
||||||
|
|
||||||
|
const avatarMenuStats = useMemo(
|
||||||
|
() => [
|
||||||
|
{ icon: <IdcardOutlined />, label: "UID", value: session?.user.id ?? "-" },
|
||||||
|
{ icon: <WalletOutlined />, label: "积分", value: `${balance.toFixed(2)} 积分` },
|
||||||
|
{ icon: <PictureOutlined />, label: "图片", value: usage.imageUsed },
|
||||||
|
{ icon: <VideoCameraOutlined />, label: "视频", value: usage.videoUsed },
|
||||||
|
{ icon: <PictureOutlined />, label: "作品", value: shownWorkCount },
|
||||||
|
],
|
||||||
|
[balance, session?.user.id, shownWorkCount, usage.imageUsed, usage.videoUsed],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className="ecommerce-standalone__topbar"
|
||||||
|
data-scroll-hidden={isTopbarHidden ? "true" : "false"}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: "none",
|
||||||
|
background: "transparent",
|
||||||
|
border: 0,
|
||||||
|
boxShadow: "none",
|
||||||
|
backdropFilter: "none",
|
||||||
|
WebkitBackdropFilter: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ecommerce-standalone__brand"
|
||||||
|
style={{ pointerEvents: "auto" }}
|
||||||
|
onClick={onOpenWorkspace}
|
||||||
|
>
|
||||||
|
<span className="ecommerce-standalone__logo" aria-hidden="true">
|
||||||
|
<img src="https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png" alt="" />
|
||||||
|
</span>
|
||||||
|
<strong>OmniAI 电商智能体</strong>
|
||||||
|
</button>
|
||||||
|
<div className="ecommerce-standalone__account">
|
||||||
|
{session ? (
|
||||||
|
<div className="ecommerce-profile-menu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ecommerce-profile-menu__trigger"
|
||||||
|
style={{ pointerEvents: "auto" }}
|
||||||
|
onClick={() => onProfileMenuOpenChange(!profileMenuOpen)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={profileMenuOpen}
|
||||||
|
>
|
||||||
|
<span className="ecommerce-standalone__credits">
|
||||||
|
{(Math.max(usage.balanceCents, 0) / 100).toFixed(2)} 积分
|
||||||
|
</span>
|
||||||
|
<LocalAvatar session={session} size="sm" />
|
||||||
|
<span className="ecommerce-profile-menu__name">{displayName}</span>
|
||||||
|
</button>
|
||||||
|
{profileMenuOpen ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ecommerce-profile-popover__backdrop"
|
||||||
|
aria-label="关闭账户信息"
|
||||||
|
onClick={() => onProfileMenuOpenChange(false)}
|
||||||
|
/>
|
||||||
|
<section className="ecommerce-profile-popover" role="dialog" aria-label="账户信息">
|
||||||
|
<div className="ecommerce-profile-popover__head">
|
||||||
|
<LocalAvatar session={session} size="md" />
|
||||||
|
<div>
|
||||||
|
<strong>{displayName}</strong>
|
||||||
|
<span>{session.user.username}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="ecommerce-profile-popover__stats">
|
||||||
|
{avatarMenuStats.map((item) => (
|
||||||
|
<div key={item.label}>
|
||||||
|
<dt>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</dt>
|
||||||
|
<dd>{item.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="ecommerce-profile-popover__actions">
|
||||||
|
<button type="button" className="is-primary" onClick={onOpenProfile}>
|
||||||
|
<UserOutlined />
|
||||||
|
个人中心
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onBugFeedback}>
|
||||||
|
<BugOutlined />
|
||||||
|
Bug 反馈
|
||||||
|
</button>
|
||||||
|
<button type="button" className="is-danger" onClick={onLogout}>
|
||||||
|
<LogoutOutlined />
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ecommerce-standalone__login-button"
|
||||||
|
style={{ pointerEvents: "auto" }}
|
||||||
|
onClick={() => onOpenAuth("login")}
|
||||||
|
>
|
||||||
|
<LoginOutlined />
|
||||||
|
<span>登录 / 注册</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user