This commit is contained in:
2026-05-02 17:56:42 +08:00
parent 2311edb2e6
commit acc55d0e13
40 changed files with 2582 additions and 931 deletions

View File

@@ -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),
});
}}