init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { act, render, screen } from '@testing-library/react';
import { createElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
collectRouteImageUrls,
extractCssImageUrls,
normalizePreloadImageUrl,
} from './routeImageReadyGateUtils';
import { RouteImageReadyGate } from './RouteImageReadyGate';
afterEach(() => {
vi.useRealTimers();
});
describe('RouteImageReadyGate image url helpers', () => {
it('extracts urls from layered CSS image values', () => {
expect(
extractCssImageUrls(
'linear-gradient(#000,#111), url("/hero.png"), url("icons/card.webp")',
),
).toEqual(['/hero.png', 'icons/card.webp']);
});
it('normalizes preloadable urls against the current document', () => {
expect(normalizePreloadImageUrl('/cover.png')).toBe(
new URL('/cover.png', document.baseURI).href,
);
expect(normalizePreloadImageUrl('data:image/png;base64,abc')).toBe(
'data:image/png;base64,abc',
);
expect(normalizePreloadImageUrl('')).toBeNull();
});
it('collects img and CSS background urls from a route root', () => {
const root = document.createElement('section');
root.innerHTML = `
<img src="/images/card.png" />
<div style='background-image: url("/images/bg.webp")'></div>
<div style='border-image-source: url("/ui/frame.png")'></div>
`;
expect(collectRouteImageUrls(root)).toEqual([
new URL('/images/card.png', document.baseURI).href,
new URL('/images/bg.webp', document.baseURI).href,
new URL('/ui/frame.png', document.baseURI).href,
]);
});
it('reveals route content after a short cap when images stay pending', () => {
vi.useFakeTimers();
render(
createElement(
RouteImageReadyGate,
{
eyebrow: '正在载入游戏',
text: '正在载入冒险...',
},
createElement(
'section',
{
'data-testid': 'route-content',
},
createElement('img', {
src: '/generated-characters/slow-cover.png',
alt: 'slow cover',
}),
),
),
);
const content = screen.getByTestId('route-content');
const visibilityGate = content.parentElement;
expect(visibilityGate?.getAttribute('aria-hidden')).toBe('true');
expect(visibilityGate?.style.visibility).toBe('hidden');
act(() => {
vi.advanceTimersByTime(1600);
});
expect(visibilityGate?.getAttribute('aria-hidden')).toBe('false');
expect(visibilityGate?.style.visibility).toBe('visible');
});
});

View File

@@ -0,0 +1,230 @@
import { type ReactNode, useEffect, useRef, useState } from 'react';
import {
collectRouteImageUrls,
hasCssImageUrlChange,
type ImagePreloadResult,
} from './routeImageReadyGateUtils';
import { RouteLoadingScreen } from './RouteLoadingScreen';
type RouteImageReadyGateProps = {
children: ReactNode;
eyebrow: string;
text: string;
};
const IMAGE_GATE_QUIET_MS = 140;
const IMAGE_GATE_MIN_VISIBLE_WAIT_MS = 260;
const IMAGE_GATE_MAX_BLOCK_MS = 1400;
const IMAGE_PRELOAD_TIMEOUT_MS = 12000;
const settledImageUrls = new Set<string>();
const imagePreloadTasks = new Map<string, Promise<ImagePreloadResult>>();
function preloadImageUrl(url: string) {
const existingTask = imagePreloadTasks.get(url);
if (existingTask) {
return existingTask;
}
const task = new Promise<ImagePreloadResult>((resolve) => {
if (typeof Image === 'undefined') {
resolve({ url, status: 'loaded' });
return;
}
const image = new Image();
let settled = false;
const settle = (status: ImagePreloadResult['status']) => {
if (settled) {
return;
}
settled = true;
window.clearTimeout(timeoutId);
settledImageUrls.add(url);
resolve({ url, status });
};
const timeoutId = window.setTimeout(() => {
settle('timeout');
}, IMAGE_PRELOAD_TIMEOUT_MS);
image.onload = () => settle('loaded');
image.onerror = () => settle('failed');
image.decoding = 'async';
image.src = url;
if (image.complete) {
settle(image.naturalWidth > 0 ? 'loaded' : 'failed');
}
});
imagePreloadTasks.set(url, task);
return task;
}
/**
* 路由首屏图片门闩:业务页面先真实挂载但不可见,
* 只等待短暂稳定窗口,不再把所有图片加载完成作为首屏硬阻塞。
*/
export function RouteImageReadyGate({
children,
eyebrow,
text,
}: RouteImageReadyGateProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const startTimeRef = useRef(0);
const scanTimerRef = useRef<number | null>(null);
const revealTimerRef = useRef<number | null>(null);
const scanVersionRef = useRef(0);
const revealedRef = useRef(false);
const [ready, setReady] = useState(false);
useEffect(() => {
const root = rootRef.current;
if (!root) {
return;
}
let disposed = false;
startTimeRef.current = window.performance.now();
revealedRef.current = false;
setReady(false);
const clearScanTimer = () => {
if (scanTimerRef.current !== null) {
window.clearTimeout(scanTimerRef.current);
scanTimerRef.current = null;
}
};
const clearRevealTimer = () => {
if (revealTimerRef.current !== null) {
window.clearTimeout(revealTimerRef.current);
revealTimerRef.current = null;
}
};
const scheduleScan = () => {
clearScanTimer();
scanTimerRef.current = window.setTimeout(runScan, IMAGE_GATE_QUIET_MS);
};
const scheduleReveal = (version: number) => {
if (revealedRef.current) {
return;
}
clearRevealTimer();
const elapsed = window.performance.now() - startTimeRef.current;
const preferredDelay = Math.max(
IMAGE_GATE_QUIET_MS,
IMAGE_GATE_MIN_VISIBLE_WAIT_MS - elapsed,
);
const maxRemainingDelay = Math.max(0, IMAGE_GATE_MAX_BLOCK_MS - elapsed);
const delay = Math.min(preferredDelay, maxRemainingDelay);
revealTimerRef.current = window.setTimeout(() => {
if (disposed || version !== scanVersionRef.current) {
return;
}
revealedRef.current = true;
setReady(true);
}, delay);
};
function runScan() {
if (disposed) {
return;
}
const version = scanVersionRef.current + 1;
scanVersionRef.current = version;
const pendingUrls = collectRouteImageUrls(root).filter(
(url) => !settledImageUrls.has(url),
);
if (pendingUrls.length > 0) {
// 首屏慢加载的核心约束:图片可预热,但不能无限期阻塞页面主体可见。
pendingUrls.forEach((url) => {
void preloadImageUrl(url);
});
}
scheduleReveal(version);
}
const observer = new MutationObserver((mutations) => {
if (disposed || revealedRef.current) {
return;
}
const shouldRescan = mutations.some((mutation) => {
if (mutation.type === 'childList') {
return (
mutation.addedNodes.length > 0 ||
mutation.removedNodes.length > 0
);
}
if (mutation.type !== 'attributes') {
return false;
}
if (
mutation.attributeName === 'src' ||
mutation.attributeName === 'srcset'
) {
return true;
}
return mutation.attributeName === 'style'
? hasCssImageUrlChange(mutation)
: false;
});
if (!shouldRescan) {
return;
}
clearRevealTimer();
scheduleScan();
});
observer.observe(root, {
attributes: true,
attributeFilter: ['src', 'srcset', 'style'],
attributeOldValue: true,
childList: true,
subtree: true,
});
scheduleScan();
return () => {
disposed = true;
observer.disconnect();
clearScanTimer();
clearRevealTimer();
};
}, []);
return (
<>
<div
ref={rootRef}
aria-hidden={!ready}
style={{
visibility: ready ? 'visible' : 'hidden',
}}
>
{children}
</div>
{!ready ? (
<div className="fixed inset-0 z-[9999]">
<RouteLoadingScreen eyebrow={eyebrow} text={text} />
</div>
) : null}
</>
);
}

View File

@@ -0,0 +1,16 @@
export function RouteLoadingScreen({
eyebrow,
text,
}: {
eyebrow: string;
text: string;
}) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#0d1016] px-6 text-zinc-200">
<div className="text-center">
<div className="text-sm tracking-[0.26em] text-zinc-500">{eyebrow}</div>
<div className="mt-3 text-lg font-semibold text-white">{text}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import {
APP_RUNTIME_ROUTES,
isKnownMainAppPagePath,
normalizeAppPath,
resolvePathForSelectionStage,
resolveSelectionStageFromPath,
} from './appPageRoutes';
describe('appPageRoutes', () => {
it('normalizes page paths for stable matching', () => {
expect(normalizeAppPath('')).toBe('/');
expect(normalizeAppPath('/CREATION/RPG/AGENT/')).toBe('/creation/rpg/agent');
});
it('resolves platform entry stages from independent paths', () => {
expect(resolveSelectionStageFromPath('/creation/rpg/agent')).toBe(
'agent-workspace',
);
expect(resolveSelectionStageFromPath('/creation/big-fish/result/')).toBe(
'big-fish-result',
);
expect(resolveSelectionStageFromPath('/gallery/puzzle/detail')).toBe(
'puzzle-gallery-detail',
);
});
it('falls back to platform for unknown paths inside the main app', () => {
expect(resolveSelectionStageFromPath('/missing')).toBe('platform');
});
it('resolves paths from selection stages', () => {
expect(resolvePathForSelectionStage('custom-world-generating')).toBe(
'/creation/rpg/generating',
);
expect(resolvePathForSelectionStage('puzzle-runtime')).toBe(
'/runtime/puzzle',
);
});
it('recognizes runtime pages as main app pages', () => {
expect(
isKnownMainAppPagePath(APP_RUNTIME_ROUTES['rpg-character-select']),
).toBe(true);
expect(isKnownMainAppPagePath('/runtime/rpg/adventure/')).toBe(true);
});
});

View File

@@ -0,0 +1,69 @@
import type { SelectionStage } from '../components/platform-entry';
export type RuntimePageRoute = 'rpg-character-select' | 'rpg-adventure';
const STAGE_ROUTE_ENTRIES = [
['platform', '/'],
['detail', '/worlds/detail'],
['agent-workspace', '/creation/rpg/agent'],
['custom-world-generating', '/creation/rpg/generating'],
['custom-world-result', '/creation/rpg/result'],
['big-fish-agent-workspace', '/creation/big-fish/agent'],
['big-fish-result', '/creation/big-fish/result'],
['big-fish-runtime', '/runtime/big-fish'],
['puzzle-agent-workspace', '/creation/puzzle/agent'],
['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>;
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 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) {
const normalizedPath = normalizeAppPath(path);
if (normalizeAppPath(window.location.pathname) === normalizedPath) {
return;
}
// 页面阶段变化是用户可感知导航,写入 history 以支持前进后退。
window.history.pushState(null, '', normalizedPath);
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { matchAppRoute } from './appRoutes';
describe('matchAppRoute', () => {
it('routes the main app by default', () => {
expect(matchAppRoute('/')).toEqual({
kind: 'game',
});
});
it('routes puzzle playground path to the standalone puzzle runtime', () => {
expect(matchAppRoute('/puzzle')).toEqual({
kind: 'puzzle-playground',
});
});
it('routes big fish playground path to the standalone big fish runtime', () => {
expect(matchAppRoute('/BIG-FISH/')).toEqual({
kind: 'big-fish-playground',
});
});
it('routes former standalone editor paths back to the main game', () => {
expect(matchAppRoute('/item-editor/tools')).toEqual({
kind: 'game',
});
expect(matchAppRoute('/behavior-editor')).toEqual({
kind: 'game',
});
expect(matchAppRoute('/NPC-EDITOR/profiles/')).toEqual({
kind: 'game',
});
});
it('routes independent page paths to the main app shell', () => {
expect(matchAppRoute('/creation/rpg/agent')).toEqual({
kind: 'game',
});
expect(matchAppRoute('/runtime/puzzle')).toEqual({
kind: 'game',
});
expect(matchAppRoute('/runtime/big-fish')).toEqual({
kind: 'game',
});
});
it('does not treat unrelated prefixes as preset editor routes', () => {
expect(matchAppRoute('/npc-editorial')).toEqual({
kind: 'game',
});
});
});

85
src/routing/appRoutes.tsx Normal file
View File

@@ -0,0 +1,85 @@
/* eslint-disable react-refresh/only-export-components */
import { type ComponentType, lazy, type LazyExoticComponent } from 'react';
import { normalizeAppPath } from './appPageRoutes';
type AppRouteComponent = LazyExoticComponent<
ComponentType<Record<string, unknown>>
>;
export type AppRouteMatch =
| {
kind: 'puzzle-playground';
}
| {
kind: 'big-fish-playground';
}
| {
kind: 'game';
};
export type ResolvedAppRoute = {
kind: AppRouteMatch['kind'];
loadingEyebrow: string;
loadingText: string;
Component: AppRouteComponent;
componentProps?: Record<string, unknown>;
};
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent;
const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent;
function normalizeRoutePath(pathname: string) {
return normalizeAppPath(pathname);
}
export function matchAppRoute(pathname: string): AppRouteMatch {
const normalizedPath = normalizeRoutePath(pathname);
if (normalizedPath === '/puzzle') {
return {
kind: 'puzzle-playground',
};
}
if (normalizedPath === '/big-fish') {
return {
kind: 'big-fish-playground',
};
}
return {
kind: 'game',
};
}
export function resolveAppRoute(pathname: string): ResolvedAppRoute {
const matchedRoute = matchAppRoute(pathname);
if (matchedRoute.kind === 'puzzle-playground') {
return {
kind: 'puzzle-playground',
loadingEyebrow: '正在载入拼图',
loadingText: '正在进入拼图关卡...',
Component: PuzzlePlaygroundApp,
};
}
if (matchedRoute.kind === 'big-fish-playground') {
return {
kind: 'big-fish-playground',
loadingEyebrow: '正在载入大鱼',
loadingText: '正在进入玩法...',
Component: BigFishPlaygroundApp,
};
}
return {
kind: 'game',
loadingEyebrow: '正在载入游戏',
loadingText: '正在载入冒险...',
Component: GameApp,
};
}

View File

@@ -0,0 +1,111 @@
const CSS_IMAGE_URL_PATTERN =
/url\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\)/gu;
export type ImagePreloadResult = {
url: string;
status: 'loaded' | 'failed' | 'timeout';
};
export function extractCssImageUrls(value: string) {
const urls: string[] = [];
CSS_IMAGE_URL_PATTERN.lastIndex = 0;
let match = CSS_IMAGE_URL_PATTERN.exec(value);
while (match) {
const rawUrl = match[1] ?? match[2] ?? match[3] ?? '';
const normalizedRawUrl = rawUrl.trim();
if (normalizedRawUrl) {
urls.push(normalizedRawUrl);
}
match = CSS_IMAGE_URL_PATTERN.exec(value);
}
return urls;
}
export function normalizePreloadImageUrl(rawUrl: string) {
const trimmedUrl = rawUrl.trim();
if (!trimmedUrl || trimmedUrl === 'none' || trimmedUrl.startsWith('#')) {
return null;
}
if (
trimmedUrl.startsWith('data:') ||
trimmedUrl.startsWith('blob:') ||
trimmedUrl.startsWith('http://') ||
trimmedUrl.startsWith('https://')
) {
return trimmedUrl;
}
const baseUrl =
typeof document === 'undefined'
? 'http://localhost/'
: document.baseURI;
try {
return new URL(trimmedUrl, baseUrl).href;
} catch {
return null;
}
}
function addNormalizedImageUrl(urls: Set<string>, rawUrl: string | null) {
if (!rawUrl) {
return;
}
const normalizedUrl = normalizePreloadImageUrl(rawUrl);
if (normalizedUrl) {
urls.add(normalizedUrl);
}
}
export function hasCssImageUrlChange(mutation: MutationRecord) {
if (!(mutation.target instanceof HTMLElement)) {
return false;
}
const previousUrls = mutation.oldValue
? extractCssImageUrls(mutation.oldValue)
: [];
const currentUrls = [
...extractCssImageUrls(mutation.target.style.backgroundImage),
...extractCssImageUrls(mutation.target.style.borderImageSource),
...extractCssImageUrls(mutation.target.style.listStyleImage),
];
if (previousUrls.length !== currentUrls.length) {
return true;
}
return currentUrls.some((url, index) => url !== previousUrls[index]);
}
export function collectRouteImageUrls(root: HTMLElement) {
const urls = new Set<string>();
const elements = [root, ...Array.from(root.querySelectorAll<HTMLElement>('*'))];
root.querySelectorAll<HTMLImageElement>('img').forEach((image) => {
addNormalizedImageUrl(urls, image.currentSrc);
addNormalizedImageUrl(urls, image.getAttribute('src'));
});
elements.forEach((element) => {
const computedStyle = window.getComputedStyle(element);
[
element.style.backgroundImage,
element.style.borderImageSource,
element.style.listStyleImage,
computedStyle.backgroundImage,
computedStyle.borderImageSource,
computedStyle.listStyleImage,
].forEach((cssImageValue) => {
extractCssImageUrls(cssImageValue).forEach((url) => {
addNormalizedImageUrl(urls, url);
});
});
});
return Array.from(urls);
}