153 lines
5.2 KiB
TypeScript
153 lines
5.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type GenerationPhase = "idle" | "queued" | "processing" | "finalizing" | "failed" | "completed";
|
|
|
|
interface GenerationDotPlaceholderProps {
|
|
className?: string;
|
|
title?: string;
|
|
phase?: GenerationPhase;
|
|
progress?: number | null;
|
|
compact?: boolean;
|
|
showProgress?: boolean;
|
|
}
|
|
|
|
const PHASE_TITLES: Record<GenerationPhase, string> = {
|
|
idle: "正在创建图片",
|
|
queued: "等待开始生成",
|
|
processing: "生成初稿中",
|
|
finalizing: "正在润饰细节",
|
|
failed: "生成失败",
|
|
completed: "生成完成",
|
|
};
|
|
|
|
const ROTATING_TITLES = ["正在创建图片", "生成初稿中", "正在润饰细节"];
|
|
|
|
function clampProgress(value: number) {
|
|
if (!Number.isFinite(value)) return 0;
|
|
return Math.max(0, Math.min(100, Math.round(value)));
|
|
}
|
|
|
|
function estimateProgress(phase: GenerationPhase, elapsedMs: number) {
|
|
if (phase === "completed") return 100;
|
|
if (phase === "failed") return 100;
|
|
if (phase === "finalizing") return 96;
|
|
if (phase === "queued") {
|
|
return clampProgress(6 + Math.min(12, elapsedMs / 3500));
|
|
}
|
|
if (phase === "processing") {
|
|
const eased = 1 - Math.exp(-elapsedMs / 85_000);
|
|
return clampProgress(18 + eased * 78);
|
|
}
|
|
return clampProgress(Math.min(8, elapsedMs / 1000));
|
|
}
|
|
|
|
export function GenerationDotPlaceholder({
|
|
className,
|
|
title,
|
|
phase = "processing",
|
|
progress,
|
|
compact = false,
|
|
showProgress = true,
|
|
}: GenerationDotPlaceholderProps) {
|
|
const [elapsedMs, setElapsedMs] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const startedAt = Date.now();
|
|
const timer = window.setInterval(() => {
|
|
setElapsedMs(Date.now() - startedAt);
|
|
}, 600);
|
|
return () => window.clearInterval(timer);
|
|
}, [phase]);
|
|
|
|
const resolvedProgress = clampProgress(
|
|
typeof progress === "number" ? progress : estimateProgress(phase, elapsedMs)
|
|
);
|
|
const rotatingTitle = ROTATING_TITLES[Math.floor(elapsedMs / 3200) % ROTATING_TITLES.length];
|
|
const resolvedTitle = title || (phase === "processing" ? rotatingTitle : PHASE_TITLES[phase]);
|
|
const dotOpacity = phase === "failed" ? 0.22 : 0.34;
|
|
|
|
const progressStyle = useMemo(
|
|
() => ({ width: `${resolvedProgress}%` }),
|
|
[resolvedProgress]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative isolate overflow-hidden rounded-[32px] bg-[#f4f4f4] text-[#565b63]",
|
|
compact ? "min-h-[190px] p-5" : "min-h-[360px] p-8",
|
|
className
|
|
)}
|
|
>
|
|
<div className="relative z-10 flex h-full min-h-[inherit] flex-col">
|
|
<div className={cn("font-semibold tracking-tight", compact ? "text-[15px]" : "text-[22px]")}>
|
|
{resolvedTitle}
|
|
</div>
|
|
<div className="flex-1" />
|
|
{showProgress ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-[12px] font-medium text-[#7d838b]">
|
|
<span>{phase === "failed" ? "已停止" : "生成进度"}</span>
|
|
<span>{resolvedProgress}%</span>
|
|
</div>
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-white/80 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)]">
|
|
<div
|
|
className={cn(
|
|
"h-full rounded-full transition-[width] duration-700 ease-out",
|
|
phase === "failed" ? "bg-[#c7ccd3]" : "bg-[#9fa4aa]"
|
|
)}
|
|
style={progressStyle}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div
|
|
className="generation-dot-field generation-dot-field-a absolute inset-0"
|
|
style={{ opacity: dotOpacity }}
|
|
/>
|
|
<div
|
|
className="generation-dot-field generation-dot-field-b absolute inset-0"
|
|
style={{ opacity: dotOpacity * 0.9 }}
|
|
/>
|
|
<style jsx>{`
|
|
.generation-dot-field {
|
|
background-image: radial-gradient(circle, rgba(40, 44, 50, 0.42) 1.35px, transparent 1.55px);
|
|
background-size: ${compact ? "14px 14px" : "18px 18px"};
|
|
animation: generation-dot-drift 7.5s ease-in-out infinite alternate;
|
|
mask-image:
|
|
radial-gradient(circle at 32% 69%, black 0 19%, rgba(0, 0, 0, 0.72) 27%, transparent 48%),
|
|
radial-gradient(circle at 68% 36%, black 0 15%, rgba(0, 0, 0, 0.58) 25%, transparent 45%);
|
|
-webkit-mask-image:
|
|
radial-gradient(circle at 32% 69%, black 0 19%, rgba(0, 0, 0, 0.72) 27%, transparent 48%),
|
|
radial-gradient(circle at 68% 36%, black 0 15%, rgba(0, 0, 0, 0.58) 25%, transparent 45%);
|
|
mask-composite: add;
|
|
-webkit-mask-composite: source-over;
|
|
}
|
|
|
|
.generation-dot-field-b {
|
|
background-size: ${compact ? "18px 18px" : "23px 23px"};
|
|
animation-duration: 9s;
|
|
animation-direction: alternate-reverse;
|
|
filter: blur(0.1px);
|
|
transform: translate3d(6px, -4px, 0);
|
|
}
|
|
|
|
@keyframes generation-dot-drift {
|
|
0% {
|
|
background-position: 0 0;
|
|
transform: translate3d(-3px, 2px, 0) scale(0.985);
|
|
}
|
|
100% {
|
|
background-position: 26px 18px;
|
|
transform: translate3d(5px, -4px, 0) scale(1.015);
|
|
}
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|