新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
3744 lines
120 KiB
TypeScript
3744 lines
120 KiB
TypeScript
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;
|