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

370 lines
11 KiB
TypeScript

import {
ArrowLeft,
CheckCircle2,
ImagePlus,
Loader2,
Play,
RefreshCw,
Upload,
} from 'lucide-react';
import {
type ChangeEvent,
type ReactNode,
useMemo,
useRef,
useState,
} from 'react';
import type {
BarkBattleConfigEditorPayload,
BarkBattleDraftConfig,
} from '../../../packages/shared/src/contracts/barkBattle';
import {
type BarkBattleAssetSlot,
regenerateBarkBattleImageAsset,
uploadBarkBattleAsset,
} from '../../services/bark-battle-creation';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
type BarkBattleResultViewProps = {
draft: BarkBattleDraftConfig;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onDraftChange: (draft: BarkBattleDraftConfig) => void;
onStartTestRun: (draft: BarkBattleDraftConfig) => void;
onPublish: (draft: BarkBattleDraftConfig) => void;
};
const SLOT_LABELS = {
'player-character': '玩家形象',
'opponent-character': '对手形象',
'ui-background': 'UI背景',
} satisfies Record<BarkBattleAssetSlot, string>;
function mapDraftToConfig(
draft: BarkBattleDraftConfig,
): BarkBattleConfigEditorPayload {
return {
title: draft.title,
description: draft.description,
themeDescription: draft.themeDescription,
playerImageDescription: draft.playerImageDescription,
opponentImageDescription: draft.opponentImageDescription,
onomatopoeia: draft.onomatopoeia,
...(draft.playerCharacterImageSrc
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
: {}),
...(draft.opponentCharacterImageSrc
? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc }
: {}),
...(draft.uiBackgroundImageSrc
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
: {}),
difficultyPreset: draft.difficultyPreset,
};
}
function applyAssetToDraft(
draft: BarkBattleDraftConfig,
slot: BarkBattleAssetSlot,
assetSrc: string,
): BarkBattleDraftConfig {
const updatedAt = new Date().toISOString();
if (slot === 'player-character') {
return { ...draft, playerCharacterImageSrc: assetSrc, updatedAt };
}
if (slot === 'opponent-character') {
return { ...draft, opponentCharacterImageSrc: assetSrc, updatedAt };
}
if (slot === 'ui-background') {
return { ...draft, uiBackgroundImageSrc: assetSrc, updatedAt };
}
return { ...draft, updatedAt };
}
function getSlotAssetSrc(
draft: BarkBattleDraftConfig,
slot: BarkBattleAssetSlot,
) {
if (slot === 'player-character') {
return draft.playerCharacterImageSrc ?? '';
}
if (slot === 'opponent-character') {
return draft.opponentCharacterImageSrc ?? '';
}
if (slot === 'ui-background') {
return draft.uiBackgroundImageSrc ?? '';
}
return '';
}
function ResultActionButton({
children,
disabled,
onClick,
tone = 'secondary',
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
tone?: 'primary' | 'secondary';
}) {
return (
<PlatformActionButton
disabled={disabled}
onClick={onClick}
tone={tone}
className="min-h-10 gap-2 text-sm sm:min-h-11"
>
{children}
</PlatformActionButton>
);
}
function BarkBattleAssetSlotControl({
draft,
slot,
disabled,
onChange,
onError,
}: {
draft: BarkBattleDraftConfig;
slot: BarkBattleAssetSlot;
disabled: boolean;
onChange: (draft: BarkBattleDraftConfig) => void;
onError: (message: string | null) => void;
}) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const assetSrc = getSlotAssetSrc(draft, slot);
const assetStatus = assetSrc ? '已替换' : '未替换';
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return;
}
setIsUploading(true);
onError(null);
try {
const asset = await uploadBarkBattleAsset({
slot,
file,
draftId: draft.draftId,
});
const nextDraft = applyAssetToDraft(draft, slot, asset.assetSrc);
onChange(nextDraft);
} catch (error) {
onError(error instanceof Error ? error.message : '上传素材失败。');
} finally {
setIsUploading(false);
}
};
const handleRegenerate = async () => {
setIsRegenerating(true);
onError(null);
try {
const result = await regenerateBarkBattleImageAsset({
slot,
config: mapDraftToConfig(draft),
draftId: draft.draftId,
});
const nextDraft = applyAssetToDraft(draft, slot, result.imageSrc);
onChange(nextDraft);
} catch (error) {
onError(error instanceof Error ? error.message : '重新生成素材失败。');
} finally {
setIsRegenerating(false);
}
};
const isSlotBusy = isUploading || isRegenerating;
return (
<PlatformSubpanel
as="article"
surface="flat"
radius="sm"
padding="none"
className="p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)] sm:p-3"
>
<div className="flex items-center justify-between gap-2 sm:gap-3">
<div className="min-w-0">
<h3 className="m-0 text-xs font-black text-[var(--platform-text-strong)] sm:text-sm">
{SLOT_LABELS[slot]}
</h3>
<div className="mt-0.5 truncate text-[11px] font-semibold text-[var(--platform-text-soft)] sm:mt-1 sm:text-xs">
{assetStatus}
</div>
</div>
{isSlotBusy ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-[var(--platform-text-soft)]" />
) : (
<ImagePlus className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
)}
</div>
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:mt-3 sm:gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
aria-label={`上传${SLOT_LABELS[slot]}文件`}
onChange={handleUpload}
/>
<PlatformActionButton
disabled={disabled || isSlotBusy}
onClick={() => fileInputRef.current?.click()}
tone="secondary"
size="xs"
shape="pill"
className="min-h-8 gap-1.5 px-2.5 py-1 text-[11px] sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs"
>
<Upload className="h-3.5 w-3.5" />
</PlatformActionButton>
<PlatformActionButton
disabled={disabled || isSlotBusy}
onClick={handleRegenerate}
tone="secondary"
size="xs"
shape="pill"
className="min-h-8 gap-1.5 px-2.5 py-1 text-[11px] sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs"
>
<RefreshCw className="h-3.5 w-3.5" />
</PlatformActionButton>
</div>
</PlatformSubpanel>
);
}
export function BarkBattleResultView({
draft,
isBusy = false,
error = null,
onBack,
onDraftChange,
onStartTestRun,
onPublish,
}: BarkBattleResultViewProps) {
const [localError, setLocalError] = useState<string | null>(null);
const previewConfig = useMemo(() => mapDraftToConfig(draft), [draft]);
const visibleError = localError ?? error;
const isActionBusy = isBusy;
return (
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-2 pb-2 pt-2 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
<div className="mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col">
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 sm:mb-3 sm:gap-3">
<PlatformActionButton
onClick={onBack}
disabled={isActionBusy}
tone="ghost"
size="xs"
className="min-h-0 gap-1.5 px-3 py-1.5 text-[11px]"
>
<ArrowLeft className="h-3.5 w-3.5" />
</PlatformActionButton>
<PlatformPillBadge
tone="success"
size="xs"
className="sm:px-3 sm:py-1"
>
稿
</PlatformPillBadge>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-0.5">
<section className="grid gap-2.5 lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)] lg:gap-3">
<div className="grid gap-2.5 lg:gap-3">
<PlatformSubpanel
as="div"
surface="flat"
radius="md"
padding="sm"
className="shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]"
data-testid="bark-battle-draft-summary-panel"
>
<div className="text-xs font-black text-[var(--platform-text-soft)] sm:text-sm">
稿
</div>
<h1 className="m-0 mt-1 text-2xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:mt-2 sm:text-4xl lg:text-5xl">
{draft.title || '未命名声浪竞技场'}
</h1>
</PlatformSubpanel>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
'player-character',
'opponent-character',
'ui-background',
] as const
).map((slot) => (
<BarkBattleAssetSlotControl
key={slot}
draft={draft}
slot={slot}
disabled={isActionBusy}
onChange={(nextDraft) => {
setLocalError(null);
onDraftChange(nextDraft);
}}
onError={setLocalError}
/>
))}
</div>
</div>
<BarkBattlePreviewCard config={previewConfig} />
</section>
{visibleError ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="md"
className="mt-3 rounded-2xl"
>
{visibleError}
</PlatformStatusMessage>
) : null}
</div>
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-2">
<ResultActionButton
disabled={isActionBusy}
onClick={() => onStartTestRun(draft)}
>
<Play className="h-4 w-4" />
</ResultActionButton>
<ResultActionButton
tone="primary"
disabled={isActionBusy}
onClick={() => onPublish(draft)}
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
</ResultActionButton>
</div>
</div>
</div>
);
}
export default BarkBattleResultView;