1
This commit is contained in:
@@ -9,6 +9,7 @@ import { AuthGate } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
ensureStoredAccessToken: vi.fn(),
|
||||
ensureAutoAuthUser: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
@@ -20,6 +21,7 @@ const authMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
@@ -76,6 +78,7 @@ const mockUser: AuthUser = {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: null,
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -127,6 +130,33 @@ test('auth gate keeps platform content visible when phone login is available', a
|
||||
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('auth gate waits for access token refresh before exposing restored user content', async () => {
|
||||
let resolveToken!: (token: string) => void;
|
||||
const tokenPromise = new Promise<string>((resolve) => {
|
||||
resolveToken = resolve;
|
||||
});
|
||||
authMocks.ensureStoredAccessToken.mockReturnValue(tokenPromise);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<div>应用内容</div>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('正在校验登录状态...')).toBeTruthy();
|
||||
expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled();
|
||||
|
||||
resolveToken('jwt-restored-token');
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(authMocks.ensureStoredAccessToken).toHaveBeenCalledTimes(1);
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: [],
|
||||
@@ -171,6 +201,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
'13800000000',
|
||||
'123456',
|
||||
);
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
expect(onAuthenticated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useGameSettings } from '../../hooks/useGameSettings';
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
ensureStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
import {
|
||||
type AuthAuditLogEntry,
|
||||
@@ -89,10 +90,15 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
|
||||
const settings = useGameSettings(user?.id ?? null);
|
||||
const readyUser = status === 'ready' ? user : null;
|
||||
const settings = useGameSettings(readyUser?.id ?? null);
|
||||
const platformThemeClass = `platform-theme--${settings.platformTheme}`;
|
||||
|
||||
const readyUser = status === 'ready' ? user : null;
|
||||
const activateReadyUser = useCallback((nextUser: AuthUser) => {
|
||||
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
}, []);
|
||||
|
||||
const closeLoginModal = useCallback(() => {
|
||||
pendingProtectedActionRef.current = null;
|
||||
@@ -154,8 +160,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
await ensureStoredAccessToken();
|
||||
activateReadyUser(nextUser);
|
||||
setError('');
|
||||
} catch (autoAuthError) {
|
||||
if (!isActive) {
|
||||
@@ -229,6 +235,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureStoredAccessToken();
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
if (!isActive) {
|
||||
return;
|
||||
@@ -270,7 +277,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
isActive = false;
|
||||
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
};
|
||||
}, []);
|
||||
}, [activateReadyUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!readyUser) {
|
||||
@@ -458,8 +465,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
try {
|
||||
const nextUser = await bindWechatPhone(phone, code);
|
||||
setBindCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
activateReadyUser(nextUser);
|
||||
} catch (bindError) {
|
||||
setError(
|
||||
bindError instanceof Error
|
||||
@@ -665,8 +671,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
try {
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
activateReadyUser(nextUser);
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
loginError instanceof Error
|
||||
|
||||
@@ -151,7 +151,7 @@ export function LoginScreen({
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setHint(
|
||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
`短信请求已提交,请留意手机短信。验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
} catch {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { expect, test } from 'vitest';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||
|
||||
const noopCreateType = () => {};
|
||||
|
||||
const baseDraftItem: CustomWorldWorkSummary = {
|
||||
workId: 'draft:session-1',
|
||||
sourceType: 'agent_session',
|
||||
@@ -32,9 +34,8 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
items={[baseDraftItem]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onRetry={() => {}}
|
||||
onCreateNew={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
@@ -44,6 +45,9 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
|
||||
expect(screen.getByText('角色 3')).toBeTruthy();
|
||||
expect(screen.getByText('地点 4')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /角色扮演 RPG/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /大鱼吃小鱼/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /拼图玩法/u })).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<CustomWorldCreationHub
|
||||
@@ -59,9 +63,8 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onRetry={() => {}}
|
||||
onCreateNew={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
@@ -96,9 +99,8 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onRetry={() => {}}
|
||||
onCreateNew={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
@@ -109,5 +111,4 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
|
||||
expect(screen.getByText('沉钟拼图')).toBeTruthy();
|
||||
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('我的拼图作品')).toBeNull();
|
||||
expect(screen.queryByText('拼图玩法')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||
|
||||
const noopCreateType = () => {};
|
||||
|
||||
test('creation hub draft card renders compiled work summary fields', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
@@ -30,9 +32,8 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onRetry={() => {}}
|
||||
onCreateNew={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
@@ -41,6 +42,9 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
expect(html).toContain('一个被潮雾切开的列岛世界');
|
||||
expect(html).toContain('玩家是失职返乡的守灯人');
|
||||
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
||||
expect(html).toContain('角色扮演 RPG');
|
||||
expect(html).toContain('大鱼吃小鱼');
|
||||
expect(html).toContain('拼图玩法');
|
||||
});
|
||||
|
||||
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
|
||||
@@ -66,9 +70,8 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onRetry={() => {}}
|
||||
onCreateNew={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
@@ -78,5 +81,4 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
|
||||
expect(html).toContain('潮雾拼图');
|
||||
expect(html).toContain('拼图');
|
||||
expect(html).not.toContain('我的拼图作品');
|
||||
expect(html).not.toContain('拼图玩法');
|
||||
});
|
||||
|
||||
@@ -11,14 +11,16 @@ import {
|
||||
type CustomWorldWorkFilter,
|
||||
CustomWorldWorkTabs,
|
||||
} from './CustomWorldWorkTabs';
|
||||
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
|
||||
|
||||
type CustomWorldCreationHubProps = {
|
||||
items: CustomWorldWorkSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onRetry: () => void;
|
||||
onCreateNew: () => void;
|
||||
createError?: string | null;
|
||||
createBusy?: boolean;
|
||||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||||
onOpenDraft: (item: CustomWorldWorkSummary) => void;
|
||||
onEnterPublished: (profileId: string) => void;
|
||||
puzzleItems?: PuzzleWorkSummary[];
|
||||
@@ -39,9 +41,10 @@ export function CustomWorldCreationHub({
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
onBack,
|
||||
onRetry,
|
||||
onCreateNew,
|
||||
createError = null,
|
||||
createBusy = false,
|
||||
onCreateType,
|
||||
onOpenDraft,
|
||||
onEnterPublished,
|
||||
puzzleItems = [],
|
||||
@@ -80,33 +83,12 @@ export function CustomWorldCreationHub({
|
||||
|
||||
return (
|
||||
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="pb-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<div className="mt-4 text-[1.8rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2.3rem]">
|
||||
创作中心
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden shrink-0 gap-2 sm:flex">
|
||||
<span className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||||
草稿 {draftCount}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||||
已发布 {publishedCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<CustomWorldCreationStartCard onCreateNew={onCreateNew} />
|
||||
<CustomWorldCreationStartCard
|
||||
busy={createBusy}
|
||||
error={createError}
|
||||
onCreateType={onCreateType}
|
||||
/>
|
||||
|
||||
<CustomWorldWorkTabs
|
||||
activeFilter={activeFilter}
|
||||
|
||||
@@ -1,27 +1,89 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import {
|
||||
PLATFORM_CREATION_TYPES,
|
||||
type PlatformCreationTypeId,
|
||||
} from '../platform-entry/platformEntryCreationTypes';
|
||||
|
||||
type CustomWorldCreationStartCardProps = {
|
||||
onCreateNew: () => void;
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||||
};
|
||||
|
||||
export function CustomWorldCreationStartCard({
|
||||
onCreateNew,
|
||||
busy = false,
|
||||
error = null,
|
||||
onCreateType,
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
return (
|
||||
<div className="platform-surface platform-surface--hero relative overflow-hidden px-5 py-5">
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="relative z-10 space-y-4">
|
||||
<div>
|
||||
<div className="text-2xl font-black text-white sm:text-3xl">
|
||||
新建作品
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-200/88">
|
||||
直接选择游戏创作模板,立刻进入对应的共创工作台。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateNew}
|
||||
className="platform-button platform-button--primary w-full justify-between rounded-[1.1rem] text-left sm:w-auto"
|
||||
>
|
||||
<span className="text-sm font-semibold">新建作品</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{PLATFORM_CREATION_TYPES.map((item) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onCreateType(item.id);
|
||||
}}
|
||||
className={`platform-interactive-card relative overflow-hidden rounded-[1.5rem] border px-4 py-4 text-left transition ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
|
||||
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span
|
||||
className={`platform-pill px-3 ${
|
||||
item.locked
|
||||
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
|
||||
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
|
||||
}`}
|
||||
>
|
||||
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
|
||||
</span>
|
||||
{item.locked ? (
|
||||
<span className="text-base leading-none text-white/40">·</span>
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4 text-white/80" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-7 text-lg font-black leading-tight text-inherit">
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-2 text-sm ${
|
||||
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
|
||||
}`}
|
||||
>
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ArrowRight, X } from 'lucide-react';
|
||||
|
||||
import { PLATFORM_CREATION_TYPES } from './platformEntryCreationTypes';
|
||||
|
||||
export interface PlatformEntryCreationTypeModalProps {
|
||||
isOpen: boolean;
|
||||
isBusy: boolean;
|
||||
@@ -10,54 +12,8 @@ export interface PlatformEntryCreationTypeModalProps {
|
||||
onSelectPuzzle: () => void;
|
||||
}
|
||||
|
||||
type CreationGameTypeCard = {
|
||||
id: 'rpg' | 'big-fish' | 'puzzle' | 'airp' | 'visual-novel';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
const CREATION_GAME_TYPES: CreationGameTypeCard[] = [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '角色扮演 RPG',
|
||||
subtitle: 'Agent 共创',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'big-fish',
|
||||
title: '大鱼吃小鱼',
|
||||
subtitle: '实时成长玩法',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图玩法',
|
||||
subtitle: '图像锚点共创',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
title: 'AIRP',
|
||||
subtitle: '敬请期待',
|
||||
badge: '锁定',
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '敬请期待',
|
||||
badge: '锁定',
|
||||
locked: true,
|
||||
},
|
||||
];
|
||||
|
||||
function CreationTypeCard(props: {
|
||||
item: CreationGameTypeCard;
|
||||
item: (typeof PLATFORM_CREATION_TYPES)[number];
|
||||
busy: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
@@ -147,7 +103,7 @@ export function PlatformEntryCreationTypeModal({
|
||||
|
||||
<div className="px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div className="grid gap-3 sm:grid-cols-5">
|
||||
{CREATION_GAME_TYPES.map((item) => (
|
||||
{PLATFORM_CREATION_TYPES.map((item) => (
|
||||
<CreationTypeCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
normalizeAgentBackedProfile,
|
||||
resolveRpgCreationErrorMessage,
|
||||
} from './platformEntryShared';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import type { PlatformEntryFlowShellProps } from './platformEntryTypes';
|
||||
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
|
||||
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
|
||||
@@ -363,9 +364,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
[authUi],
|
||||
);
|
||||
|
||||
const openCreationTypePicker = useCallback(() => {
|
||||
const prepareCreationLaunch = useCallback(() => {
|
||||
if (sessionController.isCreatingAgentSession) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasSavedGame) {
|
||||
@@ -373,13 +374,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
sessionController.setCreationTypeError(null);
|
||||
setShowCreationTypeModal(true);
|
||||
return true;
|
||||
}, [
|
||||
handleStartNewGame,
|
||||
hasSavedGame,
|
||||
sessionController,
|
||||
]);
|
||||
|
||||
const openCreationTypePicker = useCallback(() => {
|
||||
if (!prepareCreationLaunch()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowCreationTypeModal(true);
|
||||
}, [prepareCreationLaunch]);
|
||||
|
||||
const resolveBigFishErrorMessage = useCallback(
|
||||
(error: unknown, fallback: string) =>
|
||||
resolveRpgCreationErrorMessage(error, fallback),
|
||||
@@ -466,6 +475,45 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const handleCreationHubCreateType = useCallback(
|
||||
(type: PlatformCreationTypeId) => {
|
||||
if (type === 'airp' || type === 'visual-novel') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prepareCreationLaunch()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'rpg') {
|
||||
runProtectedAction(() => {
|
||||
void sessionController.openRpgAgentWorkspace();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'big-fish') {
|
||||
runProtectedAction(() => {
|
||||
void openBigFishAgentWorkspace();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'puzzle') {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleAgentWorkspace();
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
openBigFishAgentWorkspace,
|
||||
openPuzzleAgentWorkspace,
|
||||
prepareCreationLaunch,
|
||||
runProtectedAction,
|
||||
sessionController,
|
||||
],
|
||||
);
|
||||
|
||||
const leaveBigFishFlow = useCallback(() => {
|
||||
setBigFishError(null);
|
||||
setBigFishRun(null);
|
||||
@@ -925,9 +973,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
sessionController.creationTypeError ??
|
||||
puzzleError
|
||||
}
|
||||
onBack={() => {
|
||||
platformBootstrap.setPlatformTab('home');
|
||||
}}
|
||||
onRetry={() => {
|
||||
platformBootstrap.setPlatformError(null);
|
||||
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
|
||||
@@ -936,7 +981,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
});
|
||||
}}
|
||||
onCreateNew={openCreationTypePicker}
|
||||
createError={sessionController.creationTypeError ?? bigFishError ?? puzzleError}
|
||||
createBusy={
|
||||
sessionController.isCreatingAgentSession || isBigFishBusy || isPuzzleBusy
|
||||
}
|
||||
onCreateType={handleCreationHubCreateType}
|
||||
onOpenDraft={(item) => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.handleOpenCreationWork(item);
|
||||
|
||||
55
src/components/platform-entry/platformEntryCreationTypes.ts
Normal file
55
src/components/platform-entry/platformEntryCreationTypes.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export type PlatformCreationTypeId =
|
||||
| 'rpg'
|
||||
| 'big-fish'
|
||||
| 'puzzle'
|
||||
| 'airp'
|
||||
| 'visual-novel';
|
||||
|
||||
export type PlatformCreationTypeCard = {
|
||||
id: PlatformCreationTypeId;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创作页与类型弹层共用同一份模板元数据,避免多入口文案和可用状态漂移。
|
||||
*/
|
||||
export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '角色扮演 RPG',
|
||||
subtitle: 'Agent 共创',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'big-fish',
|
||||
title: '大鱼吃小鱼',
|
||||
subtitle: '实时成长玩法',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图玩法',
|
||||
subtitle: '图像锚点共创',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
title: 'AIRP',
|
||||
subtitle: '敬请期待',
|
||||
badge: '锁定',
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '敬请期待',
|
||||
badge: '锁定',
|
||||
locked: true,
|
||||
},
|
||||
];
|
||||
@@ -60,18 +60,13 @@ async function clickFirstAsyncButtonByName(
|
||||
|
||||
async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(await screen.findByText('创作中心')).toBeTruthy();
|
||||
expect(await screen.findByText('角色扮演 RPG')).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openNewRpgCreation(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
) {
|
||||
await openCreationHub(user);
|
||||
const createButtons = await screen.findAllByRole('button', {
|
||||
name: /新建作品/u,
|
||||
});
|
||||
await user.click(createButtons.at(-1)!);
|
||||
expect(screen.getByText('选择创作类型')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
}
|
||||
|
||||
@@ -544,18 +539,12 @@ beforeEach(() => {
|
||||
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
|
||||
});
|
||||
|
||||
test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => {
|
||||
test('create hub exposes direct template entry, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
const createButtons = await screen.findAllByRole('button', {
|
||||
name: /新建作品/u,
|
||||
});
|
||||
await user.click(createButtons.at(-1)!);
|
||||
|
||||
expect(screen.getByText('选择创作类型')).toBeTruthy();
|
||||
|
||||
const airpButton = screen.getByRole('button', { name: /AIRP/u });
|
||||
const visualNovelButton = screen.getByRole('button', {
|
||||
@@ -613,18 +602,17 @@ test('create tab opens compiled agent draft in result refinement page', async ()
|
||||
|
||||
await openCreationHub(user);
|
||||
|
||||
expect(screen.getByRole('button', { name: /继续完善/u })).toBeTruthy();
|
||||
expect(await screen.findByRole('button', { name: /继续完善/u })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /继续完善/u }));
|
||||
await user.click(await screen.findByRole('button', { name: /继续完善/u }));
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
expect(await screen.findByText('世界档案')).toBeTruthy();
|
||||
expect(screen.queryByText('Agent工作区:custom-world-agent-session-1')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy();
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
);
|
||||
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(screen.getByRole('button', { name: /返回创作/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('create tab resumes agent workspace when draft has no compiled result yet', async () => {
|
||||
@@ -661,9 +649,9 @@ test('create tab resumes agent workspace when draft has no compiled result yet',
|
||||
|
||||
await openCreationHub(user);
|
||||
|
||||
expect(screen.getByRole('button', { name: /继续创作/u })).toBeTruthy();
|
||||
expect(await screen.findByRole('button', { name: /继续创作/u })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /继续创作/u }));
|
||||
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||||
@@ -714,7 +702,7 @@ test('opening a compiled draft with a missing agent session falls back to create
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await user.click(screen.getByRole('button', { name: /继续完善/u }));
|
||||
await user.click(await screen.findByRole('button', { name: /继续完善/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -1227,7 +1215,7 @@ test('agent draft result back button returns to creation hub without redundant s
|
||||
await user.click(screen.getByRole('button', { name: /返回创作/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('创作中心')).toBeTruthy();
|
||||
expect(screen.getByText('角色扮演 RPG')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -1584,7 +1572,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(screen.getByRole('button', { name: /进入世界/u }));
|
||||
await user.click(await screen.findByRole('button', { name: /进入世界/u }));
|
||||
await user.click(await screen.findByRole('button', { name: '删除作品' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1662,7 +1650,7 @@ test('creation hub published work enters existing detail view', async () => {
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await user.click(screen.getByRole('button', { name: /进入世界/u }));
|
||||
await user.click(await screen.findByRole('button', { name: /进入世界/u }));
|
||||
|
||||
expect(await screen.findByText('世界信息')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth';
|
||||
import {
|
||||
API_RESPONSE_ENVELOPE_HEADER,
|
||||
API_RESPONSE_ENVELOPE_VERSION,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
parseApiErrorMessage,
|
||||
unwrapApiResponse,
|
||||
} from '../../packages/shared/src/http';
|
||||
import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
||||
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
|
||||
@@ -487,6 +487,16 @@ async function refreshAccessToken() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureStoredAccessToken() {
|
||||
const currentToken = getStoredAccessToken();
|
||||
if (currentToken) {
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
// AuthGate 恢复会话时可能只有 HttpOnly refresh cookie,本地尚无 access token。
|
||||
return refreshAccessToken();
|
||||
}
|
||||
|
||||
export async function fetchWithApiAuth(
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('authService', () => {
|
||||
'登录失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('jwt-entry-token');
|
||||
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a fresh guest credential pair for auto auth when a session is missing', async () => {
|
||||
@@ -251,7 +251,7 @@ describe('authService', () => {
|
||||
'登录失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('jwt-phone-token');
|
||||
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores renewed access token after wechat bind activation', async () => {
|
||||
@@ -272,7 +272,7 @@ describe('authService', () => {
|
||||
|
||||
expect(user.wechatBound).toBe(true);
|
||||
expect(getStoredAccessToken()).toBe('jwt-wechat-bind-token');
|
||||
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('changes phone number without emitting a global auth state refresh', async () => {
|
||||
@@ -367,7 +367,7 @@ describe('authService', () => {
|
||||
error: null,
|
||||
});
|
||||
expect(getStoredAccessToken()).toBe('jwt-callback-token');
|
||||
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/');
|
||||
});
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import type {
|
||||
AuthAuditLogsResponse,
|
||||
AuthCaptchaChallenge,
|
||||
AuthEntryResponse,
|
||||
AuthLiftRiskBlockResponse,
|
||||
AuthLoginMethod,
|
||||
AuthLoginOptionsResponse,
|
||||
AuthLiftRiskBlockResponse,
|
||||
AuthLogoutAllResponse,
|
||||
AuthMeResponse,
|
||||
AuthPhoneChangeResponse,
|
||||
@@ -166,7 +166,7 @@ export async function loginWithPhoneCode(phone: string, code: string) {
|
||||
'登录失败',
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
setStoredAccessToken(response.token, { emit: false });
|
||||
return response.user;
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ export async function bindWechatPhone(phone: string, code: string) {
|
||||
'绑定手机号失败',
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
setStoredAccessToken(response.token, { emit: false });
|
||||
return response.user;
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ export async function authEntry(username: string, password: string) {
|
||||
'登录失败',
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
setStoredAccessToken(response.token, { emit: false });
|
||||
return response.user;
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
setStoredAccessToken(authToken);
|
||||
setStoredAccessToken(authToken, { emit: false });
|
||||
}
|
||||
|
||||
if (typeof window.history?.replaceState === 'function') {
|
||||
|
||||
Reference in New Issue
Block a user