feat: polish canvas chat UI and loading states
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-21 01:43:22 +08:00
parent 4a0880a4c5
commit de955db82f
2 changed files with 430 additions and 346 deletions

View File

@ -4,8 +4,8 @@ import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
import useSWR from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain } from "lucide-react";
import useSWR, { useSWRConfig } from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
@ -184,6 +184,95 @@ function formatMessageDateLabel(timestamp: number): string {
}).format(new Date(timestamp));
}
const CANVAS_IMAGE_ANALYSIS_TITLE = "图片分析";
const CANVAS_AGENT_SECTION_TITLES = new Set([
"需求理解",
CANVAS_IMAGE_ANALYSIS_TITLE,
"执行计划",
"输出设置",
]);
type CanvasAnalysisSection = {
before: string;
analysis: string;
after: string;
};
function normalizeCanvasAgentText(value: string): string {
return String(value || "")
.replace(/\r\n/g, "\n")
.replace(/^#{1,6}\s*/gm, "")
.replace(/^\*\*(.+)\*\*$/gm, "$1")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function normalizeCanvasSectionTitle(line: string): string {
let normalized = line.trim();
if (!normalized) return "";
normalized = normalized
.replace(/^#{1,6}\s*/, "")
.replace(/^[-*]\s*/, "")
.replace(/^\[(.+)\]$/, "$1")
.replace(/^【(.+)】$/, "$1")
.replace(/[::]+$/, "")
.trim();
return normalized;
}
function extractCanvasImageAnalysisSection(content: string): CanvasAnalysisSection | null {
const lines = String(content || "").split(/\r?\n/);
let startIndex = -1;
let inlineAnalysis = "";
for (let i = 0; i < lines.length; i += 1) {
const title = normalizeCanvasSectionTitle(lines[i]);
if (!title || !title.startsWith(CANVAS_IMAGE_ANALYSIS_TITLE)) continue;
startIndex = i;
const headingText = lines[i]
.trim()
.replace(/^#{1,6}\s*/, "")
.replace(/^[-*]\s*/, "")
.replace(/^\[(.+)\]$/, "$1")
.replace(/^【(.+)】$/, "$1")
.trim();
const inlineMatch = headingText.match(/^图片分析[::]\s*(.+)$/);
if (inlineMatch?.[1]) {
inlineAnalysis = inlineMatch[1].trim();
}
break;
}
if (startIndex < 0) return null;
let endIndex = lines.length;
for (let i = startIndex + 1; i < lines.length; i += 1) {
const title = normalizeCanvasSectionTitle(lines[i]);
if (!title || title.startsWith(CANVAS_IMAGE_ANALYSIS_TITLE)) continue;
if (CANVAS_AGENT_SECTION_TITLES.has(title)) {
endIndex = i;
break;
}
}
const analysisLines = lines.slice(startIndex + 1, endIndex);
if (inlineAnalysis) {
analysisLines.unshift(inlineAnalysis);
}
const analysis = normalizeCanvasAgentText(analysisLines.join("\n"));
if (!analysis) return null;
return {
before: normalizeCanvasAgentText(lines.slice(0, startIndex).join("\n")),
analysis,
after: normalizeCanvasAgentText(lines.slice(endIndex).join("\n")),
};
}
function normalizeVideoParamsForProvider(
params: VideoGenerationParams | null | undefined,
providerType?: string
@ -311,6 +400,12 @@ interface PickedObjectAttachment extends Attachment {
};
}
type MessageVote = {
chatId: string;
messageId: string;
isUpvoted: boolean;
};
interface CanvasChatSession {
id: string;
title: string;
@ -990,17 +1085,73 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
// 获取纯 UUID(去掉 canvas- 前缀)用于 API 调用
const rawChatId = toRawCanvasChatId(activeChatId);
const voteSWRKey = rawChatId ? `/api/vote?chatId=${rawChatId}` : null;
const { mutate } = useSWRConfig();
const [votingMessageId, setVotingMessageId] = useState<string | null>(null);
// 从 API 加载历史消息
const { data: historyData } = useSWR<{
messages: ChatMessage[];
}>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher);
const { data: messageVotes } = useSWR<MessageVote[]>(
voteSWRKey,
fetcher
);
// 获取选中的图片节点
const selectedImageNodes = nodes.filter(
n => selectedNodeIds.includes(n.id) && n.type === 'image' && (n.data as any)?.src
);
const handleAssistantVote = useCallback(
async (messageId: string, type: "up" | "down") => {
if (!rawChatId) return;
setVotingMessageId(messageId);
try {
const response = await fetch("/api/vote", {
method: "PATCH",
body: JSON.stringify({
chatId: rawChatId,
messageId,
type,
}),
});
if (!response.ok) {
throw new Error("Vote failed");
}
mutate<MessageVote[]>(
voteSWRKey,
(currentVotes) => {
const baseVotes = Array.isArray(currentVotes) ? currentVotes : [];
const filteredVotes = baseVotes.filter(
(vote) => vote.messageId !== messageId
);
return [
...filteredVotes,
{
chatId: rawChatId,
messageId,
isUpvoted: type === "up",
},
];
},
{ revalidate: false }
);
toast.success(type === "up" ? "已点赞" : "已点踩");
} catch (error) {
console.error("[CanvasChat] vote failed:", error);
toast.error(type === "up" ? "点赞失败,请稍后重试" : "点踩失败,请稍后重试");
} finally {
setVotingMessageId(null);
}
},
[mutate, rawChatId, voteSWRKey]
);
const getCanvasNodeAttachment = useCallback(
(node: any, fallbackName: string): PickedObjectAttachment | null => {
const src = (node?.data as any)?.src;
@ -4578,6 +4729,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
(!prevMessageTimestamp ||
getMessageDayKey(prevMessageTimestamp) !== getMessageDayKey(messageTimestamp))
);
const currentVote = messageVotes?.find(
(vote) => vote.messageId === message.id
);
const isVotingCurrentMessage = votingMessageId === message.id;
return (
<div key={message.id} className="space-y-3">
@ -4679,90 +4834,153 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
</div>
</div>
) : (
<div
className={cn(
"min-w-0 max-w-full text-[15px] text-[#2f3640]",
isImageOnlyAssistant
? "bg-transparent px-0 py-0"
: "bg-transparent px-0 py-0"
)}
>
{/* 消息内容 */}
<div className="w-full min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] leading-relaxed">
{message.parts?.map((part: any, i: number) => {
if (part.type === 'text') {
// 提取并显示图片/视频
const rawText = part.text || '';
const imageUrls = extractImageUrls(rawText);
const videoUrls = extractVideoUrls(rawText);
const textWithoutImages = stripMediaMarkup(rawText);
return (
<div key={i}>
{textWithoutImages && (
<span className="break-words [overflow-wrap:anywhere]">
{textWithoutImages}
</span>
)}
{imageUrls.length > 0 && (
<div className={cn("flex flex-wrap gap-2", textWithoutImages ? "mt-3" : "mt-0")}>
{imageUrls.map((url, idx) => (
<div
key={idx}
className={cn(
"relative group overflow-hidden border transition-all",
"rounded-2xl border-black/5 dark:border-white/10 bg-white/70 dark:bg-zinc-900/50",
"shadow-[0_8px_24px_-16px_rgba(15,23,42,0.45)] hover:shadow-[0_12px_28px_-14px_rgba(15,23,42,0.5)]"
)}
>
<img
src={url}
alt=""
className="block max-w-full h-auto cursor-zoom-in transition-transform duration-300 group-hover:scale-[1.02]"
style={{ maxHeight: "260px" }}
onClick={() => setFullscreenPreview({ url })}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
setFullscreenPreview({ url });
}}
/>
<>
<div
className={cn(
"min-w-0 max-w-full text-[15px] text-[#2f3640]",
isImageOnlyAssistant
? "bg-transparent px-0 py-0"
: "bg-transparent px-0 py-0"
)}
>
{/* 消息内容 */}
<div className="w-full min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] leading-relaxed">
{message.parts?.map((part: any, i: number) => {
if (part.type === 'text') {
// 提取并显示图片/视频
const rawText = part.text || '';
const imageUrls = extractImageUrls(rawText);
const videoUrls = extractVideoUrls(rawText);
const textWithoutImages = stripMediaMarkup(rawText);
const analysisSection = extractCanvasImageAnalysisSection(textWithoutImages);
const plainText = normalizeCanvasAgentText(textWithoutImages);
const renderPlainText = (text: string, key: string) => (
<div
key={key}
className="whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
>
{text}
</div>
);
return (
<div key={i}>
{analysisSection ? (
<div className="space-y-3">
{analysisSection.before
? renderPlainText(analysisSection.before, `before-${i}`)
: null}
<div className="my-2 rounded-[20px] bg-[#f4f5f7] px-5 py-4">
<div className="flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-2">
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-white text-[11px] font-semibold text-[#98a2b3] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.45)]">
Q.
</span>
<span className="text-base font-semibold tracking-tight text-[#9aa1ad]">
图片分析
</span>
</span>
<ChevronUp className="h-4 w-4 text-[#9aa1ad]" />
</div>
<div className="pt-3 text-[15px] font-medium leading-7 text-[#2f3640]">
{analysisSection.analysis}
</div>
</div>
))}
</div>
)}
{videoUrls.length > 0 && (
<div className={cn("space-y-2", textWithoutImages || imageUrls.length > 0 ? "mt-3" : "mt-0")}>
{videoUrls.map((url, idx) => (
<div
key={`video-${idx}`}
className="overflow-hidden rounded-2xl border border-black/10 bg-black/5 p-2"
>
<video
src={url}
controls
className="block w-full rounded-xl bg-black"
style={{ maxHeight: "320px" }}
/>
<a
href={url}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex text-xs text-blue-600 hover:underline"
{analysisSection.after
? renderPlainText(analysisSection.after, `after-${i}`)
: null}
</div>
) : plainText ? (
<span className="break-words [overflow-wrap:anywhere]">
{plainText}
</span>
) : null}
{imageUrls.length > 0 && (
<div className={cn("flex flex-wrap gap-2", textWithoutImages ? "mt-3" : "mt-0")}>
{imageUrls.map((url, idx) => (
<div
key={idx}
className={cn(
"relative group overflow-hidden border transition-all",
"rounded-2xl border-black/5 dark:border-white/10 bg-white/70 dark:bg-zinc-900/50",
"shadow-[0_8px_24px_-16px_rgba(15,23,42,0.45)] hover:shadow-[0_12px_28px_-14px_rgba(15,23,42,0.5)]"
)}
>
下载视频
</a>
</div>
))}
</div>
)}
</div>
);
}
return null;
})}
<img
src={url}
alt=""
className="block max-w-full h-auto cursor-zoom-in transition-transform duration-300 group-hover:scale-[1.02]"
style={{ maxHeight: "260px" }}
onClick={() => setFullscreenPreview({ url })}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
setFullscreenPreview({ url });
}}
/>
</div>
))}
</div>
)}
{videoUrls.length > 0 && (
<div className={cn("space-y-2", textWithoutImages || imageUrls.length > 0 ? "mt-3" : "mt-0")}>
{videoUrls.map((url, idx) => (
<div
key={`video-${idx}`}
className="overflow-hidden rounded-2xl border border-black/10 bg-black/5 p-2"
>
<video
src={url}
controls
className="block w-full rounded-xl bg-black"
style={{ maxHeight: "320px" }}
/>
<a
href={url}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex text-xs text-blue-600 hover:underline"
>
下载视频
</a>
</div>
))}
</div>
)}
</div>
);
}
return null;
})}
</div>
</div>
</div>
<div className="mt-1 flex items-center gap-1.5 text-[#8f96a3]">
<button
type="button"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-black/5",
currentVote?.isUpvoted && "text-[#22a06b] bg-emerald-50/80"
)}
title="点赞"
disabled={isVotingCurrentMessage}
onClick={() => void handleAssistantVote(message.id, "up")}
>
<ThumbsUp className="h-4 w-4" />
</button>
<button
type="button"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-black/5",
currentVote && !currentVote.isUpvoted && "text-[#d94848] bg-rose-50/80"
)}
title="点踩"
disabled={isVotingCurrentMessage}
onClick={() => void handleAssistantVote(message.id, "down")}
>
<ThumbsDown className="h-4 w-4" />
</button>
</div>
</>
)}
</div>
</div>
@ -4781,9 +4999,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
{isLoading && !shouldShowImageLoading && (
<div className="flex w-full justify-start">
<div className="rounded-xl bg-[#f1f2f4] px-3 py-2 text-card-foreground flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">正在思考中...</span>
<div className="flex items-center gap-2 text-[#8e97a5]">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-[16px]">思考中</span>
</div>
</div>
)}

View File

@ -1,283 +1,149 @@
"use client";
import { memo } from "react";
import { SparklesIcon } from "./icons";
import { ChevronDown, Grid2x2, CircleDot } from "lucide-react";
import { cn } from "@/lib/utils";
// 预定义的颜色主题
const COLOR_THEMES = {
purple: {
ping: "bg-purple-500/20",
pulse: "bg-purple-500/10",
ring: "ring-purple-500/30",
text: "text-purple-500",
dot: "bg-purple-500",
gradientFrom: "from-purple-400",
gradientVia: "via-pink-400",
gradientTo: "to-blue-400",
overlay1From: "from-purple-500/40",
overlay1To: "to-pink-500/40",
overlay2From: "from-blue-500/30",
overlay2To: "to-purple-500/30",
},
blue: {
ping: "bg-blue-500/20",
pulse: "bg-blue-500/10",
ring: "ring-blue-500/30",
text: "text-blue-500",
dot: "bg-blue-500",
gradientFrom: "from-blue-400",
gradientVia: "via-cyan-400",
gradientTo: "to-teal-400",
overlay1From: "from-blue-500/40",
overlay1To: "to-cyan-500/40",
overlay2From: "from-teal-500/30",
overlay2To: "to-blue-500/30",
},
green: {
ping: "bg-green-500/20",
pulse: "bg-green-500/10",
ring: "ring-green-500/30",
text: "text-green-500",
dot: "bg-green-500",
gradientFrom: "from-green-400",
gradientVia: "via-emerald-400",
gradientTo: "to-teal-400",
overlay1From: "from-green-500/40",
overlay1To: "to-emerald-500/40",
overlay2From: "from-teal-500/30",
overlay2To: "to-green-500/30",
},
orange: {
ping: "bg-orange-500/20",
pulse: "bg-orange-500/10",
ring: "ring-orange-500/30",
text: "text-orange-500",
dot: "bg-orange-500",
gradientFrom: "from-orange-400",
gradientVia: "via-amber-400",
gradientTo: "to-yellow-400",
overlay1From: "from-orange-500/40",
overlay1To: "to-amber-500/40",
overlay2From: "from-yellow-500/30",
overlay2To: "to-orange-500/30",
},
red: {
ping: "bg-red-500/20",
pulse: "bg-red-500/10",
ring: "ring-red-500/30",
text: "text-red-500",
dot: "bg-red-500",
gradientFrom: "from-red-400",
gradientVia: "via-rose-400",
gradientTo: "to-pink-400",
overlay1From: "from-red-500/40",
overlay1To: "to-rose-500/40",
overlay2From: "from-pink-500/30",
overlay2To: "to-red-500/30",
},
} as const;
type ColorTheme = keyof typeof COLOR_THEMES;
type ColorTheme = "purple" | "blue" | "green" | "orange" | "red";
interface ImageGenerationLoadingProps {
imageCount?: number; // 生成图片数量,默认 1
maxImages?: number; // 最大图片数限制,默认 6
colorTheme?: ColorTheme; // 颜色主题,默认 purple
modelName?: string; // 模型名称,用于显示
providerType?: string; // 提供商类型,用于自动选择颜色
imageCount?: number;
maxImages?: number;
colorTheme?: ColorTheme;
modelName?: string;
providerType?: string;
phase?: "idle" | "queued" | "processing" | "finalizing" | "failed";
elapsedSeconds?: number;
}
/**
* 通用图片生成加载占位符组件
* 支持配置颜色主题和图片数量
*/
export const ImageGenerationLoading = memo(({
imageCount = 1,
maxImages = 6,
colorTheme,
modelName,
providerType,
phase = "processing",
elapsedSeconds = 0,
}: ImageGenerationLoadingProps) => {
// 根据 providerType 自动选择颜色主题
const getAutoColorTheme = (): ColorTheme => {
if (colorTheme) return colorTheme;
switch (providerType) {
case "gemini":
return "purple";
case "alibaba-image":
return "orange";
case "ruxa-image":
return "green";
case "fal-image":
return "blue";
case "volcano-vision":
return "blue";
default:
return "purple";
}
};
const theme = COLOR_THEMES[getAutoColorTheme()];
// 确保数量在有效范围内
const count = Math.min(Math.max(imageCount, 1), maxImages);
// 获取显示名称
const displayName = modelName || (providerType === "alibaba-image" ? "千问图片编辑" :
providerType === "ruxa-image" ? "RUXA 图片编辑" :
providerType === "fal-image" ? "FAL 图片编辑" :
providerType === "volcano-vision" ? "即梦4.5" : "AI");
const PROVIDER_MODEL_LABEL: Record<string, string> = {
"alibaba-image": "千问图片",
"ruxa-image": "RUXA 图片",
"fal-image": "FAL 图片",
"volcano-vision": "即梦 4.5",
gemini: "Gemini 图片",
};
const phaseTitle =
phase === "queued"
? "正在排队准备生成"
: phase === "finalizing"
? "图片生成完成,正在加载结果"
: "正在生成图片";
const phaseHint =
phase === "queued"
? `${displayName} 正在排队,请稍候...`
: phase === "finalizing"
? `${displayName} 正在同步结果到聊天记录...`
: `${displayName} 正在创作 ${count} 张图片,请稍候...`;
const elapsedLabel = elapsedSeconds > 0 ? `已等待 ${elapsedSeconds} 秒` : null;
return (
<div
className="group/message fade-in w-full animate-in duration-300"
data-role="assistant"
data-testid="message-assistant-image-loading"
>
<div className="flex items-start justify-start gap-3">
{/* 头像 */}
<div className="relative -mt-1 flex size-8 shrink-0 items-center justify-center">
<div className={`absolute inset-0 rounded-full ${theme.ping} animate-ping`} />
<div className={`absolute inset-0 rounded-full ${theme.pulse} animate-pulse`} />
<div className={`relative flex size-8 items-center justify-center rounded-full bg-background ring-2 ${theme.ring}`}>
<div className={`animate-spin ${theme.text}`}>
<SparklesIcon size={14} />
</div>
</div>
</div>
const PHASE_TITLE: Record<NonNullable<ImageGenerationLoadingProps["phase"]>, string> = {
idle: "准备中",
queued: "排队中",
processing: "生成中",
finalizing: "加载结果",
failed: "生成失败",
};
<div className="flex flex-col gap-3">
{/* 文本提示 */}
<div className="flex items-center gap-1.5">
<span className="text-base font-medium text-foreground">
{phaseTitle}
</span>
<span className="flex gap-1">
<span
className={`h-2 w-2 rounded-full ${theme.dot} animate-bounce`}
style={{ animationDelay: "0ms" }}
/>
<span
className={`h-2 w-2 rounded-full ${theme.dot} animate-bounce`}
style={{ animationDelay: "150ms" }}
/>
<span
className={`h-2 w-2 rounded-full ${theme.dot} animate-bounce`}
style={{ animationDelay: "300ms" }}
/>
</span>
const PHASE_HINT: Record<NonNullable<ImageGenerationLoadingProps["phase"]>, string> = {
idle: "任务已创建,正在初始化生成参数。",
queued: "任务已进入队列,请稍候。",
processing: "模型正在生成图片,请稍候。",
finalizing: "图片已生成完成,正在同步到对话。",
failed: "生成失败,请重试。",
};
export const ImageGenerationLoading = memo(
({
imageCount = 1,
maxImages = 6,
modelName,
providerType,
phase = "processing",
elapsedSeconds = 0,
}: ImageGenerationLoadingProps) => {
const count = Math.min(Math.max(imageCount, 1), maxImages);
const resolvedModelName =
modelName?.trim() || PROVIDER_MODEL_LABEL[providerType || ""] || "AI 图片模型";
const estimateSeconds = Math.max(45, Math.min(180, count * 60));
const estimateMinutes = Math.max(1, Math.ceil(estimateSeconds / 60));
const elapsedLabel =
elapsedSeconds > 0 ? `已等待 ${elapsedSeconds}s` : `预计 ${estimateSeconds}s`;
const isFailed = phase === "failed";
return (
<div
className="group/message fade-in w-full animate-in duration-300"
data-role="assistant"
data-testid="message-assistant-image-loading"
>
<div className="w-full max-w-[760px] space-y-3">
<div className="flex items-center gap-2 text-[13px] text-[#9aa3b2]">
<ChevronDown className="h-4 w-4" />
<span className="font-medium">开始生成</span>
</div>
{/* 图片占位符 - 横向排列,每个 170x200px */}
<div className="flex gap-3 flex-wrap">
{Array.from({ length: count }, (_, index) => index).map((index) => (
<div
key={index}
className="relative overflow-hidden rounded-xl"
style={{ width: "170px", height: "200px" }}
>
{/* 底层渐变背景 */}
<div className={`absolute inset-0 bg-gradient-to-br ${theme.gradientFrom} ${theme.gradientVia} ${theme.gradientTo} opacity-60`} />
{/* 动态模糊叠加层 1 */}
<div
className={`absolute inset-0 bg-gradient-to-tr ${theme.overlay1From} via-transparent ${theme.overlay1To} animate-pulse`}
style={{ animationDuration: "2s", animationDelay: `${index * 0.3}s` }}
/>
{/* 动态模糊叠加层 2 */}
<div
className={`absolute inset-0 bg-gradient-to-bl ${theme.overlay2From} via-transparent ${theme.overlay2To} animate-pulse`}
style={{ animationDuration: "2.5s", animationDelay: `${0.5 + index * 0.3}s` }}
/>
{/* 噪点纹理效果 */}
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
}}
/>
{/* 模糊叠加 */}
<div className="absolute inset-0 backdrop-blur-sm" />
{/* 中心图标 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative">
{/* 外圈光晕 */}
<div className="absolute -inset-4 rounded-full bg-white/20 animate-ping" style={{ animationDuration: "1.5s", animationDelay: `${index * 0.2}s` }} />
{/* 图片图标 */}
<svg
className="h-10 w-10 text-white/80 animate-pulse"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
style={{ animationDuration: "1.5s", animationDelay: `${index * 0.2}s` }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<div className="text-[16px] leading-8 text-[#3a4352]">
现在开始生成设计图,使用 {resolvedModelName} 模型,预计需要 {estimateMinutes} 分钟。
</div>
<div className="flex items-center gap-2 text-[#a8b1bf]">
<CircleDot className="h-3.5 w-3.5 fill-current" />
<span className="text-[16px]">{resolvedModelName}</span>
</div>
<div className="text-[42px] font-semibold text-[#37404d]">{PHASE_TITLE[phase]}</div>
<div
className={cn(
"relative overflow-hidden rounded-[22px] border border-[#e1e4ea] bg-[#eaedf2]",
count === 1 ? "h-[620px] w-full max-w-[680px]" : "grid gap-3 p-3",
count > 1 && "grid-cols-2"
)}
>
{count === 1 ? (
<>
<div className="absolute inset-0 bg-gradient-to-r from-[#e9ecf1] via-[#dfe4eb] to-[#e9ecf1] opacity-90" />
<div className="generation-skeleton-shimmer absolute inset-0 bg-[linear-gradient(105deg,transparent_0%,rgba(255,255,255,0.45)_48%,transparent_100%)]" />
</>
) : (
Array.from({ length: count }).map((_, index) => (
<div
key={index}
className="relative h-[220px] overflow-hidden rounded-2xl border border-[#dfe3e9] bg-[#e7ebf0]"
>
<div className="absolute inset-0 bg-gradient-to-r from-[#e7ebf0] via-[#dce2ea] to-[#e7ebf0]" />
<div
className="generation-skeleton-shimmer absolute inset-0 bg-[linear-gradient(105deg,transparent_0%,rgba(255,255,255,0.42)_48%,transparent_100%)]"
style={{ animationDelay: `${index * 0.2}s` }}
/>
</div>
{/* 流光效果 */}
<div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full animate-shimmer"
style={{
animation: "shimmer 2s infinite",
animationDelay: `${index * 0.4}s`,
}}
/>
</div>
))}
))
)}
</div>
{/* 提示文字 */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{phaseHint}</span>
{elapsedLabel ? <span className="text-foreground/80">{elapsedLabel}</span> : null}
<div className="flex items-center gap-2 pt-1 text-[18px] text-[#8c95a3]">
<Grid2x2 className="h-5 w-5" />
<span className="font-medium">{isFailed ? "生成失败" : "设计图生成中"}</span>
</div>
<div className="space-y-0.5 text-[#a0a8b6]">
<div className="text-[15px] font-medium">
使用 {resolvedModelName} 生成图片,预计 {estimateSeconds}
</div>
{(phase !== "processing" || elapsedSeconds > 0) && (
<div className="text-[13px]">
{PHASE_HINT[phase]} {elapsedLabel}
</div>
)}
</div>
</div>
</div>
{/* 流光动画样式 */}
<style jsx>{`
@keyframes shimmer {
0% {
<style jsx>{`
.generation-skeleton-shimmer {
transform: translateX(-100%);
animation: generation-shimmer 1.8s ease-in-out infinite;
}
100% {
transform: translateX(100%);
@keyframes generation-shimmer {
0% {
transform: translateX(-110%);
}
100% {
transform: translateX(110%);
}
}
}
`}</style>
</div>
);
});
`}</style>
</div>
);
}
);
ImageGenerationLoading.displayName = "ImageGenerationLoading";
export default ImageGenerationLoading;