This commit is contained in:
2026-05-08 11:44:42 +08:00
parent b08127031c
commit abf1f1ebea
249 changed files with 39411 additions and 887 deletions

View File

@@ -6,6 +6,7 @@ import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
@@ -43,6 +44,13 @@ import {
submitBigFishInput,
} from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import {
cancelCreativeAgentSession,
confirmCreativePuzzleTemplate,
createCreativeAgentSession,
streamCreativeAgentMessage,
streamCreativeDraftEdit,
} from '../../services/creative-agent';
import { match3dCreationClient } from '../../services/match3d-creation';
import {
clickMatch3DItem,
@@ -109,6 +117,20 @@ import {
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import { squareHoleCreationClient } from '../../services/square-hole-creation';
import {
dropSquareHoleShape,
finishSquareHoleTimeUp,
restartSquareHoleRun,
startSquareHoleRun,
stopSquareHoleRun,
} from '../../services/square-hole-runtime';
import {
deleteSquareHoleWork,
getSquareHoleWorkDetail,
listSquareHoleGallery,
listSquareHoleWorks,
} from '../../services/square-hole-works';
import { type CustomWorldProfile, WorldType } from '../../types';
import {
AuthUiContext,
@@ -136,18 +158,51 @@ async function clickFirstAsyncButtonByName(
await user.click(buttons[0]!);
}
async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
async function openCreateTemplateHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '创作');
expect(
await screen.findByRole('button', { name: /.*/u }),
await screen.findByText('10分钟创作一个精品互动玩法'),
).toBeTruthy();
expect(screen.getByRole('tab', { name: '拼图' })).toBeTruthy();
expect(screen.getByText('拼图工作区missing-session')).toBeTruthy();
}
async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '草稿');
const panel = getPlatformTabPanel('saves');
await waitFor(() => {
expect(panel.getAttribute('aria-hidden')).toBe('false');
});
expect(
await within(panel).findByRole('button', { name: //u }),
).toBeTruthy();
}
async function openDiscoverHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '发现');
const panel = getPlatformTabPanel('category');
await waitFor(() => {
expect(panel.getAttribute('aria-hidden')).toBe('false');
});
expect(
await within(panel).findByPlaceholderText(
'搜索作品号、名称、作者、描述',
),
).toBeTruthy();
return panel;
}
async function openProfilePlayedWorks(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '我的');
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('可继续')).toBeTruthy();
}
async function openExistingRpgDraft(
user: ReturnType<typeof userEvent.setup>,
actionName: string | RegExp = /(?:|)/u,
) {
await openCreationHub(user);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: actionName }));
}
@@ -289,6 +344,40 @@ vi.mock('../../services/match3d-runtime', () => ({
stopMatch3DRun: vi.fn(),
}));
vi.mock('../../services/square-hole-creation', () => ({
squareHoleCreationClient: {
createSession: vi.fn(),
executeAction: vi.fn(),
getSession: vi.fn(),
sendMessage: vi.fn(),
streamMessage: vi.fn(),
},
}));
vi.mock('../../services/square-hole-runtime', () => ({
dropSquareHoleShape: vi.fn(),
finishSquareHoleTimeUp: vi.fn(),
getSquareHoleRun: vi.fn(),
restartSquareHoleRun: vi.fn(),
startSquareHoleRun: vi.fn(),
stopSquareHoleRun: vi.fn(),
}));
vi.mock('../../services/square-hole-works', () => ({
deleteSquareHoleWork: vi.fn(),
getSquareHoleWorkDetail: vi.fn(),
listSquareHoleGallery: vi.fn(),
listSquareHoleWorks: vi.fn(),
}));
vi.mock('../../services/creative-agent', () => ({
cancelCreativeAgentSession: vi.fn(),
confirmCreativePuzzleTemplate: vi.fn(),
createCreativeAgentSession: vi.fn(),
streamCreativeAgentMessage: vi.fn(),
streamCreativeDraftEdit: vi.fn(),
}));
vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
const actual = await vi.importActual<
typeof import('../../services/puzzle-runtime/puzzleLocalRuntime')
@@ -573,6 +662,168 @@ const mockAuthUser: AuthUser = {
createdAt: new Date().toISOString(),
};
function buildMockCreativeAgentSession(
overrides: Partial<CreativeAgentSessionSnapshot> = {},
): CreativeAgentSessionSnapshot {
const sessionId = overrides.sessionId ?? 'creative-agent-session-1';
return {
sessionId,
stage: 'waiting_user',
inputSummary: {
text: null,
entryContext: 'creation_home',
images: [],
materialSummary: null,
unsupportedCapabilities: [],
},
messages: [
{
id: 'creative-agent-message-1',
role: 'assistant',
kind: 'chat',
text: '说一个灵感,我来帮你做成互动内容。',
createdAt: '2026-05-05T10:00:00.000Z',
},
],
puzzleTemplateCatalog: [],
puzzleTemplateSelection: null,
puzzleImageGenerationPlan: null,
targetBinding: null,
updatedAt: '2026-05-05T10:00:00.000Z',
...overrides,
};
}
function buildMockSquareHoleAgentSession(
overrides: Partial<Parameters<typeof buildMockSquareHoleAgentSessionImpl>[0]> = {},
) {
return buildMockSquareHoleAgentSessionImpl(overrides);
}
function buildMockSquareHoleAgentSessionImpl(
overrides: Partial<{
sessionId: string;
stage: string;
messages: Array<{ id: string; role: string; kind: string; text: string; createdAt: string }>;
updatedAt: string;
}> = {},
) {
const sessionId = overrides.sessionId ?? 'square-hole-session-1';
return {
sessionId,
currentTurn: 0,
progressPercent: 20,
stage: 'collecting_config',
anchorPack: {
theme: {
key: 'theme',
label: '题材主题',
value: '霓虹形状',
status: 'confirmed',
},
twistRule: {
key: 'twistRule',
label: '反直觉规则',
value: '颜色会误导洞口',
status: 'confirmed',
},
shapeCount: {
key: 'shapeCount',
label: '形状数量',
value: '12',
status: 'confirmed',
},
difficulty: {
key: 'difficulty',
label: '难度',
value: '5',
status: 'confirmed',
},
},
config: {
themeText: '霓虹形状',
twistRule: '颜色会误导洞口',
shapeCount: 12,
difficulty: 5,
shapeOptions: [
{
optionId: 'shape-square',
shapeKind: 'square',
label: '方块',
targetHoleId: 'hole-square',
imagePrompt: '霓虹方块',
imageSrc: null,
},
],
holeOptions: [
{
holeId: 'hole-square',
holeKind: 'square',
label: '方洞',
imagePrompt: '发光方洞',
imageSrc: null,
},
],
backgroundPrompt: '霓虹街机背景',
coverImageSrc: null,
backgroundImageSrc: null,
},
draft: null,
messages: [
{
id: 'square-hole-message-1',
role: 'assistant',
kind: 'chat',
text: '先确定方洞挑战的题材和反直觉规则。',
createdAt: '2026-05-01T10:00:00.000Z',
},
],
lastAssistantReply: '先确定方洞挑战的题材和反直觉规则。',
publishedProfileId: null,
updatedAt: '2026-05-01T10:00:00.000Z',
...overrides,
};
}
function buildMockSquareHoleRun(profileId: string) {
return {
runId: `square-hole-run-${profileId}`,
profileId,
ownerUserId: 'user-2',
status: 'running',
snapshotVersion: 1,
startedAtMs: 1_000,
durationLimitMs: 600_000,
remainingMs: 600_000,
totalShapeCount: 12,
completedShapeCount: 0,
combo: 0,
bestCombo: 0,
score: 0,
ruleLabel: '颜色会误导洞口',
currentShape: {
shapeId: 'shape-1',
shapeKind: 'square',
label: '方块',
targetHoleId: 'hole-square',
color: '#ff5f7e',
imageSrc: null,
},
holes: [
{
holeId: 'hole-square',
holeKind: 'square',
label: '方洞',
x: 0.2,
y: 0.5,
},
],
lastFeedback: null,
};
}
function buildMockPuzzleRun(
profileId: string,
levelName: string,
@@ -1779,6 +2030,50 @@ beforeEach(() => {
vi.mocked(stopMatch3DRun).mockResolvedValue({
run: buildMockMatch3DRun('match3d-profile-stopped'),
});
vi.mocked(squareHoleCreationClient.createSession).mockResolvedValue({
session: buildMockSquareHoleAgentSession(),
});
vi.mocked(squareHoleCreationClient.getSession).mockResolvedValue({
session: buildMockSquareHoleAgentSession(),
});
vi.mocked(squareHoleCreationClient.streamMessage).mockResolvedValue(
buildMockSquareHoleAgentSession(),
);
vi.mocked(squareHoleCreationClient.executeAction).mockResolvedValue({
session: buildMockSquareHoleAgentSession(),
});
vi.mocked(listSquareHoleWorks).mockResolvedValue({
items: [],
});
vi.mocked(listSquareHoleGallery).mockResolvedValue({
items: [],
});
vi.mocked(getSquareHoleWorkDetail).mockRejectedValue(
new Error('未找到方洞挑战作品'),
);
vi.mocked(deleteSquareHoleWork).mockResolvedValue({
items: [],
});
vi.mocked(startSquareHoleRun).mockResolvedValue({
run: buildMockSquareHoleRun('square-hole-profile-1'),
});
vi.mocked(dropSquareHoleShape).mockResolvedValue({
feedback: {
accepted: true,
rejectReason: null,
message: '投入成功',
},
run: buildMockSquareHoleRun('square-hole-profile-1'),
});
vi.mocked(restartSquareHoleRun).mockResolvedValue({
run: buildMockSquareHoleRun('square-hole-profile-1'),
});
vi.mocked(finishSquareHoleTimeUp).mockResolvedValue({
run: buildMockSquareHoleRun('square-hole-profile-1'),
});
vi.mocked(stopSquareHoleRun).mockResolvedValue({
run: buildMockSquareHoleRun('square-hole-profile-1'),
});
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [],
});
@@ -1871,44 +2166,119 @@ beforeEach(() => {
});
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
vi.mocked(createCreativeAgentSession).mockResolvedValue({
session: buildMockCreativeAgentSession(),
});
vi.mocked(streamCreativeAgentMessage).mockImplementation(
async (sessionId, payload) =>
buildMockCreativeAgentSession({
sessionId,
stage: 'collaborating',
messages: [
{
id: 'creative-agent-message-1',
role: 'assistant',
kind: 'chat',
text: '说一个灵感,我来帮你做成互动内容。',
createdAt: '2026-05-05T10:00:00.000Z',
},
{
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text: payload.content
.map((part) =>
part.type === 'input_text' ? part.text.trim() : '参考图',
)
.filter(Boolean)
.join(' / '),
createdAt: '2026-05-05T10:01:00.000Z',
},
{
id: 'creative-agent-message-2',
role: 'assistant',
kind: 'chat',
text: '收到,我先帮你整理成可创作方案。',
createdAt: '2026-05-05T10:01:01.000Z',
},
],
}),
);
vi.mocked(cancelCreativeAgentSession).mockResolvedValue({
session: buildMockCreativeAgentSession({ stage: 'failed' }),
});
vi.mocked(confirmCreativePuzzleTemplate).mockResolvedValue({
session: buildMockCreativeAgentSession(),
});
vi.mocked(streamCreativeDraftEdit).mockResolvedValue(
buildMockCreativeAgentSession(),
);
});
test('create hub hides RPG while keeping Match3D open and future templates locked', async () => {
test('create tab shows template tabs and embeds puzzle form by default', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openCreateTemplateHub(user);
const match3dButton = screen.getByRole('button', {
name: /.*/u,
});
const airpButton = screen.getByRole('button', { name: /AIRP/u });
const visualNovelButton = screen.getByRole('button', {
name: //u,
});
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(
screen.getByRole('tab', { name: '拼图' }).getAttribute('aria-selected'),
).toBe('true');
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
).toContain('/creation-type-references/puzzle.webp');
expect(
screen.getByRole('tab', { name: '方洞挑战' }).querySelector('img')?.src,
).toContain('/creation-type-references/square-hole.webp');
expect(
screen.getByRole('tab', { name: '视觉小说' }).querySelector('img')?.src,
).toContain('/creation-type-references/visual-novel.webp');
expect(
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
).toContain('/creation-type-references/airp.webp');
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'),
).toBeTruthy();
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-inherit'),
).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('tab', { name: //u })).toBeNull();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('platform create hub does not prefetch hidden big fish platform data', async () => {
test('embedded puzzle form routes through requireAuth while logged out', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
render(<TestWrapper withAuth />);
render(
<TestWrapper
authValue={createAuthValue({
user: null,
openLoginModal: () => {},
requireAuth,
})}
/>,
);
await openCreationHub(user);
await openCreateTemplateHub(user);
const generateButton = await screen.findByRole('button', {
name: /稿/u,
});
expect(
await screen.findByRole('button', { name: /.*/u }),
).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(listBigFishWorks).not.toHaveBeenCalled();
expect(listBigFishGallery).not.toHaveBeenCalled();
await user.click(generateButton);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(createCreativeAgentSession).not.toHaveBeenCalled();
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('opening RPG agent workspace does not refetch session snapshot in a render loop', async () => {
@@ -2002,7 +2372,7 @@ test('create tab opens compiled agent draft in result refinement page', async ()
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
@@ -2056,7 +2426,7 @@ test('create tab resumes agent workspace when draft has no compiled result yet',
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
@@ -2112,7 +2482,7 @@ test('create tab resumes agent workspace when session has no draft profile even
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
@@ -2122,7 +2492,7 @@ test('create tab resumes agent workspace when session has no draft profile even
expect(screen.queryByText('世界档案')).toBeNull();
});
test('opening a compiled draft with a missing agent session falls back to create hub', async () => {
test('opening a compiled draft with a missing agent session falls back to draft hub', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks)
@@ -2166,20 +2536,22 @@ test('opening a compiled draft with a missing agent session falls back to create
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
const fallbackDraftPanel = getPlatformTabPanel('saves');
await waitFor(() => {
expect(fallbackDraftPanel.getAttribute('aria-hidden')).toBe('false');
expect(
within(getPlatformTabPanel('create')).getByText(
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
within(fallbackDraftPanel).getByText(
'这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。',
),
).toBeTruthy();
});
expect(window.location.search).toBe('');
expect(listRpgCreationWorks).toHaveBeenCalledTimes(2);
expect(screen.getByText('还没有作品')).toBeTruthy();
expect(within(fallbackDraftPanel).getByText('还没有作品')).toBeTruthy();
expect(
screen.queryByText('Agent工作区custom-world-agent-session-missing'),
).toBeNull();
@@ -2374,6 +2746,7 @@ test('logged out public detail gates big fish start before local runtime', async
/>,
);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
);
@@ -2469,23 +2842,22 @@ test('creation hub clears all private work shelves immediately after logout stat
const { rerender } = render(<TestWrapper authValue={loggedInAuth} />);
await openCreationHub(user);
const createPanel = getPlatformTabPanel('create');
await openDraftHub(user);
const draftPanel = getPlatformTabPanel('saves');
await waitFor(() => {
expect(listRpgCreationWorks).toHaveBeenCalled();
});
expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy();
expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull();
expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull();
expect(await within(draftPanel).findByText('拼图退出缓存作品')).toBeTruthy();
expect(within(draftPanel).queryByText('RPG 退出缓存作品')).toBeNull();
expect(within(draftPanel).queryByText('大鱼退出缓存作品')).toBeNull();
rerender(<TestWrapper authValue={loggedOutAuth} />);
await waitFor(() => {
expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull();
expect(within(createPanel).queryByText('拼图退出缓存作品')).toBeNull();
expect(screen.queryByText('RPG 退出缓存作品')).toBeNull();
expect(screen.queryByText('拼图退出缓存作品')).toBeNull();
});
expect(within(createPanel).getByText('还没有作品')).toBeTruthy();
});
test('published puzzle works appear on home and mobile game category channel', async () => {
@@ -2522,12 +2894,15 @@ test('published puzzle works appear on home and mobile game category channel', a
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
});
await user.click(screen.getByRole('button', { name: '游戏分类' }));
await clickFirstButtonByName(user, '发现');
await user.click(screen.getByRole('button', { name: '分类' }));
const homePanel = getPlatformTabPanel('home');
expect(within(homePanel).getAllByText('星桥机关').length).toBeGreaterThan(0);
const discoverPanel = getPlatformTabPanel('category');
expect(
within(homePanel).getAllByRole('button', { name: //u }).length,
within(discoverPanel).getAllByText('星桥机关').length,
).toBeGreaterThan(0);
expect(
within(discoverPanel).getAllByRole('button', { name: //u }).length,
).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull();
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
@@ -2564,12 +2939,13 @@ test('published big fish works stay hidden from platform home and game category
});
expect(screen.queryByText('机械深海 大鱼吃小鱼')).toBeNull();
await user.click(screen.getByRole('button', { name: '游戏分类' }));
await clickFirstButtonByName(user, '发现');
await user.click(screen.getByRole('button', { name: '分类' }));
const homePanel = getPlatformTabPanel('home');
expect(within(homePanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
const discoverPanel = getPlatformTabPanel('category');
expect(within(discoverPanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
expect(
within(homePanel).queryAllByRole('button', { name: //u }).length,
within(discoverPanel).queryAllByRole('button', { name: //u }).length,
).toBe(0);
});
@@ -2603,6 +2979,7 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
render(<TestWrapper withAuth />);
await clickFirstButtonByName(user, '发现');
await user.click(await screen.findByRole('button', { name: '排行' }));
await waitFor(() => {
expect(document.getElementById('platform-tab-panel-category')).toBeTruthy();
@@ -2647,32 +3024,6 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
});
});
test('selecting puzzle creation while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
render(
<TestWrapper
authValue={createAuthValue({
user: null,
openLoginModal: () => {},
requireAuth,
})}
/>,
);
await openCreationHub(user);
const puzzleButton = await screen.findByRole('button', {
name: /.*/u,
});
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleButton);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => {
const openLoginModal = vi.fn();
@@ -2779,7 +3130,7 @@ test('refreshing RPG agent path restores stored agent workspace pointer', async
).toBeTruthy();
});
test('new puzzle creation entry maps raw bearer token errors to user-facing auth copy', async () => {
test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => {
const user = userEvent.setup();
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
@@ -2792,36 +3143,35 @@ test('new puzzle creation entry maps raw bearer token errors to user-facing auth
render(<TestWrapper withAuth />);
await openCreationHub(user);
const puzzleButton = screen.getByRole('button', {
name: /.*/u,
});
await openCreateTemplateHub(user);
const generateButton = screen.getByRole('button', { name: /稿/u });
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleButton);
expect((generateButton as HTMLButtonElement).disabled).toBe(false);
await user.click(generateButton);
expect(listPuzzleWorks).toHaveBeenCalled();
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
expect(createCreativeAgentSession).not.toHaveBeenCalled();
expect(
await within(getPlatformTabPanel('create')).findByText(
await screen.findByText(
'当前登录状态已失效,请重新登录后继续。',
),
).toBeTruthy();
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
});
test('hidden big fish creation entry does not render in platform create hub', async () => {
test('create tab does not render legacy gameplay creation entries', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openCreateTemplateHub(user);
expect(screen.queryByText('选择创作类型')).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(createBigFishCreationSession).not.toHaveBeenCalled();
});
test('puzzle creation timeout exits busy state and shows a readable error', async () => {
test('embedded puzzle form timeout exits busy state and shows a readable error', async () => {
const user = userEvent.setup();
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
@@ -2832,9 +3182,9 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openCreateTemplateHub(user);
const button = screen.getByRole('button', { name: /.*/u });
const button = screen.getByRole('button', { name: /稿/u });
await user.click(button);
await waitFor(() => {
@@ -2845,12 +3195,12 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
).toBeGreaterThan(0);
});
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
expect(screen.queryByText(//u)).toBeNull();
expect(createCreativeAgentSession).not.toHaveBeenCalled();
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
});
test('visible match3d creation card opens workspace even when public galleries fail', async () => {
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
const user = userEvent.setup();
const match3dSession = buildMockMatch3DAgentSession();
vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce(
new Error('读取作品广场失败'),
@@ -2858,23 +3208,16 @@ test('visible match3d creation card opens workspace even when public galleries f
vi.mocked(listMatch3DGallery).mockRejectedValueOnce(
new Error('读取抓大鹅广场失败'),
);
vi.mocked(match3dCreationClient.createSession).mockResolvedValueOnce({
session: match3dSession,
});
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openCreateTemplateHub(user);
expect(screen.queryByText('读取作品广场失败')).toBeNull();
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
await user.click(
screen.getByRole('button', { name: /.*/u }),
);
await waitFor(() => {
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(1);
});
expect(await screen.findByText('抓大鹅工作区match3d-agent-session-1')).toBeTruthy();
expect(
screen.queryByRole('tab', { name: /.*/u }),
).toBeNull();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('puzzle draft result back button returns to creation hub', async () => {
@@ -2905,7 +3248,7 @@ test('puzzle draft result back button returns to creation hub', async () => {
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
@@ -2919,11 +3262,9 @@ test('puzzle draft result back button returns to creation hub', async () => {
await user.click(screen.getByRole('button', { name: '返回' }));
expect(
await screen.findByRole('button', { name: /.*/u }),
await screen.findByText('10分钟创作一个精品互动玩法'),
).toBeTruthy();
expect(
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
).toBeNull();
expect(screen.getByText('雨夜里有一只会发光的猫站在遗迹台阶上。')).toBeTruthy();
expect(screen.queryByText('拼图结果页')).toBeNull();
});
@@ -2955,7 +3296,7 @@ test('published puzzle work card restores its source session for editing', async
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
@@ -3089,6 +3430,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
item: puzzleWork,
});
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
@@ -3274,6 +3616,7 @@ test('formal puzzle similar work keeps current run level progression', async ()
item: profileId === similarWork.profileId ? similarWork : entryWork,
}));
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
@@ -3375,6 +3718,7 @@ test('first puzzle runtime back click can open remix result page', async () => {
});
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
@@ -3428,6 +3772,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
});
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
@@ -3471,6 +3816,7 @@ test('public code search opens a published big fish work by BF code', async () =
});
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
@@ -3523,6 +3869,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru
});
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
@@ -3672,7 +4019,7 @@ test('failed draft work continues on generation progress view instead of agent w
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
@@ -4193,7 +4540,7 @@ test('agent result view does not keep legacy publish blockers when preview uses
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
@@ -4370,9 +4717,7 @@ test('agent draft result back button returns to creation hub without syncing res
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(
screen.getByRole('button', { name: /.*/u }),
).toBeTruthy();
expect(screen.getByText('10分钟创作一个精品互动玩法')).toBeTruthy();
});
expect(
@@ -4625,7 +4970,9 @@ test('agent draft result can open from server result preview without embedded le
);
});
test('authenticated users with save archives default into the saves tab', async () => {
test('authenticated users can open save archives from the profile played panel', async () => {
const user = userEvent.setup();
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
worldKey: 'custom:world-1',
@@ -4642,15 +4989,18 @@ test('authenticated users with save archives default into the saves tab', async
render(<TestWrapper withAuth />);
expect((await screen.findAllByText('全部存档')).length).toBeGreaterThan(0);
await openProfilePlayedWorks(user);
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
expect(screen.getAllByText('旧灯塔与失控航路').length).toBeGreaterThan(0);
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
expect(screen.queryByText('ARCHIVE')).toBeNull();
expect(screen.queryByText('最近存档')).toBeNull();
});
test('puzzle save archive highlights work title and level subtitle', async () => {
const user = userEvent.setup();
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
worldKey: 'puzzle:puzzle-profile-1',
@@ -4667,6 +5017,8 @@ test('puzzle save archive highlights work title and level subtitle', async () =>
render(<TestWrapper withAuth />);
await openProfilePlayedWorks(user);
expect((await screen.findAllByText('雨夜猫塔')).length).toBeGreaterThan(0);
expect(screen.getAllByText('第 2 关 · 星桥机关').length).toBeGreaterThan(0);
expect(screen.queryByText('ARCHIVE')).toBeNull();
@@ -4689,16 +5041,16 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
await clickFirstButtonByName(user, '创作');
expect(
await screen.findByRole('button', { name: /.*/u }),
await screen.findByText('10分钟创作一个精品互动玩法'),
).toBeTruthy();
resolveGalleryRequest([]);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByRole('button', {
name: /.*/u,
}),
within(getPlatformTabPanel('create')).getByText(
'10分钟创作一个精品互动玩法',
),
).toBeTruthy();
});
@@ -4750,6 +5102,8 @@ test('save tab can resume a selected archive directly into the game', async () =
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
await openProfilePlayedWorks(user);
await clickFirstAsyncButtonByName(user, //u);
await waitFor(() => {
@@ -4827,7 +5181,7 @@ test('creation hub published work can open detail view before deleting from deta
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('详情')).toBeTruthy();
@@ -4902,7 +5256,7 @@ test('creation hub published work enters existing detail view', async () => {
render(<TestWrapper withAuth />);
await openCreationHub(user);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('详情')).toBeTruthy();
@@ -4976,7 +5330,7 @@ test('creation hub published work experience button enters world directly', asyn
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
await openCreationHub(user);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: '启动' }));
@@ -5060,7 +5414,7 @@ test('creation hub published work card keeps delete action guarded by detail flo
render(<TestWrapper withAuth />);
await clickFirstButtonByName(user, '创作');
await openDraftHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '删除' }));

View File

@@ -939,6 +939,23 @@ test('shows a reachable login entry in logged out mobile shell', async () => {
expect(openLoginModal).toHaveBeenCalledTimes(1);
});
test('logged out bottom nav keeps creation centered with recommend icon', () => {
const { container } = renderLoggedOutHomeView(vi.fn());
const nav = container.querySelector('.platform-bottom-nav');
expect(nav).toBeTruthy();
const buttons = within(nav as HTMLElement).getAllByRole('button');
expect(buttons.map((button) => button.textContent)).toEqual([
'推荐',
'创作',
'发现',
]);
expect(buttons[0]?.querySelector('.lucide-gamepad-2')).toBeTruthy();
expect(buttons[1]?.querySelector('.lucide-sparkles')).toBeTruthy();
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
});
test('mobile home search submits public work code', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();

View File

@@ -1,5 +1,4 @@
import {
Archive,
ArrowRight,
Bell,
BookOpen,
@@ -8,20 +7,20 @@ import {
ChevronRight,
Clock3,
Coins,
Compass,
Copy,
Gamepad2,
Heart,
House,
LogIn,
MessageCircle,
Pencil,
Plus,
Search,
Settings,
SlidersHorizontal,
Sparkles,
Star,
Ticket,
Trophy,
UserPlus,
UserRound,
} from 'lucide-react';
@@ -83,6 +82,7 @@ import {
isMatch3DGalleryEntry,
isPuzzleGalleryEntry,
isSquareHoleGalleryEntry,
isVisualNovelGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformWorldCoverImage,
@@ -136,6 +136,7 @@ export interface RpgEntryHomeViewProps {
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
onRechargeSuccess?: () => void | Promise<void>;
createTabContent?: ReactNode;
draftTabContent?: ReactNode;
}
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
@@ -161,7 +162,7 @@ const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000;
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type MobileHomeChannel = 'recommend' | 'today' | 'category';
type DiscoverChannel = 'recommend' | 'today' | 'category' | 'ranking';
type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
const COMMUNITY_QR_CODES = [
@@ -177,13 +178,14 @@ const COMMUNITY_QR_CODES = [
},
] as const;
const MOBILE_HOME_CHANNELS: Array<{
id: MobileHomeChannel;
const DISCOVER_CHANNELS: Array<{
id: DiscoverChannel;
label: string;
}> = [
{ id: 'recommend', label: '推荐' },
{ id: 'today', label: '今日游戏' },
{ id: 'category', label: '游戏分类' },
{ id: 'today', label: '今日' },
{ id: 'category', label: '分类' },
{ id: 'ranking', label: '排行' },
];
const PLATFORM_RANKING_TABS: Array<{
@@ -377,6 +379,7 @@ function WorldCard({
feedCardKey,
enableCoverCarousel = false,
isCoverCarouselActive = false,
variant = 'standard',
}: {
entry: PlatformPublicGalleryCard;
onClick: () => void;
@@ -385,6 +388,7 @@ function WorldCard({
feedCardKey?: string;
enableCoverCarousel?: boolean;
isCoverCarouselActive?: boolean;
variant?: 'standard' | 'immersive';
}) {
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry);
const coverSlides = useMemo(() => {
@@ -465,7 +469,7 @@ function WorldCard({
onClick={onClick}
aria-label={cardLabel}
data-mobile-feed-card-key={feedCardKey}
className={`platform-public-work-card platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
className={`platform-public-work-card ${variant === 'immersive' ? 'platform-public-work-card--immersive' : ''} platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
>
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
{coverImage ? (
@@ -724,6 +728,7 @@ function PlatformTabButton({
<button
type="button"
onClick={onClick}
aria-label={label}
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
>
<span className="platform-bottom-nav__button-content">
@@ -874,7 +879,10 @@ function PlatformRankingItem({
const coverImage = resolvePlatformWorldCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = buildPlatformWorldDisplayTags(entry, 2);
const actionLabel = isPuzzleGalleryEntry(entry) ? '试玩' : '进入';
const actionLabel =
isPuzzleGalleryEntry(entry) || isVisualNovelGalleryEntry(entry)
? '试玩'
: '进入';
return (
<button
@@ -936,7 +944,10 @@ function PlatformCategoryGameItem({
describePublicGalleryCardKind(entry),
...tags.filter((tag) => tag !== categoryTag),
].slice(0, 3);
const actionLabel = isPuzzleGalleryEntry(entry) ? '试玩' : '进入';
const actionLabel =
isPuzzleGalleryEntry(entry) || isVisualNovelGalleryEntry(entry)
? '试玩'
: '进入';
const summaryText =
entry.summaryText || entry.subtitle || `${displayName} 正在等待摘要。`;
@@ -1139,6 +1150,8 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
? 'match3d'
: isSquareHoleGalleryEntry(entry)
? 'square-hole'
: isVisualNovelGalleryEntry(entry)
? 'visual-novel'
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
@@ -1249,6 +1262,8 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
? '抓鹅'
: isSquareHoleGalleryEntry(entry)
? '方洞'
: isVisualNovelGalleryEntry(entry)
? '视觉'
: describePlatformThemeLabel(entry.themeMode);
return formatPlatformWorkDisplayTag(kind);
}
@@ -2549,20 +2564,30 @@ function ProfilePlayedWorksModal({
stats,
isLoading,
error,
saveEntries,
saveError,
isResumingSaveWorldKey,
onClose,
onOpenWork,
onResumeSave,
}: {
stats: ProfilePlayStatsResponse | null;
isLoading: boolean;
error: string | null;
saveEntries: ProfileSaveArchiveSummary[];
saveError: string | null;
isResumingSaveWorldKey: string | null;
onClose: () => void;
onOpenWork?: (work: ProfilePlayedWorkSummary) => void;
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
}) {
const playedWorks = stats?.playedWorks ?? [];
const hasArchiveEntries = saveEntries.length > 0;
const hasPlayedWorks = playedWorks.length > 0;
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[34rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[38rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
@@ -2590,6 +2615,11 @@ function ProfilePlayedWorksModal({
{error}
</div>
) : null}
{saveError ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{saveError}
</div>
) : null}
{isLoading ? (
<div className="mt-5 space-y-3">
@@ -2600,43 +2630,73 @@ function ProfilePlayedWorksModal({
/>
))}
</div>
) : playedWorks.length > 0 ? (
<div className="mt-5 space-y-3">
{playedWorks.map((work) => (
<button
type="button"
key={`${work.worldKey}:${work.lastPlayedAt}`}
onClick={() => onOpenWork?.(work)}
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-left transition hover:border-[#ff4056] hover:bg-white"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="line-clamp-1 text-base font-black text-zinc-950">
{work.worldTitle}
</div>
{work.worldSubtitle ? (
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
{work.worldSubtitle}
) : hasArchiveEntries || hasPlayedWorks ? (
<div className="mt-5 space-y-5">
{hasArchiveEntries ? (
<section>
<div className="mb-2 text-xs font-black text-zinc-500">
</div>
<div className="grid gap-3">
{saveEntries.map((entry) => (
<SaveArchiveCard
key={`${entry.worldKey}:played-archive`}
entry={entry}
loading={isResumingSaveWorldKey === entry.worldKey}
onClick={() => onResumeSave(entry)}
/>
))}
</div>
</section>
) : null}
{hasPlayedWorks ? (
<section>
<div className="mb-2 text-xs font-black text-zinc-500">
</div>
<div className="space-y-3">
{playedWorks.map((work) => (
<button
type="button"
key={`${work.worldKey}:${work.lastPlayedAt}`}
onClick={() => onOpenWork?.(work)}
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-left transition hover:border-[#ff4056] hover:bg-white"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="line-clamp-1 text-base font-black text-zinc-950">
{work.worldTitle}
</div>
{work.worldSubtitle ? (
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
{work.worldSubtitle}
</div>
) : null}
</div>
<span className="shrink-0 rounded-full bg-rose-50 px-2.5 py-1 text-[11px] font-bold text-[#ff4056]">
{formatPlayedWorkType(work.worldType)}
</span>
</div>
) : null}
</div>
<span className="shrink-0 rounded-full bg-rose-50 px-2.5 py-1 text-[11px] font-bold text-[#ff4056]">
{formatPlayedWorkType(work.worldType)}
</span>
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
<span className="truncate">
{formatPlayedWorkId(work)}
</span>
<span className="truncate">
{formatSnapshotTime(work.lastPlayedAt)}
</span>
<span className="truncate">
{' '}
{formatCompactPlayTime(
work.lastObservedPlayTimeMs,
)}
</span>
</div>
</button>
))}
</div>
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
<span className="truncate">
{formatPlayedWorkId(work)}
</span>
<span className="truncate">
{formatSnapshotTime(work.lastPlayedAt)}
</span>
<span className="truncate">
{formatCompactPlayTime(work.lastObservedPlayTimeMs)}
</span>
</div>
</button>
))}
</section>
) : null}
</div>
) : (
<div className="mt-5 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-600">
@@ -2681,6 +2741,7 @@ export function RpgEntryHomeView({
onOpenPlayedWork,
onRechargeSuccess,
createTabContent,
draftTabContent,
}: RpgEntryHomeViewProps) {
const authUi = useAuthUi();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
@@ -2733,9 +2794,10 @@ export function RpgEntryHomeView({
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
const [mobileHomeChannel, setMobileHomeChannel] =
useState<MobileHomeChannel>('recommend');
const mobileFeedRef = useRef<HTMLElement | null>(null);
const [discoverChannel, setDiscoverChannel] =
useState<DiscoverChannel>('recommend');
const mobileRecommendFeedRef = useRef<HTMLElement | null>(null);
const mobileDiscoverFeedRef = useRef<HTMLElement | null>(null);
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
string | null
>(null);
@@ -2842,18 +2904,29 @@ export function RpgEntryHomeView({
isReferralCenterInitialized &&
Boolean(referralCenter) &&
referralCenter?.hasRedeemedCode !== true;
const tabIcons = {
home: House,
category: Trophy,
create: Sparkles,
saves: Archive,
profile: UserRound,
} as const;
const tabIcons: Record<
PlatformHomeTab,
ComponentType<{ className?: string }>
> = isAuthenticated
? {
home: Sparkles,
category: Compass,
create: Plus,
saves: Pencil,
profile: UserRound,
}
: {
home: Gamepad2,
category: Compass,
create: Sparkles,
saves: Pencil,
profile: UserRound,
};
const tabLabels = {
home: '首页',
category: '排行',
home: '推荐',
category: '发现',
create: '创作',
saves: '存档',
saves: '草稿',
profile: '我的',
} as const;
@@ -3398,11 +3471,19 @@ export function RpgEntryHomeView({
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2);
const mobileFeedEntries = useMemo(() => {
const recommendedFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredShelf, ...latestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, latestEntries]);
const discoverFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
const sourceEntries =
mobileHomeChannel === 'recommend'
? [...featuredShelf, ...latestEntries]
discoverChannel === 'recommend'
? recommendedFeedEntries
: filterTodayPublishedEntries(latestEntries);
sourceEntries.forEach((entry) => {
@@ -3410,16 +3491,22 @@ export function RpgEntryHomeView({
});
return Array.from(entryMap.values());
}, [featuredShelf, latestEntries, mobileHomeChannel]);
}, [discoverChannel, latestEntries, recommendedFeedEntries]);
const mobileFeedCarouselEnabled =
!isDesktopLayout && activeTab === 'home' && mobileHomeChannel !== 'category';
!isDesktopLayout &&
((activeTab === 'home' && recommendedFeedEntries.length > 0) ||
(activeTab === 'category' &&
(discoverChannel === 'recommend' || discoverChannel === 'today')));
useEffect(() => {
if (!mobileFeedCarouselEnabled) {
setMobileCenteredCardKey(null);
return undefined;
}
const feedElement = mobileFeedRef.current;
const feedElement =
activeTab === 'home'
? mobileRecommendFeedRef.current
: mobileDiscoverFeedRef.current;
const scrollElement = feedElement?.closest('.platform-tab-panel');
if (!feedElement || !scrollElement) {
setMobileCenteredCardKey(null);
@@ -3490,7 +3577,13 @@ export function RpgEntryHomeView({
scrollElement.removeEventListener('scroll', scheduleUpdate);
window.removeEventListener('resize', scheduleUpdate);
};
}, [mobileFeedCarouselEnabled, mobileFeedEntries, mobileHomeChannel]);
}, [
discoverChannel,
discoverFeedEntries,
activeTab,
mobileFeedCarouselEnabled,
recommendedFeedEntries,
]);
const activeRankingConfig = PLATFORM_RANKING_TABS.find(
(tab) => tab.id === activeRankingTab,
) as (typeof PLATFORM_RANKING_TABS)[number];
@@ -3499,9 +3592,6 @@ export function RpgEntryHomeView({
buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30),
[activeRankingTab, publicEntries],
);
const categoryPageClass = isDesktopLayout
? DESKTOP_PAGE_STAGE_CLASS
: MOBILE_PAGE_STAGE_CLASS;
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
const openLeadPublicEntry = () => {
if (leadPublicEntry) {
@@ -3512,7 +3602,101 @@ export function RpgEntryHomeView({
onTabChange('category');
};
const mobileHomeContent: ReactNode = (
const mobileRankingPanel: ReactNode = (
<section
className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`}
>
<div
className="platform-ranking-tabs flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide"
role="tablist"
aria-label="作品排行"
>
{PLATFORM_RANKING_TABS.map((tab) => {
const active = tab.id === activeRankingTab;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => setActiveRankingTab(tab.id)}
className={`platform-ranking-tab shrink-0 ${active ? 'platform-ranking-tab--active' : ''}`}
>
{tab.label}
</button>
);
})}
</div>
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : rankingEntries.length > 0 ? (
<div className="mt-3 grid min-w-0 gap-2.5">
{rankingEntries.map((entry, index) => (
<PlatformRankingItem
key={`${buildPublicGalleryCardKey(entry)}:ranking:${activeRankingTab}`}
entry={entry}
rank={index + 1}
metricLabel={activeRankingConfig.metricLabel}
metricValue={getPlatformRankingMetricValue(
entry,
activeRankingTab,
)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text={activeRankingConfig.emptyText} />
)}
</section>
);
const mobileRecommendContent: ReactNode = (
<div
className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage platform-mobile-recommend-stage`}
>
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
<section
ref={mobileRecommendFeedRef}
className="platform-mobile-home-feed platform-mobile-recommend-feed"
>
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : recommendedFeedEntries.length > 0 ? (
<div className="grid min-w-0 gap-4">
{recommendedFeedEntries.map((entry) => {
const cardKey = buildPublicGalleryCardKey(entry);
return (
<WorldCard
key={`${cardKey}:mobile-recommend`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
feedCardKey={cardKey}
enableCoverCarousel={mobileFeedCarouselEnabled}
isCoverCarouselActive={mobileCenteredCardKey === cardKey}
variant="immersive"
/>
);
})}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
)}
</section>
</div>
);
const mobileDiscoverContent: ReactNode = (
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
<PublicCodeSearchBar
value={mobileSearchKeyword}
@@ -3531,13 +3715,13 @@ export function RpgEntryHomeView({
) : (
<>
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
{MOBILE_HOME_CHANNELS.map((channel) => {
const active = mobileHomeChannel === channel.id;
{DISCOVER_CHANNELS.map((channel) => {
const active = discoverChannel === channel.id;
return (
<button
key={channel.id}
type="button"
onClick={() => setMobileHomeChannel(channel.id)}
onClick={() => setDiscoverChannel(channel.id)}
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
>
{channel.label}
@@ -3552,7 +3736,9 @@ export function RpgEntryHomeView({
</div>
) : null}
{mobileHomeChannel === 'category' ? (
{discoverChannel === 'ranking' ? (
mobileRankingPanel
) : discoverChannel === 'category' ? (
<section className="platform-category-list-panel">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
@@ -3609,17 +3795,20 @@ export function RpgEntryHomeView({
)}
</section>
) : (
<section ref={mobileFeedRef} className="platform-mobile-home-feed">
<section
ref={mobileDiscoverFeedRef}
className="platform-mobile-home-feed"
>
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : mobileFeedEntries.length > 0 ? (
) : discoverFeedEntries.length > 0 ? (
<div className="grid min-w-0 gap-3">
{mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => {
{discoverFeedEntries.map((entry: PlatformPublicGalleryCard) => {
const cardKey = buildPublicGalleryCardKey(entry);
return (
<WorldCard
key={`${cardKey}:mobile-feed:${mobileHomeChannel}`}
key={`${cardKey}:mobile-feed:${discoverChannel}`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
@@ -3641,60 +3830,13 @@ export function RpgEntryHomeView({
</div>
);
const categoryContent: ReactNode = (
<div className={categoryPageClass}>
<section
className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`}
>
<div
className="platform-ranking-tabs flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide"
role="tablist"
aria-label="作品排行"
>
{PLATFORM_RANKING_TABS.map((tab) => {
const active = tab.id === activeRankingTab;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => setActiveRankingTab(tab.id)}
className={`platform-ranking-tab shrink-0 ${active ? 'platform-ranking-tab--active' : ''}`}
>
{tab.label}
</button>
);
})}
</div>
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : rankingEntries.length > 0 ? (
<div className="mt-3 grid min-w-0 gap-2.5">
{rankingEntries.map((entry, index) => (
<PlatformRankingItem
key={`${buildPublicGalleryCardKey(entry)}:ranking:${activeRankingTab}`}
entry={entry}
rank={index + 1}
metricLabel={activeRankingConfig.metricLabel}
metricValue={getPlatformRankingMetricValue(
entry,
activeRankingTab,
)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text={activeRankingConfig.emptyText} />
)}
</section>
</div>
const categoryContent: ReactNode = isDesktopLayout ? (
<div className={DESKTOP_PAGE_STAGE_CLASS}>{mobileRankingPanel}</div>
) : (
mobileDiscoverContent
);
const createContent: ReactNode = createTabContent ?? (
const fallbackCreateStartContent: ReactNode = (
<div className={MOBILE_PAGE_STAGE_CLASS}>
<button
type="button"
@@ -3720,7 +3862,11 @@ export function RpgEntryHomeView({
</div>
</div>
</button>
</div>
);
const fallbackDraftContent: ReactNode = (
<div className={MOBILE_PAGE_STAGE_CLASS}>
<section>
<SectionHeader title="我的创作" detail="草稿与已发布" />
{isLoadingPlatform ? (
@@ -3756,53 +3902,10 @@ export function RpgEntryHomeView({
</div>
);
const savesContent: ReactNode = (
<div className={MOBILE_PAGE_STAGE_CLASS}>
{authUi?.user ? (
<>
{saveError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{saveError}
</div>
) : null}
const createContent: ReactNode = createTabContent ?? fallbackCreateStartContent;
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<SectionHeader title="全部存档" detail="最近更新时间排序" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取存档..." />
) : saveEntries.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2">
{saveEntries.map((entry) => (
<SaveArchiveCard
key={entry.worldKey}
entry={entry}
loading={isResumingSaveWorldKey === entry.worldKey}
onClick={() => onResumeSave(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="还没有可恢复的存档,去首页开始一段新的游玩吧。" />
)}
</section>
</>
) : (
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<div className="platform-subpanel rounded-[1.35rem] px-4 py-4">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
onClick={() => authUi?.openLoginModal()}
className="platform-button platform-button--primary mt-4"
>
</button>
</div>
</section>
)}
</div>
const savesContent: ReactNode = (
draftTabContent ?? fallbackDraftContent
);
const profileContent: ReactNode = (
@@ -4326,7 +4429,7 @@ export function RpgEntryHomeView({
);
const tabContentById = {
home: isDesktopLayout ? desktopHomeContent : mobileHomeContent,
home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent,
category: categoryContent,
create: createContent,
saves: savesContent,
@@ -4399,7 +4502,7 @@ export function RpgEntryHomeView({
if (!isDesktopLayout) {
return (
<div className="platform-mobile-entry-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
<RpgEntryBrandLogo />
{!isAuthenticated ? (
<button
@@ -4410,19 +4513,23 @@ export function RpgEntryHomeView({
<LogIn className="h-3.5 w-3.5" />
</button>
) : null}
) : (
<button
type="button"
onClick={openUserSurface}
className="platform-icon-button platform-mobile-topbar__action shrink-0"
aria-label="通知与账户"
>
<Bell className="h-4 w-4" />
</button>
)}
</div>
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
<div
className="platform-mobile-bottom-dock mt-3 min-w-0 shrink-0 border-t pt-2"
style={{
borderColor: 'var(--platform-line-soft)',
}}
>
<div className="platform-mobile-bottom-dock mt-3 min-w-0 shrink-0">
<div
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
@@ -4472,8 +4579,12 @@ export function RpgEntryHomeView({
stats={profilePlayStats}
isLoading={isProfilePlayStatsLoading}
error={profilePlayStatsError}
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={onCloseProfilePlayStats ?? (() => undefined)}
onOpenWork={onOpenPlayedWork}
onResumeSave={onResumeSave}
/>
) : null}
{isWalletLedgerOpen ? (
@@ -4606,8 +4717,12 @@ export function RpgEntryHomeView({
stats={profilePlayStats}
isLoading={isProfilePlayStatsLoading}
error={profilePlayStatsError}
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={onCloseProfilePlayStats ?? (() => undefined)}
onOpenWork={onOpenPlayedWork}
onResumeSave={onResumeSave}
/>
) : null}
{isWalletLedgerOpen ? (

View File

@@ -9,6 +9,9 @@ import type { CustomWorldProfile } from '../../types';
export function resolveRpgEntryErrorMessage(error: unknown, fallback: string) {
if (isTimeoutError(error)) {
if (//u.test(fallback)) {
return '开启智能创作工作区超时,请确认运行时后端已启动后重试。';
}
if (//u.test(fallback)) {
return '开启拼图创作工作台超时,请确认运行时后端已启动后重试。';
}

View File

@@ -2,9 +2,13 @@ import { expect, test } from 'vitest';
import {
buildPuzzleWorkCoverSlides,
buildPlatformWorldDisplayTags,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
isVisualNovelGalleryEntry,
mapVisualNovelWorkToPlatformGalleryCard,
resolvePlatformPublicWorkCode,
} from './rpgEntryWorldPresentation';
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
@@ -106,3 +110,25 @@ test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
},
]);
});
test('maps visual novel work to platform gallery card with VN public code', () => {
const card = mapVisualNovelWorkToPlatformGalleryCard({
runtimeKind: 'visual-novel',
profileId: 'vn-profile-demo-12345678',
ownerUserId: 'user-1',
title: '雨夜终章',
description: '失踪列车上的选择。',
coverImageSrc: '/vn-cover.png',
tags: ['悬疑', '列车'],
publishStatus: 'published',
publishReady: true,
playCount: 7,
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: '2026-05-07T00:00:00.000Z',
});
expect(isVisualNovelGalleryEntry(card)).toBe(true);
expect(card.publicWorkCode).toBe('VN-12345678');
expect(resolvePlatformPublicWorkCode(card)).toBe('VN-12345678');
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['悬疑', '列车']);
});

View File

@@ -2,6 +2,7 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
@@ -18,6 +19,7 @@ import {
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
buildSquareHolePublicWorkCode,
buildVisualNovelPublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
@@ -30,7 +32,8 @@ export type PlatformWorldCardLike =
| PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard;
| PlatformPuzzleGalleryCard
| PlatformVisualNovelGalleryCard;
export type PlatformPuzzleGalleryCard = {
sourceType: 'puzzle';
@@ -132,12 +135,35 @@ export type PlatformSquareHoleGalleryCard = {
updatedAt: string;
};
export type PlatformVisualNovelGalleryCard = {
sourceType: 'visual-novel';
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
};
export type PlatformPublicGalleryCard =
| CustomWorldGalleryCard
| PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard;
| PlatformPuzzleGalleryCard
| PlatformVisualNovelGalleryCard;
export function isLibraryWorldEntry(
entry: PlatformWorldCardLike,
@@ -169,6 +195,12 @@ export function isSquareHoleGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'square-hole';
}
export function isVisualNovelGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformVisualNovelGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'visual-novel';
}
export function mapPuzzleWorkToPlatformGalleryCard(
work: PuzzleWorkSummary,
): PlatformPuzzleGalleryCard {
@@ -280,6 +312,32 @@ export function mapBigFishWorkToPlatformGalleryCard(
};
}
export function mapVisualNovelWorkToPlatformGalleryCard(
work: VisualNovelWorkSummary,
): PlatformVisualNovelGalleryCard {
return {
sourceType: 'visual-novel',
workId: work.profileId,
profileId: work.profileId,
sourceSessionId: null,
publicWorkCode: buildVisualNovelPublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: '玩家',
worldName: work.title,
subtitle: '视觉小说模板',
summaryText: work.description,
coverImageSrc: work.coverImageSrc,
themeTags: work.tags.length > 0 ? work.tags : ['视觉小说'],
playCount: work.playCount ?? 0,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: 0,
visibility: 'published',
publishedAt: work.publishedAt,
updatedAt: work.updatedAt,
};
}
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
return {
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
@@ -452,6 +510,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
: ['方洞'];
}
if (isVisualNovelGalleryEntry(entry)) {
return entry.themeTags.length > 0
? entry.themeTags.slice(0, 3)
: ['视觉小说'];
}
if (!isLibraryWorldEntry(entry)) {
return [
describePlatformThemeLabel(entry.themeMode),
@@ -534,6 +598,10 @@ export function resolvePlatformPublicWorkCode(
return entry.publicWorkCode;
}
if (isVisualNovelGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode;
}

View File

@@ -6,12 +6,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import { WorldType, type CustomWorldProfile } from '../../types';
import {
executeRpgCreationAction,
getRpgCreationOperation,
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import { type CustomWorldProfile,WorldType } from '../../types';
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
@@ -173,6 +173,7 @@ describe('RPG Agent 草稿恢复', () => {
setCustomWorldResultViewSource,
setSelectionStage,
setPlatformTabToCreate: vi.fn(),
setPlatformTabToDraft: vi.fn(),
setPlatformError: vi.fn(),
appendBrowseHistoryEntry: vi.fn(async () => {}),
refreshCustomWorldWorks: vi.fn(async () => []),

View File

@@ -329,14 +329,8 @@ export function useRpgEntryBootstrap(
!hasInitialAgentSession &&
!hasExplicitPlatformTabSelectionRef.current
) {
setPlatformTabState(
isAuthenticated &&
canReadProtectedData &&
saveArchivesResult.status === 'fulfilled' &&
saveArchivesResult.value.length > 0
? 'saves'
: 'home',
);
// 中文注释saves 现在承载草稿列表,存档入口已并入“我的-玩过”,默认仍回到推荐页。
setPlatformTabState('home');
}
} finally {
if (isActive) {

View File

@@ -54,6 +54,7 @@ type UseRpgEntryLibraryDetailParams = {
setCustomWorldResultViewSource: (source: CustomWorldResultViewSource) => void;
setSelectionStage: (stage: SelectionStage) => void;
setPlatformTabToCreate: () => void;
setPlatformTabToDraft: () => void;
setPlatformError: (error: string | null) => void;
appendBrowseHistoryEntry: (
entry: PlatformBrowseHistoryWriteEntry,
@@ -106,6 +107,7 @@ export function useRpgEntryLibraryDetail(
setCustomWorldResultViewSource,
setSelectionStage,
setPlatformTabToCreate,
setPlatformTabToDraft,
setPlatformError,
appendBrowseHistoryEntry,
refreshCustomWorldWorks,
@@ -347,7 +349,7 @@ export function useRpgEntryLibraryDetail(
setCustomWorldResultViewSource(null);
await refreshCustomWorldWorks().catch(() => []);
setPlatformError(
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
'这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。',
);
} else {
setPlatformError(
@@ -355,7 +357,7 @@ export function useRpgEntryLibraryDetail(
);
}
setPlatformTabToCreate();
setPlatformTabToDraft();
setSelectionStage('platform');
return;
}
@@ -405,6 +407,7 @@ export function useRpgEntryLibraryDetail(
setGeneratedCustomWorldProfile,
setPlatformError,
setPlatformTabToCreate,
setPlatformTabToDraft,
setSavedCustomWorldEntries,
setSelectionStage,
suppressAgentDraftResultAutoOpen,