refactor: extract Topbar and LocalAvatar components from App.tsx
This commit is contained in:
@@ -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