Large update across server and web clients: regenerated/added many spacetime-client module bindings and input types (including new delete/work_delete input types and numerous procedure/reducer files), updates to server-rs API modules (bark_battle, jump_hop, wooden_fish, auth, module-runtime and shared contracts), and fixes in module-runtime behavior and domain logic. Frontend changes include new/updated components and tests (creative audio helpers, bark-battle/jump-hop/wooden-fish clients and views, unified generation pages, RPG entry views, and runtime shells), plus CSS and service updates. Documentation and operational notes updated (.hermes pitfalls and multiple PRD/docs) to cover daily-task refresh, banner asset fallback, recommend-key bug, and other platform behaviors. Tests and verification steps added/updated alongside these changes.
305 lines
12 KiB
TypeScript
305 lines
12 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_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>
|
||
<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>
|
||
);
|
||
}
|