This commit is contained in:
2026-05-10 22:20:54 +08:00
parent d6219f1a0c
commit 192accd796
92 changed files with 7045 additions and 1559 deletions

View File

@@ -1,8 +1,8 @@
import {
ArrowLeft,
CheckCircle2,
History,
ImagePlus,
Images,
Loader2,
MessageSquareText,
Play,
@@ -22,12 +22,9 @@ import type {
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { updatePuzzleWork } from '../../services/puzzle-works';
import {
puzzleAssetClient,
type PuzzleHistoryAsset,
} from '../../services/puzzle-works/puzzleAssetClient';
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,
@@ -67,7 +64,46 @@ 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_IMAGE_GENERATION_ESTIMATE_SECONDS = 30;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
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 [
@@ -163,6 +199,61 @@ function createDraftEditState(draft: PuzzleResultDraft): DraftEditState {
};
}
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,
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 {
@@ -180,19 +271,6 @@ function createBlankPuzzleLevel(
};
}
function formatHistoryAssetDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value || '';
}
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function buildPublishReady(
session: PuzzleAgentSessionSnapshot,
editState: DraftEditState,
@@ -209,6 +287,9 @@ function buildPublishReady(
)
.map((entry) => entry.message) ?? [];
const levels = editState.levels;
const hasGeneratingLevel = levels.some(
(level) => level.generationStatus === 'generating',
);
const blockers = [
...(session.resultPreview ? [] : ['等待结果页草稿完成后再发布。']),
...preservedBlockers,
@@ -226,7 +307,11 @@ function buildPublishReady(
...(resolveLevelFormalImageSrc(level)
? []
: [`${index + 1}关缺少正式图。`]),
...(level.generationStatus === 'generating'
? [`${index + 1}关画面正在生成。`]
: []),
]),
...(hasGeneratingLevel ? ['还有关卡画面正在生成。'] : []),
];
return {
@@ -462,142 +547,10 @@ function PuzzleThemeTagEditor({
);
}
function PuzzleHistoryAssetPickerDialog({
isBusy,
onClose,
onSelect,
}: {
isBusy: boolean;
onClose: () => void;
onSelect: (asset: PuzzleHistoryAsset) => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [assets, setAssets] = useState<PuzzleHistoryAsset[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(null);
puzzleAssetClient
.listHistoryAssets({ limit: 120 })
.then((nextAssets) => {
if (!cancelled) {
setAssets(nextAssets);
}
})
.catch((loadError) => {
if (!cancelled) {
setError(
loadError instanceof Error
? loadError.message
: '历史拼图素材读取失败。',
);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[145] 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(92vh,46rem)] w-full max-w-5xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
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">
{error ? (
<div className="platform-banner platform-banner--danger text-sm leading-6">
{error}
</div>
) : null}
{isLoading ? (
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
...
</div>
) : null}
{!isLoading && !error && assets.length <= 0 ? (
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
</div>
) : null}
{!isLoading && assets.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{assets.map((asset) => (
<button
key={asset.assetObjectId}
type="button"
disabled={isBusy}
onClick={() => onSelect(asset)}
className={`overflow-hidden rounded-[1.25rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
>
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={asset.imageSrc}
alt={asset.ownerLabel || '历史拼图素材'}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-1 px-4 py-4">
<div className="truncate text-sm font-black text-[var(--platform-text-strong)]">
{asset.ownerLabel || '未记录账号'}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-base)]">
{formatHistoryAssetDate(asset.createdAt)}
</div>
</div>
</button>
))}
</div>
) : null}
</div>
</div>
</div>,
document.body,
);
}
function PuzzleLevelDetailDialog({
draft,
generationNowMs,
generationRuntime,
imageRefreshKey,
isBusy,
level,
@@ -607,12 +560,14 @@ function PuzzleLevelDetailDialog({
onStartTestRun,
}: {
draft: PuzzleResultDraft;
generationNowMs: number;
generationRuntime: PuzzleLevelGenerationRuntime | null;
imageRefreshKey: string;
isBusy: boolean;
level: PuzzleDraftLevel;
onClose: () => void;
onGenerate: (
levelId: string,
level: PuzzleDraftLevel,
promptText?: string | null,
referenceImageSrc?: string | null,
imageModel?: PuzzleImageModelId | null,
@@ -628,10 +583,6 @@ function PuzzleLevelDetailDialog({
);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
const [isGenerationProgressActive, setIsGenerationProgressActive] =
useState(false);
const [generationCountdown, setGenerationCountdown] = useState(0);
const generationBusySeenRef = useRef(false);
const [imageModel, setImageModel] = useState<PuzzleImageModelId>(
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
);
@@ -639,18 +590,14 @@ function PuzzleLevelDetailDialog({
const hasFormalImage = Boolean(formalImageSrc);
const effectiveReferenceImageSrc =
referenceImageSrc.trim() || level.pictureReference?.trim() || '';
const isGenerationProgressVisible = isGenerationProgressActive;
const generationSecondsLeft = isBusy
? Math.max(generationCountdown, 1)
: generationCountdown;
const generationProgressPercent = Math.max(
6,
Math.round(
((PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS -
Math.max(generationSecondsLeft, 0)) /
PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS) *
100,
),
const displayImageSrc = formalImageSrc || effectiveReferenceImageSrc;
const displayImageAlt = formalImageSrc
? level.levelName || draft.workTitle || '拼图关卡'
: '拼图参考图';
const generationProgress = resolvePuzzleLevelGenerationProgress(
level,
generationRuntime,
generationNowMs,
);
const handleReferenceImageChange = async (
@@ -676,54 +623,15 @@ function PuzzleLevelDetailDialog({
}
};
useEffect(() => {
if (!isGenerationProgressActive) {
return;
}
if (generationCountdown <= 0) {
if (!isBusy) {
setIsGenerationProgressActive(false);
}
return;
}
const timer = window.setTimeout(() => {
setGenerationCountdown((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearTimeout(timer);
}, [generationCountdown, isBusy, isGenerationProgressActive]);
useEffect(() => {
if (isGenerationProgressActive && isBusy) {
generationBusySeenRef.current = true;
return;
}
if (
isGenerationProgressActive &&
!isBusy &&
generationBusySeenRef.current
) {
generationBusySeenRef.current = false;
setIsGenerationProgressActive(false);
setGenerationCountdown(0);
}
if (!isBusy) {
setIsCostConfirmOpen(false);
}
}, [isBusy, isGenerationProgressActive]);
const executeGeneration = () => {
const nextLevel = {
...level,
generationStatus: 'generating' as const,
};
setIsCostConfirmOpen(false);
setIsGenerationProgressActive(true);
generationBusySeenRef.current = false;
setGenerationCountdown(PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS);
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
nextLevel,
nextLevel.pictureDescription.trim() || undefined,
effectiveReferenceImageSrc || undefined,
imageModel,
);
@@ -780,127 +688,134 @@ function PuzzleLevelDetailDialog({
/>
</section>
{hasFormalImage ? (
<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="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<div className="mb-3 text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="relative mt-3 aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={formalImageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={level.levelName || draft.workTitle || '拼图关卡'}
className="h-full w-full object-cover"
/>
<button
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label="从历史拼图素材库选择"
title="从历史拼图素材库选择"
>
<Images className="h-4 w-4" />
</button>
</div>
</section>
) : null}
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="relative mt-3">
<textarea
value={level.pictureDescription}
disabled={isBusy}
rows={9}
onChange={(event) =>
onLevelChange({
...level,
pictureDescription: event.target.value,
})
}
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<PuzzleImageModelPicker
value={imageModel}
disabled={isBusy}
onChange={setImageModel}
/>
<label
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={effectiveReferenceImageSrc ? '更换参考图' : '添加参考图'}
>
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{effectiveReferenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
<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="hidden"
className="sr-only"
/>
</label>
</div>
<input
value={level.pictureReference ?? ''}
disabled={isBusy}
onChange={(event) =>
onLevelChange({
...level,
pictureReference: event.target.value,
})
}
className="mt-3 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm text-[var(--platform-text-strong)] outline-none"
placeholder="参考图链接或资产ID"
aria-label="图面参考"
/>
{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-14 w-14 overflow-hidden rounded-[0.9rem] 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="移除参考图"
<label
htmlFor={`puzzle-level-reference-upload-${level.levelId}`}
className={`absolute inset-0 z-0 cursor-pointer ${isBusy ? 'cursor-not-allowed' : ''}`}
title={
effectiveReferenceImageSrc ? '更换参考图' : '上传参考图'
}
>
<X className="h-4 w-4" />
</button>
<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>
) : null}
{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>
{referenceImageError ? (
<div className="mt-2 text-xs leading-5 text-red-600">
{referenceImageError}
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
) : null}
</section>
<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>
@@ -919,22 +834,22 @@ function PuzzleLevelDetailDialog({
</button>
) : null}
{isGenerationProgressVisible ? (
{generationProgress.isGenerating ? (
<div
role="progressbar"
aria-label="画面生成进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={generationProgressPercent}
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: `${generationProgressPercent}%` }}
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" />
{generationSecondsLeft}
{generationProgress.secondsLeft}
</div>
</div>
) : (
@@ -942,12 +857,17 @@ function PuzzleLevelDetailDialog({
type="button"
disabled={isBusy}
onClick={() => setIsCostConfirmOpen(true)}
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
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"
>
<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 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>
)}
@@ -986,8 +906,9 @@ function PuzzleLevelDetailDialog({
</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"
className={`platform-button platform-button--primary min-h-10 px-5 py-2 text-sm ${isBusy || generationProgress.isGenerating ? 'opacity-55' : ''}`}
>
</button>
@@ -1221,6 +1142,8 @@ function PuzzleCreativeDraftEditBar({
function PuzzleLevelListTab({
editState,
generationNowMs,
generationRuntimeByLevelId,
imageRefreshKey,
isBusy,
onAddLevel,
@@ -1228,6 +1151,8 @@ function PuzzleLevelListTab({
onOpenLevel,
}: {
editState: DraftEditState;
generationNowMs: number;
generationRuntimeByLevelId: Record<string, PuzzleLevelGenerationRuntime>;
imageRefreshKey: string;
isBusy: boolean;
onAddLevel: () => void;
@@ -1236,10 +1161,18 @@ function PuzzleLevelListTab({
}) {
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-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}
@@ -1250,7 +1183,7 @@ function PuzzleLevelListTab({
onClick={() => onOpenLevel(level.levelId)}
className="block w-full text-left"
>
<div className="aspect-[4/3] overflow-hidden bg-[var(--platform-subpanel-fill)]">
<div className="relative aspect-[4/3] overflow-hidden bg-[var(--platform-subpanel-fill)]">
{imageSrc ? (
<ResolvedAssetImage
src={imageSrc}
@@ -1263,10 +1196,23 @@ function PuzzleLevelListTab({
</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="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
{index + 1}
<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>
@@ -1452,6 +1398,10 @@ export function PuzzleResultView({
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,
);
@@ -1466,8 +1416,27 @@ export function PuzzleResultView({
return;
}
const nextState = createDraftEditState(draft);
savedEditStateRef.current = nextState;
setEditState(nextState);
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)
@@ -1492,6 +1461,45 @@ export function PuzzleResultView({
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) {
@@ -1507,6 +1515,8 @@ export function PuzzleResultView({
...level,
levelName: level.levelName.trim(),
pictureDescription: level.pictureDescription.trim(),
pictureReference: level.pictureReference?.trim() || null,
generationStatus: level.generationStatus || 'idle',
})),
};
const originalState =
@@ -1580,6 +1590,26 @@ export function PuzzleResultView({
}
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
? {
@@ -1627,6 +1657,8 @@ export function PuzzleResultView({
{activeTab === 'levels' ? (
<PuzzleLevelListTab
editState={editState}
generationNowMs={generationNowMs}
generationRuntimeByLevelId={generationRuntimeByLevelId}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
onAddLevel={() => {
@@ -1721,23 +1753,33 @@ export function PuzzleResultView({
{activeLevel ? (
<PuzzleLevelDetailDialog
draft={syncedDraft}
generationNowMs={generationNowMs}
generationRuntime={
generationRuntimeByLevelId[activeLevel.levelId] ?? null
}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
level={activeLevel}
onClose={() => setActiveLevelId(null)}
onGenerate={(levelId, promptText, referenceImageSrc, imageModel) => {
onGenerate={(nextLevel, promptText, referenceImageSrc, imageModel) => {
updateLevel(nextLevel);
onExecuteAction({
action: 'generate_puzzle_images',
levelId,
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),
levelsJson: JSON.stringify(
editState.levels.map((level) =>
level.levelId === nextLevel.levelId ? nextLevel : level,
),
),
});
}}
onLevelChange={updateLevel}