39669763fe
- 新增 module-game-distribution/src/lineage.rs:锚点可读性、可见性过滤、稳定排序、上限截断的纯函数,附 15 个单测(复用 M1 的软删除判定,不重写第二套规则)
- 新增公开只读 procedure get_game_distribution_lineage / list_game_distribution_derived_games;锚点必须公开可读(未公开/已软删除/不存在一律按不存在处理),树内只出现未软删除且已公开的节点,不补 null 占位节点
- api-server 新增 GET /games/{id}/lineage 与 /games/{id}/derived(匿名可读、no-store),响应走结构化 DTO,节点键集合由测试钉死,杜绝下发对象键/素材键
- 作者自有游戏聚合追加 forkCount(与公开详情 forkCount 同口径),供「被改编 N」入口使用
- shared-contracts 与 TS 契约新增 LineageNode / LineageResponse / DerivedResponse 三型并登记 DTO 一致性检查
- 前端新增 /games/lineage 族谱页,登记 stage 类型、路由表、页面标题与壳层渲染分支;详情页共创卡新增「查看创作族谱」入口(仅已公开作品)
- 我的作品页新增「被改编 N」行内展开,列出直接子代的标题/作者/代际/状态,含空态与失败重试(仅已公开作品)
- 补前端组件/路由/标题/客户端用例与 api-server 路由、负载映射用例;同步里程碑验收标准与技术方案 §3.4 的锚点可读性口径
1019 lines
38 KiB
TypeScript
1019 lines
38 KiB
TypeScript
import {
|
||
FolderKanban,
|
||
Gamepad2,
|
||
Home,
|
||
Monitor,
|
||
Palette,
|
||
Search,
|
||
UserRound,
|
||
} from 'lucide-react';
|
||
import {
|
||
type ComponentType,
|
||
type FormEvent,
|
||
lazy,
|
||
Suspense,
|
||
useCallback,
|
||
useEffect,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry';
|
||
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
|
||
import { PlatformProfileReferralModal } from '../../../packages/shared/src/components/PlatformProfileReferralModal';
|
||
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
|
||
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
|
||
import {
|
||
hasAppHistoryBackEntry,
|
||
pushAppHistoryPath,
|
||
replaceAppHistoryPath,
|
||
} from '../../routing/activeAppPageRoutes';
|
||
import { 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 { 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 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 };
|
||
});
|
||
|
||
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,
|
||
}: {
|
||
active: boolean;
|
||
onOpenProfile: () => void;
|
||
gameActive: boolean;
|
||
onOpenGames: () => void;
|
||
}) {
|
||
return (
|
||
<div className="platform-mobile-bottom-dock min-w-0 shrink-0 lg:hidden">
|
||
<nav
|
||
className="platform-bottom-nav grid grid-cols-2"
|
||
aria-label="移动平台导航"
|
||
>
|
||
<ActiveBottomNavButton
|
||
active={gameActive}
|
||
icon={Gamepad2}
|
||
label="游戏"
|
||
onClick={onOpenGames}
|
||
/>
|
||
<ActiveBottomNavButton
|
||
active={active}
|
||
icon={UserRound}
|
||
label="我的"
|
||
onClick={onOpenProfile}
|
||
/>
|
||
</nav>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ActivePlatformBrand() {
|
||
return (
|
||
<span
|
||
className="creation-home-brand-lockup shrink-0"
|
||
role="img"
|
||
aria-label="陶泥儿 GENARRATIVE"
|
||
>
|
||
<span className="platform-brand-logo">
|
||
<img
|
||
src="/branding/taonier-product-ip.png"
|
||
alt=""
|
||
aria-hidden="true"
|
||
draggable={false}
|
||
className="platform-brand-logo__image"
|
||
/>
|
||
<span className="platform-brand-logo__copy">
|
||
<span className="platform-brand-logo__title">
|
||
陶泥<span className="platform-brand-logo__title-suffix">儿</span>
|
||
</span>
|
||
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
|
||
</span>
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function LoadingPanel({ label }: { label: string }) {
|
||
return (
|
||
<div className="flex h-full min-h-[12rem] items-center justify-center text-sm font-semibold text-[var(--platform-text-soft)]">
|
||
{label}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MobileCreationDesktopGuide({
|
||
onReturnHome,
|
||
}: {
|
||
onReturnHome: () => void;
|
||
}) {
|
||
return (
|
||
<main
|
||
className="flex min-h-full flex-1 items-center justify-center px-4 py-8"
|
||
aria-label="桌面端创作提示"
|
||
>
|
||
<PlatformSubpanel
|
||
as="section"
|
||
radius="xl"
|
||
padding="none"
|
||
className="platform-remap-surface w-full max-w-sm px-5 py-6 text-center"
|
||
>
|
||
<span className="mx-auto flex h-14 w-14 items-center justify-center rounded-[1.05rem] bg-[rgba(199,101,50,0.12)] text-[var(--platform-accent-strong)]">
|
||
<Monitor aria-hidden="true" className="h-7 w-7" />
|
||
</span>
|
||
<h1 className="mt-4 text-xl font-black leading-tight text-[var(--platform-text-strong)]">
|
||
请在桌面端打开创作主页
|
||
</h1>
|
||
<p className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
|
||
图片画布、项目管理和素材库需要更大的操作空间。
|
||
</p>
|
||
<PlatformActionButton
|
||
onClick={onReturnHome}
|
||
size="md"
|
||
shape="pill"
|
||
className="mt-5 min-h-11 w-full"
|
||
>
|
||
<Home aria-hidden="true" className="h-4 w-4" />
|
||
返回首页
|
||
</PlatformActionButton>
|
||
</PlatformSubpanel>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
export function PlatformEntryFlowShellImpl({
|
||
selectionStage,
|
||
setSelectionStage,
|
||
}: PlatformEntryFlowShellProps) {
|
||
const authUi = useAuthUi();
|
||
// 发布灰度:命中才暴露「发布游戏 / 发布新版本」入口;读取失败按放行处理,由后端写入口把关。
|
||
const [gamePublishGateAllowed, setGamePublishGateAllowed] = useState(true);
|
||
const gamePublishGateUserId = authUi?.user?.id ?? '';
|
||
const [dashboard, setDashboard] = useState<ProfileDashboardSummary | null>(
|
||
null,
|
||
);
|
||
const [isLoadingDashboard, setIsLoadingDashboard] = useState(false);
|
||
const [isApiKeysOpen, setIsApiKeysOpen] = useState(false);
|
||
const [searchInput, setSearchInput] = useState('');
|
||
const [activeSearchKeyword, setActiveSearchKeyword] = useState('');
|
||
const [isMobileDesktopGuideOpen, setIsMobileDesktopGuideOpen] =
|
||
useState(false);
|
||
const [isMobileHomeWelcomeDismissed, setIsMobileHomeWelcomeDismissed] =
|
||
useState(false);
|
||
const isDesktopLayout = usePlatformDesktopLayout();
|
||
const platformThemeClass =
|
||
authUi?.platformTheme === 'dark'
|
||
? 'platform-theme--dark'
|
||
: 'platform-theme--light';
|
||
const shouldOpenMobileHomeWelcome = shouldOpenActiveMobileWelcomeDialog({
|
||
isDesktopLayout,
|
||
selectionStage,
|
||
platformTab: selectionStage,
|
||
pathname: typeof window === 'undefined' ? '/' : window.location.pathname,
|
||
isDismissed: isMobileHomeWelcomeDismissed,
|
||
});
|
||
const currentWalletOwnerUserId =
|
||
authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null;
|
||
const walletOwnerUserId = usePlatformWalletStore(
|
||
(state) => state.ownerUserId,
|
||
);
|
||
const storedMudPointBalance = usePlatformWalletStore(
|
||
(state) => state.mudPointBalance,
|
||
);
|
||
const storedLegacyWalletBalance = usePlatformWalletStore(
|
||
(state) => state.legacyWalletBalance,
|
||
);
|
||
const storedMudPointBalanceStatus = usePlatformWalletStore(
|
||
(state) => state.mudPointBalanceStatus,
|
||
);
|
||
const storedMudPointBalanceError = usePlatformWalletStore(
|
||
(state) => state.mudPointBalanceError,
|
||
);
|
||
const walletOwnerMatchesCurrentUser =
|
||
Boolean(currentWalletOwnerUserId) &&
|
||
walletOwnerUserId === currentWalletOwnerUserId;
|
||
const mudPointBalance = walletOwnerMatchesCurrentUser
|
||
? storedMudPointBalance
|
||
: null;
|
||
const mudPointBalanceError = walletOwnerMatchesCurrentUser
|
||
? storedMudPointBalanceError
|
||
: '';
|
||
const isWalletBalanceLoading =
|
||
Boolean(currentWalletOwnerUserId) &&
|
||
(!walletOwnerMatchesCurrentUser ||
|
||
storedMudPointBalanceStatus === 'idle' ||
|
||
storedMudPointBalanceStatus === 'loading');
|
||
|
||
const refreshDashboard = useCallback(async () => {
|
||
if (!authUi?.user || !authUi.canAccessProtectedData) {
|
||
setDashboard(null);
|
||
return;
|
||
}
|
||
setIsLoadingDashboard(true);
|
||
try {
|
||
setDashboard(await getPlatformProfileDashboard());
|
||
} catch {
|
||
setDashboard(null);
|
||
} finally {
|
||
setIsLoadingDashboard(false);
|
||
}
|
||
}, [authUi?.canAccessProtectedData, authUi?.user]);
|
||
|
||
useEffect(() => {
|
||
void refreshDashboard();
|
||
}, [refreshDashboard]);
|
||
|
||
const isProfileStage =
|
||
selectionStage === 'profile' ||
|
||
selectionStage === 'profile-payment' ||
|
||
(!isDesktopLayout && selectionStage === 'platform');
|
||
|
||
const profileCenter = usePlatformProfileCenterController({
|
||
activeTab: isProfileStage ? 'profile' : 'project',
|
||
isAuthenticated: Boolean(authUi?.user),
|
||
showRechargeEntry: true,
|
||
requestLogin: () => authUi?.openLoginModal(),
|
||
currentUser: authUi?.user,
|
||
});
|
||
const legacyWalletBalance = walletOwnerMatchesCurrentUser
|
||
? (storedLegacyWalletBalance ??
|
||
profileCenter.rechargeCenter?.walletBalance ??
|
||
null)
|
||
: null;
|
||
|
||
const openCreation = useCallback(() => {
|
||
if (!isDesktopLayout) {
|
||
setIsMobileDesktopGuideOpen(true);
|
||
return;
|
||
}
|
||
pushAppHistoryPath('/creation');
|
||
setSelectionStage('creation-home', { path: '/creation' });
|
||
}, [isDesktopLayout, setSelectionStage]);
|
||
|
||
const openProjects = useCallback(() => {
|
||
if (!isDesktopLayout) {
|
||
setIsMobileDesktopGuideOpen(true);
|
||
return;
|
||
}
|
||
pushAppHistoryPath('/project');
|
||
setSelectionStage('project', { path: '/project' });
|
||
}, [isDesktopLayout, setSelectionStage]);
|
||
|
||
const openProfile = useCallback(() => {
|
||
pushAppHistoryPath('/profile');
|
||
setSelectionStage('profile', { path: '/profile' });
|
||
}, [setSelectionStage]);
|
||
|
||
const openPaymentService = useCallback(() => {
|
||
setSelectionStage('profile-payment', { path: '/profile/payment' });
|
||
}, [setSelectionStage]);
|
||
|
||
const 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 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}
|
||
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 isGamesStage =
|
||
selectionStage === 'games' ||
|
||
selectionStage === 'game-detail' ||
|
||
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 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={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>
|
||
) : 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}
|
||
ownerView={gameSearchParams.get('owner') === '1'}
|
||
onBack={backFromGameDetail}
|
||
onPlay={openGamePlay}
|
||
onOpenLineage={openGameLineage}
|
||
/>
|
||
) : selectionStage === 'game-lineage' ? (
|
||
<GameLineagePage
|
||
rootGameId={gameId}
|
||
highlightGameId={lineageHighlightGameId}
|
||
onBack={backFromGameLineage}
|
||
onOpenDetail={openGameDetail}
|
||
/>
|
||
) : (
|
||
<GamePlayPage gameId={gameId} onBack={backFromGamePlay} />
|
||
)}
|
||
</Suspense>
|
||
) : (
|
||
<Suspense fallback={<LoadingPanel label="正在加载项目..." />}>
|
||
<ProjectGalleryView
|
||
onOpenProject={openEditorProject}
|
||
searchKeyword={activeSearchKeyword}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
</main>
|
||
</div>
|
||
</div>
|
||
<MobileProfileDock
|
||
active={isProfileStage}
|
||
gameActive={isGamesStage}
|
||
onOpenProfile={openProfile}
|
||
onOpenGames={openGames}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{profileCenter.isRechargeOpen ? (
|
||
<PlatformProfileRechargeModal
|
||
center={profileCenter.rechargeCenter}
|
||
isLoading={profileCenter.isLoadingRechargeCenter}
|
||
error={profileCenter.rechargeError}
|
||
submittingProductId={profileCenter.submittingRechargeProductId}
|
||
nativePayment={profileCenter.nativeWechatPayment}
|
||
onClose={() => profileCenter.setIsRechargeOpen(false)}
|
||
onRetry={profileCenter.loadRechargeCenter}
|
||
onBuy={profileCenter.buyRechargeProduct}
|
||
onConfirmNativePayment={profileCenter.confirmNativeWechatPayment}
|
||
onCloseNativePayment={profileCenter.closeNativeWechatPayment}
|
||
/>
|
||
) : null}
|
||
{profileCenter.isRewardCodeOpen ? (
|
||
<PlatformProfileRewardCodeRedeemModal
|
||
value={profileCenter.rewardCodeInput}
|
||
isSubmitting={profileCenter.isSubmittingRewardCode}
|
||
error={profileCenter.rewardCodeError}
|
||
success={profileCenter.rewardCodeSuccess}
|
||
onChange={profileCenter.setRewardCodeInput}
|
||
onSubmit={profileCenter.submitRewardCode}
|
||
onClose={() => profileCenter.setIsRewardCodeOpen(false)}
|
||
/>
|
||
) : null}
|
||
{profileCenter.profilePopupPanel ? (
|
||
<PlatformProfileReferralModal
|
||
panel={profileCenter.profilePopupPanel}
|
||
center={profileCenter.referralCenter}
|
||
inviteLinkUrl={profileCenter.inviteLinkUrl}
|
||
isLoading={profileCenter.isLoadingReferral}
|
||
isSubmittingRedeem={profileCenter.isSubmittingReferralRedeem}
|
||
redeemCode={profileCenter.referralRedeemCode}
|
||
copyInviteLinkState={profileCenter.inviteLinkCopyState}
|
||
copyInviteCodeState={profileCenter.inviteCodeCopyState}
|
||
error={profileCenter.referralError}
|
||
success={profileCenter.referralSuccess}
|
||
canPasteRedeemCode={canPasteFromNativeHostClipboard()}
|
||
onPasteRedeemCode={pasteReferralRedeemCode}
|
||
onClose={profileCenter.closeProfilePopupPanel}
|
||
onCopyInviteLink={profileCenter.copyInviteLink}
|
||
onCopyInviteCode={profileCenter.copyInviteCode}
|
||
onRedeemCodeChange={profileCenter.setReferralRedeemCode}
|
||
onSubmitRedeemCode={profileCenter.submitReferralRedeemCode}
|
||
/>
|
||
) : null}
|
||
{isApiKeysOpen ? (
|
||
<PlatformProfileApiKeysModal onClose={() => setIsApiKeysOpen(false)} />
|
||
) : null}
|
||
{profileCenter.isWalletLedgerOpen ? (
|
||
<PlatformProfileWalletLedgerModal
|
||
ledger={profileCenter.walletLedger}
|
||
fallbackBalance={balance ?? 0}
|
||
isLoading={profileCenter.isLoadingWalletLedger}
|
||
error={profileCenter.walletLedgerError}
|
||
onClose={() => profileCenter.setIsWalletLedgerOpen(false)}
|
||
onRetry={profileCenter.loadWalletLedger}
|
||
/>
|
||
) : null}
|
||
{profileCenter.rechargePaymentResult ? (
|
||
<PlatformRechargePaymentResultDialog
|
||
result={profileCenter.rechargePaymentResult}
|
||
onClose={() => profileCenter.setRechargePaymentResult(null)}
|
||
/>
|
||
) : null}
|
||
{profileCenter.wechatRechargeOrderConfirmationState ? (
|
||
<PlatformRechargePaymentConfirmationMask
|
||
orderId={profileCenter.wechatRechargeOrderConfirmationState.orderId}
|
||
/>
|
||
) : null}
|
||
<PlatformActiveMobileWelcomeDialog
|
||
open={shouldOpenMobileHomeWelcome}
|
||
platformThemeClass={platformThemeClass}
|
||
onClose={() => setIsMobileHomeWelcomeDismissed(true)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default PlatformEntryFlowShellImpl;
|