Files
Genarrative/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
T
suzmii 8458113c94
Project CI / Backend tests (pull_request) Failing after 22s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m17s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m30s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m27s
Project CI / Repository checks (pull_request) Failing after 26s
Project CI / Frontend tests (pull_request) Successful in 3m13s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m32s
Project CI / Native shell tests (pull_request) Successful in 8m50s
修导航高亮:从共创侧进的详情/族谱/对比也高亮「共创」(复用入口来源标记,判定收敛在一处)
2026-10-07 19:45:55 +08:00

1327 lines
50 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,
Sparkles,
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,
resolveSelectionStageFromPath,
} 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 {
CO_CREATION_DETAIL_SOURCE,
isCoCreator,
resolveGameViewerContext,
shouldCarryCoCreationSource,
} from '../game-distribution/gameViewerRole';
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,
coCreationActive,
onOpenCoCreation,
creatorActive,
onOpenCreator,
}: {
active: boolean;
onOpenProfile: () => void;
gameActive: boolean;
onOpenGames: () => void;
coCreationActive: boolean;
onOpenCoCreation: () => 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-4"
aria-label="移动平台导航"
>
<ActiveBottomNavButton
active={gameActive}
icon={Gamepad2}
label="游戏"
onClick={onOpenGames}
/>
{/*
「共创」是**顶层入口**(与游戏/创作者主页/我的并列),不再是游戏页里的子页签;
窄屏同样从底部导航直达 `/games/co-creation`。
*/}
<ActiveBottomNavButton
active={coCreationActive}
icon={Sparkles}
label="共创"
onClick={onOpenCoCreation}
/>
<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');
/*
* **入口上下文**(2026-10-07 产品口径更正:游客 = 点开游戏界面的玩家,不是「未登录访客」):
* 从共创侧(共创 Tab / 族谱页 / 对比页)打开详情时带上 `from=co-creation`,
* 详情页据此决定「看不看得到血缘图与共创标签」;其余来源 = 玩家侧(不带标记)。
* 已经在共创侧详情页里继续跳(血缘链 / 主题 / 相关作品)时保留标记,不中途掉回玩家侧。
* 用 URL 而不是内存 stage:刷新、深链、分享链接都稳定。
*/
const currentStage = resolveSelectionStageFromPath(
window.location.pathname,
);
const currentSource = new URLSearchParams(window.location.search).get(
'from',
);
if (
shouldCarryCoCreationSource({
stage: currentStage,
currentSource,
})
) {
params.set('from', CO_CREATION_DETAIL_SOURCE);
}
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]);
/**
* 「共创」页签:**只列支持共创的作品**(游戏卡片 + 同一套游标分页)。
* 它不再指向共创主题列表——主题列表页(stage `game-themes`)与路由保留,只是没有页签入口。
*/
const openGameCoCreation = useCallback(() => {
setSelectionStage('game-co-creation', { path: '/games/co-creation' });
}, [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/co-creation');
setSelectionStage('game-co-creation', { path: '/games/co-creation' });
}, [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}
coCreationActive={false}
creatorActive={false}
onOpenCreator={() => openCreator()}
onOpenProfile={openProfile}
onOpenGames={openGames}
onOpenCoCreation={openGameCoCreation}
/>
<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';
/**
* 游戏区(含共创):决定「游戏味」的宿主行为——搜索框口语、面板是否内滚。
* **不再等于导航高亮**:共创是独立顶层入口,两者的高亮互斥(见 `isGamesNavActive`)。
*/
const isGamesStage =
selectionStage === 'games' ||
selectionStage === 'game-detail' ||
selectionStage === 'game-compare' ||
selectionStage === 'game-themes' ||
selectionStage === 'game-co-creation' ||
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,
);
/**
* 本屏的**入口来源标记**:详情页上是「从哪一侧点进来」(`from=co-creation`),
* 族谱页上含义不同(`from=<gameId>` = 高亮哪个作品)——所以只认 `co-creation` 这一个值
* (复用 `resolveGameViewerContext` 的白名单,不自己判字符串)。
*/
const gameEntrySource = gameSearchParams.get('from');
/**
* 本屏是否属于「**共创侧**」上下文 —— **导航高亮(侧边栏 / 底部 dock)的唯一判据**。
*
* 用户报的 bug:共创页 → 点卡 → 详情后,侧边栏仍高亮「游戏」。根因是这里原先只看 `stage`,
* 而详情页的 stage 属于游戏区(`game-detail`),**来源被丢掉**。修法是复用详情页那套
* 已落地的来源判定(不新造参数、不新造标记):
* ① URL 的来源标记就是 `co-creation`(详情页,以及从它继续跳过去的页面);
* ② 或当前就在「共创侧才进得来的阶段」(共创页 / 族谱 / 对比,见 `shouldCarryCoCreationSource`)。
*/
const isCoCreationContext =
isGamesStage &&
(isCoCreator(resolveGameViewerContext(gameEntrySource)) ||
shouldCarryCoCreationSource({
stage: selectionStage,
currentSource: gameEntrySource,
}));
/** 游戏区、且**不在共创侧上下文**时,「游戏」这个导航项才是激活态(两者互斥、不双亮)。 */
const isGamesNavActive = isGamesStage && !isCoCreationContext;
const gameId = gameSearchParams.get('id');
const lineageHighlightGameId = gameEntrySource;
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={isGamesNavActive}
emphasized={isGamesNavActive}
icon={Gamepad2}
iconSrc="/creation-home/nav-games.png"
label="游戏"
onClick={openGames}
/>
{/*
「共创」:**顶层入口**(2026-10-07 用户口径:不该是游戏页里的子页签)。
指向 `scope="forkable"` 的那个列表页(`/games/co-creation`),与「游戏」平级。
高亮读 `isCoCreationContext`:共创页本身、以及**从共创侧点进去的详情/族谱/对比**
都算共创侧(用户报的「共创 → 详情后仍高亮游戏」就是这里原先只看 stage 造成的)。
*/}
<ActiveRailButton
active={isCoCreationContext}
emphasized={isCoCreationContext}
icon={Sparkles}
iconSrc="/creation-home/nav-discover.png"
label="共创"
onClick={openGameCoCreation}
/>
<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}
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}
onOpenGameDetail={openGameDetail}
/>
) : 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-co-creation' ? (
<GameGalleryPage
searchKeyword={activeSearchKeyword}
scope="forkable"
onOpenDetail={openGameDetail}
/>
) : selectionStage === 'game-themes' ? (
<GameThemesPage
onBack={backFromGameThemes}
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={isGamesNavActive}
coCreationActive={isCoCreationContext}
creatorActive={isCreatorStage}
onOpenCreator={() => openCreator()}
onOpenProfile={openProfile}
onOpenGames={openGames}
onOpenCoCreation={openGameCoCreation}
/>
</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;