收口前端平台组件库能力

新增 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

View File

@@ -14,6 +14,7 @@ import {
import { useMemo, useState } from 'react';
import type { CreativeAgentInputPart } from '../../../packages/shared/src/contracts/creativeAgent';
import { PlatformIconButton } from '../common/PlatformIconButton';
import type { CreationWorkShelfItem } from '../custom-world-home/creationWorkShelf';
import { RpgEntryBrandLogo } from '../rpg-entry/RpgEntryBrandLogo';
import { CreativeAgentInputComposer } from './CreativeAgentInputComposer';
@@ -191,15 +192,12 @@ function CreativeAgentDrawer({
<div className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 items-center justify-between gap-3 px-5 pb-5 pt-[max(1.1rem,env(safe-area-inset-top))]">
<RpgEntryBrandLogo decorative />
<button
type="button"
<PlatformIconButton
onClick={onClose}
className="platform-icon-button"
aria-label="关闭侧边栏"
label="关闭侧边栏"
title="关闭"
>
<PanelLeftClose className="h-4 w-4" />
</button>
icon={<PanelLeftClose className="h-4 w-4" />}
/>
</header>
<div className="shrink-0 space-y-3 px-5">
@@ -271,30 +269,24 @@ function CreativeAgentDrawer({
<UserRound className="h-5 w-5" />
</button>
<div className="flex items-center gap-3">
<button
type="button"
<PlatformIconButton
onClick={() => {
onOpenSettings();
onClose();
}}
className="platform-icon-button"
aria-label="外观"
label="外观"
title="外观"
>
<Moon className="h-4 w-4" />
</button>
<button
type="button"
icon={<Moon className="h-4 w-4" />}
/>
<PlatformIconButton
onClick={() => {
onOpenSettings();
onClose();
}}
className="platform-icon-button"
aria-label="设置"
label="设置"
title="设置"
>
<Settings className="h-4 w-4" />
</button>
icon={<Settings className="h-4 w-4" />}
/>
</div>
</footer>
</div>

View File

@@ -0,0 +1,98 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { CreativeAgentInputComposer } from './CreativeAgentInputComposer';
vi.mock('../../services/puzzleReferenceImage', () => ({
readPuzzleReferenceImageAsDataUrl: vi.fn(),
}));
test('submits typed text with uploaded reference image', async () => {
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockResolvedValue(
'data:image/png;base64,reference',
);
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<CreativeAgentInputComposer isBusy={false} onSubmit={onSubmit} />);
const composer = screen.getByLabelText('智能创作输入').closest('section');
expect(composer?.className).toContain('platform-subpanel');
expect(composer?.className).toContain('creative-agent-composer');
expect(screen.getByLabelText('智能创作输入').className).toContain(
'bg-white/90',
);
expect(screen.getByLabelText('智能创作输入').className).toContain(
'focus:ring-[var(--platform-warm-border)]',
);
const file = new File(['reference'], '参考图.png', { type: 'image/png' });
const uploadInput = screen.getByLabelText('添加参考图', {
selector: 'input',
});
fireEvent.change(uploadInput, { target: { files: [file] } });
expect(await screen.findByText('参考图.png')).toBeTruthy();
expect(screen.getByAltText('创作参考图').closest('div')?.className).toContain(
'h-12 w-12',
);
expect(screen.getByText('参考图.png').parentElement?.className).toContain(
'bg-white/68',
);
await user.type(screen.getByLabelText('智能创作输入'), '做成森林拼图');
await user.click(screen.getByRole('button', { name: '发送' }));
expect(onSubmit).toHaveBeenCalledWith({
text: '做成森林拼图',
image: {
imageUrl: 'data:image/png;base64,reference',
thumbnailUrl: 'data:image/png;base64,reference',
label: '参考图.png',
},
});
expect(screen.queryByText('参考图.png')).toBeNull();
});
test('removes selected reference image before submit', async () => {
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockResolvedValue(
'data:image/png;base64,reference',
);
const onSubmit = vi.fn();
render(<CreativeAgentInputComposer isBusy={false} onSubmit={onSubmit} />);
const file = new File(['reference'], '参考图.png', { type: 'image/png' });
fireEvent.change(screen.getByLabelText('添加参考图', { selector: 'input' }), {
target: { files: [file] },
});
expect(await screen.findByText('参考图.png')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '移除参考图' }));
await waitFor(() => expect(screen.queryByText('参考图.png')).toBeNull());
expect(onSubmit).not.toHaveBeenCalled();
});
test('shows failed reference image reads with shared status chrome', async () => {
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockRejectedValue(
new Error('参考图太大'),
);
render(<CreativeAgentInputComposer isBusy={false} onSubmit={vi.fn()} />);
const file = new File(['reference'], '参考图.png', { type: 'image/png' });
fireEvent.change(screen.getByLabelText('添加参考图', { selector: 'input' }), {
target: { files: [file] },
});
const message = await screen.findByText('参考图太大');
expect(message.className).toContain(
'border-[var(--platform-button-danger-border)]',
);
expect(message.className).toContain(
'bg-[var(--platform-button-danger-fill)]',
);
expect(message.className).toContain('text-xs');
});

View File

@@ -1,7 +1,12 @@
import { ArrowUp, ImagePlus, Loader2, Plus, X } from 'lucide-react';
import { ArrowUp, ImagePlus, Loader2, Plus } from 'lucide-react';
import { type ChangeEvent, useState } from 'react';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
export type CreativeAgentComposerImage = {
imageUrl: string;
@@ -67,40 +72,41 @@ export function CreativeAgentInputComposer({
const floating = variant === 'floating';
return (
<section
className={
floating
? 'creative-agent-composer creative-agent-composer--floating'
: 'platform-subpanel rounded-[1.35rem] p-3 sm:p-4'
}
>
const composerContent = (
<>
<div className="flex items-end gap-2">
<label
className={`platform-icon-button h-11 w-11 shrink-0 ${floating ? 'creative-agent-composer__media-button' : ''} ${isBusy ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
<PlatformIconButton
asChild="label"
className={`h-11 w-11 shrink-0 ${floating ? 'creative-agent-composer__media-button' : ''} ${isBusy ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
label={image ? '更换参考图' : '添加参考图'}
title={image ? '更换参考图' : '添加参考图'}
>
{floating ? (
<Plus className="h-5 w-5" />
) : (
<ImagePlus className="h-4 w-4" />
)}
<span className="sr-only">{image ? '更换参考图' : '添加参考图'}</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleImageChange(event);
}}
className="hidden"
/>
</label>
icon={
<>
{floating ? (
<Plus className="h-5 w-5" />
) : (
<ImagePlus className="h-4 w-4" />
)}
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleImageChange(event);
}}
className="hidden"
/>
</>
}
/>
<textarea
<PlatformTextField
variant="textarea"
value={text}
disabled={isBusy}
rows={2}
size="xs"
density="roomy"
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
@@ -108,53 +114,69 @@ export function CreativeAgentInputComposer({
submit();
}
}}
className="min-h-11 flex-1 resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-5 text-[var(--platform-text-strong)] outline-none"
className="min-h-11 flex-1"
placeholder={placeholder}
aria-label="智能创作输入"
/>
<button
type="button"
<PlatformIconButton
disabled={!canSubmit}
onClick={submit}
className="platform-icon-button h-11 w-11 shrink-0"
aria-label="发送"
className="h-11 w-11 shrink-0"
label="发送"
title="发送"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)}
</button>
icon={
isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)
}
/>
</div>
{image ? (
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-2">
<img
src={image.thumbnailUrl}
alt="创作参考图"
className="h-12 w-12 rounded-[0.8rem] object-cover"
/>
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{image.label}
</div>
<button
type="button"
disabled={isBusy}
onClick={() => setImage(null)}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
>
<X className="h-4 w-4" />
</button>
</div>
<PlatformUploadPreviewCard
layout="inline"
imageSrc={image.thumbnailUrl}
imageAlt="创作参考图"
caption={image.label}
removeLabel="移除参考图"
disabled={isBusy}
onRemove={() => setImage(null)}
className="mt-3"
removeButtonProps={{ title: '移除参考图' }}
/>
) : null}
{imageError ? (
<div className="mt-2 text-xs leading-5 text-red-600">{imageError}</div>
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mt-2"
>
{imageError}
</PlatformStatusMessage>
) : null}
</>
);
if (!floating) {
return (
<PlatformSubpanel
padding="sm"
radius="lg"
className="creative-agent-composer sm:p-4"
>
{composerContent}
</PlatformSubpanel>
);
}
return (
<section className="creative-agent-composer creative-agent-composer--floating">
{composerContent}
</section>
);
}

View File

@@ -6,6 +6,9 @@ import {
TriangleAlert,
} from 'lucide-react';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import type { CreativeAgentProcessItem } from './creativeAgentViewModel';
type CreativeAgentProcessPanelProps = {
@@ -42,34 +45,39 @@ export function CreativeAgentProcessPanel({
if (visibleItems.length === 0) {
return (
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
</div>
<PlatformSubpanel
title="过程"
titleClassName="tracking-[0.16em]"
actions={
<Clock3 className="h-4 w-4 text-[var(--platform-text-soft)]" />
</div>
}
radius="lg"
>
<div className="mt-3 text-sm font-semibold text-[var(--platform-text-base)]">
</div>
</section>
</PlatformSubpanel>
);
}
return (
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
<PlatformSubpanel
title={
<span className="flex items-center gap-2">
{isStreaming ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : null}
</div>
<div className="rounded-full border border-[var(--platform-subpanel-border)] bg-white/62 px-2.5 py-1 text-[11px] font-bold text-[var(--platform-text-base)]">
</span>
}
titleClassName="tracking-[0.16em]"
actions={
<PlatformPillBadge tone="neutral" size="xs">
{items.length}
</div>
</div>
</PlatformPillBadge>
}
radius="lg"
>
<div className="mt-3 max-h-[22rem] space-y-2 overflow-y-auto pr-1">
{visibleItems.map((item) => (
<article
@@ -77,14 +85,21 @@ export function CreativeAgentProcessPanel({
className={`rounded-[1rem] border px-3 py-3 ${PROCESS_TONE_CLASS[item.tone]}`}
>
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/82 text-[var(--platform-text-strong)] shadow-sm">
<ProcessIcon item={item} />
</span>
<PlatformIconBadge
icon={<ProcessIcon item={item} />}
size="xs"
tone="soft"
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-white/72 px-2 py-0.5 text-[11px] font-black text-[var(--platform-text-soft)]">
<PlatformPillBadge
tone="muted"
size="xs"
className="px-2 py-0.5"
>
{item.meta}
</span>
</PlatformPillBadge>
<div className="min-w-0 flex-1 text-sm font-black leading-5 text-[var(--platform-text-strong)]">
{item.title}
</div>
@@ -112,7 +127,7 @@ export function CreativeAgentProcessPanel({
</article>
))}
</div>
</section>
</PlatformSubpanel>
);
}

View File

@@ -1,6 +1,7 @@
import { Check, Loader2 } from 'lucide-react';
import type { CreativeAgentStage } from '../../../packages/shared/src/contracts/creativeAgent';
import { PlatformSlotBadge } from '../common/PlatformSlotBadge';
import {
CREATIVE_AGENT_TIMELINE,
getCreativeAgentStageDisplayLabel,
@@ -44,7 +45,7 @@ export function CreativeAgentStageTimeline({
: 'border-[var(--platform-subpanel-border)] bg-white/46 text-[var(--platform-text-soft)]'
}`}
>
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white/82">
<PlatformSlotBadge tone="soft" size="md" className="shrink-0">
{isActive ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : isDone ? (
@@ -52,7 +53,7 @@ export function CreativeAgentStageTimeline({
) : (
<span>{index + 1}</span>
)}
</span>
</PlatformSlotBadge>
<span className="leading-4">{label}</span>
</div>
);

View File

@@ -48,6 +48,12 @@ test('shows cost range and opens an independent adjustment dialog', () => {
expect(adjustDialog.parentElement).not.toBe(confirmDialog);
fireEvent.click(within(adjustDialog).getByRole('button', { name: '多关卡' }));
const levelCountInput = within(adjustDialog).getByLabelText('计划关卡数');
expect(levelCountInput.className).toContain('bg-white/90');
expect(levelCountInput.className).toContain(
'focus:ring-[var(--platform-warm-border)]',
);
expect(levelCountInput.className).toContain('font-bold');
fireEvent.change(within(adjustDialog).getByLabelText('计划关卡数'), {
target: { value: '4' },
});
@@ -61,3 +67,54 @@ test('shows cost range and opens an independent adjustment dialog', () => {
}),
);
});
test('template preview uses platform media frame with image and fallback states', () => {
const selectionWithPreview = {
...createSelection(),
previewImageSrc: '/template-preview.webp',
};
const { rerender } = render(
<CreativeAgentTemplateConfirmPanel
selection={selectionWithPreview}
isBusy={false}
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
const previewImage = screen.getByRole('img', { name: '创意拼图' });
const previewFrame = previewImage.closest('div.relative');
expect(previewFrame?.className).toContain('aspect-[16/9]');
expect(previewFrame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(previewFrame?.className).toContain('bg-white/68');
expect(previewFrame?.className).toContain('rounded-[1.25rem]');
const selectionWithoutPreview = {
...createSelection(),
previewImageSrc: '',
};
rerender(
<CreativeAgentTemplateConfirmPanel
selection={selectionWithoutPreview}
isBusy={false}
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
const fallbackFrame = screen
.getByRole('dialog', {
name: '确认拼图模板',
})
.querySelector('div.relative.aspect-\\[16\\/9\\]');
const fallbackIcon = fallbackFrame?.querySelector('svg');
expect(fallbackFrame?.className).toContain('aspect-[16/9]');
expect(fallbackFrame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(fallbackIcon?.closest('span')?.className).toContain('bg-white/84');
});

View File

@@ -1,9 +1,17 @@
import { Check, Puzzle, SlidersHorizontal, X } from 'lucide-react';
import { Check, Puzzle, SlidersHorizontal } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import type { PuzzleCreativeTemplateSelection } from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
import { PlatformStatGrid } from '../common/PlatformStatGrid';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
type CreativeAgentTemplateConfirmPanelProps = {
selection: PuzzleCreativeTemplateSelection;
@@ -12,7 +20,10 @@ type CreativeAgentTemplateConfirmPanelProps = {
onCancel: () => void;
};
function clampLevelCount(value: number, selection: PuzzleCreativeTemplateSelection) {
function clampLevelCount(
value: number,
selection: PuzzleCreativeTemplateSelection,
) {
const { min, max } = resolveLevelCountBounds(selection);
return Math.max(min, Math.min(max, value));
}
@@ -88,93 +99,89 @@ export function CreativeAgentTemplateConfirmPanel({
{pointsText}
</div>
</div>
<button
type="button"
<PlatformModalCloseButton
label="取消模板"
variant="platformIcon"
disabled={isBusy}
onClick={onCancel}
className="platform-icon-button"
aria-label="取消模板"
title="取消"
>
<X className="h-4 w-4" />
</button>
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="space-y-3">
<div className="overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/68">
<div className="aspect-[16/9] bg-[radial-gradient(circle_at_28%_20%,rgba(255,255,255,0.92),transparent_32%),linear-gradient(135deg,rgba(255,194,123,0.86),rgba(255,93,132,0.82)_52%,rgba(92,186,255,0.78))]">
{'previewImageSrc' in draftSelection &&
typeof draftSelection.previewImageSrc === 'string' &&
draftSelection.previewImageSrc.trim() ? (
<img
src={draftSelection.previewImageSrc}
alt={draftSelection.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-white/84 text-[var(--platform-text-strong)] shadow-sm">
<Puzzle className="h-6 w-6" />
</span>
</div>
)}
</div>
</div>
<PlatformMediaFrame
src={
'previewImageSrc' in draftSelection &&
typeof draftSelection.previewImageSrc === 'string'
? draftSelection.previewImageSrc
: ''
}
alt={draftSelection.title}
fallbackLabel={draftSelection.title}
fallbackContent={
<PlatformIconBadge
icon={<Puzzle className="h-6 w-6" />}
size="xl"
tone="softBright"
/>
}
aspect="landscape"
surface="soft"
className="rounded-[1.25rem]"
fallbackShellClassName="bg-[radial-gradient(circle_at_28%_20%,rgba(255,255,255,0.92),transparent_32%),linear-gradient(135deg,rgba(255,194,123,0.86),rgba(255,93,132,0.82)_52%,rgba(92,186,255,0.78))]"
fallbackClassName="tracking-normal"
/>
<div className="platform-subpanel rounded-[1.25rem] p-4">
<PlatformSubpanel as="div" radius="md">
<div className="text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
{draftSelection.reason}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="platform-subpanel rounded-[1.15rem] p-4">
<div className="text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-base font-black text-[var(--platform-text-strong)]">
{draftSelection.selectedLevelMode === 'single_level'
? '单关卡'
: '多关卡'}
</div>
</div>
<div className="platform-subpanel rounded-[1.15rem] p-4">
<div className="text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-base font-black text-[var(--platform-text-strong)]">
{draftSelection.plannedLevelCount}
</div>
</div>
</div>
</PlatformSubpanel>
<PlatformStatGrid
items={[
{
label: '关卡模式',
value:
draftSelection.selectedLevelMode === 'single_level'
? '单关卡'
: '多关卡',
},
{
label: '计划关卡',
value: `${draftSelection.plannedLevelCount}`,
},
]}
columns="two"
order="labelFirst"
surface="plain"
textAlign="left"
itemClassName="rounded-[1.15rem] p-4"
/>
</div>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)] sm:flex-row sm:justify-end">
<button
type="button"
<PlatformActionButton
tone="ghost"
disabled={isBusy}
onClick={() => setIsAdjustOpen((current) => !current)}
className="platform-button platform-button--ghost"
>
<span className="inline-flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4" />
</span>
</button>
<button
type="button"
</PlatformActionButton>
<PlatformActionButton
disabled={isBusy}
onClick={() => onConfirm(draftSelection)}
className="platform-button platform-button--primary"
>
<span className="inline-flex items-center gap-2">
<Check className="h-4 w-4" />
</span>
</button>
</PlatformActionButton>
</div>
</section>
@@ -190,54 +197,51 @@ export function CreativeAgentTemplateConfirmPanel({
<div className="text-base font-black text-[var(--platform-text-strong)]">
</div>
<button
type="button"
<PlatformModalCloseButton
label="关闭调整"
variant="platformIcon"
disabled={isBusy}
onClick={() => setIsAdjustOpen(false)}
className="platform-icon-button"
aria-label="关闭调整"
title="关闭"
>
<X className="h-4 w-4" />
</button>
/>
</div>
<div className="space-y-3 px-5 py-4">
<div className="grid grid-cols-2 gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
{[
{ value: 'single_level' as const, label: '单关卡' },
{ value: 'multi_level' as const, label: '多关卡' },
].map((item) => (
<button
key={item.value}
type="button"
disabled={isBusy || !canUseLevelMode(draftSelection, item.value)}
onClick={() => {
setDraftSelection((current) => ({
...current,
selectedLevelMode: item.value,
plannedLevelCount:
item.value === 'single_level'
? 1
: Math.max(2, current.plannedLevelCount),
}));
}}
className={`min-h-10 rounded-[0.8rem] px-3 text-sm font-bold ${
draftSelection.selectedLevelMode === item.value
? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
: 'text-[var(--platform-text-base)]'
}`}
>
{item.label}
</button>
))}
</div>
<PlatformSegmentedTabs
items={[
{
id: 'single_level',
label: '单关卡',
disabled:
isBusy || !canUseLevelMode(draftSelection, 'single_level'),
},
{
id: 'multi_level',
label: '多关卡',
disabled:
isBusy || !canUseLevelMode(draftSelection, 'multi_level'),
},
]}
activeId={draftSelection.selectedLevelMode}
onChange={(nextLevelMode) => {
setDraftSelection((current) => ({
...current,
selectedLevelMode: nextLevelMode,
plannedLevelCount:
nextLevelMode === 'single_level'
? 1
: Math.max(2, current.plannedLevelCount),
}));
}}
radius="md"
size="compact"
/>
<label className="flex min-h-11 items-center gap-3">
<span className="shrink-0 text-sm font-bold text-[var(--platform-text-base)]">
</span>
<input
<PlatformTextField
type="number"
min={levelCountBounds.min}
max={levelCountBounds.max}
@@ -258,21 +262,20 @@ export function CreativeAgentTemplateConfirmPanel({
),
}));
}}
className="min-h-11 min-w-0 flex-1 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 text-sm font-bold text-[var(--platform-text-strong)] outline-none"
density="compact"
className="min-h-11 min-w-0 flex-1 font-bold"
aria-label="计划关卡数"
/>
</label>
</div>
<div className="flex justify-end border-t border-[var(--platform-subpanel-border)] px-5 py-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
<button
type="button"
<PlatformActionButton
disabled={isBusy}
onClick={() => setIsAdjustOpen(false)}
className="platform-button platform-button--primary"
>
</button>
</PlatformActionButton>
</div>
</section>
) : null}

View File

@@ -120,6 +120,37 @@ test('target ready session exposes the puzzle result entry action', () => {
expect(screen.getByText('拼图草稿已就绪')).toBeTruthy();
expect(screen.getByText('可以进入结果页继续编辑')).toBeTruthy();
expect(screen.getByText('预计 2-12 泥点')).toBeTruthy();
const heroIconBadge = screen
.getByText('智能创作')
.closest('section')
?.querySelector('[aria-hidden="true"]');
expect(heroIconBadge?.className).toContain('h-12');
expect(heroIconBadge?.className).toContain('bg-white/18');
const targetReadyIconBadge = screen
.getByText('拼图草稿已就绪')
.closest('section')
?.querySelector('[aria-hidden="true"]');
expect(targetReadyIconBadge?.className).toContain('h-10');
expect(targetReadyIconBadge?.className).toContain(
'bg-[var(--platform-success-bg)]',
);
expect(screen.getByText('2 条').className).toContain('rounded-full');
expect(screen.getByText('2 条').className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(screen.getByText('消耗').className).toContain('rounded-full');
expect(screen.getByText('消耗').className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(screen.getByText('消耗').className).toContain(
'bg-[var(--platform-subpanel-fill)]',
);
const processIconBadge = screen
.getByText('拼图草稿已绑定')
.closest('article')
?.querySelector('[aria-hidden="true"]');
expect(processIconBadge?.className).toContain('h-7');
expect(processIconBadge?.className).toContain('bg-white/82');
fireEvent.click(screen.getByRole('button', { name: '打开草稿' }));
@@ -163,12 +194,40 @@ test('waiting confirmation shows template catalog before template config dialog'
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
const templateCards = screen.getAllByTestId('creative-agent-template-card');
const firstTemplateCard = templateCards[0] as HTMLElement;
const firstPreviewFrame = Array.from(
firstTemplateCard.querySelectorAll('div'),
).find((element) => element.className.includes('aspect-[16/9]'));
expect(firstTemplateCard.className).toContain('bg-white/72');
expect(firstTemplateCard.className).toContain('rounded-[1.25rem]');
expect(firstTemplateCard.className).toContain('p-3');
expect(firstPreviewFrame?.className).toContain('rounded-[0.95rem]');
expect(firstPreviewFrame?.className).toContain('aspect-[16/9]');
expect(firstPreviewFrame?.className).toContain('radial-gradient');
expect(firstPreviewFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
const firstFallbackIconBadge = firstPreviewFrame?.querySelector(
'[aria-hidden="true"]',
);
expect(firstFallbackIconBadge?.className).toContain('h-10');
expect(firstFallbackIconBadge?.className).toContain('bg-white/84');
expect(screen.queryByRole('dialog', { name: '确认拼图模板' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('dialog', { name: '确认拼图模板' })).toBeTruthy();
const confirmDialog = screen.getByRole('dialog', { name: '确认拼图模板' });
expect(confirmDialog).toBeTruthy();
expect(screen.getByText('预计 4 到 16 泥点')).toBeTruthy();
const dialogFallbackIconBadge = Array.from(
confirmDialog.querySelectorAll('[aria-hidden="true"]'),
).find(
(element) =>
element instanceof HTMLElement && element.className.includes('h-14'),
);
expect(dialogFallbackIconBadge?.className).toContain('bg-white/84');
fireEvent.click(screen.getByRole('button', { name: //u }));
@@ -229,6 +288,83 @@ test('switching creative session clears pending template config dialog', () => {
expect(screen.queryByRole('dialog', { name: '确认拼图模板' })).toBeNull();
});
test('creative agent empty workspace uses platform subpanels', () => {
render(
<CreativeAgentWorkspace
session={createSession({
stage: 'idle',
messages: [],
targetBinding: null,
})}
isBusy={false}
isStreaming={false}
error={null}
eventLog={[]}
onBack={() => {}}
onSubmitMessage={() => {}}
onConfirmTemplate={() => {}}
onOpenTarget={() => {}}
/>,
);
const emptyMessagePanel = screen
.getByText('发一句想法,或加一张参考图。')
.closest('div');
const processPanel = screen.getByText('等待新的创作输入').closest('section');
expect(emptyMessagePanel?.className).toContain('platform-subpanel');
expect(emptyMessagePanel?.className).toContain('rounded-[1.35rem]');
expect(processPanel?.className).toContain('platform-subpanel');
expect(processPanel?.className).toContain('rounded-[1.35rem]');
});
test('creative agent level plan cards use platform flat subpanels', () => {
render(
<CreativeAgentWorkspace
session={createSession({
stage: 'planning_puzzle_levels',
targetBinding: null,
puzzleImageGenerationPlan: {
mode: 'multi_level',
templateId: 'puzzle.default-creative',
estimatedCostRange: {
minPoints: 2,
maxPoints: 12,
pricingUnit: 'point',
reason: '按关卡数估算',
},
levels: [
{
levelId: 'level-birthday',
levelName: '生日主图',
pictureDescription: '蛋糕、气球和祝福卡片组成的主画面。',
imagePrompt: '生日主题拼图主画面',
candidateCount: 1,
},
],
},
})}
isBusy={false}
isStreaming={false}
error={null}
eventLog={[]}
onBack={() => {}}
onSubmitMessage={() => {}}
onConfirmTemplate={() => {}}
onOpenTarget={() => {}}
/>,
);
const levelCard = screen.getByText('生日主图').parentElement;
expect(levelCard?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(levelCard?.className).toContain('rounded-[1rem]');
expect(levelCard?.className).toContain('bg-white/58');
expect(levelCard?.className).toContain('px-3');
});
test('target ready puzzle result binding resolves to puzzle-result stage', () => {
expect(resolveCreativeAgentTargetSelectionStage('puzzle-result')).toBe(
'puzzle-result',
@@ -239,11 +375,19 @@ test('target ready puzzle result binding resolves to puzzle-result stage', () =>
});
test('target ready timeline renders completed labels instead of active labels', () => {
render(<CreativeAgentStageTimeline stage={'target_ready' as CreativeAgentStage} />);
render(
<CreativeAgentStageTimeline stage={'target_ready' as CreativeAgentStage} />,
);
expect(screen.getByText('素材已理解')).toBeTruthy();
expect(screen.getByText('构思已完成')).toBeTruthy();
expect(screen.getByText('草稿已生成')).toBeTruthy();
const firstStepBadge = screen
.getByText('素材已理解')
.closest('div')
?.querySelector('span');
expect(firstStepBadge?.className).toContain('h-6');
expect(firstStepBadge?.className).toContain('bg-white/82');
expect(screen.queryByText('正在理解素材')).toBeNull();
expect(screen.queryByText('正在构思')).toBeNull();
});

View File

@@ -10,6 +10,12 @@ import type {
PuzzleCreativeTemplateProtocol,
PuzzleCreativeTemplateSelection,
} from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { CreativeAgentInputComposer } from './CreativeAgentInputComposer';
import { CreativeAgentProcessPanel } from './CreativeAgentProcessPanel';
import { CreativeAgentStageTimeline } from './CreativeAgentStageTimeline';
@@ -54,31 +60,37 @@ function CreativeAgentTemplateCatalogPanel({
}
return (
<section className="platform-subpanel rounded-[1.35rem] p-4">
<PlatformSubpanel radius="lg">
<div className="grid gap-3 sm:grid-cols-3">
{templates.map((template) => (
<button
<PlatformSubpanel
as="button"
key={template.templateId}
type="button"
disabled={isBusy}
onClick={() => onSelect(template)}
className="group min-h-[10.5rem] rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-3 text-left transition hover:-translate-y-0.5 hover:bg-white/88 disabled:opacity-55"
interactive
surface="flat"
radius="md"
padding="sm"
className="group min-h-[10.5rem] hover:-translate-y-0.5"
data-testid="creative-agent-template-card"
>
<div className="overflow-hidden rounded-[0.95rem] border border-white/70 bg-[radial-gradient(circle_at_28%_20%,rgba(255,255,255,0.92),transparent_32%),linear-gradient(135deg,rgba(255,194,123,0.86),rgba(255,93,132,0.82)_52%,rgba(92,186,255,0.78))]">
<div className="flex aspect-[16/9] items-center justify-center">
{template.previewImageSrc ? (
<img
src={template.previewImageSrc}
alt={template.title}
className="h-full w-full object-cover"
/>
) : (
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/84 text-[var(--platform-text-strong)] shadow-sm">
<Puzzle className="h-4 w-4" />
</span>
)}
</div>
</div>
<PlatformMediaFrame
src={template.previewImageSrc}
alt={template.title}
fallbackLabel={template.title}
fallbackContent={
<PlatformIconBadge
icon={<Puzzle className="h-4 w-4" />}
size="base"
tone="softBright"
/>
}
aspect="landscape"
surface="none"
className="rounded-[0.95rem] border border-white/70 bg-[radial-gradient(circle_at_28%_20%,rgba(255,255,255,0.92),transparent_32%),linear-gradient(135deg,rgba(255,194,123,0.86),rgba(255,93,132,0.82)_52%,rgba(92,186,255,0.78))]"
fallbackClassName="tracking-normal"
/>
<div className="mt-3 text-sm font-black text-[var(--platform-text-strong)]">
{template.title}
</div>
@@ -88,10 +100,10 @@ function CreativeAgentTemplateCatalogPanel({
<div className="mt-3 text-xs font-bold text-[var(--platform-text-soft)]">
{`${template.defaultLevelCount} 关 · ${template.costRange.minPoints}-${template.costRange.maxPoints} 泥点`}
</div>
</button>
</PlatformSubpanel>
))}
</div>
</section>
</PlatformSubpanel>
);
}
@@ -123,7 +135,9 @@ export function CreativeAgentWorkspace({
() => buildCreativeAgentProcessItems(eventLog, session),
[eventLog, session],
);
const visibleSelection = targetBinding ? null : (selection ?? pendingSelection);
const visibleSelection = targetBinding
? null
: (selection ?? pendingSelection);
const shouldShowTemplateCatalog =
!targetBinding &&
!selection &&
@@ -134,23 +148,24 @@ export function CreativeAgentWorkspace({
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)] xl:px-1">
<div className="mb-3 flex items-center justify-between gap-3">
{showBackButton ? (
<button
type="button"
<PlatformActionButton
tone="ghost"
size="xs"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
className="min-h-0 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>
</PlatformActionButton>
) : (
<span aria-hidden="true" />
)}
<div className="platform-pill platform-pill--cool px-3 text-[11px]">
<PlatformPillBadge tone="cool" size="xs" className="px-3">
{CREATIVE_AGENT_STAGE_LABEL[stage]}
</div>
</PlatformPillBadge>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
@@ -166,20 +181,28 @@ export function CreativeAgentWorkspace({
稿
</div>
</div>
<span className="hidden h-12 w-12 shrink-0 items-center justify-center rounded-full bg-white/18 text-white sm:inline-flex">
<Puzzle className="h-5 w-5" />
</span>
<PlatformIconBadge
icon={<Puzzle className="h-5 w-5" />}
size="lg"
tone="hero"
className="hidden sm:grid"
/>
</div>
</section>
<CreativeAgentStageTimeline stage={stage} />
{targetBinding ? (
<section className="platform-subpanel flex flex-col gap-3 rounded-[1.35rem] p-4 sm:flex-row sm:items-center sm:justify-between">
<PlatformSubpanel
radius="lg"
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-[var(--platform-success-bg)] text-[var(--platform-success-text)]">
<CheckCircle2 className="h-5 w-5" />
</span>
<PlatformIconBadge
icon={<CheckCircle2 className="h-5 w-5" />}
size="base"
tone="success"
/>
<div>
<div className="text-base font-black text-[var(--platform-text-strong)]">
稿
@@ -191,15 +214,10 @@ export function CreativeAgentWorkspace({
</div>
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={onOpenTarget}
className="platform-button platform-button--primary"
>
<PlatformActionButton disabled={isBusy} onClick={onOpenTarget}>
稿
</button>
</section>
</PlatformActionButton>
</PlatformSubpanel>
) : null}
{messages.length > 0 ? (
@@ -218,9 +236,13 @@ export function CreativeAgentWorkspace({
))}
</div>
) : (
<div className="platform-subpanel rounded-[1.35rem] p-4 text-sm font-semibold text-[var(--platform-text-base)]">
<PlatformSubpanel
as="div"
radius="lg"
className="text-sm font-semibold text-[var(--platform-text-base)]"
>
</div>
</PlatformSubpanel>
)}
<CreativeAgentProcessPanel
@@ -241,15 +263,21 @@ export function CreativeAgentWorkspace({
) : null}
{session?.puzzleImageGenerationPlan ? (
<div className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<PlatformSubpanel
as="div"
title="关卡计划"
titleClassName="tracking-[0.18em]"
radius="lg"
>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{session.puzzleImageGenerationPlan.levels.map((level) => (
<div
<PlatformSubpanel
key={level.levelId}
className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/58 px-3 py-3"
as="div"
surface="flat"
radius="sm"
padding="none"
className="bg-white/58 px-3 py-3"
>
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{level.levelName}
@@ -257,16 +285,21 @@ export function CreativeAgentWorkspace({
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
{level.pictureDescription}
</div>
</div>
</PlatformSubpanel>
))}
</div>
</div>
</PlatformSubpanel>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger rounded-[1.25rem] text-sm leading-6">
<PlatformStatusMessage
tone="error"
surface="platform"
size="md"
className="rounded-[1.25rem]"
>
{error}
</div>
</PlatformStatusMessage>
) : null}
</div>
</div>