Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
@@ -60,9 +60,25 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function stubReferenceImageUpload(dataUrl: string) {
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
readAsDataURL() {
|
||||
this.result = dataUrl;
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
}
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
@@ -102,6 +118,17 @@ function createSession(
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
pictureReference: null,
|
||||
uiBackgroundPrompt: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey: null,
|
||||
levelSceneImageSrc: null,
|
||||
levelSceneImageObjectKey: null,
|
||||
uiSpritesheetImageSrc: null,
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
levelBackgroundImageSrc: null,
|
||||
levelBackgroundImageObjectKey: null,
|
||||
backgroundMusic: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
@@ -170,7 +197,7 @@ function openPuzzleLevelsTab() {
|
||||
}
|
||||
|
||||
describe('PuzzleResultView', () => {
|
||||
test('renders level list and work info tabs', () => {
|
||||
test('renders level list and work info tabs without asset config tab', () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
@@ -182,18 +209,13 @@ describe('PuzzleResultView', () => {
|
||||
|
||||
const workInfoTab = screen.getByRole('button', { name: '作品信息' });
|
||||
const levelsTab = screen.getByRole('button', { name: '拼图关卡' });
|
||||
const assetsTab = screen.getByRole('button', { name: '素材配置' });
|
||||
expect(workInfoTab).toBeTruthy();
|
||||
expect(levelsTab).toBeTruthy();
|
||||
expect(assetsTab).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
|
||||
expect(
|
||||
workInfoTab.compareDocumentPosition(levelsTab) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
levelsTab.compareDocumentPosition(assetsTab) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
|
||||
expect(screen.getByLabelText('作品名称')).toHaveProperty(
|
||||
'value',
|
||||
@@ -236,6 +258,62 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('level detail trial keeps the complete draft and only selects the target level', () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const base = createSession();
|
||||
const firstLevel = base.draft!.levels![0]!;
|
||||
const secondLevel = {
|
||||
...firstLevel,
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '钟楼猫街',
|
||||
pictureDescription: '发光钟楼下的猫咪。',
|
||||
candidates: [
|
||||
{
|
||||
...firstLevel.candidates[0]!,
|
||||
candidateId: 'candidate-2',
|
||||
imageSrc: '/puzzle/candidate-2.png',
|
||||
assetId: 'asset-2',
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-2',
|
||||
coverImageSrc: '/puzzle/candidate-2.png',
|
||||
coverAssetId: 'asset-2',
|
||||
};
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [firstLevel, secondLevel],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('钟楼猫街'));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '关卡详情' })).getByRole(
|
||||
'button',
|
||||
{ name: '关卡测试' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levels: [
|
||||
expect.objectContaining({ levelId: 'puzzle-level-1' }),
|
||||
expect.objectContaining({ levelId: 'puzzle-level-2' }),
|
||||
],
|
||||
}),
|
||||
{ levelId: 'puzzle-level-2' },
|
||||
);
|
||||
});
|
||||
|
||||
test('auto saves work info and levels through one payload', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
@@ -294,6 +372,8 @@ describe('PuzzleResultView', () => {
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
expect(dialog.className).toContain('max-w-[56rem]');
|
||||
expect(dialog.querySelector('.puzzle-level-detail-list')).toBeTruthy();
|
||||
fireEvent.change(within(dialog).getByLabelText('关卡名称'), {
|
||||
target: { value: '暖灯猫街' },
|
||||
});
|
||||
@@ -316,6 +396,7 @@ describe('PuzzleResultView', () => {
|
||||
referenceImageSrc: undefined,
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
referenceImageSrcs: [],
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: false,
|
||||
workTitle: '暖灯猫街作品',
|
||||
@@ -334,7 +415,7 @@ describe('PuzzleResultView', () => {
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
]);
|
||||
expect(within(dialog).getByText('预计剩余 90 秒')).toBeTruthy();
|
||||
expect(within(dialog).getByText('预计剩余 270 秒')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).queryByPlaceholderText('参考图链接或资产ID'),
|
||||
).toBeNull();
|
||||
@@ -343,7 +424,15 @@ describe('PuzzleResultView', () => {
|
||||
|
||||
const levelNameInput = within(dialog).getByLabelText('关卡名称');
|
||||
const formalImageTitle = within(dialog).getByText('画面图');
|
||||
const formalImageCard = formalImageTitle
|
||||
.closest('.creative-image-input-panel__image-field')
|
||||
?.querySelector('.puzzle-image-upload-card');
|
||||
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
|
||||
expect(levelNameInput.closest('.platform-subpanel')).toBeNull();
|
||||
expect(formalImageTitle.closest('.platform-subpanel')).toBeNull();
|
||||
expect(pictureDescriptionInput.closest('.platform-subpanel')).toBeNull();
|
||||
expect(formalImageCard).toBeTruthy();
|
||||
expect(formalImageCard?.className).toContain('min-h-[');
|
||||
expect(
|
||||
levelNameInput.compareDocumentPosition(formalImageTitle) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
@@ -365,6 +454,7 @@ describe('PuzzleResultView', () => {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
{ levelId: 'puzzle-level-1' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -463,6 +553,7 @@ describe('PuzzleResultView', () => {
|
||||
referenceImageSrc: undefined,
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
referenceImageSrcs: [],
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: true,
|
||||
workTitle: '暖灯猫街作品',
|
||||
@@ -552,7 +643,7 @@ describe('PuzzleResultView', () => {
|
||||
expect(
|
||||
within(reopenedDialog).getByRole('progressbar', { name: '画面生成进度' }),
|
||||
).toBeTruthy();
|
||||
expect(within(reopenedDialog).getByText('预计剩余 90 秒')).toBeTruthy();
|
||||
expect(within(reopenedDialog).getByText('预计剩余 270 秒')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('allows parallel draft editing while a level image is generating but blocks publish', () => {
|
||||
@@ -586,6 +677,7 @@ describe('PuzzleResultView', () => {
|
||||
expect.objectContaining({
|
||||
levelName: '继续编辑的猫街',
|
||||
}),
|
||||
{ levelId: 'puzzle-level-1' },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
@@ -602,7 +694,7 @@ describe('PuzzleResultView', () => {
|
||||
).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('keeps level controls enabled while regenerating the UI background', () => {
|
||||
test('asset config tab is removed and cannot trigger the legacy UI background action', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -614,76 +706,20 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '雨夜猫街竖屏拼图UI背景' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
promptText: '雨夜猫街竖屏拼图UI背景',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /生成中/u }),
|
||||
).toHaveProperty('disabled', true);
|
||||
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
|
||||
expect(screen.queryByLabelText('拼图UI背景提示词')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /生成UI背景/u })).toBeNull();
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
const addLevelButton = screen.getByRole('button', { name: /新增关卡/u });
|
||||
expect(addLevelButton).toHaveProperty('disabled', false);
|
||||
fireEvent.click(addLevelButton);
|
||||
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('restores UI background generate button when background generation fails', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
expect(onExecuteAction).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '雨夜猫街竖屏拼图UI背景' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /生成中/u })).toHaveProperty(
|
||||
'disabled',
|
||||
true,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
error="UI背景生成失败"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const generateButton = screen.getByRole('button', { name: /生成UI背景/u });
|
||||
expect(generateButton).toHaveProperty('disabled', false);
|
||||
expect(screen.queryByRole('button', { name: /生成中/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the current level dialog open when another level generation completes', () => {
|
||||
@@ -899,7 +935,8 @@ describe('PuzzleResultView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('renders UI background tab with saved prompt and runtime preview', () => {
|
||||
test('preserves generated level asset bundle in test run draft', () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
@@ -911,133 +948,53 @@ describe('PuzzleResultView', () => {
|
||||
levels: [
|
||||
{
|
||||
...level,
|
||||
uiBackgroundPrompt: '雨夜猫街竖屏拼图UI背景',
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui/background.png',
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene.png',
|
||||
levelSceneImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background.png',
|
||||
levelBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-background.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
);
|
||||
expect(screen.getByLabelText('拼图UI背景提示词')).toHaveProperty(
|
||||
'value',
|
||||
'雨夜猫街竖屏拼图UI背景',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览UI' }));
|
||||
const preview = screen.getByRole('dialog', { name: 'UI预览' });
|
||||
expect(
|
||||
within(preview)
|
||||
.getByTestId('puzzle-ui-runtime-preview-background')
|
||||
.getAttribute('src'),
|
||||
).toBe('/generated-puzzle-assets/session/ui/background.png');
|
||||
expect(within(preview).getByLabelText('拼图区边界')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('UI背景只有 objectKey 时草稿页仍显示生成图', () => {
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [
|
||||
{
|
||||
...level,
|
||||
uiBackgroundPrompt: '雨夜猫街竖屏拼图UI背景',
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui/background-object-key.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
|
||||
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background-object-key.png',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /重新生成/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('does not display local fallback as saved UI background prompt', () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
|
||||
expect(screen.getByLabelText('拼图UI背景提示词')).toHaveProperty(
|
||||
'value',
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('generates UI background with edited prompt and current levels snapshot', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '新拼图UI背景提示词' },
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /生成UI背景.*2泥点/u })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
const confirmDialog = screen.getByRole('dialog', {
|
||||
name: '确认消耗泥点',
|
||||
});
|
||||
expect(within(confirmDialog).getByText('消耗 2 泥点')).toBeTruthy();
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
levelId: 'puzzle-level-1',
|
||||
promptText: '新拼图UI背景提示词',
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
const payload = onExecuteAction.mock.calls[0]![0];
|
||||
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
uiBackgroundPrompt: '新拼图UI背景提示词',
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene.png',
|
||||
levelSceneImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background.png',
|
||||
levelBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-background.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
);
|
||||
});
|
||||
|
||||
test('素材配置隐藏背景音乐入口', () => {
|
||||
test('does not expose music or standalone UI asset controls', () => {
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
@@ -1068,30 +1025,39 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /重新生成音乐/u })).toBeNull();
|
||||
expect(screen.queryByLabelText('拼图背景音乐')).toBeNull();
|
||||
expect(screen.queryByLabelText('拼图UI背景提示词')).toBeNull();
|
||||
});
|
||||
|
||||
test('生成完成回包合并历史音乐和UI背景后试玩使用最新资源', () => {
|
||||
test('生成完成回包合并历史音乐和关卡资产后试玩使用最新资源', () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const base = createSession();
|
||||
const localLevel = {
|
||||
...base.draft!.levels![0]!,
|
||||
generationStatus: 'generating' as const,
|
||||
uiBackgroundPrompt: '旧的UI背景提示词',
|
||||
uiBackgroundImageSrc: null,
|
||||
levelSceneImageSrc: null,
|
||||
uiSpritesheetImageSrc: null,
|
||||
levelBackgroundImageSrc: null,
|
||||
backgroundMusic: null,
|
||||
};
|
||||
const incomingLevel = {
|
||||
...localLevel,
|
||||
generationStatus: 'ready' as const,
|
||||
uiBackgroundPrompt: '水果乐园UI背景',
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/ui/fruit-background.png',
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui/fruit-background.png',
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene-fruit.png',
|
||||
levelSceneImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-scene-fruit.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet-fruit.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui-spritesheet-fruit.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background-fruit.png',
|
||||
levelBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-background-fruit.png',
|
||||
backgroundMusic: {
|
||||
taskId: 'music-task-fruit',
|
||||
provider: 'vector-engine-suno',
|
||||
@@ -1135,20 +1101,18 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/fruit-background.png',
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/ui/fruit-background.png',
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene-fruit.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet-fruit.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background-fruit.png',
|
||||
backgroundMusic: expect.objectContaining({
|
||||
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
|
||||
}),
|
||||
@@ -1158,24 +1122,42 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('auto saves UI background prompt edits through levels', async () => {
|
||||
test('auto saves generated level asset bundle through levels', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
item: {} as never,
|
||||
});
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [
|
||||
{
|
||||
...level,
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
profileId="puzzle-profile-session-1"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '新的自动保存UI背景提示词' },
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
fireEvent.change(screen.getByLabelText('关卡名称'), {
|
||||
target: { value: '雨夜猫街新版' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -1188,7 +1170,13 @@ describe('PuzzleResultView', () => {
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
uiBackgroundPrompt: '新的自动保存UI背景提示词',
|
||||
levelName: '雨夜猫街新版',
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -1222,10 +1210,11 @@ describe('PuzzleResultView', () => {
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
const uploadInput = within(dialog).getByLabelText('上传参考图', {
|
||||
const uploadInput = within(dialog).getAllByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
expect(uploadInput.closest('.platform-subpanel')).toBeTruthy();
|
||||
})[0]!;
|
||||
expect(uploadInput.closest('.platform-subpanel')).toBeNull();
|
||||
expect(uploadInput.closest('.puzzle-level-detail-list')).toBeTruthy();
|
||||
const historyButton = within(dialog).getByRole('button', {
|
||||
name: '选择历史图片',
|
||||
});
|
||||
@@ -1245,7 +1234,9 @@ describe('PuzzleResultView', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
|
||||
});
|
||||
expect(screen.getByText('历史素材 · image.png')).toBeTruthy();
|
||||
expect(within(dialog).getByAltText('拼图参考图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/history/image.png',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
@@ -1261,6 +1252,7 @@ describe('PuzzleResultView', () => {
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
referenceImageSrcs: [],
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: false,
|
||||
workTitle: '暖灯猫街作品',
|
||||
@@ -1461,6 +1453,110 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor submits uploaded image directly when AI redraw is off', async () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const uploadedDataUrl = 'data:image/png;base64,level-upload';
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
const uploadInput = within(dialog).getAllByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
})[0]!;
|
||||
fireEvent.change(uploadInput, {
|
||||
target: {
|
||||
files: [new File(['x'], 'level-upload.png', { type: 'image/png' })],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
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(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
referenceImageSrc: uploadedDataUrl,
|
||||
referenceImageSrcs: [],
|
||||
aiRedraw: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor uploads prompt reference images from the description box', async () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const uploadedDataUrl = 'data:image/png;base64,level-reference';
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
const referenceInputs = within(dialog).getAllByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
expect(referenceInputs.length).toBeGreaterThanOrEqual(2);
|
||||
fireEvent.change(referenceInputs[referenceInputs.length - 1]!, {
|
||||
target: {
|
||||
files: [new File(['x'], 'prompt-reference.png', { type: 'image/png' })],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: /预览参考图 prompt-reference\.png/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
referenceImageSrc: undefined,
|
||||
referenceImageSrcs: [uploadedDataUrl],
|
||||
aiRedraw: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor hides AI redraw controls when only the formal image is shown', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
@@ -1480,7 +1576,7 @@ describe('PuzzleResultView', () => {
|
||||
expect(within(dialog).getByLabelText('画面描述')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('UI background generator reuses common image input UI without sharing level image fields', () => {
|
||||
test('standalone UI background generator stays removed from the result page', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -1491,40 +1587,11 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
|
||||
expect(screen.getByText('UI背景预览')).toBeTruthy();
|
||||
expect(screen.getByLabelText('UI背景提示词')).toBeTruthy();
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.queryByLabelText('上传拼图图片')).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('UI背景提示词'), {
|
||||
target: { value: '独立的草稿UI背景提示词' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
promptText: '独立的草稿UI背景提示词',
|
||||
}),
|
||||
);
|
||||
const payload = onExecuteAction.mock.calls[0]![0];
|
||||
expect(payload).not.toHaveProperty('referenceImageSrc');
|
||||
expect(payload).not.toHaveProperty('aiRedraw');
|
||||
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
uiBackgroundPrompt: '独立的草稿UI背景提示词',
|
||||
}),
|
||||
]);
|
||||
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
|
||||
expect(screen.queryByText('UI背景预览')).toBeNull();
|
||||
expect(screen.queryByLabelText('UI背景提示词')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /生成UI背景/u })).toBeNull();
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows creative agent draft edit bar and submits the current draft', () => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
LayoutTemplate,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Play,
|
||||
@@ -21,7 +19,6 @@ import type {
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import { updatePuzzleWork } from '../../services/puzzle-works';
|
||||
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
@@ -42,7 +39,10 @@ type PuzzleResultViewProps = {
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
|
||||
onStartTestRun?: (draft: PuzzleResultDraft) => void;
|
||||
onStartTestRun?: (
|
||||
draft: PuzzleResultDraft,
|
||||
options?: { levelId?: string | null },
|
||||
) => void;
|
||||
creativeDraftEdit?: {
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
@@ -54,8 +54,7 @@ type PuzzleResultViewProps = {
|
||||
};
|
||||
|
||||
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type PuzzleResultTab = 'levels' | 'work' | 'assets';
|
||||
type PuzzleAssetConfigTabId = 'ui';
|
||||
type PuzzleResultTab = 'levels' | 'work';
|
||||
|
||||
type DraftEditState = {
|
||||
workTitle: string;
|
||||
@@ -70,20 +69,15 @@ const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
|
||||
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
|
||||
const PUZZLE_PUBLISH_POINT_COST = 1;
|
||||
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
|
||||
const PUZZLE_UI_BACKGROUND_REFERENCE_SRC =
|
||||
'/ui-previews/puzzle-image-compact-ui-2026-05-08.png';
|
||||
const PUZZLE_LEVEL_ASSET_BUNDLE_ESTIMATE_SECONDS =
|
||||
PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS * 3;
|
||||
const PUZZLE_LEVEL_DIRECT_UPLOAD_ESTIMATE_SECONDS =
|
||||
PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS * 2;
|
||||
const PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT = 5;
|
||||
|
||||
const PUZZLE_RESULT_TABS: Array<{ id: PuzzleResultTab; label: string }> = [
|
||||
{ id: 'work', label: '作品信息' },
|
||||
{ id: 'levels', label: '拼图关卡' },
|
||||
{ id: 'assets', label: '素材配置' },
|
||||
];
|
||||
|
||||
const PUZZLE_ASSET_CONFIG_TABS: Array<{
|
||||
id: PuzzleAssetConfigTabId;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'ui', label: 'UI' },
|
||||
];
|
||||
|
||||
type PuzzleLevelGenerationRuntime = {
|
||||
@@ -91,10 +85,13 @@ type PuzzleLevelGenerationRuntime = {
|
||||
estimateSeconds: number;
|
||||
};
|
||||
|
||||
type PuzzleUiBackgroundGenerationState = {
|
||||
levelId: string;
|
||||
prompt: string;
|
||||
} | null;
|
||||
function resolvePuzzleLevelGenerationEstimateSeconds(options?: {
|
||||
aiRedraw?: boolean | null;
|
||||
}) {
|
||||
return options?.aiRedraw === false
|
||||
? PUZZLE_LEVEL_DIRECT_UPLOAD_ESTIMATE_SECONDS
|
||||
: PUZZLE_LEVEL_ASSET_BUNDLE_ESTIMATE_SECONDS;
|
||||
}
|
||||
|
||||
function resolvePuzzleLevelGenerationProgress(
|
||||
level: PuzzleDraftLevel,
|
||||
@@ -141,26 +138,6 @@ function normalizeThemeTagInput(value: string) {
|
||||
];
|
||||
}
|
||||
|
||||
function buildDefaultPuzzleUiBackgroundPrompt(
|
||||
editState: DraftEditState,
|
||||
level: PuzzleDraftLevel | null,
|
||||
) {
|
||||
const tags = editState.themeTags
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
return [
|
||||
editState.workTitle.trim(),
|
||||
editState.workDescription.trim(),
|
||||
level?.levelName.trim(),
|
||||
level?.pictureDescription.trim(),
|
||||
tags,
|
||||
'移动端拼图游戏 UI 背景,中央正方形拼图区边界清晰,拼图区外氛围与作品名称一致',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('。');
|
||||
}
|
||||
|
||||
function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) {
|
||||
const selectedCandidate =
|
||||
level.candidates.find(
|
||||
@@ -208,6 +185,12 @@ function normalizeDraftLevels(draft: PuzzleResultDraft) {
|
||||
uiBackgroundPrompt: level.uiBackgroundPrompt ?? null,
|
||||
uiBackgroundImageSrc: level.uiBackgroundImageSrc ?? null,
|
||||
uiBackgroundImageObjectKey: level.uiBackgroundImageObjectKey ?? null,
|
||||
levelSceneImageSrc: level.levelSceneImageSrc ?? null,
|
||||
levelSceneImageObjectKey: level.levelSceneImageObjectKey ?? null,
|
||||
uiSpritesheetImageSrc: level.uiSpritesheetImageSrc ?? null,
|
||||
uiSpritesheetImageObjectKey: level.uiSpritesheetImageObjectKey ?? null,
|
||||
levelBackgroundImageSrc: level.levelBackgroundImageSrc ?? null,
|
||||
levelBackgroundImageObjectKey: level.levelBackgroundImageObjectKey ?? null,
|
||||
candidates: level.candidates ?? [],
|
||||
selectedCandidateId: level.selectedCandidateId ?? null,
|
||||
coverImageSrc: level.coverImageSrc ?? null,
|
||||
@@ -296,6 +279,21 @@ function mergeDraftEditStateWithIncomingState(
|
||||
uiBackgroundImageObjectKey:
|
||||
incomingLevel.uiBackgroundImageObjectKey ??
|
||||
level.uiBackgroundImageObjectKey,
|
||||
levelSceneImageSrc:
|
||||
incomingLevel.levelSceneImageSrc ?? level.levelSceneImageSrc,
|
||||
levelSceneImageObjectKey:
|
||||
incomingLevel.levelSceneImageObjectKey ??
|
||||
level.levelSceneImageObjectKey,
|
||||
uiSpritesheetImageSrc:
|
||||
incomingLevel.uiSpritesheetImageSrc ?? level.uiSpritesheetImageSrc,
|
||||
uiSpritesheetImageObjectKey:
|
||||
incomingLevel.uiSpritesheetImageObjectKey ??
|
||||
level.uiSpritesheetImageObjectKey,
|
||||
levelBackgroundImageSrc:
|
||||
incomingLevel.levelBackgroundImageSrc ?? level.levelBackgroundImageSrc,
|
||||
levelBackgroundImageObjectKey:
|
||||
incomingLevel.levelBackgroundImageObjectKey ??
|
||||
level.levelBackgroundImageObjectKey,
|
||||
backgroundMusic: incomingLevel.backgroundMusic ?? level.backgroundMusic,
|
||||
generationStatus: incomingLevel.generationStatus || 'ready',
|
||||
};
|
||||
@@ -323,6 +321,12 @@ function createBlankPuzzleLevel(
|
||||
uiBackgroundPrompt: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey: null,
|
||||
levelSceneImageSrc: null,
|
||||
levelSceneImageObjectKey: null,
|
||||
uiSpritesheetImageSrc: null,
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
levelBackgroundImageSrc: null,
|
||||
levelBackgroundImageObjectKey: null,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
@@ -431,7 +435,7 @@ function PuzzleResultTabs({
|
||||
onChange: (tab: PuzzleResultTab) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
|
||||
<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}
|
||||
@@ -451,34 +455,6 @@ function PuzzleResultTabs({
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleAssetConfigTabs({
|
||||
activeTab,
|
||||
onChange,
|
||||
}: {
|
||||
activeTab: PuzzleAssetConfigTabId;
|
||||
onChange: (tab: PuzzleAssetConfigTabId) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
|
||||
{PUZZLE_ASSET_CONFIG_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={`min-h-10 rounded-[0.9rem] 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>
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleThemeTagEditor({
|
||||
editState,
|
||||
isBusy,
|
||||
@@ -656,11 +632,13 @@ function PuzzleLevelDetailDialog({
|
||||
level: PuzzleDraftLevel,
|
||||
promptText?: string | null,
|
||||
referenceImageSrc?: string | null,
|
||||
referenceImageSrcs?: string[],
|
||||
imageModel?: PuzzleImageModelId | null,
|
||||
aiRedraw?: boolean | null,
|
||||
estimateSeconds?: number,
|
||||
) => void;
|
||||
onLevelChange: (nextLevel: PuzzleDraftLevel) => void;
|
||||
onStartTestRun?: (level: PuzzleDraftLevel) => void;
|
||||
onStartTestRun?: (levelId: string) => void;
|
||||
}) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const [referenceImageSrc, setReferenceImageSrc] = useState('');
|
||||
@@ -668,6 +646,9 @@ function PuzzleLevelDetailDialog({
|
||||
const [referenceImageError, setReferenceImageError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [promptReferenceImages, setPromptReferenceImages] = useState<
|
||||
Array<{ id: string; label: string; imageSrc: string }>
|
||||
>([]);
|
||||
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
|
||||
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
|
||||
const [imageModel, setImageModel] = useState<PuzzleImageModelId>(
|
||||
@@ -676,12 +657,23 @@ function PuzzleLevelDetailDialog({
|
||||
const [aiRedraw, setAiRedraw] = useState(true);
|
||||
const formalImageSrc = resolveLevelFormalImageSrc(level);
|
||||
const hasFormalImage = Boolean(formalImageSrc);
|
||||
const explicitReferenceImageSrc = referenceImageSrc.trim();
|
||||
const savedReferenceImageSrc = level.pictureReference?.trim() || '';
|
||||
const effectiveReferenceImageSrc =
|
||||
referenceImageSrc.trim() || level.pictureReference?.trim() || '';
|
||||
const displayImageSrc = formalImageSrc || effectiveReferenceImageSrc;
|
||||
const displayImageAlt = formalImageSrc
|
||||
? level.levelName || draft.workTitle || '拼图关卡'
|
||||
: '拼图参考图';
|
||||
explicitReferenceImageSrc || savedReferenceImageSrc;
|
||||
const promptReferenceImageSrcs = effectiveReferenceImageSrc
|
||||
? []
|
||||
: promptReferenceImages.map((image) => image.imageSrc);
|
||||
const displayImageSrc =
|
||||
explicitReferenceImageSrc || formalImageSrc || savedReferenceImageSrc;
|
||||
const displayImageAlt = explicitReferenceImageSrc
|
||||
? '拼图参考图'
|
||||
: formalImageSrc
|
||||
? level.levelName || draft.workTitle || '拼图关卡'
|
||||
: '拼图参考图';
|
||||
const shouldShowReferenceMeta = Boolean(
|
||||
effectiveReferenceImageSrc && displayImageSrc !== effectiveReferenceImageSrc,
|
||||
);
|
||||
const generationProgress = resolvePuzzleLevelGenerationProgress(
|
||||
level,
|
||||
generationRuntime,
|
||||
@@ -706,6 +698,7 @@ function PuzzleLevelDetailDialog({
|
||||
const executeGeneration = () => {
|
||||
const nextLevel = {
|
||||
...level,
|
||||
pictureReference: effectiveReferenceImageSrc || null,
|
||||
generationStatus: 'generating' as const,
|
||||
};
|
||||
setIsCostConfirmOpen(false);
|
||||
@@ -713,11 +706,46 @@ function PuzzleLevelDetailDialog({
|
||||
nextLevel,
|
||||
nextLevel.pictureDescription.trim() || undefined,
|
||||
effectiveReferenceImageSrc || undefined,
|
||||
promptReferenceImageSrcs,
|
||||
imageModel,
|
||||
aiRedraw,
|
||||
resolvePuzzleLevelGenerationEstimateSeconds({ aiRedraw }),
|
||||
);
|
||||
};
|
||||
|
||||
const handlePromptReferenceImageFiles = async (files: File[]) => {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingSlots =
|
||||
PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT - promptReferenceImages.length;
|
||||
if (remainingSlots <= 0) {
|
||||
setReferenceImageError('参考图最多上传 5 张。');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const images = await Promise.all(
|
||||
files.slice(0, remainingSlots).map(async (file, index) => ({
|
||||
id: `level-prompt-upload:${Date.now()}:${index}:${file.name}`,
|
||||
label: file.name.trim() || `参考图 ${index + 1}`,
|
||||
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
|
||||
})),
|
||||
);
|
||||
setPromptReferenceImages((current) => [...current, ...images].slice(0, 5));
|
||||
setReferenceImageError(
|
||||
files.length > remainingSlots ? '参考图最多上传 5 张。' : null,
|
||||
);
|
||||
} catch (uploadError) {
|
||||
setReferenceImageError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '参考图读取失败,请重试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
@@ -735,7 +763,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-2xl 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-[56rem] 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">
|
||||
@@ -753,24 +781,29 @@ function PuzzleLevelDetailDialog({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<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 className="puzzle-level-detail-list divide-y divide-[var(--platform-subpanel-border)]">
|
||||
<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)]"
|
||||
>
|
||||
关卡名称
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
id={`puzzle-level-name-${level.levelId}`}
|
||||
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"
|
||||
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"
|
||||
aria-label="关卡名称"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<section className="pt-4">
|
||||
<CreativeImageInputPanel
|
||||
className="puzzle-level-detail-image-editor"
|
||||
disabled={isBusy || generationProgress.isGenerating}
|
||||
isSubmitting={generationProgress.isGenerating}
|
||||
uploadedImageSrc={displayImageSrc}
|
||||
@@ -778,8 +811,9 @@ function PuzzleLevelDetailDialog({
|
||||
uploadedImageRefreshKey={`${imageRefreshKey}:${level.levelId}`}
|
||||
canRemoveMainImage={Boolean(effectiveReferenceImageSrc)}
|
||||
canToggleAiRedraw={Boolean(effectiveReferenceImageSrc)}
|
||||
canUploadPromptReferences={!effectiveReferenceImageSrc}
|
||||
mainImageMeta={
|
||||
effectiveReferenceImageSrc ? (
|
||||
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
|
||||
@@ -804,7 +838,8 @@ function PuzzleLevelDetailDialog({
|
||||
}
|
||||
promptRows={7}
|
||||
aiRedraw={aiRedraw}
|
||||
promptReferenceImages={[]}
|
||||
promptReferenceImages={promptReferenceImages}
|
||||
promptReferenceLimit={PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT}
|
||||
imageModelPicker={
|
||||
<PuzzleImageModelPicker
|
||||
value={imageModel}
|
||||
@@ -850,6 +885,15 @@ function PuzzleLevelDetailDialog({
|
||||
pictureDescription: value,
|
||||
})
|
||||
}
|
||||
onPromptReferenceFilesSelect={(files) => {
|
||||
void handlePromptReferenceImageFiles(files);
|
||||
}}
|
||||
onPromptReferenceRemove={(referenceId) => {
|
||||
setPromptReferenceImages((current) =>
|
||||
current.filter((image) => image.id !== referenceId),
|
||||
);
|
||||
setReferenceImageError(null);
|
||||
}}
|
||||
onHistoryClick={() => setIsHistoryPickerOpen(true)}
|
||||
onSubmit={() => setIsCostConfirmOpen(true)}
|
||||
/>
|
||||
@@ -862,7 +906,7 @@ function PuzzleLevelDetailDialog({
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => onStartTestRun(level)}
|
||||
onClick={() => onStartTestRun(level.levelId)}
|
||||
className={`platform-button platform-button--secondary w-full ${isBusy ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
@@ -946,6 +990,7 @@ function PuzzleLevelDetailDialog({
|
||||
setReferenceImageLabel(
|
||||
getPuzzleHistoryAssetReferenceLabel(asset.imageSrc),
|
||||
);
|
||||
setAiRedraw(true);
|
||||
setReferenceImageError(null);
|
||||
setIsHistoryPickerOpen(false);
|
||||
}}
|
||||
@@ -1342,344 +1387,6 @@ function PuzzleWorkInfoTab({
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleUiAssetsTab({
|
||||
editState,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
uiBackgroundGeneration,
|
||||
onChange,
|
||||
onGenerate,
|
||||
}: {
|
||||
editState: DraftEditState;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerate: (prompt: string) => void;
|
||||
}) {
|
||||
const firstLevel = editState.levels[0] ?? null;
|
||||
const isGeneratingUiBackground = Boolean(
|
||||
firstLevel &&
|
||||
uiBackgroundGeneration?.levelId === firstLevel.levelId,
|
||||
);
|
||||
const formalImageSrc = firstLevel ? resolveLevelFormalImageSrc(firstLevel) : '';
|
||||
const defaultPrompt = buildDefaultPuzzleUiBackgroundPrompt(
|
||||
editState,
|
||||
firstLevel,
|
||||
);
|
||||
const prompt = firstLevel?.uiBackgroundPrompt ?? '';
|
||||
const normalizedPrompt = prompt.trim() || defaultPrompt.trim();
|
||||
const backgroundPreviewSrc =
|
||||
resolvePuzzleUiBackgroundSource(firstLevel) || PUZZLE_UI_BACKGROUND_REFERENCE_SRC;
|
||||
const hasGeneratedUiBackground = Boolean(resolvePuzzleUiBackgroundSource(firstLevel));
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
|
||||
|
||||
const updateFirstLevel = (nextLevel: PuzzleDraftLevel) => {
|
||||
onChange({
|
||||
...editState,
|
||||
levels: [nextLevel, ...editState.levels.slice(1)],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<CreativeImageInputPanel
|
||||
mainImageMode="preview"
|
||||
disabled={isBusy || !firstLevel || isGeneratingUiBackground}
|
||||
isSubmitting={isGeneratingUiBackground}
|
||||
uploadedImageSrc={backgroundPreviewSrc}
|
||||
uploadedImageAlt="拼图UI背景图"
|
||||
uploadedImageRefreshKey={`${imageRefreshKey}:ui-background`}
|
||||
mainImageInputId="puzzle-ui-background-preview"
|
||||
promptTextareaId="puzzle-ui-background-prompt-input"
|
||||
prompt={prompt}
|
||||
promptLabel="UI背景提示词"
|
||||
promptAriaLabel="拼图UI背景提示词"
|
||||
promptRows={8}
|
||||
aiRedraw={false}
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel={
|
||||
isGeneratingUiBackground
|
||||
? '生成中'
|
||||
: hasGeneratedUiBackground
|
||||
? '重新生成'
|
||||
: '生成UI背景'
|
||||
}
|
||||
submitCostLabel={`· ${PUZZLE_IMAGE_GENERATION_POINT_COST}泥点`}
|
||||
submitDisabled={
|
||||
!firstLevel ||
|
||||
!normalizedPrompt ||
|
||||
isBusy ||
|
||||
isGeneratingUiBackground
|
||||
}
|
||||
labels={{
|
||||
imageField: 'UI背景预览',
|
||||
uploadImage: '上传拼图图片',
|
||||
replaceImage: '更换拼图图片',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除拼图图片',
|
||||
removeImageConfirmTitle: '移除拼图图片?',
|
||||
removeImageConfirmBody: '移除后需要重新上传图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={(value) => {
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
updateFirstLevel({
|
||||
...firstLevel,
|
||||
uiBackgroundPrompt: value,
|
||||
});
|
||||
}}
|
||||
onSubmit={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
setIsCostConfirmOpen(true);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
className="platform-button platform-button--ghost mt-3 min-h-11 w-full justify-center gap-2 px-4 py-3"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
预览UI
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{isPreviewOpen ? (
|
||||
<PuzzleUiRuntimePreviewPanel
|
||||
backgroundPreviewSrc={backgroundPreviewSrc}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
puzzleImageSrc={formalImageSrc}
|
||||
title={editState.workTitle || firstLevel?.levelName || '拼图'}
|
||||
onClose={() => setIsPreviewOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{isCostConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-ui-point-cost-confirm-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div
|
||||
id="puzzle-ui-point-cost-confirm-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
确认消耗泥点
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
|
||||
消耗 {PUZZLE_IMAGE_GENERATION_POINT_COST} 泥点
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCostConfirmOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!firstLevel ||
|
||||
!normalizedPrompt ||
|
||||
isBusy ||
|
||||
isGeneratingUiBackground
|
||||
}
|
||||
onClick={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
updateFirstLevel({
|
||||
...firstLevel,
|
||||
uiBackgroundPrompt: normalizedPrompt,
|
||||
});
|
||||
setIsCostConfirmOpen(false);
|
||||
onGenerate(normalizedPrompt);
|
||||
}}
|
||||
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy || isGeneratingUiBackground ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleUiRuntimePreviewPanel({
|
||||
backgroundPreviewSrc,
|
||||
imageRefreshKey,
|
||||
puzzleImageSrc,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
backgroundPreviewSrc: string;
|
||||
imageRefreshKey: string;
|
||||
puzzleImageSrc: string;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[139] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="UI预览"
|
||||
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,48rem)] w-full max-w-sm 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">
|
||||
<div className="min-w-0 truncate text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
UI预览
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
className="platform-icon-button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="mx-auto aspect-[9/16] max-h-[min(78dvh,42rem)] w-full max-w-[22rem] overflow-hidden rounded-[1.4rem] border border-white/22 bg-[#16211f] shadow-[0_18px_55px_rgba(15,23,42,0.24)]">
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden px-3 pb-4 pt-3 text-white">
|
||||
<ResolvedAssetImage
|
||||
src={backgroundPreviewSrc}
|
||||
refreshKey={`${imageRefreshKey}:ui-runtime-preview`}
|
||||
alt=""
|
||||
data-testid="puzzle-ui-runtime-preview-background"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.18)_0%,rgba(15,23,42,0.05)_45%,rgba(15,23,42,0.24)_100%)]" />
|
||||
<header className="relative z-10 grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-center gap-2">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 bg-black/28 backdrop-blur">
|
||||
<ArrowLeft size={20} />
|
||||
</span>
|
||||
<span className="min-w-0 truncate rounded-full border border-white/18 bg-black/26 px-3 py-2 text-center text-sm font-black backdrop-blur">
|
||||
{title}
|
||||
</span>
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 bg-black/28 backdrop-blur">
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section className="relative z-10 mt-4 flex min-h-0 flex-1 items-center justify-center">
|
||||
<div
|
||||
className="relative aspect-square max-w-full overflow-hidden rounded-[1.25rem] border-[8px] border-white/88 bg-white/92 shadow-[0_20px_44px_rgba(15,23,42,0.32),inset_0_0_0_2px_rgba(15,23,42,0.12)]"
|
||||
style={{ width: 'min(88%, 52dvh, 100%)' }}
|
||||
aria-label="拼图区边界"
|
||||
>
|
||||
{puzzleImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={puzzleImageSrc}
|
||||
refreshKey={`${imageRefreshKey}:ui-runtime-board`}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full w-full grid-cols-3 grid-rows-3 gap-1 bg-slate-100 p-2">
|
||||
{Array.from({ length: 9 }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="rounded-[0.45rem] bg-slate-300/70"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 rounded-[0.82rem] border-2 border-black/18" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="relative z-10 mt-3 rounded-[1.35rem] border border-white/16 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="h-12 rounded-xl bg-white/14 sm:h-14"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleAssetConfigTab({
|
||||
activeAssetConfigTab,
|
||||
editState,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
uiBackgroundGeneration,
|
||||
onAssetConfigTabChange,
|
||||
onChange,
|
||||
onGenerateUiBackground,
|
||||
}: {
|
||||
activeAssetConfigTab: PuzzleAssetConfigTabId;
|
||||
editState: DraftEditState;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
|
||||
onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerateUiBackground: (prompt: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-0">
|
||||
<PuzzleAssetConfigTabs
|
||||
activeTab={activeAssetConfigTab}
|
||||
onChange={onAssetConfigTabChange}
|
||||
/>
|
||||
{activeAssetConfigTab === 'ui' ? (
|
||||
<PuzzleUiAssetsTab
|
||||
editState={editState}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
uiBackgroundGeneration={uiBackgroundGeneration}
|
||||
onChange={onChange}
|
||||
onGenerate={onGenerateUiBackground}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleResultActionBar({
|
||||
actionError,
|
||||
editState,
|
||||
@@ -1770,8 +1477,6 @@ export function PuzzleResultView({
|
||||
}: PuzzleResultViewProps) {
|
||||
const draft = session.draft;
|
||||
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('work');
|
||||
const [activeAssetConfigTab, setActiveAssetConfigTab] =
|
||||
useState<PuzzleAssetConfigTabId>('ui');
|
||||
const [activeLevelId, setActiveLevelId] = useState<string | null>(null);
|
||||
const [editState, setEditState] = useState<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
@@ -1782,8 +1487,6 @@ export function PuzzleResultView({
|
||||
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [uiBackgroundGeneration, setUiBackgroundGeneration] =
|
||||
useState<PuzzleUiBackgroundGenerationState>(null);
|
||||
const [generationRuntimeByLevelId, setGenerationRuntimeByLevelId] = useState<
|
||||
Record<string, PuzzleLevelGenerationRuntime>
|
||||
>({});
|
||||
@@ -1799,18 +1502,11 @@ export function PuzzleResultView({
|
||||
latestEditStateRef.current = editState;
|
||||
}, [editState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setUiBackgroundGeneration(null);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setEditState(null);
|
||||
latestEditStateRef.current = null;
|
||||
setActiveLevelId(null);
|
||||
setUiBackgroundGeneration(null);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
@@ -1831,7 +1527,7 @@ export function PuzzleResultView({
|
||||
nextRuntimes[level.levelId] =
|
||||
current[level.levelId] ?? {
|
||||
startedAtMs: Date.now(),
|
||||
estimateSeconds: PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS,
|
||||
estimateSeconds: resolvePuzzleLevelGenerationEstimateSeconds(),
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1846,19 +1542,6 @@ export function PuzzleResultView({
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
setUiBackgroundGeneration((current) => {
|
||||
if (
|
||||
current &&
|
||||
mergedState.levels.some(
|
||||
(level) =>
|
||||
level.levelId === current.levelId &&
|
||||
resolvePuzzleUiBackgroundSource(level),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, [draft]);
|
||||
|
||||
const syncedDraft = useMemo(() => {
|
||||
@@ -1933,6 +1616,16 @@ export function PuzzleResultView({
|
||||
uiBackgroundImageSrc: level.uiBackgroundImageSrc?.trim() || null,
|
||||
uiBackgroundImageObjectKey:
|
||||
level.uiBackgroundImageObjectKey?.trim() || null,
|
||||
levelSceneImageSrc: level.levelSceneImageSrc?.trim() || null,
|
||||
levelSceneImageObjectKey:
|
||||
level.levelSceneImageObjectKey?.trim() || null,
|
||||
uiSpritesheetImageSrc: level.uiSpritesheetImageSrc?.trim() || null,
|
||||
uiSpritesheetImageObjectKey:
|
||||
level.uiSpritesheetImageObjectKey?.trim() || null,
|
||||
levelBackgroundImageSrc:
|
||||
level.levelBackgroundImageSrc?.trim() || null,
|
||||
levelBackgroundImageObjectKey:
|
||||
level.levelBackgroundImageObjectKey?.trim() || null,
|
||||
generationStatus: level.generationStatus || 'idle',
|
||||
})),
|
||||
};
|
||||
@@ -2006,16 +1699,18 @@ export function PuzzleResultView({
|
||||
);
|
||||
}
|
||||
|
||||
const updateLevel = (nextLevel: PuzzleDraftLevel) => {
|
||||
const updateLevel = (
|
||||
nextLevel: PuzzleDraftLevel,
|
||||
estimateSeconds = resolvePuzzleLevelGenerationEstimateSeconds(),
|
||||
) => {
|
||||
setGenerationRuntimeByLevelId((current) => {
|
||||
if (nextLevel.generationStatus === 'generating') {
|
||||
return {
|
||||
...current,
|
||||
[nextLevel.levelId]:
|
||||
current[nextLevel.levelId] ?? {
|
||||
startedAtMs: Date.now(),
|
||||
estimateSeconds: PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS,
|
||||
},
|
||||
[nextLevel.levelId]: {
|
||||
startedAtMs: current[nextLevel.levelId]?.startedAtMs ?? Date.now(),
|
||||
estimateSeconds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2039,17 +1734,6 @@ export function PuzzleResultView({
|
||||
);
|
||||
};
|
||||
|
||||
const buildLevelDraft = (level: PuzzleDraftLevel): PuzzleResultDraft => ({
|
||||
...syncedDraft,
|
||||
levelName: level.levelName,
|
||||
summary: editState.workDescription.trim(),
|
||||
candidates: level.candidates,
|
||||
selectedCandidateId: level.selectedCandidateId,
|
||||
coverImageSrc: resolveLevelFormalImageSrc(level) || level.coverImageSrc,
|
||||
coverAssetId: level.coverAssetId,
|
||||
generationStatus: level.generationStatus,
|
||||
levels: [level],
|
||||
});
|
||||
const canStartTestRun = Boolean(onStartTestRun && primaryImageSrc);
|
||||
|
||||
return (
|
||||
@@ -2132,46 +1816,6 @@ export function PuzzleResultView({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'assets' ? (
|
||||
<PuzzleAssetConfigTab
|
||||
activeAssetConfigTab={activeAssetConfigTab}
|
||||
editState={editState}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
uiBackgroundGeneration={uiBackgroundGeneration}
|
||||
onAssetConfigTabChange={setActiveAssetConfigTab}
|
||||
onChange={setEditState}
|
||||
onGenerateUiBackground={(prompt) => {
|
||||
const firstLevel = editState.levels[0] ?? null;
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
setUiBackgroundGeneration({
|
||||
levelId: firstLevel.levelId,
|
||||
prompt,
|
||||
});
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
levelId: firstLevel.levelId,
|
||||
promptText: prompt,
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
summary: editState.workDescription.trim(),
|
||||
themeTags: editState.themeTags,
|
||||
levelsJson: JSON.stringify(
|
||||
editState.levels.map((level, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...level,
|
||||
uiBackgroundPrompt: prompt,
|
||||
}
|
||||
: level,
|
||||
),
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
@@ -2230,15 +1874,18 @@ export function PuzzleResultView({
|
||||
nextLevel,
|
||||
promptText,
|
||||
referenceImageSrc,
|
||||
referenceImageSrcs,
|
||||
imageModel,
|
||||
aiRedraw,
|
||||
estimateSeconds,
|
||||
) => {
|
||||
updateLevel(nextLevel);
|
||||
updateLevel(nextLevel, estimateSeconds);
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: nextLevel.levelId,
|
||||
promptText,
|
||||
referenceImageSrc,
|
||||
referenceImageSrcs,
|
||||
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aiRedraw: aiRedraw ?? true,
|
||||
candidateCount: 1,
|
||||
@@ -2257,7 +1904,7 @@ export function PuzzleResultView({
|
||||
onLevelChange={updateLevel}
|
||||
onStartTestRun={
|
||||
onStartTestRun
|
||||
? (level) => onStartTestRun(buildLevelDraft(level))
|
||||
? (levelId) => onStartTestRun(syncedDraft, { levelId })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user