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