Large changes across frontend, backend and docs to align creation-tab and generation-page behavior with new product UI/UX and Spacetime bindings. Updated hermes decision-log and pitfalls with concrete rules (banner carousel, font sizing, unread-dot tokens, template-card layout, direct card->entry routing, separation of account balance vs prize pools, removal of global page card shell, generation progress milestones and unified circular progress, and background video handling). Added GenerationProgressHero component and media assets, plus generation-related UI/tests updates (CustomWorldGenerationView, BarkBattleGeneratingView, creation hub/cards, platform entry routing, index tests). Backend and contract updates include new category fields in admin API types and admin UI form/list, spacetime-client/module/migration changes and generated bindings script. Misc: many tests adjusted, new docs and plan files added, and several server-rs crate changes to support the updated creation/ generation workflows.
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|