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:
2026-05-25 00:41:30 +08:00
parent 2ba4691bc0
commit 50a0d6f982
75 changed files with 5533 additions and 1101 deletions

View File

@@ -52,9 +52,9 @@ function createProgress(
describe('CustomWorldGenerationView', () => {
test.each(['拼图草稿生成进度', '抓大鹅草稿生成进度'])(
'hides batch module and keeps wait/timer in one row for %s',
'renders the circular hero and only the current step summary for %s',
(progressTitle) => {
render(
const { container } = render(
<CustomWorldGenerationView
settingText="竖屏生成题材"
progress={createProgress()}
@@ -63,67 +63,189 @@ describe('CustomWorldGenerationView', () => {
onBack={() => {}}
onEditSetting={() => {}}
onRetry={() => {}}
backLabel="返回创作中心"
settingDescription={null}
settingActionLabel={null}
progressTitle={progressTitle}
/>,
);
expect(container.firstChild).toBeTruthy();
expect((container.firstChild as HTMLElement).className).toContain(
'z-[1]',
);
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: '返回创作中心' }),
).toBeTruthy();
expect(
screen.getByRole('button', { name: '返回创作中心' }).className,
).toContain('text-xs');
expect(screen.getByText('世界建设中')).toBeTruthy();
expect(screen.getByText('世界建设中').className).toContain('text-xs');
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('1 分 15 秒')).toBeTruthy();
expect(screen.getByText('2 分 5 秒')).toBeTruthy();
expect(screen.queryByText('预计还需 1 分 15 秒')).toBeNull();
expect(screen.queryByText('已耗时 2 分 5 秒')).toBeNull();
expect(screen.queryByText('计时')).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('总进度').className).toContain('text-[9px]');
expect(screen.getByText('42%').className).toContain('text-[1.15rem]');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('w-[min(35rem,94vw)]');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('sm:w-[52rem]');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.getAttribute('data-ring-start-degrees'),
).toBe('225');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.getAttribute('data-ring-sweep-degrees'),
).toBe('270');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.getAttribute('data-ring-gap-degrees'),
).toBe('90');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.getAttribute('data-ring-fill-degrees'),
).toBe('113');
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(/^328\.\d{2} 1043\.\d{2}$/u);
expect(
screen.getByRole('progressbar', { name: progressTitle }),
).toBeTruthy();
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.getAttribute('aria-valuenow'),
).toBe('42');
expect(screen.getByText('当前步骤')).toBeTruthy();
expect(screen.getByText('当前步骤').className).toContain('text-[10px]');
expect(screen.getByText('编译草稿')).toBeTruthy();
expect(screen.getByText('编译草稿').className).toContain('text-[14px]');
expect(screen.getByText('进行中 50%')).toBeTruthy();
expect(screen.getByText('进行中 50%').className).toContain('text-[11px]');
expect(
screen.getByTestId('generation-current-step-card').className,
).toContain('bg-white/58');
expect(
screen.getByRole('progressbar', { name: '编译草稿 进度' }),
).toBeTruthy();
expect(screen.queryByText('收集设定')).toBeNull();
expect(screen.queryByText('写回结果')).toBeNull();
expect(screen.queryByText('当前批次')).toBeNull();
expect(screen.getByText('预计等待')).toBeTruthy();
expect(screen.getByText('计时')).toBeTruthy();
const statsNode = screen
.getByText('预计等待')
.closest('.custom-world-generation-stats');
expect(statsNode?.className).toContain(
'custom-world-generation-stats--two-column',
);
expect(statsNode?.getAttribute('style')).toContain(
'grid-template-columns: repeat(2, minmax(0, 1fr))',
);
const stepNodes = [
screen.getByText('收集设定'),
screen.getByText('编译草稿'),
screen.getByText('写回结果'),
].map((node) => node.closest('.custom-world-generation-step'));
expect(stepNodes.every(Boolean)).toBe(true);
expect(stepNodes[0]?.getAttribute('style')).toContain(
'--generation-step-delay: 0ms',
);
expect(stepNodes[1]?.getAttribute('style')).toContain(
'--generation-step-delay: 90ms',
);
expect(stepNodes[2]?.getAttribute('style')).toContain(
'--generation-step-delay: 180ms',
);
expect(screen.queryByText('正在整理当前设定步骤')).toBeNull();
},
);
test('keeps batch module for other generation pages', () => {
test('keeps the setting information panel as compact information cards', () => {
render(
<CustomWorldGenerationView
settingText="大鱼吃小鱼题材"
anchorEntries={[
{ id: 'topic', label: '题材', value: '火锅' },
{ id: 'count', label: '素材数量', value: '20 种素材' },
]}
progress={createProgress()}
isGenerating
error={null}
onBack={() => {}}
onEditSetting={() => {}}
onRetry={() => {}}
backLabel="返回创作中心"
settingDescription={null}
settingActionLabel={null}
settingTitle="当前大鱼吃小鱼信息"
progressTitle="大鱼吃小鱼草稿生成进度"
/>,
);
expect(screen.getByText('当前批次')).toBeTruthy();
expect(
screen
.getByText('预计等待')
.closest('.custom-world-generation-stats')
?.className,
).not.toContain('custom-world-generation-stats--two-column');
expect(screen.getByText('当前大鱼吃小鱼信息')).toBeTruthy();
expect(screen.getByText('当前大鱼吃小鱼信息').className).toContain('text-[13px]');
expect(screen.getByText('题材')).toBeTruthy();
expect(screen.getByText('题材').className).toContain('text-[9px]');
expect(screen.getByText('火锅')).toBeTruthy();
expect(screen.getByText('火锅').className).toContain('text-[13px]');
expect(screen.getByText('素材数量')).toBeTruthy();
expect(screen.getByText('20 种素材')).toBeTruthy();
expect(screen.queryByText('大鱼吃小鱼题材')).toBeNull();
expect(screen.getByTestId('generation-page-background-video')).toBeTruthy();
});
});

View File

@@ -1,9 +1,12 @@
import { motion } from 'motion/react';
import type { CSSProperties } from 'react';
import { useEffect, useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
import {
GenerationCurrentStepCard,
GenerationPageBackdrop,
GenerationProgressHero,
} from './GenerationProgressHero';
interface CustomWorldGenerationViewProps {
settingText: string;
@@ -81,6 +84,19 @@ function getStepStatusLabel(step: { status: string }) {
return '待处理';
}
function resolveCurrentGenerationStep(
progress: CustomWorldGenerationProgress | null,
) {
const steps = progress?.steps ?? [];
return (
steps.find((step) => step.status === 'active') ??
steps[progress?.activeStepIndex ?? -1] ??
steps.find((step) => step.status === 'pending') ??
steps.at(-1) ??
null
);
}
function buildFallbackRenderKey(
value: string | null | undefined,
fallback: string,
@@ -89,49 +105,6 @@ function buildFallbackRenderKey(
return normalizedValue ? normalizedValue : fallback;
}
function useIsMobileGenerationLayout() {
const [isMobile, setIsMobile] = useState(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia('(max-width: 639px)').matches;
});
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return undefined;
}
const mediaQuery = window.matchMedia('(max-width: 639px)');
const syncMobileLayout = () => {
setIsMobile(mediaQuery.matches);
};
syncMobileLayout();
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', syncMobileLayout);
return () => {
mediaQuery.removeEventListener('change', syncMobileLayout);
};
}
mediaQuery.addListener(syncMobileLayout);
return () => {
mediaQuery.removeListener(syncMobileLayout);
};
}, []);
return isMobile;
}
export function CustomWorldGenerationView({
settingText,
anchorEntries = [],
@@ -155,42 +128,47 @@ export function CustomWorldGenerationView({
structuredEmptyText = '正在整理当前设定结构,请稍后。',
hideBatchModule = false,
}: CustomWorldGenerationViewProps) {
const isMobileGenerationLayout = useIsMobileGenerationLayout();
void hideBatchModule;
const progressValue = getProgressPercentage(progress);
const steps = progress?.steps ?? [];
const currentStep = resolveCurrentGenerationStep(progress);
const currentStepProgress = currentStep
? getStepProgressPercentage(currentStep)
: progressValue;
const currentStepLabel = currentStep?.label ?? progress?.phaseLabel ?? '准备生成';
const currentStepStatusLabel = currentStep
? getStepStatusLabel(currentStep)
: isGenerating
? '进行中'
: '待处理';
const hasStructuredAnchors = anchorEntries.length > 0;
// 允许不同生成场景按需隐藏第二模块的说明和次级返回动作。
const normalizedSettingActionLabel = settingActionLabel?.trim() ?? '';
const normalizedSettingDescription = settingDescription?.trim() ?? '';
const hasSettingActionLabel = normalizedSettingActionLabel.length > 0;
const hasSettingDescription = normalizedSettingDescription.length > 0;
const shouldHideBatchModule =
hideBatchModule ||
progressTitle === '拼图草稿生成进度' ||
progressTitle === '抓大鹅草稿生成进度';
const estimatedWaitText =
progress?.estimatedRemainingMs != null
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
: '正在校准预计等待时间';
? formatDuration(progress.estimatedRemainingMs)
: '校准中';
const elapsedText =
progress != null
? `已耗时 ${formatDuration(progress.elapsedMs)}`
: '正在启动世界生成';
progress != null ? formatDuration(progress.elapsedMs) : '启动中';
return (
<div
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-y-auto overscroll-y-contain 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"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="platform-sticky-fade sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 px-3 pb-3 pt-1 backdrop-blur-sm sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0 sm:backdrop-blur-none">
<GenerationPageBackdrop />
<div className="relative z-10 mb-6 flex shrink-0 items-center justify-between gap-3 py-2 sm:mb-6">
<button
type="button"
onClick={onBack}
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px]"
className="inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm"
>
{backLabel}
<ArrowLeft className="h-5 w-5 shrink-0" strokeWidth={2.6} />
<span className="break-keep">{backLabel}</span>
</button>
<div className="platform-pill platform-pill--cool px-3 py-1 text-[10px] tracking-[0.2em]">
<div 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">
{isGenerating
? activeBadgeLabel
: error
@@ -199,143 +177,26 @@ export function CustomWorldGenerationView({
</div>
</div>
<div className="flex flex-none flex-col gap-4 xl:grid xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.65fr)] xl:items-stretch">
<section className="platform-surface platform-surface--soft flex flex-col px-4 py-3.5 xl:min-h-0 xl:flex-1 xl:px-5 xl:py-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:gap-6">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
{progressTitle}
</div>
<div className="mt-1 text-xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2rem] xl:text-[2.4rem]">
{progress?.phaseLabel ?? '正在启动世界生成'}
</div>
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
{progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'}
</div>
</div>
<div className="shrink-0 sm:text-right">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-3xl font-black text-[var(--platform-cool-text)] sm:text-4xl">
{progressValue}%
</div>
</div>
</div>
<div className="relative z-10 flex flex-none flex-col gap-4">
<section className="overflow-hidden px-0 pb-2 pt-0 sm:px-0">
<GenerationProgressHero
title={progressTitle}
phaseLabel={progress?.phaseLabel ?? '正在启动世界生成'}
progressValue={progressValue}
estimatedWaitText={estimatedWaitText}
elapsedText={elapsedText}
/>
<div className="platform-progress-track mt-4 h-4 overflow-hidden rounded-full xl:mt-5 xl:h-5">
<motion.div
className="h-full bg-[linear-gradient(90deg,#df7f40_0%,#cc754c_52%,#eaccb3_100%)]"
animate={{ width: `${progressValue}%` }}
transition={{ duration: 0.35, ease: 'easeOut' }}
<div className="mt-[-0.15rem] px-0 sm:px-0">
<GenerationCurrentStepCard
label={currentStepLabel}
statusLabel={currentStepStatusLabel}
progressValue={currentStepProgress}
/>
</div>
<div
className={`custom-world-generation-stats mt-4 grid gap-2 xl:gap-3 ${
shouldHideBatchModule
? 'custom-world-generation-stats--two-column grid-cols-2'
: 'sm:grid-cols-3'
}`}
style={
shouldHideBatchModule
? { gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }
: undefined
}
>
{shouldHideBatchModule ? null : (
<div className="platform-subpanel min-w-0 rounded-2xl px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{progress?.batchLabel ?? '准备中'}
</div>
</div>
)}
<div className="platform-subpanel min-w-0 rounded-2xl px-3 py-3 sm:px-4">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 break-keep text-xs font-semibold text-white sm:text-sm">
{estimatedWaitText}
</div>
</div>
<div className="platform-subpanel min-w-0 rounded-2xl px-3 py-3 sm:px-4">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 break-keep text-xs font-semibold text-white sm:text-sm">
{elapsedText}
</div>
</div>
</div>
<div className="mt-4 space-y-2 xl:grid xl:min-h-0 xl:flex-1 xl:grid-cols-2 xl:content-start xl:gap-2 xl:space-y-0 xl:overflow-y-auto xl:pr-1">
{steps.map((step, index) => {
const stepProgress = getStepProgressPercentage(step);
return (
<motion.div
key={buildFallbackRenderKey(
step.id,
`progress-step-${index}`,
)}
className={`rounded-2xl border px-4 py-3 transition-colors ${
step.status === 'completed'
? 'border-[var(--platform-success-border)] bg-[var(--platform-success-bg)]'
: step.status === 'active'
? 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)]'
: 'platform-subpanel'
} custom-world-generation-step`}
initial={
isMobileGenerationLayout
? { opacity: 0, x: '-110vw' }
: false
}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: 0.34,
ease: 'easeOut',
delay: isMobileGenerationLayout ? index * 0.09 : 0,
}}
style={
{
'--generation-step-delay': `${index * 90}ms`,
} as CSSProperties
}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 text-sm font-semibold text-white">
{step.label}
</div>
<div className="shrink-0 text-xs font-semibold text-zinc-300">
{getStepStatusLabel(step)} {stepProgress}%
</div>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/8">
<motion.div
className={`h-full rounded-full ${
step.status === 'completed'
? 'bg-[var(--platform-success-text)]'
: step.status === 'active'
? 'bg-[linear-gradient(90deg,#df7f40_0%,#cc754c_56%,#eaccb3_100%)]'
: 'bg-white/18'
}`}
animate={{ width: `${stepProgress}%` }}
transition={{ duration: 0.45, ease: 'easeOut' }}
/>
</div>
<div className="mt-2 text-xs leading-6 text-zinc-400">
{step.detail}
</div>
</motion.div>
);
})}
</div>
{error ? (
<div className="mt-4 rounded-2xl border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] px-4 py-3 text-sm leading-6 text-[var(--platform-button-danger-text)]">
<div className="mt-4 rounded-[1.4rem] border border-[#d88969]/35 bg-white/76 px-4 py-3 text-sm leading-6 text-[#a6402f] backdrop-blur-md">
{error}
</div>
) : null}
@@ -372,14 +233,14 @@ export function CustomWorldGenerationView({
</div>
</section>
<section className="platform-surface platform-surface--soft overflow-hidden px-4 py-3.5 xl:flex xl:min-h-0 xl:flex-col xl:px-5 xl:py-4">
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between xl:flex-col xl:items-start xl:gap-2">
<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 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-[var(--platform-cool-text)]">
<div className="text-[13px] font-black tracking-[0.08em] text-[#111111]">
{settingTitle}
</div>
{hasSettingDescription ? (
<div className="mt-1 text-sm text-zinc-400">
<div className="mt-2 text-[13px] leading-6 text-[#7e6656]">
{normalizedSettingDescription}
</div>
) : null}
@@ -396,26 +257,26 @@ export function CustomWorldGenerationView({
) : null}
</div>
{hasStructuredAnchors ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:min-h-0 xl:flex-1 xl:grid-cols-1 xl:overflow-y-auto xl:pr-1">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{anchorEntries.map((entry, index) => (
<div
key={buildFallbackRenderKey(
entry.id,
`anchor-entry-${index}`,
)}
className="platform-subpanel rounded-2xl px-4 py-4 xl:py-3"
className="rounded-[1.15rem] border border-[#ead6c7] bg-white/74 px-4 py-4"
>
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
<div className="text-[9px] font-bold tracking-[0.12em] text-[#8e6f5d] sm:text-[10px]">
{entry.label}
</div>
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-100">
<div className="mt-2 whitespace-pre-line text-[13px] leading-7 text-[#111111]">
{entry.value}
</div>
</div>
))}
</div>
) : (
<div className="platform-subpanel whitespace-pre-line rounded-2xl px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto xl:max-h-none xl:min-h-0 xl:flex-1">
<div className="whitespace-pre-line rounded-[1.15rem] border border-[#ead6c7] bg-white/74 px-4 py-4 text-[13px] leading-7 text-[#111111] md:max-h-[16rem] md:overflow-y-auto">
{settingText || structuredEmptyText}
</div>
)}

View File

@@ -0,0 +1,289 @@
import { Clock3, Hourglass } from 'lucide-react';
import { motion } from 'motion/react';
import { useEffect, useId, useRef } from 'react';
import generationHeroVideo from '../../media/create_bg_video.mp4';
const GENERATION_PROGRESS_RING_START_DEGREES = 225;
const GENERATION_PROGRESS_RING_SWEEP_DEGREES = 270;
const GENERATION_PROGRESS_RING_VIEWBOX = 400;
const GENERATION_PROGRESS_RING_CENTER = GENERATION_PROGRESS_RING_VIEWBOX / 2;
const GENERATION_PROGRESS_RING_RADIUS = 166;
const GENERATION_PROGRESS_RING_STROKE_WIDTH = 18;
const GENERATION_PROGRESS_RING_SWEEP_RATIO =
GENERATION_PROGRESS_RING_SWEEP_DEGREES / 360;
type GenerationProgressHeroProps = {
title: string;
phaseLabel: string;
progressValue: number;
estimatedWaitText: string;
elapsedText: string;
};
type GenerationCurrentStepCardProps = {
label: string;
statusLabel: string;
progressValue: number;
};
function clampGenerationProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
function buildGenerationRingMetrics(progressValue: number) {
const circumference = 2 * Math.PI * GENERATION_PROGRESS_RING_RADIUS;
const sweepLength = circumference * GENERATION_PROGRESS_RING_SWEEP_RATIO;
const progressLength = sweepLength * (progressValue / 100);
return {
circumference,
progressLength,
sweepLength,
};
}
export function GenerationPageBackdrop() {
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) {
return undefined;
}
video.defaultMuted = true;
video.muted = true;
video.volume = 0;
const isJsdom =
window.navigator.userAgent.toLowerCase().includes('jsdom');
const tryPlay = () => {
if (isJsdom) {
return;
}
try {
const playPromise = video.play();
if (playPromise && typeof playPromise.then === 'function') {
void playPromise.catch(() => {});
}
} catch {
// 中文注释:测试环境和某些内核可能同步拒绝 play失败时保留静音背景层不阻断页面渲染。
}
};
tryPlay();
video.addEventListener('loadeddata', tryPlay);
video.addEventListener('canplay', tryPlay);
video.addEventListener('playing', tryPlay);
window.addEventListener('focus', tryPlay);
document.addEventListener('visibilitychange', tryPlay);
return () => {
video.removeEventListener('loadeddata', tryPlay);
video.removeEventListener('canplay', tryPlay);
video.removeEventListener('playing', tryPlay);
window.removeEventListener('focus', tryPlay);
document.removeEventListener('visibilitychange', tryPlay);
};
}, []);
return (
<div className="pointer-events-none fixed inset-0 z-0 h-[100dvh] w-screen overflow-hidden bg-transparent">
<video
ref={videoRef}
data-testid="generation-page-background-video"
className="absolute left-1/2 top-1/2 h-full w-full -translate-x-1/2 -translate-y-1/2 object-cover opacity-100"
autoPlay
loop
muted
playsInline
preload="auto"
aria-hidden="true"
>
<source src={generationHeroVideo} type="video/mp4" />
</video>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,250,244,0.08)_0%,rgba(255,247,238,0.16)_52%,rgba(255,250,246,0.32)_100%)]" />
</div>
);
}
export function GenerationProgressHero({
title,
phaseLabel,
progressValue,
estimatedWaitText,
elapsedText,
}: GenerationProgressHeroProps) {
const safeProgress = clampGenerationProgress(progressValue);
const ringGradientId = useId().replace(/:/g, '');
const ringMetrics = buildGenerationRingMetrics(safeProgress);
const ringDegrees = Math.round((safeProgress / 100) * 270);
const ringTrackDasharray = `${ringMetrics.sweepLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`;
const ringFillDasharray = `${ringMetrics.progressLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`;
return (
<div className="relative mx-auto flex w-full max-w-[60rem] flex-col items-center px-1 pb-1 pt-1 sm:pt-4">
<div className="sr-only">
{title}
{phaseLabel ? ` ${phaseLabel}` : ''}
</div>
<div className="relative w-full max-w-[56rem] sm:max-w-[60rem]">
<div
className="absolute left-0 top-1/2 z-20 w-[min(6.8rem,28vw)] -translate-y-1/2 rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:w-[8rem] sm:px-3 sm:py-2.5"
data-testid="generation-hero-wait-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<Hourglass className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{estimatedWaitText}
</div>
</div>
<div
className="absolute right-0 top-1/2 z-20 w-[min(6.8rem,28vw)] -translate-y-1/2 rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:w-[8rem] sm:px-3 sm:py-2.5"
data-testid="generation-hero-elapsed-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
<Clock3 className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{elapsedText}
</div>
</div>
<div
className="relative mx-auto aspect-square w-[min(35rem,94vw)] overflow-visible rounded-full sm:w-[52rem]"
role="progressbar"
aria-label={title}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={safeProgress}
data-ring-start-degrees={GENERATION_PROGRESS_RING_START_DEGREES}
data-ring-sweep-degrees={GENERATION_PROGRESS_RING_SWEEP_DEGREES}
data-ring-fill-degrees={ringDegrees}
data-ring-gap-degrees={90}
>
<svg
data-testid="generation-hero-progress-ring"
className="pointer-events-none absolute inset-0 h-full w-full"
viewBox={`0 0 ${GENERATION_PROGRESS_RING_VIEWBOX} ${GENERATION_PROGRESS_RING_VIEWBOX}`}
aria-hidden="true"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient
id={`${ringGradientId}-progress`}
x1="32%"
y1="18%"
x2="82%"
y2="86%"
>
<stop offset="0%" stopColor="#f1a34f" />
<stop offset="100%" stopColor="#e55d16" />
</linearGradient>
</defs>
<circle
data-testid="generation-hero-progress-ring-track"
cx={GENERATION_PROGRESS_RING_CENTER}
cy={GENERATION_PROGRESS_RING_CENTER}
r={GENERATION_PROGRESS_RING_RADIUS}
fill="none"
stroke="#f3e5da"
strokeLinecap="round"
strokeWidth={GENERATION_PROGRESS_RING_STROKE_WIDTH}
strokeDasharray={ringTrackDasharray}
transform={`rotate(${GENERATION_PROGRESS_RING_START_DEGREES} ${GENERATION_PROGRESS_RING_CENTER} ${GENERATION_PROGRESS_RING_CENTER})`}
vectorEffect="non-scaling-stroke"
shapeRendering="geometricPrecision"
/>
<circle
data-testid="generation-hero-progress-ring-fill"
cx={GENERATION_PROGRESS_RING_CENTER}
cy={GENERATION_PROGRESS_RING_CENTER}
r={GENERATION_PROGRESS_RING_RADIUS}
fill="none"
stroke={`url(#${ringGradientId}-progress)`}
strokeLinecap="round"
strokeWidth={GENERATION_PROGRESS_RING_STROKE_WIDTH}
strokeDasharray={ringFillDasharray}
transform={`rotate(${GENERATION_PROGRESS_RING_START_DEGREES} ${GENERATION_PROGRESS_RING_CENTER} ${GENERATION_PROGRESS_RING_CENTER})`}
vectorEffect="non-scaling-stroke"
shapeRendering="geometricPrecision"
/>
</svg>
<div
className="relative z-10 flex h-full w-full flex-col items-center justify-start pt-[4%] text-center sm:pt-[3%]"
data-testid="generation-hero-progress-content"
>
<div className="text-[9px] font-black tracking-[0.16em] text-[#7f441f] sm:text-[10px]">
</div>
<div className="mt-1 text-[1.15rem] font-black leading-none text-[#e45e14] sm:mt-1.5 sm:text-[1.9rem]">
{safeProgress}%
</div>
</div>
</div>
</div>
</div>
);
}
export function GenerationCurrentStepCard({
label,
statusLabel,
progressValue,
}: GenerationCurrentStepCardProps) {
const safeProgress = clampGenerationProgress(progressValue);
const isActive = statusLabel === '进行中';
return (
<div
className="rounded-[1.75rem] border border-white/58 bg-white/58 px-4 py-4 shadow-[0_22px_56px_rgba(112,57,30,0.10)] backdrop-blur-md sm:px-5 sm:py-5"
data-testid="generation-current-step-card"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="text-[10px] font-black tracking-[0.16em] text-[#d94d1f] sm:text-[11px]">
</div>
<div className="mt-2 break-words text-[14px] font-black leading-tight text-[#111111] sm:text-[15px]">
{label}
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-2 pt-1 text-right">
<div className="inline-flex items-center gap-2 text-[11px] font-black leading-none tracking-[0.04em] text-[#df6118] sm:text-[12px]">
{statusLabel} {safeProgress}%
</div>
{isActive ? (
<span
className="ml-auto inline-block h-5 w-5 animate-spin rounded-full border-2 border-[#f3c8ae] border-t-[#df6118]"
aria-hidden="true"
/>
) : null}
</div>
</div>
<div
className="mt-4 h-2.5 overflow-hidden rounded-full bg-[#f5eee8]"
role="progressbar"
aria-label={`${label} 进度`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={safeProgress}
>
<motion.div
className="h-full rounded-full bg-[linear-gradient(90deg,#ef7a1f_0%,#e25f18_64%,#f0b07e_100%)]"
animate={{ width: `${safeProgress}%` }}
transition={{ duration: 0.45, ease: 'easeOut' }}
/>
</div>
</div>
);
}

View File

@@ -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({

View File

@@ -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;

View File

@@ -25,6 +25,14 @@ const testEntryConfig = {
title: '选择创作类型',
description: '先选玩法类型,再进入对应创作工作台。',
},
eventBanner: {
title: '泥点挑战',
description: '创作活动测试横幅。',
coverImageSrc: '/creation-type-references/puzzle.webp',
prizePoolMudPoints: 1000,
startsAtText: '2026-05-01',
endsAtText: '2026-05-31',
},
creationTypes: [
{
id: 'rpg',
@@ -35,6 +43,9 @@ const testEntryConfig = {
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -46,6 +57,9 @@ const testEntryConfig = {
visible: true,
open: true,
sortOrder: 30,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -57,6 +71,9 @@ const testEntryConfig = {
visible: true,
open: true,
sortOrder: 40,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -68,6 +85,9 @@ const testEntryConfig = {
visible: false,
open: true,
sortOrder: 50,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -79,6 +99,9 @@ const testEntryConfig = {
visible: false,
open: false,
sortOrder: 60,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -90,6 +113,9 @@ const testEntryConfig = {
visible: true,
open: false,
sortOrder: 70,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
],
@@ -665,17 +691,17 @@ test('creation hub works-only tab filters bark battle draft and published works'
/>,
);
expect(screen.getByRole('button', { name: '全部 2' })).toBeTruthy();
expect(screen.getByRole('button', { name: '草稿 1' })).toBeTruthy();
expect(screen.getByRole('button', { name: '已发布 1' })).toBeTruthy();
expect(screen.getByRole('tab', { name: '全部 2' })).toBeTruthy();
expect(screen.getByRole('tab', { name: '草稿 1' })).toBeTruthy();
expect(screen.getByRole('tab', { name: '已发布 1' })).toBeTruthy();
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '草稿 1' }));
await user.click(screen.getByRole('tab', { name: '草稿 1' }));
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
expect(screen.queryByText('竖屏声浪已发布')).toBeNull();
await user.click(screen.getByRole('button', { name: '已发布 1' }));
await user.click(screen.getByRole('tab', { name: '已发布 1' }));
expect(screen.queryByText('竖屏声浪草稿')).toBeNull();
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
@@ -880,6 +906,38 @@ test('creation hub published share icon is shown directly on the card header', (
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
});
test('creation hub shows RPG published share icon without library entry', () => {
render(
<CustomWorldCreationHub
items={[
{
...baseDraftItem,
workId: 'published:world-public-1',
sourceType: 'published_profile',
status: 'published',
title: '潮雾列岛已发布版',
profileId: 'world-public-1',
canResume: false,
canEnterWorld: true,
},
]}
rpgLibraryEntries={[]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy();
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
expect(screen.queryByText('作者:玩家')).toBeNull();
});
test('creation hub left swipe draft reveals delete without opening card', () => {
const onDeletePublished = vi.fn();
const onOpenDraft = vi.fn();

View File

@@ -18,6 +18,14 @@ const testEntryConfig = {
title: '选择创作类型',
description: '先选玩法类型,再进入对应创作工作台。',
},
eventBanner: {
title: '泥点挑战',
description: '创作活动测试横幅。',
coverImageSrc: '/creation-type-references/puzzle.webp',
prizePoolMudPoints: 1000,
startsAtText: '2026-05-01',
endsAtText: '2026-05-31',
},
creationTypes: [
{
id: 'rpg',
@@ -28,6 +36,9 @@ const testEntryConfig = {
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -39,17 +50,23 @@ const testEntryConfig = {
visible: true,
open: true,
sortOrder: 30,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'match3d',
title: '抓大鹅',
subtitle: '3D 消除关卡',
badge: '可创',
badge: '可创',
imageSrc: '/creation-type-references/match3d.webp',
visible: true,
open: true,
sortOrder: 40,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -61,6 +78,9 @@ const testEntryConfig = {
visible: false,
open: true,
sortOrder: 50,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -72,6 +92,9 @@ const testEntryConfig = {
visible: false,
open: false,
sortOrder: 60,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -83,6 +106,9 @@ const testEntryConfig = {
visible: true,
open: false,
sortOrder: 70,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
],
@@ -140,6 +166,96 @@ test('creation hub draft card renders compiled work summary fields', () => {
expect(html).not.toContain('大鱼吃小鱼');
});
test('creation start card renders reference-aligned banner and template metadata', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
mode="start-only"
/>,
);
expect(html).toContain('creation-event-banner');
expect(html).toContain('creation-event-banner__track');
expect(html).toContain('creation-event-banner__slide');
expect(html).toContain('creation-event-banner__timebar');
expect(html).toContain('拼图主题创作赛');
expect(html).toContain('抓大鹅主题创作赛');
expect(html).toContain('1,000');
expect(html).toContain('泥点数');
expect(html).not.toContain('泥点挑战');
expect(html).toMatch(
/creation-event-banner__timebar[\s\S]*creation-event-banner__pager[\s\S]*creation-template-card/u,
);
expect(html).toContain('creation-template-card__body');
expect(html).toContain('creation-template-card__cost-badge');
expect(html).toContain('拼图关卡创作');
expect(html).toContain('10-20泥点数');
expect(html).toContain('即将开放');
expect(html).not.toContain('可创建');
expect(html).not.toContain('可创作');
expect(html).not.toContain('creation-event-banner__counter');
expect(html).not.toContain('预计消耗 10-20 泥点');
expect(html).not.toContain('platform-creation-reference-card');
});
test('creation start card keeps typography in compact UI scale', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
mode="start-only"
/>,
);
expect(html).toMatch(/creation-template-card__title[^"]*\btext-sm\b/u);
expect(html).toMatch(/creation-template-card__subtitle[^"]*\btext-xs\b/u);
expect(html).toMatch(
/creation-template-card__cost-badge[^"]*\btext-\[11px\](?:\s|")/u,
);
expect(html).not.toMatch(
/\b(text-lg|text-xl|sm:text-base|sm:text-lg|sm:text-xl|text-\[1\.08rem\])\b/u,
);
});
test('creation start card removes the outer template list frame and tightens card grid', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
mode="start-only"
/>,
);
expect(html).toContain('creation-template-list');
expect(html).toMatch(/creation-template-list__grid[^"]*\bgap-2\b/u);
expect(html).toMatch(/creation-template-card[^"]*\bmin-h-\[12\.5rem\]/u);
expect(html).not.toMatch(
/creation-template-list[^"]*\bborder\b[^"]*\bborder-\[#f0dfd6\]/u,
);
});
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
@@ -514,3 +630,64 @@ test('creation hub published card keeps publish info without fixed action text',
expect(html).not.toContain('creation-work-card__action');
expect(html).not.toContain('>查看详情<');
});
test('creation hub root keeps the remap theme hook without the page card shell', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
mode="works-only"
/>,
);
expect(html).toContain('platform-remap-surface');
expect(html).not.toContain('platform-page-stage');
});
test('creation hub draft tabs use discover-style channel labels', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
mode="works-only"
items={[]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
puzzleItems={[
{
workId: 'puzzle:works-tab',
profileId: 'puzzle-profile-works-tab',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '测试草稿',
summary: '测试草稿',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
},
]}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(html).toContain('platform-mobile-home-channel');
expect(html).toContain('platform-mobile-home-channel--active');
expect(html).not.toContain('platform-tab--active');
});

View File

@@ -338,7 +338,7 @@ export function CustomWorldCreationHub({
const showWorkShelf = mode !== 'start-only';
return (
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
<div className="platform-remap-surface w-full space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
<div className="space-y-4 xl:space-y-3">
{showStartCard ? (
<CustomWorldCreationStartCard

View File

@@ -1,8 +1,9 @@
import { ArrowRight } from 'lucide-react';
import { Coins, Trophy } from 'lucide-react';
import { useMemo, useState, type UIEvent } from 'react';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import {
getVisiblePlatformCreationTypes,
groupVisiblePlatformCreationTypes,
type PlatformCreationTypeCard,
type PlatformCreationTypeId,
} from '../platform-entry/platformEntryCreationTypes';
@@ -15,6 +16,13 @@ type CustomWorldCreationStartCardProps = {
onCreateType: (type: PlatformCreationTypeId) => void;
};
type CreationEventBannerCard = CreationEntryConfig['eventBanner'];
function shouldShowCreationBadge(badge: string) {
const normalizedBadge = badge.trim();
return normalizedBadge !== '可创建' && normalizedBadge !== '可创作';
}
export function CustomWorldCreationStartCard({
busy = false,
error = null,
@@ -22,30 +30,161 @@ export function CustomWorldCreationStartCard({
creationTypes,
onCreateType,
}: CustomWorldCreationStartCardProps) {
// 创作首页首屏卡带与创作类型弹层保持同一份展示口径,
// 避免某个玩法只在其中一个入口被隐藏而出现状态漂移。
const visibleCreationTypes = getVisiblePlatformCreationTypes(creationTypes);
const creationTypeGroups = useMemo(
() => groupVisiblePlatformCreationTypes(creationTypes),
[creationTypes],
);
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
const activeGroup =
creationTypeGroups.find((group) => group.id === activeCategoryId) ??
creationTypeGroups[0] ??
null;
const visibleCreationTypes = activeGroup?.items ?? [];
const eventBanners = useMemo<CreationEventBannerCard[]>(
() => [
{
...entryConfig.eventBanner,
title: '拼图主题创作赛',
description: '用拼图关卡接住本周主题。',
coverImageSrc: '/creation-type-references/puzzle.webp',
prizePoolMudPoints: 1000,
},
{
...entryConfig.eventBanner,
title: '抓大鹅主题创作赛',
description: '把抓大鹅关卡做成主题挑战。',
coverImageSrc: '/creation-type-references/match3d.webp',
prizePoolMudPoints: 1000,
},
],
[entryConfig.eventBanner],
);
const [activeBannerIndex, setActiveBannerIndex] = useState(0);
function handleBannerScroll(event: UIEvent<HTMLDivElement>) {
const { clientWidth, scrollLeft } = event.currentTarget;
if (clientWidth <= 0) {
return;
}
const nextIndex = Math.max(
0,
Math.min(eventBanners.length - 1, Math.round(scrollLeft / clientWidth)),
);
setActiveBannerIndex((currentIndex) =>
currentIndex === nextIndex ? currentIndex : nextIndex,
);
}
return (
// 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
<div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5 xl:px-5 xl:py-4">
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 space-y-2.5 sm:space-y-4 xl:space-y-3">
<div className="flex items-center justify-between gap-3 xl:items-end">
<div className="text-xl font-black leading-none text-white sm:text-3xl xl:text-2xl">
{entryConfig.startCard.title}
</div>
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block xl:text-xs xl:leading-5">
{entryConfig.startCard.description}
</div>
<span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden">
{busy
? entryConfig.startCard.busyBadge
: entryConfig.startCard.idleBadge}
</span>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-3 sm:gap-5">
<section className="creation-event-banner relative overflow-hidden rounded-[1.35rem] border border-[#f0d8ca] bg-[#fff8f3] shadow-[0_16px_36px_rgba(174,111,73,0.12)] sm:rounded-[1.65rem]">
<div
className="creation-event-banner__track flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain touch-pan-x scrollbar-hide"
onScroll={handleBannerScroll}
aria-label="创作赛事横幅"
>
{eventBanners.map((banner, index) => {
const prizePoolText =
banner.prizePoolMudPoints.toLocaleString('zh-CN');
return (
<article
key={`${banner.title}:${index}`}
className="creation-event-banner__slide relative w-full shrink-0 snap-center overflow-hidden"
>
<img
src={banner.coverImageSrc}
alt=""
className="absolute inset-y-0 right-0 h-full w-[58%] object-cover object-[70%_center] opacity-95"
loading={index === 0 ? 'eager' : 'lazy'}
/>
<div className="absolute inset-0 bg-[linear-gradient(90deg,#fff8f3_0%,rgba(255,248,243,0.94)_34%,rgba(255,248,243,0.36)_68%,rgba(255,248,243,0.08)_100%)]" />
<div className="relative z-10 flex min-h-[12rem] flex-col justify-between px-4 py-4 sm:min-h-[15rem] sm:px-7 sm:py-6">
<div className="w-[68%] min-w-0 sm:w-[56%]">
<div className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[#df7949] bg-white/72 px-2.5 py-1 text-xs font-black text-[#cf6332] shadow-sm">
<Trophy className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{banner.title}</span>
</div>
<div className="mt-3 line-clamp-2 text-sm font-semibold leading-5 text-[#695143] sm:mt-5 sm:leading-6">
{banner.description}
</div>
<div className="mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full bg-white/72 px-2.5 py-1.5 text-xs font-bold text-[#6f5140] shadow-sm">
<span className="grid h-5 w-5 place-items-center rounded-full bg-[#ffb64c] text-white">
<Coins className="h-3 w-3" />
</span>
<span className="shrink-0"></span>
<span className="text-sm font-black text-[#d36b2f]">
{prizePoolText}
</span>
<span className="shrink-0"></span>
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<div className="creation-event-banner__timebar flex min-w-0 items-center justify-center gap-1.5 rounded-full border border-[#e9c5b0] bg-white/72 px-2.5 py-1.5 text-center text-[10px] font-bold text-[#9a5a39] shadow-[0_8px_18px_rgba(174,111,73,0.1)] sm:gap-2 sm:px-5 sm:py-2.5 sm:text-[11px]">
<span className="min-w-0 truncate">
&nbsp;&nbsp;{banner.startsAtText}
</span>
<span className="shrink-0 text-[#c99373]">|</span>
<span className="min-w-0 truncate">
&nbsp;&nbsp;{banner.endsAtText}
</span>
</div>
<div
className="creation-event-banner__pager flex items-center justify-center gap-1.5"
aria-hidden="true"
>
{eventBanners.map((dotBanner, dotIndex) => (
<span
key={`${dotBanner.title}:dot:${dotIndex}`}
className={
dotIndex === activeBannerIndex
? 'h-1.5 w-5 rounded-full bg-[#d9793f]'
: 'h-1.5 w-1.5 rounded-full bg-[#eadfd7]'
}
/>
))}
</div>
</div>
</div>
</article>
);
})}
</div>
</section>
<section className="creation-template-list -mx-1 px-1 sm:-mx-2 sm:px-2">
<div
className="-mx-0.5 flex snap-x items-center gap-2 overflow-x-auto px-0.5 pb-1 scrollbar-hide scroll-px-2 sm:gap-3"
role="tablist"
aria-label="玩法模板分类"
>
{creationTypeGroups.map((group) => {
const selected = group.id === activeGroup?.id;
return (
<button
key={group.id}
type="button"
role="tab"
aria-selected={selected}
onClick={() => setActiveCategoryId(group.id)}
className={`relative min-h-8 shrink-0 rounded-full px-2.5 text-xs font-black transition sm:min-h-9 sm:px-3.5 sm:text-sm ${
selected
? 'text-[#6f2f21]'
: 'text-[#7a6558] hover:text-[#6f2f21]'
}`}
>
<span>{group.label}</span>
{selected ? (
<span className="absolute bottom-0 left-3 right-3 h-1 rounded-full bg-[#d9793f]" />
) : null}
</button>
);
})}
</div>
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 scrollbar-hide sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-6 xl:gap-2.5">
<div className="creation-template-list__grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
{visibleCreationTypes.map((item) => {
const disabled = item.locked || busy;
@@ -57,47 +196,35 @@ export function CustomWorldCreationStartCard({
onClick={() => {
onCreateType(item.id);
}}
className={`platform-creation-reference-card platform-interactive-card relative flex min-h-[4.6rem] w-[11.25rem] shrink-0 snap-start flex-col overflow-hidden rounded-[1.15rem] border p-0 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] xl:min-h-[6.4rem] ${
className={`creation-template-card platform-interactive-card relative flex min-h-[12.5rem] flex-col overflow-hidden rounded-[1rem] border bg-white p-0 text-left transition sm:min-h-[15rem] sm:rounded-[1.2rem] ${
item.locked
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
: 'border-white/18 bg-white/16 text-white'
? 'cursor-not-allowed border-[#eadbd3] text-[#725b4d] opacity-72'
: 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<img
src={item.imageSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover"
loading="lazy"
/>
<div
className={`absolute inset-0 ${
item.locked
? 'bg-[linear-gradient(90deg,rgba(3,7,18,0.58),rgba(3,7,18,0.14)),linear-gradient(180deg,rgba(3,7,18,0.05)_0%,rgba(3,7,18,0.2)_42%,rgba(3,7,18,0.82)_100%)]'
: 'bg-[linear-gradient(90deg,rgba(3,7,18,0.54),rgba(3,7,18,0.04)),linear-gradient(180deg,rgba(3,7,18,0.03)_0%,rgba(3,7,18,0.14)_42%,rgba(3,7,18,0.78)_100%)]'
}`}
/>
<div className="relative z-10 flex min-h-5 items-center justify-end gap-2 px-3 pt-2.5 sm:items-start sm:gap-3 sm:px-4 sm:pt-4 xl:px-3.5 xl:pt-3">
{item.locked ? (
<span className="platform-pill platform-pill--neutral px-2.5 text-xs text-[var(--platform-text-soft)] sm:px-3 sm:text-sm">
<div className="creation-template-card__media relative aspect-[1.32/1] w-full overflow-hidden bg-[#f7ebe3]">
<img
src={item.imageSrc}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
{shouldShowCreationBadge(item.badge) ? (
<span className="absolute left-2 top-2 max-w-[calc(100%-1rem)] rounded-full bg-[#b66a3e] px-2 py-0.5 text-xs font-black text-white shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1">
{item.badge}
</span>
) : null}
{item.locked ? (
<span className="text-base leading-none text-white/40">·</span>
) : (
<ArrowRight className="h-4 w-4 text-white/80" />
)}
<span className="creation-template-card__cost-badge absolute bottom-2 right-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full bg-[#fff7ec]/92 px-2 py-1 text-[11px] font-black leading-4 text-[#b65f2c] shadow-[0_8px_18px_rgba(119,72,44,0.16)]">
<Coins className="h-3 w-3 shrink-0" />
<span className="truncate">10-20</span>
</span>
</div>
<div className="relative z-10 mt-auto px-3 pb-2.5 pt-1.5 text-white [text-shadow:0_1px_8px_rgba(0,0,0,0.76)] sm:px-4 sm:pb-4 sm:pt-4 xl:px-3.5 xl:pb-3 xl:pt-2">
<div className="truncate text-base font-black leading-tight text-white sm:text-lg xl:text-base">
<div className="creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col bg-white px-2.5 pb-2.5 pt-2.5 text-[#2f211b] sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5">
<div className="creation-template-card__title line-clamp-1 text-sm font-black leading-5 text-[#2f211b]">
{item.title}
</div>
<div
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
item.locked ? 'text-white/72' : 'text-white/88'
}`}
>
<div className="creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 text-[#6f5a4c] sm:leading-5">
{item.subtitle}
</div>
</div>
@@ -107,11 +234,11 @@ export function CustomWorldCreationStartCard({
</div>
{error ? (
<div className="platform-banner platform-banner--danger rounded-[1rem] px-3 py-2 text-sm leading-5 sm:rounded-[1.25rem] sm:leading-6">
<div className="platform-banner platform-banner--danger mt-4 rounded-[1rem] px-3 py-2 text-sm leading-5 sm:rounded-[1.25rem] sm:leading-6">
{error}
</div>
) : null}
</div>
</section>
</div>
);
}

View File

@@ -728,8 +728,6 @@ export function CustomWorldWorkCard({
{item.summary}
</div>
<div className="creation-work-card__author">{item.authorDisplayName}</div>
{isPublished ? (
<div className="creation-work-card__published-info">
{item.pointIncentive ? (

View File

@@ -23,7 +23,11 @@ export function CustomWorldWorkTabs({
onChange,
}: CustomWorldWorkTabsProps) {
return (
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide xl:pb-0">
<div
className="flex min-w-0 items-center gap-4 overflow-x-auto pb-1 scrollbar-hide xl:pb-0"
role="tablist"
aria-label="作品筛选"
>
{FILTER_OPTIONS.map((option) => {
const count =
option.id === 'draft'
@@ -36,10 +40,10 @@ export function CustomWorldWorkTabs({
<button
key={option.id}
type="button"
role="tab"
aria-selected={activeFilter === option.id}
onClick={() => onChange(option.id)}
className={`platform-tab shrink-0 px-4 py-2 text-sm xl:px-4 xl:py-1.5 xl:text-xs ${
activeFilter === option.id ? 'platform-tab--active' : ''
}`}
className={`platform-mobile-home-channel shrink-0 ${activeFilter === option.id ? 'platform-mobile-home-channel--active' : ''}`}
>
{option.label} {count}
</button>

View File

@@ -175,6 +175,39 @@ test('buildCreationWorkShelfItems keeps separate bark battle draft and published
);
});
test('buildCreationWorkShelfItems falls back to deterministic RPG public work code when library entry is missing', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [
{
workId: 'rpg-work-published',
sourceType: 'published_profile',
status: 'published',
title: '潮雾列岛已发布版',
subtitle: '旧灯塔与失控航路',
summary: '已经发布的群岛世界作品。',
coverImageSrc: null,
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: '2026-04-20T10:00:00.000Z',
stage: 'published',
stageLabel: '已发布',
playableNpcCount: 3,
landmarkCount: 4,
sessionId: null,
profileId: 'world-public-1',
canResume: false,
canEnterWorld: true,
},
],
bigFishItems: [],
puzzleItems: [],
});
expect(items).toHaveLength(1);
expect(items[0]?.publicWorkCode).toBe('CW-00000001');
expect(items[0]?.sharePath).toContain('/works/detail?work=CW-00000001');
expect(items[0]?.canShare).toBe(true);
});
test('buildCreationWorkShelfItems gives bark battle draft cover from character or reference fallback', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
@@ -1009,7 +1042,7 @@ test('bark battle draft generating state follows pending assets or missing three
});
test('CustomWorldWorkCard renders author for draft and published works', () => {
test('CustomWorldWorkCard hides author on shelf draft and published cards', () => {
const buildItem = (
status: CreationWorkShelfItem['status'],
authorDisplayName: string,
@@ -1074,8 +1107,8 @@ test('CustomWorldWorkCard renders author for draft and published works', () => {
}),
);
expect(draftHtml).toContain('作者:草稿作者');
expect(publishedHtml).toContain('作者:发布作者');
expect(draftHtml).not.toContain('作者:草稿作者');
expect(publishedHtml).not.toContain('作者:发布作者');
});
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {

View File

@@ -10,6 +10,7 @@ import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contra
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
buildBabyObjectMatchPublicWorkCode,
buildCustomWorldPublicWorkCode,
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode,
@@ -332,7 +333,10 @@ function mapRpgWorkToShelfItem(
? libraryEntries.find((entry) => entry.profileId === item.profileId)
: null;
const publicWorkCode =
item.status === 'published' ? (libraryEntry?.publicWorkCode ?? null) : null;
item.status === 'published'
? (libraryEntry?.publicWorkCode?.trim() ||
(item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null))
: null;
const badges: CreationWorkShelfBadge[] = [
buildStatusBadge(item.status),
{ id: 'type', label: 'RPG', tone: 'neutral' },

View File

@@ -18,6 +18,14 @@ const entryConfig = {
title: '选择创作类型',
description: '',
},
eventBanner: {
title: '泥点挑战',
description: '创作活动测试横幅。',
coverImageSrc: '/creation-type-references/puzzle.webp',
prizePoolMudPoints: 1000,
startsAtText: '2026-05-01',
endsAtText: '2026-05-31',
},
creationTypes: [
{
id: 'wooden-fish',
@@ -28,6 +36,9 @@ const entryConfig = {
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
],

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
derivePlatformCreationTypes,
groupVisiblePlatformCreationTypes,
getVisiblePlatformCreationTypes,
isPlatformCreationTypeOpen,
isPlatformCreationTypeVisible,
@@ -22,6 +23,9 @@ test('database entry config controls visibility open state and display order', (
visible: true,
open: false,
sortOrder: 30,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -33,6 +37,9 @@ test('database entry config controls visibility open state and display order', (
visible: true,
open: true,
sortOrder: 20,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -44,6 +51,9 @@ test('database entry config controls visibility open state and display order', (
visible: false,
open: true,
sortOrder: 10,
categoryId: 'festival',
categoryLabel: '节日主题',
categorySortOrder: 30,
updatedAtMicros: 1,
},
]);
@@ -79,6 +89,9 @@ test('visible platform creation types hide invisible cards and put locked cards
visible: false,
open: true,
sortOrder: 1,
categoryId: 'hidden',
categoryLabel: '隐藏',
categorySortOrder: 99,
updatedAtMicros: 1,
},
{
@@ -90,6 +103,9 @@ test('visible platform creation types hide invisible cards and put locked cards
visible: true,
open: false,
sortOrder: 2,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -101,6 +117,9 @@ test('visible platform creation types hide invisible cards and put locked cards
visible: true,
open: true,
sortOrder: 3,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
]);
@@ -131,6 +150,9 @@ test('edutainment switch hides baby object match creation entry from database co
visible: true,
open: true,
sortOrder: 1,
categoryId: 'character',
categoryLabel: '角色创作',
categorySortOrder: 40,
updatedAtMicros: 1,
},
{
@@ -142,6 +164,9 @@ test('edutainment switch hides baby object match creation entry from database co
visible: true,
open: true,
sortOrder: 2,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
]);
@@ -160,6 +185,9 @@ test('edutainment switch hides baby object match creation entry from database co
visible: true,
open: true,
sortOrder: 1,
categoryId: 'character',
categoryLabel: '角色创作',
categorySortOrder: 40,
updatedAtMicros: 1,
},
{
@@ -171,6 +199,9 @@ test('edutainment switch hides baby object match creation entry from database co
visible: true,
open: true,
sortOrder: 2,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
]);
@@ -194,6 +225,9 @@ test('baby object match entry is visible and open when database marks it creatab
visible: true,
open: true,
sortOrder: 90,
categoryId: 'character',
categoryLabel: '角色创作',
categorySortOrder: 40,
updatedAtMicros: 1,
},
]);
@@ -208,3 +242,76 @@ test('baby object match entry is visible and open when database marks it creatab
expect(isPlatformCreationTypeVisible(cards, 'baby-object-match')).toBe(true);
expect(isPlatformCreationTypeOpen(cards, 'baby-object-match')).toBe(true);
});
test('groups visible platform creation types by backend category metadata', () => {
const cards = derivePlatformCreationTypes([
{
id: 'puzzle',
title: '秋日暖阳',
subtitle: '记录秋日的温暖时光',
badge: '热门',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 30,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'match3d',
title: '秋日小屋',
subtitle: '打造专属的秋日小屋',
badge: '精选',
imageSrc: '/creation-type-references/match3d.webp',
visible: true,
open: true,
sortOrder: 40,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '分支叙事体验',
badge: '敬请期待',
imageSrc: '/creation-type-references/visual-novel.webp',
visible: true,
open: false,
sortOrder: 60,
categoryId: 'festival',
categoryLabel: '节日主题',
categorySortOrder: 30,
updatedAtMicros: 1,
},
{
id: 'hidden',
title: '隐藏入口',
subtitle: '隐藏',
badge: '隐藏',
imageSrc: '/creation-type-references/hidden.webp',
visible: false,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
]);
const groups = groupVisiblePlatformCreationTypes(cards);
expect(groups.map((group) => group.label)).toEqual([
'最近创作',
'节日主题',
]);
expect(groups[0]?.items.map((item) => item.id)).toEqual([
'puzzle',
'match3d',
]);
expect(groups[1]?.items.map((item) => item.id)).toEqual(['visual-novel']);
});

View File

@@ -10,9 +10,23 @@ export type PlatformCreationTypeCard = {
badge: string;
imageSrc: string;
locked: boolean;
categoryId: string;
categoryLabel: string;
categorySortOrder: number;
sortOrder: number;
hidden?: boolean;
};
export type PlatformCreationTypeGroup = {
id: string;
label: string;
sortOrder: number;
items: PlatformCreationTypeCard[];
};
const FALLBACK_CREATION_CATEGORY_ID = 'recent';
const FALLBACK_CREATION_CATEGORY_LABEL = '最近创作';
export function getVisiblePlatformCreationTypes(
creationTypes: readonly PlatformCreationTypeCard[],
) {
@@ -41,6 +55,50 @@ export function isPlatformCreationTypeOpen(
);
}
function normalizeCategoryId(value: string) {
const normalized = value.trim();
return normalized || FALLBACK_CREATION_CATEGORY_ID;
}
function normalizeCategoryLabel(value: string) {
const normalized = value.trim();
return normalized || FALLBACK_CREATION_CATEGORY_LABEL;
}
export function groupVisiblePlatformCreationTypes(
creationTypes: readonly PlatformCreationTypeCard[],
): PlatformCreationTypeGroup[] {
const groups = new Map<string, PlatformCreationTypeGroup>();
for (const item of getVisiblePlatformCreationTypes(creationTypes)) {
const categoryId = normalizeCategoryId(item.categoryId);
const categoryLabel = normalizeCategoryLabel(item.categoryLabel);
const existing = groups.get(categoryId);
if (existing) {
existing.items.push(item);
if (item.categorySortOrder < existing.sortOrder) {
existing.sortOrder = item.categorySortOrder;
}
continue;
}
groups.set(categoryId, {
id: categoryId,
label: categoryLabel,
sortOrder: item.categorySortOrder,
items: [item],
});
}
return [...groups.values()].sort((left, right) => {
if (left.sortOrder !== right.sortOrder) {
return left.sortOrder - right.sortOrder;
}
return left.label.localeCompare(right.label, 'zh-Hans-CN');
});
}
/**
* 创作入口卡片只做展示派生;配置事实源来自后端 API / SpacetimeDB前端不再保留入口默认配置。
*/
@@ -56,6 +114,10 @@ export function derivePlatformCreationTypes(
badge: item.badge,
imageSrc: item.imageSrc,
locked: !item.open,
categoryId: normalizeCategoryId(item.categoryId),
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
categorySortOrder: item.categorySortOrder,
sortOrder: item.sortOrder,
hidden:
!item.visible ||
(item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()),

View File

@@ -36,6 +36,7 @@ export type SelectionStage =
| 'jump-hop-result'
| 'jump-hop-runtime'
| 'jump-hop-gallery-detail'
| 'bark-battle-workspace'
| 'bark-battle-generating'
| 'bark-battle-result'
| 'bark-battle-runtime'

View File

@@ -35,8 +35,8 @@ import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
readPublicWorkCodeFromLocationSearch,
resolveSelectionStageFromPath,
@@ -196,9 +196,30 @@ async function clickFirstAsyncButtonByName(
async function openCreateTemplateHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '创作');
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(screen.getByRole('tab', { name: '拼图' })).toBeTruthy();
expect(screen.getByText('拼图工作区missing-session')).toBeTruthy();
const panel = getPlatformTabPanel('create');
await waitFor(() => {
expect(panel.getAttribute('aria-hidden')).toBe('false');
});
expect(
await within(panel).findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
expect(
await within(panel).findByRole('button', { name: //u }),
).toBeTruthy();
expect(within(panel).queryByText('拼图工作区missing-session')).toBeNull();
return panel;
}
async function findCreationTypeButton(name: string | RegExp) {
const matcher =
typeof name === 'string' ? new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'u') : name;
return within(getPlatformTabPanel('create')).findByRole('button', { name: matcher });
}
function queryCreationTypeButton(name: string | RegExp) {
const matcher =
typeof name === 'string' ? new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'u') : name;
return within(getPlatformTabPanel('create')).queryByRole('button', { name: matcher });
}
async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
@@ -208,7 +229,7 @@ async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
expect(panel.getAttribute('aria-hidden')).toBe('false');
});
expect(
await within(panel).findByRole('button', { name: //u }),
await within(panel).findByRole('tab', { name: //u }),
).toBeTruthy();
}
@@ -276,6 +297,14 @@ const testCreationEntryConfig = {
title: '选择创作类型',
description: '先选玩法类型,再进入对应创作工作台。',
},
eventBanner: {
title: '泥点挑战',
description: '创作活动测试横幅。',
coverImageSrc: '/creation-type-references/puzzle.webp',
prizePoolMudPoints: 1000,
startsAtText: '2026-05-01',
endsAtText: '2026-05-31',
},
creationTypes: [
{
id: 'rpg',
@@ -286,6 +315,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -297,6 +329,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 30,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -308,6 +343,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 40,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -319,6 +357,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 45,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -330,6 +371,9 @@ const testCreationEntryConfig = {
visible: false,
open: true,
sortOrder: 50,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -341,6 +385,9 @@ const testCreationEntryConfig = {
visible: false,
open: false,
sortOrder: 60,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -352,6 +399,9 @@ const testCreationEntryConfig = {
visible: true,
open: false,
sortOrder: 70,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -363,6 +413,9 @@ const testCreationEntryConfig = {
visible: false,
open: true,
sortOrder: 80,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -374,6 +427,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 90,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
],
@@ -3345,81 +3401,80 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
await openCreateTemplateHub(user);
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(screen.getByRole('tablist', { name: '选择模板' }).className).toContain(
expect(screen.getByRole('tablist', { name: '玩法模板分类' })).toBeTruthy();
expect(
screen.getByRole('tablist', { name: '玩法模板分类' }).className,
).toContain(
'scroll-px-3',
);
expect(
screen.getByRole('tab', { name: '拼图' }).getAttribute('aria-selected'),
screen.getByRole('tab', { name: '最近创作' }).getAttribute('aria-selected'),
).toBe('true');
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
).toContain('/creation-type-references/puzzle.webp');
expect(
screen.getByRole('tab', { name: '文字冒险' }).querySelector('img')?.src,
).toContain('/creation-type-references/rpg.webp');
expect(
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
).toContain('/creation-type-references/match3d.webp');
expect(
screen.getByRole('tab', { name: '汪汪声浪' }).querySelector('img')?.src,
).toContain('/creation-type-references/bark-battle.webp');
expect(
screen.getByRole('tab', { name: '宝贝识物' }).querySelector('img')?.src,
).toContain('/child-motion-demo/picture-book-grass-stage.png');
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'),
await findCreationTypeButton('拼图'),
).toBeTruthy();
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-inherit'),
await findCreationTypeButton('文字冒险'),
).toBeTruthy();
expect(
await findCreationTypeButton('抓大鹅'),
).toBeTruthy();
expect(
await findCreationTypeButton('汪汪声浪'),
).toBeTruthy();
expect(
await findCreationTypeButton('宝贝识物'),
).toBeTruthy();
expect(
queryCreationTypeButton('智能创作'),
).toBeNull();
expect(
screen
.getByRole('tab', { name: '最近创作' })
.querySelector('[class*="bg-[#d9793f]"]'),
).toBeTruthy();
expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull();
expect(screen.queryByPlaceholderText('问一问陶泥儿')).toBeNull();
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
expect(screen.queryByRole('tab', { name: /方洞挑战/u })).toBeNull();
expect(screen.queryByRole('tab', { name: '视觉小说' })).toBeNull();
expect(screen.getByRole('tab', { name: /抓大鹅/u })).toBeTruthy();
expect(screen.getByRole('tab', { name: /汪汪声浪/u })).toBeTruthy();
expect(screen.getByRole('tab', { name: /宝贝识物/u })).toBeTruthy();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('create tab switches match3d into the embedded entry form', async () => {
test('create tab opens match3d entry form from the template card', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
expect(
screen.getByRole('tab', { name: '抓大鹅' }).getAttribute('aria-selected'),
).toBe('true');
expect(await screen.findByText('抓大鹅工作区missing-session')).toBeTruthy();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('create tab switches bark battle into the embedded config form', async () => {
test('create tab opens puzzle entry form from the template card', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await findCreationTypeButton('拼图'));
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('create tab opens bark battle entry form from the template card', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('汪汪声浪'));
expect(
screen.getByRole('tab', { name: '汪汪声浪' }).getAttribute('aria-selected'),
).toBe('true');
expect(await screen.findByText('汪汪声浪配置表单')).toBeTruthy();
expect(screen.getByTestId('bark-battle-editor-back-state').textContent).toBe(
'back-hidden',
);
expect(screen.getByTestId('bark-battle-editor-title-state').textContent).toBe(
'title-hidden',
);
expect(screen.queryByText('汪汪声浪运行态')).toBeNull();
expect(createBarkBattleDraft).not.toHaveBeenCalled();
expect(publishBarkBattleWork).not.toHaveBeenCalled();
@@ -3431,7 +3486,7 @@ test('bark battle draft result can test before publish and publish to work detai
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await findCreationTypeButton('汪汪声浪'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(createBarkBattleDraft).toHaveBeenCalledWith({
@@ -3525,7 +3580,7 @@ test('bark battle form checks mud points before creating image assets', async ()
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await findCreationTypeButton('汪汪声浪'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(
@@ -3547,7 +3602,7 @@ test('bark battle draft is visible in draft shelf while image assets are generat
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await findCreationTypeButton('汪汪声浪'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('自动生成素材')).toBeTruthy();
@@ -3596,7 +3651,7 @@ test('published bark battle stays visible when refresh temporarily returns only
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await findCreationTypeButton('汪汪声浪'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await waitFor(() => {
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
@@ -3811,7 +3866,17 @@ test('persisted generating match3d draft opens generation progress after refresh
'match3d-session-generating',
);
});
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
expect(
screen
.getByRole('progressbar', { name: '抓大鹅草稿生成进度' })
.getAttribute('aria-valuenow'),
).toBe('0');
expect(screen.getByText('0%')).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-generating',
@@ -4514,7 +4579,9 @@ test('match3d result back returns to platform creation page', async () => {
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
});
@@ -6788,7 +6855,9 @@ test('puzzle draft result back button returns to creation hub', async () => {
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
@@ -6825,14 +6894,15 @@ test('persisted generating puzzle draft opens generation progress after refresh'
},
],
});
vi.mocked(getPuzzleAgentSession).mockResolvedValueOnce({
session: buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-generating',
stage: 'collecting_anchors',
progressPercent: 42,
lastAssistantReply: '正在生成拼图草稿。',
updatedAt: '2026-05-18T12:00:00.000Z',
}),
const persistedGeneratingPuzzleSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-generating',
stage: 'collecting_anchors',
progressPercent: 88,
lastAssistantReply: '正在生成拼图草稿。',
updatedAt: '2026-05-18T12:00:00.000Z',
});
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
session: persistedGeneratingPuzzleSession,
});
render(<TestWrapper withAuth />);
@@ -6845,7 +6915,19 @@ test('persisted generating puzzle draft opens generation progress after refresh'
'puzzle-session-generating',
);
});
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '拼图草稿生成进度',
}),
).toBeTruthy();
expect(
Number(
screen
.getByRole('progressbar', { name: '拼图草稿生成进度' })
.getAttribute('aria-valuenow'),
),
).toBe(0);
expect(screen.getByText('0%')).toBeTruthy();
expect(screen.queryByText('拼图结果页')).toBeNull();
});
@@ -7844,7 +7926,9 @@ test('running custom world draft generation can return to creation center with s
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
await openDraftHub(user);
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
@@ -8892,7 +8976,9 @@ test('agent draft result back button returns to creation hub without syncing res
await user.click(screen.getByRole('button', { name: /返回创作/u }));
await waitFor(() => {
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(
screen.getByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
});
expect(
@@ -9215,14 +9301,16 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
render(<TestWrapper withAuth />);
await clickFirstButtonByName(user, '创作');
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
resolveGalleryRequest([]);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByRole('tablist', {
name: '选择模板',
name: '玩法模板分类',
}),
).toBeTruthy();
});

View File

@@ -413,6 +413,11 @@ const originalUserAgent = navigator.userAgent;
const originalMaxTouchPoints = navigator.maxTouchPoints;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const DEFAULT_PROFILE_CREATED_AT = '2026-04-01T00:00:00.000Z';
function buildFreshProfileCreatedAt() {
return new Date().toISOString();
}
function dispatchPointerEvent(
target: HTMLElement,
@@ -670,6 +675,33 @@ function mockWechatMobileLayout() {
});
}
function mockNarrowMobileLayout() {
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit Mobile',
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => {
const normalizedQuery = query.replace(/\s/g, '');
return {
matches:
normalizedQuery.includes('max-width:767px') ||
normalizedQuery.includes('max-width:768px'),
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
};
}),
});
}
function renderProfileView(
onRechargeSuccess = vi.fn(),
profileDashboardOverrides: Partial<
@@ -690,7 +722,7 @@ function renderProfileView(
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
createdAt: DEFAULT_PROFILE_CREATED_AT,
...userOverrides,
},
canAccessProtectedData: true,
@@ -1056,7 +1088,7 @@ afterEach(() => {
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
createdAt: DEFAULT_PROFILE_CREATED_AT,
});
mockQrCodeToDataUrl.mockResolvedValue('data:image/png;base64,QR');
mockRedirectToPaymentUrl.mockReset();
@@ -1094,7 +1126,9 @@ test('opens wallet ledger modal from narrative coin card', async () => {
const user = userEvent.setup();
renderProfileView();
await user.click(screen.getByRole('button', { name: /\s*0/u }));
await user.click(
screen.getByRole('button', { name: /\s*0/u }),
);
expect(await screen.findByText('泥点账单')).toBeTruthy();
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
@@ -1760,19 +1794,21 @@ test('profile native qr confirmation refreshes only after server reports paid',
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('non-wechat profile shows reward code instead of recharge entry', async () => {
test('non-wechat profile opens reward code from recharge-shaped entry', async () => {
const user = userEvent.setup();
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
within(shortcutRegion).queryByRole('button', { name: //u }),
).toBeNull();
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
await user.click(within(shortcutRegion).getByRole('button', { name: //u }));
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
expect(await screen.findByPlaceholderText('输入兑换码')).toBeTruthy();
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
});
@@ -1821,7 +1857,7 @@ test('profile played works card shows count unit', () => {
});
const playedCard = screen.getByRole('button', {
name: /\s*1/u,
name: /\s*1/u,
});
expect(within(playedCard).getByText('1个')).toBeTruthy();
@@ -1832,18 +1868,120 @@ test('profile stats cards are centered without update timestamp', () => {
updatedAt: '2026-05-03T08:01:00Z',
});
const walletCard = screen.getByRole('button', { name: /\s*0/u });
const playTimeCard = screen.getByRole('button', { name: //u });
const playedCard = screen.getByRole('button', { name: /\s*0/u });
const walletCard = screen.getByRole('button', {
name: /\s*0/u,
});
const playTimeCard = screen.getByRole('button', { name: /|/u });
const playedCard = screen.getByRole('button', { name: /\s*0/u });
for (const card of [walletCard, playTimeCard, playedCard]) {
expect(card.className).toContain('items-center');
expect(card.className).toContain('justify-center');
expect(card.className).toContain('platform-profile-stat-card');
expect(card.className).toContain('text-center');
}
expect(screen.queryByText(//u)).toBeNull();
});
test('mobile profile page matches the reference layout sections', async () => {
mockWechatMobileLayout();
const { container } = renderProfileView(vi.fn(), {
walletBalance: 70,
totalPlayTimeMs: 0,
playedWorldCount: 0,
}, { createdAt: buildFreshProfileCreatedAt() });
const profilePage = container.querySelector('.platform-profile-page');
expect(profilePage).toBeTruthy();
expect(profilePage?.classList.contains('platform-page-stage')).toBe(true);
expect(profilePage?.querySelector('.platform-profile-scene-decor')).toBeTruthy();
expect(profilePage?.classList.contains('platform-profile-page')).toBe(true);
expect(profilePage?.getAttribute('style') ?? '').not.toContain('overflow: hidden');
const membershipCard = screen.getByRole('button', { name: '查看权益' });
expect(membershipCard.className).toContain('platform-profile-membership-card');
expect(
within(membershipCard).getByText('普通用户').className,
).toContain('platform-profile-membership-card__title');
expect(within(membershipCard).getByText('普通用户')).toBeTruthy();
expect(within(membershipCard).getByText('升级会员,享专属特权与福利')).toBeTruthy();
const statPanel = screen.getByRole('region', { name: '我的数据' });
expect(statPanel.className).toContain('platform-profile-stats-panel');
expect(statPanel.querySelector('.platform-profile-stats-grid')).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*70/u })).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*0/u })).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*0/u })).toBeTruthy();
expect(
within(statPanel).getByRole('button', { name: /\s*70/u }).className,
).toContain('platform-profile-stat-card');
const dailyTask = screen.getByRole('button', { name: //u });
expect(dailyTask.className).toContain('platform-profile-daily-task-card');
expect(dailyTask.querySelector('.platform-profile-daily-task-card__title')).toBeTruthy();
expect(dailyTask.querySelector('.platform-profile-daily-task-card__desc')).toBeTruthy();
expect(dailyTask.querySelector('.platform-profile-daily-task-card__progress')).toBeTruthy();
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
expect(within(dailyTask).getByText('0 / 1')).toBeTruthy();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
shortcutRegion.querySelector('.platform-profile-shortcut-grid'),
).toBeTruthy();
expect(
shortcutRegion.querySelectorAll('.platform-profile-shortcut-button'),
).toHaveLength(5);
expect(
shortcutRegion
.querySelector('.platform-profile-shortcut-grid')
?.classList.contains('platform-profile-shortcut-grid'),
).toBe(true);
for (const label of [
'泥点充值',
'邀请好友',
'兑换码',
'玩家社区',
'反馈与建议',
]) {
expect(
within(shortcutRegion).getByRole('button', { name: new RegExp(label, 'u') }),
).toBeTruthy();
}
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
for (const label of ['主题设置', '账号与安全', '通用设置']) {
expect(
within(settingsRegion).getByRole('button', { name: new RegExp(label, 'u') }),
).toBeTruthy();
}
const secondaryShortcuts = screen.getByRole('region', {
name: '次级入口',
});
expect(
within(secondaryShortcuts).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
await within(secondaryShortcuts).findByRole('button', {
name: //u,
}),
).toBeTruthy();
const profileHeader = profilePage?.querySelector('.platform-profile-header');
expect(profileHeader).toBeTruthy();
expect(profileHeader?.querySelector('.platform-profile-header__identity-row')).toBeTruthy();
expect(profileHeader?.querySelector('.platform-profile-header__name')).toBeTruthy();
expect(profileHeader?.querySelector('.platform-profile-header__code')).toBeTruthy();
const legalRegion = screen.getByRole('region', { name: '法律信息' });
expect(legalRegion.className).toContain('platform-profile-legal-strip');
expect(legalRegion.textContent).toContain('用户协议');
expect(legalRegion.textContent).toContain('隐私政策');
expect(legalRegion.textContent).toContain('免责声明');
expect(legalRegion.textContent).toContain(ICP_RECORD_NUMBER);
expect(legalRegion.textContent).toContain('2026025677');
expect(legalRegion.querySelector('.platform-profile-legal-strip__link')).toBeTruthy();
});
test('desktop account entry uses saved avatar image when available', () => {
mockDesktopLayout();
const avatarUrl = 'data:image/png;base64,AAAA';
@@ -1886,27 +2024,33 @@ test('wallet ledger modal shows empty and error states', async () => {
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
renderProfileView();
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('泥点账单')).toBeTruthy();
await waitFor(() => {
expect(screen.getByText('暂无账单记录')).toBeTruthy();
});
await user.click(screen.getByLabelText('关闭泥点账单'));
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('加载失败')).toBeTruthy();
expect(await screen.findByText('泥点账单')).toBeTruthy();
await waitFor(() => {
expect(screen.getByText('加载失败')).toBeTruthy();
});
expect(screen.getByText('重新加载')).toBeTruthy();
});
test('profile invite shortcut shows reward subtitle and invited users', async () => {
const user = userEvent.setup();
renderProfileView();
renderProfileView(vi.fn(), {}, { createdAt: buildFreshProfileCreatedAt() });
const inviteButton = screen.getByRole('button', { name: //u });
expect(within(inviteButton).getByText('双方得30')).toBeTruthy();
expect(within(inviteButton).getByText('双方得 30 泥点')).toBeTruthy();
const communityButton = screen.getByRole('button', { name: //u });
expect(within(communityButton).getByText('每日领福利')).toBeTruthy();
expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy();
await user.click(inviteButton);
@@ -1922,21 +2066,25 @@ test('profile invite shortcut shows reward subtitle and invited users', async ()
});
test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => {
renderProfileView();
renderProfileView(
vi.fn(),
{},
{ createdAt: buildFreshProfileCreatedAt() },
);
const inviteButton = screen.getByRole('button', { name: //u });
const redeemButton = await screen.findByRole('button', {
name: //u,
});
const communityButton = screen.getByRole('button', { name: //u });
const secondaryShortcuts = screen.getByRole('region', {
name: '次级入口',
});
expect(inviteButton).toBeTruthy();
expect(communityButton).toBeTruthy();
expect(
inviteButton.compareDocumentPosition(redeemButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
redeemButton.compareDocumentPosition(communityButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
within(secondaryShortcuts).getByRole('button', { name: //u }),
).toBeTruthy();
expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy();
});
@@ -2006,7 +2154,11 @@ test('profile redeem invite modal submits code and hides shortcut after success'
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
renderProfileView(
onRechargeSuccess,
{},
{ createdAt: buildFreshProfileCreatedAt() },
);
await user.click(await screen.findByRole('button', { name: //u }));
const input = await screen.findByLabelText('邀请码');
@@ -2050,11 +2202,10 @@ test('profile page shows legal entries and ICP record link', async () => {
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'),
shortcutRegion
.querySelector('.platform-profile-shortcut-grid')
?.classList.contains('platform-profile-shortcut-grid'),
).toBe(true);
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
@@ -2064,6 +2215,24 @@ test('profile page shows legal entries and ICP record link', async () => {
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
const dailyTask = screen.getByRole('button', { name: //u });
expect(dailyTask).toBeTruthy();
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
expect(
within(settingsRegion).getByRole('button', { name: //u }),
).toBeTruthy();
const secondaryShortcuts = screen.getByRole('region', {
name: '次级入口',
});
expect(
within(secondaryShortcuts).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(secondaryShortcuts).queryByRole('button', { name: //u }),
).toBeNull();
const legalRegion = screen.getByRole('region', { name: '法律信息' });
expect(
@@ -2138,6 +2307,83 @@ test('logged in draft bottom tab shows unread marker', () => {
expect(draftButton.querySelector('.platform-nav-unread-dot')).toBeTruthy();
});
test('logged in create tab shows real wallet balance beside the brand', () => {
mockNarrowMobileLayout();
const { container } = render(
<AuthUiContext.Provider
value={{
user: {
id: 'user-1',
publicUserCode: '100001',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: DEFAULT_PROFILE_CREATED_AT,
},
canAccessProtectedData: true,
openLoginModal: vi.fn(),
requireAuth: (action) => action(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}}
>
<RpgEntryHomeView
activeTab="create"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[]}
myEntries={[]}
historyEntries={[]}
profileDashboard={{
walletBalance: 1234,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: null,
}}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
createTabContent={<div></div>}
/>
</AuthUiContext.Provider>,
);
const topbar = container.querySelector('.platform-mobile-topbar');
expect(topbar).toBeTruthy();
expect(
topbar?.querySelector('.platform-mobile-create-wallet-chip'),
).toBeTruthy();
expect(topbar?.textContent).toContain('陶泥儿');
expect(topbar?.textContent).toContain('1,234泥点');
});
test('mobile discover search submits public work code', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
@@ -2248,6 +2494,15 @@ test('mobile discover keeps edutainment works in the last dedicated channel only
throw new Error('缺少发现面板');
}
const discoverStage = discoverPanel.querySelector(
'.platform-mobile-home-stage',
);
expect(discoverStage).toBeTruthy();
expect(discoverStage?.classList.contains('platform-remap-surface')).toBe(
true,
);
expect(discoverStage?.classList.contains('platform-page-stage')).toBe(false);
const channels = Array.from(
discoverPanel.querySelectorAll('.platform-mobile-home-channel'),
).map((button) => button.textContent);
@@ -3117,7 +3372,6 @@ test('desktop logged in home syncs mobile home modules without square or latest
expect(screen.queryByText('作品广场')).toBeNull();
expect(screen.queryByText('公开作品')).toBeNull();
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
});

View File

@@ -11,7 +11,7 @@ import {
Coins,
Compass,
Copy,
FileText,
Crown,
Gamepad2,
GitFork,
Heart,
@@ -20,9 +20,11 @@ import {
Palette,
Pencil,
Plus,
ScanLine,
Search,
Settings,
Share2,
ShieldCheck,
SlidersHorizontal,
Sparkles,
Star,
@@ -45,6 +47,16 @@ import {
useState,
} from 'react';
import profileClockImage from '../../../media/profile/_Image (1).png';
import profileGamepadImage from '../../../media/profile/_Image (2).png';
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
import profileCoinsImage from '../../../media/profile/_Image (4).png';
import profileInviteImage from '../../../media/profile/_Image (5).png';
import profileGiftImage from '../../../media/profile/_Image (6).png';
import profileCommunityImage from '../../../media/profile/_Image (7).png';
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
import profileMascotImage from '../../../media/profile/_Image (9).png';
import profilePointImage from '../../../media/profile/_Image.png';
import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
@@ -215,8 +227,12 @@ const MOBILE_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
const MOBILE_RECOMMEND_PAGE_STAGE_CLASS =
'platform-page-stage min-w-0 space-y-4 overflow-hidden pb-2';
const MOBILE_DISCOVER_PAGE_STAGE_CLASS =
'platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
const DESKTOP_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4';
const DESKTOP_DISCOVER_PAGE_STAGE_CLASS =
'platform-remap-surface min-w-0 space-y-5 pb-4';
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'home',
@@ -2384,12 +2400,14 @@ function ProfileStatCard({
value,
onClick,
icon,
imageSrc,
}: {
cardKey: ProfileDashboardCardKey;
label: string;
value: string;
onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null;
icon: ComponentType<{ className?: string }>;
imageSrc?: string;
}) {
const Icon = icon;
@@ -2397,16 +2415,23 @@ function ProfileStatCard({
<button
type="button"
onClick={onClick ? () => onClick(cardKey) : undefined}
className="platform-subpanel flex min-h-[5.75rem] flex-col items-center justify-center rounded-[1.35rem] px-3 py-3 text-center transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
aria-label={`${label} ${value}`}
className="platform-profile-stat-card flex min-h-[5.75rem] items-center justify-center gap-2 px-3 py-3 text-center transition"
>
<div className="flex w-full items-center justify-center gap-2 text-[var(--platform-text-soft)]">
<Icon className="h-4 w-4" />
<span className="whitespace-nowrap text-[11px] tracking-[0.16em]">
{label}
</span>
<div className="platform-profile-stat-card__icon">
{imageSrc ? (
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
) : (
<Icon className="h-5 w-5" />
)}
</div>
<div className="mt-2 whitespace-nowrap text-lg font-black leading-none text-[var(--platform-text-strong)]">
{value}
<div className="min-w-0 text-left">
<div className="platform-profile-stat-card__value whitespace-nowrap text-lg font-black leading-none text-[var(--platform-text-strong)]">
{value}
</div>
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[12px] font-medium text-[var(--platform-text-soft)]">
{label}
</div>
</div>
</button>
);
@@ -2426,11 +2451,13 @@ function ProfileShortcutButton({
subLabel,
icon,
onClick,
imageSrc,
}: {
label: string;
subLabel?: ReactNode;
icon: ComponentType<{ className?: string }>;
onClick?: (() => void) | null;
imageSrc?: string;
}) {
const Icon = icon;
@@ -2438,16 +2465,20 @@ function ProfileShortcutButton({
<button
type="button"
onClick={onClick ?? undefined}
className="platform-subpanel flex min-h-[5.25rem] flex-col items-center justify-center gap-2 rounded-[1.2rem] px-3 py-3 text-center transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
className="platform-profile-shortcut-button flex min-h-[5.25rem] flex-col items-center justify-center gap-2 px-2.5 py-3 text-center transition"
>
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
<Icon className="h-[1.125rem] w-[1.125rem]" />
<div className="platform-profile-shortcut-button__icon">
{imageSrc ? (
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
) : (
<Icon className="h-[1.125rem] w-[1.125rem]" />
)}
</div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[13px] font-semibold text-[var(--platform-text-strong)]">
{label}
</div>
{subLabel ? (
<div className="flex min-h-4 items-center justify-center gap-1 text-[11px] font-semibold text-[var(--platform-text-soft)]">
<div className="platform-profile-shortcut-button__sub-label flex min-h-4 items-center justify-center gap-1 whitespace-nowrap text-[11px] font-medium text-[var(--platform-text-soft)]">
{subLabel}
</div>
) : null}
@@ -2455,6 +2486,72 @@ function ProfileShortcutButton({
);
}
function ProfileSettingsRow({
label,
icon,
onClick,
}: {
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
}) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick}
className="platform-profile-settings-row flex w-full items-center justify-between gap-3 px-4 py-4 text-left transition"
>
<span className="flex min-w-0 items-center gap-3">
<span className="platform-profile-settings-row__icon">
<Icon className="h-5 w-5" />
</span>
<span className="truncate text-[15px] font-semibold text-[var(--platform-text-strong)]">
{label}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
</button>
);
}
function ProfileSecondaryShortcutButton({
label,
subLabel,
icon,
onClick,
}: {
label: string;
subLabel?: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
}) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick}
className="platform-profile-secondary-shortcut inline-flex items-center gap-2 rounded-full px-3 py-2 text-left"
>
<span className="platform-profile-secondary-shortcut__icon">
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block truncate text-[13px] font-semibold text-[var(--platform-text-strong)]">
{label}
</span>
{subLabel ? (
<span className="mt-0.5 block truncate text-[11px] font-medium text-[var(--platform-text-soft)]">
{subLabel}
</span>
) : null}
</span>
</button>
);
}
function ProfileLegalSection({
onOpenDocument,
}: {
@@ -2462,33 +2559,21 @@ function ProfileLegalSection({
}) {
return (
<section
className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}
className="platform-profile-legal-strip"
aria-label="法律信息"
>
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="platform-subpanel overflow-hidden rounded-[1.25rem]">
<div className="platform-profile-legal-strip__links">
{LEGAL_DOCUMENTS.map((document, index) => (
<button
key={document.id}
type="button"
onClick={() => onOpenDocument(document.id)}
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--platform-button-secondary-fill)] ${
index > 0
? 'border-t border-[var(--platform-subpanel-border)]'
: ''
}`}
className="platform-profile-legal-strip__link"
>
<span className="flex min-w-0 items-center gap-3">
<span className="platform-profile-chip flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
<FileText className="h-4 w-4" />
</span>
<span className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{document.title}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
{document.title}
{index < LEGAL_DOCUMENTS.length - 1 ? (
<span aria-hidden="true" className="platform-profile-legal-strip__divider" />
) : null}
</button>
))}
</div>
@@ -2496,7 +2581,7 @@ function ProfileLegalSection({
href={ICP_RECORD_URL}
target="_blank"
rel="noreferrer"
className="mt-3 block text-center text-xs font-semibold text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)]"
className="platform-profile-legal-strip__record"
>
{ICP_RECORD_NUMBER}
</a>
@@ -5379,7 +5464,7 @@ export function RpgEntryHomeView({
);
const mobileDiscoverContent: ReactNode = (
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
<div className={`${MOBILE_DISCOVER_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
<PublicCodeSearchBar
value={mobileSearchKeyword}
onChange={updateMobileSearchKeyword}
@@ -5594,7 +5679,7 @@ export function RpgEntryHomeView({
);
const desktopDiscoverContent: ReactNode = (
<div className={DESKTOP_PAGE_STAGE_CLASS}>
<div className={DESKTOP_DISCOVER_PAGE_STAGE_CLASS}>
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
{visibleDiscoverChannels.map((channel) => {
const active = discoverChannel === channel.id;
@@ -5849,30 +5934,55 @@ export function RpgEntryHomeView({
const savesContent: ReactNode = draftTabContent ?? fallbackDraftContent;
const profileContent: ReactNode = (
<div className={MOBILE_PAGE_STAGE_CLASS}>
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-profile-page`}>
{authUi?.user ? (
<>
<section className="platform-profile-hero rounded-[1.8rem] p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<section className="platform-profile-header">
<div className="platform-profile-header__actions">
<button
type="button"
onClick={openRechargeOrRewardCodeModal}
className="platform-profile-header__icon-button"
aria-label="打开充值入口"
>
<ScanLine className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => authUi.openSettingsModal()}
className="platform-profile-header__icon-button"
aria-label="打开设置"
>
<Settings className="h-5 w-5" />
</button>
</div>
<img
src={profileStillLifeImage}
alt=""
className="platform-profile-scene-decor"
/>
<div className="platform-profile-header__identity">
<div className="platform-profile-header__identity-row flex min-w-0 items-center gap-4">
<button
type="button"
onClick={openAvatarPicker}
className="platform-profile-avatar relative h-16 w-16 shrink-0 rounded-[1.4rem]"
className="platform-profile-avatar relative h-[5.15rem] w-[5.15rem] shrink-0 rounded-full"
aria-label="上传头像"
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full rounded-[1.4rem] object-cover"
className="h-full w-full rounded-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-2xl font-black">
{avatarLabel}
</span>
<img
src={profileMascotImage}
alt=""
className="h-full w-full rounded-full object-cover"
/>
)}
<span className="platform-profile-camera absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full">
<span className="platform-profile-camera absolute bottom-0 right-0 flex h-7 w-7 items-center justify-center rounded-full">
<Camera className="h-3.5 w-3.5" />
</span>
</button>
@@ -5887,28 +5997,27 @@ export function RpgEntryHomeView({
}
/>
<div className="min-w-0">
<div className="platform-profile-header__text min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-xl font-black text-[var(--platform-text-strong)]">
<div className="platform-profile-header__name truncate text-[20px] font-black leading-tight text-[var(--platform-text-strong)]">
{authUi.user.displayName}
</div>
<button
type="button"
onClick={openNicknameModal}
className="platform-profile-icon-button flex h-7 w-7 items-center justify-center rounded-full"
className="platform-profile-edit-button"
aria-label="修改昵称"
>
<Pencil className="h-3.5 w-3.5" />
<Pencil className="h-5 w-5" />
</button>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--platform-text-soft)]">
<span> {publicUserCode}</span>
<div className="platform-profile-header__code mt-3 flex flex-wrap items-center gap-2 text-[13px] text-[var(--platform-text-base)]">
<span> {publicUserCode}</span>
<button
type="button"
onClick={copyProfilePublicUserCode}
className="platform-profile-chip flex items-center gap-1 rounded-full px-2 py-1"
className="platform-profile-copy-button"
>
<Copy className="h-3 w-3" />
{profileCopyState === 'copied'
? '已复制'
: profileCopyState === 'failed'
@@ -5918,32 +6027,33 @@ export function RpgEntryHomeView({
</div>
</div>
</div>
<button
type="button"
onClick={openRechargeOrRewardCodeModal}
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
>
{showRechargeEntry ? (
<Coins className="h-4 w-4" />
) : (
<Ticket className="h-4 w-4" />
)}
<div>
<div className="text-xs font-bold">
{showRechargeEntry ? '充值' : '兑换码'}
</div>
<div className="text-[10px] opacity-80">
{showRechargeEntry ? '泥点/会员' : '福利奖励'}
</div>
</div>
<ChevronRight className="h-4 w-4 opacity-80" />
</button>
</div>
</section>
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<div className="grid grid-cols-3 gap-3">
<button
type="button"
onClick={openRechargeOrRewardCodeModal}
className="platform-profile-membership-card"
aria-label="查看权益"
>
<span className="platform-profile-membership-card__badge">
<Crown className="platform-profile-membership-card__crown" />
</span>
<span className="min-w-0 flex-1">
<span className="platform-profile-membership-card__title block text-[18px] font-black leading-tight text-white">
</span>
<span className="platform-profile-membership-card__subtitle mt-2 block text-[13px] font-medium text-white/92">
</span>
</span>
<span className="platform-profile-membership-card__action">
</span>
</button>
<section className="platform-profile-stats-panel" aria-label="我的数据">
<div className="platform-profile-stats-grid grid grid-cols-3 gap-3">
{isLoadingDashboard ? (
<>
<ProfileStatCardSkeleton />
@@ -5954,23 +6064,26 @@ export function RpgEntryHomeView({
<>
<ProfileStatCard
cardKey="wallet"
label="泥点"
label="泥点余额"
value="暂不可用"
icon={Coins}
imageSrc={profilePointImage}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
label="游戏时长"
label="累计游戏时长"
value="暂不可用"
icon={Clock3}
imageSrc={profileClockImage}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="玩过"
label="已玩游戏数量"
value="暂不可用"
icon={BookOpen}
imageSrc={profileGamepadImage}
onClick={onOpenProfileDashboardCard}
/>
</>
@@ -5978,23 +6091,26 @@ export function RpgEntryHomeView({
<>
<ProfileStatCard
cardKey="wallet"
label="泥点"
label="泥点余额"
value={formatDashboardCount(remainingNarrativeCoins)}
icon={Coins}
imageSrc={profilePointImage}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
label="游戏时长"
label="累计游戏时长"
value={totalPlayTime}
icon={Clock3}
imageSrc={profileClockImage}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="玩过"
label="已玩游戏数量"
value={`${formatDashboardCount(playedWorkCount)}`}
icon={BookOpen}
imageSrc={profileGamepadImage}
onClick={onOpenProfileDashboardCard}
/>
</>
@@ -6002,101 +6118,125 @@ export function RpgEntryHomeView({
</div>
</section>
<button
type="button"
onClick={openTaskCenterPanel}
className="platform-profile-daily-task-card"
>
<span className="min-w-0 flex-1">
<span className="platform-profile-daily-task-card__title block text-[15px] font-black text-[var(--platform-text-strong)]">
</span>
<span className="platform-profile-daily-task-card__desc mt-4 block text-[13px] font-medium text-[var(--platform-text-base)]">
<span className="text-[#c45b2a]">10</span>
</span>
<span className="platform-profile-daily-task-card__progress mt-4 flex items-center gap-3">
<span className="platform-profile-daily-task-card__progress-value text-[14px] font-semibold text-[#dc3f0e]">
0 / 1
</span>
<span className="platform-profile-daily-task-card__track">
<span className="platform-profile-daily-task-card__bar" />
</span>
</span>
</span>
<img
src={profileMascotImage}
alt=""
className="platform-profile-daily-task-card__mascot"
/>
<span className="platform-profile-daily-task-card__action">
</span>
</button>
<section
className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}
className="platform-profile-shortcut-panel"
aria-label="常用功能"
>
<div className="grid grid-cols-3 gap-3">
<div className="platform-profile-shortcut-grid">
<ProfileShortcutButton
label="每日任务"
subLabel={
<>
<span>10</span>
<Coins className="h-3 w-3" />
</>
}
icon={Star}
onClick={openTaskCenterPanel}
/>
<ProfileShortcutButton
label={showRechargeEntry ? '充值' : '兑换码'}
subLabel={showRechargeEntry ? '泥点/会员' : '福利奖励'}
icon={showRechargeEntry ? Coins : Ticket}
label="泥点充值"
subLabel="充值泥点"
icon={Coins}
imageSrc={profileCoinsImage}
onClick={openRechargeOrRewardCodeModal}
/>
<ProfileShortcutButton
label="存档"
subLabel={
saveEntries.length > 0
? `${saveEntries.length}个可继续`
: '继续游玩'
}
icon={Archive}
onClick={() => setProfilePopupPanel('saveArchives')}
/>
{showRechargeEntry ? (
<ProfileShortcutButton
label="兑换码"
subLabel="福利奖励"
icon={Ticket}
onClick={openRewardCodeModal}
/>
) : null}
<ProfileShortcutButton
label="邀请好友"
subLabel={
<>
<span>30</span>
<Coins className="h-3 w-3" />
</>
}
subLabel="双方得 30 泥点"
icon={UserPlus}
imageSrc={profileInviteImage}
onClick={() => openProfilePopupPanel('invite')}
/>
{canShowReferralRedeemShortcut ? (
<ProfileShortcutButton
label="填邀请码"
subLabel="新用户奖励"
icon={Ticket}
onClick={() => openProfilePopupPanel('redeem')}
/>
) : null}
<ProfileShortcutButton
label="兑换码"
subLabel="领取福利"
icon={Ticket}
imageSrc={profileGiftImage}
onClick={openRewardCodeModal}
/>
<ProfileShortcutButton
label="玩家社区"
subLabel="每日领福利"
subLabel="交流心得 领取福利"
icon={MessageCircle}
imageSrc={profileCommunityImage}
onClick={() => openProfilePopupPanel('community')}
/>
<ProfileShortcutButton
label="反馈"
subLabel="问题与建议"
label="反馈与建议"
subLabel="帮助我们做得更好"
icon={MessageCircle}
imageSrc={profileFeedbackImage}
onClick={onOpenFeedback}
/>
</div>
</section>
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<button
type="button"
<section className="platform-profile-settings-panel" aria-label="设置入口">
<ProfileSettingsRow
label="主题设置"
icon={Palette}
onClick={() => authUi.openSettingsModal('appearance')}
/>
<ProfileSettingsRow
label="账号与安全"
icon={ShieldCheck}
onClick={() => authUi.openSettingsModal('account')}
/>
<ProfileSettingsRow
label="通用设置"
icon={Settings}
onClick={() => authUi.openSettingsModal()}
className="platform-subpanel platform-interactive-card flex w-full items-center justify-between gap-3 rounded-[1.25rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
>
<div className="flex items-center gap-3">
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
<Settings className="h-[1.125rem] w-[1.125rem]" />
</div>
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="text-xs text-[var(--platform-text-soft)]">
</div>
</div>
</div>
<ChevronRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
</button>
/>
<ProfileSettingsRow
label="存档"
icon={Archive}
onClick={() => setProfilePopupPanel('saveArchives')}
/>
</section>
<section
className="platform-profile-secondary-shortcuts"
aria-label="次级入口"
>
<ProfileSecondaryShortcutButton
label="存档"
subLabel={
saveEntries.length > 0
? `${saveEntries.length}个可继续`
: '继续游玩'
}
icon={Archive}
onClick={() => setProfilePopupPanel('saveArchives')}
/>
{canShowReferralRedeemShortcut ? (
<ProfileSecondaryShortcutButton
label="填邀请码"
subLabel="新用户奖励"
icon={Ticket}
onClick={() => openProfilePopupPanel('redeem')}
/>
) : null}
</section>
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
@@ -6576,7 +6716,19 @@ export function RpgEntryHomeView({
{!isMobileRecommendTab ? (
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
<RpgEntryBrandLogo />
{!isAuthenticated ? (
{isAuthenticated && activeTab === 'create' ? (
<button
type="button"
onClick={openUserSurface}
className="platform-mobile-create-wallet-chip inline-flex shrink-0 items-center gap-1.5 rounded-full border border-[#f0cfae] bg-[#fff5eb] px-2.5 py-1.5 text-xs font-black text-[#b65f2c] shadow-[0_10px_22px_rgba(174,111,73,0.12)]"
aria-label={`${formatDashboardCount(remainingNarrativeCoins)}泥点`}
>
<span className="grid h-6 w-6 place-items-center rounded-full bg-[#ffe0ab] text-[#cf7b34]">
<Coins className="h-3.5 w-3.5" />
</span>
<span>{formatDashboardCount(remainingNarrativeCoins)}</span>
</button>
) : !isAuthenticated ? (
<button
type="button"
onClick={openUserSurface}
@@ -6724,6 +6876,19 @@ export function RpgEntryHomeView({
</div>
<div className="flex items-center gap-3">
{isAuthenticated && activeTab === 'create' ? (
<button
type="button"
onClick={openUserSurface}
className="platform-desktop-create-wallet-chip platform-desktop-search inline-flex items-center gap-2 px-3 py-2.5 text-xs font-black text-[#b65f2c]"
aria-label={`${formatDashboardCount(remainingNarrativeCoins)}泥点`}
>
<span className="grid h-7 w-7 place-items-center rounded-full bg-[#ffe0ab] text-[#cf7b34]">
<Coins className="h-3.5 w-3.5" />
</span>
<span>{formatDashboardCount(remainingNarrativeCoins)}</span>
</button>
) : null}
<button
type="button"
onClick={openUserSurface}

View File

@@ -571,6 +571,8 @@ body {
--platform-button-danger-border: rgba(185, 75, 58, 0.22);
--platform-button-danger-fill: rgba(255, 237, 229, 0.94);
--platform-button-danger-text: #a6402f;
--platform-unread-dot-fill: #8b5a3d;
--platform-unread-dot-glow: rgba(139, 90, 61, 0.34);
--platform-success-border: rgba(73, 144, 96, 0.24);
--platform-success-bg: rgba(237, 248, 239, 0.92);
--platform-success-text: #2f7b46;
@@ -837,6 +839,8 @@ body {
--platform-button-danger-border: rgba(251, 113, 133, 0.2);
--platform-button-danger-fill: rgba(244, 63, 94, 0.1);
--platform-button-danger-text: rgb(255 228 230);
--platform-unread-dot-fill: #d6a27c;
--platform-unread-dot-glow: rgba(214, 162, 124, 0.24);
--platform-success-border: rgba(52, 211, 153, 0.24);
--platform-success-bg: rgba(16, 185, 129, 0.1);
--platform-success-text: rgb(220 252 231);
@@ -1469,6 +1473,32 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
gap: 0.72rem;
}
.platform-mobile-home-channel {
position: relative;
min-height: 2rem;
border: 0;
background: transparent;
color: var(--platform-text-soft);
font-size: 0.92rem;
font-weight: 700;
white-space: nowrap;
}
.platform-mobile-home-channel--active {
color: var(--platform-text-strong);
}
.platform-mobile-home-channel--active::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0.1rem;
height: 0.16rem;
border-radius: 9999px;
background: var(--platform-warm-text);
}
.platform-category-filter-row {
display: flex;
min-width: 0;
@@ -2119,10 +2149,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
width: 0.6rem;
height: 0.6rem;
border-radius: 9999px;
background: #b64a35;
background: var(--platform-unread-dot-fill);
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.26),
0 0 12px rgba(239, 68, 68, 0.68);
0 0 12px var(--platform-unread-dot-glow);
}
@keyframes creation-work-card-spinner {
@@ -2626,10 +2656,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
width: 0.48rem;
height: 0.48rem;
border-radius: 9999px;
background: #b64a35;
background: var(--platform-unread-dot-fill);
box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.28),
0 0 12px rgba(239, 68, 68, 0.72);
0 0 12px var(--platform-unread-dot-glow);
}
.platform-bottom-nav__label,
@@ -4689,6 +4719,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
max-width: calc(100vw - 1rem);
}
.platform-profile-page {
overflow: visible;
padding-bottom: calc(var(--platform-bottom-dock-outer-height) + 0.35rem);
}
.platform-mobile-home-stage .platform-surface--hero {
max-width: 100%;
}
@@ -5632,6 +5667,587 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
filter: brightness(1.02);
}
.platform-profile-page {
gap: 0.75rem;
}
.platform-profile-header {
position: relative;
overflow: hidden;
padding: 1.05rem 0.95rem 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.62);
border-radius: 1.8rem;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.96),
rgba(250, 241, 232, 0.93)
);
box-shadow: 0 20px 50px rgba(112, 57, 30, 0.12);
}
.platform-profile-header__actions {
position: absolute;
right: 0.8rem;
top: 0.72rem;
z-index: 2;
display: flex;
align-items: center;
gap: 0.85rem;
}
.platform-profile-header__icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.2rem;
height: 2.2rem;
border: 0;
border-radius: 9999px;
background: transparent;
color: #1e120c;
}
.platform-profile-scene-decor {
position: absolute;
right: 0.15rem;
bottom: 0.1rem;
width: min(44vw, 12rem);
max-width: 12rem;
opacity: 0.98;
pointer-events: none;
user-select: none;
}
.platform-profile-header__identity {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
padding-top: 2.6rem;
padding-right: 6.75rem;
}
.platform-profile-edit-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.85rem;
height: 1.85rem;
border: 1px solid rgba(210, 185, 166, 0.7);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.78);
color: #6e5a4e;
}
.platform-profile-copy-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 1.8rem;
padding: 0 0.78rem;
border: 1px solid rgba(230, 192, 160, 0.9);
border-radius: 9999px;
background: rgba(255, 252, 248, 0.9);
color: #7f4f31;
font-size: 11px;
font-weight: 700;
}
.platform-profile-membership-card {
position: relative;
display: flex;
align-items: center;
gap: 0.85rem;
width: 100%;
min-height: 6.7rem;
padding: 1rem 1rem 1rem 0.95rem;
border: 0;
border-radius: 1.55rem;
background: linear-gradient(135deg, #eaa06a, #cf7a4a 58%, #b55c3b);
color: white;
text-align: left;
box-shadow: 0 18px 38px rgba(189, 103, 60, 0.22);
}
.platform-profile-membership-card__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
flex: none;
border-radius: 1.1rem;
background: rgba(255, 245, 233, 0.26);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
}
.platform-profile-membership-card__crown {
width: 1.9rem;
height: 1.9rem;
}
.platform-profile-membership-card__action {
flex: none;
padding: 0.92rem 1.05rem;
border: 1px solid rgba(255, 250, 244, 0.88);
border-radius: 9999px;
color: white;
font-size: 13px;
font-weight: 700;
}
.platform-profile-stats-panel,
.platform-profile-shortcut-panel,
.platform-profile-settings-panel,
.platform-profile-legal-strip {
overflow: hidden;
border: 1px solid rgba(235, 221, 208, 0.82);
border-radius: 1.45rem;
background: rgba(255, 255, 255, 0.74);
box-shadow: 0 10px 28px rgba(112, 57, 30, 0.06);
}
.platform-profile-stats-panel {
padding: 1rem 0.9rem;
}
.platform-profile-stat-card {
display: flex;
min-width: 0;
border: 0;
border-radius: 1rem;
background: transparent;
}
.platform-profile-stat-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.8rem;
height: 2.8rem;
flex: none;
border-radius: 9999px;
background: rgba(255, 243, 230, 0.9);
color: #bc5f34;
}
.platform-profile-daily-task-card {
display: flex;
align-items: center;
gap: 0.85rem;
width: 100%;
min-height: 8rem;
padding: 1rem 1rem 1rem 1.05rem;
border: 1px solid rgba(235, 221, 208, 0.82);
border-radius: 1.55rem;
background: rgba(255, 250, 246, 0.9);
text-align: left;
box-shadow: 0 10px 28px rgba(112, 57, 30, 0.06);
}
.platform-profile-daily-task-card__track {
display: inline-flex;
width: 9rem;
height: 0.45rem;
overflow: hidden;
border-radius: 9999px;
background: rgba(240, 226, 214, 0.88);
}
.platform-profile-daily-task-card__bar {
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #e47631, #ce5f2a);
}
.platform-profile-daily-task-card__mascot {
width: 7.4rem;
height: auto;
align-self: end;
margin-bottom: -0.2rem;
}
.platform-profile-daily-task-card__action {
flex: none;
padding: 0.85rem 1.15rem;
border-radius: 9999px;
background: linear-gradient(135deg, #f08b44, #e56a27);
color: white;
font-size: 14px;
font-weight: 700;
box-shadow: 0 12px 24px rgba(229, 106, 39, 0.24);
}
.platform-profile-shortcut-panel {
padding: 0.95rem 0.85rem 1rem;
}
.platform-profile-shortcut-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.25rem;
}
.platform-profile-shortcut-button {
border: 0;
border-radius: 0.95rem;
background: transparent;
}
.platform-profile-shortcut-button__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.65rem;
height: 2.65rem;
border-radius: 9999px;
background: linear-gradient(
135deg,
rgba(255, 239, 226, 0.96),
rgba(245, 202, 166, 0.92)
);
box-shadow: 0 10px 20px rgba(201, 123, 73, 0.15);
color: #c76a38;
}
.platform-profile-secondary-shortcuts {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.platform-profile-secondary-shortcut {
border: 1px solid rgba(232, 214, 201, 0.82);
background: rgba(255, 252, 248, 0.96);
color: var(--platform-text-strong);
box-shadow: 0 8px 18px rgba(112, 57, 30, 0.05);
}
.platform-profile-secondary-shortcut__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
flex: none;
border-radius: 9999px;
background: rgba(255, 237, 222, 0.95);
color: #bf673b;
}
.platform-profile-settings-panel {
padding: 0.2rem 0;
}
.platform-profile-settings-row {
border: 0;
background: transparent;
}
.platform-profile-settings-row + .platform-profile-settings-row {
border-top: 1px solid rgba(235, 221, 208, 0.82);
}
.platform-profile-settings-row__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.35rem;
height: 2.35rem;
flex: none;
border-radius: 9999px;
background: rgba(255, 245, 234, 0.95);
color: #3b2a20;
}
.platform-profile-legal-strip {
padding: 0.55rem 0.75rem 0.8rem;
text-align: center;
}
.platform-profile-legal-strip__links {
display: flex;
align-items: stretch;
justify-content: center;
gap: 0;
}
.platform-profile-legal-strip__link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.2rem 0.35rem;
border: 0;
background: transparent;
color: var(--platform-text-strong);
font-size: 13px;
font-weight: 500;
}
.platform-profile-legal-strip__divider {
width: 1px;
margin: 0.2rem 0.45rem;
background: rgba(229, 206, 190, 0.88);
}
.platform-profile-legal-strip__record {
display: block;
margin-top: 0.35rem;
color: #6e6a67;
font-size: 12px;
font-weight: 500;
}
@media (max-width: 639px) {
.platform-profile-page {
gap: 0.82rem;
}
.platform-profile-header {
padding: 0.95rem 0.85rem 0.82rem;
border-radius: 1.4rem;
}
.platform-profile-header__actions {
right: 0.64rem;
top: 0.6rem;
gap: 0.5rem;
}
.platform-profile-header__identity {
padding-top: 2.45rem;
padding-right: 4.9rem;
}
.platform-profile-header__identity-row {
align-items: flex-start;
gap: 0.78rem;
}
.platform-profile-header__text {
min-width: 0;
flex: 1 1 auto;
}
.platform-profile-header__name {
font-size: clamp(1rem, 4.8vw, 1.2rem);
line-height: 1.15;
}
.platform-profile-header__code {
margin-top: 0.55rem;
font-size: 11px;
line-height: 1.4;
}
.platform-profile-header__icon-button {
width: 1.78rem;
height: 1.78rem;
}
.platform-profile-scene-decor {
right: -0.08rem;
bottom: 0;
width: min(39vw, 8.8rem);
}
.platform-profile-membership-card {
min-height: 5.85rem;
padding: 0.78rem 0.82rem;
gap: 0.72rem;
}
.platform-profile-membership-card__badge {
width: 3.2rem;
height: 3.2rem;
}
.platform-profile-membership-card__title {
font-size: 1rem;
}
.platform-profile-membership-card__subtitle {
margin-top: 0.4rem;
font-size: 12px;
line-height: 1.45;
}
.platform-profile-membership-card__action {
padding: 0.64rem 0.78rem;
font-size: 11px;
}
.platform-profile-stats-panel,
.platform-profile-shortcut-panel,
.platform-profile-settings-panel,
.platform-profile-legal-strip {
border-radius: 1.12rem;
}
.platform-profile-stats-panel {
padding: 0.72rem 0.66rem;
}
.platform-profile-stats-grid {
gap: 0.45rem;
}
.platform-profile-stat-card {
min-height: 4.95rem;
align-items: center;
gap: 0.56rem;
padding: 0.55rem 0.42rem;
}
.platform-profile-stat-card__icon {
width: 2.35rem;
height: 2.35rem;
}
.platform-profile-stat-card__value {
font-size: 0.95rem;
line-height: 1.08;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.platform-profile-stat-card__label {
margin-top: 0.22rem;
font-size: 11px;
line-height: 1.18;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.platform-profile-daily-task-card {
min-height: 6.55rem;
padding: 0.8rem 0.8rem 0.8rem 0.86rem;
border-radius: 1.12rem;
gap: 0.7rem;
}
.platform-profile-daily-task-card__track {
width: min(7rem, 52vw);
}
.platform-profile-daily-task-card__mascot {
width: min(5.2rem, 24vw);
}
.platform-profile-daily-task-card__title {
font-size: 14px;
}
.platform-profile-daily-task-card__desc {
margin-top: 0.45rem;
font-size: 12px;
line-height: 1.42;
}
.platform-profile-daily-task-card__progress {
margin-top: 0.55rem;
gap: 0.55rem;
}
.platform-profile-daily-task-card__progress-value {
font-size: 12px;
}
.platform-profile-shortcut-panel {
padding: 0.78rem 0.66rem 0.8rem;
}
.platform-profile-shortcut-grid {
gap: 0.12rem;
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.platform-profile-shortcut-button {
min-height: 4.35rem;
padding: 0.48rem 0.08rem 0.52rem;
}
.platform-profile-shortcut-button__label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
line-height: 1.15;
white-space: normal;
}
.platform-profile-shortcut-button__sub-label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
}
.platform-profile-shortcut-button__icon {
width: 2.12rem;
height: 2.12rem;
}
.platform-profile-settings-panel {
padding: 0.2rem 0;
}
.platform-profile-settings-row {
padding: 0.72rem 0.8rem;
}
.platform-profile-settings-row__icon {
width: 2.1rem;
height: 2.1rem;
}
.platform-profile-settings-row span:last-child {
font-size: 14px;
}
.platform-profile-legal-strip {
padding: 0.5rem 0.52rem 0.68rem;
}
.platform-profile-legal-strip__links {
flex-wrap: wrap;
}
.platform-profile-legal-strip__link {
font-size: 11px;
line-height: 1.3;
}
.platform-profile-legal-strip__record {
font-size: 10px;
}
.platform-mobile-create-wallet-chip {
max-width: min(48vw, 10rem);
}
.platform-mobile-create-wallet-chip > span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.platform-mobile-create-wallet-chip,
.platform-desktop-create-wallet-chip {
min-width: 0;
}
}
.platform-role-studio__preview {
border: 1px solid var(--platform-subpanel-border);
background: radial-gradient(

54
src/index.test.ts Normal file
View File

@@ -0,0 +1,54 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
function readIndexCss() {
return fs.readFileSync(path.resolve(process.cwd(), 'src/index.css'), 'utf8');
}
function getCssBlock(source: string, selector: string) {
const selectorIndex = source.indexOf(selector);
expect(selectorIndex, `${selector} should exist in src/index.css`).toBeGreaterThanOrEqual(0);
const openBraceIndex = source.indexOf('{', selectorIndex);
expect(openBraceIndex, `${selector} should open a CSS block`).toBeGreaterThanOrEqual(0);
let depth = 0;
for (let index = openBraceIndex; index < source.length; index += 1) {
const char = source[index];
if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(openBraceIndex + 1, index);
}
}
}
throw new Error(`${selector} block is not closed`);
}
describe('index stylesheet unread dots', () => {
it('uses warm brown tokens for draft unread markers instead of red literals', () => {
const css = readIndexCss();
expect(css).toContain('--platform-unread-dot-fill: #8b5a3d;');
expect(css).toContain('--platform-unread-dot-glow: rgba(139, 90, 61, 0.34);');
expect(css).toContain('--platform-unread-dot-fill: #d6a27c;');
expect(css).toContain('--platform-unread-dot-glow: rgba(214, 162, 124, 0.24);');
for (const selector of [
'.creation-work-card__unread-dot',
'.platform-nav-unread-dot',
]) {
const block = getCssBlock(css, selector);
expect(block).toContain('background: var(--platform-unread-dot-fill);');
expect(block).toContain('var(--platform-unread-dot-glow)');
expect(block).not.toContain('#b64a35');
expect(block).not.toContain('rgba(239, 68, 68');
}
});
});

View File

@@ -19,7 +19,7 @@ describe('appPageRoutes', () => {
});
it('resolves jump-hop creation, gallery and runtime routes', () => {
expect(resolveSelectionStageFromPath('/creation/jump-hop/workspace')).toBe(
expect(resolveSelectionStageFromPath('/creation/jump-hop')).toBe(
'jump-hop-workspace',
);
expect(resolveSelectionStageFromPath('/creation/jump-hop/generating')).toBe(
@@ -35,7 +35,7 @@ describe('appPageRoutes', () => {
'jump-hop-runtime',
);
expect(resolvePathForSelectionStage('jump-hop-workspace')).toBe(
'/creation/jump-hop/workspace',
'/creation/jump-hop',
);
expect(resolvePathForSelectionStage('jump-hop-generating')).toBe(
'/creation/jump-hop/generating',
@@ -77,4 +77,44 @@ describe('appPageRoutes', () => {
'/runtime/wooden-fish',
);
});
it('resolves creation routes to the existing entry form stages', () => {
expect(resolveSelectionStageFromPath('/creation/rpg')).toBe(
'agent-workspace',
);
expect(resolveSelectionStageFromPath('/creation/big-fish')).toBe(
'big-fish-agent-workspace',
);
expect(resolveSelectionStageFromPath('/creation/match3d')).toBe(
'match3d-agent-workspace',
);
expect(resolveSelectionStageFromPath('/creation/square-hole')).toBe(
'square-hole-agent-workspace',
);
expect(resolveSelectionStageFromPath('/creation/puzzle')).toBe(
'puzzle-agent-workspace',
);
expect(resolveSelectionStageFromPath('/creation/bark-battle')).toBe(
'bark-battle-workspace',
);
expect(resolveSelectionStageFromPath('/creation/visual-novel')).toBe(
'visual-novel-agent-workspace',
);
expect(resolveSelectionStageFromPath('/creation/baby-object-match')).toBe(
'baby-object-match-workspace',
);
expect(resolvePathForSelectionStage('agent-workspace')).toBe(
'/creation/rpg',
);
expect(resolvePathForSelectionStage('puzzle-agent-workspace')).toBe(
'/creation/puzzle',
);
expect(resolvePathForSelectionStage('bark-battle-workspace')).toBe(
'/creation/bark-battle',
);
expect(resolvePathForSelectionStage('baby-object-match-workspace')).toBe(
'/creation/baby-object-match',
);
});
});

View File

@@ -9,23 +9,27 @@ const STAGE_ROUTE_ENTRIES = [
['profile-feedback', '/profile/feedback'],
['work-detail', '/works/detail'],
['detail', '/worlds/detail'],
['agent-workspace', '/creation/rpg/agent'],
['agent-workspace', '/creation/rpg'],
['big-fish-agent-workspace', '/creation/big-fish'],
['custom-world-generating', '/creation/rpg/generating'],
['custom-world-result', '/creation/rpg/result'],
['big-fish-agent-workspace', '/creation/big-fish/agent'],
['big-fish-generating', '/creation/big-fish/generating'],
['big-fish-result', '/creation/big-fish/result'],
['big-fish-runtime', '/runtime/big-fish'],
['match3d-agent-workspace', '/creation/match3d/agent'],
['match3d-agent-workspace', '/creation/match3d'],
['match3d-generating', '/creation/match3d/generating'],
['match3d-result', '/creation/match3d/result'],
['match3d-runtime', '/runtime/match3d'],
['square-hole-agent-workspace', '/creation/square-hole/agent'],
['square-hole-agent-workspace', '/creation/square-hole'],
['square-hole-generating', '/creation/square-hole/generating'],
['square-hole-result', '/creation/square-hole/result'],
['square-hole-runtime', '/runtime/square-hole'],
['jump-hop-workspace', '/creation/jump-hop/workspace'],
['jump-hop-workspace', '/creation/jump-hop'],
['jump-hop-generating', '/creation/jump-hop/generating'],
['jump-hop-result', '/creation/jump-hop/result'],
['jump-hop-gallery-detail', '/gallery/jump-hop/detail'],
['jump-hop-runtime', '/runtime/jump-hop'],
['bark-battle-workspace', '/creation/bark-battle'],
['bark-battle-generating', '/creation/bark-battle/generating'],
['bark-battle-result', '/creation/bark-battle/result'],
['bark-battle-runtime', '/runtime/bark-battle'],
@@ -34,7 +38,8 @@ const STAGE_ROUTE_ENTRIES = [
['wooden-fish-result', '/creation/wooden-fish/result'],
['wooden-fish-runtime', '/runtime/wooden-fish'],
['creative-agent-workspace', '/creation/creative-agent'],
['visual-novel-agent-workspace', '/creation/visual-novel/agent'],
['visual-novel-agent-workspace', '/creation/visual-novel'],
['visual-novel-generating', '/creation/visual-novel/generating'],
['visual-novel-result', '/creation/visual-novel/result'],
['visual-novel-gallery-detail', '/gallery/visual-novel/detail'],
['visual-novel-runtime', '/runtime/visual-novel'],
@@ -43,7 +48,8 @@ const STAGE_ROUTE_ENTRIES = [
['baby-object-match-result', '/creation/baby-object-match/result'],
['baby-object-match-runtime', '/runtime/baby-object-match'],
['baby-love-drawing-runtime', '/runtime/baby-love-drawing'],
['puzzle-agent-workspace', '/creation/puzzle/agent'],
['puzzle-agent-workspace', '/creation/puzzle'],
['puzzle-generating', '/creation/puzzle/generating'],
['puzzle-result', '/creation/puzzle/result'],
['puzzle-gallery-detail', '/gallery/puzzle/detail'],
['puzzle-runtime', '/runtime/puzzle'],

View File

@@ -9,6 +9,9 @@ export type CreationEntryTypeConfig = {
visible: boolean;
open: boolean;
sortOrder: number;
categoryId: string;
categoryLabel: string;
categorySortOrder: number;
updatedAtMicros: number;
};
@@ -23,6 +26,14 @@ export type CreationEntryConfig = {
title: string;
description: string;
};
eventBanner: {
title: string;
description: string;
coverImageSrc: string;
prizePoolMudPoints: number;
startsAtText: string;
endsAtText: string;
};
creationTypes: CreationEntryTypeConfig[];
};

View File

@@ -36,12 +36,15 @@ describe('miniGameDraftGenerationProgress', () => {
expect(progress?.steps[0]?.detail).toBe(
'建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
);
expect(progress?.estimatedRemainingMs).toBe(296_500);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.steps[2]?.detail).toBe(
'调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
);
expect(progress?.estimatedRemainingMs).toBe(446_500);
expect(progress?.overallProgress).toBe(0);
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
});
test('puzzle draft generation advances steps across the current asset pipeline', () => {
test('puzzle draft generation starts total progress from zero', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
@@ -51,42 +54,81 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 206_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(
state,
296_000,
);
const progress = buildMiniGameDraftGenerationProgress(state, 1000);
expect(imageProgress?.phaseId).toBe('puzzle-cover-image');
expect(imageProgress?.estimatedRemainingMs).toBe(273_000);
expect(imageProgress?.steps[1]?.status).toBe('completed');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0);
expect(uiProgress?.phaseId).toBe('puzzle-ui-assets');
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.estimatedRemainingMs).toBe(3_000);
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
expect(writeBackProgress?.steps[5]?.status).toBe('active');
expect(progress?.overallProgress).toBe(0);
expect(progress?.completedWeight).toBe(0);
expect(progress?.estimatedRemainingMs).toBe(448_000);
expect(progress?.steps[0]?.completed).toBe(0);
});
test('puzzle write-back step turns completed once rounded progress reaches 100%', () => {
test('puzzle draft generation total progress advances after startup', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 7000);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.overallProgress).toBeLessThan(88);
expect(progress?.phaseId).toBe('compile');
});
test('puzzle draft generation keeps current step until real progress advances it', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const longRunningProgress = buildMiniGameDraftGenerationProgress(
state,
296_000,
);
const progressedState: MiniGameDraftGenerationState = {
...state,
phase: 'puzzle-cover-image',
};
const realProgress = buildMiniGameDraftGenerationProgress(
progressedState,
296_000,
);
expect(longRunningProgress?.phaseId).toBe('compile');
expect(longRunningProgress?.steps[0]?.status).toBe('active');
expect(longRunningProgress?.steps[1]?.status).toBe('pending');
expect(longRunningProgress?.overallProgress).toBeLessThanOrEqual(98);
expect(longRunningProgress?.overallProgress).toBeGreaterThan(40);
expect(realProgress?.phaseId).toBe('puzzle-cover-image');
expect(realProgress?.steps[1]?.status).toBe('completed');
expect(realProgress?.steps[2]?.status).toBe('active');
});
test('puzzle write-back step stays active until the generation action finishes', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'puzzle-select-image',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 298_950);
const progress = buildMiniGameDraftGenerationProgress(state, 448_950);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);
expect(progress?.estimatedRemainingMs).toBe(50);
expect(progress?.steps[5]?.completed).toBe(1);
expect(progress?.steps[5]?.status).toBe('completed');
expect(progress?.steps[5]?.completed).toBe(0.98);
expect(progress?.steps[5]?.status).toBe('active');
});
test('puzzle direct upload generation skips the first image generation step', () => {
@@ -112,14 +154,14 @@ describe('miniGameDraftGenerationProgress', () => {
'生成UI与背景',
'写入正式草稿',
]);
expect(progress?.phaseId).toBe('puzzle-level-scene');
expect(progress?.phaseId).toBe('compile');
expect(progress?.steps[2]?.detail).toContain('直接使用上传图作为参考');
expect(progress?.estimatedRemainingMs).toBe(189_000);
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.phaseId).toBe('compile');
expect(writeBackProgress?.estimatedRemainingMs).toBe(3_000);
});
test('puzzle draft generation keeps moving without claiming completion before response', () => {
test('puzzle draft generation does not advance or claim completion before response', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
@@ -129,18 +171,88 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 480_000);
const progress = buildMiniGameDraftGenerationProgress(state, 630_000);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);
expect(progress?.phaseId).toBe('compile');
expect(progress?.overallProgress).toBeLessThan(88);
expect(progress?.overallProgress).toBeGreaterThan(80);
expect(progress?.estimatedRemainingMs).toBe(0);
expect(progress?.steps[5]?.completed).toBe(1);
expect(progress?.steps[5]?.status).toBe('completed');
expect(progress?.steps.every((step) => step.status === 'completed')).toBe(
expect(progress?.steps[0]?.status).toBe('active');
expect(progress?.steps[0]?.completed).toBe(0.98);
expect(progress?.steps.slice(1).every((step) => step.status === 'pending')).toBe(
true,
);
});
test('puzzle draft generation advances steps from backend progress percent only', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 88,
} as MiniGameDraftGenerationState['metadata'],
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 120_000);
const uiProgress = buildMiniGameDraftGenerationProgress(
{
...state,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 94,
} as MiniGameDraftGenerationState['metadata'],
},
230_000,
);
const writeProgress = buildMiniGameDraftGenerationProgress(
{
...state,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 96,
} as MiniGameDraftGenerationState['metadata'],
},
260_000,
);
expect(imageProgress?.phaseId).toBe('puzzle-cover-image');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[3]?.status).toBe('pending');
expect(uiProgress?.phaseId).toBe('puzzle-ui-assets');
expect(uiProgress?.steps[4]?.status).toBe('active');
expect(uiProgress?.steps[5]?.status).toBe('pending');
expect(writeProgress?.phaseId).toBe('puzzle-select-image');
expect(writeProgress?.steps[5]?.status).toBe('active');
});
test('puzzle backend milestone starts fake progress from the current step entry time', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: true,
puzzleProgressPercent: 88,
puzzleActivePhaseId: 'puzzle-cover-image',
puzzleActiveStepStartedAtMs: 120_000,
} as MiniGameDraftGenerationState['metadata'],
};
const progress = buildMiniGameDraftGenerationProgress(state, 121_000);
expect(progress?.phaseId).toBe('puzzle-cover-image');
expect(progress?.steps[2]?.status).toBe('active');
expect(progress?.steps[2]?.completed).toBeLessThan(0.02);
});
test('puzzle ready copy points to result page work info completion', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
@@ -268,6 +380,19 @@ describe('miniGameDraftGenerationProgress', () => {
);
});
test('match3d draft generation starts total progress from zero', () => {
const state = createMiniGameDraftGenerationState('match3d');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs,
);
expect(progress?.overallProgress).toBe(0);
expect(progress?.completedWeight).toBe(0);
expect(progress?.steps[0]?.completed).toBe(0);
});
test('match3d draft generation keeps backend observed asset phase', () => {
const state = {
...createMiniGameDraftGenerationState('match3d'),

View File

@@ -91,6 +91,9 @@ export type MiniGameDraftGenerationState = {
error: string | null;
metadata?: {
puzzleAiRedraw?: boolean;
puzzleActivePhaseId?: MiniGameDraftGenerationPhase;
puzzleActiveStepStartedAtMs?: number;
puzzleProgressPercent?: number;
};
};
@@ -111,10 +114,14 @@ type MiniGameAnchorSource = {
value: string;
};
const PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS = 240_000;
const PUZZLE_IMAGE_GENERATION_EXPECTED_MS = 90_000;
const PUZZLE_COMPILE_EXPECTED_MS = 8_000;
const PUZZLE_LEVEL_NAME_EXPECTED_MS = 10_000;
const PUZZLE_WRITE_DRAFT_EXPECTED_MS = 10_000;
const PUZZLE_COMPILE_MILESTONE_PROGRESS = 88;
const PUZZLE_IMAGE_MILESTONE_PROGRESS = 94;
const PUZZLE_UI_MILESTONE_PROGRESS = 96;
function shouldSkipPuzzleCoverGeneration(state: MiniGameDraftGenerationState) {
return state.metadata?.puzzleAiRedraw === false;
@@ -160,8 +167,8 @@ function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
steps.push({
id: 'puzzle-cover-image',
label: '生成拼图首图',
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 90 秒。',
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 4 分钟。',
durationMs: PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS,
});
}
@@ -204,30 +211,40 @@ function resolvePuzzleEstimatedWaitMs(state: MiniGameDraftGenerationState) {
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000;
function buildPuzzlePhaseTimeline(state: MiniGameDraftGenerationState): Array<{
phase: Extract<
MiniGameDraftGenerationPhase,
| 'compile'
| 'puzzle-level-name'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
| 'puzzle-select-image'
>;
durationMs: number;
}> {
return buildPuzzleTimedSteps(state).map((step) => ({
phase: step.id as Extract<
MiniGameDraftGenerationPhase,
| 'compile'
| 'puzzle-level-name'
| 'puzzle-cover-image'
| 'puzzle-level-scene'
| 'puzzle-ui-assets'
| 'puzzle-select-image'
>,
durationMs: step.durationMs,
}));
function resolvePuzzleBackendProgressPercent(
state: MiniGameDraftGenerationState,
) {
const progressPercent = state.metadata?.puzzleProgressPercent;
if (typeof progressPercent !== 'number' || !Number.isFinite(progressPercent)) {
return null;
}
return Math.max(0, Math.min(100, Math.round(progressPercent)));
}
function resolvePuzzlePhaseByBackendProgress(
state: MiniGameDraftGenerationState,
): MiniGameDraftGenerationPhase | null {
const progressPercent = resolvePuzzleBackendProgressPercent(state);
if (progressPercent == null) {
return null;
}
// 中文注释:拼图生成页的跨步骤只跟随后端会话真实里程碑;
// 每步内部的等待反馈仍由本地假进度补足。
if (progressPercent >= 96) {
return 'puzzle-select-image';
}
if (progressPercent >= 94) {
return 'puzzle-ui-assets';
}
if (progressPercent >= 88) {
return shouldSkipPuzzleCoverGeneration(state)
? 'puzzle-level-scene'
: 'puzzle-cover-image';
}
return null;
}
const BIG_FISH_STEPS = [
@@ -484,17 +501,9 @@ function buildMiniGameProgressSteps(
activeStepProgressRatio: number,
) {
return steps.map((step, index) => {
// 中文注释:拼图草稿编译的 action 回包才代表可进入结果页;
// 但预计写入时长已耗尽时,最后一步自身应呈现已完成,避免出现“进行中 100%”。
const isPuzzleWriteStepCompleted =
state.kind === 'puzzle' &&
state.phase !== 'failed' &&
step.id === 'puzzle-select-image' &&
clampProgress(activeStepProgressRatio * 100) >= 100;
const isCompleted =
state.phase === 'ready' ||
index < activeStepIndex ||
isPuzzleWriteStepCompleted;
index < activeStepIndex;
const isActive =
state.phase !== 'failed' && !isCompleted && index === activeStepIndex;
const isAssetStep = step.id === state.phase && state.totalAssetCount > 0;
@@ -636,32 +645,141 @@ function resolveWoodenFishPhaseByElapsedMs(
return 'wooden-fish-draft';
}
function resolvePuzzleTimelineByElapsedMs(
function resolvePuzzleActiveStepProgressRatio(
steps: ReadonlyArray<TimedMiniGameStepDefinition>,
activeStepIndex: number,
elapsedMs: number,
state: MiniGameDraftGenerationState,
) {
let elapsedBeforePhase = 0;
for (const item of buildPuzzlePhaseTimeline(state)) {
const elapsedInPhase = elapsedMs - elapsedBeforePhase;
if (elapsedInPhase < item.durationMs) {
return {
phase: item.phase,
activeStepProgressRatio: Math.max(
0,
Math.min(1, elapsedInPhase / item.durationMs),
),
};
}
elapsedBeforePhase += item.durationMs;
const activeStep = steps[activeStepIndex];
if (!activeStep) {
return 0;
}
return {
phase: 'puzzle-select-image' as const,
activeStepProgressRatio: 1,
};
const elapsedBeforeActiveStep = steps
.slice(0, activeStepIndex)
.reduce((sum, step) => sum + step.durationMs, 0);
const elapsedInActiveStep = Math.max(0, elapsedMs - elapsedBeforeActiveStep);
return Math.max(
0,
Math.min(0.98, elapsedInActiveStep / Math.max(1, activeStep.durationMs)),
);
}
function resolvePuzzleActiveStepElapsedProgressRatio(
state: MiniGameDraftGenerationState,
steps: ReadonlyArray<TimedMiniGameStepDefinition>,
activeStepIndex: number,
elapsedMs: number,
effectiveNowMs: number,
) {
if (resolvePuzzleBackendProgressPercent(state) != null) {
const stepStartedAtMs = state.metadata?.puzzleActiveStepStartedAtMs;
if (
state.metadata?.puzzleActivePhaseId === state.phase &&
typeof stepStartedAtMs === 'number' &&
Number.isFinite(stepStartedAtMs)
) {
const activeStep = steps[activeStepIndex];
if (!activeStep) {
return 0;
}
return Math.max(
0,
Math.min(
0.98,
(effectiveNowMs - stepStartedAtMs) /
Math.max(1, activeStep.durationMs),
),
);
}
return resolvePuzzleActiveStepProgressRatio(
steps,
activeStepIndex,
elapsedMs,
);
}
const activeStep = steps[activeStepIndex];
if (!activeStep) {
return 0;
}
// 中文注释:未收到后端真实里程碑时,跨步骤必须卡住;
// 但当前步骤内的假进度要按整段等待时间继续向前走,避免短步骤几秒后停死。
const fallbackDurationMs = Math.max(1, resolvePuzzleEstimatedWaitMs(state));
return Math.max(
0,
Math.min(0.98, elapsedMs / fallbackDurationMs),
);
}
function resolveElapsedActiveStepProgressRatio(
kind: MiniGameDraftGenerationKind,
elapsedMs: number,
) {
const estimatedWaitMs =
kind === 'big-fish'
? 7_000
: kind === 'square-hole'
? 12_000
: kind === 'match3d'
? MATCH3D_ESTIMATED_WAIT_MS
: kind === 'baby-object-match'
? BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS
: kind === 'jump-hop'
? JUMP_HOP_ESTIMATED_WAIT_MS
: kind === 'wooden-fish'
? WOODEN_FISH_ESTIMATED_WAIT_MS
: 1;
return Math.max(
0,
Math.min(0.98, elapsedMs / Math.max(1, estimatedWaitMs)),
);
}
function resolvePuzzleOverallProgress(
state: MiniGameDraftGenerationState,
activeStepProgressRatio: number,
) {
const backendProgressPercent = resolvePuzzleBackendProgressPercent(state);
// 中文注释88 以下的后端进度只保留为会话事实,不参与首帧总进度抬升。
// 生成页恢复时必须先从 0% 起步,再由当前步骤内的假进度平滑推进。
const backendProgressFloor =
backendProgressPercent != null &&
backendProgressPercent >= PUZZLE_COMPILE_MILESTONE_PROGRESS
? backendProgressPercent
: 0;
const range =
state.phase === 'puzzle-select-image'
? {
start: PUZZLE_UI_MILESTONE_PROGRESS,
end: PUZZLE_NON_READY_MAX_PROGRESS,
}
: state.phase === 'puzzle-ui-assets'
? {
start: PUZZLE_IMAGE_MILESTONE_PROGRESS,
end: PUZZLE_UI_MILESTONE_PROGRESS,
}
: state.phase === 'puzzle-cover-image' ||
state.phase === 'puzzle-level-scene'
? {
start: PUZZLE_COMPILE_MILESTONE_PROGRESS,
end: PUZZLE_IMAGE_MILESTONE_PROGRESS,
}
: {
start: 0,
end: PUZZLE_COMPILE_MILESTONE_PROGRESS,
};
const fakeProgress =
range.start + (range.end - range.start) * activeStepProgressRatio;
const nextProgress = Math.min(
PUZZLE_NON_READY_MAX_PROGRESS,
Math.max(range.start, backendProgressFloor, fakeProgress),
);
return nextProgress;
}
export function buildMiniGameDraftGenerationProgress(
@@ -677,17 +795,17 @@ export function buildMiniGameDraftGenerationProgress(
? state.finishedAtMs
: nowMs;
const elapsedMs = Math.max(0, effectiveNowMs - state.startedAtMs);
const puzzleTimeline =
const puzzleBackendPhase =
state.kind === 'puzzle' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? resolvePuzzleTimelineByElapsedMs(elapsedMs, state)
? resolvePuzzlePhaseByBackendProgress(state)
: null;
const normalizedState =
puzzleTimeline != null
puzzleBackendPhase != null
? {
...state,
phase: puzzleTimeline.phase,
phase: puzzleBackendPhase,
}
: state.kind === 'big-fish' &&
state.phase !== 'failed' &&
@@ -733,47 +851,94 @@ export function buildMiniGameDraftGenerationProgress(
}
: state;
const puzzleTimedSteps =
normalizedState.kind === 'puzzle'
? buildPuzzleTimedSteps(normalizedState)
: null;
const steps =
normalizedState.kind === 'puzzle'
? buildPuzzleSteps(normalizedState)
? buildWeightedPuzzleSteps(
puzzleTimedSteps ?? buildPuzzleTimedSteps(normalizedState),
)
: getStepDefinitions(normalizedState.kind);
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
const completedWeight = steps
.slice(
0,
normalizedState.phase === 'ready' ? steps.length : activeStepIndex,
)
.reduce((sum, step) => sum + step.weight, 0);
const activeStep = steps[activeStepIndex] ?? steps[0];
const assetRatio =
normalizedState.totalAssetCount > 0
? Math.min(
1,
normalizedState.completedAssetCount / normalizedState.totalAssetCount,
)
: normalizedState.phase === 'ready'
const activeStepProgressRatio =
normalizedState.kind === 'puzzle'
? normalizedState.phase === 'ready'
? 1
: normalizedState.kind === 'puzzle'
? (puzzleTimeline?.activeStepProgressRatio ?? 0)
: normalizedState.phase === 'failed'
? 0
: resolvePuzzleActiveStepElapsedProgressRatio(
normalizedState,
puzzleTimedSteps ?? buildPuzzleTimedSteps(normalizedState),
activeStepIndex,
elapsedMs,
effectiveNowMs,
)
: normalizedState.totalAssetCount > 0
? Math.min(
1,
normalizedState.completedAssetCount / normalizedState.totalAssetCount,
)
: normalizedState.phase === 'ready'
? 1
: normalizedState.kind === 'big-fish'
? 0.55
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'square-hole'
? 0.42
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'match3d'
? 0.5
: normalizedState.kind === 'baby-object-match'
? 0.52
: normalizedState.kind === 'jump-hop'
? 0.5
: normalizedState.kind === 'wooden-fish'
? 0.5
: 0;
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'baby-object-match'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'jump-hop'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: normalizedState.kind === 'wooden-fish'
? resolveElapsedActiveStepProgressRatio(
normalizedState.kind,
elapsedMs,
)
: 0;
const completedWeight =
normalizedState.kind === 'puzzle'
? steps
.slice(
0,
normalizedState.phase === 'ready' ? steps.length : activeStepIndex,
)
.reduce((sum, step) => sum + step.weight, 0)
: steps
.slice(
0,
normalizedState.phase === 'ready' ? steps.length : activeStepIndex,
)
.reduce((sum, step) => sum + step.weight, 0);
const overallProgress =
normalizedState.phase === 'failed'
? Math.max(1, completedWeight)
: normalizedState.phase === 'ready'
? 100
: completedWeight + activeStep.weight * assetRatio;
: normalizedState.kind === 'puzzle'
? resolvePuzzleOverallProgress(
normalizedState,
activeStepProgressRatio,
)
: completedWeight + activeStep.weight * activeStepProgressRatio;
const cappedOverallProgress =
normalizedState.phase === 'ready' || normalizedState.phase === 'failed'
? overallProgress
@@ -835,7 +1000,7 @@ export function buildMiniGameDraftGenerationProgress(
steps,
activeStepIndex,
normalizedState,
assetRatio,
activeStepProgressRatio,
),
};
}

View File

@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
buildCustomWorldPublicWorkCode,
buildJumpHopPublicWorkCode,
buildWoodenFishPublicWorkCode,
isSameCustomWorldPublicWorkCode,
isSameJumpHopPublicWorkCode,
isSameWoodenFishPublicWorkCode,
} from './publicWorkCode';
@@ -32,6 +34,16 @@ describe('publicWorkCode', () => {
);
});
it('builds and matches custom world public work codes from profile ids', () => {
expect(buildCustomWorldPublicWorkCode('world-public-1')).toBe('CW-00000001');
expect(isSameCustomWorldPublicWorkCode('cw-00000001', 'world-public-1')).toBe(
true,
);
expect(
isSameCustomWorldPublicWorkCode('world-public-1', 'world-public-1'),
).toBe(true);
});
it('matches wooden fish public work codes and raw profile ids', () => {
expect(
isSameWoodenFishPublicWorkCode(

View File

@@ -53,6 +53,28 @@ export function buildBabyObjectMatchPublicWorkCode(profileId: string) {
return `BO-${suffix}`;
}
function normalizeCustomWorldPublicWorkCodeSuffix(profileId: string) {
const digits = profileId
.split('')
.filter((character) => character >= '0' && character <= '9')
.join('');
if (digits.length === 0) {
const bytes = new TextEncoder().encode(profileId);
const checksum = bytes.reduce((accumulator, value) => {
return (accumulator * 131 + value) >>> 0;
}, 0);
return String(checksum % 100_000_000).padStart(8, '0');
}
return digits.slice(-8).padStart(8, '0');
}
export function buildCustomWorldPublicWorkCode(profileId: string) {
return `CW-${normalizeCustomWorldPublicWorkCodeSuffix(profileId)}`;
}
function normalizeBarkBattlePublicWorkCodeSuffix(workId: string) {
const normalized = normalizePublicCodeText(workId);
const withoutPrefix = normalized.startsWith('BB')
@@ -155,6 +177,19 @@ export function isSameBabyObjectMatchPublicWorkCode(
);
}
export function isSameCustomWorldPublicWorkCode(
keyword: string,
profileId: string,
) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildCustomWorldPublicWorkCode(profileId)) ||
normalizedKeyword === normalizePublicCodeText(profileId)
);
}
export function isSameBarkBattlePublicWorkCode(keyword: string, workId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);