Enforce Genarrative play-type SOP and update docs
Rewrite Genarrative play-type integration guidance across .codex and .hermes to define a platform-level SOP: default to form/image workbench, unify single-image asset slots (CreativeImageInputPanel), standardize series-material sheet->cut->transparent->OSS pipeline, and forbid copying legacy chat/agent workflows as the default. Add decision-log entry freezing the SOP and a pitfalls note warning against direct reuse of old play tools. Update CONTEXT.md and docs/README.md, add a new PRD file, and apply related small server-side changes (module-auth, spacetime-client mappers and runtime) to align back-end code with the new contracts and flows.
This commit is contained in:
@@ -390,9 +390,6 @@ describe('PuzzleResultView', () => {
|
||||
within(dialog).getByRole('button', { name: /生成画面/u }),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByText('消耗2泥点')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByText('等待时间可以制作更多关卡哦~'),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByText('画面图')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: /关卡测试/u }),
|
||||
@@ -1013,7 +1010,7 @@ describe('PuzzleResultView', () => {
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '新拼图UI背景提示词' },
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /生成UI背景 · 2泥点/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /生成UI背景.*2泥点/u })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
const confirmDialog = screen.getByRole('dialog', {
|
||||
name: '确认消耗泥点',
|
||||
@@ -1352,6 +1349,184 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor exposes entrance image editing controls without sharing UI background state', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const session = createSession({
|
||||
draft: {
|
||||
...createSession().draft!,
|
||||
levels: [
|
||||
{
|
||||
...createSession().draft!.levels![0]!,
|
||||
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={session}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
|
||||
expect(within(dialog).getByText('画面图')).toBeTruthy();
|
||||
expect(within(dialog).getByLabelText('上传参考图')).toBeTruthy();
|
||||
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.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
promptText: '只重绘第一关猫街画面',
|
||||
aiRedraw: true,
|
||||
}),
|
||||
);
|
||||
expect(onExecuteAction).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor keeps AI redraw switch scoped to the level image action', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const session = createSession({
|
||||
draft: {
|
||||
...createSession().draft!,
|
||||
levels: [
|
||||
{
|
||||
...createSession().draft!.levels![0]!,
|
||||
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={session}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
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(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
aiRedraw: false,
|
||||
}),
|
||||
);
|
||||
expect(onExecuteAction).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
aiRedraw: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor hides AI redraw controls when only the formal image is shown', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
|
||||
expect(within(dialog).queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(within(dialog).getByLabelText('画面描述')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('UI background generator reuses common image input UI without sharing level image fields', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
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背景提示词',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('shows creative agent draft edit bar and submits the current draft', () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
History,
|
||||
ImagePlus,
|
||||
LayoutTemplate,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
@@ -11,10 +9,9 @@ import {
|
||||
Plus,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Wand2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
@@ -29,6 +26,7 @@ import { updatePuzzleWork } from '../../services/puzzle-works';
|
||||
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
|
||||
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
|
||||
import {
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -659,6 +657,7 @@ function PuzzleLevelDetailDialog({
|
||||
promptText?: string | null,
|
||||
referenceImageSrc?: string | null,
|
||||
imageModel?: PuzzleImageModelId | null,
|
||||
aiRedraw?: boolean | null,
|
||||
) => void;
|
||||
onLevelChange: (nextLevel: PuzzleDraftLevel) => void;
|
||||
onStartTestRun?: (level: PuzzleDraftLevel) => void;
|
||||
@@ -674,6 +673,7 @@ function PuzzleLevelDetailDialog({
|
||||
const [imageModel, setImageModel] = useState<PuzzleImageModelId>(
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
);
|
||||
const [aiRedraw, setAiRedraw] = useState(true);
|
||||
const formalImageSrc = resolveLevelFormalImageSrc(level);
|
||||
const hasFormalImage = Boolean(formalImageSrc);
|
||||
const effectiveReferenceImageSrc =
|
||||
@@ -688,15 +688,7 @@ function PuzzleLevelDetailDialog({
|
||||
generationNowMs,
|
||||
);
|
||||
|
||||
const handleReferenceImageChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleReferenceImageFile = async (file: File) => {
|
||||
try {
|
||||
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
|
||||
setReferenceImageSrc(dataUrl);
|
||||
@@ -722,6 +714,7 @@ function PuzzleLevelDetailDialog({
|
||||
nextLevel.pictureDescription.trim() || undefined,
|
||||
effectiveReferenceImageSrc || undefined,
|
||||
imageModel,
|
||||
aiRedraw,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -776,134 +769,91 @@ function PuzzleLevelDetailDialog({
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(13rem,0.9fr)_minmax(0,1.1fr)]">
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<div className="mb-3 text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
画面图
|
||||
</div>
|
||||
<div className="relative aspect-square overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)]">
|
||||
<input
|
||||
id={`puzzle-level-reference-upload-${level.levelId}`}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={isBusy}
|
||||
aria-label="上传参考图"
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageChange(event);
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`puzzle-level-reference-upload-${level.levelId}`}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${isBusy ? 'cursor-not-allowed' : ''}`}
|
||||
title={
|
||||
effectiveReferenceImageSrc ? '更换参考图' : '上传参考图'
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{effectiveReferenceImageSrc ? '更换参考图' : '上传参考图'}
|
||||
</span>
|
||||
</label>
|
||||
{displayImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={displayImageSrc}
|
||||
refreshKey={`${imageRefreshKey}:${level.levelId}`}
|
||||
alt={displayImageAlt}
|
||||
className="pointer-events-none h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.92),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm">
|
||||
<ImagePlus className="h-7 w-7" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{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-4 py-2 text-sm font-black text-[var(--platform-text-strong)] shadow-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-amber-600" />
|
||||
生成中
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<CreativeImageInputPanel
|
||||
disabled={isBusy || generationProgress.isGenerating}
|
||||
isSubmitting={generationProgress.isGenerating}
|
||||
uploadedImageSrc={displayImageSrc}
|
||||
uploadedImageAlt={displayImageAlt}
|
||||
uploadedImageRefreshKey={`${imageRefreshKey}:${level.levelId}`}
|
||||
canRemoveMainImage={Boolean(effectiveReferenceImageSrc)}
|
||||
canToggleAiRedraw={Boolean(effectiveReferenceImageSrc)}
|
||||
mainImageMeta={
|
||||
effectiveReferenceImageSrc ? (
|
||||
<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>
|
||||
) : null}
|
||||
<div className="absolute bottom-3 right-3 z-10">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsHistoryPickerOpen(true)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-label="选择历史图片"
|
||||
title="选择历史图片"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
<span>历史</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{effectiveReferenceImageSrc ? (
|
||||
<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-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">
|
||||
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{referenceImageLabel || '已选择参考图'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setReferenceImageSrc('');
|
||||
setReferenceImageLabel('');
|
||||
setReferenceImageError(null);
|
||||
onLevelChange({ ...level, pictureReference: 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>
|
||||
|
||||
<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={7}
|
||||
onChange={(event) =>
|
||||
onLevelChange({
|
||||
...level,
|
||||
pictureDescription: event.target.value,
|
||||
})
|
||||
}
|
||||
className="h-[12rem] min-h-[12rem] 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 sm:h-[14rem] sm:min-h-[14rem] lg:h-full lg:min-h-[18rem]"
|
||||
aria-label="画面描述"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
mainImageInputId={`puzzle-level-reference-upload-${level.levelId}`}
|
||||
promptTextareaId={`puzzle-level-picture-description-${level.levelId}`}
|
||||
prompt={level.pictureDescription}
|
||||
promptLabel={
|
||||
effectiveReferenceImageSrc
|
||||
? '画面AI重绘要求(提示词)'
|
||||
: '画面描述'
|
||||
}
|
||||
promptRows={7}
|
||||
aiRedraw={aiRedraw}
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={
|
||||
<PuzzleImageModelPicker
|
||||
value={imageModel}
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || generationProgress.isGenerating}
|
||||
onChange={setImageModel}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
inputError={referenceImageError}
|
||||
submitLabel={hasFormalImage ? '重新生成画面' : '生成画面'}
|
||||
submitCostLabel={`消耗${PUZZLE_IMAGE_GENERATION_POINT_COST}泥点`}
|
||||
submitDisabled={
|
||||
isBusy ||
|
||||
generationProgress.isGenerating ||
|
||||
(!level.pictureDescription.trim() && !effectiveReferenceImageSrc)
|
||||
}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '更换参考图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
history: '选择历史图片',
|
||||
}}
|
||||
onMainImageFileSelect={(file) => {
|
||||
void handleReferenceImageFile(file);
|
||||
}}
|
||||
onMainImageRemove={() => {
|
||||
setReferenceImageSrc('');
|
||||
setReferenceImageLabel('');
|
||||
setReferenceImageError(null);
|
||||
setAiRedraw(true);
|
||||
onLevelChange({ ...level, pictureReference: null });
|
||||
}}
|
||||
onAiRedrawChange={setAiRedraw}
|
||||
onPromptChange={(value) =>
|
||||
onLevelChange({
|
||||
...level,
|
||||
pictureDescription: value,
|
||||
})
|
||||
}
|
||||
onHistoryClick={() => setIsHistoryPickerOpen(true)}
|
||||
onSubmit={() => setIsCostConfirmOpen(true)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -940,25 +890,7 @@ function PuzzleLevelDetailDialog({
|
||||
预计剩余 {generationProgress.secondsLeft} 秒
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsCostConfirmOpen(true)}
|
||||
className="inline-flex w-full flex-col items-center justify-center gap-1 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{hasFormalImage ? '重新生成画面' : '生成画面'}</span>
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
消耗{PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold leading-none text-white/78">
|
||||
等待时间可以制作更多关卡哦~
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isCostConfirmOpen ? (
|
||||
@@ -1453,82 +1385,75 @@ function PuzzleUiAssetsTab({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(13rem,0.78fr)_minmax(0,1fr)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
className="mx-auto aspect-[9/16] max-h-[min(62dvh,34rem)] w-full max-w-[18rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 text-left shadow-sm"
|
||||
aria-label="打开拼图UI预览"
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={backgroundPreviewSrc}
|
||||
refreshKey={`${imageRefreshKey}:ui-background`}
|
||||
alt="拼图UI背景图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
UI背景提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={isBusy || !firstLevel}
|
||||
rows={8}
|
||||
onChange={(event) => {
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
updateFirstLevel({
|
||||
...firstLevel,
|
||||
uiBackgroundPrompt: event.target.value,
|
||||
});
|
||||
}}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="拼图UI背景提示词"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
预览UI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!firstLevel ||
|
||||
!normalizedPrompt ||
|
||||
isBusy ||
|
||||
isGeneratingUiBackground
|
||||
}
|
||||
onClick={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
setIsCostConfirmOpen(true);
|
||||
}}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy || isGeneratingUiBackground ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isBusy || isGeneratingUiBackground ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="h-4 w-4" />
|
||||
)}
|
||||
{isGeneratingUiBackground
|
||||
? '生成中'
|
||||
: hasGeneratedUiBackground
|
||||
? '重新生成'
|
||||
: '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 ? (
|
||||
@@ -2301,7 +2226,13 @@ export function PuzzleResultView({
|
||||
isBusy={isBusy}
|
||||
level={activeLevel}
|
||||
onClose={() => setActiveLevelId(null)}
|
||||
onGenerate={(nextLevel, promptText, referenceImageSrc, imageModel) => {
|
||||
onGenerate={(
|
||||
nextLevel,
|
||||
promptText,
|
||||
referenceImageSrc,
|
||||
imageModel,
|
||||
aiRedraw,
|
||||
) => {
|
||||
updateLevel(nextLevel);
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_images',
|
||||
@@ -2309,7 +2240,7 @@ export function PuzzleResultView({
|
||||
promptText,
|
||||
referenceImageSrc,
|
||||
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aiRedraw: true,
|
||||
aiRedraw: aiRedraw ?? true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: !nextLevel.levelName.trim(),
|
||||
workTitle: editState.workTitle.trim(),
|
||||
|
||||
Reference in New Issue
Block a user