收口统一创作流程一期
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { JumpHopSessionResponse } from '../../../../packages/shared/src/contracts/jumpHop';
|
||||
import { jumpHopClient } from '../../../services/jump-hop/jumpHopClient';
|
||||
import { JumpHopCreationWorkspace } from './JumpHopCreationWorkspace';
|
||||
|
||||
vi.mock('../../../services/jump-hop/jumpHopClient', () => ({
|
||||
jumpHopClient: {
|
||||
createSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockCreateSession = vi.mocked(jumpHopClient.createSession);
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreateSession.mockReset();
|
||||
});
|
||||
|
||||
function createSessionResponse(): JumpHopSessionResponse {
|
||||
return {
|
||||
session: {
|
||||
sessionId: 'jump-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'draft',
|
||||
draft: null,
|
||||
createdAt: '2026-05-30T10:00:00.000Z',
|
||||
updatedAt: '2026-05-30T10:00:00.000Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('jump hop workspace submits structured payload after required fields are filled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitted = vi.fn();
|
||||
const sessionResponse = createSessionResponse();
|
||||
mockCreateSession.mockResolvedValue(sessionResponse);
|
||||
|
||||
render(
|
||||
<JumpHopCreationWorkspace onBack={() => {}} onSubmitted={onSubmitted} />,
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: '生成' });
|
||||
expect(submitButton).toHaveProperty('disabled', true);
|
||||
|
||||
await user.type(screen.getByLabelText('作品标题'), '云朵跳台');
|
||||
await user.type(screen.getByLabelText('作品简介'), '在云端一路跳到星星。');
|
||||
await user.type(screen.getByLabelText('主题标签'), '云朵 星星');
|
||||
await user.selectOptions(screen.getByLabelText('难度'), 'standard');
|
||||
await user.selectOptions(screen.getByLabelText('风格'), 'paper-toy');
|
||||
await user.type(screen.getByLabelText('角色提示词'), '一只纸片小兔');
|
||||
await user.type(screen.getByLabelText('地块提示词'), '柔软云朵平台');
|
||||
await user.type(screen.getByLabelText('终点氛围'), '星光门');
|
||||
|
||||
expect(submitButton).toHaveProperty('disabled', false);
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateSession).toHaveBeenCalledWith({
|
||||
templateId: 'jump-hop',
|
||||
workTitle: '云朵跳台',
|
||||
workDescription: '在云端一路跳到星星。',
|
||||
themeTags: ['云朵', '星星'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
characterPrompt: '一只纸片小兔',
|
||||
tilePrompt: '柔软云朵平台',
|
||||
endMoodPrompt: '星光门',
|
||||
});
|
||||
});
|
||||
expect(onSubmitted).toHaveBeenCalledWith(
|
||||
sessionResponse,
|
||||
expect.objectContaining({
|
||||
templateId: 'jump-hop',
|
||||
workTitle: '云朵跳台',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('jump hop workspace calls back when return button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(<JumpHopCreationWorkspace onBack={onBack} onSubmitted={() => {}} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('jump hop workspace can defer visible chrome to the unified creation page', () => {
|
||||
const { container } = render(
|
||||
<JumpHopCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
showBackButton={false}
|
||||
unifiedChrome
|
||||
/>,
|
||||
);
|
||||
|
||||
const workspace = container.querySelector('.jump-hop-workspace');
|
||||
expect(workspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(workspace?.className).toContain('max-w-none');
|
||||
expect(workspace?.className).not.toContain('platform-remap-surface');
|
||||
expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { ArrowLeft, Loader2, Send } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
JumpHopDifficulty,
|
||||
JumpHopSessionResponse,
|
||||
JumpHopStylePreset,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
} from '../../../../packages/shared/src/contracts/jumpHop';
|
||||
import { jumpHopClient } from '../../../services/jump-hop/jumpHopClient';
|
||||
|
||||
type JumpHopCreationWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitted: (
|
||||
result: JumpHopSessionResponse,
|
||||
payload: JumpHopWorkspaceCreateRequest,
|
||||
) => void;
|
||||
showBackButton?: boolean;
|
||||
unifiedChrome?: boolean;
|
||||
};
|
||||
|
||||
type JumpHopWorkspaceFormState = {
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
themeTags: string;
|
||||
difficulty: JumpHopDifficulty;
|
||||
stylePreset: JumpHopStylePreset;
|
||||
characterPrompt: string;
|
||||
tilePrompt: string;
|
||||
endMoodPrompt: string;
|
||||
};
|
||||
|
||||
const DEFAULT_FORM_STATE: JumpHopWorkspaceFormState = {
|
||||
workTitle: '',
|
||||
workDescription: '',
|
||||
themeTags: '',
|
||||
difficulty: 'easy',
|
||||
stylePreset: 'minimal-blocks',
|
||||
characterPrompt: '',
|
||||
tilePrompt: '',
|
||||
endMoodPrompt: '',
|
||||
};
|
||||
|
||||
export function JumpHopCreationWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitted,
|
||||
showBackButton = true,
|
||||
unifiedChrome = false,
|
||||
}: JumpHopCreationWorkspaceProps) {
|
||||
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
formState.workTitle.trim() &&
|
||||
formState.workDescription.trim() &&
|
||||
formState.themeTags.trim() &&
|
||||
formState.characterPrompt.trim() &&
|
||||
formState.tilePrompt.trim(),
|
||||
),
|
||||
[formState],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || isSubmitting || isBusy) {
|
||||
setLocalError('请先补全输入。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setLocalError(null);
|
||||
|
||||
try {
|
||||
const payload: JumpHopWorkspaceCreateRequest = {
|
||||
templateId: 'jump-hop',
|
||||
workTitle: formState.workTitle.trim(),
|
||||
workDescription: formState.workDescription.trim(),
|
||||
themeTags: formState.themeTags
|
||||
.split(/[,,、\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
difficulty: formState.difficulty,
|
||||
stylePreset: formState.stylePreset,
|
||||
characterPrompt: formState.characterPrompt.trim(),
|
||||
tilePrompt: formState.tilePrompt.trim(),
|
||||
endMoodPrompt: formState.endMoodPrompt.trim() || null,
|
||||
};
|
||||
const response = await jumpHopClient.createSession(payload);
|
||||
onSubmitted(response, payload);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '创建草稿失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
unifiedChrome
|
||||
? 'jump-hop-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
|
||||
: 'jump-hop-workspace platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4'
|
||||
}
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品标题
|
||||
</span>
|
||||
<input
|
||||
value={formState.workTitle}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workTitle: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品简介
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.workDescription}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
主题标签
|
||||
</span>
|
||||
<input
|
||||
value={formState.themeTags}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
themeTags: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
难度
|
||||
</span>
|
||||
<select
|
||||
value={formState.difficulty}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
difficulty: event.target.value as JumpHopDifficulty,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
>
|
||||
<option value="easy">easy</option>
|
||||
<option value="standard">standard</option>
|
||||
<option value="advanced">advanced</option>
|
||||
<option value="challenge">challenge</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
风格
|
||||
</span>
|
||||
<select
|
||||
value={formState.stylePreset}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
stylePreset: event.target.value as JumpHopStylePreset,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
>
|
||||
<option value="minimal-blocks">minimal-blocks</option>
|
||||
<option value="paper-toy">paper-toy</option>
|
||||
<option value="neon-glass">neon-glass</option>
|
||||
<option value="forest-stone">forest-stone</option>
|
||||
<option value="future-metal">future-metal</option>
|
||||
<option value="custom">custom</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
角色提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.characterPrompt}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
characterPrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
地块提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.tilePrompt}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
tilePrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
终点氛围
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.endMoodPrompt}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
endMoodPrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{localError || error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{localError ?? error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || isSubmitting || isBusy}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || isSubmitting || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JumpHopCreationWorkspace;
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../../packages/shared/src/contracts/match3dAgent';
|
||||
import { Match3DCreationWorkspace } from './Match3DCreationWorkspace';
|
||||
|
||||
const baseSession: Match3DAgentSessionSnapshot = {
|
||||
sessionId: 'match3d-session-1',
|
||||
currentTurn: 0,
|
||||
progressPercent: 0,
|
||||
stage: 'collecting_config',
|
||||
anchorPack: {
|
||||
theme: {
|
||||
key: 'theme',
|
||||
label: '题材主题',
|
||||
value: '水果摊',
|
||||
status: 'confirmed',
|
||||
},
|
||||
clearCount: {
|
||||
key: 'clearCount',
|
||||
label: '需要消除次数',
|
||||
value: '8',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '3',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
config: {
|
||||
themeText: '水果摊',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 8,
|
||||
difficulty: 3,
|
||||
assetStyleId: 'cel-cartoon',
|
||||
assetStyleLabel: '赛璐璐卡通',
|
||||
assetStylePrompt:
|
||||
'明亮赛璐璐卡通 2D 游戏道具风格,清晰线稿,硬边阴影,饱和配色,轮廓醒目。',
|
||||
generateClickSound: false,
|
||||
},
|
||||
draft: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '旧会话固定追问不再作为主入口。',
|
||||
createdAt: '2026-05-10T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
lastAssistantReply: '旧会话固定追问不再作为主入口。',
|
||||
publishedProfileId: null,
|
||||
updatedAt: '2026-05-10T10:00:00.000Z',
|
||||
};
|
||||
|
||||
function confirmMatch3DPointCost() {
|
||||
const confirmDialog = screen.getByRole('dialog', {
|
||||
name: '确认消耗泥点',
|
||||
});
|
||||
expect(within(confirmDialog).getByText('消耗 10 泥点')).toBeTruthy();
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
|
||||
}
|
||||
|
||||
test('match3d workspace submits derived entry form payload instead of agent chat', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
|
||||
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
|
||||
expect(screen.queryByText('2D素材风格')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '扁平图标' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '自定义' })).toBeNull();
|
||||
expect(screen.getByText('消耗10泥点')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '生成音效' })).toBeNull();
|
||||
expect(screen.queryByText('参考图')).toBeNull();
|
||||
expect(screen.queryByLabelText('上传抓大鹅参考图')).toBeNull();
|
||||
expect(screen.queryByLabelText('需要消除次数')).toBeNull();
|
||||
expect(screen.queryByLabelText('难度数值')).toBeNull();
|
||||
expect(screen.queryByText('物品')).toBeNull();
|
||||
expect(screen.queryByText('旧会话固定追问不再作为主入口。')).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
|
||||
target: { value: '赛博水果摊' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '进阶' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).not.toHaveBeenCalled();
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '赛博水果摊题材,消除16次,难度6',
|
||||
themeText: '赛博水果摊',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 16,
|
||||
difficulty: 6,
|
||||
generateClickSound: false,
|
||||
});
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('match3d workspace can defer visible chrome to the unified creation page', () => {
|
||||
const { container } = render(
|
||||
<Match3DCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={() => {}}
|
||||
title={null}
|
||||
unifiedChrome
|
||||
/>,
|
||||
);
|
||||
|
||||
const workspace = container.querySelector('.match3d-agent-workspace');
|
||||
expect(workspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(workspace?.className).toContain('max-w-none');
|
||||
expect(workspace?.className).not.toContain('h-full');
|
||||
expect(workspace?.className).not.toContain('overflow-hidden');
|
||||
expect(workspace?.className).not.toContain('platform-remap-surface');
|
||||
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull();
|
||||
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('match3d workspace omits legacy asset style fields from entry payload', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
|
||||
target: { value: '复古水果铺' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
const payload = onCreateFromForm.mock.calls[0]?.[0] ?? {};
|
||||
expect('assetStyleId' in payload).toBe(false);
|
||||
expect('assetStyleLabel' in payload).toBe(false);
|
||||
expect('assetStylePrompt' in payload).toBe(false);
|
||||
});
|
||||
|
||||
test('match3d workspace keeps click sound generation disabled from entry form', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DCreationWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
|
||||
target: { value: '海岛甜品' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
themeText: '海岛甜品',
|
||||
generateClickSound: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('match3d workspace falls back to compile action when restored from the legacy route', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DCreationWorkspace
|
||||
session={baseSession}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(screen.getByLabelText('想做一个什么题材的抓大鹅?') as HTMLTextAreaElement)
|
||||
.value,
|
||||
).toBe('水果摊');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '轻松' }).getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(screen.queryByText('2D素材风格')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '赛璐璐卡通' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: false,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
import { Loader2, Sparkles, WandSparkles } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
SendMatch3DMessageRequest,
|
||||
} from '../../../../packages/shared/src/contracts/match3dAgent';
|
||||
|
||||
type Match3DCreationWorkspaceProps = {
|
||||
session: Match3DAgentSessionSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitMessage?: (payload: SendMatch3DMessageRequest) => void;
|
||||
onExecuteAction: (payload: ExecuteMatch3DActionRequest) => void;
|
||||
onCreateFromForm?: (payload: CreateMatch3DSessionRequest) => void;
|
||||
initialFormPayload?: CreateMatch3DSessionRequest | null;
|
||||
showBackButton?: boolean;
|
||||
title?: string | null;
|
||||
unifiedChrome?: boolean;
|
||||
};
|
||||
|
||||
type Match3DFormState = {
|
||||
themeText: string;
|
||||
difficultyOptionId: Match3DDifficultyOptionId;
|
||||
};
|
||||
|
||||
const EMPTY_FORM_STATE: Match3DFormState = {
|
||||
themeText: '',
|
||||
difficultyOptionId: 'standard',
|
||||
};
|
||||
|
||||
// 中文注释:入口页只暴露难度选项,消除次数和难度数值由选项稳定派生给后端。
|
||||
const MATCH3D_DIFFICULTY_OPTIONS = [
|
||||
{ id: 'easy', label: '轻松', clearCount: 8, difficulty: 2 },
|
||||
{ id: 'standard', label: '标准', clearCount: 12, difficulty: 4 },
|
||||
{ id: 'advanced', label: '进阶', clearCount: 16, difficulty: 6 },
|
||||
{ id: 'hardcore', label: '硬核', clearCount: 21, difficulty: 8 },
|
||||
] as const;
|
||||
|
||||
type Match3DDifficultyOptionId =
|
||||
(typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id'];
|
||||
|
||||
function normalizeDifficulty(value: number) {
|
||||
return Math.max(1, Math.min(10, Math.round(value)));
|
||||
}
|
||||
|
||||
function resolveDifficultyOptionId(
|
||||
difficulty: number | null | undefined,
|
||||
clearCount: number | null | undefined,
|
||||
): Match3DDifficultyOptionId {
|
||||
const clearCountMatchedOption = MATCH3D_DIFFICULTY_OPTIONS.find(
|
||||
(option) => option.clearCount === clearCount,
|
||||
);
|
||||
if (clearCountMatchedOption) {
|
||||
return clearCountMatchedOption.id;
|
||||
}
|
||||
|
||||
if (typeof difficulty !== 'number' || !Number.isFinite(difficulty)) {
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
const normalizedDifficulty = normalizeDifficulty(Number(difficulty));
|
||||
return MATCH3D_DIFFICULTY_OPTIONS.reduce(
|
||||
(nearestOption, option) =>
|
||||
Math.abs(option.difficulty - normalizedDifficulty) <
|
||||
Math.abs(nearestOption.difficulty - normalizedDifficulty)
|
||||
? option
|
||||
: nearestOption,
|
||||
MATCH3D_DIFFICULTY_OPTIONS[1],
|
||||
).id;
|
||||
}
|
||||
|
||||
function getDifficultyOption(optionId: Match3DDifficultyOptionId) {
|
||||
return (
|
||||
MATCH3D_DIFFICULTY_OPTIONS.find((option) => option.id === optionId) ??
|
||||
MATCH3D_DIFFICULTY_OPTIONS[1]
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInitialFormState(
|
||||
session: Match3DAgentSessionSnapshot | null,
|
||||
initialFormPayload: CreateMatch3DSessionRequest | null = null,
|
||||
): Match3DFormState {
|
||||
const config = session?.config;
|
||||
const themeText =
|
||||
initialFormPayload?.themeText?.trim() ||
|
||||
config?.themeText?.trim() ||
|
||||
session?.anchorPack.theme.value?.trim() ||
|
||||
initialFormPayload?.seedText?.trim() ||
|
||||
'';
|
||||
const clearCount =
|
||||
initialFormPayload?.clearCount ?? config?.clearCount ?? null;
|
||||
const difficulty =
|
||||
initialFormPayload?.difficulty ?? config?.difficulty ?? null;
|
||||
|
||||
return {
|
||||
...EMPTY_FORM_STATE,
|
||||
themeText,
|
||||
difficultyOptionId: resolveDifficultyOptionId(difficulty, clearCount),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一创作目录内的抓大鹅工作台实现。
|
||||
*/
|
||||
export function Match3DCreationWorkspace({
|
||||
session,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onExecuteAction,
|
||||
onCreateFromForm,
|
||||
initialFormPayload = null,
|
||||
showBackButton = true,
|
||||
title = '想做个什么玩法?',
|
||||
unifiedChrome = false,
|
||||
}: Match3DCreationWorkspaceProps) {
|
||||
const [formState, setFormState] = useState<Match3DFormState>(() =>
|
||||
resolveInitialFormState(session, initialFormPayload),
|
||||
);
|
||||
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
|
||||
const appliedInitialFormKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextInitialFormKey =
|
||||
session?.sessionId ?? JSON.stringify(initialFormPayload ?? null);
|
||||
if (appliedInitialFormKeyRef.current === nextInitialFormKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedInitialFormKeyRef.current = nextInitialFormKey;
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
setIsPointCostConfirmOpen(false);
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
const themeText = formState.themeText.trim();
|
||||
const selectedDifficultyOption = getDifficultyOption(
|
||||
formState.difficultyOptionId,
|
||||
);
|
||||
const canSubmit = Boolean(themeText && !isBusy);
|
||||
const formPayload = useMemo<CreateMatch3DSessionRequest>(
|
||||
() => ({
|
||||
seedText: themeText
|
||||
? `${themeText}题材,消除${selectedDifficultyOption.clearCount}次,难度${selectedDifficultyOption.difficulty}`
|
||||
: themeText,
|
||||
themeText,
|
||||
referenceImageSrc: null,
|
||||
clearCount: selectedDifficultyOption.clearCount,
|
||||
difficulty: selectedDifficultyOption.difficulty,
|
||||
generateClickSound: false,
|
||||
}),
|
||||
[selectedDifficultyOption, themeText],
|
||||
);
|
||||
|
||||
const submitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPointCostConfirmOpen(true);
|
||||
};
|
||||
|
||||
const executeSubmitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onCreateFromForm) {
|
||||
setIsPointCostConfirmOpen(false);
|
||||
onCreateFromForm(formPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session) {
|
||||
setIsPointCostConfirmOpen(false);
|
||||
onExecuteAction({
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
unifiedChrome
|
||||
? 'match3d-agent-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
|
||||
: 'match3d-agent-workspace platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden'
|
||||
}
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={
|
||||
unifiedChrome
|
||||
? 'flex flex-col pr-0'
|
||||
: 'flex min-h-0 flex-1 flex-col overflow-hidden pr-0'
|
||||
}
|
||||
>
|
||||
{title && !unifiedChrome ? (
|
||||
<div className="mb-3 shrink-0 sm:mb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||||
{title}
|
||||
</h1>
|
||||
<span className="rounded-full border border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] px-3 py-1 text-[11px] font-black text-[var(--platform-warm-text)]">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section
|
||||
className={
|
||||
unifiedChrome
|
||||
? 'flex flex-col'
|
||||
: 'flex min-h-0 flex-1 flex-col overflow-hidden'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`grid gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] ${
|
||||
unifiedChrome
|
||||
? ''
|
||||
: 'min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] lg:grid-rows-1'
|
||||
} ${isBusy ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<label className="block min-h-0">
|
||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||
想做一个什么题材的抓大鹅?
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.themeText}
|
||||
disabled={isBusy}
|
||||
rows={5}
|
||||
placeholder=""
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
themeText: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-full min-h-[7.75rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)] sm:min-h-[9rem] lg:min-h-[14rem]"
|
||||
aria-label="想做一个什么题材的抓大鹅?"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
|
||||
<div className="shrink-0 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/44 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)]">
|
||||
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
难度
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2 lg:grid-cols-2">
|
||||
{MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
|
||||
const selected = formState.difficultyOptionId === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
difficultyOptionId: option.id,
|
||||
}))
|
||||
}
|
||||
className={`min-h-10 rounded-[0.85rem] border px-2 text-sm font-black transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)] sm:min-h-11 ${
|
||||
selected
|
||||
? 'border-[#ff7890] bg-[linear-gradient(180deg,#ff7890_0%,#ff4f6a_100%)] text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/76 text-[var(--platform-text-strong)] hover:border-[var(--platform-surface-hover-border)] hover:bg-white'
|
||||
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 shrink-0 space-y-3">
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={submitForm}
|
||||
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{session ? (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
) : (
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
)}
|
||||
<span>生成抓大鹅草稿</span>
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
消耗10泥点
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isPointCostConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="match3d-point-cost-confirm-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div
|
||||
id="match3d-point-cost-confirm-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
确认消耗泥点
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
|
||||
消耗 10 泥点
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPointCostConfirmOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={executeSubmitForm}
|
||||
className={`platform-button platform-button--primary justify-center ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Match3DCreationWorkspace;
|
||||
+1000
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { woodenFishClient } from '../../../services/wooden-fish/woodenFishClient';
|
||||
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT } from '../../../services/wooden-fish/woodenFishDefaults';
|
||||
import { WoodenFishCreationWorkspace } from './WoodenFishCreationWorkspace';
|
||||
|
||||
vi.mock('../../../services/wooden-fish/woodenFishClient', () => ({
|
||||
woodenFishClient: {
|
||||
createSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(woodenFishClient.createSession).mockReset();
|
||||
vi.mocked(woodenFishClient.createSession).mockResolvedValue({
|
||||
session: {
|
||||
sessionId: 'wooden-fish-session-test',
|
||||
ownerUserId: 'user-test',
|
||||
status: 'draft',
|
||||
draft: null,
|
||||
createdAt: '2026-05-24T00:00:00Z',
|
||||
updatedAt: '2026-05-24T00:00:00Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('敲什么输入栏初始置空但提交时仍使用默认生成提示词', async () => {
|
||||
const onSubmitted = vi.fn();
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('敲什么')).toHaveProperty('value', '');
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
|
||||
expect(onSubmitted.mock.calls[0]?.[1]).toMatchObject({
|
||||
hitObjectPrompt: WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
});
|
||||
});
|
||||
|
||||
test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀', () => {
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const sectionTitle = screen.getByText('功德有什么');
|
||||
const section = sectionTitle.closest('section');
|
||||
|
||||
expect(section).not.toBeNull();
|
||||
expect(within(section as HTMLElement).getByDisplayValue('幸运')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).queryByDisplayValue('健康')).toBeNull();
|
||||
expect(within(section as HTMLElement).queryByDisplayValue('财富')).toBeNull();
|
||||
expect(within(section as HTMLElement).queryByDisplayValue('幸运+1')).toBeNull();
|
||||
expect(within(section as HTMLElement).queryByDisplayValue('功德+1')).toBeNull();
|
||||
expect(
|
||||
within(section as HTMLElement).getByRole('button', {
|
||||
name: '新增功德词条',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('功德有什么支持通过加号新增词条并移除新增格子', () => {
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增功德词条' }));
|
||||
const secondInput = screen.getByLabelText('功德词条 2');
|
||||
fireEvent.change(secondInput, { target: { value: '健康' } });
|
||||
|
||||
expect(screen.getByDisplayValue('幸运')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('健康')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除功德词条 2' }));
|
||||
expect(screen.queryByDisplayValue('健康')).toBeNull();
|
||||
expect(screen.getByDisplayValue('幸运')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('敲击音效临时关闭提示词生成入口,仅保留上传和录音', () => {
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const sectionTitle = screen.getByText('敲击音效');
|
||||
const section = sectionTitle.closest('section');
|
||||
|
||||
expect(section).not.toBeNull();
|
||||
expect(within(section as HTMLElement).queryByText('音效描述')).toBeNull();
|
||||
expect(within(section as HTMLElement).getByText('上传')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).getByText('录音')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('敲击音效和功德词条不放进独立滚动窗', () => {
|
||||
const { container } = render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const audioSection = screen.getByText('敲击音效').closest('section');
|
||||
const floatingWordsSection = screen.getByText('功德有什么').closest('section');
|
||||
const sidePanel = audioSection?.parentElement;
|
||||
|
||||
expect(audioSection).not.toBeNull();
|
||||
expect(floatingWordsSection).not.toBeNull();
|
||||
expect(sidePanel).toBe(floatingWordsSection?.parentElement);
|
||||
expect(sidePanel?.className).not.toContain('overflow-y-auto');
|
||||
expect(sidePanel?.className).not.toContain('min-h-0');
|
||||
expect(container.querySelector('.overflow-y-auto')).toBeNull();
|
||||
expect(container.firstElementChild?.className).not.toContain('h-full');
|
||||
});
|
||||
|
||||
test('工作台只保留一个生成按钮', () => {
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole('button', { name: '生成' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('敲木鱼工作台可以交给统一创作页承载可见外壳', () => {
|
||||
const { container } = render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
showBackButton={false}
|
||||
unifiedChrome
|
||||
/>,
|
||||
);
|
||||
|
||||
const workspace = container.querySelector('.wooden-fish-workspace');
|
||||
expect(workspace?.getAttribute('data-unified-chrome')).toBe('true');
|
||||
expect(workspace?.className).toContain('max-w-none');
|
||||
expect(workspace?.className).not.toContain('platform-remap-surface');
|
||||
expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
|
||||
});
|
||||
|
||||
test('敲木鱼工作台在统一壳下不强行填满左侧图片面板高度', () => {
|
||||
const { container } = render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
showBackButton={false}
|
||||
unifiedChrome
|
||||
/>,
|
||||
);
|
||||
|
||||
const imagePanel = container.querySelector('.creative-image-input-panel');
|
||||
expect(imagePanel?.className).toContain('flex-none');
|
||||
expect(imagePanel?.className).not.toContain('flex-1');
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Plus,
|
||||
Send,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
WoodenFishAudioAsset,
|
||||
WoodenFishSessionResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
} from '../../../../packages/shared/src/contracts/woodenFish';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../../services/puzzleReferenceImage';
|
||||
import { woodenFishClient } from '../../../services/wooden-fish/woodenFishClient';
|
||||
import {
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
} from '../../../services/wooden-fish/woodenFishDefaults';
|
||||
import { CreativeAudioInputPanel } from '../../common/CreativeAudioInputPanel';
|
||||
import { CreativeImageInputPanel } from '../../common/CreativeImageInputPanel';
|
||||
|
||||
type WoodenFishCreationWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitted: (
|
||||
result: WoodenFishSessionResponse,
|
||||
payload: WoodenFishWorkspaceCreateRequest,
|
||||
) => void;
|
||||
showBackButton?: boolean;
|
||||
unifiedChrome?: boolean;
|
||||
};
|
||||
|
||||
type WoodenFishWorkspaceFormState = {
|
||||
hitObjectPrompt: string;
|
||||
hitObjectReferenceImageSrc: string;
|
||||
hitSoundAsset: WoodenFishAudioAsset | null;
|
||||
floatingWords: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_THEME_TAGS = ['敲木鱼', '解压'];
|
||||
const DEFAULT_FLOATING_WORDS = ['幸运'];
|
||||
const MAX_FLOATING_WORD_COUNT = 8;
|
||||
|
||||
const DEFAULT_FORM_STATE: WoodenFishWorkspaceFormState = {
|
||||
hitObjectPrompt: '',
|
||||
hitObjectReferenceImageSrc: '',
|
||||
hitSoundAsset: null,
|
||||
floatingWords: DEFAULT_FLOATING_WORDS,
|
||||
};
|
||||
|
||||
function normalizeFloatingWords(words: string[]) {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const word of words) {
|
||||
const trimmed = word.trim().replace(/[++]\s*1$/u, '').trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
if (normalized.length >= 8) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return normalized.length > 0 ? normalized : [...DEFAULT_FLOATING_WORDS];
|
||||
}
|
||||
|
||||
export function WoodenFishCreationWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitted,
|
||||
showBackButton = true,
|
||||
unifiedChrome = false,
|
||||
}: WoodenFishCreationWorkspaceProps) {
|
||||
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [aiRedraw, setAiRedraw] = useState(true);
|
||||
|
||||
const normalizedFloatingWords = useMemo(
|
||||
() => normalizeFloatingWords(formState.floatingWords),
|
||||
[formState.floatingWords],
|
||||
);
|
||||
const canSubmit = normalizedFloatingWords.length > 0;
|
||||
|
||||
const updateFloatingWord = (index: number, value: string) => {
|
||||
setFormState((current) => {
|
||||
const nextWords = [...current.floatingWords];
|
||||
nextWords[index] = value;
|
||||
return {
|
||||
...current,
|
||||
floatingWords: nextWords.slice(0, MAX_FLOATING_WORD_COUNT),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const addFloatingWord = () => {
|
||||
setFormState((current) => {
|
||||
if (current.floatingWords.length >= MAX_FLOATING_WORD_COUNT) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
floatingWords: [...current.floatingWords, ''],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const removeFloatingWord = (index: number) => {
|
||||
if (index <= 0) {
|
||||
return;
|
||||
}
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
floatingWords: current.floatingWords.filter(
|
||||
(_word, currentIndex) => currentIndex !== index,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || isSubmitting || isBusy) {
|
||||
setLocalError('请先补全输入。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setLocalError(null);
|
||||
|
||||
try {
|
||||
const payload: WoodenFishWorkspaceCreateRequest = {
|
||||
templateId: 'wooden-fish',
|
||||
workTitle: '',
|
||||
workDescription:
|
||||
formState.hitObjectPrompt.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
themeTags: DEFAULT_THEME_TAGS,
|
||||
hitObjectPrompt:
|
||||
formState.hitObjectPrompt.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
hitObjectReferenceImageSrc:
|
||||
formState.hitObjectReferenceImageSrc.trim() || null,
|
||||
hitSoundPrompt: null,
|
||||
hitSoundAsset:
|
||||
formState.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
floatingWords: normalizedFloatingWords,
|
||||
};
|
||||
const response = await woodenFishClient.createSession(payload);
|
||||
onSubmitted(response, payload);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '创建草稿失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
unifiedChrome
|
||||
? 'wooden-fish-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
|
||||
: 'wooden-fish-workspace platform-remap-surface mx-auto flex w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4'
|
||||
}
|
||||
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
|
||||
>
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(19rem,0.88fr)]">
|
||||
<div className="flex min-h-[26rem] min-w-0 flex-col">
|
||||
<CreativeImageInputPanel
|
||||
fillHeight={!unifiedChrome}
|
||||
disabled={isBusy || isSubmitting}
|
||||
isSubmitting={isSubmitting}
|
||||
uploadedImageSrc={formState.hitObjectReferenceImageSrc}
|
||||
uploadedImageAlt="敲击物参考图"
|
||||
mainImageInputId="wooden-fish-hit-object-reference"
|
||||
promptTextareaId="wooden-fish-hit-object-prompt"
|
||||
prompt={formState.hitObjectPrompt}
|
||||
promptLabel="敲什么"
|
||||
promptRows={4}
|
||||
aiRedraw={aiRedraw}
|
||||
promptReferenceImages={[]}
|
||||
showSubmitButton={false}
|
||||
submitLabel="生成"
|
||||
submitDisabled={!canSubmit || isSubmitting || isBusy}
|
||||
labels={{
|
||||
imageField: '参考图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '替换参考图',
|
||||
emptyImageHint: '上传图像',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图',
|
||||
removeImageConfirmBody: '移除后仍可用文字描述生成敲击物图案。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '敲击物参考图',
|
||||
closePromptReferencePreview: '关闭预览',
|
||||
}}
|
||||
onMainImageFileSelect={(file) => {
|
||||
void readPuzzleReferenceImageAsDataUrl(file)
|
||||
.then((dataUrl) => {
|
||||
setLocalError(null);
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
hitObjectReferenceImageSrc: dataUrl,
|
||||
}));
|
||||
setAiRedraw(true);
|
||||
})
|
||||
.catch((caughtError) => {
|
||||
setLocalError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '参考图读取失败。',
|
||||
);
|
||||
});
|
||||
}}
|
||||
onMainImageRemove={() => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
hitObjectReferenceImageSrc: '',
|
||||
}));
|
||||
}}
|
||||
onAiRedrawChange={setAiRedraw}
|
||||
onPromptChange={(value) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
hitObjectPrompt: value,
|
||||
}))
|
||||
}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
|
||||
<CreativeAudioInputPanel<WoodenFishAudioAsset>
|
||||
disabled={isBusy || isSubmitting}
|
||||
title="敲击音效"
|
||||
defaultLabel="默认木鱼音"
|
||||
asset={formState.hitSoundAsset}
|
||||
buildRecordedFileName={() => `wooden-fish-hit-${Date.now()}.webm`}
|
||||
onAssetChange={(asset) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
hitSoundAsset: asset,
|
||||
}))
|
||||
}
|
||||
onError={setLocalError}
|
||||
/>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
功德有什么
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{formState.floatingWords.map((word, index) => (
|
||||
<div key={index} className="relative">
|
||||
<input
|
||||
value={word}
|
||||
maxLength={16}
|
||||
disabled={isBusy || isSubmitting}
|
||||
aria-label={`功德词条 ${index + 1}`}
|
||||
onChange={(event) =>
|
||||
updateFloatingWord(index, event.target.value)
|
||||
}
|
||||
className="h-12 w-full rounded-[0.95rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 pr-10 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
{index > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || isSubmitting}
|
||||
onClick={() => removeFloatingWord(index)}
|
||||
className="absolute right-2 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full bg-white/92 text-[var(--platform-text-soft)] shadow-sm transition hover:text-[var(--platform-accent)] disabled:opacity-45"
|
||||
aria-label={`删除功德词条 ${index + 1}`}
|
||||
title="删除词条"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{formState.floatingWords.length < MAX_FLOATING_WORD_COUNT ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || isSubmitting}
|
||||
onClick={addFloatingWord}
|
||||
className="grid h-12 place-items-center rounded-[0.95rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/55 text-[var(--platform-text-soft)] transition hover:border-[var(--platform-accent)] hover:bg-white/78 hover:text-[var(--platform-accent)] disabled:opacity-45"
|
||||
aria-label="新增功德词条"
|
||||
title="新增词条"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localError || error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{localError ?? error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || isSubmitting || isBusy}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || isSubmitting || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WoodenFishCreationWorkspace;
|
||||
Reference in New Issue
Block a user