Files
omniai-web/src/components/AppShell.tsx
T

542 lines
20 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
import { toast } from "./toast/toastStore";
import type { ServerConnectionHealth } from "../api/serverConnection";
import { ossAssets } from "../data/ossAssets";
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types";
import NotificationCenter from "./NotificationCenter";
import BetaApplicationModal from "./BetaApplicationModal";
import { AnimatedPanel } from "./AnimatedPanel";
import AdminMonitor from "./AdminMonitor";
import CookieConsentBanner from "./CookieConsentBanner";
import { loadRechargeModal, type RechargeModalComponent } from "./RechargeModal/loadRechargeModal";
import { ShellIcon } from "./ShellIcon";
import { loadDarkGreenTheme } from "../styles/loadDarkGreenTheme";
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 = ossAssets.brand.logo;
const TOOL_SURFACE_VIEW_SET = new Set<WebViewKey>([
"workbench",
"canvas",
"more",
"scriptTokens",
"tokenUsage",
"ecommerceTemplates",
"sizeTemplate",
"imageWorkbench",
"resolutionUpscale",
"digitalHuman",
"dialogGenerator",
"avatarConsole",
"characterMix",
] as WebViewKey[]);
const PRIMARY_NAV_ORDER: WebViewKey[] = [
"workbench",
"ecommerce",
"sizeTemplate",
"canvas",
"scriptTokens",
"tokenUsage",
"more",
"assets",
"community",
];
function formatBalance(cents: number): string {
const value = Math.max(0, cents) / 100;
return `${value.toFixed(2)} 积分`;
}
function canReviewBetaApplications(session: WebUserSession | null): boolean {
const role = String(session?.user.role || "").trim().toLowerCase();
const username = String(session?.user.username || "").trim().toLowerCase();
return role === "admin" || username === "xqy1912";
}
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 [RechargeModal, setRechargeModal] = useState<RechargeModalComponent | null>(null);
const [infoOpen, setInfoOpen] = useState(false);
const [betaOpen, setBetaOpen] = useState(false);
const infoRef = useRef<HTMLDivElement>(null);
const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null);
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
const prevActiveViewRef = useRef<WebViewKey>(activeView);
const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null);
const isAuthView = activeView === "login";
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home";
const showPageScrollActions = showFloatingNav && !TOOL_SURFACE_VIEW_SET.has(activeView);
const visibleNavItems = useMemo(
() => {
const navItemByKey = new Map(navItems.map((item) => [item.key, item]));
return PRIMARY_NAV_ORDER
.map((key) => navItemByKey.get(key))
.filter((item): item is WebNavItem => Boolean(item));
},
[navItems],
);
useEffect(() => {
if (activeView !== prevActiveViewRef.current) {
setNavJustActivated(activeView);
prevActiveViewRef.current = activeView;
const timer = window.setTimeout(() => setNavJustActivated(null), 320);
return () => window.clearTimeout(timer);
}
}, [activeView]);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
void loadDarkGreenTheme();
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(() => {
let cancelled = false;
publicConfigClient
.get()
.then((config) => {
if (!cancelled) setPublicConfig(config);
})
.catch(() => {
if (!cancelled) setPublicConfig({});
});
return () => {
cancelled = true;
};
}, []);
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 (!infoOpen) return;
const handleInfoOutside = (event: PointerEvent) => {
if (!infoRef.current?.contains(event.target as Node)) {
setInfoOpen(false);
}
};
document.addEventListener("pointerdown", handleInfoOutside);
return () => document.removeEventListener("pointerdown", handleInfoOutside);
}, [infoOpen]);
useEffect(() => {
if (!session) {
setProfileOpen(false);
}
}, [session]);
useEffect(() => {
return () => {
if (submenuHideTimerRef.current) {
window.clearTimeout(submenuHideTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!rechargeOpen || RechargeModal) return;
let cancelled = false;
void loadRechargeModal().then((component) => {
if (!cancelled) {
setRechargeModal(() => component);
}
});
return () => {
cancelled = true;
};
}, [RechargeModal, rechargeOpen]);
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 showCommunityReview = canReviewCommunity(session);
const showCommunityCaseAdd = canManageCommunityCases(session);
const showBetaApplicationReview = canReviewBetaApplications(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" : ""}${
navJustActivated === item.key ? " nav-just-activated" : ""
}${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")}
>
<ShellIcon name="arrow-up" />
</button>
<button
type="button"
className="floating-page-scroll-actions__button"
title="到达页面底部"
aria-label="到达页面底部"
onClick={() => scrollActivePage("bottom")}
>
<ShellIcon name="arrow-down" />
</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">
<button
type="button"
className="beta-apply-button"
title="内测申请"
aria-label="内测申请"
onClick={() => setBetaOpen(true)}
>
</button>
{session && (
<NotificationCenter
items={notifications}
onNavigate={(view, _targetId) => onSelectView(view)}
onMarkRead={onMarkNotificationRead}
onMarkAllRead={onMarkAllNotificationsRead}
/>
)}
<div className="info-popover-anchor" ref={infoRef}>
<button
className="info-button"
type="button"
aria-label="网站信息"
onClick={() => setInfoOpen((c) => !c)}
>
<ShellIcon name="info-circle" />
</button>
<AnimatedPanel open={infoOpen} className="info-popover panel-surface">
<dl>
<dt></dt>
<dd>{publicConfig.icpRecord || "由服务器配置"}</dd>
<dt></dt>
<dd>{publicConfig.companyAddress || "由服务器配置"}</dd>
<dt></dt>
<dd>{publicConfig.contactPhone || "由服务器配置"}</dd>
</dl>
<div className="info-popover__links">
<a href="#/bug-feedback" onClick={() => setInfoOpen(false)}>Bug </a>
<a href="#/userAgreement" onClick={() => setInfoOpen(false)}></a>
<a href="#/privacyPolicy" onClick={() => setInfoOpen(false)}></a>
</div>
</AnimatedPanel>
</div>
<button
className="member-button"
type="button"
aria-label={`积分余额 ${displayedBalanceLabel}`}
onClick={() => toast.info("充值功能即将开放,敬请期待")}
>
<ShellIcon name="wallet" />
<span className="member-button__label">{displayedBalanceLabel}</span>
</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>
</>
) : (
<>
<ShellIcon name="login" />
<span> / </span>
</>
)}
</button>
<AnimatedPanel open={session ? profileOpen : false} 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>{session?.source === "server" ? "服务器会话" : "预览会话"}</span>
<button type="button" onClick={onLogout}>
<ShellIcon name="logout" />
退
</button>
</div>
<button
type="button"
className="profile-popover__center-btn"
onClick={() => {
setProfileOpen(false);
onSelectView("login");
}}
>
<ShellIcon name="user" />
</button>
<button
type="button"
className="profile-popover__report-btn"
onClick={() => {
setProfileOpen(false);
onSelectView("report");
}}
>
<ShellIcon name="flag" />
Bug
</button>
{showCommunityReview ? (
<>
<button
type="button"
className="profile-popover__review-btn"
onClick={() => {
setProfileOpen(false);
onSelectView("communityReview");
}}
>
<ShellIcon name="check-circle" />
</button>
</>
) : null}
{showBetaApplicationReview ? (
<button
type="button"
className="profile-popover__review-btn"
onClick={() => {
setProfileOpen(false);
onSelectView("betaApplications");
}}
>
<ShellIcon name="check-circle" />
</button>
) : null}
{showCommunityCaseAdd ? (
<>
<button
type="button"
className="profile-popover__review-btn"
onClick={() => {
setProfileOpen(false);
onSelectView("communityCaseAdd");
}}
>
<ShellIcon name="plus-circle" />
</button>
</>
) : null}
</AnimatedPanel>
</div>
</div>
</header>
) : null}
<div className="web-shell__page">{children}</div>
</main>
</div>
{session?.user.role === "admin" ? <AdminMonitor /> : null}
{rechargeOpen && RechargeModal ? (
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
) : null}
<BetaApplicationModal open={betaOpen} onClose={() => setBetaOpen(false)} />
</div>
);
}
export default AppShell;