1382 lines
51 KiB
TypeScript
1382 lines
51 KiB
TypeScript
"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>
|
||
);
|
||
}
|