优化前端首屏 tsx 冷加载

This commit is contained in:
2026-04-26 16:05:37 +08:00
parent 45898cba4e
commit d56031cf4a
14 changed files with 334 additions and 252 deletions

View File

@@ -4,27 +4,38 @@
## 1. 背景 ## 1. 背景
网站启动后首次打开页面约需三分钟才出现可用界面。已确认 Vite dev server 本身可在数秒内 ready因此本次不继续扩大 `api-server` 冷编译等待窗口,而是收口浏览器首屏可见链路 网站启动后首次打开页面约需三分钟才出现可用界面。已确认 Vite dev server 本身可在数秒内 ready浏览器 Network 面板中主要等待项集中在 `.tsx` 模块请求,因此本次不继续扩大 `api-server` 冷编译等待窗口,而是收口浏览器首屏 `.tsx` 冷转译与默认路由依赖图
## 2. 现象与根因 ## 2. 现象与根因
本次排查发现个会放大首屏等待的前端问题: 本次排查发现个会放大首屏等待的前端问题:
1. `RouteImageReadyGate` 会先挂载真实业务页面但把整页 `visibility: hidden`,扫描路由 DOM 中所有 `<img>` 和 CSS 图片,等全部图片 settled 后才显示页面。平台首页和运行时页面会渲染作品封面、角色图、图标和生成资源,任何慢图片或后端图片代理等待都会把整页可见时间拖长 1. 默认路由进入 `AuthenticatedApp -> App -> RpgRuntimeShell -> PlatformEntryFlowShellImpl`,首屏虽然只显示平台首页,但入口文件静态导入了创作中心、拼图 Agent、拼图结果页、拼图运行态等非首屏阶段组件。Vite dev 首次访问时需要逐个请求并转译这些 `.tsx`,表现为浏览器长时间卡在加载 `.tsx`
2. Vite dev server 监听范围过宽,日志中可见 `docs/``scripts/``server-rs/` 和测试文件变更都会触发 `page reload`。后端编译、文档更新或测试文件保存会让浏览器反复全量重载,叠加首屏图片门控后表现为“首次加载一直等” 2. `RouteImageReadyGate` 会先挂载真实业务页面但把整页 `visibility: hidden`,扫描路由 DOM 中所有 `<img>` 和 CSS 图片,等全部图片 settled 后才显示页面。图片不是本轮确认到的主等待项,但会放大 `.tsx` 冷转译后的可见延迟
3. Vite dev server 监听范围过宽,日志中可见 `docs/``scripts/``server-rs/` 和测试文件变更都会触发 `page reload`。后端编译、文档更新或测试文件保存会让浏览器反复全量重载,叠加 `.tsx` 冷转译后表现为“首次加载一直等”。
## 3. 修复口径 ## 3. 修复口径
### 3.1 首屏图片门控 ### 3.1 首屏 `.tsx` 冷转译
首屏门控从“等待所有图片加载完成”改为“短暂稳态等待后放行” 默认首页入口先做低风险依赖图收敛
- `App`、运行时阶段路由、面板路由避免从 barrel 文件导入,改为直连具体实现文件或类型文件。
- `PlatformEntryFlowShellImpl` 将拼图 Agent、拼图结果页、拼图详情页、拼图运行态、创作货架等非默认首屏组件改为 `lazy`
- 平台首页 Tab 保留已访问页面的挂载状态,但首访只挂载当前 Tab避免隐藏的创作页提前触发创作中心等懒加载模块。
- RPG 运行态画布和 overlay host 只在已经进入 RPG 世界后挂载,平台首页不再同步拉取运行态画布链路。
- 平台首页资料服务直连 `rpgProfileClient`,避免经过 `services/rpg-entry/index.ts` 把同域其它 client 一并纳入冷转译链路。
### 3.2 首屏图片门控
图片门控从“等待所有图片加载完成”改为“短暂稳态等待后放行”:
- 页面仍先真实挂载,保留极短等待窗口,避免首帧布局剧烈闪动。 - 页面仍先真实挂载,保留极短等待窗口,避免首帧布局剧烈闪动。
- 达到最大阻塞时间后必须显示页面,慢图片由浏览器渐进加载,不再隐藏整页。 - 达到最大阻塞时间后必须显示页面,慢图片由浏览器渐进加载,不再隐藏整页。
- 页面已经显示后,不再因为新增图片或图片地址变化重新隐藏页面。 - 页面已经显示后,不再因为新增图片或图片地址变化重新隐藏页面。
- 图片预加载继续保留,用于提前触发浏览器缓存,但不得成为首屏可见的硬阻塞。 - 图片预加载继续保留,用于提前触发浏览器缓存,但不得成为首屏可见的硬阻塞。
### 3.2 Vite 监听范围 ### 3.3 Vite 监听范围
Vite dev server 只对前端真实运行入口保持热更新敏感: Vite dev server 只对前端真实运行入口保持热更新敏感:
@@ -34,8 +45,9 @@ Vite dev server 只对前端真实运行入口保持热更新敏感:
## 4. 验收标准 ## 4. 验收标准
1. Vite ready 后,默认站点首屏不再等待所有图片完成才显示 1. Vite ready 后,默认站点首屏不再一次性转译明显非首屏的拼图/玩法结果/运行态组件
2. 慢图片、失败图片或生成资源代理慢时,页面主体仍能先显示并保持可操作 2. 默认首页冷加载 `.tsx` 请求数量下降,创作、拼图、运行态等阶段在用户进入时再加载对应 chunk
3. 修改 `docs/``server-rs/``scripts/` 或测试文件时,不再触发前端页面 reload 3. 慢图片、失败图片或生成资源代理慢时,页面主体仍能先显示并保持可操作
4. `RouteImageReadyGate` 工具测试覆盖慢图片仍会放行首屏的行为 4. 修改 `docs/``server-rs/``scripts/` 或测试文件时,不再触发前端页面 reload
5. 修改中文文件后运行编码检查,确保没有破坏 UTF-8 文本。 5. `RouteImageReadyGate` 工具测试覆盖慢图片仍会放行首屏的行为。
6. 修改中文文件后运行编码检查,确保没有破坏 UTF-8 文本。

View File

@@ -1,5 +1,5 @@
import { RpgRuntimeShell } from './components/rpg-runtime-shell'; import { RpgRuntimeShell } from './components/rpg-runtime-shell/RpgRuntimeShell';
import { useRpgRuntimeSession } from './hooks/rpg-session'; import { useRpgRuntimeSession } from './hooks/rpg-session/useRpgRuntimeSession';
export default function App() { export default function App() {
const gameShellProps = useRpgRuntimeSession(); const gameShellProps = useRpgRuntimeSession();

View File

@@ -60,7 +60,7 @@ import {
createMiniGameDraftGenerationState, createMiniGameDraftGenerationState,
type MiniGameDraftGenerationState, type MiniGameDraftGenerationState,
} from '../../services/miniGameDraftGenerationProgress'; } from '../../services/miniGameDraftGenerationProgress';
import { getPlatformProfileDashboard } from '../../services/platform-entry'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { import {
createPuzzleAgentSession, createPuzzleAgentSession,
executePuzzleAgentAction, executePuzzleAgentAction,
@@ -81,15 +81,12 @@ import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode'; import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode';
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation'; import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter'; import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry'; import {
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient'; deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetailByCode,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import type { CustomWorldProfile } from '../../types'; import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext'; import { useAuthUi } from '../auth/AuthUiContext';
import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub';
import { PuzzleAgentWorkspace } from '../puzzle-agent/PuzzleAgentWorkspace';
import { PuzzleGalleryDetailView } from '../puzzle-gallery/PuzzleGalleryDetailView';
import { PuzzleResultView } from '../puzzle-result/PuzzleResultView';
import { PuzzleRuntimeShell } from '../puzzle-runtime/PuzzleRuntimeShell';
import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling'; import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling';
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave'; import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave';
@@ -332,6 +329,41 @@ const BigFishRuntimeShell = lazy(async () => {
}; };
}); });
const CustomWorldCreationHub = lazy(async () => {
const module = await import('../custom-world-home/CustomWorldCreationHub');
return {
default: module.CustomWorldCreationHub,
};
});
const PuzzleAgentWorkspace = lazy(async () => {
const module = await import('../puzzle-agent/PuzzleAgentWorkspace');
return {
default: module.PuzzleAgentWorkspace,
};
});
const PuzzleResultView = lazy(async () => {
const module = await import('../puzzle-result/PuzzleResultView');
return {
default: module.PuzzleResultView,
};
});
const PuzzleGalleryDetailView = lazy(async () => {
const module = await import('../puzzle-gallery/PuzzleGalleryDetailView');
return {
default: module.PuzzleGalleryDetailView,
};
});
const PuzzleRuntimeShell = lazy(async () => {
const module = await import('../puzzle-runtime/PuzzleRuntimeShell');
return {
default: module.PuzzleRuntimeShell,
};
});
function LazyPanelFallback({ label }: { label: string }) { function LazyPanelFallback({ label }: { label: string }) {
return ( return (
<div className="flex h-full min-h-0 items-center justify-center"> <div className="flex h-full min-h-0 items-center justify-center">
@@ -1647,97 +1679,99 @@ export function PlatformEntryFlowShellImpl({
]); ]);
const creationHubContent = ( const creationHubContent = (
<CustomWorldCreationHub <Suspense fallback={<LazyPanelFallback label="正在加载创作中心..." />}>
items={creationHubItems} <CustomWorldCreationHub
loading={ items={creationHubItems}
platformBootstrap.isLoadingPlatform || loading={
isBigFishLoadingLibrary || platformBootstrap.isLoadingPlatform ||
isPuzzleLoadingLibrary isBigFishLoadingLibrary ||
} isPuzzleLoadingLibrary
error={ }
platformBootstrap.isLoadingPlatform || error={
isBigFishLoadingLibrary || platformBootstrap.isLoadingPlatform ||
isPuzzleLoadingLibrary isBigFishLoadingLibrary ||
? null isPuzzleLoadingLibrary
: (platformBootstrap.platformError ?? ? null
sessionController.agentWorkspaceRestoreError ?? : (platformBootstrap.platformError ??
bigFishError ?? sessionController.agentWorkspaceRestoreError ??
puzzleError) bigFishError ??
} puzzleError)
onRetry={() => { }
platformBootstrap.setPlatformError(null); onRetry={() => {
setBigFishError(null); platformBootstrap.setPlatformError(null);
setPuzzleError(null); setBigFishError(null);
void platformBootstrap.refreshCustomWorldWorks().catch((error) => { setPuzzleError(null);
platformBootstrap.setPlatformError( void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'), platformBootstrap.setPlatformError(
); resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'),
}); );
void refreshBigFishShelf(); });
void refreshPuzzleShelf(); void refreshBigFishShelf();
}} void refreshPuzzleShelf();
createError={ }}
sessionController.creationTypeError ?? bigFishError ?? puzzleError createError={
} sessionController.creationTypeError ?? bigFishError ?? puzzleError
createBusy={ }
sessionController.isCreatingAgentSession || createBusy={
isBigFishBusy || sessionController.isCreatingAgentSession ||
isPuzzleBusy isBigFishBusy ||
} isPuzzleBusy
onCreateType={handleCreationHubCreateType} }
onOpenDraft={(item) => { onCreateType={handleCreationHubCreateType}
runProtectedAction(() => { onOpenDraft={(item) => {
void detailNavigation.handleOpenCreationWork(item); runProtectedAction(() => {
}); void detailNavigation.handleOpenCreationWork(item);
}} });
onEnterPublished={(profileId) => { }}
runProtectedAction(() => { onEnterPublished={(profileId) => {
const matchedWork = creationHubItems.find( runProtectedAction(() => {
(entry) => entry.profileId === profileId, const matchedWork = creationHubItems.find(
); (entry) => entry.profileId === profileId,
if (!matchedWork) { );
return; if (!matchedWork) {
} return;
void detailNavigation.handleOpenCreationWork(matchedWork); }
}); void detailNavigation.handleOpenCreationWork(matchedWork);
}} });
onDeletePublished={(item) => { }}
handleDeletePublishedWork(item); onDeletePublished={(item) => {
}} handleDeletePublishedWork(item);
deletingWorkId={deletingCreationWorkId} }}
onExperienceRpg={(item) => { deletingWorkId={deletingCreationWorkId}
handleExperienceRpgWork(item); onExperienceRpg={(item) => {
}} handleExperienceRpgWork(item);
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} }}
bigFishItems={bigFishWorks} rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
onOpenBigFishDetail={(item) => { bigFishItems={bigFishWorks}
runProtectedAction(() => { onOpenBigFishDetail={(item) => {
void openBigFishDraft(item); runProtectedAction(() => {
}); void openBigFishDraft(item);
}} });
onExperienceBigFish={(item) => { }}
runProtectedAction(() => { onExperienceBigFish={(item) => {
void startBigFishRunFromWork(item); runProtectedAction(() => {
}); void startBigFishRunFromWork(item);
}} });
onDeleteBigFish={(item) => { }}
handleDeleteBigFishWork(item); onDeleteBigFish={(item) => {
}} handleDeleteBigFishWork(item);
puzzleItems={puzzleWorks} }}
onOpenPuzzleDetail={(item) => { puzzleItems={puzzleWorks}
runProtectedAction(() => { onOpenPuzzleDetail={(item) => {
void openPuzzleDraft(item); runProtectedAction(() => {
}); void openPuzzleDraft(item);
}} });
onExperiencePuzzle={(profileId) => { }}
runProtectedAction(() => { onExperiencePuzzle={(profileId) => {
void startPuzzleRunFromProfile(profileId); runProtectedAction(() => {
}); void startPuzzleRunFromProfile(profileId);
}} });
onDeletePuzzle={(item) => { }}
handleDeletePuzzleWork(item); onDeletePuzzle={(item) => {
}} handleDeletePuzzleWork(item);
/> }}
/>
</Suspense>
); );
return ( return (
@@ -2074,21 +2108,23 @@ export function PlatformEntryFlowShellImpl({
exit={{ opacity: 0, y: -12 }} exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col" className="flex h-full min-h-0 flex-col"
> >
<PuzzleAgentWorkspace <Suspense fallback={<LazyPanelFallback label="正在加载拼图创作..." />}>
session={puzzleSession} <PuzzleAgentWorkspace
activeOperation={puzzleOperation} session={puzzleSession}
streamingReplyText={streamingPuzzleReplyText} activeOperation={puzzleOperation}
isStreamingReply={isStreamingPuzzleReply} streamingReplyText={streamingPuzzleReplyText}
isBusy={isPuzzleBusy || isStreamingPuzzleReply} isStreamingReply={isStreamingPuzzleReply}
error={puzzleError} isBusy={isPuzzleBusy || isStreamingPuzzleReply}
onBack={leavePuzzleFlow} error={puzzleError}
onSubmitMessage={(payload) => { onBack={leavePuzzleFlow}
void submitPuzzleMessage(payload); onSubmitMessage={(payload) => {
}} void submitPuzzleMessage(payload);
onExecuteAction={(payload) => { }}
void executePuzzleAction(payload); onExecuteAction={(payload) => {
}} void executePuzzleAction(payload);
/> }}
/>
</Suspense>
</motion.div> </motion.div>
)} )}
@@ -2145,18 +2181,20 @@ export function PlatformEntryFlowShellImpl({
exit={{ opacity: 0, y: -12 }} exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col" className="flex h-full min-h-0 flex-col"
> >
<PuzzleResultView <Suspense fallback={<LazyPanelFallback label="正在加载拼图结果..." />}>
session={puzzleSession} <PuzzleResultView
author={authUi?.user ?? null} session={puzzleSession}
isBusy={isPuzzleBusy} author={authUi?.user ?? null}
error={puzzleError} isBusy={isPuzzleBusy}
onBack={() => { error={puzzleError}
setSelectionStage('puzzle-agent-workspace'); onBack={() => {
}} setSelectionStage('puzzle-agent-workspace');
onExecuteAction={(payload) => { }}
void executePuzzleAction(payload); onExecuteAction={(payload) => {
}} void executePuzzleAction(payload);
/> }}
/>
</Suspense>
</motion.div> </motion.div>
)} )}
@@ -2168,31 +2206,33 @@ export function PlatformEntryFlowShellImpl({
exit={{ opacity: 0, y: -12 }} exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col" className="flex h-full min-h-0 flex-col"
> >
<PuzzleGalleryDetailView <Suspense fallback={<LazyPanelFallback label="正在加载拼图详情..." />}>
item={selectedPuzzleDetail} <PuzzleGalleryDetailView
isBusy={isPuzzleBusy} item={selectedPuzzleDetail}
error={puzzleError} isBusy={isPuzzleBusy}
onBack={() => { error={puzzleError}
platformBootstrap.setPlatformTab( onBack={() => {
puzzleDetailReturnTarget?.tab ?? 'home', platformBootstrap.setPlatformTab(
); puzzleDetailReturnTarget?.tab ?? 'home',
setPuzzleDetailReturnTarget(null); );
setSelectionStage('platform'); setPuzzleDetailReturnTarget(null);
}} setSelectionStage('platform');
onEdit={ }}
selectedPuzzleDetail.ownerUserId === authUi?.user?.id && onEdit={
Boolean(selectedPuzzleDetail.sourceSessionId?.trim()) selectedPuzzleDetail.ownerUserId === authUi?.user?.id &&
? () => { Boolean(selectedPuzzleDetail.sourceSessionId?.trim())
runProtectedAction(() => { ? () => {
void openPuzzleDraft(selectedPuzzleDetail); runProtectedAction(() => {
}); void openPuzzleDraft(selectedPuzzleDetail);
} });
: null }
} : null
onStartGame={() => { }
void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId); onStartGame={() => {
}} void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId);
/> }}
/>
</Suspense>
</motion.div> </motion.div>
)} )}
@@ -2204,23 +2244,25 @@ export function PlatformEntryFlowShellImpl({
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-[100]" className="fixed inset-0 z-[100]"
> >
<PuzzleRuntimeShell <Suspense fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}>
run={puzzleRun} <PuzzleRuntimeShell
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating} run={puzzleRun}
error={puzzleError} isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}
onBack={() => { error={puzzleError}
setSelectionStage('puzzle-gallery-detail'); onBack={() => {
}} setSelectionStage('puzzle-gallery-detail');
onSwapPieces={(payload) => { }}
void swapPuzzlePiecesInRun(payload); onSwapPieces={(payload) => {
}} void swapPuzzlePiecesInRun(payload);
onDragPiece={(payload) => { }}
void dragPuzzlePiece(payload); onDragPiece={(payload) => {
}} void dragPuzzlePiece(payload);
onAdvanceNextLevel={() => { }}
void advancePuzzleLevel(); onAdvanceNextLevel={() => {
}} void advancePuzzleLevel();
/> }}
/>
</Suspense>
{isPuzzleNextLevelGenerating ? ( {isPuzzleNextLevelGenerating ? (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-950/62 px-5 backdrop-blur-sm"> <div className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-950/62 px-5 backdrop-blur-sm">
<div className="flex max-w-[18rem] flex-col items-center gap-3 rounded-[1.5rem] border border-white/12 bg-slate-950/92 px-6 py-5 text-center text-white shadow-[0_28px_80px_rgba(0,0,0,0.35)]"> <div className="flex max-w-[18rem] flex-col items-center gap-3 rounded-[1.5rem] border border-white/12 bg-slate-950/92 px-6 py-5 text-center text-white shadow-[0_28px_80px_rgba(0,0,0,0.35)]">

View File

@@ -23,8 +23,8 @@ import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClien
import { fetchJson } from '../../editor/shared/jsonClient'; import { fetchJson } from '../../editor/shared/jsonClient';
import { useCombatFlow } from '../../hooks/useCombatFlow'; import { useCombatFlow } from '../../hooks/useCombatFlow';
import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow'; import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow';
import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story'; import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story/useRpgRuntimeStory';
import { useRpgSessionBootstrap } from '../../hooks/rpg-session'; import { useRpgSessionBootstrap } from '../../hooks/rpg-session/useRpgSessionBootstrap';
import { buildSkillActionPrompt } from '../../prompts/customWorldEntityActionPrompts'; import { buildSkillActionPrompt } from '../../prompts/customWorldEntityActionPrompts';
import type { CustomWorldSceneImageResult } from '../../services/aiTypes'; import type { CustomWorldSceneImageResult } from '../../services/aiTypes';
import { resolveCustomWorldCampScene } from '../../services/customWorldCamp'; import { resolveCustomWorldCampScene } from '../../services/customWorldCamp';

View File

@@ -1,4 +1,4 @@
import { PlatformEntryFlowShell } from '../platform-entry'; import { PlatformEntryFlowShell } from '../platform-entry/PlatformEntryFlowShell';
import type { RpgEntryFlowShellProps } from './rpgEntryTypes'; import type { RpgEntryFlowShellProps } from './rpgEntryTypes';
import type { SelectionStage } from './rpgEntryTypes'; import type { SelectionStage } from './rpgEntryTypes';

View File

@@ -1110,6 +1110,9 @@ export function RpgEntryHomeView({
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>( const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null, null,
); );
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
() => new Set([activeTab]),
);
const isAuthenticated = Boolean(authUi?.user); const isAuthenticated = Boolean(authUi?.user);
const isDesktopLayout = usePlatformDesktopLayout(); const isDesktopLayout = usePlatformDesktopLayout();
const featuredShelf = useMemo( const featuredShelf = useMemo(
@@ -1159,6 +1162,18 @@ export function RpgEntryHomeView({
} }
}, [activeTab, onTabChange, visibleTabs]); }, [activeTab, onTabChange, visibleTabs]);
useEffect(() => {
setVisitedTabs((currentTabs) => {
if (currentTabs.has(activeTab)) {
return currentTabs;
}
const nextTabs = new Set(currentTabs);
nextTabs.add(activeTab);
return nextTabs;
});
}, [activeTab]);
useEffect(() => { useEffect(() => {
if (categoryGroups.length === 0) { if (categoryGroups.length === 0) {
setSelectedCategoryTag(null); setSelectedCategoryTag(null);
@@ -1950,11 +1965,15 @@ export function RpgEntryHomeView({
} satisfies Record<PlatformHomeTab, ReactNode>; } satisfies Record<PlatformHomeTab, ReactNode>;
const tabPanels = PLATFORM_HOME_TABS.filter((tab) => const tabPanels = PLATFORM_HOME_TABS.filter((tab) =>
visibleTabs.includes(tab), visibleTabs.includes(tab),
).map((tab) => ( ).map((tab) => {
<PlatformTabPanel key={tab} tab={tab} activeTab={activeTab}> const shouldMountPanel = tab === activeTab || visitedTabs.has(tab);
{tabContentById[tab]}
</PlatformTabPanel> return (
)); <PlatformTabPanel key={tab} tab={tab} activeTab={activeTab}>
{shouldMountPanel ? tabContentById[tab] : null}
</PlatformTabPanel>
);
});
if (!isDesktopLayout) { if (!isDesktopLayout) {
return ( return (

View File

@@ -1,6 +1,6 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import type { BottomTab } from '../../hooks/rpg-session'; import type { BottomTab } from '../../hooks/rpg-session/rpgSessionTypes';
import type { import type {
BattleRewardUi, BattleRewardUi,
CharacterChatUi, CharacterChatUi,
@@ -18,10 +18,8 @@ import type {
import { getNineSliceStyle, TAB_ICONS, UI_CHROME } from '../../uiAssets'; import { getNineSliceStyle, TAB_ICONS, UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas'; import type { GameCanvasEntitySelection } from '../GameCanvas';
import { PixelIcon } from '../PixelIcon'; import { PixelIcon } from '../PixelIcon';
import { import { PanelLoadingFallback } from '../rpg-runtime-shell/rpgRuntimeLoaders';
PanelLoadingFallback, import type { RpgAdventureStatistics } from '../rpg-runtime-shell/types';
type RpgAdventureStatistics,
} from '../rpg-runtime-shell';
const RpgAdventurePanel = lazy(async () => { const RpgAdventurePanel = lazy(async () => {
const module = await import('./RpgAdventurePanel'); const module = await import('./RpgAdventurePanel');

View File

@@ -7,11 +7,17 @@ import {
} from '../../routing/appPageRoutes'; } from '../../routing/appPageRoutes';
import { UI_CHROME } from '../../uiAssets'; import { UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext'; import { useAuthUi } from '../auth/AuthUiContext';
import { RpgRuntimeCanvasStage } from './RpgRuntimeCanvasStage';
import { RpgRuntimeStageRouter } from './RpgRuntimeStageRouter'; import { RpgRuntimeStageRouter } from './RpgRuntimeStageRouter';
import type { RpgRuntimeShellProps as RpgRuntimeShellComponentProps } from './types'; import type { RpgRuntimeShellProps as RpgRuntimeShellComponentProps } from './types';
import { useRpgRuntimeShellViewModel } from './useRpgRuntimeShellViewModel'; import { useRpgRuntimeShellViewModel } from './useRpgRuntimeShellViewModel';
const RpgRuntimeCanvasStage = lazy(async () => {
const module = await import('./RpgRuntimeCanvasStage');
return {
default: module.RpgRuntimeCanvasStage,
};
});
const RpgRuntimeOverlayHost = lazy(async () => { const RpgRuntimeOverlayHost = lazy(async () => {
const module = await import('./RpgRuntimeOverlayHost'); const module = await import('./RpgRuntimeOverlayHost');
return { return {
@@ -152,20 +158,22 @@ export function RpgRuntimeShell({
backgroundRepeat: isPlatformShell ? undefined : 'repeat', backgroundRepeat: isPlatformShell ? undefined : 'repeat',
}} }}
> >
<Suspense fallback={null}> {gameState.worldType ? (
<RpgRuntimeCanvasStage <Suspense fallback={null}>
gameState={gameState} <RpgRuntimeCanvasStage
visibleGameState={visibleGameState} gameState={gameState}
hideSelectionHero={hideSelectionHero} visibleGameState={visibleGameState}
canvasCompanionRenderStates={canvasCompanionRenderStates} hideSelectionHero={hideSelectionHero}
dialogueIndicator={dialogueIndicator} canvasCompanionRenderStates={canvasCompanionRenderStates}
sceneTransitionPhase={sceneTransitionPhase} dialogueIndicator={dialogueIndicator}
sceneTransitionToken={sceneTransitionToken} sceneTransitionPhase={sceneTransitionPhase}
setSelectedSceneEntity={setSelectedSceneEntity} sceneTransitionToken={sceneTransitionToken}
setIsMapOpen={setIsMapOpen} setSelectedSceneEntity={setSelectedSceneEntity}
setSceneTransitionDurations={setSceneTransitionDurations} setIsMapOpen={setIsMapOpen}
/> setSceneTransitionDurations={setSceneTransitionDurations}
</Suspense> />
</Suspense>
) : null}
{visibleGameState.playerCharacter && ( {visibleGameState.playerCharacter && (
<div <div
@@ -240,35 +248,37 @@ export function RpgRuntimeShell({
handleSaveAndExit={handleSaveAndExit} handleSaveAndExit={handleSaveAndExit}
/> />
<Suspense fallback={null}> {gameState.worldType ? (
<RpgRuntimeOverlayHost <Suspense fallback={null}>
gameState={gameState} <RpgRuntimeOverlayHost
isLoading={isLoading} gameState={gameState}
isMapOpen={isMapOpen} isLoading={isLoading}
setIsMapOpen={setIsMapOpen} isMapOpen={isMapOpen}
npcUi={npcUi} setIsMapOpen={setIsMapOpen}
characterChatUi={characterChatUi} npcUi={npcUi}
inventoryUi={inventoryUi} characterChatUi={characterChatUi}
companionRenderStates={companionRenderStates} inventoryUi={inventoryUi}
characterChatSummaries={characterChatSummaries} companionRenderStates={companionRenderStates}
overlayPanel={overlayPanel} characterChatSummaries={characterChatSummaries}
closeOverlayPanel={closeOverlayPanel} overlayPanel={overlayPanel}
openCampModal={openCampModal} closeOverlayPanel={closeOverlayPanel}
openPartyMemberDetails={openPartyMemberDetails} openCampModal={openCampModal}
shouldMountAdventureEntityModal={shouldMountAdventureEntityModal} openPartyMemberDetails={openPartyMemberDetails}
selectedSceneEntity={selectedSceneEntity} shouldMountAdventureEntityModal={shouldMountAdventureEntityModal}
closeAdventureEntityModal={closeAdventureEntityModal} selectedSceneEntity={selectedSceneEntity}
shouldMountCampModal={shouldMountCampModal} closeAdventureEntityModal={closeAdventureEntityModal}
showTeamModal={showTeamModal} shouldMountCampModal={shouldMountCampModal}
closeCampModal={closeCampModal} showTeamModal={showTeamModal}
onBenchCompanion={onBenchCompanion} closeCampModal={closeCampModal}
onActivateRosterCompanion={onActivateRosterCompanion} onBenchCompanion={onBenchCompanion}
shouldMountMapModal={shouldMountMapModal} onActivateRosterCompanion={onActivateRosterCompanion}
handleMapTravelToScene={handleMapTravelToScene} shouldMountMapModal={shouldMountMapModal}
shouldMountCharacterChatModal={shouldMountCharacterChatModal} handleMapTravelToScene={handleMapTravelToScene}
shouldMountNpcModals={shouldMountNpcModals} shouldMountCharacterChatModal={shouldMountCharacterChatModal}
/> shouldMountNpcModals={shouldMountNpcModals}
</Suspense> />
</Suspense>
) : null}
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import type { BottomTab } from '../../hooks/rpg-session'; import type { BottomTab } from '../../hooks/rpg-session/rpgSessionTypes';
import type { import type {
BattleRewardUi, BattleRewardUi,
CharacterChatUi, CharacterChatUi,
@@ -20,25 +20,25 @@ import type {
} from '../../types'; } from '../../types';
import { UI_CHROME } from '../../uiAssets'; import { UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas'; import type { GameCanvasEntitySelection } from '../GameCanvas';
import type { SelectionStage } from '../platform-entry'; import type { SelectionStage } from '../platform-entry/platformEntryTypes';
import type { RpgAdventureStatistics } from './types'; import type { RpgAdventureStatistics } from './types';
const RpgEntryCharacterSelectView = lazy(async () => { const RpgEntryCharacterSelectView = lazy(async () => {
const module = await import('../rpg-entry'); const module = await import('../rpg-entry/RpgEntryCharacterSelectView');
return { return {
default: module.RpgEntryCharacterSelectView, default: module.RpgEntryCharacterSelectView,
}; };
}); });
const PlatformEntryFlowShell = lazy(async () => { const PlatformEntryFlowShell = lazy(async () => {
const module = await import('../platform-entry'); const module = await import('../platform-entry/PlatformEntryFlowShell');
return { return {
default: module.PlatformEntryFlowShell, default: module.PlatformEntryFlowShell,
}; };
}); });
const RpgRuntimePanelRouter = lazy(async () => { const RpgRuntimePanelRouter = lazy(async () => {
const module = await import('../rpg-runtime-panels'); const module = await import('../rpg-runtime-panels/RpgRuntimePanelRouter');
return { return {
default: module.RpgRuntimePanelRouter, default: module.RpgRuntimePanelRouter,
}; };

View File

@@ -1,4 +1,4 @@
import type { BottomTab } from '../../hooks/rpg-session'; import type { BottomTab } from '../../hooks/rpg-session/rpgSessionTypes';
import type { import type {
BattleRewardUi, BattleRewardUi,
CharacterChatUi, CharacterChatUi,

View File

@@ -7,7 +7,7 @@ import {
} from '../../routing/appPageRoutes'; } from '../../routing/appPageRoutes';
import type { GameState } from '../../types'; import type { GameState } from '../../types';
import type { GameCanvasEntitySelection } from '../GameCanvas'; import type { GameCanvasEntitySelection } from '../GameCanvas';
import type { SelectionStage } from '../platform-entry'; import type { SelectionStage } from '../platform-entry/platformEntryTypes';
type OverlayPanel = 'character' | 'inventory' | null; type OverlayPanel = 'character' | 'inventory' | null;

View File

@@ -2,7 +2,7 @@ import { useEffect } from 'react';
import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime'; import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime';
import { useAuthUi } from '../../components/auth/AuthUiContext'; import { useAuthUi } from '../../components/auth/AuthUiContext';
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell'; import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types';
import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster'; import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster';
import { syncGameStatePlayTime } from '../../data/runtimeStats'; import { syncGameStatePlayTime } from '../../data/runtimeStats';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';

View File

@@ -1,5 +1 @@
/** export { getPlatformProfileDashboard } from './platformProfileClient';
* 平台入口服务通用封装。
* 先复用既有资料看板读取逻辑,但对 `platform-entry` 暴露通用命名。
*/
export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry';

View File

@@ -0,0 +1,5 @@
/**
* 平台首页资料读取入口。
* 直连 RPG profile client避免默认首页首访经过服务桶入口触发额外模块转译。
*/
export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry/rpgProfileClient';