收口前端平台组件库能力

新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
2026-06-10 10:24:18 +08:00
parent a4ee6ff698
commit 1ad25e30f8
226 changed files with 23364 additions and 7825 deletions

View File

@@ -11,10 +11,19 @@ describe('BarkBattleConfigEditor', () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
expect(screen.getByRole('heading', { name: '汪汪声浪大作战' })).toBeTruthy();
expect(screen.getByText('轻配置')).toBeTruthy();
expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场');
expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal');
expect(
screen.getByRole('heading', { name: '汪汪声浪大作战' }),
).toBeTruthy();
expect(screen.getByText('轻配置').className).toContain('rounded-full');
expect(screen.getByText('轻配置').className).toContain(
'border-emerald-200',
);
expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe(
'我的声浪竞技场',
);
expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe(
'normal',
);
expect(screen.queryByLabelText('资源 URL')).toBeNull();
expect(screen.queryByLabelText('玩家图片 URL')).toBeNull();
expect(screen.queryByLabelText('对手图片 URL')).toBeNull();
@@ -27,7 +36,10 @@ describe('BarkBattleConfigEditor', () => {
await userEvent.clear(screen.getByLabelText('作品标题'));
await userEvent.type(screen.getByLabelText('作品标题'), '狗狗冠军杯');
await userEvent.clear(screen.getByLabelText('主题/场景描述'));
await userEvent.type(screen.getByLabelText('主题/场景描述'), '霓虹公园声浪擂台');
await userEvent.type(
screen.getByLabelText('主题/场景描述'),
'霓虹公园声浪擂台',
);
await userEvent.clear(screen.getByLabelText('玩家形象描述'));
await userEvent.type(screen.getByLabelText('玩家形象描述'), '红围巾柴犬');
await userEvent.clear(screen.getByLabelText('对手形象描述'));
@@ -55,8 +67,10 @@ describe('BarkBattleConfigEditor', () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
const defaultWords = (screen.getByLabelText('拟声词') as HTMLTextAreaElement)
.value.split(/\n+/u)
const defaultWords = (
screen.getByLabelText('拟声词') as HTMLTextAreaElement
).value
.split(/\n+/u)
.map((word) => word.trim())
.filter(Boolean);
@@ -77,8 +91,10 @@ describe('BarkBattleConfigEditor', () => {
await userEvent.clear(screen.getByLabelText('对手形象描述'));
await userEvent.type(screen.getByLabelText('对手形象描述'), '机器人拳手');
const updatedWords = (screen.getByLabelText('拟声词') as HTMLTextAreaElement)
.value.split(/\n+/u)
const updatedWords = (
screen.getByLabelText('拟声词') as HTMLTextAreaElement
).value
.split(/\n+/u)
.map((word) => word.trim())
.filter(Boolean);
expect(updatedWords).toEqual(
@@ -94,7 +110,10 @@ describe('BarkBattleConfigEditor', () => {
await userEvent.clear(screen.getByLabelText('拟声词'));
await userEvent.type(screen.getByLabelText('拟声词'), '轰!\n破阵');
await userEvent.clear(screen.getByLabelText('主题/场景描述'));
await userEvent.type(screen.getByLabelText('主题/场景描述'), '星舰机甲擂台');
await userEvent.type(
screen.getByLabelText('主题/场景描述'),
'星舰机甲擂台',
);
expect((screen.getByLabelText('拟声词') as HTMLTextAreaElement).value).toBe(
'轰!\n破阵',
@@ -124,7 +143,9 @@ describe('BarkBattleConfigEditor', () => {
/>,
);
expect(screen.queryByRole('heading', { name: '汪汪声浪大作战' })).toBeNull();
expect(
screen.queryByRole('heading', { name: '汪汪声浪大作战' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
expect(screen.getByLabelText('汪汪声浪轻配置编辑器')).toBeTruthy();
expect(screen.getByText('外部错误')).toBeTruthy();
@@ -144,7 +165,9 @@ describe('BarkBattleConfigEditor', () => {
const editor = screen.getByLabelText('汪汪声浪轻配置编辑器');
expect(editor.className).toContain('overflow-visible');
expect(editor.className).toContain('lg:overflow-y-auto');
expect(editor.className).not.toContain('overflow-y-auto overscroll-y-contain pr-0.5');
expect(editor.className).not.toContain(
'overflow-y-auto overscroll-y-contain pr-0.5',
);
const themeLabel = screen.getByText('主题/场景描述');
expect(themeLabel.className).toContain('bg-rose-50');

View File

@@ -4,6 +4,14 @@ import { useEffect, useMemo, useState } from 'react';
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
import type { BarkBattleDifficultyPreset } from '../../../packages/shared/src/contracts/barkBattle';
import { buildBarkBattleDefaultOnomatopoeia } from '../../games/bark-battle/application/BarkBattleConfig';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
PlatformSelectField,
PlatformTextField,
} from '../common/PlatformTextField';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
export type BarkBattleConfigEditorProps = {
@@ -15,15 +23,14 @@ export type BarkBattleConfigEditorProps = {
title?: string | null;
};
const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [
const DIFFICULTY_OPTIONS: Array<{
value: BarkBattleDifficultyPreset;
label: string;
}> = [
{ value: 'easy', label: '轻松' },
{ value: 'normal', label: '标准' },
{ value: 'hard', label: '硬核' },
];
const FIELD_LABEL_CLASS =
'mb-2 inline-flex rounded-full px-2 py-0.5 text-sm font-black text-[var(--platform-text-strong)]';
const ACCENT_FIELD_LABEL_CLASS =
'mb-2 inline-flex rounded-full border border-rose-200/70 bg-rose-50/88 px-2.5 py-1 text-sm font-black text-rose-700 shadow-sm';
const DEFAULT_THEME_DESCRIPTION = '阳光草坪上的圆形声浪擂台';
const DEFAULT_PLAYER_IMAGE_DESCRIPTION = '戴红色围巾的勇敢小狗';
const DEFAULT_OPPONENT_IMAGE_DESCRIPTION = '戴蓝色头带的活力小狗';
@@ -64,7 +71,8 @@ export function BarkBattleConfigEditor({
opponentImageDescription: DEFAULT_OPPONENT_IMAGE_DESCRIPTION,
}),
);
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
const [difficultyPreset, setDifficultyPreset] =
useState<BarkBattleDifficultyPreset>('normal');
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
@@ -143,17 +151,16 @@ export function BarkBattleConfigEditor({
>
{showBackButton && onBack ? (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button
type="button"
<PlatformActionButton
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
tone="ghost"
size="xs"
className="min-h-0 self-start gap-1.5 px-3 py-1.5 text-[11px]"
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
<ArrowLeft className="h-3.5 w-3.5" />
</PlatformActionButton>
</div>
) : null}
@@ -164,9 +171,9 @@ export function BarkBattleConfigEditor({
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
{headingTitle}
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
<PlatformPillBadge tone="success" size="xs">
</span>
</PlatformPillBadge>
</div>
</div>
) : null}
@@ -176,24 +183,29 @@ export function BarkBattleConfigEditor({
>
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
<label className="block shrink-0">
<span className={FIELD_LABEL_CLASS}></span>
<input
<PlatformFieldLabel variant="pill"></PlatformFieldLabel>
<PlatformTextField
value={title}
disabled={isBusy}
onChange={(event) => setTitle(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
size="lg"
density="roomy"
className="h-11 rounded-[1.05rem] py-0"
maxLength={40}
aria-label="作品标题"
/>
</label>
<label className="block shrink-0">
<span className={FIELD_LABEL_CLASS}></span>
<textarea
<PlatformFieldLabel variant="pill"></PlatformFieldLabel>
<PlatformTextField
variant="textarea"
value={description}
disabled={isBusy}
onChange={(event) => setDescription(event.target.value)}
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
size="lg"
density="roomy"
className="h-[5.5rem] min-h-[5.5rem] rounded-[1.05rem] font-normal leading-6"
maxLength={160}
placeholder=""
aria-label="简介"
@@ -202,8 +214,8 @@ export function BarkBattleConfigEditor({
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block">
<span className={FIELD_LABEL_CLASS}></span>
<select
<PlatformFieldLabel variant="pill"></PlatformFieldLabel>
<PlatformSelectField
value={difficultyPreset}
disabled={isBusy}
onChange={(event) =>
@@ -211,7 +223,9 @@ export function BarkBattleConfigEditor({
event.target.value as BarkBattleDifficultyPreset,
)
}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
size="sm"
density="roomy"
className="h-11 rounded-[1.05rem] py-0 font-black"
aria-label="难度预设"
>
{DIFFICULTY_OPTIONS.map((option) => (
@@ -219,19 +233,23 @@ export function BarkBattleConfigEditor({
{option.label}
</option>
))}
</select>
</PlatformSelectField>
</label>
</div>
<label className="block shrink-0">
<span className={ACCENT_FIELD_LABEL_CLASS}>
<PlatformFieldLabel variant="accentPill">
/
</span>
<textarea
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
value={themeDescription}
disabled={isBusy}
onChange={(event) => setThemeDescription(event.target.value)}
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
size="lg"
density="roomy"
tone="rose"
className="h-[5.5rem] min-h-[5.5rem] rounded-[1.05rem] font-normal leading-6"
maxLength={240}
placeholder=""
aria-label="主题/场景描述"
@@ -240,24 +258,40 @@ export function BarkBattleConfigEditor({
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block">
<span className={FIELD_LABEL_CLASS}></span>
<textarea
<PlatformFieldLabel variant="pill">
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
value={playerImageDescription}
disabled={isBusy}
onChange={(event) => setPlayerImageDescription(event.target.value)}
className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
onChange={(event) =>
setPlayerImageDescription(event.target.value)
}
size="sm"
density="roomy"
tone="rose"
className="h-[5rem] min-h-[5rem] rounded-[1.05rem] leading-6"
maxLength={220}
aria-label="玩家形象描述"
/>
</label>
<label className="block">
<span className={FIELD_LABEL_CLASS}></span>
<textarea
<PlatformFieldLabel variant="pill">
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
value={opponentImageDescription}
disabled={isBusy}
onChange={(event) => setOpponentImageDescription(event.target.value)}
className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
onChange={(event) =>
setOpponentImageDescription(event.target.value)
}
size="sm"
density="roomy"
tone="rose"
className="h-[5rem] min-h-[5rem] rounded-[1.05rem] leading-6"
maxLength={220}
aria-label="对手形象描述"
/>
@@ -265,24 +299,35 @@ export function BarkBattleConfigEditor({
</div>
<label className="block shrink-0">
<span className={ACCENT_FIELD_LABEL_CLASS}></span>
<textarea
<PlatformFieldLabel variant="accentPill">
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
value={onomatopoeiaText}
disabled={isBusy}
onChange={(event) => {
setIsOnomatopoeiaCustomized(true);
setOnomatopoeiaText(event.target.value);
}}
className="h-[6.5rem] min-h-[6.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-black leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
size="sm"
density="roomy"
tone="rose"
className="h-[6.5rem] min-h-[6.5rem] rounded-[1.05rem] font-black leading-6"
maxLength={260}
aria-label="拟声词"
/>
</label>
{visibleError ? (
<div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6">
<PlatformStatusMessage
tone="error"
surface="platform"
size="md"
className="shrink-0 rounded-2xl"
>
{visibleError}
</div>
</PlatformStatusMessage>
) : null}
</div>
@@ -291,21 +336,18 @@ export function BarkBattleConfigEditor({
</div>
<div className="mt-4 flex shrink-0 flex-wrap justify-center gap-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:mt-4 lg:pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
<PlatformActionButton
disabled={isBusy}
onClick={() => runValidatedAction(onPreview)}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
className="min-h-10 gap-1.5 px-4 py-2 text-sm sm:min-h-11 sm:gap-2 sm:px-5"
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
<span>{isBusy ? '处理中' : '生成草稿'}</span>
</span>
</button>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
<span>{isBusy ? '处理中' : '生成草稿'}</span>
</PlatformActionButton>
</div>
</section>
);

View File

@@ -84,31 +84,35 @@ describe('BarkBattleGeneratingView', () => {
'video[data-testid="generation-page-background-video"] source[type="video/mp4"]',
),
).toBeTruthy();
expect(screen.getByRole('button', { name: '返回编辑' }).className).toContain(
'text-xs',
);
expect(
screen.getByRole('button', { name: '返回编辑' }).className,
).toContain('text-xs');
expect(screen.getByText('生成中').className).toContain('text-[11px]');
expect(screen.getByText('生成中').className).toContain(
'border-[var(--platform-warm-border)]',
);
expect(screen.getByText('生成中').className).toContain(
'bg-[var(--platform-warm-bg)]',
);
expect(screen.getByText('当前步骤')).toBeTruthy();
expect(screen.getByText('当前步骤').className).toContain('text-[10px]');
expect(screen.getByTestId('generation-hero-wait-card').className).toContain(
'text-center',
);
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
'text-center',
);
expect(
screen.getByTestId('generation-hero-elapsed-card').className,
).toContain('text-center');
expect(screen.getByTestId('generation-hero-wait-card').className).toContain(
'bg-white/58',
);
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
'bg-white/58',
);
expect(
screen.getByTestId('generation-hero-wait-card').parentElement
?.className,
screen.getByTestId('generation-hero-elapsed-card').className,
).toContain('bg-white/58');
expect(
screen.getByTestId('generation-hero-wait-card').parentElement?.className,
).toContain('mt-3');
expect(
screen.getByTestId('generation-hero-wait-card').parentElement
?.className,
screen.getByTestId('generation-hero-wait-card').parentElement?.className,
).toContain('px-0');
expect(screen.getByText('预计等待').className).toContain('text-[9px]');
expect(screen.getByText('已耗时').className).toContain('text-[9px]');
@@ -122,33 +126,30 @@ describe('BarkBattleGeneratingView', () => {
expect(screen.getByText('1 秒')).toBeTruthy();
expect(screen.queryByText('预计还需 3 分钟')).toBeNull();
expect(screen.queryByText('已耗时 1 秒')).toBeNull();
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
'justify-start',
);
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
'z-30',
);
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
'pt-[2%]',
);
expect(
screen.getByTestId('generation-hero-progress-content').className,
).toContain('justify-start');
expect(
screen.getByTestId('generation-hero-progress-content').className,
).toContain('z-30');
expect(
screen.getByTestId('generation-hero-progress-content').className,
).toContain('pt-[2%]');
expect(screen.getByText('玩家形象')).toBeTruthy();
expect(screen.getByText('进行中 36%')).toBeTruthy();
expect(screen.getByText('进行中 36%').className).toContain('text-[11px]');
expect(screen.getByText('总进度').className).toContain('text-[9px]');
expect(screen.getByText('0%').className).toContain('text-[1.15rem]');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
screen.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
screen.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('max-w-full');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
screen.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('aspect-square');
expect(
@@ -184,9 +185,9 @@ describe('BarkBattleGeneratingView', () => {
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
'svg',
);
expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain(
'z-0',
);
expect(
screen.getByTestId('generation-hero-progress-ring').getAttribute('class'),
).toContain('z-0');
expect(
screen
.getByTestId('generation-hero-progress-ring')
@@ -218,8 +219,8 @@ describe('BarkBattleGeneratingView', () => {
.getAttribute('stroke-dasharray'),
).toMatch(/^0\.00 1043\.\d{2}$/u);
expect(
screen.getByRole('progressbar', { name: '玩家形象 进度' }),
).toBeTruthy();
screen.getByRole('progressbar', { name: '玩家形象 进度' }).className,
).toContain('platform-progress-track');
expect(
screen
.getByRole('progressbar', { name: '玩家形象 进度' })

View File

@@ -12,6 +12,7 @@ import {
generateAllBarkBattleImageAssets,
updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import {
GenerationCurrentStepCard,
GenerationPageBackdrop,
@@ -191,7 +192,11 @@ export function BarkBattleGeneratingView({
(hasSlotAsset(previewDraft, currentStep.slot) ? 'ready' : 'generating'))
: 'generating';
const currentStepProgress =
currentStepStatus === 'ready' ? 100 : currentStepStatus === 'failed' ? 100 : 36;
currentStepStatus === 'ready'
? 100
: currentStepStatus === 'failed'
? 100
: 36;
const currentStepLabel = currentStep?.label ?? '竞技素材';
const currentStepStatusLabel = getSlotStatusLabel(currentStepStatus);
@@ -336,7 +341,10 @@ export function BarkBattleGeneratingView({
onComplete(draft, true);
})
.finally(() => {
if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) {
if (
activeBarkBattleGenerationTasks.get(startedDraftKey) ===
generationTask
) {
activeBarkBattleGenerationTasks.delete(startedDraftKey);
}
});
@@ -344,7 +352,9 @@ export function BarkBattleGeneratingView({
return () => {
cancelled = true;
// 中文注释:离开生成页后不再全局复用同一 Promise避免悬挂生成任务导致再次进入时一直转圈。
if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) {
if (
activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask
) {
activeBarkBattleGenerationTasks.delete(startedDraftKey);
}
if (startedDraftIdRef.current === startedDraftKey) {
@@ -366,9 +376,13 @@ export function BarkBattleGeneratingView({
<ArrowLeft className="h-5 w-5" strokeWidth={2.6} />
<span className="break-keep"></span>
</button>
<span className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
<PlatformPillBadge
tone="warning"
size="xs"
className="px-3 py-1.5 tracking-[0.08em] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs"
>
</span>
</PlatformPillBadge>
</div>
<div

View File

@@ -1,4 +1,7 @@
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
import { PlatformInfoBlock } from '../common/PlatformInfoBlock';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type BarkBattlePreviewCardProps = {
@@ -13,11 +16,19 @@ const DIFFICULTY_LABELS = {
export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
return (
<aside
className="platform-subpanel flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 max-lg:p-2 sm:p-4"
<PlatformSubpanel
as="aside"
padding="none"
className="flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 max-lg:p-2 sm:p-4"
aria-label="作品预览卡片"
>
<div className="flex min-h-0 flex-1 flex-col rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/76 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4">
<PlatformSubpanel
as="div"
surface="flat"
radius="sm"
padding="none"
className="flex min-h-0 flex-1 flex-col bg-white/76 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4"
>
<div
className="relative mb-2.5 grid min-h-[5.75rem] grid-cols-[1fr_auto_1fr] items-center gap-2 overflow-hidden rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] px-3 text-center text-2xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:mb-4 sm:min-h-[10rem] sm:gap-3 sm:px-4 sm:text-3xl"
data-testid="bark-battle-preview-stage"
@@ -41,9 +52,13 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
<span className="text-4xl sm:text-6xl">🐕</span>
)}
</span>
<span className="relative rounded-full bg-white/70 px-2.5 py-0.5 text-xs font-black text-[var(--platform-text-strong)] sm:px-3 sm:py-1 sm:text-base">
<PlatformPillBadge
tone="neutral"
size="xs"
className="relative border-transparent bg-white/70 px-2.5 py-0.5 text-xs text-[var(--platform-text-strong)] sm:px-3 sm:py-1 sm:text-base"
>
VS
</span>
</PlatformPillBadge>
<span className="relative grid place-items-center">
{config.opponentCharacterImageSrc ? (
<ResolvedAssetImage
@@ -62,35 +77,23 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
<p className="mt-1.5 min-h-0 text-xs font-semibold leading-5 text-[var(--platform-text-muted)] sm:mt-2 sm:min-h-[2.625rem] sm:text-sm sm:leading-6">
{config.description || '30 秒声浪拔河,喊出你的能量优势。'}
</p>
<dl className="mt-2.5 grid gap-1.5 text-xs sm:mt-4 sm:gap-2 sm:text-sm">
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{config.themeDescription || '声浪擂台'}
</dd>
</div>
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{config.playerImageDescription || '玩家'}
{' vs '}
{config.opponentImageDescription || '对手'}
</dd>
</div>
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{DIFFICULTY_LABELS[config.difficultyPreset]}
</dd>
</div>
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{config.onomatopoeia?.slice(0, 3).join(' / ') || '炸场!'}
</dd>
</div>
</dl>
</div>
</aside>
<div className="mt-2.5 grid gap-1.5 sm:mt-4 sm:gap-2">
<PlatformInfoBlock label="场景" variant="compactRow">
{config.themeDescription || '声浪擂台'}
</PlatformInfoBlock>
<PlatformInfoBlock label="形象" variant="compactRow">
{config.playerImageDescription || '玩家'}
{' vs '}
{config.opponentImageDescription || '对手'}
</PlatformInfoBlock>
<PlatformInfoBlock label="难度" variant="compactRow">
{DIFFICULTY_LABELS[config.difficultyPreset]}
</PlatformInfoBlock>
<PlatformInfoBlock label="声浪" variant="compactRow">
{config.onomatopoeia?.slice(0, 3).join(' / ') || '炸场!'}
</PlatformInfoBlock>
</div>
</PlatformSubpanel>
</PlatformSubpanel>
);
}

View File

@@ -56,6 +56,8 @@ describe('BarkBattleResultView', () => {
/>,
);
expect(screen.getByText('草稿').className).toContain('rounded-full');
expect(screen.getByText('草稿').className).toContain('border-emerald-200');
expect(screen.getByText('霓虹公园擂台')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(draft);
@@ -66,7 +68,7 @@ describe('BarkBattleResultView', () => {
});
it('uses compact mobile-first result layout classes', () => {
render(
const { container } = render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
@@ -76,13 +78,47 @@ describe('BarkBattleResultView', () => {
/>,
);
expect(screen.getByRole('heading', { name: '汪汪冠军杯', level: 1 }).className).toContain(
'text-2xl',
expect(
screen.getByRole('heading', { name: '汪汪冠军杯', level: 1 }).className,
).toContain('text-2xl');
expect(screen.getByLabelText('作品预览卡片').className).toContain(
'platform-subpanel',
);
expect(screen.getByLabelText('作品预览卡片').className).toContain(
'max-lg:p-2',
);
expect(screen.getByLabelText('作品预览卡片').className).toContain('max-lg:p-2');
expect(screen.getByTestId('bark-battle-preview-stage').className).toContain(
'min-h-[5.75rem]',
);
const previewVersusBadge = screen.getByText('VS');
expect(previewVersusBadge.className).toContain('inline-flex');
expect(previewVersusBadge.className).toContain('rounded-full');
expect(previewVersusBadge.className).toContain('border-transparent');
expect(previewVersusBadge.className).toContain('bg-white/70');
expect(previewVersusBadge.className).toContain(
'text-[var(--platform-text-strong)]',
);
const previewSceneBlock = screen.getByText('场景').parentElement;
expect(previewSceneBlock?.className).toContain('bg-white/74');
expect(previewSceneBlock?.className).toContain('rounded-[0.85rem]');
expect(previewSceneBlock?.className).toContain('sm:px-3');
expect(screen.getByText('场景').className).toContain(
'text-[var(--platform-text-muted)]',
);
expect(
within(previewSceneBlock as HTMLElement).getByText('霓虹公园擂台')
.className,
).toContain('font-black');
expect(container.querySelectorAll('article.bg-white\\/72')).toHaveLength(3);
const draftSummaryPanel = screen.getByTestId(
'bark-battle-draft-summary-panel',
);
expect(draftSummaryPanel.className).toContain('bg-white/72');
expect(draftSummaryPanel.className).toContain('rounded-[1.25rem]');
expect(draftSummaryPanel.className).toContain('p-3');
expect(draftSummaryPanel.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
});
it('uploads replacement image assets into the selected slot', async () => {
@@ -137,7 +173,8 @@ describe('BarkBattleResultView', () => {
<BarkBattleResultView
draft={{
...draft,
playerCharacterImageSrc: 'generated-bark-battle-assets/player-character/very-long-object-key.png',
playerCharacterImageSrc:
'generated-bark-battle-assets/player-character/very-long-object-key.png',
}}
onBack={() => {}}
onDraftChange={() => {}}
@@ -146,7 +183,9 @@ describe('BarkBattleResultView', () => {
/>,
);
const playerSlot = screen.getByRole('heading', { name: '玩家形象' }).closest('article');
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
expect(within(playerSlot as HTMLElement).getByText('已替换')).toBeTruthy();
expect(
@@ -154,7 +193,9 @@ describe('BarkBattleResultView', () => {
'generated-bark-battle-assets/player-character/very-long-object-key.png',
),
).toBeNull();
expect(within(playerSlot as HTMLElement).queryByText(/objectKey|object key/i)).toBeNull();
expect(
within(playerSlot as HTMLElement).queryByText(/objectKey|object key/i),
).toBeNull();
});
it('keeps result assets to three image slots with per-slot regeneration only', async () => {
@@ -190,7 +231,9 @@ describe('BarkBattleResultView', () => {
.closest('article');
expect(playerSlot).toBeTruthy();
await user.click(
within(playerSlot as HTMLElement).getByRole('button', { name: '重新生成' }),
within(playerSlot as HTMLElement).getByRole('button', {
name: '重新生成',
}),
);
await waitFor(() => {

View File

@@ -7,7 +7,13 @@ import {
RefreshCw,
Upload,
} from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useRef, useState } from 'react';
import {
type ChangeEvent,
type ReactNode,
useMemo,
useRef,
useState,
} from 'react';
import type {
BarkBattleConfigEditorPayload,
@@ -18,6 +24,10 @@ import {
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 = {
@@ -36,7 +46,9 @@ const SLOT_LABELS = {
'ui-background': 'UI背景',
} satisfies Record<BarkBattleAssetSlot, string>;
function mapDraftToConfig(draft: BarkBattleDraftConfig): BarkBattleConfigEditorPayload {
function mapDraftToConfig(
draft: BarkBattleDraftConfig,
): BarkBattleConfigEditorPayload {
return {
title: draft.title,
description: draft.description,
@@ -75,7 +87,10 @@ function applyAssetToDraft(
return { ...draft, updatedAt };
}
function getSlotAssetSrc(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) {
function getSlotAssetSrc(
draft: BarkBattleDraftConfig,
slot: BarkBattleAssetSlot,
) {
if (slot === 'player-character') {
return draft.playerCharacterImageSrc ?? '';
}
@@ -100,16 +115,14 @@ function ResultActionButton({
tone?: 'primary' | 'secondary';
}) {
return (
<button
type="button"
<PlatformActionButton
disabled={disabled}
onClick={onClick}
className={`platform-button ${
tone === 'primary' ? 'platform-button--primary' : 'platform-button--secondary'
} min-h-10 justify-center text-sm disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-11`}
tone={tone}
className="min-h-10 gap-2 text-sm sm:min-h-11"
>
{children}
</button>
</PlatformActionButton>
);
}
@@ -177,7 +190,13 @@ function BarkBattleAssetSlotControl({
const isSlotBusy = isUploading || isRegenerating;
return (
<article className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)] sm:p-3">
<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">
@@ -202,26 +221,30 @@ function BarkBattleAssetSlotControl({
aria-label={`上传${SLOT_LABELS[slot]}文件`}
onChange={handleUpload}
/>
<button
type="button"
<PlatformActionButton
disabled={disabled || isSlotBusy}
onClick={() => fileInputRef.current?.click()}
className="platform-button platform-button--secondary min-h-8 justify-center rounded-full px-2.5 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs"
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" />
</button>
<button
type="button"
</PlatformActionButton>
<PlatformActionButton
disabled={disabled || isSlotBusy}
onClick={handleRegenerate}
className="platform-button platform-button--secondary min-h-8 justify-center rounded-full px-2.5 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs"
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" />
</button>
</PlatformActionButton>
</div>
</article>
</PlatformSubpanel>
);
}
@@ -243,31 +266,43 @@ export function BarkBattleResultView({
<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">
<button
type="button"
<PlatformActionButton
onClick={onBack}
disabled={isActionBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isActionBusy ? 'opacity-45' : ''}`}
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" />
</button>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-0.5 text-[11px] font-black text-emerald-700 sm:px-3 sm:py-1">
</PlatformActionButton>
<PlatformPillBadge
tone="success"
size="xs"
className="sm:px-3 sm:py-1"
>
稿
</span>
</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">
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)] sm:p-4">
<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>
</div>
</PlatformSubpanel>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
@@ -294,9 +329,14 @@ export function BarkBattleResultView({
</section>
{visibleError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
<PlatformStatusMessage
tone="error"
surface="platform"
size="md"
className="mt-3 rounded-2xl"
>
{visibleError}
</div>
</PlatformStatusMessage>
) : null}
</div>