Refine creation tab UX, generation flow, and bindings
Large changes across frontend, backend and docs to align creation-tab and generation-page behavior with new product UI/UX and Spacetime bindings. Updated hermes decision-log and pitfalls with concrete rules (banner carousel, font sizing, unread-dot tokens, template-card layout, direct card->entry routing, separation of account balance vs prize pools, removal of global page card shell, generation progress milestones and unified circular progress, and background video handling). Added GenerationProgressHero component and media assets, plus generation-related UI/tests updates (CustomWorldGenerationView, BarkBattleGeneratingView, creation hub/cards, platform entry routing, index tests). Backend and contract updates include new category fields in admin API types and admin UI form/list, spacetime-client/module/migration changes and generated bindings script. Misc: many tests adjusted, new docs and plan files added, and several server-rs crate changes to support the updated creation/ generation workflows.
This commit is contained in:
@@ -50,7 +50,7 @@ describe('BarkBattleGeneratingView', () => {
|
||||
updatedAt: '2026-05-14T10:01:00.000Z',
|
||||
});
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<BarkBattleGeneratingView
|
||||
draft={draft}
|
||||
onBack={() => {}}
|
||||
@@ -59,9 +59,142 @@ describe('BarkBattleGeneratingView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
expect((container.firstChild as HTMLElement).className).toContain('z-[1]');
|
||||
expect(screen.getByText('总进度')).toBeTruthy();
|
||||
expect(screen.getByText('总进度').className).toContain('text-[9px]');
|
||||
const pageVideo = screen.getByTestId(
|
||||
'generation-page-background-video',
|
||||
) as HTMLVideoElement;
|
||||
expect(pageVideo.parentElement?.className).toContain('z-0');
|
||||
expect(pageVideo.parentElement?.className).toContain('bg-transparent');
|
||||
expect(pageVideo.parentElement?.className).not.toContain('bg-[#fff4ea]');
|
||||
expect((container.firstChild as HTMLElement).contains(pageVideo)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(pageVideo.autoplay).toBe(true);
|
||||
expect(pageVideo.loop).toBe(true);
|
||||
expect(pageVideo.muted).toBe(true);
|
||||
expect(pageVideo.playsInline).toBe(true);
|
||||
expect(pageVideo.getAttribute('preload')).toBe('auto');
|
||||
expect(
|
||||
document.querySelector(
|
||||
'video[data-testid="generation-page-background-video"] source[type="video/mp4"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '返回编辑' }).className).toContain(
|
||||
'text-xs',
|
||||
);
|
||||
expect(screen.getByText('生成中').className).toContain('text-[11px]');
|
||||
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-wait-card').className).toContain(
|
||||
'bg-white/58',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
|
||||
'bg-white/58',
|
||||
);
|
||||
expect(screen.getByText('预计等待').className).toContain('text-[9px]');
|
||||
expect(screen.getByText('已耗时').className).toContain('text-[9px]');
|
||||
expect(screen.getByText('预计等待').parentElement?.className).toContain(
|
||||
'justify-center',
|
||||
);
|
||||
expect(screen.getByText('已耗时').parentElement?.className).toContain(
|
||||
'justify-center',
|
||||
);
|
||||
expect(screen.getByText('3 分钟')).toBeTruthy();
|
||||
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(
|
||||
'pt-[4%]',
|
||||
);
|
||||
expect(screen.getByText('玩家形象')).toBeTruthy();
|
||||
expect(screen.getByText('对手形象')).toBeTruthy();
|
||||
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: '汪汪声浪素材生成进度' })
|
||||
.className,
|
||||
).toContain('w-[min(35rem,94vw)]');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.className,
|
||||
).toContain('sm:w-[52rem]');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.getAttribute('aria-valuenow'),
|
||||
).toBe('0');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.getAttribute('data-ring-start-degrees'),
|
||||
).toBe('225');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.getAttribute('data-ring-sweep-degrees'),
|
||||
).toBe('270');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.getAttribute('data-ring-gap-degrees'),
|
||||
).toBe('90');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.getAttribute('data-ring-fill-degrees'),
|
||||
).toBe('0');
|
||||
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
|
||||
'svg',
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring')
|
||||
.getAttribute('viewBox'),
|
||||
).toBe('0 0 400 400');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-track')
|
||||
.getAttribute('r'),
|
||||
).toBe('166');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-track')
|
||||
.getAttribute('stroke-width'),
|
||||
).toBe('18');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-fill')
|
||||
.getAttribute('stroke-dasharray'),
|
||||
).toMatch(/^0\.00 1043\.\d{2}$/u);
|
||||
expect(
|
||||
screen.getByRole('progressbar', { name: '玩家形象 进度' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '玩家形象 进度' })
|
||||
.getAttribute('aria-valuenow'),
|
||||
).toBe('36');
|
||||
expect(
|
||||
screen.getByTestId('generation-current-step-card').className,
|
||||
).toContain('bg-white/58');
|
||||
expect(screen.getByText('预览信息').className).toContain('text-[13px]');
|
||||
expect(screen.queryByText('对手形象')).toBeNull();
|
||||
expect(screen.queryByText('竞技背景')).toBeNull();
|
||||
expect(onComplete).not.toHaveBeenCalled();
|
||||
|
||||
resolveGeneration({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertCircle, ArrowLeft, CheckCircle2, Loader2, Sparkles } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
generateAllBarkBattleImageAssets,
|
||||
updateBarkBattleDraftConfig,
|
||||
} from '../../services/bark-battle-creation';
|
||||
import {
|
||||
GenerationCurrentStepCard,
|
||||
GenerationPageBackdrop,
|
||||
GenerationProgressHero,
|
||||
} from '../GenerationProgressHero';
|
||||
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
|
||||
|
||||
type BarkBattleGeneratingViewProps = {
|
||||
@@ -110,6 +115,56 @@ function buildDraftGenerationKey(draft: BarkBattleDraftConfig) {
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function getSlotStatusLabel(status: BarkBattleGeneratingSlotStatus) {
|
||||
if (status === 'ready') {
|
||||
return '完成';
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return '失败';
|
||||
}
|
||||
return '进行中';
|
||||
}
|
||||
|
||||
function formatGenerationDuration(ms: number) {
|
||||
const totalSeconds = Math.max(1, Math.ceil(Math.max(0, ms) / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes <= 0) {
|
||||
return `${seconds} 秒`;
|
||||
}
|
||||
|
||||
if (seconds === 0) {
|
||||
return `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
return `${minutes} 分 ${seconds} 秒`;
|
||||
}
|
||||
|
||||
function resolveBarkBattleProgressValue(
|
||||
slotStatuses: Partial<
|
||||
Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>
|
||||
>,
|
||||
) {
|
||||
const readyCount = GENERATION_STEPS.filter(
|
||||
(step) => slotStatuses[step.slot] === 'ready',
|
||||
).length;
|
||||
return Math.round((readyCount / GENERATION_STEPS.length) * 100);
|
||||
}
|
||||
|
||||
function resolveCurrentBarkBattleStep(
|
||||
slotStatuses: Partial<
|
||||
Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>
|
||||
>,
|
||||
) {
|
||||
return (
|
||||
GENERATION_STEPS.find((step) => slotStatuses[step.slot] === 'generating') ??
|
||||
GENERATION_STEPS.find((step) => slotStatuses[step.slot] === 'failed') ??
|
||||
GENERATION_STEPS.find((step) => slotStatuses[step.slot] !== 'ready') ??
|
||||
GENERATION_STEPS[GENERATION_STEPS.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
export function BarkBattleGeneratingView({
|
||||
draft,
|
||||
isBusy = false,
|
||||
@@ -125,10 +180,33 @@ export function BarkBattleGeneratingView({
|
||||
const [slotStatuses, setSlotStatuses] = useState<
|
||||
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
|
||||
>({});
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const primaryFailureMessage = useMemo(
|
||||
() => resolvePrimaryFailureMessage(slotFailures),
|
||||
[slotFailures],
|
||||
);
|
||||
const progressValue = resolveBarkBattleProgressValue(slotStatuses);
|
||||
const currentStep = resolveCurrentBarkBattleStep(slotStatuses);
|
||||
const currentStepStatus = currentStep
|
||||
? (slotStatuses[currentStep.slot] ??
|
||||
(hasSlotAsset(previewDraft, currentStep.slot) ? 'ready' : 'generating'))
|
||||
: 'generating';
|
||||
const currentStepProgress =
|
||||
currentStepStatus === 'ready' ? 100 : currentStepStatus === 'failed' ? 100 : 36;
|
||||
const currentStepLabel = currentStep?.label ?? '竞技素材';
|
||||
const currentStepStatusLabel = getSlotStatusLabel(currentStepStatus);
|
||||
|
||||
useEffect(() => {
|
||||
const startedAtMs = Date.now();
|
||||
const timerId = window.setInterval(() => {
|
||||
setElapsedMs(Date.now() - startedAtMs);
|
||||
}, 1000);
|
||||
setElapsedMs(0);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timerId);
|
||||
};
|
||||
}, [draft.draftId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewDraft(draft);
|
||||
@@ -277,76 +355,54 @@ export function BarkBattleGeneratingView({
|
||||
}, [draft, onComplete, onError]);
|
||||
|
||||
return (
|
||||
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-3 pb-3 pt-3 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-5xl flex-col">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
|
||||
<div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-y-auto bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5 xl:px-6 xl:pb-5 xl:pt-5">
|
||||
<GenerationPageBackdrop />
|
||||
<div className="relative z-10 mx-auto flex w-full max-w-[48rem] flex-col">
|
||||
<div className="mb-6 flex shrink-0 items-center justify-between gap-3 sm:mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
className={`inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回编辑
|
||||
<ArrowLeft className="h-5 w-5" strokeWidth={2.6} />
|
||||
<span className="break-keep">返回编辑</span>
|
||||
</button>
|
||||
<span className="rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-[11px] font-black text-sky-700">
|
||||
<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">
|
||||
生成中
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="grid min-h-0 flex-1 gap-3 overflow-y-auto lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)]">
|
||||
<div className="grid content-start gap-3">
|
||||
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
|
||||
<div className="flex items-center gap-2 text-sm font-black text-[var(--platform-text-soft)]">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
自动生成素材
|
||||
</div>
|
||||
<h1 className="m-0 mt-2 text-3xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-5xl">
|
||||
{draft.title || '未命名声浪竞技场'}
|
||||
</h1>
|
||||
</div>
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="grid content-start gap-3 overflow-hidden px-0 pb-0 pt-0">
|
||||
<GenerationProgressHero
|
||||
title="汪汪声浪素材生成进度"
|
||||
phaseLabel={draft.title || '未命名声浪竞技场'}
|
||||
progressValue={progressValue}
|
||||
estimatedWaitText="3 分钟"
|
||||
elapsedText={formatGenerationDuration(elapsedMs)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{GENERATION_STEPS.map((step) => {
|
||||
const status =
|
||||
slotStatuses[step.slot] ??
|
||||
(hasSlotAsset(previewDraft, step.slot) ? 'ready' : 'generating');
|
||||
const ready = status === 'ready';
|
||||
const failed =
|
||||
status === 'failed' || Boolean(slotFailures[step.slot]);
|
||||
const statusLabel = ready
|
||||
? `${step.label}已生成`
|
||||
: failed
|
||||
? `${step.label}生成失败`
|
||||
: `${step.label}生成中`;
|
||||
return (
|
||||
<div
|
||||
key={step.slot}
|
||||
className="flex items-center justify-between rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-3"
|
||||
aria-label={statusLabel}
|
||||
>
|
||||
<span className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{step.label}
|
||||
</span>
|
||||
{ready ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
) : failed ? (
|
||||
<AlertCircle className="h-4 w-4 text-rose-500" />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[var(--platform-text-soft)]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-[-0.15rem]">
|
||||
<GenerationCurrentStepCard
|
||||
label={currentStepLabel}
|
||||
statusLabel={currentStepStatusLabel}
|
||||
progressValue={currentStepProgress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error || primaryFailureMessage ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
<div className="rounded-[1.4rem] border border-[#d88969]/35 bg-white/76 px-4 py-3 text-sm leading-6 text-[#a6402f] backdrop-blur-md">
|
||||
{error ?? primaryFailureMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<BarkBattlePreviewCard config={previewDraft} />
|
||||
<section className="overflow-hidden rounded-[1.75rem] border border-[#eadcd1] bg-[rgba(255,250,246,0.92)] px-4 py-4 shadow-[0_20px_56px_rgba(112,57,30,0.08)] backdrop-blur-[5px] sm:px-5 sm:py-5">
|
||||
<div className="mb-4 text-[13px] font-black tracking-[0.08em] text-[#111111]">
|
||||
预览信息
|
||||
</div>
|
||||
<BarkBattlePreviewCard config={previewDraft} />
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -354,4 +410,3 @@ export function BarkBattleGeneratingView({
|
||||
}
|
||||
|
||||
export default BarkBattleGeneratingView;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user