-

{preferredImageSrc ? (
-

({
default: () => null,
}));
+vi.mock('../services/assetReadUrlService', () => ({
+ resolveAssetReadUrl: vi.fn(async (source: string | null | undefined) => {
+ const value = source?.trim() ?? '';
+ return value ? `https://signed.example${value}` : '';
+ }),
+ isGeneratedLegacyPath: vi.fn((source: string | null | undefined) => {
+ const value = source?.trim() ?? '';
+ return /^\/generated-[^/?#]+\/.+/u.test(value);
+ }),
+}));
+
async function loadAiService() {
return import('../services/aiService');
}
@@ -315,9 +326,13 @@ test('clicking新增可扮演角色 shows pending item, disables button, and mar
expect(screen.getAllByText('新').length).toBeGreaterThan(0);
});
-test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', () => {
+test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', async () => {
render(
);
+ await waitFor(() => {
+ expect(screen.getByText('世界承诺')).toBeTruthy();
+ });
+
expect(screen.getByText('世界承诺')).toBeTruthy();
expect(screen.getByText('玩家幻想')).toBeTruthy();
expect(screen.getByText('主题边界')).toBeTruthy();
@@ -378,7 +393,7 @@ test('playable tab prefers generated portrait over runtime preview placeholder',
const portrait = screen.getByRole('img', { name: '云止' });
expect((portrait as HTMLImageElement).getAttribute('src')).toBe(
- '/generated-characters/playable-portrait/master.png',
+ 'https://signed.example/generated-characters/playable-portrait/master.png',
);
expect(screen.getByText('已生成主图')).toBeTruthy();
});
@@ -395,6 +410,59 @@ test('landmark tab uses first act image as scene card preview and keeps chapter
const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' });
expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe(
- '/generated-custom-world-scenes/scene-act-1.png',
+ 'https://signed.example/generated-custom-world-scenes/scene-act-1.png',
);
});
+
+test('asset debug panel opens signed image link in dev mode', async () => {
+ const user = userEvent.setup();
+ const originalImportMetaEnv = import.meta.env;
+ const originalUrl = window.location.href;
+ const originalImage = globalThis.Image;
+
+ Object.defineProperty(import.meta, 'env', {
+ value: {
+ ...originalImportMetaEnv,
+ DEV: true,
+ },
+ configurable: true,
+ });
+ window.history.pushState({}, '', '/?debugCustomWorldAssets=1');
+ class MockImage {
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+
+ set src(_value: string) {
+ queueMicrotask(() => {
+ this.onload?.();
+ });
+ }
+ }
+ Object.defineProperty(globalThis, 'Image', {
+ value: MockImage,
+ configurable: true,
+ });
+
+ try {
+ render(
);
+
+ await user.click(screen.getByRole('button', { name: /场景\s*2/u }));
+
+ const signedLink = await screen.findByRole('link', {
+ name: /打开 沉钟栈桥章节 \/ 潮声逼近幕图签名图/u,
+ });
+ expect((signedLink as HTMLAnchorElement).href).toBe(
+ 'https://signed.example/generated-custom-world-scenes/scene-act-1.png',
+ );
+ } finally {
+ Object.defineProperty(import.meta, 'env', {
+ value: originalImportMetaEnv,
+ configurable: true,
+ });
+ window.history.pushState({}, '', originalUrl);
+ Object.defineProperty(globalThis, 'Image', {
+ value: originalImage,
+ configurable: true,
+ });
+ }
+});
diff --git a/src/components/CustomWorldResultView.tsx b/src/components/CustomWorldResultView.tsx
index 1ca3ddb8..5c780db5 100644
--- a/src/components/CustomWorldResultView.tsx
+++ b/src/components/CustomWorldResultView.tsx
@@ -6,6 +6,7 @@ import {
generateCustomWorldPlayableNpc,
generateCustomWorldStoryNpc,
} from '../services/aiService';
+import { resolveAssetReadUrl } from '../services/assetReadUrlService';
import {
Character,
CustomWorldLandmark,
@@ -405,6 +406,29 @@ export function CustomWorldResultView({
const [assetDebugStatusMap, setAssetDebugStatusMap] = useState<
Record
>({});
+ const [assetDebugResolvedImageMap, setAssetDebugResolvedImageMap] = useState<
+ Record
+ >({});
+ const assetDebugResolvedEntries = useMemo(
+ () =>
+ assetDebugEntries.map((entry) => ({
+ ...entry,
+ hasResolvedImageSrc: Object.prototype.hasOwnProperty.call(
+ assetDebugResolvedImageMap,
+ entry.id,
+ ),
+ resolvedImageSrc: assetDebugResolvedImageMap[entry.id] || entry.imageSrc,
+ })),
+ [assetDebugEntries, assetDebugResolvedImageMap],
+ );
+ const assetDebugDetectableEntries = useMemo(
+ () =>
+ assetDebugResolvedEntries.filter(
+ (entry) =>
+ entry.hasResolvedImageSrc && Boolean(entry.resolvedImageSrc.trim()),
+ ),
+ [assetDebugResolvedEntries],
+ );
const createTarget = useMemo(
() => getCreateTargetByTab(activeTab),
@@ -425,11 +449,42 @@ export function CustomWorldResultView({
useEffect(() => {
if (!assetDebugEnabled) {
- setAssetDebugStatusMap({});
+ setAssetDebugResolvedImageMap({});
return;
}
if (assetDebugEntries.length === 0) {
+ setAssetDebugResolvedImageMap({});
+ return;
+ }
+
+ let cancelled = false;
+
+ void Promise.all(
+ assetDebugEntries.map(async (entry) => [
+ entry.id,
+ await resolveAssetReadUrl(entry.imageSrc),
+ ] as const),
+ ).then((resolvedEntries) => {
+ if (cancelled) {
+ return;
+ }
+
+ setAssetDebugResolvedImageMap(Object.fromEntries(resolvedEntries));
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [assetDebugEnabled, assetDebugEntries]);
+
+ useEffect(() => {
+ if (!assetDebugEnabled) {
+ setAssetDebugStatusMap({});
+ return;
+ }
+
+ if (assetDebugDetectableEntries.length === 0) {
setAssetDebugStatusMap({});
return;
}
@@ -437,13 +492,14 @@ export function CustomWorldResultView({
let cancelled = false;
const cleanupList: Array<() => void> = [];
+ // 诊断面板只根据已解析地址做探测,避免状态流里反复访问原始 generated-* 路径。
setAssetDebugStatusMap(
Object.fromEntries(
- assetDebugEntries.map((entry) => [entry.id, 'loading' as const]),
+ assetDebugDetectableEntries.map((entry) => [entry.id, 'loading' as const]),
),
);
- assetDebugEntries.forEach((entry) => {
+ assetDebugDetectableEntries.forEach((entry) => {
const image = new Image();
const updateStatus = (status: AssetDebugLoadStatus) => {
if (cancelled) {
@@ -463,7 +519,7 @@ export function CustomWorldResultView({
image.onload = () => updateStatus('loaded');
image.onerror = () => updateStatus('error');
- image.src = entry.imageSrc;
+ image.src = entry.resolvedImageSrc;
cleanupList.push(() => {
image.onload = null;
image.onerror = null;
@@ -474,7 +530,7 @@ export function CustomWorldResultView({
cancelled = true;
cleanupList.forEach((cleanup) => cleanup());
};
- }, [assetDebugEnabled, assetDebugEntries]);
+ }, [assetDebugDetectableEntries, assetDebugEnabled]);
const startPendingProgress = (kind: EntityGenerationKind) => {
stopPendingProgressTimer();
@@ -679,7 +735,7 @@ export function CustomWorldResultView({