1
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user