Files
Genarrative/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
T
suzmii 5d91377829 族谱可视化补档(1/2):封面字段本地宽化、去掉树节点上的对比入口、复制反馈可观察
- 客户端:新增本地类型 GameDistributionLineageNodeWithCover(契约在 packages/shared,本轮不改):coverObjectKey 可能存在也可能不存在,渲染两侧都能跑;getGameLineage 返回该宽化形状
- 布局模块:节点事实带上 coverObjectKey(占位节点恒为 null),供节点卡片渲染缩略图
- 树节点信息减负:去掉「与原版对比」与游玩数(详情页已有),并清掉 GameLineagePage / GameThemePage / 壳层上已无人使用的 onOpenCompare 传递;Fork 次要动作保留并带可访问名称「Fork <标题>」
- 复制反馈可观察:详情页复制按钮的反馈窗口 1.4s → 5s,并常驻一条 role=status 的「作品 ID 已复制 / 复制失败:请手动选中作品 ID 文本。」
- e2e:那条一直红的断言改为「点击后必须出现可读反馈」(已复制按钮 或 失败提示),点击前 bringToFront——headless Chromium 下 clipboard 写入要求文档聚焦,属环境策略而非被测行为
- 同步删掉「族谱节点打开对比页」的过期用例,并把「父可见才给对比」改成断言树上没有对比入口/游玩数
2026-10-06 14:44:57 +08:00

1228 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 { resolveProfileMudPointDisplayTotal } from '../../../packages/shared/src/utils/mudPoints';
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 ??
resolveProfileMudPointDisplayTotal(
profileCenter.rechargeCenter?.mudPointBalance,
) ??
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}
/>
) : 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}
/>
) : (
<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}
membershipQuote={profileCenter.membershipQuote}
isLoadingMembershipQuote={profileCenter.isLoadingMembershipQuote}
membershipQuoteError={profileCenter.membershipQuoteError}
onClose={() => profileCenter.setIsRechargeOpen(false)}
onRetry={profileCenter.loadRechargeCenter}
onBuy={profileCenter.buyRechargeProduct}
onRequestMembershipQuote={profileCenter.requestMembershipQuote}
onSubmitMembershipCheckout={profileCenter.submitMembershipCheckout}
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;