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

335 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}