收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
@@ -65,7 +65,8 @@ test('jump hop workspace submits theme payload after required field is filled',
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
characterPrompt: '内置默认 3D 角色',
|
||||
tilePrompt: '云朵跳台主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
|
||||
tilePrompt:
|
||||
'云朵跳台主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
|
||||
endMoodPrompt: null,
|
||||
});
|
||||
});
|
||||
@@ -89,6 +90,13 @@ test('jump hop workspace calls back when return button is clicked', async () =>
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('jump hop workspace theme label uses platform section label style', () => {
|
||||
render(<JumpHopCreationWorkspace onBack={() => {}} onSubmitted={() => {}} />);
|
||||
|
||||
expect(screen.getByText('主题').className).toContain('tracking-[0.18em]');
|
||||
expect(screen.getByLabelText('主题').className).toContain('bg-white/90');
|
||||
});
|
||||
|
||||
test('jump hop workspace can defer visible chrome to the unified creation page', () => {
|
||||
const { container } = render(
|
||||
<JumpHopCreationWorkspace
|
||||
|
||||
@@ -6,6 +6,10 @@ import type {
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
} from '../../../../packages/shared/src/contracts/jumpHop';
|
||||
import { jumpHopClient } from '../../../services/jump-hop/jumpHopClient';
|
||||
import { PlatformActionButton } from '../../common/PlatformActionButton';
|
||||
import { PlatformFieldLabel } from '../../common/PlatformFieldLabel';
|
||||
import { PlatformStatusMessage } from '../../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../../common/PlatformTextField';
|
||||
|
||||
type JumpHopCreationWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
@@ -95,23 +99,22 @@ export function JumpHopCreationWorkspace({
|
||||
>
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
tone="ghost"
|
||||
size="sm"
|
||||
className="min-h-0 px-3 py-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3">
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
主题
|
||||
</span>
|
||||
<input
|
||||
<PlatformFieldLabel variant="section">主题</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
value={formState.themeText}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
@@ -119,23 +122,29 @@ export function JumpHopCreationWorkspace({
|
||||
themeText: 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"
|
||||
density="roomy"
|
||||
className="mt-2 px-3"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{localError || error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="profile"
|
||||
size="md"
|
||||
className="mt-3 rounded-2xl"
|
||||
>
|
||||
{localError ?? error}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-3">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
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' : ''}`}
|
||||
size="md"
|
||||
className={`min-h-11 gap-2 px-5 py-3 ${!canSubmit || isSubmitting || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -143,7 +152,7 @@ export function JumpHopCreationWorkspace({
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
生成
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+10
@@ -86,6 +86,10 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
);
|
||||
|
||||
expect(screen.getByText('抓大鹅')).toBeTruthy();
|
||||
expect(screen.getByText('BETA').className).toContain('rounded-full');
|
||||
expect(screen.getByText('BETA').className).toContain(
|
||||
'border-[var(--platform-warm-border)]',
|
||||
);
|
||||
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
|
||||
expect(screen.queryByText('2D素材风格')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '扁平图标' })).toBeNull();
|
||||
@@ -96,6 +100,12 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
expect(screen.queryByLabelText('上传抓大鹅参考图')).toBeNull();
|
||||
expect(screen.queryByLabelText('需要消除次数')).toBeNull();
|
||||
expect(screen.queryByLabelText('难度数值')).toBeNull();
|
||||
expect(screen.getByText('难度').closest('div')?.className).toContain(
|
||||
'platform-subpanel',
|
||||
);
|
||||
expect(screen.getByText('难度').closest('div')?.className).toContain(
|
||||
'bg-white/44',
|
||||
);
|
||||
expect(screen.queryByText('物品')).toBeNull();
|
||||
expect(screen.queryByText('旧会话固定追问不再作为主入口。')).toBeNull();
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@ import type {
|
||||
Match3DAgentSessionSnapshot,
|
||||
SendMatch3DMessageRequest,
|
||||
} from '../../../../packages/shared/src/contracts/match3dAgent';
|
||||
import { PlatformActionButton } from '../../common/PlatformActionButton';
|
||||
import { PlatformFieldLabel } from '../../common/PlatformFieldLabel';
|
||||
import { PlatformPillBadge } from '../../common/PlatformPillBadge';
|
||||
import { PlatformSegmentedTabs } from '../../common/PlatformSegmentedTabs';
|
||||
import { PlatformStatusMessage } from '../../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../../common/PlatformSubpanel';
|
||||
import { PlatformTextField } from '../../common/PlatformTextField';
|
||||
import { UnifiedConfirmDialog } from '../../common/UnifiedConfirmDialog';
|
||||
|
||||
type Match3DCreationWorkspaceProps = {
|
||||
session: Match3DAgentSessionSnapshot | null;
|
||||
@@ -196,14 +204,15 @@ export function Match3DCreationWorkspace({
|
||||
>
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
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' : ''}`}
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
className={`min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -220,9 +229,9 @@ export function Match3DCreationWorkspace({
|
||||
<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)]">
|
||||
<PlatformPillBadge tone="warning" size="xs">
|
||||
BETA
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -242,10 +251,11 @@ export function Match3DCreationWorkspace({
|
||||
} ${isBusy ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<label className={unifiedChrome ? 'block' : 'block min-h-0'}>
|
||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||
<PlatformFieldLabel variant="form">
|
||||
想做一个什么题材的抓大鹅?
|
||||
</span>
|
||||
<textarea
|
||||
</PlatformFieldLabel>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
value={formState.themeText}
|
||||
disabled={isBusy}
|
||||
rows={5}
|
||||
@@ -256,7 +266,9 @@ export function Match3DCreationWorkspace({
|
||||
themeText: event.target.value,
|
||||
}))
|
||||
}
|
||||
className={`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)] ${
|
||||
size="lg"
|
||||
density="roomy"
|
||||
className={`rounded-[1.15rem] font-normal leading-6 ${
|
||||
unifiedChrome
|
||||
? 'min-h-[11rem] sm:min-h-[12rem]'
|
||||
: 'h-full min-h-[7.75rem] sm:min-h-[9rem] lg:min-h-[14rem]'
|
||||
@@ -266,56 +278,59 @@ export function Match3DCreationWorkspace({
|
||||
</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)]">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="flat"
|
||||
radius="sm"
|
||||
padding="sm"
|
||||
className="shrink-0 bg-white/44 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)]"
|
||||
>
|
||||
<PlatformFieldLabel variant="form" className="mb-1.5">
|
||||
难度
|
||||
</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>
|
||||
</PlatformFieldLabel>
|
||||
<PlatformSegmentedTabs
|
||||
items={MATCH3D_DIFFICULTY_OPTIONS}
|
||||
activeId={formState.difficultyOptionId}
|
||||
onChange={(difficultyOptionId) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
difficultyOptionId,
|
||||
}))
|
||||
}
|
||||
columns="four"
|
||||
gap="sm"
|
||||
radius="md"
|
||||
size="choice"
|
||||
surface="soft"
|
||||
tone="rose"
|
||||
disabled={isBusy}
|
||||
className="gap-1.5 sm:gap-2 lg:grid-cols-2"
|
||||
itemClassName="min-h-10 text-sm font-black sm:min-h-11"
|
||||
/>
|
||||
</PlatformSubpanel>
|
||||
</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">
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="profile"
|
||||
size="md"
|
||||
className="rounded-2xl"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : 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"
|
||||
<PlatformActionButton
|
||||
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' : ''}`}
|
||||
className={`min-h-10 px-4 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}
|
||||
@@ -329,46 +344,22 @@ export function Match3DCreationWorkspace({
|
||||
消耗{mudPointCost}泥点
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</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)]">
|
||||
消耗 {mudPointCost} 泥点
|
||||
</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}
|
||||
<UnifiedConfirmDialog
|
||||
open={isPointCostConfirmOpen}
|
||||
title="确认消耗泥点"
|
||||
onClose={() => setIsPointCostConfirmOpen(false)}
|
||||
onConfirm={executeSubmitForm}
|
||||
confirmLabel="确定"
|
||||
confirmDisabled={!canSubmit}
|
||||
showCancel
|
||||
overlayClassName="platform-modal-backdrop z-[80]"
|
||||
panelClassName="platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div className="font-semibold">消耗 {mudPointCost} 泥点</div>
|
||||
</UnifiedConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+59
-43
@@ -285,13 +285,16 @@ test('puzzle workspace keeps the reference image upload as a primary panel', ()
|
||||
const uploadInput = screen.getByLabelText('上传拼图图片', {
|
||||
selector: 'input',
|
||||
});
|
||||
expect(screen.getByText('BETA').className).toContain('rounded-full');
|
||||
expect(screen.getByText('BETA').className).toContain('border-emerald-200');
|
||||
|
||||
const uploadCard = uploadInput.closest('.puzzle-image-upload-card');
|
||||
expect(uploadCard).not.toBeNull();
|
||||
expect(uploadCard?.closest('.platform-subpanel')).toBeNull();
|
||||
expect(container.querySelector('.puzzle-image-upload-card')).toBeTruthy();
|
||||
expect(container.querySelector('.puzzle-creation-form-body')?.className).toContain(
|
||||
'overflow-hidden',
|
||||
);
|
||||
expect(
|
||||
container.querySelector('.puzzle-creation-form-body')?.className,
|
||||
).toContain('overflow-hidden');
|
||||
expect(container.querySelector('.puzzle-image-field')?.className).toContain(
|
||||
'flex-1',
|
||||
);
|
||||
@@ -307,9 +310,9 @@ test('puzzle workspace keeps the reference image upload as a primary panel', ()
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.queryByLabelText('拼图创作模板')).toBeNull();
|
||||
expect(
|
||||
(screen.getByLabelText('画面描述') as HTMLTextAreaElement).value,
|
||||
).toBe('');
|
||||
expect((screen.getByLabelText('画面描述') as HTMLTextAreaElement).value).toBe(
|
||||
'',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('画面描述') as HTMLTextAreaElement).placeholder,
|
||||
).toBe('');
|
||||
@@ -318,9 +321,8 @@ test('puzzle workspace keeps the reference image upload as a primary panel', ()
|
||||
expect(uploadCard?.className).toContain('aspect-square');
|
||||
expect(uploadCard?.className).toContain('h-full');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: /生成拼图游戏草稿/u })
|
||||
.parentElement?.className,
|
||||
screen.getByRole('button', { name: /生成拼图游戏草稿/u }).parentElement
|
||||
?.className,
|
||||
).toContain('justify-center');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
@@ -366,7 +368,9 @@ test('puzzle workspace selects a history image from the upload card', async () =
|
||||
expect(historyButton.className).toContain('top-3');
|
||||
expect(historyButton.className).toContain('right-3');
|
||||
expect(historyButton.className).not.toContain('bottom-3');
|
||||
expect(screen.getByText('历史').closest('.puzzle-image-upload-card')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('历史').closest('.puzzle-image-upload-card'),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(historyButton);
|
||||
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
@@ -429,12 +433,12 @@ test('puzzle upload card stays light in light theme', () => {
|
||||
expect(uploadLabel.className).not.toContain('bg-white/94');
|
||||
expect(uploadLabel.className).not.toContain('border');
|
||||
expect(screen.queryByText('AI重绘')).toBeNull();
|
||||
expect(container.querySelector('.puzzle-image-upload-card')?.className).toContain(
|
||||
'bg-white/90',
|
||||
);
|
||||
expect(container.querySelector('.puzzle-image-upload-card')?.className).not.toContain(
|
||||
'bg-slate-950',
|
||||
);
|
||||
expect(
|
||||
container.querySelector('.puzzle-image-upload-card')?.className,
|
||||
).toContain('bg-white/90');
|
||||
expect(
|
||||
container.querySelector('.puzzle-image-upload-card')?.className,
|
||||
).not.toContain('bg-slate-950');
|
||||
});
|
||||
|
||||
test('puzzle workspace falls back to compile action for restored sessions', () => {
|
||||
@@ -547,9 +551,9 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(screen.getByLabelText('画面描述') as HTMLTextAreaElement).value,
|
||||
).toBe('旧街灯牌下的猫。');
|
||||
expect((screen.getByLabelText('画面描述') as HTMLTextAreaElement).value).toBe(
|
||||
'旧街灯牌下的猫。',
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '旧街灯牌下的猫和发光雨伞。' },
|
||||
@@ -695,11 +699,14 @@ test('puzzle workspace submits uploaded reference image as data URL when AI redr
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('上传拼图图片', { selector: 'input' }), {
|
||||
target: {
|
||||
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
|
||||
fireEvent.change(
|
||||
screen.getByLabelText('上传拼图图片', { selector: 'input' }),
|
||||
{
|
||||
target: {
|
||||
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('拼图图片')).toBeTruthy();
|
||||
});
|
||||
@@ -730,7 +737,9 @@ test('puzzle workspace uploads prompt references as asset object ids', async ()
|
||||
'data:image/png;base64,reference-2',
|
||||
];
|
||||
let readIndex = 0;
|
||||
stubReferenceImageUpload(uploadedSources[0] ?? 'data:image/png;base64,reference-1');
|
||||
stubReferenceImageUpload(
|
||||
uploadedSources[0] ?? 'data:image/png;base64,reference-1',
|
||||
);
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
onload: null | (() => void) = null;
|
||||
@@ -828,7 +837,8 @@ test('puzzle workspace uploads prompt reference images from the description box'
|
||||
'data:image/png;base64,reference-6',
|
||||
];
|
||||
let readIndex = 0;
|
||||
const firstUploadedSource = uploadedSources[0] || 'data:image/png;base64,reference-1';
|
||||
const firstUploadedSource =
|
||||
uploadedSources[0] || 'data:image/png;base64,reference-1';
|
||||
stubReferenceImageUpload(firstUploadedSource);
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
@@ -924,25 +934,30 @@ test('puzzle workspace shows AI redraw switch only after upload', async () => {
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
fireEvent.change(screen.getByLabelText('上传拼图图片', { selector: 'input' }), {
|
||||
target: {
|
||||
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
|
||||
fireEvent.change(
|
||||
screen.getByLabelText('上传拼图图片', { selector: 'input' }),
|
||||
{
|
||||
target: {
|
||||
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'AI重绘' }).closest('.puzzle-image-upload-card'),
|
||||
screen
|
||||
.getByRole('switch', { name: 'AI重绘' })
|
||||
.closest('.puzzle-image-upload-card'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '移除拼图图片' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '移除拼图图片' }).className).toContain(
|
||||
'left-3',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '选择历史图片' }).className).toContain(
|
||||
'right-3',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '移除拼图图片' }).className,
|
||||
).toContain('left-3');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '选择历史图片' }).className,
|
||||
).toContain('right-3');
|
||||
expect(screen.queryByText('上传图片/填写画面描述')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -960,19 +975,20 @@ test('puzzle workspace confirms before removing uploaded image', async () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('上传拼图图片', { selector: 'input' }), {
|
||||
target: {
|
||||
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
|
||||
fireEvent.change(
|
||||
screen.getByLabelText('上传拼图图片', { selector: 'input' }),
|
||||
{
|
||||
target: {
|
||||
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('拼图图片')).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' }));
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '移除拼图图片?' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('dialog', { name: '移除拼图图片?' })).toBeTruthy();
|
||||
expect(screen.getByAltText('拼图图片')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { PuzzleAgentActionRequest } from '../../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
@@ -23,12 +18,15 @@ import {
|
||||
CreativeImageInputPanel,
|
||||
type CreativeImageInputReferenceImage,
|
||||
} from '../../common/CreativeImageInputPanel';
|
||||
import { PlatformActionButton } from '../../common/PlatformActionButton';
|
||||
import { PlatformPillBadge } from '../../common/PlatformPillBadge';
|
||||
import { SquareImageCropModal } from '../../common/SquareImageCropModal';
|
||||
import {
|
||||
buildCenteredSquareImageCropRect,
|
||||
clampSquareImageCropRect,
|
||||
SquareImageCropModal,
|
||||
type SquareImageCropRect,
|
||||
} from '../../common/SquareImageCropModal';
|
||||
} from '../../common/squareImageCropModel';
|
||||
import { UnifiedConfirmDialog } from '../../common/UnifiedConfirmDialog';
|
||||
import PuzzleHistoryAssetPickerDialog from '../shared/PuzzleHistoryAssetPickerDialog';
|
||||
import {
|
||||
normalizePuzzleImageModel,
|
||||
@@ -227,10 +225,7 @@ function addPuzzlePromptReferenceImage(
|
||||
const deduped = currentImages.filter(
|
||||
(image) => image.imageSrc !== nextImage.imageSrc,
|
||||
);
|
||||
return [...deduped, nextImage].slice(
|
||||
0,
|
||||
PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT,
|
||||
);
|
||||
return [...deduped, nextImage].slice(0, PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,8 +310,11 @@ export function PuzzleCreationWorkspace({
|
||||
formState.referenceImageAssetObjectId && formState.aiRedraw
|
||||
? null
|
||||
: formState.referenceImageSrc || null;
|
||||
const promptReferenceImageSrcsForPayload =
|
||||
promptReferenceAssetObjectIds.length > 0 ? [] : promptReferenceImageSrcs;
|
||||
const promptReferenceImageSrcsForPayload = useMemo(
|
||||
() =>
|
||||
promptReferenceAssetObjectIds.length > 0 ? [] : promptReferenceImageSrcs,
|
||||
[promptReferenceAssetObjectIds.length, promptReferenceImageSrcs],
|
||||
);
|
||||
const canSubmit = formState.aiRedraw
|
||||
? Boolean(pictureDescription) && !isBusy
|
||||
: Boolean(formState.referenceImageSrc) && !isBusy;
|
||||
@@ -434,8 +432,7 @@ export function PuzzleCreationWorkspace({
|
||||
}
|
||||
|
||||
const remainingSlots =
|
||||
PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT -
|
||||
formState.referenceImageSrcs.length;
|
||||
PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT - formState.referenceImageSrcs.length;
|
||||
if (remainingSlots <= 0) {
|
||||
setReferenceImageError('参考图最多上传 5 张。');
|
||||
return;
|
||||
@@ -479,7 +476,11 @@ export function PuzzleCreationWorkspace({
|
||||
setReferenceImageError(null);
|
||||
};
|
||||
|
||||
const updateCropState = (nextCrop: { x: number; y: number; size: number }) => {
|
||||
const updateCropState = (nextCrop: {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
}) => {
|
||||
setCropState((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
@@ -609,17 +610,16 @@ export function PuzzleCreationWorkspace({
|
||||
>
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
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' : ''}`}
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
className="min-h-0 self-start gap-1.5 px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</span>
|
||||
</button>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -629,9 +629,9 @@ export function PuzzleCreationWorkspace({
|
||||
<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">
|
||||
<PlatformPillBadge tone="success" size="xs">
|
||||
BETA
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -747,43 +747,19 @@ export function PuzzleCreationWorkspace({
|
||||
}}
|
||||
/>
|
||||
) : 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)]">
|
||||
消耗 {mudPointCost} 泥点
|
||||
</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}
|
||||
<UnifiedConfirmDialog
|
||||
open={isPointCostConfirmOpen}
|
||||
title="确认消耗泥点"
|
||||
onClose={() => setIsPointCostConfirmOpen(false)}
|
||||
onConfirm={executeSubmitForm}
|
||||
confirmLabel="确定"
|
||||
confirmDisabled={!canSubmit}
|
||||
showCancel
|
||||
overlayClassName="platform-modal-backdrop z-[80]"
|
||||
panelClassName="platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div className="font-semibold">消耗 {mudPointCost} 泥点</div>
|
||||
</UnifiedConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
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 { uploadWoodenFishHitSoundAsset } from '../../../services/wooden-fish/woodenFishAssetClient';
|
||||
import { woodenFishClient } from '../../../services/wooden-fish/woodenFishClient';
|
||||
import {
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
@@ -70,10 +76,7 @@ test('敲什么输入栏初始置空但提交时仍使用默认生成提示词',
|
||||
const onSubmitted = vi.fn();
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
<WoodenFishCreationWorkspace onBack={() => {}} onSubmitted={onSubmitted} />,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('敲什么')).toHaveProperty('value', '');
|
||||
@@ -87,34 +90,45 @@ test('敲什么输入栏初始置空但提交时仍使用默认生成提示词',
|
||||
|
||||
test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀', () => {
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
<WoodenFishCreationWorkspace onBack={() => {}} onSubmitted={() => {}} />,
|
||||
);
|
||||
|
||||
const sectionTitle = screen.getByText('功德有什么');
|
||||
const section = sectionTitle.closest('section');
|
||||
|
||||
expect(section).not.toBeNull();
|
||||
expect(section?.className).toContain('platform-subpanel');
|
||||
expect(section?.className).toContain('rounded-[1.25rem]');
|
||||
expect(section?.className).toContain('p-4');
|
||||
expect(sectionTitle.className).toContain('tracking-[0.18em]');
|
||||
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();
|
||||
within(section as HTMLElement).queryByDisplayValue('幸运+1'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(section as HTMLElement).queryByDisplayValue('功德+1'),
|
||||
).toBeNull();
|
||||
const defaultInput = within(section as HTMLElement).getByLabelText(
|
||||
'功德词条 1',
|
||||
);
|
||||
expect(defaultInput.className).toContain('bg-white/86');
|
||||
expect(defaultInput.className).toContain(
|
||||
'focus:ring-[var(--platform-warm-border)]',
|
||||
);
|
||||
expect(defaultInput.className).toContain('pr-10');
|
||||
const addButton = within(section as HTMLElement).getByRole('button', {
|
||||
name: '新增功德词条',
|
||||
});
|
||||
expect(addButton.className).toContain('border-dashed');
|
||||
expect(addButton.className).toContain('h-12');
|
||||
expect(addButton.className).toContain('bg-white/55');
|
||||
});
|
||||
|
||||
test('功德有什么支持通过加号新增词条并移除新增格子', () => {
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
<WoodenFishCreationWorkspace onBack={() => {}} onSubmitted={() => {}} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增功德词条' }));
|
||||
@@ -124,17 +138,17 @@ test('功德有什么支持通过加号新增词条并移除新增格子', () =>
|
||||
expect(screen.getByDisplayValue('幸运')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('健康')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除功德词条 2' }));
|
||||
const removeButton = screen.getByRole('button', { name: '删除功德词条 2' });
|
||||
expect(removeButton.className).toContain('bg-white/94');
|
||||
expect(removeButton.className).not.toContain('platform-icon-button');
|
||||
fireEvent.click(removeButton);
|
||||
expect(screen.queryByDisplayValue('健康')).toBeNull();
|
||||
expect(screen.getByDisplayValue('幸运')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('敲击音效临时关闭提示词生成入口,仅保留上传和录音', () => {
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
<WoodenFishCreationWorkspace onBack={() => {}} onSubmitted={() => {}} />,
|
||||
);
|
||||
|
||||
const sectionTitle = screen.getByText('敲击音效');
|
||||
@@ -154,10 +168,7 @@ test('敲击音效上传后只生成本地待提交音频,点击生成时才
|
||||
});
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
<WoodenFishCreationWorkspace onBack={() => {}} onSubmitted={onSubmitted} />,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
@@ -203,10 +214,7 @@ test('未选择敲击音效时生成不上传 OSS 且使用默认木鱼音', asy
|
||||
const onSubmitted = vi.fn();
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
<WoodenFishCreationWorkspace onBack={() => {}} onSubmitted={onSubmitted} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
@@ -225,10 +233,7 @@ test('敲击音效超过 1 秒时展示错误且不提交音频', async () => {
|
||||
);
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
<WoodenFishCreationWorkspace onBack={() => {}} onSubmitted={onSubmitted} />,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
@@ -263,10 +268,7 @@ test('敲击音效上传失败时停留在工作台且不创建 session', async
|
||||
);
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
<WoodenFishCreationWorkspace onBack={() => {}} onSubmitted={() => {}} />,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
@@ -300,10 +302,7 @@ test('创建木鱼草稿失败时停留工作台并展示错误', async () => {
|
||||
);
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
<WoodenFishCreationWorkspace onBack={() => {}} onSubmitted={() => {}} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
@@ -318,16 +317,15 @@ test('提交中重复点击生成不会重复上传或创建 session', async ()
|
||||
) => void = () => {};
|
||||
vi.mocked(uploadWoodenFishHitSoundAsset).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Awaited<ReturnType<typeof uploadWoodenFishHitSoundAsset>>>((resolve) => {
|
||||
resolveUpload = resolve;
|
||||
}),
|
||||
new Promise<Awaited<ReturnType<typeof uploadWoodenFishHitSoundAsset>>>(
|
||||
(resolve) => {
|
||||
resolveUpload = resolve;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
<WoodenFishCreationWorkspace onBack={() => {}} onSubmitted={() => {}} />,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
@@ -366,7 +364,9 @@ test('提交中重复点击生成不会重复上传或创建 session', async ()
|
||||
durationMs: null,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(woodenFishClient.createSession).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() =>
|
||||
expect(woodenFishClient.createSession).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
});
|
||||
|
||||
test('替换本地敲击音效时回收旧预览地址', async () => {
|
||||
@@ -401,10 +401,7 @@ test('替换本地敲击音效时回收旧预览地址', async () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
<WoodenFishCreationWorkspace onBack={() => {}} onSubmitted={() => {}} />,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
@@ -441,14 +438,13 @@ test('替换本地敲击音效时回收旧预览地址', async () => {
|
||||
|
||||
test('敲击音效和功德词条不放进独立滚动窗', () => {
|
||||
const { container } = render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
<WoodenFishCreationWorkspace onBack={() => {}} onSubmitted={() => {}} />,
|
||||
);
|
||||
|
||||
const audioSection = screen.getByText('敲击音效').closest('section');
|
||||
const floatingWordsSection = screen.getByText('功德有什么').closest('section');
|
||||
const floatingWordsSection = screen
|
||||
.getByText('功德有什么')
|
||||
.closest('section');
|
||||
const sidePanel = audioSection?.parentElement;
|
||||
|
||||
expect(audioSection).not.toBeNull();
|
||||
@@ -462,10 +458,7 @@ test('敲击音效和功德词条不放进独立滚动窗', () => {
|
||||
|
||||
test('工作台只保留一个生成按钮', () => {
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
<WoodenFishCreationWorkspace onBack={() => {}} onSubmitted={() => {}} />,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole('button', { name: '生成' })).toHaveLength(1);
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Plus,
|
||||
Send,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { ArrowLeft, Loader2, Plus, Send, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
@@ -25,6 +19,12 @@ import {
|
||||
prepareCreativeAudioFileForLocalUse,
|
||||
} from '../../common/creativeAudioProcessing';
|
||||
import { CreativeImageInputPanel } from '../../common/CreativeImageInputPanel';
|
||||
import { PlatformActionButton } from '../../common/PlatformActionButton';
|
||||
import { PlatformIconButton } from '../../common/PlatformIconButton';
|
||||
import { PlatformStatusMessage } from '../../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../../common/PlatformSubpanel';
|
||||
import { PlatformTextField } from '../../common/PlatformTextField';
|
||||
import { PlatformUploadTile } from '../../common/PlatformUploadTile';
|
||||
|
||||
type WoodenFishCreationWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
@@ -60,7 +60,10 @@ 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();
|
||||
const trimmed = word
|
||||
.trim()
|
||||
.replace(/[++]\s*1$/u, '')
|
||||
.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
@@ -139,9 +142,13 @@ export function WoodenFishCreationWorkspace({
|
||||
try {
|
||||
const hitSoundAsset: WoodenFishAudioAsset = formState.hitSoundAsset
|
||||
? await uploadWoodenFishHitSoundAsset(
|
||||
new File([formState.hitSoundAsset.blob], formState.hitSoundAsset.fileName, {
|
||||
type: formState.hitSoundAsset.mimeType,
|
||||
}),
|
||||
new File(
|
||||
[formState.hitSoundAsset.blob],
|
||||
formState.hitSoundAsset.fileName,
|
||||
{
|
||||
type: formState.hitSoundAsset.mimeType,
|
||||
},
|
||||
),
|
||||
formState.hitSoundAsset.source,
|
||||
)
|
||||
: WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET;
|
||||
@@ -149,10 +156,12 @@ export function WoodenFishCreationWorkspace({
|
||||
templateId: 'wooden-fish',
|
||||
workTitle: '',
|
||||
workDescription:
|
||||
formState.hitObjectPrompt.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
formState.hitObjectPrompt.trim() ||
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
themeTags: DEFAULT_THEME_TAGS,
|
||||
hitObjectPrompt:
|
||||
formState.hitObjectPrompt.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
formState.hitObjectPrompt.trim() ||
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
hitObjectReferenceImageSrc:
|
||||
formState.hitObjectReferenceImageSrc.trim() || null,
|
||||
hitSoundPrompt: null,
|
||||
@@ -181,14 +190,15 @@ export function WoodenFishCreationWorkspace({
|
||||
>
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
tone="ghost"
|
||||
size="sm"
|
||||
className="min-h-0 px-3 py-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -283,14 +293,11 @@ export function WoodenFishCreationWorkspace({
|
||||
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>
|
||||
<PlatformSubpanel title="功德有什么" bodyClassName="mt-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{formState.floatingWords.map((word, index) => (
|
||||
<div key={index} className="relative">
|
||||
<input
|
||||
<PlatformTextField
|
||||
value={word}
|
||||
maxLength={16}
|
||||
disabled={isBusy || isSubmitting}
|
||||
@@ -298,51 +305,55 @@ export function WoodenFishCreationWorkspace({
|
||||
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"
|
||||
className="h-12 pr-10"
|
||||
/>
|
||||
{index > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformIconButton
|
||||
label={`删除功德词条 ${index + 1}`}
|
||||
title="删除词条"
|
||||
icon={<X className="h-3.5 w-3.5" />}
|
||||
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>
|
||||
variant="surfaceFloating"
|
||||
className="absolute right-2 top-1/2 h-7 w-7 -translate-y-1/2 text-[var(--platform-text-soft)]"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{formState.floatingWords.length < MAX_FLOATING_WORD_COUNT ? (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformUploadTile
|
||||
label="新增功德词条"
|
||||
title="新增词条"
|
||||
icon={<Plus className="h-5 w-5" />}
|
||||
showLabel={false}
|
||||
size="compact"
|
||||
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>
|
||||
className="rounded-[0.95rem] bg-white/55 hover:border-[var(--platform-accent)] hover:bg-white/78 hover:text-[var(--platform-accent)] disabled:opacity-45"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localError || error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="profile"
|
||||
size="md"
|
||||
className="mt-3 rounded-2xl"
|
||||
>
|
||||
{localError ?? error}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
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' : ''}`}
|
||||
size="md"
|
||||
className={`min-h-11 gap-2 px-5 py-3 ${!canSubmit || isSubmitting || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -350,7 +361,7 @@ export function WoodenFishCreationWorkspace({
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
生成
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user