This commit is contained in:
2026-04-22 23:44:57 +08:00
parent 76ac9d22a5
commit 84dc92646a
484 changed files with 9598 additions and 9135 deletions

View File

@@ -1,7 +1,8 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor, within } from '@testing-library/react';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { AuthUser } from '../../services/authService';
@@ -113,6 +114,19 @@ function ProtectedActionButton({ onAuthenticated }: { onAuthenticated: () => voi
);
}
function PlatformTabStateProbe() {
const [tab, setTab] = useState<'home' | 'create'>('home');
return (
<div>
<div>Tab{tab === 'home' ? '首页' : '创作'}</div>
<button type="button" onClick={() => setTab('create')}>
</button>
</div>
);
}
test('auth gate keeps platform content visible when phone login is available', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
@@ -208,6 +222,48 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(screen.queryByRole('dialog', { name: '登录账号' })).toBeNull();
});
test('auth state refresh keeps mounted platform content and local tab state', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<PlatformTabStateProbe />
</AuthGate>,
);
expect(await screen.findByText('当前Tab首页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '创作' }));
expect(screen.getByText('当前Tab创作')).toBeTruthy();
let resolveToken!: (token: string) => void;
const tokenPromise = new Promise<string>((resolve) => {
resolveToken = resolve;
});
authMocks.ensureStoredAccessToken.mockReturnValueOnce(tokenPromise);
act(() => {
window.dispatchEvent(new Event('genarrative-auth-state-changed'));
});
expect(screen.queryByText('正在校验登录状态...')).toBeNull();
expect(screen.getByText('当前Tab创作')).toBeTruthy();
await act(async () => {
resolveToken('jwt-refreshed-token');
await tokenPromise;
});
await waitFor(() => {
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2);
});
expect(screen.getByText('当前Tab创作')).toBeTruthy();
});
test('auth gate shows sms send feedback in the login modal', async () => {
const user = userEvent.setup();
@@ -239,7 +295,7 @@ test('auth gate shows sms send feedback in the login modal', async () => {
});
expect(
within(dialog).getByText('验证码已发送,有效期约 5 分钟。'),
within(dialog).getByText('短信请求已提交,请留意手机短信。验证码有效期约 5 分钟。'),
).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
});

View File

@@ -90,10 +90,19 @@ export function AuthGate({ children }: AuthGateProps) {
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
const readyUser = status === 'ready' ? user : null;
const hasRenderedPlatformContentRef = useRef(false);
const canKeepPlatformContentMounted =
hasRenderedPlatformContentRef.current &&
(status === 'checking' || status === 'recovering');
const readyUser =
status === 'ready' || canKeepPlatformContentMounted ? user : null;
const settings = useGameSettings(readyUser?.id ?? null);
const platformThemeClass = `platform-theme--${settings.platformTheme}`;
if (status === 'ready' || status === 'unauthenticated') {
hasRenderedPlatformContentRef.current = true;
}
const activateReadyUser = useCallback((nextUser: AuthUser) => {
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
setUser(nextUser);
@@ -380,6 +389,9 @@ export function AuthGate({ children }: AuthGateProps) {
const authUiValue = useMemo(
() => ({
user: readyUser,
// 平台内容在 checking/recovering 阶段可以继续挂载,避免闪烁;
// 但受保护请求只能在真实 ready 且存在用户时再启动。
canAccessProtectedData: status === 'ready' && Boolean(readyUser),
openLoginModal,
requireAuth,
openSettingsModal,
@@ -402,6 +414,7 @@ export function AuthGate({ children }: AuthGateProps) {
openSettingsModal,
readyUser,
requireAuth,
status,
settings.isHydratingSettings,
settings.isPersistingSettings,
settings.musicVolume,
@@ -412,7 +425,7 @@ export function AuthGate({ children }: AuthGateProps) {
],
);
if (status === 'checking') {
if (status === 'checking' && !canKeepPlatformContentMounted) {
return (
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
...
@@ -420,7 +433,7 @@ export function AuthGate({ children }: AuthGateProps) {
);
}
if (status === 'recovering') {
if (status === 'recovering' && !canKeepPlatformContentMounted) {
return (
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
...
@@ -485,7 +498,11 @@ export function AuthGate({ children }: AuthGateProps) {
);
}
if (status !== 'ready' && status !== 'unauthenticated') {
if (
status !== 'ready' &&
status !== 'unauthenticated' &&
!canKeepPlatformContentMounted
) {
return (
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}>
<div className="platform-auth-card max-w-md rounded-3xl px-6 py-7 text-center">

View File

@@ -12,6 +12,7 @@ export type PlatformSettingsSection =
type AuthUiContextValue = {
user: AuthUser | null;
canAccessProtectedData: boolean;
openLoginModal: (postLoginAction?: (() => void) | null) => void;
requireAuth: (action: () => void) => void;
openSettingsModal: (section?: PlatformSettingsSection) => void;

View File

@@ -3,7 +3,10 @@
import { render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { type CreationAgentTheme,CreationAgentWorkspace } from './CreationAgentWorkspace';
import {
type CreationAgentTheme,
CreationAgentWorkspace,
} from './CreationAgentWorkspace';
const testTheme: CreationAgentTheme = {
accentTextClass: 'text-emerald-100',
@@ -110,3 +113,95 @@ test('creation agent workspace renders streaming assistant text', () => {
expect(screen.getByText(//u)).toBeTruthy();
});
test('creation agent workspace hides anchors and primary action before completed progress', () => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 2,
progressPercent: 99,
anchors: [
{
key: 'worldPromise',
label: '世界承诺',
value: '一个被潮雾改写航线秩序的群岛世界。',
status: 'confirmed',
},
],
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '我们继续把设定收住。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(screen.queryByRole('button', { name: '生成结果页' })).toBeNull();
expect(screen.queryByText('世界承诺')).toBeNull();
expect(screen.queryByText('一个被潮雾改写航线秩序的群岛世界。')).toBeNull();
});
test('creation agent workspace shows primary and progress actions at completed progress', () => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 2,
progressPercent: 100,
anchors: [],
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '设定已经可以进入生成。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
quickActions={[
{
key: 'summarize',
label: '总结当前设定',
},
{
key: 'quickFill',
label: '补全剩余设定',
minTurn: 2,
},
]}
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '生成结果页' })).toBeTruthy();
expect(screen.getByRole('button', { name: '总结当前设定' })).toBeTruthy();
expect(screen.getByRole('button', { name: '补全剩余设定' })).toBeTruthy();
});

View File

@@ -5,7 +5,6 @@ import {
type CreationAgentProgressCopy,
normalizeCreationAgentProgress,
resolveCreationAgentProgressHint,
resolveCreationAnchorStatusLabel,
} from '../../services/creation-agent';
export type CreationAgentAnchorView = {
@@ -209,32 +208,6 @@ function CreationAgentMessageBubble({
);
}
function CreationAgentAnchorChip({
anchor,
theme,
}: {
anchor: CreationAgentAnchorView;
theme: CreationAgentTheme;
}) {
return (
<div className="rounded-[1.25rem] border border-white/14 bg-white/8 px-3 py-3 text-left">
<div className="flex items-center justify-between gap-2">
<span
className={`text-xs font-semibold tracking-[0.18em] ${theme.accentTextClass}`}
>
{anchor.label}
</span>
<span className="rounded-full bg-white/12 px-2 py-1 text-[0.68rem] text-white/70">
{resolveCreationAnchorStatusLabel(anchor.status)}
</span>
</div>
<div className="mt-2 line-clamp-2 text-sm leading-5 text-white/86">
{anchor.value || '等待补齐'}
</div>
</div>
);
}
function shouldShowQuickAction(
action: CreationAgentQuickAction,
session: CreationAgentSessionView,
@@ -244,10 +217,6 @@ function shouldShowQuickAction(
return false;
}
if (!action.showWhenComplete && progress >= 100 && action.minProgress !== 100) {
return false;
}
if (typeof action.minTurn === 'number' && session.currentTurn < action.minTurn) {
return false;
}
@@ -298,6 +267,7 @@ export function CreationAgentWorkspace({
}
const progress = normalizeCreationAgentProgress(session.progressPercent);
const canShowPrimaryAction = progress >= 100;
const visibleQuickActions = quickActions.filter((action) =>
shouldShowQuickAction(action, session, progress),
);
@@ -330,15 +300,17 @@ export function CreationAgentWorkspace({
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
type="button"
disabled={isBusy}
onClick={onPrimaryAction}
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-bold text-slate-950 shadow-lg disabled:cursor-not-allowed disabled:opacity-50 ${theme.accentButtonClass}`}
>
<Sparkles className="h-4 w-4" />
{primaryActionLabel}
</button>
{canShowPrimaryAction ? (
<button
type="button"
disabled={isBusy}
onClick={onPrimaryAction}
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-bold text-slate-950 shadow-lg disabled:cursor-not-allowed disabled:opacity-50 ${theme.accentButtonClass}`}
>
<Sparkles className="h-4 w-4" />
{primaryActionLabel}
</button>
) : null}
</div>
<div className="mt-6">
@@ -389,18 +361,6 @@ export function CreationAgentWorkspace({
) : null}
</div>
{session.anchors.length > 0 ? (
<div className={theme.anchorGridClass || 'grid gap-2 sm:grid-cols-2 xl:grid-cols-4'}>
{session.anchors.map((anchor) => (
<CreationAgentAnchorChip
key={anchor.key}
anchor={anchor}
theme={theme}
/>
))}
</div>
) : null}
<CreationAgentOperationBanner operation={activeOperation} />
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.6rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]">

View File

@@ -160,6 +160,25 @@ test('workspace exposes draft action when progress reaches 100', async () => {
});
});
test('workspace hides draft action before progress reaches 100', () => {
render(
<CustomWorldAgentWorkspace
session={{
...baseSession,
progressPercent: 99,
}}
activeOperation={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
/>,
);
expect(
screen.queryByRole('button', { name: '生成游戏设定草稿' }),
).toBeNull();
});
test('workspace submits recommended reply from thread', async () => {
const user = userEvent.setup();
const onSubmitMessage = vi.fn();

View File

@@ -23,8 +23,12 @@ type CustomWorldCreationHubProps = {
onCreateType: (type: PlatformCreationTypeId) => void;
onOpenDraft: (item: CustomWorldWorkSummary) => void;
onEnterPublished: (profileId: string) => void;
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
deletingWorkId?: string | null;
onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (profileId: string) => void;
onExperiencePuzzle?: ((profileId: string) => void) | null;
};
function EmptyState({ title }: { title: string }) {
@@ -47,8 +51,12 @@ export function CustomWorldCreationHub({
onCreateType,
onOpenDraft,
onEnterPublished,
onDeletePublished = null,
deletingWorkId = null,
onExperienceRpg = null,
puzzleItems = [],
onOpenPuzzleDetail,
onExperiencePuzzle = null,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
@@ -134,7 +142,7 @@ export function CustomWorldCreationHub({
<CustomWorldWorkCard
key={`${item.kind}-${item.item.workId}`}
item={item}
onClick={() => {
onOpen={() => {
if (item.kind === 'puzzle') {
onOpenPuzzleDetail?.(item.item.profileId);
return;
@@ -152,6 +160,29 @@ export function CustomWorldCreationHub({
onEnterPublished(item.item.profileId);
}
}}
onExperience={
item.kind === 'puzzle'
? item.item.publicationStatus === 'published'
? () => {
onExperiencePuzzle?.(item.item.profileId);
}
: null
: item.item.status === 'published' && item.item.canEnterWorld
? () => {
onExperienceRpg?.(item.item);
}
: null
}
onDelete={
item.kind === 'rpg' &&
item.item.status === 'published' &&
item.item.profileId
? () => {
onDeletePublished?.(item.item);
}
: null
}
deleteBusy={deletingWorkId === item.item.workId}
/>
))}
</div>

View File

@@ -28,25 +28,31 @@ export type UnifiedCreationWorkItem =
type CustomWorldWorkCardProps = {
item: UnifiedCreationWorkItem;
onClick: () => void;
onOpen: () => void;
onExperience?: (() => void) | null;
onDelete?: (() => void) | null;
deleteBusy?: boolean;
};
export function CustomWorldWorkCard({
item,
onClick,
onOpen,
onExperience = null,
onDelete = null,
deleteBusy = false,
}: CustomWorldWorkCardProps) {
const isPuzzle = item.kind === 'puzzle';
const isDraft =
item.kind === 'puzzle'
? item.item.publicationStatus === 'draft'
: item.item.status === 'draft';
const actionLabel = isPuzzle
const openActionLabel = isPuzzle
? '查看详情'
: isDraft
? item.item.playableNpcCount > 0 || item.item.landmarkCount > 0
? '继续完善'
: '继续创作'
: '进入世界';
: '查看详情';
const title = isPuzzle ? item.item.levelName : item.item.title;
const subtitle = isPuzzle ? item.item.authorDisplayName : item.item.subtitle;
const summary = item.item.summary;
@@ -153,13 +159,34 @@ export function CustomWorldWorkCard({
</>
)}
</div>
<button
type="button"
onClick={onClick}
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
>
{actionLabel}
</button>
<div className="flex flex-wrap gap-2 sm:justify-end">
<button
type="button"
onClick={onOpen}
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
>
{openActionLabel}
</button>
{onExperience ? (
<button
type="button"
onClick={onExperience}
className="platform-button platform-button--secondary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
>
</button>
) : null}
{onDelete ? (
<button
type="button"
onClick={onDelete}
disabled={deleteBusy}
className="platform-button platform-button--danger min-h-0 shrink-0 rounded-full px-4 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
>
{deleteBusy ? '删除中...' : '删除'}
</button>
) : null}
</div>
</div>
</div>
</div>

View File

@@ -45,9 +45,7 @@ import {
getPuzzleAgentSession,
streamPuzzleAgentMessage,
} from '../../services/puzzle-agent';
import {
getPuzzleGalleryDetail,
} from '../../services/puzzle-gallery';
import { getPuzzleGalleryDetail } from '../../services/puzzle-gallery';
import {
advancePuzzleNextLevel,
dragPuzzlePieceOrGroup,
@@ -55,6 +53,7 @@ import {
swapPuzzlePieces,
} from '../../services/puzzle-runtime';
import { listPuzzleWorks } from '../../services/puzzle-works';
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -105,9 +104,7 @@ const CustomWorldAgentWorkspace = lazy(async () => {
});
const BigFishAgentWorkspace = lazy(async () => {
const module = await import(
'../big-fish-creation/BigFishAgentWorkspace'
);
const module = await import('../big-fish-creation/BigFishAgentWorkspace');
return {
default: module.BigFishAgentWorkspace,
};
@@ -148,9 +145,8 @@ export function PlatformEntryFlowShellImpl({
}: PlatformEntryFlowShellProps) {
const authUi = useAuthUi();
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
CustomWorldLibraryEntry<CustomWorldProfile> | null
>(null);
const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
const [bigFishSession, setBigFishSession] =
useState<BigFishSessionSnapshotResponse | null>(null);
const [bigFishRun, setBigFishRun] =
@@ -172,6 +168,9 @@ export function PlatformEntryFlowShellImpl({
const [puzzleError, setPuzzleError] = useState<string | null>(null);
const [isPuzzleBusy, setIsPuzzleBusy] = useState(false);
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
string | null
>(null);
const [streamingPuzzleReplyText, setStreamingPuzzleReplyText] = useState('');
const [isStreamingPuzzleReply, setIsStreamingPuzzleReply] = useState(false);
const hasInitialAgentSession = Boolean(
@@ -180,6 +179,7 @@ export function PlatformEntryFlowShellImpl({
const platformBootstrap = usePlatformEntryBootstrap({
user: authUi?.user,
canAccessProtectedData: authUi?.canAccessProtectedData,
getProfileDashboard: getPlatformProfileDashboard,
handleContinueGame,
hasInitialAgentSession,
@@ -241,8 +241,10 @@ export function PlatformEntryFlowShellImpl({
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
setCustomWorldError: sessionController.setCustomWorldError,
setCustomWorldAutoSaveError: autosaveCoordinator.setCustomWorldAutoSaveError,
setCustomWorldAutoSaveState: autosaveCoordinator.setCustomWorldAutoSaveState,
setCustomWorldAutoSaveError:
autosaveCoordinator.setCustomWorldAutoSaveError,
setCustomWorldAutoSaveState:
autosaveCoordinator.setCustomWorldAutoSaveState,
setCustomWorldGenerationViewSource:
sessionController.setCustomWorldGenerationViewSource,
setCustomWorldResultViewSource:
@@ -261,7 +263,8 @@ export function PlatformEntryFlowShellImpl({
sessionController.suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression:
sessionController.releaseAgentDraftResultAutoOpenSuppression,
resetAutoSaveTrackingToIdle: autosaveCoordinator.resetAutoSaveTrackingToIdle,
resetAutoSaveTrackingToIdle:
autosaveCoordinator.resetAutoSaveTrackingToIdle,
markAutoSavedProfile: autosaveCoordinator.markAutoSavedProfile,
});
@@ -276,7 +279,8 @@ export function PlatformEntryFlowShellImpl({
autosaveCoordinator.executeAgentActionAndWait({
action: 'publish_world',
}),
syncAgentDraftResultProfile: autosaveCoordinator.syncAgentDraftResultProfile,
syncAgentDraftResultProfile:
autosaveCoordinator.syncAgentDraftResultProfile,
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
});
@@ -290,7 +294,8 @@ export function PlatformEntryFlowShellImpl({
: [],
[sessionController.generatedCustomWorldProfile],
);
const agentResultPreview = sessionController.agentSession?.resultPreview ?? null;
const agentResultPreview =
sessionController.agentSession?.resultPreview ?? null;
const agentResultPreviewBlockers = useMemo(
() => agentResultPreview?.blockers?.map((entry) => entry.message) ?? [],
[agentResultPreview],
@@ -320,7 +325,9 @@ export function PlatformEntryFlowShellImpl({
const creationHubItems =
platformBootstrap.customWorldWorkEntries.length > 0
? platformBootstrap.customWorldWorkEntries
: buildCreationHubFallbackItems(platformBootstrap.savedCustomWorldEntries);
: buildCreationHubFallbackItems(
platformBootstrap.savedCustomWorldEntries,
);
const resultViewError =
autosaveCoordinator.customWorldAutoSaveError ??
sessionController.customWorldError;
@@ -346,9 +353,7 @@ export function PlatformEntryFlowShellImpl({
);
}
if (selectionStage === 'big-fish-runtime' && !bigFishRun) {
setSelectionStage(
bigFishSession?.draft ? 'big-fish-result' : 'platform',
);
setSelectionStage(bigFishSession?.draft ? 'big-fish-result' : 'platform');
}
}, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]);
@@ -375,11 +380,7 @@ export function PlatformEntryFlowShellImpl({
sessionController.setCreationTypeError(null);
return true;
}, [
handleStartNewGame,
hasSavedGame,
sessionController,
]);
}, [handleStartNewGame, hasSavedGame, sessionController]);
const openCreationTypePicker = useCallback(() => {
if (!prepareCreationLaunch()) {
@@ -626,11 +627,7 @@ export function PlatformEntryFlowShellImpl({
setIsStreamingPuzzleReply(false);
}
},
[
isStreamingPuzzleReply,
puzzleSession,
resolvePuzzleErrorMessage,
],
[isStreamingPuzzleReply, puzzleSession, resolvePuzzleErrorMessage],
);
const executeBigFishAction = useCallback(
@@ -687,13 +684,18 @@ export function PlatformEntryFlowShellImpl({
await refreshPuzzleShelf();
}
const { session } = await getPuzzleAgentSession(puzzleSession.sessionId);
const { session } = await getPuzzleAgentSession(
puzzleSession.sessionId,
);
setPuzzleSession(session);
if (payload.action === 'compile_puzzle_draft') {
setSelectionStage('puzzle-result');
}
if (payload.action === 'publish_puzzle_work' && session.publishedProfileId) {
if (
payload.action === 'publish_puzzle_work' &&
session.publishedProfileId
) {
const galleryDetail = await getPuzzleGalleryDetail(
session.publishedProfileId,
);
@@ -701,9 +703,7 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('puzzle-gallery-detail');
}
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '执行拼图操作失败。'),
);
setPuzzleError(resolvePuzzleErrorMessage(error, '执行拼图操作失败。'));
} finally {
setIsPuzzleBusy(false);
}
@@ -757,9 +757,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(run);
setSelectionStage('puzzle-runtime');
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'),
);
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
} finally {
setIsPuzzleBusy(false);
}
@@ -803,9 +801,7 @@ export function PlatformEntryFlowShellImpl({
const { run } = await swapPuzzlePieces(puzzleRun.runId, payload);
setPuzzleRun(run);
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '交换拼图块失败。'),
);
setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。'));
} finally {
setIsPuzzleBusy(false);
}
@@ -830,9 +826,7 @@ export function PlatformEntryFlowShellImpl({
const { run } = await dragPuzzlePieceOrGroup(puzzleRun.runId, payload);
setPuzzleRun(run);
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '拖动拼图块失败。'),
);
setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。'));
} finally {
setIsPuzzleBusy(false);
}
@@ -852,9 +846,7 @@ export function PlatformEntryFlowShellImpl({
const { run } = await advancePuzzleNextLevel(puzzleRun.runId);
setPuzzleRun(run);
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '进入下一关失败。'),
);
setPuzzleError(resolvePuzzleErrorMessage(error, '进入下一关失败。'));
} finally {
setIsPuzzleBusy(false);
}
@@ -927,6 +919,68 @@ export function PlatformEntryFlowShellImpl({
});
}, [handleCustomWorldSelect, runProtectedAction, selectedDetailEntry]);
const handleExperienceRpgWork = useCallback(
(work: (typeof creationHubItems)[number]) => {
if (!work.profileId) {
return;
}
runProtectedAction(() => {
const matchedEntry = platformBootstrap.savedCustomWorldEntries.find(
(entry) => entry.profileId === work.profileId,
);
if (!matchedEntry) {
platformBootstrap.setPlatformError('未找到可体验的作品,请刷新后重试。');
return;
}
handleCustomWorldSelect(matchedEntry.profile);
});
},
[
handleCustomWorldSelect,
platformBootstrap,
platformBootstrap.savedCustomWorldEntries,
runProtectedAction,
],
);
const handleDeletePublishedWork = useCallback(
(work: (typeof creationHubItems)[number]) => {
if (!work.profileId || deletingCreationWorkId) {
return;
}
runProtectedAction(() => {
const confirmed = window.confirm(
`确认删除作品《${work.title}》吗?删除后会从你的作品列表和公开广场中移除。`,
);
if (!confirmed) {
return;
}
setDeletingCreationWorkId(work.workId);
platformBootstrap.setPlatformError(null);
void deleteRpgEntryWorldProfile(work.profileId)
.then(async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
await platformBootstrap.refreshPublishedGallery().catch(() => []);
})
.catch((error) => {
platformBootstrap.setPlatformError(
resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
});
},
[deletingCreationWorkId, platformBootstrap, runProtectedAction],
);
const openPuzzleDetail = useCallback(
async (profileId: string) => {
setIsPuzzleBusy(true);
@@ -938,9 +992,7 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab();
setSelectionStage('puzzle-gallery-detail');
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '读取拼图详情失败。'),
);
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
} finally {
setIsPuzzleBusy(false);
}
@@ -950,13 +1002,14 @@ export function PlatformEntryFlowShellImpl({
useEffect(() => {
if (
(platformBootstrap.platformTab === 'create' || selectionStage === 'platform') &&
authUi?.user?.id
(platformBootstrap.platformTab === 'create' ||
selectionStage === 'platform') &&
platformBootstrap.canReadProtectedData
) {
void refreshPuzzleShelf();
}
}, [
authUi?.user?.id,
platformBootstrap.canReadProtectedData,
platformBootstrap.platformTab,
refreshPuzzleShelf,
selectionStage,
@@ -969,9 +1022,9 @@ export function PlatformEntryFlowShellImpl({
error={
platformBootstrap.isLoadingPlatform || isPuzzleLoadingLibrary
? null
: platformBootstrap.platformError ??
sessionController.creationTypeError ??
puzzleError
: (platformBootstrap.platformError ??
sessionController.agentWorkspaceRestoreError ??
puzzleError)
}
onRetry={() => {
platformBootstrap.setPlatformError(null);
@@ -981,9 +1034,13 @@ export function PlatformEntryFlowShellImpl({
);
});
}}
createError={sessionController.creationTypeError ?? bigFishError ?? puzzleError}
createError={
sessionController.creationTypeError ?? bigFishError ?? puzzleError
}
createBusy={
sessionController.isCreatingAgentSession || isBigFishBusy || isPuzzleBusy
sessionController.isCreatingAgentSession ||
isBigFishBusy ||
isPuzzleBusy
}
onCreateType={handleCreationHubCreateType}
onOpenDraft={(item) => {
@@ -1002,12 +1059,24 @@ export function PlatformEntryFlowShellImpl({
void detailNavigation.handleOpenCreationWork(matchedWork);
});
}}
onDeletePublished={(item) => {
handleDeletePublishedWork(item);
}}
deletingWorkId={deletingCreationWorkId}
onExperienceRpg={(item) => {
handleExperienceRpgWork(item);
}}
puzzleItems={puzzleWorks}
onOpenPuzzleDetail={(profileId) => {
runProtectedAction(() => {
void openPuzzleDetail(profileId);
});
}}
onExperiencePuzzle={(profileId) => {
runProtectedAction(() => {
void startPuzzleRunFromProfile(profileId);
});
}}
/>
);
@@ -1040,8 +1109,8 @@ export function PlatformEntryFlowShellImpl({
platformError={
platformBootstrap.isLoadingPlatform
? null
: platformBootstrap.platformError ??
sessionController.creationTypeError
: (platformBootstrap.platformError ??
sessionController.agentWorkspaceRestoreError)
}
dashboardError={
platformBootstrap.isLoadingDashboard
@@ -1175,7 +1244,8 @@ export function PlatformEntryFlowShellImpl({
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{sessionController.isLoadingAgentSession
? '正在准备 Agent 共创工作区...'
: sessionController.creationTypeError || '正在恢复创作工作区...'}
: sessionController.agentWorkspaceRestoreError ||
'正在恢复创作工作区...'}
</div>
</div>
)}
@@ -1472,7 +1542,9 @@ export function PlatformEntryFlowShellImpl({
});
}}
readOnly={false}
compactAgentResultMode={sessionController.isAgentDraftResultView}
compactAgentResultMode={
sessionController.isAgentDraftResultView
}
backLabel={
sessionController.isAgentDraftResultView
? '返回创作'
@@ -1515,10 +1587,12 @@ export function PlatformEntryFlowShellImpl({
<PlatformEntryCreationTypeModal
isOpen={showCreationTypeModal}
isBusy={
sessionController.isCreatingAgentSession || isBigFishBusy || isPuzzleBusy
sessionController.isCreatingAgentSession ||
isBigFishBusy ||
isPuzzleBusy
}
error={
sessionController.creationTypeError ?? bigFishError ?? puzzleError
bigFishError ?? puzzleError ?? sessionController.creationTypeError
}
onClose={() => {
if (

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
@@ -32,6 +32,9 @@ import {
unpublishRpgEntryWorldProfile,
upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory,
} from '../../services/rpg-entry';
import { createBigFishCreationSession } from '../../services/big-fish-creation';
import { createPuzzleAgentSession } from '../../services/puzzle-agent';
import { listPuzzleWorks } from '../../services/puzzle-works';
import type { GameState } from '../../types';
import {
AuthUiContext,
@@ -39,6 +42,7 @@ import {
} from '../auth/AuthUiContext';
import {
RpgEntryFlowShell,
type RpgEntryFlowShellProps,
type SelectionStage,
} from './RpgEntryFlowShell';
@@ -63,13 +67,20 @@ async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
expect(await screen.findByText('角色扮演 RPG')).toBeTruthy();
}
async function openNewRpgCreation(
user: ReturnType<typeof userEvent.setup>,
) {
async function openNewRpgCreation(user: ReturnType<typeof userEvent.setup>) {
await openCreationHub(user);
await user.click(screen.getByRole('button', { name: / RPG/u }));
}
function getPlatformTabPanel(tab: string) {
const panel = document.getElementById(`platform-tab-panel-${tab}`);
if (!panel) {
throw new Error(`Missing platform tab panel: ${tab}`);
}
return panel;
}
vi.mock('../../services/rpg-creation', () => ({
createRpgCreationSession: vi.fn(),
executeRpgCreationAction: vi.fn(),
@@ -96,6 +107,23 @@ vi.mock('../../services/rpg-entry', () => ({
upsertRpgProfileBrowseHistory: vi.fn(),
}));
vi.mock('../../services/puzzle-works', () => ({
listPuzzleWorks: vi.fn(),
}));
vi.mock('../../services/big-fish-creation', () => ({
createBigFishCreationSession: vi.fn(),
executeBigFishCreationAction: vi.fn(),
streamBigFishCreationMessage: vi.fn(),
}));
vi.mock('../../services/puzzle-agent', () => ({
createPuzzleAgentSession: vi.fn(),
executePuzzleAgentAction: vi.fn(),
getPuzzleAgentSession: vi.fn(),
streamPuzzleAgentMessage: vi.fn(),
}));
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
CustomWorldAgentWorkspace: ({
session,
@@ -379,6 +407,7 @@ const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
type TestAuthValue = {
user: AuthUser | null;
canAccessProtectedData: boolean;
openLoginModal: (postLoginAction?: (() => void) | null) => void;
requireAuth: (action: () => void) => void;
openSettingsModal: (section?: PlatformSettingsSection) => void;
@@ -393,9 +422,12 @@ type TestAuthValue = {
settingsError: string | null;
};
function createAuthValue(overrides: Partial<TestAuthValue> = {}): TestAuthValue {
function createAuthValue(
overrides: Partial<TestAuthValue> = {},
): TestAuthValue {
return {
user: mockAuthUser,
canAccessProtectedData: true,
openLoginModal: () => {},
requireAuth: (action) => action(),
openSettingsModal: () => {},
@@ -416,10 +448,12 @@ function TestWrapper({
withAuth = false,
authValue,
onContinueGame,
onSelectWorld,
}: {
withAuth?: boolean;
authValue?: TestAuthValue;
onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void;
onSelectWorld?: RpgEntryFlowShellProps['handleCustomWorldSelect'];
} = {}) {
const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform');
@@ -433,7 +467,7 @@ function TestWrapper({
savedSnapshot={null}
handleContinueGame={onContinueGame ?? (() => {})}
handleStartNewGame={() => {}}
handleCustomWorldSelect={() => {}}
handleCustomWorldSelect={onSelectWorld ?? (() => {})}
/>
);
@@ -442,9 +476,7 @@ function TestWrapper({
}
return (
<AuthUiContext.Provider
value={authValue ?? createAuthValue()}
>
<AuthUiContext.Provider value={authValue ?? createAuthValue()}>
{content}
</AuthUiContext.Provider>
);
@@ -513,8 +545,106 @@ beforeEach(() => {
vi.mocked(createRpgCreationSession).mockResolvedValue({
session: mockSession,
});
vi.mocked(createBigFishCreationSession).mockResolvedValue({
session: {
sessionId: 'big-fish-session-1',
currentTurn: 0,
progressPercent: 0,
stage: 'clarifying',
anchorPack: {
gameplayPromise: {
key: 'gameplay_promise',
label: '核心玩法',
value: '',
status: 'missing',
},
ecologyVisualTheme: {
key: 'ecology_visual_theme',
label: '生态视觉',
value: '',
status: 'missing',
},
growthLadder: {
key: 'growth_ladder',
label: '成长阶梯',
value: '',
status: 'missing',
},
riskTempo: {
key: 'risk_tempo',
label: '风险节奏',
value: '',
status: 'missing',
},
},
draft: null,
assetSlots: [],
assetCoverage: {
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
requiredLevelCount: 0,
publishReady: false,
blockers: [],
},
messages: [],
lastAssistantReply: '先说说你想要什么样的大鱼生态。',
publishReady: false,
updatedAt: '2026-04-22T12:00:00.000Z',
},
});
vi.mocked(createPuzzleAgentSession).mockResolvedValue({
session: {
sessionId: 'puzzle-session-1',
currentTurn: 0,
progressPercent: 0,
stage: 'clarifying',
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '',
status: 'missing',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '',
status: 'missing',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '',
status: 'missing',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '',
status: 'missing',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '',
status: 'missing',
},
},
draft: null,
messages: [],
lastAssistantReply: '先说一个你最想做成拼图的画面。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-04-22T12:00:00.000Z',
},
});
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
vi.mocked(listRpgCreationWorks).mockResolvedValue([]);
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [],
});
vi.mocked(executeRpgCreationAction).mockResolvedValue({
operation: {
operationId: 'operation-draft-foundation-1',
@@ -594,9 +724,7 @@ test('create tab opens compiled agent draft in result refinement page', async ()
canEnterWorld: false,
},
]);
vi.mocked(getRpgCreationSession).mockResolvedValue(
compiledAgentDraftSession,
);
vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession);
render(<TestWrapper withAuth />);
@@ -606,12 +734,19 @@ test('create tab opens compiled agent draft in result refinement page', async ()
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('正在加载世界编辑器...')).toBeNull();
}, { timeout: 5000 });
await waitFor(
() => {
expect(screen.queryByText('正在加载世界编辑器...')).toBeNull();
},
{ timeout: 5000 },
);
expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy();
expect(screen.queryByText('Agent工作区custom-world-agent-session-1')).toBeNull();
expect(
await screen.findByText('世界档案', {}, { timeout: 5000 }),
).toBeTruthy();
expect(
screen.queryByText('Agent工作区custom-world-agent-session-1'),
).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});
@@ -706,7 +841,7 @@ test('opening a compiled draft with a missing agent session falls back to create
await waitFor(() => {
expect(
screen.getByText(
within(getPlatformTabPanel('create')).getByText(
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
),
).toBeTruthy();
@@ -807,6 +942,88 @@ test('restoring an agent workspace while logged out opens login modal before loa
expect(getRpgCreationSession).not.toHaveBeenCalled();
});
test('new creation entry maps raw bearer token errors to user-facing auth copy', async () => {
const user = userEvent.setup();
vi.mocked(createRpgCreationSession).mockRejectedValueOnce(
new ApiClientError({
message: '缺少 Authorization Bearer Token',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
await user.click(screen.getByRole('button', { name: / RPG/u }));
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByText(
'当前登录状态已失效,请重新登录后继续。',
),
).toBeTruthy();
});
expect(listPuzzleWorks).toHaveBeenCalled();
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
});
test('big fish creation timeout exits busy state and shows a readable error', async () => {
const user = userEvent.setup();
vi.mocked(createBigFishCreationSession).mockRejectedValueOnce(
Object.assign(new Error('请求超时15000ms'), {
name: 'TimeoutError',
}),
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
const button = screen.getByRole('button', { name: //u });
await user.click(button);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getAllByText(
'开启大鱼吃小鱼创作工作台超时,请确认运行时后端已启动后重试。',
).length,
).toBeGreaterThan(0);
});
expect((button as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByText(//u)).toBeNull();
});
test('puzzle creation timeout exits busy state and shows a readable error', async () => {
const user = userEvent.setup();
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
Object.assign(new Error('请求超时15000ms'), {
name: 'TimeoutError',
}),
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
const button = screen.getByRole('button', { name: //u });
await user.click(button);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getAllByText(
'开启拼图创作工作台超时,请确认运行时后端已启动后重试。',
).length,
).toBeGreaterThan(0);
});
expect((button as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByText(//u)).toBeNull();
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
const user = userEvent.setup();
@@ -851,9 +1068,7 @@ test('existing draft sessions open result page refinement instead of agent dialo
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue(
compiledAgentDraftSession,
);
vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession);
render(<TestWrapper withAuth />);
@@ -913,9 +1128,7 @@ test('agent result view shows publish blockers and disables publish-enter action
await openNewRpgCreation(user);
expect(
await screen.findByText(/ 1 /u),
).toBeTruthy();
expect(await screen.findByText(/ 1 /u)).toBeTruthy();
const actionButton = screen.getByRole('button', {
name: //u,
});
@@ -991,7 +1204,7 @@ test('agent draft result publishes before entering world and uses published prev
return (
<AuthUiContext.Provider value={createAuthValue()}>
<RpgEntryFlowShell
<RpgEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={{} as GameState}
@@ -1023,11 +1236,13 @@ test('agent draft result publishes before entering world and uses published prev
);
});
expect(
vi.mocked(executeRpgCreationAction).mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
vi
.mocked(executeRpgCreationAction)
.mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
).toBe(false);
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
@@ -1219,11 +1434,13 @@ test('agent draft result back button returns to creation hub without redundant s
});
expect(
vi.mocked(executeRpgCreationAction).mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
vi
.mocked(executeRpgCreationAction)
.mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
).toBe(false);
expect(screen.queryByText('世界档案')).toBeNull();
});
@@ -1366,7 +1583,9 @@ test('agent draft result auto-save persists the latest profile rebuilt from sync
expect(upsertRpgWorldProfile).toHaveBeenCalled();
});
const latestSavedProfile = vi.mocked(upsertRpgWorldProfile).mock.calls.at(-1)?.[0];
const latestSavedProfile = vi
.mocked(upsertRpgWorldProfile)
.mock.calls.at(-1)?.[0];
expect(latestSavedProfile?.name).toBe('潮雾列岛·session最新版');
expect(latestSavedProfile?.summary).toBe(
'作品库应该保存这份同步后的最新快照。',
@@ -1391,7 +1610,8 @@ test('agent draft result can open from server result preview without embedded le
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·服务端预览',
subtitle: '结果页改为优先消费 session.resultPreview',
summary: '即使 draft 中没有 legacyResultProfile也应该正常打开结果页。',
summary:
'即使 draft 中没有 legacyResultProfile也应该正常打开结果页。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
templateWorldType: 'WUXIA',
@@ -1420,9 +1640,7 @@ test('agent draft result can open from server result preview without embedded le
await waitFor(
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(
screen.getByText('潮雾列岛·服务端预览'),
).toBeTruthy();
expect(screen.getByText('潮雾列岛·服务端预览')).toBeTruthy();
expect(
screen.getByText('结果页改为优先消费 session.resultPreview'),
).toBeTruthy();
@@ -1454,6 +1672,37 @@ test('authenticated users with save archives default into the saves tab', async
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
});
test('manual tab switch is preserved after platform bootstrap requests finish', async () => {
const user = userEvent.setup();
let resolveGalleryRequest!: (value: []) => void;
const delayedGalleryRequest = new Promise<[]>((resolve) => {
resolveGalleryRequest = resolve;
});
vi.mocked(listRpgEntryWorldGallery).mockReturnValueOnce(
delayedGalleryRequest as Promise<[]>,
);
render(<TestWrapper withAuth />);
await clickFirstButtonByName(user, '创作');
expect(await screen.findByText('角色扮演 RPG')).toBeTruthy();
resolveGalleryRequest([]);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByText('角色扮演 RPG'),
).toBeTruthy();
});
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe(
'false',
);
expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('true');
});
test('save tab can resume a selected archive directly into the game', async () => {
const user = userEvent.setup();
const handleContinueGame = vi.fn();
@@ -1504,7 +1753,7 @@ test('save tab can resume a selected archive directly into the game', async () =
});
});
test('owned world detail can delete a work and return to the create tab list', async () => {
test('creation hub published work can open detail view before deleting from detail page', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
@@ -1572,7 +1821,7 @@ test('owned world detail can delete a work and return to the create tab list', a
render(<TestWrapper withAuth />);
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: '删除作品' }));
await waitFor(() => {
@@ -1650,9 +1899,168 @@ test('creation hub published work enters existing detail view', async () => {
render(<TestWrapper withAuth />);
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('世界信息')).toBeTruthy();
expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy();
expect(screen.getByText('已发布')).toBeTruthy();
});
test('creation hub published work experience button enters world directly', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'published:world-experience-1',
sourceType: 'published_profile',
status: 'published',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '已经发布的群岛世界作品。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: '2026-04-20T10:00:00.000Z',
stage: null,
stageLabel: '已发布',
playableNpcCount: 3,
landmarkCount: 4,
roleVisualReadyCount: 1,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: 'world-experience-1',
canResume: false,
canEnterWorld: true,
},
]);
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([
{
ownerUserId: 'user-1',
profileId: 'world-experience-1',
profile: {
id: 'world-experience-1',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '已经发布的群岛世界作品。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清群岛旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['假航灯正在扰乱航线'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '已经发布的群岛世界作品。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
},
]);
render(
<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />,
);
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: '体验' }));
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({
id: 'world-experience-1',
name: '潮雾列岛',
}),
);
});
expect(screen.queryByText('世界信息')).toBeNull();
});
test('creation hub published work delete button removes the work directly from card list', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
const publishedWork = {
workId: 'published:world-card-delete-1',
sourceType: 'published_profile' as const,
status: 'published' as const,
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '用于测试卡片删除流程的作品。',
coverImageSrc: null,
coverRenderMode: 'image' as const,
coverCharacterImageSrcs: [],
updatedAt: '2026-04-16T12:00:00.000Z',
publishedAt: '2026-04-16T12:00:00.000Z',
stage: null,
stageLabel: '已发布',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: 'world-card-delete-1',
canResume: false,
canEnterWorld: true,
};
const publishedLibraryEntry = {
ownerUserId: 'user-1',
profileId: 'world-card-delete-1',
profile: {
id: 'world-card-delete-1',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '用于测试卡片删除流程的作品。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['雾潮正在逼近港口'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'published' as const,
publishedAt: '2026-04-16T12:00:00.000Z',
updatedAt: '2026-04-16T12:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '用于测试卡片删除流程的作品。',
coverImageSrc: null,
themeMode: 'tide' as const,
playableNpcCount: 0,
landmarkCount: 0,
};
vi.mocked(listRpgCreationWorks)
.mockResolvedValueOnce([publishedWork])
.mockResolvedValue([]);
vi.mocked(listRpgEntryWorldLibrary)
.mockResolvedValueOnce([publishedLibraryEntry])
.mockResolvedValue([]);
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
render(<TestWrapper withAuth />);
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: '删除' }));
await waitFor(() => {
expect(deleteRpgEntryWorldProfile).toHaveBeenCalledWith(
'world-card-delete-1',
);
});
await waitFor(() => {
expect(screen.getByText('还没有作品')).toBeTruthy();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import type {
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { ApiClientError, isTimeoutError } from '../../services/apiClient';
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
import type { CustomWorldProfile } from '../../types';
@@ -11,6 +12,28 @@ export function resolveRpgEntryErrorMessage(
error: unknown,
fallback: string,
) {
if (isTimeoutError(error)) {
if (//u.test(fallback)) {
return '开启拼图创作工作台超时,请确认运行时后端已启动后重试。';
}
if (//u.test(fallback)) {
return '开启大鱼吃小鱼创作工作台超时,请确认运行时后端已启动后重试。';
}
if (//u.test(fallback)) {
return '开启创作工作台超时,请确认运行时后端已启动后重试。';
}
return '请求超时,请稍后重试。';
}
if (
error instanceof ApiClientError &&
error.status === 401 &&
(error.code === 'UNAUTHORIZED' ||
error.message.includes('Authorization Bearer Token'))
) {
return '当前登录状态已失效,请重新登录后继续。';
}
return error instanceof Error ? error.message : fallback;
}

View File

@@ -58,6 +58,9 @@ export function useRpgCreationSessionController(
onSessionOpened,
} = params;
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
const isHydratingInitialAgentWorkspaceRef = useRef(
Boolean(initialAgentUiStateRef.current.activeSessionId),
);
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
const isAgentDraftResultAutoOpenSuppressedRef = useRef(false);
@@ -77,6 +80,8 @@ export function useRpgCreationSessionController(
const [isStreamingAgentReply, setIsStreamingAgentReply] = useState(false);
const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
const [creationTypeError, setCreationTypeError] = useState<string | null>(null);
const [agentWorkspaceRestoreError, setAgentWorkspaceRestoreError] =
useState<string | null>(null);
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
useState<CustomWorldProfile | null>(null);
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
@@ -135,6 +140,8 @@ export function useRpgCreationSessionController(
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setAgentWorkspaceRestoreError(null);
isHydratingInitialAgentWorkspaceRef.current = false;
return;
}
@@ -144,16 +151,22 @@ export function useRpgCreationSessionController(
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setAgentWorkspaceRestoreError(null);
return;
}
let cancelled = false;
const isInitialWorkspaceRestore =
isHydratingInitialAgentWorkspaceRef.current &&
activeAgentSessionId === initialAgentUiStateRef.current.activeSessionId;
setIsLoadingAgentSession(true);
void syncAgentSessionSnapshot(activeAgentSessionId)
.then(() => {
if (!cancelled) {
setCreationTypeError(null);
setAgentWorkspaceRestoreError(null);
isHydratingInitialAgentWorkspaceRef.current = false;
}
})
.catch((error) => {
@@ -161,13 +174,20 @@ export function useRpgCreationSessionController(
return;
}
setCreationTypeError(
resolveRpgCreationErrorMessage(error, '读取 Agent 共创工作区失败。'),
);
// 登录后自动恢复的是“上一次残留的工作区指针”,
// 这里失败时应优先静默清理,避免把旧恢复错误冒充成当前登录已失效。
if (isInitialWorkspaceRestore) {
setAgentWorkspaceRestoreError(null);
} else {
setAgentWorkspaceRestoreError(
resolveRpgCreationErrorMessage(error, '读取 Agent 共创工作区失败。'),
);
}
setAgentSession(null);
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
isHydratingInitialAgentWorkspaceRef.current = false;
persistAgentUiState(null, null);
enterCreateTab?.();
setSelectionStage('platform');
@@ -353,6 +373,7 @@ export function useRpgCreationSessionController(
const { session } = await createRpgCreationSession(
seedText ? { seedText } : {},
);
isHydratingInitialAgentWorkspaceRef.current = false;
setAgentSession(session);
setAgentOperation(null);
setGeneratedCustomWorldProfile(null);
@@ -539,6 +560,7 @@ export function useRpgCreationSessionController(
isLoadingAgentSession,
creationTypeError,
setCreationTypeError,
agentWorkspaceRestoreError,
customWorldError,
setCustomWorldError,
generatedCustomWorldProfile,

View File

@@ -28,6 +28,7 @@ import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
type UseRpgEntryBootstrapParams = {
user: AuthUser | null | undefined;
canAccessProtectedData?: boolean | undefined;
getProfileDashboard: () => Promise<ProfileDashboardSummary | null>;
handleContinueGame: (
snapshot?: HydratedSavedGameSnapshot | null,
@@ -38,12 +39,19 @@ type UseRpgEntryBootstrapParams = {
export function useRpgEntryBootstrap(
params: UseRpgEntryBootstrapParams,
) {
const { user, getProfileDashboard, handleContinueGame, hasInitialAgentSession } =
params;
const {
user,
canAccessProtectedData = Boolean(user),
getProfileDashboard,
handleContinueGame,
hasInitialAgentSession,
} = params;
const isAuthenticated = Boolean(user);
const canReadProtectedData = Boolean(user) && canAccessProtectedData;
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
undefined,
);
const hasExplicitPlatformTabSelectionRef = useRef(false);
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
CustomWorldLibraryEntry<CustomWorldProfile>[]
@@ -58,7 +66,7 @@ export function useRpgEntryBootstrap(
PlatformBrowseHistoryEntry[]
>([]);
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home');
const [platformTab, setPlatformTabState] = useState<PlatformHomeTab>('home');
const [platformError, setPlatformError] = useState<string | null>(null);
const [dashboardError, setDashboardError] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
@@ -71,8 +79,15 @@ export function useRpgEntryBootstrap(
const [profileDashboard, setProfileDashboard] =
useState<ProfileDashboardSummary | null>(null);
const setPlatformTab = useCallback((nextTab: PlatformHomeTab) => {
// 区分“平台首屏默认落点”和“用户/流程显式切换”。
// 一旦显式切过 Tab就不能再被首屏异步请求回刷成首页或存档。
hasExplicitPlatformTabSelectionRef.current = true;
setPlatformTabState(nextTab);
}, []);
const refreshProfileDashboard = useCallback(async () => {
if (!user) {
if (!user || !canReadProtectedData) {
setProfileDashboard(null);
setDashboardError(null);
setIsLoadingDashboard(false);
@@ -91,10 +106,10 @@ export function useRpgEntryBootstrap(
} finally {
setIsLoadingDashboard(false);
}
}, [getProfileDashboard, user]);
}, [canReadProtectedData, getProfileDashboard, user]);
const refreshCustomWorldWorks = useCallback(async () => {
if (!user) {
if (!user || !canReadProtectedData) {
setCustomWorldWorkEntries([]);
return [];
}
@@ -102,7 +117,7 @@ export function useRpgEntryBootstrap(
const nextItems = await listRpgCreationWorks();
setCustomWorldWorkEntries(nextItems);
return nextItems;
}, [user]);
}, [canReadProtectedData, user]);
const refreshPublishedGallery = useCallback(async () => {
const nextEntries = await listRpgEntryWorldGallery();
@@ -111,7 +126,7 @@ export function useRpgEntryBootstrap(
}, []);
const refreshSavedCustomWorldLibrary = useCallback(async () => {
if (!user) {
if (!user || !canReadProtectedData) {
setSavedCustomWorldEntries([]);
return [];
}
@@ -119,7 +134,7 @@ export function useRpgEntryBootstrap(
const nextEntries = await listRpgEntryWorldLibrary();
setSavedCustomWorldEntries(nextEntries);
return nextEntries;
}, [user]);
}, [canReadProtectedData, user]);
const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => {
@@ -139,7 +154,7 @@ export function useRpgEntryBootstrap(
const handleResumeSaveEntry = useCallback(
async (entry: ProfileSaveArchiveSummary) => {
if (!user || isResumingSaveWorldKey) {
if (!user || !canReadProtectedData || isResumingSaveWorldKey) {
return;
}
@@ -162,11 +177,20 @@ export function useRpgEntryBootstrap(
setIsResumingSaveWorldKey(null);
}
},
[handleContinueGame, isResumingSaveWorldKey, user],
[canReadProtectedData, handleContinueGame, isResumingSaveWorldKey, user],
);
useEffect(() => {
let isActive = true;
const nextPlatformBootstrapUserId = user?.id ?? null;
const shouldApplyInitialPlatformTab =
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId;
if (shouldApplyInitialPlatformTab) {
// 在请求发出前先占位,避免首屏请求未完成时用户切了 Tab
// 返回结果又被误判成“还没初始化过”并强制跳回默认页。
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
}
void (async () => {
setHistoryEntries([]);
@@ -174,9 +198,9 @@ export function useRpgEntryBootstrap(
setSaveError(null);
setIsLoadingPlatform(true);
setPlatformError(null);
setIsLoadingDashboard(isAuthenticated);
setIsLoadingDashboard(canReadProtectedData);
setDashboardError(null);
if (!isAuthenticated) {
if (!canReadProtectedData) {
setSavedCustomWorldEntries([]);
setCustomWorldWorkEntries([]);
setSaveEntries([]);
@@ -192,16 +216,20 @@ export function useRpgEntryBootstrap(
historyResult,
saveArchivesResult,
] = await Promise.allSettled([
isAuthenticated
canReadProtectedData
? listRpgEntryWorldLibrary()
: Promise.resolve([]),
isAuthenticated
canReadProtectedData
? listRpgCreationWorks()
: Promise.resolve([]),
listRpgEntryWorldGallery(),
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
isAuthenticated ? listRpgProfileBrowseHistory() : Promise.resolve([]),
isAuthenticated ? listRpgProfileSaveArchives() : Promise.resolve([]),
canReadProtectedData ? getProfileDashboard() : Promise.resolve(null),
canReadProtectedData
? listRpgProfileBrowseHistory()
: Promise.resolve([]),
canReadProtectedData
? listRpgProfileSaveArchives()
: Promise.resolve([]),
]);
if (!isActive) {
@@ -227,8 +255,10 @@ export function useRpgEntryBootstrap(
}
if (
(isAuthenticated && libraryEntriesResult.status === 'rejected') ||
(isAuthenticated && workEntriesResult.status === 'rejected') ||
(canReadProtectedData &&
libraryEntriesResult.status === 'rejected') ||
(canReadProtectedData &&
workEntriesResult.status === 'rejected') ||
galleryEntriesResult.status === 'rejected'
) {
const platformFailure =
@@ -246,7 +276,7 @@ export function useRpgEntryBootstrap(
if (dashboardResult.status === 'fulfilled') {
setProfileDashboard(dashboardResult.value);
} else if (isAuthenticated) {
} else if (canReadProtectedData) {
setProfileDashboard(null);
setDashboardError(
resolveRpgEntryErrorMessage(
@@ -258,7 +288,7 @@ export function useRpgEntryBootstrap(
if (historyResult.status === 'fulfilled') {
setHistoryEntries(historyResult.value);
} else if (isAuthenticated) {
} else if (canReadProtectedData) {
setHistoryError(
resolveRpgEntryErrorMessage(historyResult.reason, '读取浏览历史失败。'),
);
@@ -266,7 +296,7 @@ export function useRpgEntryBootstrap(
if (saveArchivesResult.status === 'fulfilled') {
setSaveEntries(saveArchivesResult.value);
} else if (isAuthenticated) {
} else if (canReadProtectedData) {
setSaveEntries([]);
setSaveError(
resolveRpgEntryErrorMessage(
@@ -276,20 +306,19 @@ export function useRpgEntryBootstrap(
);
}
const nextPlatformBootstrapUserId = user?.id ?? null;
if (
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId
shouldApplyInitialPlatformTab &&
!hasInitialAgentSession &&
!hasExplicitPlatformTabSelectionRef.current
) {
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
if (!hasInitialAgentSession) {
setPlatformTab(
isAuthenticated &&
saveArchivesResult.status === 'fulfilled' &&
saveArchivesResult.value.length > 0
? 'saves'
: 'home',
);
}
setPlatformTabState(
isAuthenticated &&
canReadProtectedData &&
saveArchivesResult.status === 'fulfilled' &&
saveArchivesResult.value.length > 0
? 'saves'
: 'home',
);
}
} finally {
if (isActive) {
@@ -302,10 +331,17 @@ export function useRpgEntryBootstrap(
return () => {
isActive = false;
};
}, [getProfileDashboard, hasInitialAgentSession, isAuthenticated, user]);
}, [
canReadProtectedData,
getProfileDashboard,
hasInitialAgentSession,
isAuthenticated,
user,
]);
return {
isAuthenticated,
canReadProtectedData,
platformTab,
setPlatformTab,
savedCustomWorldEntries,

View File

@@ -589,6 +589,27 @@ body {
backdrop-filter: blur(18px);
}
.platform-tab-panel-stack {
position: relative;
min-height: 0;
overflow: hidden;
}
.platform-tab-panel {
min-height: 0;
height: 100%;
overflow-y: auto;
padding-right: 0.25rem;
}
.platform-tab-panel--hidden {
display: none;
}
.platform-tab-panel--active {
display: block;
}
.platform-surface {
position: relative;
overflow: hidden;
@@ -1151,12 +1172,6 @@ body {
box-shadow: var(--platform-desktop-hover-shadow);
}
.platform-desktop-scroll {
min-height: 0;
overflow-y: auto;
padding-right: 0.25rem;
}
.platform-auth-card {
border: 1px solid var(--platform-modal-border);
background: var(--platform-modal-fill);
@@ -1532,17 +1547,32 @@ body {
.platform-theme--light
.platform-subpanel:where([class*='bg-black/18'], [class*='bg-black/24']),
.platform-theme--light
.platform-remap-surface:where([class*='bg-black/18'], [class*='bg-black/24']) {
.platform-remap-surface:where(
[class*='bg-black/18'],
[class*='bg-black/24']
) {
background: var(--platform-subpanel-fill) !important;
}
.platform-theme--light
.platform-surface:not(.platform-surface--hero)
:where([class*='border-white/10'], [class*='border-white/12'], [class*='border-white/15']),
:where(
[class*='border-white/10'],
[class*='border-white/12'],
[class*='border-white/15']
),
.platform-theme--light
.platform-subpanel:where([class*='border-white/10'], [class*='border-white/12'], [class*='border-white/15']),
.platform-subpanel:where(
[class*='border-white/10'],
[class*='border-white/12'],
[class*='border-white/15']
),
.platform-theme--light
.platform-remap-surface:where([class*='border-white/10'], [class*='border-white/12'], [class*='border-white/15']) {
.platform-remap-surface:where(
[class*='border-white/10'],
[class*='border-white/12'],
[class*='border-white/15']
) {
border-color: var(--platform-subpanel-border) !important;
}
@@ -2412,10 +2442,8 @@ button {
}
.platform-main-shell {
padding-inline: max(0.75rem, env(safe-area-inset-left)) max(
0.75rem,
env(safe-area-inset-right)
);
padding-inline: max(0.75rem, env(safe-area-inset-left))
max(0.75rem, env(safe-area-inset-right));
padding-top: max(0.75rem, env(safe-area-inset-top));
padding-bottom: 0.5rem;
}

View File

@@ -6,6 +6,7 @@ import {
clearStoredAccessToken,
fetchWithApiAuth,
getStoredAccessToken,
isTimeoutError,
requestJson,
setStoredAccessToken,
} from './apiClient';
@@ -150,6 +151,68 @@ describe('apiClient', () => {
expect(getStoredAccessToken()).toBe('fresh-token');
});
it('hydrates a missing local bearer token before the first protected request', async () => {
fetchMock
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: {
ok: true,
token: 'fresh-token',
},
error: null,
meta: {
apiVersion: '2026-04-08',
},
}),
}),
)
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: {
value: 9,
},
error: null,
meta: {
apiVersion: '2026-04-08',
},
}),
}),
);
const result = await requestJson<{ value: number }>(
'/api/runtime/protected',
{ method: 'GET' },
'读取受保护数据失败',
);
expect(result).toEqual({ value: 9 });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/auth/refresh',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/runtime/protected',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer fresh-token',
}),
}),
);
expect(getStoredAccessToken()).toBe('fresh-token');
});
it('does not emit auth change events when 401 probe requests opt into silent mode', async () => {
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
@@ -185,6 +248,38 @@ describe('apiClient', () => {
expect(getStoredAccessToken()).toBe('');
});
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
setStoredAccessToken('expired-token', { emit: false });
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: {
ok: true,
token: 'fresh-token',
},
error: null,
meta: {
apiVersion: '2026-04-08',
},
}),
}),
)
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
const response = await fetchWithApiAuth('/api/runtime/puzzle/works', {
method: 'GET',
});
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(getStoredAccessToken()).toBe('fresh-token');
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
});
it('rejects refresh responses that do not return a renewed bearer token', async () => {
setStoredAccessToken('expired-token', { emit: false });
fetchMock
@@ -280,7 +375,43 @@ describe('apiClient', () => {
expect(result).toEqual({ value: 42 });
});
it('aborts requests when timeoutMs is reached', async () => {
setStoredAccessToken('timeout-token', { emit: false });
fetchMock.mockImplementation(
async (_input: string, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => {
reject(init.signal?.reason);
},
{ once: true },
);
}),
);
let capturedError: unknown;
try {
await requestJson(
'/api/runtime/protected',
{ method: 'POST' },
'创建会话失败',
{
timeoutMs: 20,
skipRefresh: true,
},
);
} catch (error) {
capturedError = error;
}
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(isTimeoutError(capturedError)).toBe(true);
expect(capturedError).toBeInstanceOf(Error);
});
it('surfaces response metadata through ApiClientError', async () => {
setStoredAccessToken('metadata-token', { emit: false });
fetchMock.mockResolvedValueOnce(
createResponseMock({
status: 503,

View File

@@ -30,6 +30,7 @@ export type ApiRetryOptions = {
export type ApiRequestOptions = {
retry?: ApiRetryOptions;
timeoutMs?: number;
skipAuth?: boolean;
omitEnvelopeHeader?: boolean;
skipRefresh?: boolean;
@@ -172,6 +173,57 @@ function createAbortError() {
return error;
}
function createTimeoutError(timeoutMs: number) {
const error = new Error(`请求超时:${timeoutMs}ms`);
error.name = 'TimeoutError';
return error;
}
function composeAbortSignal(
signal: AbortSignal | undefined,
timeoutMs: number | undefined,
) {
const shouldUseTimeout =
typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0;
if (!shouldUseTimeout) {
return {
signal,
cleanup: () => {},
};
}
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort(createTimeoutError(timeoutMs));
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeoutId);
signal?.removeEventListener('abort', onAbort);
};
const onAbort = () => {
controller.abort(signal?.reason ?? createAbortError());
};
if (signal?.aborted) {
cleanup();
controller.abort(signal.reason ?? createAbortError());
return {
signal: controller.signal,
cleanup,
};
}
signal?.addEventListener('abort', onAbort, { once: true });
return {
signal: controller.signal,
cleanup,
};
}
async function waitForRetry(ms: number, signal?: AbortSignal) {
if (ms <= 0) {
return;
@@ -268,6 +320,10 @@ export function isAbortError(error: unknown) {
);
}
export function isTimeoutError(error: unknown) {
return error instanceof Error && error.name === 'TimeoutError';
}
function shouldRetryError(error: unknown, attempt: number, retry: ResolvedRetryOptions) {
if (attempt >= retry.maxRetries || isAbortError(error)) {
return false;
@@ -505,21 +561,48 @@ export async function fetchWithApiAuth(
const method = (init.method ?? 'GET').toUpperCase();
const retry = resolveRetryOptions(method, options.retry);
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
const requestSignal = init.signal ?? undefined;
let attempt = 0;
let refreshAttempted = false;
for (;;) {
try {
const requestHeaders = withAuthorizationHeaders(init.headers, options);
const hasAuthHeader = Boolean(
let requestHeaders = withAuthorizationHeaders(init.headers, options);
let hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
const response = await fetch(input, {
credentials: 'same-origin',
...init,
headers: requestHeaders,
});
if (!hasAuthHeader && !options.skipAuth && !options.skipRefresh) {
try {
// 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票,
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
await ensureStoredAccessToken();
requestHeaders = withAuthorizationHeaders(init.headers, options);
hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
} catch {
// 补票失败时继续走原始请求,让调用方按真实 401 分支处理。
}
}
const timedRequest = composeAbortSignal(
requestSignal,
options.timeoutMs,
);
let response: Response;
try {
response = await fetch(input, {
credentials: 'same-origin',
...init,
signal: timedRequest.signal,
headers: requestHeaders,
});
} finally {
timedRequest.cleanup();
}
if (
response.status === 401 &&
@@ -543,7 +626,12 @@ export async function fetchWithApiAuth(
emitAuthStateChange();
}
}
} else if (response.status === 401 && hasAuthHeader && !options.skipAuth) {
} else if (
response.status === 401 &&
hasAuthHeader &&
!options.skipAuth &&
!refreshAttempted
) {
clearStoredAccessToken({ emit: false });
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
@@ -562,7 +650,7 @@ export async function fetchWithApiAuth(
attempt += 1;
await waitForRetry(
buildRetryDelayMs(attempt, retry),
init.signal ?? undefined,
requestSignal,
);
}
}

View File

@@ -1,18 +1,45 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { clearStoredAccessToken, setStoredAccessToken } from './apiClient';
import {
clearSignedAssetReadUrlCache,
getSignedAssetReadUrl,
resolveAssetReadUrl,
} from './assetReadUrlService';
function createLocalStorageMock() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
clear() {
store.clear();
},
};
}
describe('assetReadUrlService', () => {
beforeEach(() => {
vi.stubGlobal('window', {
localStorage: createLocalStorageMock(),
dispatchEvent: vi.fn(),
});
clearSignedAssetReadUrlCache();
clearStoredAccessToken({ emit: false });
setStoredAccessToken('test-access-token', { emit: false });
vi.restoreAllMocks();
});
afterEach(() => {
clearStoredAccessToken({ emit: false });
vi.useRealTimers();
});
@@ -110,4 +137,44 @@ describe('assetReadUrlService', () => {
expect(second).toBe(first);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
test('getSignedAssetReadUrl caches not-found failures for the same legacy path', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
ok: false,
data: null,
error: {
code: 'NOT_FOUND',
message: '对象不存在',
},
meta: {
apiVersion: '2026-04-08',
routeVersion: '2026-04-08',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 404,
headers: {
'Content-Type': 'application/json',
},
},
),
);
await expect(
getSignedAssetReadUrl({
legacyPublicPath: '/generated-characters/hero/missing/master.png',
}),
).rejects.toThrow();
await expect(
getSignedAssetReadUrl({
legacyPublicPath: '/generated-characters/hero/missing/master.png',
}),
).rejects.toThrow('资源不存在或暂时不可读取');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,4 +1,4 @@
import { requestJson } from './apiClient';
import { ApiClientError, requestJson } from './apiClient';
export type AssetReadUrlRequest = {
objectKey?: string;
@@ -22,9 +22,15 @@ type CachedReadUrlEntry = {
expiresAtMs: number;
};
type CachedReadUrlFailureEntry = {
expiresAtMs: number;
};
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
export function isGeneratedLegacyPath(value: string) {
@@ -81,6 +87,16 @@ function shouldReuseCachedReadUrl(entry: CachedReadUrlEntry | undefined) {
return entry.expiresAtMs - DEFAULT_CACHE_SAFETY_WINDOW_MS > Date.now();
}
function shouldReuseCachedReadUrlFailure(
entry: CachedReadUrlFailureEntry | undefined,
) {
if (!entry) {
return false;
}
return entry.expiresAtMs > Date.now();
}
export async function getSignedAssetReadUrl(
request: AssetReadUrlRequest,
signal?: AbortSignal,
@@ -91,6 +107,13 @@ export async function getSignedAssetReadUrl(
return cached.signedUrl;
}
const cachedFailure = cacheKey
? signedReadUrlFailureCache.get(cacheKey)
: undefined;
if (cachedFailure && shouldReuseCachedReadUrlFailure(cachedFailure)) {
throw new Error('资源不存在或暂时不可读取');
}
if (cacheKey) {
const pendingRequest = pendingSignedReadUrlRequests.get(cacheKey);
if (pendingRequest) {
@@ -117,25 +140,42 @@ export async function getSignedAssetReadUrl(
searchParams.set('expireSeconds', String(Math.floor(request.expireSeconds)));
}
const response = await requestJson<AssetReadUrlResponse>(
`${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`,
{
method: 'GET',
signal,
},
'获取资源访问地址失败',
);
const payload = resolveSignedReadPayload(response);
const expiresAtMs = parseExpiresAtMs(payload.expiresAt);
try {
const response = await requestJson<AssetReadUrlResponse>(
`${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`,
{
method: 'GET',
signal,
},
'获取资源访问地址失败',
);
const payload = resolveSignedReadPayload(response);
const expiresAtMs = parseExpiresAtMs(payload.expiresAt);
if (cacheKey && expiresAtMs > 0) {
signedReadUrlCache.set(cacheKey, {
signedUrl: payload.signedUrl,
expiresAtMs,
});
if (cacheKey) {
signedReadUrlFailureCache.delete(cacheKey);
}
if (cacheKey && expiresAtMs > 0) {
signedReadUrlCache.set(cacheKey, {
signedUrl: payload.signedUrl,
expiresAtMs,
});
}
return payload.signedUrl;
} catch (error) {
if (
cacheKey &&
error instanceof ApiClientError &&
error.status === 404
) {
signedReadUrlFailureCache.set(cacheKey, {
expiresAtMs: Date.now() + DEFAULT_FAILURE_CACHE_WINDOW_MS,
});
}
throw error;
}
return payload.signedUrl;
})();
if (cacheKey) {
@@ -187,5 +227,6 @@ export async function resolveAssetReadUrl(
export function clearSignedAssetReadUrlCache() {
signedReadUrlCache.clear();
signedReadUrlFailureCache.clear();
pendingSignedReadUrlRequests.clear();
}

View File

@@ -116,6 +116,10 @@ describe('authService', () => {
}),
}),
'登录失败',
{
skipAuth: true,
skipRefresh: true,
},
);
expect(getStoredAccessToken()).toBe('jwt-entry-token');
expect(window.dispatchEvent).not.toHaveBeenCalled();
@@ -195,6 +199,10 @@ describe('authService', () => {
}),
}),
'发送验证码失败',
{
skipAuth: true,
skipRefresh: true,
},
);
});
@@ -249,6 +257,10 @@ describe('authService', () => {
}),
}),
'登录失败',
{
skipAuth: true,
skipRefresh: true,
},
);
expect(getStoredAccessToken()).toBe('jwt-phone-token');
expect(window.dispatchEvent).not.toHaveBeenCalled();
@@ -319,6 +331,10 @@ describe('authService', () => {
method: 'GET',
}),
'微信登录暂不可用',
{
skipAuth: true,
skipRefresh: true,
},
);
expect(assignMock).toHaveBeenCalledWith(
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
@@ -339,6 +355,10 @@ describe('authService', () => {
method: 'GET',
}),
'读取登录方式失败',
{
skipAuth: true,
skipRefresh: true,
},
);
});

View File

@@ -22,6 +22,7 @@ import type {
LogoutResponse,
} from '../../packages/shared/src/contracts/auth';
import {
type ApiRequestOptions,
ApiClientError,
clearStoredAccessToken,
clearStoredAutoAuthCredentials,
@@ -60,6 +61,13 @@ let pendingAutoAuthUser: Promise<{
credentials: AutoAuthCredentials;
}> | null = null;
// 登录前公开认证入口不能误带旧 token也不能先触发 refresh 探测,
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
const PUBLIC_AUTH_REQUEST_OPTIONS = {
skipAuth: true,
skipRefresh: true,
} satisfies ApiRequestOptions;
export function normalizePhoneInput(phoneInput: string) {
return phoneInput.replace(/[^\d+]/gu, '').trim();
}
@@ -147,6 +155,7 @@ export async function sendPhoneLoginCode(
}),
},
'发送验证码失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
return response;
@@ -164,6 +173,7 @@ export async function loginWithPhoneCode(phone: string, code: string) {
}),
},
'登录失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
setStoredAccessToken(response.token, { emit: false });
@@ -212,6 +222,7 @@ export async function startWechatLogin() {
method: 'GET',
},
'微信登录暂不可用',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
window.location.assign(response.authorizationUrl);
@@ -224,6 +235,7 @@ export async function getAuthLoginOptions() {
method: 'GET',
},
'读取登录方式失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
}
@@ -237,6 +249,7 @@ export async function authEntry(username: string, password: string) {
body: JSON.stringify(credentials),
},
'登录失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
setStoredAccessToken(response.token, { emit: false });

View File

@@ -16,6 +16,7 @@ import {
import { readCreationAgentSessionFromSse } from '../creation-agent';
const BIG_FISH_AGENT_API_BASE = '/api/runtime/big-fish/agent/sessions';
const BIG_FISH_SESSION_START_TIMEOUT_MS = 15000;
const BIG_FISH_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 180,
@@ -41,6 +42,7 @@ export async function createBigFishCreationSession(
'创建大鱼吃小鱼共创会话失败',
{
retry: BIG_FISH_WRITE_RETRY,
timeoutMs: BIG_FISH_SESSION_START_TIMEOUT_MS,
},
);
}

View File

@@ -18,6 +18,7 @@ import {
import { readCreationAgentSessionFromSse } from '../creation-agent';
const PUZZLE_AGENT_API_BASE = '/api/runtime/puzzle/agent/sessions';
const PUZZLE_AGENT_SESSION_START_TIMEOUT_MS = 15000;
const PUZZLE_AGENT_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 180,
@@ -47,6 +48,7 @@ export async function createPuzzleAgentSession(
'创建拼图共创会话失败',
{
retry: PUZZLE_AGENT_WRITE_RETRY,
timeoutMs: PUZZLE_AGENT_SESSION_START_TIMEOUT_MS,
},
);
}

View File

@@ -17,6 +17,7 @@ import {
import { requestRpgCreationRuntimeJson } from './rpgCreationRuntimeClient';
const RPG_AGENT_API_BASE = '/custom-world/agent/sessions';
const CREATION_SESSION_START_TIMEOUT_MS = 15000;
export async function createRpgCreationSession(
payload: CreateRpgAgentSessionRequest,
@@ -29,6 +30,9 @@ export async function createRpgCreationSession(
body: JSON.stringify(payload),
},
'创建世界共创会话失败',
{
timeoutMs: CREATION_SESSION_START_TIMEOUT_MS,
},
);
}