This commit is contained in:
2026-05-01 00:33:39 +08:00
parent 61969c5116
commit fe02603ba1
68 changed files with 4586 additions and 748 deletions

View File

@@ -239,9 +239,30 @@ describe('PuzzleResultView', () => {
promptText: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: undefined,
candidateCount: 1,
levelsJson: expect.any(String),
});
const generatePayload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '暖灯猫街',
pictureDescription: '一只猫在雨夜灯牌下回头。',
}),
]);
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
const levelNameInput = within(dialog).getByLabelText('关卡名称');
const formalImageTitle = within(dialog).getByText('画面图');
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
expect(
levelNameInput.compareDocumentPosition(formalImageTitle) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
formalImageTitle.compareDocumentPosition(pictureDescriptionInput) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '暖灯猫街',
@@ -272,7 +293,10 @@ describe('PuzzleResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(dialog).getByRole('button', { name: //u })).toBeTruthy();
expect(within(dialog).queryByText('画面图')).toBeNull();
expect(within(dialog).queryByRole('button', { name: //u })).toBeNull();
fireEvent.click(screen.getByLabelText('关闭'));
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
@@ -285,7 +309,7 @@ describe('PuzzleResultView', () => {
expect.objectContaining({
levels: expect.arrayContaining([
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({ levelName: '第2关' }),
expect.objectContaining({ levelName: '' }),
]),
}),
);
@@ -309,6 +333,45 @@ describe('PuzzleResultView', () => {
);
});
test('generates image for a newly added level with the current levels snapshot', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '新关卡里有一座发光钟楼。' },
});
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1775000000000-2',
promptText: '新关卡里有一座发光钟楼。',
referenceImageSrc: undefined,
candidateCount: 1,
levelsJson: expect.any(String),
});
const payload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({
levelId: 'puzzle-level-1775000000000-2',
levelName: '',
pictureDescription: '新关卡里有一座发光钟楼。',
}),
]);
});
test('publishes with work info and serialized levels', () => {
const onExecuteAction = vi.fn();
@@ -394,6 +457,7 @@ describe('PuzzleResultView', () => {
promptText: '屋檐下的猫与暖灯街角。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
candidateCount: 1,
levelsJson: expect.any(String),
});
});
});

View File

@@ -16,7 +16,6 @@ import { createPortal } from 'react-dom';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleDraftLevel,
PuzzleGeneratedImageCandidate,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
@@ -84,7 +83,7 @@ function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) {
function buildFallbackLevelFromDraft(draft: PuzzleResultDraft): PuzzleDraftLevel {
return {
levelId: 'puzzle-level-1',
levelName: draft.levelName || draft.workTitle || '第一关',
levelName: draft.levelName || '',
pictureDescription: draft.summary,
candidates: draft.candidates,
selectedCandidateId: draft.selectedCandidateId,
@@ -103,7 +102,7 @@ function normalizeDraftLevels(draft: PuzzleResultDraft) {
return sourceLevels.map((level, index) => ({
...level,
levelId: level.levelId?.trim() || `puzzle-level-${index + 1}`,
levelName: level.levelName?.trim() || `${index + 1}`,
levelName: level.levelName?.trim() || '',
pictureDescription: level.pictureDescription?.trim() || draft.summary,
candidates: level.candidates ?? [],
selectedCandidateId: level.selectedCandidateId ?? null,
@@ -148,7 +147,7 @@ function createBlankPuzzleLevel(existingLevels: PuzzleDraftLevel[]): PuzzleDraft
const nextIndex = existingLevels.length + 1;
return {
levelId: `puzzle-level-${Date.now()}-${nextIndex}`,
levelName: `${nextIndex}`,
levelName: '',
pictureDescription: '',
candidates: [],
selectedCandidateId: null,
@@ -585,6 +584,7 @@ function PuzzleLevelDetailDialog({
const [referenceImageError, setReferenceImageError] = useState<string | null>(null);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const formalImageSrc = resolveLevelFormalImageSrc(level);
const hasFormalImage = Boolean(formalImageSrc);
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
@@ -626,7 +626,7 @@ function PuzzleLevelDetailDialog({
role="dialog"
aria-modal="true"
aria-label="关卡详情"
className="platform-modal-shell platform-remap-surface flex max-h-[min(94vh,50rem)] w-full max-w-5xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
className="platform-modal-shell platform-remap-surface flex max-h-[min(94vh,50rem)] w-full max-w-2xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
@@ -644,167 +644,159 @@ function PuzzleLevelDetailDialog({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="space-y-4">
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<input
value={level.levelName}
disabled={isBusy}
onChange={(event) =>
onLevelChange({ ...level, levelName: event.target.value })
}
className="mt-2 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"
aria-label="关卡名称"
/>
</section>
<div className="space-y-4">
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<input
value={level.levelName}
disabled={isBusy}
onChange={(event) =>
onLevelChange({ ...level, levelName: event.target.value })
}
className="mt-2 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"
aria-label="关卡名称"
/>
</section>
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="relative mt-3">
<textarea
value={level.pictureDescription}
disabled={isBusy}
rows={9}
onChange={(event) =>
onLevelChange({
...level,
pictureDescription: event.target.value,
})
}
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<div className="absolute bottom-3 right-3 flex items-center gap-2">
<label
className={`inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={referenceImageSrc ? '更换参考图' : '添加参考图'}
>
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{referenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
<button
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label="从历史拼图素材库选择"
title="从历史拼图素材库选择"
>
<Images className="h-4 w-4" />
</button>
</div>
</div>
{referenceImageSrc ? (
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
<img
src={referenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{referenceImageLabel || '已选择参考图'}
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
setReferenceImageSrc('');
setReferenceImageLabel('');
setReferenceImageError(null);
}}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
>
<X className="h-4 w-4" />
</button>
</div>
) : null}
{referenceImageError ? (
<div className="mt-2 text-xs leading-5 text-red-600">
{referenceImageError}
</div>
) : null}
</section>
</div>
<div className="space-y-4">
{hasFormalImage ? (
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
{formalImageSrc ? (
<ResolvedAssetImage
src={formalImageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={level.levelName || draft.workTitle || '拼图关卡'}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
<div className="relative mt-3 aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={formalImageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={level.levelName || draft.workTitle || '拼图关卡'}
className="h-full w-full object-cover"
/>
<button
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label="从历史拼图素材库选择"
title="从历史拼图素材库选择"
>
<Images className="h-4 w-4" />
</button>
</div>
</section>
) : null}
<button
type="button"
disabled={isBusy}
onClick={() => {
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
referenceImageSrc || undefined,
);
}}
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
</button>
{onStartTestRun ? (
<button
type="button"
disabled={isBusy || !formalImageSrc}
onClick={() => onStartTestRun(level)}
className={`platform-button platform-button--secondary w-full ${isBusy || !formalImageSrc ? 'opacity-55' : ''}`}
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="relative mt-3">
<textarea
value={level.pictureDescription}
disabled={isBusy}
rows={9}
onChange={(event) =>
onLevelChange({
...level,
pictureDescription: event.target.value,
})
}
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<label
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={referenceImageSrc ? '更换参考图' : '添加参考图'}
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{referenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
</button>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
</div>
{referenceImageSrc ? (
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
<img
src={referenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{referenceImageLabel || '已选择参考图'}
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
setReferenceImageSrc('');
setReferenceImageLabel('');
setReferenceImageError(null);
}}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
>
<X className="h-4 w-4" />
</button>
</div>
) : null}
</div>
{referenceImageError ? (
<div className="mt-2 text-xs leading-5 text-red-600">
{referenceImageError}
</div>
) : null}
</section>
</div>
</div>
<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"
disabled={isBusy}
onClick={() => onStartTestRun(level)}
className={`platform-button platform-button--secondary w-full ${isBusy ? 'opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
</span>
</button>
) : null}
<button
type="button"
disabled={isBusy}
onClick={() => {
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
referenceImageSrc || undefined,
);
}}
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
{hasFormalImage ? '重新生成画面' : '生成画面'}
</button>
</div>
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
isBusy={isBusy}
@@ -968,6 +960,7 @@ function PuzzleLevelListTab({
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{editState.levels.map((level, index) => {
const imageSrc = resolveLevelFormalImageSrc(level);
const displayLevelName = level.levelName || `${index + 1}`;
return (
<div
key={level.levelId}
@@ -983,7 +976,7 @@ function PuzzleLevelListTab({
<ResolvedAssetImage
src={imageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={level.levelName}
alt={displayLevelName}
className="h-full w-full object-cover"
/>
) : (
@@ -996,18 +989,22 @@ function PuzzleLevelListTab({
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
{index + 1}
</div>
<div className="truncate text-base font-black text-[var(--platform-text-strong)]">
{level.levelName || `${index + 1}`}
</div>
</div>
</button>
<div className="flex justify-end border-t border-[var(--platform-subpanel-border)] px-3 py-2">
<div className="flex items-end gap-2 px-4 pb-4">
<button
type="button"
onClick={() => onOpenLevel(level.levelId)}
className="min-w-0 flex-1 truncate text-left text-base font-black text-[var(--platform-text-strong)]"
>
{displayLevelName}
</button>
<button
type="button"
disabled={isBusy || editState.levels.length <= 1}
onClick={() => onDeleteLevel(level.levelId)}
className="platform-icon-button h-9 w-9"
aria-label={`删除关卡 ${level.levelName || index + 1}`}
className="platform-icon-button h-9 w-9 shrink-0"
aria-label={`删除关卡 ${displayLevelName}`}
title="删除关卡"
>
<Trash2 className="h-4 w-4" />
@@ -1393,6 +1390,7 @@ export function PuzzleResultView({
promptText,
referenceImageSrc,
candidateCount: 1,
levelsJson: JSON.stringify(editState.levels),
});
}}
onLevelChange={updateLevel}