This commit is contained in:
2026-05-13 00:28:07 +08:00
parent ef4f91a75e
commit 01c5ab985a
101 changed files with 10635 additions and 2292 deletions

View File

@@ -1,8 +1,10 @@
import {
ArrowLeft,
CheckCircle2,
Eye,
History,
ImagePlus,
LayoutTemplate,
Loader2,
MessageSquareText,
Music,
@@ -10,6 +12,7 @@ import {
Plus,
Sparkles,
Trash2,
Wand2,
X,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
@@ -58,7 +61,7 @@ type PuzzleResultViewProps = {
};
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type PuzzleResultTab = 'levels' | 'work' | 'music';
type PuzzleResultTab = 'levels' | 'work' | 'ui' | 'music';
type DraftEditState = {
workTitle: string;
@@ -74,6 +77,8 @@ const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND = 'puzzle_background_music';
const PUZZLE_BACKGROUND_MUSIC_SLOT = 'background_music';
const PUZZLE_UI_BACKGROUND_REFERENCE_SRC =
'/ui-previews/puzzle-image-compact-ui-2026-05-08.png';
type PuzzleLevelGenerationRuntime = {
startedAtMs: number;
@@ -125,6 +130,26 @@ function normalizeThemeTagInput(value: string) {
];
}
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(
@@ -169,6 +194,9 @@ function normalizeDraftLevels(draft: PuzzleResultDraft) {
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,
@@ -250,6 +278,13 @@ function mergeDraftEditStateWithIncomingState(
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,
generationStatus: incomingLevel.generationStatus || 'ready',
};
});
@@ -273,6 +308,9 @@ function createBlankPuzzleLevel(
levelName: '',
pictureDescription: '',
pictureReference: null,
uiBackgroundPrompt: null,
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey: null,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
@@ -381,10 +419,11 @@ function PuzzleResultTabs({
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">
<div className="mb-3 grid grid-cols-4 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
{[
{ id: 'levels' as const, label: '拼图关卡' },
{ id: 'work' as const, label: '作品信息' },
{ id: 'ui' as const, label: 'UI' },
{ id: 'music' as const, label: '音乐' },
].map((tab) => (
<button
@@ -1327,6 +1366,246 @@ function PuzzleWorkInfoTab({
);
}
function PuzzleUiAssetsTab({
editState,
imageRefreshKey,
isBusy,
onChange,
onGenerate,
}: {
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
onChange: (nextState: DraftEditState) => void;
onGenerate: (prompt: string) => void;
}) {
const firstLevel = editState.levels[0] ?? null;
const formalImageSrc = firstLevel ? resolveLevelFormalImageSrc(firstLevel) : '';
const defaultPrompt = buildDefaultPuzzleUiBackgroundPrompt(
editState,
firstLevel,
);
const prompt = firstLevel?.uiBackgroundPrompt ?? defaultPrompt;
const normalizedPrompt = prompt.trim() || defaultPrompt.trim();
const backgroundPreviewSrc =
firstLevel?.uiBackgroundImageSrc?.trim() || PUZZLE_UI_BACKGROUND_REFERENCE_SRC;
const [isPreviewOpen, setIsPreviewOpen] = 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}
onClick={() => {
if (!firstLevel || !normalizedPrompt) {
return;
}
updateFirstLevel({
...firstLevel,
uiBackgroundPrompt: normalizedPrompt,
});
onGenerate(normalizedPrompt);
}}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'}
</button>
</div>
</div>
</div>
</section>
{isPreviewOpen ? (
<PuzzleUiRuntimePreviewPanel
backgroundPreviewSrc={backgroundPreviewSrc}
imageRefreshKey={imageRefreshKey}
puzzleImageSrc={formalImageSrc}
title={editState.workTitle || firstLevel?.levelName || '拼图'}
onClose={() => setIsPreviewOpen(false)}
/>
) : 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=""
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 PuzzleMusicTab({
editState,
profileId,
@@ -1341,25 +1620,20 @@ function PuzzleMusicTab({
onChange: (nextState: DraftEditState) => void;
}) {
const currentMusic = editState.levels[0]?.backgroundMusic ?? null;
const [prompt, setPrompt] = useState(() =>
[
editState.workTitle.trim(),
editState.workDescription.trim(),
editState.themeTags.join(''),
'轻快、适合拼图游戏循环播放的背景音乐',
]
.filter(Boolean)
.join(''),
);
const [title, setTitle] = useState(() =>
`${editState.workTitle.trim() || '拼图'}背景音乐`.slice(0, 40),
(
currentMusic?.title?.trim() ||
editState.levels[0]?.levelName.trim() ||
editState.workTitle.trim() ||
'拼图'
).slice(0, 40),
);
const [tags, setTags] = useState('轻快, 游戏, 循环, instrumental');
const [statusText, setStatusText] = useState<string | null>(null);
const [errorText, setErrorText] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const canGenerate = prompt.trim().length > 0 && title.trim().length > 0;
const canGenerate = title.trim().length > 0;
const writeMusic = (music: CreationAudioAsset) => {
const firstLevel = editState.levels[0];
if (!firstLevel) {
@@ -1383,7 +1657,7 @@ function PuzzleMusicTab({
setErrorText(null);
try {
const task = await createBackgroundMusicTask({
prompt: prompt.trim(),
prompt: '',
title: title.trim(),
tags: tags.trim() || null,
});
@@ -1406,7 +1680,7 @@ function PuzzleMusicTab({
assetObjectId: asset.assetObjectId ?? null,
assetKind: asset.assetKind ?? PUZZLE_BACKGROUND_MUSIC_ASSET_KIND,
audioSrc: asset.audioSrc,
prompt: prompt.trim(),
prompt: '',
title: title.trim(),
updatedAt: new Date().toISOString(),
});
@@ -1473,19 +1747,6 @@ function PuzzleMusicTab({
aria-label="背景音乐风格"
/>
</label>
<label className="mt-3 block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={prompt}
disabled={isBusy || isGenerating}
rows={5}
onChange={(event) => setPrompt(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="背景音乐提示词"
/>
</label>
<button
type="button"
disabled={!canGenerate || isBusy || isGenerating}
@@ -1497,7 +1758,7 @@ function PuzzleMusicTab({
) : (
<Music className="h-4 w-4" />
)}
{currentMusic ? '重新生成音乐' : '生成音乐'}
</button>
</section>
@@ -1711,6 +1972,10 @@ export function PuzzleResultView({
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',
})),
};
@@ -1909,6 +2174,39 @@ export function PuzzleResultView({
}}
/>
) : null}
{activeTab === 'ui' ? (
<PuzzleUiAssetsTab
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
onChange={setEditState}
onGenerate={(prompt) => {
const firstLevel = editState.levels[0] ?? null;
if (!firstLevel) {
return;
}
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}
{activeTab === 'music' ? (
<PuzzleMusicTab
editState={editState}