全新popiart网站 react项目重构
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

772 lines
36 KiB

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<AppLanguage, string> = {
"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 <img className="layoutShell__sideIcon" src={item.iconSrc} alt="" />;
}
return (
<span className="layoutShell__sideIcon" aria-hidden="true">
<img className="layoutShell__sideIconImage layoutShell__sideIconImage_default" src={item.iconSrc} alt="" />
<img className="layoutShell__sideIconImage layoutShell__sideIconImage_active" src={item.activeIconSrc} alt="" />
</span>
);
}
/**
* 主布局(与路由结合):顶部栏 + 左侧栏 + 右侧内容区
*
* 说明: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<HTMLDivElement>(null);
const userMenuRef = useRef<HTMLDivElement>(null);
const languageToolRef = useRef<HTMLDivElement>(null);
const serviceToolRef = useRef<HTMLDivElement>(null);
const contentFrameShellRef = useRef<HTMLElement | null>(null);
const contentScrollRef = useRef<HTMLDivElement | null>(null);
const layoutViewportRef = useRef<HTMLDivElement>(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<HTMLAnchorElement>, 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<HTMLButtonElement>) => {
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<HTMLElement, number>();
const resetTimerByScroller = new Map<HTMLElement, number>();
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 (
<div className="layoutShellViewport" ref={layoutViewportRef}>
<div className="layoutShellScaleSpacer" ref={scaleSpacerRef}>
<div className="layoutShellScaleInner" ref={scaleInnerRef}>
<div className={["layoutShell", !promoBarVisible ? "layoutShell_promoDismissed" : ""].filter(Boolean).join(" ")}>
{promoBarVisible ? (
<div className="layoutShell__promoBar" role="banner" aria-label={t("layout.promo.aria")} onClick={() => navigate(ROUTES.SUBSCRIBE)}>
<div className="layoutShell__promoBarInner">
<div className="layoutShell__promoSegment">
<span className="layoutShell__promoBadge">{t("layout.promo.badgeNew")}</span>
<p className="layoutShell__promoText">
<span className="layoutShell__promoText_heavy">{t("layout.promo.membershipTitle")}</span>
<span>{t("layout.promo.membershipDesc")}</span>
</p>
</div>
<p className="layoutShell__promoText layoutShell__promoText_seedance">
<span className="layoutShell__promoText_heavy">{t("layout.promo.seedanceHighlight")}</span>
<span>{t("layout.promo.seedanceSuffix")}</span>
</p>
<div className="layoutShell__promoSegment">
<span className="layoutShell__promoBadge">{t("layout.promo.badgeHot")}</span>
<p className="layoutShell__promoText">
<span>{t("layout.promo.discountPrefix")}</span>
<span className="layoutShell__promoText_heavy">{t("layout.promo.discountValue")}</span>
<span>{t("layout.promo.discountSuffix")}</span>
</p>
</div>
<p className="layoutShell__promoText">
<span>{t("layout.promo.pointsPrefix")}</span>
<span className="layoutShell__promoText_heavy">{t("layout.promo.pointsValue")}</span>
</p>
</div>
<button type="button" className="layoutShell__promoClose" aria-label={t("layout.promo.close")} onClick={handlePromoBarClose}>
<img src={new URL("@/assets/images/ic_close.png", import.meta.url).href} alt="" />
</button>
</div>
) : null}
<header className="layoutShell__topbar" aria-label={t("layout.aria.topbar")} onClick={dismissMainDetailModals}>
<div className="layoutShell__topbarRight" aria-label={t("layout.aria.userArea")}>
<div className="layoutShell__topbarTools">
<button
type="button"
className="goToSkillHub"
onClick={() => {
const url = buildSkillHubUrl();
if (url) window.open(url, "_blank");
}}
>
<img className="layoutShell__topbarSkillIcon" src={LAYOUT_NAV_ASSETS.topSkill} alt="" />
<span className="layoutShell__topbarSkillText">{t("layout.topbar.skill")}</span>
</button>
{/* 使用说明 */}
<div className="layoutShell__topbarTool">
<Tooltip content={t("layout.usageTutorial")}>
<button
type="button"
className="layoutShell__topbarIconBtn"
onClick={() => {
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_USAGE_TUTORIAL);
}}
aria-label={t("layout.usageTutorial")}
>
<img src={LAYOUT_NAV_ASSETS.topHelp} alt="" />
</button>
</Tooltip>
</div>
{/* 语言菜单 */}
<div className="layoutShell__topbarTool" ref={languageToolRef}>
<Dropdown
trigger="click"
position="br"
popupVisible={languageMenuOpen}
onVisibleChange={(visible) => {
setLanguageMenuOpen(visible);
if (visible) {
setUserMenuOpen(false);
setFooterPopoverOpen(false);
setTopbarServiceQrOpen(false);
}
}}
/* 高于同区域 Tooltip避免悬停提示盖住下拉菜单 */
triggerProps={{ style: { zIndex: 2000 } }}
getPopupContainer={() => languageToolRef.current ?? document.body}
droplist={
<div className="layoutShell__languageDropdown" role="menu" aria-label={t("layout.language.menuAria")}>
{SUPPORTED_LANGUAGES.map((lng) => {
const active = i18n.language === lng;
const itemClass = ["layoutShell__languageDropdownItem", active ? "layoutShell__languageDropdownItem_active" : ""].filter(Boolean).join(" ");
return (
<button
key={lng}
type="button"
className={itemClass}
role="menuitem"
onClick={() => {
void changeLanguage(lng);
setLanguageMenuOpen(false);
}}
>
{t(LANGUAGE_LABEL_KEYS[lng])}
</button>
);
})}
</div>
}
>
<Tooltip content={t("layout.language.tooltip")} disabled={languageMenuOpen} {...(languageMenuOpen ? { popupVisible: false } : {})} style={{ zIndex: 1000 }}>
<button type="button" className="layoutShell__topbarIconBtn" aria-label={t("layout.language.menuAria")} aria-expanded={languageMenuOpen} aria-haspopup="menu">
<img src={LAYOUT_NAV_ASSETS.topLanguage} alt="" />
</button>
</Tooltip>
</Dropdown>
</div>
{/* 联系客服:顶栏定位浮层展示二维码 */}
<div className="layoutShell__topbarTool" ref={serviceToolRef}>
<button
type="button"
className={["layoutShell__topbarIconBtn", topbarServiceQrOpen ? "layoutShell__topbarIconBtn_active" : ""].filter(Boolean).join(" ")}
aria-label={t("layout.topbar.contactServiceAria")}
aria-expanded={topbarServiceQrOpen}
onClick={(e) => {
e.stopPropagation();
if (!serviceQrCode) return;
setTopbarServiceQrOpen((v) => !v);
setLanguageMenuOpen(false);
setUserMenuOpen(false);
setFooterPopoverOpen(false);
}}
>
<img src={LAYOUT_NAV_ASSETS.topService} alt="" />
</button>
{topbarServiceQrOpen ? (
<div className="layoutShell__topbarServiceQrFloat" role="dialog" aria-label={t("layout.topbar.contactServiceAria")} onClick={(e) => e.stopPropagation()}>
<img className="layoutShell__topbarServiceQrImage" src={serviceQrCode} alt={t("contactService.qrAlt")} />
<p className="layoutShell__topbarServiceQrTip">{t("layout.topbar.scan_contact_service")}</p>
</div>
) : null}
</div>
</div>
{/* 用户积分 */}
<div className="layoutShell__userPoints" onClick={() => openPointsDetailDialog()}>
<div className="layoutShell__userPointsValue">{String(user?.allCoins ?? 0)}</div>
<div className="layoutShell__userPointsLabel">{t("layout.nav.points")}</div>
</div>
{/* 登录注册 */}
{isLogin ? (
<div className="layoutShell__avatarMenu" ref={userMenuRef}>
<button
type="button"
className="layoutShell__avatar"
onClick={() => {
setUserMenuOpen((v) => !v);
setLanguageMenuOpen(false);
setFooterPopoverOpen(false);
setTopbarServiceQrOpen(false);
}}
aria-label={t("layout.userMenu.aria")}
>
<img className="layoutShell__avatarBg" src={String(user?.avatar)} />
</button>
{userMenuOpen ? (
<div className="layoutShell__avatarDropdown" role="menu" aria-label={t("layout.userMenu.actionsAria")}>
<section className="layoutShell__avatarUserCard">
<img className="layoutShell__avatarUserAvatar" src={String(user?.avatar ?? "")} />
<div className="layoutShell__avatarUserMain">
<div className="layoutShell__avatarUserName">{String(user?.name ?? "")}</div>
<div className="layoutShell__avatarUserMeta">
<span className="layoutShell__avatarUserId">{String(user?.id ?? "")}</span>
<button type="button" className="layoutShell__avatarCopyId" onClick={() => void copyText(user?.id)} aria-label={t("layout.userMenu.copyUserId")}>
<img src={new URL("@/assets/images/ic_copy.png", import.meta.url).href} />
</button>
</div>
</div>
</section>
<button type="button" className="layoutShell__avatarMemberRow" onClick={() => navigate(ROUTES.SUBSCRIBE)}>
<span className="layoutShell__avatarMemberLabel">{Number(user?.memberLevel) > 0 ? "VIP" : t(memberLevelMap[0])} </span>
<span className="layoutShell__avatarMemberAction">{t("layout.userMenu.openMembership")}</span>
<img className="layoutShell__avatarChevron" src={new URL("@/assets/images/ic_arrow_right_purple.png", import.meta.url).href} />
</button>
<section className="layoutShell__avatarPointsCard">
<div className="layoutShell__avatarPointsLeft">
<div className="layoutShell__avatarPointsTop">
<span className="layoutShell__avatarPointsValue">{String(user?.allCoins ?? 0)}</span>
<img className="layoutShell__avatarPointsIcon" src={new URL("@/assets/images/ic_stars_active.png", import.meta.url).href} />
</div>
<button type="button" className="layoutShell__avatarPointsDetail" onClick={() => openPointsDetailDialog()}>
{t("layout.userMenu.pointsDetail")}
<img className="layoutShell__avatarArrow" src={new URL("@/assets/images/ic_arrow_right_gray.png", import.meta.url).href} />
</button>
</div>
<button type="button" className="layoutShell__avatarRechargeBtn" onClick={() => void openPointsRechargeDialog()}>
{t("layout.userMenu.recharge")}
</button>
</section>
<button
type="button"
className="layoutShell__avatarDropdownItem"
role="menuitem"
onClick={() => {
void openGatewayApikeyDialog();
setUserMenuOpen(false);
}}
>
<img className="layoutShell__avatarDropdownIcon" src={new URL("@/assets/images/ic_key.png", import.meta.url).href} />
<span>{t("layout.userMenu.personalKey")}</span>
</button>
<button
type="button"
className="layoutShell__avatarDropdownItem"
role="menuitem"
onClick={() => {
openPointsDetailDialog({ initialTab: "invite" });
setUserMenuOpen(false);
}}
>
<img className="layoutShell__avatarDropdownIcon" src={new URL("@/assets/images/ic_invite.png", import.meta.url).href} />
<span>{t("layout.userMenu.inviteFriends")}</span>
</button>
<button
type="button"
className="layoutShell__avatarDropdownItem"
role="menuitem"
onClick={() => {
navigate(ROUTES.PROFILE);
setUserMenuOpen(false);
}}
>
<img className="layoutShell__avatarDropdownIcon" src={new URL("@/assets/images/ic_user.png", import.meta.url).href} />
<span>{t("layout.userMenu.profileCenter")}</span>
</button>
<button type="button" className="layoutShell__avatarDropdownItem" role="menuitem" onClick={handleLogout}>
<img className="layoutShell__avatarDropdownIcon" src={new URL("@/assets/images/ic_gout.png", import.meta.url).href} />
<span>{t("layout.userMenu.logout")}</span>
</button>
</div>
) : null}
</div>
) : (
<button type="button" className="layoutShell__avatar layoutShell__avatar_guest" aria-label={t("auth.login_or_register")} onClick={() => void openLoginDialog()}>
<img className="layoutShell__avatarBg" src={new URL("@/assets/images/avatar.png", import.meta.url).href} alt="" />
</button>
)}
</div>
</header>
<div className="layoutShell__body">
{/* 左侧栏菜单 */}
<aside className={["layoutShell__sidebar", footerPopoverOpen ? "layoutShell__sidebar_footerPopoverOpen" : ""].filter(Boolean).join(" ")} aria-label={t("layout.aria.sidebar")} onClick={dismissMainDetailModals}>
<button type="button" className="layoutShell__brand" aria-label={t("layout.brand.aria")} onClick={() => navigate(ROUTES.INDEX)}>
<img className="layoutShell__brandLogo" src={LAYOUT_NAV_ASSETS.logo} alt="" />
</button>
<nav className="layoutShell__sideNav" aria-label={t("layout.aria.sideNav")}>
{LAYOUT_SIDE_NAV_ITEMS.map((item) => {
const isActive = pathname === item.to || (item.to !== "/" && pathname.startsWith(`${item.to}/`));
const className = ["layoutShell__sideItem", isActive ? "layoutShell__sideItem_active" : ""].filter(Boolean).join(" ");
return (
<Link key={item.id} to={item.to} className={className} onClick={(evt) => handleLayoutNavClick(evt, item.to)}>
<LayoutSideNavIcon item={item} />
<span className="layoutShell__sideText">{t(item.labelKey)}</span>
{item.badgeKey ? <span className="layoutShell__sideBadge">{t(item.badgeKey)}</span> : null}
</Link>
);
})}
</nav>
<div className="layoutShell__footer">
<nav className="layoutShell__footerNav" aria-label={t("layout.footer.navAria")}>
{LAYOUT_FOOTER_NAV_ITEMS.map((item) => {
const isActive = pathname === item.to || (item.to !== "/" && pathname.startsWith(`${item.to}/`));
const className = ["layoutShell__sideItem", isActive ? "layoutShell__sideItem_active" : ""].filter(Boolean).join(" ");
return (
<Link key={item.id} to={item.to} className={className} onClick={(evt) => handleLayoutNavClick(evt, item.to)}>
<LayoutSideNavIcon item={item} />
<span className="layoutShell__sideText">{t(item.labelKey)}</span>
</Link>
);
})}
</nav>
<div className="layoutShell__footerPopoverTrigger" ref={footerPopoverRef}>
<button
type="button"
className="layoutShell__footerMenuBtn"
onClick={(e) => {
e.stopPropagation();
setFooterPopoverOpen((v) => !v);
setTopbarServiceQrOpen(false);
setLanguageMenuOpen(false);
setUserMenuOpen(false);
}}
aria-label={t("layout.footerPopover.menuAria")}
aria-expanded={footerPopoverOpen}
aria-haspopup="dialog"
>
<img src={LAYOUT_NAV_ASSETS.more} alt="" />
</button>
{footerPopoverOpen ? (
<div className="layoutShell__footerPopoverPanel" role="dialog" aria-label={t("layout.footerPopover.menuAria")} onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="layoutShell__footerPopoverLink"
onClick={() => {
setFooterPopoverOpen(false);
setTopbarServiceQrOpen(false);
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_USER_TERMS);
}}
>
{t("app.user_terms")}
</button>
<button
type="button"
className="layoutShell__footerPopoverLink"
onClick={() => {
setFooterPopoverOpen(false);
setTopbarServiceQrOpen(false);
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_PRIVACY_POLICY);
}}
>
{t("app.privacy_policy")}
</button>
<button
type="button"
className="layoutShell__footerPopoverLink"
onClick={() => {
setFooterPopoverOpen(false);
setTopbarServiceQrOpen(false);
void openLatestPrivacyPolicyLink(PRIVACY_POLICY_KEY_AI_USE_NOTICE);
}}
>
{t("app.ai_use_notice")}
</button>
<div className="layoutShell__footerPopoverMeta">
<div>{t("layout.footerPopover.icp")}</div>
<div>{t("footer.address")}</div>
<div>{t("layout.footerPopover.copyrightLine")}</div>
</div>
</div>
) : null}
</div>
</div>
</aside>
{/* 内容区 */}
<main className="layoutShell__contentFrame" aria-label={t("layout.aria.content")} ref={contentFrameShellRef}>
<div
className="layoutShell__contentScroll"
ref={(el) => {
contentScrollRef.current = el;
setLayoutContentScrollRoot(el);
}}
>
{children}
</div>
<div className="layoutShell__modalMount" ref={setLayoutContentModalMount} aria-hidden />
</main>
{shouldShowFloatingComposer && activePromptPanelHost === "layout" ? (
<div className="layoutShell__floatingComposerMount">
<GlobalPromptPanel pathname={pathname} search={search} navigateTo={(to, options) => navigate(to, options)} />
</div>
) : null}
</div>
</div>
</div>
</div>
</div>
);
}