import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Dropdown, Tooltip } from "@arco-design/web-react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useShallow } from "zustand/react/shallow"; import "./Layout.scss"; import { ROUTES } from "@/constants"; import { hasPendingExploreTemplateTabIntent } from "@/store/useExploreFeedTabStore"; import { isLayoutContentScrollLocked, isScrollEventFromLayoutModalMount, isScrollEventInLayoutContentScroll, setLayoutContentModalMount, setLayoutContentScrollRoot } from "@/layouts/layoutContentModalMount"; import { changeLanguage, type AppLanguage, SUPPORTED_LANGUAGES } from "@/i18n"; import { closeAssetDetailsModal, closeCharacterDetailModal, openGatewayApikeyDialog, openLoginDialog, openPointsDetailDialog, openPointsRechargeDialog } from "@/dialog"; import { canAccessRoute, guardRouteAccess } from "@/router/routeGuard"; import { useComposerStore } from "@/store/useComposerStore"; import { useUserStore } from "@/store/useUserStore"; import { getToken, removeToken } from "@/utils/auth"; import { buildCanvasUrl, CANVAS_BASE_URL } from "@/utils/canvasUrl"; import { openLatestPrivacyPolicyLink, PRIVACY_POLICY_KEY_AI_USE_NOTICE, PRIVACY_POLICY_KEY_PRIVACY_POLICY, PRIVACY_POLICY_KEY_USAGE_TUTORIAL, PRIVACY_POLICY_KEY_USER_TERMS } from "@/utils/openLatestPrivacyPolicyLink"; import { getParamConfig, logout } from "@/api/api"; import { useCopyText } from "@/hooks/useCopyText"; import { useLayoutViewportScale } from "@/hooks/useViewportScale"; import { memberLevelMap } from "@/constants"; import { GlobalPromptPanel } from "@/layouts/globalPromptPanel/globalPromptPanel"; interface LayoutProps { children?: ReactNode; } /** 左侧栏菜单项(文案 key 见 locales);`iconSrc` 为 `@/` 资源路径,在 JSX 内用 `new URL` 解析。 */ interface LayoutSideNavItem { id: string; labelKey: string; iconSrc: string; activeIconSrc?: string; to: string; badgeKey?: string; } interface LayoutSideNavIconProps { item: LayoutSideNavItem; } const LAYOUT_NAV_ASSETS = { logo: new URL("@/assets/images/layout/nav/popi-logo.png", import.meta.url).href, explore: new URL("@/assets/images/layout/nav/explore.png", import.meta.url).href, exploreActive: new URL("@/assets/images/layout/nav/explore_active.png", import.meta.url).href, characters: new URL("@/assets/images/layout/nav/characters.png", import.meta.url).href, charactersActive: new URL("@/assets/images/layout/nav/characters_active.png", import.meta.url).href, create: new URL("@/assets/images/layout/nav/create.png", import.meta.url).href, createActive: new URL("@/assets/images/layout/nav/create_active.png", import.meta.url).href, canvas: new URL("@/assets/images/layout/nav/popi-tv.png", import.meta.url).href, canvasActive: new URL("@/assets/images/layout/nav/popi-tv_active.png", import.meta.url).href, voice: new URL("@/assets/images/layout/nav/voice.png", import.meta.url).href, voiceActive: new URL("@/assets/images/layout/nav/voice_active.png", import.meta.url).href, subscribe: new URL("@/assets/images/layout/nav/subscribe.png", import.meta.url).href, subscribeActive: new URL("@/assets/images/layout/nav/subscribe_active.png", import.meta.url).href, profile: new URL("@/assets/images/layout/nav/profile.png", import.meta.url).href, profileActive: new URL("@/assets/images/layout/nav/profile_active.png", import.meta.url).href, more: new URL("@/assets/images/layout/nav/more.png", import.meta.url).href, topHelp: new URL("@/assets/images/layout/nav/top-language.png", import.meta.url).href, topLanguage: new URL("@/assets/images/layout/nav/top-help.png", import.meta.url).href, topService: new URL("@/assets/images/layout/nav/top-weixin.png", import.meta.url).href, topSkill: new URL("@/assets/images/layout/nav/top-skill.png", import.meta.url).href, } as const; // 侧栏图标顺序与 2026 7月官网 Figma 设计保持一致。 const LAYOUT_SIDE_NAV_ITEMS: readonly LayoutSideNavItem[] = [ { id: "explore", labelKey: "layout.nav.explore", iconSrc: LAYOUT_NAV_ASSETS.explore, activeIconSrc: LAYOUT_NAV_ASSETS.exploreActive, to: ROUTES.EXPLORE, }, { id: "characters", labelKey: "layout.nav.characters", iconSrc: LAYOUT_NAV_ASSETS.characters, activeIconSrc: LAYOUT_NAV_ASSETS.charactersActive, to: ROUTES.CHARACTERS, }, { id: "voice", labelKey: "layout.nav.voice", iconSrc: LAYOUT_NAV_ASSETS.voice, activeIconSrc: LAYOUT_NAV_ASSETS.voiceActive, to: ROUTES.VOICE, }, { id: "create", labelKey: "layout.nav.create", iconSrc: LAYOUT_NAV_ASSETS.create, activeIconSrc: LAYOUT_NAV_ASSETS.createActive, to: ROUTES.CREATE, }, { id: "canvas", labelKey: "layout.nav.canvas", iconSrc: LAYOUT_NAV_ASSETS.canvas, activeIconSrc: LAYOUT_NAV_ASSETS.canvasActive, to: CANVAS_BASE_URL ?? "", badgeKey: "layout.nav.canvasBadge", }, ]; const LANGUAGE_LABEL_KEYS: Record = { "zh-CN": "layout.language.zhCN", "zh-TW": "layout.language.zhTW", "en-US": "layout.language.enUS", }; const LAYOUT_PROMO_BAR_DISMISSED_KEY = "popiart.layout.promoBar.dismissed"; function isLayoutPromoBarDismissed(): boolean { try { return localStorage.getItem(LAYOUT_PROMO_BAR_DISMISSED_KEY) === "1"; } catch { return false; } } function buildSkillHubUrl(): string | null { const baseUrl = import.meta.env.VITE_SKILLHUB_URL; if (!baseUrl) return null; const url = new URL(baseUrl); url.searchParams.set("token", getToken() ?? ""); url.searchParams.set("t", String(Date.now())); return url.toString(); } function readServiceQrCode(value: unknown): string { if (typeof value !== "object" || value == null || !("systemConfig" in value)) return ""; const systemConfig = value.systemConfig; if (typeof systemConfig !== "object" || systemConfig == null || !("serviceQrCode" in systemConfig)) return ""; return typeof systemConfig.serviceQrCode === "string" ? systemConfig.serviceQrCode : ""; } const LAYOUT_FOOTER_NAV_ITEMS: readonly LayoutSideNavItem[] = [ { id: "subscribe", labelKey: "layout.footer.subscribe", iconSrc: LAYOUT_NAV_ASSETS.subscribe, activeIconSrc: LAYOUT_NAV_ASSETS.subscribeActive, to: ROUTES.SUBSCRIBE, }, { id: "profile", labelKey: "layout.footer.profile", iconSrc: LAYOUT_NAV_ASSETS.profile, activeIconSrc: LAYOUT_NAV_ASSETS.profileActive, to: ROUTES.PROFILE, }, ]; function LayoutSideNavIcon({ item }: LayoutSideNavIconProps) { if (!item.activeIconSrc) { return ; } return ( ); } /** * 主布局(与路由结合):顶部栏 + 左侧栏 + 右侧内容区 * * 说明:content 区域由各路由页面自己渲染,这里只保证外层布局像素级对齐。 */ export function Layout({ children }: LayoutProps) { const { pathname, search } = useLocation(); const navigate = useNavigate(); const { t, i18n } = useTranslation(); const { user, setUser, clearUser } = useUserStore( useShallow((s) => ({ user: s.user, setUser: s.setUser, clearUser: s.clearUser, })), ); const isLogin = user != null; const [userMenuOpen, setUserMenuOpen] = useState(false); const [languageMenuOpen, setLanguageMenuOpen] = useState(false); const [footerPopoverOpen, setFooterPopoverOpen] = useState(false); const [topbarServiceQrOpen, setTopbarServiceQrOpen] = useState(false); const [promoBarVisible, setPromoBarVisible] = useState(() => !isLayoutPromoBarDismissed()); const activePromptPanelHost = useComposerStore((s) => s.activePromptPanelHost); const footerPopoverRef = useRef(null); const userMenuRef = useRef(null); const languageToolRef = useRef(null); const serviceToolRef = useRef(null); const contentFrameShellRef = useRef(null); const contentScrollRef = useRef(null); const layoutViewportRef = useRef(null); const { scaleInnerRef, scaleSpacerRef } = useLayoutViewportScale(layoutViewportRef); const copyText = useCopyText(); const requestComposerCollapse = useComposerStore((s) => s.requestComposerCollapse); const requestComposerExpand = useComposerStore((s) => s.requestComposerExpand); const prevPathnameRef = useRef(pathname); useEffect(() => { if (!getToken()) { clearUser(); return; } void setUser(); }, [clearUser, setUser]); const finishLogout = (): void => { removeToken(); clearUser(); setUserMenuOpen(false); setLanguageMenuOpen(false); setFooterPopoverOpen(false); window.location.href = ROUTES.EXPLORE; }; const handleLogout = (): void => { void (async () => { try { await logout(); } catch { // 接口失败也继续本地退出 } finally { finishLogout(); } })(); }; const handleLayoutNavClick = (evt: ReactMouseEvent, to: string): void => { if (CANVAS_BASE_URL && to === CANVAS_BASE_URL) { evt.preventDefault(); const url = buildCanvasUrl(); if (url) window.open(url, "_blank"); return; } if (!canAccessRoute(to)) { evt.preventDefault(); void guardRouteAccess(to); return; } if (to === ROUTES.CREATE || to.startsWith(`${ROUTES.CREATE}/`)) { requestComposerExpand(); } }; const isVoiceArea = pathname === ROUTES.VOICE || pathname.startsWith(`${ROUTES.VOICE_MUSIC_SHARE}/`); const shouldShowFloatingComposer = pathname !== ROUTES.SUBSCRIBE && pathname !== ROUTES.CHARACTERS_CREATE && !isVoiceArea; const handlePromoBarClose = useCallback((evt: ReactMouseEvent) => { evt.stopPropagation(); evt.preventDefault(); setPromoBarVisible(false); try { localStorage.setItem(LAYOUT_PROMO_BAR_DISMISSED_KEY, "1"); } catch { // ignore quota / private mode } }, []); const [serviceQrCode, setServiceQrCode] = useState(""); useEffect(() => { void getParamConfig() .then((res) => { setServiceQrCode(readServiceQrCode(res)); }) .catch(() => { setServiceQrCode(""); }); }, []); useEffect(() => { if (pathname === ROUTES.EXPLORE && hasPendingExploreTemplateTabIntent()) { return; } const scroll = contentScrollRef.current; if (scroll != null) { scroll.scrollTop = 0; } window.scrollTo(0, 0); }, [pathname]); useEffect(() => { if (prevPathnameRef.current === pathname) return; prevPathnameRef.current = pathname; if (pathname === ROUTES.CREATE || pathname.startsWith(`${ROUTES.CREATE}/`)) { requestComposerExpand(); } }, [pathname, requestComposerExpand]); useEffect(() => { const shell = contentFrameShellRef.current; if (shell == null) return; /** 从「基准位置」到「当前位置」的位移达到该值时收起(不是 scrollTop 离文档顶部 280) */ const DISPLACEMENT_THRESHOLD = 140; /** 最近一次滚动后若这么久内仍未达到阈值,将基准移到当前位置,下次从 0 重新量 */ const BASELINE_RESET_MS = 3000; const baselineByScroller = new Map(); const resetTimerByScroller = new Map(); const clearResetTimer = (scroller: HTMLElement) => { const id = resetTimerByScroller.get(scroller); if (id != null) { window.clearTimeout(id); resetTimerByScroller.delete(scroller); } }; const scheduleBaselineResetIfIdle = (scroller: HTMLElement) => { clearResetTimer(scroller); const id = window.setTimeout(() => { baselineByScroller.set(scroller, scroller.scrollTop); resetTimerByScroller.delete(scroller); }, BASELINE_RESET_MS); resetTimerByScroller.set(scroller, id); }; const onScroll = (event: Event) => { if (isLayoutContentScrollLocked()) return; if (isScrollEventFromLayoutModalMount(event.target)) return; if (!isScrollEventInLayoutContentScroll(event.target)) return; const scroller = event.target instanceof HTMLElement ? event.target : null; if (scroller == null) return; let baselineTop = baselineByScroller.get(scroller); if (baselineTop == null) { baselineTop = scroller.scrollTop; baselineByScroller.set(scroller, baselineTop); } const top = scroller.scrollTop; const displacementFromBaseline = top - baselineTop; if (displacementFromBaseline >= DISPLACEMENT_THRESHOLD) { requestComposerCollapse(); baselineByScroller.set(scroller, top); clearResetTimer(scroller); return; } scheduleBaselineResetIfIdle(scroller); }; shell.addEventListener("scroll", onScroll, { capture: true, passive: true }); return () => { for (const scroller of resetTimerByScroller.keys()) { clearResetTimer(scroller); } shell.removeEventListener("scroll", onScroll, { capture: true }); }; }, [pathname, requestComposerCollapse]); useEffect(() => { if (!footerPopoverOpen) return; const onDocMouseDown = (evt: MouseEvent) => { const el = footerPopoverRef.current; const target = evt.target as Node | null; if (el && target && !el.contains(target)) { setFooterPopoverOpen(false); setTopbarServiceQrOpen(false); } }; document.addEventListener("mousedown", onDocMouseDown); return () => document.removeEventListener("mousedown", onDocMouseDown); }, [footerPopoverOpen]); useEffect(() => { if (!userMenuOpen) return; const onDocMouseDown = (evt: MouseEvent) => { const el = userMenuRef.current; const target = evt.target as Node | null; if (el && target && !el.contains(target)) { setUserMenuOpen(false); } }; document.addEventListener("mousedown", onDocMouseDown); return () => document.removeEventListener("mousedown", onDocMouseDown); }, [userMenuOpen]); useEffect(() => { if (!topbarServiceQrOpen) return; const onDocMouseDown = (evt: MouseEvent) => { const el = serviceToolRef.current; const target = evt.target as Node | null; if (el && target && !el.contains(target)) { setTopbarServiceQrOpen(false); } }; document.addEventListener("mousedown", onDocMouseDown); return () => document.removeEventListener("mousedown", onDocMouseDown); }, [topbarServiceQrOpen]); const dismissMainDetailModals = useCallback(() => { closeCharacterDetailModal(); closeAssetDetailsModal(); }, []); return (
{promoBarVisible ? (
navigate(ROUTES.SUBSCRIBE)}>
{t("layout.promo.badgeNew")}

{t("layout.promo.membershipTitle")} {t("layout.promo.membershipDesc")}

{t("layout.promo.seedanceHighlight")} {t("layout.promo.seedanceSuffix")}

{t("layout.promo.badgeHot")}

{t("layout.promo.discountPrefix")} {t("layout.promo.discountValue")} {t("layout.promo.discountSuffix")}

{t("layout.promo.pointsPrefix")} {t("layout.promo.pointsValue")}

) : null}
{/* 使用说明 */}
{/* 语言菜单 */}
{ setLanguageMenuOpen(visible); if (visible) { setUserMenuOpen(false); setFooterPopoverOpen(false); setTopbarServiceQrOpen(false); } }} /* 高于同区域 Tooltip,避免悬停提示盖住下拉菜单 */ triggerProps={{ style: { zIndex: 2000 } }} getPopupContainer={() => languageToolRef.current ?? document.body} droplist={
{SUPPORTED_LANGUAGES.map((lng) => { const active = i18n.language === lng; const itemClass = ["layoutShell__languageDropdownItem", active ? "layoutShell__languageDropdownItem_active" : ""].filter(Boolean).join(" "); return ( ); })}
} >
{/* 联系客服:顶栏定位浮层展示二维码 */}
{topbarServiceQrOpen ? (
e.stopPropagation()}> {t("contactService.qrAlt")}

{t("layout.topbar.scan_contact_service")}

) : null}
{/* 用户积分 */}
openPointsDetailDialog()}>
{String(user?.allCoins ?? 0)}
{t("layout.nav.points")}
{/* 登录注册 */} {isLogin ? (
{userMenuOpen ? (
{String(user?.name ?? "")}
{String(user?.id ?? "")}
{String(user?.allCoins ?? 0)}
) : null}
) : ( )}
{/* 左侧栏菜单 */} {/* 内容区 */}
{ contentScrollRef.current = el; setLayoutContentScrollRoot(el); }} > {children}
{shouldShowFloatingComposer && activePromptPanelHost === "layout" ? (
navigate(to, options)} />
) : null}
); }