Files
Genarrative/src/components/GenerationProgressHero.tsx
T
kdletters a8012109ae 继续收口生成页顶部返回按钮
抽出生成页共用返回按钮壳并复用共享图标按钮能力
将自定义世界生成页接入共享返回按钮壳
将汪汪声浪生成页接入共享返回按钮壳并保留禁用态
补充两个生成页返回按钮的样式与交互回归测试
2026-06-11 04:23:55 +08:00

335 lines
13 KiB
TypeScript

import { ArrowLeft, Clock3, Hourglass } from 'lucide-react';
import { useEffect, useId, useRef } from 'react';
import generationHeroVideo from '../../media/create_bg_video.mp4';
import { PlatformIconButton } from './common/PlatformIconButton';
import { PlatformProgressBar } from './common/PlatformProgressBar';
const GENERATION_PROGRESS_RING_GAP_DEGREES = 90;
const GENERATION_PROGRESS_RING_BOTTOM_DEGREES = 90;
// 中文注释:SVG 圆从 3 点钟方向起笔;起点放在 135deg,可让 90deg 开口居中落在正下方。
const GENERATION_PROGRESS_RING_START_DEGREES =
GENERATION_PROGRESS_RING_BOTTOM_DEGREES +
GENERATION_PROGRESS_RING_GAP_DEGREES / 2;
const GENERATION_PROGRESS_RING_FILL_START_DEGREES =
GENERATION_PROGRESS_RING_START_DEGREES;
const GENERATION_PROGRESS_RING_SWEEP_DEGREES =
360 - GENERATION_PROGRESS_RING_GAP_DEGREES;
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;
};
type GenerationHeaderBackButtonProps = {
label: string;
onClick: () => void;
disabled?: boolean;
disabledOpacity?: number;
className?: string;
};
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 GenerationHeaderBackButton({
label,
onClick,
disabled = false,
disabledOpacity,
className,
}: GenerationHeaderBackButtonProps) {
return (
<PlatformIconButton
label={label}
title={label}
variant="darkMini"
onClick={onClick}
disabled={disabled}
className={[
'gap-2 rounded-full !border-transparent !bg-transparent px-0 py-2 text-xs font-black !text-[#171411] shadow-none hover:!bg-transparent hover:!text-[#171411] sm:text-sm',
className,
]
.filter(Boolean)
.join(' ')}
style={disabled && disabledOpacity != null ? { opacity: disabledOpacity } : undefined}
icon={<ArrowLeft className="h-5 w-5 shrink-0" strokeWidth={2.6} />}
>
<span className="break-keep">{label}</span>
</PlatformIconButton>
);
}
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) * GENERATION_PROGRESS_RING_SWEEP_DEGREES,
);
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 min-w-0 max-w-[60rem] flex-col items-center px-0 pb-1 pt-1 sm:pt-4">
<div className="sr-only">
{title}
{phaseLabel ? ` ${phaseLabel}` : ''}
</div>
<div className="relative w-full min-w-0 max-w-[56rem] sm:max-w-[60rem]">
<div
className="relative mx-auto aspect-square w-[min(400px,calc(100%_-_0.75rem))] max-w-full shrink-0 overflow-visible rounded-full"
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-fill-start-degrees={
GENERATION_PROGRESS_RING_FILL_START_DEGREES
}
data-ring-sweep-degrees={GENERATION_PROGRESS_RING_SWEEP_DEGREES}
data-ring-fill-degrees={ringDegrees}
data-ring-gap-degrees={GENERATION_PROGRESS_RING_GAP_DEGREES}
>
<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_FILL_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 className="relative z-20 mt-3 grid w-full grid-cols-2 gap-2 px-0 sm:absolute sm:inset-0 sm:mt-0 sm:block sm:px-0">
<div
className="w-full 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:absolute sm:left-0 sm:top-1/2 sm:w-[8rem] sm:-translate-y-1/2 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="w-full 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:absolute sm:right-0 sm:top-1/2 sm:w-[8rem] sm:-translate-y-1/2 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>
</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>
<PlatformProgressBar
value={safeProgress}
size="sm"
ariaLabel={`${label} 进度`}
className="mt-4 bg-[#f5eee8]"
fillClassName="bg-[linear-gradient(90deg,#ef7a1f_0%,#e25f18_64%,#f0b07e_100%)]"
fillStyle={{ transitionDuration: '450ms' }}
/>
</div>
);
}