1
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user