Files
Genarrative/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
T
suzmii eeb101458a
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s
Merge remote-tracking branch 'origin/master' into feat/game-fork
# Conflicts:
#	apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs
#	apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx
#	apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx
#	docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
#	package.json
#	packages/shared/src/contracts/gameDistribution.ts
#	scripts/check-game-distribution-dto-parity.mjs
#	server-rs/crates/api-server/src/modules/game_distribution.rs
#	server-rs/crates/module-game-distribution/src/errors.rs
#	server-rs/crates/module-game-distribution/src/lib.rs
#	server-rs/crates/shared-contracts/src/game_distribution.rs
#	server-rs/crates/spacetime-client/src/active.rs
#	server-rs/crates/spacetime-client/src/active/mapper.rs
#	server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs
#	server-rs/crates/spacetime-client/src/game_distribution.rs
#	server-rs/crates/spacetime-client/src/module_bindings.rs
#	server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs
#	server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs
#	server-rs/crates/spacetime-module/src/game_distribution.rs
#	server-rs/crates/spacetime-module/src/migration.rs
#	src/components/game-distribution/GameDetailPage.tsx
#	src/components/game-distribution/GameDistributionPages.test.tsx
#	src/components/game-distribution/GamePlayPage.tsx
#	src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
#	src/services/gameDistributionClient.test.ts
#	src/services/gameDistributionClient.ts
2026-10-06 16:10:52 +08:00

1229 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}
onOpenRecharge={openRecharge}
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;