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