Files
Genarrative/src/routing/appPageRoutes.ts
T
kdletters ce0765b298 接入原生返回栈状态消费
H5 新增 navigation.canGoBack hook 并在直达二级页时补齐返回锚点

路由导航保留完整原生宿主上下文并标记应用历史状态

补齐 HostBridge 返回栈消费测试、门禁和文档
2026-06-19 10:59:47 +08:00

232 lines
8.1 KiB
TypeScript

import type { SelectionStage } from '../components/platform-entry';
import { HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS } from '../../packages/shared/src/contracts/hostBridge';
import {
buildCreationUrlSearchFromParams,
isCreationRestorePath,
isSameCreationFlowPath,
} from '../services/creationUrlState';
export type RuntimePageRoute = 'rpg-character-select' | 'rpg-adventure';
export const PUBLIC_WORK_QUERY_PARAM = 'work';
const STAGE_ROUTE_ENTRIES = [
['platform', '/'],
['profile-feedback', '/profile/feedback'],
['work-detail', '/works/detail'],
['detail', '/worlds/detail'],
['agent-workspace', '/creation/rpg'],
['big-fish-agent-workspace', '/creation/big-fish'],
['custom-world-generating', '/creation/rpg/generating'],
['custom-world-result', '/creation/rpg/result'],
['big-fish-generating', '/creation/big-fish/generating'],
['big-fish-result', '/creation/big-fish/result'],
['big-fish-runtime', '/runtime/big-fish'],
['match3d-agent-workspace', '/creation/match3d'],
['match3d-generating', '/creation/match3d/generating'],
['match3d-result', '/creation/match3d/result'],
['match3d-runtime', '/runtime/match3d'],
['square-hole-agent-workspace', '/creation/square-hole'],
['square-hole-generating', '/creation/square-hole/generating'],
['square-hole-result', '/creation/square-hole/result'],
['square-hole-runtime', '/runtime/square-hole'],
['jump-hop-workspace', '/creation/jump-hop'],
['jump-hop-generating', '/creation/jump-hop/generating'],
['jump-hop-result', '/creation/jump-hop/result'],
['jump-hop-gallery-detail', '/gallery/jump-hop/detail'],
['jump-hop-runtime', '/runtime/jump-hop'],
['puzzle-clear-workspace', '/creation/puzzle-clear'],
['puzzle-clear-generating', '/creation/puzzle-clear/generating'],
['puzzle-clear-result', '/creation/puzzle-clear/result'],
['puzzle-clear-runtime', '/runtime/puzzle-clear'],
['bark-battle-workspace', '/creation/bark-battle'],
['bark-battle-generating', '/creation/bark-battle/generating'],
['bark-battle-result', '/creation/bark-battle/result'],
['bark-battle-runtime', '/runtime/bark-battle'],
['wooden-fish-workspace', '/creation/wooden-fish'],
['wooden-fish-generating', '/creation/wooden-fish/generating'],
['wooden-fish-result', '/creation/wooden-fish/result'],
['wooden-fish-runtime', '/runtime/wooden-fish'],
['creative-agent-workspace', '/creation/creative-agent'],
['visual-novel-agent-workspace', '/creation/visual-novel'],
['visual-novel-generating', '/creation/visual-novel/generating'],
['visual-novel-result', '/creation/visual-novel/result'],
['visual-novel-gallery-detail', '/gallery/visual-novel/detail'],
['visual-novel-runtime', '/runtime/visual-novel'],
['baby-object-match-workspace', '/creation/baby-object-match'],
['baby-object-match-generating', '/creation/baby-object-match/generating'],
['baby-object-match-result', '/creation/baby-object-match/result'],
['baby-object-match-runtime', '/runtime/baby-object-match'],
['baby-love-drawing-runtime', '/runtime/baby-love-drawing'],
['puzzle-agent-workspace', '/creation/puzzle'],
['puzzle-generating', '/creation/puzzle/generating'],
['puzzle-result', '/creation/puzzle/result'],
['puzzle-gallery-detail', '/gallery/puzzle/detail'],
['puzzle-runtime', '/runtime/puzzle'],
] as const satisfies readonly (readonly [SelectionStage, string])[];
export const APP_STAGE_ROUTES: Record<SelectionStage, string> =
Object.fromEntries(STAGE_ROUTE_ENTRIES) as Record<SelectionStage, string>;
export const APP_RUNTIME_ROUTES: Record<RuntimePageRoute, string> = {
'rpg-character-select': '/runtime/rpg/characters',
'rpg-adventure': '/runtime/rpg/adventure',
};
const ROUTE_STAGE_BY_PATH = new Map(
STAGE_ROUTE_ENTRIES.map(([stage, path]) => [path, stage] as const),
) as Map<string, SelectionStage>;
const APP_RUNTIME_CONTEXT_QUERY_KEYS =
HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS;
export const APP_HISTORY_STATE_KEY = '__genarrativeAppHistoryEntry';
export function normalizeAppPath(pathname: string) {
const trimmedPathname = pathname.trim().toLowerCase();
if (!trimmedPathname || trimmedPathname === '/') {
return '/';
}
return trimmedPathname.replace(/\/+$/u, '');
}
export function resolveSelectionStageFromPath(
pathname: string,
): SelectionStage {
return ROUTE_STAGE_BY_PATH.get(normalizeAppPath(pathname)) ?? 'platform';
}
export function resolvePathForSelectionStage(stage: SelectionStage) {
return APP_STAGE_ROUTES[stage] ?? APP_STAGE_ROUTES.platform;
}
export function readPublicWorkCodeFromLocationSearch(search: string) {
const params = new URLSearchParams(search);
return params.get(PUBLIC_WORK_QUERY_PARAM)?.trim() || null;
}
export function buildPublicWorkDetailPath(publicWorkCode: string) {
return buildPublicWorkStagePath('work-detail', publicWorkCode);
}
export function buildPublicWorkStagePath(
stage: SelectionStage,
publicWorkCode: string,
) {
const code = publicWorkCode.trim();
const stagePath = resolvePathForSelectionStage(stage);
if (!code) {
return stagePath;
}
const params = new URLSearchParams();
params.set(PUBLIC_WORK_QUERY_PARAM, code);
return `${stagePath}?${params.toString()}`;
}
export function buildPublicWorkDetailUrl(
publicWorkCode: string,
origin = window.location.origin,
) {
return new URL(buildPublicWorkDetailPath(publicWorkCode), origin).href;
}
export function isKnownMainAppPagePath(pathname: string) {
const normalizedPath = normalizeAppPath(pathname);
const runtimePaths: readonly string[] = Object.values(APP_RUNTIME_ROUTES);
return (
ROUTE_STAGE_BY_PATH.has(normalizedPath) ||
runtimePaths.includes(normalizedPath)
);
}
export function pushAppHistoryPath(path: string) {
writeAppHistoryPath(path, 'push');
}
export function replaceAppHistoryPath(path: string) {
writeAppHistoryPath(path, 'replace');
}
function writeAppHistoryPath(path: string, mode: 'push' | 'replace') {
const nextUrl = new URL(path, window.location.origin);
const normalizedPath = normalizeAppPath(nextUrl.pathname);
const nextSearch = buildPreservedAppSearch(
nextUrl.search,
window.location.pathname,
normalizedPath,
window.location.search,
);
const nextRelativeUrl = `${normalizedPath}${nextSearch}`;
const currentRelativeUrl = `${normalizeAppPath(window.location.pathname)}${window.location.search}`;
if (currentRelativeUrl === nextRelativeUrl) {
return;
}
// 页面阶段变化是用户可感知导航,写入 history 以支持前进后退。
if (mode === 'replace') {
window.history.replaceState(buildAppHistoryState(), '', nextRelativeUrl);
return;
}
window.history.pushState(buildAppHistoryState(), '', nextRelativeUrl);
}
export function isAppHistoryState(state: unknown) {
return Boolean(
state &&
typeof state === 'object' &&
(state as Record<string, unknown>)[APP_HISTORY_STATE_KEY] === true,
);
}
function buildAppHistoryState() {
const currentState =
window.history.state && typeof window.history.state === 'object'
? window.history.state
: {};
return {
...currentState,
[APP_HISTORY_STATE_KEY]: true,
};
}
function buildPreservedAppSearch(
explicitNextSearch: string,
currentPathname: string,
normalizedPath: string,
search: string,
) {
const preservedParams = new URLSearchParams(explicitNextSearch);
if (
!explicitNextSearch &&
isCreationRestorePath(normalizedPath) &&
isSameCreationFlowPath(currentPathname, normalizedPath)
) {
const creationParams = new URLSearchParams(
buildCreationUrlSearchFromParams(search),
);
creationParams.forEach((value, key) => {
preservedParams.set(key, value);
});
}
const currentParams = new URLSearchParams(search);
// 中文注释:小程序 WebView 依赖这些宿主上下文判断登录和充值通道,不能被前端阶段导航清掉。
APP_RUNTIME_CONTEXT_QUERY_KEYS.forEach((key) => {
if (preservedParams.has(key)) {
return;
}
const value = currentParams.get(key)?.trim();
if (value) {
preservedParams.set(key, value);
}
});
const queryString = preservedParams.toString();
return queryString ? `?${queryString}` : '';
}