收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user