1
This commit is contained in:
@@ -134,8 +134,7 @@ export function CustomWorldGenerationView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isGenerating}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
@@ -213,7 +212,10 @@ export function CustomWorldGenerationView({
|
||||
|
||||
return (
|
||||
<div
|
||||
key={buildFallbackRenderKey(step.id, `progress-step-${index}`)}
|
||||
key={buildFallbackRenderKey(
|
||||
step.id,
|
||||
`progress-step-${index}`,
|
||||
)}
|
||||
className={`rounded-2xl border px-4 py-3 transition-colors ${
|
||||
step.status === 'completed'
|
||||
? 'border-emerald-400/16 bg-emerald-500/8'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||
|
||||
const noopCreateType = () => {};
|
||||
@@ -77,8 +77,9 @@ const testEntryConfig = {
|
||||
],
|
||||
} satisfies CreationEntryConfig;
|
||||
|
||||
const testCreationTypes = derivePlatformCreationTypes(testEntryConfig.creationTypes);
|
||||
|
||||
const testCreationTypes = derivePlatformCreationTypes(
|
||||
testEntryConfig.creationTypes,
|
||||
);
|
||||
|
||||
test('creation hub draft card renders compiled work summary fields', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
@@ -174,6 +175,50 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
|
||||
expect(html).not.toContain('我的拼图作品');
|
||||
});
|
||||
|
||||
test('creation hub marks generating and newly completed drafts', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle-work-session-1',
|
||||
profileId: 'puzzle-profile-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾拼图草稿',
|
||||
workDescription: '正在生成首张拼图主视觉。',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '正在生成首张拼图主视觉。',
|
||||
themeTags: ['潮雾'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
sourceSessionId: 'puzzle-session-1',
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
getWorkState={(item) =>
|
||||
item.kind === 'puzzle'
|
||||
? { isGenerating: true, hasUnreadUpdate: true }
|
||||
: null
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('生成中');
|
||||
expect(html).toContain('aria-label="新生成完成"');
|
||||
});
|
||||
|
||||
test('creation hub published work spans full mobile row', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
|
||||
@@ -7,13 +7,13 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type {
|
||||
PlatformCreationTypeCard,
|
||||
PlatformCreationTypeId,
|
||||
} from '../platform-entry/platformEntryCreationTypes';
|
||||
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
|
||||
import {
|
||||
buildCreationWorkShelfItems,
|
||||
type CreationWorkShelfItem,
|
||||
@@ -68,6 +68,10 @@ type CustomWorldCreationHubProps = {
|
||||
visualNovelItems?: VisualNovelWorkSummary[];
|
||||
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
|
||||
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
|
||||
getWorkState?: (
|
||||
item: CreationWorkShelfItem,
|
||||
) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null;
|
||||
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
|
||||
mode?: 'full' | 'start-only' | 'works-only';
|
||||
};
|
||||
|
||||
@@ -166,6 +170,8 @@ export function CustomWorldCreationHub({
|
||||
visualNovelItems = [],
|
||||
onOpenVisualNovelDetail = null,
|
||||
onDeleteVisualNovel = null,
|
||||
getWorkState,
|
||||
onOpenShelfItem,
|
||||
mode = 'full',
|
||||
}: CustomWorldCreationHubProps) {
|
||||
const [activeFilter, setActiveFilter] =
|
||||
@@ -191,6 +197,7 @@ export function CustomWorldCreationHub({
|
||||
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
|
||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
|
||||
getItemState: getWorkState,
|
||||
}),
|
||||
[
|
||||
bigFishItems,
|
||||
@@ -203,6 +210,7 @@ export function CustomWorldCreationHub({
|
||||
onDeletePublished,
|
||||
onDeletePuzzle,
|
||||
onDeleteVisualNovel,
|
||||
getWorkState,
|
||||
puzzleItems,
|
||||
rpgLibraryEntries,
|
||||
squareHoleItems,
|
||||
@@ -230,6 +238,8 @@ export function CustomWorldCreationHub({
|
||||
);
|
||||
|
||||
function handleOpenShelfItem(item: CreationWorkShelfItem) {
|
||||
onOpenShelfItem?.(item);
|
||||
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle':
|
||||
onOpenPuzzleDetail?.(item.source.item);
|
||||
|
||||
@@ -267,6 +267,12 @@ export function CustomWorldWorkCard({
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_100%_100%,rgba(255,255,255,0.18),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.08),rgba(0,0,0,0.08))]" />
|
||||
{item.hasUnreadUpdate ? (
|
||||
<span
|
||||
aria-label="新生成完成"
|
||||
className="pointer-events-none absolute right-2 top-2 z-30 h-2.5 w-2.5 rounded-full bg-red-500 shadow-[0_0_0_3px_rgba(255,255,255,0.26),0_0_14px_rgba(239,68,68,0.75)]"
|
||||
/>
|
||||
) : null}
|
||||
<div className="pointer-events-none relative z-20 flex min-h-[8rem] flex-col sm:min-h-[10.5rem] xl:min-h-[9.75rem]">
|
||||
<div className="pointer-events-auto absolute right-0 top-0 z-30 flex items-center gap-1">
|
||||
{onDelete ? (
|
||||
@@ -335,6 +341,11 @@ export function CustomWorldWorkCard({
|
||||
|
||||
<div className="flex items-start justify-between gap-2 pr-12 sm:gap-3 sm:pr-14">
|
||||
<div className="flex max-h-[3rem] min-w-0 flex-wrap gap-1 overflow-hidden sm:max-h-none sm:gap-2">
|
||||
{item.isGenerating ? (
|
||||
<span className="platform-pill platform-pill--cool max-w-full truncate px-2 py-0.5 text-[9px] sm:px-3 sm:py-1 sm:text-[10px]">
|
||||
生成中
|
||||
</span>
|
||||
) : null}
|
||||
{item.badges.map((badge) => (
|
||||
<span
|
||||
key={`${item.id}-${badge.id}`}
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||
import {
|
||||
buildBigFishPublicWorkCode,
|
||||
@@ -83,6 +83,8 @@ export type CreationWorkShelfItem = {
|
||||
id: string;
|
||||
kind: CreationWorkShelfKind;
|
||||
status: CreationWorkShelfStatus;
|
||||
isGenerating?: boolean;
|
||||
hasUnreadUpdate?: boolean;
|
||||
title: string;
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
@@ -114,6 +116,9 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteSquareHole?: boolean;
|
||||
canDeletePuzzle?: boolean;
|
||||
canDeleteVisualNovel?: boolean;
|
||||
getItemState?: (
|
||||
item: CreationWorkShelfItem,
|
||||
) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null;
|
||||
}) {
|
||||
const {
|
||||
rpgItems,
|
||||
@@ -129,6 +134,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteSquareHole = false,
|
||||
canDeletePuzzle = false,
|
||||
canDeleteVisualNovel = false,
|
||||
getItemState,
|
||||
} = params;
|
||||
|
||||
return [
|
||||
@@ -150,10 +156,21 @@ export function buildCreationWorkShelfItems(params: {
|
||||
...visualNovelItems.map((item) =>
|
||||
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel),
|
||||
),
|
||||
].sort(
|
||||
(left, right) =>
|
||||
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
|
||||
);
|
||||
]
|
||||
.map((item) => {
|
||||
const state = getItemState?.(item);
|
||||
return state
|
||||
? {
|
||||
...item,
|
||||
isGenerating: state.isGenerating,
|
||||
hasUnreadUpdate: state.hasUnreadUpdate,
|
||||
}
|
||||
: item;
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
|
||||
);
|
||||
}
|
||||
|
||||
function mapRpgWorkToShelfItem(
|
||||
@@ -355,14 +372,14 @@ function mapVisualNovelWorkToShelfItem(
|
||||
item: VisualNovelWorkSummary,
|
||||
canDelete: boolean,
|
||||
): CreationWorkShelfItem {
|
||||
const status =
|
||||
item.publishStatus === 'published' ? 'published' : 'draft';
|
||||
const status = item.publishStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
status === 'published' ? buildVisualNovelPublicWorkCode(item.profileId) : null;
|
||||
status === 'published'
|
||||
? buildVisualNovelPublicWorkCode(item.profileId)
|
||||
: null;
|
||||
const title = item.title?.trim() || '未命名视觉小说';
|
||||
const summary =
|
||||
item.description?.trim() ||
|
||||
(status === 'draft' ? '未填写作品描述' : '');
|
||||
item.description?.trim() || (status === 'draft' ? '未填写作品描述' : '');
|
||||
|
||||
return {
|
||||
id: item.profileId,
|
||||
|
||||
@@ -32,6 +32,8 @@ vi.mock('./Match3DModelPreview', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../services/assetReadUrlService', () => ({
|
||||
isGeneratedLegacyPath: (value: string) =>
|
||||
/^\/?generated-[^/?#]+\/.+/u.test(value.trim()),
|
||||
readAssetBytes: vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
|
||||
@@ -47,6 +49,7 @@ vi.mock('../../services/assetReadUrlService', () => ({
|
||||
vi.mock('../../services/match3d-works', () => ({
|
||||
generateMatch3DWorkTags: vi.fn(),
|
||||
publishMatch3DWork: vi.fn(),
|
||||
updateMatch3DGeneratedItemAssets: vi.fn(),
|
||||
updateMatch3DWork: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -180,6 +183,53 @@ describe('Match3DResultView', () => {
|
||||
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('试玩前保存响应缺少素材时仍把当前生成模型带入运行态', async () => {
|
||||
const 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,
|
||||
},
|
||||
];
|
||||
const profile = createProfile({ generatedItemAssets });
|
||||
const savedProfile = createProfile({ generatedItemAssets: [] });
|
||||
const onStartTestRun = vi.fn();
|
||||
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
|
||||
item: savedProfile,
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={profile}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
profileId: profile.profileId,
|
||||
generatedItemAssets,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('发布仍要求封面和标签数量满足门槛', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
@@ -277,6 +327,44 @@ describe('Match3DResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('历史素材只有 modelObjectKey 时仍能进入模型预览', () => {
|
||||
const modelObjectKey =
|
||||
'generated-match3d-assets/session/profile/items/strawberry/model/model.glb';
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
clearCount: 3,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/strawberry/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/strawberry/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey,
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: 'task-strawberry',
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /草莓/u }));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('match3d-model-preview').getAttribute('data-model-src'),
|
||||
).toBe(modelObjectKey);
|
||||
});
|
||||
|
||||
test('草稿阶段仅有切割图片时模型预览为空', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
@@ -364,6 +452,82 @@ describe('Match3DResultView', () => {
|
||||
expect(screen.queryByRole('button', { name: /水果核心物件/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('历史草稿同时带旧 draft 和 profile 模型时以 profile 模型补齐试玩资产', async () => {
|
||||
const draftAsset = {
|
||||
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: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
};
|
||||
const profileAsset = {
|
||||
...draftAsset,
|
||||
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',
|
||||
};
|
||||
const profile = createProfile({ generatedItemAssets: [profileAsset] });
|
||||
const onStartTestRun = vi.fn();
|
||||
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
|
||||
item: createProfile({ generatedItemAssets: [] }),
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={profile}
|
||||
draft={{
|
||||
profileId: profile.profileId,
|
||||
gameName: profile.gameName,
|
||||
themeText: profile.themeText,
|
||||
summary: profile.summary,
|
||||
tags: profile.tags,
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: profile.clearCount,
|
||||
difficulty: profile.difficulty,
|
||||
generatedItemAssets: [draftAsset],
|
||||
}}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /草莓/u }));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('match3d-model-preview').getAttribute('data-model-src'),
|
||||
).toBe(profileAsset.modelObjectKey);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
generatedItemAssets: [
|
||||
expect.objectContaining({
|
||||
itemId: 'match3d-item-1',
|
||||
modelObjectKey: profileAsset.modelObjectKey,
|
||||
status: 'model_ready',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('Rodin 图生模型提交前会把 generated 参考图转成 data URL', async () => {
|
||||
vi.mocked(hyper3dService.submitHyper3dImageToModel).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -393,6 +557,28 @@ describe('Match3DResultView', () => {
|
||||
],
|
||||
raw: {},
|
||||
});
|
||||
vi.mocked(match3dWorksService.updateMatch3DGeneratedItemAssets)
|
||||
.mockResolvedValue({
|
||||
item: createProfile({
|
||||
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: 'https://cdn.example.com/strawberry.glb',
|
||||
modelObjectKey: null,
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: 'task-image',
|
||||
subscriptionKey: 'sub-image',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
|
||||
render(
|
||||
@@ -440,6 +626,23 @@ describe('Match3DResultView', () => {
|
||||
expect(hyper3dService.getHyper3dDownloads).toHaveBeenCalledWith({
|
||||
taskUuid: 'task-image',
|
||||
});
|
||||
expect(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).toHaveBeenCalledWith(
|
||||
'match3d-profile-1',
|
||||
expect.objectContaining({
|
||||
generatedItemAssets: [
|
||||
expect.objectContaining({
|
||||
itemId: 'match3d-item-1',
|
||||
modelSrc: 'https://cdn.example.com/strawberry.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
status: 'model_ready',
|
||||
taskUuid: 'task-image',
|
||||
subscriptionKey: 'sub-image',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ImagePlus,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
Music,
|
||||
Play,
|
||||
Plus,
|
||||
Send,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
|
||||
import type {
|
||||
Hyper3dDownloadFilePayload,
|
||||
Hyper3dGenerationMode,
|
||||
@@ -33,12 +35,23 @@ import {
|
||||
getHyper3dTaskStatus,
|
||||
submitHyper3dImageToModel,
|
||||
} from '../../services/hyper3dModelGenerationService';
|
||||
import {
|
||||
createBackgroundMusicTask,
|
||||
createSoundEffectTask,
|
||||
publishBackgroundMusicAsset,
|
||||
publishSoundEffectAsset,
|
||||
waitForGeneratedAudioAsset,
|
||||
} from '../../services/creation-audio';
|
||||
import {
|
||||
publishMatch3DWork,
|
||||
generateMatch3DWorkTags,
|
||||
updateMatch3DGeneratedItemAssets,
|
||||
updateMatch3DWork,
|
||||
} from '../../services/match3d-works';
|
||||
import { readAssetBytes } from '../../services/assetReadUrlService';
|
||||
import {
|
||||
isGeneratedLegacyPath,
|
||||
readAssetBytes,
|
||||
} from '../../services/assetReadUrlService';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { Match3DModelPreview } from './Match3DModelPreview';
|
||||
|
||||
@@ -54,7 +67,7 @@ type Match3DResultViewProps = {
|
||||
};
|
||||
|
||||
type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type Match3DResultTab = 'work' | 'config' | 'assets';
|
||||
type Match3DResultTab = 'work' | 'config' | 'assets' | 'music';
|
||||
type Match3DAssetGenerationMode = Hyper3dGenerationMode;
|
||||
type Match3DAssetTaskStatus =
|
||||
| 'idle'
|
||||
@@ -78,6 +91,8 @@ type Match3DRodinAssetDraft = {
|
||||
status: Match3DAssetTaskStatus;
|
||||
progress: number | null;
|
||||
downloads: Hyper3dDownloadFilePayload[];
|
||||
backgroundMusic: CreationAudioAsset | null;
|
||||
clickSound: CreationAudioAsset | null;
|
||||
error: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
@@ -96,11 +111,14 @@ const MATCH3D_MIN_TAG_COUNT = 3;
|
||||
const MATCH3D_MAX_TAG_COUNT = 6;
|
||||
const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600;
|
||||
const MATCH3D_DEFAULT_ASSET_COUNT = 6;
|
||||
const MATCH3D_BACKGROUND_MUSIC_ASSET_KIND = 'match3d_background_music';
|
||||
const MATCH3D_CLICK_SOUND_ASSET_KIND = 'match3d_click_sound';
|
||||
|
||||
const MATCH3D_RESULT_TABS: Array<{ id: Match3DResultTab; label: string }> = [
|
||||
{ id: 'work', label: '作品信息' },
|
||||
{ id: 'config', label: '玩法配置' },
|
||||
{ id: 'assets', label: '3D素材' },
|
||||
{ id: 'music', label: '音乐' },
|
||||
];
|
||||
|
||||
function normalizeTags(value: string) {
|
||||
@@ -156,14 +174,58 @@ function normalizeMatch3DTagListText(value: string) {
|
||||
];
|
||||
}
|
||||
|
||||
function hasMatch3DGeneratedModelSource(asset: Match3DGeneratedItemAsset) {
|
||||
return Boolean(asset.modelSrc?.trim() || asset.modelObjectKey?.trim());
|
||||
}
|
||||
|
||||
function mergeMatch3DGeneratedItemAsset(
|
||||
base: Match3DGeneratedItemAsset,
|
||||
override: Match3DGeneratedItemAsset,
|
||||
): Match3DGeneratedItemAsset {
|
||||
const overrideHasModel = hasMatch3DGeneratedModelSource(override);
|
||||
return {
|
||||
...base,
|
||||
itemName: override.itemName.trim() || base.itemName,
|
||||
imageSrc: override.imageSrc?.trim()
|
||||
? override.imageSrc
|
||||
: base.imageSrc ?? null,
|
||||
imageObjectKey: override.imageObjectKey?.trim()
|
||||
? override.imageObjectKey
|
||||
: base.imageObjectKey ?? null,
|
||||
modelSrc: override.modelSrc?.trim()
|
||||
? override.modelSrc
|
||||
: base.modelSrc ?? null,
|
||||
modelObjectKey: override.modelObjectKey?.trim()
|
||||
? override.modelObjectKey
|
||||
: base.modelObjectKey ?? null,
|
||||
modelFileName: override.modelFileName?.trim()
|
||||
? override.modelFileName
|
||||
: base.modelFileName ?? null,
|
||||
taskUuid: override.taskUuid?.trim()
|
||||
? override.taskUuid
|
||||
: base.taskUuid ?? null,
|
||||
subscriptionKey: override.subscriptionKey?.trim()
|
||||
? override.subscriptionKey
|
||||
: base.subscriptionKey ?? null,
|
||||
backgroundMusic: override.backgroundMusic ?? base.backgroundMusic ?? null,
|
||||
clickSound: override.clickSound ?? base.clickSound ?? null,
|
||||
// 中文注释:草稿 response 可能只有图片;profile 里若已有模型,结果页和试玩不能被旧草稿快照覆盖回 image_ready。
|
||||
status:
|
||||
overrideHasModel && base.status !== 'model_ready'
|
||||
? 'model_ready'
|
||||
: base.status,
|
||||
error: override.error ?? base.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function createMatch3DAssetDrafts(
|
||||
profile: Match3DWorkProfile,
|
||||
draft: Match3DResultDraft | null = null,
|
||||
): Match3DRodinAssetDraft[] {
|
||||
const generatedAssets =
|
||||
draft?.generatedItemAssets?.length
|
||||
? draft.generatedItemAssets
|
||||
: profile.generatedItemAssets;
|
||||
const generatedAssets = resolveMatch3DResultGeneratedItemAssets(
|
||||
profile,
|
||||
draft,
|
||||
);
|
||||
if (generatedAssets?.length) {
|
||||
return generatedAssets.map((asset) =>
|
||||
createMatch3DAssetDraftFromGeneratedAsset(profile, asset),
|
||||
@@ -214,6 +276,8 @@ function createMatch3DAssetDrafts(
|
||||
status: 'idle',
|
||||
progress: null,
|
||||
downloads: [],
|
||||
backgroundMusic: null,
|
||||
clickSound: null,
|
||||
error: null,
|
||||
updatedAt: null,
|
||||
}));
|
||||
@@ -223,11 +287,13 @@ function createMatch3DAssetDraftFromGeneratedAsset(
|
||||
profile: Match3DWorkProfile,
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
): Match3DRodinAssetDraft {
|
||||
const downloads = asset.modelSrc
|
||||
const modelSource =
|
||||
asset.modelSrc?.trim() || asset.modelObjectKey?.trim() || '';
|
||||
const downloads = modelSource
|
||||
? [
|
||||
{
|
||||
name: asset.modelFileName ?? `${asset.itemName}.glb`,
|
||||
url: asset.modelSrc,
|
||||
url: modelSource,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
@@ -248,11 +314,50 @@ function createMatch3DAssetDraftFromGeneratedAsset(
|
||||
: normalizeMatch3DAssetStatus(asset.status),
|
||||
progress: asset.status === 'model_ready' ? 1 : null,
|
||||
downloads,
|
||||
backgroundMusic: asset.backgroundMusic ?? null,
|
||||
clickSound: asset.clickSound ?? null,
|
||||
error: asset.error ?? null,
|
||||
updatedAt: profile.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function createGeneratedAssetsFromDrafts(
|
||||
assetDrafts: Match3DRodinAssetDraft[],
|
||||
existingAssets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
): Match3DGeneratedItemAsset[] {
|
||||
const existingById = new Map(existingAssets.map((asset) => [asset.itemId, asset]));
|
||||
return assetDrafts.map((asset) => {
|
||||
const existing = existingById.get(asset.id);
|
||||
const modelFile = asset.downloads.find((file) => file.url.trim()) ?? null;
|
||||
const modelSource =
|
||||
modelFile?.url.trim() ||
|
||||
existing?.modelSrc?.trim() ||
|
||||
existing?.modelObjectKey?.trim() ||
|
||||
null;
|
||||
const modelObjectKey =
|
||||
modelFile?.url && isGeneratedLegacyPath(modelFile.url)
|
||||
? modelFile.url.trim().replace(/^\/+/u, '')
|
||||
: modelFile
|
||||
? null
|
||||
: existing?.modelObjectKey ?? null;
|
||||
return {
|
||||
itemId: asset.id,
|
||||
itemName: asset.name,
|
||||
imageSrc: existing?.imageSrc ?? (asset.referenceImageSrc || null),
|
||||
imageObjectKey: existing?.imageObjectKey ?? null,
|
||||
modelSrc: modelSource,
|
||||
modelObjectKey,
|
||||
modelFileName: modelFile?.name?.trim() || existing?.modelFileName || null,
|
||||
taskUuid: asset.taskUuid,
|
||||
subscriptionKey: asset.subscriptionKey,
|
||||
backgroundMusic: asset.backgroundMusic,
|
||||
clickSound: asset.clickSound,
|
||||
status: asset.status === 'done' ? 'model_ready' : asset.status,
|
||||
error: asset.error,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMatch3DAssetStatus(status: string): Match3DAssetTaskStatus {
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (
|
||||
@@ -467,13 +572,14 @@ async function resolveRodinReferenceImageDataUrl(source: string) {
|
||||
function buildPlayableProfile(
|
||||
profile: Match3DWorkProfile,
|
||||
editState: Match3DResultEditState,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
) {
|
||||
const payload = buildSavePayload(editState);
|
||||
if (!payload) {
|
||||
return profile;
|
||||
return attachMatch3DGeneratedItemAssets(profile, generatedItemAssets);
|
||||
}
|
||||
|
||||
return {
|
||||
return attachMatch3DGeneratedItemAssets({
|
||||
...profile,
|
||||
gameName: payload.gameName,
|
||||
themeText: payload.themeText ?? profile.themeText,
|
||||
@@ -482,6 +588,51 @@ function buildPlayableProfile(
|
||||
coverImageSrc: payload.coverImageSrc,
|
||||
clearCount: payload.clearCount,
|
||||
difficulty: payload.difficulty,
|
||||
}, generatedItemAssets);
|
||||
}
|
||||
|
||||
function resolveMatch3DResultGeneratedItemAssets(
|
||||
profile: Match3DWorkProfile,
|
||||
draft: Match3DResultDraft | null,
|
||||
) {
|
||||
const profileAssets = profile.generatedItemAssets ?? [];
|
||||
const draftAssets = draft?.generatedItemAssets ?? [];
|
||||
if (draftAssets.length <= 0) {
|
||||
return profileAssets;
|
||||
}
|
||||
if (profileAssets.length <= 0) {
|
||||
return draftAssets;
|
||||
}
|
||||
|
||||
const profileAssetsById = new Map(
|
||||
profileAssets.map((asset) => [asset.itemId, asset]),
|
||||
);
|
||||
const mergedAssets = draftAssets.map((draftAsset) => {
|
||||
const profileAsset = profileAssetsById.get(draftAsset.itemId);
|
||||
return profileAsset
|
||||
? mergeMatch3DGeneratedItemAsset(draftAsset, profileAsset)
|
||||
: draftAsset;
|
||||
});
|
||||
for (const profileAsset of profileAssets) {
|
||||
if (!mergedAssets.some((asset) => asset.itemId === profileAsset.itemId)) {
|
||||
mergedAssets.push(profileAsset);
|
||||
}
|
||||
}
|
||||
return mergedAssets;
|
||||
}
|
||||
|
||||
function attachMatch3DGeneratedItemAssets(
|
||||
profile: Match3DWorkProfile,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
if (generatedItemAssets.length <= 0) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
// 中文注释:试玩入口依赖当前页面可见的生成素材;保存接口若返回旧快照,不能把素材从运行态入参里丢掉。
|
||||
return {
|
||||
...profile,
|
||||
generatedItemAssets: [...generatedItemAssets],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -535,7 +686,7 @@ function Match3DResultTabs({
|
||||
onChange: (tab: Match3DResultTab) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
|
||||
<div className="mb-3 grid grid-cols-4 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
|
||||
{MATCH3D_RESULT_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -918,12 +1069,16 @@ function Match3DRodinAssetListCard({
|
||||
function Match3DRodinAssetDetail({
|
||||
asset,
|
||||
busy,
|
||||
soundBusy,
|
||||
onChange,
|
||||
onGenerateClickSound,
|
||||
onSubmit,
|
||||
}: {
|
||||
asset: Match3DRodinAssetDraft;
|
||||
busy: boolean;
|
||||
soundBusy: boolean;
|
||||
onChange: (asset: Match3DRodinAssetDraft) => void;
|
||||
onGenerateClickSound: (asset: Match3DRodinAssetDraft) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const canSubmit = Boolean(asset.referenceImageSrc.trim());
|
||||
@@ -963,6 +1118,35 @@ function Match3DRodinAssetDetail({
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
点击音效
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || soundBusy}
|
||||
onClick={() => onGenerateClickSound(asset)}
|
||||
className={`platform-icon-button h-9 w-9 ${busy || soundBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-label="生成点击音效"
|
||||
title="生成点击音效"
|
||||
>
|
||||
{soundBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Music className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{asset.clickSound?.audioSrc ? (
|
||||
<audio className="w-full" controls src={asset.clickSound.audioSrc} />
|
||||
) : (
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
暂无音效
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -973,15 +1157,19 @@ function Match3DAssetsTab({
|
||||
activeAssetId,
|
||||
assets,
|
||||
busyAssetId,
|
||||
soundBusyAssetId,
|
||||
onActiveAssetChange,
|
||||
onAssetChange,
|
||||
onGenerateClickSound,
|
||||
onSubmitAsset,
|
||||
}: {
|
||||
activeAssetId: string | null;
|
||||
assets: Match3DRodinAssetDraft[];
|
||||
busyAssetId: string | null;
|
||||
soundBusyAssetId: string | null;
|
||||
onActiveAssetChange: (assetId: string | null) => void;
|
||||
onAssetChange: (asset: Match3DRodinAssetDraft) => void;
|
||||
onGenerateClickSound: (asset: Match3DRodinAssetDraft) => void;
|
||||
onSubmitAsset: (assetId: string) => void;
|
||||
}) {
|
||||
const activeAsset = assets.find((asset) => asset.id === activeAssetId) ?? null;
|
||||
@@ -1006,7 +1194,9 @@ function Match3DAssetsTab({
|
||||
<Match3DRodinAssetDetail
|
||||
asset={activeAsset}
|
||||
busy={busyAssetId === activeAsset.id}
|
||||
soundBusy={soundBusyAssetId === activeAsset.id}
|
||||
onChange={onAssetChange}
|
||||
onGenerateClickSound={onGenerateClickSound}
|
||||
onSubmit={() => onSubmitAsset(activeAsset.id)}
|
||||
/>
|
||||
) : (
|
||||
@@ -1021,6 +1211,171 @@ function Match3DAssetsTab({
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DMusicTab({
|
||||
assetDrafts,
|
||||
editState,
|
||||
profileId,
|
||||
busy,
|
||||
onMusicGenerated,
|
||||
}: {
|
||||
assetDrafts: Match3DRodinAssetDraft[];
|
||||
editState: Match3DResultEditState;
|
||||
profileId: string;
|
||||
busy: boolean;
|
||||
onMusicGenerated: (music: CreationAudioAsset) => void;
|
||||
}) {
|
||||
const currentMusic = assetDrafts[0]?.backgroundMusic ?? null;
|
||||
const [prompt, setPrompt] = useState(() =>
|
||||
[
|
||||
editState.gameName.trim(),
|
||||
editState.themeText.trim(),
|
||||
editState.summary.trim(),
|
||||
'轻快、适合抓大鹅消除游戏循环播放的背景音乐',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(','),
|
||||
);
|
||||
const [title, setTitle] = useState(() =>
|
||||
`${editState.gameName.trim() || '抓大鹅'}背景音乐`.slice(0, 40),
|
||||
);
|
||||
const [tags, setTags] = useState('轻快, 休闲, 消除, instrumental');
|
||||
const [statusText, setStatusText] = useState<string | null>(null);
|
||||
const [errorText, setErrorText] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const canGenerate = prompt.trim().length > 0 && title.trim().length > 0;
|
||||
|
||||
const generateMusic = async () => {
|
||||
if (!canGenerate || isGenerating) {
|
||||
return;
|
||||
}
|
||||
setIsGenerating(true);
|
||||
setStatusText('生成中');
|
||||
setErrorText(null);
|
||||
try {
|
||||
const task = await createBackgroundMusicTask({
|
||||
prompt: prompt.trim(),
|
||||
title: title.trim(),
|
||||
tags: tags.trim() || null,
|
||||
});
|
||||
const asset = await waitForGeneratedAudioAsset(task.taskId, () =>
|
||||
publishBackgroundMusicAsset(task.taskId, {
|
||||
entityKind: 'match3d_work',
|
||||
entityId: profileId,
|
||||
slot: 'background_music',
|
||||
assetKind: MATCH3D_BACKGROUND_MUSIC_ASSET_KIND,
|
||||
profileId,
|
||||
storagePrefix: 'match3d_assets',
|
||||
}),
|
||||
);
|
||||
if (!asset.audioSrc) {
|
||||
throw new Error('音频生成完成但缺少播放地址。');
|
||||
}
|
||||
onMusicGenerated({
|
||||
taskId: asset.taskId,
|
||||
provider: asset.provider,
|
||||
assetObjectId: asset.assetObjectId ?? null,
|
||||
assetKind: asset.assetKind ?? MATCH3D_BACKGROUND_MUSIC_ASSET_KIND,
|
||||
audioSrc: asset.audioSrc,
|
||||
prompt: prompt.trim(),
|
||||
title: title.trim(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
setStatusText('已生成');
|
||||
} catch (caughtError) {
|
||||
setErrorText(
|
||||
caughtError instanceof Error ? caughtError.message : '背景音乐生成失败。',
|
||||
);
|
||||
setStatusText(null);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
背景音乐
|
||||
</div>
|
||||
{statusText ? (
|
||||
<span className="platform-pill platform-pill--cool px-3 py-1 text-[11px]">
|
||||
{statusText}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{currentMusic?.audioSrc ? (
|
||||
<audio className="mt-3 w-full" controls src={currentMusic.audioSrc} />
|
||||
) : (
|
||||
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
<Music className="h-4 w-4" />
|
||||
暂无音乐
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
曲名
|
||||
</span>
|
||||
<input
|
||||
value={title}
|
||||
disabled={busy || isGenerating}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="抓大鹅背景音乐曲名"
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
风格
|
||||
</span>
|
||||
<input
|
||||
value={tags}
|
||||
disabled={busy || isGenerating}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="抓大鹅背景音乐风格"
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={busy || isGenerating}
|
||||
rows={5}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="抓大鹅背景音乐提示词"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGenerate || busy || isGenerating}
|
||||
onClick={() => void generateMusic()}
|
||||
className={`platform-button platform-button--primary mt-3 min-h-11 w-full justify-center gap-2 ${!canGenerate || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Music className="h-4 w-4" />
|
||||
)}
|
||||
生成音乐
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{errorText ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{errorText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Match3DResultView({
|
||||
profile,
|
||||
draft = null,
|
||||
@@ -1038,6 +1393,7 @@ export function Match3DResultView({
|
||||
);
|
||||
const [activeAssetId, setActiveAssetId] = useState<string | null>(null);
|
||||
const [busyAssetId, setBusyAssetId] = useState<string | null>(null);
|
||||
const [soundBusyAssetId, setSoundBusyAssetId] = useState<string | null>(null);
|
||||
const [autoSaveState, setAutoSaveState] =
|
||||
useState<Match3DAutoSaveState>('idle');
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
@@ -1051,6 +1407,10 @@ export function Match3DResultView({
|
||||
);
|
||||
const canStartTestRun = testRunBlockers.length === 0;
|
||||
const canSubmit = blockers.length === 0;
|
||||
const generatedItemAssets = useMemo(
|
||||
() => resolveMatch3DResultGeneratedItemAssets(profile, draft),
|
||||
[draft?.generatedItemAssets, profile],
|
||||
);
|
||||
const totalItemCount =
|
||||
(normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) *
|
||||
3;
|
||||
@@ -1065,6 +1425,7 @@ export function Match3DResultView({
|
||||
setAssetDrafts(createMatch3DAssetDrafts(profile, draft));
|
||||
setActiveAssetId(null);
|
||||
setBusyAssetId(null);
|
||||
setSoundBusyAssetId(null);
|
||||
}, [draft?.generatedItemAssets, profile.generatedItemAssets, profile.profileId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1128,9 +1489,13 @@ export function Match3DResultView({
|
||||
setAutoSaveState('saving');
|
||||
setLocalError(null);
|
||||
const { item } = await updateMatch3DWork(profile.profileId, payload);
|
||||
const playableItem = attachMatch3DGeneratedItemAssets(
|
||||
item,
|
||||
generatedItemAssets,
|
||||
);
|
||||
setAutoSaveState('saved');
|
||||
onSaved?.(item);
|
||||
return item;
|
||||
onSaved?.(playableItem);
|
||||
return playableItem;
|
||||
};
|
||||
|
||||
const handleCoverImageChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -1211,6 +1576,102 @@ export function Match3DResultView({
|
||||
const getRodinAsset = (assetId: string) =>
|
||||
assetDrafts.find((asset) => asset.id === assetId) ?? null;
|
||||
|
||||
const persistAudioAssetDrafts = async (
|
||||
nextDrafts: Match3DRodinAssetDraft[],
|
||||
) => {
|
||||
return persistGeneratedAssetDrafts(nextDrafts);
|
||||
};
|
||||
|
||||
const persistGeneratedAssetDrafts = async (
|
||||
nextDrafts: Match3DRodinAssetDraft[],
|
||||
) => {
|
||||
const { item } = await updateMatch3DGeneratedItemAssets(profile.profileId, {
|
||||
generatedItemAssets: createGeneratedAssetsFromDrafts(
|
||||
nextDrafts,
|
||||
profile.generatedItemAssets ?? [],
|
||||
),
|
||||
});
|
||||
onSaved?.(item);
|
||||
return item;
|
||||
};
|
||||
|
||||
const patchAndPersistAudioAssetDrafts = async (
|
||||
patcher: (drafts: Match3DRodinAssetDraft[]) => Match3DRodinAssetDraft[],
|
||||
) => {
|
||||
const nextDrafts = patcher(assetDrafts);
|
||||
setAssetDrafts(nextDrafts);
|
||||
await persistAudioAssetDrafts(nextDrafts);
|
||||
};
|
||||
|
||||
const handleBackgroundMusicGenerated = async (music: CreationAudioAsset) => {
|
||||
try {
|
||||
await patchAndPersistAudioAssetDrafts((drafts) => {
|
||||
if (drafts.length <= 0) {
|
||||
return drafts;
|
||||
}
|
||||
return drafts.map((asset, index) =>
|
||||
index === 0 ? { ...asset, backgroundMusic: music } : asset,
|
||||
);
|
||||
});
|
||||
setLocalError(null);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '保存背景音乐失败。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateClickSound = async (asset: Match3DRodinAssetDraft) => {
|
||||
if (soundBusyAssetId) {
|
||||
return;
|
||||
}
|
||||
setSoundBusyAssetId(asset.id);
|
||||
setLocalError(null);
|
||||
try {
|
||||
const prompt = `${editState.themeText.trim() || '抓大鹅'}物体${asset.name.trim()}被点击消除时的短促反馈音效,清脆、可爱、适合移动端休闲游戏。`;
|
||||
const task = await createSoundEffectTask({
|
||||
prompt,
|
||||
duration: 3,
|
||||
});
|
||||
const generated = await waitForGeneratedAudioAsset(task.taskId, () =>
|
||||
publishSoundEffectAsset(task.taskId, {
|
||||
entityKind: 'match3d_item',
|
||||
entityId: asset.id,
|
||||
slot: 'click_sound',
|
||||
assetKind: MATCH3D_CLICK_SOUND_ASSET_KIND,
|
||||
profileId: profile.profileId,
|
||||
storagePrefix: 'match3d_assets',
|
||||
}),
|
||||
);
|
||||
if (!generated.audioSrc) {
|
||||
throw new Error('音效生成完成但缺少播放地址。');
|
||||
}
|
||||
const clickSound: CreationAudioAsset = {
|
||||
taskId: generated.taskId,
|
||||
provider: generated.provider,
|
||||
assetObjectId: generated.assetObjectId ?? null,
|
||||
assetKind: generated.assetKind ?? MATCH3D_CLICK_SOUND_ASSET_KIND,
|
||||
audioSrc: generated.audioSrc,
|
||||
prompt,
|
||||
title: `${asset.name}点击音效`,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await patchAndPersistAudioAssetDrafts((drafts) =>
|
||||
drafts.map((draftAsset) =>
|
||||
draftAsset.id === asset.id
|
||||
? { ...draftAsset, clickSound, updatedAt: new Date().toISOString() }
|
||||
: draftAsset,
|
||||
),
|
||||
);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '点击音效生成失败。',
|
||||
);
|
||||
} finally {
|
||||
setSoundBusyAssetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitRodinAsset = async (assetId: string) => {
|
||||
const asset = getRodinAsset(assetId);
|
||||
if (!asset || busyAssetId) {
|
||||
@@ -1302,11 +1763,24 @@ export function Match3DResultView({
|
||||
if (!modelFile) {
|
||||
throw new Error('Hyper3D 已完成但未返回可下载模型文件。');
|
||||
}
|
||||
patchRodinAsset(assetId, {
|
||||
status: 'done',
|
||||
downloads: [modelFile],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
const updatedAt = new Date().toISOString();
|
||||
const nextDrafts = assetDrafts.map((draftAsset) =>
|
||||
draftAsset.id === assetId
|
||||
? {
|
||||
...draftAsset,
|
||||
mode: 'image-to-model' as const,
|
||||
taskUuid: response.taskUuid,
|
||||
subscriptionKey: response.subscriptionKey,
|
||||
status: 'done' as const,
|
||||
progress: 1,
|
||||
downloads: [modelFile],
|
||||
error: null,
|
||||
updatedAt,
|
||||
}
|
||||
: draftAsset,
|
||||
);
|
||||
setAssetDrafts(nextDrafts);
|
||||
await persistGeneratedAssetDrafts(nextDrafts);
|
||||
} catch (caughtError) {
|
||||
patchRodinAsset(assetId, {
|
||||
status: 'failed',
|
||||
@@ -1330,7 +1804,10 @@ export function Match3DResultView({
|
||||
setIsStartingTestRun(true);
|
||||
try {
|
||||
const savedProfile = await saveNow();
|
||||
onStartTestRun(savedProfile ?? buildPlayableProfile(profile, editState));
|
||||
onStartTestRun(
|
||||
savedProfile ??
|
||||
buildPlayableProfile(profile, editState, generatedItemAssets),
|
||||
);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '启动试玩前保存失败。',
|
||||
@@ -1363,7 +1840,12 @@ export function Match3DResultView({
|
||||
}
|
||||
};
|
||||
|
||||
const busy = isBusy || isPublishing || isStartingTestRun || Boolean(busyAssetId);
|
||||
const busy =
|
||||
isBusy ||
|
||||
isPublishing ||
|
||||
isStartingTestRun ||
|
||||
Boolean(busyAssetId) ||
|
||||
Boolean(soundBusyAssetId);
|
||||
const workBusy = busy || isGeneratingTags;
|
||||
const displayError = error ?? localError;
|
||||
|
||||
@@ -1402,13 +1884,28 @@ export function Match3DResultView({
|
||||
activeAssetId={activeAssetId}
|
||||
assets={assetDrafts}
|
||||
busyAssetId={busyAssetId}
|
||||
soundBusyAssetId={soundBusyAssetId}
|
||||
onActiveAssetChange={setActiveAssetId}
|
||||
onAssetChange={updateRodinAsset}
|
||||
onGenerateClickSound={(asset) => {
|
||||
void handleGenerateClickSound(asset);
|
||||
}}
|
||||
onSubmitAsset={(assetId) => {
|
||||
void handleSubmitRodinAsset(assetId);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'music' ? (
|
||||
<Match3DMusicTab
|
||||
assetDrafts={assetDrafts}
|
||||
editState={editState}
|
||||
profileId={profile.profileId}
|
||||
busy={busy}
|
||||
onMusicGenerated={(music) => {
|
||||
void handleBackgroundMusicGenerated(music);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{displayError ? (
|
||||
|
||||
@@ -189,7 +189,7 @@ function resolveMatch3DGeneratedModelTypeIds(items: Match3DItemSnapshot[]) {
|
||||
].sort(compareMatch3DGeneratedTypeId);
|
||||
}
|
||||
|
||||
function buildMatch3DGeneratedAssetTypeMap(
|
||||
export function buildMatch3DGeneratedAssetTypeMap(
|
||||
run: Match3DRunSnapshot,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
) {
|
||||
@@ -1467,7 +1467,7 @@ function relayoutTrayPreviewEntries(runtime: TrayPreviewRuntime) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildMatch3DTrayModelSourceMap(
|
||||
export function buildMatch3DTrayModelSourceMap(
|
||||
referenceItems: Match3DItemSnapshot[],
|
||||
slotItems: Array<Match3DItemSnapshot | null>,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
|
||||
@@ -4,6 +4,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useEffect } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Match3DGeneratedItemAsset,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DRunSnapshot,
|
||||
@@ -21,7 +24,9 @@ import {
|
||||
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE,
|
||||
applyMatch3DRendererCanvasLayout,
|
||||
buildMatch3DGeneratedAssetTypeMap,
|
||||
buildMatch3DPhysicsEntrySignature,
|
||||
buildMatch3DTrayModelSourceMap,
|
||||
createMatch3DCannonShape,
|
||||
createMatch3DThreeGeometry,
|
||||
measureMatch3DItemPreviewDimension,
|
||||
@@ -222,6 +227,62 @@ test('3D 物理条目签名随 run 和视觉资源变化,避免旧模型复用
|
||||
);
|
||||
});
|
||||
|
||||
test('生成模型按运行态类型编号映射,并兼容仅 modelObjectKey 的历史素材', () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
run.items = run.items.slice(0, 3).map((item, index) => ({
|
||||
...item,
|
||||
itemInstanceId: `generated-model-${index}`,
|
||||
itemTypeId:
|
||||
index === 0
|
||||
? 'match3d-type-02'
|
||||
: index === 1
|
||||
? 'match3d-type-01'
|
||||
: 'match3d-type-03',
|
||||
visualKey: 'block-red-2x4',
|
||||
}));
|
||||
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
status: 'model_ready',
|
||||
modelSrc: null,
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
},
|
||||
{
|
||||
itemId: 'match3d-item-2',
|
||||
itemName: '苹果',
|
||||
status: 'model_ready',
|
||||
modelSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-2-item/model/model.glb',
|
||||
modelObjectKey: null,
|
||||
},
|
||||
];
|
||||
|
||||
const boardMap = buildMatch3DGeneratedAssetTypeMap(
|
||||
run,
|
||||
generatedItemAssets,
|
||||
);
|
||||
const trayMap = buildMatch3DTrayModelSourceMap(
|
||||
run.items,
|
||||
[],
|
||||
generatedItemAssets,
|
||||
);
|
||||
|
||||
expect(boardMap.get('match3d-type-01')?.modelSrc).toBe(
|
||||
generatedItemAssets[0]!.modelObjectKey,
|
||||
);
|
||||
expect(boardMap.get('match3d-type-02')?.modelSrc).toBe(
|
||||
generatedItemAssets[1]!.modelSrc,
|
||||
);
|
||||
expect(trayMap.get('match3d-type-01')).toBe(
|
||||
generatedItemAssets[0]!.modelObjectKey,
|
||||
);
|
||||
expect(trayMap.get('match3d-type-02')).toBe(
|
||||
generatedItemAssets[1]!.modelSrc,
|
||||
);
|
||||
});
|
||||
|
||||
test('本地试玩按消除次数生成类型并在 25 类封顶', () => {
|
||||
const smallRun = startLocalMatch3DRun(12);
|
||||
const largeRun = startLocalMatch3DRun(100);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
Match3DVisualIcon,
|
||||
resolveVisualSeed,
|
||||
} from './match3dVisualAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
|
||||
type Match3DRuntimeShellProps = {
|
||||
run: Match3DRunSnapshot | null;
|
||||
@@ -85,6 +86,7 @@ function resolveTrayPreviewItem(
|
||||
}
|
||||
|
||||
const MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT = true;
|
||||
const DEFAULT_MATCH3D_MUSIC_VOLUME = 0.42;
|
||||
|
||||
function formatTimer(value: number) {
|
||||
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
|
||||
@@ -312,7 +314,10 @@ export function Match3DRuntimeShell({
|
||||
onClickItem,
|
||||
onTimeExpired,
|
||||
}: Match3DRuntimeShellProps) {
|
||||
const authUi = useAuthUi();
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const backgroundAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const clickAudioRefs = useRef<Record<string, HTMLAudioElement>>({});
|
||||
const [pendingClick, setPendingClick] = useState<PendingClick | null>(null);
|
||||
const [feedbackEvent, setFeedbackEvent] =
|
||||
useState<Match3DFeedbackEvent | null>(null);
|
||||
@@ -365,6 +370,60 @@ export function Match3DRuntimeShell({
|
||||
}, [run]);
|
||||
|
||||
const shouldUse3DRender = !force2DRender;
|
||||
const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME;
|
||||
const backgroundMusicSrc =
|
||||
generatedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
|
||||
?.backgroundMusic?.audioSrc ?? null;
|
||||
const clickSoundByTypeId = useMemo(() => {
|
||||
if (!run) {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
const readyAssets = generatedItemAssets.filter(
|
||||
(asset) => asset.clickSound?.audioSrc,
|
||||
);
|
||||
const sortedTypes = [
|
||||
...new Set(run.items.map((item) => item.itemTypeId)),
|
||||
].sort();
|
||||
return new Map(
|
||||
sortedTypes.flatMap((typeId, index) => {
|
||||
const src = readyAssets[index]?.clickSound?.audioSrc?.trim();
|
||||
return src ? [[typeId, src] as const] : [];
|
||||
}),
|
||||
);
|
||||
}, [generatedItemAssets, run]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = backgroundAudioRef.current;
|
||||
if (!audio || !backgroundMusicSrc || !run || !isRunState(run.status, 'running')) {
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
}
|
||||
return;
|
||||
}
|
||||
audio.volume = Math.max(0, Math.min(1, musicVolume));
|
||||
void audio.play().catch(() => {});
|
||||
}, [backgroundMusicSrc, musicVolume, run]);
|
||||
|
||||
useEffect(() => {
|
||||
Object.values(clickAudioRefs.current).forEach((audio) => {
|
||||
audio.volume = Math.max(0, Math.min(1, musicVolume));
|
||||
});
|
||||
}, [musicVolume]);
|
||||
|
||||
const playClickSound = useCallback(
|
||||
(item: Match3DItemSnapshot) => {
|
||||
const src = clickSoundByTypeId.get(item.itemTypeId);
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
const current = clickAudioRefs.current[src] ?? new Audio(src);
|
||||
clickAudioRefs.current[src] = current;
|
||||
current.currentTime = 0;
|
||||
current.volume = Math.max(0, Math.min(1, musicVolume));
|
||||
void current.play().catch(() => {});
|
||||
},
|
||||
[clickSoundByTypeId, musicVolume],
|
||||
);
|
||||
const handleTrayPreviewFallback = useCallback(() => {
|
||||
setForce2DRender(true);
|
||||
}, []);
|
||||
@@ -382,6 +441,7 @@ export function Match3DRuntimeShell({
|
||||
const optimisticRun = buildOptimisticRun(run, item);
|
||||
const clientEventId = buildClientEventId(item.itemInstanceId);
|
||||
// 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。
|
||||
playClickSound(item);
|
||||
setPendingClick({
|
||||
clientEventId,
|
||||
itemInstanceId: item.itemInstanceId,
|
||||
@@ -447,6 +507,14 @@ export function Match3DRuntimeShell({
|
||||
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#16221f] text-white`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
|
||||
{backgroundMusicSrc ? (
|
||||
<audio
|
||||
ref={backgroundAudioRef}
|
||||
src={backgroundMusicSrc}
|
||||
loop
|
||||
preload="auto"
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]`}
|
||||
style={{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
|
||||
@@ -31,6 +31,7 @@ function TestHarness({
|
||||
options?: { onUpdate?: (text: string) => void },
|
||||
) => Promise<TestSession>;
|
||||
}) {
|
||||
const hasOpenedRef = useRef(false);
|
||||
const flow = usePlatformCreationAgentFlowController<
|
||||
TestSession,
|
||||
Record<string, never>,
|
||||
@@ -80,8 +81,12 @@ function TestHarness({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
hasOpenedRef.current = true;
|
||||
void flow.openWorkspace({});
|
||||
}, []);
|
||||
}, [flow]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -98,7 +103,9 @@ function TestHarness({
|
||||
</button>
|
||||
<div data-testid="messages">
|
||||
{flow.session?.messages.map((message) => (
|
||||
<div key={message.id}>{`${message.role}:${message.kind}:${message.text}`}</div>
|
||||
<div
|
||||
key={message.id}
|
||||
>{`${message.role}:${message.kind}:${message.text}`}</div>
|
||||
))}
|
||||
</div>
|
||||
{flow.error ? <div>{flow.error}</div> : null}
|
||||
@@ -106,6 +113,193 @@ function TestHarness({
|
||||
);
|
||||
}
|
||||
|
||||
type ActionTestSession = TestSession & {
|
||||
draft?: { profileId: string } | null;
|
||||
};
|
||||
|
||||
function ActionErrorHarness({
|
||||
onActionError,
|
||||
}: {
|
||||
onActionError: (params: {
|
||||
session: ActionTestSession;
|
||||
setSession: (session: ActionTestSession) => void;
|
||||
}) => void;
|
||||
}) {
|
||||
const [stage, setStage] = useState('platform');
|
||||
const hasOpenedRef = useRef(false);
|
||||
const flow = usePlatformCreationAgentFlowController<
|
||||
ActionTestSession,
|
||||
Record<string, never>,
|
||||
{ session: ActionTestSession },
|
||||
TestMessagePayload,
|
||||
{ action: string },
|
||||
{ session: ActionTestSession }
|
||||
>({
|
||||
client: {
|
||||
createSession: async () => ({
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-draft-1' },
|
||||
},
|
||||
}),
|
||||
getSession: async () => ({
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-draft-1' },
|
||||
},
|
||||
}),
|
||||
streamMessage: async () => ({
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-draft-1' },
|
||||
}),
|
||||
executeAction: async () => {
|
||||
throw new Error('模型生成失败');
|
||||
},
|
||||
selectSession: (response) => response.session,
|
||||
},
|
||||
createPayload: {},
|
||||
workspaceStage: 'match3d-agent-workspace',
|
||||
resultStage: 'match3d-result',
|
||||
platformStage: 'platform',
|
||||
isCompileAction: () => true,
|
||||
resolveErrorMessage: (error, fallback) =>
|
||||
error instanceof Error ? error.message : fallback,
|
||||
errorMessages: {
|
||||
open: '打开失败',
|
||||
restoreMissingSession: '缺少会话',
|
||||
restore: '恢复失败',
|
||||
submit: '发送失败',
|
||||
execute: '执行失败',
|
||||
},
|
||||
enterCreateTab: () => {},
|
||||
setSelectionStage: setStage,
|
||||
onActionError: ({ session, setSession }) => {
|
||||
onActionError({ session, setSession });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
hasOpenedRef.current = true;
|
||||
void flow.openWorkspace({});
|
||||
}, [flow]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void flow.executeAction({ action: 'match3d_compile_draft' });
|
||||
}}
|
||||
>
|
||||
执行
|
||||
</button>
|
||||
<div data-testid="stage">{stage}</div>
|
||||
<div data-testid="profile">
|
||||
{flow.session?.draft?.profileId ?? 'missing'}
|
||||
</div>
|
||||
{flow.error ? <div>{flow.error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionCompleteHarness({
|
||||
onActionComplete,
|
||||
}: {
|
||||
onActionComplete: () => { openResult?: boolean } | void;
|
||||
}) {
|
||||
const [stage, setStage] = useState('platform');
|
||||
const hasOpenedRef = useRef(false);
|
||||
const flow = usePlatformCreationAgentFlowController<
|
||||
ActionTestSession,
|
||||
Record<string, never>,
|
||||
{ session: ActionTestSession },
|
||||
TestMessagePayload,
|
||||
{ action: string },
|
||||
{ session: ActionTestSession }
|
||||
>({
|
||||
client: {
|
||||
createSession: async () => ({
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-draft-1' },
|
||||
},
|
||||
}),
|
||||
getSession: async () => ({
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-draft-1' },
|
||||
},
|
||||
}),
|
||||
streamMessage: async () => ({
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-draft-1' },
|
||||
}),
|
||||
executeAction: async () => ({
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-ready-1' },
|
||||
},
|
||||
}),
|
||||
selectSession: (response) => response.session,
|
||||
},
|
||||
createPayload: {},
|
||||
workspaceStage: 'match3d-agent-workspace',
|
||||
resultStage: 'match3d-result',
|
||||
platformStage: 'platform',
|
||||
isCompileAction: () => true,
|
||||
resolveErrorMessage: (error, fallback) =>
|
||||
error instanceof Error ? error.message : fallback,
|
||||
errorMessages: {
|
||||
open: '打开失败',
|
||||
restoreMissingSession: '缺少会话',
|
||||
restore: '恢复失败',
|
||||
submit: '发送失败',
|
||||
execute: '执行失败',
|
||||
},
|
||||
enterCreateTab: () => {},
|
||||
setSelectionStage: setStage,
|
||||
onActionComplete: ({ setSession, response }) => {
|
||||
setSession(response.session);
|
||||
return onActionComplete();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
hasOpenedRef.current = true;
|
||||
void flow.openWorkspace({});
|
||||
}, [flow]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void flow.executeAction({ action: 'match3d_compile_draft' });
|
||||
}}
|
||||
>
|
||||
执行
|
||||
</button>
|
||||
<div data-testid="stage">{stage}</div>
|
||||
<div data-testid="profile">
|
||||
{flow.session?.draft?.profileId ?? 'missing'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
test('creation agent flow preserves streamed assistant text when stream fails', async () => {
|
||||
const streamMessage = vi.fn(async (_sessionId, _payload, options) => {
|
||||
options?.onUpdate?.('先把方洞万能的反差定住。');
|
||||
@@ -123,9 +317,7 @@ test('creation agent flow preserves streamed assistant text when stream fails',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('方洞挑战聊天生成失败:LLM 请求超时'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('方洞挑战聊天生成失败:LLM 请求超时')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('messages').textContent).toContain(
|
||||
@@ -135,3 +327,67 @@ test('creation agent flow preserves streamed assistant text when stream fails',
|
||||
'assistant:warning:先把方洞万能的反差定住。',
|
||||
);
|
||||
});
|
||||
|
||||
test('creation agent flow exposes session setter after compile action fails', async () => {
|
||||
const onActionError = vi.fn(
|
||||
({
|
||||
setSession,
|
||||
}: {
|
||||
session: ActionTestSession;
|
||||
setSession: (session: ActionTestSession) => void;
|
||||
}) => {
|
||||
setSession({
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
draft: { profileId: 'profile-after-failure' },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
render(<ActionErrorHarness onActionError={onActionError} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '执行' })).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole('button', { name: '执行' }).click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('模型生成失败')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(onActionError).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('profile').textContent).toBe(
|
||||
'profile-after-failure',
|
||||
);
|
||||
expect(screen.getByTestId('stage').textContent).toBe(
|
||||
'match3d-agent-workspace',
|
||||
);
|
||||
});
|
||||
|
||||
test('creation agent flow suppresses compile result stage for background completion', async () => {
|
||||
const onActionComplete = vi.fn(() => ({ openResult: false }));
|
||||
|
||||
render(<ActionCompleteHarness onActionComplete={onActionComplete} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('stage').textContent).toBe(
|
||||
'match3d-agent-workspace',
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole('button', { name: '执行' }).click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onActionComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('profile').textContent).toBe('profile-ready-1');
|
||||
expect(screen.getByTestId('stage').textContent).toBe(
|
||||
'match3d-agent-workspace',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -75,16 +75,16 @@ type PlatformCreationAgentFlowControllerOptions<
|
||||
enterCreateTab: () => void;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
onSessionOpened?: () => void;
|
||||
onOpenError?: (params: {
|
||||
error: unknown;
|
||||
errorMessage: string;
|
||||
}) => void;
|
||||
onOpenError?: (params: { error: unknown; errorMessage: string }) => void;
|
||||
onActionComplete?: (params: {
|
||||
payload: TActionPayload;
|
||||
response: TActionResponse;
|
||||
session: TSession;
|
||||
setSession: (session: TSession) => void;
|
||||
}) => Promise<void> | void;
|
||||
}) =>
|
||||
| Promise<{ openResult?: boolean } | void>
|
||||
| { openResult?: boolean }
|
||||
| void;
|
||||
beforeExecuteAction?: (params: {
|
||||
payload: TActionPayload;
|
||||
session: TSession;
|
||||
@@ -93,12 +93,14 @@ type PlatformCreationAgentFlowControllerOptions<
|
||||
payload: TActionPayload;
|
||||
error: unknown;
|
||||
errorMessage: string;
|
||||
}) => void;
|
||||
session: TSession;
|
||||
setSession: (session: TSession) => void;
|
||||
}) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function buildOptimisticMessage<TMessagePayload extends CreationAgentMessageLike>(
|
||||
payload: TMessagePayload,
|
||||
) {
|
||||
function buildOptimisticMessage<
|
||||
TMessagePayload extends CreationAgentMessageLike,
|
||||
>(payload: TMessagePayload) {
|
||||
return {
|
||||
id: payload.clientMessageId,
|
||||
role: 'user',
|
||||
@@ -157,40 +159,43 @@ export function usePlatformCreationAgentFlowController<
|
||||
setIsStreamingReply(false);
|
||||
}, []);
|
||||
|
||||
const openWorkspace = useCallback(async (createPayload?: TCreatePayload) => {
|
||||
if (isBusy) {
|
||||
return null;
|
||||
}
|
||||
const openWorkspace = useCallback(
|
||||
async (createPayload?: TCreatePayload) => {
|
||||
if (isBusy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
resetStreamingReply();
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
resetStreamingReply();
|
||||
|
||||
try {
|
||||
const response = await options.client.createSession(
|
||||
createPayload ?? options.createPayload,
|
||||
);
|
||||
const nextSession = options.client.selectSession(response);
|
||||
setSession(nextSession);
|
||||
options.enterCreateTab();
|
||||
options.onSessionOpened?.();
|
||||
options.setSelectionStage(options.workspaceStage);
|
||||
return nextSession;
|
||||
} catch (caughtError) {
|
||||
const errorMessage = options.resolveErrorMessage(
|
||||
caughtError,
|
||||
options.errorMessages.open,
|
||||
);
|
||||
setError(errorMessage);
|
||||
options.onOpenError?.({
|
||||
error: caughtError,
|
||||
errorMessage,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [isBusy, options, resetStreamingReply]);
|
||||
try {
|
||||
const response = await options.client.createSession(
|
||||
createPayload ?? options.createPayload,
|
||||
);
|
||||
const nextSession = options.client.selectSession(response);
|
||||
setSession(nextSession);
|
||||
options.enterCreateTab();
|
||||
options.onSessionOpened?.();
|
||||
options.setSelectionStage(options.workspaceStage);
|
||||
return nextSession;
|
||||
} catch (caughtError) {
|
||||
const errorMessage = options.resolveErrorMessage(
|
||||
caughtError,
|
||||
options.errorMessages.open,
|
||||
);
|
||||
setError(errorMessage);
|
||||
options.onOpenError?.({
|
||||
error: caughtError,
|
||||
errorMessage,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[isBusy, options, resetStreamingReply],
|
||||
);
|
||||
|
||||
const restoreDraft = useCallback(
|
||||
async (sessionId: string | null | undefined) => {
|
||||
@@ -215,7 +220,10 @@ export function usePlatformCreationAgentFlowController<
|
||||
return nextSession;
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
options.resolveErrorMessage(caughtError, options.errorMessages.restore),
|
||||
options.resolveErrorMessage(
|
||||
caughtError,
|
||||
options.errorMessages.restore,
|
||||
),
|
||||
);
|
||||
options.enterCreateTab();
|
||||
options.setSelectionStage(options.platformStage);
|
||||
@@ -259,8 +267,7 @@ export function usePlatformCreationAgentFlowController<
|
||||
setSession(nextSession);
|
||||
updateStreamingReplyText('');
|
||||
} catch (caughtError) {
|
||||
const interruptedReplyText =
|
||||
latestStreamingReplyTextRef.current.trim();
|
||||
const interruptedReplyText = latestStreamingReplyTextRef.current.trim();
|
||||
// 上游流可能在已经吐出可读回复后才失败;把这段回复落进本地消息列表,避免 UI 收尾时突然消失。
|
||||
if (interruptedReplyText) {
|
||||
const interruptedMessage =
|
||||
@@ -276,7 +283,10 @@ export function usePlatformCreationAgentFlowController<
|
||||
);
|
||||
}
|
||||
setError(
|
||||
options.resolveErrorMessage(caughtError, options.errorMessages.submit),
|
||||
options.resolveErrorMessage(
|
||||
caughtError,
|
||||
options.errorMessages.submit,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsStreamingReply(false);
|
||||
@@ -301,13 +311,17 @@ export function usePlatformCreationAgentFlowController<
|
||||
targetSession.sessionId,
|
||||
payload,
|
||||
);
|
||||
await options.onActionComplete?.({
|
||||
const actionCompleteResult = await options.onActionComplete?.({
|
||||
payload,
|
||||
response,
|
||||
session: targetSession,
|
||||
setSession,
|
||||
});
|
||||
if (options.isCompileAction(payload)) {
|
||||
if (
|
||||
options.isCompileAction(payload) &&
|
||||
(typeof actionCompleteResult !== 'object' ||
|
||||
actionCompleteResult?.openResult !== false)
|
||||
) {
|
||||
options.setSelectionStage(options.resultStage);
|
||||
}
|
||||
} catch (caughtError) {
|
||||
@@ -316,10 +330,12 @@ export function usePlatformCreationAgentFlowController<
|
||||
options.errorMessages.execute,
|
||||
);
|
||||
setError(errorMessage);
|
||||
options.onActionError?.({
|
||||
await options.onActionError?.({
|
||||
payload,
|
||||
error: caughtError,
|
||||
errorMessage,
|
||||
session: targetSession,
|
||||
setSession,
|
||||
});
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Music,
|
||||
Play,
|
||||
Plus,
|
||||
Sparkles,
|
||||
@@ -15,12 +16,18 @@ import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
PuzzleDraftLevel,
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import {
|
||||
createBackgroundMusicTask,
|
||||
publishBackgroundMusicAsset,
|
||||
waitForGeneratedAudioAsset,
|
||||
} from '../../services/creation-audio';
|
||||
import { updatePuzzleWork } from '../../services/puzzle-works';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
@@ -51,7 +58,7 @@ type PuzzleResultViewProps = {
|
||||
};
|
||||
|
||||
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type PuzzleResultTab = 'levels' | 'work';
|
||||
type PuzzleResultTab = 'levels' | 'work' | 'music';
|
||||
|
||||
type DraftEditState = {
|
||||
workTitle: string;
|
||||
@@ -65,6 +72,8 @@ const PUZZLE_MAX_THEME_TAG_COUNT = 6;
|
||||
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
|
||||
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
|
||||
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
|
||||
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND = 'puzzle_background_music';
|
||||
const PUZZLE_BACKGROUND_MUSIC_SLOT = 'background_music';
|
||||
|
||||
type PuzzleLevelGenerationRuntime = {
|
||||
startedAtMs: number;
|
||||
@@ -164,6 +173,7 @@ function normalizeDraftLevels(draft: PuzzleResultDraft) {
|
||||
selectedCandidateId: level.selectedCandidateId ?? null,
|
||||
coverImageSrc: level.coverImageSrc ?? null,
|
||||
coverAssetId: level.coverAssetId ?? null,
|
||||
backgroundMusic: level.backgroundMusic ?? null,
|
||||
generationStatus: level.generationStatus || 'idle',
|
||||
}));
|
||||
}
|
||||
@@ -267,6 +277,7 @@ function createBlankPuzzleLevel(
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
backgroundMusic: null,
|
||||
generationStatus: 'idle',
|
||||
};
|
||||
}
|
||||
@@ -370,10 +381,11 @@ function PuzzleResultTabs({
|
||||
onChange: (tab: PuzzleResultTab) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
|
||||
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
|
||||
{[
|
||||
{ id: 'levels' as const, label: '拼图关卡' },
|
||||
{ id: 'work' as const, label: '作品信息' },
|
||||
{ id: 'music' as const, label: '音乐' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -1315,6 +1327,189 @@ function PuzzleWorkInfoTab({
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleMusicTab({
|
||||
editState,
|
||||
profileId,
|
||||
sessionId,
|
||||
isBusy,
|
||||
onChange,
|
||||
}: {
|
||||
editState: DraftEditState;
|
||||
profileId: string | null;
|
||||
sessionId: string;
|
||||
isBusy: boolean;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
}) {
|
||||
const currentMusic = editState.levels[0]?.backgroundMusic ?? null;
|
||||
const [prompt, setPrompt] = useState(() =>
|
||||
[
|
||||
editState.workTitle.trim(),
|
||||
editState.workDescription.trim(),
|
||||
editState.themeTags.join(','),
|
||||
'轻快、适合拼图游戏循环播放的背景音乐',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(','),
|
||||
);
|
||||
const [title, setTitle] = useState(() =>
|
||||
`${editState.workTitle.trim() || '拼图'}背景音乐`.slice(0, 40),
|
||||
);
|
||||
const [tags, setTags] = useState('轻快, 游戏, 循环, instrumental');
|
||||
const [statusText, setStatusText] = useState<string | null>(null);
|
||||
const [errorText, setErrorText] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const canGenerate = prompt.trim().length > 0 && title.trim().length > 0;
|
||||
const writeMusic = (music: CreationAudioAsset) => {
|
||||
const firstLevel = editState.levels[0];
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...editState,
|
||||
levels: [
|
||||
{ ...firstLevel, backgroundMusic: music },
|
||||
...editState.levels.slice(1),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const generateMusic = async () => {
|
||||
if (!canGenerate || isGenerating || !editState.levels[0]) {
|
||||
return;
|
||||
}
|
||||
setIsGenerating(true);
|
||||
setStatusText('生成中');
|
||||
setErrorText(null);
|
||||
try {
|
||||
const task = await createBackgroundMusicTask({
|
||||
prompt: prompt.trim(),
|
||||
title: title.trim(),
|
||||
tags: tags.trim() || null,
|
||||
});
|
||||
const asset = await waitForGeneratedAudioAsset(task.taskId, () =>
|
||||
publishBackgroundMusicAsset(task.taskId, {
|
||||
entityKind: 'puzzle_work',
|
||||
entityId: profileId ?? sessionId,
|
||||
slot: PUZZLE_BACKGROUND_MUSIC_SLOT,
|
||||
assetKind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND,
|
||||
profileId,
|
||||
storagePrefix: 'puzzle_assets',
|
||||
}),
|
||||
);
|
||||
if (!asset.audioSrc) {
|
||||
throw new Error('音频生成完成但缺少播放地址。');
|
||||
}
|
||||
writeMusic({
|
||||
taskId: asset.taskId,
|
||||
provider: asset.provider,
|
||||
assetObjectId: asset.assetObjectId ?? null,
|
||||
assetKind: asset.assetKind ?? PUZZLE_BACKGROUND_MUSIC_ASSET_KIND,
|
||||
audioSrc: asset.audioSrc,
|
||||
prompt: prompt.trim(),
|
||||
title: title.trim(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
setStatusText('已生成');
|
||||
} catch (caughtError) {
|
||||
setErrorText(
|
||||
caughtError instanceof Error ? caughtError.message : '背景音乐生成失败。',
|
||||
);
|
||||
setStatusText(null);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
背景音乐
|
||||
</div>
|
||||
{statusText ? (
|
||||
<span className="platform-pill platform-pill--cool px-3 py-1 text-[11px]">
|
||||
{statusText}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{currentMusic?.audioSrc ? (
|
||||
<audio
|
||||
className="mt-3 w-full"
|
||||
controls
|
||||
src={currentMusic.audioSrc}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
<Music className="h-4 w-4" />
|
||||
暂无音乐
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
曲名
|
||||
</span>
|
||||
<input
|
||||
value={title}
|
||||
disabled={isBusy || isGenerating}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="背景音乐曲名"
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
风格
|
||||
</span>
|
||||
<input
|
||||
value={tags}
|
||||
disabled={isBusy || isGenerating}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="背景音乐风格"
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={isBusy || isGenerating}
|
||||
rows={5}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="背景音乐提示词"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGenerate || isBusy || isGenerating}
|
||||
onClick={() => void generateMusic()}
|
||||
className={`platform-button platform-button--primary mt-3 min-h-11 w-full justify-center gap-2 ${!canGenerate || isBusy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Music className="h-4 w-4" />
|
||||
)}
|
||||
生成音乐
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{errorText ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{errorText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleResultActionBar({
|
||||
actionError,
|
||||
editState,
|
||||
@@ -1686,7 +1881,8 @@ export function PuzzleResultView({
|
||||
}}
|
||||
onOpenLevel={setActiveLevelId}
|
||||
/>
|
||||
) : (
|
||||
) : null}
|
||||
{activeTab === 'work' ? (
|
||||
<PuzzleWorkInfoTab
|
||||
editState={editState}
|
||||
tagGenerationError={tagGenerationError}
|
||||
@@ -1712,7 +1908,16 @@ export function PuzzleResultView({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
{activeTab === 'music' ? (
|
||||
<PuzzleMusicTab
|
||||
editState={editState}
|
||||
profileId={profileId ?? null}
|
||||
sessionId={session.sessionId}
|
||||
isBusy={isBusy}
|
||||
onChange={setEditState}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
|
||||
@@ -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';
|
||||
@@ -33,10 +29,6 @@ import type {
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
fetchCreationEntryConfig,
|
||||
type CreationEntryConfig,
|
||||
} from '../../services/creationEntryConfigService';
|
||||
import {
|
||||
createBigFishCreationSession,
|
||||
getBigFishCreationSession,
|
||||
@@ -48,6 +40,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,
|
||||
@@ -155,7 +151,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]!);
|
||||
}
|
||||
|
||||
@@ -169,9 +165,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();
|
||||
}
|
||||
@@ -194,14 +188,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();
|
||||
@@ -348,7 +342,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(),
|
||||
@@ -435,6 +430,7 @@ vi.mock('../../services/match3d-works', () => ({
|
||||
getMatch3DWorkDetail: vi.fn(),
|
||||
listMatch3DGallery: vi.fn(),
|
||||
listMatch3DWorks: vi.fn(),
|
||||
updateMatch3DGeneratedItemAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-runtime', () => ({
|
||||
@@ -700,13 +696,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>
|
||||
@@ -847,7 +852,9 @@ function buildMockCreativeAgentSession(
|
||||
}
|
||||
|
||||
function buildMockSquareHoleAgentSession(
|
||||
overrides: Partial<Parameters<typeof buildMockSquareHoleAgentSessionImpl>[0]> = {},
|
||||
overrides: Partial<
|
||||
Parameters<typeof buildMockSquareHoleAgentSessionImpl>[0]
|
||||
> = {},
|
||||
) {
|
||||
return buildMockSquareHoleAgentSessionImpl(overrides);
|
||||
}
|
||||
@@ -856,7 +863,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;
|
||||
}> = {},
|
||||
) {
|
||||
@@ -1549,7 +1562,9 @@ beforeEach(() => {
|
||||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||
'1',
|
||||
);
|
||||
vi.mocked(fetchCreationEntryConfig).mockResolvedValue(testCreationEntryConfig);
|
||||
vi.mocked(fetchCreationEntryConfig).mockResolvedValue(
|
||||
testCreationEntryConfig,
|
||||
);
|
||||
vi.mocked(getProfileDashboard).mockResolvedValue({
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
@@ -2167,12 +2182,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('未重新开始抓大鹅运行态'),
|
||||
);
|
||||
@@ -2522,13 +2533,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();
|
||||
@@ -3178,9 +3228,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: '搜索' }));
|
||||
|
||||
@@ -3225,9 +3274,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: '搜索' }));
|
||||
|
||||
@@ -3373,9 +3421,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);
|
||||
@@ -3423,6 +3471,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(startMatch3DRun).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',
|
||||
@@ -3705,9 +3821,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();
|
||||
});
|
||||
@@ -3812,10 +3926,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();
|
||||
});
|
||||
|
||||
@@ -3887,9 +4001,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();
|
||||
});
|
||||
@@ -3933,9 +4045,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');
|
||||
});
|
||||
|
||||
@@ -4059,9 +4169,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: '搜索' }));
|
||||
|
||||
@@ -4123,9 +4232,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);
|
||||
});
|
||||
|
||||
@@ -4170,10 +4281,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!,
|
||||
@@ -4258,9 +4366,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: '搜索' }));
|
||||
|
||||
@@ -4361,9 +4468,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: '启动' }));
|
||||
@@ -4415,9 +4521,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: '搜索' }));
|
||||
|
||||
@@ -4507,9 +4612,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: '搜索' }));
|
||||
|
||||
@@ -4517,9 +4621,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(
|
||||
@@ -4548,6 +4650,81 @@ 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,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dWork.profileId),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'M3-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
expect(await screen.findByText('详情')).toBeTruthy();
|
||||
expect(screen.getByText('水果抓大鹅')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-public-1');
|
||||
});
|
||||
expect(
|
||||
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({
|
||||
@@ -4560,23 +4737,16 @@ 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(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'M3-EPUBLIC1');
|
||||
const searchInput =
|
||||
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'M3-LEMODEL1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
expect(await screen.findByText('详情')).toBeTruthy();
|
||||
expect(screen.getByText('水果抓大鹅')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-public-1');
|
||||
});
|
||||
expect(
|
||||
await screen.findByText('抓大鹅运行态:match3d-run-match3d-profile-public-1'),
|
||||
).toBeTruthy();
|
||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||||
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 () => {
|
||||
@@ -4643,6 +4813,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,
|
||||
@@ -5087,11 +5292,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(() => {
|
||||
@@ -5729,9 +5930,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([]);
|
||||
|
||||
@@ -6114,11 +6313,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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
327
src/index.css
327
src/index.css
@@ -514,7 +514,11 @@ body {
|
||||
rgba(255, 91, 132, 0.12),
|
||||
transparent 34%
|
||||
),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 246, 249, 0.94));
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.98),
|
||||
rgba(255, 246, 249, 0.94)
|
||||
);
|
||||
--platform-recommend-runtime-state-text: var(--platform-text-strong);
|
||||
--puzzle-runtime-shell-fill: var(--platform-body-fill);
|
||||
--puzzle-runtime-stage-fill: radial-gradient(
|
||||
@@ -522,7 +526,11 @@ body {
|
||||
rgba(255, 91, 132, 0.13),
|
||||
transparent 30%
|
||||
),
|
||||
radial-gradient(circle at 18% 82%, rgba(255, 138, 115, 0.13), transparent 28%),
|
||||
radial-gradient(
|
||||
circle at 18% 82%,
|
||||
rgba(255, 138, 115, 0.13),
|
||||
transparent 28%
|
||||
),
|
||||
linear-gradient(180deg, #fffefe 0%, #fff7fa 58%, #fff1f5 100%);
|
||||
--puzzle-runtime-grid-line: rgba(130, 75, 95, 0.06);
|
||||
--puzzle-runtime-text-strong: var(--platform-text-strong);
|
||||
@@ -538,7 +546,11 @@ body {
|
||||
--puzzle-runtime-piece-border: rgba(232, 191, 205, 0.54);
|
||||
--puzzle-runtime-piece-empty-fill: rgba(255, 228, 236, 0.34);
|
||||
--puzzle-runtime-piece-empty-text: rgba(92, 70, 80, 0.38);
|
||||
--puzzle-runtime-piece-selected-fill: linear-gradient(135deg, #ff4f8b, #ff8a73);
|
||||
--puzzle-runtime-piece-selected-fill: linear-gradient(
|
||||
135deg,
|
||||
#ff4f8b,
|
||||
#ff8a73
|
||||
);
|
||||
--puzzle-runtime-piece-selected-text: #fff7fb;
|
||||
--puzzle-runtime-piece-selected-border: rgba(255, 79, 139, 0.68);
|
||||
--puzzle-runtime-next-card-overlay: rgba(61, 24, 38, 0.06);
|
||||
@@ -739,7 +751,8 @@ body {
|
||||
linear-gradient(180deg, rgba(8, 10, 14, 0.22), rgba(8, 10, 14, 0.9));
|
||||
--platform-recommend-runtime-fill: #030303;
|
||||
--platform-recommend-runtime-border: rgba(255, 255, 255, 0.08);
|
||||
--platform-recommend-runtime-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.025),
|
||||
--platform-recommend-runtime-shadow: inset 0 0 0 1px
|
||||
rgba(255, 255, 255, 0.025),
|
||||
0 18px 44px rgba(0, 0, 0, 0.18);
|
||||
--platform-recommend-runtime-state-fill: #030303;
|
||||
--platform-recommend-runtime-state-text: rgba(255, 255, 255, 0.92);
|
||||
@@ -749,7 +762,11 @@ body {
|
||||
rgba(251, 191, 36, 0.18),
|
||||
transparent 28%
|
||||
),
|
||||
radial-gradient(circle at 20% 80%, rgba(249, 115, 22, 0.16), transparent 26%),
|
||||
radial-gradient(
|
||||
circle at 20% 80%,
|
||||
rgba(249, 115, 22, 0.16),
|
||||
transparent 26%
|
||||
),
|
||||
linear-gradient(180deg, #2d160e, #020617);
|
||||
--puzzle-runtime-grid-line: rgba(255, 255, 255, 0.04);
|
||||
--puzzle-runtime-text-strong: #ffffff;
|
||||
@@ -1903,6 +1920,7 @@ body {
|
||||
|
||||
.platform-bottom-nav__icon-shell,
|
||||
.platform-desktop-rail__icon-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1934,6 +1952,19 @@ body {
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.platform-nav-unread-dot {
|
||||
position: absolute;
|
||||
right: 0.16rem;
|
||||
top: 0.12rem;
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
border-radius: 9999px;
|
||||
background: #ef4444;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 255, 255, 0.28),
|
||||
0 0 12px rgba(239, 68, 68, 0.72);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__label,
|
||||
.platform-desktop-rail__label {
|
||||
color: var(--platform-nav-item-text);
|
||||
@@ -2021,8 +2052,10 @@ body {
|
||||
.puzzle-runtime-stage__grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--puzzle-runtime-grid-line) 1px, transparent 1px),
|
||||
background-image: linear-gradient(
|
||||
var(--puzzle-runtime-grid-line) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(90deg, var(--puzzle-runtime-grid-line) 1px, transparent 1px);
|
||||
background-size: 34px 34px;
|
||||
opacity: 0.8;
|
||||
@@ -2365,8 +2398,7 @@ body {
|
||||
|
||||
.platform-mobile-bottom-dock .platform-bottom-nav__button {
|
||||
min-height: calc(
|
||||
var(--platform-bottom-nav-height) - var(--platform-bottom-nav-padding) *
|
||||
2
|
||||
var(--platform-bottom-nav-height) - var(--platform-bottom-nav-padding) * 2
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5229,8 +5261,7 @@ button {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--platform-page-border);
|
||||
border-radius: 1.6rem;
|
||||
background:
|
||||
radial-gradient(
|
||||
background: radial-gradient(
|
||||
circle at 50% 18%,
|
||||
var(--platform-shell-glow-2),
|
||||
transparent 32%
|
||||
@@ -5650,7 +5681,9 @@ button {
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.platform-mobile-entry-shell:has(.platform-tab-panel--active .creative-agent-home)
|
||||
.platform-mobile-entry-shell:has(
|
||||
.platform-tab-panel--active .creative-agent-home
|
||||
)
|
||||
> .platform-mobile-topbar {
|
||||
display: none;
|
||||
}
|
||||
@@ -5680,8 +5713,13 @@ button {
|
||||
/* 玩法参考图卡片始终压在暗色图像蒙版上,需绕过浅色主题的深色文字重映射。 */
|
||||
.platform-theme .platform-creation-reference-card,
|
||||
.platform-theme .platform-creation-reference-card *,
|
||||
.platform-theme--light .platform-remap-surface .platform-creation-reference-card,
|
||||
.platform-theme--light .platform-remap-surface .platform-creation-reference-card * {
|
||||
.platform-theme--light
|
||||
.platform-remap-surface
|
||||
.platform-creation-reference-card,
|
||||
.platform-theme--light
|
||||
.platform-remap-surface
|
||||
.platform-creation-reference-card
|
||||
* {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
@@ -5715,10 +5753,24 @@ button {
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.92), transparent 18%),
|
||||
radial-gradient(circle at 82% 18%, rgba(255, 255, 255, 0.56), transparent 17%),
|
||||
linear-gradient(180deg, #f8fcff 0%, #eaf7ff 26%, var(--child-motion-sky) 52%, #dcefd0 70%, #cde3bd 100%);
|
||||
background: radial-gradient(
|
||||
circle at 18% 12%,
|
||||
rgba(255, 255, 255, 0.92),
|
||||
transparent 18%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 82% 18%,
|
||||
rgba(255, 255, 255, 0.56),
|
||||
transparent 17%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
#f8fcff 0%,
|
||||
#eaf7ff 26%,
|
||||
var(--child-motion-sky) 52%,
|
||||
#dcefd0 70%,
|
||||
#cde3bd 100%
|
||||
);
|
||||
color: var(--child-motion-text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
@@ -5740,21 +5792,56 @@ button {
|
||||
}
|
||||
|
||||
.child-motion-demo::before {
|
||||
background:
|
||||
radial-gradient(circle at 12% 16%, var(--child-motion-cloud) 0 3.4%, transparent 3.6%),
|
||||
radial-gradient(circle at 16% 15%, rgba(255, 255, 255, 0.86) 0 2.2%, transparent 2.5%),
|
||||
radial-gradient(circle at 17.8% 16.2%, rgba(255, 255, 255, 0.9) 0 2.7%, transparent 3%),
|
||||
radial-gradient(circle at 76% 13%, var(--child-motion-cloud) 0 4.1%, transparent 4.3%),
|
||||
radial-gradient(circle at 82% 12.6%, rgba(255, 255, 255, 0.88) 0 2.5%, transparent 2.8%),
|
||||
radial-gradient(circle at 85% 14.2%, rgba(255, 255, 255, 0.82) 0 2.1%, transparent 2.4%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0) 0 62%, rgba(255, 255, 255, 0.08) 100%);
|
||||
background: radial-gradient(
|
||||
circle at 12% 16%,
|
||||
var(--child-motion-cloud) 0 3.4%,
|
||||
transparent 3.6%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 16% 15%,
|
||||
rgba(255, 255, 255, 0.86) 0 2.2%,
|
||||
transparent 2.5%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 17.8% 16.2%,
|
||||
rgba(255, 255, 255, 0.9) 0 2.7%,
|
||||
transparent 3%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 76% 13%,
|
||||
var(--child-motion-cloud) 0 4.1%,
|
||||
transparent 4.3%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 82% 12.6%,
|
||||
rgba(255, 255, 255, 0.88) 0 2.5%,
|
||||
transparent 2.8%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 85% 14.2%,
|
||||
rgba(255, 255, 255, 0.82) 0 2.1%,
|
||||
transparent 2.4%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0) 0 62%,
|
||||
rgba(255, 255, 255, 0.08) 100%
|
||||
);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.child-motion-demo::after {
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 100%, rgba(61, 120, 76, 0.26) 0 32%, transparent 58%),
|
||||
linear-gradient(180deg, transparent 0 58%, rgba(255, 255, 255, 0.12) 76%, transparent 100%);
|
||||
background: radial-gradient(
|
||||
ellipse at 50% 100%,
|
||||
rgba(61, 120, 76, 0.26) 0 32%,
|
||||
transparent 58%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
transparent 0 58%,
|
||||
rgba(255, 255, 255, 0.12) 76%,
|
||||
transparent 100%
|
||||
);
|
||||
mix-blend-mode: soft-light;
|
||||
opacity: 0.68;
|
||||
}
|
||||
@@ -5765,9 +5852,16 @@ button {
|
||||
width: min(100vw, calc(100vh * 16 / 9));
|
||||
height: min(100vh, calc(100vw * 9 / 16));
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0)),
|
||||
radial-gradient(circle at 50% 18%, rgba(255, 255, 255, 0.6), transparent 24%),
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.12),
|
||||
rgba(255, 255, 255, 0)
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 50% 18%,
|
||||
rgba(255, 255, 255, 0.6),
|
||||
transparent 24%
|
||||
),
|
||||
linear-gradient(180deg, #f3fbff 0%, #e4f3ff 32%, #d9efc4 56%, #bbdea1 100%);
|
||||
box-shadow: 0 30px 100px rgba(62, 98, 53, 0.18);
|
||||
isolation: isolate;
|
||||
@@ -5802,9 +5896,16 @@ button {
|
||||
|
||||
.child-motion-stage::after {
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, transparent 18%),
|
||||
radial-gradient(ellipse at 50% 82%, rgba(255, 245, 220, 0.16), transparent 42%),
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
transparent 18%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse at 50% 82%,
|
||||
rgba(255, 245, 220, 0.16),
|
||||
transparent 42%
|
||||
),
|
||||
linear-gradient(180deg, transparent 0 58%, rgba(80, 141, 72, 0.14) 100%);
|
||||
opacity: 0.95;
|
||||
}
|
||||
@@ -5816,10 +5917,23 @@ button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.08)),
|
||||
radial-gradient(circle at 50% 33%, rgba(255, 255, 255, 0.42), transparent 30%),
|
||||
linear-gradient(120deg, rgba(255, 255, 255, 0.1) 0 11%, transparent 11% 20%, rgba(255, 255, 255, 0.08) 20% 30%, transparent 30% 100%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.58),
|
||||
rgba(255, 255, 255, 0.08)
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 50% 33%,
|
||||
rgba(255, 255, 255, 0.42),
|
||||
transparent 30%
|
||||
),
|
||||
linear-gradient(
|
||||
120deg,
|
||||
rgba(255, 255, 255, 0.1) 0 11%,
|
||||
transparent 11% 20%,
|
||||
rgba(255, 255, 255, 0.08) 20% 30%,
|
||||
transparent 30% 100%
|
||||
);
|
||||
filter: blur(8px) saturate(0.92);
|
||||
opacity: 0.34;
|
||||
transform: scale(1.04);
|
||||
@@ -5855,10 +5969,21 @@ button {
|
||||
z-index: 2;
|
||||
height: 47%;
|
||||
border-radius: 50% 50% 0 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 10%, rgba(255, 255, 255, 0.22), transparent 30%),
|
||||
radial-gradient(ellipse at 42% 30%, rgba(255, 246, 205, 0.2) 0 8%, transparent 18%),
|
||||
radial-gradient(ellipse at 70% 25%, rgba(255, 255, 255, 0.18) 0 5%, transparent 14%),
|
||||
background: radial-gradient(
|
||||
ellipse at 50% 10%,
|
||||
rgba(255, 255, 255, 0.22),
|
||||
transparent 30%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse at 42% 30%,
|
||||
rgba(255, 246, 205, 0.2) 0 8%,
|
||||
transparent 18%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse at 70% 25%,
|
||||
rgba(255, 255, 255, 0.18) 0 5%,
|
||||
transparent 14%
|
||||
),
|
||||
linear-gradient(180deg, rgba(135, 194, 104, 0.92), rgba(69, 145, 76, 0.98));
|
||||
box-shadow:
|
||||
inset 0 26px 70px rgba(255, 255, 255, 0.16),
|
||||
@@ -5875,25 +6000,67 @@ button {
|
||||
.child-motion-floor::before {
|
||||
inset: 14% 10% auto 16%;
|
||||
height: 18%;
|
||||
background:
|
||||
radial-gradient(circle at 8% 50%, rgba(96, 148, 60, 0.68) 0 12%, transparent 13%),
|
||||
radial-gradient(circle at 21% 42%, rgba(96, 148, 60, 0.58) 0 9%, transparent 10%),
|
||||
radial-gradient(circle at 33% 55%, rgba(255, 255, 255, 0.2) 0 7%, transparent 8%),
|
||||
radial-gradient(circle at 45% 40%, rgba(96, 148, 60, 0.62) 0 11%, transparent 12%),
|
||||
radial-gradient(circle at 58% 52%, rgba(255, 255, 255, 0.16) 0 6%, transparent 7%),
|
||||
radial-gradient(circle at 69% 42%, rgba(96, 148, 60, 0.62) 0 10%, transparent 11%),
|
||||
radial-gradient(circle at 82% 50%, rgba(255, 255, 255, 0.18) 0 7%, transparent 8%);
|
||||
background: radial-gradient(
|
||||
circle at 8% 50%,
|
||||
rgba(96, 148, 60, 0.68) 0 12%,
|
||||
transparent 13%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 21% 42%,
|
||||
rgba(96, 148, 60, 0.58) 0 9%,
|
||||
transparent 10%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 33% 55%,
|
||||
rgba(255, 255, 255, 0.2) 0 7%,
|
||||
transparent 8%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 45% 40%,
|
||||
rgba(96, 148, 60, 0.62) 0 11%,
|
||||
transparent 12%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 58% 52%,
|
||||
rgba(255, 255, 255, 0.16) 0 6%,
|
||||
transparent 7%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 69% 42%,
|
||||
rgba(96, 148, 60, 0.62) 0 10%,
|
||||
transparent 11%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 82% 50%,
|
||||
rgba(255, 255, 255, 0.18) 0 7%,
|
||||
transparent 8%
|
||||
);
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.child-motion-floor::after {
|
||||
inset: auto 6% 10%;
|
||||
height: 15%;
|
||||
background:
|
||||
radial-gradient(circle at 18% 50%, rgba(55, 104, 53, 0.42) 0 10%, transparent 11%),
|
||||
radial-gradient(circle at 38% 50%, rgba(255, 255, 255, 0.12) 0 6%, transparent 7%),
|
||||
radial-gradient(circle at 60% 48%, rgba(55, 104, 53, 0.38) 0 11%, transparent 12%),
|
||||
radial-gradient(circle at 80% 52%, rgba(255, 255, 255, 0.1) 0 5%, transparent 6%);
|
||||
background: radial-gradient(
|
||||
circle at 18% 50%,
|
||||
rgba(55, 104, 53, 0.42) 0 10%,
|
||||
transparent 11%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 38% 50%,
|
||||
rgba(255, 255, 255, 0.12) 0 6%,
|
||||
transparent 7%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 60% 48%,
|
||||
rgba(55, 104, 53, 0.38) 0 11%,
|
||||
transparent 12%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 52%,
|
||||
rgba(255, 255, 255, 0.1) 0 5%,
|
||||
transparent 6%
|
||||
);
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
@@ -5945,7 +6112,11 @@ button {
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(112, 143, 97, 0.2);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(242, 248, 236, 0.92));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.8),
|
||||
rgba(242, 248, 236, 0.92)
|
||||
);
|
||||
color: var(--child-motion-text);
|
||||
font-size: clamp(0.72rem, 1.45vw, 0.95rem);
|
||||
font-weight: 900;
|
||||
@@ -5960,12 +6131,11 @@ button {
|
||||
aspect-ratio: 1;
|
||||
transform: translateX(-50%) rotateX(66deg);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
conic-gradient(
|
||||
from -90deg,
|
||||
rgba(255, 255, 255, 0.88) 0 var(--child-motion-ring-progress),
|
||||
rgba(102, 190, 95, 0.22) var(--child-motion-ring-progress) 360deg
|
||||
);
|
||||
background: conic-gradient(
|
||||
from -90deg,
|
||||
rgba(255, 255, 255, 0.88) 0 var(--child-motion-ring-progress),
|
||||
rgba(102, 190, 95, 0.22) var(--child-motion-ring-progress) 360deg
|
||||
);
|
||||
box-shadow:
|
||||
0 0 18px rgba(120, 191, 110, 0.34),
|
||||
0 0 0 6px rgba(255, 255, 255, 0.12),
|
||||
@@ -5976,8 +6146,11 @@ button {
|
||||
position: absolute;
|
||||
inset: 14%;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
radial-gradient(circle at 50% 45%, rgba(255, 255, 255, 0.1), transparent 40%),
|
||||
background: radial-gradient(
|
||||
circle at 50% 45%,
|
||||
rgba(255, 255, 255, 0.1),
|
||||
transparent 40%
|
||||
),
|
||||
linear-gradient(180deg, rgba(151, 215, 139, 0.82), rgba(73, 151, 74, 0.94));
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.38);
|
||||
content: '';
|
||||
@@ -5987,7 +6160,11 @@ button {
|
||||
position: absolute;
|
||||
inset: 34%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(150, 231, 137, 0.86));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.68),
|
||||
rgba(150, 231, 137, 0.86)
|
||||
);
|
||||
opacity: 0.62;
|
||||
box-shadow: 0 0 22px rgba(124, 199, 112, 0.44);
|
||||
}
|
||||
@@ -6013,7 +6190,9 @@ button {
|
||||
width: clamp(3.4rem, 7vw, 5.6rem);
|
||||
height: clamp(6rem, 13vw, 10rem);
|
||||
transform: translateX(-50%);
|
||||
transition: left 260ms ease, transform 220ms ease;
|
||||
transition:
|
||||
left 260ms ease,
|
||||
transform 220ms ease;
|
||||
filter: drop-shadow(0 6px 14px rgba(56, 92, 55, 0.18));
|
||||
}
|
||||
|
||||
@@ -6027,8 +6206,11 @@ button {
|
||||
.child-motion-avatar__leg {
|
||||
position: absolute;
|
||||
display: block;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(77, 109, 79, 0.44), rgba(41, 65, 44, 0.7)),
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(77, 109, 79, 0.44),
|
||||
rgba(41, 65, 44, 0.7)
|
||||
),
|
||||
rgba(245, 250, 245, 0.1);
|
||||
opacity: 0.6;
|
||||
border: 1px solid rgba(239, 249, 235, 0.18);
|
||||
@@ -6259,8 +6441,11 @@ button {
|
||||
z-index: 30;
|
||||
display: none;
|
||||
place-items: center;
|
||||
background:
|
||||
radial-gradient(circle at 24% 22%, rgba(255, 255, 255, 0.88), transparent 20%),
|
||||
background: radial-gradient(
|
||||
circle at 24% 22%,
|
||||
rgba(255, 255, 255, 0.88),
|
||||
transparent 20%
|
||||
),
|
||||
linear-gradient(180deg, #f7fcff 0%, #dff3ff 54%, #c9e6b9 100%);
|
||||
color: var(--child-motion-text);
|
||||
font-size: 1.25rem;
|
||||
|
||||
90
src/services/creation-audio/creationAudioGenerationClient.ts
Normal file
90
src/services/creation-audio/creationAudioGenerationClient.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
AudioGenerationTaskResponse,
|
||||
CreateBackgroundMusicRequest,
|
||||
CreateSoundEffectRequest,
|
||||
GeneratedAudioAssetResponse,
|
||||
PublishGeneratedAudioAssetRequest,
|
||||
} from '../../../packages/shared/src/contracts/creationAudio';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const CREATION_AUDIO_API_BASE = '/api/creation/audio';
|
||||
|
||||
const CREATION_AUDIO_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 500,
|
||||
maxDelayMs: 1200,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export function createBackgroundMusicTask(payload: CreateBackgroundMusicRequest) {
|
||||
return requestJson<AudioGenerationTaskResponse>(
|
||||
`${CREATION_AUDIO_API_BASE}/background-music`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交背景音乐生成失败',
|
||||
{ retry: CREATION_AUDIO_RETRY, timeoutMs: 20000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function publishBackgroundMusicAsset(
|
||||
taskId: string,
|
||||
payload: PublishGeneratedAudioAssetRequest,
|
||||
) {
|
||||
return requestJson<GeneratedAudioAssetResponse>(
|
||||
`${CREATION_AUDIO_API_BASE}/background-music/${encodeURIComponent(taskId)}/asset`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成背景音乐素材失败',
|
||||
{ retry: CREATION_AUDIO_RETRY, timeoutMs: 30000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function createSoundEffectTask(payload: CreateSoundEffectRequest) {
|
||||
return requestJson<AudioGenerationTaskResponse>(
|
||||
`${CREATION_AUDIO_API_BASE}/sound-effect`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交音效生成失败',
|
||||
{ retry: CREATION_AUDIO_RETRY, timeoutMs: 20000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function publishSoundEffectAsset(
|
||||
taskId: string,
|
||||
payload: PublishGeneratedAudioAssetRequest,
|
||||
) {
|
||||
return requestJson<GeneratedAudioAssetResponse>(
|
||||
`${CREATION_AUDIO_API_BASE}/sound-effect/${encodeURIComponent(taskId)}/asset`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成音效素材失败',
|
||||
{ retry: CREATION_AUDIO_RETRY, timeoutMs: 30000 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForGeneratedAudioAsset(
|
||||
taskId: string,
|
||||
publish: () => Promise<GeneratedAudioAssetResponse>,
|
||||
) {
|
||||
let latestAsset: GeneratedAudioAssetResponse | null = null;
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
latestAsset = await publish();
|
||||
if (latestAsset.audioSrc?.trim()) {
|
||||
return latestAsset;
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 3000));
|
||||
}
|
||||
throw new Error(latestAsset?.status || `音频生成超时:${taskId}`);
|
||||
}
|
||||
1
src/services/creation-audio/index.ts
Normal file
1
src/services/creation-audio/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './creationAudioGenerationClient';
|
||||
@@ -6,5 +6,7 @@ export {
|
||||
listMatch3DWorks,
|
||||
match3dWorksClient,
|
||||
publishMatch3DWork,
|
||||
updateMatch3DAudioAssets,
|
||||
updateMatch3DGeneratedItemAssets,
|
||||
updateMatch3DWork,
|
||||
} from './match3dWorksClient';
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
Match3DWorkDetailResponse,
|
||||
Match3DWorkMutationResponse,
|
||||
Match3DWorksResponse,
|
||||
PutMatch3DAudioAssetsRequest,
|
||||
PutMatch3DWorkRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
@@ -81,6 +82,27 @@ export function updateMatch3DWork(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存抓大鹅结果页生成的素材快照。
|
||||
*/
|
||||
export function updateMatch3DGeneratedItemAssets(
|
||||
profileId: string,
|
||||
payload: PutMatch3DAudioAssetsRequest,
|
||||
) {
|
||||
return requestJson<Match3DWorkMutationResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/audio-assets`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'更新抓大鹅生成素材失败',
|
||||
{ retry: MATCH3D_WORKS_WRITE_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
export const updateMatch3DAudioAssets = updateMatch3DGeneratedItemAssets;
|
||||
|
||||
/**
|
||||
* 根据当前作品名称与题材生成发布标签。
|
||||
*/
|
||||
@@ -128,5 +150,7 @@ export const match3dWorksClient = {
|
||||
listGallery: listMatch3DGallery,
|
||||
list: listMatch3DWorks,
|
||||
publish: publishMatch3DWork,
|
||||
updateAudioAssets: updateMatch3DAudioAssets,
|
||||
updateGeneratedItemAssets: updateMatch3DGeneratedItemAssets,
|
||||
update: updateMatch3DWork,
|
||||
};
|
||||
|
||||
@@ -186,6 +186,24 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
expect(progress?.steps[0]?.detail).toBe('根据题材设定生成作品名称与标签。');
|
||||
});
|
||||
|
||||
test('match3d draft generation keeps backend observed model phase', () => {
|
||||
const state = {
|
||||
...createMiniGameDraftGenerationState('match3d'),
|
||||
phase: 'match3d-generate-models' as const,
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 3,
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 20_000,
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('match3d-generate-models');
|
||||
expect(progress?.steps.at(-1)?.completed).toBe(1);
|
||||
expect(progress?.steps.at(-1)?.total).toBe(3);
|
||||
});
|
||||
|
||||
test('match3d generation anchors show theme and fixed three items', () => {
|
||||
const entries = buildMatch3DGenerationAnchorEntries(null, {
|
||||
themeText: '水果',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
} from '../../packages/shared/src/contracts/match3dAgent';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldGenerationStep,
|
||||
@@ -180,6 +180,17 @@ const MATCH3D_STEPS = [
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const MATCH3D_PHASE_ORDER: Partial<
|
||||
Record<MiniGameDraftGenerationPhase, number>
|
||||
> = {
|
||||
'match3d-work-title': 0,
|
||||
'match3d-item-names': 1,
|
||||
'match3d-material-sheet': 2,
|
||||
'match3d-slice-images': 3,
|
||||
'match3d-upload-images': 4,
|
||||
'match3d-generate-models': 5,
|
||||
};
|
||||
|
||||
function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
@@ -283,23 +294,23 @@ function resolveSquareHolePhaseByElapsedMs(
|
||||
|
||||
function resolveMatch3DPhaseByElapsedMs(
|
||||
elapsedMs: number,
|
||||
currentPhase: MiniGameDraftGenerationPhase,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (elapsedMs >= 92_000) {
|
||||
return 'match3d-generate-models';
|
||||
}
|
||||
if (elapsedMs >= 72_000) {
|
||||
return 'match3d-upload-images';
|
||||
}
|
||||
if (elapsedMs >= 58_000) {
|
||||
return 'match3d-slice-images';
|
||||
}
|
||||
if (elapsedMs >= 16_000) {
|
||||
return 'match3d-material-sheet';
|
||||
}
|
||||
if (elapsedMs >= 4_000) {
|
||||
return 'match3d-item-names';
|
||||
}
|
||||
return 'match3d-work-title';
|
||||
const elapsedPhase =
|
||||
elapsedMs >= 92_000
|
||||
? 'match3d-generate-models'
|
||||
: elapsedMs >= 72_000
|
||||
? 'match3d-upload-images'
|
||||
: elapsedMs >= 58_000
|
||||
? 'match3d-slice-images'
|
||||
: elapsedMs >= 16_000
|
||||
? 'match3d-material-sheet'
|
||||
: elapsedMs >= 4_000
|
||||
? 'match3d-item-names'
|
||||
: 'match3d-work-title';
|
||||
const elapsedOrder = MATCH3D_PHASE_ORDER[elapsedPhase] ?? 0;
|
||||
const currentOrder = MATCH3D_PHASE_ORDER[currentPhase] ?? -1;
|
||||
return currentOrder > elapsedOrder ? currentPhase : elapsedPhase;
|
||||
}
|
||||
|
||||
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
|
||||
@@ -367,7 +378,7 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs),
|
||||
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase),
|
||||
}
|
||||
: state;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user