Files
Genarrative/src/components/match3d-result/Match3DResultView.tsx
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

3744 lines
120 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowLeft,
CheckCircle2,
Eye,
ImageIcon,
ImagePlus,
Loader2,
Play,
Plus,
Send,
Settings,
Trash2,
Wand2,
} 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 {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
Match3DWorkProfile,
PutMatch3DWorkRequest,
} from '../../../packages/shared/src/contracts/match3dWorks';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import {
generateMatch3DCoverImage,
generateMatch3DItemAssets,
generateMatch3DWorkTags,
publishMatch3DWork,
updateMatch3DGeneratedItemAssets,
updateMatch3DWork,
} from '../../services/match3d-works';
import {
getMatch3DGeneratedImageViewSources,
mergeMatch3DGeneratedItemAssetsForRuntime,
normalizeMatch3DGeneratedItemAssetsForRuntime,
resolveMatch3DGeneratedImageAssetSource,
resolveMatch3DGeneratedModelAssetSource,
} from '../../services/match3dGeneratedModelCache';
import {
buildMatch3DItemSpritesheetViewRegions,
loadMatch3DSpritesheetAssetRegions,
type Match3DDecodedSpritesheetRegion,
} from '../../services/match3dSpritesheetParser';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformMediaTileGrid } from '../common/PlatformMediaTileGrid';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformPillSwitch } from '../common/PlatformPillSwitch';
import { PlatformProgressBar } from '../common/PlatformProgressBar';
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
import { PlatformStatGrid } from '../common/PlatformStatGrid';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTagEditor } from '../common/PlatformTagEditor';
import { PlatformTextField } from '../common/PlatformTextField';
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
import { UnifiedConfirmDialog } from '../common/UnifiedConfirmDialog';
import {
MATCH3D_RUNTIME_BOARD_BASE_CLASS,
MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS,
MATCH3D_RUNTIME_BOARD_WIDTH,
MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS,
MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS,
MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS,
MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
MATCH3D_RUNTIME_HEADER_CARD_CLASS,
MATCH3D_RUNTIME_LEVEL_BADGE_CLASS,
MATCH3D_RUNTIME_STAGE_CLASS,
MATCH3D_RUNTIME_TIMER_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';
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 Match3DItemSpritesheetPreviewGroup = {
itemIndex: number;
itemName: string;
regions: Match3DDecodedSpritesheetRegion[];
};
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';
};
type Match3DCoverReferenceDraft = {
id: string;
label: string;
imageSrc: string;
source: 'asset' | 'upload';
};
const MATCH3D_MIN_TAG_COUNT = 3;
const MATCH3D_MAX_TAG_COUNT = 6;
const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600;
const MATCH3D_DEFAULT_ASSET_COUNT = 20;
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH = 2;
const MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE = 20;
const MATCH3D_COVER_REFERENCE_IMAGE_LIMIT = 6;
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素材' },
];
// 中文注释:结果页难度配置必须与创作入口页保持同一组派生参数。
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: 20,
},
] 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';
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 findMatch3DGeneratedBackgroundAsset(
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
): Match3DGeneratedBackgroundAsset | null {
return (
generatedItemAssets.find((asset) => asset.backgroundAsset)
?.backgroundAsset ?? null
);
}
function promoteMatch3DGeneratedBackgroundAsset(
profile: Match3DWorkProfile,
): Match3DWorkProfile {
const fallbackBackground =
profile.generatedBackgroundAsset ??
findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets ?? []);
if (!fallbackBackground) {
return profile;
}
return {
...profile,
backgroundPrompt:
profile.backgroundPrompt ?? fallbackBackground.prompt ?? null,
backgroundImageSrc:
profile.backgroundImageSrc ??
fallbackBackground.imageSrc ??
fallbackBackground.imageObjectKey ??
null,
backgroundImageObjectKey:
profile.backgroundImageObjectKey ??
fallbackBackground.imageObjectKey ??
fallbackBackground.imageSrc ??
null,
generatedBackgroundAsset:
profile.generatedBackgroundAsset ?? fallbackBackground,
};
}
function resolveMatch3DContainerPreviewSource(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
draft?.generatedBackgroundAsset?.containerImageSrc?.trim() ||
draft?.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.containerImageSrc?.trim() ||
profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
''
);
}
function resolveMatch3DUiSpritesheetPreviewSource(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
draft?.generatedBackgroundAsset?.uiSpritesheetImageSrc?.trim() ||
draft?.generatedBackgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
draft?.generatedBackgroundAsset?.containerImageSrc?.trim() ||
draft?.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.uiSpritesheetImageSrc?.trim() ||
profile.generatedBackgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.containerImageSrc?.trim() ||
profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
''
);
}
function resolveMatch3DItemSpritesheetPreviewSource(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
draft?.generatedBackgroundAsset?.itemSpritesheetImageSrc?.trim() ||
draft?.generatedBackgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.itemSpritesheetImageSrc?.trim() ||
profile.generatedBackgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
''
);
}
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 collectMatch3DRegenerateItemNames(
values: readonly string[],
assets: readonly Match3DItemAssetDraft[],
) {
const existingNames = new Set(
assets.map((asset) => asset.name.trim()).filter(Boolean),
);
return normalizeMatch3DItemNameList(values).filter((name) =>
existingNames.has(name.trim()),
);
}
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?.levelSceneImageSrc?.trim() ||
asset.backgroundAsset?.levelSceneImageObjectKey?.trim() ||
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.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.itemSize?.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?.levelScenePrompt?.trim() ?? '',
asset.backgroundAsset?.levelSceneImageSrc?.trim() ?? '',
asset.backgroundAsset?.levelSceneImageObjectKey?.trim() ?? '',
asset.backgroundAsset?.imageSrc?.trim() ?? '',
asset.backgroundAsset?.imageObjectKey?.trim() ?? '',
asset.backgroundAsset?.uiSpritesheetPrompt?.trim() ?? '',
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ?? '',
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ?? '',
asset.backgroundAsset?.itemSpritesheetPrompt?.trim() ?? '',
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ?? '',
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.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,
itemSize: override.itemSize ?? base.itemSize ?? null,
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: '圆形空间周边装饰物',
},
];
const fallbackSeeds = Array.from(
{ length: MATCH3D_DEFAULT_ASSET_COUNT },
(_, index) => {
const seed = seeds[index];
return (
seed ?? {
id: `generated-item-${index + 1}`,
name: `${theme}物品${index + 1}`,
usage: '局内点击消除物件',
}
);
},
);
return fallbackSeeds.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 ?? existing?.backgroundMusic ?? null,
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 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 (
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="sm"
className="bg-white/62"
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 ? (
<PlatformProgressBar
value={normalizedProgress * 100}
ariaLabel="物品素材生成进度百分比"
fillClassName="bg-[var(--platform-accent)]"
trackStyle={{ backgroundColor: 'rgba(255,255,255,0.7)' }}
/>
) : 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-[var(--platform-button-danger-text)]">
{generationState.error}
</div>
) : null}
</PlatformSubpanel>
);
}
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)];
}
async function readImageAsDataUrl(file: File) {
return readPuzzleReferenceImageAsDataUrl(file);
}
async function readCoverReferenceImageAsDataUrl(file: File) {
return readPuzzleReferenceImageAsDataUrl(file);
}
function resolveMatch3DCoverSourceAssets(
assetDrafts: Match3DItemAssetDraft[],
backgroundPreviewSrc: string,
containerPreviewSrc: 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,
},
]
: []),
...(containerPreviewSrc.trim() &&
containerPreviewSrc.trim() !== backgroundPreviewSrc.trim()
? [
{
id: 'ui:container',
label: '游戏容器图',
imageSrc: containerPreviewSrc,
kind: 'ui' as const,
},
]
: []),
];
return [...itemAssets, ...uiAssets];
}
function createMatch3DCoverReferenceDraftFromSource(
asset: Match3DCoverSourceAsset,
): Match3DCoverReferenceDraft {
return {
id: asset.id,
label: asset.label,
imageSrc: asset.imageSrc,
source: 'asset',
};
}
function addMatch3DCoverReferenceDraft(
currentReferences: Match3DCoverReferenceDraft[],
nextReference: Match3DCoverReferenceDraft,
) {
const deduped = currentReferences.filter(
(reference) => reference.imageSrc !== nextReference.imageSrc,
);
return [nextReference, ...deduped].slice(
0,
MATCH3D_COVER_REFERENCE_IMAGE_LIMIT,
);
}
function buildPlayableProfile(
profile: Match3DWorkProfile,
editState: Match3DResultEditState,
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
) {
const payload = buildSavePayload(editState);
if (!payload) {
return promoteMatch3DGeneratedBackgroundAsset(
attachMatch3DGeneratedItemAssets(profile, generatedItemAssets),
);
}
return promoteMatch3DGeneratedBackgroundAsset(
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 buildCoverImageUpdatedProfile(
profile: Match3DWorkProfile,
editState: Match3DResultEditState,
responseItem: Match3DWorkProfile,
coverImageSrc: string,
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
) {
// 中文注释:封面生成只允许更新封面字段;接口回包如果来自旧快照,不能覆盖当前物品素材或难度配置。
const visibleProfile = buildPlayableProfile(
profile,
{
...editState,
coverImageSrc,
},
generatedItemAssets.length > 0
? generatedItemAssets
: (profile.generatedItemAssets ?? []),
);
return {
...visibleProfile,
coverImageSrc,
updatedAt: responseItem.updatedAt || visibleProfile.updatedAt,
};
}
function resolveMatch3DResultGeneratedItemAssets(
profile: Match3DWorkProfile,
draft: Match3DResultDraft | null,
) {
const profileAssets = profile.generatedItemAssets ?? [];
const draftAssets = draft?.generatedItemAssets ?? [];
if (draftAssets.length <= 0) {
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
if (profileAssets.length <= 0) {
return normalizeMatch3DGeneratedItemAssetsForRuntime(draftAssets);
}
return mergeMatch3DGeneratedItemAssetsForRuntime(
draftAssets.map((draftAsset) => {
const profileAsset = profileAssets.find(
(asset) => asset.itemId === draftAsset.itemId,
);
return profileAsset
? mergeMatch3DGeneratedItemAsset(draftAsset, profileAsset)
: draftAsset;
}),
profileAssets,
);
}
function attachMatch3DGeneratedItemAssets(
profile: Match3DWorkProfile,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
if (generatedItemAssets.length <= 0) {
return promoteMatch3DGeneratedBackgroundAsset(profile);
}
// 中文注释:试玩入口依赖当前页面可见的生成素材;保存接口若返回旧快照,不能把素材从运行态入参里丢掉。
return promoteMatch3DGeneratedBackgroundAsset({
...profile,
generatedItemAssets:
normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets),
});
}
function buildPersistableGeneratedItemAssets(
assetDrafts: Match3DItemAssetDraft[],
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
if (generatedItemAssets.length <= 0) {
return [];
}
return normalizeMatch3DGeneratedItemAssetsForRuntime(
createGeneratedAssetsFromDrafts(assetDrafts, generatedItemAssets).filter(
hasPersistableMatch3DGeneratedItemAsset,
),
);
}
function Match3DResultHeader({
autoSaveState,
isBusy,
onBack,
}: {
autoSaveState: Match3DAutoSaveState;
isBusy: boolean;
onBack: () => void;
}) {
const badge =
autoSaveState === 'saving' ? (
<PlatformPillBadge tone="warning" size="xs" className="px-3 py-1">
</PlatformPillBadge>
) : autoSaveState === 'saved' ? (
<PlatformPillBadge tone="success" size="xs" className="px-3 py-1">
</PlatformPillBadge>
) : autoSaveState === 'error' ? (
<PlatformPillBadge tone="danger" size="xs" className="px-3 py-1">
</PlatformPillBadge>
) : null;
return (
<div className="mb-4 flex items-center justify-between gap-3">
<PlatformActionButton
onClick={onBack}
disabled={isBusy}
tone="ghost"
size="xs"
className="min-h-0 self-start py-1.5 text-[11px]"
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</PlatformActionButton>
{badge}
</div>
);
}
function Match3DResultTabs({
activeTab,
onChange,
}: {
activeTab: Match3DResultTab;
onChange: (tab: Match3DResultTab) => void;
}) {
return (
<PlatformSegmentedTabs
items={MATCH3D_RESULT_TABS}
activeId={activeTab}
onChange={onChange}
columns="three"
className="mb-3"
itemClassName="px-2 sm:px-3"
/>
);
}
function Match3DWorkInfoTab({
editState,
isBusy,
onChange,
onGenerateTags,
}: {
editState: Match3DResultEditState;
isBusy: boolean;
onChange: (nextState: Match3DResultEditState) => void;
onGenerateTags: () => void;
}) {
const tags = normalizeTags(editState.tagsText);
const updateTags = (nextTags: string[]) => {
onChange({ ...editState, tagsText: nextTags.join('') });
};
return (
<PlatformSubpanel radius="lg" padding="lg" className="space-y-3">
<label className="block">
<PlatformFieldLabel variant="section"></PlatformFieldLabel>
<PlatformTextField
value={editState.gameName}
disabled={isBusy}
onChange={(event) =>
onChange({ ...editState, gameName: event.target.value })
}
size="lg"
className="mt-2"
aria-label="作品名称"
/>
</label>
<label className="block">
<PlatformFieldLabel variant="section"></PlatformFieldLabel>
<PlatformTextField
variant="textarea"
value={editState.summary}
disabled={isBusy}
onChange={(event) =>
onChange({ ...editState, summary: event.target.value })
}
rows={6}
size="md"
className="mt-2"
aria-label="作品描述"
/>
</label>
<PlatformTagEditor
title="作品标签"
tags={tags}
disabled={isBusy}
maxTags={MATCH3D_MAX_TAG_COUNT}
addLabel="新增作品标签"
generateLabel="AI生成作品标签"
parseInput={normalizeMatch3DTagListText}
onChange={updateTags}
onGenerate={onGenerateTags}
generateIcon={<Wand2 className="h-4 w-4" />}
tone="warm"
padding="none"
/>
</PlatformSubpanel>
);
}
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>
<PlatformModalCloseButton
onClick={onClose}
label="关闭"
variant="platformIcon"
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{children}
</div>
</div>
</div>,
document.body,
);
}
type Match3DCoverImageEditorProps = {
editState: Match3DResultEditState;
sourceAssets: Match3DCoverSourceAsset[];
isGenerating: boolean;
uploadedImageSrc: string;
referenceImages: Match3DCoverReferenceDraft[];
aiRedraw: boolean;
prompt: string;
error: string | null;
onAiRedrawChange: (enabled: boolean) => void;
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
onPromptChange: (value: string) => void;
onReferenceSelect: (source: string) => void;
onReferenceFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
onReferenceRemove: (referenceId: string) => void;
onUploadedImageRemove: () => void;
onSubmit: () => void;
};
function Match3DCoverImageEditor({
editState,
sourceAssets,
isGenerating,
uploadedImageSrc,
referenceImages,
aiRedraw,
prompt,
error,
onAiRedrawChange,
onFileChange,
onPromptChange,
onReferenceSelect,
onReferenceFileChange,
onReferenceRemove,
onUploadedImageRemove,
onSubmit,
}: Match3DCoverImageEditorProps) {
const previewSrc = uploadedImageSrc || editState.coverImageSrc;
const promptLabel = uploadedImageSrc ? 'AI重绘要求' : '封面描述';
const canSubmit = Boolean(uploadedImageSrc.trim() || prompt.trim());
return (
<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="relative aspect-square overflow-hidden rounded-[1.35rem] border border-[var(--platform-subpanel-border)] bg-white/70">
<input
id="match3d-cover-upload-input"
type="file"
accept="image/*"
className="sr-only"
disabled={isGenerating}
onChange={onFileChange}
/>
<label
htmlFor="match3d-cover-upload-input"
className={`absolute inset-0 z-0 ${isGenerating ? 'cursor-not-allowed' : 'cursor-pointer'}`}
title={previewSrc ? '更换封面图' : '上传封面图'}
>
<span className="sr-only">
{previewSrc ? '更换封面图' : '上传封面图'}
</span>
</label>
{previewSrc ? (
<ResolvedAssetImage
src={previewSrc}
alt="封面图预览"
className="pointer-events-none h-full w-full object-cover"
/>
) : (
<div className="pointer-events-none grid h-full w-full place-items-center text-[var(--platform-text-soft)]">
<ImageIcon className="h-10 w-10" />
</div>
)}
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.10)_0%,rgba(255,255,255,0.02)_44%,rgba(255,255,255,0.16)_100%)]" />
{uploadedImageSrc ? (
<>
<PlatformPillSwitch
label="AI重绘"
aria-label="AI重绘"
checked={aiRedraw}
disabled={isGenerating}
onChange={(event) => onAiRedrawChange(event.target.checked)}
className="absolute bottom-3 left-3 z-10"
/>
<PlatformIconButton
variant="surfaceFloating"
label="移除封面图"
title="移除封面图"
icon={<Trash2 className="h-4 w-4" />}
disabled={isGenerating}
onClick={onUploadedImageRemove}
className="absolute left-3 top-3 z-10 h-10 w-10"
/>
</>
) : (
<label
htmlFor="match3d-cover-upload-input"
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[var(--platform-accent)] ${isGenerating ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
>
/
</label>
)}
</div>
</div>
<div className="space-y-3">
<label className="block">
<PlatformFieldLabel variant="section">
{promptLabel}
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
value={prompt}
disabled={isGenerating}
rows={5}
onChange={(event) => onPromptChange(event.target.value)}
size="md"
className="mt-2"
aria-label={promptLabel}
/>
</label>
{!uploadedImageSrc ? (
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<PlatformFieldLabel variant="section"></PlatformFieldLabel>
<PlatformIconButton
asChild="label"
className="h-9 w-9 cursor-pointer"
label="上传参考图"
title="上传参考图"
icon={
<>
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept="image/*"
className="sr-only"
disabled={isGenerating}
onChange={onReferenceFileChange}
/>
</>
}
/>
</div>
{referenceImages.length > 0 ? (
<div className="mb-3 grid grid-cols-3 gap-2 sm:grid-cols-4">
{referenceImages.map((reference) => (
<PlatformUploadPreviewCard
key={reference.id}
imageSrc={reference.imageSrc}
imageAlt=""
caption={reference.label}
removeLabel={`移除参考图 ${reference.label}`}
onRemove={() => onReferenceRemove(reference.id)}
disabled={isGenerating}
resolveAsset
className="h-auto w-full rounded-[1rem] border-[var(--platform-warm-border)] bg-white/74"
removeIcon={<Trash2 className="h-3.5 w-3.5" />}
removeButtonProps={{
title: '移除参考图',
className:
'bottom-1.5 right-1.5 top-auto h-6 w-6 bg-white/92 text-[var(--platform-text-strong)] shadow-sm hover:bg-white hover:text-[var(--platform-accent)]',
}}
/>
))}
</div>
) : null}
{sourceAssets.length > 0 ? (
<PlatformAssetPickerGrid
items={sourceAssets}
loadingLabel="读取中..."
emptyLabel="暂无可引用素材"
disabled={isGenerating}
getKey={(asset) => asset.id}
getImageSrc={(asset) => asset.imageSrc}
getImageAlt={() => ''}
getTitle={(asset) => asset.label}
getAriaLabel={(asset) => `引用${asset.label}`}
isSelected={(asset) =>
referenceImages.some(
(reference) => reference.imageSrc === asset.imageSrc,
)
}
onSelect={(asset) => onReferenceSelect(asset.imageSrc)}
gridClassName="grid grid-cols-3 gap-2 sm:grid-cols-4"
cardClassName="bg-white/74"
cardRadiusClassName="rounded-[1rem]"
imageShellClassName="aspect-square"
bodyClassName="truncate px-2 py-2 text-[11px] font-semibold text-[var(--platform-text-base)]"
/>
) : null}
</div>
) : null}
{error ? (
<PlatformStatusMessage tone="error" surface="platform" size="md">
{error}
</PlatformStatusMessage>
) : null}
<PlatformActionButton
disabled={!canSubmit || isGenerating}
onClick={onSubmit}
size="md"
fullWidth
className="min-h-11 gap-2"
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : uploadedImageSrc && !aiRedraw ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Wand2 className="h-4 w-4" />
)}
{uploadedImageSrc && !aiRedraw ? '使用当前图片' : '生成封面图'}
</PlatformActionButton>
</div>
</div>
);
}
function Match3DPublishDialog({
blockers,
editState,
isBusy,
isGeneratingCover,
isPublishing,
sourceAssets,
uploadedImageSrc,
referenceImages,
aiRedraw,
prompt,
coverError,
publishError,
onAiRedrawChange,
onClose,
onFileChange,
onPromptChange,
onPublish,
onReferenceSelect,
onReferenceFileChange,
onReferenceRemove,
onUploadedImageRemove,
onSubmitCover,
}: {
blockers: string[];
editState: Match3DResultEditState;
isBusy: boolean;
isGeneratingCover: boolean;
isPublishing: boolean;
sourceAssets: Match3DCoverSourceAsset[];
uploadedImageSrc: string;
referenceImages: Match3DCoverReferenceDraft[];
aiRedraw: boolean;
prompt: string;
coverError: string | null;
publishError: string | null;
onAiRedrawChange: (enabled: boolean) => void;
onClose: () => void;
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
onPromptChange: (value: string) => void;
onPublish: () => void;
onReferenceSelect: (source: string) => void;
onReferenceFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
onReferenceRemove: (referenceId: string) => void;
onUploadedImageRemove: () => void;
onSubmitCover: () => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const publishReady = blockers.length === 0;
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 && !isGeneratingCover) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="发布抓大鹅作品"
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,50rem)] 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)]">
</div>
<PlatformModalCloseButton
onClick={onClose}
disabled={isGeneratingCover}
label="关闭"
variant="platformIcon"
className={isGeneratingCover ? 'cursor-not-allowed opacity-55' : ''}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="mb-4 space-y-2">
<PlatformFieldLabel variant="section"></PlatformFieldLabel>
{publishError ? (
<PlatformStatusMessage tone="error" surface="platform">
{publishError}
</PlatformStatusMessage>
) : publishReady ? (
<PlatformStatusMessage tone="success" surface="platform">
</PlatformStatusMessage>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{blockers.map((blocker, index) => (
<PlatformStatusMessage
key={`match3d-publish-blocker-${index}-${blocker}`}
tone="warning"
surface="platform"
>
{blocker}
</PlatformStatusMessage>
))}
</div>
)}
</div>
<PlatformFieldLabel variant="section" className="mb-3 block">
</PlatformFieldLabel>
<Match3DCoverImageEditor
editState={editState}
sourceAssets={sourceAssets}
isGenerating={isGeneratingCover}
uploadedImageSrc={uploadedImageSrc}
referenceImages={referenceImages}
aiRedraw={aiRedraw}
prompt={prompt}
error={coverError}
onAiRedrawChange={onAiRedrawChange}
onFileChange={onFileChange}
onPromptChange={onPromptChange}
onReferenceSelect={onReferenceSelect}
onReferenceFileChange={onReferenceFileChange}
onReferenceRemove={onReferenceRemove}
onUploadedImageRemove={onUploadedImageRemove}
onSubmit={onSubmitCover}
/>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4 sm:flex-row sm:justify-end">
<PlatformActionButton
onClick={onClose}
disabled={isGeneratingCover || isPublishing}
tone="ghost"
>
</PlatformActionButton>
<PlatformActionButton
onClick={onPublish}
disabled={!publishReady || isBusy}
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
广
</PlatformActionButton>
</div>
</div>
</div>,
document.body,
);
}
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">
<PlatformSubpanel radius="lg" padding="lg">
<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(204,117,76,0.16)]" />
<div
className="absolute left-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-[linear-gradient(90deg,#df7f40_0%,#c7653d_54%,#e2ab86_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-[var(--platform-warm-border)] ${
selected
? 'border-[var(--platform-surface-hover-border)] bg-white shadow-[0_8px_18px_rgba(112,57,30,0.16)]'
: 'border-[var(--platform-subpanel-border)] bg-white/90 hover:border-[var(--platform-surface-hover-border)]'
} ${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-[var(--platform-warm-bg)]'
}`}
/>
</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>
<PlatformSegmentedTabs
items={MATCH3D_DIFFICULTY_OPTIONS.map((option) => ({
id: option.id,
label: (
<>
<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>
</>
),
ariaLabel: `${option.label} ${option.clearCount}次 · ${option.itemTypeCount}`,
}))}
activeId={selectedOption.id}
onChange={(optionId) =>
applyDifficultyOption(getMatch3DDifficultyOption(optionId))
}
columns="four"
gap="sm"
size="choice"
surface="transparent"
tone="warm"
frame="bare"
disabled={isBusy}
className="mt-3"
/>
</div>
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="sm"
className="mt-3"
data-testid="match3d-difficulty-summary-panel"
>
<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>
<PlatformPillBadge
tone="neutral"
size="sm"
className="border-transparent bg-[var(--platform-accent)] text-white shadow-[0_8px_18px_rgba(112,57,30,0.16)]"
>
{selectedOption.difficulty}
</PlatformPillBadge>
</div>
</PlatformSubpanel>
<div className="sr-only" aria-live="polite">
{selectedOption.label}
</div>
</PlatformSubpanel>
<PlatformSubpanel radius="lg" padding="lg">
<PlatformStatGrid
items={[
{ label: '需要消除', value: `${selectedOption.clearCount}` },
{ label: '总物品数', value: `${totalItemCount}` },
{ label: '物品种类', value: `${runtimeTypeCount}` },
{ label: '已生成物品种类', value: `${readyItemTypeCount}` },
]}
columns="twoToFour"
order="labelFirst"
surface="plain"
/>
</PlatformSubpanel>
</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-[var(--platform-surface-hover-border)] bg-[var(--platform-warm-bg)]'
: 'border-[var(--platform-subpanel-border)] bg-white/76 hover:border-[var(--platform-surface-hover-border)] 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-[var(--platform-warm-border)]"
aria-label={`打开${asset.name}物品素材`}
>
<PlatformMediaFrame
src={previewSource}
alt=""
fallbackLabel={`${asset.name}素材预览`}
fallbackContent={
<ImageIcon className="h-7 w-7 text-[var(--platform-text-soft)]" />
}
aspect="square"
surface="bright"
imageClassName="h-full w-full object-contain p-1"
className="min-h-0 rounded-[0.95rem]"
fallbackClassName="tracking-normal"
/>
<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">
<PlatformIconButton
onClick={onDelete}
className="h-8 w-8 shrink-0 text-[var(--platform-button-danger-text)]"
label="删除物品素材"
title="删除"
icon={<Trash2 className="h-4 w-4" />}
/>
</div>
</div>
</div>
);
}
function Match3DItemAssetDetail({
asset,
busy,
onChange,
}: {
asset: Match3DItemAssetDraft;
busy: boolean;
onChange: (asset: Match3DItemAssetDraft) => void;
}) {
const previewSources = resolveMatch3DAssetDraftPreviewSources(asset);
const [activePreviewIndex, setActivePreviewIndex] = useState(() =>
Math.floor(previewSources.length / 2),
);
const safeActivePreviewIndex = previewSources[activePreviewIndex]
? activePreviewIndex
: 0;
const thumbnailPreviewSources =
previewSources.length > 0
? previewSources
: Array.from({ length: 4 }, () => undefined as string | undefined);
const activePreviewSource = previewSources[safeActivePreviewIndex];
useEffect(() => {
setActivePreviewIndex(Math.floor(previewSources.length / 2));
}, [asset.id, previewSources.length]);
return (
<PlatformSubpanel radius="xl" padding="sm" className="min-h-0 sm:p-5">
<div className="mx-auto flex min-h-0 w-full max-w-[34rem] flex-col gap-4">
<div className="space-y-3" aria-label={`${asset.name}五视角预览`}>
<PlatformMediaFrame
src={activePreviewSource}
alt=""
fallbackLabel={`${asset.name}当前视角`}
fallbackContent={
<ImageIcon className="h-12 w-12 text-[var(--platform-text-soft)]" />
}
aspect="square"
surface="bright"
imageClassName="h-full w-full object-contain p-3 sm:p-4"
className="mx-auto w-full max-w-[22rem] rounded-[1.15rem]"
fallbackClassName="tracking-normal"
data-testid="match3d-item-preview-stage"
/>
<div
className="grid grid-flow-col gap-2 overflow-x-auto pb-1"
data-testid="match3d-item-preview-thumbnails"
style={{ gridAutoColumns: 'calc((100% - 1.5rem) / 4)' }}
>
{thumbnailPreviewSources.map((source, index) => {
const hasSource = Boolean(source);
const isActive =
previewSources.length > 0 && index === safeActivePreviewIndex;
return (
<button
key={`${source ?? 'empty-thumbnail'}-${index}`}
type="button"
disabled={!hasSource || busy}
onClick={() => setActivePreviewIndex(index)}
aria-label={
hasSource
? `切换${asset.name}视角${index + 1}`
: `空视角槽 ${index + 1}`
}
aria-pressed={isActive}
className={`grid aspect-square place-items-center overflow-hidden rounded-[0.65rem] border bg-white/82 transition ${
isActive
? 'border-[var(--platform-surface-hover-border)] ring-2 ring-[var(--platform-warm-border)]'
: 'border-[var(--platform-subpanel-border)]'
} ${!hasSource || busy ? 'cursor-not-allowed opacity-58' : 'hover:border-[var(--platform-surface-hover-border)]'}`}
>
<PlatformMediaFrame
src={source}
alt=""
fallbackLabel={`视角${index + 1}`}
fallbackContent={
<ImageIcon className="h-6 w-6 text-[var(--platform-text-soft)]" />
}
aspect="square"
surface="none"
imageClassName="h-full w-full object-contain p-1"
className="h-full w-full rounded-[0.65rem]"
fallbackClassName="tracking-normal"
/>
</button>
);
})}
</div>
</div>
<label className="block">
<PlatformFieldLabel variant="section"></PlatformFieldLabel>
<PlatformTextField
value={asset.name}
disabled={busy}
onChange={(event) =>
onChange({ ...asset, name: event.target.value })
}
className="mt-2"
/>
</label>
</div>
</PlatformSubpanel>
);
}
function Match3DAssetsTab({
activeAssetId,
assets,
batchGenerationState,
onActiveAssetChange,
onAddBatch,
onRegenerateBatch,
onAssetChange,
onDeleteAsset,
}: {
activeAssetId: string | null;
assets: Match3DItemAssetDraft[];
batchGenerationState: Match3DBatchItemGenerationState;
onActiveAssetChange: (assetId: string | null) => void;
onAddBatch: () => void;
onRegenerateBatch: () => void;
onAssetChange: (asset: Match3DItemAssetDraft) => void;
onDeleteAsset: (assetId: string) => void;
}) {
const activeAsset =
assets.find((asset) => asset.id === activeAssetId) ?? null;
return (
<div className="min-h-0 space-y-3">
<div className="flex flex-wrap justify-end gap-2">
<PlatformActionButton
onClick={onRegenerateBatch}
tone="ghost"
size="xs"
className="min-h-10 gap-2"
>
<Wand2 className="h-4 w-4" />
</PlatformActionButton>
<PlatformActionButton
onClick={onAddBatch}
tone="ghost"
size="xs"
className="min-h-10 gap-2"
>
<Plus className="h-4 w-4" />
</PlatformActionButton>
</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}
onChange={onAssetChange}
/>
</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);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
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>
<PlatformTextField
value={value}
disabled={isGenerating}
onChange={(event) => onChangeValue(index, event.target.value)}
aria-label={`物品名称 ${index + 1}`}
placeholder="物品名称"
/>
</label>
))}
<PlatformActionButton
disabled={isGenerating}
onClick={onAddInput}
tone="ghost"
size="xs"
fullWidth
className="min-h-10 gap-2"
>
<Plus className="h-4 w-4" />
</PlatformActionButton>
</div>
<div className="flex flex-wrap gap-2">
{parsedNames.map((name) => (
<PlatformPillBadge key={name} tone="neutral" size="xs">
{name}
</PlatformPillBadge>
))}
</div>
{error ? (
<PlatformStatusMessage tone="error" surface="platform" size="md">
{error}
</PlatformStatusMessage>
) : null}
<Match3DBatchGenerationProgress generationState={generationState} />
<PlatformActionButton
disabled={parsedNames.length <= 0 || isGenerating}
onClick={() => setIsCostConfirmOpen(true)}
size="md"
fullWidth
className="min-h-11 gap-2"
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
· {pointsCost}
</PlatformActionButton>
<UnifiedConfirmDialog
open={isCostConfirmOpen}
title="确认消耗泥点"
onClose={() => setIsCostConfirmOpen(false)}
onConfirm={() => {
setIsCostConfirmOpen(false);
onSubmit();
}}
confirmLabel="确定"
confirmDisabled={parsedNames.length <= 0 || isGenerating}
showCancel
showCloseButton={false}
portal={false}
overlayClassName="platform-modal-backdrop z-[90]"
panelClassName="platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div className="font-semibold"> {pointsCost} </div>
</UnifiedConfirmDialog>
</div>
</Match3DModalShell>
);
}
function Match3DBatchRegenerateItemsPanel({
values,
targetItemNames,
generationState,
error,
onChangeValue,
onClose,
onSubmit,
}: {
values: string[];
targetItemNames: string[];
generationState: Match3DBatchItemGenerationState;
error: string | null;
onChangeValue: (index: number, value: string) => void;
onClose: () => void;
onSubmit: () => void;
}) {
const isGenerating = generationState.phase === 'generating';
const pointsCost = calculateMatch3DItemAssetsPointsCost(
targetItemNames.length,
);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
return (
<Match3DModalShell title="批量重新生成物品" onClose={onClose}>
<div className="space-y-4">
<div className="grid gap-2 sm:grid-cols-2">
{values.map((value, index) => (
<label key={index} className="block">
<span className="sr-only"> {index + 1}</span>
<PlatformTextField
value={value}
disabled={isGenerating}
onChange={(event) => onChangeValue(index, event.target.value)}
aria-label={`重新生成物品名称 ${index + 1}`}
placeholder="物品名称"
/>
</label>
))}
</div>
<div className="flex flex-wrap gap-2">
{targetItemNames.map((name) => (
<PlatformPillBadge key={name} tone="neutral" size="xs">
{name}
</PlatformPillBadge>
))}
</div>
{error ? (
<PlatformStatusMessage tone="error" surface="platform" size="md">
{error}
</PlatformStatusMessage>
) : null}
<Match3DBatchGenerationProgress generationState={generationState} />
<PlatformActionButton
disabled={targetItemNames.length <= 0 || isGenerating}
onClick={() => setIsCostConfirmOpen(true)}
size="md"
fullWidth
className="min-h-11 gap-2"
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
· {pointsCost}
</PlatformActionButton>
<UnifiedConfirmDialog
open={isCostConfirmOpen}
title="确认消耗泥点"
onClose={() => setIsCostConfirmOpen(false)}
onConfirm={() => {
setIsCostConfirmOpen(false);
onSubmit();
}}
confirmLabel="确定"
confirmDisabled={targetItemNames.length <= 0 || isGenerating}
showCancel
showCloseButton={false}
portal={false}
overlayClassName="platform-modal-backdrop z-[90]"
panelClassName="platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div className="font-semibold"> {pointsCost} </div>
</UnifiedConfirmDialog>
</div>
</Match3DModalShell>
);
}
function Match3DAssetConfigTabs({
activeTab,
onChange,
}: {
activeTab: Match3DAssetConfigTab;
onChange: (tab: Match3DAssetConfigTab) => void;
}) {
return (
<PlatformSegmentedTabs
items={MATCH3D_ASSET_CONFIG_TABS}
activeId={activeTab}
onChange={onChange}
radius="lg"
size="sm"
surface="soft"
className="mb-3"
/>
);
}
function Match3DUIAssetsTab({
backgroundPreviewSrc,
uiSpritesheetPreviewSrc,
itemSpritesheetPreviewSrc,
itemNames,
error,
}: {
backgroundPreviewSrc: string;
uiSpritesheetPreviewSrc: string;
itemSpritesheetPreviewSrc: string;
itemNames: readonly string[];
error: string | null;
}) {
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [itemSpritesheetGroups, setItemSpritesheetGroups] = useState<
Match3DItemSpritesheetPreviewGroup[]
>([]);
useEffect(() => {
if (!itemSpritesheetPreviewSrc) {
setItemSpritesheetGroups((current) =>
current.length > 0 ? [] : current,
);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
void loadMatch3DSpritesheetAssetRegions({
source: itemSpritesheetPreviewSrc,
maxRegions: 100,
minArea: 16,
alphaThreshold: 8,
signal: controller.signal,
})
.then((regions) => {
if (!cancelled) {
setItemSpritesheetGroups(
buildMatch3DItemSpritesheetViewRegions(regions, itemNames),
);
}
})
.catch(() => {
if (!cancelled) {
setItemSpritesheetGroups([]);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [itemNames, itemSpritesheetPreviewSrc]);
return (
<div className="space-y-3">
<PlatformSubpanel radius="lg" padding="lg">
<div className="grid gap-4 sm:grid-cols-2">
<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页面预览"
>
<PlatformMediaFrame
src={backgroundPreviewSrc}
alt="游戏背景图"
fallbackLabel="游戏背景图"
fallbackContent={
<ImageIcon className="h-8 w-8 text-[var(--platform-text-soft)]" />
}
aspect="portrait"
surface="none"
className="h-full w-full rounded-none"
/>
<span className="sr-only">UI页面预览</span>
</button>
<div className="flex min-h-0 flex-col gap-3">
<div className="aspect-square overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3 shadow-sm">
<PlatformMediaFrame
src={uiSpritesheetPreviewSrc}
alt="UI素材图"
fallbackLabel="UI素材图"
fallbackContent={
<ImageIcon className="h-8 w-8 text-[var(--platform-text-soft)]" />
}
aspect="square"
surface="none"
imageClassName="h-full w-full object-contain"
className="h-full w-full rounded-none"
/>
</div>
<PlatformActionButton
onClick={() => setIsPreviewOpen(true)}
tone="ghost"
size="md"
className="min-h-11 gap-2"
>
<Eye className="h-4 w-4" />
UI页面
</PlatformActionButton>
</div>
</div>
</PlatformSubpanel>
{itemSpritesheetPreviewSrc ? (
<PlatformSubpanel radius="lg" padding="lg">
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
<div className="aspect-square overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3 shadow-sm">
<PlatformMediaFrame
src={itemSpritesheetPreviewSrc}
alt="物品素材图"
fallbackLabel="物品素材图"
fallbackContent={
<ImageIcon className="h-8 w-8 text-[var(--platform-text-soft)]" />
}
aspect="square"
surface="none"
imageClassName="h-full w-full object-contain"
className="h-full w-full rounded-none"
/>
</div>
{itemSpritesheetGroups.length > 0 ? (
<div className="grid max-h-[24rem] content-start gap-3 overflow-y-auto pr-1 sm:grid-cols-2 xl:grid-cols-3">
{itemSpritesheetGroups.map((group) => (
<PlatformSubpanel
key={`${group.itemIndex}-${group.itemName}`}
as="div"
surface="flat"
radius="sm"
padding="sm"
className="bg-white/58"
>
<div className="mb-2 truncate text-xs font-black text-[var(--platform-text-strong)]">
{group.itemName}
</div>
<PlatformMediaTileGrid
columns="five"
gap="sm"
tileSurface="bare"
imageClassName="h-full w-full object-contain p-1"
tileClassName="rounded-[0.55rem] border-white/70 bg-white/82"
items={group.regions.map((region, regionIndex) => ({
id: `${group.itemIndex}-${regionIndex}-${region.imageSrc}`,
src: region.imageSrc,
testId: `match3d-item-spritesheet-preview-${group.itemIndex}-${regionIndex}`,
fallbackLabel: group.itemName,
}))}
/>
</PlatformSubpanel>
))}
</div>
) : null}
</div>
</PlatformSubpanel>
) : null}
{error ? (
<PlatformStatusMessage tone="error" surface="platform" size="md">
{error}
</PlatformStatusMessage>
) : null}
{isPreviewOpen ? (
<Match3DUIRuntimePreviewPanel
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC}
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 grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-start gap-2 sm:grid-cols-[2.75rem_minmax(0,1fr)_2.75rem]">
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
<ArrowLeft size={20} />
</span>
<span className={`${MATCH3D_RUNTIME_HEADER_CARD_CLASS} mx-auto`}>
<span className="flex max-w-full items-center justify-center gap-1.5">
<span className={MATCH3D_RUNTIME_LEVEL_BADGE_CLASS}>
1
</span>
<span className="min-w-0 truncate text-sm font-black sm:text-base">
</span>
</span>
<span className={MATCH3D_RUNTIME_TIMER_CLASS}>1:30</span>
</span>
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
<Settings size={18} />
</span>
</header>
<section className={`z-10 ${MATCH3D_RUNTIME_STAGE_CLASS}`}>
<div
className={`${MATCH3D_RUNTIME_BOARD_BASE_CLASS} ${
containerPreviewSrc
? MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS
: MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS
}`}
style={{ width: MATCH3D_RUNTIME_BOARD_WIDTH }}
aria-hidden="true"
data-testid="match3d-ui-preview-board"
>
{containerPreviewSrc ? (
<ResolvedAssetImage
src={containerPreviewSrc}
alt=""
aria-hidden="true"
className={MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS}
/>
) : (
<div className={MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS} />
)}
</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,
uiSpritesheetPreviewSrc,
itemSpritesheetPreviewSrc,
itemNames,
backgroundGenerationError,
batchGenerationState,
onActiveAssetChange,
onAddBatch,
onRegenerateBatch,
onAssetChange,
onAssetConfigTabChange,
onDeleteAsset,
}: {
activeAssetConfigTab: Match3DAssetConfigTab;
activeAssetId: string | null;
assetDrafts: Match3DItemAssetDraft[];
backgroundPreviewSrc: string;
uiSpritesheetPreviewSrc: string;
itemSpritesheetPreviewSrc: string;
itemNames: readonly string[];
backgroundGenerationError: string | null;
batchGenerationState: Match3DBatchItemGenerationState;
onActiveAssetChange: (assetId: string | null) => void;
onAddBatch: () => void;
onRegenerateBatch: () => void;
onAssetChange: (asset: Match3DItemAssetDraft) => void;
onAssetConfigTabChange: (tab: Match3DAssetConfigTab) => void;
onDeleteAsset: (assetId: string) => void;
}) {
return (
<div className="min-h-0">
<Match3DAssetConfigTabs
activeTab={activeAssetConfigTab}
onChange={onAssetConfigTabChange}
/>
{activeAssetConfigTab === 'items' ? (
<Match3DAssetsTab
activeAssetId={activeAssetId}
assets={assetDrafts}
batchGenerationState={batchGenerationState}
onActiveAssetChange={onActiveAssetChange}
onAddBatch={onAddBatch}
onRegenerateBatch={onRegenerateBatch}
onAssetChange={onAssetChange}
onDeleteAsset={onDeleteAsset}
/>
) : null}
{activeAssetConfigTab === 'ui' ? (
<Match3DUIAssetsTab
backgroundPreviewSrc={backgroundPreviewSrc}
uiSpritesheetPreviewSrc={uiSpritesheetPreviewSrc}
itemSpritesheetPreviewSrc={itemSpritesheetPreviewSrc}
itemNames={itemNames}
error={backgroundGenerationError}
/>
) : null}
</div>
);
}
export function Match3DResultView({
profile,
draft = null,
isBusy = false,
error = null,
onBack,
onSaved,
onPublished,
onStartTestRun,
}: Match3DResultViewProps) {
const promotedProfile = useMemo(
() => promoteMatch3DGeneratedBackgroundAsset(profile),
[profile],
);
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 [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false);
const [hasAttemptedPublish, setHasAttemptedPublish] = useState(false);
const [coverUploadedImageSrc, setCoverUploadedImageSrc] = useState('');
const [coverReferenceImages, setCoverReferenceImages] = useState<
Match3DCoverReferenceDraft[]
>([]);
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 [isBatchRegeneratePanelOpen, setIsBatchRegeneratePanelOpen] =
useState(false);
const [batchItemNameValues, setBatchItemNameValues] = useState(['']);
const [batchRegenerateItemNameValues, setBatchRegenerateItemNameValues] =
useState<string[]>([]);
const [batchGenerationState, setBatchGenerationState] =
useState<Match3DBatchItemGenerationState>({
phase: 'idle',
progress: null,
itemNames: [],
message: null,
error: null,
});
const [batchAddError, setBatchAddError] = useState<string | null>(null);
const [batchRegenerateError, setBatchRegenerateError] = useState<
string | null
>(null);
const [backgroundGenerationError, setBackgroundGenerationError] = useState<
string | 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(promotedProfile, draft),
[draft, promotedProfile],
);
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) ??
promotedProfile.clearCount) * 3;
const backgroundPreviewSrc = useMemo(
() =>
resolveMatch3DBackgroundPreviewSource(
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, promotedProfile],
);
const containerPreviewSrc = useMemo(
() =>
resolveMatch3DContainerPreviewSource(
promotedProfile,
draft,
generatedItemAssets,
) || MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC,
[draft, generatedItemAssets, promotedProfile],
);
const uiSpritesheetPreviewSrc = useMemo(
() =>
resolveMatch3DUiSpritesheetPreviewSource(
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, promotedProfile],
);
const itemSpritesheetPreviewSrc = useMemo(
() =>
resolveMatch3DItemSpritesheetPreviewSource(
promotedProfile,
draft,
generatedItemAssets,
),
[draft, generatedItemAssets, promotedProfile],
);
const generatedItemNames = useMemo(
() => generatedItemAssets.map((asset) => asset.itemName),
[generatedItemAssets],
);
const coverSourceAssets = useMemo(
() =>
resolveMatch3DCoverSourceAssets(
assetDrafts,
backgroundPreviewSrc,
containerPreviewSrc,
),
[assetDrafts, backgroundPreviewSrc, containerPreviewSrc],
);
useEffect(() => {
setEditState(createEditState(profile));
setAutoSaveState('idle');
setLocalError(null);
setIsPublishDialogOpen(false);
setHasAttemptedPublish(false);
setCoverUploadedImageSrc('');
setCoverReferenceImages([]);
setCoverPrompt('');
setCoverAiRedraw(false);
setCoverPanelError(null);
setBackgroundGenerationError(null);
// 中文注释:表单草稿只在切换作品或服务端更新时间变化时重置,避免父级重渲染打断本地编辑。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile.profileId, profile.updatedAt]);
useEffect(() => {
setAssetDrafts(createMatch3DAssetDrafts(profile, draft));
setActiveAssetId(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;
}
setAutoSaveState('error');
setLocalError(
saveError instanceof Error ? saveError.message : '自动保存失败。',
);
});
}, MATCH3D_AUTOSAVE_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [editState, generatedItemAssets, 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);
setCoverUploadedImageSrc(dataUrl);
setCoverAiRedraw(true);
setCoverPanelError(null);
} catch (caughtError) {
setCoverPanelError(
caughtError instanceof Error ? caughtError.message : '封面图读取失败。',
);
}
};
const handleCoverReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0] ?? null;
event.target.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readCoverReferenceImageAsDataUrl(file);
setCoverReferenceImages((current) =>
addMatch3DCoverReferenceDraft(current, {
id: `upload:${Date.now()}:${file.name}`,
label: file.name.trim() || '自定义参考图',
imageSrc: dataUrl,
source: 'upload',
}),
);
setCoverPanelError(null);
} catch (caughtError) {
setCoverPanelError(
caughtError instanceof Error ? caughtError.message : '参考图读取失败。',
);
}
};
const resetCoverEditor = () => {
setCoverUploadedImageSrc('');
setCoverReferenceImages([]);
setCoverPrompt(
[
editState.gameName.trim(),
editState.themeText.trim(),
'抓大鹅作品封面图,主体清晰,适合作品卡片',
]
.filter(Boolean)
.join(''),
);
setCoverAiRedraw(true);
setCoverPanelError(null);
};
const openPublishDialog = () => {
setHasAttemptedPublish(false);
resetCoverEditor();
setIsPublishDialogOpen(true);
};
const closePublishDialog = () => {
if (isGeneratingCover || isPublishing) {
return;
}
setHasAttemptedPublish(false);
setIsPublishDialogOpen(false);
};
const handleSubmitCoverPanel = async () => {
const uploadedImageSrc = coverUploadedImageSrc.trim();
const prompt = coverPrompt.trim();
const referenceImageSrcs = coverReferenceImages
.map((reference) => reference.imageSrc.trim())
.filter(Boolean);
if (!uploadedImageSrc && !prompt) {
setCoverPanelError('请上传图片或填写封面描述。');
return;
}
if (uploadedImageSrc && !coverAiRedraw) {
setEditState((current) => ({
...current,
coverImageSrc: uploadedImageSrc,
}));
setCoverPanelError(null);
setCoverUploadedImageSrc('');
setCoverAiRedraw(true);
return;
}
setIsGeneratingCover(true);
setCoverPanelError(null);
try {
const response = await generateMatch3DCoverImage(profile.profileId, {
prompt:
prompt ||
`${editState.gameName.trim() || '抓大鹅'}作品封面图,${editState.themeText.trim() || '休闲消除'}题材`,
uploadedImageSrc: uploadedImageSrc || null,
referenceImageSrcs: uploadedImageSrc ? [] : referenceImageSrcs,
});
setEditState((current) => ({
...current,
coverImageSrc: response.coverImageSrc,
}));
onSaved?.(
buildCoverImageUpdatedProfile(
profile,
editState,
response.item,
response.coverImageSrc,
generatedItemAssets,
),
);
setCoverUploadedImageSrc('');
setCoverReferenceImages([]);
setCoverAiRedraw(true);
} 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 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 handleOpenBatchRegeneratePanel = () => {
setBatchRegenerateError(null);
setBatchRegenerateItemNameValues(
assetDrafts.length > 0 ? assetDrafts.map((asset) => asset.name) : [''],
);
setBatchGenerationState((current) =>
current.phase === 'generating'
? current
: {
phase: 'idle',
progress: null,
itemNames: [],
message: null,
error: null,
},
);
setIsBatchRegeneratePanelOpen(true);
};
const handleSubmitBatchRegenerateItems = () => {
const itemNames = collectMatch3DRegenerateItemNames(
batchRegenerateItemNameValues,
assetDrafts,
);
if (itemNames.length <= 0 || batchGenerationState.phase === 'generating') {
setBatchRegenerateError('请填写至少一个物品名称。');
return;
}
setBatchRegenerateError(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,
mode: 'replace',
})
.then((response) => {
window.clearInterval(progressTimer);
const refreshedProfile = attachMatch3DGeneratedItemAssets(
response.item,
response.generatedItemAssets,
);
setAssetDrafts(createMatch3DAssetDrafts(refreshedProfile, null));
setBatchRegenerateItemNameValues(
response.generatedItemAssets.map((asset) => asset.itemName),
);
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
: '批量重新生成物品素材失败。';
setBatchRegenerateError(message);
setBatchGenerationState({
phase: 'failed',
progress: null,
itemNames,
message: null,
error: message,
});
});
};
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 () => {
setHasAttemptedPublish(true);
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);
setIsPublishDialogOpen(false);
setHasAttemptedPublish(false);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error
? caughtError.message
: '发布抓大鹅作品失败。',
);
} finally {
setIsPublishing(false);
}
};
const busy = isBusy || isPublishing || isStartingTestRun || isGeneratingCover;
const workBusy = busy || isGeneratingTags;
const displayError = error ?? localError;
const dialogPublishError = hasAttemptedPublish ? (error ?? localError) : null;
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}
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}
uiSpritesheetPreviewSrc={uiSpritesheetPreviewSrc}
itemSpritesheetPreviewSrc={itemSpritesheetPreviewSrc}
itemNames={generatedItemNames}
backgroundGenerationError={backgroundGenerationError}
batchGenerationState={batchGenerationState}
onActiveAssetChange={setActiveAssetId}
onAddBatch={() => {
setBatchAddError(null);
setBatchGenerationState((current) =>
current.phase === 'generating'
? current
: {
phase: 'idle',
progress: null,
itemNames: [],
message: null,
error: null,
},
);
setIsBatchAddPanelOpen(true);
}}
onRegenerateBatch={handleOpenBatchRegeneratePanel}
onAssetChange={updateItemAsset}
onAssetConfigTabChange={setActiveAssetConfigTab}
onDeleteAsset={(assetId) => {
void handleDeleteAssetDraft(assetId);
}}
/>
) : null}
</div>
{displayError ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="md"
className="mt-3"
>
{displayError}
</PlatformStatusMessage>
) : 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">
<PlatformActionButton
onClick={handleStartTestRun}
disabled={!canStartTestRun || busy}
tone="ghost"
size="md"
className="min-h-11 gap-2"
>
{isStartingTestRun ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</PlatformActionButton>
<PlatformActionButton
onClick={openPublishDialog}
disabled={busy}
size="md"
className="min-h-11 gap-2"
>
{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' ? '更新发布' : '发布'}
</PlatformActionButton>
</div>
{isPublishDialogOpen ? (
<Match3DPublishDialog
blockers={blockers}
editState={editState}
sourceAssets={coverSourceAssets}
isBusy={busy}
isGeneratingCover={isGeneratingCover}
isPublishing={isPublishing}
uploadedImageSrc={coverUploadedImageSrc}
referenceImages={coverReferenceImages}
aiRedraw={coverAiRedraw}
prompt={coverPrompt}
coverError={coverPanelError}
publishError={dialogPublishError}
onAiRedrawChange={setCoverAiRedraw}
onClose={closePublishDialog}
onFileChange={handleCoverImageChange}
onPromptChange={setCoverPrompt}
onPublish={() => {
void handlePublish();
}}
onReferenceSelect={(source) => {
const sourceAsset = coverSourceAssets.find(
(asset) => asset.imageSrc === source,
);
if (!sourceAsset) {
return;
}
setCoverReferenceImages((current) =>
addMatch3DCoverReferenceDraft(
current,
createMatch3DCoverReferenceDraftFromSource(sourceAsset),
),
);
setCoverPanelError(null);
}}
onReferenceFileChange={handleCoverReferenceImageChange}
onReferenceRemove={(referenceId) => {
setCoverReferenceImages((current) =>
current.filter((reference) => reference.id !== referenceId),
);
}}
onUploadedImageRemove={() => {
setCoverUploadedImageSrc('');
setCoverAiRedraw(true);
}}
onSubmitCover={() => {
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}
{isBatchRegeneratePanelOpen ? (
<Match3DBatchRegenerateItemsPanel
values={batchRegenerateItemNameValues}
targetItemNames={collectMatch3DRegenerateItemNames(
batchRegenerateItemNameValues,
assetDrafts,
)}
generationState={batchGenerationState}
error={batchRegenerateError}
onChangeValue={(index, value) => {
setBatchRegenerateItemNameValues((current) =>
current.map((item, itemIndex) =>
itemIndex === index ? value : item,
),
);
}}
onClose={() => {
setIsBatchRegeneratePanelOpen(false);
}}
onSubmit={() => {
handleSubmitBatchRegenerateItems();
}}
/>
) : null}
</div>
);
}
export default Match3DResultView;