This commit is contained in:
2026-05-14 01:11:58 +08:00
parent b13870f71b
commit 5a55180b78
61 changed files with 5050 additions and 1057 deletions

View File

@@ -1,6 +1,5 @@
import {
ArrowLeft,
Box,
CheckCircle2,
Eye,
ImageIcon,
@@ -10,6 +9,7 @@
Play,
Plus,
Send,
Trash2,
Wand2,
X,
} from 'lucide-react';
@@ -29,6 +29,8 @@ import type {
Match3DWorkProfile,
PutMatch3DWorkRequest,
} from '../../../packages/shared/src/contracts/match3dWorks';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import {
createBackgroundMusicTask,
createSoundEffectTask,
@@ -40,8 +42,8 @@ import {
generateMatch3DBackgroundImage,
generateMatch3DCoverImage,
generateMatch3DItemAssets,
publishMatch3DWork,
generateMatch3DWorkTags,
publishMatch3DWork,
updateMatch3DGeneratedItemAssets,
updateMatch3DWork,
} from '../../services/match3d-works';
@@ -50,8 +52,14 @@ import {
resolveMatch3DGeneratedImageAssetSource,
resolveMatch3DGeneratedModelAssetSource,
} from '../../services/match3dGeneratedModelCache';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import { useAuthUi } from '../auth/AuthUiContext';
import {
MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
MATCH3D_RUNTIME_GLASS_SPINNER_CLASS,
MATCH3D_RUNTIME_GLASS_TIMER_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
} from '../match3d-runtime/match3dRuntimeUiStyles';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type Match3DResultViewProps = {
@@ -135,7 +143,11 @@ const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600;
const MATCH3D_DEFAULT_ASSET_COUNT = 6;
const MATCH3D_BACKGROUND_MUSIC_ASSET_KIND = 'match3d_background_music';
const MATCH3D_CLICK_SOUND_ASSET_KIND = 'match3d_click_sound';
const MATCH3D_AUDIO_POINTS_COST = 10;
const MATCH3D_BACKGROUND_MUSIC_POINTS_COST = 5;
const MATCH3D_CLICK_SOUND_POINTS_COST = 10;
const MATCH3D_UI_BACKGROUND_POINTS_COST = 2;
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH = 2;
const MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE = 5;
const MATCH3D_RESULT_TABS: Array<{ id: Match3DResultTab; label: string }> = [
{ id: 'work', label: '作品信息' },
@@ -180,13 +192,11 @@ const MATCH3D_DIFFICULTY_OPTIONS = [
type Match3DDifficultyOptionId =
(typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id'];
type Match3DDifficultyOption = (typeof MATCH3D_DIFFICULTY_OPTIONS)[number];
const MATCH3D_FALLBACK_BACKGROUND_PREVIEW_SRC =
const MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC =
'/match3d-background-references/pot-fused-reference.png';
const MATCH3D_DIFFICULTY_CARD_CLASS =
'min-h-[5.25rem] rounded-[1rem] border px-3 py-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200';
const MATCH3D_MATERIAL_TAB_BUTTON_CLASS =
'min-h-10 rounded-[0.9rem] px-3 text-sm font-bold transition';
@@ -279,7 +289,7 @@ function resolveMatch3DBackgroundPreviewSource(
'',
)
.find(Boolean) ||
MATCH3D_FALLBACK_BACKGROUND_PREVIEW_SRC
''
);
}
@@ -300,9 +310,24 @@ function resolveMatch3DBackgroundPrompt(
);
}
function resolveMatch3DContainerPreviewSource(
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
'',
)
.find(Boolean) || ''
);
}
function buildFallbackMatch3DBackgroundPrompt(themeText: string) {
const theme = themeText.trim() || '抓大鹅';
return `${theme}题材抓大鹅游戏竖屏背景图,绿色纵向渐变背景与居中浅锅、圆盘状竞技区域自然融合,中央区域留出清晰可玩空间,无文字、无水印、无 UI、无按钮、无倒计时、无物品。`;
return `${theme}题材抓大鹅游戏竖屏背景图,表现题材环境、绿色纵向渐变和轻快休闲氛围,中央区域保持干净通透,无锅、无圆盘、无托盘、无拼图槽、无物品槽、无文字、无水印、无 UI、无按钮、无倒计时、无物品。`;
}
function normalizeTags(value: string) {
@@ -348,19 +373,6 @@ function buildFallbackMatch3DClickSoundPrompt(
return `${normalizedTheme}题材抓大鹅中“${normalizedName}”被点击并消除时的短促反馈音效,清脆、可爱、有轻微弹跳感,适合移动端休闲游戏。`;
}
function buildFallbackMatch3DBackgroundMusicPrompt(
editState: Match3DResultEditState,
) {
return [
editState.gameName.trim(),
editState.themeText.trim(),
editState.summary.trim(),
'轻快、适合抓大鹅消除游戏循环播放的背景音乐',
]
.filter(Boolean)
.join('');
}
function normalizeMatch3DTag(value: string) {
return value
.trim()
@@ -380,18 +392,6 @@ function normalizeMatch3DTagListText(value: string) {
];
}
function parseMatch3DItemNameInput(value: string) {
return [
...new Set(
value
.split(/[\n,;]/u)
.map((item) => item.trim().replace(/^[-*\d.)\s]+/u, ''))
.filter(Boolean)
.map((item) => item.slice(0, 12)),
),
];
}
function normalizeMatch3DItemName(value: string) {
return value
.trim()
@@ -403,6 +403,16 @@ function normalizeMatch3DItemNameList(values: readonly string[]) {
return [...new Set(values.map(normalizeMatch3DItemName).filter(Boolean))];
}
function calculateMatch3DItemAssetsPointsCost(itemCount: number) {
if (itemCount <= 0) {
return 0;
}
return (
Math.ceil(itemCount / MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE) *
MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH
);
}
function hasMatch3DGeneratedModelSource(asset: Match3DGeneratedItemAsset) {
return Boolean(asset.modelSrc?.trim() || asset.modelObjectKey?.trim());
}
@@ -462,6 +472,8 @@ function hasPersistableMatch3DGeneratedItemAsset(
asset.subscriptionKey?.trim() ||
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
asset.backgroundAsset?.prompt?.trim() ||
asset.backgroundMusic ||
asset.clickSound,
@@ -499,6 +511,9 @@ function getMatch3DGeneratedItemAssetPersistenceSignature(
asset.backgroundAsset?.prompt?.trim() ?? '',
asset.backgroundAsset?.imageSrc?.trim() ?? '',
asset.backgroundAsset?.imageObjectKey?.trim() ?? '',
asset.backgroundAsset?.containerPrompt?.trim() ?? '',
asset.backgroundAsset?.containerImageSrc?.trim() ?? '',
asset.backgroundAsset?.containerImageObjectKey?.trim() ?? '',
asset.backgroundAsset?.status?.trim() ?? '',
asset.backgroundAsset?.error?.trim() ?? '',
asset.clickSound?.audioSrc?.trim() ??
@@ -817,28 +832,6 @@ function normalizeMatch3DAssetStatus(status: string): Match3DAssetTaskStatus {
return 'unknown';
}
function getMatch3DAssetStatusLabel(status: Match3DAssetTaskStatus) {
if (status === 'idle') return '未生成';
if (status === 'submitting') return '提交中';
if (status === 'waiting') return '排队中';
if (status === 'generating') return '生成中';
if (status === 'image_ready') return '素材已就绪';
if (status === 'done') return '已完成';
if (status === 'failed') return '失败';
return '待确认';
}
function getMatch3DAssetStatusPillClass(status: Match3DAssetTaskStatus) {
if (status === 'done') return 'platform-pill--success';
if (status === 'failed') return 'platform-pill--rose';
if (status === 'image_ready') return 'platform-pill--cool';
if (status === 'generating' || status === 'submitting') {
return 'platform-pill--warm';
}
if (status === 'waiting') return 'platform-pill--cool';
return 'platform-pill--neutral';
}
function Match3DAudioProgress({
label,
progress,
@@ -863,6 +856,35 @@ function Match3DAudioProgress({
);
}
function Match3DResolvedAudio({
ariaLabel,
src,
}: {
ariaLabel?: string;
src: string;
}) {
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
expireSeconds: 300,
});
if (!resolvedUrl) {
return (
<div className="mt-3 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3 text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
);
}
return (
<audio
className="mt-3 w-full"
controls
src={resolvedUrl}
aria-label={ariaLabel}
/>
);
}
function getMatch3DBatchGenerationStatusLabel(
phase: Match3DBatchItemGenerationState['phase'],
) {
@@ -1653,48 +1675,120 @@ function Match3DConfigTab({
onChange: (nextState: Match3DResultEditState) => void;
}) {
const selectedOption = getMatch3DDifficultyOptionFromEditState(editState);
const selectedOptionIndex = MATCH3D_DIFFICULTY_OPTIONS.findIndex(
(option) => option.id === selectedOption.id,
);
const selectedSliderIndex = Math.max(0, selectedOptionIndex);
const runtimeTypeCount = selectedOption.itemTypeCount;
const readyItemTypeCount = getMatch3DReadyItemTypeCount(generatedItemAssets);
const trackProgress =
selectedSliderIndex / Math.max(1, MATCH3D_DIFFICULTY_OPTIONS.length - 1);
const applyDifficultyOption = (option: Match3DDifficultyOption) => {
onChange({
...editState,
clearCountText: String(option.clearCount),
difficultyText: String(option.difficulty),
});
};
const handleSliderChange = (event: ChangeEvent<HTMLInputElement>) => {
const nextIndex = Number.parseInt(event.target.value, 10);
const nextOption = MATCH3D_DIFFICULTY_OPTIONS[nextIndex];
if (nextOption) {
applyDifficultyOption(nextOption);
}
};
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
const selected = selectedOption.id === option.id;
return (
<button
key={option.id}
type="button"
disabled={isBusy}
onClick={() =>
onChange({
...editState,
clearCountText: String(option.clearCount),
difficultyText: String(option.difficulty),
})
}
className={`${MATCH3D_DIFFICULTY_CARD_CLASS} ${
selected
? 'border-[#ff7890] bg-[linear-gradient(180deg,#ff7890_0%,#ff4f6a_100%)] text-white shadow-[0_10px_24px_rgba(244,63,94,0.18)]'
: 'border-[var(--platform-subpanel-border)] bg-white/76 text-[var(--platform-text-strong)] hover:border-rose-200 hover:bg-white'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
>
<div className="text-base font-black">{option.label}</div>
<div className="relative px-1 pb-1 pt-2">
<div className="relative mx-[1.35rem] h-10">
<div className="absolute left-0 right-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-white/75 shadow-[inset_0_0_0_1px_rgba(244,114,182,0.16)]" />
<div
className="absolute left-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-[linear-gradient(90deg,#ff8aac_0%,#ff5f7e_54%,#ff9b88_100%)] transition-[width] duration-200"
style={{ width: `${trackProgress * 100}%` }}
/>
{MATCH3D_DIFFICULTY_OPTIONS.map((option, index) => {
const selected = selectedOption.id === option.id;
return (
<div
className={`mt-2 grid grid-cols-2 gap-1 text-[11px] font-bold ${
key={option.id}
aria-hidden="true"
className={`absolute top-1/2 flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
selected
? 'text-white/88'
: 'text-[var(--platform-text-base)]'
}`}
? 'border-[#ff5f7e] bg-white shadow-[0_8px_18px_rgba(244,63,94,0.2)]'
: 'border-rose-100 bg-white/90 hover:border-rose-200'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
style={{
left: `${(index / (MATCH3D_DIFFICULTY_OPTIONS.length - 1)) * 100}%`,
}}
>
<span>{option.clearCount} </span>
<span>{option.itemTypeCount} </span>
<span
className={`h-3.5 w-3.5 rounded-full ${
selected
? 'bg-[var(--platform-accent)]'
: 'bg-rose-100'
}`}
/>
</div>
</button>
);
})}
);
})}
<input
type="range"
min={0}
max={MATCH3D_DIFFICULTY_OPTIONS.length - 1}
step={1}
value={selectedSliderIndex}
disabled={isBusy}
onChange={handleSliderChange}
className="absolute inset-x-0 top-1/2 z-10 h-10 -translate-y-1/2 cursor-pointer opacity-0 disabled:cursor-not-allowed"
aria-label="难度"
aria-valuetext={selectedOption.label}
/>
</div>
<div className="mt-3 grid grid-cols-4 gap-1">
{MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
const selected = selectedOption.id === option.id;
return (
<button
key={option.id}
type="button"
disabled={isBusy}
onClick={() => applyDifficultyOption(option)}
className={`rounded-[0.9rem] px-1.5 py-2 text-center transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
selected
? 'bg-[#fff1f5] text-[var(--platform-text-strong)] shadow-[inset_0_0_0_1px_rgba(244,63,94,0.18)]'
: 'text-[var(--platform-text-base)] hover:bg-white/58'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
>
<div className="text-sm font-black">{option.label}</div>
<div className="mt-1 text-[10px] font-bold leading-4 text-[var(--platform-text-soft)]">
{option.clearCount} · {option.itemTypeCount}
</div>
</button>
);
})}
</div>
</div>
<div className="mt-3 rounded-[1rem] border border-rose-100/80 bg-white/62 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-lg font-black text-[var(--platform-text-strong)]">
{selectedOption.label}
</div>
<div className="mt-1 text-xs font-bold text-[var(--platform-text-base)]">
{selectedOption.clearCount} · {selectedOption.itemTypeCount}{' '}
</div>
</div>
<div className="rounded-full bg-[var(--platform-accent)] px-3 py-1 text-xs font-black text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]">
{selectedOption.difficulty}
</div>
</div>
</div>
<div className="sr-only" aria-live="polite">
{selectedOption.label}
</div>
</section>
@@ -1735,67 +1829,51 @@ function Match3DItemAssetListCard({
onClick: () => void;
onDelete: () => void;
}) {
const pillClass = getMatch3DAssetStatusPillClass(asset.status);
const previewSources = resolveMatch3DAssetDraftPreviewSources(asset);
const previewSource = previewSources[0] ?? asset.referenceImageSrc.trim();
return (
<div
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${
active ? 'border-emerald-300/55 bg-emerald-500/10' : 'platform-subpanel'
className={`group min-w-0 rounded-[1.15rem] border p-2 text-left transition-colors ${
active
? 'border-rose-300/70 bg-rose-50/80'
: 'border-[var(--platform-subpanel-border)] bg-white/76 hover:border-rose-200 hover:bg-white'
}`}
>
<div className="flex items-start gap-3">
<div className="grid min-h-full grid-rows-[minmax(0,1fr)_auto] gap-2">
<button
type="button"
onClick={onClick}
className="flex min-w-0 flex-1 items-start gap-3 text-left"
className="grid min-h-0 gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200"
aria-label={`打开${asset.name}物品素材`}
>
<div className="platform-subpanel grid h-[4.75rem] w-[4.75rem] shrink-0 place-items-center overflow-hidden rounded-[1rem]">
{asset.referenceImageSrc ? (
<div className="grid aspect-square min-h-0 place-items-center overflow-hidden rounded-[0.95rem] border border-[var(--platform-subpanel-border)] bg-white/82">
{previewSource ? (
<ResolvedAssetImage
src={asset.referenceImageSrc}
src={previewSource}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
className="h-full w-full object-contain p-1"
/>
) : (
<Box className="h-7 w-7 text-[var(--platform-text-soft)]" />
<ImageIcon className="h-7 w-7 text-[var(--platform-text-soft)]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 text-[15px] font-semibold leading-5 text-[var(--platform-text-strong)]">
{asset.name}
</div>
<span
className={`platform-pill ${pillClass} shrink-0 px-2.5 py-1 text-[10px]`}
>
{getMatch3DAssetStatusLabel(asset.status)}
</span>
</div>
<div className="mt-1.5 line-clamp-2 text-sm leading-5 text-[var(--platform-text-base)]">
{asset.usage}
</div>
<div className="mt-2 flex flex-wrap gap-2">
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{previewSources.length}
</span>
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
2D素材
</span>
</div>
</div>
</button>
<button
type="button"
onClick={onDelete}
className="platform-icon-button h-8 w-8 shrink-0"
aria-label="删除物品素材"
title="删除"
>
<X className="h-4 w-4" />
<span className="truncate text-[13px] font-bold leading-5 text-[var(--platform-text-strong)]">
{asset.name}
</span>
</button>
<div className="flex min-w-0 justify-end">
<button
type="button"
onClick={onDelete}
className="platform-icon-button h-8 w-8 shrink-0 text-rose-500"
aria-label="删除物品素材"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
@@ -1821,11 +1899,14 @@ function Match3DItemAssetDetail({
return (
<section className="platform-subpanel min-h-0 rounded-[1.5rem] p-4 sm:p-5">
<div className="grid min-h-0 gap-4 lg:grid-cols-[minmax(18rem,0.95fr)_minmax(14rem,0.62fr)]">
<div className="grid aspect-square min-h-[18rem] grid-cols-2 gap-2 overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3">
<div
className="grid aspect-square min-h-[18rem] grid-cols-[repeat(5,minmax(0,1fr))] grid-rows-1 gap-2 overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3"
aria-label={`${asset.name}五视角预览`}
>
{previewSources.map((source, index) => (
<div
key={`${source}-${index}`}
className="grid min-h-0 place-items-center overflow-hidden rounded-[0.9rem] bg-white/80"
className="grid aspect-square h-auto min-h-0 w-full self-center place-items-center overflow-hidden rounded-[0.9rem] bg-white/80"
>
<ResolvedAssetImage
src={source}
@@ -1835,6 +1916,14 @@ function Match3DItemAssetDetail({
/>
</div>
))}
{previewSources.length <= 0 ? (
<div
className="col-span-5 grid min-h-0 place-items-center text-[var(--platform-text-soft)]"
aria-hidden="true"
>
<ImageIcon className="h-10 w-10" />
</div>
) : null}
</div>
<div className="min-h-0 space-y-3">
@@ -1862,8 +1951,8 @@ function Match3DItemAssetDetail({
disabled={busy || soundBusy}
onClick={() => onGenerateClickSound(asset)}
className={`platform-icon-button h-9 w-9 ${busy || soundBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label={`生成点击音效,${MATCH3D_AUDIO_POINTS_COST}光点`}
title={`生成点击音效 · ${MATCH3D_AUDIO_POINTS_COST}光点`}
aria-label={`生成点击音效,${MATCH3D_CLICK_SOUND_POINTS_COST}光点`}
title={`生成点击音效 · ${MATCH3D_CLICK_SOUND_POINTS_COST}光点`}
>
{soundBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -1892,10 +1981,9 @@ function Match3DItemAssetDetail({
/>
) : null}
{asset.clickSound?.audioSrc ? (
<audio
className="mt-3 w-full"
controls
<Match3DResolvedAudio
src={asset.clickSound.audioSrc}
ariaLabel={`${asset.name}点击音效`}
/>
) : (
<div className="mt-3 text-sm font-semibold text-[var(--platform-text-soft)]">
@@ -1948,7 +2036,10 @@ function Match3DAssetsTab({
</button>
</div>
<Match3DBatchGenerationProgress generationState={batchGenerationState} />
<section className="space-y-3" aria-label="抓大鹅 2D 素材列表">
<section
className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-4"
aria-label="抓大鹅 2D 素材列表"
>
{assets.map((asset) => (
<Match3DItemAssetListCard
key={asset.id}
@@ -2002,6 +2093,7 @@ function Match3DBatchAddItemsPanel({
}) {
const parsedNames = normalizeMatch3DItemNameList(values);
const isGenerating = generationState.phase === 'generating';
const pointsCost = calculateMatch3DItemAssetsPointsCost(parsedNames.length);
return (
<Match3DModalShell title="批量新增物品" onClose={onClose}>
@@ -2061,7 +2153,7 @@ function Match3DBatchAddItemsPanel({
) : (
<Plus className="h-4 w-4" />
)}
· {pointsCost}
</button>
</div>
</Match3DModalShell>
@@ -2191,7 +2283,10 @@ function Match3DMusicTab({
/>
) : null}
{currentMusic?.audioSrc ? (
<audio className="mt-3 w-full" controls src={currentMusic.audioSrc} />
<Match3DResolvedAudio
src={currentMusic.audioSrc}
ariaLabel="抓大鹅背景音乐"
/>
) : (
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
<Music className="h-4 w-4" />
@@ -2236,7 +2331,7 @@ function Match3DMusicTab({
) : (
<Music className="h-4 w-4" />
)}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {MATCH3D_AUDIO_POINTS_COST}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {MATCH3D_BACKGROUND_MUSIC_POINTS_COST}
</button>
</section>
@@ -2279,6 +2374,7 @@ function Match3DAssetConfigTabs({
function Match3DUIAssetsTab({
backgroundPreviewSrc,
containerPreviewSrc,
backgroundPrompt,
busy,
isGenerating,
@@ -2286,6 +2382,7 @@ function Match3DUIAssetsTab({
onGenerate,
}: {
backgroundPreviewSrc: string;
containerPreviewSrc: string;
backgroundPrompt: string;
busy: boolean;
isGenerating: boolean;
@@ -2353,7 +2450,7 @@ function Match3DUIAssetsTab({
) : (
<Wand2 className="h-4 w-4" />
)}
· {MATCH3D_UI_BACKGROUND_POINTS_COST}
</button>
</div>
</div>
@@ -2369,6 +2466,7 @@ function Match3DUIAssetsTab({
{isPreviewOpen ? (
<Match3DUIRuntimePreviewPanel
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
onClose={() => setIsPreviewOpen(false)}
/>
) : null}
@@ -2378,9 +2476,11 @@ function Match3DUIAssetsTab({
function Match3DUIRuntimePreviewPanel({
backgroundPreviewSrc,
containerPreviewSrc,
onClose,
}: {
backgroundPreviewSrc: string;
containerPreviewSrc: string;
onClose: () => void;
}) {
return (
@@ -2395,14 +2495,14 @@ function Match3DUIRuntimePreviewPanel({
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
<header className="relative z-10 flex items-center justify-between gap-2">
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 backdrop-blur">
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
<ArrowLeft size={20} />
</span>
<span className="flex items-center gap-2 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-sm font-black backdrop-blur">
<span className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>
1:30
</span>
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 backdrop-blur">
<span className="h-4 w-4 rounded-full border-2 border-white/84 border-l-transparent" />
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
<span className={MATCH3D_RUNTIME_GLASS_SPINNER_CLASS} />
</span>
</header>
@@ -2412,16 +2512,25 @@ function Match3DUIRuntimePreviewPanel({
style={{ width: 'min(92%, 58dvh, 100%)' }}
aria-hidden="true"
>
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
{containerPreviewSrc ? (
<ResolvedAssetImage
src={containerPreviewSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-[-4%] h-[108%] w-[108%] object-contain"
/>
) : (
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
)}
</div>
</section>
<section className="relative z-10 mt-3 rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
<section className={`relative z-10 ${MATCH3D_RUNTIME_GLASS_TRAY_CLASS}`}>
<div className="grid grid-cols-7 gap-1.5">
{Array.from({ length: 7 }).map((_, index) => (
<span
key={index}
className="h-14 rounded-xl bg-white/10 sm:h-16"
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
/>
))}
</div>
@@ -2437,6 +2546,7 @@ function Match3DAssetConfigTab({
activeAssetId,
assetDrafts,
backgroundPreviewSrc,
containerPreviewSrc,
backgroundPrompt,
backgroundGenerationError,
batchGenerationState,
@@ -2459,6 +2569,7 @@ function Match3DAssetConfigTab({
activeAssetId: string | null;
assetDrafts: Match3DItemAssetDraft[];
backgroundPreviewSrc: string;
containerPreviewSrc: string;
backgroundPrompt: string;
backgroundGenerationError: string | null;
batchGenerationState: Match3DBatchItemGenerationState;
@@ -2507,6 +2618,7 @@ function Match3DAssetConfigTab({
{activeAssetConfigTab === 'ui' ? (
<Match3DUIAssetsTab
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
backgroundPrompt={backgroundPrompt}
busy={busy}
isGenerating={isGeneratingBackground}
@@ -2578,7 +2690,7 @@ export function Match3DResultView({
const [isGeneratingTags, setIsGeneratingTags] = useState(false);
const generatedItemAssets = useMemo(
() => resolveMatch3DResultGeneratedItemAssets(profile, draft),
[draft?.generatedItemAssets, profile],
[draft, profile],
);
const blockers = useMemo(
() => buildPublishBlockers(editState, generatedItemAssets),
@@ -2606,6 +2718,12 @@ export function Match3DResultView({
() => resolveMatch3DBackgroundPrompt(profile, draft, generatedItemAssets),
[draft, generatedItemAssets, profile],
);
const containerPreviewSrc = useMemo(
() =>
resolveMatch3DContainerPreviewSource(generatedItemAssets) ||
MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC,
[generatedItemAssets],
);
const coverSourceAssets = useMemo(
() => resolveMatch3DCoverSourceAssets(assetDrafts, backgroundPreviewSrc),
[assetDrafts, backgroundPreviewSrc],
@@ -2621,6 +2739,8 @@ export function Match3DResultView({
setCoverPanelError(null);
setBackgroundGenerationError(null);
setIsGeneratingBackground(false);
// 中文注释:表单草稿只在切换作品或服务端更新时间变化时重置,避免父级重渲染打断本地编辑。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile.profileId, profile.updatedAt]);
useEffect(() => {
@@ -2628,6 +2748,8 @@ export function Match3DResultView({
setActiveAssetId(null);
setSoundBusyAssetId(null);
setSoundGenerationProgress(null);
// 中文注释:素材草稿只跟随持久化素材字段和作品切换重建,避免无关 profile 字段刷新关闭当前面板。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
draft?.generatedItemAssets,
profile.generatedItemAssets,
@@ -2690,7 +2812,7 @@ export function Match3DResultView({
cancelled = true;
window.clearTimeout(timer);
};
}, [editState, generatedItemAssets, onSaved, profile]);
}, [editState, generatedItemAssets, isGeneratingBackground, onSaved, profile]);
const saveNow = async () => {
const payload = buildSavePayload(editState);
@@ -2823,7 +2945,11 @@ export function Match3DResultView({
setIsGeneratingTags(true);
try {
const response = await generateMatch3DWorkTags({ gameName, themeText });
const response = await generateMatch3DWorkTags({
gameName,
themeText,
summary: editState.summary.trim(),
});
const nextTags = normalizeTags(response.tags.join(''));
if (nextTags.length <= 0) {
throw new Error('未生成有效标签。');
@@ -3223,6 +3349,7 @@ export function Match3DResultView({
activeAssetId={activeAssetId}
assetDrafts={assetDrafts}
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
backgroundPrompt={backgroundPrompt}
backgroundGenerationError={backgroundGenerationError}
batchGenerationState={batchGenerationState}