Merge branch 'codex/profile-mobile-ui-reference'

This commit is contained in:
2026-05-25 01:41:05 +08:00
74 changed files with 5512 additions and 1090 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);
@@ -3135,7 +3390,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>
@@ -5369,7 +5454,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}
@@ -5584,7 +5669,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;
@@ -5839,30 +5924,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>
@@ -5877,28 +5987,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'
@@ -5908,32 +6017,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 />
@@ -5944,23 +6054,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}
/>
</>
@@ -5968,23 +6081,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}
/>
</>
@@ -5992,101 +6108,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} />
@@ -6566,7 +6706,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}
@@ -6714,6 +6866,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}