3489 lines
113 KiB
TypeScript
3489 lines
113 KiB
TypeScript
import {
|
||
ArrowLeft,
|
||
CheckCircle2,
|
||
Eye,
|
||
ImageIcon,
|
||
ImagePlus,
|
||
Loader2,
|
||
Music,
|
||
Play,
|
||
Plus,
|
||
Send,
|
||
Trash2,
|
||
Wand2,
|
||
X,
|
||
} from 'lucide-react';
|
||
import {
|
||
type ChangeEvent,
|
||
type ReactNode,
|
||
useEffect,
|
||
useMemo,
|
||
useState,
|
||
} from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
|
||
import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent';
|
||
import type {
|
||
Match3DGeneratedItemAsset,
|
||
Match3DWorkProfile,
|
||
PutMatch3DWorkRequest,
|
||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
|
||
import {
|
||
createBackgroundMusicTask,
|
||
createSoundEffectTask,
|
||
publishBackgroundMusicAsset,
|
||
publishSoundEffectAsset,
|
||
waitForGeneratedAudioAsset,
|
||
} from '../../services/creation-audio';
|
||
import {
|
||
generateMatch3DBackgroundImage,
|
||
generateMatch3DCoverImage,
|
||
generateMatch3DItemAssets,
|
||
generateMatch3DWorkTags,
|
||
publishMatch3DWork,
|
||
updateMatch3DGeneratedItemAssets,
|
||
updateMatch3DWork,
|
||
} from '../../services/match3d-works';
|
||
import {
|
||
getMatch3DGeneratedImageViewSources,
|
||
resolveMatch3DGeneratedImageAssetSource,
|
||
resolveMatch3DGeneratedModelAssetSource,
|
||
} from '../../services/match3dGeneratedModelCache';
|
||
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 = {
|
||
profile: Match3DWorkProfile;
|
||
draft?: Match3DResultDraft | null;
|
||
isBusy?: boolean;
|
||
error?: string | null;
|
||
onBack: () => void;
|
||
onSaved?: (profile: Match3DWorkProfile) => void;
|
||
onPublished?: (profile: Match3DWorkProfile) => void;
|
||
onStartTestRun: (
|
||
profile: Match3DWorkProfile,
|
||
options?: { itemTypeCountOverride?: number },
|
||
) => void;
|
||
};
|
||
|
||
type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||
type Match3DResultTab = 'work' | 'config' | 'assets';
|
||
type Match3DAssetConfigTab = 'items' | 'ui' | 'music';
|
||
type Match3DAssetTaskStatus =
|
||
| 'idle'
|
||
| 'submitting'
|
||
| 'waiting'
|
||
| 'generating'
|
||
| 'image_ready'
|
||
| 'done'
|
||
| 'failed'
|
||
| 'unknown';
|
||
|
||
type Match3DBatchItemGenerationState = {
|
||
phase: 'idle' | 'generating' | 'done' | 'failed';
|
||
progress: number | null;
|
||
itemNames: string[];
|
||
message: string | null;
|
||
error: string | null;
|
||
};
|
||
|
||
type Match3DItemAssetDraft = {
|
||
id: string;
|
||
name: string;
|
||
usage: string;
|
||
prompt: string;
|
||
referenceImageSrc: string;
|
||
imageViews: Match3DGeneratedItemAsset['imageViews'];
|
||
taskUuid: string | null;
|
||
subscriptionKey: string | null;
|
||
status: Match3DAssetTaskStatus;
|
||
progress: number | null;
|
||
downloads: Array<{ name: string; url: string }>;
|
||
soundPrompt: string;
|
||
backgroundMusicTitle: string | null;
|
||
backgroundMusicStyle: string | null;
|
||
backgroundMusicPrompt: string | null;
|
||
backgroundMusic: CreationAudioAsset | null;
|
||
clickSound: CreationAudioAsset | null;
|
||
backgroundAsset: Match3DGeneratedItemAsset['backgroundAsset'] | null;
|
||
error: string | null;
|
||
updatedAt: string | null;
|
||
};
|
||
|
||
type Match3DResultEditState = {
|
||
gameName: string;
|
||
summary: string;
|
||
tagsText: string;
|
||
coverImageSrc: string;
|
||
themeText: string;
|
||
clearCountText: string;
|
||
difficultyText: string;
|
||
};
|
||
|
||
type Match3DCoverSourceAsset = {
|
||
id: string;
|
||
label: string;
|
||
imageSrc: string;
|
||
kind: 'item' | 'ui';
|
||
};
|
||
|
||
const MATCH3D_MIN_TAG_COUNT = 3;
|
||
const MATCH3D_MAX_TAG_COUNT = 6;
|
||
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_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: '作品信息' },
|
||
{ id: 'config', label: '难度配置' },
|
||
{ id: 'assets', label: '素材配置' },
|
||
];
|
||
|
||
const MATCH3D_ASSET_CONFIG_TABS: Array<{
|
||
id: Match3DAssetConfigTab;
|
||
label: string;
|
||
}> = [
|
||
{ id: 'items', label: '物品' },
|
||
{ id: 'ui', label: 'UI' },
|
||
{ id: 'music', label: '背景音乐' },
|
||
];
|
||
|
||
// 中文注释:结果页难度配置必须与创作入口页保持同一组派生参数。
|
||
const MATCH3D_DIFFICULTY_OPTIONS = [
|
||
{ id: 'easy', label: '轻松', clearCount: 8, difficulty: 2, itemTypeCount: 3 },
|
||
{
|
||
id: 'standard',
|
||
label: '标准',
|
||
clearCount: 12,
|
||
difficulty: 4,
|
||
itemTypeCount: 9,
|
||
},
|
||
{
|
||
id: 'advanced',
|
||
label: '进阶',
|
||
clearCount: 16,
|
||
difficulty: 6,
|
||
itemTypeCount: 15,
|
||
},
|
||
{
|
||
id: 'hardcore',
|
||
label: '硬核',
|
||
clearCount: 21,
|
||
difficulty: 8,
|
||
itemTypeCount: 21,
|
||
},
|
||
] as const;
|
||
|
||
type Match3DDifficultyOptionId =
|
||
(typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id'];
|
||
type Match3DDifficultyOption = (typeof MATCH3D_DIFFICULTY_OPTIONS)[number];
|
||
|
||
const MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC =
|
||
'/match3d-background-references/pot-fused-reference.png';
|
||
|
||
const MATCH3D_MATERIAL_TAB_BUTTON_CLASS =
|
||
'min-h-10 rounded-[0.9rem] px-3 text-sm font-bold transition';
|
||
|
||
function resolveMatch3DDifficultyOptionId(
|
||
difficulty: number | null | undefined,
|
||
clearCount: number | null | undefined,
|
||
): Match3DDifficultyOptionId {
|
||
const byClearCount = MATCH3D_DIFFICULTY_OPTIONS.find(
|
||
(option) => option.clearCount === clearCount,
|
||
);
|
||
if (byClearCount) {
|
||
return byClearCount.id;
|
||
}
|
||
|
||
if (typeof difficulty !== 'number' || !Number.isFinite(difficulty)) {
|
||
return 'standard';
|
||
}
|
||
|
||
const normalizedDifficulty = Math.max(
|
||
1,
|
||
Math.min(10, Math.round(difficulty)),
|
||
);
|
||
return MATCH3D_DIFFICULTY_OPTIONS.reduce(
|
||
(nearestOption, option) =>
|
||
Math.abs(option.difficulty - normalizedDifficulty) <
|
||
Math.abs(nearestOption.difficulty - normalizedDifficulty)
|
||
? option
|
||
: nearestOption,
|
||
MATCH3D_DIFFICULTY_OPTIONS[1],
|
||
).id;
|
||
}
|
||
|
||
function getMatch3DDifficultyOption(optionId: Match3DDifficultyOptionId) {
|
||
return (
|
||
MATCH3D_DIFFICULTY_OPTIONS.find((option) => option.id === optionId) ??
|
||
MATCH3D_DIFFICULTY_OPTIONS[1]
|
||
);
|
||
}
|
||
|
||
function getMatch3DReadyItemTypeCount(
|
||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||
) {
|
||
return generatedItemAssets.filter(hasMatch3DGeneratedFiveViewImageSource)
|
||
.length;
|
||
}
|
||
|
||
function getMatch3DPlayableItemTypeCount(
|
||
targetItemTypeCount: number,
|
||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||
) {
|
||
return Math.max(
|
||
1,
|
||
Math.min(
|
||
targetItemTypeCount,
|
||
getMatch3DReadyItemTypeCount(generatedItemAssets),
|
||
),
|
||
);
|
||
}
|
||
|
||
function getMatch3DDifficultyOptionFromEditState(
|
||
editState: Match3DResultEditState,
|
||
) {
|
||
return getMatch3DDifficultyOption(
|
||
resolveMatch3DDifficultyOptionId(
|
||
normalizeDifficulty(editState.difficultyText),
|
||
normalizePositiveInteger(editState.clearCountText),
|
||
),
|
||
);
|
||
}
|
||
|
||
function resolveMatch3DBackgroundPreviewSource(
|
||
profile: Match3DWorkProfile,
|
||
draft: Match3DResultDraft | null,
|
||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||
) {
|
||
return (
|
||
draft?.backgroundImageSrc?.trim() ||
|
||
draft?.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||
draft?.backgroundImageObjectKey?.trim() ||
|
||
draft?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||
profile.backgroundImageSrc?.trim() ||
|
||
profile.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||
profile.backgroundImageObjectKey?.trim() ||
|
||
profile.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||
generatedItemAssets
|
||
.map(
|
||
(asset) =>
|
||
asset.backgroundAsset?.imageSrc?.trim() ||
|
||
asset.backgroundAsset?.imageObjectKey?.trim() ||
|
||
'',
|
||
)
|
||
.find(Boolean) ||
|
||
''
|
||
);
|
||
}
|
||
|
||
function resolveMatch3DBackgroundPrompt(
|
||
profile: Match3DWorkProfile,
|
||
draft: Match3DResultDraft | null,
|
||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||
) {
|
||
return (
|
||
draft?.backgroundPrompt?.trim() ||
|
||
draft?.generatedBackgroundAsset?.prompt?.trim() ||
|
||
profile.backgroundPrompt?.trim() ||
|
||
profile.generatedBackgroundAsset?.prompt?.trim() ||
|
||
generatedItemAssets
|
||
.map((asset) => asset.backgroundAsset?.prompt?.trim() || '')
|
||
.find(Boolean) ||
|
||
buildFallbackMatch3DBackgroundPrompt(profile.themeText)
|
||
);
|
||
}
|
||
|
||
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、无按钮、无倒计时、无物品。`;
|
||
}
|
||
|
||
function normalizeTags(value: string) {
|
||
return [
|
||
...new Set(
|
||
value
|
||
.split(/[\n,,、]/u)
|
||
.map((entry) => normalizeMatch3DTag(entry))
|
||
.filter(Boolean),
|
||
),
|
||
];
|
||
}
|
||
|
||
function buildMatch3DAssetPrompt(
|
||
profile: Match3DWorkProfile,
|
||
assetName: string,
|
||
usage: string,
|
||
) {
|
||
return buildMatch3DAssetPromptFromDraft(profile.themeText, assetName, usage);
|
||
}
|
||
|
||
function buildMatch3DAssetPromptFromDraft(
|
||
themeText: string,
|
||
assetName: string,
|
||
usage: string,
|
||
) {
|
||
const normalizedTheme = themeText.trim() || '主题';
|
||
return [
|
||
`${normalizedTheme}题材抓大鹅游戏内2D素材:${assetName}`,
|
||
usage,
|
||
'适合移动端游戏直接显示,主体清晰,五视角一致,干净背景。',
|
||
]
|
||
.filter(Boolean)
|
||
.join(',');
|
||
}
|
||
|
||
function buildFallbackMatch3DClickSoundPrompt(
|
||
profile: Pick<Match3DWorkProfile, 'themeText'>,
|
||
assetName: string,
|
||
) {
|
||
const normalizedTheme = profile.themeText.trim() || '抓大鹅';
|
||
const normalizedName = assetName.trim() || '物品';
|
||
return `${normalizedTheme}题材抓大鹅中“${normalizedName}”被点击并消除时的短促反馈音效,清脆、可爱、有轻微弹跳感,适合移动端休闲游戏。`;
|
||
}
|
||
|
||
function normalizeMatch3DTag(value: string) {
|
||
return value
|
||
.trim()
|
||
.replace(/^[\s#"'`,。、“”《》;:!?,.、]+/u, '')
|
||
.replace(/[\s#"'`,。、“”《》;:!?,.、]+$/u, '')
|
||
.slice(0, 12);
|
||
}
|
||
|
||
function normalizeMatch3DTagListText(value: string) {
|
||
return [
|
||
...new Set(
|
||
value
|
||
.split(/[\n,,、]/u)
|
||
.map((entry) => normalizeMatch3DTag(entry))
|
||
.filter(Boolean),
|
||
),
|
||
];
|
||
}
|
||
|
||
function normalizeMatch3DItemName(value: string) {
|
||
return value
|
||
.trim()
|
||
.replace(/^[-*•\d.、)\s]+/u, '')
|
||
.slice(0, 12);
|
||
}
|
||
|
||
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());
|
||
}
|
||
|
||
function hasMatch3DGeneratedImageSource(asset: Match3DGeneratedItemAsset) {
|
||
return getMatch3DGeneratedImageViewSources(asset).length > 0;
|
||
}
|
||
|
||
function hasMatch3DGeneratedFiveViewImageSource(
|
||
asset: Match3DGeneratedItemAsset,
|
||
) {
|
||
return (
|
||
(asset.imageViews ?? []).filter(
|
||
(view) =>
|
||
Boolean(view.imageSrc?.trim()) || Boolean(view.imageObjectKey?.trim()),
|
||
).length >= 5
|
||
);
|
||
}
|
||
|
||
function resolveMatch3DGeneratedImageViewSourceFromDraft(
|
||
view: NonNullable<Match3DGeneratedItemAsset['imageViews']>[number],
|
||
) {
|
||
return view.imageObjectKey?.trim() || view.imageSrc?.trim() || '';
|
||
}
|
||
|
||
function resolveMatch3DAssetDraftImageViewSources(
|
||
asset: Match3DItemAssetDraft,
|
||
) {
|
||
return [
|
||
...new Set(
|
||
(asset.imageViews ?? [])
|
||
.map(resolveMatch3DGeneratedImageViewSourceFromDraft)
|
||
.filter(Boolean),
|
||
),
|
||
];
|
||
}
|
||
|
||
function resolveMatch3DAssetDraftPreviewSources(asset: Match3DItemAssetDraft) {
|
||
const imageViewSources = resolveMatch3DAssetDraftImageViewSources(asset);
|
||
if (imageViewSources.length > 0) {
|
||
return imageViewSources.slice(0, 5);
|
||
}
|
||
const fallbackSource = asset.referenceImageSrc.trim();
|
||
return fallbackSource ? [fallbackSource] : [];
|
||
}
|
||
|
||
function hasPersistableMatch3DGeneratedItemAsset(
|
||
asset: Match3DGeneratedItemAsset,
|
||
) {
|
||
return Boolean(
|
||
asset.imageSrc?.trim() ||
|
||
asset.imageObjectKey?.trim() ||
|
||
getMatch3DGeneratedImageViewSources(asset).length > 0 ||
|
||
asset.modelSrc?.trim() ||
|
||
asset.modelObjectKey?.trim() ||
|
||
asset.taskUuid?.trim() ||
|
||
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,
|
||
);
|
||
}
|
||
|
||
function getMatch3DGeneratedItemAssetPersistenceSignature(
|
||
asset: Match3DGeneratedItemAsset,
|
||
) {
|
||
return [
|
||
asset.itemId.trim(),
|
||
asset.itemName.trim(),
|
||
asset.imageSrc?.trim() ?? '',
|
||
asset.imageObjectKey?.trim() ?? '',
|
||
...(asset.imageViews ?? []).flatMap((view) => [
|
||
view.viewId.trim(),
|
||
String(view.viewIndex),
|
||
view.imageSrc?.trim() ?? '',
|
||
view.imageObjectKey?.trim() ?? '',
|
||
]),
|
||
asset.modelSrc?.trim() ?? '',
|
||
asset.modelObjectKey?.trim() ?? '',
|
||
asset.modelFileName?.trim() ?? '',
|
||
asset.taskUuid?.trim() ?? '',
|
||
asset.subscriptionKey?.trim() ?? '',
|
||
asset.status.trim(),
|
||
asset.soundPrompt?.trim() ?? '',
|
||
asset.backgroundMusicTitle?.trim() ?? '',
|
||
asset.backgroundMusicStyle?.trim() ?? '',
|
||
asset.backgroundMusicPrompt?.trim() ?? '',
|
||
asset.backgroundMusic?.audioSrc?.trim() ??
|
||
asset.backgroundMusic?.assetObjectId?.trim() ??
|
||
asset.backgroundMusic?.taskId?.trim() ??
|
||
'',
|
||
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() ??
|
||
asset.clickSound?.assetObjectId?.trim() ??
|
||
asset.clickSound?.taskId?.trim() ??
|
||
'',
|
||
asset.error?.trim() ?? '',
|
||
].join('\u001f');
|
||
}
|
||
|
||
function shouldPersistGeneratedItemAssets(
|
||
currentAssets: readonly Match3DGeneratedItemAsset[],
|
||
savedAssets: readonly Match3DGeneratedItemAsset[] = [],
|
||
) {
|
||
if (currentAssets.length <= 0) {
|
||
return false;
|
||
}
|
||
if (currentAssets.length !== savedAssets.length) {
|
||
return true;
|
||
}
|
||
|
||
return currentAssets.some((asset, index) => {
|
||
const savedAsset = savedAssets[index];
|
||
return (
|
||
!savedAsset ||
|
||
getMatch3DGeneratedItemAssetPersistenceSignature(asset) !==
|
||
getMatch3DGeneratedItemAssetPersistenceSignature(savedAsset)
|
||
);
|
||
});
|
||
}
|
||
|
||
function mergeMatch3DGeneratedItemAsset(
|
||
base: Match3DGeneratedItemAsset,
|
||
override: Match3DGeneratedItemAsset,
|
||
): Match3DGeneratedItemAsset {
|
||
const overrideHasModel = hasMatch3DGeneratedModelSource(override);
|
||
const overrideHasImages = hasMatch3DGeneratedImageSource(override);
|
||
return {
|
||
...base,
|
||
itemName: override.itemName.trim() || base.itemName,
|
||
imageSrc: override.imageSrc?.trim()
|
||
? override.imageSrc
|
||
: (base.imageSrc ?? null),
|
||
imageObjectKey: override.imageObjectKey?.trim()
|
||
? override.imageObjectKey
|
||
: (base.imageObjectKey ?? null),
|
||
imageViews:
|
||
override.imageViews && override.imageViews.length > 0
|
||
? override.imageViews
|
||
: (base.imageViews ?? []),
|
||
modelSrc: override.modelSrc?.trim()
|
||
? override.modelSrc
|
||
: (base.modelSrc ?? null),
|
||
modelObjectKey: override.modelObjectKey?.trim()
|
||
? override.modelObjectKey
|
||
: (base.modelObjectKey ?? null),
|
||
modelFileName: override.modelFileName?.trim()
|
||
? override.modelFileName
|
||
: (base.modelFileName ?? null),
|
||
taskUuid: override.taskUuid?.trim()
|
||
? override.taskUuid
|
||
: (base.taskUuid ?? null),
|
||
subscriptionKey: override.subscriptionKey?.trim()
|
||
? override.subscriptionKey
|
||
: (base.subscriptionKey ?? null),
|
||
backgroundMusic: override.backgroundMusic ?? base.backgroundMusic ?? null,
|
||
clickSound: override.clickSound ?? base.clickSound ?? null,
|
||
backgroundAsset: override.backgroundAsset ?? base.backgroundAsset ?? null,
|
||
soundPrompt: override.soundPrompt?.trim()
|
||
? override.soundPrompt
|
||
: (base.soundPrompt ?? null),
|
||
backgroundMusicTitle: override.backgroundMusicTitle?.trim()
|
||
? override.backgroundMusicTitle
|
||
: (base.backgroundMusicTitle ?? null),
|
||
backgroundMusicStyle: override.backgroundMusicStyle?.trim()
|
||
? override.backgroundMusicStyle
|
||
: (base.backgroundMusicStyle ?? null),
|
||
backgroundMusicPrompt: override.backgroundMusicPrompt?.trim()
|
||
? override.backgroundMusicPrompt
|
||
: (base.backgroundMusicPrompt ?? null),
|
||
// 中文注释:新草稿以 2D 多视角图片为正式素材;历史模型字段只做兼容保留。
|
||
status:
|
||
overrideHasModel && base.status !== 'model_ready'
|
||
? 'model_ready'
|
||
: overrideHasImages
|
||
? 'image_ready'
|
||
: base.status,
|
||
error: override.error ?? base.error ?? null,
|
||
};
|
||
}
|
||
|
||
function createMatch3DAssetDrafts(
|
||
profile: Match3DWorkProfile,
|
||
draft: Match3DResultDraft | null = null,
|
||
): Match3DItemAssetDraft[] {
|
||
const generatedAssets = resolveMatch3DResultGeneratedItemAssets(
|
||
profile,
|
||
draft,
|
||
);
|
||
if (generatedAssets?.length) {
|
||
return generatedAssets.map((asset) =>
|
||
createMatch3DAssetDraftFromGeneratedAsset(profile, asset),
|
||
);
|
||
}
|
||
|
||
const theme = profile.themeText.trim() || '主题';
|
||
const seeds = [
|
||
{
|
||
id: 'primary-item',
|
||
name: `${theme}核心物件`,
|
||
usage: '局内主要点击消除物件',
|
||
},
|
||
{
|
||
id: 'rare-item',
|
||
name: `${theme}稀有物件`,
|
||
usage: '用于增强辨识度的高价值物件',
|
||
},
|
||
{
|
||
id: 'bonus-item',
|
||
name: `${theme}奖励物件`,
|
||
usage: '通关或连消反馈奖励',
|
||
},
|
||
{
|
||
id: 'obstacle-item',
|
||
name: `${theme}干扰物件`,
|
||
usage: '堆叠层中的视觉遮挡物',
|
||
},
|
||
{
|
||
id: 'tray-prop',
|
||
name: `${theme}托盘道具`,
|
||
usage: '备选栏和结算展示道具',
|
||
},
|
||
{
|
||
id: 'scene-prop',
|
||
name: `${theme}场景小物`,
|
||
usage: '圆形空间周边装饰物',
|
||
},
|
||
].slice(0, MATCH3D_DEFAULT_ASSET_COUNT);
|
||
|
||
return seeds.map((seed) => ({
|
||
...seed,
|
||
prompt: buildMatch3DAssetPrompt(profile, seed.name, seed.usage),
|
||
referenceImageSrc: profile.referenceImageSrc ?? profile.coverImageSrc ?? '',
|
||
imageViews: [],
|
||
taskUuid: null,
|
||
subscriptionKey: null,
|
||
status: 'idle',
|
||
progress: null,
|
||
downloads: [],
|
||
soundPrompt: buildFallbackMatch3DClickSoundPrompt(profile, seed.name),
|
||
backgroundMusicTitle: null,
|
||
backgroundMusicStyle: null,
|
||
backgroundMusicPrompt: null,
|
||
backgroundMusic: null,
|
||
clickSound: null,
|
||
backgroundAsset: null,
|
||
error: null,
|
||
updatedAt: null,
|
||
}));
|
||
}
|
||
|
||
function createMatch3DAssetDraftFromGeneratedAsset(
|
||
profile: Match3DWorkProfile,
|
||
asset: Match3DGeneratedItemAsset,
|
||
): Match3DItemAssetDraft {
|
||
const modelSource = resolveMatch3DGeneratedModelAssetSource(asset);
|
||
const imageSource = resolveMatch3DGeneratedImageAssetSource(asset);
|
||
const downloads = modelSource
|
||
? [
|
||
{
|
||
name: asset.modelFileName ?? `${asset.itemName}.glb`,
|
||
url: modelSource,
|
||
},
|
||
]
|
||
: [];
|
||
|
||
return {
|
||
id: asset.itemId,
|
||
name: asset.itemName,
|
||
usage: '局内点击消除物件',
|
||
prompt: buildMatch3DAssetPrompt(
|
||
profile,
|
||
asset.itemName,
|
||
'局内点击消除物件',
|
||
),
|
||
referenceImageSrc:
|
||
imageSource ||
|
||
asset.imageSrc ||
|
||
profile.referenceImageSrc ||
|
||
profile.coverImageSrc ||
|
||
'',
|
||
imageViews: asset.imageViews ?? [],
|
||
taskUuid: asset.taskUuid ?? null,
|
||
subscriptionKey: asset.subscriptionKey ?? null,
|
||
status:
|
||
asset.status === 'model_ready'
|
||
? 'done'
|
||
: normalizeMatch3DAssetStatus(asset.status),
|
||
progress: asset.status === 'model_ready' ? 1 : null,
|
||
downloads,
|
||
soundPrompt:
|
||
asset.soundPrompt?.trim() ||
|
||
asset.clickSound?.prompt?.trim() ||
|
||
buildFallbackMatch3DClickSoundPrompt(profile, asset.itemName),
|
||
backgroundMusicTitle:
|
||
asset.backgroundMusicTitle ?? asset.backgroundMusic?.title ?? null,
|
||
backgroundMusicStyle: asset.backgroundMusicStyle ?? null,
|
||
backgroundMusicPrompt:
|
||
asset.backgroundMusicPrompt ?? asset.backgroundMusic?.prompt ?? null,
|
||
backgroundMusic: asset.backgroundMusic ?? null,
|
||
backgroundAsset: asset.backgroundAsset ?? null,
|
||
clickSound: asset.clickSound ?? null,
|
||
error: asset.error ?? null,
|
||
updatedAt: profile.updatedAt,
|
||
};
|
||
}
|
||
|
||
function createGeneratedAssetsFromDrafts(
|
||
assetDrafts: Match3DItemAssetDraft[],
|
||
existingAssets: readonly Match3DGeneratedItemAsset[] = [],
|
||
): Match3DGeneratedItemAsset[] {
|
||
const existingById = new Map(
|
||
existingAssets.map((asset) => [asset.itemId, asset]),
|
||
);
|
||
return assetDrafts.map((asset) => {
|
||
const existing = existingById.get(asset.id);
|
||
const modelFile = asset.downloads.find((file) => file.url.trim()) ?? null;
|
||
const modelSource =
|
||
modelFile?.url.trim() ||
|
||
existing?.modelSrc?.trim() ||
|
||
existing?.modelObjectKey?.trim() ||
|
||
null;
|
||
const modelObjectKey =
|
||
modelFile?.url && isGeneratedLegacyPath(modelFile.url)
|
||
? modelFile.url.trim().replace(/^\/+/u, '')
|
||
: (existing?.modelObjectKey ?? null);
|
||
return {
|
||
itemId: asset.id,
|
||
itemName: asset.name,
|
||
imageSrc: existing?.imageSrc ?? (asset.referenceImageSrc || null),
|
||
imageObjectKey: existing?.imageObjectKey ?? null,
|
||
imageViews: asset.imageViews ?? existing?.imageViews ?? [],
|
||
modelSrc: modelSource,
|
||
modelObjectKey,
|
||
modelFileName: modelFile?.name?.trim() || existing?.modelFileName || null,
|
||
taskUuid: asset.taskUuid,
|
||
subscriptionKey: asset.subscriptionKey,
|
||
soundPrompt: asset.soundPrompt.trim() || existing?.soundPrompt || null,
|
||
backgroundMusicTitle:
|
||
asset.backgroundMusicTitle ?? existing?.backgroundMusicTitle ?? null,
|
||
backgroundMusicStyle:
|
||
asset.backgroundMusicStyle ?? existing?.backgroundMusicStyle ?? null,
|
||
backgroundMusicPrompt:
|
||
asset.backgroundMusicPrompt ?? existing?.backgroundMusicPrompt ?? null,
|
||
backgroundMusic: asset.backgroundMusic,
|
||
clickSound: asset.clickSound,
|
||
backgroundAsset:
|
||
asset.backgroundAsset ??
|
||
existing?.backgroundAsset ??
|
||
(asset.id === assetDrafts[0]?.id
|
||
? (existingAssets
|
||
.map((candidate) => candidate.backgroundAsset ?? null)
|
||
.find(Boolean) ?? null)
|
||
: null),
|
||
// 中文注释:当前主链路只要求 2D 图片素材;但历史草稿若已有平台模型字段,保存时不能把模型状态降回图片态。
|
||
status: modelSource?.trim()
|
||
? 'model_ready'
|
||
: hasMatch3DGeneratedImageSource(
|
||
existing ?? ({} as Match3DGeneratedItemAsset),
|
||
)
|
||
? 'image_ready'
|
||
: asset.status === 'done'
|
||
? 'model_ready'
|
||
: asset.status,
|
||
error: asset.error,
|
||
};
|
||
});
|
||
}
|
||
|
||
function normalizeMatch3DAssetStatus(status: string): Match3DAssetTaskStatus {
|
||
const normalized = status.trim().toLowerCase();
|
||
if (
|
||
normalized === 'waiting' ||
|
||
normalized === 'pending' ||
|
||
normalized === 'queued'
|
||
) {
|
||
return 'waiting';
|
||
}
|
||
if (
|
||
normalized === 'generating' ||
|
||
normalized === 'running' ||
|
||
normalized === 'processing'
|
||
) {
|
||
return 'generating';
|
||
}
|
||
if (normalized === 'image_ready') {
|
||
return 'image_ready';
|
||
}
|
||
if (
|
||
normalized === 'done' ||
|
||
normalized === 'succeeded' ||
|
||
normalized === 'success' ||
|
||
normalized === 'completed'
|
||
) {
|
||
return 'done';
|
||
}
|
||
if (
|
||
normalized === 'failed' ||
|
||
normalized === 'error' ||
|
||
normalized === 'canceled' ||
|
||
normalized === 'cancelled'
|
||
) {
|
||
return 'failed';
|
||
}
|
||
|
||
return 'unknown';
|
||
}
|
||
|
||
function Match3DAudioProgress({
|
||
label,
|
||
progress,
|
||
}: {
|
||
label: string;
|
||
progress: number;
|
||
}) {
|
||
const normalizedProgress = Math.max(0, Math.min(1, progress));
|
||
return (
|
||
<div className="mt-3" role="status" aria-label={label}>
|
||
<div className="mb-1 flex items-center justify-between text-[11px] font-semibold text-[var(--platform-text-soft)]">
|
||
<span>{label}</span>
|
||
<span>{Math.round(normalizedProgress * 100)}%</span>
|
||
</div>
|
||
<div className="h-2 overflow-hidden rounded-full bg-white/70">
|
||
<div
|
||
className="h-full rounded-full bg-[var(--platform-accent)] transition-[width] duration-300"
|
||
style={{ width: `${Math.round(normalizedProgress * 100)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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'],
|
||
) {
|
||
if (phase === 'done') return '生成完成';
|
||
if (phase === 'failed') return '生成失败';
|
||
if (phase === 'generating') return '生成中';
|
||
return '';
|
||
}
|
||
|
||
function Match3DBatchGenerationProgress({
|
||
generationState,
|
||
}: {
|
||
generationState: Match3DBatchItemGenerationState;
|
||
}) {
|
||
if (generationState.phase === 'idle') {
|
||
return null;
|
||
}
|
||
|
||
const normalizedProgress =
|
||
generationState.progress === null
|
||
? null
|
||
: Math.max(0, Math.min(1, generationState.progress));
|
||
|
||
return (
|
||
<div
|
||
className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3"
|
||
role="status"
|
||
aria-label="物品素材生成进度"
|
||
>
|
||
<div className="mb-2 flex items-center justify-between text-xs font-bold text-[var(--platform-text-base)]">
|
||
<span>
|
||
{getMatch3DBatchGenerationStatusLabel(generationState.phase)}
|
||
</span>
|
||
{normalizedProgress !== null ? (
|
||
<span>{Math.round(normalizedProgress * 100)}%</span>
|
||
) : null}
|
||
</div>
|
||
{normalizedProgress !== null ? (
|
||
<div className="h-2 overflow-hidden rounded-full bg-white/70">
|
||
<div
|
||
className="h-full rounded-full bg-[var(--platform-accent)] transition-[width] duration-300"
|
||
style={{ width: `${Math.round(normalizedProgress * 100)}%` }}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
{generationState.message ? (
|
||
<div className="mt-2 text-sm font-semibold text-[var(--platform-text-base)]">
|
||
{generationState.message}
|
||
</div>
|
||
) : null}
|
||
{generationState.error ? (
|
||
<div className="mt-2 text-sm font-semibold text-rose-600">
|
||
{generationState.error}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function normalizePositiveInteger(value: string) {
|
||
const parsed = Number.parseInt(value.trim(), 10);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||
}
|
||
|
||
function normalizeDifficulty(value: string) {
|
||
const parsed = Number.parseInt(value.trim(), 10);
|
||
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 10 ? parsed : null;
|
||
}
|
||
|
||
function createEditState(profile: Match3DWorkProfile): Match3DResultEditState {
|
||
const difficultyOption = getMatch3DDifficultyOption(
|
||
resolveMatch3DDifficultyOptionId(profile.difficulty, profile.clearCount),
|
||
);
|
||
return {
|
||
gameName: profile.gameName,
|
||
summary: profile.summary,
|
||
tagsText: profile.tags.join(','),
|
||
coverImageSrc:
|
||
profile.coverImageSrc?.trim() || profile.referenceImageSrc?.trim() || '',
|
||
themeText: profile.themeText,
|
||
clearCountText: String(difficultyOption.clearCount),
|
||
difficultyText: String(difficultyOption.difficulty),
|
||
};
|
||
}
|
||
|
||
function buildSavePayload(
|
||
editState: Match3DResultEditState,
|
||
): PutMatch3DWorkRequest | null {
|
||
const clearCount = normalizePositiveInteger(editState.clearCountText);
|
||
const difficulty = normalizeDifficulty(editState.difficultyText);
|
||
const gameName = editState.gameName.trim();
|
||
const themeText = editState.themeText.trim();
|
||
const summary = editState.summary.trim();
|
||
const tags = normalizeTags(editState.tagsText);
|
||
|
||
if (!gameName || !themeText || !clearCount || !difficulty) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
gameName,
|
||
themeText,
|
||
summary,
|
||
tags,
|
||
coverImageSrc: editState.coverImageSrc.trim() || null,
|
||
clearCount,
|
||
difficulty,
|
||
};
|
||
}
|
||
|
||
function buildPublishBlockers(
|
||
editState: Match3DResultEditState,
|
||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||
) {
|
||
const tags = normalizeTags(editState.tagsText);
|
||
const selectedDifficulty = getMatch3DDifficultyOptionFromEditState(editState);
|
||
const readyItemTypeCount = getMatch3DReadyItemTypeCount(generatedItemAssets);
|
||
const assetReadyBlocker =
|
||
generatedItemAssets.length > 0 &&
|
||
readyItemTypeCount < selectedDifficulty.itemTypeCount
|
||
? [
|
||
`当前难度需要 ${selectedDifficulty.itemTypeCount} 种物品,已生成 ${readyItemTypeCount} 种,请先在素材配置中补齐。`,
|
||
]
|
||
: [];
|
||
const blockers = [
|
||
...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
|
||
...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
|
||
...(editState.summary.trim() ? [] : ['简介不能为空。']),
|
||
...(editState.coverImageSrc.trim() ? [] : ['封面图不能为空。']),
|
||
...(tags.length >= MATCH3D_MIN_TAG_COUNT &&
|
||
tags.length <= MATCH3D_MAX_TAG_COUNT
|
||
? []
|
||
: [
|
||
`标签数量需要在 ${MATCH3D_MIN_TAG_COUNT} 到 ${MATCH3D_MAX_TAG_COUNT} 个之间。`,
|
||
]),
|
||
...(normalizePositiveInteger(editState.clearCountText)
|
||
? []
|
||
: ['需要消除次数必须为正整数。']),
|
||
...(normalizeDifficulty(editState.difficultyText)
|
||
? []
|
||
: ['难度必须为 1 到 10。']),
|
||
...assetReadyBlocker,
|
||
];
|
||
|
||
return [...new Set(blockers)];
|
||
}
|
||
|
||
function buildTestRunBlockers(editState: Match3DResultEditState) {
|
||
const blockers = [
|
||
...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
|
||
...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
|
||
...(normalizePositiveInteger(editState.clearCountText)
|
||
? []
|
||
: ['需要消除次数必须为正整数。']),
|
||
...(normalizeDifficulty(editState.difficultyText)
|
||
? []
|
||
: ['难度必须为 1 到 10。']),
|
||
];
|
||
|
||
return [...new Set(blockers)];
|
||
}
|
||
|
||
function readImageAsDataUrl(file: File) {
|
||
return new Promise<string>((resolve, reject) => {
|
||
if (!file.type.startsWith('image/')) {
|
||
reject(new Error('请选择图片文件。'));
|
||
return;
|
||
}
|
||
|
||
const reader = new FileReader();
|
||
reader.onerror = () => reject(new Error('封面图读取失败,请重试。'));
|
||
reader.onload = () => resolve(String(reader.result || ''));
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
function resolveMatch3DCoverSourceAssets(
|
||
assetDrafts: Match3DItemAssetDraft[],
|
||
backgroundPreviewSrc: string,
|
||
): Match3DCoverSourceAsset[] {
|
||
const itemAssets = assetDrafts
|
||
.filter((asset) => asset.referenceImageSrc.trim())
|
||
.map((asset) => ({
|
||
id: `item:${asset.id}`,
|
||
label: asset.name,
|
||
imageSrc: asset.referenceImageSrc,
|
||
kind: 'item' as const,
|
||
}));
|
||
const uiAssets = backgroundPreviewSrc.trim()
|
||
? [
|
||
{
|
||
id: 'ui:background',
|
||
label: '游戏背景图',
|
||
imageSrc: backgroundPreviewSrc,
|
||
kind: 'ui' as const,
|
||
},
|
||
]
|
||
: [];
|
||
return [...itemAssets, ...uiAssets];
|
||
}
|
||
|
||
function buildPlayableProfile(
|
||
profile: Match3DWorkProfile,
|
||
editState: Match3DResultEditState,
|
||
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
|
||
) {
|
||
const payload = buildSavePayload(editState);
|
||
if (!payload) {
|
||
return attachMatch3DGeneratedItemAssets(profile, generatedItemAssets);
|
||
}
|
||
|
||
return attachMatch3DGeneratedItemAssets(
|
||
{
|
||
...profile,
|
||
gameName: payload.gameName,
|
||
themeText: payload.themeText ?? profile.themeText,
|
||
summary: payload.summary,
|
||
tags: payload.tags,
|
||
coverImageSrc: payload.coverImageSrc,
|
||
clearCount: payload.clearCount,
|
||
difficulty: payload.difficulty,
|
||
},
|
||
generatedItemAssets,
|
||
);
|
||
}
|
||
|
||
function resolveMatch3DResultGeneratedItemAssets(
|
||
profile: Match3DWorkProfile,
|
||
draft: Match3DResultDraft | null,
|
||
) {
|
||
const profileAssets = profile.generatedItemAssets ?? [];
|
||
const draftAssets = draft?.generatedItemAssets ?? [];
|
||
if (draftAssets.length <= 0) {
|
||
return profileAssets;
|
||
}
|
||
if (profileAssets.length <= 0) {
|
||
return draftAssets;
|
||
}
|
||
|
||
const profileAssetsById = new Map(
|
||
profileAssets.map((asset) => [asset.itemId, asset]),
|
||
);
|
||
const mergedAssets = draftAssets.map((draftAsset) => {
|
||
const profileAsset = profileAssetsById.get(draftAsset.itemId);
|
||
return profileAsset
|
||
? mergeMatch3DGeneratedItemAsset(draftAsset, profileAsset)
|
||
: draftAsset;
|
||
});
|
||
for (const profileAsset of profileAssets) {
|
||
if (!mergedAssets.some((asset) => asset.itemId === profileAsset.itemId)) {
|
||
mergedAssets.push(profileAsset);
|
||
}
|
||
}
|
||
return mergedAssets;
|
||
}
|
||
|
||
function attachMatch3DGeneratedItemAssets(
|
||
profile: Match3DWorkProfile,
|
||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||
) {
|
||
if (generatedItemAssets.length <= 0) {
|
||
return profile;
|
||
}
|
||
|
||
// 中文注释:试玩入口依赖当前页面可见的生成素材;保存接口若返回旧快照,不能把素材从运行态入参里丢掉。
|
||
return {
|
||
...profile,
|
||
generatedItemAssets: [...generatedItemAssets],
|
||
};
|
||
}
|
||
|
||
function attachMatch3DGeneratedBackgroundAsset(
|
||
assets: readonly Match3DGeneratedItemAsset[],
|
||
backgroundAsset: NonNullable<Match3DWorkProfile['generatedBackgroundAsset']>,
|
||
) {
|
||
if (assets.length <= 0) {
|
||
return [];
|
||
}
|
||
|
||
return assets.map((asset, index) =>
|
||
index === 0 ? { ...asset, backgroundAsset } : asset,
|
||
);
|
||
}
|
||
|
||
function buildPersistableGeneratedItemAssets(
|
||
assetDrafts: Match3DItemAssetDraft[],
|
||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||
) {
|
||
if (generatedItemAssets.length <= 0) {
|
||
return [];
|
||
}
|
||
|
||
return createGeneratedAssetsFromDrafts(
|
||
assetDrafts,
|
||
generatedItemAssets,
|
||
).filter(hasPersistableMatch3DGeneratedItemAsset);
|
||
}
|
||
|
||
function Match3DResultHeader({
|
||
autoSaveState,
|
||
isBusy,
|
||
onBack,
|
||
}: {
|
||
autoSaveState: Match3DAutoSaveState;
|
||
isBusy: boolean;
|
||
onBack: () => void;
|
||
}) {
|
||
const badge =
|
||
autoSaveState === 'saving' ? (
|
||
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||
保存中
|
||
</div>
|
||
) : autoSaveState === 'saved' ? (
|
||
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||
已自动保存
|
||
</div>
|
||
) : autoSaveState === 'error' ? (
|
||
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
|
||
保存失败
|
||
</div>
|
||
) : null;
|
||
|
||
return (
|
||
<div className="mb-4 flex items-center justify-between gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onBack}
|
||
disabled={isBusy}
|
||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||
>
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<ArrowLeft className="h-3.5 w-3.5" />
|
||
返回
|
||
</span>
|
||
</button>
|
||
{badge}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Match3DResultTabs({
|
||
activeTab,
|
||
onChange,
|
||
}: {
|
||
activeTab: Match3DResultTab;
|
||
onChange: (tab: Match3DResultTab) => 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">
|
||
{MATCH3D_RESULT_TABS.map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
onClick={() => onChange(tab.id)}
|
||
className={`min-h-10 rounded-[1rem] px-2 text-sm font-bold transition sm:px-3 ${
|
||
activeTab === tab.id
|
||
? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
|
||
: 'text-[var(--platform-text-base)] hover:bg-white/60'
|
||
}`}
|
||
aria-pressed={activeTab === tab.id}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Match3DWorkInfoTab({
|
||
editState,
|
||
isBusy,
|
||
onChange,
|
||
onOpenCoverPanel,
|
||
onGenerateTags,
|
||
}: {
|
||
editState: Match3DResultEditState;
|
||
isBusy: boolean;
|
||
onChange: (nextState: Match3DResultEditState) => void;
|
||
onOpenCoverPanel: () => void;
|
||
onGenerateTags: () => void;
|
||
}) {
|
||
const [isAddingTag, setIsAddingTag] = useState(false);
|
||
const [newTagText, setNewTagText] = useState('');
|
||
const tags = normalizeTags(editState.tagsText);
|
||
const updateTags = (nextTags: string[]) => {
|
||
onChange({ ...editState, tagsText: nextTags.join(',') });
|
||
};
|
||
const addTags = () => {
|
||
const nextTags = [
|
||
...new Set([...tags, ...normalizeMatch3DTagListText(newTagText)]),
|
||
].slice(0, MATCH3D_MAX_TAG_COUNT);
|
||
updateTags(nextTags);
|
||
setNewTagText('');
|
||
setIsAddingTag(false);
|
||
};
|
||
|
||
return (
|
||
<section className="platform-subpanel space-y-3 rounded-[1.35rem] p-4 sm:p-5">
|
||
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-2">
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={onOpenCoverPanel}
|
||
className={`h-20 w-28 shrink-0 overflow-hidden rounded-[0.85rem] bg-[radial-gradient(circle_at_35%_24%,rgba(190,242,100,0.28),transparent_34%),linear-gradient(135deg,rgba(16,185,129,0.18),rgba(251,146,60,0.16))] ${isBusy ? 'cursor-not-allowed opacity-55' : 'transition hover:ring-2 hover:ring-emerald-200'}`}
|
||
aria-label="编辑碰面图"
|
||
>
|
||
{editState.coverImageSrc ? (
|
||
<ResolvedAssetImage
|
||
src={editState.coverImageSrc}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="grid h-full w-full place-items-center text-emerald-700">
|
||
<ImagePlus className="h-6 w-6" />
|
||
</div>
|
||
)}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={onOpenCoverPanel}
|
||
className="platform-button platform-button--ghost flex min-h-10 items-center justify-center gap-2 px-3 py-2 text-sm"
|
||
>
|
||
<ImagePlus className="h-4 w-4" />
|
||
碰面图
|
||
</button>
|
||
</div>
|
||
|
||
<label className="block">
|
||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
作品名称
|
||
</span>
|
||
<input
|
||
value={editState.gameName}
|
||
disabled={isBusy}
|
||
onChange={(event) =>
|
||
onChange({ ...editState, gameName: event.target.value })
|
||
}
|
||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
|
||
aria-label="作品名称"
|
||
/>
|
||
</label>
|
||
|
||
<label className="block">
|
||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
作品描述
|
||
</span>
|
||
<textarea
|
||
value={editState.summary}
|
||
disabled={isBusy}
|
||
onChange={(event) =>
|
||
onChange({ ...editState, summary: event.target.value })
|
||
}
|
||
rows={6}
|
||
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>
|
||
|
||
<div>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
作品标签
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={onGenerateTags}
|
||
className="platform-icon-button h-9 w-9"
|
||
aria-label="AI生成作品标签"
|
||
title="AI生成作品标签"
|
||
>
|
||
{isBusy ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Wand2 className="h-4 w-4" />
|
||
)}
|
||
</button>
|
||
{!isAddingTag ? (
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={() => setIsAddingTag(true)}
|
||
className="platform-icon-button h-9 w-9"
|
||
aria-label="新增作品标签"
|
||
title="新增作品标签"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{tags.map((tag) => (
|
||
<span
|
||
key={tag}
|
||
className="inline-flex items-center gap-1.5 rounded-full border border-emerald-300/35 bg-emerald-100/68 px-3 py-1.5 text-xs font-semibold text-emerald-700"
|
||
>
|
||
{tag}
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={() => updateTags(tags.filter((item) => item !== tag))}
|
||
className="rounded-full text-emerald-800/70 transition hover:text-emerald-950 disabled:opacity-45"
|
||
aria-label={`删除标签 ${tag}`}
|
||
title="删除标签"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</span>
|
||
))}
|
||
{tags.length <= 0 ? (
|
||
<span className="text-sm text-[var(--platform-text-soft)]">
|
||
暂无标签
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
{isAddingTag ? (
|
||
<div className="mt-3 flex flex-col gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2 sm:flex-row">
|
||
<input
|
||
autoFocus
|
||
value={newTagText}
|
||
disabled={isBusy}
|
||
onChange={(event) => setNewTagText(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
addTags();
|
||
}
|
||
if (event.key === 'Escape') {
|
||
setIsAddingTag(false);
|
||
setNewTagText('');
|
||
}
|
||
}}
|
||
className="min-h-10 flex-1 rounded-[0.8rem] border border-transparent bg-white/92 px-3 text-sm text-[var(--platform-text-strong)] outline-none"
|
||
placeholder="输入新标签"
|
||
aria-label="新题材标签"
|
||
/>
|
||
<div className="flex gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={addTags}
|
||
className="platform-button platform-button--primary min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
|
||
>
|
||
添加
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={() => {
|
||
setIsAddingTag(false);
|
||
setNewTagText('');
|
||
}}
|
||
className="platform-button platform-button--ghost min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Match3DModalShell({
|
||
title,
|
||
children,
|
||
onClose,
|
||
}: {
|
||
title: string;
|
||
children: ReactNode;
|
||
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-[146] 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={title}
|
||
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,48rem)] 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)]">
|
||
{title}
|
||
</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">
|
||
{children}
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|
||
|
||
function Match3DCoverImagePanel({
|
||
editState,
|
||
sourceAssets,
|
||
isGenerating,
|
||
selectedReferenceSrc,
|
||
aiRedraw,
|
||
prompt,
|
||
error,
|
||
onAiRedrawChange,
|
||
onClose,
|
||
onFileChange,
|
||
onPromptChange,
|
||
onReferenceSelect,
|
||
onSubmit,
|
||
}: {
|
||
editState: Match3DResultEditState;
|
||
sourceAssets: Match3DCoverSourceAsset[];
|
||
isGenerating: boolean;
|
||
selectedReferenceSrc: string;
|
||
aiRedraw: boolean;
|
||
prompt: string;
|
||
error: string | null;
|
||
onAiRedrawChange: (enabled: boolean) => void;
|
||
onClose: () => void;
|
||
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||
onPromptChange: (value: string) => void;
|
||
onReferenceSelect: (source: string) => void;
|
||
onSubmit: () => void;
|
||
}) {
|
||
const previewSrc = selectedReferenceSrc || editState.coverImageSrc;
|
||
const canSubmit = Boolean(previewSrc.trim() || prompt.trim());
|
||
|
||
return (
|
||
<Match3DModalShell title="碰面图" onClose={onClose}>
|
||
<div className="grid min-h-0 gap-4 lg:grid-cols-[minmax(16rem,0.85fr)_minmax(0,1.15fr)]">
|
||
<div className="space-y-3">
|
||
<div className="aspect-square overflow-hidden rounded-[1.35rem] border border-[var(--platform-subpanel-border)] bg-white/70">
|
||
{previewSrc ? (
|
||
<ResolvedAssetImage
|
||
src={previewSrc}
|
||
alt="碰面图预览"
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="grid h-full w-full place-items-center text-[var(--platform-text-soft)]">
|
||
<ImageIcon className="h-10 w-10" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<label className="platform-button platform-button--ghost flex min-h-11 cursor-pointer items-center justify-center gap-2 px-4 py-3 text-sm">
|
||
<ImagePlus className="h-4 w-4" />
|
||
上传图片
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="sr-only"
|
||
disabled={isGenerating}
|
||
onChange={onFileChange}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<label className="flex items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3">
|
||
<span className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
AI重绘
|
||
</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={aiRedraw}
|
||
disabled={isGenerating}
|
||
onChange={(event) => onAiRedrawChange(event.target.checked)}
|
||
aria-label="碰面图AI重绘"
|
||
/>
|
||
</label>
|
||
|
||
<label className="block">
|
||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
提示词
|
||
</span>
|
||
<textarea
|
||
value={prompt}
|
||
disabled={isGenerating}
|
||
rows={5}
|
||
onChange={(event) => onPromptChange(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>
|
||
|
||
{sourceAssets.length > 0 ? (
|
||
<div>
|
||
<div className="mb-2 text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
引用素材
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||
{sourceAssets.map((asset) => (
|
||
<button
|
||
key={asset.id}
|
||
type="button"
|
||
disabled={isGenerating}
|
||
onClick={() => onReferenceSelect(asset.imageSrc)}
|
||
className={`overflow-hidden rounded-[1rem] border bg-white/74 text-left transition ${
|
||
selectedReferenceSrc === asset.imageSrc
|
||
? 'border-emerald-300 ring-2 ring-emerald-100'
|
||
: 'border-[var(--platform-subpanel-border)] hover:border-emerald-200'
|
||
}`}
|
||
aria-label={`引用${asset.label}`}
|
||
>
|
||
<div className="aspect-square overflow-hidden">
|
||
<ResolvedAssetImage
|
||
src={asset.imageSrc}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
</div>
|
||
<div className="truncate px-2 py-2 text-[11px] font-semibold text-[var(--platform-text-base)]">
|
||
{asset.label}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{error ? (
|
||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
|
||
<button
|
||
type="button"
|
||
disabled={!canSubmit || isGenerating}
|
||
onClick={onSubmit}
|
||
className={`platform-button platform-button--primary min-h-11 w-full justify-center gap-2 px-4 py-3 ${!canSubmit || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
|
||
>
|
||
{isGenerating ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : aiRedraw ? (
|
||
<Wand2 className="h-4 w-4" />
|
||
) : (
|
||
<CheckCircle2 className="h-4 w-4" />
|
||
)}
|
||
{aiRedraw ? '生成碰面图' : '使用当前图片'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Match3DModalShell>
|
||
);
|
||
}
|
||
|
||
function Match3DConfigTab({
|
||
editState,
|
||
isBusy,
|
||
generatedItemAssets,
|
||
totalItemCount,
|
||
onChange,
|
||
}: {
|
||
editState: Match3DResultEditState;
|
||
isBusy: boolean;
|
||
generatedItemAssets: readonly Match3DGeneratedItemAsset[];
|
||
totalItemCount: number;
|
||
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="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
|
||
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
|
||
? '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
|
||
className={`h-3.5 w-3.5 rounded-full ${
|
||
selected
|
||
? 'bg-[var(--platform-accent)]'
|
||
: 'bg-rose-100'
|
||
}`}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
<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>
|
||
|
||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||
{[
|
||
['需要消除', `${selectedOption.clearCount} 次`],
|
||
['总物品数', `${totalItemCount} 件`],
|
||
['物品种类', `${runtimeTypeCount} 种`],
|
||
['已生成物品种类', `${readyItemTypeCount} 种`],
|
||
].map(([label, value]) => (
|
||
<div
|
||
key={label}
|
||
className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3"
|
||
>
|
||
<div className="text-[11px] font-bold tracking-[0.14em] text-[var(--platform-text-soft)]">
|
||
{label}
|
||
</div>
|
||
<div className="mt-1 text-lg font-black text-[var(--platform-text-strong)]">
|
||
{value}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Match3DItemAssetListCard({
|
||
asset,
|
||
active,
|
||
onClick,
|
||
onDelete,
|
||
}: {
|
||
asset: Match3DItemAssetDraft;
|
||
active: boolean;
|
||
onClick: () => void;
|
||
onDelete: () => void;
|
||
}) {
|
||
const previewSources = resolveMatch3DAssetDraftPreviewSources(asset);
|
||
const previewSource = previewSources[0] ?? asset.referenceImageSrc.trim();
|
||
|
||
return (
|
||
<div
|
||
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="grid min-h-full grid-rows-[minmax(0,1fr)_auto] gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
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="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={previewSource}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="h-full w-full object-contain p-1"
|
||
/>
|
||
) : (
|
||
<ImageIcon className="h-7 w-7 text-[var(--platform-text-soft)]" />
|
||
)}
|
||
</div>
|
||
<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>
|
||
);
|
||
}
|
||
|
||
function Match3DItemAssetDetail({
|
||
asset,
|
||
busy,
|
||
soundBusy,
|
||
soundProgress,
|
||
onChange,
|
||
onGenerateClickSound,
|
||
}: {
|
||
asset: Match3DItemAssetDraft;
|
||
busy: boolean;
|
||
soundBusy: boolean;
|
||
soundProgress: number | null;
|
||
onChange: (asset: Match3DItemAssetDraft) => void;
|
||
onGenerateClickSound: (asset: Match3DItemAssetDraft) => void;
|
||
}) {
|
||
const previewSources = resolveMatch3DAssetDraftPreviewSources(asset);
|
||
|
||
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-[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 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}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="h-full w-full object-contain"
|
||
/>
|
||
</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">
|
||
<label className="block">
|
||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
素材名称
|
||
</span>
|
||
<input
|
||
value={asset.name}
|
||
disabled={busy}
|
||
onChange={(event) =>
|
||
onChange({ ...asset, name: event.target.value })
|
||
}
|
||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||
/>
|
||
</label>
|
||
|
||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3">
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
点击音效
|
||
</span>
|
||
<button
|
||
type="button"
|
||
disabled={busy || soundBusy}
|
||
onClick={() => onGenerateClickSound(asset)}
|
||
className={`platform-icon-button h-9 w-9 ${busy || soundBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||
aria-label={`生成点击音效,${MATCH3D_CLICK_SOUND_POINTS_COST}光点`}
|
||
title={`生成点击音效 · ${MATCH3D_CLICK_SOUND_POINTS_COST}光点`}
|
||
>
|
||
{soundBusy ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Music className="h-4 w-4" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
<label className="block">
|
||
<span className="sr-only">点击音效提示词</span>
|
||
<textarea
|
||
value={asset.soundPrompt}
|
||
disabled={busy || soundBusy}
|
||
rows={4}
|
||
onChange={(event) =>
|
||
onChange({ ...asset, soundPrompt: event.target.value })
|
||
}
|
||
className="mb-3 w-full resize-none rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-2.5 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||
aria-label={`${asset.name}点击音效提示词`}
|
||
/>
|
||
</label>
|
||
{soundBusy && soundProgress !== null ? (
|
||
<Match3DAudioProgress
|
||
label="音效生成中"
|
||
progress={soundProgress}
|
||
/>
|
||
) : null}
|
||
{asset.clickSound?.audioSrc ? (
|
||
<Match3DResolvedAudio
|
||
src={asset.clickSound.audioSrc}
|
||
ariaLabel={`${asset.name}点击音效`}
|
||
/>
|
||
) : (
|
||
<div className="mt-3 text-sm font-semibold text-[var(--platform-text-soft)]">
|
||
暂无音效
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Match3DAssetsTab({
|
||
activeAssetId,
|
||
assets,
|
||
batchGenerationState,
|
||
soundBusyAssetId,
|
||
soundGenerationProgress,
|
||
onActiveAssetChange,
|
||
onAddBatch,
|
||
onAssetChange,
|
||
onDeleteAsset,
|
||
onGenerateClickSound,
|
||
}: {
|
||
activeAssetId: string | null;
|
||
assets: Match3DItemAssetDraft[];
|
||
batchGenerationState: Match3DBatchItemGenerationState;
|
||
soundBusyAssetId: string | null;
|
||
soundGenerationProgress: number | null;
|
||
onActiveAssetChange: (assetId: string | null) => void;
|
||
onAddBatch: () => void;
|
||
onAssetChange: (asset: Match3DItemAssetDraft) => void;
|
||
onDeleteAsset: (assetId: string) => void;
|
||
onGenerateClickSound: (asset: Match3DItemAssetDraft) => void;
|
||
}) {
|
||
const activeAsset =
|
||
assets.find((asset) => asset.id === activeAssetId) ?? null;
|
||
|
||
return (
|
||
<div className="min-h-0 space-y-3">
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={onAddBatch}
|
||
className="platform-button platform-button--ghost min-h-10 gap-2 px-4 py-2 text-sm"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
批量新增
|
||
</button>
|
||
</div>
|
||
<Match3DBatchGenerationProgress generationState={batchGenerationState} />
|
||
<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}
|
||
asset={asset}
|
||
active={asset.id === activeAssetId}
|
||
onClick={() => onActiveAssetChange(asset.id)}
|
||
onDelete={() => onDeleteAsset(asset.id)}
|
||
/>
|
||
))}
|
||
</section>
|
||
|
||
{activeAsset ? (
|
||
<Match3DModalShell
|
||
title={activeAsset.name || '物品素材'}
|
||
onClose={() => onActiveAssetChange(null)}
|
||
>
|
||
<Match3DItemAssetDetail
|
||
asset={activeAsset}
|
||
busy={false}
|
||
soundBusy={soundBusyAssetId === activeAsset.id}
|
||
soundProgress={
|
||
soundBusyAssetId === activeAsset.id
|
||
? soundGenerationProgress
|
||
: null
|
||
}
|
||
onChange={onAssetChange}
|
||
onGenerateClickSound={onGenerateClickSound}
|
||
/>
|
||
</Match3DModalShell>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Match3DBatchAddItemsPanel({
|
||
values,
|
||
generationState,
|
||
error,
|
||
onAddInput,
|
||
onChangeValue,
|
||
onClose,
|
||
onSubmit,
|
||
}: {
|
||
values: string[];
|
||
generationState: Match3DBatchItemGenerationState;
|
||
error: string | null;
|
||
onAddInput: () => void;
|
||
onChangeValue: (index: number, value: string) => void;
|
||
onClose: () => void;
|
||
onSubmit: () => void;
|
||
}) {
|
||
const parsedNames = normalizeMatch3DItemNameList(values);
|
||
const isGenerating = generationState.phase === 'generating';
|
||
const pointsCost = calculateMatch3DItemAssetsPointsCost(parsedNames.length);
|
||
|
||
return (
|
||
<Match3DModalShell title="批量新增物品" onClose={onClose}>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
{values.map((value, index) => (
|
||
<label key={index} className="block">
|
||
<span className="sr-only">物品名称 {index + 1}</span>
|
||
<input
|
||
value={value}
|
||
disabled={isGenerating}
|
||
onChange={(event) => onChangeValue(index, event.target.value)}
|
||
className="w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||
aria-label={`物品名称 ${index + 1}`}
|
||
placeholder="物品名称"
|
||
/>
|
||
</label>
|
||
))}
|
||
<button
|
||
type="button"
|
||
disabled={isGenerating}
|
||
onClick={onAddInput}
|
||
className={`platform-button platform-button--ghost min-h-10 w-full justify-center gap-2 px-4 py-2 text-sm ${isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
新增物品名称
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
{parsedNames.map((name) => (
|
||
<span
|
||
key={name}
|
||
className="platform-pill platform-pill--neutral px-3 py-1 text-[11px]"
|
||
>
|
||
{name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{error ? (
|
||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
|
||
<Match3DBatchGenerationProgress generationState={generationState} />
|
||
|
||
<button
|
||
type="button"
|
||
disabled={parsedNames.length <= 0 || isGenerating}
|
||
onClick={onSubmit}
|
||
className={`platform-button platform-button--primary min-h-11 w-full justify-center gap-2 px-4 py-3 ${parsedNames.length <= 0 || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
|
||
>
|
||
{isGenerating ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Plus className="h-4 w-4" />
|
||
)}
|
||
生成物品素材 · {pointsCost}光点
|
||
</button>
|
||
</div>
|
||
</Match3DModalShell>
|
||
);
|
||
}
|
||
|
||
function Match3DMusicTab({
|
||
assetDrafts,
|
||
editState,
|
||
profileId,
|
||
busy,
|
||
onMusicGenerated,
|
||
}: {
|
||
assetDrafts: Match3DItemAssetDraft[];
|
||
editState: Match3DResultEditState;
|
||
profileId: string;
|
||
busy: boolean;
|
||
onMusicGenerated: (
|
||
music: CreationAudioAsset,
|
||
metadata: {
|
||
prompt: string;
|
||
style: string;
|
||
title: string;
|
||
},
|
||
) => void;
|
||
}) {
|
||
const currentMusic = assetDrafts[0]?.backgroundMusic ?? null;
|
||
const [title, setTitle] = useState(
|
||
() =>
|
||
assetDrafts[0]?.backgroundMusicTitle?.trim() ||
|
||
currentMusic?.title?.trim() ||
|
||
`${editState.gameName.trim() || '抓大鹅'}音乐`.slice(0, 40),
|
||
);
|
||
const [tags, setTags] = useState(
|
||
assetDrafts[0]?.backgroundMusicStyle?.trim() ||
|
||
'轻快, 休闲, 消除, instrumental',
|
||
);
|
||
const [statusText, setStatusText] = useState<string | null>(null);
|
||
const [errorText, setErrorText] = useState<string | null>(null);
|
||
const [isGenerating, setIsGenerating] = useState(false);
|
||
const [generationProgress, setGenerationProgress] = useState<number | null>(
|
||
null,
|
||
);
|
||
const canGenerate = title.trim().length > 0;
|
||
|
||
const generateMusic = async () => {
|
||
if (!canGenerate || isGenerating) {
|
||
return;
|
||
}
|
||
setIsGenerating(true);
|
||
setGenerationProgress(0.12);
|
||
setStatusText('提交中');
|
||
setErrorText(null);
|
||
try {
|
||
const task = await createBackgroundMusicTask({
|
||
prompt: '',
|
||
title: title.trim(),
|
||
tags: tags.trim() || null,
|
||
});
|
||
setGenerationProgress(0.35);
|
||
setStatusText('生成中');
|
||
const asset = await waitForGeneratedAudioAsset(task.taskId, async () => {
|
||
const result = await publishBackgroundMusicAsset(task.taskId, {
|
||
entityKind: 'match3d_work',
|
||
entityId: profileId,
|
||
slot: 'background_music',
|
||
assetKind: MATCH3D_BACKGROUND_MUSIC_ASSET_KIND,
|
||
profileId,
|
||
storagePrefix: 'match3d_assets',
|
||
});
|
||
setGenerationProgress(result.audioSrc?.trim() ? 0.92 : 0.58);
|
||
setStatusText(result.audioSrc?.trim() ? '转存中' : '生成中');
|
||
return result;
|
||
});
|
||
if (!asset.audioSrc) {
|
||
throw new Error('音频生成完成但缺少播放地址。');
|
||
}
|
||
onMusicGenerated(
|
||
{
|
||
taskId: asset.taskId,
|
||
provider: asset.provider,
|
||
assetObjectId: asset.assetObjectId ?? null,
|
||
assetKind: asset.assetKind ?? MATCH3D_BACKGROUND_MUSIC_ASSET_KIND,
|
||
audioSrc: asset.audioSrc,
|
||
prompt: '',
|
||
title: title.trim(),
|
||
updatedAt: new Date().toISOString(),
|
||
},
|
||
{
|
||
prompt: '',
|
||
style: tags.trim(),
|
||
title: title.trim(),
|
||
},
|
||
);
|
||
setGenerationProgress(1);
|
||
setStatusText('已生成');
|
||
} catch (caughtError) {
|
||
setErrorText(
|
||
caughtError instanceof Error
|
||
? caughtError.message
|
||
: '背景音乐生成失败。',
|
||
);
|
||
setStatusText(null);
|
||
setGenerationProgress(null);
|
||
} finally {
|
||
setIsGenerating(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
背景音乐
|
||
</div>
|
||
{statusText ? (
|
||
<span className="platform-pill platform-pill--cool px-3 py-1 text-[11px]">
|
||
{statusText}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{isGenerating && generationProgress !== null ? (
|
||
<Match3DAudioProgress
|
||
label="音乐生成中"
|
||
progress={generationProgress}
|
||
/>
|
||
) : null}
|
||
{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" />
|
||
暂无音乐
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||
<label className="block">
|
||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
曲名
|
||
</span>
|
||
<input
|
||
value={title}
|
||
disabled={busy || isGenerating}
|
||
onChange={(event) => setTitle(event.target.value)}
|
||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||
aria-label="抓大鹅背景音乐曲名"
|
||
/>
|
||
</label>
|
||
<label className="mt-3 block">
|
||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
风格
|
||
</span>
|
||
<input
|
||
value={tags}
|
||
disabled={busy || isGenerating}
|
||
onChange={(event) => setTags(event.target.value)}
|
||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||
aria-label="抓大鹅背景音乐风格"
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
disabled={!canGenerate || busy || isGenerating}
|
||
onClick={() => void generateMusic()}
|
||
className={`platform-button platform-button--primary mt-3 min-h-11 w-full justify-center gap-2 ${!canGenerate || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
|
||
>
|
||
{isGenerating ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Music className="h-4 w-4" />
|
||
)}
|
||
{currentMusic ? '重新生成音乐' : '生成音乐'} · {MATCH3D_BACKGROUND_MUSIC_POINTS_COST}光点
|
||
</button>
|
||
</section>
|
||
|
||
{errorText ? (
|
||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||
{errorText}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Match3DAssetConfigTabs({
|
||
activeTab,
|
||
onChange,
|
||
}: {
|
||
activeTab: Match3DAssetConfigTab;
|
||
onChange: (tab: Match3DAssetConfigTab) => void;
|
||
}) {
|
||
return (
|
||
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
|
||
{MATCH3D_ASSET_CONFIG_TABS.map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
onClick={() => onChange(tab.id)}
|
||
className={`${MATCH3D_MATERIAL_TAB_BUTTON_CLASS} ${
|
||
activeTab === tab.id
|
||
? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
|
||
: 'text-[var(--platform-text-base)] hover:bg-white/60'
|
||
}`}
|
||
aria-pressed={activeTab === tab.id}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Match3DUIAssetsTab({
|
||
backgroundPreviewSrc,
|
||
containerPreviewSrc,
|
||
backgroundPrompt,
|
||
busy,
|
||
isGenerating,
|
||
error,
|
||
onGenerate,
|
||
}: {
|
||
backgroundPreviewSrc: string;
|
||
containerPreviewSrc: string;
|
||
backgroundPrompt: string;
|
||
busy: boolean;
|
||
isGenerating: boolean;
|
||
error: string | null;
|
||
onGenerate: (prompt: string) => void;
|
||
}) {
|
||
const [prompt, setPrompt] = useState(backgroundPrompt);
|
||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setPrompt(backgroundPrompt);
|
||
}, [backgroundPrompt]);
|
||
|
||
const normalizedPrompt = prompt.trim();
|
||
|
||
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(14rem,0.72fr)_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}
|
||
alt="游戏背景图"
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
<span className="sr-only">打开UI页面预览</span>
|
||
</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)]">
|
||
画面描述提示词
|
||
</span>
|
||
<textarea
|
||
value={prompt}
|
||
disabled={busy || isGenerating}
|
||
rows={7}
|
||
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="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={!normalizedPrompt || busy || isGenerating}
|
||
onClick={() => onGenerate(normalizedPrompt)}
|
||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
|
||
>
|
||
{isGenerating ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Wand2 className="h-4 w-4" />
|
||
)}
|
||
重新生成 · {MATCH3D_UI_BACKGROUND_POINTS_COST}光点
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{error ? (
|
||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
|
||
{isPreviewOpen ? (
|
||
<Match3DUIRuntimePreviewPanel
|
||
backgroundPreviewSrc={backgroundPreviewSrc}
|
||
containerPreviewSrc={containerPreviewSrc}
|
||
onClose={() => setIsPreviewOpen(false)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Match3DUIRuntimePreviewPanel({
|
||
backgroundPreviewSrc,
|
||
containerPreviewSrc,
|
||
onClose,
|
||
}: {
|
||
backgroundPreviewSrc: string;
|
||
containerPreviewSrc: string;
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<Match3DModalShell title="UI预览" onClose={onClose}>
|
||
<div className="mx-auto aspect-[9/16] max-h-[min(78dvh,44rem)] w-full max-w-[24rem] overflow-hidden rounded-[1.4rem] border border-white/18 bg-[#16221f] 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">
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
|
||
<ResolvedAssetImage
|
||
src={backgroundPreviewSrc}
|
||
alt=""
|
||
aria-hidden="true"
|
||
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={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
|
||
<ArrowLeft size={20} />
|
||
</span>
|
||
<span className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>
|
||
1:30
|
||
</span>
|
||
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
|
||
<span className={MATCH3D_RUNTIME_GLASS_SPINNER_CLASS} />
|
||
</span>
|
||
</header>
|
||
|
||
<section className="relative z-10 mt-3 flex min-h-0 flex-1 items-center justify-center">
|
||
<div
|
||
className="relative aspect-square max-w-full rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]"
|
||
style={{ width: 'min(92%, 58dvh, 100%)' }}
|
||
aria-hidden="true"
|
||
>
|
||
{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 ${MATCH3D_RUNTIME_GLASS_TRAY_CLASS}`}>
|
||
<div className="grid grid-cols-7 gap-1.5">
|
||
{Array.from({ length: 7 }).map((_, index) => (
|
||
<span
|
||
key={index}
|
||
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</Match3DModalShell>
|
||
);
|
||
}
|
||
|
||
function Match3DAssetConfigTab({
|
||
activeAssetConfigTab,
|
||
activeAssetId,
|
||
assetDrafts,
|
||
backgroundPreviewSrc,
|
||
containerPreviewSrc,
|
||
backgroundPrompt,
|
||
backgroundGenerationError,
|
||
batchGenerationState,
|
||
busy,
|
||
editState,
|
||
isGeneratingBackground,
|
||
profileId,
|
||
soundBusyAssetId,
|
||
soundGenerationProgress,
|
||
onActiveAssetChange,
|
||
onAddBatch,
|
||
onAssetChange,
|
||
onAssetConfigTabChange,
|
||
onDeleteAsset,
|
||
onGenerateBackground,
|
||
onGenerateClickSound,
|
||
onMusicGenerated,
|
||
}: {
|
||
activeAssetConfigTab: Match3DAssetConfigTab;
|
||
activeAssetId: string | null;
|
||
assetDrafts: Match3DItemAssetDraft[];
|
||
backgroundPreviewSrc: string;
|
||
containerPreviewSrc: string;
|
||
backgroundPrompt: string;
|
||
backgroundGenerationError: string | null;
|
||
batchGenerationState: Match3DBatchItemGenerationState;
|
||
busy: boolean;
|
||
editState: Match3DResultEditState;
|
||
isGeneratingBackground: boolean;
|
||
profileId: string;
|
||
soundBusyAssetId: string | null;
|
||
soundGenerationProgress: number | null;
|
||
onActiveAssetChange: (assetId: string | null) => void;
|
||
onAddBatch: () => void;
|
||
onAssetChange: (asset: Match3DItemAssetDraft) => void;
|
||
onAssetConfigTabChange: (tab: Match3DAssetConfigTab) => void;
|
||
onDeleteAsset: (assetId: string) => void;
|
||
onGenerateBackground: (prompt: string) => void;
|
||
onGenerateClickSound: (asset: Match3DItemAssetDraft) => void;
|
||
onMusicGenerated: (
|
||
music: CreationAudioAsset,
|
||
metadata: {
|
||
prompt: string;
|
||
style: string;
|
||
title: string;
|
||
},
|
||
) => void;
|
||
}) {
|
||
return (
|
||
<div className="min-h-0">
|
||
<Match3DAssetConfigTabs
|
||
activeTab={activeAssetConfigTab}
|
||
onChange={onAssetConfigTabChange}
|
||
/>
|
||
{activeAssetConfigTab === 'items' ? (
|
||
<Match3DAssetsTab
|
||
activeAssetId={activeAssetId}
|
||
assets={assetDrafts}
|
||
batchGenerationState={batchGenerationState}
|
||
soundBusyAssetId={soundBusyAssetId}
|
||
soundGenerationProgress={soundGenerationProgress}
|
||
onActiveAssetChange={onActiveAssetChange}
|
||
onAddBatch={onAddBatch}
|
||
onAssetChange={onAssetChange}
|
||
onDeleteAsset={onDeleteAsset}
|
||
onGenerateClickSound={onGenerateClickSound}
|
||
/>
|
||
) : null}
|
||
{activeAssetConfigTab === 'ui' ? (
|
||
<Match3DUIAssetsTab
|
||
backgroundPreviewSrc={backgroundPreviewSrc}
|
||
containerPreviewSrc={containerPreviewSrc}
|
||
backgroundPrompt={backgroundPrompt}
|
||
busy={busy}
|
||
isGenerating={isGeneratingBackground}
|
||
error={backgroundGenerationError}
|
||
onGenerate={onGenerateBackground}
|
||
/>
|
||
) : null}
|
||
{activeAssetConfigTab === 'music' ? (
|
||
<Match3DMusicTab
|
||
assetDrafts={assetDrafts}
|
||
editState={editState}
|
||
profileId={profileId}
|
||
busy={busy}
|
||
onMusicGenerated={onMusicGenerated}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Match3DResultView({
|
||
profile,
|
||
draft = null,
|
||
isBusy = false,
|
||
error = null,
|
||
onBack,
|
||
onSaved,
|
||
onPublished,
|
||
onStartTestRun,
|
||
}: Match3DResultViewProps) {
|
||
const [editState, setEditState] = useState(() => createEditState(profile));
|
||
const [activeTab, setActiveTab] = useState<Match3DResultTab>('work');
|
||
const [activeAssetConfigTab, setActiveAssetConfigTab] =
|
||
useState<Match3DAssetConfigTab>('items');
|
||
const [assetDrafts, setAssetDrafts] = useState<Match3DItemAssetDraft[]>(() =>
|
||
createMatch3DAssetDrafts(profile, draft),
|
||
);
|
||
const [activeAssetId, setActiveAssetId] = useState<string | null>(null);
|
||
const [isCoverPanelOpen, setIsCoverPanelOpen] = useState(false);
|
||
const [coverReferenceSrc, setCoverReferenceSrc] = useState('');
|
||
const [coverPrompt, setCoverPrompt] = useState('');
|
||
const [coverAiRedraw, setCoverAiRedraw] = useState(false);
|
||
const [isGeneratingCover, setIsGeneratingCover] = useState(false);
|
||
const [coverPanelError, setCoverPanelError] = useState<string | null>(null);
|
||
const [isBatchAddPanelOpen, setIsBatchAddPanelOpen] = useState(false);
|
||
const [batchItemNameValues, setBatchItemNameValues] = useState(['']);
|
||
const [batchGenerationState, setBatchGenerationState] =
|
||
useState<Match3DBatchItemGenerationState>({
|
||
phase: 'idle',
|
||
progress: null,
|
||
itemNames: [],
|
||
message: null,
|
||
error: null,
|
||
});
|
||
const [batchAddError, setBatchAddError] = useState<string | null>(null);
|
||
const [isGeneratingBackground, setIsGeneratingBackground] = useState(false);
|
||
const [backgroundGenerationError, setBackgroundGenerationError] = useState<
|
||
string | null
|
||
>(null);
|
||
const [soundBusyAssetId, setSoundBusyAssetId] = useState<string | null>(null);
|
||
const [soundGenerationProgress, setSoundGenerationProgress] = useState<
|
||
number | null
|
||
>(null);
|
||
const [autoSaveState, setAutoSaveState] =
|
||
useState<Match3DAutoSaveState>('idle');
|
||
const [localError, setLocalError] = useState<string | null>(null);
|
||
const [isPublishing, setIsPublishing] = useState(false);
|
||
const [isStartingTestRun, setIsStartingTestRun] = useState(false);
|
||
const [isGeneratingTags, setIsGeneratingTags] = useState(false);
|
||
const generatedItemAssets = useMemo(
|
||
() => resolveMatch3DResultGeneratedItemAssets(profile, draft),
|
||
[draft, profile],
|
||
);
|
||
const blockers = useMemo(
|
||
() => buildPublishBlockers(editState, generatedItemAssets),
|
||
[editState, generatedItemAssets],
|
||
);
|
||
const testRunBlockers = useMemo(
|
||
() => buildTestRunBlockers(editState),
|
||
[editState],
|
||
);
|
||
const canStartTestRun = testRunBlockers.length === 0;
|
||
const canSubmit = blockers.length === 0;
|
||
const totalItemCount =
|
||
(normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) *
|
||
3;
|
||
const backgroundPreviewSrc = useMemo(
|
||
() =>
|
||
resolveMatch3DBackgroundPreviewSource(
|
||
profile,
|
||
draft,
|
||
generatedItemAssets,
|
||
),
|
||
[draft, generatedItemAssets, profile],
|
||
);
|
||
const backgroundPrompt = useMemo(
|
||
() => 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],
|
||
);
|
||
|
||
useEffect(() => {
|
||
setEditState(createEditState(profile));
|
||
setAutoSaveState('idle');
|
||
setLocalError(null);
|
||
setCoverReferenceSrc('');
|
||
setCoverPrompt('');
|
||
setCoverAiRedraw(false);
|
||
setCoverPanelError(null);
|
||
setBackgroundGenerationError(null);
|
||
setIsGeneratingBackground(false);
|
||
// 中文注释:表单草稿只在切换作品或服务端更新时间变化时重置,避免父级重渲染打断本地编辑。
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [profile.profileId, profile.updatedAt]);
|
||
|
||
useEffect(() => {
|
||
setAssetDrafts(createMatch3DAssetDrafts(profile, draft));
|
||
setActiveAssetId(null);
|
||
setSoundBusyAssetId(null);
|
||
setSoundGenerationProgress(null);
|
||
// 中文注释:素材草稿只跟随持久化素材字段和作品切换重建,避免无关 profile 字段刷新关闭当前面板。
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [
|
||
draft?.generatedItemAssets,
|
||
profile.generatedItemAssets,
|
||
profile.profileId,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
const payload = buildSavePayload(editState);
|
||
if (!payload) {
|
||
return undefined;
|
||
}
|
||
|
||
const currentTags = normalizeTags(profile.tags.join(','));
|
||
const nextTags = payload.tags;
|
||
const changed =
|
||
payload.gameName !== profile.gameName ||
|
||
payload.themeText !== profile.themeText ||
|
||
payload.summary !== profile.summary ||
|
||
(payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') ||
|
||
payload.clearCount !== profile.clearCount ||
|
||
payload.difficulty !== profile.difficulty ||
|
||
nextTags.length !== currentTags.length ||
|
||
nextTags.some((tag, index) => tag !== currentTags[index]);
|
||
|
||
if (!changed) {
|
||
return undefined;
|
||
}
|
||
|
||
setAutoSaveState('saving');
|
||
setLocalError(null);
|
||
let cancelled = false;
|
||
const timer = window.setTimeout(() => {
|
||
void updateMatch3DWork(profile.profileId, payload)
|
||
.then(({ item }) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
const playableItem = attachMatch3DGeneratedItemAssets(
|
||
item,
|
||
generatedItemAssets,
|
||
);
|
||
setAutoSaveState('saved');
|
||
onSaved?.(playableItem);
|
||
})
|
||
.catch((saveError) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
if (isGeneratingBackground) {
|
||
return;
|
||
}
|
||
setAutoSaveState('error');
|
||
setLocalError(
|
||
saveError instanceof Error ? saveError.message : '自动保存失败。',
|
||
);
|
||
});
|
||
}, MATCH3D_AUTOSAVE_DEBOUNCE_MS);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearTimeout(timer);
|
||
};
|
||
}, [editState, generatedItemAssets, isGeneratingBackground, onSaved, profile]);
|
||
|
||
const saveNow = async () => {
|
||
const payload = buildSavePayload(editState);
|
||
if (!payload) {
|
||
setLocalError(testRunBlockers[0] ?? '请补全作品信息。');
|
||
return null;
|
||
}
|
||
|
||
setAutoSaveState('saving');
|
||
setLocalError(null);
|
||
const { item } = await updateMatch3DWork(profile.profileId, payload);
|
||
const currentGeneratedItemAssets = buildPersistableGeneratedItemAssets(
|
||
assetDrafts,
|
||
generatedItemAssets,
|
||
);
|
||
let playableItem = attachMatch3DGeneratedItemAssets(
|
||
item,
|
||
currentGeneratedItemAssets.length > 0
|
||
? currentGeneratedItemAssets
|
||
: generatedItemAssets,
|
||
);
|
||
if (
|
||
shouldPersistGeneratedItemAssets(
|
||
currentGeneratedItemAssets,
|
||
item.generatedItemAssets ?? [],
|
||
)
|
||
) {
|
||
// 中文注释:试玩和发布前必须先把当前可见 2D 多视角素材写回 profile。
|
||
const { item: persistedItem } = await updateMatch3DGeneratedItemAssets(
|
||
profile.profileId,
|
||
{
|
||
generatedItemAssets: currentGeneratedItemAssets,
|
||
},
|
||
);
|
||
playableItem = attachMatch3DGeneratedItemAssets(
|
||
persistedItem,
|
||
currentGeneratedItemAssets,
|
||
);
|
||
}
|
||
setAutoSaveState('saved');
|
||
onSaved?.(playableItem);
|
||
return playableItem;
|
||
};
|
||
|
||
const handleCoverImageChange = async (
|
||
event: ChangeEvent<HTMLInputElement>,
|
||
) => {
|
||
const file = event.target.files?.[0] ?? null;
|
||
event.target.value = '';
|
||
if (!file) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const dataUrl = await readImageAsDataUrl(file);
|
||
setCoverReferenceSrc(dataUrl);
|
||
setCoverAiRedraw(true);
|
||
setCoverPanelError(null);
|
||
} catch (caughtError) {
|
||
setCoverPanelError(
|
||
caughtError instanceof Error ? caughtError.message : '封面图读取失败。',
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleOpenCoverPanel = () => {
|
||
setCoverReferenceSrc(editState.coverImageSrc);
|
||
setCoverPrompt(
|
||
[
|
||
editState.gameName.trim(),
|
||
editState.themeText.trim(),
|
||
'抓大鹅作品碰面图,主体清晰,适合作品卡片',
|
||
]
|
||
.filter(Boolean)
|
||
.join(','),
|
||
);
|
||
setCoverAiRedraw(false);
|
||
setCoverPanelError(null);
|
||
setIsCoverPanelOpen(true);
|
||
};
|
||
|
||
const handleSubmitCoverPanel = async () => {
|
||
const referenceSrc = coverReferenceSrc.trim();
|
||
const prompt = coverPrompt.trim();
|
||
if (!referenceSrc && !prompt) {
|
||
setCoverPanelError('请上传图片、引用素材或填写提示词。');
|
||
return;
|
||
}
|
||
if (!coverAiRedraw) {
|
||
setEditState((current) => ({
|
||
...current,
|
||
coverImageSrc: referenceSrc,
|
||
}));
|
||
setIsCoverPanelOpen(false);
|
||
setCoverPanelError(null);
|
||
return;
|
||
}
|
||
|
||
setIsGeneratingCover(true);
|
||
setCoverPanelError(null);
|
||
try {
|
||
const response = await generateMatch3DCoverImage(profile.profileId, {
|
||
prompt:
|
||
prompt ||
|
||
`${editState.gameName.trim() || '抓大鹅'}作品碰面图,${editState.themeText.trim() || '休闲消除'}题材`,
|
||
referenceImageSrc: referenceSrc || null,
|
||
});
|
||
setEditState((current) => ({
|
||
...current,
|
||
coverImageSrc: response.coverImageSrc,
|
||
}));
|
||
setIsCoverPanelOpen(false);
|
||
onSaved?.(response.item);
|
||
} catch (caughtError) {
|
||
setCoverPanelError(
|
||
caughtError instanceof Error ? caughtError.message : '碰面图生成失败。',
|
||
);
|
||
} finally {
|
||
setIsGeneratingCover(false);
|
||
}
|
||
};
|
||
|
||
const handleGenerateTags = async () => {
|
||
const gameName = editState.gameName.trim();
|
||
const themeText = editState.themeText.trim();
|
||
if (!gameName || !themeText) {
|
||
setLocalError('请先补齐作品名称和题材主题。');
|
||
return;
|
||
}
|
||
|
||
setIsGeneratingTags(true);
|
||
try {
|
||
const response = await generateMatch3DWorkTags({
|
||
gameName,
|
||
themeText,
|
||
summary: editState.summary.trim(),
|
||
});
|
||
const nextTags = normalizeTags(response.tags.join(','));
|
||
if (nextTags.length <= 0) {
|
||
throw new Error('未生成有效标签。');
|
||
}
|
||
setEditState((current) => ({
|
||
...current,
|
||
tagsText: nextTags.join(','),
|
||
}));
|
||
setLocalError(null);
|
||
} catch (caughtError) {
|
||
setLocalError(
|
||
caughtError instanceof Error
|
||
? caughtError.message
|
||
: 'AI 生成标签失败。',
|
||
);
|
||
} finally {
|
||
setIsGeneratingTags(false);
|
||
}
|
||
};
|
||
|
||
const updateItemAsset = (nextAsset: Match3DItemAssetDraft) => {
|
||
setAssetDrafts((currentAssets) =>
|
||
currentAssets.map((asset) =>
|
||
asset.id === nextAsset.id ? nextAsset : asset,
|
||
),
|
||
);
|
||
};
|
||
|
||
const persistAudioAssetDrafts = async (
|
||
nextDrafts: Match3DItemAssetDraft[],
|
||
) => {
|
||
return persistGeneratedAssetDrafts(nextDrafts);
|
||
};
|
||
|
||
const persistGeneratedAssetDrafts = async (
|
||
nextDrafts: Match3DItemAssetDraft[],
|
||
) => {
|
||
const nextAssets = createGeneratedAssetsFromDrafts(
|
||
nextDrafts,
|
||
profile.generatedItemAssets ?? [],
|
||
);
|
||
const { item } = await updateMatch3DGeneratedItemAssets(profile.profileId, {
|
||
generatedItemAssets: nextAssets,
|
||
});
|
||
onSaved?.(item);
|
||
return item;
|
||
};
|
||
|
||
const handleDeleteAssetDraft = async (assetId: string) => {
|
||
if (busy) {
|
||
return;
|
||
}
|
||
const nextDrafts = assetDrafts.filter((asset) => asset.id !== assetId);
|
||
setAssetDrafts(nextDrafts);
|
||
if (activeAssetId === assetId) {
|
||
setActiveAssetId(null);
|
||
}
|
||
try {
|
||
await persistGeneratedAssetDrafts(nextDrafts);
|
||
setLocalError(null);
|
||
} catch (caughtError) {
|
||
setAssetDrafts(assetDrafts);
|
||
setLocalError(
|
||
caughtError instanceof Error
|
||
? caughtError.message
|
||
: '删除物品素材失败。',
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleSubmitBatchAddItems = () => {
|
||
const itemNames = normalizeMatch3DItemNameList(batchItemNameValues);
|
||
if (itemNames.length <= 0 || batchGenerationState.phase === 'generating') {
|
||
setBatchAddError('请填写至少一个物品名称。');
|
||
return;
|
||
}
|
||
|
||
setBatchAddError(null);
|
||
setBatchGenerationState({
|
||
phase: 'generating',
|
||
progress: 0.08,
|
||
itemNames,
|
||
message: `正在生成 ${itemNames.length} 种物品素材`,
|
||
error: null,
|
||
});
|
||
|
||
let progressTick = 0;
|
||
const progressTimer = window.setInterval(() => {
|
||
progressTick += 1;
|
||
setBatchGenerationState((current) => {
|
||
if (current.phase !== 'generating') {
|
||
return current;
|
||
}
|
||
const currentProgress = current.progress ?? 0.08;
|
||
const ceiling = progressTick < 4 ? 0.42 : 0.88;
|
||
return {
|
||
...current,
|
||
progress: Math.min(ceiling, currentProgress + 0.08),
|
||
};
|
||
});
|
||
}, 1200);
|
||
|
||
void generateMatch3DItemAssets(profile.profileId, { itemNames })
|
||
.then((response) => {
|
||
window.clearInterval(progressTimer);
|
||
const refreshedProfile = attachMatch3DGeneratedItemAssets(
|
||
response.item,
|
||
response.generatedItemAssets,
|
||
);
|
||
setAssetDrafts(createMatch3DAssetDrafts(refreshedProfile, null));
|
||
setBatchItemNameValues(['']);
|
||
setBatchGenerationState({
|
||
phase: 'done',
|
||
progress: 1,
|
||
itemNames,
|
||
message: `已生成 ${itemNames.length} 种物品素材`,
|
||
error: null,
|
||
});
|
||
setLocalError(null);
|
||
onSaved?.(refreshedProfile);
|
||
})
|
||
.catch((caughtError) => {
|
||
window.clearInterval(progressTimer);
|
||
const message =
|
||
caughtError instanceof Error
|
||
? caughtError.message
|
||
: '批量新增物品素材失败。';
|
||
setBatchAddError(message);
|
||
setBatchGenerationState({
|
||
phase: 'failed',
|
||
progress: null,
|
||
itemNames,
|
||
message: null,
|
||
error: message,
|
||
});
|
||
});
|
||
};
|
||
|
||
const handleGenerateBackground = async (prompt: string) => {
|
||
const normalizedPrompt = prompt.trim();
|
||
if (!normalizedPrompt || isGeneratingBackground) {
|
||
setBackgroundGenerationError('请填写画面描述提示词。');
|
||
return;
|
||
}
|
||
|
||
setIsGeneratingBackground(true);
|
||
setBackgroundGenerationError(null);
|
||
try {
|
||
const response = await generateMatch3DBackgroundImage(profile.profileId, {
|
||
prompt: normalizedPrompt,
|
||
});
|
||
const nextGeneratedAssets = attachMatch3DGeneratedBackgroundAsset(
|
||
response.item.generatedItemAssets?.length
|
||
? response.item.generatedItemAssets
|
||
: generatedItemAssets,
|
||
response.generatedBackgroundAsset,
|
||
);
|
||
const refreshedProfile = attachMatch3DGeneratedItemAssets(
|
||
response.item,
|
||
nextGeneratedAssets,
|
||
);
|
||
setAssetDrafts(createMatch3DAssetDrafts(refreshedProfile, null));
|
||
onSaved?.(refreshedProfile);
|
||
setLocalError(null);
|
||
} catch (caughtError) {
|
||
setBackgroundGenerationError(
|
||
caughtError instanceof Error
|
||
? caughtError.message
|
||
: 'UI背景图生成失败。',
|
||
);
|
||
} finally {
|
||
setIsGeneratingBackground(false);
|
||
}
|
||
};
|
||
|
||
const patchAndPersistAudioAssetDrafts = async (
|
||
patcher: (drafts: Match3DItemAssetDraft[]) => Match3DItemAssetDraft[],
|
||
) => {
|
||
const nextDrafts = patcher(assetDrafts);
|
||
setAssetDrafts(nextDrafts);
|
||
await persistAudioAssetDrafts(nextDrafts);
|
||
};
|
||
|
||
const handleBackgroundMusicGenerated = async (
|
||
music: CreationAudioAsset,
|
||
metadata: {
|
||
prompt: string;
|
||
style: string;
|
||
title: string;
|
||
},
|
||
) => {
|
||
try {
|
||
await patchAndPersistAudioAssetDrafts((drafts) => {
|
||
if (drafts.length <= 0) {
|
||
return drafts;
|
||
}
|
||
return drafts.map((asset, index) =>
|
||
index === 0
|
||
? {
|
||
...asset,
|
||
backgroundMusic: music,
|
||
backgroundMusicPrompt: metadata.prompt,
|
||
backgroundMusicStyle: metadata.style,
|
||
backgroundMusicTitle: metadata.title,
|
||
}
|
||
: asset,
|
||
);
|
||
});
|
||
setLocalError(null);
|
||
} catch (caughtError) {
|
||
setLocalError(
|
||
caughtError instanceof Error
|
||
? caughtError.message
|
||
: '保存背景音乐失败。',
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleGenerateClickSound = async (asset: Match3DItemAssetDraft) => {
|
||
if (soundBusyAssetId) {
|
||
return;
|
||
}
|
||
setSoundBusyAssetId(asset.id);
|
||
setSoundGenerationProgress(0.12);
|
||
setLocalError(null);
|
||
try {
|
||
const prompt =
|
||
asset.soundPrompt.trim() ||
|
||
buildFallbackMatch3DClickSoundPrompt(
|
||
{ themeText: editState.themeText },
|
||
asset.name,
|
||
);
|
||
const task = await createSoundEffectTask({
|
||
prompt,
|
||
duration: 3,
|
||
});
|
||
setSoundGenerationProgress(0.35);
|
||
const generated = await waitForGeneratedAudioAsset(
|
||
task.taskId,
|
||
async () => {
|
||
const result = await publishSoundEffectAsset(task.taskId, {
|
||
entityKind: 'match3d_item',
|
||
entityId: asset.id,
|
||
slot: 'click_sound',
|
||
assetKind: MATCH3D_CLICK_SOUND_ASSET_KIND,
|
||
profileId: profile.profileId,
|
||
storagePrefix: 'match3d_assets',
|
||
});
|
||
setSoundGenerationProgress(result.audioSrc?.trim() ? 0.92 : 0.58);
|
||
return result;
|
||
},
|
||
);
|
||
if (!generated.audioSrc) {
|
||
throw new Error('音效生成完成但缺少播放地址。');
|
||
}
|
||
const clickSound: CreationAudioAsset = {
|
||
taskId: generated.taskId,
|
||
provider: generated.provider,
|
||
assetObjectId: generated.assetObjectId ?? null,
|
||
assetKind: generated.assetKind ?? MATCH3D_CLICK_SOUND_ASSET_KIND,
|
||
audioSrc: generated.audioSrc,
|
||
prompt,
|
||
title: `${asset.name}点击音效`,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
await patchAndPersistAudioAssetDrafts((drafts) =>
|
||
drafts.map((draftAsset) =>
|
||
draftAsset.id === asset.id
|
||
? {
|
||
...draftAsset,
|
||
clickSound,
|
||
soundPrompt: prompt,
|
||
updatedAt: new Date().toISOString(),
|
||
}
|
||
: draftAsset,
|
||
),
|
||
);
|
||
setSoundGenerationProgress(1);
|
||
} catch (caughtError) {
|
||
setLocalError(
|
||
caughtError instanceof Error
|
||
? caughtError.message
|
||
: '点击音效生成失败。',
|
||
);
|
||
} finally {
|
||
setSoundBusyAssetId(null);
|
||
window.setTimeout(() => setSoundGenerationProgress(null), 350);
|
||
}
|
||
};
|
||
|
||
const handleStartTestRun = async () => {
|
||
if (!canStartTestRun || isStartingTestRun) {
|
||
setLocalError(testRunBlockers[0] ?? null);
|
||
return;
|
||
}
|
||
|
||
setIsStartingTestRun(true);
|
||
try {
|
||
const savedProfile = await saveNow();
|
||
const playableProfile =
|
||
savedProfile ??
|
||
buildPlayableProfile(profile, editState, generatedItemAssets);
|
||
const playableAssets =
|
||
playableProfile.generatedItemAssets ?? generatedItemAssets;
|
||
const targetItemTypeCount =
|
||
getMatch3DDifficultyOptionFromEditState(editState).itemTypeCount;
|
||
const playableItemTypeCount = getMatch3DPlayableItemTypeCount(
|
||
targetItemTypeCount,
|
||
playableAssets,
|
||
);
|
||
onStartTestRun(playableProfile, {
|
||
itemTypeCountOverride: playableItemTypeCount,
|
||
});
|
||
} catch (caughtError) {
|
||
setLocalError(
|
||
caughtError instanceof Error
|
||
? caughtError.message
|
||
: '启动试玩前保存失败。',
|
||
);
|
||
} finally {
|
||
setIsStartingTestRun(false);
|
||
}
|
||
};
|
||
|
||
const handlePublish = async () => {
|
||
if (!canSubmit || isPublishing) {
|
||
setLocalError(blockers[0] ?? null);
|
||
return;
|
||
}
|
||
|
||
setIsPublishing(true);
|
||
try {
|
||
const savedProfile = await saveNow();
|
||
const { item } = await publishMatch3DWork(
|
||
savedProfile?.profileId ?? profile.profileId,
|
||
);
|
||
onPublished?.(
|
||
attachMatch3DGeneratedItemAssets(
|
||
item,
|
||
savedProfile?.generatedItemAssets ?? generatedItemAssets,
|
||
),
|
||
);
|
||
setLocalError(null);
|
||
} catch (caughtError) {
|
||
setLocalError(
|
||
caughtError instanceof Error
|
||
? caughtError.message
|
||
: '发布抓大鹅作品失败。',
|
||
);
|
||
} finally {
|
||
setIsPublishing(false);
|
||
}
|
||
};
|
||
|
||
const busy =
|
||
isBusy ||
|
||
isPublishing ||
|
||
isStartingTestRun ||
|
||
isGeneratingCover ||
|
||
isGeneratingBackground ||
|
||
Boolean(soundBusyAssetId);
|
||
const workBusy = busy || isGeneratingTags;
|
||
const displayError = error ?? localError;
|
||
|
||
return (
|
||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)]">
|
||
<Match3DResultHeader
|
||
autoSaveState={autoSaveState}
|
||
isBusy={busy}
|
||
onBack={onBack}
|
||
/>
|
||
|
||
<Match3DResultTabs activeTab={activeTab} onChange={setActiveTab} />
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||
{activeTab === 'work' ? (
|
||
<Match3DWorkInfoTab
|
||
editState={editState}
|
||
isBusy={workBusy}
|
||
onChange={setEditState}
|
||
onOpenCoverPanel={handleOpenCoverPanel}
|
||
onGenerateTags={handleGenerateTags}
|
||
/>
|
||
) : null}
|
||
{activeTab === 'config' ? (
|
||
<Match3DConfigTab
|
||
editState={editState}
|
||
generatedItemAssets={generatedItemAssets}
|
||
isBusy={busy}
|
||
totalItemCount={totalItemCount}
|
||
onChange={setEditState}
|
||
/>
|
||
) : null}
|
||
{activeTab === 'assets' ? (
|
||
<Match3DAssetConfigTab
|
||
activeAssetConfigTab={activeAssetConfigTab}
|
||
activeAssetId={activeAssetId}
|
||
assetDrafts={assetDrafts}
|
||
backgroundPreviewSrc={backgroundPreviewSrc}
|
||
containerPreviewSrc={containerPreviewSrc}
|
||
backgroundPrompt={backgroundPrompt}
|
||
backgroundGenerationError={backgroundGenerationError}
|
||
batchGenerationState={batchGenerationState}
|
||
busy={busy}
|
||
editState={editState}
|
||
isGeneratingBackground={isGeneratingBackground}
|
||
profileId={profile.profileId}
|
||
soundBusyAssetId={soundBusyAssetId}
|
||
soundGenerationProgress={soundGenerationProgress}
|
||
onActiveAssetChange={setActiveAssetId}
|
||
onAddBatch={() => {
|
||
setBatchAddError(null);
|
||
setBatchGenerationState((current) =>
|
||
current.phase === 'generating'
|
||
? current
|
||
: {
|
||
phase: 'idle',
|
||
progress: null,
|
||
itemNames: [],
|
||
message: null,
|
||
error: null,
|
||
},
|
||
);
|
||
setIsBatchAddPanelOpen(true);
|
||
}}
|
||
onAssetChange={updateItemAsset}
|
||
onAssetConfigTabChange={setActiveAssetConfigTab}
|
||
onDeleteAsset={(assetId) => {
|
||
void handleDeleteAssetDraft(assetId);
|
||
}}
|
||
onGenerateBackground={(prompt) => {
|
||
void handleGenerateBackground(prompt);
|
||
}}
|
||
onGenerateClickSound={(asset) => {
|
||
void handleGenerateClickSound(asset);
|
||
}}
|
||
onMusicGenerated={(music, metadata) => {
|
||
void handleBackgroundMusicGenerated(music, metadata);
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
|
||
{displayError ? (
|
||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||
{displayError}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-3 flex flex-col gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:flex-row sm:justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={handleStartTestRun}
|
||
disabled={!canStartTestRun || busy}
|
||
className={`platform-button platform-button--ghost min-h-11 justify-center gap-2 px-5 py-3 ${!canStartTestRun || busy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||
>
|
||
{isStartingTestRun ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Play className="h-4 w-4" />
|
||
)}
|
||
试玩
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handlePublish}
|
||
disabled={!canSubmit || busy}
|
||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || busy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||
>
|
||
{isPublishing ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : profile.publicationStatus === 'published' ? (
|
||
<CheckCircle2 className="h-4 w-4" />
|
||
) : (
|
||
<Send className="h-4 w-4" />
|
||
)}
|
||
{profile.publicationStatus === 'published' ? '更新发布' : '发布'}
|
||
</button>
|
||
</div>
|
||
|
||
{isCoverPanelOpen ? (
|
||
<Match3DCoverImagePanel
|
||
editState={editState}
|
||
sourceAssets={coverSourceAssets}
|
||
isGenerating={isGeneratingCover}
|
||
selectedReferenceSrc={coverReferenceSrc}
|
||
aiRedraw={coverAiRedraw}
|
||
prompt={coverPrompt}
|
||
error={coverPanelError}
|
||
onAiRedrawChange={setCoverAiRedraw}
|
||
onClose={() => {
|
||
if (!isGeneratingCover) {
|
||
setIsCoverPanelOpen(false);
|
||
}
|
||
}}
|
||
onFileChange={handleCoverImageChange}
|
||
onPromptChange={setCoverPrompt}
|
||
onReferenceSelect={(source) => {
|
||
setCoverReferenceSrc(source);
|
||
setCoverAiRedraw(true);
|
||
setCoverPanelError(null);
|
||
}}
|
||
onSubmit={() => {
|
||
void handleSubmitCoverPanel();
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{isBatchAddPanelOpen ? (
|
||
<Match3DBatchAddItemsPanel
|
||
values={batchItemNameValues}
|
||
generationState={batchGenerationState}
|
||
error={batchAddError}
|
||
onAddInput={() => {
|
||
setBatchItemNameValues((current) => [...current, '']);
|
||
}}
|
||
onChangeValue={(index, value) => {
|
||
setBatchItemNameValues((current) =>
|
||
current.map((item, itemIndex) =>
|
||
itemIndex === index ? value : item,
|
||
),
|
||
);
|
||
}}
|
||
onClose={() => {
|
||
setIsBatchAddPanelOpen(false);
|
||
}}
|
||
onSubmit={() => {
|
||
handleSubmitBatchAddItems();
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default Match3DResultView;
|