Merge remote-tracking branch 'origin/master' into hermes/hermes-1e775b03
Some checks failed
CI / verify (pull_request) Has been cancelled
Some checks failed
CI / verify (pull_request) Has been cancelled
# Conflicts: # docs/technical/README.md # src/components/custom-world-home/CustomWorldCreationHub.tsx # src/components/custom-world-home/creationWorkShelf.ts # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
@@ -11,18 +11,14 @@ import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
Match3DAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type {
|
||||
PuzzleAnchorPack,
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||
@@ -31,6 +27,10 @@ import type {
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
readPublicWorkCodeFromLocationSearch,
|
||||
resolveSelectionStageFromPath,
|
||||
} from '../../routing/appPageRoutes';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
submitBigFishInput,
|
||||
} from '../../services/big-fish-runtime';
|
||||
import { listBigFishWorks } from '../../services/big-fish-works';
|
||||
import {
|
||||
type CreationEntryConfig,
|
||||
fetchCreationEntryConfig,
|
||||
} from '../../services/creationEntryConfigService';
|
||||
import {
|
||||
cancelCreativeAgentSession,
|
||||
confirmCreativePuzzleTemplate,
|
||||
@@ -152,7 +156,7 @@ async function clickFirstButtonByName(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: string | RegExp,
|
||||
) {
|
||||
const buttons = screen.getAllByRole('button', { name });
|
||||
const buttons = await screen.findAllByRole('button', { name });
|
||||
await user.click(buttons[0]!);
|
||||
}
|
||||
|
||||
@@ -166,9 +170,7 @@ async function clickFirstAsyncButtonByName(
|
||||
|
||||
async function openCreateTemplateHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(
|
||||
await screen.findByRole('tablist', { name: '选择模板' }),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '拼图' })).toBeTruthy();
|
||||
expect(screen.getByText('拼图工作区:missing-session')).toBeTruthy();
|
||||
}
|
||||
@@ -191,14 +193,14 @@ async function openDiscoverHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
expect(panel.getAttribute('aria-hidden')).toBe('false');
|
||||
});
|
||||
expect(
|
||||
await within(panel).findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
),
|
||||
await within(panel).findByPlaceholderText('搜索作品号、名称、作者、描述'),
|
||||
).toBeTruthy();
|
||||
return panel;
|
||||
}
|
||||
|
||||
async function openProfilePlayedWorks(user: ReturnType<typeof userEvent.setup>) {
|
||||
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();
|
||||
@@ -228,6 +230,87 @@ function getPlatformTabPanel(tab: string) {
|
||||
return panel;
|
||||
}
|
||||
|
||||
const testCreationEntryConfig = {
|
||||
startCard: {
|
||||
title: '新建作品',
|
||||
description: '选择模板后进入对应的创作表单。',
|
||||
idleBadge: '模板 Tab',
|
||||
busyBadge: '正在开启',
|
||||
},
|
||||
typeModal: {
|
||||
title: '选择创作类型',
|
||||
description: '先选玩法类型,再进入对应创作工作台。',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '拼图关卡创作',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 30,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '抓大鹅',
|
||||
subtitle: '3D 消除关卡',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/match3d.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 40,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'square-hole',
|
||||
title: '方洞挑战',
|
||||
subtitle: '形状投放挑战',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/square-hole.webp',
|
||||
visible: false,
|
||||
open: true,
|
||||
sortOrder: 50,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '分支叙事体验',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/visual-novel.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 60,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
title: 'AIRP',
|
||||
subtitle: '敬请期待',
|
||||
badge: '即将开放',
|
||||
imageSrc: '/creation-type-references/airp.webp',
|
||||
visible: true,
|
||||
open: false,
|
||||
sortOrder: 70,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'creative-agent',
|
||||
title: '智能创作',
|
||||
subtitle: '对话式创作实验',
|
||||
badge: '内测',
|
||||
imageSrc: '/creation-type-references/creative-agent.webp',
|
||||
visible: false,
|
||||
open: true,
|
||||
sortOrder: 80,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
} satisfies CreationEntryConfig;
|
||||
|
||||
const rpgCreationServiceMocks = vi.hoisted(() => ({
|
||||
createRpgCreationSession: vi.fn(),
|
||||
deleteRpgCreationAgentSession: vi.fn(),
|
||||
@@ -264,7 +347,8 @@ vi.mock('../../services/rpg-creation/index', () => ({
|
||||
|
||||
vi.mock('../../services/rpg-entry', () => ({
|
||||
clearRpgProfileBrowseHistory: vi.fn(),
|
||||
deleteRpgEntryWorldProfile: rpgEntryLibraryServiceMocks.deleteRpgEntryWorldProfile,
|
||||
deleteRpgEntryWorldProfile:
|
||||
rpgEntryLibraryServiceMocks.deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail:
|
||||
rpgEntryLibraryServiceMocks.getRpgEntryWorldGalleryDetail,
|
||||
getRpgProfileDashboard: vi.fn(),
|
||||
@@ -287,6 +371,10 @@ vi.mock('../../services/rpg-entry', () => ({
|
||||
upsertRpgProfileBrowseHistory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/creationEntryConfigService', () => ({
|
||||
fetchCreationEntryConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-works', () => ({
|
||||
listPuzzleWorks: vi.fn(),
|
||||
}));
|
||||
@@ -347,6 +435,7 @@ vi.mock('../../services/match3d-works', () => ({
|
||||
getMatch3DWorkDetail: vi.fn(),
|
||||
listMatch3DGallery: vi.fn(),
|
||||
listMatch3DWorks: vi.fn(),
|
||||
updateMatch3DGeneratedItemAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
const match3dRuntimeServiceMocks = vi.hoisted(() => ({
|
||||
@@ -633,13 +722,22 @@ vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
|
||||
vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
|
||||
Match3DRuntimeShell: ({
|
||||
run,
|
||||
generatedItemAssets = [],
|
||||
onBack,
|
||||
}: {
|
||||
run: Match3DRunSnapshot | null;
|
||||
generatedItemAssets?: Match3DWorkSummary['generatedItemAssets'];
|
||||
onBack: () => void;
|
||||
}) => (
|
||||
<div className="match3d-runtime-shell-mock">
|
||||
<div>抓大鹅运行态:{run?.runId ?? 'missing-run'}</div>
|
||||
<div data-testid="match3d-runtime-generated-model-count">
|
||||
{
|
||||
generatedItemAssets.filter(
|
||||
(asset) => asset.modelSrc?.trim() || asset.modelObjectKey?.trim(),
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
@@ -780,7 +878,9 @@ function buildMockCreativeAgentSession(
|
||||
}
|
||||
|
||||
function buildMockSquareHoleAgentSession(
|
||||
overrides: Partial<Parameters<typeof buildMockSquareHoleAgentSessionImpl>[0]> = {},
|
||||
overrides: Partial<
|
||||
Parameters<typeof buildMockSquareHoleAgentSessionImpl>[0]
|
||||
> = {},
|
||||
) {
|
||||
return buildMockSquareHoleAgentSessionImpl(overrides);
|
||||
}
|
||||
@@ -789,7 +889,13 @@ function buildMockSquareHoleAgentSessionImpl(
|
||||
overrides: Partial<{
|
||||
sessionId: string;
|
||||
stage: string;
|
||||
messages: Array<{ id: string; role: string; kind: string; text: string; createdAt: string }>;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
kind: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
updatedAt: string;
|
||||
}> = {},
|
||||
) {
|
||||
@@ -1445,15 +1551,17 @@ function TestWrapper({
|
||||
onSelectWorld?: RpgEntryFlowShellProps['handleCustomWorldSelect'];
|
||||
} = {}) {
|
||||
const [selectionStage, setSelectionStage] = useState<SelectionStage>(() =>
|
||||
window.location.pathname === '/creation/rpg/agent'
|
||||
? 'agent-workspace'
|
||||
: 'platform',
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
);
|
||||
const [initialPublicWorkCode] = useState(() =>
|
||||
readPublicWorkCodeFromLocationSearch(window.location.search),
|
||||
);
|
||||
|
||||
const content = (
|
||||
<RpgEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
initialPublicWorkCode={initialPublicWorkCode}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
handleContinueGame={onContinueGame ?? (() => {})}
|
||||
@@ -1500,6 +1608,9 @@ beforeEach(() => {
|
||||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||
'1',
|
||||
);
|
||||
vi.mocked(fetchCreationEntryConfig).mockResolvedValue(
|
||||
testCreationEntryConfig,
|
||||
);
|
||||
vi.mocked(getProfileDashboard).mockResolvedValue({
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
@@ -2117,12 +2228,8 @@ beforeEach(() => {
|
||||
vi.mocked(deleteMatch3DWork).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockRejectedValue(
|
||||
new Error('未启动抓大鹅运行态'),
|
||||
);
|
||||
vi.mocked(clickMatch3DItem).mockRejectedValue(
|
||||
new Error('未执行抓大鹅点击'),
|
||||
);
|
||||
vi.mocked(startMatch3DRun).mockRejectedValue(new Error('未启动抓大鹅运行态'));
|
||||
vi.mocked(clickMatch3DItem).mockRejectedValue(new Error('未执行抓大鹅点击'));
|
||||
vi.mocked(restartMatch3DRun).mockRejectedValue(
|
||||
new Error('未重新开始抓大鹅运行态'),
|
||||
);
|
||||
@@ -2436,9 +2543,6 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
|
||||
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');
|
||||
@@ -2457,6 +2561,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
|
||||
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(screen.getByRole('tab', { name: /抓大鹅/u })).toBeTruthy();
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
@@ -2474,13 +2579,52 @@ test('create tab switches match3d into the embedded entry form', async () => {
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '抓大鹅' }).getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
await screen.findByText('抓大鹅工作区:missing-session'),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByText('抓大鹅工作区:missing-session')).toBeTruthy();
|
||||
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('running match3d form generation can return to draft tab and reopen progress', async () => {
|
||||
const user = userEvent.setup();
|
||||
const runningSession = buildMockMatch3DAgentSession({
|
||||
draft: null,
|
||||
stage: 'collecting_config',
|
||||
});
|
||||
let resolveCompile!: (value: {
|
||||
session: Match3DAgentSessionSnapshot;
|
||||
}) => void;
|
||||
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
|
||||
session: runningSession,
|
||||
});
|
||||
vi.mocked(match3dCreationClient.executeAction).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveCompile = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
|
||||
await user.click(screen.getByRole('button', { name: '生成抓大鹅草稿' }));
|
||||
|
||||
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
|
||||
expect(await screen.findByText('抓大鹅草稿')).toBeTruthy();
|
||||
expect(screen.getAllByText('生成中').length).toBeGreaterThan(0);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /继续创作《抓大鹅草稿》/u }),
|
||||
);
|
||||
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveCompile({ session: buildMockMatch3DAgentSession() });
|
||||
});
|
||||
});
|
||||
|
||||
test('embedded puzzle form routes through requireAuth while logged out', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
@@ -3130,9 +3274,8 @@ test('logged out public detail gates big fish start before local runtime', async
|
||||
);
|
||||
|
||||
await openDiscoverHub(user);
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'BF-NPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
@@ -3177,9 +3320,8 @@ test('public code search blocks edutainment work when entry switch is disabled',
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'PZ-TMENT1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
@@ -3325,9 +3467,9 @@ test('published puzzle works appear on home and mobile game category channel', a
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
|
||||
const discoverPanel = getPlatformTabPanel('category');
|
||||
expect(
|
||||
within(discoverPanel).getAllByText('星桥机关').length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(within(discoverPanel).getAllByText('星桥机关').length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
within(discoverPanel).getAllByRole('button', { name: /机关/u }).length,
|
||||
).toBeGreaterThan(0);
|
||||
@@ -3375,6 +3517,74 @@ test('home recommendation starts embedded puzzle without global auth reset on lo
|
||||
});
|
||||
});
|
||||
|
||||
test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => {
|
||||
const match3dCard: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-card-1',
|
||||
profileId: 'match3d-profile-card-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'match3d-session-card-1',
|
||||
gameName: '果园抓大鹅',
|
||||
themeText: '果园',
|
||||
summary: '消除果园模型。',
|
||||
tags: ['果园', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 3,
|
||||
difficulty: 5,
|
||||
publicationStatus: 'published',
|
||||
playCount: 3,
|
||||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishReady: true,
|
||||
generatedItemAssets: [],
|
||||
};
|
||||
const match3dDetail: Match3DWorkSummary = {
|
||||
...match3dCard,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: 'task-strawberry',
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(listMatch3DGallery).mockResolvedValue({
|
||||
items: [match3dCard],
|
||||
});
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: match3dDetail,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dCard.profileId),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-card-1',
|
||||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-generated-model-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
});
|
||||
|
||||
test('home recommendation surfaces start failure instead of staying in loading state', async () => {
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
@@ -3657,9 +3867,7 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy
|
||||
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
|
||||
expect(createCreativeAgentSession).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'当前登录状态已失效,请重新登录后继续。',
|
||||
),
|
||||
await screen.findByText('当前登录状态已失效,请重新登录后继续。'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
|
||||
});
|
||||
@@ -3764,10 +3972,10 @@ test('puzzle draft result back button returns to creation hub', async () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('tablist', { name: '选择模板' }),
|
||||
screen.getByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('雨夜里有一只会发光的猫站在遗迹台阶上。')).toBeTruthy();
|
||||
expect(screen.queryByText('拼图结果页')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -3839,9 +4047,7 @@ test('first launch puzzle onboarding can be skipped from top right', async () =>
|
||||
expect(screen.queryByText('待定待定待定')).toBeNull();
|
||||
});
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||
),
|
||||
window.localStorage.getItem('genarrative.puzzle-onboarding.first-visit.v1'),
|
||||
).toBe('1');
|
||||
expect(generatePuzzleOnboardingWork).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -3885,9 +4091,7 @@ test('first launch puzzle onboarding falls back to local run when generate route
|
||||
expect(screen.queryByText('资源不存在')).toBeNull();
|
||||
expect(startPuzzleRun).not.toHaveBeenCalled();
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||
),
|
||||
window.localStorage.getItem('genarrative.puzzle-onboarding.first-visit.v1'),
|
||||
).toBe('1');
|
||||
});
|
||||
|
||||
@@ -4011,9 +4215,8 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
@@ -4075,9 +4278,11 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
|
||||
);
|
||||
});
|
||||
expect(
|
||||
(await screen.findAllByText('星桥机关', undefined, {
|
||||
timeout: 3000,
|
||||
})).length,
|
||||
(
|
||||
await screen.findAllByText('星桥机关', undefined, {
|
||||
timeout: 3000,
|
||||
})
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -4122,10 +4327,7 @@ test('formal puzzle similar work keeps current run level progression', async ()
|
||||
entryProfileId: clearedThirdLevel.entryProfileId,
|
||||
currentLevelIndex: 4,
|
||||
currentGridSize: 5 as const,
|
||||
playedProfileIds: [
|
||||
'puzzle-profile-public-1',
|
||||
'puzzle-profile-similar-2',
|
||||
],
|
||||
playedProfileIds: ['puzzle-profile-public-1', 'puzzle-profile-similar-2'],
|
||||
currentLevel: {
|
||||
...buildMockPuzzleRun('puzzle-profile-similar-2', '风塔试炼')
|
||||
.currentLevel!,
|
||||
@@ -4210,9 +4412,8 @@ test('formal puzzle similar work keeps current run level progression', async ()
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
@@ -4313,9 +4514,8 @@ test('first puzzle runtime back click can open remix result page', async () => {
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||||
@@ -4367,9 +4567,8 @@ test('public code search opens a published puzzle by PZ code', async () => {
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
@@ -4432,6 +4631,39 @@ test('missing puzzle public detail returns to platform home', async () => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('direct missing public work detail alert returns to platform home', async () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/works/detail?work=PZ-7A7B18D9',
|
||||
);
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect(await screen.findByText('正在读取作品详情...')).toBeTruthy();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('作品不存在或已下架,将返回首页。');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/');
|
||||
});
|
||||
expect(window.location.search).toBe('');
|
||||
await waitFor(() => {
|
||||
expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe(
|
||||
'false',
|
||||
);
|
||||
});
|
||||
expect(screen.queryByText('详情')).toBeNull();
|
||||
expect(screen.queryByText('未找到拼图作品。')).toBeNull();
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('public code search opens a published big fish work by BF code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bigFishWork: BigFishWorkSummary = {
|
||||
@@ -4459,9 +4691,8 @@ 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(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'BF-NPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
@@ -4469,9 +4700,7 @@ test('public code search opens a published big fish work by BF code', async () =
|
||||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startBigFishRun).toHaveBeenCalledWith(
|
||||
'big-fish-session-public-1',
|
||||
);
|
||||
expect(startBigFishRun).toHaveBeenCalledWith('big-fish-session-public-1');
|
||||
});
|
||||
expect(await screen.findByText('Lv.1/8 · 进行中')).toBeTruthy();
|
||||
expect(getBigFishCreationSession).not.toHaveBeenCalledWith(
|
||||
@@ -4500,11 +4729,15 @@ test('public code search opens a published Match3D work by M3 code and starts ru
|
||||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishReady: true,
|
||||
generatedItemAssets: [],
|
||||
};
|
||||
|
||||
vi.mocked(listMatch3DGallery).mockResolvedValue({
|
||||
items: [match3dWork],
|
||||
});
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: match3dWork,
|
||||
});
|
||||
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dWork.profileId),
|
||||
});
|
||||
@@ -4512,9 +4745,8 @@ 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(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'M3-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
@@ -4528,11 +4760,81 @@ test('public code search opens a published Match3D work by M3 code and starts ru
|
||||
);
|
||||
});
|
||||
expect(
|
||||
await screen.findByText('抓大鹅运行态:match3d-run-match3d-profile-public-1'),
|
||||
await screen.findByText(
|
||||
'抓大鹅运行态:match3d-run-match3d-profile-public-1',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('published Match3D runtime receives persisted generated models', async () => {
|
||||
const user = userEvent.setup();
|
||||
const match3dWork: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-model-1',
|
||||
profileId: 'match3d-profile-model-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'match3d-session-model-1',
|
||||
gameName: '果园抓大鹅',
|
||||
themeText: '果园',
|
||||
summary: '消除果园里的水果模型。',
|
||||
tags: ['果园', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 3,
|
||||
difficulty: 5,
|
||||
publicationStatus: 'published',
|
||||
playCount: 3,
|
||||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishReady: true,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: 'task-strawberry',
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(listMatch3DGallery).mockResolvedValue({
|
||||
items: [match3dWork],
|
||||
});
|
||||
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dWork.profileId),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'M3-LEMODEL1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-public-1',
|
||||
);
|
||||
});
|
||||
expect(
|
||||
await screen.findByTestId('match3d-runtime-generated-model-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
|
||||
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -4597,6 +4899,41 @@ test('starting draft generation leaves the agent workspace and shows the generat
|
||||
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
|
||||
});
|
||||
|
||||
test('running custom world draft generation can return to creation center with shelf badge', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||||
buildExistingRpgDraftWork({
|
||||
stage: 'clarifying',
|
||||
stageLabel: '补齐关键锚点',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
}),
|
||||
]);
|
||||
vi.mocked(getRpgCreationResultView).mockResolvedValue({
|
||||
...buildResultViewForSession(mockSession),
|
||||
targetStage: 'agent-workspace',
|
||||
resultViewSource: null,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openExistingRpgDraft(user, /继续创作/u);
|
||||
expect(
|
||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '开始生成草稿' }));
|
||||
|
||||
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
|
||||
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
|
||||
await openDraftHub(user);
|
||||
|
||||
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
|
||||
expect(screen.getAllByText('生成中').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('refresh restores running draft generation progress instead of agent workspace', async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
@@ -5041,11 +5378,7 @@ test('agent draft result test button enters current draft without publish gate',
|
||||
await openExistingRpgDraft(user, /继续完善/u);
|
||||
await screen.findByText('世界档案', {}, { timeout: 5000 });
|
||||
await user.click(
|
||||
await screen.findByRole(
|
||||
'button',
|
||||
{ name: '作品测试' },
|
||||
{ timeout: 5000 },
|
||||
),
|
||||
await screen.findByRole('button', { name: '作品测试' }, { timeout: 5000 }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -5683,9 +6016,7 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(
|
||||
await screen.findByRole('tablist', { name: '选择模板' }),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
|
||||
|
||||
resolveGalleryRequest([]);
|
||||
|
||||
@@ -6068,11 +6399,7 @@ test('creation hub published work card keeps delete action guarded by detail flo
|
||||
expect(dialog.className).toContain('platform-modal-shell');
|
||||
expect(dialog.className).toContain('platform-remap-surface');
|
||||
expect(dialog.className).toContain('rounded-[1.75rem]');
|
||||
expect(
|
||||
within(dialog).getByText('确认删除《潮雾列岛》吗?'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '确认删除' }),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByText('确认删除《潮雾列岛》吗?')).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '确认删除' })).toBeTruthy();
|
||||
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -609,7 +609,9 @@ function renderLoggedOutHomeView(
|
||||
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
|
||||
recommendRuntimeError={overrides.recommendRuntimeError}
|
||||
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
|
||||
onSelectPreviousRecommendEntry={overrides.onSelectPreviousRecommendEntry}
|
||||
onSelectPreviousRecommendEntry={
|
||||
overrides.onSelectPreviousRecommendEntry
|
||||
}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
|
||||
/>
|
||||
@@ -617,6 +619,76 @@ function renderLoggedOutHomeView(
|
||||
);
|
||||
}
|
||||
|
||||
function renderLoggedInHomeView(
|
||||
overrides: Partial<
|
||||
Pick<
|
||||
RpgEntryHomeViewProps,
|
||||
'activeTab' | 'hasUnreadDraftUpdate' | 'draftTabContent'
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: (action) => action(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab={overrides.activeTab ?? 'saves'}
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={[]}
|
||||
latestEntries={[]}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={null}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
isResumingSaveWorldKey={null}
|
||||
platformError={null}
|
||||
dashboardError={null}
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={vi.fn()}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
hasUnreadDraftUpdate={overrides.hasUnreadDraftUpdate ?? false}
|
||||
draftTabContent={overrides.draftTabContent}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderStatefulLoggedOutHomeView(
|
||||
overrides: Partial<
|
||||
Pick<
|
||||
@@ -691,7 +763,9 @@ function renderStatefulLoggedOutHomeView(
|
||||
}
|
||||
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
|
||||
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
|
||||
onSelectPreviousRecommendEntry={overrides.onSelectPreviousRecommendEntry}
|
||||
onSelectPreviousRecommendEntry={
|
||||
overrides.onSelectPreviousRecommendEntry
|
||||
}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
|
||||
/>
|
||||
@@ -937,7 +1011,9 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async
|
||||
unmount();
|
||||
|
||||
renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' });
|
||||
const expiredShortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
const expiredShortcutRegion = screen.getByRole('region', {
|
||||
name: '常用功能',
|
||||
});
|
||||
expect(
|
||||
within(expiredShortcutRegion).queryByRole('button', {
|
||||
name: /填邀请码/u,
|
||||
@@ -945,7 +1021,6 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
test('invite query opens login modal for logged out users', async () => {
|
||||
const openLoginModal = vi.fn();
|
||||
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
|
||||
@@ -1041,6 +1116,21 @@ test('logged out bottom nav turns active recommend tab into next action', () =>
|
||||
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('logged in draft bottom tab shows unread marker', () => {
|
||||
const { container } = renderLoggedInHomeView({
|
||||
hasUnreadDraftUpdate: true,
|
||||
draftTabContent: <div>草稿内容</div>,
|
||||
});
|
||||
|
||||
const nav = container.querySelector('.platform-bottom-nav');
|
||||
expect(nav).toBeTruthy();
|
||||
const draftButton = within(nav as HTMLElement).getByRole('button', {
|
||||
name: '草稿,有新草稿',
|
||||
});
|
||||
|
||||
expect(draftButton.querySelector('.platform-nav-unread-dot')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mobile discover search submits public work code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
@@ -1048,9 +1138,8 @@ test('mobile discover search submits public work code', async () => {
|
||||
renderStatefulLoggedOutHomeView({ onSearchPublicCode });
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
const searchInput =
|
||||
screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'PZ-PROFILE1{enter}');
|
||||
|
||||
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
|
||||
@@ -1092,7 +1181,8 @@ test('discover search fuzzy matches public work id, name, author and description
|
||||
throw new Error('缺少发现面板');
|
||||
}
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
const searchInput =
|
||||
screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'MOON01{enter}');
|
||||
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
|
||||
expect(within(discoverPanel).getByText('月井机关')).toBeTruthy();
|
||||
@@ -1175,7 +1265,8 @@ test('mobile discover keeps edutainment works in the last dedicated channel only
|
||||
).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('普通拼图作品')).toBeNull();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
const searchInput =
|
||||
screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, '儿童动作热身{enter}');
|
||||
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();
|
||||
@@ -1213,7 +1304,8 @@ test('mobile discover hides edutainment channel and work when switch is disabled
|
||||
expect(channels).toEqual(['推荐', '今日', '分类', '排行']);
|
||||
expect(within(discoverPanel).queryByText('关闭后隐藏的热身 Demo')).toBeNull();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
const searchInput =
|
||||
screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'PZ-EDUOFF1{enter}');
|
||||
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('关闭后隐藏的热身 Demo')).toBeNull();
|
||||
@@ -1230,7 +1322,8 @@ test('discover search keeps public code fallback when local works do not match',
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
const searchInput =
|
||||
screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'CW-REMOTE-ONLY{enter}');
|
||||
|
||||
expect(onSearchPublicCode).toHaveBeenCalledWith('CW-REMOTE-ONLY');
|
||||
@@ -1264,7 +1357,9 @@ test('logged out mobile shell defaults to discover tab', () => {
|
||||
|
||||
const activePanel = container.querySelector('.platform-tab-panel--active');
|
||||
expect(activePanel?.id).toBe('platform-tab-panel-category');
|
||||
expect(screen.getByPlaceholderText('搜索作品号、名称、作者、描述')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByPlaceholderText('搜索作品号、名称、作者、描述'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('logged out recommend tab opens login modal and shows cover only', async () => {
|
||||
@@ -1283,7 +1378,9 @@ test('logged out recommend tab opens login modal and shows cover only', async ()
|
||||
);
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector('.platform-recommend-cover-only')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('.platform-recommend-cover-only'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
|
||||
expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull();
|
||||
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
||||
@@ -1305,7 +1402,9 @@ test('logged out recommend cover opens login modal again', async () => {
|
||||
await user.click(
|
||||
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }),
|
||||
);
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(2);
|
||||
expect(openLoginModal).toHaveBeenLastCalledWith();
|
||||
@@ -1648,7 +1747,11 @@ test('mobile discover recommend feed only rotates the card closest to screen cen
|
||||
value: (handle: number) => window.clearTimeout(handle),
|
||||
});
|
||||
|
||||
const firstEntry = buildCarouselPuzzleEntry('center1', '中心拼图一', 'center-one');
|
||||
const firstEntry = buildCarouselPuzzleEntry(
|
||||
'center1',
|
||||
'中心拼图一',
|
||||
'center-one',
|
||||
);
|
||||
const secondEntry = buildCarouselPuzzleEntry(
|
||||
'center2',
|
||||
'中心拼图二',
|
||||
|
||||
@@ -9,16 +9,16 @@ import {
|
||||
Coins,
|
||||
Compass,
|
||||
Copy,
|
||||
GitFork,
|
||||
Gamepad2,
|
||||
GitFork,
|
||||
Heart,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
Settings,
|
||||
Share2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Star,
|
||||
@@ -161,6 +161,7 @@ export interface RpgEntryHomeViewProps {
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
createTabContent?: ReactNode;
|
||||
draftTabContent?: ReactNode;
|
||||
hasUnreadDraftUpdate?: boolean;
|
||||
}
|
||||
|
||||
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
|
||||
@@ -896,9 +897,7 @@ function RecommendRuntimeMeta({
|
||||
onPointerCancel={onDragPointerCancel}
|
||||
>
|
||||
<div className="platform-recommend-work-meta__row">
|
||||
<div
|
||||
className="platform-recommend-work-meta__identity"
|
||||
>
|
||||
<div className="platform-recommend-work-meta__identity">
|
||||
<span
|
||||
className="platform-recommend-work-meta__avatar"
|
||||
aria-hidden="true"
|
||||
@@ -1044,23 +1043,30 @@ function PlatformTabButton({
|
||||
icon: Icon,
|
||||
onClick,
|
||||
emphasized = false,
|
||||
showDot = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
emphasized?: boolean;
|
||||
showDot?: boolean;
|
||||
}) {
|
||||
const ariaLabel = showDot ? `${label},有新草稿` : label;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
aria-label={ariaLabel}
|
||||
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
|
||||
>
|
||||
<span className="platform-bottom-nav__button-content">
|
||||
<span className="platform-bottom-nav__icon-shell">
|
||||
<Icon className="platform-bottom-nav__icon" />
|
||||
{showDot ? (
|
||||
<span aria-hidden="true" className="platform-nav-unread-dot" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="platform-bottom-nav__label">{label}</span>
|
||||
</span>
|
||||
@@ -1074,21 +1080,29 @@ function DesktopTabButton({
|
||||
icon: Icon,
|
||||
onClick,
|
||||
emphasized = false,
|
||||
showDot = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
emphasized?: boolean;
|
||||
showDot?: boolean;
|
||||
}) {
|
||||
const ariaLabel = showDot ? `${label},有新草稿` : label;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${active ? 'platform-desktop-rail__button--active' : ''}`}
|
||||
>
|
||||
<span className="platform-desktop-rail__icon-shell">
|
||||
<Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" />
|
||||
{showDot ? (
|
||||
<span aria-hidden="true" className="platform-nav-unread-dot" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="platform-desktop-rail__label text-[11px] font-semibold tracking-[0.2em]">
|
||||
{label}
|
||||
@@ -1496,7 +1510,7 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: 'rpg';
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
@@ -1608,7 +1622,7 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||||
? '方洞'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? '视觉'
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
return formatPlatformWorkDisplayTag(kind);
|
||||
}
|
||||
|
||||
@@ -2857,9 +2871,7 @@ function ProfileReferralModal({
|
||||
复制邀请
|
||||
</button>
|
||||
<div className="rounded-xl bg-zinc-50 px-3.5 py-3">
|
||||
<div className="text-xs font-black text-zinc-900">
|
||||
成功邀请
|
||||
</div>
|
||||
<div className="text-xs font-black text-zinc-900">成功邀请</div>
|
||||
{center?.invitedUsers?.length ? (
|
||||
<div className="mt-3 max-h-44 space-y-2 overflow-y-auto pr-1">
|
||||
{center.invitedUsers.map((user) => (
|
||||
@@ -3031,9 +3043,7 @@ function ProfilePlayedWorksModal({
|
||||
</span>
|
||||
<span className="truncate">
|
||||
时长{' '}
|
||||
{formatCompactPlayTime(
|
||||
work.lastObservedPlayTimeMs,
|
||||
)}
|
||||
{formatCompactPlayTime(work.lastObservedPlayTimeMs)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -3096,6 +3106,7 @@ export function RpgEntryHomeView({
|
||||
onRechargeSuccess,
|
||||
createTabContent,
|
||||
draftTabContent,
|
||||
hasUnreadDraftUpdate = false,
|
||||
}: RpgEntryHomeViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||
@@ -3116,9 +3127,8 @@ export function RpgEntryHomeView({
|
||||
);
|
||||
const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false);
|
||||
const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false);
|
||||
const [taskCenter, setTaskCenter] = useState<ProfileTaskCenterResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [taskCenter, setTaskCenter] =
|
||||
useState<ProfileTaskCenterResponse | null>(null);
|
||||
const [taskCenterError, setTaskCenterError] = useState<string | null>(null);
|
||||
const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false);
|
||||
const [claimingTaskId, setClaimingTaskId] = useState<string | null>(null);
|
||||
@@ -3333,7 +3343,9 @@ export function RpgEntryHomeView({
|
||||
}, [activeTab, isAuthenticated, onTabChange, visibleTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleDiscoverChannels.some((channel) => channel.id === discoverChannel)) {
|
||||
if (
|
||||
!visibleDiscoverChannels.some((channel) => channel.id === discoverChannel)
|
||||
) {
|
||||
setDiscoverChannel('recommend');
|
||||
}
|
||||
}, [discoverChannel, visibleDiscoverChannels]);
|
||||
@@ -3932,7 +3944,9 @@ export function RpgEntryHomeView({
|
||||
const updateCenteredCard = () => {
|
||||
frameId = null;
|
||||
const cards = Array.from(
|
||||
feedElement.querySelectorAll<HTMLElement>('[data-mobile-feed-card-key]'),
|
||||
feedElement.querySelectorAll<HTMLElement>(
|
||||
'[data-mobile-feed-card-key]',
|
||||
),
|
||||
);
|
||||
const viewportRect = scrollElement.getBoundingClientRect();
|
||||
const viewportCenterY =
|
||||
@@ -3992,7 +4006,12 @@ export function RpgEntryHomeView({
|
||||
scrollElement.removeEventListener('scroll', scheduleUpdate);
|
||||
window.removeEventListener('resize', scheduleUpdate);
|
||||
};
|
||||
}, [discoverChannel, discoverFeedEntries, activeTab, mobileFeedCarouselEnabled]);
|
||||
}, [
|
||||
discoverChannel,
|
||||
discoverFeedEntries,
|
||||
activeTab,
|
||||
mobileFeedCarouselEnabled,
|
||||
]);
|
||||
const activeRankingConfig = PLATFORM_RANKING_TABS.find(
|
||||
(tab) => tab.id === activeRankingTab,
|
||||
) as (typeof PLATFORM_RANKING_TABS)[number];
|
||||
@@ -4049,8 +4068,7 @@ export function RpgEntryHomeView({
|
||||
setRecommendDragCommitDirection(direction);
|
||||
const panelHeight =
|
||||
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
|
||||
const commitDistance =
|
||||
panelHeight > 0 ? panelHeight : window.innerHeight;
|
||||
const commitDistance = panelHeight > 0 ? panelHeight : window.innerHeight;
|
||||
setRecommendDragOffsetY(
|
||||
direction === 1 ? -commitDistance : commitDistance,
|
||||
);
|
||||
@@ -4103,7 +4121,8 @@ export function RpgEntryHomeView({
|
||||
|
||||
const deltaY = event.clientY - drag.startY;
|
||||
drag.dragging =
|
||||
drag.dragging || Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2;
|
||||
drag.dragging ||
|
||||
Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2;
|
||||
if (!drag.dragging) {
|
||||
return;
|
||||
}
|
||||
@@ -4113,12 +4132,7 @@ export function RpgEntryHomeView({
|
||||
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
|
||||
const dragLimit =
|
||||
cardHeight > 0 ? cardHeight : RECOMMEND_ENTRY_DRAG_LIMIT_PX;
|
||||
setRecommendDragOffsetY(
|
||||
Math.max(
|
||||
-dragLimit,
|
||||
Math.min(dragLimit, deltaY),
|
||||
),
|
||||
);
|
||||
setRecommendDragOffsetY(Math.max(-dragLimit, Math.min(dragLimit, deltaY)));
|
||||
}, []);
|
||||
const endRecommendDrag = useCallback(
|
||||
(event: PointerEvent<HTMLElement>) => {
|
||||
@@ -4187,25 +4201,28 @@ export function RpgEntryHomeView({
|
||||
useEffect(() => {
|
||||
setRecommendShareState('idle');
|
||||
}, [activeRecommendEntryKey]);
|
||||
const shareRecommendEntry = useCallback((entry: PlatformPublicGalleryCard) => {
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
|
||||
if (!publicWorkCode) {
|
||||
setRecommendShareState('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
|
||||
void copyTextToClipboard(shareText).then((copied) => {
|
||||
setRecommendShareState(copied ? 'copied' : 'failed');
|
||||
if (recommendShareResetTimerRef.current !== null) {
|
||||
window.clearTimeout(recommendShareResetTimerRef.current);
|
||||
const shareRecommendEntry = useCallback(
|
||||
(entry: PlatformPublicGalleryCard) => {
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
|
||||
if (!publicWorkCode) {
|
||||
setRecommendShareState('failed');
|
||||
return;
|
||||
}
|
||||
recommendShareResetTimerRef.current = window.setTimeout(() => {
|
||||
recommendShareResetTimerRef.current = null;
|
||||
setRecommendShareState('idle');
|
||||
}, 1400);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
|
||||
void copyTextToClipboard(shareText).then((copied) => {
|
||||
setRecommendShareState(copied ? 'copied' : 'failed');
|
||||
if (recommendShareResetTimerRef.current !== null) {
|
||||
window.clearTimeout(recommendShareResetTimerRef.current);
|
||||
}
|
||||
recommendShareResetTimerRef.current = window.setTimeout(() => {
|
||||
recommendShareResetTimerRef.current = null;
|
||||
setRecommendShareState('idle');
|
||||
}, 1400);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const openActiveRecommendEntry = useCallback(() => {
|
||||
if (!activeRecommendEntry) {
|
||||
return;
|
||||
@@ -4304,7 +4321,9 @@ export function RpgEntryHomeView({
|
||||
<section className="platform-recommend-runtime-panel">
|
||||
<RecommendCoverOnlyCard
|
||||
entry={activeRecommendEntry}
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
|
||||
activeRecommendEntry,
|
||||
)}
|
||||
onClick={openActiveRecommendEntry}
|
||||
/>
|
||||
</section>
|
||||
@@ -4481,7 +4500,10 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="platform-category-sort-button">
|
||||
<button
|
||||
type="button"
|
||||
className="platform-category-sort-button"
|
||||
>
|
||||
<span>按综合排序</span>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -4535,22 +4557,26 @@ export function RpgEntryHomeView({
|
||||
<EmptyShelf text="正在读取公开作品..." />
|
||||
) : discoverFeedEntries.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-3">
|
||||
{discoverFeedEntries.map((entry: PlatformPublicGalleryCard) => {
|
||||
const cardKey = buildPublicGalleryCardKey(entry);
|
||||
{discoverFeedEntries.map(
|
||||
(entry: PlatformPublicGalleryCard) => {
|
||||
const cardKey = buildPublicGalleryCardKey(entry);
|
||||
|
||||
return (
|
||||
<WorldCard
|
||||
key={`${cardKey}:mobile-feed:${discoverChannel}`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="w-full"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
feedCardKey={cardKey}
|
||||
enableCoverCarousel={mobileFeedCarouselEnabled}
|
||||
isCoverCarouselActive={mobileCenteredCardKey === cardKey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<WorldCard
|
||||
key={`${cardKey}:mobile-feed:${discoverChannel}`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="w-full"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
feedCardKey={cardKey}
|
||||
enableCoverCarousel={mobileFeedCarouselEnabled}
|
||||
isCoverCarouselActive={
|
||||
mobileCenteredCardKey === cardKey
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
|
||||
@@ -4676,11 +4702,9 @@ export function RpgEntryHomeView({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const categoryContent: ReactNode = isDesktopLayout ? (
|
||||
desktopDiscoverContent
|
||||
) : (
|
||||
mobileDiscoverContent
|
||||
);
|
||||
const categoryContent: ReactNode = isDesktopLayout
|
||||
? desktopDiscoverContent
|
||||
: mobileDiscoverContent;
|
||||
|
||||
const fallbackCreateStartContent: ReactNode = (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
@@ -4748,11 +4772,10 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
);
|
||||
|
||||
const createContent: ReactNode = createTabContent ?? fallbackCreateStartContent;
|
||||
const createContent: ReactNode =
|
||||
createTabContent ?? fallbackCreateStartContent;
|
||||
|
||||
const savesContent: ReactNode = (
|
||||
draftTabContent ?? fallbackDraftContent
|
||||
);
|
||||
const savesContent: ReactNode = draftTabContent ?? fallbackDraftContent;
|
||||
|
||||
const profileContent: ReactNode = (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
@@ -5013,268 +5036,275 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.55fr)_22rem]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openLeadPublicEntry}
|
||||
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
|
||||
>
|
||||
{desktopHeroCover ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={desktopHeroCover}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-34"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[24rem] flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="platform-pill platform-pill--warm">作品</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
{leadPublicEntry
|
||||
? describePublicGalleryCardKind(leadPublicEntry)
|
||||
: '作品'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[35rem]">
|
||||
<div className="text-5xl font-semibold leading-[1.08] text-white">
|
||||
{leadPublicEntry?.worldName ?? '浏览玩家作品'}
|
||||
</div>
|
||||
<div className="mt-4 text-base leading-8 text-zinc-200/86">
|
||||
{leadPublicEntry?.summaryText ||
|
||||
leadPublicEntry?.subtitle ||
|
||||
'挑一个玩家作品,开始今天的游玩。'}
|
||||
</div>
|
||||
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-white/18 bg-white/18 px-4 py-2 text-sm font-semibold text-white/92">
|
||||
<span>查看作品</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{desktopHeroStripEntries.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-5">
|
||||
{desktopHeroStripEntries.map((entry, index) => {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const displayName = formatPlatformWorkDisplayName(
|
||||
entry.worldName,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:hero-strip`}
|
||||
className="platform-subpanel overflow-hidden rounded-[1.15rem]"
|
||||
>
|
||||
<div className="relative aspect-[1.35/1] overflow-hidden">
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(91,24,46,0.34))]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-[11px] text-[color:color-mix(in_srgb,var(--platform-text-base)_82%,transparent)]">
|
||||
<span className="text-[var(--platform-text-soft)]">
|
||||
{`${index + 1}`.padStart(2, '0')}
|
||||
</span>
|
||||
<span className="line-clamp-1">{displayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<SectionHeader title="今日游戏" detail="TODAY GAMES" />
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
TODAY
|
||||
</span>
|
||||
</div>
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取今日游戏..." />
|
||||
) : desktopTodayEntries.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{desktopTodayEntries.slice(0, 3).map((entry, index) => (
|
||||
<DesktopTrendingItem
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-today`}
|
||||
entry={entry}
|
||||
rank={index + 1}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.55fr)_22rem]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openLeadPublicEntry}
|
||||
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
|
||||
>
|
||||
{desktopHeroCover ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={desktopHeroCover}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-34"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="今天暂时还没有新游戏。" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[24rem] flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="platform-pill platform-pill--warm">
|
||||
作品
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
{leadPublicEntry
|
||||
? describePublicGalleryCardKind(leadPublicEntry)
|
||||
: '作品'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid gap-5 ${desktopLibraryPreview.length > 0 || visibleHistoryEntries.length > 0 ? '2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]' : ''}`}
|
||||
>
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<SectionHeader title="推荐" detail="RECOMMENDED" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取推荐作品..." />
|
||||
) : desktopFeaturedGrid.length > 0 ? (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{desktopFeaturedGrid.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-featured`}
|
||||
entry={entry}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
className="w-full min-w-0"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="暂时还没有推荐作品。" />
|
||||
)}
|
||||
</section>
|
||||
<div className="max-w-[35rem]">
|
||||
<div className="text-5xl font-semibold leading-[1.08] text-white">
|
||||
{leadPublicEntry?.worldName ?? '浏览玩家作品'}
|
||||
</div>
|
||||
<div className="mt-4 text-base leading-8 text-zinc-200/86">
|
||||
{leadPublicEntry?.summaryText ||
|
||||
leadPublicEntry?.subtitle ||
|
||||
'挑一个玩家作品,开始今天的游玩。'}
|
||||
</div>
|
||||
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-white/18 bg-white/18 px-4 py-2 text-sm font-semibold text-white/92">
|
||||
<span>查看作品</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{desktopLibraryPreview.length > 0 || visibleHistoryEntries.length > 0 ? (
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<SectionHeader
|
||||
title={desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'}
|
||||
detail="QUICK ACCESS"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
|
||||
{desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'}
|
||||
</div>
|
||||
|
||||
{desktopLibraryPreview.length > 0 ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
{desktopLibraryPreview.map((entry) => {
|
||||
const displayName = formatPlatformWorkDisplayName(
|
||||
entry.worldName,
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-mine`}
|
||||
type="button"
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
{displayName}
|
||||
{desktopHeroStripEntries.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-5">
|
||||
{desktopHeroStripEntries.map((entry, index) => {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const displayName = formatPlatformWorkDisplayName(
|
||||
entry.worldName,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:hero-strip`}
|
||||
className="platform-subpanel overflow-hidden rounded-[1.15rem]"
|
||||
>
|
||||
<div className="relative aspect-[1.35/1] overflow-hidden">
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(91,24,46,0.34))]" />
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
|
||||
{entry.visibility === 'published'
|
||||
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
||||
: '草稿待完善'}
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-[11px] text-[color:color-mix(in_srgb,var(--platform-text-base)_82%,transparent)]">
|
||||
<span className="text-[var(--platform-text-soft)]">
|
||||
{`${index + 1}`.padStart(2, '0')}
|
||||
</span>
|
||||
<span className="line-clamp-1">{displayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
{entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<SectionHeader title="今日游戏" detail="TODAY GAMES" />
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
TODAY
|
||||
</span>
|
||||
</div>
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取今日游戏..." />
|
||||
) : desktopTodayEntries.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{desktopTodayEntries.slice(0, 3).map((entry, index) => (
|
||||
<DesktopTrendingItem
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-today`}
|
||||
entry={entry}
|
||||
rank={index + 1}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-3">
|
||||
{visibleHistoryEntries.slice(0, 2).map((entry) => {
|
||||
const displayName = formatPlatformWorkDisplayName(
|
||||
entry.worldName,
|
||||
);
|
||||
<EmptyShelf text="今天暂时还没有新游戏。" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid gap-5 ${desktopLibraryPreview.length > 0 || visibleHistoryEntries.length > 0 ? '2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]' : ''}`}
|
||||
>
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<SectionHeader title="推荐" detail="RECOMMENDED" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取推荐作品..." />
|
||||
) : desktopFeaturedGrid.length > 0 ? (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{desktopFeaturedGrid.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-featured`}
|
||||
entry={entry}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
className="w-full min-w-0"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="暂时还没有推荐作品。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{desktopLibraryPreview.length > 0 ||
|
||||
visibleHistoryEntries.length > 0 ? (
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<SectionHeader
|
||||
title={
|
||||
desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'
|
||||
}
|
||||
detail="QUICK ACCESS"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
|
||||
{desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'}
|
||||
</div>
|
||||
|
||||
{desktopLibraryPreview.length > 0 ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
{desktopLibraryPreview.map((entry) => {
|
||||
const displayName = formatPlatformWorkDisplayName(
|
||||
entry.worldName,
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-mine`}
|
||||
type="button"
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
|
||||
{entry.visibility === 'published'
|
||||
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
||||
: '草稿待完善'}
|
||||
</div>
|
||||
</div>
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
{entry.visibility === 'published'
|
||||
? '已发布'
|
||||
: '草稿'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-3">
|
||||
{visibleHistoryEntries.slice(0, 2).map((entry) => {
|
||||
const displayName = formatPlatformWorkDisplayName(
|
||||
entry.worldName,
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openRecommendGalleryDetail({
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: entry.visitedAt,
|
||||
updatedAt: entry.visitedAt,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: entry.themeMode,
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
likeCount: 0,
|
||||
})
|
||||
}
|
||||
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
|
||||
作者:{entry.authorDisplayName}
|
||||
</div>
|
||||
</div>
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
浏览
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取作品分类..." />
|
||||
) : activeCategoryGroup && desktopCategoryGrid.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-4 flex min-w-0 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{categoryGroups.map((group) => {
|
||||
const active = group.tag === activeCategoryGroup.tag;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
|
||||
key={`${group.tag}:desktop-category`}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openRecommendGalleryDetail({
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: entry.visitedAt,
|
||||
updatedAt: entry.visitedAt,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: entry.themeMode,
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
likeCount: 0,
|
||||
})
|
||||
}
|
||||
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
|
||||
onClick={() => setSelectedCategoryTag(group.tag)}
|
||||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
|
||||
作者:{entry.authorDisplayName}
|
||||
</div>
|
||||
</div>
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
浏览
|
||||
</span>
|
||||
{group.tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{desktopCategoryGrid.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}`}
|
||||
entry={entry}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
className="w-full min-w-0"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyShelf text="暂时还没有可分类的作品。" />
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取作品分类..." />
|
||||
) : activeCategoryGroup && desktopCategoryGrid.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-4 flex min-w-0 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{categoryGroups.map((group) => {
|
||||
const active = group.tag === activeCategoryGroup.tag;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${group.tag}:desktop-category`}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategoryTag(group.tag)}
|
||||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
{group.tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{desktopCategoryGrid.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}`}
|
||||
entry={entry}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
className="w-full min-w-0"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyShelf text="暂时还没有可分类的作品。" />
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -5403,6 +5433,7 @@ export function RpgEntryHomeView({
|
||||
: tabIcons[tab]
|
||||
}
|
||||
emphasized={tab === 'create'}
|
||||
showDot={tab === 'saves' && hasUnreadDraftUpdate}
|
||||
onClick={() => {
|
||||
if (activeTab === 'home' && tab === 'home') {
|
||||
selectNextRecommendEntry();
|
||||
@@ -5548,6 +5579,7 @@ export function RpgEntryHomeView({
|
||||
label={tabLabels[tab]}
|
||||
icon={tabIcons[tab]}
|
||||
emphasized={tab === 'create'}
|
||||
showDot={tab === 'saves' && hasUnreadDraftUpdate}
|
||||
onClick={() => {
|
||||
if (!isAuthenticated && tab === 'home') {
|
||||
onTabChange(tab);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type {
|
||||
Match3DGeneratedItemAsset,
|
||||
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,
|
||||
@@ -12,6 +14,7 @@ import type {
|
||||
SquareHoleShapeOption,
|
||||
SquareHoleWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import {
|
||||
@@ -105,6 +108,7 @@ export type PlatformMatch3DGalleryCard = {
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
generatedItemAssets?: Match3DGeneratedItemAsset[];
|
||||
};
|
||||
|
||||
export type PlatformSquareHoleGalleryCard = {
|
||||
@@ -251,6 +255,7 @@ export function mapMatch3DWorkToPlatformGalleryCard(
|
||||
visibility: 'published',
|
||||
publishedAt: work.publishedAt ?? null,
|
||||
updatedAt: work.updatedAt,
|
||||
generatedItemAssets: work.generatedItemAssets ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,14 @@ type UseRpgCreationSessionControllerParams = {
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
enterCreateTab?: (() => void) | undefined;
|
||||
onSessionOpened?: (() => void) | undefined;
|
||||
onDraftGenerationStarted?: ((sessionId: string) => void) | undefined;
|
||||
onDraftGenerationCompleted?:
|
||||
| ((params: {
|
||||
sessionId: string;
|
||||
profileId: string | null;
|
||||
viewedImmediately: boolean;
|
||||
}) => void)
|
||||
| undefined;
|
||||
};
|
||||
|
||||
type PendingAgentUserMessage = {
|
||||
@@ -67,6 +75,8 @@ export function useRpgCreationSessionController(
|
||||
setSelectionStage,
|
||||
enterCreateTab,
|
||||
onSessionOpened,
|
||||
onDraftGenerationStarted,
|
||||
onDraftGenerationCompleted,
|
||||
} = params;
|
||||
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
|
||||
const shouldRestoreInitialAgentUiStateRef = useRef(
|
||||
@@ -471,7 +481,7 @@ export function useRpgCreationSessionController(
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectionStage !== 'custom-world-generating' ||
|
||||
!activeAgentSessionId ||
|
||||
customWorldGenerationViewSource !== 'agent-draft-foundation' ||
|
||||
!isDraftFoundationOperation(agentOperation) ||
|
||||
agentOperation.status !== 'completed'
|
||||
@@ -480,6 +490,7 @@ export function useRpgCreationSessionController(
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const generationSessionId = activeAgentSessionId;
|
||||
void (async () => {
|
||||
for (
|
||||
let attempt = 1;
|
||||
@@ -494,11 +505,9 @@ export function useRpgCreationSessionController(
|
||||
return;
|
||||
}
|
||||
|
||||
const latestResultView = activeAgentSessionId
|
||||
? await syncAgentCreationResultView(activeAgentSessionId).catch(
|
||||
() => null,
|
||||
)
|
||||
: null;
|
||||
const latestResultView = await syncAgentCreationResultView(
|
||||
generationSessionId,
|
||||
).catch(() => null);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -512,11 +521,19 @@ export function useRpgCreationSessionController(
|
||||
continue;
|
||||
}
|
||||
|
||||
setGeneratedCustomWorldProfile(draftResultProfile);
|
||||
setAgentDraftGenerationStartedAt(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource('agent-draft');
|
||||
setSelectionStage('custom-world-result');
|
||||
const shouldOpenResult = selectionStage === 'custom-world-generating';
|
||||
onDraftGenerationCompleted?.({
|
||||
sessionId: generationSessionId,
|
||||
profileId: draftResultProfile.id ?? null,
|
||||
viewedImmediately: shouldOpenResult,
|
||||
});
|
||||
if (shouldOpenResult) {
|
||||
setGeneratedCustomWorldProfile(draftResultProfile);
|
||||
setCustomWorldResultViewSource('agent-draft');
|
||||
setSelectionStage('custom-world-result');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -533,6 +550,7 @@ export function useRpgCreationSessionController(
|
||||
agentOperation,
|
||||
agentSession,
|
||||
customWorldGenerationViewSource,
|
||||
onDraftGenerationCompleted,
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
syncAgentCreationResultView,
|
||||
@@ -619,6 +637,11 @@ export function useRpgCreationSessionController(
|
||||
setCustomWorldResultViewSource(
|
||||
resultView.resultViewSource ?? 'agent-draft',
|
||||
);
|
||||
onDraftGenerationCompleted?.({
|
||||
sessionId: activeAgentSessionId,
|
||||
profileId: resultProfile.id ?? null,
|
||||
viewedImmediately: selectionStage === 'custom-world-result',
|
||||
});
|
||||
isAgentDraftResultAutoOpenSuppressedRef.current = false;
|
||||
if (selectionStage === 'agent-workspace') {
|
||||
setSelectionStage('custom-world-result');
|
||||
@@ -633,6 +656,7 @@ export function useRpgCreationSessionController(
|
||||
activeAgentSessionId,
|
||||
agentSession,
|
||||
generatedCustomWorldProfile,
|
||||
onDraftGenerationCompleted,
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
syncAgentCreationResultView,
|
||||
@@ -815,6 +839,7 @@ export function useRpgCreationSessionController(
|
||||
setCustomWorldGenerationViewSource('agent-draft-foundation');
|
||||
setCustomWorldResultViewSource(null);
|
||||
setAgentDraftGenerationStartedAt(Date.now());
|
||||
onDraftGenerationStarted?.(activeAgentSessionId);
|
||||
setSelectionStage('custom-world-generating');
|
||||
}
|
||||
|
||||
@@ -851,7 +876,12 @@ export function useRpgCreationSessionController(
|
||||
);
|
||||
}
|
||||
},
|
||||
[activeAgentSessionId, persistAgentUiState, setSelectionStage],
|
||||
[
|
||||
activeAgentSessionId,
|
||||
onDraftGenerationStarted,
|
||||
persistAgentUiState,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
const setNormalizedGeneratedCustomWorldProfile = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user