Files
Genarrative/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
T
suzmii 909f3d5fa2
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
网页端共创主题:共创 Tab + 主题页(逐根内嵌作品树)+ 详情页主题入口 + 网关清单
- 客户端:新增 listThemes(游标分页,读 nextCursor)与 getTheme(404 由页面渲染成降级态);getGame 的返回类型增量 themes(匿名也返回;无主题时是空数组而不是缺键)
- 共创 Tab:广场页新增「游戏 / 共创」页签,复用既有 game-category-tabs 结构与类名,切到 /games/themes
- 主题列表页 GameThemesPage:卡片展示 name / summary / badge / memberCount;只有 nextCursor 非空才渲染「加载更多」并在点击时追加;空态与失败重试照既有写法
- 主题页 GameThemePage:标题 / 简介 / 角标 + 成员根清单;**逐根内嵌渲染作品树**——直接复用族谱页(GameLineagePage 新增 embedded 变体,只隐藏页面外壳:返回按钮 / 页头 / 脚注),树查询、可见性过滤、稳定排序与截断全部走 M3 那一份实现,不新建第二套树查询或树渲染(每根一次 /lineage 的 N+1 是技术方案 §3.10.4 已接受的取舍);已发布但可见成员为空 → 明确空态而不是错误;主题不存在 / 未发布 → 错误态
- 详情页:共创信息卡渲染所属主题角标(点击进主题页),第 N 代作品同样可见(服务端按根反查);无主题时不渲染空容器
- 路由登记:新增 /games/themes 与 /games/theme(详情用 ?id=),四处同批登记(STAGE_ROUTE_ENTRIES + SelectionStage 类型 + 壳层 lazy/回调/渲染分支 + 页面标题),并同步三份 nginx SPA allowlist 与 Pingora MAIN_SPA_PATHS:check:nginx-spa-routes(20 SPA routes / 3 模板)与 check:pingora-route-parity 均绿
- 测试:主题列表三例(渲染与卡片跳转 / 有游标时的加载更多与追加 / 空态与失败重试)、主题页三例(逐根两棵树且复用 lineage 查询 + 全屏入口参数 / 空成员空态 / 404 错误态)、详情页主题入口两例(渲染并可跳转 / 无主题不渲染容器)、路由与标题登记断言、壳层三例(页签切换 / 列表进主题页 / 主题页按 ?id= 渲染并复用族谱查询)
2026-10-06 03:45:38 +08:00

1222 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
FolderKanban,
Gamepad2,
Home,
Monitor,
Palette,
Search,
UserRound,
UsersRound,
} from 'lucide-react';
import {
type ComponentType,
type FormEvent,
lazy,
Suspense,
useCallback,
useEffect,
useState,
} from 'react';
import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry';
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { PlatformProfileReferralModal } from '../../../packages/shared/src/components/PlatformProfileReferralModal';
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
import {
hasAppHistoryBackEntry,
pushAppHistoryPath,
replaceAppHistoryPath,
} from '../../routing/activeAppPageRoutes';
import {
connectionsPath,
type CreatorConnectionKind,
creatorPath,
} from '../../services/creatorProfileClient';
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import { useAuthUi } from '../auth/AuthUiContext';
import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { ClientDownloadEntry } from '../creation-home/ClientDownloadEntry';
import { setCreatorViewer } from '../creator/creatorRelationshipState';
import { PaymentCheckoutView } from './PaymentCheckoutView';
import {
PlatformActiveMobileWelcomeDialog,
shouldOpenActiveMobileWelcomeDialog,
} from './PlatformActiveMobileWelcomeDialog';
import {
resolveActivePublicUserCode,
resolveActiveUserAvatarLabel,
} from './platformActiveProfileModel';
import { PlatformActiveProfileView } from './PlatformActiveProfileView';
import type { PlatformEntryFlowShellProps } from './platformEntryActiveTypes';
import { usePlatformDesktopLayout } from './platformEntryResponsive';
import { PlatformPaymentServiceView } from './PlatformPaymentServiceView';
import { PlatformProfileApiKeysModal } from './PlatformProfileApiKeysModal';
import {
canPasteFromNativeHostClipboard,
readNativeHostClipboardText,
} from './platformProfileHostClipboard';
import { PlatformProfileRewardCodeRedeemModal } from './PlatformProfileRewardCodeRedeemModal';
import {
PlatformRechargePaymentConfirmationMask,
PlatformRechargePaymentResultDialog,
} from './PlatformRechargePaymentStatusDialogs';
import { usePlatformProfileCenterController } from './usePlatformProfileCenterController';
const ImageCanvasEditorView = lazy(async () => {
const module = await import('../image-editor/ImageCanvasEditorView');
return { default: module.ImageCanvasEditorView };
});
const CreationLandingView = lazy(async () => {
const module = await import('../creation-home/CreationLandingView');
return { default: module.CreationLandingView };
});
const ProjectGalleryView = lazy(async () => {
const module = await import('../project/ProjectGalleryView');
return { default: module.ProjectGalleryView };
});
const GameGalleryPage = lazy(async () => {
const module = await import('../game-distribution/GameGalleryPage');
return { default: module.GameGalleryPage };
});
const CreatorPage = lazy(async () => {
const module = await import('../creator/CreatorPage');
return { default: module.CreatorPage };
});
const CreatorConnectionsPage = lazy(async () => {
const module = await import('../creator/CreatorConnectionsPage');
return { default: module.CreatorConnectionsPage };
});
const GameDetailPage = lazy(async () => {
const module = await import('../game-distribution/GameDetailPage');
return { default: module.GameDetailPage };
});
const GamePublishPage = lazy(async () => {
const module = await import('../game-distribution/GamePublishPage');
return { default: module.GamePublishPage };
});
const MyGamesPage = lazy(async () => {
const module = await import('../game-distribution/MyGamesPage');
return { default: module.MyGamesPage };
});
const GamePlayPage = lazy(async () => {
const module = await import('../game-distribution/GamePlayPage');
return { default: module.GamePlayPage };
});
const GameLineagePage = lazy(async () => {
const module = await import('../game-distribution/GameLineagePage');
return { default: module.GameLineagePage };
});
const GameComparePage = lazy(async () => {
const module = await import('../game-distribution/GameComparePage');
return { default: module.GameComparePage };
});
const GameThemesPage = lazy(async () => {
const module = await import('../game-distribution/GameThemesPage');
return { default: module.GameThemesPage };
});
const GameThemePage = lazy(async () => {
const module = await import('../game-distribution/GameThemePage');
return { default: module.GameThemePage };
});
type ActiveRailButtonProps = {
active: boolean;
emphasized?: boolean;
icon: ComponentType<{ className?: string }>;
iconSrc: string;
label: string;
onClick: () => void;
};
function ActiveRailButton({
active,
emphasized = false,
icon: Icon,
iconSrc,
label,
onClick,
}: ActiveRailButtonProps) {
return (
<button
type="button"
aria-current={active ? 'page' : undefined}
aria-label={label}
className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${active ? 'platform-desktop-rail__button--active' : ''}`}
onClick={onClick}
>
<span className="platform-desktop-rail__icon-shell">
<img
src={iconSrc}
alt=""
aria-hidden="true"
draggable={false}
className="creation-home-nav-art"
/>
<Icon
className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]"
aria-hidden="true"
/>
</span>
<span className="platform-desktop-rail__label text-[11px] font-semibold">
{label}
</span>
</button>
);
}
function ActiveBottomNavButton({
active,
emphasized = false,
icon: Icon,
label,
onClick,
}: Omit<ActiveRailButtonProps, 'iconSrc'>) {
return (
<button
type="button"
aria-current={active ? 'page' : undefined}
aria-label={label}
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
onClick={onClick}
>
<span className="platform-bottom-nav__button-content">
<span className="platform-bottom-nav__icon-shell">
<Icon className="platform-bottom-nav__icon" aria-hidden="true" />
</span>
<span className="platform-bottom-nav__label">{label}</span>
{active ? (
<span
aria-hidden="true"
className="platform-bottom-nav__active-mark"
/>
) : null}
</span>
</button>
);
}
function MobileProfileDock({
active,
onOpenProfile,
gameActive,
onOpenGames,
creatorActive,
onOpenCreator,
}: {
active: boolean;
onOpenProfile: () => void;
gameActive: boolean;
onOpenGames: () => void;
creatorActive: boolean;
onOpenCreator: () => void;
}) {
return (
<div className="platform-mobile-bottom-dock min-w-0 shrink-0 lg:hidden">
<nav
className="platform-bottom-nav grid grid-cols-3"
aria-label="移动平台导航"
>
<ActiveBottomNavButton
active={gameActive}
icon={Gamepad2}
label="游戏"
onClick={onOpenGames}
/>
<ActiveBottomNavButton
active={creatorActive}
icon={UsersRound}
label="创作者主页"
onClick={onOpenCreator}
/>
<ActiveBottomNavButton
active={active}
icon={UserRound}
label="我的"
onClick={onOpenProfile}
/>
</nav>
</div>
);
}
function ActivePlatformBrand() {
return (
<span
className="creation-home-brand-lockup shrink-0"
role="img"
aria-label="陶泥儿 GENARRATIVE"
>
<span className="platform-brand-logo">
<img
src="/branding/taonier-product-ip.png"
alt=""
aria-hidden="true"
draggable={false}
className="platform-brand-logo__image"
/>
<span className="platform-brand-logo__copy">
<span className="platform-brand-logo__title">
陶泥<span className="platform-brand-logo__title-suffix">儿</span>
</span>
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
</span>
</span>
</span>
);
}
function LoadingPanel({ label }: { label: string }) {
return (
<div className="flex h-full min-h-[12rem] items-center justify-center text-sm font-semibold text-[var(--platform-text-soft)]">
{label}
</div>
);
}
function MobileCreationDesktopGuide({
onReturnHome,
}: {
onReturnHome: () => void;
}) {
return (
<main
className="flex min-h-full flex-1 items-center justify-center px-4 py-8"
aria-label="桌面端创作提示"
>
<PlatformSubpanel
as="section"
radius="xl"
padding="none"
className="platform-remap-surface w-full max-w-sm px-5 py-6 text-center"
>
<span className="mx-auto flex h-14 w-14 items-center justify-center rounded-[1.05rem] bg-[rgba(199,101,50,0.12)] text-[var(--platform-accent-strong)]">
<Monitor aria-hidden="true" className="h-7 w-7" />
</span>
<h1 className="mt-4 text-xl font-black leading-tight text-[var(--platform-text-strong)]">
请在桌面端打开创作主页
</h1>
<p className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
图片画布、项目管理和素材库需要更大的操作空间。
</p>
<PlatformActionButton
onClick={onReturnHome}
size="md"
shape="pill"
className="mt-5 min-h-11 w-full"
>
<Home aria-hidden="true" className="h-4 w-4" />
返回首页
</PlatformActionButton>
</PlatformSubpanel>
</main>
);
}
export function PlatformEntryFlowShellImpl({
selectionStage,
setSelectionStage,
}: PlatformEntryFlowShellProps) {
const authUi = useAuthUi();
// 发布灰度:命中才暴露「发布游戏 / 发布新版本」入口;读取失败按放行处理,由后端写入口把关。
const [gamePublishGateAllowed, setGamePublishGateAllowed] = useState(true);
const gamePublishGateUserId = authUi?.user?.id ?? '';
const [dashboard, setDashboard] = useState<ProfileDashboardSummary | null>(
null,
);
const [isLoadingDashboard, setIsLoadingDashboard] = useState(false);
const [isApiKeysOpen, setIsApiKeysOpen] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [activeSearchKeyword, setActiveSearchKeyword] = useState('');
const [isMobileDesktopGuideOpen, setIsMobileDesktopGuideOpen] =
useState(false);
const [isMobileHomeWelcomeDismissed, setIsMobileHomeWelcomeDismissed] =
useState(false);
const isDesktopLayout = usePlatformDesktopLayout();
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
const shouldOpenMobileHomeWelcome = shouldOpenActiveMobileWelcomeDialog({
isDesktopLayout,
selectionStage,
platformTab: selectionStage,
pathname: typeof window === 'undefined' ? '/' : window.location.pathname,
isDismissed: isMobileHomeWelcomeDismissed,
});
const currentWalletOwnerUserId =
authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null;
const walletOwnerUserId = usePlatformWalletStore(
(state) => state.ownerUserId,
);
const storedMudPointBalance = usePlatformWalletStore(
(state) => state.mudPointBalance,
);
const storedLegacyWalletBalance = usePlatformWalletStore(
(state) => state.legacyWalletBalance,
);
const storedMudPointBalanceStatus = usePlatformWalletStore(
(state) => state.mudPointBalanceStatus,
);
const storedMudPointBalanceError = usePlatformWalletStore(
(state) => state.mudPointBalanceError,
);
const walletOwnerMatchesCurrentUser =
Boolean(currentWalletOwnerUserId) &&
walletOwnerUserId === currentWalletOwnerUserId;
const mudPointBalance = walletOwnerMatchesCurrentUser
? storedMudPointBalance
: null;
const mudPointBalanceError = walletOwnerMatchesCurrentUser
? storedMudPointBalanceError
: '';
const isWalletBalanceLoading =
Boolean(currentWalletOwnerUserId) &&
(!walletOwnerMatchesCurrentUser ||
storedMudPointBalanceStatus === 'idle' ||
storedMudPointBalanceStatus === 'loading');
const refreshDashboard = useCallback(async () => {
if (!authUi?.user || !authUi.canAccessProtectedData) {
setDashboard(null);
return;
}
setIsLoadingDashboard(true);
try {
setDashboard(await getPlatformProfileDashboard());
} catch {
setDashboard(null);
} finally {
setIsLoadingDashboard(false);
}
}, [authUi?.canAccessProtectedData, authUi?.user]);
useEffect(() => {
void refreshDashboard();
}, [refreshDashboard]);
const isProfileStage =
selectionStage === 'profile' ||
selectionStage === 'profile-payment' ||
(!isDesktopLayout && selectionStage === 'platform');
const profileCenter = usePlatformProfileCenterController({
activeTab: isProfileStage ? 'profile' : 'project',
isAuthenticated: Boolean(authUi?.user),
showRechargeEntry: true,
requestLogin: () => authUi?.openLoginModal(),
currentUser: authUi?.user,
});
const legacyWalletBalance = walletOwnerMatchesCurrentUser
? (storedLegacyWalletBalance ??
profileCenter.rechargeCenter?.walletBalance ??
null)
: null;
const openCreation = useCallback(() => {
if (!isDesktopLayout) {
setIsMobileDesktopGuideOpen(true);
return;
}
pushAppHistoryPath('/creation');
setSelectionStage('creation-home', { path: '/creation' });
}, [isDesktopLayout, setSelectionStage]);
const openProjects = useCallback(() => {
if (!isDesktopLayout) {
setIsMobileDesktopGuideOpen(true);
return;
}
pushAppHistoryPath('/project');
setSelectionStage('project', { path: '/project' });
}, [isDesktopLayout, setSelectionStage]);
const openProfile = useCallback(() => {
pushAppHistoryPath('/profile');
setSelectionStage('profile', { path: '/profile' });
}, [setSelectionStage]);
const openPaymentService = useCallback(() => {
setSelectionStage('profile-payment', { path: '/profile/payment' });
}, [setSelectionStage]);
const creatorViewer = authUi?.canAccessProtectedData
? (authUi.user?.id ?? null)
: null;
useEffect(() => {
setCreatorViewer(creatorViewer);
}, [creatorViewer]);
const openCreator = useCallback(
(id?: string) => {
const path = id ? creatorPath(id) : '/creators';
pushAppHistoryPath(path);
setSelectionStage('creator-home', { path });
},
[setSelectionStage],
);
const openCreatorConnections = useCallback(
(id: string, tab: CreatorConnectionKind) => {
const path = connectionsPath(id, tab);
pushAppHistoryPath(path);
setSelectionStage('creator-connections', { path });
},
[setSelectionStage],
);
const backFromCreator = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
const owner =
selectionStage === 'creator-connections'
? new URLSearchParams(window.location.search).get('id')
: null;
// 兜底返回替换当前条目,避免主页深链与本人主页互相新增历史。
const path = owner ? creatorPath(owner) : '/games';
replaceAppHistoryPath(path);
setSelectionStage(owner ? 'creator-home' : 'games', { path });
}, [selectionStage, setSelectionStage]);
const openGames = useCallback(() => {
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
useEffect(() => {
if (!gamePublishGateUserId) {
// 未登录时不展示发布入口,登录后由 authUi 变化触发重新读取。
setGamePublishGateAllowed(false);
return;
}
let cancelled = false;
loadFrontendRuntimeConfig()
.then((config) => {
if (!cancelled) {
setGamePublishGateAllowed(
config.gameDistributionPublishEnabled !== false,
);
}
})
.catch(() => {
if (!cancelled) setGamePublishGateAllowed(true);
});
return () => {
cancelled = true;
};
}, [gamePublishGateUserId]);
const openGamePublish = useCallback(() => {
setSelectionStage('game-publish', { path: '/games/publish' });
}, [setSelectionStage]);
const openGamePublishVersion = useCallback(
(gameId: string) => {
const path = `/games/publish?game=${encodeURIComponent(gameId)}`;
setSelectionStage('game-publish', { path });
},
[setSelectionStage],
);
const openMyGames = useCallback(() => {
setSelectionStage('game-mine', { path: '/games/mine' });
}, [setSelectionStage]);
const openGameDetail = useCallback(
(gameId: string, options?: { owner?: boolean }) => {
const params = new URLSearchParams({ id: gameId });
// 作者视角要一直带着 owner 作用域:刷新或直接打开该链接时详情页仍读
// `GET /my-games/{gameId}`,否则作者点自己的未公开作品只会拿到公开接口的 404。
if (options?.owner) params.set('owner', '1');
const path = `/games/detail?${params.toString()}`;
setSelectionStage('game-detail', { path });
},
[setSelectionStage],
);
const openGamePlay = useCallback(
(gameId: string) => {
const path = `/games/play?id=${encodeURIComponent(gameId)}`;
setSelectionStage('game-play', { path });
},
[setSelectionStage],
);
/**
* 打开创作族谱页。路由按规范只要求 `?id=<rootGameId>`;这里额外带上 `from=<当前作品>`,
* 让页面对「用户点进来时所在的作品」做高亮,直接打开链接时缺省高亮根节点。
*/
const openGameLineage = useCallback(
(rootGameId: string, fromGameId?: string) => {
const params = new URLSearchParams({ id: rootGameId });
if (fromGameId && fromGameId !== rootGameId) {
params.set('from', fromGameId);
}
const path = `/games/lineage?${params.toString()}`;
setSelectionStage('game-lineage', { path });
},
[setSelectionStage],
);
/** 族谱页从详情页进来,返回优先走浏览器原生返回,兜底回广场。 */
const backFromGameLineage = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
replaceAppHistoryPath('/games');
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
/** 打开「原版与二创对比」:左侧原版(父作品)、右侧二创(当前作品)。 */
const openGameCompare = useCallback(
(leftGameId: string, rightGameId: string) => {
const params = new URLSearchParams({
left: leftGameId,
right: rightGameId,
});
const path = `/games/compare?${params.toString()}`;
setSelectionStage('game-compare', { path });
},
[setSelectionStage],
);
/** 对比页同理:原生返回优先,兜底回广场。 */
const backFromGameCompare = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
replaceAppHistoryPath('/games');
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
/** 共创主题列表(`/games` 的「共创」页签)。 */
const openGameThemes = useCallback(() => {
setSelectionStage('game-themes', { path: '/games/themes' });
}, [setSelectionStage]);
const backFromGameThemes = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
replaceAppHistoryPath('/games');
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
/** 主题详情:复用 `?id=` 约定(与 `/games/lineage?id=`、`/games/detail?id=` 同款)。 */
const openGameTheme = useCallback(
(themeId: string) => {
const path = `/games/theme?id=${encodeURIComponent(themeId)}`;
setSelectionStage('game-theme', { path });
},
[setSelectionStage],
);
const backFromGameTheme = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
replaceAppHistoryPath('/games/themes');
setSelectionStage('game-themes', { path: '/games/themes' });
}, [setSelectionStage]);
/**
* 详情页可能从广场或「我的游戏」进入,返回优先走浏览器原生返回,这样能回到
* 用户真正的来源页;直接打开详情链接等没有可回退的应用内历史时再兜底回广场。
*/
const backFromGameDetail = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
replaceAppHistoryPath('/games');
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
/** 游玩页同理,兜底是它自己的详情页,避免详情↔游玩互相塞历史条目。 */
const backFromGamePlay = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
const currentGameId = new URLSearchParams(window.location.search).get('id');
const path = currentGameId
? `/games/detail?id=${encodeURIComponent(currentGameId)}`
: '/games';
replaceAppHistoryPath(path);
setSelectionStage(currentGameId ? 'game-detail' : 'games', { path });
}, [setSelectionStage]);
const openEditorProject = useCallback(
(projectId: string, options?: { guide?: boolean; tool?: string }) => {
if (!isDesktopLayout) {
setIsMobileDesktopGuideOpen(true);
return;
}
const params = new URLSearchParams();
params.set('projectid', projectId);
if (options?.guide) {
params.set('guide', 'toolbar');
}
if (options?.tool) {
params.set('tool', options.tool);
}
const path = `/editor/canvas?${params.toString()}`;
pushAppHistoryPath(path);
setSelectionStage('image-editor', { path });
},
[isDesktopLayout, setSelectionStage],
);
const replaceWithProjectGallery = useCallback(() => {
replaceAppHistoryPath('/project');
setSelectionStage('project', { path: '/project' });
}, [setSelectionStage]);
/**
* 「填邀请码」的粘贴动作。
*
* 宿主能力留在宿主:共享弹层只按 `canPasteRedeemCode` 决定是否渲染按钮,读取宿主
* 剪贴板由主站自己完成,读到文本只填进输入框,不自动提交。
*/
const pasteReferralRedeemCode = useCallback(() => {
void readNativeHostClipboardText().then((text) => {
if (text) {
profileCenter.setReferralRedeemCode(text);
}
});
}, [profileCenter]);
const isMobileToolStage =
!isDesktopLayout &&
(selectionStage === 'creation-home' ||
selectionStage === 'project' ||
selectionStage === 'image-editor');
if ((!isDesktopLayout && isMobileDesktopGuideOpen) || isMobileToolStage) {
return (
<>
<MobileCreationDesktopGuide
onReturnHome={() => {
setIsMobileDesktopGuideOpen(false);
if (selectionStage === 'platform') {
return;
}
setSelectionStage('platform', { path: '/' });
}}
/>
<MobileProfileDock
active={false}
gameActive={false}
creatorActive={false}
onOpenCreator={() => openCreator()}
onOpenProfile={openProfile}
onOpenGames={openGames}
/>
<PlatformActiveMobileWelcomeDialog
open={shouldOpenMobileHomeWelcome}
platformThemeClass={platformThemeClass}
onClose={() => setIsMobileHomeWelcomeDismissed(true)}
/>
</>
);
}
if (selectionStage === 'image-editor') {
return (
<div className="image-editor-stage-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
<Suspense fallback={<LoadingPanel label="正在加载编辑器..." />}>
<ImageCanvasEditorView
legacyWalletBalance={legacyWalletBalance}
onProjectAccessLost={replaceWithProjectGallery}
/>
</Suspense>
</div>
);
}
if (selectionStage === 'payment-checkout') {
const checkoutToken =
typeof window === 'undefined'
? ''
: (window.location.pathname.split('/').filter(Boolean).at(-1) ?? '');
return <PaymentCheckoutView checkoutToken={checkoutToken} />;
}
const isAuthenticated = Boolean(authUi?.user);
const balance = mudPointBalance?.totalPoints ?? legacyWalletBalance;
const isCreationStage =
!isProfileStage &&
(selectionStage === 'platform' || selectionStage === 'creation-home');
const isCreatorStage =
selectionStage === 'creator-home' ||
selectionStage === 'creator-connections';
const isGamesStage =
selectionStage === 'games' ||
selectionStage === 'game-detail' ||
selectionStage === 'game-compare' ||
selectionStage === 'game-themes' ||
selectionStage === 'game-theme' ||
selectionStage === 'game-play' ||
selectionStage === 'game-mine' ||
selectionStage === 'game-lineage' ||
selectionStage === 'game-publish';
/**
* 游玩页自己撑满剩余高度,面板不能再滚动,否则移动端会出现整页可滚的
* 半屏播放器;其余舞台继续沿用面板滚动。
*/
const isGamePlayStage = selectionStage === 'game-play';
const gameSearchParams = new URLSearchParams(
typeof window === 'undefined' ? '' : window.location.search,
);
const gameId = gameSearchParams.get('id');
const lineageHighlightGameId = gameSearchParams.get('from');
const compareLeftGameId = gameSearchParams.get('left');
const compareRightGameId = gameSearchParams.get('right');
const creatorConnectionTab = gameSearchParams.get('tab');
const publishGameId = gameSearchParams.get('game');
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
const avatarLabel = resolveActiveUserAvatarLabel(authUi?.user);
const publicUserCode = resolveActivePublicUserCode(authUi?.user);
const openRecharge = () => {
profileCenter.setIsRechargeOpen(true);
profileCenter.loadRechargeCenter();
};
const openUserSurface = () => {
if (isAuthenticated) {
authUi?.openAccountModal();
return;
}
authUi?.openLoginModal();
};
const openFeedback = () => {
window.open(FLOATING_FEEDBACK_FORM_URL, '_blank', 'noopener,noreferrer');
};
const submitSearch = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const keyword = searchInput.trim();
setActiveSearchKeyword(keyword);
if (isProfileStage) {
openCreation();
}
};
return (
<>
<div
className={`flex h-full min-h-0 min-w-0 flex-col overflow-hidden ${isCreationStage ? 'creation-home-stage--landing' : ''}`}
>
<div className="platform-desktop-shell platform-desktop-shell--workbench platform-mobile-entry-shell flex h-full min-h-0 flex-col">
<div className="platform-desktop-layout flex min-h-0 flex-1">
<aside className="platform-desktop-leftbar hidden shrink-0 flex-col lg:flex">
<ActivePlatformBrand />
<nav
className="platform-desktop-rail flex shrink-0 flex-col gap-3"
aria-label="平台导航"
>
<ActiveRailButton
active={isCreationStage}
emphasized={isCreationStage}
icon={Palette}
iconSrc="/creation-home/nav-create.png"
label="创作"
onClick={openCreation}
/>
<ActiveRailButton
active={selectionStage === 'project'}
icon={FolderKanban}
iconSrc="/creation-home/nav-projects.png"
label="项目"
onClick={openProjects}
/>
<ActiveRailButton
active={isGamesStage}
emphasized={isGamesStage}
icon={Gamepad2}
iconSrc="/creation-home/nav-games.png"
label="游戏"
onClick={openGames}
/>
<ActiveRailButton
active={isCreatorStage}
icon={UsersRound}
iconSrc="/creation-home/nav-profile.png"
label="创作者主页"
onClick={() => openCreator()}
/>
<ActiveRailButton
active={isProfileStage}
icon={UserRound}
iconSrc="/creation-home/nav-profile.png"
label="我的"
onClick={openProfile}
/>
</nav>
</aside>
<div className="platform-desktop-main flex min-w-0 flex-1 flex-col">
<header className="platform-desktop-topbar flex min-h-16 shrink-0 flex-nowrap items-center justify-between gap-1 px-4 py-2 sm:gap-3 sm:px-6 sm:py-0">
<div className="platform-desktop-topbar__brand min-w-0 shrink-0 lg:hidden">
<ActivePlatformBrand />
</div>
<div className="platform-desktop-topbar__brand-search hidden min-w-0 flex-1 items-center lg:flex">
<form
className="platform-desktop-search flex w-full max-w-[34rem] items-center gap-2 px-3 py-2"
role="search"
onSubmit={submitSearch}
>
<img
src="/creation-home/topbar-search.png"
alt=""
aria-hidden="true"
className="creation-home-topbar-art creation-home-topbar-art--search"
/>
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
<input
type="search"
value={searchInput}
placeholder={
isGamesStage
? '搜索游戏、作者或标签'
: '搜索项目、素材、作者或描述'
}
aria-label={isGamesStage ? '搜索游戏' : '搜索项目和素材'}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
onChange={(event) => {
const value = event.target.value;
setSearchInput(value);
if (!value.trim()) {
setActiveSearchKeyword('');
}
}}
/>
<button
type="submit"
className="shrink-0 text-sm font-semibold text-[var(--platform-text-base)]"
>
搜索
</button>
</form>
</div>
<div className="platform-desktop-topbar__actions flex min-w-0 shrink-0 items-center gap-1 sm:gap-2 lg:gap-3">
<ClientDownloadEntry />
{isAuthenticated ? (
<PlatformMudPointWalletEntry
variant={isDesktopLayout ? 'desktop' : 'mobile'}
balance={balance}
breakdown={mudPointBalance}
isLoading={isWalletBalanceLoading}
error={mudPointBalanceError || null}
className={
isDesktopLayout
? 'platform-desktop-create-wallet-chip'
: 'platform-mobile-create-wallet-chip'
}
onRequestDetails={profileCenter.loadRechargeCenter}
onRecharge={openRecharge}
onOpenLedger={profileCenter.openWalletLedgerPanel}
/>
) : null}
<button
type="button"
onClick={openUserSurface}
className="platform-account-entry platform-desktop-search flex items-center gap-1 px-1 py-1 text-left sm:gap-2 sm:px-2 lg:gap-3 lg:px-3 lg:py-2.5"
>
<span
className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full text-base font-black text-white"
style={{
background: 'var(--platform-profile-avatar-fill)',
boxShadow: 'var(--platform-profile-avatar-shadow)',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
avatarLabel
)}
</span>
<span className="hidden min-w-0 sm:block">
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{authUi?.user?.displayName || '登录'}
</span>
<span className="block truncate text-xs text-[var(--platform-text-soft)]">
{authUi?.user ? publicUserCode : '账号入口'}
</span>
</span>
</button>
</div>
</header>
<main
id={isCreationStage ? 'platform-tab-panel-create' : undefined}
className={`platform-tab-panel min-h-0 min-w-0 flex-1 ${isGamePlayStage ? 'platform-tab-panel--game-play' : 'overflow-auto'} ${isCreationStage || isProfileStage || isGamePlayStage ? '' : 'px-3 py-4 sm:px-6 sm:py-6'}`}
>
{selectionStage === 'profile-payment' ? (
<PlatformPaymentServiceView
user={authUi?.user}
onBack={openProfile}
onLogin={() => authUi?.openLoginModal()}
/>
) : isProfileStage ? (
<PlatformActiveProfileView
dashboard={dashboard}
isLoadingDashboard={isLoadingDashboard}
isLoadingWalletBalance={isWalletBalanceLoading}
legacyWalletBalance={legacyWalletBalance}
mudPointBalance={mudPointBalance}
user={authUi?.user}
onLogin={() => authUi?.openLoginModal()}
onOpenApiKeys={() => setIsApiKeysOpen(true)}
onOpenPaymentService={openPaymentService}
onOpenCommunity={() =>
profileCenter.openProfilePopupPanel('community')
}
onOpenFeedback={openFeedback}
onOpenInvite={() =>
profileCenter.openProfilePopupPanel('invite')
}
onOpenRecharge={openRecharge}
onOpenRewardCode={profileCenter.openRewardCodeModal}
onOpenSettings={() => authUi?.openSettingsModal()}
onOpenWalletLedger={profileCenter.openWalletLedgerPanel}
onUserUpdated={(user) => authUi?.setCurrentUser(user)}
/>
) : isCreationStage ? (
<Suspense
fallback={<LoadingPanel label="正在加载创作主页..." />}
>
<CreationLandingView
onOpenProject={openEditorProject}
onOpenProjects={openProjects}
searchKeyword={activeSearchKeyword}
/>
</Suspense>
) : isCreatorStage ? (
<Suspense
fallback={<LoadingPanel label="正在加载创作者主页…" />}
>
{selectionStage === 'creator-connections' ? (
<CreatorConnectionsPage
id={gameId}
tab={
creatorConnectionTab === 'following' ||
creatorConnectionTab === 'followers'
? creatorConnectionTab
: null
}
onOpenCreator={openCreator}
onOpenConnections={openCreatorConnections}
onBack={backFromCreator}
/>
) : (
<CreatorPage
id={gameId}
onOpenConnections={openCreatorConnections}
onOpenGame={openGameDetail}
onBack={backFromCreator}
/>
)}
</Suspense>
) : isGamesStage ? (
<Suspense
fallback={<LoadingPanel label="正在加载游戏广场..." />}
>
{selectionStage === 'games' ? (
<GameGalleryPage
searchKeyword={activeSearchKeyword}
onOpenDetail={openGameDetail}
onOpenMyGames={openMyGames}
onOpenThemes={openGameThemes}
onOpenPublish={
gamePublishGateAllowed ? openGamePublish : undefined
}
/>
) : selectionStage === 'game-publish' ? (
<GamePublishPage
onBack={openGames}
onOpenMyGames={openMyGames}
updateGameId={publishGameId}
/>
) : selectionStage === 'game-mine' ? (
<MyGamesPage
onBack={openGames}
onOpenDetail={(gameId) =>
openGameDetail(gameId, { owner: true })
}
onPublishVersion={
gamePublishGateAllowed
? openGamePublishVersion
: undefined
}
onResumePublish={
gamePublishGateAllowed ? openGamePublish : undefined
}
publishEnabled={gamePublishGateAllowed}
/>
) : selectionStage === 'game-detail' ? (
<GameDetailPage
gameId={gameId}
onOpenCreator={openCreator}
ownerView={gameSearchParams.get('owner') === '1'}
onBack={backFromGameDetail}
onPlay={openGamePlay}
onOpenLineage={openGameLineage}
onOpenCompare={openGameCompare}
onOpenTheme={openGameTheme}
/>
) : selectionStage === 'game-lineage' ? (
<GameLineagePage
rootGameId={gameId}
highlightGameId={lineageHighlightGameId}
onBack={backFromGameLineage}
onOpenDetail={openGameDetail}
onOpenCompare={openGameCompare}
/>
) : selectionStage === 'game-compare' ? (
<GameComparePage
leftGameId={compareLeftGameId}
rightGameId={compareRightGameId}
onBack={backFromGameCompare}
onOpenDetail={openGameDetail}
/>
) : selectionStage === 'game-themes' ? (
<GameThemesPage
onBack={backFromGameThemes}
onOpenGallery={openGames}
onOpenTheme={openGameTheme}
/>
) : selectionStage === 'game-theme' ? (
<GameThemePage
themeId={gameId}
onBack={backFromGameTheme}
onOpenDetail={openGameDetail}
onOpenLineage={openGameLineage}
onOpenCompare={openGameCompare}
/>
) : (
<GamePlayPage gameId={gameId} onBack={backFromGamePlay} />
)}
</Suspense>
) : (
<Suspense fallback={<LoadingPanel label="正在加载项目..." />}>
<ProjectGalleryView
onOpenProject={openEditorProject}
searchKeyword={activeSearchKeyword}
/>
</Suspense>
)}
</main>
</div>
</div>
<MobileProfileDock
active={isProfileStage}
gameActive={isGamesStage}
creatorActive={isCreatorStage}
onOpenCreator={() => openCreator()}
onOpenProfile={openProfile}
onOpenGames={openGames}
/>
</div>
</div>
{profileCenter.isRechargeOpen ? (
<PlatformProfileRechargeModal
center={profileCenter.rechargeCenter}
isLoading={profileCenter.isLoadingRechargeCenter}
error={profileCenter.rechargeError}
submittingProductId={profileCenter.submittingRechargeProductId}
nativePayment={profileCenter.nativeWechatPayment}
onClose={() => profileCenter.setIsRechargeOpen(false)}
onRetry={profileCenter.loadRechargeCenter}
onBuy={profileCenter.buyRechargeProduct}
onConfirmNativePayment={profileCenter.confirmNativeWechatPayment}
onCloseNativePayment={profileCenter.closeNativeWechatPayment}
/>
) : null}
{profileCenter.isRewardCodeOpen ? (
<PlatformProfileRewardCodeRedeemModal
value={profileCenter.rewardCodeInput}
isSubmitting={profileCenter.isSubmittingRewardCode}
error={profileCenter.rewardCodeError}
success={profileCenter.rewardCodeSuccess}
onChange={profileCenter.setRewardCodeInput}
onSubmit={profileCenter.submitRewardCode}
onClose={() => profileCenter.setIsRewardCodeOpen(false)}
/>
) : null}
{profileCenter.profilePopupPanel ? (
<PlatformProfileReferralModal
panel={profileCenter.profilePopupPanel}
center={profileCenter.referralCenter}
inviteLinkUrl={profileCenter.inviteLinkUrl}
isLoading={profileCenter.isLoadingReferral}
isSubmittingRedeem={profileCenter.isSubmittingReferralRedeem}
redeemCode={profileCenter.referralRedeemCode}
copyInviteLinkState={profileCenter.inviteLinkCopyState}
copyInviteCodeState={profileCenter.inviteCodeCopyState}
error={profileCenter.referralError}
success={profileCenter.referralSuccess}
canPasteRedeemCode={canPasteFromNativeHostClipboard()}
onPasteRedeemCode={pasteReferralRedeemCode}
onClose={profileCenter.closeProfilePopupPanel}
onCopyInviteLink={profileCenter.copyInviteLink}
onCopyInviteCode={profileCenter.copyInviteCode}
onRedeemCodeChange={profileCenter.setReferralRedeemCode}
onSubmitRedeemCode={profileCenter.submitReferralRedeemCode}
/>
) : null}
{isApiKeysOpen ? (
<PlatformProfileApiKeysModal onClose={() => setIsApiKeysOpen(false)} />
) : null}
{profileCenter.isWalletLedgerOpen ? (
<PlatformProfileWalletLedgerModal
ledger={profileCenter.walletLedger}
fallbackBalance={balance ?? 0}
isLoading={profileCenter.isLoadingWalletLedger}
error={profileCenter.walletLedgerError}
onClose={() => profileCenter.setIsWalletLedgerOpen(false)}
onRetry={profileCenter.loadWalletLedger}
/>
) : null}
{profileCenter.rechargePaymentResult ? (
<PlatformRechargePaymentResultDialog
result={profileCenter.rechargePaymentResult}
onClose={() => profileCenter.setRechargePaymentResult(null)}
/>
) : null}
{profileCenter.wechatRechargeOrderConfirmationState ? (
<PlatformRechargePaymentConfirmationMask
orderId={profileCenter.wechatRechargeOrderConfirmationState.orderId}
/>
) : null}
<PlatformActiveMobileWelcomeDialog
open={shouldOpenMobileHomeWelcome}
platformThemeClass={platformThemeClass}
onClose={() => setIsMobileHomeWelcomeDismissed(true)}
/>
</>
);
}
export default PlatformEntryFlowShellImpl;