收口前端平台组件库能力

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

@@ -20,22 +20,24 @@ vi.mock('../ResolvedAssetImage', () => ({
src,
alt,
className,
refreshKey,
'data-testid': dataTestId,
}: {
src?: string | null;
alt?: string;
className?: string;
refreshKey?: string | number | null;
'data-testid'?: string;
}) => (
}) =>
src ? (
<img
src={src}
alt={alt}
className={className}
data-refresh-key={refreshKey ?? undefined}
data-testid={dataTestId}
/>
) : null
),
) : null,
}));
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
@@ -79,6 +81,24 @@ function stubReferenceImageUpload(dataUrl: string) {
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
}
test('renders missing draft notice with shared PlatformSubpanel chrome', () => {
render(
<PuzzleResultView
session={{ ...createSession(), draft: null }}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
const noticePanel = screen
.getByText('还没有可编辑的拼图草稿')
.closest('.platform-subpanel');
expect(noticePanel?.className).toContain('rounded-[1rem]');
expect(noticePanel?.className).toContain('sm:p-5');
expect(noticePanel?.className).toContain('text-[var(--platform-text-base)]');
});
function createSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
@@ -227,8 +247,27 @@ describe('PuzzleResultView', () => {
);
openPuzzleLevelsTab();
const levelImage = screen.getByRole('img', { name: '雨夜猫街' });
const mediaFrame = levelImage.closest('div.relative');
expect(mediaFrame?.className).toContain('aspect-[4/3]');
expect(mediaFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
expect(mediaFrame?.className).toContain('rounded-none');
expect(screen.getByText('雨夜猫街')).toBeTruthy();
const levelTitleButton = within(
screen.getByLabelText('拼图关卡列表'),
).getByRole('button', { name: '雨夜猫街' });
const levelCard = levelTitleButton.closest('.platform-subpanel');
expect(levelCard?.className).toContain('rounded-[1.35rem]');
expect(levelCard?.className).toContain('p-0');
expect(levelCard?.className).toContain('overflow-hidden');
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
fireEvent.click(screen.getByText('雨夜猫街'));
const levelDialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(levelDialog).getByText('关卡名称').className).toContain(
'tracking-[0.18em]',
);
});
test('result action bar restores draft trial entry', () => {
@@ -334,10 +373,17 @@ describe('PuzzleResultView', () => {
target: { value: '暖灯猫街合集' },
});
expect(screen.getByText('保存中').className).toContain(
'border-[var(--platform-warm-border)]',
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getByText('已自动保存').className).toContain(
'border-emerald-200',
);
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
@@ -356,6 +402,38 @@ describe('PuzzleResultView', () => {
);
});
test('auto save failure badge uses PlatformPillBadge danger chrome', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockRejectedValue(
new Error('保存失败'),
);
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
fireEvent.change(screen.getByLabelText('作品名称'), {
target: { value: '暖灯猫街异常版' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
const failureBadge = screen
.getAllByText('保存失败')
.find((element) => element.tagName.toLowerCase() === 'span');
expect(failureBadge?.className).toContain(
'border-[var(--platform-button-danger-border)]',
);
});
test('opens an independent level detail dialog for generation and test play', () => {
const onExecuteAction = vi.fn();
const onStartTestRun = vi.fn();
@@ -387,7 +465,9 @@ describe('PuzzleResultView', () => {
name: '确认消耗泥点',
});
expect(within(confirmDialog).getByText('消耗 2 泥点')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
fireEvent.click(
within(confirmDialog).getByRole('button', { name: '确定' }),
);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
@@ -405,7 +485,9 @@ describe('PuzzleResultView', () => {
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
expect(screen.getByRole('progressbar', { name: '画面生成进度' })).toBeTruthy();
expect(
screen.getByRole('progressbar', { name: '画面生成进度' }),
).toBeTruthy();
const generatePayload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({
@@ -421,6 +503,19 @@ describe('PuzzleResultView', () => {
).toBeNull();
const levelList = screen.getByLabelText('拼图关卡列表');
expect(within(levelList).getAllByText('生成中').length).toBeGreaterThan(0);
const generationBadges = within(levelList)
.getAllByText('生成中')
.map((element) => element.closest('span'));
expect(
generationBadges.some((badge) =>
badge?.className.includes('bg-white/94'),
),
).toBe(true);
expect(
generationBadges.some((badge) =>
badge?.className.includes('bg-amber-100'),
),
).toBe(true);
const levelNameInput = within(dialog).getByLabelText('关卡名称');
const formalImageTitle = within(dialog).getByText('画面图');
@@ -439,7 +534,9 @@ describe('PuzzleResultView', () => {
name: '关闭关卡图片预览',
}),
);
expect(within(dialog).getByRole('button', { name: '更换参考图' })).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '更换参考图' }),
).toBeTruthy();
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
expect(levelNameInput.closest('.platform-subpanel')).toBeNull();
expect(formalImageTitle.closest('.platform-subpanel')).toBeNull();
@@ -701,7 +798,9 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(within(publishDialog).getByText('还有关卡画面正在生成。')).toBeTruthy();
expect(
within(publishDialog).getByText('还有关卡画面正在生成。'),
).toBeTruthy();
expect(
within(publishDialog).getByRole('button', { name: /发布到广场/u }),
).toHaveProperty('disabled', true);
@@ -837,11 +936,30 @@ describe('PuzzleResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(within(publishDialog).getByText('发布检查').className).toContain(
'tracking-[0.18em]',
);
expect(within(publishDialog).getByText('封面关卡').className).toContain(
'tracking-[0.18em]',
);
const coverImage = within(publishDialog).getByRole('img', {
name: '雨夜猫街',
});
expect(coverImage.closest('div.relative')?.className).toContain(
'aspect-square',
);
expect(coverImage.closest('div.relative')?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(coverImage.closest('div.relative')?.className).toContain(
'bg-white/68',
);
expect(coverImage.getAttribute('data-refresh-key')).toBe(
'2026-04-26T10:00:00.000Z:/puzzle/candidate-1.png:1',
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '发布拼图作品' })).getByRole(
'button',
{ name: /发布到广场/u },
),
within(publishDialog).getByRole('button', { name: /发布到广场/u }),
);
expect(onExecuteAction).toHaveBeenCalledWith(
@@ -1286,7 +1404,8 @@ describe('PuzzleResultView', () => {
levels: [
{
...createSession().draft!.levels![0]!,
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
pictureReference:
'/generated-puzzle-assets/history/saved-reference.png',
},
],
},
@@ -1302,6 +1421,14 @@ describe('PuzzleResultView', () => {
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const referenceImage = within(dialog).getByAltText('拼图参考图');
const referenceRow = referenceImage.closest('div')?.parentElement;
expect(referenceRow?.className).toContain('flex items-center gap-3');
expect(referenceRow?.className).toContain('bg-white/72');
expect(within(dialog).getByText('已选择参考图').className).toContain(
'truncate',
);
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
@@ -1313,7 +1440,8 @@ describe('PuzzleResultView', () => {
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
referenceImageSrc: '/generated-puzzle-assets/history/saved-reference.png',
referenceImageSrc:
'/generated-puzzle-assets/history/saved-reference.png',
aiRedraw: true,
}),
);
@@ -1367,7 +1495,8 @@ describe('PuzzleResultView', () => {
levels: [
{
...createSession().draft!.levels![0]!,
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
pictureReference:
'/generated-puzzle-assets/history/saved-reference.png',
},
],
},
@@ -1387,18 +1516,22 @@ describe('PuzzleResultView', () => {
expect(within(dialog).getByText('画面图')).toBeTruthy();
expect(within(dialog).getByLabelText('上传参考图')).toBeTruthy();
expect(within(dialog).getByRole('switch', { name: 'AI重绘' })).toHaveProperty(
'checked',
true,
);
expect(
within(dialog).getByRole('switch', { name: 'AI重绘' }),
).toHaveProperty('checked', true);
expect(
within(dialog).getByRole('button', { name: '选择历史图片' }),
).toBeTruthy();
fireEvent.change(within(dialog).getByLabelText('画面AI重绘要求提示词'), {
target: { value: '只重绘第一关猫街画面' },
});
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
fireEvent.change(
within(dialog).getByLabelText('画面AI重绘要求提示词'),
{
target: { value: '只重绘第一关猫街画面' },
},
);
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
@@ -1429,7 +1562,8 @@ describe('PuzzleResultView', () => {
levels: [
{
...createSession().draft!.levels![0]!,
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
pictureReference:
'/generated-puzzle-assets/history/saved-reference.png',
},
],
},
@@ -1448,7 +1582,9 @@ describe('PuzzleResultView', () => {
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.click(within(dialog).getByRole('switch', { name: 'AI重绘' }));
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
@@ -1497,14 +1633,18 @@ describe('PuzzleResultView', () => {
});
await waitFor(() => {
expect(within(dialog).getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
expect(
within(dialog).getByRole('switch', { name: 'AI重绘' }),
).toBeTruthy();
});
expect(within(dialog).getByAltText('拼图参考图')).toHaveProperty(
'src',
uploadedDataUrl,
);
fireEvent.click(within(dialog).getByRole('switch', { name: 'AI重绘' }));
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
@@ -1539,11 +1679,10 @@ describe('PuzzleResultView', () => {
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const referenceInputs = within(dialog).getAllByLabelText('上传参考图', {
const referenceInput = within(dialog).getByLabelText('上传描述参考图', {
selector: 'input',
});
expect(referenceInputs.length).toBeGreaterThanOrEqual(2);
fireEvent.change(referenceInputs[referenceInputs.length - 1]!, {
fireEvent.change(referenceInput, {
target: {
files: [new File(['x'], 'prompt-reference.png', { type: 'image/png' })],
},
@@ -1556,7 +1695,9 @@ describe('PuzzleResultView', () => {
}),
).toBeTruthy();
});
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
@@ -1628,7 +1769,19 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.change(screen.getByLabelText('智能修订拼图草稿'), {
const creativeDraftInput = screen.getByLabelText('智能修订拼图草稿');
const creativeDraftPanel = creativeDraftInput.closest('.platform-subpanel');
const creativeDraftIconBadge = creativeDraftPanel?.querySelector(
'span[aria-hidden="true"]',
);
expect(creativeDraftPanel?.className).toContain('rounded-[1.35rem]');
expect(creativeDraftPanel?.className).toContain('sm:p-4');
expect(creativeDraftIconBadge?.className).toContain('h-9');
expect(creativeDraftIconBadge?.className).toContain('rounded-full');
expect(creativeDraftIconBadge?.className).toContain('bg-white/72');
fireEvent.change(creativeDraftInput, {
target: { value: '把标题改得轻松一点' },
});
fireEvent.click(screen.getByRole('button', { name: '修改' }));

View File

@@ -5,7 +5,6 @@ import {
MessageSquareText,
Play,
Plus,
Sparkles,
Trash2,
X,
} from 'lucide-react';
@@ -24,13 +23,26 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformProgressBar } from '../common/PlatformProgressBar';
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTagEditor } from '../common/PlatformTagEditor';
import { PlatformTextField } from '../common/PlatformTextField';
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
import { UnifiedConfirmDialog } from '../common/UnifiedConfirmDialog';
import PuzzleHistoryAssetPickerDialog from '../unified-creation/shared/PuzzleHistoryAssetPickerDialog';
import {
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
type PuzzleImageModelId,
} from '../unified-creation/shared/puzzleImageModelOptions';
import { PuzzleImageModelPicker } from '../unified-creation/shared/PuzzleImageModelPicker';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleResultViewProps = {
session: PuzzleAgentSessionSnapshot;
@@ -271,7 +283,8 @@ function mergeDraftEditStateWithIncomingState(
selectedCandidateId: incomingLevel.selectedCandidateId,
coverImageSrc: incomingLevel.coverImageSrc,
coverAssetId: incomingLevel.coverAssetId,
pictureReference: incomingLevel.pictureReference ?? level.pictureReference,
pictureReference:
incomingLevel.pictureReference ?? level.pictureReference,
uiBackgroundPrompt:
incomingLevel.uiBackgroundPrompt ?? level.uiBackgroundPrompt,
uiBackgroundImageSrc:
@@ -396,32 +409,33 @@ function PuzzleResultHeader({
}) {
const autoSaveBadge =
autoSaveState === 'saving' ? (
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
<PlatformPillBadge tone="warning" size="xs" className="px-3 py-1">
</div>
</PlatformPillBadge>
) : autoSaveState === 'saved' ? (
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
<PlatformPillBadge tone="success" size="xs" className="px-3 py-1">
</div>
</PlatformPillBadge>
) : autoSaveState === 'error' ? (
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
<PlatformPillBadge tone="danger" size="xs" className="px-3 py-1">
</div>
</PlatformPillBadge>
) : null;
return (
<div className="mb-4 flex items-center justify-between gap-3">
<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 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>
{autoSaveBadge}
</div>
);
@@ -435,23 +449,12 @@ function PuzzleResultTabs({
onChange: (tab: PuzzleResultTab) => void;
}) {
return (
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
{PUZZLE_RESULT_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`min-h-10 rounded-[1rem] px-3 text-sm font-bold transition ${
activeTab === tab.id
? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
: 'text-[var(--platform-text-base)] hover:bg-white/60'
}`}
aria-pressed={activeTab === tab.id}
>
{tab.label}
</button>
))}
</div>
<PlatformSegmentedTabs
items={PUZZLE_RESULT_TABS}
activeId={activeTab}
onChange={onChange}
className="mb-3"
/>
);
}
@@ -468,144 +471,20 @@ function PuzzleThemeTagEditor({
onChange: (nextState: DraftEditState) => void;
onGenerateTags: () => void;
}) {
const [newTagText, setNewTagText] = useState('');
const [isAddingTag, setIsAddingTag] = useState(false);
const addTags = () => {
const nextTags = normalizeThemeTagInput(newTagText);
if (nextTags.length <= 0) {
setIsAddingTag(false);
setNewTagText('');
return;
}
onChange({
...editState,
themeTags: [...new Set([...editState.themeTags, ...nextTags])],
});
setNewTagText('');
setIsAddingTag(false);
};
return (
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={isBusy}
onClick={onGenerateTags}
className="platform-icon-button h-9 w-9"
aria-label="AI生成作品标签"
title="AI生成作品标签"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</button>
{!isAddingTag ? (
<button
type="button"
disabled={isBusy}
onClick={() => setIsAddingTag(true)}
className="platform-icon-button h-9 w-9"
aria-label="新增作品标签"
title="新增作品标签"
>
<Plus className="h-4 w-4" />
</button>
) : null}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{editState.themeTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1.5 rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1.5 text-xs font-semibold text-amber-700"
>
{tag}
<button
type="button"
disabled={isBusy}
onClick={() => {
onChange({
...editState,
themeTags: editState.themeTags.filter(
(currentTag) => currentTag !== tag,
),
});
}}
className="rounded-full text-amber-800/70 transition hover:text-amber-950 disabled:opacity-45"
aria-label={`删除标签 ${tag}`}
title="删除标签"
>
<X className="h-3.5 w-3.5" />
</button>
</span>
))}
{editState.themeTags.length <= 0 ? (
<span className="text-sm text-[var(--platform-text-soft)]">
</span>
) : null}
</div>
{isAddingTag ? (
<div className="mt-3 flex flex-col gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2 sm:flex-row">
<input
autoFocus
value={newTagText}
disabled={isBusy}
onChange={(event) => setNewTagText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addTags();
}
if (event.key === 'Escape') {
setIsAddingTag(false);
setNewTagText('');
}
}}
className="min-h-10 flex-1 rounded-[0.8rem] border border-transparent bg-white/92 px-3 text-sm text-[var(--platform-text-strong)] outline-none"
placeholder="输入新标签"
aria-label="新题材标签"
/>
<div className="flex gap-2">
<button
type="button"
disabled={isBusy}
onClick={addTags}
className="platform-button platform-button--primary min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
setIsAddingTag(false);
setNewTagText('');
}}
className="platform-button platform-button--ghost min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
</div>
</div>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</section>
<PlatformTagEditor
title="作品标签"
radius="lg"
padding="lg"
tags={editState.themeTags}
disabled={isBusy}
error={error}
addLabel="新增作品标签"
generateLabel="AI生成作品标签"
parseInput={normalizeThemeTagInput}
onChange={(themeTags) => onChange({ ...editState, themeTags })}
onGenerate={onGenerateTags}
/>
);
}
@@ -672,7 +551,8 @@ function PuzzleLevelDetailDialog({
? level.levelName || draft.workTitle || '拼图关卡'
: '拼图参考图';
const shouldShowReferenceMeta = Boolean(
effectiveReferenceImageSrc && displayImageSrc !== effectiveReferenceImageSrc,
effectiveReferenceImageSrc &&
displayImageSrc !== effectiveReferenceImageSrc,
);
const generationProgress = resolvePuzzleLevelGenerationProgress(
level,
@@ -733,7 +613,9 @@ function PuzzleLevelDetailDialog({
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
})),
);
setPromptReferenceImages((current) => [...current, ...images].slice(0, 5));
setPromptReferenceImages((current) =>
[...current, ...images].slice(0, 5),
);
setReferenceImageError(
files.length > remainingSlots ? '参考图最多上传 5 张。' : null,
);
@@ -770,14 +652,11 @@ function PuzzleLevelDetailDialog({
<div className="min-w-0 truncate text-base font-semibold text-[var(--platform-text-strong)]">
{level.levelName || '关卡详情'}
</div>
<button
type="button"
<PlatformIconButton
onClick={onClose}
aria-label="关闭"
className="platform-icon-button"
>
<X className="h-4 w-4" />
</button>
label="关闭"
icon={<X className="h-4 w-4" />}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
@@ -785,18 +664,21 @@ function PuzzleLevelDetailDialog({
<section className="grid gap-2 pb-4 sm:grid-cols-[7.5rem_minmax(0,1fr)] sm:items-center">
<label
htmlFor={`puzzle-level-name-${level.levelId}`}
className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]"
className="contents"
>
<PlatformFieldLabel variant="section">
</PlatformFieldLabel>
</label>
<input
<PlatformTextField
id={`puzzle-level-name-${level.levelId}`}
value={level.levelName}
disabled={isBusy}
onChange={(event) =>
onLevelChange({ ...level, levelName: event.target.value })
}
className="w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
size="lg"
density="roomy"
aria-label="关卡名称"
/>
</section>
@@ -814,18 +696,16 @@ function PuzzleLevelDetailDialog({
canUploadPromptReferences={!effectiveReferenceImageSrc}
mainImageMeta={
shouldShowReferenceMeta ? (
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-12 w-12 overflow-hidden rounded-[0.85rem] bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={effectiveReferenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{referenceImageLabel || '已选择参考图'}
</div>
</div>
<PlatformUploadPreviewCard
layout="inline"
imageSrc={effectiveReferenceImageSrc}
imageAlt="拼图参考图"
caption={referenceImageLabel || '已选择参考图'}
removeLabel="移除参考图"
resolveAsset
className="bg-white/72 py-3"
imageShellClassName="rounded-[0.85rem] bg-[var(--platform-subpanel-fill)]"
/>
) : null
}
mainImageInputId={`puzzle-level-reference-upload-${level.levelId}`}
@@ -854,7 +734,8 @@ function PuzzleLevelDetailDialog({
submitDisabled={
isBusy ||
generationProgress.isGenerating ||
(!level.pictureDescription.trim() && !effectiveReferenceImageSrc)
(!level.pictureDescription.trim() &&
!effectiveReferenceImageSrc)
}
labels={{
imageField: '画面图',
@@ -864,7 +745,7 @@ function PuzzleLevelDetailDialog({
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferenceUpload: '上传描述参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
@@ -906,83 +787,50 @@ function PuzzleLevelDetailDialog({
<div className="shrink-0 space-y-3 border-t border-[var(--platform-subpanel-border)] bg-[var(--platform-page-fill)] px-5 py-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
{onStartTestRun && hasFormalImage ? (
<button
type="button"
<PlatformActionButton
disabled={isBusy}
onClick={() => onStartTestRun(level.levelId)}
className={`platform-button platform-button--secondary w-full ${isBusy ? 'opacity-55' : ''}`}
tone="secondary"
fullWidth
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
</span>
</button>
</PlatformActionButton>
) : null}
{generationProgress.isGenerating ? (
<div
role="progressbar"
aria-label="画面生成进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={generationProgress.progressPercent}
className="platform-progress-track relative h-12 overflow-hidden rounded-full"
<PlatformProgressBar
value={generationProgress.progressPercent}
size="lg"
ariaLabel="画面生成进度"
fillClassName="bg-amber-600"
>
<div
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
style={{ width: `${generationProgress.progressPercent}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-sm font-bold text-white">
<Loader2 className="h-4 w-4 animate-spin" />
{generationProgress.secondsLeft}
</div>
</div>
</PlatformProgressBar>
) : null}
</div>
{isCostConfirmOpen ? (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
onClick={() => setIsCostConfirmOpen(false)}
>
<section
role="dialog"
aria-modal="true"
aria-label="确认消耗泥点"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<Sparkles className="h-4 w-4" />
</span>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
</div>
<div className="px-5 py-4 text-sm font-semibold text-[var(--platform-text-base)]">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</div>
<div className="flex items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4">
<button
type="button"
onClick={() => setIsCostConfirmOpen(false)}
className="platform-button platform-button--ghost min-h-10 px-4 py-2 text-sm"
>
</button>
<button
type="button"
disabled={isBusy || generationProgress.isGenerating}
onClick={executeGeneration}
className={`platform-button platform-button--primary min-h-10 px-5 py-2 text-sm ${isBusy || generationProgress.isGenerating ? 'opacity-55' : ''}`}
>
</button>
</div>
</section>
<UnifiedConfirmDialog
open={isCostConfirmOpen}
title="确认消耗泥点"
onClose={() => setIsCostConfirmOpen(false)}
onConfirm={executeGeneration}
confirmLabel="确定"
confirmDisabled={isBusy || generationProgress.isGenerating}
showCancel
portal={false}
overlayClassName="absolute z-20 bg-black/45"
panelClassName="platform-remap-surface rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
>
<div className="font-semibold">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</div>
) : null}
</UnifiedConfirmDialog>
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
@@ -1054,63 +902,65 @@ function PuzzlePublishDialog({
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
<PlatformIconButton
onClick={onClose}
aria-label="关闭"
className="platform-icon-button"
>
<X className="h-4 w-4" />
</button>
label="关闭"
icon={<X className="h-4 w-4" />}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_14rem]">
<div className="space-y-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<PlatformFieldLabel variant="section">
</div>
</PlatformFieldLabel>
{actionError ? (
<div className="platform-banner platform-banner--danger text-sm leading-6">
<PlatformStatusMessage tone="error" surface="platform">
{actionError}
</div>
</PlatformStatusMessage>
) : publishReady ? (
<div className="space-y-2">
<div className="platform-banner platform-banner--success text-sm leading-6">
<PlatformStatusMessage tone="success" surface="platform">
</div>
<div className="platform-banner platform-banner--warning text-sm font-semibold leading-6">
</PlatformStatusMessage>
<PlatformStatusMessage
tone="warning"
surface="platform"
className="font-semibold"
>
{PUZZLE_PUBLISH_POINT_COST}
</div>
</PlatformStatusMessage>
</div>
) : (
<div className="space-y-2">
{blockers.map((blocker, index) => (
<div
<PlatformStatusMessage
key={`puzzle-publish-blocker-${index}-${blocker}`}
className="platform-banner platform-banner--warning text-sm leading-6"
tone="warning"
surface="platform"
>
{blocker}
</div>
</PlatformStatusMessage>
))}
</div>
)}
</div>
<div className="space-y-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<PlatformFieldLabel variant="section">
</div>
<div className="aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
{formalImageSrc ? (
<ResolvedAssetImage
src={formalImageSrc}
refreshKey={imageRefreshKey}
alt={primaryLevel?.levelName || editState.workTitle}
className="h-full w-full object-cover"
/>
) : null}
</div>
</PlatformFieldLabel>
<PlatformMediaFrame
src={formalImageSrc}
refreshKey={imageRefreshKey}
alt={primaryLevel?.levelName || editState.workTitle}
fallbackLabel="封面关卡"
fallbackContent={<span className="sr-only"></span>}
aspect="square"
surface="soft"
className="rounded-[1.15rem]"
/>
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{editState.workTitle}
</div>
@@ -1119,23 +969,17 @@ function PuzzlePublishDialog({
</div>
<div className="flex flex-col-reverse gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
className="platform-button platform-button--ghost"
>
<PlatformActionButton onClick={onClose} tone="ghost">
</button>
<button
type="button"
</PlatformActionButton>
<PlatformActionButton
onClick={onPublish}
disabled={!publishReady || isBusy}
className={`platform-button platform-button--primary ${!publishReady || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isBusy
? '发布中...'
: `发布到广场 · ${PUZZLE_PUBLISH_POINT_COST}泥点`}
</button>
</PlatformActionButton>
</div>
</div>
</div>,
@@ -1173,16 +1017,29 @@ function PuzzleCreativeDraftEditBar({
};
return (
<section className="platform-subpanel mb-3 rounded-[1.35rem] p-3 sm:p-4">
<PlatformSubpanel
as="section"
radius="lg"
padding="sm"
className="mb-3 sm:p-4"
>
<div className="flex items-end gap-2">
<span className="mb-1 hidden h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/72 text-[var(--platform-text-base)] sm:inline-flex">
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquareText className="h-4 w-4" />
)}
<span className="mb-1 hidden sm:inline-flex">
<PlatformIconBadge
tone="soft"
size="sm"
className="bg-white/72 text-[var(--platform-text-base)] shadow-none"
icon={
isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquareText className="h-4 w-4" />
)
}
/>
</span>
<textarea
<PlatformTextField
variant="textarea"
value={instruction}
disabled={isBusy}
rows={2}
@@ -1193,25 +1050,33 @@ function PuzzleCreativeDraftEditBar({
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"
size="xs"
density="roomy"
className="min-h-11 flex-1"
placeholder="让 Agent 调整标题、标签或关卡描述"
aria-label="智能修订拼图草稿"
/>
<button
type="button"
<PlatformActionButton
disabled={!canSubmit}
onClick={submit}
className="platform-button platform-button--secondary min-h-11 px-4 py-2 text-sm"
tone="secondary"
size="xs"
className="min-h-11"
>
{isBusy ? '修改中' : '修改'}
</button>
</PlatformActionButton>
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-[1rem] text-sm leading-6">
<PlatformStatusMessage
tone="error"
surface="platform"
size="md"
className="mt-3 rounded-[1rem]"
>
{error}
</div>
</PlatformStatusMessage>
) : null}
</section>
</PlatformSubpanel>
);
}
@@ -1249,44 +1114,55 @@ function PuzzleLevelListTab({
generationNowMs,
);
return (
<div
<PlatformSubpanel
as="div"
key={level.levelId}
className="platform-subpanel overflow-hidden rounded-[1.35rem] p-0"
radius="lg"
padding="none"
className="overflow-hidden"
>
<button
type="button"
onClick={() => onOpenLevel(level.levelId)}
className="block w-full text-left"
>
<div className="relative aspect-[4/3] overflow-hidden bg-[var(--platform-subpanel-fill)]">
{imageSrc ? (
<ResolvedAssetImage
src={imageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={displayLevelName}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
{generationProgress.isGenerating ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/68 backdrop-blur-[2px]">
<div className="flex items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-1.5 text-xs font-black text-[var(--platform-text-strong)] shadow-sm">
<Loader2 className="h-3.5 w-3.5 animate-spin text-amber-600" />
<PlatformMediaFrame
src={imageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={displayLevelName}
fallbackLabel="暂无正式图"
aspect="standard"
surface="none"
className="rounded-none"
fallbackClassName="tracking-normal text-[var(--platform-text-soft)]"
previewOverlay={
generationProgress.isGenerating ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/68 backdrop-blur-[2px]">
<PlatformPillBadge
tone="neutral"
size="sm"
icon={
<Loader2 className="h-3.5 w-3.5 animate-spin text-amber-600" />
}
className="gap-2 border-white/80 bg-white/94 py-1.5 text-[var(--platform-text-strong)] shadow-sm"
>
</PlatformPillBadge>
</div>
</div>
) : null}
</div>
) : null
}
/>
<div className="space-y-1 px-4 py-4">
<div className="flex items-center justify-between gap-2 text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
<span>{index + 1}</span>
{generationProgress.isGenerating ? (
<span className="rounded-full bg-amber-100 px-2 py-0.5 tracking-normal text-amber-700">
<PlatformPillBadge
tone="warning"
size="xs"
className="border-transparent bg-amber-100 tracking-normal text-amber-700"
>
</span>
</PlatformPillBadge>
) : null}
</div>
</div>
@@ -1299,27 +1175,25 @@ function PuzzleLevelListTab({
>
{displayLevelName}
</button>
<button
type="button"
<PlatformIconButton
disabled={isBusy || editState.levels.length <= 1}
onClick={() => onDeleteLevel(level.levelId)}
className="platform-icon-button h-9 w-9 shrink-0"
aria-label={`删除关卡 ${displayLevelName}`}
label={`删除关卡 ${displayLevelName}`}
title="删除关卡"
>
<Trash2 className="h-4 w-4" />
</button>
icon={<Trash2 className="h-4 w-4" />}
className="h-9 w-9 shrink-0"
/>
</div>
</div>
</PlatformSubpanel>
);
})}
</div>
<button
type="button"
<PlatformActionButton
disabled={isBusy}
onClick={onAddLevel}
className="platform-button platform-button--secondary w-full"
tone="secondary"
fullWidth
>
<span className="inline-flex items-center gap-2">
<Plus className="h-4 w-4" />
@@ -1328,7 +1202,7 @@ function PuzzleLevelListTab({
<span className="mt-1 block text-[11px] font-semibold leading-none text-[var(--platform-text-soft)]">
</span>
</button>
</PlatformActionButton>
</div>
);
}
@@ -1348,36 +1222,34 @@ function PuzzleWorkInfoTab({
}) {
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<input
<PlatformSubpanel title="作品名称" radius="lg" padding="lg">
<PlatformTextField
value={editState.workTitle}
disabled={isBusy}
onChange={(event) =>
onChange({ ...editState, workTitle: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
size="lg"
className="mt-2"
aria-label="作品名称"
/>
</section>
</PlatformSubpanel>
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<textarea
<PlatformSubpanel title="作品描述" radius="lg" padding="lg">
<PlatformTextField
variant="textarea"
value={editState.workDescription}
disabled={isBusy}
rows={6}
onChange={(event) =>
onChange({ ...editState, workDescription: event.target.value })
}
className="mt-3 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
size="md"
density="roomy"
className="mt-3"
aria-label="作品描述"
/>
</section>
</PlatformSubpanel>
<PuzzleThemeTagEditor
editState={editState}
@@ -1422,32 +1294,29 @@ function PuzzleResultActionBar({
return (
<div className="mt-4 flex items-center justify-end gap-3 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
{onStartTestRun ? (
<button
type="button"
<PlatformActionButton
onClick={onStartTestRun}
disabled={isBusy || !canStartTestRun}
className={`platform-button platform-button--ghost ${isBusy || !canStartTestRun ? 'cursor-not-allowed opacity-55' : ''}`}
tone="ghost"
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
</span>
</button>
</PlatformActionButton>
) : null}
<button
type="button"
<PlatformActionButton
onClick={() => {
setHasAttemptedPublish(false);
setShowPublishDialog(true);
}}
disabled={isBusy}
className={`platform-button platform-button--primary ${isBusy ? 'opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
</span>
</button>
</PlatformActionButton>
{showPublishDialog ? (
<PuzzlePublishDialog
@@ -1527,11 +1396,10 @@ export function PuzzleResultView({
const nextRuntimes: Record<string, PuzzleLevelGenerationRuntime> = {};
mergedState.levels.forEach((level) => {
if (level.generationStatus === 'generating') {
nextRuntimes[level.levelId] =
current[level.levelId] ?? {
startedAtMs: Date.now(),
estimateSeconds: resolvePuzzleLevelGenerationEstimateSeconds(),
};
nextRuntimes[level.levelId] = current[level.levelId] ?? {
startedAtMs: Date.now(),
estimateSeconds: resolvePuzzleLevelGenerationEstimateSeconds(),
};
}
});
return nextRuntimes;
@@ -1625,8 +1493,7 @@ export function PuzzleResultView({
uiSpritesheetImageSrc: level.uiSpritesheetImageSrc?.trim() || null,
uiSpritesheetImageObjectKey:
level.uiSpritesheetImageObjectKey?.trim() || null,
levelBackgroundImageSrc:
level.levelBackgroundImageSrc?.trim() || null,
levelBackgroundImageSrc: level.levelBackgroundImageSrc?.trim() || null,
levelBackgroundImageObjectKey:
level.levelBackgroundImageObjectKey?.trim() || null,
generationStatus: level.generationStatus || 'idle',
@@ -1695,9 +1562,14 @@ export function PuzzleResultView({
if (!draft || !editState || !syncedDraft) {
return (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
<PlatformSubpanel
as="div"
radius="sm"
padding="lg"
className="text-sm text-[var(--platform-text-base)]"
>
稿
</div>
</PlatformSubpanel>
</div>
);
}
@@ -1822,9 +1694,14 @@ export function PuzzleResultView({
</div>
{autoSaveError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
<PlatformStatusMessage
tone="error"
surface="platform"
size="md"
className="mt-3"
>
{autoSaveError}
</div>
</PlatformStatusMessage>
) : null}
<PuzzleResultActionBar
@@ -1836,9 +1713,7 @@ export function PuzzleResultView({
publishReady={publishState.publishReady}
publishBlockers={publishState.blockers}
onStartTestRun={
onStartTestRun
? () => onStartTestRun(syncedDraft)
: undefined
onStartTestRun ? () => onStartTestRun(syncedDraft) : undefined
}
onPublish={() => {
if (!publishState.publishReady) {