1
This commit is contained in:
@@ -27,7 +27,7 @@ import {
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
PUZZLE_IMAGE_MODEL_ORIGINAL,
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
type PuzzleImageModelId,
|
||||
} from '../puzzle-agent/puzzleImageModelOptions';
|
||||
import { PuzzleImageModelPicker } from '../puzzle-agent/PuzzleImageModelPicker';
|
||||
@@ -56,6 +56,8 @@ type DraftEditState = {
|
||||
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;
|
||||
|
||||
function normalizeThemeTagInput(value: string) {
|
||||
return [
|
||||
@@ -597,11 +599,29 @@ function PuzzleLevelDetailDialog({
|
||||
null,
|
||||
);
|
||||
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_ORIGINAL,
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
);
|
||||
const formalImageSrc = resolveLevelFormalImageSrc(level);
|
||||
const hasFormalImage = Boolean(formalImageSrc);
|
||||
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 handleReferenceImageChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
@@ -626,6 +646,59 @@ 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 = () => {
|
||||
setIsCostConfirmOpen(false);
|
||||
setIsGenerationProgressActive(true);
|
||||
generationBusySeenRef.current = false;
|
||||
setGenerationCountdown(PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS);
|
||||
onGenerate(
|
||||
level.levelId,
|
||||
level.pictureDescription.trim() || undefined,
|
||||
referenceImageSrc || undefined,
|
||||
imageModel,
|
||||
);
|
||||
};
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
@@ -801,25 +874,83 @@ function PuzzleLevelDetailDialog({
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onGenerate(
|
||||
level.levelId,
|
||||
level.pictureDescription.trim() || undefined,
|
||||
referenceImageSrc || undefined,
|
||||
imageModel,
|
||||
);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{hasFormalImage ? '重新生成画面' : '生成画面'}
|
||||
</button>
|
||||
{isGenerationProgressVisible ? (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="画面生成进度"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={generationProgressPercent}
|
||||
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}%` }}
|
||||
/>
|
||||
<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} 秒
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</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"
|
||||
onClick={executeGeneration}
|
||||
className="platform-button platform-button--primary min-h-10 px-5 py-2 text-sm"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isHistoryPickerOpen ? (
|
||||
<PuzzleHistoryAssetPickerDialog
|
||||
isBusy={isBusy}
|
||||
@@ -1420,8 +1551,12 @@ export function PuzzleResultView({
|
||||
levelId,
|
||||
promptText,
|
||||
referenceImageSrc,
|
||||
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_ORIGINAL,
|
||||
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
candidateCount: 1,
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
summary: activeLevel.pictureDescription.trim(),
|
||||
themeTags: editState.themeTags,
|
||||
levelsJson: JSON.stringify(editState.levels),
|
||||
});
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user