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 = 155;
|
|
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 z-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-30 flex h-full w-full flex-col items-center justify-start pt-[2%] text-center sm:pt-[1.5%]"
|
|
data-testid="generation-hero-progress-content"
|
|
>
|
|
<div className="relative z-30 text-[9px] font-black tracking-[0.16em] text-[#7f441f] sm:text-[10px]">
|
|
总进度
|
|
</div>
|
|
<div className="relative z-30 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>
|
|
);
|
|
}
|