diff --git a/docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md b/docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md
new file mode 100644
index 00000000..ddb9277d
--- /dev/null
+++ b/docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md
@@ -0,0 +1,25 @@
+# 路由首屏图片等待门闩 2026-04-25
+
+## 背景
+
+平台首页、作品详情、拼图运行态等页面会在首屏展示作品封面、角色图、场景背景图或拼图原图。此前页面主体可能先进入,图片随后分批出现,移动端尤其容易看到背景图闪入、封面占位与真实图片切换。
+
+## 落地约束
+
+1. 入口路由组件加载完成后,页面主体先挂载但保持不可见,继续让业务数据请求、图片请求和布局计算正常发生。
+2. 门闩扫描当前路由根节点内的 `
`、`src/currentSrc` 和 CSS `background-image / border-image-source` 中的 `url(...)`。
+3. 扫描到的图片统一通过 `Image()` 预加载;图片成功、失败或超时都视为 settled,避免单张异常图片阻塞整站进入。
+4. 所有已发现图片 settled 且 DOM 短暂稳定后,再一次性显示页面主体。
+5. 门闩只负责前端呈现时机,不承接作品封面选择、资产读链路、玩法逻辑或后端数据裁决。
+
+## 体验规则
+
+- 等待态继续复用 `RouteLoadingScreen`,只显示简短加载文案,不在 UI 中追加规则说明。
+- 页面主体隐藏时使用 `visibility: hidden`,不能用 `display: none`,否则浏览器可能不触发布局与图片加载。
+- 图片加载失败不直接改写业务 UI;后续仍由原页面的兜底图、占位图或错误态处理。
+
+## 涉及文件
+
+- `src/routing/RouteImageReadyGate.tsx`
+- `src/routing/RouteImageReadyGate.test.ts`
+- `src/main.tsx`
diff --git a/src/main.tsx b/src/main.tsx
index 36380773..8728ced9 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -6,6 +6,7 @@ import {StrictMode, Suspense} from 'react';
import {createRoot} from 'react-dom/client';
import {resolveAppRoute} from './routing/appRoutes';
+import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
import {RouteLoadingScreen} from './routing/RouteLoadingScreen';
type AppRoot = ReturnType;
@@ -29,7 +30,12 @@ const RouteComponent = route.Component;
root.render(
}>
-
+
+
+
,
);
diff --git a/src/routing/RouteImageReadyGate.test.ts b/src/routing/RouteImageReadyGate.test.ts
new file mode 100644
index 00000000..fd1bf04f
--- /dev/null
+++ b/src/routing/RouteImageReadyGate.test.ts
@@ -0,0 +1,44 @@
+// @vitest-environment jsdom
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ collectRouteImageUrls,
+ extractCssImageUrls,
+ normalizePreloadImageUrl,
+} from './routeImageReadyGateUtils';
+
+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 = `
+
+
+
+ `;
+
+ 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,
+ ]);
+ });
+});
diff --git a/src/routing/RouteImageReadyGate.tsx b/src/routing/RouteImageReadyGate.tsx
new file mode 100644
index 00000000..2613c2c8
--- /dev/null
+++ b/src/routing/RouteImageReadyGate.tsx
@@ -0,0 +1,233 @@
+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_PRELOAD_TIMEOUT_MS = 12000;
+
+const settledImageUrls = new Set();
+const imagePreloadTasks = new Map>();
+
+function preloadImageUrl(url: string) {
+ const existingTask = imagePreloadTasks.get(url);
+ if (existingTask) {
+ return existingTask;
+ }
+
+ const task = new Promise((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;
+}
+
+/**
+ * 路由首屏图片门闩:业务页面先真实挂载但不可见,
+ * 等当前 DOM 中已发现的图片全部 settled 后再一次性显示。
+ */
+export function RouteImageReadyGate({
+ children,
+ eyebrow,
+ text,
+}: RouteImageReadyGateProps) {
+ const rootRef = useRef(null);
+ const startTimeRef = useRef(0);
+ const scanTimerRef = useRef(null);
+ const revealTimerRef = useRef(null);
+ const scanVersionRef = useRef(0);
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ const root = rootRef.current;
+ if (!root) {
+ return;
+ }
+
+ let disposed = false;
+ startTimeRef.current = window.performance.now();
+ 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) => {
+ clearRevealTimer();
+
+ const elapsed = window.performance.now() - startTimeRef.current;
+ const delay = Math.max(
+ IMAGE_GATE_QUIET_MS,
+ IMAGE_GATE_MIN_VISIBLE_WAIT_MS - elapsed,
+ );
+
+ revealTimerRef.current = window.setTimeout(() => {
+ if (disposed || version !== scanVersionRef.current) {
+ return;
+ }
+
+ const pendingUrls = collectRouteImageUrls(root).filter(
+ (url) => !settledImageUrls.has(url),
+ );
+ if (pendingUrls.length > 0) {
+ scheduleScan();
+ return;
+ }
+
+ 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) {
+ scheduleReveal(version);
+ return;
+ }
+
+ // 已进入页面但新 DOM 批量挂载图片时,先回到等待态,避免图片逐张闪入。
+ setReady(false);
+ void Promise.allSettled(pendingUrls.map(preloadImageUrl)).then(() => {
+ if (disposed || version !== scanVersionRef.current) {
+ return;
+ }
+ scheduleScan();
+ });
+ }
+
+ const observer = new MutationObserver((mutations) => {
+ if (disposed) {
+ 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 (
+ <>
+
+ {children}
+
+ {!ready ? (
+
+
+
+ ) : null}
+ >
+ );
+}
diff --git a/src/routing/routeImageReadyGateUtils.ts b/src/routing/routeImageReadyGateUtils.ts
new file mode 100644
index 00000000..4b9b290c
--- /dev/null
+++ b/src/routing/routeImageReadyGateUtils.ts
@@ -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, 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();
+ const elements = [root, ...Array.from(root.querySelectorAll('*'))];
+
+ root.querySelectorAll('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);
+}