Files
Genarrative/src/components/puzzle-result/PuzzleResultView.tsx
高物 548db78ca7 Update Match3D/image-generation docs & code
Adds/updates documentation, assets and implementation for Match3D and puzzle image generation workflows. Key changes: decision logs and pitfalls updated to prefer VectorEngine Gemini for Match3D material sheets and to require edits (multipart) for 1:1 container reference images; guidance added for when to use APIMart vs VectorEngine. .env.example clarified APIMart/Responses config. Many new public assets and PPT visuals added. Code changes across frontend and backend: updated shared contracts, server-rs match3d/puzzle/image-generation handlers, VectorEngine/OpenAI image generation clients, and multiple React components/tests to handle UI/background/container image signing, edits workflow, and puzzle UI background resolution. Added src/services/puzzle-runtime/puzzleUiBackgroundSource.ts and related test updates. Includes notes about multipart HTTP/1.1 requirement and test/verification commands in docs.
2026-05-14 20:34:45 +08:00

2275 lines
79 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowLeft,
CheckCircle2,
Eye,
History,
ImagePlus,
LayoutTemplate,
Loader2,
MessageSquareText,
Play,
Plus,
Sparkles,
Trash2,
Wand2,
X,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleDraftLevel,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { updatePuzzleWork } from '../../services/puzzle-works';
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
import {
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
type PuzzleImageModelId,
} from '../puzzle-agent/puzzleImageModelOptions';
import { PuzzleImageModelPicker } from '../puzzle-agent/PuzzleImageModelPicker';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleResultViewProps = {
session: PuzzleAgentSessionSnapshot;
profileId?: string | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
onStartTestRun?: (draft: PuzzleResultDraft) => void;
creativeDraftEdit?: {
isBusy: boolean;
error: string | null;
onSubmit: (payload: {
instruction: string;
currentDraft: PuzzleResultDraft;
}) => Promise<CreativeDraftEditResult | null> | void;
} | null;
};
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type PuzzleResultTab = 'levels' | 'work' | 'assets';
type PuzzleAssetConfigTabId = 'ui';
type DraftEditState = {
workTitle: string;
workDescription: string;
themeTags: string[];
levels: PuzzleDraftLevel[];
};
const PUZZLE_MIN_THEME_TAG_COUNT = 3;
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
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_RESULT_TABS: Array<{ id: PuzzleResultTab; label: string }> = [
{ id: 'levels', label: '拼图关卡' },
{ id: 'work', label: '作品信息' },
{ id: 'assets', label: '素材配置' },
];
const PUZZLE_ASSET_CONFIG_TABS: Array<{
id: PuzzleAssetConfigTabId;
label: string;
}> = [
{ id: 'ui', label: 'UI' },
];
type PuzzleLevelGenerationRuntime = {
startedAtMs: number;
estimateSeconds: number;
};
function resolvePuzzleLevelGenerationProgress(
level: PuzzleDraftLevel,
runtime: PuzzleLevelGenerationRuntime | null,
nowMs: number,
) {
if (level.generationStatus !== 'generating') {
return {
isGenerating: false,
progressPercent: 0,
secondsLeft: 0,
};
}
const estimateSeconds =
runtime?.estimateSeconds ?? PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS;
const elapsedSeconds = runtime
? Math.max(0, Math.floor((nowMs - runtime.startedAtMs) / 1000))
: 0;
const secondsLeft = Math.max(0, estimateSeconds - elapsedSeconds);
const progressPercent = Math.min(
96,
Math.max(
6,
Math.round(((estimateSeconds - secondsLeft) / estimateSeconds) * 100),
),
);
return {
isGenerating: true,
progressPercent,
secondsLeft,
};
}
function normalizeThemeTagInput(value: string) {
return [
...new Set(
value
.split(/[\n,]/u)
.map((entry) => entry.trim())
.filter(Boolean),
),
];
}
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(
(candidate) =>
candidate.selected ||
(level.selectedCandidateId
? candidate.candidateId === level.selectedCandidateId
: false),
) ??
level.candidates[level.candidates.length - 1] ??
null;
return (
selectedCandidate?.imageSrc?.trim() || level.coverImageSrc?.trim() || ''
);
}
function buildFallbackLevelFromDraft(
draft: PuzzleResultDraft,
): PuzzleDraftLevel {
return {
levelId: 'puzzle-level-1',
levelName: draft.levelName || '',
pictureDescription: draft.summary,
candidates: draft.candidates,
selectedCandidateId: draft.selectedCandidateId,
coverImageSrc: draft.coverImageSrc,
coverAssetId: draft.coverAssetId,
generationStatus: draft.generationStatus,
};
}
function normalizeDraftLevels(draft: PuzzleResultDraft) {
const sourceLevels =
draft.levels && draft.levels.length > 0
? draft.levels
: [buildFallbackLevelFromDraft(draft)];
return sourceLevels.map((level, index) => ({
...level,
levelId: level.levelId?.trim() || `puzzle-level-${index + 1}`,
levelName: level.levelName?.trim() || '',
pictureDescription: level.pictureDescription?.trim() || draft.summary,
pictureReference: level.pictureReference ?? null,
uiBackgroundPrompt: level.uiBackgroundPrompt ?? null,
uiBackgroundImageSrc: level.uiBackgroundImageSrc ?? null,
uiBackgroundImageObjectKey: level.uiBackgroundImageObjectKey ?? null,
candidates: level.candidates ?? [],
selectedCandidateId: level.selectedCandidateId ?? null,
coverImageSrc: level.coverImageSrc ?? null,
coverAssetId: level.coverAssetId ?? null,
backgroundMusic: level.backgroundMusic ?? null,
generationStatus: level.generationStatus || 'idle',
}));
}
function syncDraftFromEditState(
draft: PuzzleResultDraft,
editState: DraftEditState,
): PuzzleResultDraft {
const levels = editState.levels;
const primaryLevel = levels[0] ?? buildFallbackLevelFromDraft(draft);
return {
...draft,
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
levelName: primaryLevel.levelName,
summary: editState.workDescription.trim(),
themeTags: editState.themeTags,
candidates: primaryLevel.candidates,
selectedCandidateId: primaryLevel.selectedCandidateId,
coverImageSrc: primaryLevel.coverImageSrc,
coverAssetId: primaryLevel.coverAssetId,
generationStatus: primaryLevel.generationStatus,
levels,
};
}
function createDraftEditState(draft: PuzzleResultDraft): DraftEditState {
return {
workTitle: draft.workTitle ?? '',
workDescription: draft.workDescription ?? '',
themeTags: normalizeThemeTagInput(draft.themeTags.join('')),
levels: normalizeDraftLevels(draft),
};
}
function mergeDraftEditStateWithIncomingState(
currentState: DraftEditState | null,
incomingState: DraftEditState,
): DraftEditState {
if (!currentState) {
return incomingState;
}
const incomingLevelsById = new Map(
incomingState.levels.map((level) => [level.levelId, level]),
);
const shouldPreserveLocalEdits = currentState.levels.some((level) => {
const incomingLevel = incomingLevelsById.get(level.levelId);
return (
level.generationStatus === 'generating' &&
Boolean(incomingLevel) &&
incomingLevel?.generationStatus !== 'generating'
);
});
if (!shouldPreserveLocalEdits) {
return incomingState;
}
const mergedLevels = currentState.levels.map((level) => {
const incomingLevel = incomingLevelsById.get(level.levelId);
if (
!incomingLevel ||
level.generationStatus !== 'generating' ||
incomingLevel.generationStatus === 'generating'
) {
return level;
}
return {
...level,
candidates: incomingLevel.candidates,
selectedCandidateId: incomingLevel.selectedCandidateId,
coverImageSrc: incomingLevel.coverImageSrc,
coverAssetId: incomingLevel.coverAssetId,
pictureReference: incomingLevel.pictureReference ?? level.pictureReference,
uiBackgroundPrompt:
incomingLevel.uiBackgroundPrompt ?? level.uiBackgroundPrompt,
uiBackgroundImageSrc:
incomingLevel.uiBackgroundImageSrc ?? level.uiBackgroundImageSrc,
uiBackgroundImageObjectKey:
incomingLevel.uiBackgroundImageObjectKey ??
level.uiBackgroundImageObjectKey,
backgroundMusic: incomingLevel.backgroundMusic ?? level.backgroundMusic,
generationStatus: incomingLevel.generationStatus || 'ready',
};
});
const mergedLevelIds = new Set(mergedLevels.map((level) => level.levelId));
const appendedIncomingLevels = incomingState.levels.filter(
(level) => !mergedLevelIds.has(level.levelId),
);
return {
...currentState,
levels: [...mergedLevels, ...appendedIncomingLevels],
};
}
function createBlankPuzzleLevel(
existingLevels: PuzzleDraftLevel[],
): PuzzleDraftLevel {
const nextIndex = existingLevels.length + 1;
return {
levelId: `puzzle-level-${Date.now()}-${nextIndex}`,
levelName: '',
pictureDescription: '',
pictureReference: null,
uiBackgroundPrompt: null,
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey: null,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
backgroundMusic: null,
generationStatus: 'idle',
};
}
function buildPublishReady(
session: PuzzleAgentSessionSnapshot,
editState: DraftEditState,
) {
const preservedBlockers =
session.resultPreview?.blockers
.filter(
(entry) =>
![
'MISSING_LEVEL_NAME',
'INVALID_TAG_COUNT',
'MISSING_COVER_IMAGE',
].includes(entry.code),
)
.map((entry) => entry.message) ?? [];
const levels = editState.levels;
const hasGeneratingLevel = levels.some(
(level) => level.generationStatus === 'generating',
);
const blockers = [
...(session.resultPreview ? [] : ['等待结果页草稿完成后再发布。']),
...preservedBlockers,
...(editState.workTitle.trim() ? [] : ['作品名称不能为空。']),
...(editState.workDescription.trim() ? [] : ['作品描述不能为空。']),
...(editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT
? []
: [
`正式标签数量必须在 ${PUZZLE_MIN_THEME_TAG_COUNT}${PUZZLE_MAX_THEME_TAG_COUNT} 之间。`,
]),
...(levels.length > 0 ? [] : ['至少需要一个拼图关卡。']),
...levels.flatMap((level, index) => [
...(level.levelName.trim() ? [] : [`${index + 1}关名称不能为空。`]),
...(resolveLevelFormalImageSrc(level)
? []
: [`${index + 1}关缺少正式图。`]),
...(level.generationStatus === 'generating'
? [`${index + 1}关画面正在生成。`]
: []),
]),
...(hasGeneratingLevel ? ['还有关卡画面正在生成。'] : []),
];
return {
blockers: [...new Set(blockers.filter(Boolean))],
publishReady: blockers.filter(Boolean).length === 0,
};
}
function PuzzleResultHeader({
autoSaveState,
isBusy,
onBack,
}: {
autoSaveState: PuzzleAutoSaveState;
isBusy: boolean;
onBack: () => void;
}) {
const autoSaveBadge =
autoSaveState === 'saving' ? (
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'saved' ? (
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'error' ? (
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
</div>
) : null;
return (
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
{autoSaveBadge}
</div>
);
}
function PuzzleResultTabs({
activeTab,
onChange,
}: {
activeTab: PuzzleResultTab;
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">
{PUZZLE_RESULT_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`min-h-10 rounded-[1rem] px-3 text-sm font-bold transition ${
activeTab === tab.id
? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
: 'text-[var(--platform-text-base)] hover:bg-white/60'
}`}
aria-pressed={activeTab === tab.id}
>
{tab.label}
</button>
))}
</div>
);
}
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,
error,
onChange,
onGenerateTags,
}: {
editState: DraftEditState;
isBusy: boolean;
error: string | null;
onChange: (nextState: DraftEditState) => void;
onGenerateTags: () => void;
}) {
const [newTagText, setNewTagText] = useState('');
const [isAddingTag, setIsAddingTag] = useState(false);
const addTags = () => {
const nextTags = normalizeThemeTagInput(newTagText);
if (nextTags.length <= 0) {
setIsAddingTag(false);
setNewTagText('');
return;
}
onChange({
...editState,
themeTags: [...new Set([...editState.themeTags, ...nextTags])],
});
setNewTagText('');
setIsAddingTag(false);
};
return (
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={isBusy}
onClick={onGenerateTags}
className="platform-icon-button h-9 w-9"
aria-label="AI生成作品标签"
title="AI生成作品标签"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</button>
{!isAddingTag ? (
<button
type="button"
disabled={isBusy}
onClick={() => setIsAddingTag(true)}
className="platform-icon-button h-9 w-9"
aria-label="新增作品标签"
title="新增作品标签"
>
<Plus className="h-4 w-4" />
</button>
) : null}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{editState.themeTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1.5 rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1.5 text-xs font-semibold text-amber-700"
>
{tag}
<button
type="button"
disabled={isBusy}
onClick={() => {
onChange({
...editState,
themeTags: editState.themeTags.filter(
(currentTag) => currentTag !== tag,
),
});
}}
className="rounded-full text-amber-800/70 transition hover:text-amber-950 disabled:opacity-45"
aria-label={`删除标签 ${tag}`}
title="删除标签"
>
<X className="h-3.5 w-3.5" />
</button>
</span>
))}
{editState.themeTags.length <= 0 ? (
<span className="text-sm text-[var(--platform-text-soft)]">
</span>
) : null}
</div>
{isAddingTag ? (
<div className="mt-3 flex flex-col gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2 sm:flex-row">
<input
autoFocus
value={newTagText}
disabled={isBusy}
onChange={(event) => setNewTagText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addTags();
}
if (event.key === 'Escape') {
setIsAddingTag(false);
setNewTagText('');
}
}}
className="min-h-10 flex-1 rounded-[0.8rem] border border-transparent bg-white/92 px-3 text-sm text-[var(--platform-text-strong)] outline-none"
placeholder="输入新标签"
aria-label="新题材标签"
/>
<div className="flex gap-2">
<button
type="button"
disabled={isBusy}
onClick={addTags}
className="platform-button platform-button--primary min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
setIsAddingTag(false);
setNewTagText('');
}}
className="platform-button platform-button--ghost min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
>
</button>
</div>
</div>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</section>
);
}
function PuzzleLevelDetailDialog({
draft,
generationNowMs,
generationRuntime,
imageRefreshKey,
isBusy,
level,
onClose,
onGenerate,
onLevelChange,
onStartTestRun,
}: {
draft: PuzzleResultDraft;
generationNowMs: number;
generationRuntime: PuzzleLevelGenerationRuntime | null;
imageRefreshKey: string;
isBusy: boolean;
level: PuzzleDraftLevel;
onClose: () => void;
onGenerate: (
level: PuzzleDraftLevel,
promptText?: string | null,
referenceImageSrc?: string | null,
imageModel?: PuzzleImageModelId | null,
) => void;
onLevelChange: (nextLevel: PuzzleDraftLevel) => void;
onStartTestRun?: (level: PuzzleDraftLevel) => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [referenceImageSrc, setReferenceImageSrc] = useState('');
const [referenceImageLabel, setReferenceImageLabel] = useState('');
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
const [imageModel, setImageModel] = useState<PuzzleImageModelId>(
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
);
const formalImageSrc = resolveLevelFormalImageSrc(level);
const hasFormalImage = Boolean(formalImageSrc);
const effectiveReferenceImageSrc =
referenceImageSrc.trim() || level.pictureReference?.trim() || '';
const displayImageSrc = formalImageSrc || effectiveReferenceImageSrc;
const displayImageAlt = formalImageSrc
? level.levelName || draft.workTitle || '拼图关卡'
: '拼图参考图';
const generationProgress = resolvePuzzleLevelGenerationProgress(
level,
generationRuntime,
generationNowMs,
);
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
setReferenceImageSrc(dataUrl);
setReferenceImageLabel(file.name.trim() || '本地参考图');
setReferenceImageError(null);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
);
}
};
const executeGeneration = () => {
const nextLevel = {
...level,
generationStatus: 'generating' as const,
};
setIsCostConfirmOpen(false);
onGenerate(
nextLevel,
nextLevel.pictureDescription.trim() || undefined,
effectiveReferenceImageSrc || undefined,
imageModel,
);
};
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[138] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
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]"
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)]">
{level.levelName || '关卡详情'}
</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="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="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" />
</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)]">
{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="画面描述"
/>
<PuzzleImageModelPicker
value={imageModel}
disabled={isBusy}
onChange={setImageModel}
/>
</div>
</section>
</div>
</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}
{generationProgress.isGenerating ? (
<div
role="progressbar"
aria-label="画面生成进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={generationProgress.progressPercent}
className="platform-progress-track relative h-12 overflow-hidden rounded-full"
>
<div
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
style={{ width: `${generationProgress.progressPercent}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-sm font-bold text-white">
<Loader2 className="h-4 w-4 animate-spin" />
{generationProgress.secondsLeft}
</div>
</div>
) : (
<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>
)}
</div>
{isCostConfirmOpen ? (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
onClick={() => setIsCostConfirmOpen(false)}
>
<section
role="dialog"
aria-modal="true"
aria-label="确认消耗泥点"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<Sparkles className="h-4 w-4" />
</span>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
</div>
<div className="px-5 py-4 text-sm font-semibold text-[var(--platform-text-base)]">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</div>
<div className="flex items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4">
<button
type="button"
onClick={() => setIsCostConfirmOpen(false)}
className="platform-button platform-button--ghost min-h-10 px-4 py-2 text-sm"
>
</button>
<button
type="button"
disabled={isBusy || generationProgress.isGenerating}
onClick={executeGeneration}
className={`platform-button platform-button--primary min-h-10 px-5 py-2 text-sm ${isBusy || generationProgress.isGenerating ? 'opacity-55' : ''}`}
>
</button>
</div>
</section>
</div>
) : null}
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
isBusy={isBusy}
onClose={() => setIsHistoryPickerOpen(false)}
onSelect={(asset) => {
setReferenceImageSrc(asset.imageSrc);
setReferenceImageLabel(
`历史素材 · ${asset.ownerLabel || '未记录账号'}`,
);
setReferenceImageError(null);
setIsHistoryPickerOpen(false);
}}
/>
) : null}
</div>
</div>,
document.body,
);
}
function PuzzlePublishDialog({
actionError,
blockers,
editState,
imageRefreshKey,
isBusy,
publishReady,
onClose,
onPublish,
}: {
actionError: string | null;
blockers: string[];
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
publishReady: boolean;
onClose: () => void;
onPublish: () => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const primaryLevel = editState.levels[0] ?? null;
const formalImageSrc = primaryLevel
? resolveLevelFormalImageSrc(primaryLevel)
: '';
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[140] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="发布拼图作品"
className="platform-modal-shell platform-remap-surface flex max-h-[min(90vh,42rem)] w-full max-w-3xl 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="text-base font-semibold text-[var(--platform-text-strong)]">
</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="grid gap-4 md:grid-cols-[minmax(0,1fr)_14rem]">
<div className="space-y-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{actionError ? (
<div className="platform-banner platform-banner--danger text-sm leading-6">
{actionError}
</div>
) : publishReady ? (
<div className="space-y-2">
<div className="platform-banner platform-banner--success text-sm leading-6">
</div>
<div className="platform-banner platform-banner--warning text-sm font-semibold leading-6">
{PUZZLE_PUBLISH_POINT_COST}
</div>
</div>
) : (
<div className="space-y-2">
{blockers.map((blocker, index) => (
<div
key={`puzzle-publish-blocker-${index}-${blocker}`}
className="platform-banner platform-banner--warning text-sm leading-6"
>
{blocker}
</div>
))}
</div>
)}
</div>
<div className="space-y-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
{formalImageSrc ? (
<ResolvedAssetImage
src={formalImageSrc}
refreshKey={imageRefreshKey}
alt={primaryLevel?.levelName || editState.workTitle}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{editState.workTitle}
</div>
</div>
</div>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
className="platform-button platform-button--ghost"
>
</button>
<button
type="button"
onClick={onPublish}
disabled={!publishReady || isBusy}
className={`platform-button platform-button--primary ${!publishReady || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isBusy
? '发布中...'
: `发布到广场 · ${PUZZLE_PUBLISH_POINT_COST}泥点`}
</button>
</div>
</div>
</div>,
document.body,
);
}
function PuzzleCreativeDraftEditBar({
currentDraft,
error,
isBusy,
onSubmit,
}: {
currentDraft: PuzzleResultDraft;
error: string | null;
isBusy: boolean;
onSubmit: (payload: {
instruction: string;
currentDraft: PuzzleResultDraft;
}) => Promise<CreativeDraftEditResult | null> | void;
}) {
const [instruction, setInstruction] = useState('');
const trimmedInstruction = instruction.trim();
const canSubmit = Boolean(trimmedInstruction) && !isBusy;
const submit = () => {
if (!canSubmit) {
return;
}
void onSubmit({
instruction: trimmedInstruction,
currentDraft,
});
setInstruction('');
};
return (
<section className="platform-subpanel mb-3 rounded-[1.35rem] p-3 sm:p-4">
<div className="flex items-end gap-2">
<span className="mb-1 hidden h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/72 text-[var(--platform-text-base)] sm:inline-flex">
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquareText className="h-4 w-4" />
)}
</span>
<textarea
value={instruction}
disabled={isBusy}
rows={2}
onChange={(event) => setInstruction(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
submit();
}
}}
className="min-h-11 flex-1 resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-5 text-[var(--platform-text-strong)] outline-none"
placeholder="让 Agent 调整标题、标签或关卡描述"
aria-label="智能修订拼图草稿"
/>
<button
type="button"
disabled={!canSubmit}
onClick={submit}
className="platform-button platform-button--secondary min-h-11 px-4 py-2 text-sm"
>
{isBusy ? '修改中' : '修改'}
</button>
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-[1rem] text-sm leading-6">
{error}
</div>
) : null}
</section>
);
}
function PuzzleLevelListTab({
editState,
generationNowMs,
generationRuntimeByLevelId,
imageRefreshKey,
isBusy,
onAddLevel,
onDeleteLevel,
onOpenLevel,
}: {
editState: DraftEditState;
generationNowMs: number;
generationRuntimeByLevelId: Record<string, PuzzleLevelGenerationRuntime>;
imageRefreshKey: string;
isBusy: boolean;
onAddLevel: () => void;
onDeleteLevel: (levelId: string) => void;
onOpenLevel: (levelId: string) => void;
}) {
return (
<div className="space-y-3">
<div
aria-label="拼图关卡列表"
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}`;
const generationProgress = resolvePuzzleLevelGenerationProgress(
level,
generationRuntimeByLevelId[level.levelId] ?? null,
generationNowMs,
);
return (
<div
key={level.levelId}
className="platform-subpanel overflow-hidden rounded-[1.35rem] p-0"
>
<button
type="button"
onClick={() => onOpenLevel(level.levelId)}
className="block w-full text-left"
>
<div className="relative aspect-[4/3] overflow-hidden bg-[var(--platform-subpanel-fill)]">
{imageSrc ? (
<ResolvedAssetImage
src={imageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={displayLevelName}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
{generationProgress.isGenerating ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/68 backdrop-blur-[2px]">
<div className="flex items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-1.5 text-xs font-black text-[var(--platform-text-strong)] shadow-sm">
<Loader2 className="h-3.5 w-3.5 animate-spin text-amber-600" />
</div>
</div>
) : null}
</div>
<div className="space-y-1 px-4 py-4">
<div className="flex items-center justify-between gap-2 text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
<span>{index + 1}</span>
{generationProgress.isGenerating ? (
<span className="rounded-full bg-amber-100 px-2 py-0.5 tracking-normal text-amber-700">
</span>
) : null}
</div>
</div>
</button>
<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 shrink-0"
aria-label={`删除关卡 ${displayLevelName}`}
title="删除关卡"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
<button
type="button"
disabled={isBusy}
onClick={onAddLevel}
className="platform-button platform-button--secondary w-full"
>
<span className="inline-flex items-center gap-2">
<Plus className="h-4 w-4" />
</span>
<span className="mt-1 block text-[11px] font-semibold leading-none text-[var(--platform-text-soft)]">
</span>
</button>
</div>
);
}
function PuzzleWorkInfoTab({
editState,
tagGenerationError,
isBusy,
onChange,
onGenerateTags,
}: {
editState: DraftEditState;
tagGenerationError: string | null;
isBusy: boolean;
onChange: (nextState: DraftEditState) => void;
onGenerateTags: () => void;
}) {
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<input
value={editState.workTitle}
disabled={isBusy}
onChange={(event) =>
onChange({ ...editState, workTitle: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="作品名称"
/>
</section>
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<textarea
value={editState.workDescription}
disabled={isBusy}
rows={6}
onChange={(event) =>
onChange({ ...editState, workDescription: event.target.value })
}
className="mt-3 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="作品描述"
/>
</section>
<PuzzleThemeTagEditor
editState={editState}
error={tagGenerationError}
isBusy={isBusy}
onChange={onChange}
onGenerateTags={onGenerateTags}
/>
</div>
);
}
function PuzzleUiAssetsTab({
editState,
imageRefreshKey,
isBusy,
onChange,
onGenerate,
}: {
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
onChange: (nextState: DraftEditState) => void;
onGenerate: (prompt: string) => void;
}) {
const firstLevel = editState.levels[0] ?? null;
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">
<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}
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 ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
{hasGeneratedUiBackground ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}
</button>
</div>
</div>
</div>
</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}
onClick={() => {
if (!firstLevel || !normalizedPrompt) {
return;
}
updateFirstLevel({
...firstLevel,
uiBackgroundPrompt: normalizedPrompt,
});
setIsCostConfirmOpen(false);
onGenerate(normalizedPrompt);
}}
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy ? '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,
onAssetConfigTabChange,
onChange,
onGenerateUiBackground,
}: {
activeAssetConfigTab: PuzzleAssetConfigTabId;
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
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}
onChange={onChange}
onGenerate={onGenerateUiBackground}
/>
) : null}
</div>
);
}
function PuzzleResultActionBar({
actionError,
editState,
imageRefreshKey,
isBusy,
canStartTestRun,
publishReady,
publishBlockers,
onPublish,
onStartTestRun,
}: {
actionError: string | null;
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
canStartTestRun: boolean;
publishReady: boolean;
publishBlockers: string[];
onPublish: () => void;
onStartTestRun?: () => void;
}) {
const [showPublishDialog, setShowPublishDialog] = useState(false);
const [hasAttemptedPublish, setHasAttemptedPublish] = useState(false);
const closePublishDialog = () => {
setHasAttemptedPublish(false);
setShowPublishDialog(false);
};
return (
<div className="mt-4 flex items-center justify-end gap-3 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
{onStartTestRun ? (
<button
type="button"
onClick={onStartTestRun}
disabled={isBusy || !canStartTestRun}
className={`platform-button platform-button--ghost ${isBusy || !canStartTestRun ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
</span>
</button>
) : null}
<button
type="button"
onClick={() => {
setHasAttemptedPublish(false);
setShowPublishDialog(true);
}}
disabled={isBusy}
className={`platform-button platform-button--primary ${isBusy ? 'opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
</span>
</button>
{showPublishDialog ? (
<PuzzlePublishDialog
actionError={hasAttemptedPublish ? actionError : null}
blockers={publishBlockers}
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
publishReady={publishReady}
onClose={closePublishDialog}
onPublish={() => {
setHasAttemptedPublish(true);
onPublish();
}}
/>
) : null}
</div>
);
}
export function PuzzleResultView({
session,
profileId = null,
isBusy = false,
error = null,
onBack,
onExecuteAction,
onStartTestRun,
creativeDraftEdit = null,
}: PuzzleResultViewProps) {
const draft = session.draft;
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('levels');
const [activeAssetConfigTab, setActiveAssetConfigTab] =
useState<PuzzleAssetConfigTabId>('ui');
const [activeLevelId, setActiveLevelId] = useState<string | null>(null);
const [editState, setEditState] = useState<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
);
const [autoSaveState, setAutoSaveState] =
useState<PuzzleAutoSaveState>('idle');
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
null,
);
const [generationRuntimeByLevelId, setGenerationRuntimeByLevelId] = useState<
Record<string, PuzzleLevelGenerationRuntime>
>({});
const [generationNowMs, setGenerationNowMs] = useState(() => Date.now());
const savedEditStateRef = useRef<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
);
useEffect(() => {
if (!draft) {
setEditState(null);
setActiveLevelId(null);
setAutoSaveState('idle');
setAutoSaveError(null);
setTagGenerationError(null);
return;
}
const nextState = createDraftEditState(draft);
setEditState((currentState) => {
const mergedState = mergeDraftEditStateWithIncomingState(
currentState,
nextState,
);
savedEditStateRef.current = nextState;
return mergedState;
});
setGenerationRuntimeByLevelId((current) => {
const nextRuntimes: Record<string, PuzzleLevelGenerationRuntime> = {};
nextState.levels.forEach((level) => {
if (level.generationStatus === 'generating') {
nextRuntimes[level.levelId] =
current[level.levelId] ?? {
startedAtMs: Date.now(),
estimateSeconds: PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS,
};
}
});
return nextRuntimes;
});
setActiveLevelId((currentLevelId) =>
currentLevelId &&
nextState.levels.some((level) => level.levelId === currentLevelId)
? currentLevelId
: null,
);
setAutoSaveState('idle');
setAutoSaveError(null);
setTagGenerationError(null);
}, [draft]);
const syncedDraft = useMemo(() => {
if (!draft || !editState) {
return null;
}
return syncDraftFromEditState(draft, editState);
}, [draft, editState]);
const primaryLevel = editState?.levels[0] ?? null;
const primaryImageSrc = primaryLevel
? resolveLevelFormalImageSrc(primaryLevel)
: '';
const imageRefreshKey = `${session.updatedAt}:${primaryImageSrc}:${editState?.levels.length ?? 0}`;
const activeLevel =
editState?.levels.find((level) => level.levelId === activeLevelId) ?? null;
const hasGeneratingLevel = Boolean(
editState?.levels.some((level) => level.generationStatus === 'generating'),
);
useEffect(() => {
if (!hasGeneratingLevel) {
return;
}
const timer = window.setInterval(() => {
setGenerationNowMs(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, [hasGeneratingLevel]);
useEffect(() => {
if (!editState) {
return;
}
const activeGeneratingLevelIds = new Set(
editState.levels
.filter((level) => level.generationStatus === 'generating')
.map((level) => level.levelId),
);
setGenerationRuntimeByLevelId((current) => {
let changed = false;
const nextRuntime: Record<string, PuzzleLevelGenerationRuntime> = {};
Object.entries(current).forEach(([levelId, runtime]) => {
if (!activeGeneratingLevelIds.has(levelId)) {
changed = true;
return;
}
nextRuntime[levelId] = runtime;
});
return changed ? nextRuntime : current;
});
}, [editState]);
useEffect(() => {
if (!draft || !editState || !profileId) {
return;
}
const normalizedState: DraftEditState = {
...editState,
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
themeTags: normalizeThemeTagInput(editState.themeTags.join('')),
levels: editState.levels.map((level) => ({
...level,
levelName: level.levelName.trim(),
pictureDescription: level.pictureDescription.trim(),
pictureReference: level.pictureReference?.trim() || null,
uiBackgroundPrompt: level.uiBackgroundPrompt?.trim() || null,
uiBackgroundImageSrc: level.uiBackgroundImageSrc?.trim() || null,
uiBackgroundImageObjectKey:
level.uiBackgroundImageObjectKey?.trim() || null,
generationStatus: level.generationStatus || 'idle',
})),
};
const originalState =
savedEditStateRef.current ?? createDraftEditState(draft);
const changed =
JSON.stringify(normalizedState) !== JSON.stringify(originalState);
if (!changed || normalizedState.levels.length <= 0) {
return;
}
setAutoSaveState('saving');
setAutoSaveError(null);
let cancelled = false;
const timer = window.setTimeout(() => {
const firstLevel = normalizedState.levels[0]!;
void updatePuzzleWork(profileId, {
workTitle: normalizedState.workTitle,
workDescription: normalizedState.workDescription,
levelName: firstLevel.levelName,
summary: normalizedState.workDescription,
themeTags: normalizedState.themeTags,
coverImageSrc: resolveLevelFormalImageSrc(firstLevel) || null,
coverAssetId: firstLevel.coverAssetId ?? null,
levels: normalizedState.levels,
})
.then(() => {
if (cancelled) {
return;
}
// 自动保存成功后推进比较基线,避免新增后再删除回首版形态时漏同步后端。
savedEditStateRef.current = normalizedState;
setAutoSaveState('saved');
})
.catch((saveError) => {
if (cancelled) {
return;
}
setAutoSaveState('error');
setAutoSaveError(
saveError instanceof Error ? saveError.message : '自动保存失败。',
);
});
}, PUZZLE_AUTOSAVE_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [draft, editState, profileId]);
const publishState = useMemo(() => {
if (!editState) {
return {
blockers: ['等待结果页草稿完成后再发布。'],
publishReady: false,
};
}
return buildPublishReady(session, editState);
}, [editState, session]);
if (!draft || !editState || !syncedDraft) {
return (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
稿
</div>
</div>
);
}
const updateLevel = (nextLevel: PuzzleDraftLevel) => {
setGenerationRuntimeByLevelId((current) => {
if (nextLevel.generationStatus === 'generating') {
return {
...current,
[nextLevel.levelId]:
current[nextLevel.levelId] ?? {
startedAtMs: Date.now(),
estimateSeconds: PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS,
},
};
}
if (!current[nextLevel.levelId]) {
return current;
}
const nextRuntime = { ...current };
delete nextRuntime[nextLevel.levelId];
return nextRuntime;
});
setEditState((currentState) =>
currentState
? {
...currentState,
levels: currentState.levels.map((level) =>
level.levelId === nextLevel.levelId ? nextLevel : level,
),
}
: currentState,
);
};
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 (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
<PuzzleResultHeader
autoSaveState={autoSaveState}
isBusy={isBusy}
onBack={onBack}
/>
<PuzzleResultTabs activeTab={activeTab} onChange={setActiveTab} />
{creativeDraftEdit ? (
<PuzzleCreativeDraftEditBar
currentDraft={syncedDraft}
error={creativeDraftEdit.error}
isBusy={creativeDraftEdit.isBusy}
onSubmit={creativeDraftEdit.onSubmit}
/>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{activeTab === 'levels' ? (
<PuzzleLevelListTab
editState={editState}
generationNowMs={generationNowMs}
generationRuntimeByLevelId={generationRuntimeByLevelId}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
onAddLevel={() => {
const nextLevel = createBlankPuzzleLevel(editState.levels);
setEditState({
...editState,
levels: [...editState.levels, nextLevel],
});
setActiveLevelId(nextLevel.levelId);
}}
onDeleteLevel={(levelId) => {
if (editState.levels.length <= 1) {
return;
}
const nextLevels = editState.levels.filter(
(level) => level.levelId !== levelId,
);
setEditState({
...editState,
levels: nextLevels,
});
if (activeLevelId === levelId) {
setActiveLevelId(null);
}
}}
onOpenLevel={setActiveLevelId}
/>
) : null}
{activeTab === 'work' ? (
<PuzzleWorkInfoTab
editState={editState}
tagGenerationError={tagGenerationError}
isBusy={isBusy}
onChange={setEditState}
onGenerateTags={() => {
const workTitle = editState.workTitle.trim();
const workDescription = editState.workDescription.trim();
if (!workTitle || !workDescription) {
setTagGenerationError('请先填写作品名称和作品描述。');
return;
}
setTagGenerationError(null);
const firstLevel = editState.levels[0] ?? null;
onExecuteAction({
action: 'generate_puzzle_tags',
workTitle,
workDescription,
levelName: firstLevel?.levelName.trim(),
summary: workDescription,
themeTags: editState.themeTags,
levelsJson: JSON.stringify(editState.levels),
});
}}
/>
) : null}
{activeTab === 'assets' ? (
<PuzzleAssetConfigTab
activeAssetConfigTab={activeAssetConfigTab}
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
onAssetConfigTabChange={setActiveAssetConfigTab}
onChange={setEditState}
onGenerateUiBackground={(prompt) => {
const firstLevel = editState.levels[0] ?? null;
if (!firstLevel) {
return;
}
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 ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
{!error && autoSaveError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{autoSaveError}
</div>
) : null}
<PuzzleResultActionBar
actionError={error}
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
canStartTestRun={canStartTestRun}
publishReady={publishState.publishReady}
publishBlockers={publishState.blockers}
onStartTestRun={
onStartTestRun
? () => onStartTestRun(syncedDraft)
: undefined
}
onPublish={() => {
if (!publishState.publishReady) {
return;
}
const firstLevel = editState.levels[0]!;
onExecuteAction({
action: 'publish_puzzle_work',
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
levelName: firstLevel.levelName.trim(),
summary: editState.workDescription.trim(),
themeTags: editState.themeTags,
levelsJson: JSON.stringify(editState.levels),
});
}}
/>
{activeLevel ? (
<PuzzleLevelDetailDialog
draft={syncedDraft}
generationNowMs={generationNowMs}
generationRuntime={
generationRuntimeByLevelId[activeLevel.levelId] ?? null
}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
level={activeLevel}
onClose={() => setActiveLevelId(null)}
onGenerate={(nextLevel, promptText, referenceImageSrc, imageModel) => {
updateLevel(nextLevel);
onExecuteAction({
action: 'generate_puzzle_images',
levelId: nextLevel.levelId,
promptText,
referenceImageSrc,
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
candidateCount: 1,
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
summary: editState.workDescription.trim(),
themeTags: editState.themeTags,
levelsJson: JSON.stringify(
editState.levels.map((level) =>
level.levelId === nextLevel.levelId ? nextLevel : level,
),
),
});
}}
onLevelChange={updateLevel}
onStartTestRun={
onStartTestRun
? (level) => onStartTestRun(buildLevelDraft(level))
: undefined
}
/>
) : null}
</div>
);
}
export default PuzzleResultView;