收口前端平台组件库能力

新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
2026-06-10 10:24:18 +08:00
parent a4ee6ff698
commit 1ad25e30f8
226 changed files with 23364 additions and 7825 deletions
@@ -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>
);
@@ -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>
);
}
@@ -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>
);