收口统一创作流程一期

This commit is contained in:
2026-05-31 14:46:32 +00:00
parent 724d8be405
commit 23dec91bd6
36 changed files with 919 additions and 469 deletions

View File

@@ -1,15 +1,20 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { UnifiedCreationPage } from './UnifiedCreationPage';
import { getUnifiedCreationSpec } from './unifiedCreationSpecs';
describe('UnifiedCreationPage', () => {
test('按后端字段 spec 暴露统一创作页字段契约', () => {
const onBack = vi.fn();
render(
<UnifiedCreationPage spec={getUnifiedCreationSpec('wooden-fish')}>
<UnifiedCreationPage
spec={getUnifiedCreationSpec('wooden-fish')}
onBack={onBack}
>
<div></div>
</UnifiedCreationPage>,
);
@@ -41,6 +46,8 @@ describe('UnifiedCreationPage', () => {
expect(screen.getByTestId('unified-creation-play-badge').textContent).toBe(
'wooden-fish',
);
fireEvent.click(screen.getByRole('button', { name: '返回' }));
expect(onBack).toHaveBeenCalledTimes(1);
expect(screen.queryByLabelText('创作字段')).toBeNull();
expect(screen.queryByTestId('unified-creation-visible-field')).toBeNull();
expect(
@@ -48,7 +55,19 @@ describe('UnifiedCreationPage', () => {
.getByText('敲木鱼工作台')
.closest('.unified-creation-page__content')
?.className,
).not.toContain('overflow-y-auto');
expect(root?.className).not.toContain('overflow-hidden');
).toContain('flex');
expect(
screen
.getByText('敲木鱼工作台')
.closest('.unified-creation-page__content')
?.className,
).toContain('min-h-max');
expect(
screen
.getByText('敲木鱼工作台')
.closest('.unified-creation-page__content')
?.className,
).not.toContain('min-h-0');
expect(root?.className).toContain('overflow-y-auto');
});
});

View File

@@ -1,3 +1,4 @@
import { ArrowLeft } from 'lucide-react';
import type { ReactNode } from 'react';
import type { UnifiedCreationSpec } from './unifiedCreationSpecs';
@@ -5,15 +6,19 @@ import type { UnifiedCreationSpec } from './unifiedCreationSpecs';
type UnifiedCreationPageProps = {
spec: UnifiedCreationSpec;
children: ReactNode;
onBack?: () => void;
isBackDisabled?: boolean;
};
export function UnifiedCreationPage({
spec,
children,
onBack,
isBackDisabled = false,
}: UnifiedCreationPageProps) {
return (
<div
className="unified-creation-page platform-remap-surface mx-auto flex w-full max-w-5xl flex-col px-3 pt-2 sm:px-4 sm:pt-3"
className="unified-creation-page platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-y-auto overflow-x-hidden px-3 pt-2 sm:px-4 sm:pt-3"
data-play-id={spec.playId}
data-field-kinds={spec.fields.map((field) => field.kind).join(',')}
data-workspace-stage={spec.workspaceStage}
@@ -21,10 +26,22 @@ export function UnifiedCreationPage({
data-result-stage={spec.resultStage}
>
<header className="unified-creation-page__header shrink-0 pb-3">
<div className="flex items-center justify-between gap-3">
<h1 className="m-0 min-w-0 truncate text-[1.35rem] font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-[1.65rem]">
{spec.title}
</h1>
<div className="mb-2 flex items-center justify-between gap-3">
{onBack ? (
<button
type="button"
onClick={onBack}
disabled={isBackDisabled}
className={`platform-button platform-button--ghost min-h-0 shrink-0 px-3 py-1.5 text-[11px] ${
isBackDisabled ? 'cursor-not-allowed opacity-45' : ''
}`}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
) : (
<span aria-hidden="true" className="min-h-8 w-0 shrink-0" />
)}
<span
className="unified-creation-page__play-badge shrink-0 rounded-full border border-[var(--platform-subpanel-border)] bg-white/80 px-3 py-1 text-[11px] font-black text-[var(--platform-text-soft)]"
data-testid="unified-creation-play-badge"
@@ -32,6 +49,11 @@ export function UnifiedCreationPage({
{spec.playId}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<h1 className="m-0 min-w-0 truncate text-[1.35rem] font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-[1.65rem]">
{spec.title}
</h1>
</div>
</header>
<div className="sr-only" data-testid="unified-creation-spec">
<h1>{spec.title}</h1>
@@ -49,7 +71,7 @@ export function UnifiedCreationPage({
))}
</ul>
</div>
<div className="unified-creation-page__content pb-3 sm:pb-4">
<div className="unified-creation-page__content flex min-h-max flex-col pb-3 sm:pb-4">
{children}
</div>
</div>

View File

@@ -0,0 +1,184 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { UnifiedCreationWorkspace } from './UnifiedCreationWorkspace';
import { getUnifiedCreationSpec } from './unifiedCreationSpecs';
vi.mock('./workspaces/PuzzleCreationWorkspace', () => ({
PuzzleCreationWorkspace: ({
unifiedChrome,
}: {
unifiedChrome?: boolean;
}) => (
<div
className="puzzle-agent-workspace"
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
>
</div>
),
}));
vi.mock('./workspaces/Match3DCreationWorkspace', () => ({
Match3DCreationWorkspace: ({
unifiedChrome,
}: {
unifiedChrome?: boolean;
}) => (
<div
className="match3d-agent-workspace"
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
>
</div>
),
}));
vi.mock('./workspaces/JumpHopCreationWorkspace', () => ({
JumpHopCreationWorkspace: ({
unifiedChrome,
}: {
unifiedChrome?: boolean;
}) => (
<div
className="jump-hop-workspace"
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
>
</div>
),
}));
vi.mock('./workspaces/WoodenFishCreationWorkspace', () => ({
WoodenFishCreationWorkspace: ({
unifiedChrome,
}: {
unifiedChrome?: boolean;
}) => (
<div
className="wooden-fish-workspace"
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
>
</div>
),
}));
describe('UnifiedCreationWorkspace', () => {
test('统一承载四条首批创作入口', () => {
const onBack = vi.fn();
const puzzleResult = render(
<UnifiedCreationWorkspace
playId="puzzle"
spec={getUnifiedCreationSpec('puzzle')}
session={null}
isBusy={false}
error={null}
onBack={onBack}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
onAutoSaveForm={() => {}}
initialFormPayload={null}
/>,
);
const puzzleWorkspace = screen
.getByText('拼图工作台')
.closest('[data-unified-chrome]');
const puzzlePage = screen
.getByText('拼图工作台')
.closest('.unified-creation-page');
expect(puzzleWorkspace?.getAttribute('data-unified-chrome')).toBe('true');
expect(puzzlePage?.getAttribute('data-play-id')).toBe('puzzle');
expect(screen.getByRole('button', { name: '返回' })).toBeTruthy();
puzzleResult.unmount();
const match3dResult = render(
<UnifiedCreationWorkspace
playId="match3d"
spec={getUnifiedCreationSpec('match3d')}
session={null}
isBusy={false}
error={null}
onBack={onBack}
onExecuteAction={() => {}}
onSubmitMessage={() => {}}
onCreateFromForm={() => {}}
initialFormPayload={null}
/>,
);
const match3dWorkspace = screen
.getByText('抓大鹅工作台')
.closest('[data-unified-chrome]');
const match3dPage = screen
.getByText('抓大鹅工作台')
.closest('.unified-creation-page');
expect(match3dWorkspace?.getAttribute('data-unified-chrome')).toBe('true');
expect(match3dPage?.getAttribute('data-play-id')).toBe('match3d');
match3dResult.unmount();
const jumpHopResult = render(
<UnifiedCreationWorkspace
playId="jump-hop"
spec={getUnifiedCreationSpec('jump-hop')}
isBusy={false}
error={null}
onBack={onBack}
onSubmitted={() => {}}
/>,
);
const jumpHopWorkspace = screen
.getByText('跳一跳工作台')
.closest('[data-unified-chrome]');
const jumpHopPage = screen
.getByText('跳一跳工作台')
.closest('.unified-creation-page');
expect(jumpHopWorkspace?.getAttribute('data-unified-chrome')).toBe('true');
expect(jumpHopPage?.getAttribute('data-play-id')).toBe('jump-hop');
jumpHopResult.unmount();
const woodenFishResult = render(
<UnifiedCreationWorkspace
playId="wooden-fish"
spec={getUnifiedCreationSpec('wooden-fish')}
isBusy={false}
error={null}
onBack={onBack}
onSubmitted={() => {}}
/>,
);
const woodenFishWorkspace = screen
.getByText('敲木鱼工作台')
.closest('[data-unified-chrome]');
const woodenFishPage = screen
.getByText('敲木鱼工作台')
.closest('.unified-creation-page');
expect(woodenFishWorkspace?.getAttribute('data-unified-chrome')).toBe(
'true',
);
expect(woodenFishPage?.getAttribute('data-play-id')).toBe('wooden-fish');
woodenFishResult.unmount();
});
test('统一页头返回按钮会透传给当前玩法壳层', async () => {
const onBack = vi.fn();
render(
<UnifiedCreationWorkspace
playId="jump-hop"
spec={getUnifiedCreationSpec('jump-hop')}
isBusy={false}
error={null}
onBack={onBack}
onSubmitted={() => {}}
/>,
);
screen.getByRole('button', { name: '返回' }).click();
expect(onBack).toHaveBeenCalledTimes(1);
expect(screen.queryAllByRole('button', { name: '返回' })).toHaveLength(1);
});
});

View File

@@ -0,0 +1,125 @@
import type { ComponentProps } from 'react';
import { UnifiedCreationPage } from './UnifiedCreationPage';
import type { UnifiedCreationSpec } from './unifiedCreationSpecs';
import { Match3DCreationWorkspace } from './workspaces/Match3DCreationWorkspace';
import { PuzzleCreationWorkspace } from './workspaces/PuzzleCreationWorkspace';
import { JumpHopCreationWorkspace } from './workspaces/JumpHopCreationWorkspace';
import { WoodenFishCreationWorkspace } from './workspaces/WoodenFishCreationWorkspace';
type PuzzleCreationWorkspaceProps = ComponentProps<
typeof PuzzleCreationWorkspace
>;
type Match3DCreationWorkspaceProps = ComponentProps<
typeof Match3DCreationWorkspace
>;
type JumpHopCreationWorkspaceProps = ComponentProps<
typeof JumpHopCreationWorkspace
>;
type WoodenFishCreationWorkspaceProps = ComponentProps<
typeof WoodenFishCreationWorkspace
>;
type UnifiedCreationWorkspaceBaseProps = {
spec: UnifiedCreationSpec;
};
type PuzzleUnifiedCreationWorkspaceProps =
UnifiedCreationWorkspaceBaseProps & {
playId: 'puzzle';
} & PuzzleCreationWorkspaceProps;
type Match3DUnifiedCreationWorkspaceProps =
UnifiedCreationWorkspaceBaseProps & {
playId: 'match3d';
} & Match3DCreationWorkspaceProps;
type JumpHopUnifiedCreationWorkspaceProps =
UnifiedCreationWorkspaceBaseProps & {
playId: 'jump-hop';
} & JumpHopCreationWorkspaceProps;
type WoodenFishUnifiedCreationWorkspaceProps =
UnifiedCreationWorkspaceBaseProps & {
playId: 'wooden-fish';
} & WoodenFishCreationWorkspaceProps;
export type UnifiedCreationWorkspaceProps =
| PuzzleUnifiedCreationWorkspaceProps
| Match3DUnifiedCreationWorkspaceProps
| JumpHopUnifiedCreationWorkspaceProps
| WoodenFishUnifiedCreationWorkspaceProps;
export function UnifiedCreationWorkspace(props: UnifiedCreationWorkspaceProps) {
switch (props.playId) {
case 'puzzle':
return (
<UnifiedCreationPage spec={props.spec} onBack={props.onBack}>
<PuzzleCreationWorkspace
session={props.session}
isBusy={props.isBusy}
error={props.error}
onBack={props.onBack}
onSubmitMessage={props.onSubmitMessage}
onExecuteAction={props.onExecuteAction}
onCreateFromForm={props.onCreateFromForm}
onAutoSaveForm={props.onAutoSaveForm}
initialFormPayload={props.initialFormPayload}
showBackButton={false}
title={null}
unifiedChrome
/>
</UnifiedCreationPage>
);
case 'match3d':
return (
<UnifiedCreationPage spec={props.spec} onBack={props.onBack}>
<Match3DCreationWorkspace
session={props.session}
isBusy={props.isBusy}
error={props.error}
onBack={props.onBack}
onExecuteAction={props.onExecuteAction}
onCreateFromForm={props.onCreateFromForm}
onSubmitMessage={props.onSubmitMessage}
initialFormPayload={props.initialFormPayload}
showBackButton={false}
title={null}
unifiedChrome
/>
</UnifiedCreationPage>
);
case 'jump-hop':
return (
<UnifiedCreationPage spec={props.spec} onBack={props.onBack}>
<JumpHopCreationWorkspace
isBusy={props.isBusy}
error={props.error}
onBack={props.onBack}
onSubmitted={props.onSubmitted}
showBackButton={false}
unifiedChrome
/>
</UnifiedCreationPage>
);
case 'wooden-fish':
return (
<UnifiedCreationPage spec={props.spec} onBack={props.onBack}>
<WoodenFishCreationWorkspace
isBusy={props.isBusy}
error={props.error}
onBack={props.onBack}
onSubmitted={props.onSubmitted}
showBackButton={false}
unifiedChrome
/>
</UnifiedCreationPage>
);
default: {
const exhaustiveCheck: never = props;
return exhaustiveCheck;
}
}
}
export default UnifiedCreationWorkspace;

View File

@@ -50,4 +50,22 @@ describe('UnifiedGenerationPage', () => {
expect(screen.getAllByText('生成拼图首图').length).toBeGreaterThan(0);
expect(screen.getByText('当前拼图信息')).toBeTruthy();
});
test('jump-hop generation page uses unified copy', () => {
render(
<UnifiedGenerationPage
playId="jump-hop"
settingText="云端糖果塔"
progress={createProgress()}
isGenerating
onBack={() => {}}
onEditSetting={() => {}}
onRetry={() => {}}
/>,
);
expect(document.body.textContent).toContain('跳一跳草稿生成进度');
expect(screen.getByText('素材生成中')).toBeTruthy();
expect(screen.getByText('当前跳一跳信息')).toBeTruthy();
});
});

View File

@@ -0,0 +1,157 @@
import { X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
puzzleAssetClient,
type PuzzleHistoryAsset,
} from '../../../services/puzzle-works/puzzleAssetClient';
import {
formatPuzzleHistoryAssetCreatedAt,
getPuzzleHistoryAssetDisplayName,
} from '../../../services/puzzle-works/puzzleHistoryAsset';
import { useAuthUi } from '../../auth/AuthUiContext';
import { ResolvedAssetImage } from '../../ResolvedAssetImage';
type PuzzleHistoryAssetPickerDialogProps = {
isBusy: boolean;
onClose: () => void;
onSelect: (asset: PuzzleHistoryAsset) => void;
};
export function PuzzleHistoryAssetPickerDialog({
isBusy,
onClose,
onSelect,
}: PuzzleHistoryAssetPickerDialogProps) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [assets, setAssets] = useState<PuzzleHistoryAsset[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(null);
puzzleAssetClient
.listHistoryAssets({ limit: 120 })
.then((nextAssets) => {
if (!cancelled) {
setAssets(nextAssets);
}
})
.catch((loadError) => {
if (!cancelled) {
setError(
loadError instanceof Error
? loadError.message
: '历史拼图素材读取失败。',
);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[145] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="选择历史图片"
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,46rem)] w-full max-w-5xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="platform-icon-button"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{error ? (
<div className="platform-banner platform-banner--danger text-sm leading-6">
{error}
</div>
) : null}
{isLoading ? (
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
...
</div>
) : null}
{!isLoading && !error && assets.length <= 0 ? (
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
</div>
) : null}
{!isLoading && assets.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{assets.map((asset) => {
const displayName = getPuzzleHistoryAssetDisplayName(
asset.imageSrc,
);
return (
<button
key={asset.assetObjectId}
type="button"
disabled={isBusy}
onClick={() => onSelect(asset)}
className={`overflow-hidden rounded-[1.25rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
>
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={asset.imageSrc}
alt={displayName}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-1 px-4 py-4">
<div className="truncate text-sm font-black text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-base)]">
{formatPuzzleHistoryAssetCreatedAt(asset.createdAt)}
</div>
</div>
</button>
);
})}
</div>
) : null}
</div>
</div>
</div>,
document.body,
);
}
export default PuzzleHistoryAssetPickerDialog;

View File

@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react';
import {
getPuzzleImageModelLabel,
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_OPTIONS,
type PuzzleImageModelId,
} from './puzzleImageModelOptions';
type PuzzleImageModelPickerProps = {
value: PuzzleImageModelId;
disabled?: boolean;
onChange: (value: PuzzleImageModelId) => void;
};
export function PuzzleImageModelPicker({
value,
disabled = false,
onChange,
}: PuzzleImageModelPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const normalizedValue = normalizePuzzleImageModel(value);
useEffect(() => {
if (!isOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
window.addEventListener('pointerdown', handlePointerDown);
return () => window.removeEventListener('pointerdown', handlePointerDown);
}, [isOpen]);
return (
<div ref={rootRef} className="absolute bottom-3 left-3 z-10">
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen((current) => !current)}
className={`inline-flex min-h-8 max-w-[10rem] items-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 px-3 text-[11px] font-bold text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label="图片生成模式"
title="图片生成模式"
>
<span className="truncate">
{getPuzzleImageModelLabel(normalizedValue)}
</span>
</button>
{isOpen ? (
<div
role="menu"
className="absolute bottom-10 left-0 min-w-[11rem] overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/98 p-1 shadow-[0_16px_40px_rgba(0,0,0,0.18)]"
>
{PUZZLE_IMAGE_MODEL_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
role="menuitemradio"
aria-checked={option.id === normalizedValue}
onClick={() => {
onChange(option.id);
setIsOpen(false);
}}
className={`block min-h-9 w-full rounded-[0.8rem] px-3 text-left text-xs font-bold transition ${
option.id === normalizedValue
? 'bg-amber-100/80 text-amber-800'
: 'text-[var(--platform-text-base)] hover:bg-[var(--platform-subpanel-fill)]'
}`}
>
{option.label}
</button>
))}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,30 @@
export const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
export const PUZZLE_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
export type PuzzleImageModelId =
| typeof PUZZLE_IMAGE_MODEL_GPT_IMAGE_2
| typeof PUZZLE_IMAGE_MODEL_NANOBANANA2;
export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{
id: PuzzleImageModelId;
label: string;
}> = [
{ id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: '标准模式' },
{ id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: '创意模式' },
];
export function normalizePuzzleImageModel(
value: string | null | undefined,
): PuzzleImageModelId {
return (
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === value)?.id ??
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2
);
}
export function getPuzzleImageModelLabel(model: PuzzleImageModelId) {
return (
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === model)?.label ??
'标准模式'
);
}

View File

@@ -6,9 +6,9 @@ import {
} from './unifiedCreationSpecs';
describe('unified creation specs', () => {
test('一期只接拼图、抓大鹅和敲木鱼', () => {
test('统一壳当前覆盖拼图、抓大鹅、跳一跳和敲木鱼', () => {
expect(listUnifiedCreationSpecs().map((spec) => spec.playId).sort()).toEqual(
['match3d', 'puzzle', 'wooden-fish'],
['jump-hop', 'match3d', 'puzzle', 'wooden-fish'],
);
});
@@ -22,7 +22,7 @@ describe('unified creation specs', () => {
expect([...fieldKinds].sort()).toEqual(['audio', 'image', 'select', 'text']);
});
test('条链路都映射到统一创作、生成、结果阶段', () => {
test('条链路都映射到统一创作、生成、结果阶段', () => {
expect(getUnifiedCreationSpec('puzzle')).toMatchObject({
workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating',
@@ -33,6 +33,11 @@ describe('unified creation specs', () => {
generationStage: 'match3d-generating',
resultStage: 'match3d-result',
});
expect(getUnifiedCreationSpec('jump-hop')).toMatchObject({
workspaceStage: 'jump-hop-workspace',
generationStage: 'jump-hop-generating',
resultStage: 'jump-hop-result',
});
expect(getUnifiedCreationSpec('wooden-fish')).toMatchObject({
workspaceStage: 'wooden-fish-workspace',
generationStage: 'wooden-fish-generating',

View File

@@ -58,6 +58,63 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
},
],
},
'jump-hop': {
playId: 'jump-hop',
title: '想做个什么玩法?',
workspaceStage: 'jump-hop-workspace',
generationStage: 'jump-hop-generating',
resultStage: 'jump-hop-result',
fields: [
{
id: 'workTitle',
kind: 'text',
label: '作品标题',
required: true,
},
{
id: 'workDescription',
kind: 'text',
label: '作品简介',
required: true,
},
{
id: 'themeTags',
kind: 'text',
label: '主题标签',
required: true,
},
{
id: 'difficulty',
kind: 'select',
label: '难度',
required: true,
},
{
id: 'stylePreset',
kind: 'select',
label: '风格',
required: true,
},
{
id: 'characterPrompt',
kind: 'text',
label: '角色提示词',
required: true,
},
{
id: 'tilePrompt',
kind: 'text',
label: '地块提示词',
required: true,
},
{
id: 'endMoodPrompt',
kind: 'text',
label: '终点氛围',
required: false,
},
],
},
'wooden-fish': {
playId: 'wooden-fish',
title: '想做个什么玩法?',

View File

@@ -13,6 +13,12 @@ const UNIFIED_GENERATION_COPY = {
progressTitle: '抓大鹅草稿生成进度',
activeBadgeLabel: '素材生成中',
},
'jump-hop': {
retryLabel: '重新生成草稿',
settingTitle: '当前跳一跳信息',
progressTitle: '跳一跳草稿生成进度',
activeBadgeLabel: '素材生成中',
},
'wooden-fish': {
retryLabel: '重新生成草稿',
settingTitle: '当前敲木鱼信息',

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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,
});
});

View File

@@ -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;

View File

@@ -0,0 +1,786 @@
import { ArrowLeft } from 'lucide-react';
import {
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { PuzzleAgentActionRequest } from '../../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../../packages/shared/src/contracts/puzzleAgentSession';
import { getPuzzleHistoryAssetReferenceLabel } from '../../../services/puzzle-works/puzzleHistoryAsset';
import {
cropPuzzleReferenceImageDataUrl,
isPuzzleReferenceImageSquare,
readPuzzleReferenceImageAsDataUrl,
readPuzzleReferenceImageForUpload,
} from '../../../services/puzzleReferenceImage';
import {
CreativeImageInputPanel,
type CreativeImageInputReferenceImage,
} from '../../common/CreativeImageInputPanel';
import {
buildCenteredSquareImageCropRect,
clampSquareImageCropRect,
SquareImageCropModal,
type SquareImageCropRect,
} from '../../common/SquareImageCropModal';
import PuzzleHistoryAssetPickerDialog from '../shared/PuzzleHistoryAssetPickerDialog';
import {
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
type PuzzleImageModelId,
} from '../shared/puzzleImageModelOptions';
import { PuzzleImageModelPicker } from '../shared/PuzzleImageModelPicker';
type PuzzleCreationWorkspaceProps = {
session: PuzzleAgentSessionSnapshot | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitMessage: (payload: SendPuzzleAgentMessageRequest) => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
onCreateFromForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
onAutoSaveForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
initialFormPayload?: CreatePuzzleAgentSessionRequest | null;
showBackButton?: boolean;
title?: string | null;
unifiedChrome?: boolean;
};
type PuzzleFormState = {
pictureDescription: string;
referenceImageSrc: string;
referenceImageAssetObjectId: string;
referenceImageLabel: string;
referenceImageSrcs: CreativeImageInputReferenceImage[];
imageModel: PuzzleImageModelId;
aiRedraw: boolean;
};
const EMPTY_FORM_STATE: PuzzleFormState = {
pictureDescription: '',
referenceImageSrc: '',
referenceImageAssetObjectId: '',
referenceImageLabel: '',
referenceImageSrcs: [],
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
};
const PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT = 5;
type PuzzleImageCropState = {
source: string;
label: string;
fileName: string;
imageSize: { width: number; height: number };
cropRect: SquareImageCropRect;
error: string | null;
isSaving: boolean;
};
function resolveInitialFormState(
session: PuzzleAgentSessionSnapshot | null,
initialFormPayload: CreatePuzzleAgentSessionRequest | null = null,
): PuzzleFormState {
const shouldTreatEmptyPayloadAsFreshForm =
!session &&
Boolean(initialFormPayload) &&
Object.keys(initialFormPayload ?? {}).length === 0;
if (shouldTreatEmptyPayloadAsFreshForm) {
return EMPTY_FORM_STATE;
}
const formDraft = session?.draft?.formDraft;
if (formDraft) {
return {
pictureDescription: formDraft.pictureDescription ?? '',
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
referenceImageAssetObjectId:
initialFormPayload?.referenceImageAssetObjectId ?? '',
referenceImageLabel: initialFormPayload?.referenceImageSrc
? '已选择拼图图片'
: '',
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
initialFormPayload?.referenceImageSrcs,
initialFormPayload?.referenceImageAssetObjectIds,
),
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
aiRedraw: initialFormPayload?.aiRedraw ?? true,
};
}
if (initialFormPayload) {
return {
pictureDescription:
initialFormPayload.pictureDescription ??
initialFormPayload.seedText ??
'',
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
referenceImageAssetObjectId:
initialFormPayload.referenceImageAssetObjectId ?? '',
referenceImageLabel: initialFormPayload.referenceImageSrc
? '已选择拼图图片'
: '',
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
initialFormPayload.referenceImageSrcs,
initialFormPayload.referenceImageAssetObjectIds,
),
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
aiRedraw: initialFormPayload.aiRedraw ?? true,
};
}
if (!session) {
return EMPTY_FORM_STATE;
}
return {
pictureDescription:
session.draft?.formDraft?.pictureDescription ||
session.draft?.levels?.[0]?.pictureDescription ||
session.anchorPack.visualSubject.value ||
session.seedText ||
'',
referenceImageSrc: '',
referenceImageAssetObjectId: '',
referenceImageLabel: '',
referenceImageSrcs: [],
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
};
}
function normalizePuzzlePromptReferenceSources(
sources: readonly string[] | null | undefined,
) {
const normalizedSources: string[] = [];
for (const source of sources ?? []) {
const normalized = source.trim();
if (
normalized &&
!normalizedSources.some((current) => current === normalized)
) {
normalizedSources.push(normalized);
}
if (normalizedSources.length >= PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT) {
break;
}
}
return normalizedSources;
}
function createPuzzlePromptReferenceImagesFromSources(
sources: readonly string[] | null | undefined,
assetObjectIds: readonly string[] | null | undefined = [],
): CreativeImageInputReferenceImage[] {
const assetIds = normalizePuzzleAssetObjectIds(assetObjectIds);
const sourceImages = normalizePuzzlePromptReferenceSources(sources).map(
(imageSrc, index) => ({
id: `restored:${index}:${imageSrc}`,
label: `参考图 ${index + 1}`,
imageSrc,
assetObjectId: assetIds[index] ?? null,
}),
);
if (sourceImages.length > 0) {
return sourceImages;
}
return assetIds.map((assetObjectId, index) => ({
id: `restored-asset:${index}:${assetObjectId}`,
label: `参考图 ${index + 1}`,
imageSrc: '',
assetObjectId,
}));
}
function normalizePuzzleAssetObjectIds(
assetObjectIds: readonly (string | null | undefined)[] | null | undefined,
) {
const normalizedIds: string[] = [];
for (const assetObjectId of assetObjectIds ?? []) {
const normalized = assetObjectId?.trim() ?? '';
if (
normalized &&
!normalizedIds.some((current) => current === normalized)
) {
normalizedIds.push(normalized);
}
if (normalizedIds.length >= PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT) {
break;
}
}
return normalizedIds;
}
function addPuzzlePromptReferenceImage(
currentImages: CreativeImageInputReferenceImage[],
nextImage: CreativeImageInputReferenceImage,
) {
const deduped = currentImages.filter(
(image) => image.imageSrc !== nextImage.imageSrc,
);
return [...deduped, nextImage].slice(
0,
PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT,
);
}
/**
* 拼图创作入口已从 Agent 对话改为填表式。
* 统一创作目录内的拼图工作台实现。
*/
export function PuzzleCreationWorkspace({
session,
isBusy = false,
error = null,
onBack,
onExecuteAction,
onCreateFromForm,
onAutoSaveForm,
initialFormPayload = null,
showBackButton = true,
title = '想做个什么玩法?',
unifiedChrome = false,
}: PuzzleCreationWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
);
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
const previousSessionIdRef = useRef<string | null>(
session?.sessionId ?? null,
);
const appliedInitialFormKeyRef = useRef<string | null>(null);
useEffect(() => {
const currentSessionId = session?.sessionId ?? null;
if (
currentSessionId &&
previousSessionIdRef.current === null &&
appliedInitialFormKeyRef.current ===
JSON.stringify(initialFormPayload ?? null)
) {
previousSessionIdRef.current = currentSessionId;
return;
}
previousSessionIdRef.current = currentSessionId;
const nextInitialFormKey =
currentSessionId ?? JSON.stringify(initialFormPayload ?? null);
if (appliedInitialFormKeyRef.current === nextInitialFormKey) {
return;
}
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
setReferenceImageError(null);
setCropState(null);
setIsHistoryPickerOpen(false);
setIsPointCostConfirmOpen(false);
}, [initialFormPayload, session]);
const pictureDescription = formState.pictureDescription.trim();
const promptReferenceImageSrcs = useMemo(
() =>
normalizePuzzlePromptReferenceSources(
formState.referenceImageSrc
? []
: formState.referenceImageSrcs.map((image) => image.imageSrc),
),
[formState.referenceImageSrc, formState.referenceImageSrcs],
);
const promptReferenceAssetObjectIds = useMemo(
() =>
formState.referenceImageSrc
? []
: normalizePuzzleAssetObjectIds(
formState.referenceImageSrcs.map((image) => image.assetObjectId),
),
[formState.referenceImageSrc, formState.referenceImageSrcs],
);
const mainReferenceImageSrcForPayload =
formState.referenceImageAssetObjectId && formState.aiRedraw
? null
: formState.referenceImageSrc || null;
const promptReferenceImageSrcsForPayload =
promptReferenceAssetObjectIds.length > 0 ? [] : promptReferenceImageSrcs;
const canSubmit = formState.aiRedraw
? Boolean(pictureDescription) && !isBusy
: Boolean(formState.referenceImageSrc) && !isBusy;
const autosavePayload = useMemo(
() => ({
seedText: pictureDescription,
pictureDescription,
referenceImageSrc: mainReferenceImageSrcForPayload,
referenceImageSrcs: promptReferenceImageSrcsForPayload,
referenceImageAssetObjectId:
formState.referenceImageAssetObjectId || null,
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
}),
[
formState.aiRedraw,
formState.referenceImageAssetObjectId,
formState.imageModel,
mainReferenceImageSrcForPayload,
promptReferenceAssetObjectIds,
promptReferenceImageSrcsForPayload,
pictureDescription,
],
);
const autosaveSignature = JSON.stringify([
autosavePayload.pictureDescription,
autosavePayload.referenceImageSrc,
autosavePayload.referenceImageSrcs,
autosavePayload.referenceImageAssetObjectId,
autosavePayload.referenceImageAssetObjectIds,
autosavePayload.aiRedraw,
autosavePayload.imageModel,
]);
const lastAutosaveSignatureRef = useRef(autosaveSignature);
const autosaveSessionIdRef = useRef(session?.sessionId ?? null);
useEffect(() => {
const currentSessionId = session?.sessionId ?? null;
if (autosaveSessionIdRef.current === currentSessionId) {
return;
}
autosaveSessionIdRef.current = currentSessionId;
lastAutosaveSignatureRef.current = autosaveSignature;
}, [autosaveSignature, session]);
useEffect(() => {
if (
!session ||
session.stage !== 'collecting_anchors' ||
!session.draft?.formDraft ||
!onAutoSaveForm ||
lastAutosaveSignatureRef.current === autosaveSignature
) {
return;
}
const timer = window.setTimeout(() => {
lastAutosaveSignatureRef.current = autosaveSignature;
onAutoSaveForm(autosavePayload);
}, 700);
return () => window.clearTimeout(timer);
}, [
autosavePayload,
autosaveSignature,
onAutoSaveForm,
session?.draft?.formDraft,
session?.stage,
session,
]);
const handleReferenceImageFile = async (file: File) => {
try {
const uploadImage = await readPuzzleReferenceImageForUpload(file);
if (!isPuzzleReferenceImageSquare(uploadImage)) {
const imageSize = {
width: uploadImage.width,
height: uploadImage.height,
};
setCropState({
source: uploadImage.dataUrl,
label: file.name.trim() || '本地拼图图片',
fileName: file.name.trim() || 'puzzle-reference.jpg',
imageSize,
cropRect: buildCenteredSquareImageCropRect(imageSize),
error: null,
isSaving: false,
});
setReferenceImageError(null);
return;
}
setFormState((current) => ({
...current,
referenceImageSrc: uploadImage.dataUrl,
referenceImageAssetObjectId: '',
referenceImageLabel: file.name.trim() || '本地拼图图片',
}));
setReferenceImageError(null);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
? uploadError.message
: '拼图图片读取失败,请重试。',
);
}
};
const handlePromptReferenceImageFiles = async (files: File[]) => {
if (files.length === 0) {
return;
}
const remainingSlots =
PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT -
formState.referenceImageSrcs.length;
if (remainingSlots <= 0) {
setReferenceImageError('参考图最多上传 5 张。');
return;
}
try {
const images = await Promise.all(
files.slice(0, remainingSlots).map(async (file, index) => ({
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
label: file.name.trim() || `参考图 ${index + 1}`,
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
assetObjectId: null,
})),
);
setFormState((current) => ({
...current,
referenceImageSrcs: images.reduce(
addPuzzlePromptReferenceImage,
current.referenceImageSrcs,
),
}));
setReferenceImageError(
files.length > remainingSlots ? '参考图最多上传 5 张。' : null,
);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
);
}
};
const removePromptReferenceImage = (referenceId: string) => {
setFormState((current) => ({
...current,
referenceImageSrcs: current.referenceImageSrcs.filter(
(image) => image.id !== referenceId,
),
}));
setReferenceImageError(null);
};
const updateCropState = (nextCrop: { x: number; y: number; size: number }) => {
setCropState((current) => {
if (!current) {
return current;
}
const clamped = clampSquareImageCropRect(current.imageSize, nextCrop);
return {
...current,
cropRect: clamped,
};
});
};
const applyCropState = async () => {
const currentCropState = cropState;
if (!currentCropState) {
return;
}
setCropState({
...currentCropState,
isSaving: true,
error: null,
});
try {
const dataUrl = await cropPuzzleReferenceImageDataUrl({
source: currentCropState.source,
cropX: currentCropState.cropRect.x,
cropY: currentCropState.cropRect.y,
cropSize: currentCropState.cropRect.size,
});
setFormState((current) => ({
...current,
referenceImageSrc: dataUrl,
referenceImageAssetObjectId: '',
referenceImageLabel: currentCropState.label,
}));
setCropState(null);
setReferenceImageError(null);
} catch (cropError) {
setCropState({
...currentCropState,
isSaving: false,
error:
cropError instanceof Error
? cropError.message
: '拼图图片裁剪失败,请重试。',
});
}
};
const submitForm = () => {
if (!canSubmit) {
return;
}
if (formState.aiRedraw) {
setIsPointCostConfirmOpen(true);
return;
}
executeSubmitForm();
};
const executeSubmitForm = () => {
if (!canSubmit) {
return;
}
const payloadPictureDescription = formState.aiRedraw
? pictureDescription
: pictureDescription || formState.referenceImageLabel || '上传拼图图片';
const payload = {
seedText: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: mainReferenceImageSrcForPayload,
referenceImageSrcs: promptReferenceImageSrcsForPayload,
referenceImageAssetObjectId:
formState.referenceImageAssetObjectId || null,
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
};
if (!session && onCreateFromForm) {
setIsPointCostConfirmOpen(false);
onCreateFromForm(payload);
return;
}
setIsPointCostConfirmOpen(false);
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: mainReferenceImageSrcForPayload,
referenceImageSrcs: promptReferenceImageSrcsForPayload,
referenceImageAssetObjectId:
formState.referenceImageAssetObjectId || null,
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
candidateCount: 1,
});
};
const removeReferenceImage = () => {
setFormState((current) => ({
...current,
referenceImageSrc: '',
referenceImageAssetObjectId: '',
referenceImageLabel: '',
aiRedraw: true,
}));
setReferenceImageError(null);
};
return (
<div
className={
unifiedChrome
? 'puzzle-agent-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
: 'puzzle-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' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
</div>
) : null}
{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-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
BETA
</span>
</div>
</div>
) : null}
<CreativeImageInputPanel
className={unifiedChrome ? 'min-h-0 flex-none' : ''}
fillHeight={!unifiedChrome}
disabled={isBusy}
isSubmitting={isBusy}
uploadedImageSrc={formState.referenceImageSrc}
uploadedImageAlt="拼图图片"
mainImageInputId="puzzle-image-upload-input"
promptTextareaId="puzzle-picture-description-input"
prompt={formState.pictureDescription}
promptLabel={
formState.referenceImageSrc ? '画面AI重绘要求提示词' : '画面描述'
}
promptRows={2}
aiRedraw={formState.aiRedraw}
promptReferenceImages={formState.referenceImageSrcs}
promptReferenceLimit={PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT}
imageLimitHint="图片≤6MB"
imageModelPicker={
<PuzzleImageModelPicker
value={formState.imageModel}
disabled={isBusy}
onChange={(imageModel) =>
setFormState((current) => ({
...current,
imageModel,
}))
}
/>
}
inputError={referenceImageError}
error={error}
submitLabel="生成拼图游戏草稿"
submitCostLabel={formState.aiRedraw ? '消耗2泥点' : null}
submitDisabled={!canSubmit}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
history: '选择历史图片',
}}
onMainImageFileSelect={handleReferenceImageFile}
onMainImageRemove={removeReferenceImage}
onAiRedrawChange={(enabled) => {
setFormState((current) => ({
...current,
aiRedraw: enabled,
}));
}}
onPromptChange={(value) => {
setFormState((current) => ({
...current,
pictureDescription: value,
}));
}}
onPromptReferenceFilesSelect={(files) => {
void handlePromptReferenceImageFiles(files);
}}
onPromptReferenceRemove={removePromptReferenceImage}
onHistoryClick={() => setIsHistoryPickerOpen(true)}
onSubmit={submitForm}
/>
{cropState ? (
<SquareImageCropModal
source={cropState.source}
imageSize={cropState.imageSize}
cropRect={cropState.cropRect}
titleId="puzzle-image-crop-title"
labels={{
title: '裁剪拼图图片',
close: '关闭拼图图片裁剪',
editor: '拼图图片裁剪操作区',
previewAlt: '拼图图片裁剪预览',
cancel: '取消',
submit: '应用',
saving: '裁剪中',
}}
error={cropState.error}
isSaving={cropState.isSaving}
onCropRectChange={updateCropState}
onClose={() => setCropState(null)}
onSubmit={() => {
void applyCropState();
}}
/>
) : null}
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
isBusy={isBusy}
onClose={() => setIsHistoryPickerOpen(false)}
onSelect={(asset) => {
setFormState((current) => ({
...current,
referenceImageSrc: asset.imageSrc,
referenceImageAssetObjectId: asset.assetObjectId,
referenceImageLabel: getPuzzleHistoryAssetReferenceLabel(
asset.imageSrc,
),
}));
setReferenceImageError(null);
setIsHistoryPickerOpen(false);
}}
/>
) : null}
{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="puzzle-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="puzzle-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)]">
2
</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 PuzzleCreationWorkspace;

View File

@@ -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');
});

View File

@@ -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;