Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
CheckCircleOutlined,
|
||||
FlagOutlined,
|
||||
LoginOutlined,
|
||||
LogoutOutlined,
|
||||
PlusCircleOutlined,
|
||||
UserOutlined,
|
||||
WalletOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ServerConnectionHealth } from "../api/serverConnection";
|
||||
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
|
||||
import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types";
|
||||
import NotificationCenter from "./NotificationCenter";
|
||||
import { RechargeModal } from "./RechargeModal/RechargeModal";
|
||||
|
||||
interface AppShellProps {
|
||||
activeView: WebViewKey;
|
||||
navItems: WebNavItem[];
|
||||
session: WebUserSession | null;
|
||||
usage: WebUsageSummary;
|
||||
notifications: WebNotification[];
|
||||
backendHealth: ServerConnectionHealth;
|
||||
workspaceExpanded: boolean;
|
||||
onSelectView: (view: WebViewKey) => void;
|
||||
onLogout: () => void;
|
||||
onOpenLogin: () => void;
|
||||
onMarkNotificationRead?: (id: string, isRead?: boolean) => void;
|
||||
onMarkAllNotificationsRead?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const BRAND_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png";
|
||||
|
||||
function formatBalance(cents: number): string {
|
||||
const value = Math.max(0, cents) / 100;
|
||||
return `${value.toFixed(2)} 积分`;
|
||||
}
|
||||
|
||||
function AppShell({
|
||||
activeView,
|
||||
navItems,
|
||||
session,
|
||||
usage,
|
||||
notifications,
|
||||
backendHealth,
|
||||
workspaceExpanded,
|
||||
onSelectView,
|
||||
onLogout,
|
||||
onOpenLogin,
|
||||
onMarkNotificationRead,
|
||||
onMarkAllNotificationsRead,
|
||||
children,
|
||||
}: AppShellProps) {
|
||||
const activePackage = session?.user.activePackages?.[0];
|
||||
const profileRef = useRef<HTMLDivElement>(null);
|
||||
const submenuHideTimerRef = useRef<number | null>(null);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [rechargeOpen, setRechargeOpen] = useState(false);
|
||||
const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null);
|
||||
const isAuthView = activeView === "login";
|
||||
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
|
||||
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home";
|
||||
const toolSurfaceViews = [
|
||||
"workbench",
|
||||
"canvas",
|
||||
"more",
|
||||
"scriptTokens",
|
||||
"tokenUsage",
|
||||
"ecommerceTemplates",
|
||||
"sizeTemplate",
|
||||
"imageWorkbench",
|
||||
"resolutionUpscale",
|
||||
"digitalHuman",
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
] as WebViewKey[];
|
||||
const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView);
|
||||
|
||||
const visibleNavItems = useMemo(
|
||||
() => {
|
||||
const orderedKeys: WebViewKey[] = [
|
||||
"workbench",
|
||||
"ecommerce",
|
||||
"sizeTemplate",
|
||||
"canvas",
|
||||
"scriptTokens",
|
||||
"tokenUsage",
|
||||
"community",
|
||||
"assets",
|
||||
"more",
|
||||
];
|
||||
return orderedKeys
|
||||
.map((key) => navItems.find((item) => item.key === key))
|
||||
.filter((item): item is WebNavItem => Boolean(item));
|
||||
},
|
||||
[navItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.dataset.theme = "dark";
|
||||
document.documentElement.dataset.uiTheme = "dark-green";
|
||||
document.documentElement.style.colorScheme = "dark";
|
||||
|
||||
const metaThemeColor = document.querySelector<HTMLMetaElement>("meta[name='theme-color']");
|
||||
if (metaThemeColor) {
|
||||
metaThemeColor.content = "#0d0d0f";
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileOpen) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!profileRef.current?.contains(event.target as Node)) {
|
||||
setProfileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [profileOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
setProfileOpen(false);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (submenuHideTimerRef.current) {
|
||||
window.clearTimeout(submenuHideTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showSubmenu = (key: WebViewKey) => {
|
||||
if (submenuHideTimerRef.current) {
|
||||
window.clearTimeout(submenuHideTimerRef.current);
|
||||
submenuHideTimerRef.current = null;
|
||||
}
|
||||
setOpenSubmenuKey(key);
|
||||
};
|
||||
|
||||
const scheduleHideSubmenu = () => {
|
||||
if (submenuHideTimerRef.current) {
|
||||
window.clearTimeout(submenuHideTimerRef.current);
|
||||
}
|
||||
submenuHideTimerRef.current = window.setTimeout(() => {
|
||||
setOpenSubmenuKey(null);
|
||||
submenuHideTimerRef.current = null;
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const scrollActivePage = (direction: "top" | "bottom") => {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const targets = [
|
||||
...Array.from(document.querySelectorAll<HTMLElement>(".web-shell__page")),
|
||||
...Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
".workbench-landing-page, .ecommerce-landing-page, .workspace-page-shell__content, .community-page",
|
||||
),
|
||||
),
|
||||
document.scrollingElement as HTMLElement | null,
|
||||
].filter((target): target is HTMLElement => Boolean(target));
|
||||
|
||||
targets.forEach((target) => {
|
||||
const top = direction === "top" ? 0 : target.scrollHeight;
|
||||
target.scrollTo({ top, behavior: "smooth" });
|
||||
});
|
||||
};
|
||||
|
||||
const displayName = session?.user.displayName || session?.user.username || "预览创作者";
|
||||
const avatarLabel = displayName.trim().slice(0, 1).toUpperCase() || "创";
|
||||
const avatarUrl = session?.user.avatarUrl || null;
|
||||
const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise");
|
||||
const displayedBalanceCents =
|
||||
session && isEnterpriseAccount
|
||||
? (usage.enterpriseBalanceCents ?? session.user.enterpriseBalanceCents ?? usage.balanceCents)
|
||||
: usage.balanceCents;
|
||||
const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分";
|
||||
const isPreviewSession = session?.source === "mock-fallback";
|
||||
const showCommunityReview = canReviewCommunity(session);
|
||||
const showCommunityCaseAdd = canManageCommunityCases(session);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`web-shell event-strip-hidden${
|
||||
isAuthView ? " is-auth-view" : ""
|
||||
}${isImmersiveView ? " is-immersive-view" : ""}`}
|
||||
data-theme="dark"
|
||||
data-ui-theme="dark-green"
|
||||
data-view={activeView}
|
||||
>
|
||||
<div className="web-shell__stage">
|
||||
{showFloatingNav ? (
|
||||
<aside
|
||||
className={`floating-nav${workspaceExpanded ? " is-expanded" : " is-browse"}`}
|
||||
aria-label="网页端功能导航"
|
||||
>
|
||||
{visibleNavItems.map((item, index) => {
|
||||
const childActive = item.children?.some((child) => child.key === activeView) ?? false;
|
||||
const isActive = activeView === item.key || childActive;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`floating-nav__item${item.children?.length ? " has-children" : ""}${
|
||||
isActive ? " is-active" : ""
|
||||
}${openSubmenuKey === item.key ? " is-submenu-open" : ""}`}
|
||||
onMouseEnter={item.children?.length ? () => showSubmenu(item.key) : undefined}
|
||||
onMouseLeave={item.children?.length ? scheduleHideSubmenu : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`floating-nav__button${isActive ? " is-active" : ""}${
|
||||
workspaceExpanded && index === 3 ? " has-divider" : ""
|
||||
}`}
|
||||
title={`${item.label} / ${item.hint}`}
|
||||
aria-label={item.label}
|
||||
onClick={() => onSelectView(item.children?.[0]?.key ?? item.key)}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="floating-nav__label">{item.label}</span>
|
||||
</button>
|
||||
{item.children?.length ? (
|
||||
<div className="floating-nav__submenu" aria-label={`${item.label} 子导航`}>
|
||||
{item.children.map((child) => (
|
||||
<button
|
||||
key={child.key}
|
||||
type="button"
|
||||
className={`floating-nav__subbutton${activeView === child.key ? " is-active" : ""}`}
|
||||
title={`${child.label} / ${child.hint}`}
|
||||
aria-label={child.label}
|
||||
onClick={() => onSelectView(child.key)}
|
||||
>
|
||||
{child.icon}
|
||||
<span>{child.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
) : null}
|
||||
{showPageScrollActions ? (
|
||||
<div className="floating-page-scroll-actions" aria-label="页面滚动">
|
||||
<button
|
||||
type="button"
|
||||
className="floating-page-scroll-actions__button"
|
||||
title="返回页面顶部"
|
||||
aria-label="返回页面顶部"
|
||||
onClick={() => scrollActivePage("top")}
|
||||
>
|
||||
<ArrowUpOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="floating-page-scroll-actions__button"
|
||||
title="到达页面底部"
|
||||
aria-label="到达页面底部"
|
||||
onClick={() => scrollActivePage("bottom")}
|
||||
>
|
||||
<ArrowDownOutlined />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<main className="web-shell__content">
|
||||
{!isImmersiveView ? (
|
||||
<header className="web-topbar">
|
||||
<button className="brand-lockup" type="button" onClick={() => onSelectView("home")}>
|
||||
<span className="brand-lockup__mark" aria-hidden="true">
|
||||
<img className="brand-lockup__logo" src={BRAND_LOGO_URL} alt="" />
|
||||
</span>
|
||||
<span className="brand-lockup__name">OmniAI</span>
|
||||
</button>
|
||||
<div className="web-topbar__actions">
|
||||
{session && (
|
||||
<NotificationCenter
|
||||
items={notifications}
|
||||
onNavigate={(view, _targetId) => onSelectView(view)}
|
||||
onMarkRead={onMarkNotificationRead}
|
||||
onMarkAllRead={onMarkAllNotificationsRead}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="member-button"
|
||||
type="button"
|
||||
aria-label={`积分余额 ${displayedBalanceLabel}`}
|
||||
onClick={() => setRechargeOpen(true)}
|
||||
>
|
||||
<WalletOutlined />
|
||||
{displayedBalanceLabel}
|
||||
</button>
|
||||
<div className="profile-popover-anchor" ref={profileRef}>
|
||||
<button
|
||||
className={`profile-button${session ? " profile-button--member" : " profile-button--guest"}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (session) {
|
||||
setProfileOpen((current) => !current);
|
||||
} else {
|
||||
onOpenLogin();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{session ? (
|
||||
<>
|
||||
<span className="profile-button__avatar">
|
||||
{avatarUrl ? <img src={avatarUrl} alt={displayName} /> : avatarLabel}
|
||||
</span>
|
||||
<span>{displayName}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LoginOutlined />
|
||||
<span>登录 / 注册</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{session && profileOpen ? (
|
||||
<div className="profile-popover panel-surface">
|
||||
<div className="profile-popover__head">
|
||||
<span className="profile-popover__avatar">
|
||||
{avatarUrl ? <img src={avatarUrl} alt={displayName} /> : avatarLabel}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{displayName}</strong>
|
||||
<span>{session ? session.user.role || "已登录" : "预览模式"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="profile-popover__stats">
|
||||
<dt>UID</dt>
|
||||
<dd>{session?.user.id || "preview"}</dd>
|
||||
<dt>{isEnterpriseAccount ? "企业积分" : "积分"}</dt>
|
||||
<dd>{displayedBalanceLabel}</dd>
|
||||
<dt>图片</dt>
|
||||
<dd>{usage.imageUsed}</dd>
|
||||
<dt>视频</dt>
|
||||
<dd>{usage.videoUsed}</dd>
|
||||
</dl>
|
||||
<div className="profile-popover__footer">
|
||||
<span>{import.meta.env.VITE_KEY_SERVER_URL || "使用预览数据"}</span>
|
||||
<button type="button" onClick={onLogout}>
|
||||
<LogoutOutlined />
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__center-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("login");
|
||||
}}
|
||||
>
|
||||
<UserOutlined />
|
||||
个人中心
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__report-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("report");
|
||||
}}
|
||||
>
|
||||
<FlagOutlined />
|
||||
投诉举报
|
||||
</button>
|
||||
{showCommunityReview ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__review-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("communityReview");
|
||||
}}
|
||||
>
|
||||
<CheckCircleOutlined />
|
||||
社区审核
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{showCommunityCaseAdd ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__review-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("communityCaseAdd");
|
||||
}}
|
||||
>
|
||||
<PlusCircleOutlined />
|
||||
添加案例
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
) : null}
|
||||
<div className="web-shell__page">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppShell;
|
||||
Reference in New Issue
Block a user