Merge branch 'codex/profile-mobile-ui-reference'
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
289
src/components/GenerationProgressHero.tsx
Normal file
289
src/components/GenerationProgressHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ describe('BarkBattleGeneratingView', () => {
|
||||
updatedAt: '2026-05-14T10:01:00.000Z',
|
||||
});
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<BarkBattleGeneratingView
|
||||
draft={draft}
|
||||
onBack={() => {}}
|
||||
@@ -59,9 +59,142 @@ describe('BarkBattleGeneratingView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
expect((container.firstChild as HTMLElement).className).toContain('z-[1]');
|
||||
expect(screen.getByText('总进度')).toBeTruthy();
|
||||
expect(screen.getByText('总进度').className).toContain('text-[9px]');
|
||||
const pageVideo = screen.getByTestId(
|
||||
'generation-page-background-video',
|
||||
) as HTMLVideoElement;
|
||||
expect(pageVideo.parentElement?.className).toContain('z-0');
|
||||
expect(pageVideo.parentElement?.className).toContain('bg-transparent');
|
||||
expect(pageVideo.parentElement?.className).not.toContain('bg-[#fff4ea]');
|
||||
expect((container.firstChild as HTMLElement).contains(pageVideo)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(pageVideo.autoplay).toBe(true);
|
||||
expect(pageVideo.loop).toBe(true);
|
||||
expect(pageVideo.muted).toBe(true);
|
||||
expect(pageVideo.playsInline).toBe(true);
|
||||
expect(pageVideo.getAttribute('preload')).toBe('auto');
|
||||
expect(
|
||||
document.querySelector(
|
||||
'video[data-testid="generation-page-background-video"] source[type="video/mp4"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '返回编辑' }).className).toContain(
|
||||
'text-xs',
|
||||
);
|
||||
expect(screen.getByText('生成中').className).toContain('text-[11px]');
|
||||
expect(screen.getByText('当前步骤')).toBeTruthy();
|
||||
expect(screen.getByText('当前步骤').className).toContain('text-[10px]');
|
||||
expect(screen.getByTestId('generation-hero-wait-card').className).toContain(
|
||||
'text-center',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
|
||||
'text-center',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-wait-card').className).toContain(
|
||||
'bg-white/58',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
|
||||
'bg-white/58',
|
||||
);
|
||||
expect(screen.getByText('预计等待').className).toContain('text-[9px]');
|
||||
expect(screen.getByText('已耗时').className).toContain('text-[9px]');
|
||||
expect(screen.getByText('预计等待').parentElement?.className).toContain(
|
||||
'justify-center',
|
||||
);
|
||||
expect(screen.getByText('已耗时').parentElement?.className).toContain(
|
||||
'justify-center',
|
||||
);
|
||||
expect(screen.getByText('3 分钟')).toBeTruthy();
|
||||
expect(screen.getByText('1 秒')).toBeTruthy();
|
||||
expect(screen.queryByText('预计还需 3 分钟')).toBeNull();
|
||||
expect(screen.queryByText('已耗时 1 秒')).toBeNull();
|
||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
||||
'justify-start',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
||||
'pt-[4%]',
|
||||
);
|
||||
expect(screen.getByText('玩家形象')).toBeTruthy();
|
||||
expect(screen.getByText('对手形象')).toBeTruthy();
|
||||
expect(screen.getByText('竞技背景')).toBeTruthy();
|
||||
expect(screen.getByText('进行中 36%')).toBeTruthy();
|
||||
expect(screen.getByText('进行中 36%').className).toContain('text-[11px]');
|
||||
expect(screen.getByText('总进度').className).toContain('text-[9px]');
|
||||
expect(screen.getByText('0%').className).toContain('text-[1.15rem]');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.className,
|
||||
).toContain('w-[min(35rem,94vw)]');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.className,
|
||||
).toContain('sm:w-[52rem]');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.getAttribute('aria-valuenow'),
|
||||
).toBe('0');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.getAttribute('data-ring-start-degrees'),
|
||||
).toBe('225');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.getAttribute('data-ring-sweep-degrees'),
|
||||
).toBe('270');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.getAttribute('data-ring-gap-degrees'),
|
||||
).toBe('90');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.getAttribute('data-ring-fill-degrees'),
|
||||
).toBe('0');
|
||||
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
|
||||
'svg',
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring')
|
||||
.getAttribute('viewBox'),
|
||||
).toBe('0 0 400 400');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-track')
|
||||
.getAttribute('r'),
|
||||
).toBe('166');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-track')
|
||||
.getAttribute('stroke-width'),
|
||||
).toBe('18');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-fill')
|
||||
.getAttribute('stroke-dasharray'),
|
||||
).toMatch(/^0\.00 1043\.\d{2}$/u);
|
||||
expect(
|
||||
screen.getByRole('progressbar', { name: '玩家形象 进度' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '玩家形象 进度' })
|
||||
.getAttribute('aria-valuenow'),
|
||||
).toBe('36');
|
||||
expect(
|
||||
screen.getByTestId('generation-current-step-card').className,
|
||||
).toContain('bg-white/58');
|
||||
expect(screen.getByText('预览信息').className).toContain('text-[13px]');
|
||||
expect(screen.queryByText('对手形象')).toBeNull();
|
||||
expect(screen.queryByText('竞技背景')).toBeNull();
|
||||
expect(onComplete).not.toHaveBeenCalled();
|
||||
|
||||
resolveGeneration({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertCircle, ArrowLeft, CheckCircle2, Loader2, Sparkles } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
generateAllBarkBattleImageAssets,
|
||||
updateBarkBattleDraftConfig,
|
||||
} from '../../services/bark-battle-creation';
|
||||
import {
|
||||
GenerationCurrentStepCard,
|
||||
GenerationPageBackdrop,
|
||||
GenerationProgressHero,
|
||||
} from '../GenerationProgressHero';
|
||||
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
|
||||
|
||||
type BarkBattleGeneratingViewProps = {
|
||||
@@ -110,6 +115,56 @@ function buildDraftGenerationKey(draft: BarkBattleDraftConfig) {
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function getSlotStatusLabel(status: BarkBattleGeneratingSlotStatus) {
|
||||
if (status === 'ready') {
|
||||
return '完成';
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return '失败';
|
||||
}
|
||||
return '进行中';
|
||||
}
|
||||
|
||||
function formatGenerationDuration(ms: number) {
|
||||
const totalSeconds = Math.max(1, Math.ceil(Math.max(0, ms) / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes <= 0) {
|
||||
return `${seconds} 秒`;
|
||||
}
|
||||
|
||||
if (seconds === 0) {
|
||||
return `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
return `${minutes} 分 ${seconds} 秒`;
|
||||
}
|
||||
|
||||
function resolveBarkBattleProgressValue(
|
||||
slotStatuses: Partial<
|
||||
Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>
|
||||
>,
|
||||
) {
|
||||
const readyCount = GENERATION_STEPS.filter(
|
||||
(step) => slotStatuses[step.slot] === 'ready',
|
||||
).length;
|
||||
return Math.round((readyCount / GENERATION_STEPS.length) * 100);
|
||||
}
|
||||
|
||||
function resolveCurrentBarkBattleStep(
|
||||
slotStatuses: Partial<
|
||||
Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>
|
||||
>,
|
||||
) {
|
||||
return (
|
||||
GENERATION_STEPS.find((step) => slotStatuses[step.slot] === 'generating') ??
|
||||
GENERATION_STEPS.find((step) => slotStatuses[step.slot] === 'failed') ??
|
||||
GENERATION_STEPS.find((step) => slotStatuses[step.slot] !== 'ready') ??
|
||||
GENERATION_STEPS[GENERATION_STEPS.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
export function BarkBattleGeneratingView({
|
||||
draft,
|
||||
isBusy = false,
|
||||
@@ -125,10 +180,33 @@ export function BarkBattleGeneratingView({
|
||||
const [slotStatuses, setSlotStatuses] = useState<
|
||||
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
|
||||
>({});
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const primaryFailureMessage = useMemo(
|
||||
() => resolvePrimaryFailureMessage(slotFailures),
|
||||
[slotFailures],
|
||||
);
|
||||
const progressValue = resolveBarkBattleProgressValue(slotStatuses);
|
||||
const currentStep = resolveCurrentBarkBattleStep(slotStatuses);
|
||||
const currentStepStatus = currentStep
|
||||
? (slotStatuses[currentStep.slot] ??
|
||||
(hasSlotAsset(previewDraft, currentStep.slot) ? 'ready' : 'generating'))
|
||||
: 'generating';
|
||||
const currentStepProgress =
|
||||
currentStepStatus === 'ready' ? 100 : currentStepStatus === 'failed' ? 100 : 36;
|
||||
const currentStepLabel = currentStep?.label ?? '竞技素材';
|
||||
const currentStepStatusLabel = getSlotStatusLabel(currentStepStatus);
|
||||
|
||||
useEffect(() => {
|
||||
const startedAtMs = Date.now();
|
||||
const timerId = window.setInterval(() => {
|
||||
setElapsedMs(Date.now() - startedAtMs);
|
||||
}, 1000);
|
||||
setElapsedMs(0);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timerId);
|
||||
};
|
||||
}, [draft.draftId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewDraft(draft);
|
||||
@@ -277,76 +355,54 @@ export function BarkBattleGeneratingView({
|
||||
}, [draft, onComplete, onError]);
|
||||
|
||||
return (
|
||||
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-3 pb-3 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
|
||||
<div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-y-auto bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5 xl:px-6 xl:pb-5 xl:pt-5">
|
||||
<GenerationPageBackdrop />
|
||||
<div className="relative z-10 mx-auto flex w-full max-w-[48rem] flex-col">
|
||||
<div className="mb-6 flex shrink-0 items-center justify-between gap-3 sm:mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
className={`inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回编辑
|
||||
<ArrowLeft className="h-5 w-5" strokeWidth={2.6} />
|
||||
<span className="break-keep">返回编辑</span>
|
||||
</button>
|
||||
<span className="rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-[11px] font-black text-sky-700">
|
||||
<span className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
|
||||
生成中
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="grid min-h-0 flex-1 gap-3 overflow-y-auto lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)]">
|
||||
<div className="grid content-start gap-3">
|
||||
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
|
||||
<div className="flex items-center gap-2 text-sm font-black text-[var(--platform-text-soft)]">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
自动生成素材
|
||||
</div>
|
||||
<h1 className="m-0 mt-2 text-3xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-5xl">
|
||||
{draft.title || '未命名声浪竞技场'}
|
||||
</h1>
|
||||
</div>
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="grid content-start gap-3 overflow-hidden px-0 pb-0 pt-0">
|
||||
<GenerationProgressHero
|
||||
title="汪汪声浪素材生成进度"
|
||||
phaseLabel={draft.title || '未命名声浪竞技场'}
|
||||
progressValue={progressValue}
|
||||
estimatedWaitText="3 分钟"
|
||||
elapsedText={formatGenerationDuration(elapsedMs)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{GENERATION_STEPS.map((step) => {
|
||||
const status =
|
||||
slotStatuses[step.slot] ??
|
||||
(hasSlotAsset(previewDraft, step.slot) ? 'ready' : 'generating');
|
||||
const ready = status === 'ready';
|
||||
const failed =
|
||||
status === 'failed' || Boolean(slotFailures[step.slot]);
|
||||
const statusLabel = ready
|
||||
? `${step.label}已生成`
|
||||
: failed
|
||||
? `${step.label}生成失败`
|
||||
: `${step.label}生成中`;
|
||||
return (
|
||||
<div
|
||||
key={step.slot}
|
||||
className="flex items-center justify-between rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-3"
|
||||
aria-label={statusLabel}
|
||||
>
|
||||
<span className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{step.label}
|
||||
</span>
|
||||
{ready ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
) : failed ? (
|
||||
<AlertCircle className="h-4 w-4 text-rose-500" />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[var(--platform-text-soft)]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-[-0.15rem]">
|
||||
<GenerationCurrentStepCard
|
||||
label={currentStepLabel}
|
||||
statusLabel={currentStepStatusLabel}
|
||||
progressValue={currentStepProgress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error || primaryFailureMessage ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
<div className="rounded-[1.4rem] border border-[#d88969]/35 bg-white/76 px-4 py-3 text-sm leading-6 text-[#a6402f] backdrop-blur-md">
|
||||
{error ?? primaryFailureMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<BarkBattlePreviewCard config={previewDraft} />
|
||||
<section className="overflow-hidden rounded-[1.75rem] border border-[#eadcd1] bg-[rgba(255,250,246,0.92)] px-4 py-4 shadow-[0_20px_56px_rgba(112,57,30,0.08)] backdrop-blur-[5px] sm:px-5 sm:py-5">
|
||||
<div className="mb-4 text-[13px] font-black tracking-[0.08em] text-[#111111]">
|
||||
预览信息
|
||||
</div>
|
||||
<BarkBattlePreviewCard config={previewDraft} />
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -354,4 +410,3 @@ export function BarkBattleGeneratingView({
|
||||
}
|
||||
|
||||
export default BarkBattleGeneratingView;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
开始时间 {banner.startsAtText}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#c99373]">|</span>
|
||||
<span className="min-w-0 truncate">
|
||||
结束时间 {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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user