ai-chatbot/components/home/lovart-home.tsx
Your Name 3f3523f1bb
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
新版首页支持弹窗登录并接入Google后台配置
2026-02-14 00:39:46 +08:00

1382 lines
51 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.

"use client";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
import {
ArrowRight,
ArrowUp,
Bell,
ChevronDown,
Clapperboard,
Folder,
Heart,
Home,
ImageIcon,
Info,
Loader2,
Monitor,
Palette,
Plus,
Send,
Settings,
Sparkles,
Star,
Upload,
User,
X,
Zap,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { fetcher } from "@/lib/utils";
import {
type CanvasLaunchAttachment,
getCanvasLaunchStorageKey,
} from "@/lib/canvas-launch";
import type { MembershipConfig } from "@/lib/membership/config";
type HomeUser = {
id: string;
email?: string | null;
name?: string | null;
image?: string | null;
type?: string | null;
};
type CanvasProject = {
id: string;
title: string;
thumbnail?: string | null;
createdAt?: string;
updatedAt?: string;
};
type FeaturedImage = {
id: string;
thumbnailUrl: string;
originalUrl: string;
prompt?: string | null;
featuredCategory?: string | null;
user?: {
name?: string;
department?: string;
};
};
type FeaturedResponse = {
images: FeaturedImage[];
pagination?: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
};
type ModelOption = {
id: string;
modelId: string;
displayName: string;
providerType: string;
supportsImageGen?: boolean;
};
type PublicAuthSettings = {
isDingTalkEnabled?: boolean;
isGoogleEnabled?: boolean;
isGoogleConfigured?: boolean;
};
type MembershipSummaryResponse = {
config: MembershipConfig;
currentPlanId: string;
currentPlan: {
id: string;
name: string;
monthlyPrice: number;
annualPrice: number;
monthlyCredits: number;
dailyFreeCredits: number;
features: string[];
badge?: string;
highlighted?: boolean;
};
summary: {
currentCredits: number;
dailyFreeCredits: number;
usedThisMonth: number;
monthlyLimit: number | null;
remainingThisMonth: number | null;
totalImages: number;
};
};
type LovartHomeProps = {
user: HomeUser | null;
isAuthenticated: boolean;
siteName: string;
siteLogo?: string | null;
surpriseCategories: string[];
heroPromoText?: string | null;
heroPromoActionText?: string | null;
heroTitleText?: string | null;
heroSubtitleText?: string | null;
};
type UploadFileItem = {
id: string;
file: File;
previewUrl: string;
};
const DISCOVERY_DEFAULT_TABS = [
"品牌设计",
"海报与广告",
"插画",
"UI设计",
"角色设计",
"影片与分镜",
"产品设计",
"建筑设计",
];
const PROMPT_TAGS = [
{ label: "Nano Banana Pro", icon: Sparkles, highlighted: true },
{ label: "Design", icon: ImageIcon, highlighted: false },
{ label: "Branding", icon: Star, highlighted: false },
{ label: "Illustration", icon: Palette, highlighted: false },
{ label: "E-Commerce", icon: Monitor, highlighted: false },
{ label: "Video", icon: Clapperboard, highlighted: false },
];
const MASONRY_HEIGHTS = [420, 290, 340, 470, 300, 360, 430, 320];
const FEATURED_PAGE_SIZE = 24;
function formatRelativeTime(value?: string) {
if (!value) return "更新于今天";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "更新于今天";
const diffMs = Date.now() - date.getTime();
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diffMs < hour) return `更新于 ${Math.max(1, Math.round(diffMs / minute))} 分钟前`;
if (diffMs < day) return `更新于 ${Math.round(diffMs / hour)} 小时前`;
return `更新于 ${Math.round(diffMs / day)} 天前`;
}
function formatPrice(currency: string, value: number) {
if (value <= 0) return "免费";
return `${currency}${value}`;
}
export function LovartHome({
user,
isAuthenticated,
siteName,
siteLogo,
surpriseCategories,
heroPromoText,
heroPromoActionText,
heroTitleText,
heroSubtitleText,
}: LovartHomeProps) {
const router = useRouter();
const [prompt, setPrompt] = useState("");
const [files, setFiles] = useState<UploadFileItem[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeCategory, setActiveCategory] = useState("全部");
const [showAllProjects, setShowAllProjects] = useState(false);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [billingCycle, setBillingCycle] = useState<"monthly" | "annual">("monthly");
const [selectedModelId, setSelectedModelId] = useState<string>("");
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
});
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [loginEmail, setLoginEmail] = useState("");
const [loginPassword, setLoginPassword] = useState("");
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [typedPreviewText, setTypedPreviewText] = useState("");
const [previewCursorVisible, setPreviewCursorVisible] = useState(true);
const projectSectionRef = useRef<HTMLDivElement | null>(null);
const featuredLoadMoreRef = useRef<HTMLDivElement | null>(null);
const filesRef = useRef<UploadFileItem[]>([]);
const { data: canvasData, mutate: mutateCanvas } = useSWR<{
success: boolean;
data: CanvasProject[];
}>(isAuthenticated ? "/api/canvas" : null, fetcher);
const { data: featuredPages, size: featuredPageCount, setSize: setFeaturedPageCount, isValidating: isFeaturedValidating } =
useSWRInfinite<FeaturedResponse>(
(pageIndex, previousPageData) => {
if (
previousPageData?.pagination &&
previousPageData.pagination.page >= previousPageData.pagination.totalPages
) {
return null;
}
return `/api/featured?page=${pageIndex + 1}&pageSize=${FEATURED_PAGE_SIZE}`;
},
fetcher,
{
revalidateFirstPage: false,
parallel: false,
}
);
const { data: membershipData } = useSWR<MembershipSummaryResponse>(
isAuthenticated ? "/api/user/membership-summary" : null,
fetcher
);
const { data: modelsData } = useSWR<{
models: ModelOption[];
}>(isAuthenticated ? "/api/models" : null, fetcher);
const { data: siteSettingsData } = useSWR<{
canvasDefaultModelId?: string | null;
lovartComposerPreviewTexts?: string[];
}>("/api/site-settings", fetcher);
const { data: authSettingsData } = useSWR<PublicAuthSettings>(
"/api/auth-settings",
fetcher
);
const membershipConfig = membershipData?.config;
const membershipPlans = membershipConfig?.plans || [];
const currentPlan = membershipData?.currentPlan;
const groupedModelRows = useMemo(() => {
const rows = membershipConfig?.modelRows || [];
return rows.reduce<Record<string, typeof rows>>((acc, row) => {
if (!acc[row.category]) {
acc[row.category] = [];
}
acc[row.category].push(row);
return acc;
}, {});
}, [membershipConfig?.modelRows]);
const imageModels = useMemo(
() => (modelsData?.models || []).filter((model) => model.supportsImageGen),
[modelsData?.models]
);
const allComposerModels = useMemo<ModelOption[]>(
() => [
{
id: "auto",
modelId: "auto",
displayName: "智能自动",
providerType: "auto",
supportsImageGen: true,
},
...imageModels,
],
[imageModels]
);
const currentComposerModel = useMemo(
() => allComposerModels.find((model) => model.id === selectedModelId),
[allComposerModels, selectedModelId]
);
const discoveryTabs = useMemo(() => {
const merged = [
"全部",
...surpriseCategories.filter((item) => item && item !== "全部"),
...DISCOVERY_DEFAULT_TABS,
];
return Array.from(new Set(merged)).slice(0, 12);
}, [surpriseCategories]);
const composerPreviewTexts = useMemo(() => {
const configured = (siteSettingsData?.lovartComposerPreviewTexts || [])
.map((item) => String(item || "").trim())
.filter(Boolean);
if (configured.length > 0) {
return configured;
}
return [
"让 Lovart 打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
];
}, [siteSettingsData?.lovartComposerPreviewTexts]);
const loadedFeaturedImages = useMemo(() => {
return (featuredPages || []).flatMap((page) => page.images || []);
}, [featuredPages]);
const featuredPagination = featuredPages?.[featuredPages.length - 1]?.pagination;
const hasMoreFeatured = Boolean(
featuredPagination && featuredPagination.page < featuredPagination.totalPages
);
const isLoadingFeatured = !featuredPages && isFeaturedValidating;
const isLoadingMoreFeatured =
isFeaturedValidating &&
!!featuredPages &&
featuredPageCount > 0 &&
featuredPageCount > featuredPages.length;
const featuredItems = useMemo(() => {
const images = loadedFeaturedImages;
if (activeCategory === "全部") {
return images;
}
return images.filter((item) =>
item.featuredCategory
? String(item.featuredCategory) === activeCategory
: String(item.prompt || "").toLowerCase().includes(activeCategory.toLowerCase())
);
}, [loadedFeaturedImages, activeCategory]);
const recentProjects = useMemo(() => {
const projects = canvasData?.data || [];
return showAllProjects ? projects : projects.slice(0, 4);
}, [canvasData?.data, showAllProjects]);
const hasMoreProjects = useMemo(() => {
return (canvasData?.data || []).length > 4;
}, [canvasData?.data]);
const canUseGoogleLogin = Boolean(
authSettingsData?.isGoogleEnabled && authSettingsData?.isGoogleConfigured
);
const canUseDingTalkLogin = Boolean(authSettingsData?.isDingTalkEnabled);
const hasThirdPartyLoginOptions = canUseGoogleLogin || canUseDingTalkLogin;
useEffect(() => {
if (selectedModelId) return;
const defaultModelId = siteSettingsData?.canvasDefaultModelId || "auto";
setSelectedModelId(defaultModelId);
}, [selectedModelId, siteSettingsData?.canvasDefaultModelId]);
useEffect(() => {
if (!selectedModelId) return;
if (!modelsData) return;
if (allComposerModels.length === 0) return;
if (allComposerModels.some((model) => model.id === selectedModelId)) return;
setSelectedModelId("auto");
}, [allComposerModels, selectedModelId, modelsData]);
useEffect(() => {
if (!isAuthenticated) return;
setLoginDialogOpen(false);
}, [isAuthenticated]);
useEffect(() => {
if (!loginDialogOpen) {
setIsLoggingIn(false);
setLoginPassword("");
}
}, [loginDialogOpen]);
useEffect(() => {
if (typeof window === "undefined") return;
try {
const saved = localStorage.getItem("lovart-home-image-params");
if (!saved) return;
const parsed = JSON.parse(saved) as Partial<ImageGenerationParams>;
setImageParams((prev) => ({ ...prev, ...parsed }));
} catch (error) {
console.error("[LovartHome] Failed to load image params:", error);
}
}, []);
const handleImageParamsChange = (nextParams: ImageGenerationParams) => {
setImageParams(nextParams);
if (typeof window === "undefined") return;
try {
localStorage.setItem("lovart-home-image-params", JSON.stringify(nextParams));
} catch (error) {
console.error("[LovartHome] Failed to save image params:", error);
}
};
useEffect(() => {
if (!featuredLoadMoreRef.current || !hasMoreFeatured) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0]?.isIntersecting || isLoadingMoreFeatured) return;
setFeaturedPageCount((current) => current + 1);
},
{ rootMargin: "1000px 0px 1200px 0px" }
);
observer.observe(featuredLoadMoreRef.current);
return () => observer.disconnect();
}, [hasMoreFeatured, isLoadingMoreFeatured, setFeaturedPageCount]);
useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout> | null = null;
if (composerPreviewTexts.length === 0) {
setTypedPreviewText("");
return () => {
if (timer) clearTimeout(timer);
};
}
const runTyping = (textIndex: number) => {
if (disposed) return;
const currentText = composerPreviewTexts[textIndex] || "";
let charIndex = 0;
const typeNextChar = () => {
if (disposed) return;
setTypedPreviewText(currentText.slice(0, charIndex));
charIndex += 1;
if (charIndex <= currentText.length) {
timer = setTimeout(typeNextChar, 55);
return;
}
timer = setTimeout(() => {
runTyping((textIndex + 1) % composerPreviewTexts.length);
}, 1300);
};
typeNextChar();
};
runTyping(0);
return () => {
disposed = true;
if (timer) clearTimeout(timer);
};
}, [composerPreviewTexts]);
useEffect(() => {
const blinkTimer = setInterval(() => {
setPreviewCursorVisible((value) => !value);
}, 450);
return () => clearInterval(blinkTimer);
}, []);
useEffect(() => {
filesRef.current = files;
}, [files]);
useEffect(() => {
return () => {
filesRef.current.forEach((item) => URL.revokeObjectURL(item.previewUrl));
};
}, []);
const handleAddFiles = (targetFiles: FileList | null) => {
if (!targetFiles || targetFiles.length === 0) return;
const accepted = Array.from(targetFiles)
.filter((file) => file.type.startsWith("image/"))
.slice(0, 6);
if (accepted.length === 0) {
toast.error("请上传图片文件");
return;
}
const next = accepted.map((file) => ({
id: `${file.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
file,
previewUrl: URL.createObjectURL(file),
}));
setFiles((prev) => [...prev, ...next].slice(0, 6));
};
const removeFile = (id: string) => {
setFiles((prev) => {
const target = prev.find((item) => item.id === id);
if (target) {
URL.revokeObjectURL(target.previewUrl);
}
return prev.filter((item) => item.id !== id);
});
};
const uploadSelectedFiles = async (): Promise<CanvasLaunchAttachment[]> => {
const result: CanvasLaunchAttachment[] = [];
for (const item of files) {
const formData = new FormData();
formData.append("file", item.file);
const response = await fetch("/api/files/upload", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok || !data?.url) {
throw new Error(data?.error || "上传失败");
}
result.push({
url: data.url,
name: item.file.name,
contentType: item.file.type || "image/jpeg",
});
}
return result;
};
const openLoginDialog = () => {
setLoginDialogOpen(true);
};
const handleEmailLogin = async () => {
const email = loginEmail.trim();
if (!email || !loginPassword) {
toast.error("请输入邮箱和密码");
return;
}
setIsLoggingIn(true);
try {
const result = await signIn("credentials", {
email,
password: loginPassword,
redirect: false,
});
if (result?.error) {
toast.error("登录失败,请检查账号密码");
return;
}
window.location.href = "/";
} catch (_error) {
toast.error("登录失败,请稍后重试");
} finally {
setIsLoggingIn(false);
}
};
const handleGoogleLogin = async () => {
if (!canUseGoogleLogin) {
toast.error("Google 登录未启用");
return;
}
await signIn("google", { callbackUrl: "/" });
};
const handleDingTalkLogin = () => {
window.location.href = "/login";
};
const createCanvasFromComposer = async () => {
if (!isAuthenticated) {
openLoginDialog();
return;
}
if (!prompt.trim() && files.length === 0) return;
setIsSubmitting(true);
try {
const uploadedAttachments = await uploadSelectedFiles();
const title = prompt.trim()
? prompt.trim().slice(0, 24)
: `新建项目 ${new Date().toLocaleDateString("zh-CN")}`;
const canvasResponse = await fetch("/api/canvas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
const canvasResult = await canvasResponse.json();
if (!canvasResponse.ok || !canvasResult?.data?.id) {
throw new Error(canvasResult?.error || "创建项目失败");
}
const canvasId: string = canvasResult.data.id;
if (typeof window !== "undefined") {
sessionStorage.setItem(
getCanvasLaunchStorageKey(canvasId),
JSON.stringify({
prompt:
prompt.trim() ||
(uploadedAttachments.length > 0
? "请基于这些图片继续生成设计方案"
: ""),
attachments: uploadedAttachments,
autoSend: true,
createdAt: Date.now(),
selectedModelId: selectedModelId || "auto",
imageParams,
})
);
}
files.forEach((item) => URL.revokeObjectURL(item.previewUrl));
setFiles([]);
setPrompt("");
await mutateCanvas();
router.push(`/canvas/${canvasId}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建项目失败");
} finally {
setIsSubmitting(false);
}
};
const openNewCanvas = () => {
if (!isAuthenticated) {
openLoginDialog();
return;
}
router.push("/canvas?new=1");
};
const openAllProjectsInPage = () => {
if (!isAuthenticated) {
openLoginDialog();
return;
}
setShowAllProjects(true);
projectSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const composerPlaceholder = `${typedPreviewText || composerPreviewTexts[0] || "描述你想创作的内容"}${
previewCursorVisible ? " |" : ""
}`;
return (
<div className="min-h-svh bg-[#f4f4f4] text-[#171717]">
<header className="fixed left-0 top-0 z-40 h-[68px] w-full bg-[#f4f4f4]/90 backdrop-blur">
<div className="flex h-full items-center justify-between px-7">
<button
type="button"
className="flex items-center gap-2"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
{siteLogo ? (
<img src={siteLogo} alt={siteName} className="h-8 w-8 rounded-full object-cover" />
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white">
<Sparkles className="h-4 w-4" />
</div>
)}
<span className="text-[20px] font-semibold leading-none tracking-tight">
{siteName || "Lovart"}
</span>
</button>
<div className="flex items-center gap-2">
<button className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10">
<Bell className="h-3.5 w-3.5" />
</button>
<HoverCard openDelay={100} closeDelay={120}>
<HoverCardTrigger asChild>
<Button
variant="ghost"
onClick={() => setUpgradeDialogOpen(true)}
className="h-8 rounded-[14px] bg-[#1c1c1e] px-2.5 text-[14px] text-white hover:bg-black"
>
<span className="mr-1.5">升级</span>
<div className="inline-flex items-center gap-1 rounded-[9px] bg-white/10 px-1.5 py-0.5 text-xs">
<Zap className="h-3 w-3 text-amber-300" />
{membershipData?.summary?.currentCredits ?? 0}
</div>
</Button>
</HoverCardTrigger>
<HoverCardContent align="end" sideOffset={10} className="w-[430px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl">
<div className="p-6">
<div className="mb-4 flex items-start justify-between">
<div>
<div className="text-[30px] font-semibold leading-none text-black">
{currentPlan?.name || "Free"}
</div>
</div>
<Button
size="sm"
className="rounded-2xl bg-[#2f3542] px-5 text-white hover:bg-[#242a36]"
onClick={() => setUpgradeDialogOpen(true)}
>
升级
</Button>
</div>
<div className="space-y-3 border-t border-black/10 pt-4 text-base">
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-2 text-black/80">
<Sparkles className="h-4 w-4" />
积分
</span>
<span>{membershipData?.summary?.currentCredits ?? 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-2 text-black/80">
<Upload className="h-4 w-4" />
每日免费积分
</span>
<span>{membershipData?.summary?.dailyFreeCredits ?? 0}</span>
</div>
</div>
<button
type="button"
className="mt-6 inline-flex items-center gap-1 text-sm font-medium text-black/80 hover:text-black"
onClick={() => setUpgradeDialogOpen(true)}
>
使用详情
<ChevronDown className="h-4 w-4 -rotate-90" />
</button>
</div>
</HoverCardContent>
</HoverCard>
{isAuthenticated ? (
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
{(user?.name || user?.email || "mu").slice(0, 2).toLowerCase()}
</button>
) : (
<button
type="button"
className="inline-flex h-8 items-center justify-center rounded-full border border-black/15 bg-white px-3 text-xs font-medium text-black/80 hover:bg-black/5"
onClick={openLoginDialog}
>
登录
</button>
)}
</div>
</div>
</header>
<aside className="fixed left-6 top-[292px] z-30 hidden flex-col items-center gap-2 lg:flex">
<button
type="button"
onClick={openNewCanvas}
className="flex h-[52px] w-[52px] items-center justify-center rounded-full border border-black/10 bg-white shadow"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white">
<Plus className="h-4 w-4" />
</div>
</button>
<div className="flex w-[56px] flex-col items-center gap-1 rounded-[28px] border border-black/10 bg-white px-1.5 py-2.5 shadow">
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full bg-black/5"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
<Home className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={openAllProjectsInPage}
>
<Folder className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={() => toast.info("个人主页模块后续开放")}
>
<User className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={() => toast.info("帮助中心后续开放")}
>
<Info className="h-[18px] w-[18px]" />
</button>
{user?.type === "admin" ? (
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={() => router.push("/settings")}
>
<Settings className="h-[18px] w-[18px]" />
</button>
) : null}
</div>
</aside>
<main className="mx-auto max-w-[1240px] px-6 pb-20 pt-[84px]">
<section className="mt-8 text-center">
<button
type="button"
onClick={() => setUpgradeDialogOpen(true)}
className="inline-flex items-center gap-2 rounded-full bg-[#fde8bf] px-4 py-2 text-sm text-black/80"
>
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
<span className="text-[#ff7a00]">{heroPromoActionText || "立即升级"}</span>
</button>
<div className="mt-8 flex items-center justify-center gap-2">
{siteLogo ? (
<img src={siteLogo} alt={siteName} className="h-10 w-10 rounded-full object-cover" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-black text-white">
<Sparkles className="h-4 w-4" />
</div>
)}
<h1 className="text-[34px] font-semibold tracking-tight">
{heroTitleText || `${siteName || "Lovart"} 让设计更简单`}
</h1>
</div>
<p className="mt-2 text-[13px] text-black/35">
{heroSubtitleText || "懂你的设计代理,帮你搞定一切"}
</p>
<div className="mx-auto mt-6 w-full max-w-[780px] rounded-[22px] border border-black/10 bg-white px-4 pb-3 pt-4 shadow-[0_10px_24px_rgba(0,0,0,0.05)]">
<Textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={composerPlaceholder}
className="min-h-[76px] resize-none border-0 bg-transparent px-1 text-[12px] shadow-none focus-visible:ring-0"
/>
{files.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 px-1">
{files.map((item) => (
<div key={item.id} className="group relative h-12 w-12 overflow-hidden rounded-lg border border-black/10">
<img src={item.previewUrl} alt={item.file.name} className="h-full w-full object-cover" />
<button
type="button"
className="absolute right-0 top-0 hidden h-5 w-5 items-center justify-center rounded-bl bg-black/60 text-xs text-white group-hover:flex"
onClick={() => removeFile(item.id)}
>
x
</button>
</div>
))}
</div>
)}
<div className="flex items-center justify-between border-t border-black/8 pt-3">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<label className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-black/10 text-black/60 hover:bg-black/5">
<Upload className="h-4 w-4" />
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
handleAddFiles(event.target.files);
event.target.value = "";
}}
/>
</label>
<Select value={selectedModelId || "auto"} onValueChange={setSelectedModelId}>
<SelectTrigger className="h-8 w-auto min-w-[110px] rounded-full border border-blue-100 bg-blue-50 px-3 text-xs text-blue-600">
<span className="truncate max-w-[100px]">
{currentComposerModel?.displayName || "智能自动"}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto" className="text-xs">
智能自动
</SelectItem>
{imageModels.map((model) => (
<SelectItem key={model.id} value={model.id} className="text-xs">
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="min-w-0 flex-1 overflow-hidden">
<ImageParamsBar
modelName={currentComposerModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline
providerType={currentComposerModel?.providerType}
/>
</div>
</div>
<div className="flex items-center gap-1.5 pl-2">
<Button
onClick={createCanvasFromComposer}
disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
className="h-8 w-8 rounded-full bg-[#9ca3af] p-0 text-white hover:bg-black"
>
{isSubmitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-center gap-1.5">
{PROMPT_TAGS.map((tag) => (
<button
key={tag.label}
type="button"
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[12px] ${
tag.highlighted
? "border-[#ffb15f] bg-[#fff4e7] text-[#ff8c1f]"
: "border-black/15 bg-white text-black/70"
}`}
>
<tag.icon className="h-3 w-3" />
{tag.label}
</button>
))}
</div>
</section>
<section ref={projectSectionRef} className="mt-20">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-[18px] font-semibold tracking-tight">最近项目</h2>
{hasMoreProjects ? (
<button
type="button"
className="inline-flex items-center gap-1 text-[13px] text-black/45 hover:text-black"
onClick={() => setShowAllProjects((value) => !value)}
>
{showAllProjects ? "收起" : "查看更多"}
<ArrowRight className={`h-3.5 w-3.5 transition-transform ${showAllProjects ? "rotate-90" : ""}`} />
</button>
) : null}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<button
type="button"
onClick={openNewCanvas}
className="rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5"
>
<div className="flex aspect-[4/3] items-center justify-center rounded-2xl bg-[#efefef]">
<div className="text-center">
<div className="mb-2 text-3xl">+</div>
<div className="text-[14px]">新建项目</div>
</div>
</div>
</button>
{recentProjects.map((item) => (
<button
key={item.id}
type="button"
onClick={() => router.push(`/canvas/${item.id}`)}
className="group rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5"
>
<div className="aspect-[4/3] overflow-hidden rounded-2xl bg-[#efefef]">
{item.thumbnail ? (
<img
src={item.thumbnail}
alt={item.title}
className="h-full w-full object-cover transition-transform duration-300 ease-out group-hover:scale-[1.03]"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-black/35">Untitled</div>
)}
</div>
<div className="mt-3 text-[14px] font-medium">{item.title || "Untitled"}</div>
<div className="mt-1 text-xs text-black/35">{formatRelativeTime(item.updatedAt || item.createdAt)}</div>
</button>
))}
{recentProjects.length === 0 && (
<div className="rounded-[22px] border border-dashed border-black/15 bg-white p-6 text-center text-black/40 md:col-span-3">
还没有项目,点击“新建项目”开始。
</div>
)}
</div>
</section>
<section className="mt-16">
<h2 className="text-[18px] font-semibold tracking-tight">灵感发现</h2>
<div className="mt-3 flex flex-wrap gap-1.5">
{discoveryTabs.map((category) => (
<button
key={category}
type="button"
onClick={() => setActiveCategory(category)}
className={`rounded-xl px-3 py-1.5 text-[12px] ${
activeCategory === category
? "bg-[#ececec] text-black"
: "text-black/55 hover:bg-white"
}`}
>
{category}
</button>
))}
</div>
<div className="mt-5 columns-1 gap-4 md:columns-2 xl:columns-4">
{featuredItems.map((item, index) => (
<button
key={item.id}
type="button"
onClick={() => window.open(item.originalUrl || item.thumbnailUrl, "_blank")}
className="group mb-4 block w-full break-inside-avoid rounded-2xl border border-black/8 bg-white text-left shadow-sm"
>
<div
className="overflow-hidden rounded-t-2xl bg-[#e8e8e8]"
style={{ height: `${MASONRY_HEIGHTS[index % MASONRY_HEIGHTS.length]}px` }}
>
<img
src={item.thumbnailUrl || item.originalUrl}
alt={item.prompt || "灵感图"}
className="h-full w-full object-cover transition-transform duration-300 ease-out group-hover:scale-[1.02]"
/>
</div>
<div className="flex items-center justify-between px-3 py-2 text-[12px] text-black/60">
<div className="line-clamp-1 font-medium text-black/85">
{item.user?.name || "创作者"}
</div>
<div className="inline-flex items-center gap-2 text-[11px]">
<span className="inline-flex items-center gap-1">
<Sparkles className="h-2.5 w-2.5" />
{(1200 + index * 37).toLocaleString()}
</span>
<span className="inline-flex items-center gap-1">
<Heart className="h-2.5 w-2.5" />
{(20 + (index % 9) * 11).toLocaleString()}
</span>
</div>
</div>
</button>
))}
{featuredItems.length === 0 && (
<div className="rounded-xl border border-dashed border-black/15 bg-white p-10 text-center text-black/35">
{isLoadingFeatured ? "灵感内容加载中..." : "当前没有可展示的灵感数据。"}
</div>
)}
</div>
<div ref={featuredLoadMoreRef} className="mt-3 flex h-10 items-center justify-center">
{isLoadingMoreFeatured ? (
<div className="inline-flex items-center gap-2 text-sm text-black/45">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在加载更多灵感...
</div>
) : null}
</div>
</section>
</main>
<button
type="button"
className="fixed bottom-8 right-8 z-20 flex h-11 w-11 items-center justify-center rounded-full border border-black/10 bg-white text-black/50 shadow hover:text-black"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
<Send className="h-4 w-4 -rotate-90" />
</button>
{loginDialogOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4 backdrop-blur-[1px]"
onClick={() => setLoginDialogOpen(false)}
>
<div
className="w-full max-w-[580px] rounded-[28px] bg-white p-8 shadow-2xl sm:p-10"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-2">
{siteLogo ? (
<img
src={siteLogo}
alt={siteName}
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white">
<Sparkles className="h-4 w-4" />
</div>
)}
<span className="text-[20px] font-semibold leading-none tracking-tight text-black">
{siteName}
</span>
</div>
<button
type="button"
className="rounded-full p-1 text-black/45 hover:bg-black/5 hover:text-black/70"
onClick={() => setLoginDialogOpen(false)}
>
<X className="h-5 w-5" />
</button>
</div>
<div className="mb-7 text-center">
<h3 className="text-[22px] font-semibold text-black">欢迎来到 {siteName}</h3>
<p className="mt-2 text-sm text-black/45">登录或注册以继续</p>
</div>
{hasThirdPartyLoginOptions ? (
<div className="space-y-2.5">
{canUseGoogleLogin ? (
<button
type="button"
className="flex h-14 w-full items-center justify-center gap-2 rounded-xl border border-black/10 text-[18px] font-medium text-black hover:bg-black/[0.03]"
onClick={handleGoogleLogin}
>
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#EA4335"
d="M12 10.2v3.9h5.5c-.2 1.2-1.4 3.5-5.5 3.5-3.3 0-6-2.7-6-6s2.7-6 6-6c1.9 0 3.2.8 3.9 1.5l2.7-2.6C16.9 2.9 14.6 2 12 2 6.5 2 2 6.5 2 12s4.5 10 10 10c5.8 0 9.6-4.1 9.6-9.8 0-.7-.1-1.2-.2-1.7H12z"
/>
</svg>
使用 Google
</button>
) : null}
{canUseDingTalkLogin ? (
<button
type="button"
className="flex h-14 w-full items-center justify-center gap-2 rounded-xl border border-black/10 text-[18px] font-medium text-black hover:bg-black/[0.03]"
onClick={handleDingTalkLogin}
>
使用钉钉
</button>
) : null}
<div className="my-2 border-t border-black/10" />
</div>
) : null}
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
handleEmailLogin();
}}
>
<Input
value={loginEmail}
onChange={(event) => setLoginEmail(event.target.value)}
placeholder="电子邮箱"
type="email"
className="h-14 rounded-xl border-black/20 px-4 text-base"
/>
<Input
value={loginPassword}
onChange={(event) => setLoginPassword(event.target.value)}
placeholder="密码"
type="password"
className="h-14 rounded-xl border-black/20 px-4 text-base"
/>
<Button
type="submit"
className="mt-1 h-14 w-full rounded-xl bg-black text-[17px] text-white hover:bg-black/90"
disabled={isLoggingIn}
>
{isLoggingIn ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
登录中...
</span>
) : (
"使用邮箱继续"
)}
</Button>
</form>
<p className="mt-5 text-center text-xs text-black/40">
继续即表示您同意使用条款和隐私政策
</p>
</div>
</div>
) : null}
<Dialog open={upgradeDialogOpen} onOpenChange={setUpgradeDialogOpen}>
<DialogContent className="max-h-[92vh] w-[95vw] max-w-[1240px] overflow-hidden rounded-3xl border-black/10 bg-[#f7f7f7] p-0">
<div className="flex h-full max-h-[92vh] flex-col">
<DialogHeader className="border-b border-black/6 px-6 py-5">
<DialogTitle className="text-center text-5xl font-bold text-black">升级您的套餐</DialogTitle>
<div className="mt-3 flex items-center justify-center gap-2">
<Button
variant={billingCycle === "monthly" ? "default" : "outline"}
className="h-8 rounded-full px-4 text-xs"
onClick={() => setBillingCycle("monthly")}
>
月付
</Button>
<Button
variant={billingCycle === "annual" ? "default" : "outline"}
className="h-8 rounded-full px-4 text-xs"
onClick={() => setBillingCycle("annual")}
>
年付
</Button>
<span className="rounded-full bg-[#ffe68f] px-2 py-1 text-xs text-[#8b5e00]">
{membershipConfig?.annualDiscountLabel || "限时优惠"}
</span>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-5 pb-8 pt-4">
{membershipPlans.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5">
{membershipPlans.map((plan) => {
const isCurrent = currentPlan?.id === plan.id;
const annualMonthlyPrice =
plan.annualPrice > 0 ? Number((plan.annualPrice / 12).toFixed(2)) : 0;
return (
<div
key={plan.id}
className={`rounded-2xl border bg-white p-4 ${
plan.highlighted
? "border-[#7d87ff] shadow-[0_0_0_2px_rgba(125,135,255,0.25)]"
: "border-black/10"
}`}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-3xl font-semibold text-black">{plan.name}</h3>
{plan.badge ? (
<span className="rounded-full bg-[#eef0ff] px-2 py-1 text-[10px] text-[#5b65db]">
{plan.badge}
</span>
) : null}
</div>
<div className="mb-3">
{plan.monthlyPrice <= 0 ? (
<div className="text-4xl font-bold text-black">免费</div>
) : billingCycle === "monthly" ? (
<div className="text-4xl font-bold text-black">
{formatPrice(membershipConfig?.currency || "$", plan.monthlyPrice)}
<span className="ml-1 text-sm font-normal text-black/45">/月</span>
</div>
) : (
<div>
<div className="text-4xl font-bold text-black">
{formatPrice(membershipConfig?.currency || "$", plan.annualPrice)}
<span className="ml-1 text-sm font-normal text-black/45">/年</span>
</div>
<div className="mt-1 text-xs text-black/45">
折合 {formatPrice(membershipConfig?.currency || "$", annualMonthlyPrice)}/月
</div>
</div>
)}
</div>
<div className="mb-4 text-sm text-black/60">
每月 {plan.monthlyCredits.toLocaleString()} 快速积分
</div>
<Button
className="mb-4 h-9 w-full rounded-xl bg-black text-white hover:bg-black/90"
disabled={isCurrent}
onClick={() => {
if (isCurrent) return;
toast.info("支付能力后续接入,已预留套餐配置接口");
}}
>
{isCurrent ? "当前套餐" : "升级"}
</Button>
<ul className="space-y-1.5 text-xs text-black/65">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-black/40" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
);
})}
</div>
) : (
<div className="rounded-xl border border-dashed border-black/15 bg-white p-8 text-center text-sm text-black/40">
会员配置暂未启用,请在系统设置中完善会员 JSON 配置。
</div>
)}
<section className="mt-10">
<h4 className="mb-4 text-center text-4xl font-semibold text-black">常见问题</h4>
<div className="space-y-2">
{(membershipConfig?.faqs || []).map((faq) => (
<details
key={faq.question}
className="rounded-xl border border-black/8 bg-white px-4 py-3 text-sm text-black/70"
>
<summary className="cursor-pointer list-none font-medium">{faq.question}</summary>
<p className="mt-2 text-black/60">{faq.answer}</p>
</details>
))}
</div>
</section>
<section className="mt-10">
<h4 className="mb-2 text-center text-4xl font-semibold text-black">
模型积分消耗 & 每月生成数量
</h4>
<p className="mb-4 text-center text-xs text-black/45">
以下表格基于系统设置中的会员配置,可直接在后台调整。
</p>
<div className="overflow-x-auto rounded-2xl border border-black/10 bg-white">
<table className="min-w-[900px] w-full text-sm">
<thead>
<tr className="bg-black/[0.03]">
<th className="px-3 py-3 text-left font-semibold text-black/75">模型</th>
{membershipPlans.map((plan) => (
<th key={plan.id} className="px-3 py-3 text-left font-semibold text-black/75">
{plan.name}
</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(groupedModelRows).map(([category, rows]) => (
<Fragment key={category}>
<tr className="bg-black/[0.045]">
<td
colSpan={membershipPlans.length + 1}
className="px-3 py-2 text-xs font-semibold text-black/60"
>
{category}
</td>
</tr>
{rows.map((row) => (
<tr key={`${category}-${row.model}`} className="border-t border-black/[0.06]">
<td className="px-3 py-2 text-black/70">{row.model}</td>
{membershipPlans.map((plan) => (
<td key={`${row.model}-${plan.id}`} className="px-3 py-2 text-black/60">
{row.values[plan.id] ?? "-"}
{row.values[plan.id] && row.values[plan.id] !== "-" ? row.unit : ""}
</td>
))}
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
</section>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}