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
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>
|
|
);
|
|
}
|
|
|