Allow history-generated image paths to be submitted where Data URLs were previously required and avoid treating partial/result-page generations as blocking the whole draft. Backend: resolve history /generated-* references via resolve_puzzle_reference_image_as_data_url and convert to PuzzleDownloadedImage; add PuzzleDownloadedImage::from_resolved_reference_image; extend draft handling to apply generated level metadata (auto-naming) and normalize generation_status to treat levels with images as ready. API: add shouldAutoNameLevel to action contracts and use it to request/refine generated level names. Spacetime/module and mappers: normalize completed level statuses when saving/reading so result-page background or per-level generation doesn't mask completed drafts. Frontend: expose resolver helpers, only mark a work as generating when no usable cover or ready level exists, keep level controls enabled during UI-background regeneration, and add tests covering history-image submission, auto-naming, and UI-background/partial-generation behaviors.
2339 lines
81 KiB
TypeScript
2339 lines
81 KiB
TypeScript
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 { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||
import { updatePuzzleWork } from '../../services/puzzle-works';
|
||
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
|
||
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: 'work', label: '作品信息' },
|
||
{ id: 'levels', label: '拼图关卡' },
|
||
{ id: 'assets', label: '素材配置' },
|
||
];
|
||
|
||
const PUZZLE_ASSET_CONFIG_TABS: Array<{
|
||
id: PuzzleAssetConfigTabId;
|
||
label: string;
|
||
}> = [
|
||
{ id: 'ui', label: 'UI' },
|
||
];
|
||
|
||
type PuzzleLevelGenerationRuntime = {
|
||
startedAtMs: number;
|
||
estimateSeconds: number;
|
||
};
|
||
|
||
type PuzzleUiBackgroundGenerationState = {
|
||
levelId: string;
|
||
prompt: string;
|
||
} | null;
|
||
|
||
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(
|
||
getPuzzleHistoryAssetReferenceLabel(asset.imageSrc),
|
||
);
|
||
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,
|
||
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">
|
||
<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>
|
||
</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,
|
||
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>('work');
|
||
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 [uiBackgroundGeneration, setUiBackgroundGeneration] =
|
||
useState<PuzzleUiBackgroundGenerationState>(null);
|
||
const [generationRuntimeByLevelId, setGenerationRuntimeByLevelId] = useState<
|
||
Record<string, PuzzleLevelGenerationRuntime>
|
||
>({});
|
||
const [generationNowMs, setGenerationNowMs] = useState(() => Date.now());
|
||
const latestEditStateRef = useRef<DraftEditState | null>(
|
||
draft ? createDraftEditState(draft) : null,
|
||
);
|
||
const savedEditStateRef = useRef<DraftEditState | null>(
|
||
draft ? createDraftEditState(draft) : null,
|
||
);
|
||
|
||
useEffect(() => {
|
||
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);
|
||
return;
|
||
}
|
||
const nextState = createDraftEditState(draft);
|
||
const mergedState = mergeDraftEditStateWithIncomingState(
|
||
latestEditStateRef.current,
|
||
nextState,
|
||
);
|
||
latestEditStateRef.current = mergedState;
|
||
savedEditStateRef.current = nextState;
|
||
setEditState(mergedState);
|
||
setGenerationRuntimeByLevelId((current) => {
|
||
const nextRuntimes: Record<string, PuzzleLevelGenerationRuntime> = {};
|
||
mergedState.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 &&
|
||
mergedState.levels.some((level) => level.levelId === currentLevelId)
|
||
? currentLevelId
|
||
: null,
|
||
);
|
||
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(() => {
|
||
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}
|
||
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 ? (
|
||
<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,
|
||
shouldAutoNameLevel: !nextLevel.levelName.trim(),
|
||
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;
|