From 00b2720237d792834944f9ee2b59478d695cb482 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 25 Jun 2026 23:30:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=94=BB=E5=B8=83=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E4=B8=8E=E5=88=B7=E6=96=B0=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缺少项目 ID 的画布直达链接会回到创作页 路由级黑底加载只保留给移动端推荐页 补充画布直达和推荐加载开关回归测试 --- src/App.test.tsx | 55 +++++++++++++++++++++++++++++++ src/App.tsx | 37 ++++++++++++++++++--- src/main.tsx | 38 ++++++++++++++++----- src/routing/appPageRoutes.test.ts | 23 +++++++++++++ src/routing/appPageRoutes.ts | 16 +++++++++ src/routing/appRoutes.test.ts | 14 +++++++- src/routing/appRoutes.tsx | 7 ++++ 7 files changed, 176 insertions(+), 14 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index eb0c1cc91..65056755e 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -294,6 +294,61 @@ describe('App navigation history', () => { ); }); + test('缺少 projectid 的项目画布直达会替换回创作页', () => { + window.history.replaceState(null, '', '/editor/canvas'); + const replaceStateSpy = vi.spyOn(window.history, 'replaceState'); + + renderApp(); + + expect(screen.getByTestId('selection-stage').textContent).toBe( + 'creation-home', + ); + expect(window.location.pathname).toBe('/creation'); + expect(window.location.search).toBe(''); + expect(replaceStateSpy).toHaveBeenCalledWith( + { [APP_HISTORY_STATE_KEY]: true }, + '', + '/creation', + ); + + replaceStateSpy.mockRestore(); + }); + + test('带 projectid 的项目画布直达保持编辑器阶段', () => { + mockMatchMedia(false); + window.history.replaceState( + null, + '', + '/editor/canvas?projectid=project-from-url', + ); + + renderApp(); + + expect(screen.getByTestId('selection-stage').textContent).toBe( + 'image-editor', + ); + expect(window.location.pathname).toBe('/editor/canvas'); + expect(window.location.search).toBe('?projectid=project-from-url'); + }); + + test('历史回到缺少 projectid 的项目画布时会替换回创作页', async () => { + mockMatchMedia(false); + window.history.replaceState(null, '', '/creation'); + + renderApp(); + + window.history.pushState(null, '', '/editor/canvas'); + await act(async () => { + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + expect(screen.getByTestId('selection-stage').textContent).toBe( + 'creation-home', + ); + expect(window.location.pathname).toBe('/creation'); + expect(window.location.search).toBe(''); + }); + test('项目画布导航只写入一次带 projectid 的历史记录', async () => { mockMatchMedia(true); window.history.replaceState(null, '', '/creation'); diff --git a/src/App.tsx b/src/App.tsx index 1bff59a58..9a11611d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { readPublicWorkCodeFromLocationSearch, resolveInitialSelectionStageFromPath, resolvePathForSelectionStage, + shouldRedirectEditorCanvasWithoutProject, } from './routing/appPageRoutes'; import type { RpgRuntimeAppIntent } from './RpgRuntimeApp'; import { @@ -62,6 +63,23 @@ function isRpgRuntimeRoute(pathname: string) { ); } +function resolveInitialAppSelectionStage() { + if ( + shouldRedirectEditorCanvasWithoutProject( + window.location.pathname, + window.location.search, + ) + ) { + replaceAppHistoryPath('/creation'); + return 'creation-home'; + } + + return resolveInitialSelectionStageFromPath( + window.location.pathname, + getInitialPlatformDesktopLayout(), + ); +} + export default function App() { const authUi = useAuthUi(); const runtimeIntentTokenRef = useRef(0); @@ -75,11 +93,8 @@ export default function App() { const [isRuntimeActive, setIsRuntimeActive] = useState(() => isRpgRuntimeRoute(window.location.pathname), ); - const [selectionStage, setRawSelectionStage] = useState(() => - resolveInitialSelectionStageFromPath( - window.location.pathname, - getInitialPlatformDesktopLayout(), - ), + const [selectionStage, setRawSelectionStage] = useState( + resolveInitialAppSelectionStage, ); const [runtimeReturnStage, setRuntimeReturnStage] = useState('platform'); @@ -111,6 +126,18 @@ export default function App() { window.history.state, ); + if ( + shouldRedirectEditorCanvasWithoutProject( + window.location.pathname, + window.location.search, + ) + ) { + replaceAppHistoryPath('/creation'); + setIsRuntimeActive(false); + setRawSelectionStage('creation-home'); + return; + } + if (isRpgRuntimeRoute(window.location.pathname)) { setIsRuntimeActive(true); return; diff --git a/src/main.tsx b/src/main.tsx index c730f9992..4a16f2c5a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,9 +6,13 @@ import {StrictMode, Suspense} from 'react'; import {createRoot} from 'react-dom/client'; import {FloatingFeedbackEntry} from './components/common/FloatingFeedbackEntry'; +import {getInitialPlatformDesktopLayout} from './components/platform-entry/platformEntryResponsive'; import {stabilizeMobileViewportKeyboardFocus} from './mobileViewportKeyboardFocus'; import {lockMobileViewportZoom} from './mobileViewportZoomLock'; -import {resolveAppRoute} from './routing/appRoutes'; +import { + resolveAppRoute, + shouldUseRecommendationRouteLoading, +} from './routing/appRoutes'; import {RouteImageReadyGate} from './routing/RouteImageReadyGate'; import {RouteLoadingScreen} from './routing/RouteLoadingScreen'; import { @@ -39,6 +43,11 @@ function markWechatMiniProgramRuntime() { const root = window.__tavernRealmsRoot__ ??= createRoot(rootElement); const RouteComponent = route.Component; +const routeElement = ; +const shouldGateRouteImages = shouldUseRecommendationRouteLoading( + window.location.pathname, + getInitialPlatformDesktopLayout(), +); lockMobileViewportZoom(); stabilizeMobileViewportKeyboardFocus(); @@ -47,13 +56,26 @@ void refreshNativeAppHostRuntime(); root.render( - }> - - - + + ) : null + } + > + {shouldGateRouteImages ? ( + + {routeElement} + + ) : ( + routeElement + )} , diff --git a/src/routing/appPageRoutes.test.ts b/src/routing/appPageRoutes.test.ts index c77578138..d08b2c784 100644 --- a/src/routing/appPageRoutes.test.ts +++ b/src/routing/appPageRoutes.test.ts @@ -5,9 +5,11 @@ import { describe, expect, it } from 'vitest'; import { isAppHistoryState, pushAppHistoryPath, + readEditorCanvasProjectIdFromLocationSearch, replaceAppHistoryPath, resolvePathForSelectionStage, resolveSelectionStageFromPath, + shouldRedirectEditorCanvasWithoutProject, } from './appPageRoutes'; describe('appPageRoutes', () => { @@ -39,6 +41,27 @@ describe('appPageRoutes', () => { expect(window.location.search).toBe('?projectid=project-from-route-test'); }); + it('detects editor canvas routes that are missing project id', () => { + expect(readEditorCanvasProjectIdFromLocationSearch('?projectid=abc')).toBe( + 'abc', + ); + expect(shouldRedirectEditorCanvasWithoutProject('/editor/canvas', '')).toBe( + true, + ); + expect( + shouldRedirectEditorCanvasWithoutProject('/EDITOR/CANVAS/', '?foo=bar'), + ).toBe(true); + expect( + shouldRedirectEditorCanvasWithoutProject( + '/editor/canvas', + '?projectid=project-from-route-test', + ), + ).toBe(false); + expect(shouldRedirectEditorCanvasWithoutProject('/creation', '')).toBe( + false, + ); + }); + it('resolves the project route', () => { expect(resolveSelectionStageFromPath('/project')).toBe('project'); expect(resolveSelectionStageFromPath('/PROJECT/')).toBe('project'); diff --git a/src/routing/appPageRoutes.ts b/src/routing/appPageRoutes.ts index f1bfbe7a9..7b5186859 100644 --- a/src/routing/appPageRoutes.ts +++ b/src/routing/appPageRoutes.ts @@ -9,6 +9,7 @@ import { export type RuntimePageRoute = 'rpg-character-select' | 'rpg-adventure'; export const PUBLIC_WORK_QUERY_PARAM = 'work'; +export const EDITOR_CANVAS_PROJECT_ID_QUERY_PARAM = 'projectid'; const STAGE_ROUTE_ENTRIES = [ ['platform', '/'], @@ -122,6 +123,21 @@ export function readPublicWorkCodeFromLocationSearch(search: string) { return params.get(PUBLIC_WORK_QUERY_PARAM)?.trim() || null; } +export function readEditorCanvasProjectIdFromLocationSearch(search: string) { + const params = new URLSearchParams(search); + return params.get(EDITOR_CANVAS_PROJECT_ID_QUERY_PARAM)?.trim() || null; +} + +export function shouldRedirectEditorCanvasWithoutProject( + pathname: string, + search: string, +) { + return ( + normalizeAppPath(pathname) === APP_STAGE_ROUTES['image-editor'] && + !readEditorCanvasProjectIdFromLocationSearch(search) + ); +} + export function buildPublicWorkDetailPath(publicWorkCode: string) { return buildPublicWorkStagePath('work-detail', publicWorkCode); } diff --git a/src/routing/appRoutes.test.ts b/src/routing/appRoutes.test.ts index 05f3dd53f..8980eabed 100644 --- a/src/routing/appRoutes.test.ts +++ b/src/routing/appRoutes.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { matchAppRoute } from './appRoutes'; +import { matchAppRoute, shouldUseRecommendationRouteLoading } from './appRoutes'; afterEach(() => { vi.unstubAllEnvs(); @@ -89,3 +89,15 @@ describe('matchAppRoute', () => { }); }); }); + +describe('shouldUseRecommendationRouteLoading', () => { + it('only enables the dark recommendation loading screen for mobile root route', () => { + expect(shouldUseRecommendationRouteLoading('/', false)).toBe(true); + expect(shouldUseRecommendationRouteLoading('/', true)).toBe(false); + expect(shouldUseRecommendationRouteLoading('/creation', false)).toBe(false); + expect(shouldUseRecommendationRouteLoading('/project', false)).toBe(false); + expect(shouldUseRecommendationRouteLoading('/editor/canvas', false)).toBe( + false, + ); + }); +}); diff --git a/src/routing/appRoutes.tsx b/src/routing/appRoutes.tsx index 27ffa541e..a253667db 100644 --- a/src/routing/appRoutes.tsx +++ b/src/routing/appRoutes.tsx @@ -54,6 +54,13 @@ function normalizeRoutePath(pathname: string) { return normalizeAppPath(pathname); } +export function shouldUseRecommendationRouteLoading( + pathname: string, + isDesktopLayout: boolean, +) { + return normalizeRoutePath(pathname) === '/' && !isDesktopLayout; +} + export function matchAppRoute(pathname: string): AppRouteMatch { const normalizedPath = normalizeRoutePath(pathname);