Files
Genarrative/src/components/GenerationProgressHero.tsx
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

298 lines
12 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 { Clock3, Hourglass } from 'lucide-react';
import { useEffect, useId, useRef } from 'react';
import generationHeroVideo from '../../media/create_bg_video.mp4';
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;
};
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) * 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>
);
}